From 9613420d1d5c2432b0202d5a1e62c19e94eb5299 Mon Sep 17 00:00:00 2001 From: Sabo Sabev Date: Wed, 20 May 2026 17:00:44 +0300 Subject: [PATCH] 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/, GET /home-image, GET /api/sections, POST /api/keywords/, 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) --- .dockerignore | 25 ++ .env.example | 5 +- Dockerfile | 19 ++ README.md | 62 +++- docker-compose.yml | 21 ++ generate_html.py | 23 +- help_processor.py | 211 +++++-------- requirements-webapp.txt | 5 + requirements.txt | 8 +- save_keywords.py | 17 +- webapp/__init__.py | 0 webapp/main.py | 229 ++++++++++++++ webapp/templates/viewer.html | 576 +++++++++++++++++++++++++++++++++++ 13 files changed, 1034 insertions(+), 167 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements-webapp.txt create mode 100644 webapp/__init__.py create mode 100644 webapp/main.py create mode 100644 webapp/templates/viewer.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96b661c --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example index f401d7c..02dfe91 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ 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 -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d2d1d48 --- /dev/null +++ b/Dockerfile @@ -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=*"] diff --git a/README.md b/README.md index 63a0f95..9949a33 100644 --- a/README.md +++ b/README.md @@ -120,5 +120,65 @@ UNIQUE constraint: `(prefix, file_path)` |---|---|---| | `MIN_SECTION_TOKENS` | 60 | Под този праг секцията се слива с предишната | | `MAX_AI_CHARS` | 4000 | Символи, пращани към Claude | -| `AI_MODEL` | claude-sonnet-4-6 | Модел за класификация | +| `AI_MODEL` | claude-haiku-4-5 | Модел за класификация | | `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/` | Сервира картинка от `OUTPUT_DIR/images/` | +| GET | `/home-image` | Home image (от `HOME_IMAGE` env var или `Bairaci.png`) | +| GET | `/api/sections` | JSON списък със секции; query `?prefix=` | +| POST | `/api/keywords/` | `{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-а директно. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..76fc715 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/generate_html.py b/generate_html.py index 151c54e..150451d 100644 --- a/generate_html.py +++ b/generate_html.py @@ -11,16 +11,13 @@ from datetime import datetime from typing import Optional try: - import pyodbc + import psycopg2 except ImportError: - sys.exit("Инсталирай pyodbc: pip install pyodbc") + sys.exit("Инсталирай psycopg2: pip install psycopg2-binary") CONN_STR = os.getenv( "HELP_DB_CONN", - "DRIVER={ODBC Driver 18 for SQL Server};" - "TrustServerCertificate=yes;" - "SERVER=94.26.63.238,13151;DATABASE=blondina;" - "UID=blondina_login;PWD=blondina_parola_123" + "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!" ) 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): - conn = pyodbc.connect(CONN_STR, autocommit=True) + conn = psycopg2.connect(CONN_STR) cur = conn.cursor() if prefix: cur.execute(""" SELECT s.prefix, s.code, s.title, s.keywords, s.char_count, s.source_file, s.output_path, s.updated_at, s.images, s.html_text, f.section_count - FROM RIP_help_sections s - LEFT JOIN RIP_help_files f + 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 = ? + WHERE s.prefix = %s ORDER BY s.code - """, prefix) + """, (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 + 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 """) diff --git a/help_processor.py b/help_processor.py index eff3f1d..8bf81c7 100644 --- a/help_processor.py +++ b/help_processor.py @@ -33,7 +33,7 @@ from datetime import datetime from dataclasses import dataclass, field from typing import Optional -import pyodbc +import psycopg2 import anthropic from docx import Document from bs4 import BeautifulSoup @@ -73,7 +73,7 @@ log = logging.getLogger(__name__) MIN_SECTION_TOKENS = 60 # секции под тази граница се сливат с предишната MAX_AI_CHARS = 4000 # максимален текст, изпращан към Claude за класификация -AI_MODEL = "claude-sonnet-4-6" +AI_MODEL = "claude-haiku-4-5" 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: + """PostgreSQL backend (psycopg2). Connection string е libpq формат: + 'host=... port=... dbname=... user=... password=...' + """ def __init__(self, conn_str: str): - self.conn_str = _ensure_trust_server_certificate(conn_str) - self.conn = pyodbc.connect(self.conn_str, autocommit=False) + self.conn_str = conn_str + self.conn = psycopg2.connect(conn_str) self._ensure_schema() def _ensure_schema(self): - """Създава таблиците ако не съществуват.""" + """Създава таблиците ако не съществуват (Postgres syntax).""" cur = self.conn.cursor() cur.execute(""" - IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_files') - CREATE TABLE RIP_help_files ( - id INT IDENTITY PRIMARY KEY, - prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', - file_path NVARCHAR(1000) NOT NULL, + CREATE TABLE IF NOT EXISTS rip_help_files ( + id SERIAL PRIMARY KEY, + prefix VARCHAR(50) NOT NULL DEFAULT 'HLP', + file_path VARCHAR(1000) NOT NULL, file_hash CHAR(64) NOT NULL, - processed_at DATETIME2 NOT NULL DEFAULT GETDATE(), - section_count INT NOT NULL DEFAULT 0, - CONSTRAINT UQ_RIP_help_files_prefix_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' + processed_at TIMESTAMP NOT NULL DEFAULT NOW(), + section_count INTEGER NOT NULL DEFAULT 0, + UNIQUE (prefix, file_path) ) - 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(""" - IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name='RIP_help_sections') - CREATE TABLE RIP_help_sections ( - id INT IDENTITY PRIMARY KEY, - prefix NVARCHAR(50) NOT NULL DEFAULT 'HLP', - code NVARCHAR(80) NOT NULL UNIQUE, - source_file NVARCHAR(1000) NOT NULL, - title NVARCHAR(500), - keywords NVARCHAR(300), - char_count INT, - output_path NVARCHAR(1000), - images NVARCHAR(MAX), - created_at DATETIME2 NOT NULL DEFAULT GETDATE(), - updated_at DATETIME2 NOT NULL DEFAULT GETDATE() - )""") - # Migrate: добавяме колонка prefix ако таблицата е по-стара версия - cur.execute(""" - IF NOT EXISTS ( - SELECT 1 FROM sys.columns - WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='prefix' + CREATE TABLE IF NOT EXISTS rip_help_sections ( + id SERIAL PRIMARY KEY, + prefix VARCHAR(50) NOT NULL DEFAULT 'HLP', + code VARCHAR(80) NOT NULL UNIQUE, + source_file VARCHAR(1000) NOT NULL, + title VARCHAR(500), + keywords VARCHAR(300), + char_count INTEGER, + output_path VARCHAR(1000), + images TEXT, + html_text TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() ) - 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(""" - IF NOT EXISTS ( - SELECT 1 FROM sys.columns - WHERE object_id=OBJECT_ID('RIP_help_sections') AND name='images' - ) - ALTER TABLE RIP_help_sections ADD images NVARCHAR(MAX) NULL + CREATE INDEX IF NOT EXISTS ix_rip_help_sections_keywords + ON rip_help_sections(keywords) """) - # Migrate: добавяме колонка 'html_text' (rich HTML с форматиране) cur.execute(""" - IF NOT EXISTS ( - SELECT 1 FROM sys.columns - 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) + CREATE INDEX IF NOT EXISTS ix_rip_help_sections_prefix + ON rip_help_sections(prefix) """) self.conn.commit() log.info("Схемата е проверена / създадена.") @@ -274,8 +210,8 @@ class Database: def get_file_hash(self, prefix: str, file_path: str) -> Optional[str]: cur = self.conn.cursor() cur.execute( - "SELECT file_hash FROM RIP_help_files WHERE prefix=? AND file_path=?", - prefix, file_path + "SELECT file_hash FROM rip_help_files WHERE prefix=%s AND file_path=%s", + (prefix, file_path) ) row = cur.fetchone() 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): cur = self.conn.cursor() cur.execute(""" - MERGE RIP_help_files AS t - USING (SELECT ? AS prefix, ? AS file_path, ? AS file_hash, ? AS section_count) AS s - ON t.prefix = s.prefix AND t.file_path = s.file_path - WHEN MATCHED THEN - UPDATE SET file_hash=s.file_hash, section_count=s.section_count, - processed_at=GETDATE() - WHEN NOT MATCHED THEN - 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) + INSERT INTO rip_help_files (prefix, file_path, file_hash, section_count) + VALUES (%s, %s, %s, %s) + ON CONFLICT (prefix, file_path) DO UPDATE SET + file_hash = EXCLUDED.file_hash, + section_count= EXCLUDED.section_count, + processed_at = NOW() + """, (prefix, file_path, file_hash, section_count)) self.conn.commit() def delete_sections_for_file(self, prefix: str, file_path: str): cur = self.conn.cursor() cur.execute( - "DELETE FROM RIP_help_sections WHERE prefix=? AND source_file=?", - prefix, file_path + "DELETE FROM rip_help_sections WHERE prefix=%s AND source_file=%s", + (prefix, file_path) ) self.conn.commit() @@ -307,21 +240,20 @@ class Database: """Връща всички source_file пътища за даден префикс.""" cur = self.conn.cursor() cur.execute(""" - SELECT file_path FROM RIP_help_files WHERE prefix=? + SELECT file_path FROM rip_help_files WHERE prefix=%s UNION - SELECT source_file FROM RIP_help_sections WHERE prefix=? - """, prefix, prefix) + SELECT source_file FROM rip_help_sections WHERE prefix=%s + """, (prefix, prefix)) return [r[0] for r in cur.fetchall()] def section_output_paths_for(self, prefix: str, source_files: list[str]) -> list[str]: if not source_files: return [] cur = self.conn.cursor() - placeholders = ",".join("?" for _ in source_files) cur.execute( - f"SELECT output_path FROM RIP_help_sections " - f"WHERE prefix=? AND source_file IN ({placeholders})", - prefix, *source_files + "SELECT output_path FROM rip_help_sections " + "WHERE prefix=%s AND source_file = ANY(%s)", + (prefix, list(source_files)) ) return [r[0] for r in cur.fetchall() if r[0]] @@ -329,17 +261,16 @@ class Database: if not source_files: return 0 cur = self.conn.cursor() - placeholders = ",".join("?" for _ in source_files) cur.execute( - f"DELETE FROM RIP_help_sections " - f"WHERE prefix=? AND source_file IN ({placeholders})", - prefix, *source_files + "DELETE FROM rip_help_sections " + "WHERE prefix=%s AND source_file = ANY(%s)", + (prefix, list(source_files)) ) sec_deleted = cur.rowcount cur.execute( - f"DELETE FROM RIP_help_files " - f"WHERE prefix=? AND file_path IN ({placeholders})", - prefix, *source_files + "DELETE FROM rip_help_files " + "WHERE prefix=%s AND file_path = ANY(%s)", + (prefix, list(source_files)) ) self.conn.commit() return sec_deleted @@ -347,22 +278,22 @@ class Database: def insert_section(self, prefix: str, ps: ProcessedSection, output_path: str): cur = self.conn.cursor() cur.execute(""" - MERGE RIP_help_sections AS t - USING (SELECT ? AS code) AS s ON t.code = s.code - WHEN MATCHED THEN - UPDATE SET prefix=?, source_file=?, title=?, keywords=?, - char_count=?, output_path=?, images=?, html_text=?, - updated_at=GETDATE() - WHEN NOT MATCHED THEN - INSERT (prefix, code, source_file, title, keywords, char_count, output_path, - images, html_text) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); - """, - ps.code, # USING - prefix, ps.source_file, ps.title, ps.keywords, # UPDATE SET - ps.char_count, output_path, ps.images_json, ps.html_text, - prefix, ps.code, ps.source_file, ps.title, ps.keywords, # INSERT - ps.char_count, output_path, ps.images_json, ps.html_text) + INSERT INTO rip_help_sections + (prefix, code, source_file, title, keywords, + char_count, output_path, images, html_text) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (code) DO UPDATE SET + prefix = EXCLUDED.prefix, + source_file = EXCLUDED.source_file, + title = EXCLUDED.title, + keywords = EXCLUDED.keywords, + char_count = EXCLUDED.char_count, + output_path = EXCLUDED.output_path, + images = EXCLUDED.images, + html_text = EXCLUDED.html_text, + updated_at = NOW() + """, (prefix, ps.code, ps.source_file, ps.title, ps.keywords, + ps.char_count, output_path, ps.images_json, ps.html_text)) self.conn.commit() def close(self): diff --git a/requirements-webapp.txt b/requirements-webapp.txt new file mode 100644 index 0000000..7cdc7df --- /dev/null +++ b/requirements-webapp.txt @@ -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 diff --git a/requirements.txt b/requirements.txt index 157aa1e..4d9b523 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,13 @@ anthropic>=0.25.0 -pyodbc>=5.0.0 +psycopg2-binary>=2.9.9 python-docx>=1.1.0 beautifulsoup4>=4.12.0 lxml>=5.0.0 pdfplumber>=0.11.0 chardet>=5.0.0 +pywin32; sys_platform == "win32" + +# Webapp +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +jinja2>=3.1.0 diff --git a/save_keywords.py b/save_keywords.py index 58a714b..2beb2e8 100644 --- a/save_keywords.py +++ b/save_keywords.py @@ -2,7 +2,7 @@ save_keywords.py ================ Чете keywords_changes.json (генериран от браузъра) -и записва промените в SQL Server. +и записва промените в PostgreSQL. Стартирай с: python save_keywords.py """ @@ -12,16 +12,13 @@ from pathlib import Path from datetime import datetime try: - import pyodbc + import psycopg2 except ImportError: - sys.exit("Инсталирай pyodbc: pip install pyodbc") + sys.exit("Инсталирай psycopg2: pip install psycopg2-binary") CONN_STR = os.getenv( "HELP_DB_CONN", - "DRIVER={ODBC Driver 18 for SQL Server};" - "TrustServerCertificate=yes;" - "SERVER=94.26.63.238,13151;DATABASE=blondina;" - "UID=blondina_login;PWD=blondina_parola_123" + "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!" ) CHANGES_FILE = Path(__file__).parent / "keywords_changes.json" @@ -38,7 +35,7 @@ def main(): return print(f"Записвам {len(changes)} промени в БД...") - conn = pyodbc.connect(CONN_STR, autocommit=False) + conn = psycopg2.connect(CONN_STR) cur = conn.cursor() ok, err = 0, 0 @@ -49,8 +46,8 @@ def main(): continue try: cur.execute( - "UPDATE RIP_help_sections SET keywords=?, updated_at=GETDATE() WHERE code=?", - keywords, code + "UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s", + (keywords, code) ) if cur.rowcount > 0: ok += 1 diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webapp/main.py b/webapp/main.py new file mode 100644 index 0000000..9400464 --- /dev/null +++ b/webapp/main.py @@ -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] към .""" + 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/" — превръщаме в URL /images/ + fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel + parts.append( + f'' + ) + last = m.end() + parts.append(_esc(text[last:])) + return "".join(parts).replace("\n", "
") + + +def _rich_html_with_images(html: str) -> str: + """Подменя [IMG: ...] placeholder-и с ; не escape-ва HTML.""" + def sub(m): + rel = m.group(1).strip().replace("\\", "/") + fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel + return (f'') + 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) diff --git a/webapp/templates/viewer.html b/webapp/templates/viewer.html new file mode 100644 index 0000000..fa90069 --- /dev/null +++ b/webapp/templates/viewer.html @@ -0,0 +1,576 @@ + + + + + +RIP Help Viewer{% if prefix %} — {{ prefix }}{% endif %} + + + + +
+

BG16RFPR001-1.001-0068

+ | + + {% if prefix %}|prefix={{ prefix }}{% endif %} + генериран: {{ generated }} +
+ +
+ {% if home_url %}
00 / Home
{% endif %} +
01 / Редактор
+
02 / Търсене
+
03 / Генератор
+
+ +{% if home_url %} +
+
Home
+
+{% endif %} + +
+
+ + +
+
+ + + + + + + + + + + +
КодЗаглавиеКлючови думиSource файлОбновен
+
+
+ + + +
+
+
+
+ Няма избрани секции + +
+
+
+
+
Избери секции от таб Търсене
+
+
+
+
+

ГЕНЕРИРАЙ ДОКУМЕНТ

+
+ + + +
+
+
+
+ +
+ + + +