Automatischer Push: 45 Datei(en) geändert
- .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]>
This commit is contained in:
1 parent
e8472409e9
commit
eb48f8822d
45 files changed
+8645
-262
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: bookstack-upload
|
||||
description: 'Lädt freigegebene Vault-Notizen (Tag #review-done) aus dem Ordner /Bookstack über die BookStack-API hoch — legt Seiten an oder aktualisiert sie, löst Regal/Buch/Kapitel aus dem Frontmatter auf und fragt nach, wenn die Zuordnung fehlt. Nutze diesen Skill, wenn von BookStack-Upload, Wiki-Upload, „hochladen nach Bookstack", „Doku veröffentlichen" oder vom Verarbeiten des /Bookstack-Ordners die Rede ist.'
|
||||
description: 'Lädt freigegebene Vault-Notizen (Tag #review-done) aus dem Ordner /Bookstack über die BookStack-API hoch — legt Seiten an oder aktualisiert sie, setzt Buch- und Kapitelbeschreibungen, zieht Verweise zwischen den Seiten nach, löst Regal/Buch/Kapitel aus dem Frontmatter auf und fragt nach, wenn die Zuordnung fehlt. Nutze diesen Skill, wenn von BookStack-Upload, Wiki-Upload, „hochladen nach Bookstack", „Doku veröffentlichen" oder vom Verarbeiten des /Bookstack-Ordners die Rede ist.'
|
||||
---
|
||||
|
||||
# BookStack-Upload
|
||||
@@ -23,7 +23,15 @@ Frage niemals nach dem Token und schreibe ihn nirgends hin.
|
||||
|
||||
Arbeitsverzeichnis: `D:\Claude\Code Projekte\active - Bookstack Upload\skill\scripts`
|
||||
|
||||
2. **Bericht lesen.** Er listet je Notiz ANLEGEN, AKTUALISIEREN, ÜBERSPRUNGEN oder FEHLER.
|
||||
2. **Bericht lesen.** Er listet je Datei ANLEGEN, AKTUALISIEREN, ÜBERSPRUNGEN oder FEHLER,
|
||||
bei `_Buchbeschreibung.md` und `_Kapitelbeschreibung.md` zusätzlich BESCHREIBUNG
|
||||
(„würde gesetzt“ oder „unverändert“). Die Zeile
|
||||
`N× Verweis wird in der Verweisrunde aufgelöst` zählt Links auf Seiten, die erst im
|
||||
selben Lauf entstehen, und ist kein Fehler. FEHLER-Zeilen mit „Beschreibung zu lang“
|
||||
oder „Vorspann fehlt“ dem Anwender vor dem Upload vorlegen: Er kürzt oder ergänzt den
|
||||
Vorspann (Text zwischen H1 und erster H2). Ebenso FEHLER-Zeilen mit
|
||||
„Beschreibung nicht wohlgeformt: Hervorhebung falsch verschachtelt, nichts gesendet“:
|
||||
Er korrigiert die Hervorhebung im Vorspann, etwa `***a***` → `**a**`.
|
||||
|
||||
3. **Fehlende Zuordnung klären.** Meldet der Bericht für eine Notiz, dass Buch oder
|
||||
Kapitel nicht existiert oder `bookstack_book` fehlt:
|
||||
@@ -35,6 +43,9 @@ Frage niemals nach dem Token und schreibe ihn nirgends hin.
|
||||
`bookstack_book`, `bookstack_chapter`).
|
||||
- Soll etwas Neues angelegt werden, den Anwender das ausdrücklich bestätigen lassen
|
||||
und dann `--struktur-anlegen` ergänzen.
|
||||
- Meldet der Bericht bei einer Beschreibungsdatei „bookstack_chapter fehlt in der
|
||||
Kapitelbeschreibung“ oder „bookstack_chapter gehört nicht in eine Buchbeschreibung“,
|
||||
genauso nachfragen und das Frontmatter korrigieren.
|
||||
|
||||
4. **Bericht vorlegen und Freigabe einholen.** Erst nach ausdrücklichem „ja" schreiben:
|
||||
|
||||
@@ -44,7 +55,11 @@ Frage niemals nach dem Token und schreibe ihn nirgends hin.
|
||||
|
||||
5. **Ergebnis melden.** Was wurde angelegt, was aktualisiert, was übersprungen. Hinweise
|
||||
zu nicht übertragenen Bildern und nicht auflösbaren Wikilinks (offene Verweise)
|
||||
weitergeben.
|
||||
weitergeben. `ARCHIVIERT`-Zeilen sind normaler Ablauf (Notizen wandern nach
|
||||
erfolgreichem Upload nach `nicht hochladen - ARCHIV/`), kein Fehler. Ebenso
|
||||
`VERLINKT`-Zeilen: Die Verweisrunde hat Links auf Seiten desselben Laufs nachgezogen.
|
||||
`ARCHIV-FEHLER` dagegen melden: Im Archiv liegt schon eine Datei mit demselben Pfad,
|
||||
die Notiz ist liegen geblieben. Nicht selbst löschen, den Anwender entscheiden lassen.
|
||||
|
||||
## Grenzen
|
||||
|
||||
@@ -52,5 +67,11 @@ Frage niemals nach dem Token und schreibe ihn nirgends hin.
|
||||
- Notizen mit offenem `#review` werden bewusst übersprungen. Nicht umgehen.
|
||||
- Bilder werden in die BookStack-Bildergalerie hochgeladen und eingebunden. Nicht
|
||||
gefundene oder abgelehnte Bilder erscheinen als Hinweis im Bericht.
|
||||
- Wikilinks auf bereits hochgeladene Notizen werden zu echten Seitenlinks. Verweise auf
|
||||
noch nicht hochgeladene Notizen bleiben Klartext und werden im Bericht gemeldet.
|
||||
- Wikilinks auf bereits hochgeladene Notizen und auf Seiten desselben Laufs werden zu
|
||||
echten Seitenlinks, Links auf Beschreibungsdateien zeigen auf deren Übersichtsseite.
|
||||
Verweise auf Notizen ohne Seite bleiben Klartext und werden im Bericht mit dem Ziel
|
||||
gemeldet, wie es in der Notiz steht.
|
||||
- `_Buchbeschreibung.md` und `_Kapitelbeschreibung.md` brauchen `#review-done` wie jede
|
||||
Seite. Andere Dateien mit `_` oder `.` am Anfang lädt der Lauf ohne `--nur` nicht hoch.
|
||||
- Nach dem Archivieren zeigen Wikilinks aus dem übrigen Vault noch auf den alten Pfad.
|
||||
Der Skill zieht sie nicht nach; das beim Ergebnis dem Anwender sagen.
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Verschiebt bereits hochgeladene Notizen aus dem aktiven Bereich in ein Archiv.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-11
|
||||
Doku: docs/code/archiv.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ARCHIV_ORDNERNAME = "nicht hochladen - ARCHIV"
|
||||
|
||||
|
||||
def ist_archiviert(pfad: Path, quelle: Path) -> bool:
|
||||
"""True, wenn der Pfad bereits unterhalb des Archiv-Unterverzeichnisses liegt.
|
||||
|
||||
Liegt pfad außerhalb von quelle (z. B. --nur mit einem externen Pfad), gilt
|
||||
das als „nicht archiviert" — archiviere() wird für solche Pfade gar nicht
|
||||
erst aufgerufen (siehe hochladen.py).
|
||||
"""
|
||||
try:
|
||||
teile = pfad.relative_to(quelle).parts
|
||||
except ValueError:
|
||||
return False
|
||||
return ARCHIV_ORDNERNAME in teile
|
||||
|
||||
|
||||
def archivziel(pfad: Path, quelle: Path) -> Path:
|
||||
"""Berechnet den Zielpfad im Archiv, ohne etwas zu verschieben."""
|
||||
relativ = pfad.relative_to(quelle)
|
||||
return quelle / ARCHIV_ORDNERNAME / relativ
|
||||
|
||||
|
||||
def archiviere(pfad: Path, quelle: Path) -> Path:
|
||||
"""Verschiebt die Datei ins Archiv-Unterverzeichnis; legt Ordner bei Bedarf an.
|
||||
|
||||
Existiert das Ziel schon, bleibt die Quelle liegen und es kommt FileExistsError.
|
||||
Unter Windows wirft rename das ohnehin, unter POSIX würde es still überschreiben.
|
||||
"""
|
||||
ziel = archivziel(pfad, quelle)
|
||||
if ziel.exists():
|
||||
raise FileExistsError(f"Archivziel existiert schon: {ziel}")
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
pfad.rename(ziel)
|
||||
return ziel
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Findet im Quellordner die Notizen, die zum Upload freigegeben sind."""
|
||||
"""Findet im Quellordner die Notizen und Beschreibungsdateien, die zum Upload freigegeben sind.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-05
|
||||
Doku: docs/code/auswahl.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +16,10 @@ MAX_TITEL = 255
|
||||
FREIGABE = re.compile(r"(?<![\w#-])#review-done(?![\w-])")
|
||||
OFFEN = re.compile(r"(?<![\w#-])#review(?![\w-])")
|
||||
UEBERSCHRIFT = re.compile(r"^#\s+(.+)$", re.MULTILINE)
|
||||
BESCHREIBUNGSDATEIEN: dict[str, str] = {
|
||||
"_Buchbeschreibung.md": "buch",
|
||||
"_Kapitelbeschreibung.md": "kapitel",
|
||||
}
|
||||
|
||||
|
||||
def ist_freigegeben(notiz: Notiz) -> bool:
|
||||
@@ -18,6 +27,11 @@ def ist_freigegeben(notiz: Notiz) -> bool:
|
||||
return bool(FREIGABE.search(notiz.roh)) and not OFFEN.search(notiz.roh)
|
||||
|
||||
|
||||
def beschreibungsart(pfad: Path) -> str | None:
|
||||
"""'buch', 'kapitel' oder None — exakter Vergleich auf pfad.name."""
|
||||
return BESCHREIBUNGSDATEIEN.get(pfad.name)
|
||||
|
||||
|
||||
def finde_kandidaten(quelle: Path) -> list[Notiz]:
|
||||
if not quelle.is_dir():
|
||||
return []
|
||||
@@ -31,6 +45,27 @@ def finde_kandidaten(quelle: Path) -> list[Notiz]:
|
||||
return gefunden
|
||||
|
||||
|
||||
def finde_beschreibungen(quelle: Path) -> list[Notiz]:
|
||||
"""Freigegebene Beschreibungsdateien unter quelle (rglob, Archiv eingeschlossen).
|
||||
Reihenfolge: alle 'buch' vor allen 'kapitel', je Art nach Pfad sortiert."""
|
||||
if not quelle.is_dir():
|
||||
return []
|
||||
buecher: list[Notiz] = []
|
||||
kapitel: list[Notiz] = []
|
||||
for pfad in sorted(quelle.rglob("*.md")):
|
||||
art = beschreibungsart(pfad)
|
||||
if art is None:
|
||||
continue
|
||||
notiz = lade_notiz(pfad)
|
||||
if not ist_freigegeben(notiz):
|
||||
continue
|
||||
if art == "buch":
|
||||
buecher.append(notiz)
|
||||
else:
|
||||
kapitel.append(notiz)
|
||||
return buecher + kapitel
|
||||
|
||||
|
||||
def titel_von(notiz: Notiz) -> str:
|
||||
"""Erste H1, sonst Frontmatter-title, sonst Dateiname."""
|
||||
treffer = UEBERSCHRIFT.search(notiz.rumpf)
|
||||
|
||||
@@ -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})
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Findet Bilder im Vault und lädt sie in die BookStack-Bildergalerie."""
|
||||
"""Findet Bilder im Vault und lädt sie in die BookStack-Bildergalerie.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-05
|
||||
Doku: docs/code/bilder.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -48,13 +53,24 @@ class Bilduploader:
|
||||
self._api = api
|
||||
self._vault = vault
|
||||
|
||||
def _lies_galerie(self, seiten_id: int) -> dict[str, str]:
|
||||
"""Bereits an dieser Seite hängende Galeriebilder, Name → URL; ApiFehler geht durch."""
|
||||
eintraege = self._api.liste(f"/api/image-gallery?filter[uploaded_to]={seiten_id}")
|
||||
return {str(eintrag.get("name", "")): str(eintrag.get("url", "")) for eintrag in eintraege}
|
||||
|
||||
def _vorhandene(self, seiten_id: int) -> dict[str, str]:
|
||||
"""Bereits an dieser Seite hängende Galeriebilder, Name → URL."""
|
||||
"""Wie _lies_galerie, aber ein ApiFehler ergibt {}; nur für lade_hoch."""
|
||||
try:
|
||||
eintraege = self._api.liste(f"/api/image-gallery?filter[uploaded_to]={seiten_id}")
|
||||
return self._lies_galerie(seiten_id)
|
||||
except ApiFehler:
|
||||
return {}
|
||||
return {str(eintrag.get("name", "")): str(eintrag.get("url", "")) for eintrag in eintraege}
|
||||
|
||||
def vorhandene(self, seiten_id: int) -> dict[str, str]:
|
||||
"""Galeriebilder der Seite, Name → URL; nur lesend, für die Verweisrunde.
|
||||
|
||||
Ein ApiFehler geht durch: Ein leeres Ergebnis sähe dort aus wie ein fehlendes Bild.
|
||||
"""
|
||||
return self._lies_galerie(seiten_id)
|
||||
|
||||
def lade_hoch(
|
||||
self, notiz_pfad: Path, seiten_id: int, namen: list[str]
|
||||
@@ -65,6 +81,9 @@ class Bilduploader:
|
||||
if not namen:
|
||||
return tabelle, hinweise
|
||||
|
||||
# Ein Lesefehler der Galerie ergibt hier {}, gewollt: Das Bild wird neu hochgeladen, statt den
|
||||
# Upload scheitern zu lassen, und kostet höchstens ein Doppelbild. Die Verweisrunde liest über
|
||||
# vorhandene, dort geht der Fehler durch.
|
||||
vorhanden = self._vorhandene(seiten_id)
|
||||
|
||||
for name in dict.fromkeys(namen):
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Ergebnistypen eines Laufs: Berichtszeile samt dem Stand, den die Verweisrunde braucht.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-09-30
|
||||
Doku: docs/code/ergebnis.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.struktur import Ziel
|
||||
|
||||
# Aktionen, deren Datei danach archiviert wird und deren Seite in den Endindex kommt.
|
||||
ARCHIVIERBAR = ("ANLEGEN", "AKTUALISIEREN", "ÜBERSPRUNGEN")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Seitenstand:
|
||||
"""Was Durchgang 1 über eine Seite weiß; Eingang der Verweisrunde."""
|
||||
|
||||
titel: str
|
||||
rumpf: str # ohne Titelüberschrift, Eingang für konvertiere
|
||||
ziel: Ziel
|
||||
seiten_id: int | None # None nur bei ANLEGEN im Probelauf
|
||||
prioritaet: int | None
|
||||
markdown: str # bildfrei, Verweise nach Index aus Durchgang 1
|
||||
aufgeloeste_verweise: dict[str, str]
|
||||
offene_verweise: list[str]
|
||||
bildnamen: list[str]
|
||||
bilder: dict[str, str] | None # Tabelle des letzten PUT in Durchgang 1; None = kein PUT
|
||||
|
||||
|
||||
@dataclass
|
||||
class Beschreibungsstand:
|
||||
"""Was Durchgang 1 über eine Buch- oder Kapitelbeschreibung weiß."""
|
||||
|
||||
art: str # "buch" | "kapitel"
|
||||
ziel_id: int
|
||||
vorspann: str
|
||||
html: str
|
||||
aufgeloeste_verweise: dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ergebnis:
|
||||
"""Eine Berichtszeile; seite und beschreibung tragen nur Zeilen aus Durchgang 1."""
|
||||
|
||||
pfad: Path
|
||||
aktion: str
|
||||
text: str
|
||||
seite: Seitenstand | None = None
|
||||
beschreibung: Beschreibungsstand | None = None
|
||||
|
||||
|
||||
def seiten_nutzlast(
|
||||
titel: str, ziel: Ziel, markdown: str, prioritaet: int | None = None
|
||||
) -> dict[str, object]:
|
||||
"""name, markdown, chapter_id (wenn ziel.kapitel_id) sonst book_id, priority wenn nicht None."""
|
||||
daten: dict[str, object] = {"name": titel, "markdown": markdown}
|
||||
# Bewusst wahr/falsch statt is None: Die Platzhalter-ID 0 geht nie als chapter_id hinaus.
|
||||
if ziel.kapitel_id:
|
||||
daten["chapter_id"] = ziel.kapitel_id
|
||||
else:
|
||||
daten["book_id"] = ziel.buch_id
|
||||
if prioritaet is not None:
|
||||
daten["priority"] = prioritaet
|
||||
return daten
|
||||
@@ -1,29 +1,59 @@
|
||||
"""CLI: findet freigegebene Notizen und spielt sie nach BookStack."""
|
||||
"""Kommandozeile: lädt freigegebene Notizen und Beschreibungsdateien nach BookStack, zieht Verweise nach und archiviert.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-05
|
||||
Doku: docs/code/hochladen.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.abgleich import Aktion, berechne_pruefsumme, entscheide
|
||||
from bookstack.api import ApiFehler, BookStackApi
|
||||
from bookstack.auswahl import finde_kandidaten, ist_freigegeben, titel_von
|
||||
from bookstack.archiv import ARCHIV_ORDNERNAME, archiviere, ist_archiviert
|
||||
from bookstack.auswahl import (
|
||||
beschreibungsart,
|
||||
finde_beschreibungen,
|
||||
finde_kandidaten,
|
||||
ist_freigegeben,
|
||||
titel_von,
|
||||
)
|
||||
from bookstack.beschreibung import (
|
||||
UEBERSICHTSTITEL,
|
||||
BeschreibungsFehler,
|
||||
beschreibungs_pruefsumme,
|
||||
pruefe_beschreibung_html,
|
||||
pruefe_ziel_felder,
|
||||
setze_beschreibung,
|
||||
vorspann,
|
||||
zu_beschreibungs_html,
|
||||
)
|
||||
from bookstack.bilder import Bilduploader
|
||||
from bookstack.ergebnis import (
|
||||
ARCHIVIERBAR,
|
||||
Beschreibungsstand,
|
||||
Ergebnis,
|
||||
Seitenstand,
|
||||
seiten_nutzlast,
|
||||
)
|
||||
from bookstack.konfiguration import KonfigurationsFehler, lade_konfiguration
|
||||
from bookstack.konvertierung import konvertiere, ohne_titelueberschrift
|
||||
from bookstack.notiz import Notiz, lade_notiz, schreibe_felder
|
||||
from bookstack.struktur import StrukturFehler, Strukturaufloeser
|
||||
from bookstack.verweise import baue_index
|
||||
from bookstack.verweise import Verweisindex, baue_index
|
||||
from bookstack.verweisrunde import (
|
||||
baue_endindex,
|
||||
fuehre_verweisrunde,
|
||||
haenge_offene_an,
|
||||
plane_endindex,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ergebnis:
|
||||
pfad: Path
|
||||
aktion: str
|
||||
text: str
|
||||
# Fehler je Datei in Durchgang 1: Sie werden zur FEHLER-Zeile, der Lauf geht weiter.
|
||||
DATEIFEHLER = (StrukturFehler, ApiFehler, OSError, BeschreibungsFehler)
|
||||
|
||||
|
||||
def _stelle_ausgabe_um() -> None:
|
||||
@@ -56,17 +86,46 @@ def _argumente(argumente: list[str]) -> argparse.Namespace:
|
||||
return zerleger.parse_args(argumente)
|
||||
|
||||
|
||||
def _jetzt() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _anzeige(pfad: Path, quelle: Path) -> Path:
|
||||
"""Pfad für den Bericht: relativ zur Quelle, sonst wie er ist."""
|
||||
try:
|
||||
return pfad.relative_to(quelle)
|
||||
except ValueError:
|
||||
return pfad
|
||||
|
||||
|
||||
def _mit_art(notizen: list[Notiz]) -> list[tuple[Notiz, str]]:
|
||||
"""Beschreibungsdateien samt Art ('buch' oder 'kapitel'); andere Notizen fallen heraus."""
|
||||
paare: list[tuple[Notiz, str]] = []
|
||||
for notiz in notizen:
|
||||
art = beschreibungsart(notiz.pfad)
|
||||
if art is not None:
|
||||
paare.append((notiz, art))
|
||||
return paare
|
||||
|
||||
|
||||
def verarbeite(
|
||||
notiz: Notiz,
|
||||
aufloeser: Strukturaufloeser,
|
||||
api: BookStackApi,
|
||||
*,
|
||||
anwenden: bool,
|
||||
verweise: dict[str, str],
|
||||
verweise: Verweisindex,
|
||||
bilduploader: Bilduploader,
|
||||
titel: str | None = None,
|
||||
prioritaet: int | None = None,
|
||||
) -> Ergebnis:
|
||||
titel = titel_von(notiz)
|
||||
rumpf = ohne_titelueberschrift(notiz.rumpf, titel)
|
||||
"""Durchgang 1 für eine Seite: anlegen, aktualisieren oder überspringen.
|
||||
|
||||
Das Ergebnis trägt bei jeder dieser Aktionen einen Seitenstand, auch im Probelauf.
|
||||
Offene Verweise hängt erst haenge_offene_an an, nach der Verweisrunde.
|
||||
"""
|
||||
seitentitel = titel_von(notiz) if titel is None else titel
|
||||
rumpf = ohne_titelueberschrift(notiz.rumpf, seitentitel)
|
||||
umwandlung = konvertiere(rumpf, verweise=verweise)
|
||||
ziel = aufloeser.aufloesen(
|
||||
notiz.frontmatter.get("bookstack_shelf"),
|
||||
@@ -75,34 +134,53 @@ def verarbeite(
|
||||
)
|
||||
# Bewusst ueber den Pass-1-Markdown: Bild-URLs haengen an der Seiten-ID,
|
||||
# nicht am Inhalt, und wuerden sonst bei jedem Lauf ein Update ausloesen.
|
||||
pruefsumme = berechne_pruefsumme(titel, umwandlung.markdown, ziel.buch_id, ziel.kapitel_id)
|
||||
pruefsumme = berechne_pruefsumme(
|
||||
seitentitel, umwandlung.markdown, ziel.buch_id, ziel.kapitel_id
|
||||
)
|
||||
entscheidung = entscheide(notiz, pruefsumme)
|
||||
|
||||
beschreibung = f"{titel} → Buch-ID {ziel.buch_id}"
|
||||
if ziel.kapitel_id:
|
||||
beschreibung += f", Kapitel-ID {ziel.kapitel_id}"
|
||||
# Die Bildhinweise aus Pass 1 sagen nur, dass zu diesem Zeitpunkt noch keine URL
|
||||
# vorlag — nicht, dass das Bild fehlschlägt. Was wirklich passiert ist, meldet
|
||||
# weiter unten der Bilduploader (bzw. im Probelauf „würde hochgeladen").
|
||||
for offen in umwandlung.offene_verweise:
|
||||
beschreibung += f"\n Verweis nicht auflösbar: {offen}"
|
||||
def stand(seiten_id: int | None, bilder: dict[str, str] | None) -> Seitenstand:
|
||||
return Seitenstand(
|
||||
titel=seitentitel,
|
||||
rumpf=rumpf,
|
||||
ziel=ziel,
|
||||
seiten_id=seiten_id,
|
||||
prioritaet=prioritaet,
|
||||
markdown=umwandlung.markdown,
|
||||
aufgeloeste_verweise=umwandlung.aufgeloeste_verweise,
|
||||
offene_verweise=umwandlung.offene_verweise,
|
||||
bildnamen=umwandlung.bildnamen,
|
||||
bilder=bilder,
|
||||
)
|
||||
|
||||
text = f"{seitentitel} → Buch-ID {ziel.buch_id}"
|
||||
# is not None: Die Platzhalter-ID 0 eines geplanten Kapitels erscheint im Probelauf.
|
||||
if ziel.kapitel_id is not None:
|
||||
text += f", Kapitel-ID {ziel.kapitel_id}"
|
||||
for angelegt in ziel.angelegt:
|
||||
beschreibung += f"\n Angelegt: {angelegt}"
|
||||
text += f"\n Angelegt: {angelegt}"
|
||||
|
||||
if entscheidung.aktion is Aktion.UEBERSPRINGEN:
|
||||
return Ergebnis(notiz.pfad, "ÜBERSPRUNGEN", entscheidung.begruendung)
|
||||
return Ergebnis(
|
||||
notiz.pfad,
|
||||
"ÜBERSPRUNGEN",
|
||||
entscheidung.begruendung,
|
||||
seite=stand(entscheidung.seiten_id, None),
|
||||
)
|
||||
if not anwenden:
|
||||
# Die Bildhinweise aus Pass 1 sagen nur, dass noch keine URL vorlag; im Probelauf
|
||||
# steht deshalb „würde hochgeladen“, was wirklich passiert, meldet der Bilduploader.
|
||||
for name in umwandlung.bildnamen:
|
||||
beschreibung += f"\n Bild würde hochgeladen: {name}"
|
||||
return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung)
|
||||
text += f"\n Bild würde hochgeladen: {name}"
|
||||
return Ergebnis(
|
||||
notiz.pfad,
|
||||
entscheidung.aktion.name,
|
||||
text,
|
||||
seite=stand(entscheidung.seiten_id, None),
|
||||
)
|
||||
|
||||
def nutzlast(markdown: str) -> dict[str, object]:
|
||||
daten: dict[str, object] = {"name": titel, "markdown": markdown}
|
||||
if ziel.kapitel_id:
|
||||
daten["chapter_id"] = ziel.kapitel_id
|
||||
else:
|
||||
daten["book_id"] = ziel.buch_id
|
||||
return daten
|
||||
return seiten_nutzlast(seitentitel, ziel, markdown, prioritaet)
|
||||
|
||||
if entscheidung.aktion is Aktion.AKTUALISIEREN:
|
||||
try:
|
||||
@@ -114,32 +192,137 @@ def verarbeite(
|
||||
raise
|
||||
# Seite wurde serverseitig gelöscht — neu anlegen statt scheitern.
|
||||
antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown))
|
||||
beschreibung += "\n Hinweis: Seite war gelöscht, neu angelegt."
|
||||
text += "\n Hinweis: Seite war gelöscht, neu angelegt."
|
||||
else:
|
||||
antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown))
|
||||
|
||||
seiten_id = int(antwort["id"])
|
||||
# Die ID steht sofort im Frontmatter: Scheitert danach der Bildteil, aktualisiert der nächste
|
||||
# Lauf diese Seite, statt sie ein zweites Mal anzulegen. Der Hash folgt erst am Ende, damit
|
||||
# der nächste Lauf eine halb fertige Seite nicht für fertig hält.
|
||||
schreibe_felder(notiz.pfad, {"bookstack_page_id": str(seiten_id)})
|
||||
|
||||
# Die Tabelle geht in den Seitenstand; die Verweisrunde sendet nie ohne sie.
|
||||
bilder: dict[str, str] = {}
|
||||
if umwandlung.bildnamen:
|
||||
tabelle, bildhinweise = bilduploader.lade_hoch(
|
||||
bilder, bildhinweise = bilduploader.lade_hoch(
|
||||
notiz.pfad, seiten_id, umwandlung.bildnamen
|
||||
)
|
||||
for hinweis in bildhinweise:
|
||||
beschreibung += f"\n Hinweis: {hinweis}"
|
||||
if tabelle:
|
||||
zweiter = konvertiere(rumpf, verweise=verweise, bilder=tabelle)
|
||||
text += f"\n Hinweis: {hinweis}"
|
||||
if bilder:
|
||||
zweiter = konvertiere(rumpf, verweise=verweise, bilder=bilder)
|
||||
api.anfrage("PUT", f"/api/pages/{seiten_id}", nutzlast(zweiter.markdown))
|
||||
beschreibung += f"\n Bilder übertragen: {len(tabelle)}"
|
||||
text += f"\n Bilder übertragen: {len(bilder)}"
|
||||
|
||||
schreibe_felder(
|
||||
notiz.pfad,
|
||||
{
|
||||
"bookstack_page_id": str(seiten_id),
|
||||
"bookstack_hash": pruefsumme,
|
||||
"bookstack_synced": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
"bookstack_synced": _jetzt(),
|
||||
},
|
||||
)
|
||||
return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung)
|
||||
return Ergebnis(notiz.pfad, entscheidung.aktion.name, text, seite=stand(seiten_id, bilder))
|
||||
|
||||
|
||||
def verarbeite_beschreibung(
|
||||
notiz: Notiz,
|
||||
art: str,
|
||||
aufloeser: Strukturaufloeser,
|
||||
api: BookStackApi,
|
||||
*,
|
||||
anwenden: bool,
|
||||
verweise: Verweisindex,
|
||||
bilduploader: Bilduploader,
|
||||
) -> list[Ergebnis]:
|
||||
"""Durchgang 1 für eine Beschreibungsdatei: erst die Übersichtsseite, dann die Beschreibung.
|
||||
|
||||
Fehler in pruefe_ziel_felder und im Seitenteil gehen an fuehre_aus. Fehler im
|
||||
Beschreibungsteil werden hier zur FEHLER-Zeile; die Seitenzeile bleibt stehen.
|
||||
"""
|
||||
pruefe_ziel_felder(art, notiz.frontmatter)
|
||||
seitenzeile = verarbeite(
|
||||
notiz,
|
||||
aufloeser,
|
||||
api,
|
||||
anwenden=anwenden,
|
||||
verweise=verweise,
|
||||
bilduploader=bilduploader,
|
||||
titel=UEBERSICHTSTITEL[art],
|
||||
prioritaet=0,
|
||||
)
|
||||
ergebnisse = [seitenzeile]
|
||||
# verarbeite liefert bei ANLEGEN, AKTUALISIEREN und ÜBERSPRUNGEN immer einen Seitenstand.
|
||||
assert seitenzeile.seite is not None
|
||||
ziel = seitenzeile.seite.ziel
|
||||
ziel_id = ziel.buch_id if art == "buch" else ziel.kapitel_id
|
||||
kennung = f"Buch-ID {ziel.buch_id}"
|
||||
if art != "buch":
|
||||
kennung += f", Kapitel-ID {ziel.kapitel_id}"
|
||||
try:
|
||||
if ziel_id is None:
|
||||
raise BeschreibungsFehler("bookstack_chapter fehlt in der Kapitelbeschreibung")
|
||||
text = vorspann(notiz.rumpf)
|
||||
umgewandelt = zu_beschreibungs_html(text, verweise)
|
||||
pruefe_beschreibung_html(umgewandelt.html)
|
||||
pruefsumme = beschreibungs_pruefsumme(ziel_id, umgewandelt.html)
|
||||
stand = Beschreibungsstand(
|
||||
art=art,
|
||||
ziel_id=ziel_id,
|
||||
vorspann=text,
|
||||
html=umgewandelt.html,
|
||||
aufgeloeste_verweise=umgewandelt.aufgeloeste_verweise,
|
||||
)
|
||||
# unverändert gilt auch im Probelauf; gesendet wird nur mit --anwenden und bei Abweichung.
|
||||
if notiz.frontmatter.get("bookstack_beschreibung_hash", "").strip() == pruefsumme:
|
||||
meldung = f"unverändert → {kennung}"
|
||||
elif not anwenden:
|
||||
meldung = f"würde gesetzt → {kennung}"
|
||||
else:
|
||||
setze_beschreibung(api, art, ziel_id, umgewandelt.html)
|
||||
schreibe_felder(notiz.pfad, {"bookstack_beschreibung_hash": pruefsumme})
|
||||
meldung = f"gesetzt → {kennung}"
|
||||
except (BeschreibungsFehler, ApiFehler, OSError) as fehler:
|
||||
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
|
||||
return ergebnisse
|
||||
ergebnisse.append(Ergebnis(notiz.pfad, "BESCHREIBUNG", meldung, beschreibung=stand))
|
||||
return ergebnisse
|
||||
|
||||
|
||||
def _archiviere(gruppen: dict[Path, list[Ergebnis]], quelle: Path, *, anwenden: bool) -> int:
|
||||
"""Verschiebt je Pfad einmal ins Archiv; Rückgabe: Zahl der ARCHIV-FEHLER.
|
||||
|
||||
Archiviert wird ein Pfad mit einer Zeile aus ARCHIVIERBAR und ohne FEHLER-Zeile,
|
||||
also nur, wenn Seite, Beschreibung und Verweisrunde gelungen sind.
|
||||
"""
|
||||
archiviert = 0
|
||||
fehler_anzahl = 0
|
||||
for pfad, zeilen in gruppen.items():
|
||||
aktionen = {ergebnis.aktion for ergebnis in zeilen}
|
||||
if "FEHLER" in aktionen or aktionen.isdisjoint(ARCHIVIERBAR):
|
||||
continue
|
||||
try:
|
||||
relativ = pfad.relative_to(quelle)
|
||||
except ValueError:
|
||||
continue # z. B. --nur mit einem Pfad außerhalb der Quelle — nicht archivieren.
|
||||
if ist_archiviert(pfad, quelle):
|
||||
continue
|
||||
if not anwenden:
|
||||
print(f" würde archiviert: {relativ}")
|
||||
continue
|
||||
try:
|
||||
neu = archiviere(pfad, quelle)
|
||||
except OSError as fehler:
|
||||
# Archivkollision oder gesperrte Datei: Die Notiz bleibt liegen, der Lauf geht weiter.
|
||||
fehler_anzahl += 1
|
||||
print(f" ARCHIV-FEHLER {relativ}\n {fehler}")
|
||||
continue
|
||||
archiviert += 1
|
||||
print(f" ARCHIVIERT {relativ} → {neu.relative_to(quelle)}")
|
||||
if archiviert:
|
||||
print(f"{archiviert}× ins Archiv verschoben ({ARCHIV_ORDNERNAME})")
|
||||
return fehler_anzahl
|
||||
|
||||
|
||||
def fuehre_aus(argumente: list[str]) -> int:
|
||||
@@ -160,6 +343,8 @@ def fuehre_aus(argumente: list[str]) -> int:
|
||||
nur_planen=not einstellungen.anwenden,
|
||||
)
|
||||
bilduploader = Bilduploader(api, konfiguration.vault)
|
||||
quelle = konfiguration.quelle
|
||||
anwenden = einstellungen.anwenden
|
||||
|
||||
if einstellungen.baum:
|
||||
try:
|
||||
@@ -169,25 +354,43 @@ def fuehre_aus(argumente: list[str]) -> int:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# Auswahl: --nur ordnet die eine Datei über ihren Namen als Beschreibung oder Seite ein.
|
||||
if einstellungen.nur:
|
||||
notiz = lade_notiz(einstellungen.nur)
|
||||
kandidaten = [notiz] if ist_freigegeben(notiz) else []
|
||||
if not kandidaten:
|
||||
if not ist_freigegeben(notiz):
|
||||
print(f"{einstellungen.nur} trägt kein #review-done — nichts zu tun.")
|
||||
return 0
|
||||
beschreibungen = _mit_art([notiz])
|
||||
kandidaten = [] if beschreibungen else [notiz]
|
||||
else:
|
||||
kandidaten = finde_kandidaten(konfiguration.quelle)
|
||||
beschreibungen = _mit_art(finde_beschreibungen(quelle))
|
||||
kandidaten = finde_kandidaten(quelle)
|
||||
|
||||
kopf = "BookStack-Upload" + ("" if einstellungen.anwenden else " (Probelauf)")
|
||||
print(f"{kopf} — Quelle: {konfiguration.quelle}")
|
||||
if not kandidaten:
|
||||
kopf = "BookStack-Upload" + ("" if anwenden else " (Probelauf)")
|
||||
print(f"{kopf} — Quelle: {quelle}")
|
||||
if not beschreibungen and not kandidaten:
|
||||
print("Keine Notiz mit #review-done gefunden.")
|
||||
return 0
|
||||
|
||||
verweise = baue_index(konfiguration.quelle, konfiguration.basis_url)
|
||||
verweise = baue_index(quelle, konfiguration.basis_url, konfiguration.vault)
|
||||
|
||||
# Durchgang 1: erst Beschreibungsdateien (Bücher vor Kapiteln), dann Seiten; der Index bleibt fest.
|
||||
ergebnisse: list[Ergebnis] = []
|
||||
fehler_anzahl = 0
|
||||
for notiz, art in beschreibungen:
|
||||
try:
|
||||
ergebnisse.extend(
|
||||
verarbeite_beschreibung(
|
||||
notiz,
|
||||
art,
|
||||
aufloeser,
|
||||
api,
|
||||
anwenden=anwenden,
|
||||
verweise=verweise,
|
||||
bilduploader=bilduploader,
|
||||
)
|
||||
)
|
||||
except DATEIFEHLER as fehler:
|
||||
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
|
||||
for notiz in kandidaten:
|
||||
try:
|
||||
ergebnisse.append(
|
||||
@@ -195,29 +398,49 @@ def fuehre_aus(argumente: list[str]) -> int:
|
||||
notiz,
|
||||
aufloeser,
|
||||
api,
|
||||
anwenden=einstellungen.anwenden,
|
||||
anwenden=anwenden,
|
||||
verweise=verweise,
|
||||
bilduploader=bilduploader,
|
||||
)
|
||||
)
|
||||
except (StrukturFehler, ApiFehler, OSError) as fehler:
|
||||
fehler_anzahl += 1
|
||||
except DATEIFEHLER as fehler:
|
||||
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
|
||||
|
||||
# Verweisrunde nur mit --anwenden; der Probelauf plant den Endindex nur und sendet nichts.
|
||||
if anwenden:
|
||||
endindex = baue_endindex(
|
||||
verweise,
|
||||
ergebnisse,
|
||||
quelle=quelle,
|
||||
vault=konfiguration.vault,
|
||||
basis_url=konfiguration.basis_url,
|
||||
)
|
||||
ergebnisse += fuehre_verweisrunde(ergebnisse, endindex, api, bilduploader)
|
||||
else:
|
||||
endindex = plane_endindex(verweise, ergebnisse, quelle=quelle, vault=konfiguration.vault)
|
||||
aufgeloest = haenge_offene_an(ergebnisse, endindex)
|
||||
|
||||
# Bericht nach Pfad gruppiert; Zeilen der Verweisrunde stehen unter den Zeilen ihrer Datei.
|
||||
gruppen: dict[Path, list[Ergebnis]] = {}
|
||||
for ergebnis in ergebnisse:
|
||||
try:
|
||||
anzeige = ergebnis.pfad.relative_to(konfiguration.quelle)
|
||||
except ValueError:
|
||||
anzeige = ergebnis.pfad
|
||||
print(f" {ergebnis.aktion:<14} {anzeige}\n {ergebnis.text}")
|
||||
gruppen.setdefault(ergebnis.pfad, []).append(ergebnis)
|
||||
for pfad, zeilen in gruppen.items():
|
||||
anzeige = _anzeige(pfad, quelle)
|
||||
for ergebnis in zeilen:
|
||||
print(f" {ergebnis.aktion:<14} {anzeige}\n {ergebnis.text}")
|
||||
|
||||
zaehler: dict[str, int] = {}
|
||||
for ergebnis in ergebnisse:
|
||||
zaehler[ergebnis.aktion] = zaehler.get(ergebnis.aktion, 0) + 1
|
||||
print("\n" + ", ".join(f"{anzahl}× {name}" for name, anzahl in sorted(zaehler.items())))
|
||||
if not einstellungen.anwenden:
|
||||
if not anwenden and aufgeloest:
|
||||
print(f"{aufgeloest}× Verweis wird in der Verweisrunde aufgelöst")
|
||||
|
||||
archiv_fehler = _archiviere(gruppen, quelle, anwenden=anwenden)
|
||||
|
||||
if not anwenden:
|
||||
print("Probelauf — nichts geschrieben. Mit --anwenden ausführen.")
|
||||
return 1 if fehler_anzahl else 0
|
||||
return 1 if zaehler.get("FEHLER", 0) or archiv_fehler else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Übersetzt Obsidian-Eigenheiten in Markdown, das BookStack versteht."""
|
||||
"""Ü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
|
||||
|
||||
@@ -6,6 +11,8 @@ 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]*")
|
||||
@@ -20,16 +27,44 @@ 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)
|
||||
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: Mapping[str, str]
|
||||
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:
|
||||
@@ -43,7 +78,7 @@ def _ersetze_ausserhalb_von_code(text: str, sammler: "Sammler") -> str:
|
||||
|
||||
def _ersetze(text: str, sammler: Sammler) -> str:
|
||||
def bild(treffer: re.Match[str]) -> str:
|
||||
name = treffer.group(1).strip()
|
||||
name = _ohne_tabellen_escape(treffer.group(1))
|
||||
if name not in sammler.bildnamen:
|
||||
sammler.bildnamen.append(name)
|
||||
url = sammler.bilder.get(name)
|
||||
@@ -53,15 +88,18 @@ def _ersetze(text: str, sammler: Sammler) -> str:
|
||||
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())
|
||||
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:
|
||||
return f"[{text_teil}]({url})"
|
||||
if kurz not in sammler.offene_verweise:
|
||||
sammler.offene_verweise.append(kurz)
|
||||
return text_teil
|
||||
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()
|
||||
@@ -89,10 +127,11 @@ def ohne_titelueberschrift(rumpf: str, titel: str) -> str:
|
||||
def konvertiere(
|
||||
rumpf: str,
|
||||
*,
|
||||
verweise: Mapping[str, str] | None = None,
|
||||
verweise: Mapping[str, str] | None = None, # Verweisindex oder schlichtes dict
|
||||
bilder: Mapping[str, str] | None = None,
|
||||
) -> Umwandlung:
|
||||
sammler = Sammler(verweise=verweise or {}, bilder=bilder or {})
|
||||
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(
|
||||
@@ -100,4 +139,5 @@ def konvertiere(
|
||||
hinweise=sammler.hinweise,
|
||||
bildnamen=sammler.bildnamen,
|
||||
offene_verweise=sammler.offene_verweise,
|
||||
aufgeloeste_verweise=sammler.aufgeloeste_verweise,
|
||||
)
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Löst Regal-, Buch- und Kapitelnamen in IDs auf und legt Fehlendes auf Wunsch an."""
|
||||
"""Löst Regal-, Buch- und Kapitelnamen in IDs auf und legt Fehlendes auf Wunsch an.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-05
|
||||
Doku: docs/code/struktur.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,8 +18,8 @@ class StrukturFehler(Exception):
|
||||
|
||||
@dataclass
|
||||
class Ziel:
|
||||
buch_id: int
|
||||
kapitel_id: int | None = None
|
||||
buch_id: int # 0: Buch ist im Probelauf nur geplant
|
||||
kapitel_id: int | None = None # 0: Kapitel nur geplant, None: kein Kapitel
|
||||
angelegt: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@@ -22,6 +27,11 @@ def _passt(eintrag: dict, name: str) -> bool:
|
||||
return str(eintrag.get("name", "")).strip().casefold() == name.strip().casefold()
|
||||
|
||||
|
||||
def _geplant_schluessel(sammlung: str, *namen: str) -> tuple[str, ...]:
|
||||
"""Schlüssel für das Probelauf-Gedächtnis; Namen vergleicht er wie _passt."""
|
||||
return (sammlung, *(name.strip().casefold() for name in namen))
|
||||
|
||||
|
||||
class Strukturaufloeser:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -35,6 +45,8 @@ class Strukturaufloeser:
|
||||
# Im Probelauf darf nichts entstehen. Fehlendes wird nur benannt.
|
||||
self._nur_planen = nur_planen
|
||||
self._speicher: dict[str, list[dict]] = {}
|
||||
# Probelauf: schon als „wird angelegt“ gemeldete Regale, Bücher und Kapitel.
|
||||
self._geplant: set[tuple[str, ...]] = set()
|
||||
|
||||
def _sammlung(self, name: str) -> list[dict]:
|
||||
if name not in self._speicher:
|
||||
@@ -63,14 +75,26 @@ class Strukturaufloeser:
|
||||
zusatz = " (wird angelegt)" if self._nur_planen else ""
|
||||
return f'{art} "{name.strip()}"{zusatz}'
|
||||
|
||||
def _plane(
|
||||
self, schluessel: tuple[str, ...], art: str, name: str, angelegt: list[str]
|
||||
) -> None:
|
||||
"""Probelauf: meldet eine geplante Neuanlage nur beim ersten Mal."""
|
||||
if schluessel in self._geplant:
|
||||
return
|
||||
self._geplant.add(schluessel)
|
||||
angelegt.append(self._vermerke(art, name))
|
||||
|
||||
def _haenge_an_regal(self, regal_name: str, buch_id: int, angelegt: list[str]) -> None:
|
||||
regal = self._finde("shelves", regal_name)
|
||||
if regal is None:
|
||||
if not self._anlegen_erlaubt:
|
||||
raise self._fehlt("Regal", regal_name)
|
||||
angelegt.append(self._vermerke("Regal", regal_name))
|
||||
if self._nur_planen:
|
||||
self._plane(
|
||||
_geplant_schluessel("shelves", regal_name), "Regal", regal_name, angelegt
|
||||
)
|
||||
return
|
||||
angelegt.append(self._vermerke("Regal", regal_name))
|
||||
self._api.anfrage(
|
||||
"POST", "/api/shelves", {"name": regal_name.strip(), "books": [buch_id]}
|
||||
)
|
||||
@@ -98,10 +122,11 @@ class Strukturaufloeser:
|
||||
if gefunden is None:
|
||||
if not self._anlegen_erlaubt:
|
||||
raise self._fehlt("Buch", buch)
|
||||
angelegt.append(self._vermerke("Buch", buch))
|
||||
if self._nur_planen:
|
||||
self._plane(_geplant_schluessel("books", buch), "Buch", buch, angelegt)
|
||||
gefunden = {"id": 0}
|
||||
else:
|
||||
angelegt.append(self._vermerke("Buch", buch))
|
||||
gefunden = self._api.anfrage("POST", "/api/books", {"name": buch.strip()})
|
||||
self._verwerfe("books")
|
||||
buch_id = int(gefunden["id"])
|
||||
@@ -115,10 +140,13 @@ class Strukturaufloeser:
|
||||
if treffer is None:
|
||||
if not self._anlegen_erlaubt:
|
||||
raise self._fehlt("Kapitel", kapitel)
|
||||
angelegt.append(self._vermerke("Kapitel", kapitel))
|
||||
if self._nur_planen:
|
||||
self._plane(
|
||||
_geplant_schluessel("chapters", buch, kapitel), "Kapitel", kapitel, angelegt
|
||||
)
|
||||
treffer = {"id": 0}
|
||||
else:
|
||||
angelegt.append(self._vermerke("Kapitel", kapitel))
|
||||
treffer = self._api.anfrage(
|
||||
"POST", "/api/chapters", {"book_id": buch_id, "name": kapitel.strip()}
|
||||
)
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
"""Baut den Index bereits hochgeladener Notizen für die Wikilink-Auflösung."""
|
||||
"""Baut den Verweisindex hochgeladener Notizen und löst Wikilink-Ziele darüber auf.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-08-05
|
||||
Doku: docs/code/verweise.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.archiv import ARCHIV_ORDNERNAME
|
||||
from bookstack.auswahl import beschreibungsart
|
||||
from bookstack.notiz import lade_notiz
|
||||
|
||||
|
||||
@@ -17,18 +25,147 @@ def permalink(basis_url: str, seiten_id: int) -> str:
|
||||
return f"{basis_url.rstrip('/')}/link/{seiten_id}"
|
||||
|
||||
|
||||
def baue_index(quelle: Path, basis_url: str) -> dict[str, str]:
|
||||
"""Dateiname ohne Endung (kleingeschrieben) → Permalink."""
|
||||
def normalisiere_ziel(ziel: str) -> str:
|
||||
"""Anker ab '#' weg, Tabellen-Escape (abschließender Backslash) weg, '\\' → '/',
|
||||
führende '/' weg, abschließendes '.md' weg (ohne Rücksicht auf Groß/klein), casefold, strip."""
|
||||
text = ziel.split("#", 1)[0].strip()
|
||||
if text.endswith("\\"):
|
||||
text = text[:-1]
|
||||
text = text.replace("\\", "/").strip().lstrip("/")
|
||||
if text.casefold().endswith(".md"):
|
||||
text = text[:-3]
|
||||
return text.casefold().strip()
|
||||
|
||||
|
||||
def _stamm(schluessel: str) -> str:
|
||||
return schluessel.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _relativ(pfad: Path, basis: Path) -> Path | None:
|
||||
try:
|
||||
return pfad.resolve().relative_to(basis.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def schluessel_fuer(pfad: Path, quelle: Path, vault: Path | None = None) -> list[str]:
|
||||
"""Indexschlüssel einer Notiz.
|
||||
Basis ist vault, wenn gesetzt und pfad darunter liegt, sonst quelle (Vergleich nach resolve()).
|
||||
Erster Schlüssel: pfad relativ zur Basis, normalisiert.
|
||||
Liegt pfad unter quelle/…/ARCHIV_ORDNERNAME/…, zweiter Schlüssel für denselben Pfad
|
||||
ohne das erste Vorkommen von ARCHIV_ORDNERNAME. Liegt pfad unter keiner Basis: []."""
|
||||
relativ = _relativ(pfad, vault) if vault is not None else None
|
||||
if relativ is None:
|
||||
relativ = _relativ(pfad, quelle)
|
||||
if relativ is None:
|
||||
return []
|
||||
schluessel = [normalisiere_ziel(relativ.as_posix())]
|
||||
in_quelle = _relativ(pfad, quelle)
|
||||
if in_quelle is not None and ARCHIV_ORDNERNAME in in_quelle.parts[:-1]:
|
||||
# Beide Pfade enden auf derselben Datei; gezählt wird von hinten, damit das
|
||||
# erste Vorkommen unterhalb der Quelle fällt, nicht ein gleichnamiger Ordner darüber.
|
||||
stelle = (
|
||||
len(relativ.parts) - len(in_quelle.parts) + in_quelle.parts.index(ARCHIV_ORDNERNAME)
|
||||
)
|
||||
if stelle >= 0:
|
||||
teile = relativ.parts[:stelle] + relativ.parts[stelle + 1 :]
|
||||
schluessel.append(normalisiere_ziel("/".join(teile)))
|
||||
return schluessel
|
||||
|
||||
|
||||
class Verweisindex(Mapping[str, str]):
|
||||
"""Normalisiertes Wikilink-Ziel → Permalink, mit eindeutigem Rückfall über Pfadende oder Dateinamen."""
|
||||
|
||||
def __init__(
|
||||
self, eintraege: Mapping[str, str] | None = None, aliase: Iterable[str] = ()
|
||||
) -> None:
|
||||
self._eintraege: dict[str, str] = {}
|
||||
for schluessel, url in (eintraege or {}).items():
|
||||
self._eintraege[normalisiere_ziel(schluessel)] = url
|
||||
# Archiv-Zusatzschlüssel: Nur sie darf ein echter Schlüssel einer anderen Notiz verdrängen.
|
||||
self._aliase: frozenset[str] = frozenset(
|
||||
eintrag for eintrag in map(normalisiere_ziel, aliase) if eintrag in self._eintraege
|
||||
)
|
||||
self._staemme: dict[str, set[str]] = {}
|
||||
for schluessel, url in self._eintraege.items():
|
||||
self._staemme.setdefault(_stamm(schluessel), set()).add(url)
|
||||
|
||||
def __getitem__(self, schluessel: str) -> str:
|
||||
return self._eintraege[schluessel]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._eintraege)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._eintraege)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Verweisindex({self._eintraege!r})"
|
||||
|
||||
def aufloesen(self, ziel: str) -> str | None:
|
||||
schluessel = normalisiere_ziel(ziel)
|
||||
if not schluessel:
|
||||
return None
|
||||
if schluessel in self._eintraege:
|
||||
return self._eintraege[schluessel]
|
||||
if "/" in schluessel:
|
||||
# Ein Ziel mit Pfad trifft nur ganze Pfadenden, nie den Dateinamen allein: Sonst landete
|
||||
# [[Y/_Kapitelbeschreibung]] still auf dem einzigen Kapitel, das der Index kennt.
|
||||
endung = "/" + schluessel
|
||||
urls = {url for eintrag, url in self._eintraege.items() if eintrag.endswith(endung)}
|
||||
else:
|
||||
# Nur eindeutig: _kapitelbeschreibung gibt es je Kapitel einmal, der Stamm allein darf dann nie treffen.
|
||||
urls = self._staemme.get(schluessel, set())
|
||||
if len(urls) == 1:
|
||||
return next(iter(urls))
|
||||
return None
|
||||
|
||||
def ergaenzt(self, weitere: Mapping[str, str], aliase: Iterable[str] = ()) -> Verweisindex:
|
||||
"""Neuer Index; bei gleichem Schlüssel gewinnt weitere.
|
||||
Ausnahme: Ein Schlüssel aus aliase verdrängt keinen echten Schlüssel, nur einen Alias."""
|
||||
neue_aliase = {normalisiere_ziel(schluessel) for schluessel in aliase}
|
||||
zusammen = dict(self._eintraege)
|
||||
zusammen_aliase = set(self._aliase)
|
||||
for schluessel, url in weitere.items():
|
||||
eintrag = normalisiere_ziel(schluessel)
|
||||
if eintrag not in neue_aliase:
|
||||
zusammen[eintrag] = url
|
||||
zusammen_aliase.discard(eintrag)
|
||||
elif eintrag not in zusammen or eintrag in zusammen_aliase:
|
||||
zusammen[eintrag] = url
|
||||
zusammen_aliase.add(eintrag)
|
||||
return Verweisindex(zusammen, zusammen_aliase)
|
||||
|
||||
|
||||
def trage_ein(eintraege: dict[str, str], aliase: set[str], schluessel: list[str], url: str) -> None:
|
||||
"""Schlüssel einer Notiz aus schluessel_fuer: der erste echt, weitere als Alias.
|
||||
Ein echter Schlüssel überschreibt einen Alias; ein Alias belegt nur, was kein echter Schlüssel belegt."""
|
||||
for stelle, eintrag in enumerate(schluessel):
|
||||
if stelle == 0:
|
||||
eintraege[eintrag] = url
|
||||
aliase.discard(eintrag)
|
||||
elif eintrag not in eintraege or eintrag in aliase:
|
||||
eintraege[eintrag] = url
|
||||
aliase.add(eintrag)
|
||||
|
||||
|
||||
def baue_index(quelle: Path, basis_url: str, vault: Path | None = None) -> Verweisindex:
|
||||
"""rglob('*.md') sortiert. Überspringt Namen ab '.' oder '_', außer beschreibungsart(pfad)
|
||||
ist nicht None. Nimmt nur ganzzahlige bookstack_page_id. Jeder Schlüssel aus
|
||||
schluessel_fuer → permalink, eingetragen über trage_ein."""
|
||||
if not quelle.is_dir():
|
||||
return {}
|
||||
index: dict[str, str] = {}
|
||||
return Verweisindex()
|
||||
eintraege: dict[str, str] = {}
|
||||
aliase: set[str] = set()
|
||||
for pfad in sorted(quelle.rglob("*.md")):
|
||||
if pfad.name.startswith((".", "_")):
|
||||
if pfad.name.startswith((".", "_")) and beschreibungsart(pfad) is None:
|
||||
continue
|
||||
roh_id = lade_notiz(pfad).frontmatter.get("bookstack_page_id", "").strip()
|
||||
try:
|
||||
seiten_id = int(roh_id)
|
||||
except ValueError:
|
||||
continue
|
||||
index[pfad.stem.casefold()] = permalink(basis_url, seiten_id)
|
||||
return index
|
||||
# Nicht die Sortierung entscheidet: Eine archivierte Notiz verdrängt nie die neue am alten Pfad.
|
||||
schluessel = schluessel_fuer(pfad, quelle, vault)
|
||||
trage_ein(eintraege, aliase, schluessel, permalink(basis_url, seiten_id))
|
||||
return Verweisindex(eintraege, aliase)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Zieht nach Durchgang 1 die Verweise auf Seiten desselben Laufs nach und meldet, was offen bleibt.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-09-30
|
||||
Doku: docs/code/verweisrunde.md · Version und Änderungen: CHANGELOG.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.abgleich import berechne_pruefsumme
|
||||
from bookstack.api import ApiFehler, BookStackApi
|
||||
from bookstack.beschreibung import (
|
||||
BeschreibungsFehler,
|
||||
beschreibungs_pruefsumme,
|
||||
pruefe_beschreibung_html,
|
||||
setze_beschreibung,
|
||||
zu_beschreibungs_html,
|
||||
)
|
||||
from bookstack.bilder import Bilduploader
|
||||
from bookstack.ergebnis import (
|
||||
ARCHIVIERBAR,
|
||||
Beschreibungsstand,
|
||||
Ergebnis,
|
||||
Seitenstand,
|
||||
seiten_nutzlast,
|
||||
)
|
||||
from bookstack.konvertierung import konvertiere
|
||||
from bookstack.notiz import schreibe_felder
|
||||
from bookstack.verweise import Verweisindex, permalink, schluessel_fuer, trage_ein
|
||||
|
||||
PLATZHALTER_PRAEFIX = "geplant:"
|
||||
|
||||
|
||||
def _jetzt() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _neu_oder_geaendert(vorher: dict[str, str], nachher: dict[str, str]) -> int:
|
||||
"""Zahl der Verweisziele, deren URL neu dazukam oder sich geändert hat."""
|
||||
return sum(1 for ziel, url in nachher.items() if vorher.get(ziel) != url)
|
||||
|
||||
|
||||
def baue_endindex(
|
||||
verweise: Verweisindex, ergebnisse: list[Ergebnis], *, quelle: Path, vault: Path, basis_url: str
|
||||
) -> Verweisindex:
|
||||
"""verweise.ergaenzt(...) um jede Zeile mit Aktion aus ARCHIVIERBAR und seite.seiten_id
|
||||
nicht None: alle schluessel_fuer(pfad) → permalink(basis_url, seiten_id)."""
|
||||
weitere: dict[str, str] = {}
|
||||
aliase: set[str] = set()
|
||||
for ergebnis in ergebnisse:
|
||||
seite = ergebnis.seite
|
||||
if ergebnis.aktion not in ARCHIVIERBAR or seite is None or seite.seiten_id is None:
|
||||
continue
|
||||
# Über trage_ein, nicht per Zuweisung: Sonst entschiede die Reihenfolge der Zeilen, ob der
|
||||
# Alias einer archivierten Notiz die neue Notiz am alten Pfad verdrängt.
|
||||
trage_ein(
|
||||
weitere,
|
||||
aliase,
|
||||
schluessel_fuer(ergebnis.pfad, quelle, vault),
|
||||
permalink(basis_url, seite.seiten_id),
|
||||
)
|
||||
return verweise.ergaenzt(weitere, aliase)
|
||||
|
||||
|
||||
def plane_endindex(
|
||||
verweise: Verweisindex, ergebnisse: list[Ergebnis], *, quelle: Path, vault: Path
|
||||
) -> Verweisindex:
|
||||
"""Probelauf: jede Zeile mit Aktion 'ANLEGEN' und seite → PLATZHALTER_PRAEFIX + pfad.as_posix()."""
|
||||
weitere: dict[str, str] = {}
|
||||
aliase: set[str] = set()
|
||||
for ergebnis in ergebnisse:
|
||||
if ergebnis.aktion != "ANLEGEN" or ergebnis.seite is None:
|
||||
continue
|
||||
trage_ein(
|
||||
weitere,
|
||||
aliase,
|
||||
schluessel_fuer(ergebnis.pfad, quelle, vault),
|
||||
PLATZHALTER_PRAEFIX + ergebnis.pfad.as_posix(),
|
||||
)
|
||||
return verweise.ergaenzt(weitere, aliase)
|
||||
|
||||
|
||||
def _bildtabelle(
|
||||
seite: Seitenstand, seiten_id: int, bilduploader: Bilduploader
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""(Tabelle für den PUT, fehlende Bildnamen). Lädt nie hoch, liest höchstens die Galerie."""
|
||||
if seite.bilder is not None:
|
||||
# Tabelle aus Durchgang 1; deckt sie nicht alle Bildnamen ab, steht das unter fehlend.
|
||||
tabelle = seite.bilder
|
||||
elif not seite.bildnamen:
|
||||
return {}, []
|
||||
else:
|
||||
# Durchgang 1 hat nichts gesendet (ÜBERSPRUNGEN): Die Tabelle kommt lesend aus der Galerie.
|
||||
vorhanden = bilduploader.vorhandene(seiten_id)
|
||||
tabelle = {name: vorhanden[name] for name in seite.bildnamen if vorhanden.get(name)}
|
||||
fehlend = [name for name in dict.fromkeys(seite.bildnamen) if name not in tabelle]
|
||||
return tabelle, fehlend
|
||||
|
||||
|
||||
def _ziehe_seite_nach(
|
||||
pfad: Path,
|
||||
seite: Seitenstand,
|
||||
seiten_id: int,
|
||||
endindex: Verweisindex,
|
||||
api: BookStackApi,
|
||||
bilduploader: Bilduploader,
|
||||
) -> Ergebnis | None:
|
||||
neu = konvertiere(seite.rumpf, verweise=endindex)
|
||||
if neu.markdown == seite.markdown:
|
||||
return None
|
||||
tabelle, fehlend = _bildtabelle(seite, seiten_id, bilduploader)
|
||||
# Nie ein PUT mit Platzhaltertext: Fehlt ein Bildname in der Tabelle, gibt es nur die Zeile.
|
||||
if fehlend:
|
||||
namen = ", ".join(fehlend)
|
||||
return Ergebnis(
|
||||
pfad,
|
||||
"FEHLER",
|
||||
f"Verweise nicht nachgezogen: Bild fehlt in der Galerie der Seite ({namen}). "
|
||||
"Der nächste Lauf aktualisiert die Seite.",
|
||||
)
|
||||
markdown = neu.markdown
|
||||
if seite.bildnamen:
|
||||
markdown = konvertiere(seite.rumpf, verweise=endindex, bilder=tabelle).markdown
|
||||
api.anfrage(
|
||||
"PUT",
|
||||
f"/api/pages/{seiten_id}",
|
||||
seiten_nutzlast(seite.titel, seite.ziel, markdown, seite.prioritaet),
|
||||
)
|
||||
# Gespeichert wird die Summe des bildfreien Markdowns mit den Verweisen dieses PUT.
|
||||
pruefsumme = berechne_pruefsumme(
|
||||
seite.titel, neu.markdown, seite.ziel.buch_id, seite.ziel.kapitel_id
|
||||
)
|
||||
schreibe_felder(pfad, {"bookstack_hash": pruefsumme, "bookstack_synced": _jetzt()})
|
||||
anzahl = _neu_oder_geaendert(seite.aufgeloeste_verweise, neu.aufgeloeste_verweise)
|
||||
return Ergebnis(pfad, "VERLINKT", f"{anzahl} Verweise nachgezogen")
|
||||
|
||||
|
||||
def _ziehe_beschreibung_nach(
|
||||
pfad: Path, stand: Beschreibungsstand, endindex: Verweisindex, api: BookStackApi
|
||||
) -> Ergebnis | None:
|
||||
neu = zu_beschreibungs_html(stand.vorspann, endindex)
|
||||
if neu.html == stand.html:
|
||||
return None
|
||||
pruefe_beschreibung_html(neu.html)
|
||||
setze_beschreibung(api, stand.art, stand.ziel_id, neu.html)
|
||||
schreibe_felder(
|
||||
pfad, {"bookstack_beschreibung_hash": beschreibungs_pruefsumme(stand.ziel_id, neu.html)}
|
||||
)
|
||||
anzahl = _neu_oder_geaendert(stand.aufgeloeste_verweise, neu.aufgeloeste_verweise)
|
||||
return Ergebnis(pfad, "VERLINKT", f"{anzahl} Verweise in der Beschreibung nachgezogen")
|
||||
|
||||
|
||||
def fuehre_verweisrunde(
|
||||
ergebnisse: list[Ergebnis],
|
||||
endindex: Verweisindex,
|
||||
api: BookStackApi,
|
||||
bilduploader: Bilduploader,
|
||||
) -> list[Ergebnis]:
|
||||
"""Zweiter Durchgang, nur mit --anwenden. Liefert neue VERLINKT- und FEHLER-Zeilen."""
|
||||
neue: list[Ergebnis] = []
|
||||
for ergebnis in ergebnisse:
|
||||
seite = ergebnis.seite
|
||||
beschreibung = ergebnis.beschreibung
|
||||
try:
|
||||
if (
|
||||
seite is not None
|
||||
and seite.seiten_id is not None
|
||||
and ergebnis.aktion in ARCHIVIERBAR
|
||||
):
|
||||
zeile = _ziehe_seite_nach(
|
||||
ergebnis.pfad, seite, seite.seiten_id, endindex, api, bilduploader
|
||||
)
|
||||
elif beschreibung is not None and ergebnis.aktion == "BESCHREIBUNG":
|
||||
zeile = _ziehe_beschreibung_nach(ergebnis.pfad, beschreibung, endindex, api)
|
||||
else:
|
||||
continue
|
||||
except (ApiFehler, OSError, BeschreibungsFehler) as fehler:
|
||||
zeile = Ergebnis(ergebnis.pfad, "FEHLER", str(fehler))
|
||||
if zeile is not None:
|
||||
neue.append(zeile)
|
||||
return neue
|
||||
|
||||
|
||||
def haenge_offene_an(ergebnisse: list[Ergebnis], endindex: Verweisindex) -> int:
|
||||
"""Hängt an jede Zeile mit seite „Verweis nicht auflösbar“ für zwei Gruppen von Zielen an,
|
||||
soweit endindex sie nicht auflöst: die offenen Verweise aus Durchgang 1 (seite.offene_verweise)
|
||||
und die Ziele aus seite.aufgeloeste_verweise. Rückgabe: Zahl der offenen Verweise, die erst
|
||||
der Endindex auflöst; die Ziele aus aufgeloeste_verweise werden gemeldet, nicht gezählt."""
|
||||
aufgeloest = 0
|
||||
for ergebnis in ergebnisse:
|
||||
if ergebnis.seite is None:
|
||||
continue
|
||||
for ziel in ergebnis.seite.offene_verweise:
|
||||
if endindex.aufloesen(ziel) is None:
|
||||
ergebnis.text += f"\n Verweis nicht auflösbar: {ziel}"
|
||||
else:
|
||||
aufgeloest += 1
|
||||
# Ein Stamm, der in Durchgang 1 eindeutig war, kann im Endindex mehrdeutig werden;
|
||||
# der PUT der Runde entfernt den Link dann, und das darf nicht still passieren.
|
||||
for ziel in ergebnis.seite.aufgeloeste_verweise:
|
||||
if endindex.aufloesen(ziel) is None:
|
||||
ergebnis.text += f"\n Verweis nicht auflösbar: {ziel}"
|
||||
return aufgeloest
|
||||
Reference in new issue
Block a user