"""Wandelt den Vorspann einer Beschreibungsdatei in Beschreibungs-HTML und setzt es per PUT an Buch oder Kapitel. Ersteller: Pascal Michel Erstellt: 2026-09-30 Doku: docs/code/beschreibung.md · Version und Änderungen: CHANGELOG.md """ from __future__ import annotations import hashlib import re from collections.abc import Mapping from dataclasses import dataclass, field from html import escape from html.parser import HTMLParser from bookstack.api import BookStackApi from bookstack.konvertierung import EINBETTUNG, REVIEW_TAG, WIKILINK, ankertext, zerlege_wikilink from bookstack.verweise import Verweisindex MAX_BESCHREIBUNG_HTML = 2000 UEBERSICHTSTITEL: dict[str, str] = {"buch": "Über dieses Buch", "kapitel": "Über dieses Kapitel"} ZAUNMARKEN = ("```", "~~~") H1_ZEILE = re.compile(r"^#\s+\S") H2_ZEILE = re.compile(r"^##\s") UL_ANFANG = re.compile(r"^[-*+]\s") UL_PUNKT = re.compile(r"^\s*[-*+]\s+(.*)$") OL_ANFANG = re.compile(r"^\d+[.)]\s") OL_PUNKT = re.compile(r"^\s*\d+[.)]\s+(.*)$") KLEINE_UEBERSCHRIFT = re.compile(r"^#{3,6}\s+(.*)$") ZITAT = re.compile(r"^\s*>\s?") CALLOUT_ZEILE = re.compile(r"^\[!(\w+)\][ \t]*(.*)$") CODE_SPAN = re.compile(r"`([^`\n]+)`") MARKDOWN_LINK = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)") ERLAUBTE_LINKANFAENGE = ("http://", "https://", "mailto:", "/") FETT = re.compile(r"\*\*(?=\S)(.+?)(?<=\S)\*\*") KURSIV = re.compile(r"\*(?=\S)([^*]+?)(?<=\S)\*") PLATZHALTER = re.compile(r"\x00(\d+)\x00") class BeschreibungsFehler(Exception): """Beschreibung nicht setzbar; Text geht als FEHLER in den Bericht.""" @dataclass class BeschreibungsHtml: html: str aufgeloeste_verweise: dict[str, str] = field(default_factory=dict) offene_verweise: list[str] = field(default_factory=list) def pruefe_ziel_felder(art: str, frontmatter: Mapping[str, str]) -> None: """Buchbeschreibung ohne bookstack_chapter, Kapitelbeschreibung mit nicht leerem bookstack_chapter.""" kapitel = (frontmatter.get("bookstack_chapter") or "").strip() if art == "buch" and kapitel: raise BeschreibungsFehler("bookstack_chapter gehört nicht in eine Buchbeschreibung") if art != "buch" and not kapitel: raise BeschreibungsFehler("bookstack_chapter fehlt in der Kapitelbeschreibung") def _zaunmarke(zeile: str) -> str | None: anfang = zeile.lstrip()[:3] return anfang if anfang in ZAUNMARKEN else None def vorspann(rumpf: str) -> str: """Text nach der ersten H1 bis vor die erste folgende H2, gestrippt. Ohne H1 ab dem Anfang, ohne H2 bis zum Ende. Zeilen in Code-Zäunen zählen nicht als Überschrift. """ zeilen = rumpf.split("\n") ausserhalb: list[bool] = [] zaun: str | None = None for zeile in zeilen: marke = _zaunmarke(zeile) if zaun is None: ausserhalb.append(marke is None) zaun = marke else: ausserhalb.append(False) if marke == zaun: zaun = None anfang = next( (nummer + 1 for nummer, zeile in enumerate(zeilen) if ausserhalb[nummer] and H1_ZEILE.match(zeile)), 0, ) ende = next( ( nummer for nummer in range(anfang, len(zeilen)) if ausserhalb[nummer] and H2_ZEILE.match(zeilen[nummer]) ), len(zeilen), ) return "\n".join(zeilen[anfang:ende]).strip() def _bloecke(text: str) -> list[tuple[str, list[str]]]: """Textblöcke an Leerzeilen getrennt; Code-Zäune als eigene Blöcke ohne die Zaunzeilen.""" bloecke: list[tuple[str, list[str]]] = [] laufend: list[str] = [] code: list[str] = [] zaun: str | None = None for zeile in text.split("\n"): marke = _zaunmarke(zeile) if zaun is not None: if marke == zaun: bloecke.append(("code", code)) code, zaun = [], None else: code.append(zeile) continue if marke is not None: if laufend: bloecke.append(("text", laufend)) laufend = [] zaun = marke elif zeile.strip(): laufend.append(zeile) elif laufend: bloecke.append(("text", laufend)) laufend = [] if zaun is not None: bloecke.append(("code", code)) if laufend: bloecke.append(("text", laufend)) return bloecke def _inline(text: str, verweise: Verweisindex, ergebnis: BeschreibungsHtml) -> str: """Inline-Regeln 8.1 bis 8.6; Code, Einbettungen und Links stehen bis zum Schluss als Platzhalter.""" stuecke: list[str] = [] def merke(stueck: str) -> str: stuecke.append(stueck) return f"\x00{len(stuecke) - 1}\x00" def code_span(treffer: re.Match[str]) -> str: return merke(f"{escape(treffer.group(1), quote=False)}") def einbettung(treffer: re.Match[str]) -> str: name = treffer.group(1).strip() if name.endswith("\\"): name = name[:-1].strip() return merke(escape(name, quote=False)) def wikilink(treffer: re.Match[str]) -> str: anker = ankertext(treffer.group(1), treffer.group(2)) if anker is not None: return merke(escape(anker, quote=False)) ziel, anzeigetext = zerlege_wikilink(treffer.group(1), treffer.group(2)) url = verweise.aufloesen(ziel) if url: ergebnis.aufgeloeste_verweise[ziel] = url return merke(f'{escape(anzeigetext, quote=False)}') if ziel not in ergebnis.offene_verweise: ergebnis.offene_verweise.append(ziel) return merke(escape(anzeigetext, quote=False)) def markdown_link(treffer: re.Match[str]) -> str: linktext, url = treffer.group(1), treffer.group(2) if not url.startswith(ERLAUBTE_LINKANFAENGE): return treffer.group(0) return merke(f'{escape(linktext, quote=False)}') text = CODE_SPAN.sub(code_span, text) text = EINBETTUNG.sub(einbettung, text) text = WIKILINK.sub(wikilink, text) text = MARKDOWN_LINK.sub(markdown_link, text) text = escape(text, quote=False) text = FETT.sub(r"\1", text) text = KURSIV.sub(r"\1", text) # Ein Linktext kann selbst einen Platzhalter tragen (Code im Alias); deshalb bis keiner mehr übrig ist. while PLATZHALTER.search(text): text = PLATZHALTER.sub(lambda treffer: stuecke[int(treffer.group(1))], text) return text def _liste( tag: str, punkt: re.Pattern[str], zeilen: list[str], verweise: Verweisindex, ergebnis: BeschreibungsHtml, ) -> str: eintraege: list[str] = [] for zeile in zeilen: treffer = punkt.match(zeile) if treffer: eintraege.append(treffer.group(1).strip()) else: eintraege[-1] = f"{eintraege[-1]} {zeile.strip()}" innen = "".join(f"
  • {_inline(eintrag, verweise, ergebnis)}
  • " for eintrag in eintraege) return f"<{tag}>{innen}" def _absaetze(zeilen: list[str], verweise: Verweisindex, ergebnis: BeschreibungsHtml) -> list[str]: teile: list[str] = [] laufend: list[str] = [] def abschliessen() -> None: if laufend: teile.append(f"

    {_inline(' '.join(laufend), verweise, ergebnis)}

    ") laufend.clear() for roh in zeilen: zeile = ZITAT.sub("", roh, count=1).strip() callout = CALLOUT_ZEILE.match(zeile) if callout: zeile = f"**{callout.group(2).strip() or callout.group(1).capitalize()}**" ueberschrift = KLEINE_UEBERSCHRIFT.match(zeile) if ueberschrift: abschliessen() inhalt = _inline(ueberschrift.group(1).strip(), verweise, ergebnis) teile.append(f"

    {inhalt}

    ") elif zeile: laufend.append(zeile) else: abschliessen() abschliessen() return teile def zu_beschreibungs_html(markdown: str, verweise: Verweisindex) -> BeschreibungsHtml: """Markdown des Vorspanns als HTML aus p, a href, ul, ol, li, strong, em und code.""" ergebnis = BeschreibungsHtml(html="") text = REVIEW_TAG.sub("", markdown.replace("\x00", "")) text = "\n".join(zeile.rstrip() for zeile in text.split("\n")) teile: list[str] = [] for art, zeilen in _bloecke(text): if art == "code": inhalt = " ".join(zeile.strip() for zeile in zeilen if zeile.strip()) if inhalt: teile.append(f"

    {escape(inhalt, quote=False)}

    ") elif UL_ANFANG.match(zeilen[0]): teile.append(_liste("ul", UL_PUNKT, zeilen, verweise, ergebnis)) elif OL_ANFANG.match(zeilen[0]): teile.append(_liste("ol", OL_PUNKT, zeilen, verweise, ergebnis)) else: teile.extend(_absaetze(zeilen, verweise, ergebnis)) ergebnis.html = "".join(teile) return ergebnis class _Verschachtelung(HTMLParser): """Merkt sich, ob jedes schließende Tag das zuletzt geöffnete schließt.""" def __init__(self) -> None: super().__init__(convert_charrefs=True) self.offen: list[str] = [] self.fehler = False def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag != "br": self.offen.append(tag) def handle_endtag(self, tag: str) -> None: if tag == "br": return if self.offen and self.offen[-1] == tag: self.offen.pop() else: self.fehler = True def pruefe_beschreibung_html(html: str) -> None: """Leer, über MAX_BESCHREIBUNG_HTML Zeichen oder falsch verschachtelt: BeschreibungsFehler, gesendet wird dann nichts.""" if not html.strip(): raise BeschreibungsFehler("Vorspann fehlt") if len(html) > MAX_BESCHREIBUNG_HTML: raise BeschreibungsFehler( f"Beschreibung zu lang: {len(html)} Zeichen HTML, erlaubt {MAX_BESCHREIBUNG_HTML}, nichts gesendet" ) # Der Server repariert falsch verschachteltes HTML nach eigenem Ermessen und speichert dann # anderes HTML als gesendet. Die Prüfsumme läuft über das gesendete HTML und merkt das nicht. pruefer = _Verschachtelung() pruefer.feed(html) pruefer.close() if pruefer.fehler or pruefer.offen: raise BeschreibungsFehler( "Beschreibung nicht wohlgeformt: Hervorhebung falsch verschachtelt, nichts gesendet" ) def beschreibungs_pruefsumme(ziel_id: int, html: str) -> str: """Prüfsumme über Ziel-ID und gesendetes HTML; ein anderes Ziel gilt als Änderung.""" return "sha256:" + hashlib.sha256(f"{ziel_id}\x00{html}".encode("utf-8")).hexdigest() def setze_beschreibung(api: BookStackApi, art: str, ziel_id: int, html: str) -> None: """PUT description_html an das Buch (art 'buch') oder das Kapitel; ID 0 ist ein Platzhalter und wird nie gesendet.""" if ziel_id == 0: raise BeschreibungsFehler("Beschreibung nicht gesetzt: Ziel-ID 0 ist ein Platzhalter aus dem Probelauf") pfad = f"/api/books/{ziel_id}" if art == "buch" else f"/api/chapters/{ziel_id}" api.anfrage("PUT", pfad, {"description_html": html})