- .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]>
144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""Ü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"(?<![\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) # 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""
|
|
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,
|
|
)
|