Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover), .claude/agents, .claude/skills, .claude/workflows, .claude/settings.json. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
fa1c26b407
137 files changed
+43926
No files matched your search
@@ -0,0 +1,296 @@
|
||||
"""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})
|
||||
Reference in new issue
Block a user