Add API tab with ERP-oriented docs and live request tester.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 09:39:47 +03:00
parent 026fc4bc00
commit 2e356653d7

View File

@@ -251,6 +251,43 @@
.rescan-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 10px; }
.rescan-box input[type=file] { font-size: 12px; }
.rescan-status { font-family: var(--mono); font-size: 12px; color: var(--muted); min-height: 1.2em; }
.api-layout { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.1fr); gap: 20px; }
@media (max-width: 900px) { .api-layout { grid-template-columns: 1fr; } }
.api-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 18px;
}
.api-card h3 {
font-family: var(--mono); font-size: 12px; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase; margin-bottom: 12px;
}
.api-card p { font-size: 13px; color: var(--text); line-height: 1.55; margin-bottom: 10px; }
.api-ep {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 10px 12px; margin-bottom: 8px; background: var(--bg3);
}
.api-ep .method {
font-family: var(--mono); font-size: 11px; font-weight: 600;
color: #1e4a8c; background: #d6e4f7; padding: 2px 7px; border-radius: 4px;
margin-right: 8px;
}
.api-ep .method.post { color: #1a6b45; background: #d8f0e4; }
.api-ep code { font-family: var(--mono); font-size: 12px; }
.api-ep .desc { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.45; }
.api-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
.api-row select, .api-row input[type=text] {
background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-family: var(--mono); font-size: 12px; padding: 7px 10px;
}
.api-row select { min-width: 160px; }
.api-row input[type=text] { flex: 1; min-width: 180px; }
.api-out {
font-family: var(--mono); font-size: 11px; line-height: 1.45;
background: #1a1a24; color: #d7dde8; border-radius: var(--radius);
padding: 12px; max-height: 420px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
.api-note { font-size: 12px; color: var(--muted); margin-top: 8px; }
</style>
</head>
<body>
@@ -268,6 +305,7 @@
<div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('editor')">Ключови думи</div>
<div class="tab" onclick="switchTab('search')">Търсене</div>
<div class="tab" onclick="switchTab('generator')">Документ</div>
<div class="tab" onclick="switchTab('api')">API</div>
</div>
{% if home_url %}
@@ -368,6 +406,57 @@
</div>
</div>
<div id="tab-api" class="panel">
<div class="api-layout">
<div class="api-card">
<h3>Документация — за ERP / интеграции</h3>
<p>Base URL: <code id="api-base"></code></p>
<p>JSON API за четене на help секции, търсене и (опционално) rescan. Готово за връзка с външни системи.</p>
<div class="api-ep"><span class="method">GET</span><code>/healthz</code>
<div class="desc">Здраве на услугата и БД; <code>output_dir_ok</code>, <code>txt_count</code>.</div></div>
<div class="api-ep"><span class="method">GET</span><code>/api/sections?prefix=RIP</code>
<div class="desc">Списък секции (метаданни + preview текст). Филтър по <code>prefix</code>.</div></div>
<div class="api-ep"><span class="method">GET</span><code>/api/search?q=…&amp;prefix=RIP</code>
<div class="desc">Търсене по код / заглавие / keywords / html_text.</div></div>
<div class="api-ep"><span class="method">GET</span><code>/api/section/{code}</code>
<div class="desc">Една секция с <strong>пълен</strong> текст (<code>text</code>, <code>text_html</code>).</div></div>
<div class="api-ep"><span class="method post">POST</span><code>/api/keywords/{code}</code>
<div class="desc">Body JSON: <code>{"keywords":"а, б, в"}</code> — обновява ключови думи.</div></div>
<div class="api-ep"><span class="method post">POST</span><code>/api/rescan</code>
<div class="desc">multipart: <code>file</code> (zip/docx/…), <code>prefix</code>, <code>force</code>. Header опционално: <code>X-Rescan-Token</code>.</div></div>
<div class="api-ep"><span class="method">GET</span><code>/api/rescan/{job_id}</code>
<div class="desc">Статус на rescan задача (<code>queued</code> / <code>processing</code> / <code>done</code> / <code>error</code>).</div></div>
<p class="api-note">OpenAPI / Swagger: <a href="/docs" target="_blank" rel="noopener">/docs</a> · ReDoc: <a href="/redoc" target="_blank" rel="noopener">/redoc</a></p>
<p class="api-note">Типичен ERP поток: <code>GET /api/search</code> → избор на <code>code</code> → <code>GET /api/section/{code}</code> за съдържание.</p>
</div>
<div class="api-card">
<h3>Тестер</h3>
<div class="api-row">
<select id="api-endpoint" onchange="apiFillExample()">
<option value="GET /healthz">GET /healthz</option>
<option value="GET /api/sections">GET /api/sections</option>
<option value="GET /api/search" selected>GET /api/search</option>
<option value="GET /api/section">GET /api/section/{code}</option>
<option value="POST /api/keywords">POST /api/keywords/{code}</option>
</select>
<input type="text" id="api-prefix" placeholder="prefix" value="{{ prefix or 'RIP' }}">
</div>
<div class="api-row">
<input type="text" id="api-q" placeholder="q / code / keywords JSON" value="лимитни карти">
</div>
<div class="api-row">
<button type="button" class="btn-primary" onclick="runApiTest()">Изпълни</button>
<button type="button" class="btn-ghost" onclick="copyApiOut()">Копирай отговора</button>
<span class="stats" id="api-status"></span>
</div>
<pre class="api-out" id="api-out">Избери endpoint и натисни „Изпълни“.</pre>
</div>
</div>
</div>
<div id="toast"></div>
<script type="application/json" id="sections-data">{{ sections_json | safe }}</script>
@@ -401,13 +490,14 @@ function toast(msg, isErr) {
}
function switchTab(name) {
const order = [{% if home_url %}'home',{% endif %}'editor','search','generator'];
const order = [{% if home_url %}'home',{% endif %}'editor','search','generator','api'];
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', order[i] === name));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('tab-' + name);
if (panel) panel.classList.add('active');
if (name === 'generator') renderGenerator();
if (name === 'search') doSearch();
if (name === 'api') initApiTab();
}
async function startRescan() {
@@ -870,6 +960,97 @@ function download(filename, content, mime) {
a.click();
}
// ── API TAB ─────────────────────────────
function initApiTab() {
const base = document.getElementById('api-base');
if (base) base.textContent = location.origin;
}
function apiFillExample() {
const v = document.getElementById('api-endpoint').value;
const q = document.getElementById('api-q');
if (v.includes('/search')) q.value = 'лимитни карти';
else if (v.includes('/section')) q.value = (ALL[0] && ALL[0].code) || 'RIP_0001_SEC_0001';
else if (v.includes('/keywords')) q.value = '{"keywords":"тест, erp, demo"}';
else if (v.includes('/sections')) q.value = '';
else q.value = '';
}
async function runApiTest() {
const ep = document.getElementById('api-endpoint').value;
const prefix = (document.getElementById('api-prefix').value || '').trim();
const q = (document.getElementById('api-q').value || '').trim();
const st = document.getElementById('api-status');
const out = document.getElementById('api-out');
let method = 'GET';
let url = '';
let body = null;
let headers = {};
if (ep === 'GET /healthz') {
url = '/healthz';
} else if (ep === 'GET /api/sections') {
url = '/api/sections' + (prefix ? ('?prefix=' + encodeURIComponent(prefix)) : '');
} else if (ep === 'GET /api/search') {
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (prefix) qs.set('prefix', prefix);
url = '/api/search?' + qs.toString();
} else if (ep === 'GET /api/section') {
if (!q) { toast('Въведи code', true); return; }
url = '/api/section/' + encodeURIComponent(q);
} else if (ep === 'POST /api/keywords') {
method = 'POST';
// q field: either code only (use sample keywords) or JSON with keywords; code from first token
let code = q;
let kwBody = { keywords: 'тест, erp' };
try {
const parsed = JSON.parse(q);
if (parsed && typeof parsed === 'object') {
if (parsed.code) code = parsed.code;
if (parsed.keywords != null) kwBody = { keywords: parsed.keywords };
}
} catch (_) {
// plain code — keep default keywords body
}
if (!code || code.startsWith('{')) {
code = (ALL[0] && ALL[0].code) || '';
}
if (!code) { toast('Няма code за POST keywords', true); return; }
url = '/api/keywords/' + encodeURIComponent(code);
headers['Content-Type'] = 'application/json';
body = JSON.stringify(kwBody);
} else {
toast('Непознат endpoint', true);
return;
}
st.textContent = method + ' ' + url + ' …';
out.textContent = 'Зареждане…';
const t0 = performance.now();
try {
const res = await fetch(url, { method, headers, body });
const text = await res.text();
let pretty = text;
try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch (_) {}
const ms = Math.round(performance.now() - t0);
st.textContent = res.status + ' · ' + ms + ' ms · ' + (text.length) + ' B';
out.textContent = pretty;
} catch (e) {
st.textContent = 'грешка';
out.textContent = String(e);
toast(e.message, true);
}
}
function copyApiOut() {
const t = document.getElementById('api-out').textContent || '';
navigator.clipboard.writeText(t).then(
() => toast('Копирано'),
() => toast('Неуспешно копиране', true)
);
}
// ── INIT ─────────────────────────────
document.getElementById('total-count').textContent = ALL.length + ' секции';
try {
@@ -877,6 +1058,7 @@ try {
renderKwCloud();
doSearch();
renderGenerator();
initApiTab();
} catch (e) {
console.error('init failed', e);
toast('Грешка при зареждане на UI: ' + e.message, true);