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

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
# Не копираме Windows-локални скриптове в Docker контейнера
help_processor.py
generate_html.py
save_keywords.py
*.bat
*.log
help_viewer.html
keywords_changes*.json
.env
.env.local
__pycache__/
*.pyc
.git/
.claude/
.idea/
.vscode/
Output/
output/
data/
README.md
# Включваме само нужното
!Dockerfile
!requirements-webapp.txt
!webapp/

View File

@@ -1,5 +1,6 @@
REM Copy to .env and fill in real values. .env is gitignored. REM Copy to .env and fill in real values. .env is gitignored.
REM Loaded by .bat файловете чрез: for /f "delims=" %%a in (.env) do set "%%a" REM Loaded by .bat файловете чрез: call _load_env.bat
REM HELP_DB_CONN е libpq формат за PostgreSQL.
ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX
HELP_DB_CONN=DRIVER={ODBC Driver 18 for SQL Server};TrustServerCertificate=yes;SERVER=host,port;DATABASE=db;UID=user;PWD=password HELP_DB_CONN=host=192.168.88.18 port=5432 dbname=rip_help_system user=postgres password=YOUR_PASSWORD

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# Slim Python image; psycopg2-binary включва свои libs, не е нужен build deps
FROM python:3.12-slim
WORKDIR /app
# Сваляме само файловете нужни за webapp-а (не и Windows-only deps)
# Затова инсталираме отделен webapp-requirements без pywin32 / pdfplumber / docx
COPY requirements-webapp.txt /app/
RUN pip install --no-cache-dir -r requirements-webapp.txt
# Кода
COPY webapp /app/webapp
# Изходна директория за картинките — Docker volume mount
ENV OUTPUT_DIR=/data/help_output
EXPOSE 8000
CMD ["uvicorn", "webapp.main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]

View File

@@ -120,5 +120,65 @@ UNIQUE constraint: `(prefix, file_path)`
|---|---|---| |---|---|---|
| `MIN_SECTION_TOKENS` | 60 | Под този праг секцията се слива с предишната | | `MIN_SECTION_TOKENS` | 60 | Под този праг секцията се слива с предишната |
| `MAX_AI_CHARS` | 4000 | Символи, пращани към Claude | | `MAX_AI_CHARS` | 4000 | Символи, пращани към Claude |
| `AI_MODEL` | claude-sonnet-4-6 | Модел за класификация | | `AI_MODEL` | claude-haiku-4-5 | Модел за класификация |
| `MIN_IMAGE_PX` | 50 | Картинки под NxN px се пропускат | | `MIN_IMAGE_PX` | 50 | Картинки под NxN px се пропускат |
---
## Webapp (FastAPI)
`webapp/` съдържа FastAPI app за server deploy (Coolify / Docker).
### Endpoint-и
| Метод | Път | Описание |
|---|---|---|
| GET | `/` | HTML viewer (Home/Editor/Search/Generator); query `?prefix=RIP&home=1` |
| GET | `/images/<file>` | Сервира картинка от `OUTPUT_DIR/images/` |
| GET | `/home-image` | Home image (от `HOME_IMAGE` env var или `Bairaci.png`) |
| GET | `/api/sections` | JSON списък със секции; query `?prefix=` |
| POST | `/api/keywords/<code>` | `{keywords: "..."}` → UPDATE в DB |
| GET | `/healthz` | Health check (DB ping) |
### Env vars
| Var | Default | Описание |
|---|---|---|
| `HELP_DB_CONN` | — | libpq формат за Postgres (задължително) |
| `OUTPUT_DIR` | `./data` | Директория с `images/` подпапка |
| `HOME_IMAGE` | — | Абсолютен път към home картинка (опционален) |
### Локален dev
```
pip install -r requirements-webapp.txt
$env:HELP_DB_CONN="host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=..."
$env:OUTPUT_DIR="q:\RIP_Help_Source\Output"
python -m uvicorn webapp.main:app --reload
# отвори http://127.0.0.1:8000/?prefix=RIP&home=1
```
### Coolify deploy
1. В Coolify: New Resource → Application → Public/Private Repository
2. URL: `https://git.inex-project.net/sabo/rip-help-system.git`
3. Build pack: **Dockerfile**
4. Environment variables (в Coolify UI, не в git):
- `HELP_DB_CONN=host=... port=5432 dbname=rip_help_system user=... password=...`
- `OUTPUT_DIR=/data/help_output`
- `HOME_IMAGE=/data/help_output/Bairaci.png` (по избор)
5. Persistent storage (volume): `/data/help_output` (там качваш картинките с WinSCP/rsync)
6. Port: 8000
7. Custom domain → Coolify прави HTTPS (Let's Encrypt) автоматично
### Качване на картинки на сървъра
Локалният `help_processor.py` пише в `q:\RIP_Help_Source\Output\` (включително `images/` подпапка). За deploy:
```
# с WinSCP — sync на цялата директория
local: q:\RIP_Help_Source\Output\
remote: /home/sabo/share/help_output/ ← Coolify volume mount
```
Базата (Postgres на 192.168.88.18) се ползва от webapp-а директно.

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
# Local dev / Coolify production
# За local: docker compose up --build
# За Coolify: качваш през git, Coolify сам пуска docker build + run
version: '3.8'
services:
webapp:
build: .
container_name: rip-help-webapp
environment:
# libpq формат (Postgres). Коригирай към твоя Postgres host.
HELP_DB_CONN: "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
OUTPUT_DIR: "/data/help_output"
HOME_IMAGE: "/data/help_output/Bairaci.png"
volumes:
# Картинките се очакват в OUTPUT_DIR/images/
# На сървъра ще качваш с WinSCP/rsync (напр. /home/sabo/share/help_output)
- ./data:/data/help_output:ro
ports:
- "8000:8000"
restart: unless-stopped

View File

@@ -11,16 +11,13 @@ from datetime import datetime
from typing import Optional from typing import Optional
try: try:
import pyodbc import psycopg2
except ImportError: except ImportError:
sys.exit("Инсталирай pyodbc: pip install pyodbc") sys.exit("Инсталирай psycopg2: pip install psycopg2-binary")
CONN_STR = os.getenv( CONN_STR = os.getenv(
"HELP_DB_CONN", "HELP_DB_CONN",
"DRIVER={ODBC Driver 18 for SQL Server};" "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
"TrustServerCertificate=yes;"
"SERVER=94.26.63.238,13151;DATABASE=blondina;"
"UID=blondina_login;PWD=blondina_parola_123"
) )
OUT_HTML = Path(__file__).parent / "help_viewer.html" OUT_HTML = Path(__file__).parent / "help_viewer.html"
@@ -80,26 +77,26 @@ def _rich_html_with_images(html: str, output_dir: Path, embed: bool = False) ->
def fetch_sections(prefix: Optional[str] = None): def fetch_sections(prefix: Optional[str] = None):
conn = pyodbc.connect(CONN_STR, autocommit=True) conn = psycopg2.connect(CONN_STR)
cur = conn.cursor() cur = conn.cursor()
if prefix: if prefix:
cur.execute(""" cur.execute("""
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, SELECT s.prefix, s.code, s.title, s.keywords, s.char_count,
s.source_file, s.output_path, s.updated_at, s.source_file, s.output_path, s.updated_at,
s.images, s.html_text, f.section_count s.images, s.html_text, f.section_count
FROM RIP_help_sections s FROM rip_help_sections s
LEFT JOIN RIP_help_files f LEFT JOIN rip_help_files f
ON f.file_path = s.source_file AND f.prefix = s.prefix ON f.file_path = s.source_file AND f.prefix = s.prefix
WHERE s.prefix = ? WHERE s.prefix = %s
ORDER BY s.code ORDER BY s.code
""", prefix) """, (prefix,))
else: else:
cur.execute(""" cur.execute("""
SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, SELECT s.prefix, s.code, s.title, s.keywords, s.char_count,
s.source_file, s.output_path, s.updated_at, s.source_file, s.output_path, s.updated_at,
s.images, s.html_text, f.section_count s.images, s.html_text, f.section_count
FROM RIP_help_sections s FROM rip_help_sections s
LEFT JOIN RIP_help_files f LEFT JOIN rip_help_files f
ON f.file_path = s.source_file AND f.prefix = s.prefix ON f.file_path = s.source_file AND f.prefix = s.prefix
ORDER BY s.prefix, s.code ORDER BY s.prefix, s.code
""") """)

View File

@@ -33,7 +33,7 @@ from datetime import datetime
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
import pyodbc import psycopg2
import anthropic import anthropic
from docx import Document from docx import Document
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@@ -73,7 +73,7 @@ log = logging.getLogger(__name__)
MIN_SECTION_TOKENS = 60 # секции под тази граница се сливат с предишната MIN_SECTION_TOKENS = 60 # секции под тази граница се сливат с предишната
MAX_AI_CHARS = 4000 # максимален текст, изпращан към Claude за класификация MAX_AI_CHARS = 4000 # максимален текст, изпращан към Claude за класификация
AI_MODEL = "claude-sonnet-4-6" AI_MODEL = "claude-haiku-4-5"
MIN_IMAGE_PX = 50 # картинки под NxN px се пропускат (иконки/булети) MIN_IMAGE_PX = 50 # картинки под NxN px се пропускат (иконки/булети)
@@ -157,116 +157,52 @@ class ProcessedSection:
# База данни # База данни
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
def _ensure_trust_server_certificate(conn_str: str) -> str:
"""Добавя TrustServerCertificate=yes към connection string ако липсва."""
if not conn_str:
return conn_str
if re.search(r"TrustServerCertificate\s*=", conn_str, re.IGNORECASE):
return conn_str
sep = "" if conn_str.rstrip().endswith(";") else ";"
return f"{conn_str}{sep}TrustServerCertificate=yes;"
class Database: class Database:
"""PostgreSQL backend (psycopg2). Connection string е libpq формат:
'host=... port=... dbname=... user=... password=...'
"""
def __init__(self, conn_str: str): def __init__(self, conn_str: str):
self.conn_str = _ensure_trust_server_certificate(conn_str) self.conn_str = conn_str
self.conn = pyodbc.connect(self.conn_str, autocommit=False) self.conn = psycopg2.connect(conn_str)
self._ensure_schema() self._ensure_schema()
def _ensure_schema(self): def _ensure_schema(self):
"""Създава таблиците ако не съществуват.""" """Създава таблиците ако не съществуват (Postgres syntax)."""
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_files') CREATE TABLE IF NOT EXISTS rip_help_files (
CREATE TABLE RIP_help_files ( id SERIAL PRIMARY KEY,
id INT IDENTITY PRIMARY KEY, prefix VARCHAR(50) NOT NULL DEFAULT 'HLP',
prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', file_path VARCHAR(1000) NOT NULL,
file_path NVARCHAR(1000) NOT NULL,
file_hash CHAR(64) NOT NULL, file_hash CHAR(64) NOT NULL,
processed_at DATETIME2 NOT NULL DEFAULT GETDATE(), processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
section_count INT NOT NULL DEFAULT 0, section_count INTEGER NOT NULL DEFAULT 0,
CONSTRAINT UQ_RIP_help_files_prefix_path UNIQUE (prefix, file_path) UNIQUE (prefix, file_path)
)""")
# Migrate: добавяме колонка prefix ако таблицата е по-стара версия
cur.execute("""
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id=OBJECT_ID('RIP_help_files') AND name='prefix'
) )
BEGIN
ALTER TABLE RIP_help_files ADD prefix NVARCHAR(50) NOT NULL
CONSTRAINT DF_RIP_help_files_prefix DEFAULT 'HLP' WITH VALUES;
END
""")
# Migrate: ако има стара UNIQUE на file_path сама (без prefix), сваляме я
cur.execute("""
DECLARE @c NVARCHAR(200);
SELECT @c = i.name FROM sys.indexes i
WHERE i.object_id=OBJECT_ID('RIP_help_files')
AND i.is_unique=1
AND i.name <> 'UQ_RIP_help_files_prefix_path'
AND i.name NOT LIKE 'PK_%'
AND (SELECT COUNT(*) FROM sys.index_columns ic
WHERE ic.object_id=i.object_id AND ic.index_id=i.index_id) = 1;
IF @c IS NOT NULL EXEC('ALTER TABLE RIP_help_files DROP CONSTRAINT [' + @c + ']');
""")
# Migrate: създаваме новата composite UNIQUE ако липсва
cur.execute("""
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name='UQ_RIP_help_files_prefix_path'
AND object_id=OBJECT_ID('RIP_help_files')
)
ALTER TABLE RIP_help_files
ADD CONSTRAINT UQ_RIP_help_files_prefix_path UNIQUE (prefix, file_path)
""") """)
cur.execute(""" cur.execute("""
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_sections') CREATE TABLE IF NOT EXISTS rip_help_sections (
CREATE TABLE RIP_help_sections ( id SERIAL PRIMARY KEY,
id INT IDENTITY PRIMARY KEY, prefix VARCHAR(50) NOT NULL DEFAULT 'HLP',
prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', code VARCHAR(80) NOT NULL UNIQUE,
code NVARCHAR(80) NOT NULL UNIQUE, source_file VARCHAR(1000) NOT NULL,
source_file NVARCHAR(1000) NOT NULL, title VARCHAR(500),
title NVARCHAR(500), keywords VARCHAR(300),
keywords NVARCHAR(300), char_count INTEGER,
char_count INT, output_path VARCHAR(1000),
output_path NVARCHAR(1000), images TEXT,
images NVARCHAR(MAX), html_text TEXT,
created_at DATETIME2 NOT NULL DEFAULT GETDATE(), created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at DATETIME2 NOT NULL DEFAULT GETDATE() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)""")
# Migrate: добавяме колонка prefix ако таблицата е по-стара версия
cur.execute("""
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='prefix'
) )
ALTER TABLE RIP_help_sections ADD prefix NVARCHAR(50) NOT NULL
CONSTRAINT DF_RIP_help_sections_prefix DEFAULT 'HLP' WITH VALUES
""") """)
# Migrate: добавяме колонка 'images' ако таблицата е създадена по-стара версия
cur.execute(""" cur.execute("""
IF NOT EXISTS ( CREATE INDEX IF NOT EXISTS ix_rip_help_sections_keywords
SELECT 1 FROM sys.columns ON rip_help_sections(keywords)
WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='images'
)
ALTER TABLE RIP_help_sections ADD images NVARCHAR(MAX) NULL
""") """)
# Migrate: добавяме колонка 'html_text' (rich HTML с форматиране)
cur.execute(""" cur.execute("""
IF NOT EXISTS ( CREATE INDEX IF NOT EXISTS ix_rip_help_sections_prefix
SELECT 1 FROM sys.columns ON rip_help_sections(prefix)
WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='html_text'
)
ALTER TABLE RIP_help_sections ADD html_text NVARCHAR(MAX) NULL
""")
# Индекси за търсене по ключови думи и заглавие
cur.execute("""
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name='IX_RIP_help_sections_keywords' AND object_id=OBJECT_ID('RIP_help_sections')
)
CREATE INDEX IX_RIP_help_sections_keywords ON RIP_help_sections(keywords)
""") """)
self.conn.commit() self.conn.commit()
log.info("Схемата е проверена / създадена.") log.info("Схемата е проверена / създадена.")
@@ -274,8 +210,8 @@ class Database:
def get_file_hash(self, prefix: str, file_path: str) -> Optional[str]: def get_file_hash(self, prefix: str, file_path: str) -> Optional[str]:
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute( cur.execute(
"SELECT file_hash FROM RIP_help_files WHERE prefix=? AND file_path=?", "SELECT file_hash FROM rip_help_files WHERE prefix=%s AND file_path=%s",
prefix, file_path (prefix, file_path)
) )
row = cur.fetchone() row = cur.fetchone()
return row[0] if row else None return row[0] if row else None
@@ -283,23 +219,20 @@ class Database:
def upsert_file(self, prefix: str, file_path: str, file_hash: str, section_count: int): def upsert_file(self, prefix: str, file_path: str, file_hash: str, section_count: int):
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
MERGE RIP_help_files AS t INSERT INTO rip_help_files (prefix, file_path, file_hash, section_count)
USING (SELECT ? AS prefix, ? AS file_path, ? AS file_hash, ? AS section_count) AS s VALUES (%s, %s, %s, %s)
ON t.prefix = s.prefix AND t.file_path = s.file_path ON CONFLICT (prefix, file_path) DO UPDATE SET
WHEN MATCHED THEN file_hash = EXCLUDED.file_hash,
UPDATE SET file_hash=s.file_hash, section_count=s.section_count, section_count= EXCLUDED.section_count,
processed_at=GETDATE() processed_at = NOW()
WHEN NOT MATCHED THEN """, (prefix, file_path, file_hash, section_count))
INSERT (prefix, file_path, file_hash, section_count)
VALUES (s.prefix, s.file_path, s.file_hash, s.section_count);
""", prefix, file_path, file_hash, section_count)
self.conn.commit() self.conn.commit()
def delete_sections_for_file(self, prefix: str, file_path: str): def delete_sections_for_file(self, prefix: str, file_path: str):
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute( cur.execute(
"DELETE FROM RIP_help_sections WHERE prefix=? AND source_file=?", "DELETE FROM rip_help_sections WHERE prefix=%s AND source_file=%s",
prefix, file_path (prefix, file_path)
) )
self.conn.commit() self.conn.commit()
@@ -307,21 +240,20 @@ class Database:
"""Връща всички source_file пътища за даден префикс.""" """Връща всички source_file пътища за даден префикс."""
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
SELECT file_path FROM RIP_help_files WHERE prefix=? SELECT file_path FROM rip_help_files WHERE prefix=%s
UNION UNION
SELECT source_file FROM RIP_help_sections WHERE prefix=? SELECT source_file FROM rip_help_sections WHERE prefix=%s
""", prefix, prefix) """, (prefix, prefix))
return [r[0] for r in cur.fetchall()] return [r[0] for r in cur.fetchall()]
def section_output_paths_for(self, prefix: str, source_files: list[str]) -> list[str]: def section_output_paths_for(self, prefix: str, source_files: list[str]) -> list[str]:
if not source_files: if not source_files:
return [] return []
cur = self.conn.cursor() cur = self.conn.cursor()
placeholders = ",".join("?" for _ in source_files)
cur.execute( cur.execute(
f"SELECT output_path FROM RIP_help_sections " "SELECT output_path FROM rip_help_sections "
f"WHERE prefix=? AND source_file IN ({placeholders})", "WHERE prefix=%s AND source_file = ANY(%s)",
prefix, *source_files (prefix, list(source_files))
) )
return [r[0] for r in cur.fetchall() if r[0]] return [r[0] for r in cur.fetchall() if r[0]]
@@ -329,17 +261,16 @@ class Database:
if not source_files: if not source_files:
return 0 return 0
cur = self.conn.cursor() cur = self.conn.cursor()
placeholders = ",".join("?" for _ in source_files)
cur.execute( cur.execute(
f"DELETE FROM RIP_help_sections " "DELETE FROM rip_help_sections "
f"WHERE prefix=? AND source_file IN ({placeholders})", "WHERE prefix=%s AND source_file = ANY(%s)",
prefix, *source_files (prefix, list(source_files))
) )
sec_deleted = cur.rowcount sec_deleted = cur.rowcount
cur.execute( cur.execute(
f"DELETE FROM RIP_help_files " "DELETE FROM rip_help_files "
f"WHERE prefix=? AND file_path IN ({placeholders})", "WHERE prefix=%s AND file_path = ANY(%s)",
prefix, *source_files (prefix, list(source_files))
) )
self.conn.commit() self.conn.commit()
return sec_deleted return sec_deleted
@@ -347,22 +278,22 @@ class Database:
def insert_section(self, prefix: str, ps: ProcessedSection, output_path: str): def insert_section(self, prefix: str, ps: ProcessedSection, output_path: str):
cur = self.conn.cursor() cur = self.conn.cursor()
cur.execute(""" cur.execute("""
MERGE RIP_help_sections AS t INSERT INTO rip_help_sections
USING (SELECT ? AS code) AS s ON t.code = s.code (prefix, code, source_file, title, keywords,
WHEN MATCHED THEN char_count, output_path, images, html_text)
UPDATE SET prefix=?, source_file=?, title=?, keywords=?, VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
char_count=?, output_path=?, images=?, html_text=?, ON CONFLICT (code) DO UPDATE SET
updated_at=GETDATE() prefix = EXCLUDED.prefix,
WHEN NOT MATCHED THEN source_file = EXCLUDED.source_file,
INSERT (prefix, code, source_file, title, keywords, char_count, output_path, title = EXCLUDED.title,
images, html_text) keywords = EXCLUDED.keywords,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); char_count = EXCLUDED.char_count,
""", output_path = EXCLUDED.output_path,
ps.code, # USING images = EXCLUDED.images,
prefix, ps.source_file, ps.title, ps.keywords, # UPDATE SET html_text = EXCLUDED.html_text,
ps.char_count, output_path, ps.images_json, ps.html_text, updated_at = NOW()
prefix, ps.code, ps.source_file, ps.title, ps.keywords, # INSERT """, (prefix, ps.code, ps.source_file, ps.title, ps.keywords,
ps.char_count, output_path, ps.images_json, ps.html_text) ps.char_count, output_path, ps.images_json, ps.html_text))
self.conn.commit() self.conn.commit()
def close(self): def close(self):

5
requirements-webapp.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
jinja2>=3.1.0
psycopg2-binary>=2.9.9
pydantic>=2.0.0

View File

@@ -1,7 +1,13 @@
anthropic>=0.25.0 anthropic>=0.25.0
pyodbc>=5.0.0 psycopg2-binary>=2.9.9
python-docx>=1.1.0 python-docx>=1.1.0
beautifulsoup4>=4.12.0 beautifulsoup4>=4.12.0
lxml>=5.0.0 lxml>=5.0.0
pdfplumber>=0.11.0 pdfplumber>=0.11.0
chardet>=5.0.0 chardet>=5.0.0
pywin32; sys_platform == "win32"
# Webapp
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
jinja2>=3.1.0

View File

@@ -2,7 +2,7 @@
save_keywords.py save_keywords.py
================ ================
Чете keywords_changes.json (генериран от браузъра) Чете keywords_changes.json (генериран от браузъра)
и записва промените в SQL Server. и записва промените в PostgreSQL.
Стартирай с: python save_keywords.py Стартирай с: python save_keywords.py
""" """
@@ -12,16 +12,13 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
try: try:
import pyodbc import psycopg2
except ImportError: except ImportError:
sys.exit("Инсталирай pyodbc: pip install pyodbc") sys.exit("Инсталирай psycopg2: pip install psycopg2-binary")
CONN_STR = os.getenv( CONN_STR = os.getenv(
"HELP_DB_CONN", "HELP_DB_CONN",
"DRIVER={ODBC Driver 18 for SQL Server};" "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
"TrustServerCertificate=yes;"
"SERVER=94.26.63.238,13151;DATABASE=blondina;"
"UID=blondina_login;PWD=blondina_parola_123"
) )
CHANGES_FILE = Path(__file__).parent / "keywords_changes.json" CHANGES_FILE = Path(__file__).parent / "keywords_changes.json"
@@ -38,7 +35,7 @@ def main():
return return
print(f"Записвам {len(changes)} промени в БД...") print(f"Записвам {len(changes)} промени в БД...")
conn = pyodbc.connect(CONN_STR, autocommit=False) conn = psycopg2.connect(CONN_STR)
cur = conn.cursor() cur = conn.cursor()
ok, err = 0, 0 ok, err = 0, 0
@@ -49,8 +46,8 @@ def main():
continue continue
try: try:
cur.execute( cur.execute(
"UPDATE RIP_help_sections SET keywords=?, updated_at=GETDATE() WHERE code=?", "UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
keywords, code (keywords, code)
) )
if cur.rowcount > 0: if cur.rowcount > 0:
ok += 1 ok += 1

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>