Plugin-Entwicklerhandbuch¶
Dieses Handbuch erklärt, wie Plugins für Bibliogon entwickelt werden. Plugins erweitern die Plattform mit neuen Funktionen, ohne den Kern zu verändern.
Architekturüberblick¶
Bibliogon verwendet PluginForge (PyPI) als Plugin-Framework, basierend auf pluggy. Plugins sind eigenständige Python-Pakete, die über Entry Points entdeckt werden.
Frontend (React) -> Backend (FastAPI) -> PluginForge -> Dein Plugin
Jedes Plugin kann: - API-Endpunkte hinzufügen (FastAPI-Router) - Hooks implementieren (Inhaltstransformation, Exportformate) - UI-Erweiterungen deklarieren (Seitenleistenaktionen, Toolbar-Buttons, Einstellungen, Seiten) - Eigene Konfiguration mitbringen (YAML)
Verzeichnisstruktur¶
plugins/bibliogon-plugin-{name}/
bibliogon_{name}/
__init__.py
plugin.py # Plugin-Klasse (erforderlich)
routes.py # FastAPI-Router (optional)
{modul}.py # Geschäftslogik-Module
tests/
test_{name}.py
pyproject.toml # Paketmetadaten + Entry Point (erforderlich)
Namenskonventionen:
- Plugin-Ordner: bibliogon-plugin-{name} (Kebab-Case)
- Python-Paket: bibliogon_{name} (Snake-Case)
- Plugin-Name im Code: {name} (Kleinbuchstaben, z.B. "help", "export", "grammar")
Minimales Plugin¶
pyproject.toml¶
[tool.poetry]
name = "bibliogon-plugin-meinplugin"
version = "1.0.0"
description = "Mein eigenes Bibliogon-Plugin"
authors = ["Dein Name"]
license = "MIT"
packages = [{include = "bibliogon_meinplugin"}]
[tool.poetry.dependencies]
python = "^3.11"
pluginforge = "^0.5.0"
fastapi = "^0.135.0"
[tool.poetry.plugins."bibliogon.plugins"]
meinplugin = "bibliogon_meinplugin.plugin:MeinPlugin"
Der Entry Point [tool.poetry.plugins."bibliogon.plugins"] ist der Mechanismus, über den PluginForge das Plugin entdeckt.
Plugin im Backend registrieren¶
Für gebündelte Plugins (jedes Plugin, das innerhalb des Bibliogon-Repositorys unter plugins/ ausgeliefert wird) muss zusätzlich ein Path-Dependency-Eintrag in backend/pyproject.toml angelegt werden, damit das Backend-Poetry-Environment das Plugin installiert und dessen Entry Points auffindbar werden:
[tool.poetry.dependencies]
# ...vorhandene Einträge...
bibliogon-plugin-myplugin = {path = "../plugins/bibliogon-plugin-myplugin", develop = true}
Anschließend poetry lock und poetry install im Verzeichnis backend/ ausführen. Wenn dieser Schritt vergessen wird, ist das Plugin in CI unsichtbar (lokal funktioniert es für alle, deren venv die dist-info aus einer früheren Installation noch enthält, aber frische Checkouts und der CI-Runner laden nur, was in pyproject.toml deklariert ist). ZIP-distribuierte Drittanbieter-Plugins sind ausgenommen, weil sie zur Laufzeit über sys.path installiert werden, nicht zur Setup-Zeit.
plugin.py¶
from typing import Any
from pluginforge import BasePlugin
class MeinPlugin(BasePlugin):
name = "meinplugin"
version = "1.0.0"
api_version = "1"
license_tier = "core" # In Bibliogon ist "core" der einzige verwendete Wert; alle Plugins sind frei nutzbar.
depends_on: list[str] = [] # z.B. ["export"] wenn Export-Plugin benötigt
def activate(self) -> None:
"""Wird beim Laden des Plugins aufgerufen."""
from .routes import set_config
set_config(self.config)
def get_routes(self) -> list[Any]:
"""FastAPI-Router zurückgeben."""
from .routes import router
return [router]
def get_frontend_manifest(self) -> dict[str, Any] | None:
"""UI-Erweiterungen deklarieren. None wenn kein UI."""
return None
routes.py¶
from fastapi import APIRouter
router = APIRouter(prefix="/meinplugin", tags=["meinplugin"])
_config: dict = {}
def set_config(config: dict) -> None:
global _config
_config = config
@router.get("/hello")
def hello():
return {"message": "Hallo von meinem Plugin!"}
Regeln:
- routes.py enthält NUR Endpunkt-Definitionen, die an Service-Funktionen delegieren
- Geschäftslogik gehört in separate Module (z.B. service.py, analyzer.py)
- Kein direkter Datenbankzugriff in Routes; Service-Funktionen verwenden
- Pydantic v2 für alle Request/Response-Schemas
Hooks¶
Plugins können Hooks implementieren, die in backend/app/hookspecs.py definiert sind. Hooks ermöglichen die Teilnahme an Kernabläufen ohne Änderung des Kerncodes.
Verfügbare Hooks¶
| Hook | Zweck | Rückgabe |
|---|---|---|
export_formats() |
Unterstützte Exportformate deklarieren | list[dict] |
export_execute(book, fmt, options) |
Export ausführen (erstes Ergebnis gewinnt) | Path oder None |
chapter_pre_save(content, chapter_id) |
Inhalt vor dem Speichern transformieren | str oder None |
content_pre_import(content, language) |
Markdown beim Import transformieren | str oder None |
Hook implementieren¶
In der plugin.py eine Methode mit dem Hook-Namen hinzufügen:
class MeinPlugin(BasePlugin):
name = "meinplugin"
def content_pre_import(self, content: str, language: str) -> str | None:
"""Importiertes Markdown vor der Konvertierung bereinigen."""
cleaned = content.replace("\r\n", "\n")
return cleaned
Konfiguration¶
Plugin-Konfiguration liegt unter backend/config/plugins/{name}.yaml.
YAML-Struktur¶
plugin:
name: "meinplugin"
display_name:
de: "Mein Plugin"
en: "My Plugin"
description:
de: "Beschreibung des Plugins"
en: "Plugin description"
version: "1.0.0"
license: "MIT"
depends_on: []
api_version: "1"
settings:
meine_option: true
schwellwert: 0.8
Auf Konfiguration zugreifen¶
def activate(self) -> None:
schwellwert = self.config.get("settings", {}).get("schwellwert", 0.5)
Sichtbarkeitsregeln für Einstellungen¶
Jede Einstellung in der YAML muss entweder:
1. Im Plugin-UI editierbar sein (Einstellungen > Plugins > {Name}), ODER
2. Mit # INTERNAL Kommentar markiert sein
Versteckte Einstellungen, die Nutzerverhalten beeinflussen, sind nicht erlaubt.
Frontend-Manifest¶
Plugins deklarieren UI-Erweiterungen über get_frontend_manifest(). Das Frontend fragt /api/plugins/manifests ab, um alle Erweiterungen zu entdecken.
Verfügbare UI-Slots¶
| Slot | Position | Anwendungsfall |
|---|---|---|
pages |
App-Navigation | Vollständige Plugin-Seite |
sidebar_actions |
BookEditor-Seitenleiste | Aktionsbuttons |
toolbar_buttons |
Editor-Toolbar | Formatierungstools |
editor_panels |
Neben dem Editor | Seitenpanels |
settings_section |
Einstellungen > Plugins | Plugin-Konfiguration |
export_options |
Export-Dialog | Formatspezifische Optionen |
Beispiel: Seite hinzufügen¶
def get_frontend_manifest(self) -> dict[str, Any] | None:
return {
"pages": [
{
"id": "meinplugin",
"path": "/meinplugin",
"label": {"de": "Mein Plugin", "en": "My Plugin"},
"icon": "puzzle", # lucide-react Icon-Name
},
],
}
ZIP-Distribution¶
Plugins von Drittanbietern werden als ZIP-Dateien verteilt und über Einstellungen > Plugins installiert.
ZIP-Struktur¶
meinplugin.zip
plugin.yaml # Erforderlich: Plugin-Metadaten
bibliogon_meinplugin/
__init__.py
plugin.py
routes.py
config/
meinplugin.yaml # Plugin-Konfiguration
plugin.yaml (erforderlich für ZIP-Plugins)¶
name: meinplugin
display_name:
de: "Mein Plugin"
en: "My Plugin"
version: "1.0.0"
package: bibliogon_meinplugin
entry_class: MeinPlugin
Namensvalidierung¶
Plugin-Namen müssen dem Muster entsprechen: [a-z][a-z0-9_-]{1,48}[a-z0-9] (3-50 Zeichen, Kleinbuchstaben, Ziffern, Bindestriche, Unterstriche).
Tests¶
Plugin-Tests liegen unter plugins/bibliogon-plugin-{name}/tests/.
# Tests für ein bestimmtes Plugin
make test-plugin-{name}
# Alle Plugin-Tests
make test-plugins
Abhängigkeiten¶
Benötigt dein Plugin eine Abhängigkeit, die nicht im Core ist, deklariere sie in deiner pyproject.toml. Für ZIP-verteilte Plugins müssen Abhängigkeiten gebündelt oder bereits in der Bibliogon-Umgebung verfügbar sein.
Füge KEINE neuen Abhängigkeiten zum Core hinzu, ohne zu fragen. Aktueller Stack:
- Backend: FastAPI, SQLAlchemy, Pydantic v2, pluginforge, PyYAML, httpx
- Frontend: React 19, TypeScript 6, Vite 8 (Rolldown-Bundler), TipTap, Radix UI, Lucide. Node.js 24+ erforderlich (engines.node >=24.0.0).
Vorhandene Plugins als Referenz¶
| Plugin | Komplexität | Gutes Beispiel für |
|---|---|---|
| help | Einfach | Routes + Config + i18n |
| ms-tools | Mittel | Hooks + Per-Book-Einstellungen + UI-Panel |
| export | Komplex | Mehrere Formate + Async-Jobs + Scaffolding |
| audiobook | Komplex | Externe APIs + SSE-Fortschritt + Persistenz |
| git-sync | Mittel | Import-Plugin + Plugin-zu-Plugin-Abhängigkeit |
Beginne mit dem Help-Plugin als Vorlage, dann ms-tools für Hook-Implementierungsmuster.
Import-Plugin-Muster (aus PGS-01)¶
Wenn ein Plugin den Import eines neuen Formats oder einer neuen Quelle unterstützen soll, ist der Core-Import-Orchestrator (backend/app/import_plugins/) der Integrationspunkt. Das erste externe Import-Plugin (plugin-git-sync, PGS-01) brachte vier Architekturmuster hervor, die künftige Import-Plugins kennen sollten.
Muster 1: Quell-Adapter statt Format-Neuimplementierung¶
Problem. Dein Plugin soll Bücher aus einer neuen Quelle importieren (Git-URL, Cloud-Link, Gist, ...), aber das zugrundeliegende Format hat bereits einen Handler im Core oder in einem anderen Plugin. Den Parser zu duplizieren erzeugt Code, der auseinanderdriftet.
Lösung. Dein Plugin wird ein Quell-Adapter: es holt/bereitet die Daten in einen Dateisystem-Pfad und übergibt dann an den bestehenden Format-Handler. Das Format nicht noch einmal parsen.
PGS-01 Beispiel. GitImportHandler.clone(url, target_dir) klont in das Staging-Verzeichnis des Orchestrators und gibt den Projekt-Root-Pfad zurück. Der Endpoint ruft danach find_handler(staged_path) auf, das den bestehenden WbtImportHandler (Core, aus CIO-02) greift. Das Plugin parst weder config/metadata.yaml noch läuft es durch manuscript/ — das macht WbtImportHandler.
Vorteile.
- Keine Duplikation. Ein Bugfix im Format-Handler hilft automatisch jeder Quelle.
- Konsistente DetectedProject-Payloads über alle Quellen (gleiche Preview, gleiche Duplikat-Erkennung, gleiche Override-Allowlist).
- Dein Plugin bleibt klein — ~100 LOC Handler statt 500+.
Wann NICHT verwenden. Wenn das Format wirklich neu ist (kein bestehender Handler produziert ein DetectedProject daraus), baust du ein echtes ImportPlugin und parst selbst. Quell-Adapter funktioniert nur, wenn ein Format-Handler nachgelagert bereitsteht.
Muster 2: Zwei Registries im Core (ImportPlugin vs RemoteSourceHandler)¶
Problem. Ein Datei-Pfad-Input hat bei detect() einen Dateisystem-Pfad; eine URL nicht — sie muss erst geklont/geholt werden. Beide Shapes in ein Registry zu stopfen erzwingt isinstance-Heuristiken in find_handler(), was ein Codegeruch ist.
Lösung. Getrennte Registries für getrennte Input-Shapes. Beide teilen den temp_ref- + Staging-Verzeichnis-Mechanismus für execute.
ImportPlugin(inbackend/app/import_plugins/protocol.py): Datei-Pfad-Inputs.can_handle(path) -> bool,detect(path),execute(path, ...).RemoteSourceHandler(inbackend/app/import_plugins/registry.py, neu in PGS-01): URL-Inputs.can_handle(url) -> bool,clone(url, target_dir) -> Path. Nach dem Klonen dispatcht der Orchestrator perfind_handler()auf dem geklonten Pfad — die Format-Erkennung nutzt also dasImportPlugin-Registry wieder.
Beim Hinzufügen einer dritten Input-Shape. Wenn dein Plugin eine neue Shape bringt, die in keines passt (z. B. "Buch aus SQL-Abfrage-Ergebnis"), abwägen: (a) im Plugin auf eine der bestehenden Shapes normalisieren, (b) drittes Registry + neuer Endpoint (POST /api/import/detect/{kind}). Bevorzuge (a) — hält die Registry-Anzahl klein.
Anti-Muster. if input.startswith("http"): ... elif Path(input).is_dir(): ... in einem einzelnen find_handler mischt Shape-Erkennung in die Abstraktion. Dispatch bleibt semantisch, nicht syntaktisch.
Muster 3: Plugin-zu-Plugin-Abhängigkeit via Path-Dep¶
Problem. Dein Plugin braucht Utility-Code aus einem anderen Plugin (z. B. tiptap_to_markdown aus plugin-export). Du willst den Code nicht kopieren und kannst das andere Plugin (noch) nicht per pip installieren, weil beide im gleichen Monorepo liegen.
Lösung. Abhängigkeit in pyproject.toml per relativem Pfad deklarieren:
[tool.poetry.dependencies]
bibliogon-plugin-export = {path = "../bibliogon-plugin-export", develop = true}
poetry install im Plugin-Verzeichnis bindet das andere Plugin in die venv. Imports funktionieren wie bei einem PyPI-Paket.
PGS-01 Beispiel. plugin-git-sync deklariert bibliogon-plugin-export als Path-Dep. Phase 1 nutzt die Abhängigkeit zur Laufzeit noch nicht — sie ist Gerüst für PGS-02 (Export-to-Repo), das per from bibliogon_export.tiptap_to_md import tiptap_to_markdown Bücher zurück ins Git-Repo serialisieren wird. Die Deklaration kommt früh, damit die Architektur sichtbar ist, bevor der Code folgt.
Beim PyPI-Release. Ein Path-Dep löst bei pip install bibliogon-plugin-git-sync außerhalb des Monorepos nicht auf. Der Publish-Schritt muss ihn auf einen Versions-Pin umstellen:
bibliogon-plugin-export = ">=1.0.0,<2.0.0"
Das passiert beim PyPI-Release, nicht während der Entwicklung.
Wenn die Abhängigkeit optional ist. Wenn dein Plugin auch ohne die andere funktioniert, keinen Path-Dep deklarieren — deferred Import innerhalb des betroffenen Code-Pfads, ImportError abfangen, graceful degradieren. Path-Deps sind für Pflicht-Abhängigkeiten.
Muster 4: PluginForge-Activation -> Core-Registry-Bridge¶
Problem. PluginForge erkennt Plugins per Entry-Points; Bibliogons Core-Registries (ImportPlugin, RemoteSourceHandler, Hookspecs, ...) haben eigene register(...)-Funktionen. Etwas muss "PluginForge hat das Plugin geladen" und "Bibliogon kennt seine Handler" verbinden.
Lösung. Der activate()-Hook des Plugins importiert die Core-Registrierungsfunktion deferred und ruft sie auf:
# plugins/bibliogon-plugin-git-sync/bibliogon_git_sync/plugin.py
from pluginforge import BasePlugin
class GitSyncPlugin(BasePlugin):
name = "git-sync"
version = "1.0.0"
api_version = "1"
license_tier = "core"
def activate(self) -> None:
from bibliogon_git_sync.handlers.git_handler import GitImportHandler
from .registration import register_git_handler
register_git_handler(GitImportHandler())
Und registration.py:
def register_git_handler(handler: object) -> None:
from app.import_plugins import register_remote_handler
register_remote_handler(handler) # type: ignore[arg-type]
Warum deferred Imports. Ein Import von app.* auf Modul-Ebene koppelt das Plugin-Modul an das voll geladene Bibliogon-Backend. Das bricht Plugin-Unit-Tests, die nur die Handler-Logik testen wollen. Verlagern in activate() (das erst im App-Lifespan feuert) hält das Plugin-Modul eigenständig importierbar.
Timing. PluginForge ruft activate() während manager.discover_plugins() im App-Lifespan auf, vor dem ersten HTTP-Request. Wenn eine Route feuert, sind alle Registrierungen bereits passiert.
Anti-Muster. Side-Effect-Imports auf Modul-Ebene (register_remote_handler(...) ganz unten in plugin.py) funktionieren in Produktion, brechen aber eigenständige Testläufe und machen Import-Ordering fragil. Immer über activate().
Dein erstes Plugin schreiben (PGS-01 als Vorlage)¶
Schritt-für-Schritt mit der Shape von PGS-01. Endzustand: funktionsfähiges Plugin-Gerüst zum Ausbauen.
Schritt 1: Entscheide, was dein Plugin tut¶
Drei häufige Shapes:
| Shape | Protocol | Registriert bei | Beispiel |
|---|---|---|---|
| Neues Format | ImportPlugin |
app.import_plugins.register |
WbtImportHandler (Core, CIO-02) |
| Neue Quelle | RemoteSourceHandler |
app.import_plugins.register_remote_handler |
GitImportHandler (PGS-01) |
| Neues Core-Verhalten | Pluggy @hookimpl |
BibliogonHookSpec (siehe backend/app/hookspecs.py) |
plugin-grammar (content_pre_import) |
Eins wählen. Wenn deine Arbeit wirklich zwei umfasst (z. B. Format-Plugin + Hookspec), beides — PluginForge erlaubt das.
Schritt 2: Plugin-Paket erstellen¶
Layout identisch zu den anderen 10 Plugins:
plugins/bibliogon-plugin-<name>/
├── pyproject.toml
├── README.md
├── bibliogon_<name>/
│ ├── __init__.py
│ ├── plugin.py # BasePlugin-Subclass, activate()-Hook
│ └── handlers/
│ ├── __init__.py
│ └── <kind>_handler.py
└── tests/
├── __init__.py
└── test_<kind>_handler.py
Minimal-pyproject.toml: siehe englische Version oben (Struktur identisch).
Außerdem das Plugin in backend/pyproject.toml als Path-Dep eintragen (siehe "Plugin im Backend registrieren" oben). Wer das vergisst, macht das Plugin für CI unsichtbar.
Schritt 3: Protocol implementieren¶
Shape vom ähnlichsten Plugin aus Schritt 1 kopieren. Für RemoteSourceHandler ist die Minimal-Signatur:
class <Name>Handler:
source_kind = "<kind>"
def can_handle(self, url: str) -> bool: ...
def clone(self, url: str, target_dir: Path) -> Path: ...
Gib den Pfad zurück, durch den der Orchestrator dispatchen soll (meist ein Unterverzeichnis in target_dir). Exceptions für nicht-wiederherstellbare Fehler werfen; der Endpoint mappt auf HTTP 502.
Schritt 4: Activation verdrahten¶
Siehe englische Version oben (Code identisch). Deferred Imports sind tragend. Im Funktionskörper halten.
Schritt 5: Tests hinzufügen¶
Drei Ebenen, jede in eigener Datei:
- Plugin-Ebene (
plugins/bibliogon-plugin-<name>/tests/test_<kind>_handler.py): Unit-Tests der Handler-Klasse. Externe Services mocken (GitPython, HTTP-Clients, ...). Kein App-Load. - Endpoint-Ebene (
backend/tests/test_import_<kind>_endpoint.py):TestClient(app), trifftPOST /api/import/detect/<kind>, mockt die externe Dependency des Handlers, damit die Plugin-Endpoint-Handler-Kette ohne Netzwerk getestet wird.scope="module"amclient-Fixture, um Lifespan-State-Akkumulation zu begrenzen (siehe RecursionError-Notiz in.claude/rules/lessons-learned.md). - Plugin-Smoke (gleiche Datei, 1-2 Tests):
list_remote_handlers()(oder Aequivalent) muss deinen Handler nach Lifespan enthalten. Regressions-Guard gegen "Plugin nicht inapp.yamlenabled-Liste".
Schritt 6: In app.yaml aktivieren¶
plugins:
enabled:
- export
- help
- ...
- <name>
backend/config/app.yaml.example editieren — diese Datei ist die Quelle der Wahrheit für Neuinstallationen. Lokales backend/config/app.yaml ist gitignored; PS-01 kopiert .example beim ersten Start.
Schritt 7: Ausliefern¶
docs/ROADMAP.md: Eintrag der Phase auf[x]mit Ein-Absatz-Abschlussnotiz.docs/help/_meta.yaml: Nav-Eintrag ergänzen, wenn dein Plugin user-sichtbares Verhalten hat.docs/help/{de,en}/<topic>/<slug>.md: user-orientierte Hilfe-Seite schreiben. Mindestens DE + EN.backend/config/plugins/help.yaml: mindestens einen FAQ-Eintrag ergänzen, der Nutzer auf das neue Feature hinweist.Makefile:test-plugin-<name>-Target hinzufügen und intest-plugins-Liste aufnehmen.
Schritt 8: Häufige Fallstricke¶
- Handler zur Laufzeit nicht registriert. Plugin nicht in
app.yaml-enabled-Liste. PluginForge hat den Entry-Point erkannt, aber Activation übersprungen. - Plugin läuft lokal, fällt in CI aus. Path-Dep fehlt in
backend/pyproject.toml. Die Backend-venv ist die autoritative Umgebung; CI installiert genau das, was dort deklariert ist. - Import-Zyklus beim Plugin-Load. Etwas in
plugin.pyauf Modul-Ebene importiertapp.*. Inactivate()oder einen anderen Funktionskörper verschieben. - Einzeltests grün, volle Suite mit RecursionError rot. Per-Test-
TestClient(app)-Fixtures akkumulieren Plugin-Route-State am geteilten FastAPI-Singleton.scope="module"(siehe.claude/rules/lessons-learned.md). - Plugin-zu-Plugin-Dep löst nicht auf. Relativer
path = "../..."in deinerpyproject.tomlpasst nicht zum tatsächlichen Layout. Korrigieren oderpoetry locklaufen lassen. can_handledes Handlers feuert nie. Registrierungs-Reihenfolge prüfen: first-registered wins infind_handler(). Wenn ein früherer Handler alles greift, ist deiner unerreichbar.
Referenz: plugin-git-sync Quellcode-Walkthrough¶
Konkretes Beispiel für alles oben: PGS-01-Commits in Reihenfolge lesen — jeder ist ein einzelner atomarer Schritt:
| Commit | Zuständigkeit |
|---|---|
c93d496 |
Plugin-Gerüst + pyproject + Backend-Path-Dep |
4fb9e99 |
Frontend-Input + API-Client + i18n |
c14c8c7 |
Core-Registry + Endpoint (noch kein Plugin-Verhalten) |
a3616f3 |
Handler-Implementierung + Plugin-Tests |
df6cb39 |
app.yaml-Wiring + E2E-Integrationstest |
ced994c |
ROADMAP-Flip + Hilfe-Docs |
Jeden Diff neben dieser Anleitung studieren.
Bidirektionale Sync-Patterns (aus PGS-02..05)¶
PGS-01 brachte Bücher in Bibliogon hinein. Phasen 2-5 schließen den Round-Trip — Buch neu scaffolden und zurück zum Remote pushen. Vier Patterns, die jedes Plugin trifft, das externen State verändert.
Muster 5: Per-Buch-Lock für Cross-Subsystem-Operationen¶
Problem. Klick auf "Commit überall" fächert den Aufruf in zwei Subsysteme auf (Core-Git + plugin-git-sync). Ohne Koordination racen zwei gleichzeitige Fächerungen (alter Dialog in anderem Tab, Re-Click während langsamem ersten Versuch) am Working Tree und am last_committed_at-Cursor.
Lösung. Schlüsselter Lock auf book_id mit kurzem Timeout. PGS-05 liefert app.services.git_sync_lock.book_commit_lock(book_id, timeout=30):
from app.services.git_sync_lock import book_commit_lock
with book_commit_lock(book_id, timeout=30):
# core git zuerst (kleinerer Blast Radius), plugin-git-sync danach
...
Timeout mappt im Router auf HTTP 503, nie 500. Der Nutzer sieht "anderer Commit läuft" und versucht es erneut.
Wann nutzen. Immer wenn eine User-Aktion auf >=2 mutierende Subsysteme derselben Resource auffächert. Lock ist per Resource, nicht per Prozess.
Anti-Pattern. Implizit auf "niemand klickt zweimal" zu vertrauen ist der Bug; funktioniert in QA, scheitert wenn SSE-Reconnects denselben Aufruf wiederholen, wenn der Toast eines langsamen ersten Versuchs abläuft und der Nutzer neu klickt usw. Immer locken.
Muster 6: Weiche Per-Subsystem-Fehleraggregation¶
Problem. Wenn Core-Git + plugin-git-sync gefächert werden, ist Teil-Fehlschlag die Norm: eine Seite gelingt, die andere scheitert an Auth, Netzwerk oder "nichts zu committen". Ein hartes raise HTTPException(500) verliert den Erfolg und lässt den Nutzer mit generischem Fehler stehen.
Lösung. Per-Subsystem-Resultat mit stabilem Status-Enum:
class SubsystemResult:
status: Literal["ok", "skipped", "nothing_to_commit", "failed"]
detail: str | None = None
commit_sha: str | None = None
pushed: bool = False
Beide Subsystem-Resultate landen im Response-Body, auch wenn eines scheiterte. Toast-Stufe (success / warning / error) entscheidet die Client-Seite aus den kombinierten Stati, sodass der Nutzer "Core erfolgreich, Plugin fehlgeschlagen (Auth)" sieht statt "Internal Server Error".
503 bleibt — feuert aber NUR wenn der Per-Buch-Lock nicht zu bekommen ist. Subsystem-Level-Fehler bleiben im 200-Payload.
Wann nutzen. Endpoint, der >=2 Subsysteme orchestriert wo Teilerfolg sinnvoll ist. Wenn beide atomar gelingen müssen (z.B. Finanztransaktion), passt das Pattern nicht — Transaction Boundary nutzen.
Muster 7: One-Shot-Pushurl für Credential-Injection¶
Problem. PAT in origin-URL via git remote set-url einbetten funktioniert für HTTPS-Push, aber dann liegt der PAT in .git/config auf der Platte. Backup-Read des Repos würde den Token leaken.
Lösung. Eingebettete URL kurz vor dem Push setzen, Original-URL im finally wiederherstellen. PGS-02s _push:
original_url = next(repo.remotes.origin.urls)
auth_url = git_credentials.inject_pat_into_url(original_url, book_id)
try:
if auth_url != original_url:
repo.remotes.origin.set_url(auth_url)
info = repo.remotes.origin.push(refspec=f"{branch}:{branch}")
finally:
if auth_url != original_url:
repo.remotes.origin.set_url(original_url)
Nach Return ist die URL auf Platte wieder original. Regression-Test (test_commit_push_uses_per_book_pat_without_persisting_to_git_config) liest .git/config nach dem Push und assertet, dass der Token nie auftaucht.
Wann nutzen. Immer wenn ein Secret in ein Config-Feld als temporärer Auth-Carrier eingebettet wird.
Per-Buch-Credential-Helper. PGS-02-FU-01 fügte app.services.git_credentials hinzu, sodass mehrere Subsysteme einen einzigen Per-Buch-PAT-Slot teilen. Wenn du Credentials für ein neues Subsystem am selben Buch brauchst, diesen Helper wiederverwenden statt parallelen Store bauen. Encrypted-at-rest via Fernet mit Schlüssel aus BIBLIOGON_CREDENTIALS_SECRET.
Muster 8: Fehlertolerante Lazy-Imports für Side-Effects¶
Problem. Dein Plugin produziert ein Primär-Artefakt (z.B. einen Commit) und eine "nice to have"-Begleitdatei (z.B. Markdown-Sidefile neben dem kanonischen JSON für lesbare Git-Diffs). Der Begleitschreiber hängt via Path-Dep an einem Konverter eines anderen Plugins. Wenn der Begleitschreiber bricht, muss das Primär-Artefakt trotzdem rausgehen.
Lösung. Helper innerhalb try/except lazy importieren, im Fehlerfall loggen und weitermachen. PGS-05s Markdown-Sidefile-Emitter:
def _write_md_side_file(json_path: Path) -> None:
try:
from bibliogon_export.tiptap_to_md import tiptap_to_markdown # lazy
except Exception:
logger.exception("Markdown side-file: import failed; skipping.")
return
try:
# ... convert + write
except Exception:
logger.exception("Markdown side-file: conversion failed; skipping.")
Der Commit landet trotzdem; das Sidefile vielleicht nicht. Nächster Commit versucht es erneut.
Wann nutzen. Immer wenn du ein nicht-kanonisches Begleit-Artefakt produzierst. Wenn das Begleit-Artefakt das einzige Artefakt ist (z.B. EPUB-Output des Export-Plugins), passt das Pattern nicht — Fehler müssen hart hochpoppen.
Anti-Pattern. Eager-Import des Helpers oben im Plugin-Modul: ein zukünftiger Refactor des Helper-Plugins bricht die Lade-Discovery deines Plugins, obwohl deine Primär-Arbeit nichts damit zu tun hat.
Three-Way-Diff-Patterns (aus PGS-03 + PGS-03-FU-01)¶
Wenn dein Plugin Inhalte aus einer externen Quelle re-importiert, die der Nutzer auch lokal editiert hat, musst du den Diff so aufbereiten, dass der Nutzer auflösen kann. PGS-03 lieferte einen Three-Way-Diff (base / local / remote) über Kapitel; die Patterns generalisieren.
Muster 9: Git-Refs ohne Working-Tree-Checkout lesen¶
Problem. Base-vs-Remote-Diff erfordert das Lesen von Dateiinhalt an ZWEI Commits. Naives git checkout <ref> wechselt den Working Tree und bricht parallele Commit-to-Repo-Flows.
Lösung. git ls-tree -r --name-only <commit> <prefix> + git show <commit>:<path> sind read-only und berühren den Working Tree nie. PGS-03s _read_wbt_at_ref(clone_path, ref):
commit = repo.commit(ref)
tree = repo.git.ls_tree("-r", "--name-only", commit.hexsha, prefix).splitlines()
for path in tree:
if path.endswith(".md"):
content = repo.git.show(f"{commit.hexsha}:{path}")
# ...
Ref erst zu Commit auflösen, dann sind die show-Aufrufe deterministisch, auch wenn der Branch unter dir wandert.
Wann nutzen. Immer wenn du Inhalt an mehreren Refs in derselben logischen Operation brauchst. Working Tree als exklusiv für Commit-to-Repo / Merge / Checkout behandeln — nie für Read-Only-Inspektion.
Muster 10: Reine Klassifikation + seiteneffektige Anwendung¶
Problem. Ein Diff hat zwei Verantwortungen: herausfinden was sich geändert hat (Per-Kapitel-Klassifikation) und Nutzer-Auflösung anwenden (DB mutieren). Vermischen ergibt eine 200-Zeilen-Funktion, untestbar ohne reales Git-Repo + DB.
Lösung. Zwei getrennte Funktionen:
_classify(base, local, remote) -> list[ChapterDiff]: rein. Drei dicts identity → content rein, Liste von Klassifikationen raus. Kein Git, keine DB. Unit-testbar aus In-Memory-dicts.apply_resolutions(db, *, book_id, resolutions): seiteneffektig. Mutiert DB nach Per-Kapitel-Wahl und bumpt den Cursor.
diff_book(db, book_id) ist der dünne Glue, der Inputs liest (via Pattern 9) und in _classify einspeist.
Wann nutzen. Jede nicht-triviale Entscheidung, die in einer DB-Mutation endet. Die Klassifikations-Hälfte verdient ~10 eigene Unit-Tests für Edge-Cases (unchanged, beidseitig-entfernt, identische-Edits-beidseitig, Blank-Line-only-Differenzen, ...). Dieselbe Coverage über End-to-End-Fixtures zu erreichen ist 5x langsamer und 10x spröder.
Normalisierungstolerante Vergleiche. PGS-03s _normalize strippt trailing Whitespace pro Zeile, kollabiert Blank-Line-Runs, trimmt führenden/abschließenden Whitespace vor Equality. Markdown-Round-Trips über TipTap → Markdown → Datei → TipTap fügen oft einen finalen Newline hinzu oder weg; ohne Normalisierung würde jedes "unchanged"-Kapitel als "local_changed" klassifiziert.
Muster 11: Post-Process-Collapse für Rename-Detection¶
Problem. Eine Datei, die von slug-a nach slug-b mit identischem Body wandert, klassifiziert als *_removed für den alten Slug UND *_added für den neuen — zwei verwirrende Zeilen, die der Nutzer mental paaren muss.
Lösung. Basis-Klassifizierer bleibt simpel (kennt keine Renames). Rename-Detection als separater Pass _collapse_renames(diffs) paart (removed, added)-Zeilen mit matchenden normalisierten Bodies in eine einzige renamed_*-Zeile. PGS-03-FU-01:
def _collapse_renames(diffs: list[ChapterDiff]) -> list[ChapterDiff]:
# gruppiere nach Klassifikation
# fuer (remote_removed, remote_added): match Bodies, ersetze durch renamed_remote
# fuer (local_removed, local_added): match Bodies, ersetze durch renamed_local
# ungepaarte Zeilen unveraendert lassen
Nur strikte Body-Matches. Near-Misses (z.B. kleine Edits im Body während des Renames) bleiben als unabhängige removed + added stehen, sodass der Nutzer den echten Diff sieht. Fuzzy-Schwellen erzeugen False Positives, die unabhängige Kapitel falsch paaren.
Cross-Side-Pairing verboten. Nie remote_removed mit local_added paaren, auch bei identischem Body — das ist Zufall, kein Rename, und es als solchen zu behandeln würde unabhängige Arbeit stillschweigend mergen.
Wann nutzen. Jede "Rename"-Detection über einem Per-Item-Klassifizierer. Klassifizierer dumm halten, Post-Process gezielt.
Multi-Branch / Translation-Group-Patterns (aus PGS-04 + PGS-04-FU-01)¶
Wenn dein Plugin mehrere Varianten derselben Resource aus einer Quelle importiert (z.B. Uebersetzungen eines Buchs auf verschiedenen Git-Branches), ist Failure-Isolation wichtiger als Erfolg.
Muster 12: Stabile Reason-Slugs + Payload-driven Skip-Surface¶
Problem. Über N Branches iterieren und jeden importieren ist der einfache Teil. Schwer wird's bei den 2 von 5 Branches, die scheitern: WBT-Layout fehlt, Kapitel-Struktur inkompatibel, Metadata invalid. Wenn du try/except und nur loggst, sieht der Nutzer 3 importierte Bücher und verliert 2 still.
Lösung. Jeden Per-Item-Failure in ein strukturiertes SkippedItem-Payload neben den Erfolgen aufs Ergebnisobjekt fangen. PGS-04-FU-01s MultiBranchResult.skipped: list[SkippedBranch]:
@dataclass
class SkippedBranch:
branch: str
reason: Literal["no_wbt_layout", "import_failed"]
detail: str # gekuerzte Diagnostik-Zeile
Zwei Failure-Modes mit eigenen Slugs:
no_wbt_layout— strukturelle Vorbedingung scheiterte (config-Dir fehlt). Branch ist im Scope, aber kein Buch.import_failed— innerer Importer warf. Inkl. Exception-Klasse + Message, auf 500 Zeichen gekürzt.
Router echot skipped[] im Response (default [] bei sauberen Imports), Frontend rendert eine "Aufmerksamkeit erforderlich"-Sektion pro Eintrag.
Stabile englische Slugs in der API; lokalisierte Labels im Frontend. Der Slug ist der API-Vertrag — nie ohne Migration ändern. Frontend mappt Slug → User-sichtbarer String pro Sprache. Wenn ein neuer Failure-Mode (vierter Slug) dazukommt, gewinnt die API einen neuen Wert, alte Frontends fallen aufs Rendern des Raw-Slug zurück statt zu crashen.
Detail server-side kürzen. 5MB-Exception-Payload ist DoS-Vektor und für den Nutzer nutzlos. 500 Zeichen reichen für Exception-Klasse + ersten Satz von str(exc).
Wann nutzen. Jedes Iterate-and-Import-Pattern, wo Teilerfolg realistisch ist. Pattern transferiert auch auf Nicht-Import-Iterationen — Bulk-Export, Bulk-Validation, Batch-Translation.
Anti-Pattern. Teil-Failures hinter einem result.success: bool-Flag verstecken. Der Nutzer hat keinen Weg zurück zum Verlorenen.
Referenz: PGS-02..05 Commit-Walkthrough¶
Jede Phase landete in 1-3 atomaren Commits. In Reihenfolge neben dieser Anleitung lesen:
| Phase | Zuständigkeit | Commits |
|---|---|---|
| PGS-02 | Commit-to-Repo + Push (Overwrite-MVP) | aa25d74 (Backend) + 782490e (Frontend) |
| PGS-02-FU-01 | Per-Buch-PAT shared across Subsysteme | 32137bb |
| PGS-03 | Three-Way-Diff + Per-Kapitel-Auflösung | c87b7dd (Backend) + 1338d87 (Frontend) |
| PGS-03-FU-01 | mark_conflict + Rename-Detection | 819e571 + 5bfd76a + e58d9e1 |
| PGS-04 | Translation-Group Multi-Branch-Import | 4aa7153 + 9c8eee5 |
| PGS-04-FU-01 | Skipped-Branch-Surface + reusable Panel | 06c7c1b + 75046b9 |
| PGS-05 | Unified-Commit-Fan-Out + Per-Buch-Lock | 6af6f5c + b0133ec |