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:
Pascal MichelandClaude Opus 5 committed 2026-09-17 22:38:15 +02:00
commit c064c729ed
82 files changed
+13428

No files matched your search

@@ -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."
),
)