diff --git a/.env.example b/.env.example
index 02dfe91..cf7bc90 100644
--- a/.env.example
+++ b/.env.example
@@ -4,3 +4,9 @@ REM HELP_DB_CONN е libpq формат за PostgreSQL.
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_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
diff --git a/.gitignore b/.gitignore
index d8e5472..ec0f2d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,10 @@ keywords_changes*.json
Output/
output/
+# Local WIP / obsolete
+_local_wip/
+api/
+
# Archives
*.zip
*.tar.gz
diff --git a/COOLIFY.md b/COOLIFY.md
new file mode 100644
index 0000000..f50bd6e
--- /dev/null
+++ b/COOLIFY.md
@@ -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`
diff --git a/Dockerfile b/Dockerfile
index d2d1d48..72ccb4c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,11 +8,13 @@ WORKDIR /app
COPY requirements-webapp.txt /app/
RUN pip install --no-cache-dir -r requirements-webapp.txt
-# Кода
+# Кода + home image в образа (fallback ако липсва в share)
COPY webapp /app/webapp
+COPY Bairaci.png /app/Bairaci.png
-# Изходна директория за картинките — Docker volume mount
-ENV OUTPUT_DIR=/data/help_output
+# Секции/картинки — Coolify Persistent Storage mount
+ENV OUTPUT_DIR=/mnt/mssql/share/RIP/RIP_Help_Source/Output
+ENV HOME_IMAGE=/app/Bairaci.png
EXPOSE 8000
diff --git a/RIP_load.bat b/RIP_load.bat
index b825420..7dc4933 100644
--- a/RIP_load.bat
+++ b/RIP_load.bat
@@ -1,9 +1,10 @@
-:@echo off
+@echo off
chcp 65001 > nul
call "%~dp0_load_env.bat" || exit /b 1
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.
-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
diff --git a/RIP_load_force.bat b/RIP_load_force.bat
index 8400e94..1c2ad9f 100644
--- a/RIP_load_force.bat
+++ b/RIP_load_force.bat
@@ -1,9 +1,10 @@
-:@echo off
+@echo off
chcp 65001 > nul
call "%~dp0_load_env.bat" || exit /b 1
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.
-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
diff --git a/docker-compose.yml b/docker-compose.yml
index 76fc715..6371f14 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,21 +1,16 @@
-# Local dev / Coolify production
-# За local: docker compose up --build
-# За Coolify: качваш през git, Coolify сам пуска docker build + run
-version: '3.8'
-
+# Local / Coolify
+# Coolify: git deploy + env vars + persistent storage (не задължително compose)
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"
+ HELP_DB_CONN: "${HELP_DB_CONN}"
+ OUTPUT_DIR: "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
+ HOME_IMAGE: "/mnt/mssql/share/RIP/RIP_Help_Source/Output/Bairaci.png"
volumes:
- # Картинките се очакват в OUTPUT_DIR/images/
- # На сървъра ще качваш с WinSCP/rsync (напр. /home/sabo/share/help_output)
- - ./data:/data/help_output:ro
+ # Host share (качен с WinSCP) → същият път в контейнера
+ - /mnt/mssql/share/RIP/RIP_Help_Source/Output:/mnt/mssql/share/RIP/RIP_Help_Source/Output:ro
ports:
- "8000:8000"
restart: unless-stopped
diff --git a/help_processor.py b/help_processor.py
index 8bf81c7..4013205 100644
--- a/help_processor.py
+++ b/help_processor.py
@@ -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(
path: Path,
file_index: int,
@@ -850,6 +858,7 @@ def process_file(
output_dir: Path,
prefix: str = "HLP",
force: bool = False,
+ remote_root: Optional[str] = None,
) -> int:
"""Обработва един файл. Връща броя записани секции (0 = пропуснат)."""
rel = str(path)
@@ -937,7 +946,7 @@ def process_file(
html_text=html_text,
)
- # Записваме текста в изходна директория
+ # Локален staging; в БД — remote път (ако е зададен) за Coolify/API
out_path = output_dir / f"{code}.txt"
out_path.write_text(
f"КОД: {code}\nФАЙЛ: {rel}\nЗАГЛАВИЕ: {title}\nКЛЮЧОВИ ДУМИ: {keywords}\n"
@@ -946,7 +955,7 @@ def process_file(
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
log.debug(f" {code}: {title[:60]} ({len(image_rel_paths)} img)")
@@ -966,6 +975,7 @@ def process_directory(
prefix: str = "HLP",
force: bool = False,
purge_missing: bool = False,
+ remote_root: Optional[str] = None,
):
if not _PREFIX_RE.match(prefix):
raise ValueError(
@@ -991,13 +1001,17 @@ def process_directory(
if p.is_file() and p.suffix.lower() in extensions and not _under_output(p)
]
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}
total_sections = 0
try:
for idx, path in enumerate(sorted(files), 1):
- n = process_file(path, idx, db, client, output_dir,
- prefix=prefix, force=force)
+ n = process_file(
+ path, idx, db, client, output_dir,
+ prefix=prefix, force=force, remote_root=remote_root,
+ )
total_sections += n
if purge_missing:
@@ -1013,11 +1027,19 @@ def process_directory(
removed_files = 0
for op in disk_paths:
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)
if opath.exists():
- opath.unlink()
- removed_files += 1
- code = opath.stem
+ try:
+ if opath.resolve() != local_txt.resolve():
+ opath.unlink()
+ removed_files += 1
+ except Exception:
+ pass
for img in (output_dir / "images").glob(f"{code}_*"):
try:
img.unlink()
@@ -1040,14 +1062,14 @@ def process_directory(
def main():
parser = argparse.ArgumentParser(
- description="Help-файл декомпозитор с SQL Server + Anthropic"
+ description="Help-файл декомпозитор с PostgreSQL + Anthropic"
)
parser.add_argument("input_dir", help="Входна директория с help-файлове")
parser.add_argument("output_dir", help="Изходна директория за текстови секции")
parser.add_argument(
"--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(
"--api-key",
@@ -1071,12 +1093,20 @@ def main():
help="След обработката изтрива от БД и диска секциите за източници, "
"които вече не съществуват във входната директория (само в дадения 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()
if not args.api_key:
sys.exit("Грешка: липсва Anthropic API ключ (--api-key или ANTHROPIC_API_KEY).")
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(
input_dir=Path(args.input_dir),
@@ -1086,6 +1116,7 @@ def main():
prefix=args.prefix,
force=args.force,
purge_missing=args.purge_missing,
+ remote_root=args.remote_root,
)
diff --git a/webapp/main.py b/webapp/main.py
index 9400464..a7ae1e1 100644
--- a/webapp/main.py
+++ b/webapp/main.py
@@ -2,63 +2,58 @@
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
+ GET / — viewer HTML (?prefix=&home=)
+ GET /images/{filename} — картинки от OUTPUT_DIR/images
+ GET /home-image — Bairaci / HOME_IMAGE
+ GET /api/sections — JSON секции (?prefix=)
+ GET /api/search — търсене (?q=&prefix=)
+ GET /api/section/{code} — една секция с пълен текст
+ POST /api/keywords/{code} — обновяване на keywords
+ GET /healthz — health + OUTPUT_DIR статус
-Конфигурация (env vars):
- HELP_DB_CONN — libpq формат за Postgres
- OUTPUT_DIR — директорията със секциите и картинките (default: ./data)
- HOME_IMAGE — път към home картинка (опционален)
+Env:
+ HELP_DB_CONN — libpq Postgres
+ OUTPUT_DIR — локален/mounted път към секциите (default: share)
+ HOME_IMAGE — път към home картинка
"""
-import os, sys, json, re
-from pathlib import Path
+from __future__ import annotations
+
+import json
+import os
+import re
from datetime import datetime
+from pathlib import Path
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 import FastAPI, HTTPException, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
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
+DEFAULT_SHARE = "/mnt/mssql/share/RIP/RIP_Help_Source/Output"
+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", DEFAULT_SHARE))
+HOME_IMAGE = os.getenv("HOME_IMAGE") # absolute path or None
_IMG_PLACEHOLDER_RE = re.compile(r"\[IMG:\s*([^\]]+?)\s*\]")
-
-# ──────────────────────────────────────────────
-# App
-# ──────────────────────────────────────────────
-
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"))
-# 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():
@@ -66,19 +61,60 @@ def db_conn():
def _esc(s: str) -> str:
- return (str(s or "")
- .replace("&", "&").replace("<", "<")
- .replace(">", ">").replace('"', """))
+ return (
+ str(s or "")
+ .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:
- """Конвертира [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'
str:
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 (
+ 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,))
+def _row_to_dict(cols, r, *, full_text: bool = False) -> dict:
+ 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"] = []
+
+ 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"):
+ d["text_html"] = _rich_html_with_images(d["html_text"])
+ d["text"] = body if full_text else (body[:800] if body else "")
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"])
+ if full_text:
+ d["text"] = body
+ d["text_html"] = _text_to_html(body) if body else ""
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"] = body[:800] 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()
return rows
@@ -164,38 +207,91 @@ def fetch_sections(prefix: Optional[str] = None) -> list[dict]:
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
-def viewer(request: Request,
- prefix: Optional[str] = Query(None),
- home: Optional[str] = Query(None)):
+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"),
- })
+ home_url = "/home-image" if (home or HOME_IMAGE or (BASE_DIR.parent / "Bairaci.png").exists()) 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)
+ for candidate in (
+ OUTPUT_DIR / "Bairaci.png",
+ OUTPUT_DIR.parent / "Bairaci.png",
+ BASE_DIR.parent / "Bairaci.png",
+ ):
+ if candidate.is_file():
+ img_path = str(candidate)
+ break
if not img_path or not Path(img_path).is_file():
raise HTTPException(404, "home image not configured")
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")
def api_sections(prefix: Optional[str] = Query(None)):
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):
keywords: str
@@ -203,10 +299,10 @@ class KeywordsUpdate(BaseModel):
@app.post("/api/keywords/{code}")
def update_keywords(code: str, body: KeywordsUpdate):
conn = db_conn()
- cur = conn.cursor()
+ cur = conn.cursor()
cur.execute(
"UPDATE rip_help_sections SET keywords=%s, updated_at=NOW() WHERE code=%s",
- (body.keywords, code)
+ (body.keywords, code),
)
if cur.rowcount == 0:
conn.close()
@@ -218,12 +314,35 @@ def update_keywords(code: str, body: KeywordsUpdate):
@app.get("/healthz")
def healthz():
+ db_status = "ok"
try:
conn = db_conn()
- cur = conn.cursor()
+ 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)
+ 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,
+ }