Align Coolify webapp with WinSCP share and remote output paths.
Resolve section text from OUTPUT_DIR by basename, add /api/search and richer /healthz, default mount to /mnt/.../Output, support --remote-root in processor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,3 +4,9 @@ REM HELP_DB_CONN е libpq формат за PostgreSQL.
|
|||||||
|
|
||||||
ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX
|
ANTHROPIC_API_KEY=sk-ant-api03-XXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
HELP_DB_CONN=host=192.168.88.18 port=5432 dbname=rip_help_system user=postgres password=YOUR_PASSWORD
|
HELP_DB_CONN=host=192.168.88.18 port=5432 dbname=rip_help_system user=postgres password=YOUR_PASSWORD
|
||||||
|
HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
|
||||||
|
REM Coolify Environment Variables (същите ключове):
|
||||||
|
REM HELP_DB_CONN=...
|
||||||
|
REM OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
REM HOME_IMAGE=/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,6 +19,10 @@ keywords_changes*.json
|
|||||||
Output/
|
Output/
|
||||||
output/
|
output/
|
||||||
|
|
||||||
|
# Local WIP / obsolete
|
||||||
|
_local_wip/
|
||||||
|
api/
|
||||||
|
|
||||||
# Archives
|
# Archives
|
||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|||||||
38
COOLIFY.md
Normal file
38
COOLIFY.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Coolify — RIP Help System
|
||||||
|
|
||||||
|
## Build (Configuration → General)
|
||||||
|
|
||||||
|
| Поле | Стойност |
|
||||||
|
|------|----------|
|
||||||
|
| Build Pack | Dockerfile |
|
||||||
|
| Base Directory | `/` |
|
||||||
|
| Dockerfile Location | `/Dockerfile` |
|
||||||
|
|
||||||
|
Dockerfile се редактира **само в git** (няма Edit Dockerfile след Add Resource).
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```
|
||||||
|
HELP_DB_CONN=host=... port=5432 dbname=rip_help_system user=... password=...
|
||||||
|
OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
HOME_IMAGE=/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistent Storage
|
||||||
|
|
||||||
|
| Host path | Container path | Mode |
|
||||||
|
|-----------|----------------|------|
|
||||||
|
| `/mnt/mssql/share/RIP/RIP_Help_Source/Output` | `/mnt/mssql/share/RIP/RIP_Help_Source/Output` | read-only OK |
|
||||||
|
|
||||||
|
(Файловете се качват с WinSCP в този host path.)
|
||||||
|
|
||||||
|
## Проверки след Redeploy
|
||||||
|
|
||||||
|
1. `/healthz` → `status/db ok`, `output_dir_ok: true`, `txt_count` > 0
|
||||||
|
2. `/api/sections?prefix=RIP` → `text` не е празен
|
||||||
|
3. `/api/search?q=поръчки&prefix=RIP`
|
||||||
|
4. `/?prefix=RIP` — dashboard/viewer
|
||||||
|
|
||||||
|
## Порт
|
||||||
|
|
||||||
|
`8000`
|
||||||
@@ -8,11 +8,13 @@ WORKDIR /app
|
|||||||
COPY requirements-webapp.txt /app/
|
COPY requirements-webapp.txt /app/
|
||||||
RUN pip install --no-cache-dir -r requirements-webapp.txt
|
RUN pip install --no-cache-dir -r requirements-webapp.txt
|
||||||
|
|
||||||
# Кода
|
# Кода + home image в образа (fallback ако липсва в share)
|
||||||
COPY webapp /app/webapp
|
COPY webapp /app/webapp
|
||||||
|
COPY Bairaci.png /app/Bairaci.png
|
||||||
|
|
||||||
# Изходна директория за картинките — Docker volume mount
|
# Секции/картинки — Coolify Persistent Storage mount
|
||||||
ENV OUTPUT_DIR=/data/help_output
|
ENV OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
ENV HOME_IMAGE=/app/Bairaci.png
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
:@echo off
|
@echo off
|
||||||
chcp 65001 > nul
|
chcp 65001 > nul
|
||||||
call "%~dp0_load_env.bat" || exit /b 1
|
call "%~dp0_load_env.bat" || exit /b 1
|
||||||
set PYTHONIOENCODING=utf-8
|
set PYTHONIOENCODING=utf-8
|
||||||
|
if not defined HELP_REMOTE_ROOT set HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
|
||||||
echo === INCREMENTAL prefix=RIP ===
|
echo === INCREMENTAL prefix=RIP remote=%HELP_REMOTE_ROOT% ===
|
||||||
echo.
|
echo.
|
||||||
python help_processor.py --prefix=RIP "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
|
python help_processor.py --prefix=RIP --remote-root="%HELP_REMOTE_ROOT%" "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
:@echo off
|
@echo off
|
||||||
chcp 65001 > nul
|
chcp 65001 > nul
|
||||||
call "%~dp0_load_env.bat" || exit /b 1
|
call "%~dp0_load_env.bat" || exit /b 1
|
||||||
set PYTHONIOENCODING=utf-8
|
set PYTHONIOENCODING=utf-8
|
||||||
|
if not defined HELP_REMOTE_ROOT set HELP_REMOTE_ROOT=/mnt/mssql/share/RIP/RIP_Help_Source/Output
|
||||||
|
|
||||||
echo === FORCE + PURGE prefix=RIP ===
|
echo === FORCE + PURGE prefix=RIP remote=%HELP_REMOTE_ROOT% ===
|
||||||
echo.
|
echo.
|
||||||
python help_processor.py --prefix=RIP --force --purge-missing "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
|
python help_processor.py --prefix=RIP --force --purge-missing --remote-root="%HELP_REMOTE_ROOT%" "q:\RIP_Help_Source" "q:\RIP_Help_Source\Output"
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
# Local dev / Coolify production
|
# Local / Coolify
|
||||||
# За local: docker compose up --build
|
# Coolify: git deploy + env vars + persistent storage (не задължително compose)
|
||||||
# За Coolify: качваш през git, Coolify сам пуска docker build + run
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
webapp:
|
webapp:
|
||||||
build: .
|
build: .
|
||||||
container_name: rip-help-webapp
|
container_name: rip-help-webapp
|
||||||
environment:
|
environment:
|
||||||
# libpq формат (Postgres). Коригирай към твоя Postgres host.
|
HELP_DB_CONN: "${HELP_DB_CONN}"
|
||||||
HELP_DB_CONN: "host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
|
OUTPUT_DIR: "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
|
||||||
OUTPUT_DIR: "/data/help_output"
|
HOME_IMAGE: "/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png"
|
||||||
HOME_IMAGE: "/data/help_output/Bairaci.png"
|
|
||||||
volumes:
|
volumes:
|
||||||
# Картинките се очакват в OUTPUT_DIR/images/
|
# Host share (качен с WinSCP) → същият път в контейнера
|
||||||
# На сървъра ще качваш с WinSCP/rsync (напр. /home/sabo/share/help_output)
|
- /mnt/mssql/share/RIP/RIP_Help_Source/Output:/mnt/mssql/share/RIP/RIP_Help_Source/Output:ro
|
||||||
- ./data:/data/help_output:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -842,6 +842,14 @@ def make_code(prefix: str, file_index: int, sec_index: int) -> str:
|
|||||||
# Основна обработка
|
# Основна обработка
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _db_output_path(local_path: Path, code: str, remote_root: Optional[str]) -> str:
|
||||||
|
"""Локален staging файл; в БД — сървърен път ако е зададен remote_root."""
|
||||||
|
if remote_root:
|
||||||
|
root = remote_root.replace("\\", "/").rstrip("/")
|
||||||
|
return f"{root}/{code}.txt"
|
||||||
|
return str(local_path)
|
||||||
|
|
||||||
|
|
||||||
def process_file(
|
def process_file(
|
||||||
path: Path,
|
path: Path,
|
||||||
file_index: int,
|
file_index: int,
|
||||||
@@ -850,6 +858,7 @@ def process_file(
|
|||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
prefix: str = "HLP",
|
prefix: str = "HLP",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
remote_root: Optional[str] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Обработва един файл. Връща броя записани секции (0 = пропуснат)."""
|
"""Обработва един файл. Връща броя записани секции (0 = пропуснат)."""
|
||||||
rel = str(path)
|
rel = str(path)
|
||||||
@@ -937,7 +946,7 @@ def process_file(
|
|||||||
html_text=html_text,
|
html_text=html_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Записваме текста в изходна директория
|
# Локален staging; в БД — remote път (ако е зададен) за Coolify/API
|
||||||
out_path = output_dir / f"{code}.txt"
|
out_path = output_dir / f"{code}.txt"
|
||||||
out_path.write_text(
|
out_path.write_text(
|
||||||
f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n"
|
f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n"
|
||||||
@@ -946,7 +955,7 @@ def process_file(
|
|||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
db.insert_section(prefix, ps, str(out_path))
|
db.insert_section(prefix, ps, _db_output_path(out_path, code, remote_root))
|
||||||
saved += 1
|
saved += 1
|
||||||
log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)")
|
log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)")
|
||||||
|
|
||||||
@@ -966,6 +975,7 @@ def process_directory(
|
|||||||
prefix: str = "HLP",
|
prefix: str = "HLP",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
purge_missing: bool = False,
|
purge_missing: bool = False,
|
||||||
|
remote_root: Optional[str] = None,
|
||||||
):
|
):
|
||||||
if not _PREFIX_RE.match(prefix):
|
if not _PREFIX_RE.match(prefix):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -991,13 +1001,17 @@ def process_directory(
|
|||||||
if p.is_file() and p.suffix.lower() in extensions and not _under_output(p)
|
if p.is_file() and p.suffix.lower() in extensions and not _under_output(p)
|
||||||
]
|
]
|
||||||
log.info(f"Prefix={prefix} Намерени {len(files)} файла в {input_dir}")
|
log.info(f"Prefix={prefix} Намерени {len(files)} файла в {input_dir}")
|
||||||
|
if remote_root:
|
||||||
|
log.info(f"DB output_path root: {remote_root.replace(chr(92), '/').rstrip('/')}")
|
||||||
|
|
||||||
current_paths = {str(p) for p in files}
|
current_paths = {str(p) for p in files}
|
||||||
total_sections = 0
|
total_sections = 0
|
||||||
try:
|
try:
|
||||||
for idx, path in enumerate(sorted(files), 1):
|
for idx, path in enumerate(sorted(files), 1):
|
||||||
n = process_file(path, idx, db, client, output_dir,
|
n = process_file(
|
||||||
prefix=prefix, force=force)
|
path, idx, db, client, output_dir,
|
||||||
|
prefix=prefix, force=force, remote_root=remote_root,
|
||||||
|
)
|
||||||
total_sections += n
|
total_sections += n
|
||||||
|
|
||||||
if purge_missing:
|
if purge_missing:
|
||||||
@@ -1013,11 +1027,19 @@ def process_directory(
|
|||||||
removed_files = 0
|
removed_files = 0
|
||||||
for op in disk_paths:
|
for op in disk_paths:
|
||||||
try:
|
try:
|
||||||
|
code = Path(str(op).replace("\\", "/")).stem
|
||||||
|
local_txt = output_dir / f"{code}.txt"
|
||||||
|
if local_txt.exists():
|
||||||
|
local_txt.unlink()
|
||||||
|
removed_files += 1
|
||||||
opath = Path(op)
|
opath = Path(op)
|
||||||
if opath.exists():
|
if opath.exists():
|
||||||
|
try:
|
||||||
|
if opath.resolve() != local_txt.resolve():
|
||||||
opath.unlink()
|
opath.unlink()
|
||||||
removed_files += 1
|
removed_files += 1
|
||||||
code = opath.stem
|
except Exception:
|
||||||
|
pass
|
||||||
for img in (output_dir / "images").glob(f"{code}_*"):
|
for img in (output_dir / "images").glob(f"{code}_*"):
|
||||||
try:
|
try:
|
||||||
img.unlink()
|
img.unlink()
|
||||||
@@ -1040,14 +1062,14 @@ def process_directory(
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Help-файл декомпозитор с SQL Server + Anthropic"
|
description="Help-файл декомпозитор с PostgreSQL + Anthropic"
|
||||||
)
|
)
|
||||||
parser.add_argument("input_dir", help="Входна директория с help-файлове")
|
parser.add_argument("input_dir", help="Входна директория с help-файлове")
|
||||||
parser.add_argument("output_dir", help="Изходна директория за текстови секции")
|
parser.add_argument("output_dir", help="Изходна директория за текстови секции")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--conn",
|
"--conn",
|
||||||
default=os.getenv("HELP_DB_CONN"),
|
default=os.getenv("HELP_DB_CONN"),
|
||||||
help="SQL Server connection string (или HELP_DB_CONN env var)"
|
help="Postgres libpq connection string (или HELP_DB_CONN env var)"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--api-key",
|
"--api-key",
|
||||||
@@ -1071,12 +1093,20 @@ def main():
|
|||||||
help="След обработката изтрива от БД и диска секциите за източници, "
|
help="След обработката изтрива от БД и диска секциите за източници, "
|
||||||
"които вече не съществуват във входната директория (само в дадения prefix)"
|
"които вече не съществуват във входната директория (само в дадения prefix)"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-root",
|
||||||
|
default=os.getenv("HELP_REMOTE_ROOT"),
|
||||||
|
help="Сървърен корен за output_path в БД "
|
||||||
|
"(напр. /mnt/mssql/share/RIP/RIP_Help_Source/Output). "
|
||||||
|
"Файловете се пишат локално; в БД се записва remote път. "
|
||||||
|
"Или env HELP_REMOTE_ROOT."
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not args.api_key:
|
if not args.api_key:
|
||||||
sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).")
|
sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).")
|
||||||
if not args.conn:
|
if not args.conn:
|
||||||
sys.exit("Грешка: липсва SQL Server connection string (--conn или HELP_DB_CONN).")
|
sys.exit("Грешка: липсва Postgres connection string (--conn или HELP_DB_CONN).")
|
||||||
|
|
||||||
process_directory(
|
process_directory(
|
||||||
input_dir=Path(args.input_dir),
|
input_dir=Path(args.input_dir),
|
||||||
@@ -1086,6 +1116,7 @@ def main():
|
|||||||
prefix=args.prefix,
|
prefix=args.prefix,
|
||||||
force=args.force,
|
force=args.force,
|
||||||
purge_missing=args.purge_missing,
|
purge_missing=args.purge_missing,
|
||||||
|
remote_root=args.remote_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
309
webapp/main.py
309
webapp/main.py
@@ -2,63 +2,58 @@
|
|||||||
FastAPI webapp за RIP Help System.
|
FastAPI webapp за RIP Help System.
|
||||||
|
|
||||||
Endpoint-и:
|
Endpoint-и:
|
||||||
GET / — viewer HTML (с опционален ?prefix=&home=)
|
GET / — viewer HTML (?prefix=&home=)
|
||||||
GET /images/{path} — статичен сервинг на картинки от OUTPUT_DIR
|
GET /images/{filename} — картинки от OUTPUT_DIR/images
|
||||||
GET /home-image — home image ако HOME_IMAGE е зададен
|
GET /home-image — Bairaci / HOME_IMAGE
|
||||||
GET /api/sections — JSON списък със секции (?prefix=)
|
GET /api/sections — JSON секции (?prefix=)
|
||||||
POST /api/keywords/{code} — обновяване на keywords в БД
|
GET /api/search — търсене (?q=&prefix=)
|
||||||
GET /healthz — health check
|
GET /api/section/{code} — една секция с пълен текст
|
||||||
|
POST /api/keywords/{code} — обновяване на keywords
|
||||||
|
GET /healthz — health + OUTPUT_DIR статус
|
||||||
|
|
||||||
Конфигурация (env vars):
|
Env:
|
||||||
HELP_DB_CONN — libpq формат за Postgres
|
HELP_DB_CONN — libpq Postgres
|
||||||
OUTPUT_DIR — директорията със секциите и картинките (default: ./data)
|
OUTPUT_DIR — локален/mounted път към секциите (default: share)
|
||||||
HOME_IMAGE — път към home картинка (опционален)
|
HOME_IMAGE — път към home картинка
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os, sys, json, re
|
from __future__ import annotations
|
||||||
from pathlib import Path
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from fastapi import FastAPI, HTTPException, Request, Query
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Configuration
|
# Configuration
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
DEFAULT_SHARE = "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
|
||||||
|
|
||||||
CONN_STR = os.getenv(
|
CONN_STR = os.getenv(
|
||||||
"HELP_DB_CONN",
|
"HELP_DB_CONN",
|
||||||
"host=192.168.88.18 port=5432 dbname=rip_help_system user=sa password=Parola~12345!!!"
|
"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"))
|
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", DEFAULT_SHARE))
|
||||||
HOME_IMAGE = os.getenv("HOME_IMAGE") # абсолютен път или None
|
HOME_IMAGE = os.getenv("HOME_IMAGE") # absolute path or None
|
||||||
|
|
||||||
|
|
||||||
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
|
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# App
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
app = FastAPI(title="RIP Help System")
|
app = FastAPI(title="RIP Help System", version="0.2.0")
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
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
|
# Helpers
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
def db_conn():
|
def db_conn():
|
||||||
@@ -66,19 +61,60 @@ def db_conn():
|
|||||||
|
|
||||||
|
|
||||||
def _esc(s: str) -> str:
|
def _esc(s: str) -> str:
|
||||||
return (str(s or "")
|
return (
|
||||||
.replace("&", "&").replace("<", "<")
|
str(s or "")
|
||||||
.replace(">", ">").replace('"', """))
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _basename(path_str: str) -> str:
|
||||||
|
return Path(str(path_str).replace("\\", "/")).name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_section_txt(code: str, output_path: Optional[str]) -> Optional[Path]:
|
||||||
|
"""Намира .txt в OUTPUT_DIR по code / basename (игнорира q:\\ от БД)."""
|
||||||
|
candidates = []
|
||||||
|
if code:
|
||||||
|
candidates.append(OUTPUT_DIR / f"{code}.txt")
|
||||||
|
if output_path:
|
||||||
|
candidates.append(OUTPUT_DIR / _basename(output_path))
|
||||||
|
# ако output_path вече е абсолютен linux път и съществува
|
||||||
|
p = Path(output_path)
|
||||||
|
if p.is_file():
|
||||||
|
candidates.insert(0, p)
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_section_body(code: str, output_path: Optional[str]) -> str:
|
||||||
|
path = _resolve_section_txt(code, output_path)
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="cp1251")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
parts = raw.split("─" * 60, 1)
|
||||||
|
return parts[1].strip() if len(parts) > 1 else raw
|
||||||
|
|
||||||
|
|
||||||
def _text_to_html(text: str) -> str:
|
def _text_to_html(text: str) -> str:
|
||||||
"""Конвертира [IMG: images/foo.png] към <img src="/images/foo.png">."""
|
|
||||||
parts = []
|
parts = []
|
||||||
last = 0
|
last = 0
|
||||||
for m in _IMG_PLACEHOLDER_RE.finditer(text):
|
for m in _IMG_PLACEHOLDER_RE.finditer(text):
|
||||||
parts.append(_esc(text[last:m.start()]))
|
parts.append(_esc(text[last:m.start()]))
|
||||||
rel = m.group(1).strip().replace("\\", "/")
|
rel = m.group(1).strip().replace("\\", "/")
|
||||||
# Очакваме path като "images/<filename>" — превръщаме в URL /images/<filename>
|
|
||||||
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
|
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<img src="/images/{_esc(fname)}" alt="" '
|
f'<img src="/images/{_esc(fname)}" alt="" '
|
||||||
@@ -91,70 +127,77 @@ def _text_to_html(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _rich_html_with_images(html: str) -> str:
|
def _rich_html_with_images(html: str) -> str:
|
||||||
"""Подменя [IMG: ...] placeholder-и с <img src="/images/...">; не escape-ва HTML."""
|
|
||||||
def sub(m):
|
def sub(m):
|
||||||
rel = m.group(1).strip().replace("\\", "/")
|
rel = m.group(1).strip().replace("\\", "/")
|
||||||
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
|
fname = rel.split("/", 1)[1] if rel.startswith("images/") else rel
|
||||||
return (f'<img src="/images/{_esc(fname)}" alt="" '
|
return (
|
||||||
|
f'<img src="/images/{_esc(fname)}" alt="" '
|
||||||
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
|
f'style="max-width:100%;max-height:240px;display:block;margin:8px 0;'
|
||||||
f'border:1px solid #d8dce3;border-radius:6px">')
|
f'border:1px solid #d8dce3;border-radius:6px">'
|
||||||
|
)
|
||||||
|
|
||||||
return _IMG_PLACEHOLDER_RE.sub(sub, html)
|
return _IMG_PLACEHOLDER_RE.sub(sub, html)
|
||||||
|
|
||||||
|
|
||||||
def fetch_sections(prefix: Optional[str] = None) -> list[dict]:
|
def _row_to_dict(cols, r, *, full_text: bool = False) -> 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 = dict(zip(cols, r))
|
||||||
d["updated_at"] = str(d["updated_at"])[:16] if d["updated_at"] else ""
|
d["updated_at"] = str(d["updated_at"])[:16] if d["updated_at"] else ""
|
||||||
try:
|
try:
|
||||||
d["images"] = json.loads(d["images"]) if d.get("images") else []
|
d["images"] = json.loads(d["images"]) if d.get("images") else []
|
||||||
except Exception:
|
except Exception:
|
||||||
d["images"] = []
|
d["images"] = []
|
||||||
# Render rich HTML или fallback към plain text + image placeholders
|
|
||||||
|
body = _read_section_body(d.get("code") or "", d.get("output_path"))
|
||||||
|
# нормализиран път за клиента / дебъг
|
||||||
|
resolved = _resolve_section_txt(d.get("code") or "", d.get("output_path"))
|
||||||
|
d["resolved_path"] = str(resolved) if resolved else None
|
||||||
|
|
||||||
if d.get("html_text"):
|
if d.get("html_text"):
|
||||||
d["text_html"] = _rich_html_with_images(d["html_text"])
|
d["text_html"] = _rich_html_with_images(d["html_text"])
|
||||||
|
d["text"] = body if full_text else (body[:800] if body else "")
|
||||||
else:
|
else:
|
||||||
# Plain text fallback — четем от output_path ако може
|
if full_text:
|
||||||
body = ""
|
d["text"] = body
|
||||||
op = d.get("output_path")
|
d["text_html"] = _text_to_html(body) if body else ""
|
||||||
if op:
|
else:
|
||||||
# Опитваме се да намерим файла в OUTPUT_DIR (по basename)
|
d["text"] = body[:800] if body else ""
|
||||||
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 ""
|
d["text_html"] = _text_to_html(body[:1200]) if body else ""
|
||||||
rows.append(d)
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sections(
|
||||||
|
prefix: Optional[str] = None,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
full_text: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
conn = db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
sql = """
|
||||||
|
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 1=1
|
||||||
|
"""
|
||||||
|
params: list = []
|
||||||
|
if prefix:
|
||||||
|
sql += " AND s.prefix = %s"
|
||||||
|
params.append(prefix)
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
sql += """ AND (
|
||||||
|
s.code ILIKE %s OR s.title ILIKE %s OR COALESCE(s.keywords,'') ILIKE %s
|
||||||
|
OR COALESCE(s.html_text,'') ILIKE %s
|
||||||
|
)"""
|
||||||
|
params.extend([like, like, like, like])
|
||||||
|
sql += " ORDER BY s.prefix, s.code"
|
||||||
|
|
||||||
|
cur.execute(sql, params)
|
||||||
|
cols = [c[0] for c in cur.description]
|
||||||
|
rows = [_row_to_dict(cols, r, full_text=full_text) for r in cur.fetchall()]
|
||||||
conn.close()
|
conn.close()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -164,38 +207,91 @@ def fetch_sections(prefix: Optional[str] = None) -> list[dict]:
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def viewer(request: Request,
|
def viewer(
|
||||||
|
request: Request,
|
||||||
prefix: Optional[str] = Query(None),
|
prefix: Optional[str] = Query(None),
|
||||||
home: Optional[str] = Query(None)):
|
home: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
sections = fetch_sections(prefix)
|
sections = fetch_sections(prefix)
|
||||||
home_url = "/home-image" if (home or HOME_IMAGE) else None
|
home_url = "/home-image" if (home or HOME_IMAGE or (BASE_DIR.parent / "Bairaci.png").exists()) else None
|
||||||
return templates.TemplateResponse(request, "viewer.html", {
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"viewer.html",
|
||||||
|
{
|
||||||
"sections_json": json.dumps(sections, ensure_ascii=False, default=str),
|
"sections_json": json.dumps(sections, ensure_ascii=False, default=str),
|
||||||
"section_count": len(sections),
|
"section_count": len(sections),
|
||||||
"prefix": prefix or "",
|
"prefix": prefix or "",
|
||||||
"home_url": home_url,
|
"home_url": home_url,
|
||||||
"generated": datetime.now().strftime("%d.%m.%Y %H:%M"),
|
"generated": datetime.now().strftime("%d.%m.%Y %H:%M"),
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/home-image")
|
@app.get("/home-image")
|
||||||
def home_image():
|
def home_image():
|
||||||
img_path = HOME_IMAGE
|
img_path = HOME_IMAGE
|
||||||
# Ако HOME_IMAGE не е зададен, пробваме Bairaci.png в проекта
|
|
||||||
if not img_path:
|
if not img_path:
|
||||||
candidate = BASE_DIR.parent / "Bairaci.png"
|
for candidate in (
|
||||||
if candidate.exists():
|
OUTPUT_DIR / "Bairaci.png",
|
||||||
|
OUTPUT_DIR.parent / "Bairaci.png",
|
||||||
|
BASE_DIR.parent / "Bairaci.png",
|
||||||
|
):
|
||||||
|
if candidate.is_file():
|
||||||
img_path = str(candidate)
|
img_path = str(candidate)
|
||||||
|
break
|
||||||
if not img_path or not Path(img_path).is_file():
|
if not img_path or not Path(img_path).is_file():
|
||||||
raise HTTPException(404, "home image not configured")
|
raise HTTPException(404, "home image not configured")
|
||||||
return FileResponse(img_path)
|
return FileResponse(img_path)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/images/{filename:path}")
|
||||||
|
def serve_image(filename: str):
|
||||||
|
# само basename — без path traversal
|
||||||
|
safe = Path(filename).name
|
||||||
|
path = OUTPUT_DIR / "images" / safe
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(404, f"image not found: {safe}")
|
||||||
|
return FileResponse(path)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/sections")
|
@app.get("/api/sections")
|
||||||
def api_sections(prefix: Optional[str] = Query(None)):
|
def api_sections(prefix: Optional[str] = Query(None)):
|
||||||
return JSONResponse(fetch_sections(prefix))
|
return JSONResponse(fetch_sections(prefix))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search")
|
||||||
|
def api_search(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
prefix: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
rows = fetch_sections(prefix, q=q.strip())
|
||||||
|
return JSONResponse({"q": q, "prefix": prefix, "count": len(rows), "results": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/section/{code}")
|
||||||
|
def api_section(code: str):
|
||||||
|
conn = db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
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.code = %s
|
||||||
|
""",
|
||||||
|
(code,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cols = [c[0] for c in cur.description] if cur.description else []
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, f"section {code} not found")
|
||||||
|
return JSONResponse(_row_to_dict(cols, row, full_text=True))
|
||||||
|
|
||||||
|
|
||||||
class KeywordsUpdate(BaseModel):
|
class KeywordsUpdate(BaseModel):
|
||||||
keywords: str
|
keywords: str
|
||||||
|
|
||||||
@@ -206,7 +302,7 @@ def update_keywords(code: str, body: KeywordsUpdate):
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
|
"UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
|
||||||
(body.keywords, code)
|
(body.keywords, code),
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -218,12 +314,35 @@ def update_keywords(code: str, body: KeywordsUpdate):
|
|||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
|
db_status = "ok"
|
||||||
try:
|
try:
|
||||||
conn = db_conn()
|
conn = db_conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT 1")
|
cur.execute("SELECT 1")
|
||||||
cur.fetchone()
|
cur.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"status": "ok", "db": "ok"}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse({"status": "error", "db": str(e)}, status_code=503)
|
return JSONResponse(
|
||||||
|
{"status": "error", "db": str(e), "output_dir": str(OUTPUT_DIR)},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
out_ok = OUTPUT_DIR.is_dir()
|
||||||
|
sample = None
|
||||||
|
txt_count = 0
|
||||||
|
if out_ok:
|
||||||
|
try:
|
||||||
|
txts = list(OUTPUT_DIR.glob("*.txt"))
|
||||||
|
txt_count = len(txts)
|
||||||
|
sample = txts[0].name if txts else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"db": db_status,
|
||||||
|
"output_dir": str(OUTPUT_DIR),
|
||||||
|
"output_dir_ok": out_ok,
|
||||||
|
"txt_count": txt_count,
|
||||||
|
"sample": sample,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user