Files
rip-help-system/webapp/templates/viewer.html
2026-09-11 13:58:06 +03:00

1477 lines
62 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="bg">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RIP Help Viewer{% if prefix %} — {{ prefix }}{% endif %}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f6f8;
--bg2: #ffffff;
--bg3: #eef0f4;
--border: #d8dce3;
--accent: #3a6fcf;
--accent2: #2ca36f;
--text: #1a1a1f;
--muted: #6a6a78;
--danger: #c84545;
--tag-bg: #e7efff;
--tag-text: #2c5cb8;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
--radius: 6px;
--radius-lg: 12px;
}
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; min-height: 100vh; }
header {
display: flex; align-items: center; gap: 16px; padding: 14px 24px;
border-bottom: 1px solid #1f3a6f;
background: linear-gradient(90deg, #2d5fb0 0%, #3a6fcf 55%, #5589dd 100%);
color: #ffffff; box-shadow: 0 2px 6px rgba(0,0,0,.10);
position: sticky; top: 0; z-index: 100;
}
header h1 {
font-family: var(--mono); font-size: 16px; font-weight: 600; color: #ffffff;
letter-spacing: .05em; padding: 4px 12px;
background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.28);
border-radius: var(--radius);
}
header .sep { color: rgba(255,255,255,.35); }
header .pfx { font-family: var(--mono); font-size: 12px; color: rgba(255,255,255,.85); }
.gen-time { font-size: 11px; color: rgba(255,255,255,.80); margin-left: auto; font-family: var(--mono); }
.tabs {
display: flex; gap: 4px; padding: 0 24px;
background: linear-gradient(180deg, #d6e4f7 0%, #e8f0fa 100%);
border-bottom: 1px solid #b7c9e3;
}
.tab {
padding: 11px 18px; font-size: 13px; font-family: var(--sans); font-weight: 500;
color: #3d5a80; cursor: pointer;
border-bottom: 2px solid transparent;
transition: color .15s, border-color .15s, background .15s;
user-select: none; border-radius: 6px 6px 0 0;
}
.tab.active {
color: #1e4a8c; border-color: #2d5fb0;
background: rgba(255,255,255,.55);
}
.tab:hover:not(.active) { color: #1a1a2e; background: rgba(255,255,255,.28); }
.panel { display: none; padding: 20px 24px; }
.panel.active { display: block; }
.toolbar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
input[type=text], textarea {
background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-family: var(--sans); font-size: 13px; padding: 7px 12px;
outline: none; transition: border-color .15s;
}
input[type=text]:focus, textarea:focus { border-color: var(--accent); }
.search-box { flex: 1; min-width: 220px; }
button {
padding: 7px 16px; border-radius: var(--radius); border: none;
font-family: var(--mono); font-size: 12px; cursor: pointer; transition: opacity .15s;
}
button:hover:not(:disabled) { opacity: .85; }
button:disabled { opacity: .5; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--accent2); color: #fff; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-ghost { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.stats { font-size: 11px; color: var(--muted); font-family: var(--mono); }
.tbl-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); }
table { width: 100%; border-collapse: collapse; }
thead th {
background: var(--bg3); padding: 10px 12px; text-align: left;
font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase;
border-bottom: 1px solid var(--border); white-space: nowrap;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background .1s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--bg3); }
td { padding: 8px 12px; vertical-align: top; }
.code-badge {
font-family: var(--mono); font-size: 11px; color: var(--accent);
background: rgba(58,111,207,.10); padding: 2px 7px; border-radius: 4px; white-space: nowrap;
}
.kw-cell { display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
.kw-tags { display: flex; flex-wrap: wrap; gap: 4px; min-height: 18px; }
.tag {
font-family: var(--mono); font-size: 11px;
background: var(--tag-bg); color: var(--tag-text);
padding: 2px 8px; border-radius: 20px; white-space: nowrap;
}
.kw-edit-row { display: flex; gap: 6px; align-items: center; }
.kw-input {
flex: 1; min-width: 180px;
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); padding: 5px 10px; font-size: 12px; font-family: var(--sans);
}
.kw-input:focus {
border-color: var(--accent); background: var(--bg2);
box-shadow: 0 0 0 2px rgba(58,111,207,.15); outline: none;
}
.kw-input.changed { border-color: var(--accent2); background: rgba(44,163,111,.05); }
.kw-input.saving { border-color: var(--accent); background: rgba(58,111,207,.05); }
.kw-input.saved { border-color: var(--accent2); background: rgba(44,163,111,.10); }
.kw-input.error { border-color: var(--danger); background: rgba(200,69,69,.05); }
.save-btn {
padding: 5px 12px; font-size: 12px;
background: var(--accent2); color: #fff;
border-radius: var(--radius); border: none; cursor: pointer;
display: none; font-family: var(--mono);
}
.save-btn.visible { display: inline-block; }
.save-btn:hover { opacity: .9; }
.src-file { font-size: 11px; color: var(--muted); font-family: var(--mono); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.title-cell { max-width: 220px; }
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
.card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 14px 16px; transition: border-color .15s; cursor: pointer;
}
.card:hover { border-color: var(--accent); }
.card.selected { border-color: var(--accent2); background: rgba(44,163,111,.10); }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
.card-title { font-weight: 500; font-size: 13px; line-height: 1.4; }
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.card-text { font-size: 12px; color: var(--muted); line-height: 1.6; max-height: 280px; overflow: hidden; }
.card-text img { max-width: 100%; max-height: 200px; display: block; margin: 6px 0; border-radius: 4px; border: 1px solid var(--border); }
.card-section-label {
font-family: var(--mono); font-size: 10px; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase; margin: 10px 0 6px;
}
.card-actions { display: flex; gap: 8px; margin-top: 10px; }
.card-actions button { font-size: 11px; padding: 5px 10px; }
.modal-backdrop {
display: none; position: fixed; inset: 0; background: rgba(20,24,36,.45);
z-index: 200; align-items: center; justify-content: center; padding: 24px;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
width: min(900px, 100%); max-height: min(86vh, 900px); overflow: auto;
box-shadow: 0 16px 48px rgba(0,0,0,.18); padding: 20px 22px;
}
.modal-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 12px; }
.modal-head h2 { font-size: 16px; font-weight: 500; line-height: 1.35; }
.modal-body { font-size: 13px; line-height: 1.65; color: var(--text); }
.modal-body img { max-width: 100%; max-height: 320px; display: block; margin: 10px 0; border: 1px solid var(--border); border-radius: 6px; }
.kw-cloud {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;
padding: 12px 14px; background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius-lg); max-height: 140px; overflow-y: auto;
}
.kw-cloud .tag { cursor: pointer; user-select: none; }
.kw-cloud .tag:hover { outline: 2px solid var(--accent); }
.kw-cloud .tag.active { background: var(--accent); color: #fff; }
.search-mode { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--muted); }
.search-mode label { display: flex; align-items: center; gap: 4px; cursor: pointer; font-family: var(--mono); font-size: 11px; }
.card-footer { margin-top: 8px; font-size: 11px; color: var(--muted); font-family: var(--mono); }
.check-icon { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; margin-top: 2px; transition: all .15s; }
.card.selected .check-icon { background: var(--accent2); border-color: var(--accent2); }
.gen-layout { display: grid; grid-template-columns: 1fr 280px; gap: 20px; }
.selected-list { display: flex; flex-direction: column; gap: 8px; }
.sel-item {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 10px 14px; display: flex; align-items: center; gap: 10px; cursor: grab;
}
.sel-item:active { cursor: grabbing; }
.sel-item.drag-over { border-color: var(--accent); background: rgba(58,111,207,.10); }
.drag-handle { color: var(--muted); font-size: 16px; user-select: none; }
.sel-item-info { flex: 1; }
.sel-item-title { font-size: 13px; font-weight: 500; }
.sel-item-code { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.remove-btn { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 0 4px; }
.remove-btn:hover { color: var(--danger); }
.gen-panel {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 20px; position: sticky; top: 80px;
}
.gen-panel h3 { font-family: var(--mono); font-size: 13px; color: var(--muted); margin-bottom: 16px; letter-spacing: .06em; }
.format-btns { display: flex; flex-direction: column; gap: 8px; }
.format-btn {
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--border);
background: var(--bg3); color: var(--text); font-family: var(--mono); font-size: 12px;
cursor: pointer; text-align: left; transition: all .15s;
display: flex; align-items: center; gap: 10px;
}
.format-btn:hover { border-color: var(--accent); color: var(--accent); }
.format-dot { width: 8px; height: 8px; border-radius: 50%; }
.dot-word { background: #2b7fd4; }
.dot-html { background: #e0854a; }
.dot-pdf { background: #e05c5c; }
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .big { font-size: 40px; margin-bottom: 12px; }
#toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--accent2); color: #fff;
padding: 10px 20px; border-radius: var(--radius);
font-family: var(--mono); font-size: 13px;
opacity: 0; transform: translateY(10px);
transition: all .3s; pointer-events: none; z-index: 999;
}
#toast.show { opacity: 1; transform: translateY(0); }
#toast.error { background: var(--danger); }
.sep { color: var(--border); margin: 0 4px; }
.home-wrap { display: flex; flex-direction: column; align-items: center; padding: 24px; }
.home-wrap img {
max-width: min(920px, 100%); height: auto; border-radius: var(--radius-lg);
border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
.rescan-box {
width: min(560px, 100%);
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 18px;
}
.rescan-box h3 {
font-family: var(--mono); font-size: 12px; color: var(--muted);
letter-spacing: .06em; text-transform: uppercase; margin-bottom: 12px;
}
.rescan-pick { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 14px; }
.rescan-pick .format-btn { width: 100%; }
.rescan-pick .format-btn.selected {
border-color: var(--accent); color: var(--accent);
background: rgba(58,111,207,.08);
}
.rescan-file-input {
position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden;
}
.rescan-selection {
font-family: var(--mono); font-size: 12px; color: var(--text);
margin-bottom: 12px; line-height: 1.45;
}
.rescan-selection:empty { display: none; }
.rescan-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 10px; }
.rescan-status { font-family: var(--mono); font-size: 12px; color: var(--muted); min-height: 1.2em; }
.rescan-status.busy { color: var(--accent); }
.rescan-status.ok { color: var(--accent2); }
.rescan-status.error { color: var(--danger); }
.rescan-filemeta {
font-family: var(--mono); font-size: 12px; color: var(--muted);
margin-bottom: 12px; line-height: 1.45;
}
.rescan-filemeta:empty { display: none; }
.rescan-filemeta.warn { color: var(--danger); }
.wipe-box { margin-top: 16px; }
.wipe-box input[type=text] { width: 100%; font-family: var(--mono); }
.src-file { cursor: pointer; }
.src-file:hover { color: var(--accent); text-decoration: underline; }
.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>
<header>
<h1>BG16RFPR001-1.001-0068</h1>
<span class="sep">|</span>
<span class="pfx" id="total-count"></span>
{% if prefix %}<span class="sep">|</span><span class="pfx">prefix={{ prefix }}</span>{% endif %}
<span class="gen-time">генериран: {{ generated }}</span>
</header>
<div class="tabs">
{% if home_url %}<div class="tab active" onclick="switchTab('home')">Начало</div>{% endif %}
<div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('scan')">Сканиране</div>
<div class="tab" 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 %}
<div id="tab-home" class="panel active">
<div class="home-wrap">
<img src="{{ home_url }}" alt="Home">
</div>
</div>
{% endif %}
<div id="tab-scan" class="panel {% if not home_url %}active{% endif %}">
<div class="rescan-box">
<h3>Help файлове</h3>
<div class="rescan-pick">
<button type="button" class="format-btn" id="rescan-pick-file"
aria-pressed="false" onclick="pickScanSource('file')">Избери файл</button>
<button type="button" class="format-btn" id="rescan-pick-folder"
aria-pressed="false" onclick="pickScanSource('folder')">Избери папка</button>
</div>
<input type="file" id="rescan-file" class="rescan-file-input"
accept=".zip,.docx,.doc,.html,.htm,.pdf,.txt"
onchange="onScanFilePicked()">
<input type="file" id="rescan-folder" class="rescan-file-input"
webkitdirectory directory multiple
onchange="onScanFolderPicked()">
<div class="rescan-selection" id="rescan-selection"></div>
<div class="rescan-filemeta" id="rescan-filemeta"></div>
<div class="rescan-row">
<label class="stats"><input type="checkbox" id="rescan-force"> force</label>
<button type="button" class="btn-primary" id="rescan-btn" onclick="startRescan()">Старт</button>
</div>
<div class="rescan-status" id="rescan-status">Избери файл или папка.</div>
</div>
<div class="rescan-box wipe-box">
<h3>Изтриване на секции</h3>
<p class="stats" style="margin-bottom:10px;line-height:1.45">
Копирай името на файла от таб „Ключови думи“ и го постави тук.
Изтриват се всички секции за този файл (не самият help файл).
</p>
<div class="rescan-row">
<input type="text" id="wipe-filename" placeholder="напр. atra-manual.docx"
oninput="onWipeNameInput()" onkeydown="if(event.key==='Enter'){confirmWipeFile();event.preventDefault();}">
</div>
<div class="rescan-filemeta" id="wipe-filemeta"></div>
<div class="rescan-row">
<button type="button" class="btn-danger" id="wipe-btn" onclick="confirmWipeFile()" disabled>Изтрий всички секции</button>
</div>
<div class="rescan-status" id="wipe-status"></div>
</div>
</div>
<div id="tab-editor" class="panel">
<div class="toolbar">
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
<span class="stats" id="editor-stats"></span>
<span class="stats" id="editor-file-stats"></span>
</div>
<div class="tbl-wrap">
<table id="editor-table">
<thead>
<tr>
<th>Код</th>
<th>Заглавие</th>
<th>Ключови думи</th>
<th>Source файл</th>
<th>Обновен</th>
</tr>
</thead>
<tbody id="editor-body"></tbody>
</table>
</div>
</div>
<div id="tab-search" class="panel">
<div class="toolbar">
<input type="text" class="search-box" id="search-input"
placeholder='напр. „търся инфо за лимитни карти“ — служебните думи се прескачат'
oninput="doSearch()">
<div class="search-mode">
<label><input type="radio" name="search-mode" value="all" checked onchange="doSearch()"> всичко</label>
<label><input type="radio" name="search-mode" value="keywords" onchange="doSearch()"> само ключови</label>
<label><input type="radio" name="search-mode" value="text" onchange="doSearch()"> само секции</label>
</div>
<span class="stats" id="search-stats"></span>
<button class="btn-primary" onclick="addSelectedToGenerator()">Добави избраните → Генератор</button>
</div>
<div class="card-section-label" style="margin-top:0">Ключови думи — клик за филтър</div>
<div class="kw-cloud" id="kw-cloud"></div>
<div class="card-section-label">Секции (резултати)</div>
<div class="results-grid" id="search-results"></div>
</div>
<div id="tab-generator" class="panel">
<div class="gen-layout">
<div>
<div class="toolbar">
<span class="stats" id="gen-stats">Няма избрани секции</span>
<button class="btn-ghost" onclick="clearGenerator()">Изчисти</button>
</div>
<div class="selected-list" id="selected-list">
<div class="empty-state">
<div class="big">⬡</div>
<div>Избери секции от таб Търсене</div>
</div>
</div>
</div>
<div class="gen-panel">
<h3>ГЕНЕРИРАЙ HTML</h3>
<div class="format-btns">
<button class="format-btn" onclick="generateDoc('html')">
<span class="format-dot dot-html"></span> Отвори HTML документ
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" id="section-modal" onclick="if(event.target===this)closeSection()">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-head">
<div>
<div class="code-badge" id="modal-code"></div>
<h2 id="modal-title" style="margin-top:8px"></h2>
</div>
<button class="btn-ghost" type="button" onclick="closeSection()">Затвори</button>
</div>
<div class="card-section-label">Ключови думи</div>
<div class="card-tags" id="modal-tags"></div>
<div class="card-section-label">Секция</div>
<div class="modal-body" id="modal-body">Зареждане…</div>
</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>
<div class="api-ep"><span class="method">GET</span><code>/api/file-extractions?file=…&amp;prefix=RIP</code>
<div class="desc">Брой и кодове на секциите за даден файл; <code>ok</code> ако SEC номерацията е уникална и последователна.</div></div>
<div class="api-ep"><span class="method post">DELETE</span><code>/api/file-extractions?file=…&amp;prefix=RIP</code>
<div class="desc">Изтрива всички секции за файла (не самия help файл). Header опционално: <code>X-Rescan-Token</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 class="modal-backdrop" id="wipe-modal" onclick="if(event.target===this)closeWipeModal()">
<div class="modal" role="dialog" aria-modal="true" style="width:min(460px,100%)">
<div class="modal-head">
<h2>Изтриване на секции</h2>
<button type="button" class="btn-ghost" onclick="closeWipeModal()">✕</button>
</div>
<div class="modal-body" id="wipe-modal-body"></div>
<div class="rescan-row" style="margin:16px 0 0">
<button type="button" class="btn-danger" id="wipe-confirm-btn" onclick="doWipeFile()">Изтрий</button>
<button type="button" class="btn-ghost" onclick="closeWipeModal()">Отказ</button>
</div>
</div>
</div>
<div id="toast"></div>
<script type="application/json" id="sections-data">{{ sections_json | safe }}</script>
<script>
let ALL = [];
try {
ALL = JSON.parse(document.getElementById('sections-data').textContent || '[]');
} catch (e) {
console.error('sections JSON parse failed', e);
ALL = [];
}
function esc(s) {
return String(s ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function shortPath(p) {
if (!p) return '';
const parts = String(p).replace(/\\/g,'/').split('/');
return parts.slice(-2).join('/');
}
function toast(msg, isErr) {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = msg;
el.classList.toggle('error', !!isErr);
el.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove('show'), 2800);
}
function switchTab(name) {
const order = [{% if home_url %}'home',{% endif %}'scan','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();
}
const SCAN_EXT = ['.zip', '.docx', '.doc', '.html', '.htm', '.pdf', '.txt'];
const PAGE_PREFIX = {{ (prefix or 'RIP') | tojson }};
let scanMode = null;
let scanFiles = [];
let fileStats = null;
let wipeStats = null;
let wipeTarget = null;
let wipeLookupTimer = null;
function scanExt(name) {
const n = String(name || '');
const i = n.lastIndexOf('.');
return i >= 0 ? n.slice(i).toLowerCase() : '';
}
function fileBase(p) {
if (!p) return '';
const parts = String(p).replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || '';
}
function sectionCountLabel(n) {
const x = Number(n) || 0;
if (x === 1) return '1 секция';
return x + ' секции';
}
function setScanStatus(text, kind) {
const st = document.getElementById('rescan-status');
st.textContent = text;
st.classList.remove('busy', 'ok', 'error');
if (kind) st.classList.add(kind);
}
function setWipeStatus(text, kind) {
const st = document.getElementById('wipe-status');
if (!st) return;
st.textContent = text || '';
st.classList.remove('busy', 'ok', 'error');
if (kind) st.classList.add(kind);
}
function fileCountBg(n) {
return n === 1 ? '1 файл' : n + ' файла';
}
function folderNameFrom(files) {
const p = (files[0] && files[0].webkitRelativePath) || '';
const top = p.replace(/\\/g, '/').split('/')[0];
return top || (files[0] && files[0].name) || 'папка';
}
function statsLine(stats) {
if (!stats) return '';
const n = stats.count || 0;
let line = 'Секции: ' + n;
if (n && stats.first && stats.last) {
line += stats.first === stats.last
? ' · ' + stats.first
: ' · ' + stats.first + ' – ' + stats.last;
}
if (stats.ok === false && stats.issues && stats.issues.length) {
line += ' · ' + stats.issues.join('; ');
}
return line;
}
function renderFileStats() {
const meta = document.getElementById('rescan-filemeta');
const fileSelected = scanMode === 'file' && scanFiles[0];
if (!fileStats || !fileSelected) {
if (meta) { meta.textContent = ''; meta.classList.remove('warn'); }
return;
}
if (meta) {
meta.textContent = statsLine(fileStats);
meta.classList.toggle('warn', fileStats.ok === false);
}
}
function renderWipeStats() {
const meta = document.getElementById('wipe-filemeta');
const btn = document.getElementById('wipe-btn');
const name = (document.getElementById('wipe-filename') || {}).value;
const base = fileBase((name || '').trim());
if (!base) {
if (meta) { meta.textContent = ''; meta.classList.remove('warn'); }
if (btn) btn.disabled = true;
return;
}
const n = wipeStats ? (wipeStats.count || 0) : 0;
if (meta) {
meta.textContent = wipeStats
? (statsLine(wipeStats) + (wipeStats.file ? ' · ' + wipeStats.file : ''))
: '';
meta.classList.toggle('warn', !!(wipeStats && wipeStats.ok === false));
}
if (btn) btn.disabled = false;
}
async function fetchFileStats(name) {
const base = fileBase(name);
if (!base) return null;
try {
const url = '/api/file-extractions?file=' + encodeURIComponent(base)
+ '&prefix=' + encodeURIComponent(PAGE_PREFIX);
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
throw new Error(typeof detail === 'string' ? detail : ('HTTP ' + res.status));
}
return data;
} catch (e) {
const rows = ALL.filter(r => fileBase(r.source_file).toLowerCase() === base.toLowerCase());
const codes = rows.map(r => r.code).filter(Boolean).sort();
return {
file: base,
count: rows.length,
first: codes[0] || null,
last: codes[codes.length - 1] || null,
ok: true,
issues: [],
};
}
}
async function loadFileStats(name) {
if (!name) {
fileStats = null;
renderFileStats();
return;
}
fileStats = await fetchFileStats(name);
renderFileStats();
}
function onWipeNameInput() {
if (wipeLookupTimer) clearTimeout(wipeLookupTimer);
const raw = (document.getElementById('wipe-filename').value || '').trim();
const base = fileBase(raw);
if (!base) {
wipeStats = null;
renderWipeStats();
setWipeStatus('');
return;
}
wipeLookupTimer = setTimeout(async () => {
wipeStats = await fetchFileStats(base);
renderWipeStats();
const n = wipeStats ? (wipeStats.count || 0) : 0;
setWipeStatus(n ? ('Намерени ' + sectionCountLabel(n) + '.') : 'Няма секции за този файл.');
}, 250);
}
async function refreshSections() {
const p = {{ (prefix or '') | tojson }};
const url = p ? '/api/sections?prefix=' + encodeURIComponent(p) : '/api/sections';
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status);
ALL = await res.json();
document.getElementById('total-count').textContent = ALL.length + ' секции';
const q = document.getElementById('editor-search');
if (q && q.value) filterEditor();
else renderEditor(ALL);
renderKwCloud();
doSearch();
}
function setScanSelection(mode, files) {
scanMode = mode;
scanFiles = files;
const fileBtn = document.getElementById('rescan-pick-file');
const folderBtn = document.getElementById('rescan-pick-folder');
fileBtn.classList.toggle('selected', mode === 'file');
folderBtn.classList.toggle('selected', mode === 'folder');
fileBtn.setAttribute('aria-pressed', mode === 'file' ? 'true' : 'false');
folderBtn.setAttribute('aria-pressed', mode === 'folder' ? 'true' : 'false');
const sel = document.getElementById('rescan-selection');
if (!mode) {
sel.textContent = '';
setScanStatus('Избери файл или папка.');
loadFileStats('');
return;
}
if (mode === 'file') {
if (!files[0]) {
sel.textContent = '';
setScanStatus('Избери файл.', 'error');
loadFileStats('');
return;
}
sel.textContent = 'Файл: ' + files[0].name;
setScanStatus('Готово за старт.');
loadFileStats(files[0].name);
return;
}
loadFileStats('');
if (!files.length) {
sel.textContent = 'Папка: няма подходящи файлове';
setScanStatus('Няма .zip, .docx, .html, .pdf или .txt в папката.', 'error');
return;
}
sel.textContent = 'Папка: ' + folderNameFrom(files) + ' · ' + fileCountBg(files.length);
setScanStatus('Готово за старт.');
}
function pickScanSource(mode) {
if (document.getElementById('rescan-btn').disabled) return;
const id = mode === 'folder' ? 'rescan-folder' : 'rescan-file';
document.getElementById(id).click();
}
function onScanFilePicked() {
document.getElementById('rescan-folder').value = '';
const inp = document.getElementById('rescan-file');
const f = inp.files && inp.files[0];
if (!f) { setScanSelection(null, []); return; }
if (!SCAN_EXT.includes(scanExt(f.name))) {
setScanSelection('file', []);
setScanStatus('Неподдържан тип. Допустими: zip, docx, html, pdf, txt.', 'error');
return;
}
setScanSelection('file', [f]);
}
function onScanFolderPicked() {
document.getElementById('rescan-file').value = '';
const inp = document.getElementById('rescan-folder');
const raw = inp.files ? [...inp.files] : [];
const files = raw.filter(f => {
const name = f.name || '';
if (!name || name.startsWith('.')) return false;
const path = (f.webkitRelativePath || '').replace(/\\/g, '/');
if (path.split('/').some(p => p.startsWith('.') || p === '__MACOSX')) return false;
return SCAN_EXT.includes(scanExt(name));
});
setScanSelection('folder', files);
}
function confirmWipeFile() {
const raw = (document.getElementById('wipe-filename').value || '').trim();
const name = fileBase(raw);
if (!name) {
setWipeStatus('Постави име на файл.', 'error');
return;
}
const n = (wipeStats && wipeStats.count) || 0;
wipeTarget = name;
document.getElementById('wipe-modal-body').textContent =
'Ще се изтрият всички секции за «' + name + '» (' + n + ' бр.). Самият help файл не се трие.';
document.getElementById('wipe-modal').classList.add('open');
}
function closeWipeModal() {
const modal = document.getElementById('wipe-modal');
if (modal) modal.classList.remove('open');
wipeTarget = null;
}
async function doWipeFile() {
const name = wipeTarget;
if (!name) return;
const btn = document.getElementById('wipe-confirm-btn');
btn.disabled = true;
try {
const token = localStorage.getItem('RESCAN_TOKEN') || '';
const headers = {};
if (token) headers['X-Rescan-Token'] = token;
let url = '/api/file-extractions?file=' + encodeURIComponent(name)
+ '&prefix=' + encodeURIComponent(PAGE_PREFIX);
if (token) url += '&token=' + encodeURIComponent(token);
const res = await fetch(url, { method: 'DELETE', headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
throw new Error(typeof detail === 'string' ? detail : (data.message || ('HTTP ' + res.status)));
}
document.getElementById('wipe-modal').classList.remove('open');
wipeTarget = null;
await refreshSections();
wipeStats = await fetchFileStats(name);
renderWipeStats();
setWipeStatus('Изтрити секции: ' + (data.deleted || 0) + ' за ' + name + '.', 'ok');
toast('Изтрити: ' + (data.deleted || 0));
} catch (e) {
toast(e.message, true);
setWipeStatus('Грешка: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
async function startRescan() {
const btn = document.getElementById('rescan-btn');
const fileBtn = document.getElementById('rescan-pick-file');
const folderBtn = document.getElementById('rescan-pick-folder');
if (!scanFiles.length) {
setScanStatus(scanMode === 'folder' ? 'Няма подходящи файлове в папката.' : 'Избери файл или папка.', 'error');
return;
}
const fd = new FormData();
scanFiles.forEach(f => fd.append('file', f));
fd.append('prefix', PAGE_PREFIX);
fd.append('force', document.getElementById('rescan-force').checked ? 'true' : 'false');
const token = localStorage.getItem('RESCAN_TOKEN') || '';
if (token) fd.append('token', token);
btn.disabled = true;
fileBtn.disabled = true;
folderBtn.disabled = true;
setScanStatus('Качване...', 'busy');
try {
const headers = {};
if (token) headers['X-Rescan-Token'] = token;
const res = await fetch('/api/rescan', { method: 'POST', body: fd, headers });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail;
const msg = typeof detail === 'string' ? detail : (detail && JSON.stringify(detail)) || ('HTTP ' + res.status);
throw new Error(msg);
}
setScanStatus('Job ' + data.job_id + ' — обработка...', 'busy');
await pollRescan(data.job_id);
} catch (e) {
setScanStatus('Грешка: ' + e.message, 'error');
} finally {
btn.disabled = false;
fileBtn.disabled = false;
folderBtn.disabled = false;
}
}
async function pollRescan(jobId) {
for (let i = 0; i < 180; i++) {
await new Promise(r => setTimeout(r, 2000));
const res = await fetch('/api/rescan/' + encodeURIComponent(jobId));
const data = await res.json();
const line = data.status + (data.message ? ' — ' + data.message : '');
if (data.status === 'done') {
const files = data.files || [];
const bad = files.some(f => f && f.ok === false);
let msg = 'Готово.';
if (typeof data.sections === 'number') msg += ' Записани: ' + data.sections;
if (files.length === 1) {
const f = files[0];
msg = 'Готово. Секции: ' + (f.count || 0);
if (f.first && f.last) {
msg += f.first === f.last ? ' · ' + f.first : ' · ' + f.first + ' – ' + f.last;
}
if (f.ok === false && f.issues && f.issues.length) msg += ' · ' + f.issues.join('; ');
if (f.keywords_ok === false || (f.ai_errors || 0) > 0) {
msg += ' · ключовите думи са с локален fallback';
}
}
setScanStatus(msg, bad ? 'error' : 'ok');
try {
await refreshSections();
if (scanMode === 'file' && scanFiles[0]) await loadFileStats(scanFiles[0].name);
const wipeName = fileBase((document.getElementById('wipe-filename').value || '').trim());
if (wipeName) {
wipeStats = await fetchFileStats(wipeName);
renderWipeStats();
}
} catch (e) {
setScanStatus((msg || 'Готово.') + ' Презареди страницата.', bad ? 'error' : 'ok');
}
return;
}
if (data.status === 'error') {
setScanStatus('Грешка: ' + (data.message || 'unknown'), 'error');
return;
}
setScanStatus(line, 'busy');
}
setScanStatus('Timeout — провери /api/rescan/' + jobId, 'error');
}
// ── EDITOR ────────────────────────────
function renderEditor(rows) {
const tbody = document.getElementById('editor-body');
tbody.innerHTML = rows.map(r => `
<tr>
<td><span class="code-badge">${r.code}</span></td>
<td class="title-cell">${esc(r.title)}</td>
<td>
<div class="kw-cell">
<div class="kw-tags" id="tags-${r.code}">
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('')}
</div>
<div class="kw-edit-row">
<input class="kw-input" data-code="${r.code}" value="${esc(r.keywords||'')}"
placeholder="ключови думи, разделени със запетая"
oninput="onKwChange(this)"
onkeydown="if(event.key==='Enter'){saveOne(this);event.preventDefault();}">
<button class="save-btn" id="sb-${r.code}"
onclick="saveOne(document.querySelector(\`[data-code='${r.code}']\`))">✓ Запази</button>
</div>
</div>
</td>
<td><span class="src-file" title="Кликни за копиране: ${esc(fileBase(r.source_file))}"
data-file="${esc(fileBase(r.source_file))}"
onclick="copySourceFile(this.dataset.file, event)">${esc(shortPath(r.source_file))}</span></td>
<td style="font-size:11px;color:var(--muted);font-family:var(--mono);white-space:nowrap">${r.updated_at}</td>
</tr>
`).join('');
document.getElementById('editor-stats').textContent = rows.length + ' реда';
}
function filterEditor() {
const q = document.getElementById('editor-search').value.toLowerCase();
const filtered = q ? ALL.filter(r =>
(r.code||'').toLowerCase().includes(q) ||
(r.title||'').toLowerCase().includes(q) ||
(r.keywords||'').toLowerCase().includes(q) ||
(r.source_file||'').toLowerCase().includes(q)
) : ALL;
renderEditor(filtered);
}
function copySourceFile(name, ev) {
if (ev) ev.stopPropagation();
const base = fileBase(name);
if (!base) return;
const inp = document.getElementById('wipe-filename');
if (inp) inp.value = base;
onWipeNameInput();
const done = () => toast('Копирано: ' + base);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(base).then(done, done);
} else {
done();
}
}
function onKwChange(inp) {
const code = inp.dataset.code;
inp.classList.add('changed');
inp.classList.remove('saved', 'error');
document.getElementById('sb-' + code).classList.add('visible');
const tagsHost = document.getElementById('tags-' + code);
if (tagsHost) {
tagsHost.innerHTML = inp.value.split(',')
.filter(k => k.trim())
.map(k => `<span class="tag">${esc(k.trim())}</span>`)
.join('');
}
}
async function saveOne(inp) {
const code = inp.dataset.code;
const keywords = inp.value;
const btn = document.getElementById('sb-' + code);
inp.classList.remove('changed', 'saved', 'error');
inp.classList.add('saving');
try {
const res = await fetch(`/api/keywords/${encodeURIComponent(code)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({keywords})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
inp.classList.remove('saving');
inp.classList.add('saved');
if (btn) btn.classList.remove('visible');
const row = ALL.find(r => r.code === code);
if (row) row.keywords = keywords;
toast(`Запазено: ${code}`);
setTimeout(() => inp.classList.remove('saved'), 1500);
} catch (e) {
inp.classList.remove('saving');
inp.classList.add('error');
toast(`Грешка: ${e.message}`, true);
}
}
// ── SEARCH ────────────────────────────
const selected = new Set();
let genOrder = [];
const activeKw = new Set();
function allKeywords() {
const map = new Map();
ALL.forEach(r => {
(r.keywords || '').split(',').map(k => k.trim()).filter(Boolean).forEach(k => {
map.set(k, (map.get(k) || 0) + 1);
});
});
return [...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'bg'));
}
function renderKwCloud() {
const host = document.getElementById('kw-cloud');
if (!host) return;
const kws = allKeywords();
if (!kws.length) {
host.innerHTML = '<span class="stats">Няма ключови думи в данните</span>';
return;
}
host.innerHTML = kws.map(([k, n]) =>
`<span class="tag ${activeKw.has(k.toLowerCase()) ? 'active' : ''}"
data-kw="${esc(k)}" title="${n} секции">${esc(k)}</span>`
).join('');
host.onclick = (ev) => {
const t = ev.target.closest('.tag[data-kw]');
if (!t) return;
toggleKw(ev, t.getAttribute('data-kw'));
};
}
function toggleKw(ev, kw) {
ev.stopPropagation();
const key = kw.toLowerCase();
if (activeKw.has(key)) activeKw.delete(key);
else activeKw.add(key);
// sync into search box as quoted tokens for clarity
const box = document.getElementById('search-input');
const parts = [...activeKw].map(k => (/\s/.test(k) ? `"${k}"` : k));
box.value = parts.join(' ');
renderKwCloud();
doSearch();
}
function tokenizeQuery(q) {
// Служебни думи (винаги се прескачат)
const STOP = new Set([
'аз','ти','той','тя','то','ние','вие','те','ме','ми','му','го','я','ги','ни','ви','се','си',
'съм','е','сме','сте','са','бях','беше','бяха','ще','да','не','ли','дали',
'и','или','но','а','нито','че','като','ако','защото','понеже',
'за','от','на','в','във','с','със','по','към','до','при','след','преди','без','над','под','през','около','между',
'този','тази','това','тези','онзи','онази','онова','онези','един','една','едно','едни',
'как','кой','коя','кое','кои','къде','кога','защо','колко','какъв','каква','какво','какви',
'инфо','информация','търся','търсим','търсите','искам','искаме','искате','моля',
'покажи','покажете','кажи','кажете','дай','дайте','намери','намерете','имам','имаме','има','няма',
'може','могат','трябва','също','още','вече','само','много','малко','добре','така','тук','там',
'сега','тогава','пак','отново','примерно','например','относно','връзка',
'please','info','information','about','for','the','a','an','i','am','looking','find','show','get',
]);
const tokens = [];
const re = /"([^"]+)"|(\S+)/g;
let m;
while ((m = re.exec(q)) !== null) {
let t = (m[1] || m[2] || '').toLowerCase();
t = t.replace(/^[\s.,;:!?()\[\]{}"'«»]+|[\s.,;:!?()\[\]{}"'«»]+$/g, '');
if (!t) continue;
const quoted = !!m[1];
if (!quoted && (STOP.has(t) || t.length < 2)) continue;
tokens.push({ raw: t, quoted });
}
return tokens;
}
/** Грубо стемване за BG: лимитни/лимитна → лимит ; карти/карта → карт */
function stemBg(w) {
w = String(w || '').toLowerCase();
if (w.length < 4) return w;
const ends = [
'ите','ата','ята','ият','ия','ът','ят','та','те','то',
'ове','еве','ам','ят','ях','иш','ит',
'ни','на','но','ен','ни','и','а','о','я','у','ю','е',
];
for (const e of ends) {
if (w.length > e.length + 3 && w.endsWith(e)) return w.slice(0, -e.length);
}
return w;
}
function wordsStemmed(s) {
return (String(s || '').toLowerCase().match(/[а-яa-z0-9]+/gi) || []).map(stemBg);
}
/** Дали терминът се среща някъде в корпуса (ключови + заглавие + текст) */
function tokenInCorpus(token) {
const st = stemBg(token);
if (st.length < 2) return false;
return ALL.some(r => {
const words = wordsStemmed(
(r.keywords || '') + ' ' + (r.title || '') + ' ' + (r.text || '')
);
return words.some(w => w === st || (st.length >= 4 && (w.startsWith(st) || st.startsWith(w))));
});
}
function fieldHay(r, mode) {
const kw = r.keywords || '';
const title = r.title || '';
const text = r.text || '';
if (mode === 'keywords') return kw;
if (mode === 'text') return text + ' ' + title;
return kw + ' ' + title + ' ' + text;
}
function rowMatchesToken(r, token, mode) {
const hay = fieldHay(r, mode);
if (token.quoted) return hay.toLowerCase().includes(token.raw);
const st = stemBg(token.raw);
const words = wordsStemmed(hay);
return words.some(w => w === st || (st.length >= 4 && w.startsWith(st)));
}
function searchMode() {
const el = document.querySelector('input[name="search-mode"]:checked');
return el ? el.value : 'all';
}
function doSearch() {
const raw = document.getElementById('search-input').value.trim();
const mode = searchMode();
let tokens = raw ? tokenizeQuery(raw.toLowerCase()) : [];
// Паразити = думи, които ги няма в ключовите думи / зоната за търсене → прескачат се
const kept = [];
const skipped = [];
for (const t of tokens) {
if (t.quoted || tokenInCorpus(t.raw)) kept.push(t);
else skipped.push(t.raw);
}
tokens = kept;
const results = tokens.length === 0 ? ALL : ALL.filter(r =>
tokens.every(t => rowMatchesToken(r, t, mode))
);
let note = '';
if (tokens.length) note += ' · филтър: ' + tokens.map(t => t.raw).join(', ');
if (skipped.length) note += ' · пропуснати: ' + skipped.join(', ');
if (raw && !tokens.length) note = ' · само паразитни думи — показани всички';
document.getElementById('search-stats').textContent =
results.length + ' секции' + note;
document.getElementById('search-results').innerHTML = results.map(r => `
<div class="card ${selected.has(r.code)?'selected':''}" onclick="toggleSelect('${r.code}', this)">
<div class="card-header">
<div>
<div class="code-badge" style="margin-bottom:6px">${r.code}</div>
<div class="card-title">${esc(r.title)}</div>
</div>
<div class="check-icon"></div>
</div>
<div class="card-section-label">Ключови думи</div>
<div class="card-tags">
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('') || '<span class="stats">—</span>'}
</div>
<div class="card-section-label">Секция</div>
<div class="card-text">${r.text_html || esc(r.text||'(няма текст — провери OUTPUT_DIR)')}</div>
<div class="card-actions">
<button type="button" class="btn-ghost" onclick="event.stopPropagation(); openSection('${r.code}')">Цяла секция</button>
</div>
<div class="card-footer">${esc(shortPath(r.source_file))} &nbsp;·&nbsp; ${r.char_count||0} знака</div>
</div>
`).join('');
}
async function openSection(code) {
const modal = document.getElementById('section-modal');
document.getElementById('modal-code').textContent = code;
document.getElementById('modal-title').textContent = 'Зареждане…';
document.getElementById('modal-tags').innerHTML = '';
document.getElementById('modal-body').textContent = 'Зареждане…';
modal.classList.add('open');
try {
const res = await fetch('/api/section/' + encodeURIComponent(code));
if (!res.ok) throw new Error('HTTP ' + res.status);
const r = await res.json();
document.getElementById('modal-title').textContent = r.title || code;
document.getElementById('modal-tags').innerHTML =
(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('')
|| '<span class="stats">—</span>';
document.getElementById('modal-body').innerHTML =
r.text_html || esc(r.text || '(празно)').replace(/\n/g, '<br>');
} catch (e) {
document.getElementById('modal-body').textContent = 'Грешка: ' + e.message;
}
}
function closeSection() {
document.getElementById('section-modal').classList.remove('open');
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeWipeModal();
closeSection();
}
});
function toggleSelect(code, el) {
if (selected.has(code)) {
selected.delete(code);
el.classList.remove('selected');
genOrder = genOrder.filter(c => c !== code);
} else {
selected.add(code);
el.classList.add('selected');
genOrder.push(code);
}
}
function addSelectedToGenerator() {
if (selected.size === 0) { toast('Избери поне една секция'); return; }
switchTab('generator');
}
// ── GENERATOR ─────────────────────────
function renderGenerator() {
const list = document.getElementById('selected-list');
const stats = document.getElementById('gen-stats');
if (genOrder.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="big">⬡</div><div>Избери секции от таб Търсене</div></div>';
stats.textContent = 'Няма избрани секции';
return;
}
stats.textContent = genOrder.length + ' секции избрани';
list.innerHTML = genOrder.map((code, idx) => {
const r = ALL.find(x => x.code === code);
if (!r) return '';
return `
<div class="sel-item" draggable="true" data-idx="${idx}"
ondragstart="dragStart(event,${idx})"
ondragover="dragOver(event,${idx})"
ondrop="dragDrop(event,${idx})"
ondragleave="this.classList.remove('drag-over')">
<span class="drag-handle">⠿</span>
<div class="sel-item-info">
<div class="sel-item-title">${esc(r.title)}</div>
<div class="sel-item-code">${code}</div>
</div>
<button class="remove-btn" onclick="removeFromGen('${code}')">✕</button>
</div>`;
}).join('');
}
function removeFromGen(code) {
selected.delete(code);
genOrder = genOrder.filter(c => c !== code);
renderGenerator();
}
function clearGenerator() { selected.clear(); genOrder = []; renderGenerator(); }
let dragIdx = null;
function dragStart(e, idx) { dragIdx = idx; e.dataTransfer.effectAllowed = 'move'; }
function dragOver(e, idx) { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
function dragDrop(e, idx) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (dragIdx === null || dragIdx === idx) return;
const moved = genOrder.splice(dragIdx, 1)[0];
genOrder.splice(idx, 0, moved);
dragIdx = null;
renderGenerator();
}
function generateDoc(fmt) {
if (genOrder.length === 0) { toast('Избери секции първо'); return; }
const sections = genOrder.map(code => ALL.find(r => r.code === code)).filter(Boolean);
const html = buildHtmlDoc(sections);
const blob = new Blob([html], {type: 'text/html;charset=utf-8'});
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (!win) {
toast('Браузърът блокира new tab — позволи pop-ups', true);
download('help_document.html', html, 'text/html');
} else {
toast('HTML документът е отворен в нов tab');
}
}
function buildHtmlDoc(sections) {
// Картинките са relative URLs — за самостоятелен файл клиента ги embed-ва не може
// лесно. За първа итерация: оставяме URLs както са (същият домейн ги сервира).
const body = sections.map(s => `
<section>
<h2>${esc(s.title)}</h2>
<p class="meta">${esc(s.code)} &nbsp;·&nbsp; ${esc(s.keywords||'')}</p>
<div class="content">${s.text_html || esc(s.text||'').replace(/\n/g,'<br>')}</div>
</section>
`).join('<hr>');
// Превръщаме относителни /images/... в абсолютни (HOST + path) за работа в нов tab
const base = location.protocol + '//' + location.host;
const bodyAbs = body.replace(/src="\/images\//g, `src="${base}/images/`);
return `<!DOCTYPE html><html lang="bg"><head><meta charset="UTF-8">
<title>Help документ</title>
<style>
body{font-family:Georgia,serif;max-width:860px;margin:40px auto;padding:0 20px;color:#222;line-height:1.7}
h1{font-size:24px;border-bottom:2px solid #333;padding-bottom:10px}
h2{font-size:18px;margin-top:0;color:#1a1a2e}
.meta{font-size:11px;color:#888;font-family:monospace;margin-bottom:12px}
.content{font-size:14px}
hr{border:none;border-top:1px solid #e0e0e0;margin:32px 0}
section{margin-bottom:8px}
img{max-width:100%;border:1px solid #eee;border-radius:4px;margin:8px 0}
</style></head><body>
<h1>Help документ</h1>
<p style="font-size:12px;color:#888">Генериран: ${new Date().toLocaleString('bg-BG')}</p>
<hr>
${bodyAbs}
</body></html>`;
}
function download(filename, content, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], {type: mime}));
a.download = filename;
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 {
renderEditor(ALL);
renderKwCloud();
doSearch();
renderGenerator();
initApiTab();
renderWipeStats();
} catch (e) {
console.error('init failed', e);
toast('Грешка при зареждане на UI: ' + e.message, true);
}
</script>
</body>
</html>