Files
rip-help-system/generate_html.py
Sabo Sabev 9613420d1d Migrate to PostgreSQL + add FastAPI webapp for Coolify deploy
Backend migration:
- Replace pyodbc/SQL Server with psycopg2/PostgreSQL throughout
- Rewrite Database class with portable SQL: SERIAL, ON CONFLICT, NOW()
- Lowercase table names (rip_help_files, rip_help_sections) - Postgres convention
- libpq connection string format in HELP_DB_CONN

Webapp (webapp/):
- FastAPI app: GET /, GET /images/<f>, GET /home-image, GET /api/sections,
  POST /api/keywords/<code>, GET /healthz
- Jinja2 template extracted from generate_html.py with HTTP image URLs
- Direct keyword save to DB (no JSON download detour)
- Same prefix scoping as CLI tools (?prefix=RIP)

Deployment:
- Dockerfile (python:3.12-slim + uvicorn)
- docker-compose.yml for local dev
- requirements-webapp.txt (minimal, no Windows-only deps)
- .dockerignore excludes pipeline scripts and BAT files
- README updated with webapp section and Coolify deploy guide

Also: switch AI model to claude-haiku-4-5 (~3x cheaper, same quality for this task)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 17:00:44 +03:00

936 lines
35 KiB
Python
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.
"""
generate_html.py
================
Чете секциите от SQL Server и генерира help_viewer.html.
Стартирай с: python generate_html.py
"""
import os, sys, json, re, base64, mimetypes, argparse
from pathlib import Path
from datetime import datetime
from typing import Optional
try:
import psycopg2
except ImportError:
sys.exit("Инсталирай psycopg2: pip install psycopg2-binary")
CONN_STR = os.getenv(
"HELP_DB_CONN",
"host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
)
OUT_HTML = Path(__file__).parent / "help_viewer.html"
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
def _esc(s: str) -> str:
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
def _img_src(rel: str, output_dir: Path, embed: bool) -> str:
"""file:// URI или base64 data URI за картинка."""
abs_path = (output_dir / rel).resolve()
if not abs_path.exists():
return _esc(rel)
if embed:
try:
mime = mimetypes.guess_type(str(abs_path))[0] or "image/png"
b64 = base64.b64encode(abs_path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{b64}"
except Exception:
return abs_path.as_uri()
return abs_path.as_uri()
def _text_to_html(text: str, output_dir: Path, embed: bool = False) -> str:
"""Конвертира [IMG: images/foo.png] към <img>; escape-ва останалия текст."""
parts = []
last = 0
for m in _IMG_PLACEHOLDER_RE.finditer(text):
parts.append(_esc(text[last:m.start()]))
rel = m.group(1).strip().replace("\\", "/")
src = _img_src(rel, output_dir, embed)
parts.append(
f'<img src="{src}" alt="" '
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
f'border:1px solid #d8dce3;border-radius:6px">'
)
last = m.end()
parts.append(_esc(text[last:]))
return "".join(parts).replace("\n", "<br>")
def _rich_html_with_images(html: str, output_dir: Path, embed: bool = False) -> str:
"""Същото като _text_to_html, но входът е вече HTML — НЕ escape-ва."""
def sub(m):
rel = m.group(1).strip().replace("\\", "/")
src = _img_src(rel, output_dir, embed)
return (f'<img src="{src}" alt="" '
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
f'border:1px solid #d8dce3;border-radius:6px">')
return _IMG_PLACEHOLDER_RE.sub(sub, html)
def fetch_sections(prefix: Optional[str] = None):
conn = psycopg2.connect(CONN_STR)
cur = conn.cursor()
if prefix:
cur.execute("""
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count,
s.source_file, s.output_path, s.updated_at,
s.images, s.html_text, f.section_count
FROM rip_help_sections s
LEFT JOIN rip_help_files f
ON f.file_path = s.source_file AND f.prefix = s.prefix
WHERE s.prefix = %s
ORDER BY s.code
""", (prefix,))
else:
cur.execute("""
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count,
s.source_file, s.output_path, s.updated_at,
s.images, s.html_text, f.section_count
FROM rip_help_sections s
LEFT JOIN rip_help_files f
ON f.file_path = s.source_file AND f.prefix = s.prefix
ORDER BY s.prefix, s.code
""")
cols = [c[0] for c in cur.description]
rows = []
for r in cur.fetchall():
d = dict(zip(cols, r))
d["updated_at"] = str(d["updated_at"])[:16] if d["updated_at"] else ""
# парсваме images JSON
try:
d["images"] = json.loads(d["images"]) if d.get("images") else []
except Exception:
d["images"] = []
# прочитаме текста от .txt файла ако съществува
d["text"] = ""
d["text_html"] = "" # file:// — за viewer-а
d["text_html_embed"] = "" # base64 data: — за export (self-contained)
out_dir = Path(d["output_path"]).parent if d.get("output_path") else None
if d.get("output_path") and Path(d["output_path"]).exists():
try:
txt_path = Path(d["output_path"])
raw = txt_path.read_text(encoding="utf-8")
parts = raw.split("" * 60, 1)
body = parts[1].strip() if len(parts) > 1 else raw
d["text"] = body[:800]
except Exception:
pass
# rich HTML от БД има приоритет; иначе fallback към plain text
if d.get("html_text") and out_dir:
d["text_html"] = _rich_html_with_images(d["html_text"], out_dir, embed=False)
d["text_html_embed"] = _rich_html_with_images(d["html_text"], out_dir, embed=True)
elif out_dir and d["text"]:
d["text_html"] = _text_to_html(d["text"][:1200], out_dir, embed=False)
d["text_html_embed"] = _text_to_html(d["text"], out_dir, embed=True)
rows.append(d)
conn.close()
return rows
def _home_image_data_uri(home_path: Optional[str]) -> Optional[str]:
"""Връща data: URI ако файлът съществува, иначе None."""
if not home_path:
return None
p = Path(home_path).expanduser()
if not p.is_absolute():
p = (Path(__file__).parent / p).resolve()
if not p.is_file():
print(f" [home] файлът не е намерен: {p}", file=sys.stderr)
return None
mime = mimetypes.guess_type(str(p))[0] or "image/png"
b64 = base64.b64encode(p.read_bytes()).decode("ascii")
return f"data:{mime};base64,{b64}"
def build_html(sections, home_image: Optional[str] = None):
data_json = json.dumps(sections, ensure_ascii=False)
generated = datetime.now().strftime("%d.%m.%Y %H:%M")
home_uri = _home_image_data_uri(home_image)
if home_uri:
home_tab_html = '<div class="tab active" onclick="switchTab(\'home\')">00 / Home</div>'
editor_tab_cls = "tab"
editor_panel_cls = "panel"
home_panel_html = (
'<div id="tab-home" class="panel active">'
f' <div class="home-wrap"><img src="{home_uri}" alt="Home"></div>'
'</div>'
)
tab_index_list = "['home','editor','search','generator']"
initial_tab = "home"
else:
home_tab_html = ""
editor_tab_cls = "tab active"
editor_panel_cls = "panel active"
home_panel_html = ""
tab_index_list = "['editor','search','generator']"
initial_tab = "editor"
return f"""<!DOCTYPE html>
<html lang="bg">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help Viewer</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 ── */
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 #total-count {{ color: rgba(255,255,255,.85) !important; }}
.gen-time {{
font-size: 11px;
color: rgba(255,255,255,.80);
margin-left: auto;
font-family: var(--mono);
}}
/* ── Tabs ── */
.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); }}
/* ── Panels ── */
.panel {{ display: none; padding: 20px 24px; }}
.panel.active {{ display: block; }}
/* ── Toolbar ── */
.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: #0a1a12; }}
.btn-danger {{ background: var(--danger); color: #fff; }}
.btn-ghost {{ background: var(--bg3); color: var(--text); border: 1px solid var(--border); }}
/* ── Stats bar ── */
.stats {{ font-size: 11px; color: var(--muted); font-family: var(--mono); }}
/* ── Table ── */
.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); }}
.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; }}
/* ── Search results ── */
.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); }}
/* ── Generator ── */
.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 ── */
#toast {{
position: fixed; bottom: 24px; right: 24px;
background: var(--accent2); color: #0a1a12;
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); }}
/* ── Save panel ── */
#save-panel {{
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--bg2); border: 1px solid var(--accent);
border-radius: var(--radius-lg); padding: 14px 24px;
display: flex; align-items: center; gap: 16px;
box-shadow: 0 6px 24px rgba(0,0,0,.12);
opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 200;
}}
#save-panel.show {{ opacity: 1; pointer-events: auto; }}
#save-count {{ font-family: var(--mono); font-size: 13px; color: var(--accent); }}
.sep {{ color: var(--border); margin: 0 4px; }}
/* ── Home tab ── */
.home-wrap {{
display: flex; justify-content: center; align-items: flex-start;
padding: 24px;
}}
.home-wrap img {{
max-width: 100%; max-height: calc(100vh - 200px);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0,0,0,.06);
}}
</style>
</head>
<body>
<header>
<h1>BG16RFPR001-1.001-0068</h1>
<span class="sep">|</span>
<span style="font-size:12px;color:var(--muted);font-family:var(--mono)" id="total-count"></span>
<span class="gen-time">генериран: {generated}</span>
</header>
<div class="tabs">
{home_tab_html}
<div class="{editor_tab_cls}" onclick="switchTab('editor')">01 / Редактор</div>
<div class="tab" onclick="switchTab('search')">02 / Търсене</div>
<div class="tab" onclick="switchTab('generator')">03 / Генератор</div>
</div>
{home_panel_html}
<!-- ══════════════════════════════════════
ТАБ 1: РЕДАКТОР
══════════════════════════════════════ -->
<div id="tab-editor" class="{editor_panel_cls}">
<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>
<!-- ══════════════════════════════════════
ТАБ 2: ТЪРСЕНЕ
══════════════════════════════════════ -->
<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>
<!-- ══════════════════════════════════════
ТАБ 3: ГЕНЕРАТОР
══════════════════════════════════════ -->
<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('docx')">
<span class="format-dot dot-word"></span> Word (.docx)
</button>
<button class="format-btn" onclick="generateDoc('html')">
<span class="format-dot dot-html"></span> HTML файл
</button>
<button class="format-btn" onclick="generateDoc('pdf')">
<span class="format-dot dot-pdf"></span> PDF
</button>
</div>
<div style="margin-top:20px;font-size:11px;color:var(--muted);line-height:1.7">
Подреди секциите с drag &amp; drop преди генериране.
<br><br>
За Word и PDF е нужен Python backend — засега се генерира HTML.
</div>
</div>
</div>
</div>
<!-- Save panel -->
<div id="save-panel">
<span id="save-count">0 промени</span>
<button class="btn-success" onclick="saveChanges()">Запази в JSON</button>
<button class="btn-ghost" onclick="discardChanges()">Отхвърли</button>
</div>
<div id="toast"></div>
<script>
const ALL = {data_json};
const changes = {{}}; // code -> new keywords
const selected = new Set(); // codes selected for generator
let genOrder = [];
// ── Init ──────────────────────────────
document.getElementById('total-count').textContent = ALL.length + ' секции';
renderEditor(ALL);
doSearch();
renderGenerator();
// ── Tabs ──────────────────────────────
function switchTab(name) {{
const order = {tab_index_list};
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();
}}
// ── 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;
changes[code] = inp.value;
inp.classList.add('changed');
document.getElementById('sb-' + code).classList.add('visible');
updateSavePanel();
// live preview of tags
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('');
}}
}}
function saveOne(inp) {{
const code = inp.dataset.code;
changes[code] = inp.value;
inp.classList.remove('changed');
const btn = document.getElementById('sb-' + code);
if (btn) {{ btn.classList.remove('visible'); }}
const row = ALL.find(r => r.code === code);
if (row) row.keywords = inp.value;
updateSavePanel();
toast('Маркирано — натисни "Запази в JSON" долу за да приложиш в БД');
}}
// ── SEARCH ────────────────────────────
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();
}}
// Drag & drop
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();
}}
// ── GENERATE DOC ──────────────────────
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 за този файл');
download('help_document.html', html, 'text/html');
}} else {{
toast('HTML документът е отворен в нов tab');
}}
return;
}}
toast('За Word и PDF е нужен Python backend — ще го добавим след това');
}}
function buildHtmlDoc(sections) {{
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_embed || s.text_html || esc(s.text||'').replace(/\\n/g,'<br>')}}</div>
</section>
`).join('<hr>');
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}}
</style></head><body>
<h1>Help документ</h1>
<p style="font-size:12px;color:#888">Генериран: ${{new Date().toLocaleString('bg-BG')}}</p>
<hr>
${{body}}
</body></html>`;
}}
// ── SAVE CHANGES ──────────────────────
function updateSavePanel() {{
const n = Object.keys(changes).length;
const panel = document.getElementById('save-panel');
document.getElementById('save-count').textContent = n + ' промени';
panel.classList.toggle('show', n > 0);
}}
function saveChanges() {{
const data = Object.entries(changes).map(([code, keywords]) => ({{code, keywords}}));
const json = JSON.stringify(data, null, 2);
download('keywords_changes.json', json, 'application/json');
toast('JSON файлът е свален — пусни save_keywords.py за да запишеш в БД');
Object.keys(changes).forEach(k => delete changes[k]);
updateSavePanel();
}}
function discardChanges() {{
Object.keys(changes).forEach(k => delete changes[k]);
document.querySelectorAll('.save-btn').forEach(b => b.classList.remove('visible'));
updateSavePanel();
toast('Промените са отхвърлени');
}}
// ── 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) {{
const el = document.getElementById('toast');
el.textContent = msg;
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>"""
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Генерира help_viewer.html от БД")
ap.add_argument(
"--prefix",
default=os.getenv("HELP_PREFIX"),
help="Филтрира viewer-а по prefix (например 'HLP', 'PROJ_X'). "
"Ако липсва, показва всички префикси."
)
ap.add_argument(
"--out",
default=str(OUT_HTML),
help=f"Изходен HTML път (default: {OUT_HTML.name})."
)
ap.add_argument(
"--home",
default=None,
help="Път към изображение, което да се покаже като Home таб (пръв). "
"Ако липсва — няма Home таб (трите стандартни таба остават)."
)
args = ap.parse_args()
print("Четем от базата данни...")
if args.prefix:
print(f" Филтър по prefix: {args.prefix}")
if args.home:
print(f" Home image: {args.home}")
try:
sections = fetch_sections(prefix=args.prefix)
except Exception as e:
sys.exit(f"Грешка при свързване с БД: {e}")
print(f"Намерени {len(sections)} секции.")
html = build_html(sections, home_image=args.home)
out_path = Path(args.out)
out_path.write_text(html, encoding="utf-8")
print(f"Генериран: {out_path}")
import webbrowser
webbrowser.open(out_path.as_uri())
print("Отворен в браузъра.")