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>
This commit is contained in:
2026-05-20 17:00:44 +03:00
parent 711053b8bd
commit 9613420d1d
13 changed files with 1034 additions and 167 deletions

0
webapp/__init__.py Normal file
View File

229
webapp/main.py Normal file
View File

@@ -0,0 +1,229 @@
"""
FastAPI webapp за RIP Help System.
Endpoint-и:
GET / — viewer HTML (с опционален ?prefix=&home=)
GET /images/{path} — статичен сервинг на картинки от OUTPUT_DIR
GET /home-image — home image ако HOME_IMAGE е зададен
GET /api/sections — JSON списък със секции (?prefix=)
POST /api/keywords/{code} — обновяване на keywords в БД
GET /healthz — health check
Конфигурация (env vars):
HELP_DB_CONN — libpq формат за Postgres
OUTPUT_DIR — директорията със секциите и картинките (default: ./data)
HOME_IMAGE — път към home картинка (опционален)
"""
import os, sys, json, re
from pathlib import Path
from datetime import datetime
from typing import Optional
import psycopg2
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
# ──────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────
CONN_STR = os.getenv(
"HELP_DB_CONN",
"host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
)
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", Path(__file__).parent.parent / "data"))
HOME_IMAGE = os.getenv("HOME_IMAGE") # абсолютен път или None
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
# ──────────────────────────────────────────────
# App
# ──────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
app = FastAPI(title="RIP Help System")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Static mount за картинките — само ако директорията съществува
_images_dir = OUTPUT_DIR / "images"
if _images_dir.exists():
app.mount("/images", StaticFiles(directory=str(_images_dir)), name="images")
# ──────────────────────────────────────────────
# DB
# ──────────────────────────────────────────────
def db_conn():
return psycopg2.connect(CONN_STR)
def _esc(s: str) -> str:
return (str(s or "")
.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
def _text_to_html(text: str) -> str:
"""Конвертира [IMG: images/foo.png] към <img src="/images/foo.png">."""
parts = []
last = 0
for m in _IMG_PLACEHOLDER_RE.finditer(text):
parts.append(_esc(text[last:m.start()]))
rel = m.group(1).strip().replace("\\", "/")
# Очакваме path като "images/<filename>" — превръщаме в URL /images/<filename>
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
parts.append(
f'<img src="/images/{_esc(fname)}" 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) -> str:
"""Подменя [IMG: ...] placeholder-и с <img src="/images/...">; не escape-ва HTML."""
def sub(m):
rel = m.group(1).strip().replace("\\", "/")
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
return (f'<img src="/images/{_esc(fname)}" 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) -> list[dict]:
conn = db_conn()
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 ""
try:
d["images"] = json.loads(d["images"]) if d.get("images") else []
except Exception:
d["images"] = []
# Render rich HTML или fallback към plain text + image placeholders
if d.get("html_text"):
d["text_html"] = _rich_html_with_images(d["html_text"])
else:
# Plain text fallback — четем от output_path ако може
body = ""
op = d.get("output_path")
if op:
# Опитваме се да намерим файла в OUTPUT_DIR (по basename)
txt_name = Path(op).name
txt_path = OUTPUT_DIR / txt_name
if txt_path.exists():
try:
raw = txt_path.read_text(encoding="utf-8")
parts = raw.split("" * 60, 1)
body = parts[1].strip() if len(parts) > 1 else raw
except Exception:
pass
d["text"] = body[:800]
d["text_html"] = _text_to_html(body[:1200]) if body else ""
rows.append(d)
conn.close()
return rows
# ──────────────────────────────────────────────
# Routes
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def viewer(request: Request,
prefix: Optional[str] = Query(None),
home: Optional[str] = Query(None)):
sections = fetch_sections(prefix)
home_url = "/home-image" if (home or HOME_IMAGE) else None
return templates.TemplateResponse(request, "viewer.html", {
"sections_json": json.dumps(sections, ensure_ascii=False, default=str),
"section_count": len(sections),
"prefix": prefix or "",
"home_url": home_url,
"generated": datetime.now().strftime("%d.%m.%Y %H:%M"),
})
@app.get("/home-image")
def home_image():
img_path = HOME_IMAGE
# Ако HOME_IMAGE не е зададен, пробваме Bairaci.png в проекта
if not img_path:
candidate = BASE_DIR.parent / "Bairaci.png"
if candidate.exists():
img_path = str(candidate)
if not img_path or not Path(img_path).is_file():
raise HTTPException(404, "home image not configured")
return FileResponse(img_path)
@app.get("/api/sections")
def api_sections(prefix: Optional[str] = Query(None)):
return JSONResponse(fetch_sections(prefix))
class KeywordsUpdate(BaseModel):
keywords: str
@app.post("/api/keywords/{code}")
def update_keywords(code: str, body: KeywordsUpdate):
conn = db_conn()
cur = conn.cursor()
cur.execute(
"UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
(body.keywords, code)
)
if cur.rowcount == 0:
conn.close()
raise HTTPException(404, f"section {code} not found")
conn.commit()
conn.close()
return {"ok": True, "code": code}
@app.get("/healthz")
def healthz():
try:
conn = db_conn()
cur = conn.cursor()
cur.execute("SELECT 1")
cur.fetchone()
conn.close()
return {"status": "ok", "db": "ok"}
except Exception as e:
return JSONResponse({"status": "error", "db": str(e)}, status_code=503)

View File

@@ -0,0 +1,576 @@
<!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; 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 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>
</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();
}
// ── 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>