"""Übersetzt Obsidian-Eigenheiten in Markdown, das BookStack versteht. Ersteller: Pascal Michel Erstellt: 2026-08-05 Doku: docs/code/konvertierung.md · Version und Änderungen: CHANGELOG.md """ from __future__ import annotations import re from collections.abc import Mapping from dataclasses import dataclass, field from bookstack.verweise import Verweisindex EINBETTUNG = re.compile(r"!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]") WIKILINK = re.compile(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]") REVIEW_TAG = 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) # Einträge: ziel aus zerlege_wikilink aufgeloeste_verweise: dict[str, str] = field(default_factory=dict) # ziel → URL @dataclass class Sammler: verweise: Verweisindex 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) aufgeloeste_verweise: dict[str, str] = field(default_factory=dict) def _ohne_tabellen_escape(text: str) -> str: # In Markdown-Tabellen steht [[Ziel\|Alias]]; der Backslash gehört nicht zum Ziel. text = text.strip() if text.endswith("\\"): text = text[:-1] return text.strip() def zerlege_wikilink(ziel_roh: str, alias: str | None) -> tuple[str, str]: """(ziel, anzeigetext). ziel: ziel_roh ohne Anker, ohne abschließenden Backslash, gestrippt, sonst wie geschrieben. anzeigetext: alias gestrippt, sonst letztes Segment von ziel.""" ziel = _ohne_tabellen_escape(ziel_roh.split("#", 1)[0]) anzeigetext = (alias or "").strip() or ziel.split("/")[-1] return ziel, anzeigetext def ankertext(ziel_roh: str, alias: str | None) -> str | None: """Anzeigetext eines reinen Ankerlinks wie [[#Abschnitt]] oder [[#Abschnitt|Text]], sonst None. Alias gestrippt, sonst der Anker ohne '#'.""" ziel = _ohne_tabellen_escape(ziel_roh) if not ziel.startswith("#"): return None return (alias or "").strip() or ziel.lstrip("#").strip() 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 = _ohne_tabellen_escape(treffer.group(1)) 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: anker = ankertext(treffer.group(1), treffer.group(2)) if anker is not None: # Ein Anker auf die eigene Seite ist kein Verweis: Klartext, nichts in offene_verweise. return anker ziel, anzeigetext = zerlege_wikilink(treffer.group(1), treffer.group(2)) url = sammler.verweise.aufloesen(ziel) if url: sammler.aufgeloeste_verweise[ziel] = url return f"[{anzeigetext}]({url})" if ziel not in sammler.offene_verweise: sammler.offene_verweise.append(ziel) return anzeigetext 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, # Verweisindex oder schlichtes dict bilder: Mapping[str, str] | None = None, ) -> Umwandlung: index = verweise if isinstance(verweise, Verweisindex) else Verweisindex(verweise) sammler = Sammler(verweise=index, 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, aufgeloeste_verweise=sammler.aufgeloeste_verweise, )