From d50093e893126d0f5f4b658b845fc558b1a83ce4 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:04:13 +0200 Subject: [PATCH] docs read fix --- app/repo_docs.py | 56 +++++++++++++++++++++++++++++++---------- app/static/portal.js | 4 +++ app/templates/layout.py | 24 ++++++++++++++++-- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/app/repo_docs.py b/app/repo_docs.py index 57adef0..0aed431 100644 --- a/app/repo_docs.py +++ b/app/repo_docs.py @@ -32,6 +32,13 @@ MAX_DOC_FILES = 100 MAX_DOC_BYTES = 300_000 REQUEST_TIMEOUT = 8 +# Gitea si u stromu repozitáře vynucuje vlastní stránkování (api.DEFAULT_PAGING_NUM, ve +# výchozím nastavení 30 položek) a náš per_page si klidně sníží. Strom vrací VŠECHNY soubory, +# ne jen *.md, takže bez dotažení dalších stránek by se dokumentace ve větším repu vůbec +# nenašla. Proto čteme stránku po stránce, dokud Gitea hlásí truncated. +TREE_PAGE_SIZE = 100 +MAX_TREE_PAGES = 50 + class RepoDocsError(RuntimeError): """Dokumentaci se nepodařilo načíst (chybí token, Gitea je nedostupná, repo neexistuje).""" @@ -65,16 +72,31 @@ def list_markdown_files(repo: str, branch: str = "") -> list[dict]: raise RepoDocsError("Služba nemá evidovaný název repozitáře.") ref = (branch or "").strip() or _default_branch(repo) - payload = _request_json(f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/git/trees/{quote(ref, safe='')}?recursive=true&per_page=1000") + tree_url = f"/api/v1/repos/{quote(gitea_org())}/{quote(repo)}/git/trees/{quote(ref, safe='')}" files = [] - for entry in payload.get("tree") or []: - if entry.get("type") != "blob": - continue - path = entry.get("path") or "" - if not is_markdown_path(path): - continue - files.append({"path": path, "size": int(entry.get("size") or 0)}) + seen_entries = 0 + for page in range(1, MAX_TREE_PAGES + 1): + payload = _request_json(f"{tree_url}?recursive=true&per_page={TREE_PAGE_SIZE}&page={page}") + entries = payload.get("tree") or [] + if not entries: + break + + seen_entries += len(entries) + for entry in entries: + if entry.get("type") != "blob": + continue + path = entry.get("path") or "" + if not is_markdown_path(path): + continue + files.append({"path": path, "size": int(entry.get("size") or 0)}) + + total_count = int(payload.get("total_count") or 0) + if not payload.get("truncated") or (total_count and seen_entries >= total_count): + break + if len(files) >= MAX_DOC_FILES: + logger.info("Repozitář %s má více než %s souborů *.md, seznam je zkrácený.", repo, MAX_DOC_FILES) + break files.sort(key=lambda item: (item["path"].count("/"), item["path"].lower())) return files[:MAX_DOC_FILES] @@ -105,19 +127,23 @@ def read_markdown(repo: str, path: str, branch: str = "") -> str: def render_markdown(text: str, repo: str) -> str: """Vyrenderuje markdown do HTML přes Gitea API. - Portál nemá markdown knihovnu a Gitea render umí a zároveň si výstupní HTML sanitizuje. - Když render selže, vrátíme obsah jako neformátovaný (escapovaný) text - náhled tak - funguje i při výpadku render endpointu. + Portál nemá markdown knihovnu, Gitea render umí a zároveň si výstupní HTML sanitizuje. + Náhled ale nesmí skončit prázdný: když render selže NEBO vrátí prázdné tělo (Gitea to + dělá i bez chybové hlášky, např. když si nepřečte pole text), zobrazíme obsah souboru + jako neformátovaný escapovaný text. """ + if not text.strip(): + return '

Soubor je prázdný.

' + payload = json.dumps( { "text": text, "mode": "gfm", "context": f"/{gitea_org()}/{repo}", - "wiki": False, } ).encode("utf-8") + rendered = "" try: rendered = _request_bytes( "/api/v1/markdown", @@ -126,11 +152,15 @@ def render_markdown(text: str, repo: str) -> str: accept="text/html", content_type="application/json", ).decode("utf-8", errors="replace") - return f'
{rendered}
' except RepoDocsError as exc: logger.warning("Gitea markdown render selhal (%s), zobrazuji neformátovaný text: %s", repo, exc) + + if not rendered.strip(): + logger.warning("Gitea markdown render vrátil prázdný výstup (%s), zobrazuji neformátovaný text.", repo) return f'
{html.escape(text)}
' + return f'
{rendered}
' + def _default_branch(repo: str) -> str: """Výchozí větev repozitáře podle Gitea (služby zakládané portálem mají main).""" diff --git a/app/static/portal.js b/app/static/portal.js index 22de0a5..80575de 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -177,6 +177,10 @@ function loadLazyPanel(panel) { return response.text(); }) .then((text) => { + // Prázdná odpověď by panel jen vyprázdnila a vypadalo by to jako zaseknuté načítání. + if (!text.trim()) { + throw new Error("server vrátil prázdnou odpověď"); + } panel.innerHTML = text; }) .catch((error) => { diff --git a/app/templates/layout.py b/app/templates/layout.py index c193e71..4d94157 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -1,10 +1,30 @@ import html import unicodedata +from pathlib import Path from ..config import PORTAL_PREFIX APP_NAME = "CSBot Services Portal" +STATIC_DIR = Path(__file__).resolve().parent.parent / "static" + + +def _asset_version(filename: str) -> str: + """Otisk statického souboru do URL (?v=...). + + Starlette u statiky neposílá Cache-Control, takže prohlížeč umí po nasazení držet starý + styles.css/portal.js a nová stránka pak vypadá rozbitě. Otisk se počítá při startu + aplikace - po redeployi kontejneru se změní a prohlížeč si soubor stáhne znovu. + """ + try: + return str(int((STATIC_DIR / filename).stat().st_mtime)) + except OSError: + return "0" + + +STYLES_VERSION = _asset_version("styles.css") +SCRIPT_VERSION = _asset_version("portal.js") + # Sekce viditelné pouze pro tento e-mail (Migration Readiness, Runtime Management, Environment). SUPER_ADMIN_EMAIL = "jiri.uhlir59@gmail.com" @@ -104,8 +124,8 @@ def page(title: str, body: str, user=None) -> str: {APP_NAME} - {html.escape(title)} - - + +