"""
FastAPI webapp за RIP Help System.
Endpoint-и:
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:
HELP_DB_CONN — libpq Postgres
OUTPUT_DIR — локален/mounted път към секциите (default: share)
HOME_IMAGE — път към home картинка
"""
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, Header, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from help_codes import (
WIPED_HASH,
numbering_report,
parse_code,
remove_section_outputs,
source_basename,
)
# ──────────────────────────────────────────────
# Configuration
# ──────────────────────────────────────────────
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*\]")
BASE_DIR = Path(__file__).parent
from webapp import rescan as rescan_mod
app = FastAPI(title="RIP Help System", version="0.3.0")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
app.include_router(rescan_mod.router)
# ──────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────
def db_conn():
return psycopg2.connect(CONN_STR)
def _esc(s: str) -> str:
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:
parts = []
last = 0
for m in _IMG_PLACEHOLDER_RE.finditer(text):
parts.append(_esc(text[last:m.start()]))
rel = m.group(1).strip().replace("\\", "/")
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:
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 _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:
if full_text:
d["text"] = body
d["text_html"] = _text_to_html(body) if body else ""
else:
d["text"] = body[:800] if body else ""
d["text_html"] = _text_to_html(body[:1200]) if body else ""
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
# ──────────────────────────────────────────────
# Routes
# ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
def viewer(
request: Request,
prefix: Optional[str] = Query(None),
home: Optional[str] = Query(None),
):
sections = fetch_sections(prefix)
# Escape < so inside text_html cannot break the page script tag
sections_json = json.dumps(sections, ensure_ascii=False, default=str).replace("<", "\\u003c")
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": sections_json,
"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
if not img_path:
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
@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}
def _safe_help_filename(file: str) -> str:
name = Path(str(file or "").replace("\\", "/")).name
if not name or name in (".", ".."):
raise HTTPException(400, "file is required")
return name
def _ensure_file_index_col(cur):
cur.execute(
"ALTER TABLE rip_help_files ADD COLUMN IF NOT EXISTS file_index INTEGER"
)
def _matching_source_paths(cur, prefix: str, identity: str) -> list[str]:
name = source_basename(identity).lower()
if not name:
return []
cur.execute(
"""
SELECT file_path FROM rip_help_files WHERE prefix=%s
UNION
SELECT source_file FROM rip_help_sections WHERE prefix=%s
""",
(prefix, prefix),
)
found: list[str] = []
seen: set[str] = set()
for (p,) in cur.fetchall():
if p and source_basename(p).lower() == name and p not in seen:
seen.add(p)
found.append(p)
return found
def _file_index_for(cur, prefix: str, paths: list[str]) -> Optional[int]:
if not paths:
return None
cur.execute(
"SELECT file_index FROM rip_help_files "
"WHERE prefix=%s AND file_path = ANY(%s) AND file_index IS NOT NULL",
(prefix, list(paths)),
)
from_col = [r[0] for r in cur.fetchall() if r[0]]
if from_col:
return min(from_col)
cur.execute(
"SELECT code FROM rip_help_sections WHERE prefix=%s AND source_file = ANY(%s)",
(prefix, list(paths)),
)
found: list[int] = []
for (code,) in cur.fetchall():
parsed = parse_code(code)
if parsed:
found.append(parsed[1])
if not found:
return None
tally: dict[int, int] = {}
for idx in found:
tally[idx] = tally.get(idx, 0) + 1
return max(tally, key=lambda k: (tally[k], -k))
@app.get("/api/file-extractions")
def api_file_extractions(
file: str = Query(..., min_length=1),
prefix: str = Query("RIP"),
):
"""Брой и номерация на секциите за даден help файл."""
name = _safe_help_filename(file)
conn = db_conn()
try:
cur = conn.cursor()
_ensure_file_index_col(cur)
conn.commit()
paths = _matching_source_paths(cur, prefix, name)
rows = []
if paths:
cur.execute(
"SELECT code, source_file FROM rip_help_sections "
"WHERE prefix=%s AND source_file = ANY(%s) ORDER BY code",
(prefix, list(paths)),
)
rows = cur.fetchall()
codes = [r[0] for r in rows]
report = numbering_report(codes)
fi = _file_index_for(cur, prefix, paths)
if fi is not None:
report["file_index"] = fi
return {
"file": name,
"prefix": prefix,
"sources": sorted({source_basename(r[1]) for r in rows if r[1]}),
**report,
}
finally:
conn.close()
@app.delete("/api/file-extractions")
def api_delete_file_extractions(
file: str = Query(..., min_length=1),
prefix: str = Query("RIP"),
x_rescan_token: Optional[str] = Header(None, alias="X-Rescan-Token"),
token: Optional[str] = Query(None),
):
"""Изтрива всички секции за файла. Самият help файл не се пипа."""
rescan_mod._check_token(x_rescan_token or token)
name = _safe_help_filename(file)
conn = db_conn()
try:
cur = conn.cursor()
_ensure_file_index_col(cur)
paths = _matching_source_paths(cur, prefix, name)
rows = []
if paths:
cur.execute(
"SELECT code, output_path FROM rip_help_sections "
"WHERE prefix=%s AND source_file = ANY(%s)",
(prefix, list(paths)),
)
rows = cur.fetchall()
codes = [r[0] for r in rows]
file_index = _file_index_for(cur, prefix, paths)
remove_section_outputs(OUTPUT_DIR, codes, [r[1] for r in rows if r[1]])
if file_index:
cur.execute(
"SELECT code, source_file FROM rip_help_sections WHERE prefix=%s",
(prefix,),
)
name_l = name.lower()
shared = False
for code, src in cur.fetchall():
parsed = parse_code(code)
if (
parsed
and parsed[1] == file_index
and source_basename(src).lower() != name_l
):
shared = True
break
if shared:
file_index = None
if paths:
cur.execute(
"DELETE FROM rip_help_sections WHERE prefix=%s AND source_file = ANY(%s)",
(prefix, list(paths)),
)
cur.execute(
"DELETE FROM rip_help_files WHERE prefix=%s AND file_path = ANY(%s)",
(prefix, list(paths)),
)
if file_index:
cur.execute(
"""
INSERT INTO rip_help_files (prefix, file_path, file_hash, section_count, file_index)
VALUES (%s, %s, %s, 0, %s)
ON CONFLICT (prefix, file_path) DO UPDATE SET
file_hash = EXCLUDED.file_hash,
section_count = 0,
processed_at = NOW(),
file_index = EXCLUDED.file_index
""",
(prefix, name, WIPED_HASH, file_index),
)
conn.commit()
return {
"ok": True,
"file": name,
"prefix": prefix,
"deleted": len(codes),
"codes": codes,
"file_index": file_index,
}
finally:
conn.close()
@app.get("/healthz", summary="Статус на услугата и БД; output_dir_ok, txt_count.")
def healthz():
db_status = "ok"
try:
conn = db_conn()
cur = conn.cursor()
cur.execute("SELECT 1")
cur.fetchone()
conn.close()
except Exception as e:
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,
}