1069 lines
46 KiB
HTML
1069 lines
46 KiB
HTML
<!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 { opacity: .85; }
|
||
.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; gap: 20px; 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(640px, 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-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>
|
||
|
||
<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('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 class="rescan-box">
|
||
<h3>Rescan — качи help файлове</h3>
|
||
<div class="rescan-row">
|
||
<input type="file" id="rescan-file" accept=".zip,.docx,.doc,.html,.htm,.pdf,.txt">
|
||
<label class="stats"><input type="checkbox" id="rescan-force"> force</label>
|
||
<button class="btn-primary" id="rescan-btn" onclick="startRescan()">Старт</button>
|
||
</div>
|
||
<div class="rescan-status" id="rescan-status">ZIP или един файл → обработка на сървъра → БД + Output</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div id="tab-editor" class="panel {% if not home_url %}active{% endif %}">
|
||
<div class="toolbar">
|
||
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
|
||
<span class="stats" id="editor-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=…&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>
|
||
<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,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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 %}'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() {
|
||
const inp = document.getElementById('rescan-file');
|
||
const st = document.getElementById('rescan-status');
|
||
const btn = document.getElementById('rescan-btn');
|
||
if (!inp.files || !inp.files[0]) {
|
||
st.textContent = 'Избери файл първо.';
|
||
return;
|
||
}
|
||
const fd = new FormData();
|
||
fd.append('file', inp.files[0]);
|
||
fd.append('prefix', {{ (prefix or 'RIP') | tojson }});
|
||
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;
|
||
st.textContent = 'Качване...';
|
||
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) throw new Error(data.detail || ('HTTP ' + res.status));
|
||
st.textContent = 'Job ' + data.job_id + ' — обработка...';
|
||
await pollRescan(data.job_id);
|
||
} catch (e) {
|
||
st.textContent = 'Грешка: ' + e.message;
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function pollRescan(jobId) {
|
||
const st = document.getElementById('rescan-status');
|
||
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();
|
||
st.textContent = data.status + (data.message ? ' — ' + data.message : '');
|
||
if (data.status === 'done') {
|
||
st.textContent = 'Готово. Презареждане...';
|
||
location.reload();
|
||
return;
|
||
}
|
||
if (data.status === 'error') {
|
||
st.textContent = 'Грешка: ' + (data.message || 'unknown');
|
||
return;
|
||
}
|
||
}
|
||
st.textContent = 'Timeout — провери /api/rescan/' + jobId;
|
||
}
|
||
|
||
// ── 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(r.source_file)}">${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 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))} · ${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') 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)} · ${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();
|
||
} catch (e) {
|
||
console.error('init failed', e);
|
||
toast('Грешка при зареждане на UI: ' + e.message, true);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|