"""Génération du Guide d'utilisation téléchargeable en Markdown et PDF (#105). Source unique de vérité : la modale ``#help-modal`` de ``frontend/index.html`` (comme dans l'application) + les blocs ``data-i18n`` résolus dans les locales ``frontend/locales/{fr,en}.json`` — le téléchargement reflète donc exactement ce que voit l'utilisateur, dans sa langue. Le Markdown est produit par un convertisseur HTML→MD minimal (stdlib) ; le PDF passe par le moteur d'export existant (WeasyPrint) avec repli reportlab quand les bibliothèques natives GTK manquent (Windows). """ from __future__ import annotations import datetime import hashlib import html import json import logging import re from html.parser import HTMLParser from pathlib import Path logger = logging.getLogger("obsigate.guide") ROOT = Path(__file__).resolve().parent.parent INDEX_HTML = ROOT / "frontend" / "index.html" LOCALES_DIR = ROOT / "frontend" / "locales" VERSION_FILE = ROOT / "VERSION" DIAGRAMS_DIR = ROOT / "backend" / "assets" / "guide_diagrams" def diagram_png_for(code: str) -> Path | None: """Chemin du PNG pré-rendu (scripts/build_guide_diagrams.py) pour un code Mermaid, ou None. Le hash doit rester synchrone avec le script de build : sha1(unescape(code).strip())[:16].""" normalized = html.unescape(code).strip() sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] png = DIAGRAMS_DIR / (sha + ".png") return png if png.exists() else None # Éléments décoratifs exclus des exports _SKIP_CLASSES = {"help-hero-visual", "editor-modal", "help-nav"} # En-tête HTML du guide (mode lecture) _HEADER_BLOCK = "ObsiGate User Guide" class Node: """Noeud DOM minimal (stdlib only).""" __slots__ = ("attrs", "children", "parent", "tag") def __init__(self, tag: str, attrs: dict[str, str | None], parent: Node | None = None): self.tag = tag self.attrs = attrs self.children: list[Node | str] = [] self.parent = parent def cls(self) -> str: return self.attrs.get("class") or "" def i18n(self) -> str | None: v = self.attrs.get("data-i18n") return v if isinstance(v, str) else None def find_all(self, tag: str) -> list[Node]: out: list[Node] = [] for c in self.children: if isinstance(c, Node): if c.tag == tag: out.append(c) out.extend(c.find_all(tag)) return out _VOID_TAGS = {"br", "img", "hr", "input", "meta", "link"} class _TreeBuilder(HTMLParser): """Constructeur d'arbre tolérant (ignore les balises orphelines).""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.root = Node("#root", {}) self.cur = self.root def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: a = {k: v for k, v in attrs} node = Node(tag, a, self.cur) self.cur.children.append(node) if tag not in _VOID_TAGS: self.cur = node def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: a = {k: v for k, v in attrs} self.cur.children.append(Node(tag, a, self.cur)) def handle_endtag(self, tag: str) -> None: n: Node | None = self.cur while n is not None and n.tag != tag: n = n.parent if n is not None and n.parent is not None: self.cur = n.parent def handle_data(self, data: str) -> None: self.cur.children.append(data) # --------------------------------------------------------------------------- # Extraction / cache # --------------------------------------------------------------------------- _cache: dict[tuple[str, str], tuple[tuple[float, int, float, int], bytes]] = {} def _read_index_html() -> str: return INDEX_HTML.read_text(encoding="utf-8") def _guide_fragment(index_html: str) -> str: """Le HTML de #help-modal…help-content jusqu'au footer du guide.""" start = index_html.index('id="help-modal"') cstart = index_html.index('
', start) end = index_html.index('