"""
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, HTTPException, Query, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
# ──────────────────────────────────────────────
# 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
app = FastAPI(title="RIP Help System", version="0.2.0")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# ──────────────────────────────────────────────
# 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)
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
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}
@app.get("/healthz")
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,
}