Files
claude-regelwerk/.claude/skills/bookstack-upload/scripts/bookstack/konvertierung.py
T
Pascal MichelandClaude Opus 5 c064c729ed Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 22:38:15 +02:00

104 lines
3.6 KiB
Python

"""Übersetzt Obsidian-Eigenheiten in Markdown, das BookStack versteht."""
from __future__ import annotations
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
EINBETTUNG = re.compile(r"!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]")
WIKILINK = re.compile(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]")
REVIEW_TAG = re.compile(r"(?<![\w#-])#review(?:-done)?(?![\w-])[ \t]*")
CALLOUT = re.compile(r"^>\s*\[!(\w+)\][ \t]*(.*)$", re.MULTILINE)
ZAUN = re.compile(r"(```.*?```|~~~.*?~~~)", re.DOTALL)
TITELZEILE = re.compile(r"^#\s+(.+?)[ \t]*$", re.MULTILINE)
MAX_TITEL = 255
@dataclass
class Umwandlung:
markdown: str
hinweise: list[str] = field(default_factory=list)
bildnamen: list[str] = field(default_factory=list)
offene_verweise: list[str] = field(default_factory=list)
@dataclass
class Sammler:
verweise: Mapping[str, str]
bilder: Mapping[str, str]
hinweise: list[str] = field(default_factory=list)
bildnamen: list[str] = field(default_factory=list)
offene_verweise: list[str] = field(default_factory=list)
def _ersetze_ausserhalb_von_code(text: str, sammler: "Sammler") -> str:
teile = ZAUN.split(text)
for stelle, teil in enumerate(teile):
if stelle % 2 == 1: # ungerade Indizes sind die Codeblöcke
continue
teile[stelle] = _ersetze(teil, sammler)
return "".join(teile)
def _ersetze(text: str, sammler: Sammler) -> str:
def bild(treffer: re.Match[str]) -> str:
name = treffer.group(1).strip()
if name not in sammler.bildnamen:
sammler.bildnamen.append(name)
url = sammler.bilder.get(name)
if url:
return f"![{name}]({url})"
sammler.hinweise.append(f"Bild nicht übertragen: {name}")
return f"*(Bild „{name}“ wurde nicht übertragen)*"
def link(treffer: re.Match[str]) -> str:
ziel, alias = treffer.group(1), treffer.group(2)
kurz = ziel.split("/")[-1].split("#")[0].strip()
text_teil = (alias or kurz).strip()
url = sammler.verweise.get(kurz.casefold())
if url:
return f"[{text_teil}]({url})"
if kurz not in sammler.offene_verweise:
sammler.offene_verweise.append(kurz)
return text_teil
def callout(treffer: re.Match[str]) -> str:
titel = treffer.group(2).strip() or treffer.group(1).capitalize()
return f"> **{titel}**"
text = EINBETTUNG.sub(bild, text)
text = WIKILINK.sub(link, text)
text = CALLOUT.sub(callout, text)
return REVIEW_TAG.sub("", text)
def ohne_titelueberschrift(rumpf: str, titel: str) -> str:
"""Entfernt die erste H1, wenn sie den Seitentitel wiederholt.
BookStack stellt den Seitennamen ohnehin als Überschrift dar. Bliebe die H1
im Text stehen, stünde die Überschrift zweimal auf der Seite.
Weicht die H1 vom Titel ab, bleibt sie unangetastet.
"""
treffer = TITELZEILE.search(rumpf)
if treffer is None or treffer.group(1).strip()[:MAX_TITEL] != titel:
return rumpf
return rumpf[: treffer.start()] + rumpf[treffer.end() :].lstrip("\n")
def konvertiere(
rumpf: str,
*,
verweise: Mapping[str, str] | None = None,
bilder: Mapping[str, str] | None = None,
) -> Umwandlung:
sammler = Sammler(verweise=verweise or {}, bilder=bilder or {})
markdown = _ersetze_ausserhalb_von_code(rumpf, sammler)
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
return Umwandlung(
markdown=markdown,
hinweise=sammler.hinweise,
bildnamen=sammler.bildnamen,
offene_verweise=sammler.offene_verweise,
)