"""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)}