"""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(' '.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)}