- .claude/agents/planer.md - .claude/agents/quorum-stimme.md - .claude/skills/bookstack-upload/SKILL.md - .claude/skills/bookstack-upload/scripts/bookstack/archiv.py - .claude/skills/bookstack-upload/scripts/bookstack/auswahl.py - .claude/skills/bookstack-upload/scripts/bookstack/beschreibung.py - .claude/skills/bookstack-upload/scripts/bookstack/bilder.py - .claude/skills/bookstack-upload/scripts/bookstack/ergebnis.py - .claude/skills/bookstack-upload/scripts/bookstack/hochladen.py - .claude/skills/bookstack-upload/scripts/bookstack/konvertierung.py - .claude/skills/bookstack-upload/scripts/bookstack/struktur.py - .claude/skills/bookstack-upload/scripts/bookstack/verweise.py - … und 33 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""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"<code>{escape(treffer.group(1), quote=False)}</code>")
|
|
|
|
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'<a href="{escape(url, quote=True)}">{escape(anzeigetext, quote=False)}</a>')
|
|
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'<a href="{escape(url, quote=True)}">{escape(linktext, quote=False)}</a>')
|
|
|
|
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"<strong>\1</strong>", text)
|
|
text = KURSIV.sub(r"<em>\1</em>", 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"<li>{_inline(eintrag, verweise, ergebnis)}</li>" for eintrag in eintraege)
|
|
return f"<{tag}>{innen}</{tag}>"
|
|
|
|
|
|
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"<p>{_inline(' '.join(laufend), verweise, ergebnis)}</p>")
|
|
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"<p><strong>{inhalt}</strong></p>")
|
|
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"<p><code>{escape(inhalt, quote=False)}</code></p>")
|
|
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})
|