Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover), .claude/agents, .claude/skills, .claude/workflows, .claude/settings.json. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
fa1c26b407
137 files changed
+43926
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."
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user