Files
planer2/backend/planung.py
2026-09-07 07:44:00 +02:00

178 lines
6.2 KiB
Python

"""Eine Planung: der Kartensatz, wie er nach der nächsten Umsetzung aussehen soll.
Die Übersicht beantwortet „was kann mein Projekt". Eine geplante Funktion gehört
dort nicht hin — sie wäre gelogen. Deshalb liegt die Planung in einer eigenen
Kopie und der Projektordner bleibt unberührt, bis sie umgesetzt wird.
Kopiert wird der ganze Ordner, nicht nur die Karten. Gespräch und
Urteilsgedächtnis gehören zur Planung: was während des Planens besprochen und
beurteilt wurde, gilt für den geplanten Stand, nicht für den heutigen.
Solange eine Planung läuft, arbeitet `projekte.wurzel()` in der Kopie. Keine
Schreibfunktion muss davon wissen.
Es gibt höchstens eine Planung je Projekt. Sie endet auf zwei Arten: umgesetzt —
dann ersetzt die Kopie den Projektstand — oder abgebrochen, dann verschwindet sie
spurlos.
"""
import shutil
import parser
import projekte
import verzeichnis
# Was beim Vergleich zählt: der sichtbare Inhalt einer Funktion, nicht ihre Datei.
# Wird eine Karte umbenannt, wandert ihre Datei — der Vergleich hinge sonst an
# einem Dateinamen statt an dem, was dasteht.
FELDER = ("ziel", "regeln", "testen", "stand")
# Dateien, die dem Projekt gehören und nicht der Planung. Sie überleben das
# Umsetzen, auch wenn die Kopie sie nicht kennt. Das Gespräch und das
# Urteilsgedächtnis stehen bewusst nicht hier: die gehören zum geplanten Stand.
NUR_PROJEKT = (verzeichnis.DATEI,)
def laeuft(projekt: str) -> bool:
return (projekte.stand(projekt) / projekte.PLANUNG).is_dir()
def _kopie(projekt: str):
return projekte.stand(projekt) / projekte.PLANUNG
def starte(projekt: str):
"""Arbeitskopie anlegen. Ab hier zeigt `wurzel()` dorthin."""
ziel = _kopie(projekt)
if ziel.is_dir():
raise ValueError("Für dieses Projekt läuft schon eine Planung")
shutil.copytree(projekte.stand(projekt), ziel,
ignore=shutil.ignore_patterns(projekte.PLANUNG))
def brich_ab(projekt: str):
"""Planung verwerfen. Der Projektstand hat sie nie gesehen."""
shutil.rmtree(_kopie(projekt), ignore_errors=True)
def setze_um(projekt: str):
"""Die Kopie wird der neue Projektstand.
Erst die Kopie herausheben, dann den alten Stand räumen, dann die Kopie an
seine Stelle. Ein Absturz mitten im Räumen ließe sonst ein Projekt ohne Karten
zurück — und die Planung gleich mit.
Was dem Projekt gehört und nicht der Planung, bleibt stehen: das
Arbeitsverzeichnis wurde vielleicht erst nach dem Planungsstart festgelegt und
fehlt dann in der Kopie. Das Räumen hat es mitgenommen, und der nächste Lauf
fand kein Verzeichnis mehr.
"""
kopie = _kopie(projekt)
if not kopie.is_dir():
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner = projekte.stand(projekt)
daneben = ordner.parent / f"{ordner.name}.neu"
shutil.rmtree(daneben, ignore_errors=True)
kopie.rename(daneben)
for eintrag in ordner.iterdir():
if eintrag.name in NUR_PROJEKT and not (daneben / eintrag.name).exists():
eintrag.rename(daneben / eintrag.name)
continue
shutil.rmtree(eintrag) if eintrag.is_dir() else eintrag.unlink()
for eintrag in daneben.iterdir():
eintrag.rename(ordner / eintrag.name)
daneben.rmdir()
def _karten(ordner) -> dict:
"""titel -> Sektionen. Verglichen wird über den Titel, nicht über die Datei."""
raus = {}
for datei in sorted(ordner.glob("*.md")):
s = parser.zerlege(datei.read_text())
raus[s["titel"]] = s
return raus
def _funktionstext(f: dict) -> str:
return "\n".join(str(f.get(feld) or "") for feld in FELDER)
def _funktionen(karte: dict) -> dict:
return {f["text"]: _funktionstext(f) for f in karte["funktionen"]}
def _kartentext(karte: dict) -> str:
return "\n".join((karte.get("art") or "", karte.get("ziel") or "",
karte.get("regeln") or ""))
def aenderungen(projekt: str) -> dict:
"""Was die Planung am Projektstand ändert — je Karte und je Funktion.
Ohne laufende Planung ist das leer. Die Übersicht zeigt dann nichts an, und
die Karten sehen aus wie immer.
"""
if not laeuft(projekt):
return {}
alt = _karten(projekte.stand(projekt))
neu = _karten(_kopie(projekt))
raus = {}
for titel in set(alt) | set(neu):
a, b = alt.get(titel), neu.get(titel)
if a is None:
raus[titel] = {"art": "neu", "funktionen": {
f["text"]: "neu" for f in b["funktionen"]}}
continue
if b is None:
raus[titel] = {"art": "weg", "funktionen": {
f["text"]: "weg" for f in a["funktionen"]}}
continue
fa, fb = _funktionen(a), _funktionen(b)
funktionen = {}
for name in set(fa) | set(fb):
if name not in fa:
funktionen[name] = "neu"
elif name not in fb:
funktionen[name] = "weg"
elif fa[name] != fb[name]:
funktionen[name] = "geaendert"
kopf = _kartentext(a) != _kartentext(b)
if funktionen or kopf:
raus[titel] = {"art": "geaendert", "funktionen": funktionen,
"kopf": kopf}
return raus
def _datei_zu(ordner, titel: str):
for datei in ordner.glob("*.md"):
if parser.zerlege(datei.read_text())["titel"] == titel:
return datei
return None
def zuruecksetzen(projekt: str, titel: str):
"""Eine Karte auf den Projektstand zurückholen.
Drei Fälle: neu dazugekommen (löschen), weggefallen (zurückholen), geändert
(überschreiben). Der Rest der Planung bleibt stehen.
"""
if not laeuft(projekt):
raise ValueError("Für dieses Projekt läuft keine Planung")
ordner, kopie = projekte.stand(projekt), _kopie(projekt)
urspruenglich = _datei_zu(ordner, titel)
geplant = _datei_zu(kopie, titel)
if urspruenglich is None and geplant is None:
raise FileNotFoundError(f"Karte „{titel}“ nicht gefunden")
if urspruenglich is None:
geplant.unlink()
return
ziel = geplant or (kopie / urspruenglich.name)
ziel.write_text(urspruenglich.read_text())
def stand(projekt: str) -> dict:
return {"laeuft": laeuft(projekt), "aenderungen": aenderungen(projekt)}