Files
rip-help-system/webapp/templates/viewer.html
sabo 46a2f2272b Add server-side /api/rescan upload pipeline and Home UI.
Accept ZIP/single help files, process with help_processor into OUTPUT_DIR+Postgres, pollable jobs, optional RESCAN_TOKEN.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:20:45 +03:00

651 lines
27 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: 2px; padding: 0 24px; background: var(--bg2); border-bottom: 1px solid var(--border); }
.tab {
padding: 10px 20px; font-size: 13px; font-family: var(--mono); color: var(--muted);
cursor: pointer; border-bottom: 2px solid transparent;
transition: color .15s, border-color .15s; user-select: none;
}
.tab.active { color: var(--accent); border-color: var(--accent); }
.tab:hover:not(.active) { color: var(--text); }
.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-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; }
</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')">00 / Home</div>{% endif %}
<div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('editor')">01 / Редактор</div>
<div class="tab" onclick="switchTab('search')">02 / Търсене</div>
<div class="tab" onclick="switchTab('generator')">03 / Генератор</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='Няколко думи разделени с интервал (AND); за фраза - "в кавички"'
oninput="doSearch()">
<span class="stats" id="search-stats"></span>
<button class="btn-primary" onclick="addSelectedToGenerator()">Добави избраните → Генератор</button>
</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>ГЕНЕРИРАЙ ДОКУМЕНТ</h3>
<div class="format-btns">
<button class="format-btn" onclick="generateDoc('html')">
<span class="format-dot dot-html"></span> HTML файл (нов tab)
</button>
<button class="format-btn" onclick="generateDoc('docx')">
<span class="format-dot dot-word"></span> Word (.docx) — TODO
</button>
<button class="format-btn" onclick="generateDoc('pdf')">
<span class="format-dot dot-pdf"></span> PDF — TODO
</button>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const ALL = {{ sections_json | safe }};
document.getElementById('total-count').textContent = ALL.length + ' секции';
renderEditor(ALL);
doSearch();
renderGenerator();
function switchTab(name) {
const order = [{% if home_url %}'home',{% endif %}'editor','search','generator'];
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', order[i] === name));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
if (name === 'generator') renderGenerator();
}
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 = [];
function tokenizeQuery(q) {
const tokens = [];
const re = /"([^"]+)"|(\S+)/g;
let m;
while ((m = re.exec(q)) !== null) {
const t = (m[1] || m[2] || '').toLowerCase();
if (t) tokens.push(t);
}
return tokens;
}
function doSearch() {
const q = document.getElementById('search-input').value.trim().toLowerCase();
const tokens = q ? tokenizeQuery(q) : [];
const results = tokens.length === 0 ? ALL : ALL.filter(r => {
const hay = ((r.keywords||'') + ' ' + (r.title||'') + ' ' + (r.text||'')).toLowerCase();
return tokens.every(t => hay.includes(t));
});
document.getElementById('search-stats').textContent =
results.length + ' резултата' + (tokens.length > 1 ? ' (за ' + tokens.length + ' термина)' : '');
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-tags">
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('')}
</div>
<div class="card-text">${r.text_html || esc(r.text||'(няма preview)')}</div>
<div class="card-footer">${esc(shortPath(r.source_file))} &nbsp;·&nbsp; ${r.char_count} знака</div>
</div>
`).join('');
}
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);
if (fmt === 'html') {
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');
}
return;
}
toast('Word / PDF — TODO', true);
}
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>`;
}
// ── UTILS ─────────────────────────────
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 = p.replace(/\\/g,'/').split('/');
return parts.slice(-2).join('/');
}
function toast(msg, isError) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.toggle('error', !!isError);
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 3000);
}
function download(filename, content, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], {type: mime}));
a.download = filename;
a.click();
}
</script>
</body>
</html>