From c6ff1942d75d3679388988fc94341fd6ac9eee1a Mon Sep 17 00:00:00 2001 From: leonatwork Date: Tue, 20 Oct 2026 13:46:07 +0000 Subject: [PATCH] =?UTF-8?q?agent:=20Bearbeite=20GitHub-Issue=20#13=20volls?= =?UTF-8?q?t=C3=A4ndig=20und=20liefere=20eine=20reviewbare=20L=C3=B6sung.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lies zuerst den vollständigen Issue-Thread inklusive aller Kommentare, CLAUDE.md und die relevanten Repository-Regeln. Prüfe vorhandene PRs und aktuellen Code, bevor du Änderungen machst. Leite die verbindlichen Akzeptanzkriterien aus dem Issue ab und dokumentiere nicht erfüllbare oder widersprüchliche Kriterien klar. Arbeite testgetrieben: Erzeuge Regressionstests, verifiziere ihr korrektes Fehlschlagen vor dem Fix und führe danach die relevanten Tests sowie die vorgeschriebenen Qualitätsprüfungen aus. Ändere nur, was für das Issue erforderlich ist. Arbeite ausschließlich auf dem ausgecheckten Stand von origin/main: checke keine Branches fremder offener PRs aus, cherry-picke nichts daraus und baue nicht auf ihnen auf. Hängt die Lösung von einem noch offenen PR ab, liefere nur deinen eigenen Anteil und dokumentiere die Abhängigkeit im Handover. Keine Produktionsdeployments, keine Änderungen an Produktionsdaten oder Produktions-Supabase und kein --prod. Merge den PR nicht. Issue: https://github.com/leonatwork/plotwerk/issues/13 Titel: [Agent] Kapitel teilen, zusammenführen und archivieren Beschreibung: ## Ziel und Nutzen Autorinnen sollen Kapitel mit Text archivieren und wiederherstellen können, ohne Text, Plan oder Notizen zu verlieren. Das ist Paket 1 des Umbau-Issues. ## Ist-Zustand / Problem Entfernen geht nur für geplante Kapitel ohne Text (geplantesKapitelEntfernen, planung.ts:475). entferneKapitel (store.ts:755) löscht Dateien ohne Schnappschuss und ohne Archiv. Ein archiv/-Ordner existiert nicht. ## Umfang (In Scope) - Archivieren und Wiederherstellen von Kapiteln mit Text, Plan und Notizen über archiv/ - Schnappschuss vor jeder Aktion (E8), Abbruch ohne Änderung bei Fehler - Archivierte Kapitel aus Kapitelliste, Export und KI-Kontext ausschließen - Befehle in Kapitelliste und Kapitel-Inspektor, i18n DE/EN - Reine Funktionen in core/ mit Vitest gegen MemoryFs mit Demo-Projekt, Akzeptanztest - Ergänzung von archiv/ im Projektformat in docs/architektur.md ## Nicht Teil dieses Issues (Out of Scope) - Endgültiges Löschen von Kapiteln mit Text - Zusammenführen (Paket 2) und Teilen (Paket 3), eigene PRs danach - Teilen und Zusammenführen von Akten - KI-Analysen neu berechnen - Änderungen am Datenmodell außer additiven Feldern ## Fachliche und UX-Entscheidungen - Aufteilung in 3 PRs: Archiv, Zusammenführen, Teilen (Leon: 1A) - Archivort ist der Projektordner archiv/, nicht der macOS-Papierkorb (Leon: 2A) - KI-Analysen und Hinweise bleiben erhalten und werden als veraltet markiert (Leon: 3A) - E1: IDs bleiben stabil und werden nie doppelt vergeben - Annahme: Verlinkte archivierte Kapitel bleiben auflösbar oder erscheinen als 'archiviert' - Offen: Umsetzung der Veraltet-Markierung für Hinweise (.plotwerk/hinweise.json) legt der Agent im Code fest, ohne Datenmodell-Brüche ## Prüfbare Akzeptanzkriterien - AC-1: Archivieren eines Kapitels mit Text verschiebt Manuskript- und Plandatei nach archiv/ und entfernt es aus projekt.md → kapitel. Text, Plan und Notizen bleiben unverändert erhalten. - AC-2: Vor dem Archivieren und vor dem Wiederherstellen liegt ein neuer Schnappschuss des betroffenen Kapitels in .versionen/. - AC-3: Ein archiviertes Kapitel taucht nicht mehr in Kapitelliste, Export und KI-Kontext auf. - AC-4: Wiederherstellen setzt das Kapitel mit Text, Plan und Notizen an seine alte Position in der Reihenfolge zurück. Die ID bleibt gleich und wird nie doppelt vergeben. - AC-5: Schlägt der Schnappschuss fehl, bleiben alle Dateien unverändert, und eine Meldung erscheint in DE und EN. - AC-6: KI-Analysen und Hinweise zu einem archivierten Kapitel bleiben erhalten und werden als veraltet behandelt. - AC-7: Kapitelliste und Kapitel-Inspektor bieten Archivieren und Wiederherstellen an (Texte in src/i18n/de und src/i18n/en), und archiv/ ist in docs/architektur.md ergänzt. - AC-8: npm test, npm run typecheck und npm run build sind grün. Die Funktionen werden mit Vitest in core/ gegen MemoryFs mit dem Demo-Projekt getestet, und ein Akzeptanztest in abnahme/akzeptanz.test.tsx deckt AC-1 bis AC-6 ab. ## QS und Nachweis - Vitest in core/ für Archivieren, Wiederherstellen, Schnappschüsse, ID-Stabilität und Fehlerpfad - UI-Tests für die Befehle in Kapitelliste und Inspektor - Akzeptanztest in abnahme/akzeptanz.test.tsx - npm test, npm run typecheck, npm run build und npm run sperrliste grün - Manuell auf dem Mac: Archivieren und Wiederherstellen im Demo-Projekt, Dateien in Obsidian prüfen ## Offene Fragen – _Automatisch nach dem Formular „Agent-Auftrag“ strukturiert (Hermes-Triage). Die ursprüngliche Beschreibung steht unten unverändert._
Ursprüngliche Beschreibung ### Ziel und Nutzen Als Autorin möchte ich ein Kapitel an einer Stelle teilen, zwei Kapitel zusammenführen und ein Kapitel mit Text archivieren, damit ich die Struktur des Buchs umbauen kann, ohne Text von Hand zu kopieren und ohne Notizen oder Planung zu verlieren. ### Ist-Zustand / Problem - Entfernen geht nur für geplante Kapitel ohne Text. `geplantesKapitelEntfernen` wirft bei Text einen Fehler (`src/state/slices/planung.ts:475-481`, Meldung „Kapitel mit Text werden hier nicht gelöscht.“ in `src/i18n/de/state.ts:17`). - `entferneKapitel` löscht Manuskript- und Plandatei ohne Schnappschuss und ohne Archiv (`src/state/store.ts:755-769`). - Teilen und Zusammenführen gibt es nicht. Nur Figuren lassen sich zusammenführen (`src/state/store.ts:805`). - Szenen hängen über `Szene.index` an den Abschnitten zwischen `* * *` (`core/model/types.ts:714-730`). Teilen oder Zusammenführen verschiebt diese Indizes. - Notizen und andere Stellen hängen per `TextAnker` mit Kapitel-ID am Text (`core/model/types.ts:306-313`, Anmerkung `:323-334`). Finden und Erzeugen: `ankerFinden` und `ankerErzeugen` (`core/text/index.ts:410,491`). ### Umfang (In Scope) - **Teilen** an der Cursorposition (bevorzugt an einem Absatz- oder Szenenwechsel): Der Text davor bleibt im Kapitel, der Rest wird ein neues Kapitel direkt dahinter. Titel vorgeschlagen, änderbar. - **Zusammenführen** zweier benachbarter Kapitel: Der Text des zweiten wird an das erste angehängt, getrennt durch einen Szenentrenner `* * *` (E3). Das zweite Kapitel wird archiviert. - **Archivieren**: Ein Kapitel mit Text wird mit Manuskript- und Plandatei nach `archiv/` verschoben und aus `projekt.md → kapitel` entfernt. Es lässt sich wiederherstellen und landet dann wieder in der Reihenfolge. - Vor jeder dieser Aktionen einen Schnappschuss aller betroffenen Kapitel anlegen (E8). - Notizen und Anker wandern mit: Anker, deren Stelle nach dem Teilen im neuen Kapitel liegt, bekommen die neue Kapitel-ID. Gilt für Anmerkungen und alle anderen Daten mit `TextAnker`. - Szenen des Kapitelplans werden nach den neuen Abschnitten neu zugeordnet (`index`, `kapitel`), Szenen-IDs bleiben. - Befehle in der Kapitelliste und im Kapitel-Inspektor, Texte in `src/i18n/{de,en}`. - Die Umbau-Logik als reine Funktionen in `core/` mit Tests. ### Nicht Teil dieses Issues (Out of Scope) - Endgültiges Löschen von Kapiteln mit Text. - Mehr als zwei Kapitel in einem Schritt zusammenführen. - Teilen und Zusammenführen von Akten. - KI-Analysen neu berechnen (sie bleiben, siehe Offen). - Änderungen am Datenmodell außer additiven Feldern (`core/model/types.ts` nur additiv). ### Fachliche und UX-Entscheidungen - Geklärt (Code): E1: IDs bleiben stabil. Beim Teilen behält der erste Teil die alte ID, der zweite bekommt eine neue ID über das bestehende Verfahren (`neueId`, wie beim Import in `src/state/slices/importexport.ts:101`). Beim Zusammenführen behält das erste Kapitel seine ID. Das zweite wird archiviert, seine ID wird nicht wiederverwendet. - Geklärt (Code): E4: Ein Anker, der sich nach dem Umbau nicht mehr eindeutig finden lässt, wird „verwaist“, nie still falsch. - Geklärt (Code): E7: Teilen und Zusammenführen ändern keinen Text außer dem eingefügten Trenner `* * *`. - Annahme: Der Kapitelplan des ersten Kapitels bleibt beim ersten Teil. Der zweite Teil bekommt einen Standardplan (`standardPlan`) mit den Szenen, die zu ihm gewandert sind. - Annahme: Akt-Zuordnung (`KapitelPlan.akt`) übernimmt der neue Teil vom Original. - Annahme: Teilen und Zusammenführen zählen nicht als geschriebene Wörter (Zählregel `docs/architektur.md:85-90`). - Annahme: Ein Kapitel, das in einem anderen Datensatz noch verlinkt ist (Verweise, Fäden, Zeitleiste), bleibt nach dem Archivieren auflösbar oder wird als „archiviert“ angezeigt, nicht als fehlend. - Fehlerfall: Schlägt ein Schnappschuss fehl, bricht die Aktion ab, ohne etwas zu ändern. - Offen (Leon): Ein eigener Ordner `archiv/` im Projekt oder der Papierkorb von macOS? - Offen (Leon): Was passiert mit KI-Analysen und Hinweisen zur alten ID (`.plotwerk/analysen/.json`, `.plotwerk/hinweise.json`)? Behalten und als veraltet markieren, mitarchivieren oder verwerfen? ### Prüfbare Akzeptanzkriterien AC-1: Wenn ich ein Kapitel am Cursor teile, dann enthält es danach nur den Text davor, und direkt dahinter steht ein neues Kapitel mit dem Rest. Beide Texte zusammen ergeben den alten Text. AC-2: Eine Notiz, deren Stelle im zweiten Teil liegt, hängt danach am neuen Kapitel und markiert dieselbe Stelle. AC-3: Szenen des geteilten Kapitels stehen danach im richtigen Kapitel mit passendem `index`. Ihre IDs sind unverändert. AC-4: Wenn ich zwei Kapitel zusammenführe, dann steht der Text des zweiten nach einem `* * *` am Ende des ersten, und das zweite Kapitel ist archiviert. AC-5: Vor jedem Teilen, Zusammenführen und Archivieren liegt ein neuer Schnappschuss jedes betroffenen Kapitels in `.versionen/`. AC-6: Ein archiviertes Kapitel taucht nicht mehr in Kapitelliste, Export und KI-Kontext auf. Nach dem Wiederherstellen ist es mit Text, Plan und Notizen wieder da. AC-7: Die alte ID des ersten Kapitels bleibt in allen Fällen erhalten. Keine ID wird doppelt vergeben. AC-8: Fehlerfall: Schlägt der Schnappschuss fehl, bleiben alle Dateien unverändert, und eine Meldung erscheint in DE und EN. ### QS und Nachweis - Agent: Vitest in `core/` für Teilen, Zusammenführen und Archivieren gegen `MemoryFs` mit dem Demo-Projekt: Textsumme, Anker-Umzug, Szenen-Index, ID-Stabilität, Schnappschüsse, Fehlerpfad. UI-Tests für die Befehle in Kapitelliste und Inspektor. `npm test`, `npm run typecheck` und `npm run build` grün. Der Agent-Server ist Linux und kann die Mac-App nicht bauen. - Manuell auf dem Mac: Kapitel mit Notizen und Szenen im Demo-Projekt teilen, wieder zusammenführen, archivieren und wiederherstellen. Danach in Obsidian prüfen, dass die Dateien lesbar sind. ### Technischer Kontext und Referenzen - Relevante Dateien: `src/state/slices/planung.ts`, `src/state/store.ts`, `core/model/types.ts` (`Szene`, `TextAnker`, `Anmerkung`, `KapitelPlan`), `core/text/index.ts` (Anker), `core/projekt/lesen.ts` und `core/projekt/schreiben.ts`, `src/features/schreiben/KapitelListe.tsx`, `src/features/akte/KapitelInspektor.tsx`. - Architektur: E1, E3, E4, E7, E8 in `docs/architektur.md`. Das Projektformat in `docs/architektur.md` wird um `archiv/` ergänzt, falls Leon den Ordner wählt. - Verwandt: Issue zu „Suchen und Ersetzen im ganzen Projekt“ (gleiche Schnappschuss-Logik). ### Sicherheits- und Liefergrenzen - [X] Das Issue enthält keine Secrets, Tokens, Zugangsdaten oder vertraulichen Manuskript- und Kundendaten. - [X] Der Agent darf Code und Tests ändern sowie einen Draft-PR öffnen, aber nicht selbst mergen, releasen oder deployen. - [X] Änderungen an produktiver Infrastruktur oder veröffentlichten Releases sind nicht Teil dieses Auftrags. _Angelegt von Claude Code im Auftrag von @leonatwork auf Basis einer Code-Analyse (Stand 2026-10-20)._
Zusätzliche Repo-Regeln: Commits tragen ein DCO-Sign-off (das macht dev-loop automatisch). Auf diesem Server gibt es kein Rust/Tauri: nur TypeScript-Teil (core/, src/) mit npm run build und npm test prüfen, src-tauri nicht bauen. Keine Inhalte aus privaten Manuskripten; Beispiele nur aus fixtures/. Farben nur über Tokens in src/theme/tokens.css, UI nach docs/design/screens, core/ bleibt reines TypeScript, core/ki nutzt nur ProjektLeser. Reviewer verdict: APPROVE after 1 round(s). Generated by dev-loop (not pushed). Signed-off-by: leonatwork --- abnahme/akzeptanz.test.tsx | 56 ++++++++++++ core/index/index.ts | 1 + core/ki/index.ts | 2 +- core/ki/ki.test.ts | 10 +++ core/model/types.ts | 8 ++ core/projekt/archiv.test.ts | 114 ++++++++++++++++++++++++ core/projekt/archiv.ts | 105 ++++++++++++++++++++++ core/projekt/extern.ts | 1 + core/projekt/index.ts | 1 + core/projekt/lesen.ts | 27 ++++++ core/projekt/schreiben.ts | 4 +- docs/architektur.md | 18 ++++ docs/issue-13-handover.md | 60 +++++++++++++ src/features/akte/KapitelInspektor.tsx | 16 ++++ src/features/akte/index.tsx | 4 +- src/features/schreiben/KapitelListe.tsx | 18 ++++ src/features/schreiben/archiv.test.tsx | 73 +++++++++++++++ src/features/schreiben/verweise.ts | 10 ++- src/i18n/de/schreiben.ts | 9 ++ src/i18n/en/schreiben.ts | 9 ++ src/state/slices/planung.ts | 2 +- src/state/slices/statistik.ts | 2 +- src/state/store.test.ts | 7 +- src/state/store.ts | 54 ++++++++++- 24 files changed, 599 insertions(+), 12 deletions(-) create mode 100644 core/projekt/archiv.test.ts create mode 100644 core/projekt/archiv.ts create mode 100644 docs/issue-13-handover.md create mode 100644 src/features/schreiben/archiv.test.tsx diff --git a/abnahme/akzeptanz.test.tsx b/abnahme/akzeptanz.test.tsx index 9f5aa71..15d9e20 100644 --- a/abnahme/akzeptanz.test.tsx +++ b/abnahme/akzeptanz.test.tsx @@ -25,6 +25,62 @@ import { docZuMarkdown } from '../src/features/schreiben/markdown'; import { zusammenfassungFuer } from '../src/state/slices/planung'; import { erstelleStore, useStore } from '../src/state/store'; import { berechneIndex } from '@core/index/index'; +import { baueBuchKontext } from '@core/ki/kontext'; +import { zielName } from '../src/features/schreiben/verweise'; + +describe('Issue #13 AC-1 bis AC-6: Kapitel archivieren und wiederherstellen', () => { + it('bewahrt Kapitel, Plan, Notizen und bezahlte KI-Ergebnisse; schließt Archiv vom Buch aus und reserviert IDs', async () => { + const fs = demoFs(); + const store = await geladenerStore(fs); + const original = store.getState().daten!; + const versionen = (await fs.auflisten('.versionen')).length; + await store.getState().kapitelArchivieren('flut'); + const d = store.getState().daten!; + expect(d.projekt.kapitel).not.toContain('flut'); + expect(d.kapitel.flut).toBeUndefined(); + expect(await fs.lesen('archiv/manuskript/flut.md')).toBeTruthy(); + expect(await fs.lesen('archiv/plan/kapitel/flut.md')).toBeTruthy(); + expect(d.anmerkungen).toEqual(original.anmerkungen); + expect(original.anmerkungen.some(a => a.anker.kapitel === 'flut')).toBe(true); + expect(d.analysen.flut).toMatchObject({ ...original.analysen.flut, veraltet: true }); + const hinweise = d.kiHinweise.filter(h => h.zitate.some(z => z.kapitel === 'flut')); + expect(hinweise.length).toBeGreaterThan(0); + expect(hinweise.every(h => h.veraltet)).toBe(true); + expect(baueBuchKontext(d).kapitel.some(k => k.id === 'flut')).toBe(false); + expect(zielName(d, { typ: 'kapitel', id: 'flut' })).toContain('archiviert'); + expect(store.getState().neueId('Flut', [])).not.toBe('flut'); + store.getState().indexNeuBerechnen(); + expect(store.getState().index.hinweise.some(h => h.zitate.some(z => z.kapitel === 'flut'))).toBe(false); + const exportiert = await store.getState().exportErstellen({ format: 'docx', kapitel: d.projekt.kapitel, titelseite: false, schrift: 'newsreader' }); + expect(await rohtextDocx(exportiert)).not.toContain('dass ich eine halbe Stunde zu früh bin'); + expect((await fs.auflisten('.versionen')).length).toBe(versionen + 2); + const statistik = JSON.stringify(store.getState().statistik.offen); + await store.getState().kapitelWiederherstellen('flut'); + expect(store.getState().daten!.projekt.kapitel).toEqual(original.projekt.kapitel); + expect(store.getState().daten!.kapitel.flut).toEqual(original.kapitel.flut); + expect(store.getState().daten!.plaene.flut).toEqual(original.plaene.flut); + expect(store.getState().daten!.anmerkungen).toEqual(original.anmerkungen); + expect(JSON.stringify(store.getState().statistik.offen)).toBe(statistik); + expect((await fs.auflisten('.versionen')).length).toBe(versionen + 4); + }); + + it('zeigt einen Sicherungsfehler und ändert weder Dateien noch Store', async () => { + class FehlerFs extends MemoryFs { + override async schreiben(p: string, t: string) { + if (p.startsWith('.versionen/')) throw new Error('disk full'); + return super.schreiben(p, t); + } + } + const fs = new FehlerFs(demoDateien()); + const store = await geladenerStore(fs); + const d = store.getState().daten; + const dateien = fs.alleDateien(); + await expect(store.getState().kapitelArchivieren('ebbe')).rejects.toThrow('Schnappschuss'); + expect(store.getState().speichern.fehler).toContain('Schnappschuss'); + expect(store.getState().daten).toBe(d); + expect(fs.alleDateien()).toEqual(dateien); + }); +}); const FIXTURE_DOCX = join(__dirname, '../fixtures/test-manuskript.docx'); diff --git a/core/index/index.ts b/core/index/index.ts index 73d0411..52f772b 100644 --- a/core/index/index.ts +++ b/core/index/index.ts @@ -160,6 +160,7 @@ function beruehrteStufen(h: Hinweis, daten: ProjektDaten, stellen: Stelle[]): Se /** KI-Hinweise erneut prüfen: jedes Zitat muss (normalisiert) im Kapiteltext stehen, sonst fällt der Hinweis weg. */ function kiHinweisGueltig(h: Hinweis, daten: ProjektDaten): boolean { + if (h.veraltet) return false; if (!h.zitate?.length) return false; return h.zitate.every((z) => { const md = textVon(daten, z.kapitel); diff --git a/core/ki/index.ts b/core/ki/index.ts index 0db2b15..6f4a6dc 100644 --- a/core/ki/index.ts +++ b/core/ki/index.ts @@ -212,7 +212,7 @@ async function planeLauf(leser: ProjektLeser, auftrag: PruefAuftrag, einstellung const uebersprungen: Id[] = []; for (const k of kandidaten) { const a = daten.analysen[k.id]; - if (cacheNutzen && a && a.textHash && a.textHash === k.hash) uebersprungen.push(k.id); + if (cacheNutzen && a && !a.veraltet && a.textHash && a.textHash === k.hash) uebersprungen.push(k.id); else kapitel.push(k); } return { daten, kontext, rolle, modell: deploymentFuer(einstellungen, rolle), kapitel, uebersprungen }; diff --git a/core/ki/ki.test.ts b/core/ki/ki.test.ts index e45e3d5..30ab5bf 100644 --- a/core/ki/ki.test.ts +++ b/core/ki/ki.test.ts @@ -479,6 +479,16 @@ describe('Bewusst vage (harte Regel 4) und Etikett „offen“', () => { }); describe('Ergebnis-Cache pro Kapitel-Hash (E13)', () => { + it('veraltete Archiv-Analyse wird auch bei identischem Text nicht aus dem Cache verwendet', async () => { + const fs = frischFs(); + const r1 = await pruefe(fs, { umfang: 'kapitel', kapitel: 'ebbe' }, erstelleDemoKiTransport({ verzoegerungMs: 0 }), STANDARD_KI); + await fs.schreiben(analysePfad('ebbe'), serializeAnalyse({ ...r1.analysen[0]!, veraltet: true })); + const { transport, bodies } = aufzeichnend(); + const r2 = await pruefe(fs, { umfang: 'kapitel', kapitel: 'ebbe' }, transport, STANDARD_KI); + expect(bodies.map(aufgabeKapitel)).toEqual(['ebbe']); + expect(r2.uebersprungen).toEqual([]); + expect(r2.analysen[0]?.veraltet).toBeUndefined(); + }); it('überspringt unveränderte Kapitel mit Analyse, prüft geänderte; Tiefencheck und „erzwingen“ prüfen alles', async () => { const fs = frischFs(); const erst = aufzeichnend(); diff --git a/core/model/types.ts b/core/model/types.ts index bbbf23c..7979604 100644 --- a/core/model/types.ts +++ b/core/model/types.ts @@ -409,6 +409,8 @@ export interface Zitat { } export interface Hinweis { + /** Nach einem Kapitelumbau erhalten, aber nicht mehr als aktueller Hinweis verwendet. */ + veraltet?: boolean; /** stabiler Schlüssel aus Typ + normalisierten Zitaten (für Entscheidungen) */ schluessel: string; typ: HinweisTyp; @@ -431,6 +433,8 @@ export interface LoseEnde { /** .plotwerk/analysen/.json – bezahltes KI-Ergebnis, bleibt im Projektordner */ export interface KapitelAnalyse { + /** Kapitel wurde archiviert; erst eine neue Prüfung macht die Analyse wieder aktuell. */ + veraltet?: boolean; kapitel: Id; textHash: string; zusammenfassung: string; @@ -469,6 +473,10 @@ export interface ManuskriptKonflikt { } export interface ProjektDaten { + /** Separate, nur lesende Ablage; gehört nicht zum aktiven Buch oder KI-Kontext. */ + archiv?: Record; + /** Reservierung bleibt auch erhalten, wenn eine Archivdatei extern fehlt/beschädigt ist. */ + reservierteKapitelIds?: Id[]; /** Erkennungsergebnis für manuskript/; fehlt bei Daten aus älteren Ständen. */ manuskriptKonflikte?: ManuskriptKonflikt[]; projekt: Projekt; diff --git a/core/projekt/archiv.test.ts b/core/projekt/archiv.test.ts new file mode 100644 index 0000000..49f6266 --- /dev/null +++ b/core/projekt/archiv.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { demoDateien, demoFs } from '../../fixtures/demoProjekt'; +import { ladeProjekt, MemoryFs } from './index'; +import { archiviereKapitel, stelleKapitelWiederHer } from './archiv'; +import { uebernehmeExterneAenderungen } from './extern'; + +describe('Kapitelarchiv (Issue #13)', () => { + it('archiviert Flut mit echten Demo-Ankern und kapitelübergreifenden Hinweisen; externe Archivänderungen bleiben auflösbar', async () => { + const fs = demoFs(); + const original = await ladeProjekt(fs); + const hinweise = original.kiHinweise.filter(h => h.zitate.some(z => z.kapitel === 'flut')); + expect(hinweise.length).toBeGreaterThan(0); + expect(original.anmerkungen.some(a => a.anker.kapitel === 'flut')).toBe(true); + const d = await archiviereKapitel(fs, 'flut'); + expect(d.kiHinweise.filter(h => hinweise.some(alt => alt.schluessel === h.schluessel))).toEqual(hinweise.map(h => ({ ...h, veraltet: true }))); + expect(d.anmerkungen).toEqual(original.anmerkungen); + await fs.schreiben('archiv/plan/kapitel/flut.md', (await fs.lesen('archiv/plan/kapitel/flut.md'))! + '\n'); + const geladen = await ladeProjekt(fs); + expect(uebernehmeExterneAenderungen(d, geladen, ['archiv/plan/kapitel/flut.md']).archiv).toBe(geladen.archiv); + const neu = await stelleKapitelWiederHer(fs, 'flut'); + expect(neu.anmerkungen).toEqual(original.anmerkungen); + expect(neu.kapitel.flut).toEqual(original.kapitel.flut); + }); + + it('Sicherungsfehler beim Wiederherstellen lässt das vollständige Archiv unverändert', async () => { + const fs = demoFs(); + await archiviereKapitel(fs, 'flut'); + class FehlerFs extends MemoryFs { + override async schreiben(p: string, t: string) { + if (p.startsWith('.versionen/')) throw new Error('disk full'); + return super.schreiben(p, t); + } + } + const fehlerFs = new FehlerFs(fs.alleDateien()); + await expect(stelleKapitelWiederHer(fehlerFs, 'flut')).rejects.toThrow('Schnappschuss'); + expect(fehlerFs.alleDateien()).toEqual(fs.alleDateien()); + }); + + it('überschreibt beim Wiederherstellen keinen aktiven Stand derselben ID', async () => { + const fs = demoFs(); + await archiviereKapitel(fs, 'flut'); + await fs.schreiben('manuskript/flut.md', demoDateien()['manuskript/flut.md']!); + const vorher = fs.alleDateien(); + await expect(stelleKapitelWiederHer(fs, 'flut')).rejects.toThrow(); + expect(fs.alleDateien()).toEqual(vorher); + }); + it('erhält rohe Dateien, Notizen und IDs; neue Sicherung vor jeder Aktion, auch in derselben Sekunde', async () => { + const fs = demoFs(); + const vorher = fs.alleDateien(); + const original = await ladeProjekt(fs); + const zeit = new Date('2026-10-20T12:00:00Z'); + const d = await archiviereKapitel(fs, 'ebbe', zeit); + expect(d.projekt.kapitel).not.toContain('ebbe'); + expect(d.kapitel.ebbe).toBeUndefined(); + expect(d.archiv?.ebbe.kapitel).toEqual(original.kapitel.ebbe); + expect(await fs.lesen('archiv/manuskript/ebbe.md')).toBe(vorher['manuskript/ebbe.md']); + expect(await fs.lesen('archiv/plan/kapitel/ebbe.md')).toBe(vorher['plan/kapitel/ebbe.md']); + expect(await fs.lesen('manuskript/ebbe.md')).toBeNull(); + expect(await fs.lesen('plan/kapitel/ebbe.md')).toBeNull(); + expect(d.anmerkungen).toEqual(original.anmerkungen); + expect(d.analysen.ebbe).toMatchObject({ ...original.analysen.ebbe, veraltet: true }); + const versionen = await fs.auflisten('.versionen'); + const neu = await stelleKapitelWiederHer(fs, 'ebbe', zeit); + expect(neu.projekt.kapitel).toEqual(original.projekt.kapitel); + expect(neu.kapitel.ebbe).toEqual(original.kapitel.ebbe); + expect(neu.plaene.ebbe).toEqual(original.plaene.ebbe); + expect(await fs.lesen('manuskript/ebbe.md')).toBe(vorher['manuskript/ebbe.md']); + expect(await fs.lesen('plan/kapitel/ebbe.md')).toBe(vorher['plan/kapitel/ebbe.md']); + expect((await fs.auflisten('.versionen')).length).toBe(versionen.length + 2); + expect(neu.analysen.ebbe?.veraltet).toBe(true); + }); + + it('stellt mehrere archivierte Nachbarn unabhängig von der Wiederherstellungsreihenfolge richtig zurück', async () => { + const fs = demoFs(); + const vorher = (await ladeProjekt(fs)).projekt.kapitel; + await archiviereKapitel(fs, vorher[0]!); + await archiviereKapitel(fs, vorher[1]!); + await stelleKapitelWiederHer(fs, vorher[0]!); + expect((await stelleKapitelWiederHer(fs, vorher[1]!)).projekt.kapitel).toEqual(vorher); + await expect(stelleKapitelWiederHer(fs, vorher[1]!)).rejects.toThrow(); + }); + + it.each(['de', 'en'] as const)('Sicherungsfehler (%s) hinterlässt auch bei der zweiten Sicherung keinerlei Änderung', async sprache => { + class FehlerFs extends MemoryFs { + n = 0; + override async schreiben(p: string, t: string) { + if (p.startsWith('.versionen/') && ++this.n === 2) throw new Error('disk full'); + return super.schreiben(p, t); + } + } + const fs = new FehlerFs(demoDateien()); + const vorher = fs.alleDateien(); + await expect(archiviereKapitel(fs, 'ebbe', undefined, sprache)).rejects.toThrow(sprache === 'de' ? 'Schnappschuss' : 'snapshot'); + expect(fs.alleDateien()).toEqual(vorher); + }); + + it('rollt einen Fehler beim Verschieben zurück und überschreibt keine vorhandene Archivdatei', async () => { + class FehlerFs extends MemoryFs { + override async entfernen(p: string) { + if (p === 'plan/kapitel/ebbe.md') throw new Error('disk full'); + return super.entfernen(p); + } + } + const fs = new FehlerFs(demoDateien()); + const vorher = fs.alleDateien(); + await expect(archiviereKapitel(fs, 'ebbe')).rejects.toThrow(); + expect(fs.alleDateien()).toEqual(vorher); + const normal = demoFs(); + await normal.schreiben('archiv/manuskript/ebbe.md', 'fremde Datei'); + const stand = normal.alleDateien(); + await expect(archiviereKapitel(normal, 'ebbe')).rejects.toThrow(); + expect(normal.alleDateien()).toEqual(stand); + }); +}); diff --git a/core/projekt/archiv.ts b/core/projekt/archiv.ts new file mode 100644 index 0000000..3d9b014 --- /dev/null +++ b/core/projekt/archiv.ts @@ -0,0 +1,105 @@ +/** Kapitelumbau ohne DOM/Tauri. Rohe Dateien bewegen, niemals Manuskript neu serialisieren. */ +import type { ProjektDateisystem } from './fs'; +import type { Id, ProjektDaten } from '../model/types'; +import { analysePfad, kapitelPfad, ladeProjekt, parseArchivIndex, parseFrontmatter, parseProjekt, PFADE, planPfad } from './lesen'; +import { serializeProjekt } from './schreiben'; +import { versionsPfadMitZusatz } from '../versionen'; + +export const ARCHIV_FEHLER = { + de: { sicherung: 'Schnappschuss fehlgeschlagen. Die Kapitelaktion wurde abgebrochen.', ungueltig: 'Kapitelaktion nicht möglich: Dateien fehlen, sind widersprüchlich oder das Ziel ist bereits belegt.' }, + en: { sicherung: 'The snapshot failed. The chapter action was cancelled.', ungueltig: 'Chapter action unavailable: files are missing, inconsistent or the destination already exists.' }, +}; + +/** Neue aktive Reihenfolge respektieren; archivierte Nachbarn bleiben an ihren alten Ankern. */ +function volleReihenfolge(aktiv: Id[], alt: Id[], archiviert: Id[]): Id[] { + const neu = [...aktiv]; + for (const id of alt.filter(id => archiviert.includes(id))) { + const danach = alt.slice(alt.indexOf(id) + 1).find(k => neu.includes(k)); + neu.splice(danach ? neu.indexOf(danach) : neu.length, 0, id); + } + return neu; +} + +async function umbauen(fs: ProjektDateisystem, id: Id, wiederherstellen: boolean, zeit: Date, sprache: 'de' | 'en'): Promise { + const t = ARCHIV_FEHLER[sprache]; + const ungueltig = () => new Error(t.ungueltig); + if (!id || /[/\\]|^\.{1,2}$/.test(id)) throw ungueltig(); + const projektText = await fs.lesen(PFADE.projekt); + if (projektText === null || parseFrontmatter(projektText).fehler) throw ungueltig(); + const projekt = parseProjekt(projektText); + const geladen = await ladeProjekt(fs); + if (geladen.manuskriptKonflikte?.some(k => k.kapitelIds.includes(id))) throw ungueltig(); + const indexText = await fs.lesen(PFADE.archiv); + let index; + try { index = indexText === null ? { version: 1 as const, kapitel: [], reihenfolge: [] } : parseArchivIndex(indexText); } + catch { throw ungueltig(); } + if (wiederherstellen ? (!index.kapitel.includes(id) || projekt.kapitel.includes(id)) : (!projekt.kapitel.includes(id) || index.kapitel.includes(id))) throw ungueltig(); + const reihenfolge = volleReihenfolge(projekt.kapitel, index.reihenfolge, index.kapitel); + const dateien: { quelle: string; ziel: string; inhalt: string }[] = []; + for (const pfad of [kapitelPfad(id), planPfad(id)]) { + const quelle = wiederherstellen ? `archiv/${pfad}` : pfad; + const ziel = wiederherstellen ? pfad : `archiv/${pfad}`; + if (await fs.existiert(ziel)) throw ungueltig(); + const inhalt = await fs.lesen(quelle); + // Ohne Manuskriptdatei gibt es keinen Textstand zu archivieren; ein Plan darf fehlen. + if (inhalt === null) { + if (pfad === kapitelPfad(id)) throw ungueltig(); + continue; + } + const fm = parseFrontmatter(inhalt); + if (fm.fehler || (fm.daten.id !== undefined && fm.daten.id !== id)) throw ungueltig(); + dateien.push({ quelle, ziel, inhalt }); + } + + // Änderungsjournal: auch bereits geschriebene Sicherungen bei einem Fehler entfernen. + const journal = new Map(); + async function aendere(pfad: string, inhalt: string | null) { + if (!journal.has(pfad)) journal.set(pfad, await fs.lesen(pfad)); + if (inhalt === null) await fs.entfernen(pfad); + else await fs.schreiben(pfad, inhalt); + } + let sichert = true; + try { + for (const d of dateien) { + // Archiv-Sicherungen liegen im ursprünglichen Kapitel-Versionsverlauf. + const pfad = d.quelle.replace(/^archiv\//, ''); + let n = 1; + while (await fs.existiert(versionsPfadMitZusatz(pfad, zeit, n))) n++; + await aendere(versionsPfadMitZusatz(pfad, zeit, n), d.inhalt); + } + sichert = false; + for (const d of dateien) await aendere(d.ziel, d.inhalt); + if (!wiederherstellen) { + const analyse = await fs.lesen(analysePfad(id)); + if (analyse !== null) await aendere(analysePfad(id), JSON.stringify({ ...JSON.parse(analyse), veraltet: true }, null, 2) + '\n'); + const hinweise = await fs.lesen(PFADE.hinweise); + if (hinweise !== null) { + const roh: unknown = JSON.parse(hinweise); + if (!Array.isArray(roh)) throw ungueltig(); + const neu = roh.map(h => h.zitate?.some((z: { kapitel: Id }) => z.kapitel === id) || h.plan?.kapitel === id ? { ...h, veraltet: true } : h); + await aendere(PFADE.hinweise, JSON.stringify(neu, null, 2) + '\n'); + } + } + for (const d of dateien) await aendere(d.quelle, null); + const archiviert = wiederherstellen ? index.kapitel.filter(k => k !== id) : [...index.kapitel, id]; + await aendere(PFADE.archiv, JSON.stringify({ version: 1, kapitel: archiviert, reihenfolge }, null, 2) + '\n'); + const kapitel = wiederherstellen ? reihenfolge.filter(k => !archiviert.includes(k)) : projekt.kapitel.filter(k => k !== id); + await aendere(PFADE.projekt, serializeProjekt({ ...projekt, kapitel }, projektText)); + return await ladeProjekt(fs); + } catch (fehler) { + const rollbackFehler: unknown[] = []; + for (const [pfad, alt] of [...journal].reverse()) { + try { + if (await fs.lesen(pfad) === alt) continue; + if (alt === null) await fs.entfernen(pfad); + else await fs.schreiben(pfad, alt); + } catch (e) { rollbackFehler.push(e); } + } + if (rollbackFehler.length) throw new AggregateError([fehler, ...rollbackFehler], sprache === 'de' ? 'Rücksetzen fehlgeschlagen. Bitte Projektdateien und Schnappschüsse prüfen.' : 'Rollback failed. Please check project files and snapshots.'); + if (sichert) throw new Error(t.sicherung, { cause: fehler }); + throw fehler; + } +} + +export const archiviereKapitel = (fs: ProjektDateisystem, id: Id, zeit = new Date(), sprache: 'de' | 'en' = 'de') => umbauen(fs, id, false, zeit, sprache); +export const stelleKapitelWiederHer = (fs: ProjektDateisystem, id: Id, zeit = new Date(), sprache: 'de' | 'en' = 'de') => umbauen(fs, id, true, zeit, sprache); diff --git a/core/projekt/extern.ts b/core/projekt/extern.ts index d506d13..8302d29 100644 --- a/core/projekt/extern.ts +++ b/core/projekt/extern.ts @@ -47,6 +47,7 @@ const imOrdner = (pfad: string, ordner: string, endung: string) => export function uebernehmeExterneAenderungen(alt: ProjektDaten, neu: ProjektDaten, pfade: string[]): ProjektDaten { let d: ProjektDaten = alt; + if (pfade.some(p => p.startsWith('archiv/'))) d = { ...d, archiv: neu.archiv, reservierteKapitelIds: neu.reservierteKapitelIds }; const kapitelBetroffen = new Set(); if (pfade.some(istManuskriptPfad) || pfade.includes(PFADE.projekt)) { d = { ...d, manuskriptKonflikte: neu.manuskriptKonflikte ?? [] }; diff --git a/core/projekt/index.ts b/core/projekt/index.ts index 40a22dd..1a0336e 100644 --- a/core/projekt/index.ts +++ b/core/projekt/index.ts @@ -6,3 +6,4 @@ export * from './memoryFs'; export * from './extern'; export * from './binaer'; export * from './konflikte'; +export { archiviereKapitel, stelleKapitelWiederHer } from './archiv'; diff --git a/core/projekt/lesen.ts b/core/projekt/lesen.ts index 7e6f358..e61605b 100644 --- a/core/projekt/lesen.ts +++ b/core/projekt/lesen.ts @@ -84,6 +84,7 @@ export const PFADE = { /** KI-Vorschläge für Figuren, Orte, Beziehungen (KiVorschlaege) */ vorschlaege: '.plotwerk/vorschlaege.json', versionen: '.versionen', + archiv: 'archiv/index.json', // Werkzeuge G1–G8 (docs/architektur.md) /** G1 Zeitleiste und Zustände (Zeitleiste) */ zeitleiste: 'plan/zeitleiste.md', @@ -652,6 +653,7 @@ export function parseAnalyse(text: string, id?: Id): KapitelAnalyse { .map((l) => ({ id: s(l.id), name: s(l.name), eroeffnet: l.eroeffnet === true })), modell: s(a.modell), datum: s(a.datum), + ...(a.veraltet === true ? { veraltet: true } : {}), }; } @@ -672,6 +674,7 @@ export function parseHinweise(text: string): Hinweis[] { quelle: eines(h.quelle, ['lokal', 'ki'] as const, 'ki'), offen: h.offen === true ? true : undefined, status: eines(h.status, ['neu', 'absicht', 'erledigt'] as const, 'neu'), + veraltet: h.veraltet === true ? true : undefined, }), ); } @@ -1310,6 +1313,14 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp manuskriptRoh.dateien, manuskriptRoh.eintraege, projekt.kapitel, planDateien, sprache, ); const nichtGeladen = nichtGeladeneKapitel(konflikte); + const archiv: NonNullable = {}; + const archivIndex = await lies(PFADE.archiv, parseArchivIndex); + for (const id of archivIndex?.kapitel ?? []) { + const k = await liesFrontmatter(`archiv/${kapitelPfad(id)}`, parseKapitel); + const p = await liesFrontmatter(`archiv/${planPfad(id)}`, parseKapitelPlan); + if (k && k.id === id && (!p || p.id === id)) archiv[id] = { kapitel: k, plan: p ?? standardPlan(k) }; + else warnungen.push({ pfad: `archiv/${kapitelPfad(id)}`, grund: sprache === 'de' ? 'Archivkapitel fehlt oder hat eine widersprüchliche ID.' : 'Archived chapter is missing or has an inconsistent ID.' }); + } const reihenfolge: Id[] = []; const gesehen = new Set(); const dazu = (id: Id) => { @@ -1334,6 +1345,8 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp return { daten: { manuskriptKonflikte: konflikte, + archiv, + reservierteKapitelIds: archivIndex?.reihenfolge ?? [], projekt: { ...projekt, kapitel: reihenfolge }, kapitel, plaene, @@ -1373,3 +1386,17 @@ export async function ladeProjektMitBericht(leser: ProjektLeser, sprache: LadeSp export async function ladeProjekt(leser: ProjektLeser): Promise { return (await ladeProjektMitBericht(leser)).daten; } + +/** Strikte Archiv-Metadaten: keine Pfade aus handbearbeiteten IDs konstruieren. */ +export function parseArchivIndex(text: string): { version: 1; kapitel: Id[]; reihenfolge: Id[] } { + const roh: unknown = JSON.parse(text); + if (!istObjekt(roh) || roh.version !== 1 || !Array.isArray(roh.kapitel) || !Array.isArray(roh.reihenfolge)) throw new Error('Invalid archive index'); + const ids = (v: unknown[]): string[] => { + if (v.some(id => typeof id !== 'string' || !id || /[/\\]|^\.{1,2}$/.test(id)) || new Set(v).size !== v.length) throw new Error('Invalid archive IDs'); + return v as string[]; + }; + const kapitel = ids(roh.kapitel); + const reihenfolge = ids(roh.reihenfolge); + if (kapitel.some(id => !reihenfolge.includes(id))) throw new Error('Invalid archive order'); + return { version: 1, kapitel, reihenfolge }; +} diff --git a/core/projekt/schreiben.ts b/core/projekt/schreiben.ts index 998d092..2961e10 100644 --- a/core/projekt/schreiben.ts +++ b/core/projekt/schreiben.ts @@ -83,9 +83,9 @@ const S = { gruppe: ['id', 'name', 'typ', 'status', 'mitglieder', 'strang', 'spuren', 'praesenz', 'netz', 'rollen'], spur: ['id', 'text', 'kapitel', 'anker', 'status'], ort: ['id', 'name', 'aliase', 'in'], - hinweis: ['schluessel', 'typ', 'titel', 'zitate', 'erklaerung', 'quelle', 'offen', 'status'], + hinweis: ['schluessel', 'typ', 'titel', 'zitate', 'erklaerung', 'quelle', 'offen', 'status', 'veraltet'], zitat: ['kapitel', 'text'], - analyse: ['kapitel', 'textHash', 'zusammenfassung', 'kurz', 'wendung', 'antagonistenBezug', 'loseEnden', 'modell', 'datum'], + analyse: ['kapitel', 'textHash', 'zusammenfassung', 'kurz', 'wendung', 'antagonistenBezug', 'loseEnden', 'modell', 'datum', 'veraltet'], } as const; type Roh = Record; diff --git a/docs/architektur.md b/docs/architektur.md index cabf05b..28c4661 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -47,6 +47,9 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ├── plan/ideen.md Frontmatter: ideen[] ├── plan/motive.md Frontmatter: motive[] (id, name, aliase) – Motive im Tab „Erkannt“ (S-10) ├── plan/kapitel/.md Frontmatter: KapitelPlan ohne notiz (szene = „Kurzbeschreibung“, funktion? = „Funktion im Buch“) · Body: notiz +├── archiv/index.json {version: 1, kapitel: [archivierte IDs], reihenfolge: [aktive + archivierte IDs]} +├── archiv/manuskript/.md Archivierter Manuskriptstand, unveränderte Originaldatei +├── archiv/plan/kapitel/.md Archivierter Kapitelplan einschließlich Notizen, unveränderte Originaldatei ├── figuren/.md Frontmatter: Figur ohne wahrheit/notizen · Body: "## Wahrheit hinter den Kulissen" + "## Notizen" ├── gruppen/.md Frontmatter: Gruppe ohne notizen · Body: "## Notizen" ├── orte/.md Frontmatter: Ort ohne beschreibung/notizen · Body: Beschreibung + "## Notizen" @@ -77,6 +80,21 @@ Design: `docs/design/screens/*.dc.html` (verbindlich, Pixelwerte und Farben exak ``` `projekt.md` hat zusätzlich das optionale Feld `angaben` (autor, untertitel, genre, sprache; G7, nicht an die KI). + +**Kapitelarchiv (Issue #13, Paket 1):** Archivierte Kapitel stehen nicht in `projekt.md → kapitel` und werden +separat als `ProjektDaten.archiv` geladen, nie als aktiver Text/Plan, Export oder KI-Kontext. Die IDs bleiben +reserviert. `archiv/index.json` erhält die alte Position anhand der Nachbarn; aktive Umordnungen haben Vorrang. +Vor Archivieren und Wiederherstellen entsteht immer ein **neuer** Schnappschuss von Manuskript und vorhandenem +Plan im ursprünglichen `.versionen//`-Verlauf (auch bei unverändertem Inhalt in derselben Sekunde). +Bei einem I/O-Fehler setzt ein Änderungsjournal bereits ausgeführte Schreibvorgänge zurück. Ist auch das +Rücksetzen nicht möglich, meldet die App den Fehler ausdrücklich; eine Mehrdatei-Crash-Transaktion bietet die +Dateisystem-Schnittstelle nicht. Anmerkungen und sonstige ID-Verweise bleiben unverändert im Projekt; +Verweisbeschriftungen zeigen „archiviert“. `.plotwerk/analysen/.json` und betroffene Einträge in +`.plotwerk/hinweise.json` bleiben erhalten, einschließlich unbekannter JSON-Felder, und erhalten additiv +`veraltet: true`. Die Markierung bleibt auch nach Wiederherstellen bestehen; der Analyse-Cache verwendet +solche Ergebnisse nicht, und veraltete Hinweise werden nicht als aktuelle Hinweise angezeigt. Keine automatische +KI-Neuberechnung. Archivieren/Wiederherstellen verändert nur den Wortzahlstand, zählt nicht als Schreiben. + KI-Ablagen haben alle die Form `{version: 1, eintraege: [], lauf?: {datum, modell, kostenUsd?}}`. Fehlende Werkzeug-Dateien füllt `ladeProjekt` mit Standardwerten (`leereZeitleiste()` mit `STANDARD_ZUSTANDSARTEN` usw.); KI-Ablagen fehlen in `ProjektDaten`, solange es die Datei nicht gibt. Farben in Daten (`ZustandsArt.farbe`) sind diff --git a/docs/issue-13-handover.md b/docs/issue-13-handover.md new file mode 100644 index 0000000..62db38e --- /dev/null +++ b/docs/issue-13-handover.md @@ -0,0 +1,60 @@ +# Issue #13 – Handover: Kapitelarchiv (Paket 1) + +## Grundlage und Umfang + +- Basis: ausgecheckter Stand `8eee912c8e00e702d2a2b6e5554fa30ea1819849`, identisch mit der lokalen Referenz + `origin/main`; Arbeitsbaum vor Beginn sauber. Keine fremden PR-Branches, Cherry-Picks oder Abhängigkeiten + auf fremden PR-Code. Keine Commits, Branch-Erstellung, Pushes, PR-Erstellung oder Deployments. +- Verbindlich ist die bereitgestellte strukturierte Issue-Fassung: **nur Archivieren/Wiederherstellen**. + Die ursprüngliche Beschreibung und der Titel nennen außerdem Teilen/Zusammenführen; diese sind durch + die spätere Umfangsentscheidung ausdrücklich Pakete 2 und 3 und hier nicht implementiert. +- `CLAUDE.md`, `CONTRIBUTING.md`, Architektur und Designreferenzen geprüft; im Workspace kein `AGENTS.md`. + `kb ask "Kapitel archivieren: Schnappschüsse, Projektformat und Zuständigkeiten Issue 13"` lieferte keine Notiz. +- **Prüflücke:** `gh issue view 13 --repo leonatwork/plotwerk --comments` und die Abfrage offener PRs wurden + vom Harness verweigert. Vollständiger Live-Thread und aktuelle PR-Abhängigkeiten sind daher nicht + verifiziert. Vor Review mit dem Live-Thread abgleichen; Grundlage war der vollständig im Auftrag + mitgelieferte Issue-Text. Keine bekannte Code-Abhängigkeit auf offene PRs. + +## Akzeptanzkriterien und Umsetzung + +| AC | Verbindliches Kriterium | Nachweis | +| --- | --- | --- | +| 1 | Manuskript/Plan nach `archiv/`, aus aktiver Reihenfolge entfernen; Text/Plan/Notizen erhalten | `core/projekt/archiv.test.ts`: rohe Dateien bytegleich, echte Demo-Anmerkungen unverändert; Abnahmetest mit Flut | +| 2 | Neuer Schnappschuss vor Archivieren und Wiederherstellen | Beide Dateien im ursprünglichen Versionsverlauf, neue Kollisionsnummer auch bei identischem Inhalt in derselben Sekunde | +| 3 | Kein Archivkapitel in Kapitelliste, Export oder KI-Kontext | UI-Test; unabhängiges DOCX-Lesen mit mammoth und `baueBuchKontext` im Abnahmetest | +| 4 | Alte Position/ID wiederherstellen, keine ID doppelt vergeben | Nachbarn im Archivindex; Test mehrerer benachbarter Kapitel, Wiederherstellung, Neuanlage mit gleichem Titel und Zielkollision | +| 5 | Schnappschussfehler ohne Änderung, Meldung DE/EN | MemoryFs-Tests vor erster/zweiter Sicherung und beim Wiederherstellen; UI-Fehleranzeige in beiden Sprachen und erfolgreicher erneuter Versuch | +| 6 | Analysen/Hinweise erhalten und veraltet behandeln | Additives `veraltet: true`; Flut-Test mit realen kapitelübergreifenden Hinweisen; Ausschluss aktueller Hinweise und Regressionstest für KI-Cache | +| 7 | Befehle in Liste/Inspektor, DE/EN, Projektformat dokumentiert | Beide Komponenten, Archiv-Inspektor über Liste/Verweis, Wörterbücher und `docs/architektur.md` | +| 8 | Vitest core/MemoryFs/Demo, Abnahme AC-1–6, Qualitätschecks | Testdateien und Läufe unten | + +Archivierte Dateien liegen unter `archiv/manuskript/.md` und `archiv/plan/kapitel/.md`. +`archiv/index.json` hält reservierte IDs und die vollständige Reihenfolge. Aktive Umordnungen haben Vorrang; +archivierte Kapitel bleiben vor ihrem ursprünglichen nächsten verfügbaren Nachbarn, sonst am Ende. +Anmerkungen und weitere ID-Verweise bleiben in ihren bestehenden Ablagen, Beschriftungen zeigen „archiviert“. +Veraltete KI-Ergebnisse bleiben auch nach Wiederherstellung veraltet, bis eine ausdrücklich gestartete neue +Prüfung sie ersetzt. Keine automatische KI-Neuberechnung. Wiederherstellung zählt nicht als Schreiben. + +## TDD und Prüfungen + +- Zuerst Core-Regressionstests: ohne Modul Importfehler; mit minimaler werfender API vier fachlich rote Tests. +- Vor State/UI-Implementierung: beide UI- und beide Abnahmetests rot (fehlende Befehle/Store-Aktionen). +- Zusätzliche Regressionen vor den jeweiligen Korrekturen: externe Archivübernahme, Öffnen archivierter + Verweise und erneuter Versuch nach Sicherungsfehler nachweislich rot, danach grün. +- `npm test`: finaler vollständiger Lauf grün, **1.253 Tests in 122 Dateien**. +- `npm run typecheck`, `npm run build`: grün. Build meldet die bestehende Chunkgrößenwarnung. +- `npm run sperrliste`: Exit 0, aber **keine inhaltliche Prüfung**, weil + `/home/hermes/.config/plotwerk/sperrliste.txt` auf diesem Server fehlt. +- `git diff --check`: grün. + +## Offene Abnahme und technische Grenze + +- Manuell auf dem Mac noch auszuführen: Demo-Kapitel Flut mit Anmerkungen archivieren, Archiv-Inspektor und + Wiederherstellung prüfen; Markdown/Plan und Schnappschüsse in Obsidian vergleichen. Dasselbe mit englischer + Oberfläche. Screenshot/Video für den späteren PR ergänzen. Linux bietet hier keine Mac-/Tauri-Abnahme. +- Ein I/O-Fehler wird über ein Änderungsjournal zurückgesetzt, einschließlich neuer Sicherungen. + Die absolute Forderung „ohne Änderung bei jedem Fehler“ ist bei gleichzeitigem **Rollback-I/O-Fehler** + oder Prozessabbruch nicht garantierbar: `ProjektDateisystem` bietet keine Mehrdatei-Transaktion. + Bei fehlgeschlagenem Rücksetzen erscheint eine explizite Meldung. Diese Grenze ist auch in der Architektur + festgehalten; Schnappschussfehler im regulären Fehlerpfad sind unverändert/rollback-geprüft. +- Projektformat und Datenmodell sind additiv. `core/ki` bleibt ausschließlich lesend; Import-Schutztests grün. diff --git a/src/features/akte/KapitelInspektor.tsx b/src/features/akte/KapitelInspektor.tsx index 23626c8..35b6746 100644 --- a/src/features/akte/KapitelInspektor.tsx +++ b/src/features/akte/KapitelInspektor.tsx @@ -188,6 +188,20 @@ export function KapitelPlanInspektor({ id, daten, index, zurueck }: { id: Id; da const uid = useId(); const t = useTexte(); const ti = t.akte.inspektor; + const fehler = useStore((s) => s.speichern.fehler); + const archivieren = useStore((s) => s.kapitelArchivieren); + const wiederherstellen = useStore((s) => s.kapitelWiederherstellen); + const archiviert = daten.archiv?.[id]; + if (archiviert) return ( + + {t.schreiben.archiv.status} +

{archiviert.kapitel.titel}

+ + + + {fehler && {fehler}} +
+ ); if (!plan) return null; const nr = daten.projekt.kapitel.indexOf(id) + 1; const hatText = !!daten.kapitel[id]?.text.trim(); @@ -332,6 +346,8 @@ export function KapitelPlanInspektor({ id, daten, index, zurueck }: { id: Id; da daten={daten} /> + + {fehler && {fehler}} {geplant && !hatText ? ( } + {Object.keys(archiv ?? {}).length > 0 && ( +
+ {t.schreiben.archiv.liste} + {Object.entries(archiv ?? {}).map(([id, eintrag]) => ( +
+ + +
+ ))} +
+ )} + {fehler && {fehler}}