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