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:
25
.dockerignore
Normal file
25
.dockerignore
Normal 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/
|
||||||
@@ -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
19
Dockerfile
Normal 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=*"]
|
||||||
62
README.md
62
README.md
@@ -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
21
docker-compose.yml
Normal 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
|
||||||
@@ -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
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -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
5
requirements-webapp.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
0
webapp/__init__.py
Normal file
229
webapp/main.py
Normal file
229
webapp/main.py
Normal 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("&", "&").replace("<", "<")
|
||||||
|
.replace(">", ">").replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
576
webapp/templates/viewer.html
Normal file
576
webapp/templates/viewer.html
Normal 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))} · ${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)} · ${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user