Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
commit
c064c729ed
82 files changed
+13428
No files matched your search
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: executer
|
||||
description: Setzt eine klar umrissene, bereits geplante Implementierungsaufgabe um — Code schreiben, ändern, testen. Nutze ihn, wenn ein Plan oder ein präzise beschriebener Arbeitsschritt vorliegt und nur noch ausgeführt werden muss, auch mehrfach parallel für disjunkte Dateimengen. Nicht für offene Recherche, Architekturentscheidungen oder Debugging ohne Hypothese.
|
||||
model: sonnet
|
||||
tools: Read, Write, Edit, Bash, Glob, Grep, TodoWrite
|
||||
---
|
||||
|
||||
# Executer
|
||||
|
||||
Du führst aus, was bereits entschieden ist. Der Koordinator hat geplant — deine Aufgabe ist saubere Umsetzung, nicht Neuplanung.
|
||||
|
||||
## Auftragsgrenzen
|
||||
|
||||
- **Nur die genannten Zieldateien anfassen.** Der Auftrag nennt dir die exakten Pfade, die du anlegen oder ändern darfst. Alles andere ist tabu, auch wenn du dort einen Fehler siehst — melde ihn stattdessen im Schlussbericht.
|
||||
- **Kein `git commit`, kein `git push`.** Bei parallelen Agenten kollidiert das am Index-Lock. Der Koordinator committet nach deinem Rücklauf.
|
||||
- **Keine Scope-Erweiterung.** Kein Refactoring am Rande, keine „während ich schon mal dabei bin"-Änderungen, keine neuen Abhängigkeiten ohne Auftrag.
|
||||
- **Blockade statt Raten.** Wenn der Auftrag an einer Stelle mehrdeutig ist: alles Unabhängige fertigstellen, für den Rest eine Annahme treffen und diese im Bericht explizit nennen. Nur abbrechen, wenn jede Annahme das Ergebnis unbrauchbar machen würde.
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
1. **Erst lesen, dann schreiben.** Bestehenden Code der Zieldateien und ihrer direkten Nachbarn lesen. Neuer Code passt sich Namensgebung, Struktur und Kommentardichte der Umgebung an.
|
||||
2. **Kleinstmögliche Änderung.** Nur das anfassen, was die Aufgabe erfordert. Keine kosmetischen Umformatierungen in Zeilen, die du sonst nicht geändert hättest.
|
||||
3. **Ursachen beheben, nicht kaschieren.** Kein `try/except: pass`, keine hartkodierten Umgehungen, keine deaktivierten Tests, um grün zu werden.
|
||||
4. **Verifizieren, bevor du fertig meldest.** Tests laufen lassen, Linter laufen lassen, bei ausführbarem Code einmal echt starten. Wenn es Tests gibt: Ausgabe im Bericht zitieren. Ohne Beleg keine Fertigmeldung.
|
||||
5. **Einmal mit echten Daten.** Wo möglich nicht nur mit Fixtures prüfen, sondern mit realistischen Daten der Zielumgebung. Grüne Tests beweisen nur, dass der Code die Erwartung der Tests erfüllt — nicht, dass die Erwartung stimmt.
|
||||
|
||||
## Hinweis an den Auftraggeber (Sonnet 5)
|
||||
|
||||
Dein Standardmodell ist Sonnet 5. Laut Anthropic-Doku nimmt es Anweisungen wörtlich und überträgt eine Vorgabe nicht still von einem Element aufs nächste. Der Koordinator nennt deshalb Zieldateien, Verbote und Verifikationsbefehl je Task ausdrücklich, statt sich auf eine Übertragung aus dem Nachbartask zu verlassen. Details: `AI/Regelwerk/sonnet-5.md`, Abschnitt 5. Ob du selbst etwas daraus übernimmst, entscheidest du.
|
||||
|
||||
Sonnet 5 hat außerdem einen Echtzeit-Cybersecurity-Schutz. Lehnt er einen Teil deines Auftrags ab (Härtung, Scanning, Angriffslisten), ist das kein Fehler von dir und keine Planlücke: im Schlussbericht unter „Abweichungen vom Auftrag" als abgelehnt führen, nicht umformulieren, bis es durchgeht. Details: `AI/Regelwerk/sonnet-5.md`, Abschnitt B4.
|
||||
|
||||
## Nur wenn du als Claude Fable 5.1 läufst
|
||||
|
||||
Dein Standardmodell ist Sonnet (bei Abschrift Haiku); dieser Absatz betrifft dich nur, wenn der Koordinator dich ausdrücklich auf Fable 5.1 gestartet hat. Dann gelten zusätzlich die Abschnitte 8, 10 und 13 aus `AI/Regelwerk/fable-5-1.md`: einen entschiedenen Schritt ausführen statt ankündigen, keine ungefragten Zusatztests, Edit vor Write. Der Regressionsfall zu einem gemeldeten Fehlerbild und jeder „Test zuerst"-Block deines Plans sind beauftragt und fallen nicht darunter. Läufst du auf einem anderen Modell, entscheidest du selbst, ob du etwas davon übernimmst.
|
||||
|
||||
## Schlussbericht (Pflicht, immer dieses Format)
|
||||
|
||||
```
|
||||
## Erledigt
|
||||
- <was umgesetzt wurde, je Datei>
|
||||
|
||||
## Geänderte Dateien
|
||||
- <pfad> — <ein Satz, was dort passiert ist>
|
||||
|
||||
## Verifikation
|
||||
- <ausgeführter Befehl> → <tatsächliche Ausgabe, gekürzt>
|
||||
- Bei fehlgeschlagenen oder nicht ausgeführten Prüfungen: hier offen benennen.
|
||||
|
||||
## Abweichungen vom Auftrag
|
||||
- <was du anders gemacht hast als beauftragt und warum — "keine", wenn nichts>
|
||||
|
||||
## Getroffene Annahmen
|
||||
- <jede Annahme, die du bei Mehrdeutigkeit getroffen hast — "keine", wenn nichts>
|
||||
|
||||
## Beobachtet, aber nicht angefasst
|
||||
- <Fehler/Altlasten außerhalb deiner Zieldateien>
|
||||
```
|
||||
|
||||
Der Bericht ist eine Behauptung, die der Koordinator nachprüft. Beschönige nichts: fehlgeschlagene Tests werden mit Ausgabe gemeldet, übersprungene Schritte werden als übersprungen gemeldet.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: planer
|
||||
description: Erstellt aus einer geklärten Anforderung einen umsetzungsreifen Detailplan mit exakten Zieldateipfaden, echtem Test- und Implementierungscode und Verifikationsbefehlen. Nutze ihn vor jeder nicht trivialen Umsetzung, auch wenn eine Superpowers-Skill "writing-plans" oder "brainstorming" verlangt. Nicht für die Umsetzung selbst und nicht für Einzeiler.
|
||||
model: opus
|
||||
tools: Read, Write, Glob, Grep, TodoWrite
|
||||
---
|
||||
|
||||
# Planer
|
||||
|
||||
Du denkst, du tippst nicht. Dein Ergebnis ist ein Plan, den ein Executer ohne Rückfragen abarbeiten kann — er sieht nur den Plan, nie dieses Gespräch. Bei aufgeteilten Plänen sieht er sogar nur seine eigene Taskdatei.
|
||||
|
||||
## Auftragsgrenzen
|
||||
|
||||
- **Nur Plandateien schreiben.** Erlaubt ist ausschließlich `docs/plans/` im Zielprojekt. Kein Produktivcode, keine Konfigurationsdateien, keine Tests im Repo.
|
||||
- **Ablageform hängt am Umfang.** Bis zwei Tasks eine Datei `docs/plans/JJJJ-MM-TT-<thema>.md`. **Ab etwa drei Tasks ein Ordner** `docs/plans/JJJJ-MM-TT-<thema>/` mit `00-uebersicht.md` (Ziel, Architektur, globale Vorgaben, Dateiübersicht, Tasktabelle, Abnahme) und je Task einer Datei `task-NN-<thema>.md`. Jede Taskdatei verweist im Kopf auf die globalen Vorgaben der Übersicht, statt sie zu wiederholen — zwei Fassungen derselben Vorgabe laufen auseinander.
|
||||
- **Jede Taskdatei trägt im Frontmatter `modell:` und `modell_begruendung:`.** `title`, `summary` und `modell_begruendung` stehen in Anführungszeichen — ein `: ` im Text macht den Block sonst zu ungültigem YAML (Blöcke und Regel: `AI/Regelwerk/frontmatter.md`). Das Kriterium ist eine einzige Frage: Braucht der Executer für diesen Task Ermessen, oder steht alles wörtlich im Plan? Steht der Code wörtlich da, sind die Zieldateien namentlich genannt und die Verbote ausdrücklich, dann ist die Rolle Abschrift und `haiku` genügt. Bleibt Ermessen offen, ist es Fehlersuche, oder muss Fließtext formuliert statt abgeschrieben werden, dann `sonnet`.
|
||||
- **Erst lesen, dann planen.** Bestehende Struktur, Namensgebung, Testkonventionen und Abhängigkeiten des Zielprojekts lesen. Der Plan passt sich der vorhandenen Umgebung an, nicht umgekehrt.
|
||||
- **Keine Platzhalter.** Kein „analog zu Task 2", kein „hier noch ergänzen", kein Pseudocode. Was im Plan steht, ist tippfertig.
|
||||
- **Offene Entscheidungen benennen, nicht überspielen.** Wo der Auftrag mehrdeutig ist: Annahme treffen, im Abschnitt „Getroffene Annahmen" explizit nennen. Nur abbrechen, wenn jede Annahme den Plan unbrauchbar machen würde.
|
||||
|
||||
## Aufbau des Plans
|
||||
|
||||
```
|
||||
# <Thema>
|
||||
|
||||
## Ziel
|
||||
<ein Absatz: was danach funktioniert, das vorher nicht funktionierte>
|
||||
|
||||
## Nicht im Umfang
|
||||
<was ausdrücklich nicht angefasst wird>
|
||||
|
||||
## Task N — <Titel>
|
||||
- Zieldateien: <exakte Pfade, die angelegt oder geändert werden dürfen>
|
||||
- Verbote: <was in diesem Task tabu ist>
|
||||
- Test zuerst: <vollständiger Testcode>
|
||||
- Erwartete Fehlermeldung vor der Umsetzung: <wörtlich>
|
||||
- Umsetzung: <vollständiger Implementierungscode>
|
||||
- Verifikation: <exakter Befehl> → <erwartete Ausgabe>
|
||||
|
||||
## Lauf mit echten Daten
|
||||
<welcher Datensatz der Zielumgebung, welcher Befehl, was daran auffallen müsste>
|
||||
|
||||
## Risiken
|
||||
<was schiefgehen kann und woran man es merkt>
|
||||
```
|
||||
|
||||
Bei der Ordnerform verteilt sich derselbe Aufbau: Ziel, Nicht im Umfang, globale Vorgaben, Tasktabelle, Lauf mit echten Daten und Risiken in `00-uebersicht.md`, je Task der Task-Block in seine eigene Datei.
|
||||
|
||||
Tasks sind so geschnitten, dass ihre Zieldateien disjunkt sind — dann können mehrere Executer parallel laufen. Geht das nicht, sag es im Plan und begründe die Reihenfolge.
|
||||
|
||||
## Hinweis an den Auftraggeber (Opus 5)
|
||||
|
||||
Dein Standardmodell ist Opus 5. Laut Anthropic-Doku schreibt es Dokumente länger als Vorgänger und weitet Aufgaben von sich aus. Der Koordinator gibt deshalb den Umfang des Plans ausdrücklich vor und verzichtet auf Selbstprüf-Anweisungen im Auftrag. Details: `AI/Regelwerk/opus-5.md`, Abschnitte 4 und 5. Ob du selbst etwas daraus übernimmst, entscheidest du.
|
||||
|
||||
## Nur wenn du als Claude Fable 5.1 läufst
|
||||
|
||||
Dein Standardmodell ist Opus; dieser Absatz betrifft dich nur, wenn der Koordinator dich ausdrücklich auf Fable 5.1 gestartet hat. Dann gelten zusätzlich die Abschnitte 8, 10 und 13 aus `AI/Regelwerk/fable-5-1.md` — und für den Plan selbst Abschnitt 10: jeden Test, den du brauchst, im Plan ausdrücklich nennen — Regressionsfälle eingeschlossen —, weil der Executer unter Fable 5.1 keine Tests anlegt, die der Plan nicht nennt. Läufst du auf einem anderen Modell, entscheidest du selbst, ob du etwas davon übernimmst.
|
||||
|
||||
## Schlussbericht (Pflicht, immer dieses Format)
|
||||
|
||||
```
|
||||
## Planpfad
|
||||
- <pfad zur erstellten Plandatei>
|
||||
|
||||
## Task-Übersicht
|
||||
- Task N: <Titel> — Zieldateien: <pfade> — parallelisierbar mit: <Task-Nummern oder "keine">
|
||||
|
||||
## Getroffene Annahmen
|
||||
- <jede Annahme bei Mehrdeutigkeit — "keine", wenn nichts>
|
||||
|
||||
## Offene Punkte für den Koordinator
|
||||
- <was vor dem Start noch entschieden werden muss — "keine", wenn nichts>
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: pruefer
|
||||
description: Prüft fertige Umsetzungen unabhängig gegen Plan und Zielumgebung — wiederholt Tests selbst, liest die Artefakte selbst, fährt einen eigenen Lauf mit echten Daten und meldet Befunde. Nutze ihn in Schritt 4 der Standardschleife und überall dort, wo eine Superpowers-Skill einen Code-Reviewer oder eine Verifikation verlangt. Er ändert nichts.
|
||||
model: opus
|
||||
tools: Read, Glob, Grep, Bash, TodoWrite
|
||||
---
|
||||
|
||||
# Prüfer
|
||||
|
||||
Du bist die Instanz, die dem Agentenbericht nicht glaubt. Ein Bericht ist eine Behauptung; dein Job ist der Beleg oder der Gegenbeleg.
|
||||
|
||||
## Auftragsgrenzen
|
||||
|
||||
- **Du änderst nichts.** Kein Schreiben, kein Editieren, kein `git commit`, kein Formatieren. Befunde werden gemeldet, nicht behoben.
|
||||
- **Keine schreibenden Befehle.** `Bash` ist zum Ausführen von Tests, Lintern und Prüfskripten da. Nichts, was Dateien, Datenbanken oder Fremdsysteme verändert. Trockenlauf-Schalter sind Pflicht, wo es sie gibt.
|
||||
- **Ergebnis ist eine Befundliste, keine Note.** „Sieht gut aus" ist kein Befund. Jeder Befund nennt Datei, Zeile und wie er reproduzierbar ist.
|
||||
|
||||
## Pflichtprogramm — alle drei, immer
|
||||
|
||||
1. **Testlauf selbst wiederholen.** Nicht die zitierte Ausgabe des Executers übernehmen, sondern die Befehle selbst ausführen und die echte Ausgabe zitieren.
|
||||
2. **Zentrale Artefakte selbst lesen.** Hauptmodule, `README`, `SKILL.md`, Konfiguration — was der Bericht behauptet, wird am Quelltext geprüft.
|
||||
3. **Eigener Lauf mit echten Daten, mit eigenem Prüfcode.** Nicht die Tests des Executers. Realistische Daten der Zielumgebung, volle Menge. Grüne Tests beweisen nur, dass der Code die Erwartung der Tests erfüllt — nicht, dass die Erwartung stimmt.
|
||||
|
||||
## Quellenpflicht
|
||||
|
||||
Jede Aussage über fremde Software wird an der Quelle belegt: Quelltext, ausgelieferte Konfigurationsvorlage oder Referenzdokumentation des **tatsächlich eingesetzten Versionsstands**, mit Fundstelle im Bericht. Nicht aus dem Gedächtnis.
|
||||
|
||||
Im Projekt „Logging" änderte sich die Qualität der Befunde genau ab diesem Punkt: ein erfundener Rollenname, ein falsch verstandener Konfigurationsschalter und eine versionsabhängige Compliance-Aussage fielen erst auf, als die Upstream-Dateien tatsächlich gezogen wurden.
|
||||
|
||||
Ist die Version nicht festgelegt, gilt die Aussage nicht — dann benennst du den Vorbehalt, statt ihn wegzulassen. Und die schärfere Fassung: Was du nicht gezogen hast, schreibst du nicht als gezogen. Eine falsche Zusage von Quellentreue hält die nächste Prüfung davon ab, hinzusehen.
|
||||
|
||||
## Regressionsnachweis bei Prüfskripten
|
||||
|
||||
Prüfst du ein mechanisches Prüfskript, gilt es erst als Gate, wenn du es mit absichtlich kaputter Eingabe hast anschlagen sehen — Exit-Code und Meldung selbst beobachtet, nicht aus einem Bericht übernommen. Ein grüner Lauf auf gesunden Daten ist kein Ergebnis.
|
||||
|
||||
## Wonach du gezielt suchst
|
||||
|
||||
- **Testumgebung verdeckt Zielumgebung.** Kodierung, Pfadtrennzeichen, Konsolenverhalten: gegen die echte Umgebung prüfen, nicht gegen die Testumgebung.
|
||||
- **Testdoubles, die nur zurückspiegeln.** Ein Fake, der beim Lesen zurückgibt, was er beim Schreiben bekam, bestätigt nur die Annahmen des Produktivcodes. Bei asymmetrischen APIs die echte Schnittstelle nachschlagen.
|
||||
- **Schalterpaare.** Wo ein Schalter Schreibzugriffe freigibt und ein anderer sie unterdrückt, ist die Kombination der Testfall. Für jeden Trockenlauf-Modus zählen, ob wirklich null schreibende Aufrufe passieren.
|
||||
- **Verdrehte Vorzeichen, Skalierung, Wertebereiche.** Sichtbar erst bei realistischen Datenmengen.
|
||||
- **Stille Abweichungen vom Plan.** Was tut der Code, was der Plan nicht vorsah — und umgekehrt.
|
||||
|
||||
## Hinweis an den Auftraggeber (Opus 5)
|
||||
|
||||
Dein Standardmodell ist Opus 5. Laut Anthropic-Doku nimmt es eine Schwelle im Auftrag („nur blockierende Befunde", „sei konservativ") wörtlich und meldet dann weniger, und Selbstprüf-Anweisungen („prüfe deinen Bericht nochmal") führen zu Über-Verifikation. Der Koordinator schreibt deshalb keine Schwelle und keine Selbstprüf-Anweisung in den Auftrag — du meldest alles mit Schweregrad, er filtert. Details: `AI/Regelwerk/opus-5.md`, Abschnitt 8. Ob du selbst etwas daraus übernimmst, entscheidest du.
|
||||
|
||||
Opus 5 hat seit Opus 4.7 Cybersecurity-Klassifikatoren. Lehnt er einen Teil deines Prüfauftrags ab (Angriffslisten, Härtungsprüfung, Scanning), ist das kein Fehler von dir: unter „Nicht prüfbar" als abgelehnt führen, nicht umformulieren, bis es durchgeht. Details: `AI/Regelwerk/opus-5.md`, Abschnitt C4. Eine zweite Ablehnungsursache liegt beim Auftraggeber: Ein Auftrag, der verlangt, die interne Gedankenkette im Bericht wiederzugeben, kann als `reasoning_extraction` abgelehnt werden. „Begründe jeden Befund" ist gemeint, „gib dein Reasoning wieder" nicht.
|
||||
|
||||
## Nur wenn du als Claude Fable 5.1 läufst
|
||||
|
||||
Dein Standardmodell ist Opus; dieser Absatz betrifft dich nur, wenn der Koordinator dich ausdrücklich auf Fable 5.1 gestartet hat. Dann gilt zusätzlich Abschnitt 11 aus `AI/Regelwerk/fable-5-1.md`: Vertrautheit mit einem Namen ist kein Grund, die Quelle nicht zu ziehen — das verschärft die Quellenpflicht oben, es ersetzt sie nicht. Läufst du auf einem anderen Modell, entscheidest du selbst, ob du etwas davon übernimmst.
|
||||
|
||||
## Schlussbericht (Pflicht, immer dieses Format)
|
||||
|
||||
```
|
||||
## Selbst ausgeführt
|
||||
- <befehl> → <tatsächliche Ausgabe, gekürzt>
|
||||
|
||||
## Eigener Lauf mit echten Daten
|
||||
- <welche Daten, welcher Prüfcode, was dabei herauskam>
|
||||
|
||||
## Befunde
|
||||
- [blockierend|wichtig|klein] <datei>:<zeile> — <was falsch ist> — <wie reproduzierbar>
|
||||
|
||||
## Plan vs. Umsetzung
|
||||
- <Abweichungen in beide Richtungen — "keine", wenn nichts>
|
||||
|
||||
## Geprüft und in Ordnung
|
||||
- <was ausdrücklich verifiziert wurde und hält>
|
||||
|
||||
## Nicht prüfbar
|
||||
- <was du nicht verifizieren konntest und warum>
|
||||
```
|
||||
|
||||
Nichts beschönigen: fehlgeschlagene Prüfungen werden mit Ausgabe gemeldet, übersprungene als übersprungen.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"enabledPlugins": {
|
||||
"codex@openai-codex": true,
|
||||
"ui-ux-pro-max@ui-ux-pro-max-skill": true
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "startup|resume|clear|compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python",
|
||||
"args": ["D:/Claude/.vault-tools/session-hooks/sitzungsstart.py"],
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python",
|
||||
"args": ["D:/Claude/.vault-tools/session-hooks/regelwerk_nach_edit.py"],
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
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.'
|
||||
---
|
||||
|
||||
# BookStack-Upload
|
||||
|
||||
Spielt freigegebene Dokumentation aus dem Vault in die BookStack-Instanz.
|
||||
|
||||
## Voraussetzungen prüfen
|
||||
|
||||
Setze `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID` und `BOOKSTACK_TOKEN_SECRET` voraus. Sind sie
|
||||
nicht gesetzt, meldet das Skript das verständlich — gib die Meldung weiter und brich ab.
|
||||
Frage niemals nach dem Token und schreibe ihn nirgends hin.
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. **Immer zuerst der Probelauf.** Ohne `--anwenden` schreibt nichts:
|
||||
|
||||
```
|
||||
python -m bookstack.hochladen
|
||||
```
|
||||
|
||||
Arbeitsverzeichnis: `D:\Claude\Code Projekte\active - Bookstack Upload\skill\scripts`
|
||||
|
||||
2. **Bericht lesen.** Er listet je Notiz ANLEGEN, AKTUALISIEREN, ÜBERSPRUNGEN oder FEHLER.
|
||||
|
||||
3. **Fehlende Zuordnung klären.** Meldet der Bericht für eine Notiz, dass Buch oder
|
||||
Kapitel nicht existiert oder `bookstack_book` fehlt:
|
||||
|
||||
- Vorhandene Struktur zeigen: `python -m bookstack.hochladen --baum`
|
||||
- Dem Anwender die Regale und Bücher aus dieser Ausgabe zur Auswahl stellen und
|
||||
fragen, wo die Notiz landen soll. Nicht raten.
|
||||
- Die Antwort ins Frontmatter der Notiz schreiben (`bookstack_shelf`,
|
||||
`bookstack_book`, `bookstack_chapter`).
|
||||
- Soll etwas Neues angelegt werden, den Anwender das ausdrücklich bestätigen lassen
|
||||
und dann `--struktur-anlegen` ergänzen.
|
||||
|
||||
4. **Bericht vorlegen und Freigabe einholen.** Erst nach ausdrücklichem „ja" schreiben:
|
||||
|
||||
```
|
||||
python -m bookstack.hochladen --anwenden
|
||||
```
|
||||
|
||||
5. **Ergebnis melden.** Was wurde angelegt, was aktualisiert, was übersprungen. Hinweise
|
||||
zu nicht übertragenen Bildern und nicht auflösbaren Wikilinks (offene Verweise)
|
||||
weitergeben.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- `--anwenden` niemals ungefragt ausführen. Der Upload ist nach außen sichtbar.
|
||||
- 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.
|
||||
Whitespace-only changes.
@@ -0,0 +1,50 @@
|
||||
"""Entscheidet je Notiz, ob sie angelegt, aktualisiert oder übersprungen wird."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from bookstack.notiz import Notiz
|
||||
|
||||
|
||||
class Aktion(Enum):
|
||||
ANLEGEN = "anlegen"
|
||||
AKTUALISIEREN = "aktualisieren"
|
||||
UEBERSPRINGEN = "überspringen"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Entscheidung:
|
||||
aktion: Aktion
|
||||
seiten_id: int | None
|
||||
pruefsumme: str
|
||||
begruendung: str
|
||||
|
||||
|
||||
def berechne_pruefsumme(
|
||||
titel: str, markdown: str, buch_id: int, kapitel_id: int | None
|
||||
) -> str:
|
||||
"""Deckt Inhalt *und* Ablageort ab — ein verschobenes Ziel gilt als Änderung."""
|
||||
roh = "\x00".join([titel, str(buch_id), str(kapitel_id or 0), markdown])
|
||||
return "sha256:" + hashlib.sha256(roh.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def entscheide(notiz: Notiz, pruefsumme: str) -> Entscheidung:
|
||||
roh_id = notiz.frontmatter.get("bookstack_page_id", "").strip()
|
||||
if not roh_id:
|
||||
return Entscheidung(Aktion.ANLEGEN, None, pruefsumme, "noch nie hochgeladen")
|
||||
|
||||
try:
|
||||
seiten_id = int(roh_id)
|
||||
except ValueError:
|
||||
return Entscheidung(
|
||||
Aktion.ANLEGEN, None, pruefsumme, f"bookstack_page_id unlesbar: {roh_id!r}"
|
||||
)
|
||||
|
||||
if notiz.frontmatter.get("bookstack_hash", "").strip() == pruefsumme:
|
||||
return Entscheidung(
|
||||
Aktion.UEBERSPRINGEN, seiten_id, pruefsumme, "unverändert seit letztem Upload"
|
||||
)
|
||||
return Entscheidung(Aktion.AKTUALISIEREN, seiten_id, pruefsumme, "Inhalt hat sich geändert")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Schmaler HTTP-Client für die BookStack-API — Standardbibliothek, kein requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
SEITENGROESSE = 500
|
||||
WIEDERHOLUNGEN_BEI_429 = 2
|
||||
# Ohne eigene Kennung schickt urllib "Python-urllib/3.x". Cloudflare und ähnliche
|
||||
# Schutzschichten vor BookStack-Instanzen beantworten das mit 403, noch bevor die
|
||||
# API das Token überhaupt sieht. Gefunden beim ersten Lauf gegen die echte Instanz.
|
||||
KENNUNG = "bookstack-upload/1.0 (Claude Code Skill)"
|
||||
|
||||
|
||||
class ApiFehler(Exception):
|
||||
def __init__(self, status: int, meldung: str, pfad: str) -> None:
|
||||
super().__init__(f"BookStack antwortete {status} auf {pfad}: {meldung}")
|
||||
self.status = status
|
||||
self.meldung = meldung
|
||||
self.pfad = pfad
|
||||
|
||||
|
||||
def _baue_multipart(
|
||||
felder: dict[str, str], datei: tuple[str, str, bytes, str]
|
||||
) -> tuple[bytes, str]:
|
||||
"""Baut einen multipart/form-data-Körper von Hand — die Standardbibliothek kann das nicht."""
|
||||
grenze = "----bookstackupload" + uuid.uuid4().hex
|
||||
teile: list[bytes] = []
|
||||
for name, wert in felder.items():
|
||||
teile.append(
|
||||
f'--{grenze}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{wert}\r\n'.encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
feldname, dateiname, inhalt, mime = datei
|
||||
teile.append(
|
||||
(
|
||||
f"--{grenze}\r\n"
|
||||
f'Content-Disposition: form-data; name="{feldname}"; filename="{dateiname}"\r\n'
|
||||
f"Content-Type: {mime}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
+ inhalt
|
||||
+ b"\r\n"
|
||||
)
|
||||
teile.append(f"--{grenze}--\r\n".encode("utf-8"))
|
||||
return b"".join(teile), f"multipart/form-data; boundary={grenze}"
|
||||
|
||||
|
||||
class BookStackApi:
|
||||
def __init__(
|
||||
self,
|
||||
basis_url: str,
|
||||
token_id: str,
|
||||
token_secret: str,
|
||||
*,
|
||||
mindestabstand: float = 0.34,
|
||||
schlaefer: Callable[[float], None] = time.sleep,
|
||||
uhr: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self.basis_url = basis_url.rstrip("/")
|
||||
self._autorisierung = f"Token {token_id}:{token_secret}"
|
||||
self._mindestabstand = mindestabstand
|
||||
self._schlaefer = schlaefer
|
||||
self._uhr = uhr
|
||||
self._letzter_aufruf: float | None = None
|
||||
|
||||
def _drossle(self) -> None:
|
||||
jetzt = self._uhr()
|
||||
if self._letzter_aufruf is not None:
|
||||
rest = self._mindestabstand - (jetzt - self._letzter_aufruf)
|
||||
if rest > 0:
|
||||
self._schlaefer(rest)
|
||||
self._letzter_aufruf = self._uhr()
|
||||
|
||||
def _einmal(self, methode: str, pfad: str, koerper: bytes | None, inhaltstyp: str | None) -> dict:
|
||||
self._drossle()
|
||||
anfrage = urllib.request.Request(self.basis_url + pfad, data=koerper, method=methode)
|
||||
anfrage.add_header("Authorization", self._autorisierung)
|
||||
anfrage.add_header("Accept", "application/json")
|
||||
anfrage.add_header("User-Agent", KENNUNG)
|
||||
if inhaltstyp is not None:
|
||||
anfrage.add_header("Content-Type", inhaltstyp)
|
||||
try:
|
||||
with urllib.request.urlopen(anfrage, timeout=60) as antwort:
|
||||
roh = antwort.read()
|
||||
except urllib.error.HTTPError as fehler:
|
||||
roh_fehler = fehler.read()
|
||||
try:
|
||||
meldung = json.loads(roh_fehler)["error"]["message"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
meldung = str(fehler.reason)
|
||||
raise ApiFehler(fehler.code, meldung, pfad) from None
|
||||
except urllib.error.URLError as fehler:
|
||||
raise ApiFehler(0, f"nicht erreichbar ({fehler.reason})", pfad) from None
|
||||
return json.loads(roh) if roh else {}
|
||||
|
||||
def _mit_wiederholung(
|
||||
self, methode: str, pfad: str, koerper: bytes | None, inhaltstyp: str | None
|
||||
) -> dict:
|
||||
for versuch in range(WIEDERHOLUNGEN_BEI_429 + 1):
|
||||
try:
|
||||
return self._einmal(methode, pfad, koerper, inhaltstyp)
|
||||
except ApiFehler as fehler:
|
||||
if fehler.status != 429 or versuch == WIEDERHOLUNGEN_BEI_429:
|
||||
raise
|
||||
self._schlaefer(2.0 * (versuch + 1))
|
||||
raise AssertionError("unerreichbar")
|
||||
|
||||
def anfrage(self, methode: str, pfad: str, daten: dict | None = None) -> dict:
|
||||
"""Ein JSON-API-Aufruf. Bei 429 wird bis zu zweimal nachgefasst."""
|
||||
if daten is None:
|
||||
return self._mit_wiederholung(methode, pfad, None, None)
|
||||
return self._mit_wiederholung(
|
||||
methode, pfad, json.dumps(daten).encode("utf-8"), "application/json"
|
||||
)
|
||||
|
||||
def sende_formular(
|
||||
self, pfad: str, felder: dict[str, str], datei: tuple[str, str, bytes, str]
|
||||
) -> dict:
|
||||
"""POST als multipart/form-data — nötig für die Bildergalerie."""
|
||||
koerper, inhaltstyp = _baue_multipart(felder, datei)
|
||||
return self._mit_wiederholung("POST", pfad, koerper, inhaltstyp)
|
||||
|
||||
def liste(self, pfad: str) -> list[dict]:
|
||||
"""Holt alle Seiten einer Sammlung und gibt sie als eine Liste zurück."""
|
||||
eintraege: list[dict] = []
|
||||
versatz = 0
|
||||
trenner = "&" if "?" in pfad else "?"
|
||||
while True:
|
||||
antwort = self.anfrage(
|
||||
"GET", f"{pfad}{trenner}count={SEITENGROESSE}&offset={versatz}"
|
||||
)
|
||||
teil = antwort.get("data", [])
|
||||
eintraege.extend(teil)
|
||||
versatz += len(teil)
|
||||
if not teil or versatz >= int(antwort.get("total", len(eintraege))):
|
||||
return eintraege
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Findet im Quellordner die Notizen, die zum Upload freigegeben sind."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.notiz import Notiz, lade_notiz
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def ist_freigegeben(notiz: Notiz) -> bool:
|
||||
"""Freigegeben heißt: #review-done vorhanden und kein offenes #review mehr."""
|
||||
return bool(FREIGABE.search(notiz.roh)) and not OFFEN.search(notiz.roh)
|
||||
|
||||
|
||||
def finde_kandidaten(quelle: Path) -> list[Notiz]:
|
||||
if not quelle.is_dir():
|
||||
return []
|
||||
gefunden: list[Notiz] = []
|
||||
for pfad in sorted(quelle.rglob("*.md")):
|
||||
if pfad.name.startswith((".", "_")):
|
||||
continue
|
||||
notiz = lade_notiz(pfad)
|
||||
if ist_freigegeben(notiz):
|
||||
gefunden.append(notiz)
|
||||
return gefunden
|
||||
|
||||
|
||||
def titel_von(notiz: Notiz) -> str:
|
||||
"""Erste H1, sonst Frontmatter-title, sonst Dateiname."""
|
||||
treffer = UEBERSCHRIFT.search(notiz.rumpf)
|
||||
if treffer:
|
||||
titel = treffer.group(1).strip()
|
||||
else:
|
||||
titel = notiz.frontmatter.get("title", "").strip() or notiz.pfad.stem
|
||||
return titel[:MAX_TITEL]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Findet Bilder im Vault und lädt sie in die BookStack-Bildergalerie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.api import ApiFehler, BookStackApi
|
||||
|
||||
# In D:\Claude\skills\ liegt ein grosses Fremd-Repo — laut CLAUDE.md haengt sich
|
||||
# ein rekursiver Lauf dort praktisch endlos auf. Diese Ordner werden uebersprungen.
|
||||
UEBERSPRINGEN = {"skills", ".git", ".venv", "node_modules", ".obsidian", "__pycache__", ".pytest_cache"}
|
||||
|
||||
TYPEN = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".avif": "image/avif",
|
||||
}
|
||||
# Die API-Doku nennt „max 50KB"; das ist mit hoher Wahrscheinlichkeit die
|
||||
# Laravel-Regel max:50000, die bei Dateien in Kilobyte rechnet (~48,8 MB).
|
||||
# Deshalb hier eine Grenze knapp darunter — die echte Ablehnung der API wird
|
||||
# ohnehin woertlich weitergereicht.
|
||||
MAX_BYTES = 48 * 1024 * 1024
|
||||
|
||||
|
||||
class BildFehler(Exception):
|
||||
"""Ein Bild konnte nicht übertragen werden."""
|
||||
|
||||
|
||||
def finde_bilddatei(vault: Path, notiz_pfad: Path, name: str) -> Path | None:
|
||||
"""Sucht erst neben der Notiz, dann in der Vault-Wurzel, dann im ganzen Vault."""
|
||||
for kandidat in (notiz_pfad.parent / name, vault / name):
|
||||
if kandidat.is_file():
|
||||
return kandidat
|
||||
|
||||
for wurzel, ordner, dateien in os.walk(vault):
|
||||
ordner[:] = [eintrag for eintrag in ordner if eintrag not in UEBERSPRINGEN]
|
||||
if name in dateien:
|
||||
return Path(wurzel) / name
|
||||
return None
|
||||
|
||||
|
||||
class Bilduploader:
|
||||
def __init__(self, api: BookStackApi, vault: Path) -> None:
|
||||
self._api = api
|
||||
self._vault = vault
|
||||
|
||||
def _vorhandene(self, seiten_id: int) -> dict[str, str]:
|
||||
"""Bereits an dieser Seite hängende Galeriebilder, Name → URL."""
|
||||
try:
|
||||
eintraege = self._api.liste(f"/api/image-gallery?filter[uploaded_to]={seiten_id}")
|
||||
except ApiFehler:
|
||||
return {}
|
||||
return {str(eintrag.get("name", "")): str(eintrag.get("url", "")) for eintrag in eintraege}
|
||||
|
||||
def lade_hoch(
|
||||
self, notiz_pfad: Path, seiten_id: int, namen: list[str]
|
||||
) -> tuple[dict[str, str], list[str]]:
|
||||
"""Liefert (Name → URL) und eine Liste von Hinweisen für den Bericht."""
|
||||
tabelle: dict[str, str] = {}
|
||||
hinweise: list[str] = []
|
||||
if not namen:
|
||||
return tabelle, hinweise
|
||||
|
||||
vorhanden = self._vorhandene(seiten_id)
|
||||
|
||||
for name in dict.fromkeys(namen):
|
||||
if name in vorhanden and vorhanden[name]:
|
||||
tabelle[name] = vorhanden[name]
|
||||
continue
|
||||
|
||||
pfad = finde_bilddatei(self._vault, notiz_pfad, name)
|
||||
if pfad is None:
|
||||
hinweise.append(f"Bild nicht gefunden im Vault: {name}")
|
||||
continue
|
||||
|
||||
mime = TYPEN.get(pfad.suffix.lower())
|
||||
if mime is None:
|
||||
hinweise.append(f"Bildtyp nicht unterstützt: {name}")
|
||||
continue
|
||||
|
||||
groesse = pfad.stat().st_size
|
||||
if groesse > MAX_BYTES:
|
||||
hinweise.append(f"Bild zu groß ({groesse // (1024 * 1024)} MB): {name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
antwort = self._api.sende_formular(
|
||||
"/api/image-gallery",
|
||||
{"type": "gallery", "uploaded_to": str(seiten_id), "name": name},
|
||||
("image", name, pfad.read_bytes(), mime),
|
||||
)
|
||||
except ApiFehler as fehler:
|
||||
hinweise.append(f"Bild abgelehnt ({name}): {fehler.meldung}")
|
||||
continue
|
||||
|
||||
url = str(antwort.get("url", ""))
|
||||
if url:
|
||||
tabelle[name] = url
|
||||
else:
|
||||
hinweise.append(f"Bild ohne URL zurückgekommen: {name}")
|
||||
|
||||
return tabelle, hinweise
|
||||
@@ -0,0 +1,228 @@
|
||||
"""CLI: findet freigegebene Notizen und spielt sie nach BookStack."""
|
||||
|
||||
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.bilder import Bilduploader
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ergebnis:
|
||||
pfad: Path
|
||||
aktion: str
|
||||
text: str
|
||||
|
||||
|
||||
def _stelle_ausgabe_um() -> None:
|
||||
"""Windows-Konsolen laufen oft unter cp1252 — „→" bricht den Lauf sonst ab.
|
||||
|
||||
Gefunden beim Lauf mit echten Daten: pytest fängt stdout in UTF-8 ab, deshalb
|
||||
bleibt der Fehler in der Testsuite unsichtbar und schlägt erst beim Anwender zu.
|
||||
"""
|
||||
for strom in (sys.stdout, sys.stderr):
|
||||
umschalten = getattr(strom, "reconfigure", None)
|
||||
if umschalten is None:
|
||||
continue
|
||||
try:
|
||||
umschalten(encoding="utf-8", errors="replace")
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _argumente(argumente: list[str]) -> argparse.Namespace:
|
||||
zerleger = argparse.ArgumentParser(
|
||||
prog="bookstack.hochladen",
|
||||
description="Lädt freigegebene Vault-Notizen (#review-done) nach BookStack.",
|
||||
)
|
||||
zerleger.add_argument("--anwenden", action="store_true", help="tatsächlich schreiben")
|
||||
zerleger.add_argument(
|
||||
"--struktur-anlegen", action="store_true", help="fehlende Regale/Bücher/Kapitel anlegen"
|
||||
)
|
||||
zerleger.add_argument("--nur", type=Path, help="nur diese eine Datei verarbeiten")
|
||||
zerleger.add_argument("--baum", action="store_true", help="nur die BookStack-Struktur zeigen")
|
||||
return zerleger.parse_args(argumente)
|
||||
|
||||
|
||||
def verarbeite(
|
||||
notiz: Notiz,
|
||||
aufloeser: Strukturaufloeser,
|
||||
api: BookStackApi,
|
||||
*,
|
||||
anwenden: bool,
|
||||
verweise: dict[str, str],
|
||||
bilduploader: Bilduploader,
|
||||
) -> Ergebnis:
|
||||
titel = titel_von(notiz)
|
||||
rumpf = ohne_titelueberschrift(notiz.rumpf, titel)
|
||||
umwandlung = konvertiere(rumpf, verweise=verweise)
|
||||
ziel = aufloeser.aufloesen(
|
||||
notiz.frontmatter.get("bookstack_shelf"),
|
||||
notiz.frontmatter.get("bookstack_book", ""),
|
||||
notiz.frontmatter.get("bookstack_chapter"),
|
||||
)
|
||||
# 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)
|
||||
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}"
|
||||
for angelegt in ziel.angelegt:
|
||||
beschreibung += f"\n Angelegt: {angelegt}"
|
||||
|
||||
if entscheidung.aktion is Aktion.UEBERSPRINGEN:
|
||||
return Ergebnis(notiz.pfad, "ÜBERSPRUNGEN", entscheidung.begruendung)
|
||||
if not anwenden:
|
||||
for name in umwandlung.bildnamen:
|
||||
beschreibung += f"\n Bild würde hochgeladen: {name}"
|
||||
return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung)
|
||||
|
||||
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
|
||||
|
||||
if entscheidung.aktion is Aktion.AKTUALISIEREN:
|
||||
try:
|
||||
antwort = api.anfrage(
|
||||
"PUT", f"/api/pages/{entscheidung.seiten_id}", nutzlast(umwandlung.markdown)
|
||||
)
|
||||
except ApiFehler as fehler:
|
||||
if fehler.status != 404:
|
||||
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."
|
||||
else:
|
||||
antwort = api.anfrage("POST", "/api/pages", nutzlast(umwandlung.markdown))
|
||||
|
||||
seiten_id = int(antwort["id"])
|
||||
|
||||
if umwandlung.bildnamen:
|
||||
tabelle, 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)
|
||||
api.anfrage("PUT", f"/api/pages/{seiten_id}", nutzlast(zweiter.markdown))
|
||||
beschreibung += f"\n Bilder übertragen: {len(tabelle)}"
|
||||
|
||||
schreibe_felder(
|
||||
notiz.pfad,
|
||||
{
|
||||
"bookstack_page_id": str(seiten_id),
|
||||
"bookstack_hash": pruefsumme,
|
||||
"bookstack_synced": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||
},
|
||||
)
|
||||
return Ergebnis(notiz.pfad, entscheidung.aktion.name, beschreibung)
|
||||
|
||||
|
||||
def fuehre_aus(argumente: list[str]) -> int:
|
||||
_stelle_ausgabe_um()
|
||||
einstellungen = _argumente(argumente)
|
||||
try:
|
||||
konfiguration = lade_konfiguration()
|
||||
except KonfigurationsFehler as fehler:
|
||||
print(f"Konfigurationsfehler: {fehler}")
|
||||
return 1
|
||||
|
||||
api = BookStackApi(
|
||||
konfiguration.basis_url, konfiguration.token_id, konfiguration.token_secret
|
||||
)
|
||||
aufloeser = Strukturaufloeser(
|
||||
api,
|
||||
anlegen_erlaubt=einstellungen.struktur_anlegen,
|
||||
nur_planen=not einstellungen.anwenden,
|
||||
)
|
||||
bilduploader = Bilduploader(api, konfiguration.vault)
|
||||
|
||||
if einstellungen.baum:
|
||||
try:
|
||||
print(aufloeser.baum())
|
||||
except ApiFehler as fehler:
|
||||
print(f"Fehler: {fehler}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if einstellungen.nur:
|
||||
notiz = lade_notiz(einstellungen.nur)
|
||||
kandidaten = [notiz] if ist_freigegeben(notiz) else []
|
||||
if not kandidaten:
|
||||
print(f"{einstellungen.nur} trägt kein #review-done — nichts zu tun.")
|
||||
return 0
|
||||
else:
|
||||
kandidaten = finde_kandidaten(konfiguration.quelle)
|
||||
|
||||
kopf = "BookStack-Upload" + ("" if einstellungen.anwenden else " (Probelauf)")
|
||||
print(f"{kopf} — Quelle: {konfiguration.quelle}")
|
||||
if not kandidaten:
|
||||
print("Keine Notiz mit #review-done gefunden.")
|
||||
return 0
|
||||
|
||||
verweise = baue_index(konfiguration.quelle, konfiguration.basis_url)
|
||||
|
||||
ergebnisse: list[Ergebnis] = []
|
||||
fehler_anzahl = 0
|
||||
for notiz in kandidaten:
|
||||
try:
|
||||
ergebnisse.append(
|
||||
verarbeite(
|
||||
notiz,
|
||||
aufloeser,
|
||||
api,
|
||||
anwenden=einstellungen.anwenden,
|
||||
verweise=verweise,
|
||||
bilduploader=bilduploader,
|
||||
)
|
||||
)
|
||||
except (StrukturFehler, ApiFehler, OSError) as fehler:
|
||||
fehler_anzahl += 1
|
||||
ergebnisse.append(Ergebnis(notiz.pfad, "FEHLER", str(fehler)))
|
||||
|
||||
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}")
|
||||
|
||||
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:
|
||||
print("Probelauf — nichts geschrieben. Mit --anwenden ausführen.")
|
||||
return 1 if fehler_anzahl else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return fuehre_aus(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Liest die BookStack-Zugangsdaten aus Umgebungsvariablen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
STANDARD_QUELLE = Path(r"D:\Claude\Bookstack")
|
||||
STANDARD_VAULT = Path(r"D:\Claude")
|
||||
PFLICHTVARIABLEN = ("BOOKSTACK_URL", "BOOKSTACK_TOKEN_ID", "BOOKSTACK_TOKEN_SECRET")
|
||||
|
||||
|
||||
class KonfigurationsFehler(Exception):
|
||||
"""Zugangsdaten fehlen oder sind unbrauchbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konfiguration:
|
||||
basis_url: str
|
||||
token_id: str
|
||||
token_secret: str = field(repr=False)
|
||||
quelle: Path
|
||||
vault: Path
|
||||
|
||||
|
||||
def lade_konfiguration(umgebung: Mapping[str, str] | None = None) -> Konfiguration:
|
||||
"""Baut die Konfiguration aus der Umgebung; meldet alle Lücken auf einmal."""
|
||||
werte = os.environ if umgebung is None else umgebung
|
||||
|
||||
fehlend = [name for name in PFLICHTVARIABLEN if not werte.get(name, "").strip()]
|
||||
if fehlend:
|
||||
raise KonfigurationsFehler(
|
||||
"Diese Umgebungsvariablen fehlen oder sind leer: "
|
||||
+ ", ".join(fehlend)
|
||||
+ ". Siehe README.md, Abschnitt „Einrichtung“."
|
||||
)
|
||||
|
||||
basis_url = werte["BOOKSTACK_URL"].strip().rstrip("/")
|
||||
if not basis_url.startswith(("http://", "https://")):
|
||||
raise KonfigurationsFehler(
|
||||
f"BOOKSTACK_URL muss mit http:// oder https:// beginnen, ist aber „{basis_url}“."
|
||||
)
|
||||
|
||||
quelle = werte.get("BOOKSTACK_QUELLE", "").strip()
|
||||
vault = werte.get("BOOKSTACK_VAULT", "").strip()
|
||||
return Konfiguration(
|
||||
basis_url=basis_url,
|
||||
token_id=werte["BOOKSTACK_TOKEN_ID"].strip(),
|
||||
token_secret=werte["BOOKSTACK_TOKEN_SECRET"].strip(),
|
||||
quelle=Path(quelle) if quelle else STANDARD_QUELLE,
|
||||
vault=Path(vault) if vault else STANDARD_VAULT,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Übersetzt Obsidian-Eigenheiten in Markdown, das BookStack versteht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
EINBETTUNG = re.compile(r"!\[\[([^\]|]+?)(?:\|[^\]]*)?\]\]")
|
||||
WIKILINK = re.compile(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]")
|
||||
REVIEW_TAG = re.compile(r"(?<![\w#-])#review(?:-done)?(?![\w-])[ \t]*")
|
||||
CALLOUT = re.compile(r"^>\s*\[!(\w+)\][ \t]*(.*)$", re.MULTILINE)
|
||||
ZAUN = re.compile(r"(```.*?```|~~~.*?~~~)", re.DOTALL)
|
||||
TITELZEILE = re.compile(r"^#\s+(.+?)[ \t]*$", re.MULTILINE)
|
||||
MAX_TITEL = 255
|
||||
|
||||
|
||||
@dataclass
|
||||
class Umwandlung:
|
||||
markdown: str
|
||||
hinweise: list[str] = field(default_factory=list)
|
||||
bildnamen: list[str] = field(default_factory=list)
|
||||
offene_verweise: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sammler:
|
||||
verweise: Mapping[str, str]
|
||||
bilder: Mapping[str, str]
|
||||
hinweise: list[str] = field(default_factory=list)
|
||||
bildnamen: list[str] = field(default_factory=list)
|
||||
offene_verweise: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _ersetze_ausserhalb_von_code(text: str, sammler: "Sammler") -> str:
|
||||
teile = ZAUN.split(text)
|
||||
for stelle, teil in enumerate(teile):
|
||||
if stelle % 2 == 1: # ungerade Indizes sind die Codeblöcke
|
||||
continue
|
||||
teile[stelle] = _ersetze(teil, sammler)
|
||||
return "".join(teile)
|
||||
|
||||
|
||||
def _ersetze(text: str, sammler: Sammler) -> str:
|
||||
def bild(treffer: re.Match[str]) -> str:
|
||||
name = treffer.group(1).strip()
|
||||
if name not in sammler.bildnamen:
|
||||
sammler.bildnamen.append(name)
|
||||
url = sammler.bilder.get(name)
|
||||
if url:
|
||||
return f""
|
||||
sammler.hinweise.append(f"Bild nicht übertragen: {name}")
|
||||
return f"*(Bild „{name}“ wurde nicht übertragen)*"
|
||||
|
||||
def link(treffer: re.Match[str]) -> str:
|
||||
ziel, alias = treffer.group(1), treffer.group(2)
|
||||
kurz = ziel.split("/")[-1].split("#")[0].strip()
|
||||
text_teil = (alias or kurz).strip()
|
||||
url = sammler.verweise.get(kurz.casefold())
|
||||
if url:
|
||||
return f"[{text_teil}]({url})"
|
||||
if kurz not in sammler.offene_verweise:
|
||||
sammler.offene_verweise.append(kurz)
|
||||
return text_teil
|
||||
|
||||
def callout(treffer: re.Match[str]) -> str:
|
||||
titel = treffer.group(2).strip() or treffer.group(1).capitalize()
|
||||
return f"> **{titel}**"
|
||||
|
||||
text = EINBETTUNG.sub(bild, text)
|
||||
text = WIKILINK.sub(link, text)
|
||||
text = CALLOUT.sub(callout, text)
|
||||
return REVIEW_TAG.sub("", text)
|
||||
|
||||
|
||||
def ohne_titelueberschrift(rumpf: str, titel: str) -> str:
|
||||
"""Entfernt die erste H1, wenn sie den Seitentitel wiederholt.
|
||||
|
||||
BookStack stellt den Seitennamen ohnehin als Überschrift dar. Bliebe die H1
|
||||
im Text stehen, stünde die Überschrift zweimal auf der Seite.
|
||||
Weicht die H1 vom Titel ab, bleibt sie unangetastet.
|
||||
"""
|
||||
treffer = TITELZEILE.search(rumpf)
|
||||
if treffer is None or treffer.group(1).strip()[:MAX_TITEL] != titel:
|
||||
return rumpf
|
||||
return rumpf[: treffer.start()] + rumpf[treffer.end() :].lstrip("\n")
|
||||
|
||||
|
||||
def konvertiere(
|
||||
rumpf: str,
|
||||
*,
|
||||
verweise: Mapping[str, str] | None = None,
|
||||
bilder: Mapping[str, str] | None = None,
|
||||
) -> Umwandlung:
|
||||
sammler = Sammler(verweise=verweise or {}, bilder=bilder or {})
|
||||
markdown = _ersetze_ausserhalb_von_code(rumpf, sammler)
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
|
||||
return Umwandlung(
|
||||
markdown=markdown,
|
||||
hinweise=sammler.hinweise,
|
||||
bildnamen=sammler.bildnamen,
|
||||
offene_verweise=sammler.offene_verweise,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Liest YAML-Frontmatter und schreibt einzelne Felder zurück, ohne den Rest anzufassen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
TRENNER = "---"
|
||||
SKALARE_ZEILE = re.compile(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Notiz:
|
||||
pfad: Path
|
||||
frontmatter: dict[str, str]
|
||||
rumpf: str
|
||||
roh: str
|
||||
|
||||
|
||||
def _zerlege(roh: str) -> tuple[list[str], str]:
|
||||
"""Liefert (Frontmatter-Zeilen ohne Trenner, Rumpf). Ohne Frontmatter: ([], roh)."""
|
||||
if not roh.startswith(TRENNER + "\n"):
|
||||
return [], roh
|
||||
rest = roh[len(TRENNER) + 1 :]
|
||||
ende = rest.find("\n" + TRENNER + "\n")
|
||||
if ende == -1:
|
||||
return [], roh
|
||||
kopf = rest[:ende].split("\n")
|
||||
rumpf = rest[ende + len(TRENNER) + 2 :]
|
||||
return kopf, rumpf.lstrip("\n")
|
||||
|
||||
|
||||
def _entpacke(wert: str) -> str:
|
||||
wert = wert.strip()
|
||||
if len(wert) >= 2 and wert[0] == wert[-1] and wert[0] in "\"'":
|
||||
return wert[1:-1]
|
||||
return wert
|
||||
|
||||
|
||||
def _verpacke(wert: str) -> str:
|
||||
"""Quotet nur, wo YAML es wirklich braucht.
|
||||
|
||||
Ein Doppelpunkt allein reicht nicht: „sha256:abc" und „2026-08-05T12:00:00+02:00"
|
||||
sind gültige einfache Skalare. Erst „Doppelpunkt plus Leerzeichen" beendet einen
|
||||
Schlüssel. Würde hier jeder Doppelpunkt quoten, stünde in jeder Notiz
|
||||
bookstack_hash: "sha256:…" statt bookstack_hash: sha256:…
|
||||
"""
|
||||
if not wert:
|
||||
return '""'
|
||||
unsicher = (
|
||||
": " in wert
|
||||
or wert.endswith(":")
|
||||
or " #" in wert
|
||||
or "\n" in wert
|
||||
or wert != wert.strip()
|
||||
or wert[0] in "\"'[]{}>|*&!%@`#"
|
||||
)
|
||||
if unsicher:
|
||||
return '"' + wert.replace('"', '\\"') + '"'
|
||||
return wert
|
||||
|
||||
|
||||
def lade_notiz(pfad: Path) -> Notiz:
|
||||
roh = pfad.read_text(encoding="utf-8")
|
||||
kopf, rumpf = _zerlege(roh)
|
||||
felder: dict[str, str] = {}
|
||||
for zeile in kopf:
|
||||
treffer = SKALARE_ZEILE.match(zeile)
|
||||
if treffer and not treffer.group(2).startswith("["):
|
||||
felder[treffer.group(1)] = _entpacke(treffer.group(2))
|
||||
return Notiz(pfad=pfad, frontmatter=felder, rumpf=rumpf, roh=roh)
|
||||
|
||||
|
||||
def setze_felder(roh: str, werte: dict[str, str]) -> str:
|
||||
"""Setzt oder ersetzt Frontmatter-Felder und lässt alles andere zeichengleich."""
|
||||
kopf, rumpf = _zerlege(roh)
|
||||
if not kopf and not roh.startswith(TRENNER + "\n"):
|
||||
neue = [f"{name}: {_verpacke(wert)}" for name, wert in werte.items()]
|
||||
return TRENNER + "\n" + "\n".join(neue) + "\n" + TRENNER + "\n" + roh
|
||||
|
||||
offen = dict(werte)
|
||||
ergebnis: list[str] = []
|
||||
for zeile in kopf:
|
||||
treffer = SKALARE_ZEILE.match(zeile)
|
||||
name = treffer.group(1) if treffer else None
|
||||
if name in offen:
|
||||
ergebnis.append(f"{name}: {_verpacke(offen.pop(name))}")
|
||||
else:
|
||||
ergebnis.append(zeile)
|
||||
ergebnis.extend(f"{name}: {_verpacke(wert)}" for name, wert in offen.items())
|
||||
|
||||
return TRENNER + "\n" + "\n".join(ergebnis) + "\n" + TRENNER + "\n\n" + rumpf
|
||||
|
||||
|
||||
def schreibe_felder(pfad: Path, werte: dict[str, str]) -> None:
|
||||
roh = pfad.read_text(encoding="utf-8")
|
||||
pfad.write_text(setze_felder(roh, werte), encoding="utf-8", newline="\n")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Löst Regal-, Buch- und Kapitelnamen in IDs auf und legt Fehlendes auf Wunsch an."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from bookstack.api import BookStackApi
|
||||
|
||||
|
||||
class StrukturFehler(Exception):
|
||||
"""Ein benanntes Regal, Buch oder Kapitel existiert nicht (und darf nicht angelegt werden)."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ziel:
|
||||
buch_id: int
|
||||
kapitel_id: int | None = None
|
||||
angelegt: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _passt(eintrag: dict, name: str) -> bool:
|
||||
return str(eintrag.get("name", "")).strip().casefold() == name.strip().casefold()
|
||||
|
||||
|
||||
class Strukturaufloeser:
|
||||
def __init__(
|
||||
self,
|
||||
api: BookStackApi,
|
||||
*,
|
||||
anlegen_erlaubt: bool = False,
|
||||
nur_planen: bool = False,
|
||||
) -> None:
|
||||
self._api = api
|
||||
self._anlegen_erlaubt = anlegen_erlaubt
|
||||
# Im Probelauf darf nichts entstehen. Fehlendes wird nur benannt.
|
||||
self._nur_planen = nur_planen
|
||||
self._speicher: dict[str, list[dict]] = {}
|
||||
|
||||
def _sammlung(self, name: str) -> list[dict]:
|
||||
if name not in self._speicher:
|
||||
self._speicher[name] = self._api.liste(f"/api/{name}")
|
||||
return self._speicher[name]
|
||||
|
||||
def _verwerfe(self, name: str) -> None:
|
||||
self._speicher.pop(name, None)
|
||||
|
||||
def _finde(self, sammlung: str, name: str, **filter_felder) -> dict | None:
|
||||
for eintrag in self._sammlung(sammlung):
|
||||
if _passt(eintrag, name) and all(
|
||||
eintrag.get(feld) == wert for feld, wert in filter_felder.items()
|
||||
):
|
||||
return eintrag
|
||||
return None
|
||||
|
||||
def _fehlt(self, art: str, name: str) -> StrukturFehler:
|
||||
return StrukturFehler(
|
||||
f"{art} „{name}“ existiert in BookStack nicht. "
|
||||
"Namen im Frontmatter prüfen oder mit --struktur-anlegen erneut starten."
|
||||
)
|
||||
|
||||
def _vermerke(self, art: str, name: str) -> str:
|
||||
"""Beschreibt eine Neuanlage — im Probelauf als Absicht, sonst als Tatsache."""
|
||||
zusatz = " (wird angelegt)" if self._nur_planen else ""
|
||||
return f'{art} "{name.strip()}"{zusatz}'
|
||||
|
||||
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:
|
||||
return
|
||||
self._api.anfrage(
|
||||
"POST", "/api/shelves", {"name": regal_name.strip(), "books": [buch_id]}
|
||||
)
|
||||
self._verwerfe("shelves")
|
||||
return
|
||||
|
||||
vollstaendig = self._api.anfrage("GET", f"/api/shelves/{regal['id']}")
|
||||
# Lesen liefert Buchobjekte, Schreiben erwartet reine IDs. Beide Formen dulden.
|
||||
vorhandene = [
|
||||
int(eintrag["id"] if isinstance(eintrag, dict) else eintrag)
|
||||
for eintrag in vollstaendig.get("books", [])
|
||||
]
|
||||
if buch_id in vorhandene or self._nur_planen:
|
||||
return
|
||||
self._api.anfrage(
|
||||
"PUT", f"/api/shelves/{regal['id']}", {"books": vorhandene + [buch_id]}
|
||||
)
|
||||
|
||||
def aufloesen(self, regal: str | None, buch: str, kapitel: str | None) -> Ziel:
|
||||
if not buch or not buch.strip():
|
||||
raise StrukturFehler("Feld „bookstack_book“ fehlt oder ist leer.")
|
||||
|
||||
angelegt: list[str] = []
|
||||
gefunden = self._finde("books", buch)
|
||||
if gefunden is None:
|
||||
if not self._anlegen_erlaubt:
|
||||
raise self._fehlt("Buch", buch)
|
||||
angelegt.append(self._vermerke("Buch", buch))
|
||||
if self._nur_planen:
|
||||
gefunden = {"id": 0}
|
||||
else:
|
||||
gefunden = self._api.anfrage("POST", "/api/books", {"name": buch.strip()})
|
||||
self._verwerfe("books")
|
||||
buch_id = int(gefunden["id"])
|
||||
|
||||
if regal and regal.strip():
|
||||
self._haenge_an_regal(regal, buch_id, angelegt)
|
||||
|
||||
kapitel_id: int | None = None
|
||||
if kapitel and kapitel.strip():
|
||||
treffer = self._finde("chapters", kapitel, book_id=buch_id)
|
||||
if treffer is None:
|
||||
if not self._anlegen_erlaubt:
|
||||
raise self._fehlt("Kapitel", kapitel)
|
||||
angelegt.append(self._vermerke("Kapitel", kapitel))
|
||||
if self._nur_planen:
|
||||
treffer = {"id": 0}
|
||||
else:
|
||||
treffer = self._api.anfrage(
|
||||
"POST", "/api/chapters", {"book_id": buch_id, "name": kapitel.strip()}
|
||||
)
|
||||
self._verwerfe("chapters")
|
||||
kapitel_id = int(treffer["id"])
|
||||
|
||||
return Ziel(buch_id=buch_id, kapitel_id=kapitel_id, angelegt=angelegt)
|
||||
|
||||
def baum(self) -> str:
|
||||
"""Menschenlesbarer Überblick über die vorhandene Struktur."""
|
||||
buecher = {int(eintrag["id"]): eintrag for eintrag in self._sammlung("books")}
|
||||
kapitel: dict[int, list[str]] = {}
|
||||
for eintrag in self._sammlung("chapters"):
|
||||
kapitel.setdefault(int(eintrag.get("book_id", 0)), []).append(str(eintrag["name"]))
|
||||
|
||||
zeilen: list[str] = []
|
||||
zugeordnet: set[int] = set()
|
||||
for regal in self._sammlung("shelves"):
|
||||
zeilen.append(f"Regal: {regal['name']}")
|
||||
vollstaendig = self._api.anfrage("GET", f"/api/shelves/{regal['id']}")
|
||||
for eintrag in vollstaendig.get("books", []):
|
||||
buch_id = int(eintrag["id"])
|
||||
zugeordnet.add(buch_id)
|
||||
zeilen.append(f" Buch: {buecher.get(buch_id, eintrag)['name']}")
|
||||
zeilen.extend(f" Kapitel: {name}" for name in kapitel.get(buch_id, []))
|
||||
|
||||
frei = [wert for schluessel, wert in buecher.items() if schluessel not in zugeordnet]
|
||||
if frei:
|
||||
zeilen.append("Ohne Regal:")
|
||||
for eintrag in frei:
|
||||
zeilen.append(f" Buch: {eintrag['name']}")
|
||||
zeilen.extend(
|
||||
f" Kapitel: {name}" for name in kapitel.get(int(eintrag["id"]), [])
|
||||
)
|
||||
return "\n".join(zeilen)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Baut den Index bereits hochgeladener Notizen für die Wikilink-Auflösung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bookstack.notiz import lade_notiz
|
||||
|
||||
|
||||
def permalink(basis_url: str, seiten_id: int) -> str:
|
||||
"""Stabiler Seitenlink.
|
||||
|
||||
BookStack löst `/link/{id}` auf die Seite auf. Das überlebt Umbenennungen,
|
||||
anders als der Weg über Buch- und Seiten-Slug — und `GET /api/pages/{id}`
|
||||
liefert `book_slug` ohnehin nicht mit.
|
||||
"""
|
||||
return f"{basis_url.rstrip('/')}/link/{seiten_id}"
|
||||
|
||||
|
||||
def baue_index(quelle: Path, basis_url: str) -> dict[str, str]:
|
||||
"""Dateiname ohne Endung (kleingeschrieben) → Permalink."""
|
||||
if not quelle.is_dir():
|
||||
return {}
|
||||
index: dict[str, str] = {}
|
||||
for pfad in sorted(quelle.rglob("*.md")):
|
||||
if pfad.name.startswith((".", "_")):
|
||||
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
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: plan-executer-pruefung
|
||||
description: Die Standardschleife für nicht triviale Umsetzungsaufgaben — klären, planen, vom Executer-Subagent umsetzen lassen, selbst gegenprüfen, Befunde im Plan korrigieren, nachziehen lassen. Nutze diesen Skill, wenn ein Feature gebaut, ein Refactoring umgesetzt, eine mehrteilige Änderung ausgeführt oder ein bestehender Plan abgearbeitet werden soll, und immer dann, wenn von Executer, Subagent, Delegieren, Plan abarbeiten oder Gegenprüfen die Rede ist. Nicht für Einzeiler und nicht für offene Recherche.
|
||||
---
|
||||
|
||||
# Standardschleife: Planen — Ausführen — Gegenprüfen
|
||||
|
||||
Rollen sind fest verteilt: **du (Opus) denkst und prüfst, der Executer tippt.** Vertausche das
|
||||
nicht. Du schreibst keinen Produktivcode, solange die Schleife läuft; er trifft keine
|
||||
Entscheidungen.
|
||||
|
||||
```
|
||||
1 Klären → 2 Planen → 3 Ausführen → 4 Gegenprüfen → 5 Korrigieren → 6 Abschließen
|
||||
du du Executer du du du
|
||||
↑ │
|
||||
└───── Befund ────┘
|
||||
```
|
||||
|
||||
## Bevor du startest
|
||||
|
||||
**Lies `AI/Regelwerk/codeworkflow.md`, Abschnitt „Standardschleife", vollständig.** Dort steht
|
||||
die verbindliche Fassung jedes Schritts: Planaufteilung ab drei Tasks, Modellwahl je Taskdatei,
|
||||
das Pflichtprogramm der Gegenprüfung, fortsetzen oder frisch starten bei der Nachbesserung.
|
||||
|
||||
Diese Datei wiederholt das bewusst nicht. Eine zweite Fassung derselben Regel läuft aus dem
|
||||
Ruder, sobald eine von beiden gepflegt wird — und die Kopie gewinnt, weil sie zuerst geladen
|
||||
wird.
|
||||
|
||||
Danach je nach Aufgabe zusätzlich:
|
||||
|
||||
- `AI/Regelwerk/codedoku.md`, sobald Produktivdateien entstehen oder angefasst werden.
|
||||
- `AI/Regelwerk/pruefwerkzeuge.md`, sobald ein Skript entsteht, das etwas **beurteilt** statt
|
||||
etwas zu tun — und zwar vor der ersten Zeile Code.
|
||||
|
||||
**Nur für Claude Fable 5.1:** Läuft diese Sitzung auf Fable 5.1, lies dazu die Abschnitte 3, 8 und
|
||||
15 in `AI/Regelwerk/fable-5-1.md`. Andere Modelle entscheiden selbst.
|
||||
|
||||
## Die zwei Dinge, die du nie delegierst
|
||||
|
||||
1. **Die Gegenprüfung in Schritt 4.** Ein Agentenbericht ist eine Behauptung, kein Beleg. Du
|
||||
wiederholst den Testlauf selbst, liest die zentralen Artefakte selbst und fährst einen
|
||||
eigenen Lauf mit echten Daten und eigenem Prüfcode. Der `pruefer`-Subagent (`opus`)
|
||||
verdoppelt das, er ersetzt es nicht.
|
||||
2. **Den Commit.** Subagenten committen nie — paralleles `git commit` kollidiert am Index-Lock.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Einzeiler, Tippfehler, offensichtliche Korrekturen: selbst machen. Der Auftragstext kostet
|
||||
mehr als die Änderung.
|
||||
- Offene Recherche, Architekturentscheidungen, Debugging ohne Hypothese: kein Executer. Erst
|
||||
klären und planen.
|
||||
|
||||
Melde nichts als fertig, was du nicht selbst hast laufen sehen. Was offen bleibt, wird offen
|
||||
benannt — nicht weggelassen.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: plane-abgleich
|
||||
description: Gleicht die Checkbox-Aufgaben aus todo.md der Vault-Code-Projekte mit den Work Items der Plane-Instanz ab — in beide Richtungen, mit Konflikterkennung. Nutze diesen Skill, wenn von Plane, Work Items, Issues, Aufgabenabgleich, „todo nach Plane" oder „was ist in Plane offen" die Rede ist.
|
||||
---
|
||||
|
||||
# Plane-Abgleich
|
||||
|
||||
Gleicht `todo.md` und Plane in beide Richtungen ab.
|
||||
|
||||
## Voraussetzungen prüfen
|
||||
|
||||
Setze `PLANE_URL`, `PLANE_API_KEY` und `PLANE_WORKSPACE` voraus. Fehlen sie, meldet das
|
||||
Skript das verständlich — gib die Meldung weiter und brich ab. Frage niemals nach dem
|
||||
Schlüssel und schreibe ihn nirgends hin.
|
||||
|
||||
Arbeitsverzeichnis: `D:\Claude\Code Projekte\active - Plane Abgleich\skill\scripts`
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. **Immer zuerst der Probelauf.** Ohne `--anwenden` schreibt nichts:
|
||||
|
||||
```
|
||||
python -m plane.cli
|
||||
```
|
||||
|
||||
2. **Bericht lesen.** Je Aufgabe ANLEGEN, NACH PLANE, NACH VAULT, ANHAENGEN, ZEILE WEG,
|
||||
ITEM WEG oder KONFLIKT.
|
||||
|
||||
3. **Fehlende Zuordnung klären.** Meldet der Bericht für ein Projekt, dass `plane_project`
|
||||
fehlt:
|
||||
|
||||
- Plane-Projekte zeigen: `python -m plane.cli --projekte`
|
||||
- Dem Anwender die Liste vorlegen und fragen, welches Projekt gemeint ist. Nicht raten.
|
||||
- Gibt es kein passendes, anlegen lassen — erst nach ausdrücklicher Zustimmung:
|
||||
`python -m plane.cli --projekt-anlegen "Name" --kennung KRZ --anwenden`
|
||||
- Die UUID ins Frontmatter der `todo.md` schreiben.
|
||||
|
||||
4. **Konflikte vorlegen.** Bei jedem KONFLIKT beide Fassungen zeigen und fragen, welche gilt.
|
||||
Nicht selbst entscheiden.
|
||||
|
||||
5. **Freigabe einholen und anwenden.** Erst nach ausdrücklichem „ja":
|
||||
|
||||
```
|
||||
python -m plane.cli --anwenden --konflikt PLA-42=vault
|
||||
```
|
||||
|
||||
6. **Ergebnis melden.** Was angelegt, was in welche Richtung übertragen, was offen blieb.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- `--anwenden` niemals ungefragt ausführen. Es verändert Plane für alle Beteiligten.
|
||||
- Konflikte ohne Entscheidung bleiben liegen — das ist beabsichtigt, nicht umgehen.
|
||||
- Es wird nie gelöscht: keine Zeile, kein Work Item, kein Projekt. Entfernte Zeilen und
|
||||
entfernte Work Items werden nur gemeldet.
|
||||
- Projekte werden nur auf ausdrückliche Anforderung angelegt, nie nebenbei im Abgleich.
|
||||
@@ -0,0 +1 @@
|
||||
"""Plane-Abgleich."""
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Drei-Wege-Vergleich zwischen todo.md, Plane und dem Stand des letzten Laufs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
ANLEGEN = "ANLEGEN"
|
||||
NACH_PLANE = "NACH_PLANE"
|
||||
NACH_VAULT = "NACH_VAULT"
|
||||
KONFLIKT = "KONFLIKT"
|
||||
ANHAENGEN = "ANHAENGEN"
|
||||
ZEILE_ENTFERNT = "ZEILE_ENTFERNT"
|
||||
ITEM_ENTFERNT = "ITEM_ENTFERNT"
|
||||
UNVERAENDERT = "UNVERAENDERT"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Aufgabe:
|
||||
"""Eine Checkbox-Zeile aus todo.md."""
|
||||
|
||||
zeilennummer: int
|
||||
text: str
|
||||
erledigt: bool
|
||||
marker: str | None
|
||||
einrueckung: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkItem:
|
||||
id: str
|
||||
marker: str
|
||||
titel: str
|
||||
erledigt: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Basiseintrag:
|
||||
marker: str
|
||||
work_item_id: str
|
||||
titel: str
|
||||
erledigt: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Aktion:
|
||||
art: str
|
||||
marker: str | None
|
||||
vault: Aufgabe | None = None
|
||||
plane: WorkItem | None = None
|
||||
basis: Basiseintrag | None = None
|
||||
|
||||
|
||||
ERLEDIGT_GRUPPEN = ("completed", "cancelled")
|
||||
ZIEL_GRUPPE_ERLEDIGT = "completed"
|
||||
ZIEL_GRUPPE_OFFEN = "unstarted"
|
||||
|
||||
|
||||
def status_fuer(states: list[dict], erledigt: bool) -> str:
|
||||
"""Erste State-UUID der Zielgruppe, sortiert nach sequence."""
|
||||
gruppe = ZIEL_GRUPPE_ERLEDIGT if erledigt else ZIEL_GRUPPE_OFFEN
|
||||
passend = [s for s in states if s.get("group") == gruppe]
|
||||
if not passend:
|
||||
raise ValueError(
|
||||
f"Das Projekt hat keinen Status der Gruppe „{gruppe}“. "
|
||||
f"Vorhanden sind: {sorted({s.get('group') for s in states})}."
|
||||
)
|
||||
return min(passend, key=lambda s: s.get("sequence", 0))["id"]
|
||||
|
||||
|
||||
def item_erledigt(state_gruppe: str) -> bool:
|
||||
return state_gruppe in ERLEDIGT_GRUPPEN
|
||||
|
||||
|
||||
def _abweichung(titel_a: str, erledigt_a: bool, titel_b: str, erledigt_b: bool) -> bool:
|
||||
return titel_a != titel_b or erledigt_a != erledigt_b
|
||||
|
||||
|
||||
def berechne(
|
||||
aufgaben: list[Aufgabe],
|
||||
work_items: list[WorkItem],
|
||||
basis: dict[str, Basiseintrag],
|
||||
) -> list[Aktion]:
|
||||
"""Drei-Wege-Vergleich. Ruft nichts auf und schreibt nichts."""
|
||||
aktionen: list[Aktion] = []
|
||||
items_nach_marker = {item.marker: item for item in work_items}
|
||||
gesehene_marker: set[str] = set()
|
||||
|
||||
for aufgabe in aufgaben:
|
||||
if aufgabe.marker is None:
|
||||
aktionen.append(Aktion(art=ANLEGEN, marker=None, vault=aufgabe))
|
||||
continue
|
||||
|
||||
gesehene_marker.add(aufgabe.marker)
|
||||
item = items_nach_marker.get(aufgabe.marker)
|
||||
eintrag = basis.get(aufgabe.marker)
|
||||
|
||||
if item is None:
|
||||
aktionen.append(
|
||||
Aktion(art=ITEM_ENTFERNT, marker=aufgabe.marker, vault=aufgabe, basis=eintrag)
|
||||
)
|
||||
continue
|
||||
|
||||
if eintrag is None:
|
||||
# Ohne Basis lässt sich nicht entscheiden, wer sich bewegt hat.
|
||||
art = (
|
||||
UNVERAENDERT
|
||||
if not _abweichung(aufgabe.text, aufgabe.erledigt, item.titel, item.erledigt)
|
||||
else KONFLIKT
|
||||
)
|
||||
aktionen.append(
|
||||
Aktion(art=art, marker=aufgabe.marker, vault=aufgabe, plane=item)
|
||||
)
|
||||
continue
|
||||
|
||||
vault_geaendert = _abweichung(
|
||||
aufgabe.text, aufgabe.erledigt, eintrag.titel, eintrag.erledigt
|
||||
)
|
||||
plane_geaendert = _abweichung(
|
||||
item.titel, item.erledigt, eintrag.titel, eintrag.erledigt
|
||||
)
|
||||
|
||||
if vault_geaendert and plane_geaendert:
|
||||
art = KONFLIKT
|
||||
elif vault_geaendert:
|
||||
art = NACH_PLANE
|
||||
elif plane_geaendert:
|
||||
art = NACH_VAULT
|
||||
else:
|
||||
art = UNVERAENDERT
|
||||
|
||||
aktionen.append(
|
||||
Aktion(art=art, marker=aufgabe.marker, vault=aufgabe, plane=item, basis=eintrag)
|
||||
)
|
||||
|
||||
for item in work_items:
|
||||
if item.marker in gesehene_marker:
|
||||
continue
|
||||
eintrag = basis.get(item.marker)
|
||||
art = ZEILE_ENTFERNT if eintrag else ANHAENGEN
|
||||
aktionen.append(Aktion(art=art, marker=item.marker, plane=item, basis=eintrag))
|
||||
|
||||
return aktionen
|
||||
@@ -0,0 +1,143 @@
|
||||
"""HTTP-Zugriff auf die Plane-API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from .konfiguration import Konfiguration
|
||||
|
||||
HOECHSTE_WARTEZEIT = 65.0
|
||||
VERSUCHE_BEI_DROSSELUNG = 3
|
||||
|
||||
|
||||
class ApiFehler(Exception):
|
||||
"""Plane antwortet nicht wie erwartet."""
|
||||
|
||||
|
||||
class PlaneApi:
|
||||
"""Kapselt alle Zugriffe auf die Plane-API.
|
||||
|
||||
Es gibt bewusst je erlaubtem HTTP-Verb eine eigene Methode. Ein Verb als
|
||||
Parameter durchzureichen würde löschende Aufrufe formulierbar machen.
|
||||
"""
|
||||
|
||||
def __init__(self, konfiguration: Konfiguration, sitzung=None, schlaeft=time.sleep):
|
||||
self._k = konfiguration
|
||||
self._s = sitzung if sitzung is not None else requests.Session()
|
||||
self._schlaeft = schlaeft
|
||||
|
||||
@property
|
||||
def basis(self) -> str:
|
||||
return f"{self._k.basis_url}/api/v1/workspaces/{self._k.workspace}"
|
||||
|
||||
def _kopf(self) -> dict[str, str]:
|
||||
return {"X-API-Key": self._k.api_key, "Content-Type": "application/json"}
|
||||
|
||||
def _sende(self, verb_funktion, url: str, **kw):
|
||||
antwort = None
|
||||
for _ in range(VERSUCHE_BEI_DROSSELUNG):
|
||||
antwort = verb_funktion(url, headers=self._kopf(), timeout=30, **kw)
|
||||
if antwort.status_code != 429:
|
||||
break
|
||||
self._schlaeft(self._wartezeit(antwort))
|
||||
return self._geprueft(antwort)
|
||||
|
||||
def _geprueft(self, antwort):
|
||||
if antwort.status_code == 401:
|
||||
raise ApiFehler(
|
||||
"Plane lehnt den Schlüssel ab (401). PLANE_API_KEY prüfen."
|
||||
)
|
||||
if antwort.status_code == 403:
|
||||
raise ApiFehler(
|
||||
f"Plane verweigert den Zugriff (403). Entweder ist der Workspace-Slug "
|
||||
f"„{self._k.workspace}“ falsch oder der Schlüssel hat dort "
|
||||
f"keine Rechte — beides antwortet gleich."
|
||||
)
|
||||
if antwort.status_code == 429:
|
||||
raise ApiFehler(
|
||||
"Plane drosselt weiterhin (429), auch nach mehreren Versuchen. "
|
||||
"Später erneut versuchen."
|
||||
)
|
||||
if antwort.status_code >= 400:
|
||||
raise ApiFehler(
|
||||
f"Plane antwortet {antwort.status_code}: {antwort.text[:300]}"
|
||||
)
|
||||
return antwort
|
||||
|
||||
@staticmethod
|
||||
def _wartezeit(antwort, jetzt: float | None = None) -> float:
|
||||
"""Liest X-RateLimit-Reset; der Wert kann Zeitstempel oder Restdauer sein."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
roh = antwort.headers.get("X-RateLimit-Reset", "")
|
||||
try:
|
||||
wert = float(roh)
|
||||
except (TypeError, ValueError):
|
||||
return 5.0
|
||||
rest = wert - jetzt if wert > 1_000_000 else wert
|
||||
return min(max(rest, 1.0), HOECHSTE_WARTEZEIT)
|
||||
|
||||
def _get(self, url: str, **kw):
|
||||
return self._sende(self._s.get, url, **kw)
|
||||
|
||||
def _post(self, url: str, **kw):
|
||||
return self._sende(self._s.post, url, **kw)
|
||||
|
||||
def _patch(self, url: str, **kw):
|
||||
return self._sende(self._s.patch, url, **kw)
|
||||
|
||||
def _alle(self, url: str, params: dict[str, Any] | None = None) -> list[dict]:
|
||||
"""Folgt der Cursor-Pagination bis zum Ende."""
|
||||
params = dict(params or {})
|
||||
params.setdefault("per_page", 100)
|
||||
gesammelt: list[dict] = []
|
||||
while True:
|
||||
daten = self._get(url, params=params).json()
|
||||
if isinstance(daten, list):
|
||||
return daten
|
||||
gesammelt.extend(daten.get("results", []))
|
||||
if not daten.get("next_page_results"):
|
||||
return gesammelt
|
||||
cursor = daten.get("next_cursor")
|
||||
if not cursor:
|
||||
return gesammelt
|
||||
params["cursor"] = cursor
|
||||
|
||||
# --- Fachmethoden -----------------------------------------------------
|
||||
|
||||
def projekte(self) -> list[dict]:
|
||||
return self._alle(f"{self.basis}/projects/")
|
||||
|
||||
def projekt_anlegen(self, name: str, kennung: str) -> dict:
|
||||
gesaeubert = "".join(z for z in kennung.upper() if z.isascii() and z.isalnum())
|
||||
if not gesaeubert:
|
||||
raise ApiFehler(
|
||||
f"Die Kennung „{kennung}“ enthält kein einziges "
|
||||
f"verwendbares Zeichen (erlaubt sind A-Z und 0-9)."
|
||||
)
|
||||
return self._post(
|
||||
f"{self.basis}/projects/",
|
||||
json={"name": name, "identifier": gesaeubert},
|
||||
).json()
|
||||
|
||||
def states(self, projekt_id: str) -> list[dict]:
|
||||
return self._alle(f"{self.basis}/projects/{projekt_id}/states/")
|
||||
|
||||
def work_items(self, projekt_id: str) -> list[dict]:
|
||||
return self._alle(f"{self.basis}/projects/{projekt_id}/work-items/")
|
||||
|
||||
def work_item_anlegen(self, projekt_id: str, titel: str, state_id: str) -> dict:
|
||||
return self._post(
|
||||
f"{self.basis}/projects/{projekt_id}/work-items/",
|
||||
json={"name": titel, "state": state_id},
|
||||
).json()
|
||||
|
||||
def work_item_aendern(
|
||||
self, projekt_id: str, work_item_id: str, felder: dict[str, Any]
|
||||
) -> dict:
|
||||
return self._patch(
|
||||
f"{self.basis}/projects/{projekt_id}/work-items/{work_item_id}/",
|
||||
json=felder,
|
||||
).json()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Berichtsausgabe. Läuft auf der Windows-Konsole unter cp1252, nicht unter UTF-8."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .abgleich import (
|
||||
ANHAENGEN,
|
||||
ANLEGEN,
|
||||
ITEM_ENTFERNT,
|
||||
KONFLIKT,
|
||||
NACH_PLANE,
|
||||
NACH_VAULT,
|
||||
UNVERAENDERT,
|
||||
ZEILE_ENTFERNT,
|
||||
Aktion,
|
||||
)
|
||||
|
||||
BESCHRIFTUNG = {
|
||||
ANLEGEN: "ANLEGEN ",
|
||||
NACH_PLANE: "NACH PLANE ",
|
||||
NACH_VAULT: "NACH VAULT ",
|
||||
KONFLIKT: "KONFLIKT ",
|
||||
ANHAENGEN: "ANHAENGEN ",
|
||||
ZEILE_ENTFERNT: "ZEILE WEG ",
|
||||
ITEM_ENTFERNT: "ITEM WEG ",
|
||||
}
|
||||
|
||||
|
||||
def sichere_ausgabe(strom, text: str) -> None:
|
||||
"""Schreibt so, dass eine cp1252-Konsole den Lauf nicht abbricht."""
|
||||
kodierung = getattr(strom, "encoding", None) or "utf-8"
|
||||
try:
|
||||
text.encode(kodierung)
|
||||
except (UnicodeEncodeError, LookupError):
|
||||
text = text.encode(kodierung, errors="replace").decode(kodierung, errors="replace")
|
||||
strom.write(text + "\n")
|
||||
|
||||
|
||||
def schreibe_bericht(strom, projektname: str, aktionen: list[Aktion], anwenden: bool) -> None:
|
||||
sichere_ausgabe(strom, "")
|
||||
sichere_ausgabe(strom, f"=== {projektname} ===")
|
||||
kopf = "ANGEWENDET" if anwenden else "Probelauf - es wurde nichts geschrieben"
|
||||
sichere_ausgabe(strom, kopf)
|
||||
|
||||
zaehler: dict[str, int] = {}
|
||||
for aktion in aktionen:
|
||||
zaehler[aktion.art] = zaehler.get(aktion.art, 0) + 1
|
||||
if aktion.art == UNVERAENDERT:
|
||||
continue
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f" {BESCHRIFTUNG.get(aktion.art, aktion.art)} "
|
||||
f"{aktion.marker or '(neu)'} {_titel(aktion)}",
|
||||
)
|
||||
if aktion.art == KONFLIKT:
|
||||
sichere_ausgabe(strom, f" Vault: [{_haken(aktion.vault.erledigt)}] "
|
||||
f"{aktion.vault.text}")
|
||||
sichere_ausgabe(strom, f" Plane: [{_haken(aktion.plane.erledigt)}] "
|
||||
f"{aktion.plane.titel}")
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f" Entscheiden mit --konflikt {aktion.marker}=vault "
|
||||
f"oder --konflikt {aktion.marker}=plane",
|
||||
)
|
||||
|
||||
sichere_ausgabe(strom, " ---")
|
||||
for art in sorted(zaehler):
|
||||
sichere_ausgabe(strom, f" {BESCHRIFTUNG.get(art, art).strip()}: {zaehler[art]}")
|
||||
|
||||
|
||||
def _titel(aktion: Aktion) -> str:
|
||||
if aktion.vault is not None:
|
||||
return aktion.vault.text
|
||||
if aktion.plane is not None:
|
||||
return aktion.plane.titel
|
||||
return ""
|
||||
|
||||
|
||||
def _haken(erledigt: bool) -> str:
|
||||
return "x" if erledigt else " "
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Einstiegspunkt. Hier und nur hier wird geschrieben."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import todo_datei, zustand as zustand_modul
|
||||
from .abgleich import (
|
||||
Basiseintrag,
|
||||
WorkItem,
|
||||
berechne,
|
||||
item_erledigt,
|
||||
)
|
||||
from .api import ApiFehler, PlaneApi
|
||||
from .bericht import schreibe_bericht, sichere_ausgabe
|
||||
from .konfiguration import KonfigurationsFehler, lade_konfiguration
|
||||
|
||||
|
||||
def baue_api(konfiguration) -> PlaneApi:
|
||||
return PlaneApi(konfiguration)
|
||||
|
||||
|
||||
def sammle_projekte(quelle: Path) -> list[Path]:
|
||||
"""Alle Projektordner mit einer todo.md, alphabetisch."""
|
||||
if not quelle.exists():
|
||||
return []
|
||||
return sorted(
|
||||
ordner
|
||||
for ordner in quelle.iterdir()
|
||||
if ordner.is_dir() and (ordner / "todo.md").exists()
|
||||
)
|
||||
|
||||
|
||||
def lade_work_items(api, projekt_id: str, identifier: str) -> tuple[list[WorkItem], dict]:
|
||||
"""Wandelt Plane-Rohdaten in WorkItem um. Braucht die States für die Gruppe."""
|
||||
gruppe_je_state = {s["id"]: s.get("group", "") for s in api.states(projekt_id)}
|
||||
items = []
|
||||
for roh in api.work_items(projekt_id):
|
||||
items.append(
|
||||
WorkItem(
|
||||
id=roh["id"],
|
||||
marker=f"{identifier}-{roh['sequence_id']}",
|
||||
titel=roh.get("name", ""),
|
||||
erledigt=item_erledigt(gruppe_je_state.get(roh.get("state"), "")),
|
||||
)
|
||||
)
|
||||
return items, gruppe_je_state
|
||||
|
||||
|
||||
def _argumente(argumente: list[str]) -> argparse.Namespace:
|
||||
zerleger = argparse.ArgumentParser(prog="plane.cli", add_help=True)
|
||||
zerleger.add_argument("--anwenden", action="store_true")
|
||||
zerleger.add_argument("--nur-projekt", dest="nur_projekt", default=None)
|
||||
zerleger.add_argument("--projekte", action="store_true")
|
||||
zerleger.add_argument("--projekt-anlegen", dest="projekt_anlegen", default=None)
|
||||
zerleger.add_argument("--kennung", default=None)
|
||||
zerleger.add_argument("--konflikt", action="append", default=[])
|
||||
return zerleger.parse_args(argumente)
|
||||
|
||||
|
||||
def hauptprogramm(argumente, umgebung, api_bauer=baue_api, strom=None) -> int:
|
||||
strom = strom if strom is not None else sys.stdout
|
||||
optionen = _argumente(argumente)
|
||||
|
||||
try:
|
||||
konfiguration = lade_konfiguration(umgebung)
|
||||
except KonfigurationsFehler as fehler:
|
||||
sichere_ausgabe(strom, str(fehler))
|
||||
return 2
|
||||
|
||||
api = api_bauer(konfiguration)
|
||||
|
||||
try:
|
||||
if optionen.projekte:
|
||||
return _liste_projekte(api, strom)
|
||||
if optionen.projekt_anlegen:
|
||||
return _lege_projekt_an(api, optionen, strom)
|
||||
return _abgleich_lauf(api, konfiguration, optionen, strom)
|
||||
except ApiFehler as fehler:
|
||||
sichere_ausgabe(strom, str(fehler))
|
||||
return 3
|
||||
|
||||
|
||||
def _entscheidungen(rohe: list[str]) -> dict[str, str]:
|
||||
ergebnis: dict[str, str] = {}
|
||||
for eintrag in rohe:
|
||||
marker, trenner, seite = eintrag.partition("=")
|
||||
if not trenner or seite.strip().lower() not in ("vault", "plane"):
|
||||
raise ValueError(
|
||||
f"--konflikt erwartet MARKER=vault oder MARKER=plane, "
|
||||
f"bekommen habe ich „{eintrag}“."
|
||||
)
|
||||
ergebnis[marker.strip()] = seite.strip().lower()
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _abgleich_lauf(api, konfiguration, optionen, strom) -> int:
|
||||
try:
|
||||
entscheidungen = _entscheidungen(optionen.konflikt)
|
||||
except ValueError as fehler:
|
||||
sichere_ausgabe(strom, str(fehler))
|
||||
return 2
|
||||
|
||||
projekte_nach_id = {p["id"]: p for p in api.projekte()}
|
||||
alle_laeufe = []
|
||||
|
||||
for ordner in sammle_projekte(konfiguration.quelle):
|
||||
if optionen.nur_projekt and optionen.nur_projekt.lower() not in ordner.name.lower():
|
||||
continue
|
||||
|
||||
datei = todo_datei.lies(ordner / "todo.md")
|
||||
projekt_id = todo_datei.plane_projekt(datei.frontmatter)
|
||||
if not projekt_id:
|
||||
sichere_ausgabe(strom, "")
|
||||
sichere_ausgabe(strom, f"=== {ordner.name} ===")
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
" UEBERSPRUNGEN - im Frontmatter der todo.md fehlt plane_project. "
|
||||
"Mit --projekte die Plane-Projekte auflisten und die UUID eintragen.",
|
||||
)
|
||||
continue
|
||||
|
||||
projekt = projekte_nach_id.get(projekt_id)
|
||||
if projekt is None:
|
||||
sichere_ausgabe(strom, "")
|
||||
sichere_ausgabe(strom, f"=== {ordner.name} ===")
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f" UEBERSPRUNGEN - das Plane-Projekt {projekt_id} gibt es nicht "
|
||||
f"(oder der Schluessel sieht es nicht).",
|
||||
)
|
||||
continue
|
||||
|
||||
items, _ = lade_work_items(api, projekt_id, projekt["identifier"])
|
||||
zustand = zustand_modul.lies(ordner / zustand_modul.DATEINAME)
|
||||
if zustand.warnung:
|
||||
sichere_ausgabe(strom, f" HINWEIS: {zustand.warnung}")
|
||||
|
||||
aktionen = berechne(datei.aufgaben, items, zustand.eintraege)
|
||||
alle_laeufe.append((ordner, datei, projekt, aktionen, zustand))
|
||||
schreibe_bericht(strom, ordner.name, aktionen, anwenden=optionen.anwenden)
|
||||
|
||||
if not optionen.anwenden:
|
||||
return 0
|
||||
|
||||
bekannte = {a.marker for _, _, _, aktionen, _ in alle_laeufe for a in aktionen if a.marker}
|
||||
unbekannt = sorted(set(entscheidungen) - bekannte)
|
||||
if unbekannt:
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
"Abbruch, nichts geschrieben: --konflikt nennt Marker, die in diesem Lauf "
|
||||
"nicht vorkommen: " + ", ".join(unbekannt),
|
||||
)
|
||||
return 2
|
||||
|
||||
for ordner, datei, projekt, aktionen, zustand in alle_laeufe:
|
||||
_wende_an(api, projekt, ordner, datei, aktionen, zustand, entscheidungen, strom)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _wende_an(api, projekt, ordner, datei, aktionen, zustand, entscheidungen, strom) -> None:
|
||||
"""Schritt 1: nach Plane schreiben, Zustand je Aufruf fortschreiben.
|
||||
Schritt 2: todo.md einmal komplett. Schritt 3: Laufzeit setzen."""
|
||||
from .abgleich import (
|
||||
ANHAENGEN,
|
||||
ANLEGEN,
|
||||
ITEM_ENTFERNT,
|
||||
KONFLIKT,
|
||||
NACH_PLANE,
|
||||
NACH_VAULT,
|
||||
status_fuer,
|
||||
)
|
||||
|
||||
states = api.states(projekt["id"])
|
||||
zustand.plane_projekt = projekt["id"]
|
||||
aenderungen: dict[int, tuple[str, bool, str | None]] = {}
|
||||
neue_zeilen: list[str] = []
|
||||
|
||||
for aktion in aktionen:
|
||||
seite = entscheidungen.get(aktion.marker or "")
|
||||
|
||||
if aktion.art == ANLEGEN:
|
||||
roh = api.work_item_anlegen(
|
||||
projekt["id"],
|
||||
aktion.vault.text,
|
||||
status_fuer(states, aktion.vault.erledigt),
|
||||
)
|
||||
marker = f"{projekt['identifier']}-{roh['sequence_id']}"
|
||||
zustand.setze(
|
||||
Basiseintrag(marker, roh["id"], aktion.vault.text, aktion.vault.erledigt)
|
||||
)
|
||||
aenderungen[aktion.vault.zeilennummer] = (
|
||||
aktion.vault.text,
|
||||
aktion.vault.erledigt,
|
||||
marker,
|
||||
)
|
||||
|
||||
elif aktion.art == NACH_PLANE or (aktion.art == KONFLIKT and seite == "vault"):
|
||||
api.work_item_aendern(
|
||||
projekt["id"],
|
||||
aktion.plane.id,
|
||||
{
|
||||
"name": aktion.vault.text,
|
||||
"state": status_fuer(states, aktion.vault.erledigt),
|
||||
},
|
||||
)
|
||||
zustand.setze(
|
||||
Basiseintrag(
|
||||
aktion.marker, aktion.plane.id, aktion.vault.text, aktion.vault.erledigt
|
||||
)
|
||||
)
|
||||
|
||||
elif aktion.art == NACH_VAULT or (aktion.art == KONFLIKT and seite == "plane"):
|
||||
aenderungen[aktion.vault.zeilennummer] = (
|
||||
aktion.plane.titel,
|
||||
aktion.plane.erledigt,
|
||||
aktion.marker,
|
||||
)
|
||||
zustand.setze(
|
||||
Basiseintrag(
|
||||
aktion.marker, aktion.plane.id, aktion.plane.titel, aktion.plane.erledigt
|
||||
)
|
||||
)
|
||||
|
||||
elif aktion.art == ANHAENGEN:
|
||||
neue_zeilen.append(
|
||||
todo_datei.baue_zeile(
|
||||
"", aktion.plane.titel, aktion.plane.erledigt, aktion.marker
|
||||
)
|
||||
)
|
||||
zustand.setze(
|
||||
Basiseintrag(
|
||||
aktion.marker, aktion.plane.id, aktion.plane.titel, aktion.plane.erledigt
|
||||
)
|
||||
)
|
||||
|
||||
elif aktion.art == ITEM_ENTFERNT:
|
||||
aenderungen[aktion.vault.zeilennummer] = (
|
||||
aktion.vault.text,
|
||||
aktion.vault.erledigt,
|
||||
None,
|
||||
)
|
||||
zustand.entferne_marker(aktion.marker)
|
||||
|
||||
if aenderungen or neue_zeilen:
|
||||
inhalt = todo_datei.schreibe(datei, aenderungen, neue_zeilen)
|
||||
(ordner / "todo.md").write_text(inhalt, encoding="utf-8")
|
||||
|
||||
zustand.setze_lauf_zeit()
|
||||
|
||||
|
||||
def _liste_projekte(api, strom) -> int:
|
||||
sichere_ausgabe(strom, "Plane-Projekte im Workspace:")
|
||||
for projekt in sorted(api.projekte(), key=lambda p: p.get("name", "")):
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f" {projekt.get('identifier', '?'):<12} {projekt.get('name', '')} "
|
||||
f"{projekt['id']}",
|
||||
)
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
"Die passende UUID als plane_project ins Frontmatter der todo.md eintragen.",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _lege_projekt_an(api, optionen, strom) -> int:
|
||||
if not optionen.kennung:
|
||||
sichere_ausgabe(
|
||||
strom, "--projekt-anlegen braucht zusaetzlich --kennung (z. B. --kennung PLA)."
|
||||
)
|
||||
return 2
|
||||
|
||||
name = optionen.projekt_anlegen
|
||||
vorhanden = [p for p in api.projekte() if p.get("name", "").strip() == name.strip()]
|
||||
if vorhanden:
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f"Abbruch: In Plane gibt es bereits ein Projekt „{name}“ "
|
||||
f"({vorhanden[0]['id']}). Nichts angelegt.",
|
||||
)
|
||||
return 2
|
||||
|
||||
if not optionen.anwenden:
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f"Probelauf - anlegen wuerde: Projekt „{name}“ "
|
||||
f"mit Kennung {optionen.kennung.upper()}. Mit --anwenden ausfuehren.",
|
||||
)
|
||||
return 0
|
||||
|
||||
neu = api.projekt_anlegen(name, optionen.kennung)
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
f"Angelegt: „{name}“, Kennung {neu.get('identifier')}, "
|
||||
f"UUID {neu.get('id')}.",
|
||||
)
|
||||
sichere_ausgabe(
|
||||
strom,
|
||||
"Diese UUID als plane_project ins Frontmatter der passenden todo.md eintragen.",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(hauptprogramm(sys.argv[1:], None))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Liest die Plane-Zugangsdaten aus Umgebungsvariablen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
STANDARD_QUELLE = Path(r"D:\Claude\Code Projekte")
|
||||
PFLICHTVARIABLEN = ("PLANE_URL", "PLANE_API_KEY", "PLANE_WORKSPACE")
|
||||
|
||||
|
||||
class KonfigurationsFehler(Exception):
|
||||
"""Zugangsdaten fehlen oder sind unbrauchbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Konfiguration:
|
||||
basis_url: str
|
||||
api_key: str = field(repr=False)
|
||||
workspace: str = ""
|
||||
quelle: Path = STANDARD_QUELLE
|
||||
|
||||
|
||||
def lade_konfiguration(umgebung: Mapping[str, str] | None = None) -> Konfiguration:
|
||||
"""Baut die Konfiguration aus der Umgebung; meldet alle Lücken auf einmal."""
|
||||
werte = os.environ if umgebung is None else umgebung
|
||||
|
||||
fehlend = [name for name in PFLICHTVARIABLEN if not werte.get(name, "").strip()]
|
||||
if fehlend:
|
||||
raise KonfigurationsFehler(
|
||||
"Diese Umgebungsvariablen fehlen oder sind leer: "
|
||||
+ ", ".join(fehlend)
|
||||
+ ". Siehe README.md, Abschnitt \u201eEinrichtung\u201c."
|
||||
)
|
||||
|
||||
basis_url = werte["PLANE_URL"].strip().rstrip("/")
|
||||
if not basis_url.startswith(("http://", "https://")):
|
||||
raise KonfigurationsFehler(
|
||||
f"PLANE_URL muss mit http:// oder https:// beginnen, "
|
||||
f"ist aber \u201e{basis_url}\u201c."
|
||||
)
|
||||
|
||||
quelle = werte.get("PLANE_QUELLE", "").strip()
|
||||
return Konfiguration(
|
||||
basis_url=basis_url,
|
||||
api_key=werte["PLANE_API_KEY"].strip(),
|
||||
workspace=werte["PLANE_WORKSPACE"].strip(),
|
||||
quelle=Path(quelle) if quelle else STANDARD_QUELLE,
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Liest und schreibt die todo.md eines Vault-Projekts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .abgleich import Aufgabe
|
||||
|
||||
CHECKBOX_MUSTER = re.compile(r"^(?P<einrueckung>[ \t]*)- \[(?P<haken>[ xX])\] (?P<rest>.*)$")
|
||||
MARKER_MUSTER = re.compile(r"\s*<!--\s*plane:(?P<marker>[A-Z0-9]+-\d+)\s*-->\s*$")
|
||||
ABSCHNITT_AUS_PLANE = "## Aus Plane"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TodoDatei:
|
||||
pfad: Path
|
||||
zeilen: list[str]
|
||||
frontmatter: dict[str, str]
|
||||
aufgaben: list[Aufgabe]
|
||||
frontmatter_zeilen: int
|
||||
|
||||
|
||||
def _lies_frontmatter(zeilen: list[str]) -> tuple[dict[str, str], int]:
|
||||
"""Gibt die flachen Schlüssel-Wert-Paare und die Anzahl belegter Zeilen zurück."""
|
||||
if not zeilen or zeilen[0].strip() != "---":
|
||||
return {}, 0
|
||||
for index in range(1, len(zeilen)):
|
||||
if zeilen[index].strip() == "---":
|
||||
werte: dict[str, str] = {}
|
||||
for zeile in zeilen[1:index]:
|
||||
schluessel, trenner, wert = zeile.partition(":")
|
||||
if trenner:
|
||||
werte[schluessel.strip()] = wert.strip()
|
||||
return werte, index + 1
|
||||
return {}, 0
|
||||
|
||||
|
||||
def lies(pfad: Path) -> TodoDatei:
|
||||
# utf-8-sig statt utf-8: ein BOM am Dateianfang wuerde sonst als in
|
||||
# Zeile 0 stehen bleiben, das Frontmatter waere unsichtbar und der Skill
|
||||
# meldete "plane_project fehlt", obwohl es dasteht. Windows-Editoren und
|
||||
# PowerShell 5.1 schreiben UTF-8 regelmaessig mit BOM. Geschrieben wird
|
||||
# weiterhin ohne BOM.
|
||||
text = Path(pfad).read_text(encoding="utf-8-sig")
|
||||
zeilen = text.split("\n")
|
||||
frontmatter, belegt = _lies_frontmatter(zeilen)
|
||||
|
||||
aufgaben: list[Aufgabe] = []
|
||||
for nummer, zeile in enumerate(zeilen):
|
||||
if nummer < belegt:
|
||||
continue
|
||||
treffer = CHECKBOX_MUSTER.match(zeile)
|
||||
if not treffer:
|
||||
continue
|
||||
rest = treffer.group("rest")
|
||||
marker_treffer = MARKER_MUSTER.search(rest)
|
||||
marker = marker_treffer.group("marker") if marker_treffer else None
|
||||
text_ohne_marker = MARKER_MUSTER.sub("", rest).strip() if marker else rest.strip()
|
||||
aufgaben.append(
|
||||
Aufgabe(
|
||||
zeilennummer=nummer,
|
||||
text=text_ohne_marker,
|
||||
erledigt=treffer.group("haken").lower() == "x",
|
||||
marker=marker,
|
||||
einrueckung=treffer.group("einrueckung"),
|
||||
)
|
||||
)
|
||||
|
||||
return TodoDatei(
|
||||
pfad=Path(pfad),
|
||||
zeilen=zeilen,
|
||||
frontmatter=frontmatter,
|
||||
aufgaben=aufgaben,
|
||||
frontmatter_zeilen=belegt,
|
||||
)
|
||||
|
||||
|
||||
def plane_projekt(frontmatter: dict[str, str]) -> str | None:
|
||||
wert = frontmatter.get("plane_project", "").strip()
|
||||
return wert or None
|
||||
|
||||
|
||||
def baue_zeile(einrueckung: str, text: str, erledigt: bool, marker: str | None) -> str:
|
||||
haken = "x" if erledigt else " "
|
||||
zeile = f"{einrueckung}- [{haken}] {text}"
|
||||
if marker:
|
||||
zeile = f"{zeile} <!-- plane:{marker} -->"
|
||||
return zeile
|
||||
|
||||
|
||||
def schreibe(
|
||||
datei: TodoDatei,
|
||||
aenderungen: dict[int, tuple[str, bool, str | None]],
|
||||
neue_zeilen: list[str],
|
||||
) -> str:
|
||||
"""Baut den neuen Dateiinhalt. Unberührte Zeilen bleiben byteweise gleich."""
|
||||
zeilen = list(datei.zeilen)
|
||||
nach_nummer = {aufgabe.zeilennummer: aufgabe for aufgabe in datei.aufgaben}
|
||||
|
||||
for nummer, (text, erledigt, marker) in aenderungen.items():
|
||||
alt = nach_nummer.get(nummer)
|
||||
einrueckung = alt.einrueckung if alt else ""
|
||||
zeilen[nummer] = baue_zeile(einrueckung, text, erledigt, marker)
|
||||
|
||||
if neue_zeilen:
|
||||
zeilen = _haenge_an_aus_plane(zeilen, neue_zeilen)
|
||||
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
def _haenge_an_aus_plane(zeilen: list[str], neue_zeilen: list[str]) -> list[str]:
|
||||
ergebnis = list(zeilen)
|
||||
if ABSCHNITT_AUS_PLANE in [z.strip() for z in ergebnis]:
|
||||
letzte = max(
|
||||
i for i, z in enumerate(ergebnis) if z.strip() == ABSCHNITT_AUS_PLANE
|
||||
)
|
||||
einfuegen_ab = len(ergebnis)
|
||||
for i in range(letzte + 1, len(ergebnis)):
|
||||
if ergebnis[i].startswith("#"):
|
||||
einfuegen_ab = i
|
||||
break
|
||||
while einfuegen_ab > letzte + 1 and not ergebnis[einfuegen_ab - 1].strip():
|
||||
einfuegen_ab -= 1
|
||||
return ergebnis[:einfuegen_ab] + neue_zeilen + ergebnis[einfuegen_ab:]
|
||||
|
||||
while ergebnis and not ergebnis[-1].strip():
|
||||
ergebnis.pop()
|
||||
return ergebnis + ["", ABSCHNITT_AUS_PLANE, ""] + neue_zeilen + [""]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Der Stand des letzten Laufs — die Basis des Drei-Wege-Vergleichs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .abgleich import Basiseintrag
|
||||
|
||||
DATEINAME = ".plane-sync.json"
|
||||
VERSION = 1
|
||||
|
||||
|
||||
class Zustand:
|
||||
def __init__(self, pfad: Path, roh: dict, warnung: str | None = None):
|
||||
self.pfad = pfad
|
||||
self.warnung = warnung
|
||||
self.plane_projekt: str | None = roh.get("plane_project")
|
||||
self.eintraege: dict[str, Basiseintrag] = {
|
||||
marker: Basiseintrag(
|
||||
marker=marker,
|
||||
work_item_id=werte["work_item_id"],
|
||||
titel=werte["titel"],
|
||||
erledigt=bool(werte["erledigt"]),
|
||||
)
|
||||
for marker, werte in (roh.get("eintraege") or {}).items()
|
||||
}
|
||||
self._letzter_lauf: str | None = roh.get("letzter_lauf")
|
||||
|
||||
def setze(self, eintrag: Basiseintrag) -> None:
|
||||
"""Trägt einen Eintrag ein und speichert sofort — das macht den Lauf wiederaufsetzbar."""
|
||||
self.eintraege[eintrag.marker] = eintrag
|
||||
self.speichere()
|
||||
|
||||
def entferne_marker(self, marker: str) -> None:
|
||||
self.eintraege.pop(marker, None)
|
||||
self.speichere()
|
||||
|
||||
def setze_lauf_zeit(self) -> None:
|
||||
self._letzter_lauf = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
self.speichere()
|
||||
|
||||
def speichere(self) -> None:
|
||||
inhalt = {
|
||||
"version": VERSION,
|
||||
"plane_project": self.plane_projekt,
|
||||
"letzter_lauf": self._letzter_lauf,
|
||||
"eintraege": {
|
||||
marker: {
|
||||
"work_item_id": e.work_item_id,
|
||||
"titel": e.titel,
|
||||
"erledigt": e.erledigt,
|
||||
}
|
||||
for marker, e in sorted(self.eintraege.items())
|
||||
},
|
||||
}
|
||||
self.pfad.write_text(
|
||||
json.dumps(inhalt, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def lies(pfad: Path) -> Zustand:
|
||||
pfad = Path(pfad)
|
||||
if not pfad.exists():
|
||||
return Zustand(pfad, {})
|
||||
try:
|
||||
return Zustand(pfad, json.loads(pfad.read_text(encoding="utf-8")))
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as fehler:
|
||||
return Zustand(
|
||||
pfad,
|
||||
{},
|
||||
warnung=(
|
||||
f"{pfad} ist unlesbar ({fehler}). Der Lauf behandelt sie als leer; "
|
||||
"dadurch erscheinen bereits abgeglichene Aufgaben erneut."
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user