From a845c62fa58e56e0d15224b98a45c550404cddd8 Mon Sep 17 00:00:00 2001 From: leonatwork Date: Tue, 20 Oct 2026 13:26:47 +0000 Subject: [PATCH] =?UTF-8?q?agent:=20Bearbeite=20GitHub-Issue=20#12=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/12 Titel: [Agent] Automatische Sicherung als ZIP außerhalb des Projektordners Beschreibung: ## Ziel und Nutzen Plotwerk sichert das Projekt automatisch als ZIP außerhalb des Projektordners, damit ein Stand bleibt, wenn der Ordner verloren geht, beschädigt wird oder ein Sync-Dienst ihn zerstört. ## Ist-Zustand / Problem Gesichert wird nur im Projektordner (.versionen/). Der fs_*-Pfadwächter (pfad.rs) erlaubt keine externen Pfade. extern_dateien.rs schreibt nur an Pfade aus dem nativen Dialog. jszip ist bereits Abhängigkeit. Die Frist beim Beenden beträgt 3 s (beenden.rs). ## Umfang (In Scope) - Paket 1: reine Funktionen in core/ (Dateiauswahl, ZIP-Aufbau, Dateiname, Rotation) und ein enger Rust-Command für .zip im Sicherungsordner - Paket 2: Einstellungen (an/aus, Ort, mit/ohne .versionen/), tägliche Sicherung beim ersten Öffnen, Menüpunkt 'Ablage → Projekt als Archiv sichern …', Fehlerhinweis in DE/EN, Ausblenden in der Browser-Demo - Paket 3: Wiederherstellen vom Startbildschirm in einen neuen, leeren Ordner, inklusive Hinweis bei leerer Liste ## Nicht Teil dieses Issues (Out of Scope) - Sicherung in Cloud oder auf Server - Verschlüsselung der ZIPs - Wiederherstellen in den bestehenden Projektordner - Änderungen an der Schnappschuss-Politik E8 - Erkennen von Sync-Konflikten - Sicherung beim Beenden (nicht gewählt) - Änderungen am fs_*-Pfadwächter ## Fachliche und UX-Entscheidungen - Geklärt (Leon): Aufteilung in 3 Pakete (Kern/Rust, Auslöser/UI, Wiederherstellen) - Geklärt (Leon): Auslöser ist täglich beim ersten Öffnen - Geklärt (Leon): Standardort ~/Library/Application Support/app.plotwerk.mac/Sicherungen// - Geklärt (Code): .plotwerk/ wird mitgesichert; der SQLite-Index liegt nicht im Projektordner - Geklärt (Code): Ein Ordner nach Wahl braucht eine eigene, enge Freigabe im neuen Command - Annahme: Dateiname --.zip, die Rotation fasst nur dieses Muster an - Annahme: Aufbewahrung 7 täglich und 4 wöchentlich - Annahme: Recherche-Bilder werden mitgesichert - Annahme: Sicherungsfehler blockieren nie das Schreiben, Hinweis mit Grund - Annahme: In der Browser-Demo entfällt die Sicherung, Menüpunkt und Einstellung sind ausgeblendet ## Prüfbare Akzeptanzkriterien - AC-1: Wenn ich ein Projekt an einem Tag zum ersten Mal öffne, dann liegt danach eine ZIP-Datei im Sicherungsordner, und der Projektordner bleibt unverändert. (Paket 2) - AC-2: Die ZIP-Datei enthält alle Projektdateien inklusive .plotwerk/, aber keine Temp-Dateien und kein .DS_Store. .versionen/ fehlt, wenn die Einstellung aus ist. (Paket 1) - AC-3: Nach mehr als 7 täglichen Sicherungen bleiben die neuesten 7 Tagesstände und je Woche einer für 4 Wochen. Andere Dateien im Ordner bleiben unberührt. (Paket 1) - AC-4: 'Ablage → Projekt als Archiv sichern …' legt sofort eine Sicherung an, auch wenn es heute schon eine gibt. (Paket 2) - AC-5: Vom Startbildschirm aus lässt sich eine Sicherung in einen neuen Ordner entpacken und öffnen. Der Inhalt gleicht dem gesicherten Stand. (Paket 3) - AC-6: Der neue Rust-Command lehnt Pfade außerhalb des Sicherungsordners, '..' und andere Dateiendungen als .zip ab. (Paket 1) - AC-7: Ist der Ordner nicht beschreibbar, erscheint ein Hinweis mit Grund in DE und EN. Schreiben und Autosave laufen weiter. (Paket 2) - AC-8: Die automatische Sicherung beim ersten Öffnen verlängert das Beenden nicht über die bestehende Frist von 3 s hinaus. Es gibt keinen Sicherungslauf beim Beenden. (Paket 2) ## QS und Nachweis - Vitest in core/: Dateiauswahl, ZIP-Aufbau gegen MemoryFs, Rotation mit festen Zeitpunkten - UI-Tests für Menübefehl, Einstellung und Wiederherstellen-Dialog mit gemocktem Backend (Paket 2 und 3) - Rust-Unit-Tests für Pfadprüfung und Namensmuster - npm test, npm run typecheck und npm run build grün; npm run sperrliste vor dem Commit; Commits mit -s - Manuell auf dem Mac: Tages-Sicherung, Ordner nach Wahl, Wiederherstellen mit Recherche-Bildern, nicht beschreibbarer Ordner ## 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, dass Plotwerk mein Projekt regelmäßig als ZIP außerhalb des Projektordners sichert, damit ich auch dann einen Stand habe, wenn der Ordner selbst beschädigt, gelöscht oder von einem Sync-Dienst zerstört wird. ### Ist-Zustand / Problem - Gesichert wird nur im Projektordner: Autosave und Schnappschüsse in `.versionen/` (`docs/handbuch.md:53,224`, E8). Die Aufbewahrung regeln `isoWoche` und `zuLoeschen` (`core/versionen/index.ts:107-137`) sowie `aufbewahrungAnwenden` (`core/versionen/index.ts:162`). - Geht der Projektordner verloren, sind die Schnappschüsse mit weg. Eine Sicherung an anderem Ort gibt es nicht. - Alle `fs_*`-Commands arbeiten relativ zum geöffneten Projekt. Der Pfadwächter lehnt absolute Pfade und `..` ab (`src-tauri/src/pfad.rs:1-30`). Dateien außerhalb schreibt nur `extern_dateien.rs`, und nur an Pfade, die der native Dialog in dieser Sitzung geliefert hat (`src-tauri/src/extern_dateien.rs:1-2`). - `jszip` ist schon eine Abhängigkeit (`package.json:35`) und wird in `core/epub` und `core/docx` genutzt. - Beim Beenden wartet der Rust-Kern höchstens 3 s auf die Oberfläche (`src-tauri/src/beenden.rs:12`). ### Umfang (In Scope) - ZIP des ganzen Projekts mit allen Projektdateien inklusive `.plotwerk/` und `recherche/`. Ohne Temp-Dateien und `.DS_Store`. `.versionen/` optional (Einstellung). - Auslöser: einmal täglich beim ersten Öffnen des Projekts und/oder beim Beenden (siehe Offen). - Zielordner: Standard `~/Library/Application Support/app.plotwerk.mac/Sicherungen//`, alternativ ein Ordner nach Wahl. Gespeichert als App-Einstellung (`einstellung_lesen`/`einstellung_schreiben`, `src-tauri/src/projekt.rs:401,411`), nie im Projektordner. - Rotation, z. B. 7 tägliche und 4 wöchentliche Sicherungen. Logik als reine Funktion in `core/` (wie `zuLoeschen`). - Menüpunkt „Ablage → Projekt als Archiv sichern …“ (DE/EN) für eine Sicherung von Hand. - Wiederherstellen vom Startbildschirm (`src/app/Startbildschirm.tsx`): Sicherung wählen, in einen neuen, leeren Ordner entpacken und öffnen. - Rust: ein neuer, eng begrenzter Command. Er schreibt, listet und löscht nur ZIP-Dateien mit festem Namensmuster im Sicherungsordner. Der Pfadwächter für `fs_*` bleibt unverändert. - Einstellungen: an/aus, Ort, mit oder ohne `.versionen/`. Texte in `src/i18n/{de,en}`. ### Nicht Teil dieses Issues (Out of Scope) - Sicherung in Cloud-Dienste oder auf Server. - Verschlüsselung der ZIP-Dateien. - Wiederherstellen in den bestehenden Projektordner (Überschreiben). - Änderungen an der Schnappschuss-Politik E8. - Erkennen von Sync-Konflikten (siehe Issue zu Konfliktkopien und doppelten Kapitel-IDs). ### Fachliche und UX-Entscheidungen - Geklärt (Code): `.plotwerk/` ist kein Cache. Dort liegen die bezahlten KI-Ergebnisse (E2, `docs/architektur.md:12`; Pfade in `core/projekt/lesen.ts:81-117`). Es wird deshalb mitgesichert. Der einzige Cache ist der SQLite-Index `plotwerk-index.db` (`src-tauri/src/index.rs:7`), und der liegt nicht im Projektordner. - Geklärt (Code): Externe Pfade sind heute nur über den Dialog erlaubt. Ein gemerkter Ordner nach Wahl braucht daher eine eigene, enge Freigabe im neuen Command. - Annahme: Dateiname `--.zip`. Die Rotation fasst nur Dateien mit diesem Muster an. - Annahme: Eine Sicherung beim Beenden darf das Beenden nicht über die 3-s-Frist hinaus aufhalten. Reicht die Zeit nicht, entfällt sie, und die nächste tägliche Sicherung holt sie nach. - Annahme: Fehler beim Sichern blockieren nie das Schreiben. Sie erscheinen als Hinweis mit Grund. - Annahme: In der Browser-Demo entfällt die Sicherung. Menüpunkt und Einstellung sind dort ausgeblendet. - Leerer Zustand: Ohne Sicherungen zeigt „Wiederherstellen“ einen Hinweis. - Offen (Leon): Standardort `~/Library/Application Support/app.plotwerk.mac/Sicherungen/` oder ein sichtbarer Ordner, etwa in `~/Documents`? - Offen (Leon): Aufbewahrung 7 täglich und 4 wöchentlich, oder anders? - Offen (Leon): Täglich, beim Beenden oder beides? - Offen (Leon): Recherche-Bilder (`recherche/dateien/`) mitsichern? Sie können groß werden. ### Prüfbare Akzeptanzkriterien AC-1: Wenn ich ein Projekt an einem Tag zum ersten Mal öffne, dann liegt danach eine ZIP-Datei im Sicherungsordner, und der Projektordner bleibt unverändert. AC-2: Die ZIP-Datei enthält alle Projektdateien inklusive `.plotwerk/`, aber keine Temp-Dateien und kein `.DS_Store`. `.versionen/` fehlt, wenn die Einstellung aus ist. AC-3: Nach mehr als 7 täglichen Sicherungen bleiben die neuesten 7 Tagesstände und je Woche einer für 4 Wochen. Andere Dateien im Ordner bleiben unberührt. AC-4: „Ablage → Projekt als Archiv sichern …“ legt sofort eine Sicherung an, auch wenn es heute schon eine gibt. AC-5: Vom Startbildschirm aus lässt sich eine Sicherung in einen neuen Ordner entpacken und öffnen. Der Inhalt gleicht dem gesicherten Stand. AC-6: Der neue Rust-Command lehnt Pfade außerhalb des Sicherungsordners, `..` und andere Dateiendungen als `.zip` ab. AC-7: Fehlerfall: Ist der Ordner nicht beschreibbar, erscheint ein Hinweis mit Grund in DE und EN. Schreiben und Autosave laufen weiter. AC-8: Das Beenden dauert durch die Sicherung nicht länger als die bestehende Frist von 3 s. ### QS und Nachweis - Agent: Vitest in `core/` für Dateiauswahl (Ausschlüsse), ZIP-Aufbau mit `jszip` gegen `MemoryFs` und Rotation (Zeitpunkte fest vorgegeben). UI-Tests für Menübefehl, Einstellung und Wiederherstellen-Dialog mit gemocktem Backend. Rust-Unit-Tests für den neuen Command (Pfadprüfung, Namensmuster); `cargo test` läuft in der CI auf macOS. `npm test`, `npm run typecheck` und `npm run build` grün. - Manuell auf dem Mac: tägliche Sicherung, Sicherung beim Beenden, Ordner nach Wahl, Wiederherstellen eines Projekts mit Recherche-Bildern, nicht beschreibbarer Ordner. ### Technischer Kontext und Referenzen - Relevante Dateien: `core/versionen/index.ts`, `core/projekt/lesen.ts` (`PFADE`), `core/epub/index.ts` (Beispiel für `jszip`), `src-tauri/src/extern_dateien.rs`, `src-tauri/src/pfad.rs`, `src-tauri/src/projekt.rs`, `src-tauri/src/beenden.rs`, `src-tauri/src/menue.rs`, `src/app/Startbildschirm.tsx`, `src/state/funktionen.ts` (Muster für App-Einstellungen). - Architektur: E2, E8, E9 und „Rust-Kommandos“ in `docs/architektur.md`. - Verwandt: Issue zu Konfliktkopien von iCloud und OneDrive und doppelten Kapitel-IDs. ### 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 2 round(s). Generated by dev-loop (not pushed). Signed-off-by: leonatwork --- core/sicherungen/index.test.ts | 57 +++ core/sicherungen/index.ts | 101 ++++++ docs/issue-12-handover.md | 63 ++++ src-tauri/src/lib.rs | 16 + src-tauri/src/menue.rs | 6 + src-tauri/src/projekt.rs | 4 +- src-tauri/src/sicherungen.rs | 336 ++++++++++++++++++ src/app/App.tsx | 1 + src/app/Startbildschirm.tsx | 4 + src/app/befehle.ts | 9 + src/backend/tauri/archiv.test.ts | 68 ++++ src/backend/tauri/archiv.ts | 53 +++ src/backend/tauri/backend.test.ts | 15 + src/backend/tauri/index.ts | 2 + src/backend/types.ts | 12 + src/features/ki/EinstellungenDialog.tsx | 2 + src/features/sicherungen/dienst.ts | 15 + src/features/sicherungen/sicherungen.test.tsx | 85 +++++ src/features/sicherungen/ui.tsx | 63 ++++ src/i18n/de/index.ts | 2 + src/i18n/de/sicherungen.ts | 9 + src/i18n/en/index.ts | 2 + src/i18n/en/sicherungen.ts | 11 + src/state/store.ts | 2 + 24 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 core/sicherungen/index.test.ts create mode 100644 core/sicherungen/index.ts create mode 100644 docs/issue-12-handover.md create mode 100644 src-tauri/src/sicherungen.rs create mode 100644 src/backend/tauri/archiv.test.ts create mode 100644 src/backend/tauri/archiv.ts create mode 100644 src/features/sicherungen/dienst.ts create mode 100644 src/features/sicherungen/sicherungen.test.tsx create mode 100644 src/features/sicherungen/ui.tsx create mode 100644 src/i18n/de/sicherungen.ts create mode 100644 src/i18n/en/sicherungen.ts diff --git a/core/sicherungen/index.test.ts b/core/sicherungen/index.test.ts new file mode 100644 index 0000000..13fa111 --- /dev/null +++ b/core/sicherungen/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import JSZip from 'jszip'; +import { MemoryFs } from '../projekt/memoryFs'; +import { archivDateien, baueArchiv, archivName, archivZeit, rotation, entpackeArchiv } from './index'; + +describe('externe Projektsicherungen', () => { + it('sichert alle Dateien inklusive KI, unbekannter Dateien und binärer Recherche unverändert', async () => { + const fs = new MemoryFs({ 'projekt.md': '---\ntitel: Hafenlichter\nkapitel: []\n---\n', '.plotwerk/hinweise.json': '[]', 'extras/notizen.txt': 'Notiz', '.versionen/manuskript/a/2026.md': 'alt', '.DS_Store': 'x', 'manuskript/.a.md.plotwerk-1-2.tmp': 'x', 'extras/a.tmp': 'x' }); + await fs.schreibenBinaer('recherche/dateien/bild.png', new Uint8Array([0, 255, 128])); + const vorher = fs.alleDateien(); + const schreiben = vi.spyOn(fs, 'schreiben'); + const daten = await baueArchiv(fs, false); + const zip = await JSZip.loadAsync(daten); + expect(Object.keys(zip.files).filter((p) => !zip.files[p]!.dir).sort()).toEqual(['.plotwerk/hinweise.json', 'extras/notizen.txt', 'projekt.md', 'recherche/dateien/bild.png']); + expect(await zip.file('recherche/dateien/bild.png')!.async('uint8array')).toEqual(new Uint8Array([0, 255, 128])); + expect(fs.alleDateien()).toEqual(vorher); + expect(schreiben).not.toHaveBeenCalled(); + expect(archivDateien(Object.keys(vorher), true)).toContain('.versionen/manuskript/a/2026.md'); + expect(await entpackeArchiv(daten)).toEqual(expect.arrayContaining([{ pfad: 'recherche/dateien/bild.png', daten: [0, 255, 128] }])); + }); + + it('verwirft unsichere Pfade und Archive ohne projekt.md', async () => { + expect(() => archivDateien(['../privat.md'], true)).toThrow(); + const zip = new JSZip().file('../projekt.md', 'x'); + await expect(entpackeArchiv(await zip.generateAsync({ type: 'uint8array' }))).rejects.toThrow(); + await expect(entpackeArchiv(await new JSZip().file('notiz.md', 'x').generateAsync({ type: 'uint8array' }))).rejects.toThrow(); + }); + + it('liefert lokale Datumsnamen und überschreibt auch in derselben Minute keinen Stand', () => { + const zeit = new Date(2026, 9, 20, 12, 34); + expect(archivName('Hafenlichter', zeit, [])).toBe('Hafenlichter-2026-10-20-1234.zip'); + expect(archivName('Hafenlichter', zeit, ['Hafenlichter-2026-10-20-1234.zip'])).toBe('Hafenlichter-2026-10-20-1235.zip'); + expect(archivName('../Hafen:lichter', zeit, [])).not.toMatch(/[/:\\]/); + }); + + it.each([' Hafenlichter ', 'Ha\u0308fenlichter', ' Ha\u0308fenlichter '])('behält den von Rust geprüften Ordnernamen %j exakt bei', (projekt) => { + const zeit = new Date(2026, 9, 20, 12, 34); + const name = `${projekt}-2026-10-20-1234.zip`; + const folge = `${projekt}-2026-10-20-1235.zip`; + expect(archivName(projekt, zeit, [])).toBe(name); + expect(archivName(projekt, zeit, [name])).toBe(folge); + expect(archivZeit(name, projekt)).toEqual(zeit); + const fremd = `${projekt.normalize('NFC').trim()}-2026-10-20-1100.zip`; + expect(archivZeit(fremd, projekt)).toBeNull(); + expect(rotation([name, folge, fremd], projekt)).toEqual([name]); + }); + + it('behält sieben verschiedene Tagesstände und vier ältere ISO-Wochen, nur eigene gültige ZIPs', () => { + const namen = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30].map((tag) => `Hafenlichter-2026-09-${String(tag).padStart(2, '0')}-1200.zip`); + const fremd = ['anders-2026-09-01-1200.zip', 'Notiz.txt', 'Hafenlichter-2026-02-30-1200.zip']; + const loeschen = rotation([...namen, ...fremd, 'Hafenlichter-2026-09-30-1100.zip'], 'Hafenlichter'); + expect(loeschen).not.toEqual(expect.arrayContaining(fremd)); + const behalten = namen.filter((n) => !loeschen.includes(n)); + expect(behalten).toEqual([6, 13, 20, 23, 24, 25, 26, 27, 28, 29, 30].map((tag) => `Hafenlichter-2026-09-${String(tag).padStart(2, '0')}-1200.zip`)); + expect(loeschen).toContain('Hafenlichter-2026-09-30-1100.zip'); + }); +}); diff --git a/core/sicherungen/index.ts b/core/sicherungen/index.ts new file mode 100644 index 0000000..4f4b9c9 --- /dev/null +++ b/core/sicherungen/index.ts @@ -0,0 +1,101 @@ +/** ZIP-Sicherungen: ausschließlich Lesen, keine Plattform- oder DOM-Abhängigkeit. */ +import JSZip from 'jszip'; +import type { ProjektLeser } from '../projekt/fs'; +import { isoWoche } from '../versionen'; + +export interface ArchivLeser extends ProjektLeser { + lesenBinaer?(pfad: string): Promise; +} + +export function sichererArchivPfad(pfad: string): boolean { + return !!pfad && !pfad.startsWith('/') && !/[\\:\x00]/.test(pfad) + && pfad.split('/').every((t) => !!t && t !== '.' && t !== '..'); +} + +export function archivDateien(pfade: string[], mitVersionen: boolean): string[] { + return pfade.filter((p) => { + if (!sichererArchivPfad(p)) throw new Error(`Ungültiger Archivpfad: ${p}`); + return !p.split('/').some((t) => t === '.DS_Store' || t.startsWith('._') || t.endsWith('.tmp') || t.endsWith('.temp') || t.endsWith('~')) + && (mitVersionen || !p.startsWith('.versionen/')); + }).sort(); +} + +export async function baueArchiv(leser: ArchivLeser, mitVersionen: boolean): Promise { + const zip = new JSZip(); + for (const pfad of archivDateien(await leser.auflisten(''), mitVersionen)) { + const daten = leser.lesenBinaer ? await leser.lesenBinaer(pfad) : await leser.lesen(pfad); + if (daten === null) throw new Error(`Datei während der Sicherung verschwunden: ${pfad}`); + zip.file(pfad, daten, { createFolders: false }); + } + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} + +export function projektName(titel: string): string { + // Rust prüft den rohen Ordnernamen als ZIP-Präfix: Unicode und Rand-Leerraum erhalten. + return titel.replace(/[\\/:\x00-\x1f]/g, '-').replace(/^\.+/, '') || 'Projekt'; +} + +const z = (n: number) => String(n).padStart(2, '0'); +export function lokalerTag(d: Date): string { + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; +} + +/** Bei Kollision die nächste freie Namensminute; tatsächlicher ZIP-Zeitstempel bleibt unverändert. */ +export function archivName(titel: string, zeit: Date, vorhanden: string[]): string { + const d = new Date(zeit); + const belegt = new Set(vorhanden); + for (;;) { + const name = `${projektName(titel)}-${lokalerTag(d)}-${z(d.getHours())}${z(d.getMinutes())}.zip`; + if (!belegt.has(name)) return name; + d.setMinutes(d.getMinutes() + 1); + } +} + +export function archivZeit(name: string, titel: string): Date | null { + const prefix = `${projektName(titel)}-`; + if (!name.startsWith(prefix)) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})\.zip$/.exec(name.slice(prefix.length)); + if (!m) return null; + const [, jahr, monat, tag, stunde, minute] = m; + const d = new Date(+jahr!, +monat! - 1, +tag!, +stunde!, +minute!); + return lokalerTag(d) === `${jahr}-${monat}-${tag}` && d.getHours() === +stunde! && d.getMinutes() === +minute! ? d : null; +} + +/** Sieben neueste verschiedene Tage, danach der neueste Stand je ISO-Woche für vier Wochen. */ +export function rotation(namen: string[], titel: string): string[] { + const gueltig = namen.flatMap((name) => { + const zeit = archivZeit(name, titel); + return zeit ? [{ name, zeit }] : []; + }).sort((a, b) => b.zeit.getTime() - a.zeit.getTime()); + const tage = new Set(); + const wochen = new Set(); + const behalten = new Set(); + for (const { name, zeit } of gueltig) { + const tag = lokalerTag(zeit); + if (tage.has(tag)) continue; + if (tage.size < 7) { tage.add(tag); behalten.add(name); continue; } + const woche = isoWoche(zeit); + if (!wochen.has(woche) && wochen.size < 4) { wochen.add(woche); behalten.add(name); } + } + return gueltig.filter((a) => !behalten.has(a.name)).map((a) => a.name); +} + +export interface ArchivDatei { pfad: string; daten: number[] } + +/** Vor Übergabe an Rust prüfen; Rust prüft erneut vor jeglichem Schreiben. */ +export async function entpackeArchiv(daten: Uint8Array): Promise { + const zip = await JSZip.loadAsync(daten, { checkCRC32: true }); + const dateien: ArchivDatei[] = []; + let groesse = 0; + for (const datei of Object.values(zip.files)) { + const original = (datei as typeof datei & { unsafeOriginalName?: string }).unsafeOriginalName ?? datei.name; + if (!sichererArchivPfad(original.replace(/\/$/, '')) || original !== datei.name) throw new Error(`Ungültiger Archivpfad: ${original}`); + if (datei.dir) continue; + const bytes = await datei.async('uint8array'); + groesse += bytes.length; + if (groesse > 1024 * 1024 * 1024 || dateien.length >= 100000) throw new Error('Archiv zu groß'); + dateien.push({ pfad: datei.name, daten: Array.from(bytes) }); + } + if (!dateien.some((d) => d.pfad === 'projekt.md')) throw new Error('projekt.md fehlt im Archiv'); + return dateien; +} diff --git a/docs/issue-12-handover.md b/docs/issue-12-handover.md new file mode 100644 index 0000000..1ce409d --- /dev/null +++ b/docs/issue-12-handover.md @@ -0,0 +1,63 @@ +# Issue #12 – externe ZIP-Sicherungen + +## Grundlage und Grenzen der Prüfung + +- Ausgangsstand: `f20de68326a41a8c862ab88f08d4ab7a815f3059`, identisch mit lokalem `origin/main`; sauberer Arbeitsbaum zu Beginn. Kein Branchwechsel, keine fremden PR-Änderungen übernommen. +- Verbindliche Grundlage ist der im Auftrag vollständig bereitgestellte strukturierte Issue-Text. **GitHub-Thread und offene PRs konnten nicht unabhängig geprüft werden:** `gh` ist durch die Tool-Berechtigungen gesperrt. Vor Freigabe bitte Kommentare und offene PRs auf nachträgliche Entscheidungen/Überschneidungen prüfen. Es ist keine bekannte offene-PR-Abhängigkeit eingebaut. +- Gelesen: `CLAUDE.md`, `CONTRIBUTING.md`, `docs/architektur.md`, Design-Screen-Verzeichnis und dessen README; Wissensbasis zu Architektur/Konventionen abgefragt. Kein `AGENTS.md` im Repository gefunden. +- Die geklärte Entscheidung „täglich beim ersten Öffnen, keine Sicherung beim Beenden“ hat Vorrang vor der ursprünglichen Beschreibung und ihrem manuellen Beenden-Test. +- Kein Commit, Push, Branch oder PR angelegt. Keine Releases/Deployments oder Produktionsdatenzugriffe. + +## Umsetzung und Akzeptanzkriterien + +| AC | Umsetzung / automatischer Nachweis | +| --- | --- | +| 1 | Projektladen startet eine unabhängige Tagesprüfung. Rust speichert den letzten erfolgreichen lokalen Kalendertag je kanonischem Projektpfad in App-Einstellungen. Archivkern liest ausschließlich; Fehler oder lange Archivläufe blockieren das Öffnen nicht. | +| 2 | ZIP mit allen gelisteten Dateien, `.plotwerk`, unbekannten Zusatzdateien und binären Recherche-Bildern. Ausschlüsse: `.DS_Store`, AppleDouble `._*`, `.tmp`, `.temp`, `*~`; `.versionen` optional. MemoryFs-/ZIP-Roundtrip-Test mit binären Bytes. | +| 3 | Neuester Stand der sieben verschiedenen jüngsten Tage, danach der jeweils neueste Stand in vier älteren ISO-Wochen. Nur gültige projektspezifische Datums-ZIP-Namen werden rotiert; andere Dateien bleiben unberührt. Feste Kalender-Testdaten. | +| 4 | Natives Menü „Ablage → Projekt als Archiv sichern …“ / „Back Up Project as Archive…“; Editoränderungen zuerst speichern, dann unabhängig vom Tagesmarker sichern. Wiederholter Menüaufruf getestet. | +| 5 | Startbildschirm → Wiederherstellungsdialog mit Liste und leerem Zustand; alternativ native ZIP-Auswahl. ZIP-Prüfung/CRC in TypeScript; Rust prüft alle relativen Pfade, Duplikate und Datei-/Ordnerkonflikte vor Schreiben und fordert einen neuen/leeren Zielordner. Danach öffnen/laden. Binäre Inhalte bleiben Bytes. | +| 6 | Neue, separat registrierte Archiv-Commands: Schreib-/Löschpfade werden vom Backend aus Projektkennung und freigegebenem Ort abgeleitet, keine beliebigen Zielpfade. Genaues Namensmuster, echte Kalendertage, `.zip`, Symlink-/Traversal-Prüfungen. Rust-Unit-Tests vorhanden, hier nicht ausführbar. `pfad.rs` unverändert. | +| 7 | DE/EN-Fehlerhinweis mit Ursache, im geöffneten Editor schließbar; Archivlauf außerhalb der Autosave-/Beenden-Warteschlange. Tests für beide Sprachen und weiterhin möglichen Schreibzugriff. | +| 8 | Kein ZIP-Beenden-Callback. Bestehende 2800-ms-Oberflächen-/3-s-Rust-Frist unverändert. Backend-Test beendet trotz hängender Tagesprüfung und prüft, dass keine Archiv-Commands beim Beenden gestartet werden. | + +Die Sicherung selbst verändert keine Projektdatei. Die bereits vorhandene E8-Schnappschussrotation beim Öffnen bleibt aktiv; die absolute Formulierung „Projektordner bleibt unverändert“ kann deshalb für den gesamten bestehenden Öffnungsvorgang nicht garantiert werden, ohne die ausdrücklich ausgeschlossene E8-Politik zu ändern. + +## Festgelegte Implementierungsdetails + +- Automatik standardmäßig an, `.versionen` standardmäßig aus. App-Einstellungen, niemals Projektmetadaten. +- Standardbasis: Tauri `app_data_dir()/Sicherungen`, auf macOS `~/Library/Application Support/app.plotwerk.mac/Sicherungen`; darunter Projektordnername. Alternativ native Ordnerwahl als Basis, ebenfalls mit Projekt-Unterordner. Wiederherstellungsliste umfasst die aktuell gewählte Basis; Archive früherer Orte bleiben über die ZIP-Auswahl erreichbar. +- Gleiche Projektordnernamen teilen unter derselben Sicherungsbasis einen Unterordner und eine Namensfamilie. Tagesmarker bleiben je Projektpfad getrennt. Bei mehreren gleichnamigen Projekten bitte unterschiedliche Sicherungsbasen verwenden; getrennte dauerhafte Projektidentitäten sind nicht Teil des vorhandenen Modells. +- Namenskollisionen in derselben Minute: nächste freie Namensminute statt Überschreiben; ZIP-Eintragsdatum bleibt die reale Archivzeit. Damit bleibt das vereinbarte Namensmuster erhalten. Bei sehr vielen Sicherungen bis über Mitternacht kann der Name zum nächsten Tag wandern; der Tagesmarker nutzt weiterhin den tatsächlichen Tag. +- Schreib-/Rotationsaufrufe prüfen, dass der Sicherungsort seit Laufbeginn nicht geändert wurde; ein Ortswechsel bricht den alten Lauf mit Hinweis ab. +- Wiederherstellung bis 1 GiB entpacktem Inhalt / 100.000 Dateien; native Archivlesegrenze 1 GiB. ZIP-Aufbau und Wiederherstellung erfolgen im Speicher. Bei abgebrochener Wiederherstellung kann ein teilweise befüllter neuer Zielordner bleiben; bestehende Dateien werden nie überschrieben. + +## TDD und Qualitätsnachweise + +- Archivkern-Tests vor Implementierung: roter Lauf wegen fehlendem `core/sicherungen/index.ts`. +- UI-Tests vor Implementierung: roter Lauf wegen fehlendem `src/features/sicherungen/ui.tsx`. +- Tauri-Adapter-Tests vor Implementierung: roter Lauf wegen fehlendem `src/backend/tauri/archiv.ts`. +- Zusätzliche Ortswechsel-Regression vor Fix: Assertion scheitert wegen fehlender Ortsbindung im Schreibheader; anschließend Header und Rust-Prüfung ergänzt. +- Rotationstest-Erwartung korrigiert: ISO-Wochen enden am Sonntag, daher 6./13./20. September statt 2./9./16.; keine Änderung der Fachregel. +- `npm test`: 122 Dateien / 1233 Tests grün beim Gesamtlauf. Nach Ergänzung der DE/EN-/Beenden-Tests abschließend die fünf relevanten Testdateien ausgeführt: 42 Tests grün (`core/sicherungen`, Sicherungen-UI, Tauri-Archivadapter, Tauri-Backend, i18n). Typecheck und Build danach erneut grün. +- `npm run typecheck` und `npm run build`: grün. Build meldet bestehende große Chunks; kein Buildfehler. +- `git diff --check`: grün. +- `npm run sperrliste`: beendet erfolgreich, **keine Begriffsprüfung**, weil `/home/hermes/.config/plotwerk/sperrliste.txt` fehlt. +- Rust fmt/Clippy/Unit-Tests und native Mac-Oberfläche konnten gemäß Servervorgabe nicht ausgeführt werden. Rust-Sicherheitstests sind als Quellcode vorhanden; ein tatsächlicher roter/grüner Rust-Lauf ist nicht nachgewiesen. + +## Noch erforderliche Mac-Abnahme + +1. Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`; gegebenenfalls Formatierung nachziehen. +2. Projekt erstmals öffnen, ZIP am Standardort prüfen; erneut am selben Tag öffnen, keine zweite automatische ZIP. Manuelles Archiv zweimal in derselben Minute erzeugen. +3. Ort über nativen Dialog ändern, App neu starten, gemerkten Ort prüfen; Projektunterordner und Symlink darauf als Ziel ablehnen. +4. ZIP mit Recherche-Bildern und `.plotwerk` wiederherstellen, Dateien byteweise vergleichen; belegten Zielordner ablehnen; leere Liste und abgebrochene Dialoge prüfen. +5. Nicht beschreibbaren Sicherungsordner auf DE/EN prüfen; weiter tippen und Autosave prüfen. Während eines großen ZIP-Laufs beenden, Frist ≤ 3 s. +6. Sichtbare UI auf dem Mac prüfen und Screenshot für Review ergänzen. Für Sicherungsdialog/Einstellungen existiert kein eigener Design-Screen; Umsetzung verwendet die vorhandenen Dialog-/Button-/Einstellungs-Bausteine ohne neue Farben. + +## Review-Korrektur: identisches Projektpräfix in TypeScript und Rust + +- Ausschließlich den gemeldeten Namensunterschied korrigiert: `projektName` erhält Unicode-Normalform und Leerraum am Rand des von Rust geprüften Ordnernamens. Dadurch verwenden ZIP-Aufbau, Kollisionserkennung und Rotation denselben exakten Präfix wie Rust. Keine Rust-Änderung erforderlich. +- Sechs neue Regressionstests für Rand-Leerzeichen, zerlegten Umlaut (`a` + U+0308) und deren Kombination: drei Kernfälle für Archivnamen/Kollisionen/Datum/Rotation sowie drei Adapterfälle mit gemockter Rust-Präfixprüfung. Alle sechs vor dem Fix mit dem erwarteten Namensfehler rot, danach grün. +- Nachweis: `npm test` mit 122 Dateien / 1241 Tests grün; `npm run typecheck`, `npm run build` und `git diff --check` grün. Nach einer reinen Typkorrektur beim Lesen des Test-Headers beide betroffenen Testdateien erneut ausgeführt: 14 Tests grün. +- GitHub-Thread-/PR-Prüfung weiterhin durch Tool-Berechtigungen gesperrt. Rust/Tauri gemäß Servervorgabe nicht gebaut. Auf dem Mac zusätzlich Tages- und manuelle Sicherung für Ordnernamen mit Rand-Leerzeichen und zerlegtem Umlaut prüfen. +- Kein Commit oder Push. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index afd4430..89997ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod pfad; mod projekt; mod recherche; mod schluessel; +mod sicherungen; mod sprache; mod ueberwachung; mod vorlesen; @@ -28,6 +29,8 @@ pub struct Zustand { einstellungen: Mutex<()>, extern_lesen: Mutex>, extern_schreiben: Mutex>, + archiv_lesen: Mutex>, + archiv_schreiben: Mutex<()>, /// Vom Öffnen-/Neu-Dialog gelieferte Projektordner erlaubt: Mutex>, ki: OnceLock, @@ -43,6 +46,8 @@ impl Default for Zustand { einstellungen: Mutex::new(()), extern_lesen: Mutex::default(), extern_schreiben: Mutex::default(), + archiv_lesen: Mutex::default(), + archiv_schreiben: Mutex::default(), erlaubt: Mutex::default(), ki: OnceLock::new(), beenden: beenden::Beenden::default(), @@ -87,6 +92,17 @@ pub fn run() { extern_dateien::datei_lesen_extern, extern_dateien::datei_speichern, extern_dateien::datei_schreiben_extern, + sicherungen::archiv_einstellungen, + sicherungen::archiv_einstellen, + sicherungen::archiv_ort_waehlen, + sicherungen::archiv_info, + sicherungen::archiv_projekt_lesen, + sicherungen::archiv_schreiben, + sicherungen::archiv_entfernen, + sicherungen::archiv_liste, + sicherungen::archiv_waehlen, + sicherungen::archiv_lesen, + sicherungen::archiv_wiederherstellen, ki::schluessel_setzen, ki::schluessel_vorhanden, ki::schluessel_loeschen, diff --git a/src-tauri/src/menue.rs b/src-tauri/src/menue.rs index 58845b7..a6396c0 100644 --- a/src-tauri/src/menue.rs +++ b/src-tauri/src/menue.rs @@ -111,6 +111,12 @@ pub fn erstellen(app: &AppHandle) -> tauri::Result> { t("Exportieren …", "Export…"), Some("CmdOrCtrl+E"), )?) + .item(&befehl( + app, + "projekt-archiv", + t("Projekt als Archiv sichern …", "Back Up Project as Archive…"), + None, + )?) .separator() .item(&befehl( app, diff --git a/src-tauri/src/projekt.rs b/src-tauri/src/projekt.rs index db1dce4..541d6aa 100644 --- a/src-tauri/src/projekt.rs +++ b/src-tauri/src/projekt.rs @@ -74,7 +74,7 @@ pub(crate) fn sperre(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> } /// Öffnet einen bereits geprüften Ordner: registrieren, Überwachung starten, merken. -fn oeffnen(app: &AppHandle, zustand: &Zustand, pfad: &Path) -> Result { +pub(crate) fn oeffnen(app: &AppHandle, zustand: &Zustand, pfad: &Path) -> Result { let wurzel = Wurzel::neu(pfad)?; let kennung = sperre(&zustand.projekte).oeffnen(wurzel.clone()); match ueberwachung::starten(app.clone(), wurzel.clone(), zustand.eigene.clone()) { @@ -414,7 +414,7 @@ pub async fn einstellung_schreiben( schluessel: String, wert: serde_json::Value, ) -> Result<(), String> { - if schluessel == einstellungen::LETZTES_PROJEKT { + if [einstellungen::LETZTES_PROJEKT, "sicherungsOrt", "sicherungen", "sicherungstage"].contains(&schluessel.as_str()) { return Err(crate::sprache::t( "„letztesProjekt“ wird nur beim Öffnen eines Projekts gesetzt.", "“letztesProjekt” is only set when a project is opened.", diff --git a/src-tauri/src/sicherungen.rs b/src-tauri/src/sicherungen.rs new file mode 100644 index 0000000..e2ab6b4 --- /dev/null +++ b/src-tauri/src/sicherungen.rs @@ -0,0 +1,336 @@ +//! Enge ZIP-Freigabe außerhalb des Projekts; fs_*-Pfadwächter bleibt unabhängig. +use std::collections::HashSet; +use std::path::{Component, Path, PathBuf}; + +use percent_encoding::percent_decode_str; +use serde::{Deserialize, Serialize}; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::{AppHandle, Manager, State, WebviewWindow}; +use tauri_plugin_dialog::DialogExt; + +use crate::projekt::{einstellungsdatei, sperre, ProjektInfo}; +use crate::{dateien, einstellungen, Zustand}; + +const ORT: &str = "sicherungsOrt"; +const OPTIONEN: &str = "sicherungen"; +const TAGE: &str = "sicherungstage"; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Optionen { + aktiv: bool, + mit_versionen: bool, + ort: Option, +} + +fn optionen(app: &AppHandle, zustand: &Zustand) -> Result { + let _s = sperre(&zustand.einstellungen); + let datei = einstellungsdatei(app)?; + let mut o = einstellungen::lesen(&datei, OPTIONEN) + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or(Optionen { aktiv: true, mit_versionen: false, ort: None }); + o.ort = einstellungen::lesen(&datei, ORT).and_then(|v| v.as_str().map(str::to_owned)); + Ok(o) +} + +fn fehler(e: impl std::fmt::Display) -> String { + crate::tf!("Sicherung nicht möglich: {e}", "Backup unavailable: {e}") +} + +fn name_sauber(name: &str) -> bool { + !name.is_empty() && !name.starts_with('.') && !name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':')) +} + +fn projektname(pfad: &Path) -> Result { + let name = pfad.file_name().and_then(|n| n.to_str()).unwrap_or("Projekt"); + if !name_sauber(name) { return Err(fehler("Ungültiger Projektname / invalid project name")); } + Ok(name.to_owned()) +} + +/// Validiert exakt -YYYY-MM-DD-HHmm.zip (echtes Kalenderdatum). +fn gueltiger_name(name: &str, projekt: &str) -> bool { + if !name_sauber(name) { return false; } + let Some(rest) = name.strip_prefix(&format!("{projekt}-")).and_then(|n| n.strip_suffix(".zip")) else { return false; }; + let b = rest.as_bytes(); + if b.len() != 15 || b[4] != b'-' || b[7] != b'-' || b[10] != b'-' + || b.iter().enumerate().any(|(i, c)| ![4, 7, 10].contains(&i) && !c.is_ascii_digit()) { return false; } + let zahl = |a: usize, z: usize| rest[a..z].parse::().unwrap_or(0); + let (jahr, monat, tag) = (zahl(0, 4), zahl(5, 7), zahl(8, 10)); + let schalt = jahr % 4 == 0 && (jahr % 100 != 0 || jahr % 400 == 0); + let tage = match monat { 2 => if schalt { 29 } else { 28 }, 4 | 6 | 9 | 11 => 30, 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, _ => 0 }; + jahr >= 1000 && tag > 0 && tag <= tage && zahl(11, 13) < 24 && zahl(13, 15) < 60 +} + +fn zip_pfad(ordner: &Path, name: &str, projekt: &str) -> Result { + if !gueltiger_name(name, projekt) { return Err(fehler("Ungültiger ZIP-Name / invalid ZIP name")); } + let p = ordner.join(name); + if let Ok(meta) = std::fs::symlink_metadata(&p) { + if !meta.is_file() || meta.file_type().is_symlink() { return Err(fehler("Kein reguläres ZIP / not a regular ZIP")); } + if p.canonicalize().map_err(fehler)?.parent() != Some(ordner) { return Err(fehler("ZIP außerhalb des Sicherungsordners / ZIP outside backup folder")); } + } + Ok(p) +} + +fn basis(app: &AppHandle, zustand: &Zustand) -> Result { + if let Some(ort) = optionen(app, zustand)?.ort { + let p = PathBuf::from(ort); + // Der gespeicherte Ort wird ausschließlich vom nativen Dialog gesetzt und kanonisch gemerkt. + if p.components().any(|c| matches!(c, Component::ParentDir)) { return Err(fehler("Ungültiger Ordner / invalid folder")); } + let echt = p.canonicalize().map_err(fehler)?; + if echt != p || !echt.is_dir() { return Err(fehler("Sicherungsordner wurde verschoben / backup folder moved")); } + return Ok(echt); + } + app.path().app_data_dir().map(|p| p.join("Sicherungen")).map_err(fehler) +} + +fn ordner(app: &AppHandle, zustand: &Zustand, kennung: u32) -> Result<(PathBuf, String, String), String> { + let wurzel = sperre(&zustand.projekte).wurzel(Some(kennung))?; + let name = projektname(wurzel.pfad())?; + let ziel = basis(app, zustand)?.join(&name); + // Auch ein Symlink auf einen Projektunterordner darf niemals als Sicherungsziel dienen. + if ziel.exists() && std::fs::symlink_metadata(&ziel).map_err(fehler)?.file_type().is_symlink() { return Err(fehler("Verknüpfung als Ziel / symlink destination")); } + let eltern = ziel.parent().ok_or_else(|| fehler("Kein Ziel / missing destination"))?; + let mut vorhanden = eltern; + while !vorhanden.exists() { + vorhanden = vorhanden.parent().ok_or_else(|| fehler("Kein Ziel / missing destination"))?; + } + if vorhanden.canonicalize().map_err(fehler)?.starts_with(wurzel.pfad()) { return Err(fehler("Sicherungsort liegt im Projekt / backup location is inside project")); } + std::fs::create_dir_all(eltern).map_err(fehler)?; + let eltern = eltern.canonicalize().map_err(fehler)?; + if eltern.starts_with(wurzel.pfad()) { return Err(fehler("Sicherungsort liegt im Projekt / backup location is inside project")); } + std::fs::create_dir_all(&ziel).map_err(fehler)?; + let ziel = ziel.canonicalize().map_err(fehler)?; + if ziel.starts_with(wurzel.pfad()) { return Err(fehler("Sicherungsort liegt im Projekt / backup location is inside project")); } + Ok((ziel, name, wurzel.pfad().to_string_lossy().into_owned())) +} + +fn namen(ordner: &Path, name: &str) -> Result, String> { + let mut result = Vec::new(); + if !ordner.exists() { return Ok(result); } + for e in std::fs::read_dir(ordner).map_err(fehler)? { + let e = e.map_err(fehler)?; + let n = e.file_name().to_string_lossy().into_owned(); + if gueltiger_name(&n, name) && zip_pfad(ordner, &n, name).is_ok() { result.push(n); } + } + result.sort(); + Ok(result) +} + +fn heute() -> Result { + // macOS lokale Kalenderzeit, unabhängig von einer vom Webview gelieferten Uhrzeit. + let ausgabe = std::process::Command::new("/bin/date").arg("+%Y-%m-%d").output().map_err(fehler)?; + if !ausgabe.status.success() { return Err(fehler("Datum nicht verfügbar / date unavailable")); } + Ok(String::from_utf8(ausgabe.stdout).map_err(fehler)?.trim().to_owned()) +} + +#[tauri::command] +pub async fn archiv_einstellungen(app: AppHandle, zustand: State<'_, Zustand>) -> Result { optionen(&app, &zustand) } + +#[tauri::command] +pub async fn archiv_einstellen(app: AppHandle, zustand: State<'_, Zustand>, mut wert: Optionen) -> Result<(), String> { + let bisher = optionen(&app, &zustand)?; + if wert.ort.is_some() && wert.ort != bisher.ort { return Err(fehler("Ort nur über Dialog / location requires dialog")); } + let _s = sperre(&zustand.einstellungen); + let datei = einstellungsdatei(&app)?; + if wert.ort.is_none() { einstellungen::schreiben(&datei, ORT, serde_json::Value::Null)?; } + wert.ort = None; + einstellungen::schreiben(&datei, OPTIONEN, serde_json::to_value(wert).map_err(fehler)?) +} + +#[tauri::command] +pub async fn archiv_ort_waehlen(app: AppHandle, fenster: WebviewWindow, zustand: State<'_, Zustand>) -> Result, String> { + let Some(p) = app.dialog().file().set_parent(&fenster).set_title(crate::sprache::t("Sicherungsordner wählen", "Choose Backup Folder")).set_can_create_directories(true).blocking_pick_folder() else { return Ok(None); }; + let p = p.into_path().map_err(fehler)?.canonicalize().map_err(fehler)?; + if let Ok(w) = sperre(&zustand.projekte).wurzel(None) { + if p.starts_with(w.pfad()) { return Err(fehler("Sicherungsort liegt im Projekt / backup location is inside project")); } + } + let text = p.to_string_lossy().into_owned(); + let _s = sperre(&zustand.einstellungen); + einstellungen::schreiben(&einstellungsdatei(&app)?, ORT, serde_json::json!(text))?; + Ok(Some(text)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Info { aktiv: bool, mit_versionen: bool, ort: String, name: String, namen: Vec, heute_gesichert: bool } + +#[tauri::command] +pub async fn archiv_info(app: AppHandle, zustand: State<'_, Zustand>, kennung: u32) -> Result { + let o = optionen(&app, &zustand)?; + let (ort, name, projekt) = ordner(&app, &zustand, kennung)?; + let heute = heute()?; + let gesichert = { + let _s = sperre(&zustand.einstellungen); + einstellungen::lesen(&einstellungsdatei(&app)?, TAGE).and_then(|v| v.get(&projekt).and_then(|t| t.as_str()).map(|t| t == heute)).unwrap_or(false) + }; + Ok(Info { aktiv: o.aktiv, mit_versionen: o.mit_versionen, ort: ort.to_string_lossy().into_owned(), namen: namen(&ort, &name)?, name, heute_gesichert: gesichert }) +} + +/// Enger Lesezugriff für vollständige Projektarchive, auch unbekannte Binärdateien. +#[tauri::command] +pub async fn archiv_projekt_lesen(zustand: State<'_, Zustand>, kennung: u32, pfad: String) -> Result { + let w = sperre(&zustand.projekte).wurzel(Some(kennung))?; + let p = w.aufloesen(&pfad, false)?; + Ok(Response::new(std::fs::read(p).map_err(fehler)?)) +} + +#[tauri::command] +pub async fn archiv_schreiben(app: AppHandle, zustand: State<'_, Zustand>, request: Request<'_>) -> Result<(), String> { + let _lauf = sperre(&zustand.archiv_schreiben); + let InvokeBody::Raw(daten) = request.body() else { return Err(fehler("Binärdaten erwartet / binary data expected")); }; + if !daten.starts_with(b"PK\x03\x04") { return Err(fehler("Kein ZIP / not a ZIP")); } + let header = |key: &str| request.headers().get(key).and_then(|v| v.to_str().ok()).ok_or_else(|| fehler("Kopfzeile fehlt / missing header")); + let kennung: u32 = header("plotwerk-kennung")?.parse().map_err(fehler)?; + let name = percent_decode_str(header("plotwerk-name")?).decode_utf8().map_err(fehler)?; + let erwartet = percent_decode_str(header("plotwerk-ort")?).decode_utf8().map_err(fehler)?; + let (ort, projektname, projekt) = ordner(&app, &zustand, kennung)?; + if ort != Path::new(erwartet.as_ref()) { return Err(fehler("Sicherungsort wurde geändert / backup location changed")); } + let p = zip_pfad(&ort, &name, &projektname)?; + if p.exists() { return Err(fehler("Sicherung existiert bereits / backup already exists")); } + dateien::schreiben_atomar(&p, daten).map_err(fehler)?; + let tag = heute()?; + let _s = sperre(&zustand.einstellungen); + let datei = einstellungsdatei(&app)?; + let mut tage = einstellungen::lesen(&datei, TAGE).and_then(|v| v.as_object().cloned()).unwrap_or_default(); + tage.insert(projekt, serde_json::json!(tag)); + einstellungen::schreiben(&datei, TAGE, serde_json::Value::Object(tage)) +} + +#[tauri::command] +pub async fn archiv_entfernen(app: AppHandle, zustand: State<'_, Zustand>, kennung: u32, name: String, ort: String) -> Result<(), String> { + let _lauf = sperre(&zustand.archiv_schreiben); + let (ziel, projekt, _) = ordner(&app, &zustand, kennung)?; + if ziel != Path::new(&ort) { return Err(fehler("Sicherungsort wurde geändert / backup location changed")); } + std::fs::remove_file(zip_pfad(&ziel, &name, &projekt)?).map_err(fehler) +} + +#[derive(Serialize, Deserialize)] +pub struct Eintrag { ordner: String, name: String } + +#[tauri::command] +pub async fn archiv_liste(app: AppHandle, zustand: State<'_, Zustand>) -> Result, String> { + let basis = basis(&app, &zustand)?; + let mut liste = Vec::new(); + if !basis.exists() { return Ok(liste); } + let basis = basis.canonicalize().map_err(fehler)?; + for e in std::fs::read_dir(&basis).map_err(fehler)? { + let e = e.map_err(fehler)?; + if !e.file_type().map_err(fehler)?.is_dir() { continue; } + let projekt = e.file_name().to_string_lossy().into_owned(); + let ordner = e.path().canonicalize().map_err(fehler)?; + if ordner.parent() != Some(basis.as_path()) { continue; } + for name in namen(&ordner, &projekt)? { liste.push(Eintrag { ordner: ordner.to_string_lossy().into_owned(), name }); } + } + liste.sort_by(|a, b| b.name.cmp(&a.name)); + Ok(liste) +} + +#[derive(Serialize)] +pub struct Gewaehlt { name: String, pfad: String } + +#[tauri::command] +pub async fn archiv_waehlen(app: AppHandle, fenster: WebviewWindow, zustand: State<'_, Zustand>, eintrag: Option) -> Result, String> { + let p = if let Some(e) = eintrag { + let basis = basis(&app, &zustand)?.canonicalize().map_err(fehler)?; + let roh = PathBuf::from(&e.ordner); + if roh.components().any(|c| matches!(c, Component::ParentDir)) { return Err(fehler("Ungültiger Pfad / invalid path")); } + let o = roh.canonicalize().map_err(fehler)?; + if o.parent() != Some(basis.as_path()) { return Err(fehler("Außerhalb des Sicherungsordners / outside backup folder")); } + let projekt = projektname(&o)?; + zip_pfad(&o, &e.name, &projekt)? + } else { + let Some(p) = app.dialog().file().set_parent(&fenster).set_title(crate::sprache::t("Sicherung wählen", "Choose Backup")).add_filter("ZIP", &["zip"]).blocking_pick_file() else { return Ok(None); }; + let p = p.into_path().map_err(fehler)?.canonicalize().map_err(fehler)?; + if p.extension().and_then(|e| e.to_str()) != Some("zip") { return Err(fehler("Nur ZIP / ZIP only")); } + p + }; + let p = p.canonicalize().map_err(fehler)?; + sperre(&zustand.archiv_lesen).insert(p.clone()); + Ok(Some(Gewaehlt { name: p.file_name().unwrap_or_default().to_string_lossy().into_owned(), pfad: p.to_string_lossy().into_owned() })) +} + +#[tauri::command] +pub async fn archiv_lesen(zustand: State<'_, Zustand>, pfad: String) -> Result { + let p = PathBuf::from(pfad).canonicalize().map_err(fehler)?; + if !sperre(&zustand.archiv_lesen).remove(&p) { return Err(fehler("ZIP nicht gewählt / ZIP not selected")); } + let m = std::fs::metadata(&p).map_err(fehler)?; + if !m.is_file() || m.len() > 1024 * 1024 * 1024 { return Err(fehler("ZIP zu groß / ZIP too large")); } + Ok(Response::new(std::fs::read(p).map_err(fehler)?)) +} + +#[derive(Deserialize)] +pub struct ArchivDatei { pfad: String, daten: Vec } + +fn restore_pfade(dateien: &[ArchivDatei]) -> Result<(), String> { + let mut gesehen = HashSet::new(); + let mut groesse = 0usize; + if dateien.len() > 100000 || !dateien.iter().any(|d| d.pfad == "projekt.md") { return Err(fehler("Kein Projektarchiv / not a project archive")); } + for d in dateien { + let p = Path::new(&d.pfad); + if d.pfad.is_empty() || d.pfad.contains(['\\', ':', '\0']) || d.pfad.split('/').any(|t| t.is_empty() || t == "." || t == "..") + || p.components().any(|c| !matches!(c, Component::Normal(_))) || !gesehen.insert(d.pfad.to_lowercase()) { return Err(fehler("Unsicherer Archivpfad / unsafe archive path")); } + groesse = groesse.checked_add(d.daten.len()).ok_or_else(|| fehler("Archiv zu groß / archive too large"))?; + if groesse > 1024 * 1024 * 1024 { return Err(fehler("Archiv zu groß / archive too large")); } + } + for d in dateien { + let mut p = Path::new(&d.pfad).parent(); + while let Some(eltern) = p { + if gesehen.contains(&eltern.to_string_lossy().to_lowercase()) { return Err(fehler("Datei-/Ordnerkonflikt / file/folder conflict")); } + p = eltern.parent(); + } + } + Ok(()) +} + +#[tauri::command] +pub async fn archiv_wiederherstellen(app: AppHandle, fenster: WebviewWindow, zustand: State<'_, Zustand>, dateien: Vec, titel: String) -> Result, String> { + restore_pfade(&dateien)?; + let vorschlag = titel.replace(['/', '\\', ':'], "-"); + let Some(p) = app.dialog().file().set_parent(&fenster).set_title(crate::sprache::t("In neuen Ordner wiederherstellen", "Restore into New Folder")).set_file_name(&vorschlag).set_can_create_directories(true).blocking_save_file() else { return Ok(None); }; + let p = p.into_path().map_err(fehler)?; + if p.exists() && (!p.is_dir() || std::fs::symlink_metadata(&p).map_err(fehler)?.file_type().is_symlink() || std::fs::read_dir(&p).map_err(fehler)?.next().is_some()) { return Err(fehler("Zielordner muss neu oder leer sein / destination must be new or empty")); } + std::fs::create_dir_all(&p).map_err(fehler)?; + let p = p.canonicalize().map_err(fehler)?; + let wurzel = crate::pfad::Wurzel::neu(&p)?; + for d in dateien { + let ziel = wurzel.aufloesen(&d.pfad, false)?; + if let Some(eltern) = ziel.parent() { std::fs::create_dir_all(eltern).map_err(fehler)?; } + // Erneut nach dem Anlegen auflösen; niemals eine inzwischen angelegte Datei überschreiben. + let ziel = wurzel.aufloesen(&d.pfad, false)?; + use std::io::Write; + let mut datei = std::fs::OpenOptions::new().write(true).create_new(true).open(ziel).map_err(fehler)?; + datei.write_all(&d.daten).map_err(fehler)?; + datei.sync_all().map_err(fehler)?; + } + sperre(&zustand.erlaubt).insert(p.clone()); + crate::projekt::oeffnen(&app, &zustand, &p).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn muster_und_pfade() { + let tmp = tempfile::tempdir().unwrap(); + let o = tmp.path().canonicalize().unwrap(); + assert!(zip_pfad(&o, "Hafenlichter-2026-10-20-1234.zip", "Hafenlichter").is_ok()); + for n in ["../Hafenlichter-2026-10-20-1234.zip", "/Hafenlichter-2026-10-20-1234.zip", "Hafenlichter-2026-10-20-1234.txt", "Hafenlichter-2026-02-30-1234.zip", "Hafenlichter-2026-10-20-2460.zip", "Anders-2026-10-20-1234.zip"] { assert!(zip_pfad(&o, n, "Hafenlichter").is_err(), "{n}"); } + } + #[cfg(unix)] + #[test] + fn symlinks_werden_verworfen() { + let tmp = tempfile::tempdir().unwrap(); + let aussen = tempfile::NamedTempFile::new().unwrap(); + let o = tmp.path().canonicalize().unwrap(); + std::os::unix::fs::symlink(aussen.path(), o.join("Hafenlichter-2026-10-20-1234.zip")).unwrap(); + assert!(zip_pfad(&o, "Hafenlichter-2026-10-20-1234.zip", "Hafenlichter").is_err()); + } + #[test] + fn restore_verwirft_traversal_duplikate_und_dateikonflikte() { + for pfad in ["../x", "/x", "a/../x", "a\\x", "a//x", "projekt.md", "PROJEKT.MD", "projekt.md/x"] { + let dateien = vec![ArchivDatei { pfad: "projekt.md".into(), daten: vec![] }, ArchivDatei { pfad: pfad.into(), daten: vec![] }]; + assert!(restore_pfade(&dateien).is_err(), "{pfad}"); + } + assert!(restore_pfade(&[ArchivDatei { pfad: "projekt.md".into(), daten: vec![] }, ArchivDatei { pfad: "recherche/dateien/bild.png".into(), daten: vec![0, 255] }]).is_ok()); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 83b64d4..ac4e993 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -29,6 +29,7 @@ export function App() { {ladeStatus === 'bereit' ? ( + {fehler &&
{fehler}
}
) : ladeStatus === 'start' ? ( diff --git a/src/app/Startbildschirm.tsx b/src/app/Startbildschirm.tsx index 6f42894..9216442 100644 --- a/src/app/Startbildschirm.tsx +++ b/src/app/Startbildschirm.tsx @@ -3,6 +3,7 @@ import { demoGeaendert, demoZuruecksetzen } from '../backend/demoSpeicher'; import { useTexte } from '../i18n'; import { useStore } from '../state/store'; import { Button, IconDokument, IconOrdner, IconPlus, Textfeld } from '../ui'; +import { WiederherstellenDialog } from '../features/sicherungen/ui'; /** Ohne geöffnetes Projekt: Projektordner öffnen, neues Projekt, Word-Datei importieren. */ export function Startbildschirm() { @@ -12,6 +13,7 @@ export function Startbildschirm() { const neuesProjekt = useStore((s) => s.neuesProjekt); const oeffneDialog = useStore((s) => s.oeffneDialog); const [neu, setNeu] = useState(false); + const [restore, setRestore] = useState(false); const [titel, setTitel] = useState(''); const [beschaeftigt, setBeschaeftigt] = useState(false); const [meldung, setMeldung] = useState(null); @@ -35,6 +37,7 @@ export function Startbildschirm() {

Plotwerk

{t.app.start.text}

+ {backend?.art === 'tauri' && backend.archiv && } @@ -77,6 +80,7 @@ export function Startbildschirm() { )}
+ {restore && setRestore(false)} />} ); } diff --git a/src/app/befehle.ts b/src/app/befehle.ts index d37c8a6..065756f 100644 --- a/src/app/befehle.ts +++ b/src/app/befehle.ts @@ -18,6 +18,7 @@ export type Befehl = | 'pruefen' | 'buch-pruefen' | 'projekt-oeffnen' + | 'projekt-archiv' | 'vorschau' // Werkzeuge G1–G8 | 'zeit-vorschlaege' @@ -51,6 +52,14 @@ export async function befehlAusfuehren(befehl: Befehl): Promise { } const sichern = () => s.editorSichern().catch((e: unknown) => console.error('Sichern vor dem Befehl fehlgeschlagen:', e)); switch (befehl) { + case 'projekt-archiv': + if (s.daten && s.backend?.art === 'tauri') { + await s.editorSichern(); + await s.alleSpeichernAbwarten(); + const { sichereProjekt } = await import('../features/sicherungen/dienst'); + await sichereProjekt(false); + } + break; case 'schnellsuche': if (s.daten) s.oeffneDialog('schnellsuche'); break; diff --git a/src/backend/tauri/archiv.test.ts b/src/backend/tauri/archiv.test.ts new file mode 100644 index 0000000..bb009ba --- /dev/null +++ b/src/backend/tauri/archiv.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { MemoryFs } from '@core/projekt'; +import { erstelleArchivBackend } from './archiv'; +import { aufruf } from './aufruf'; +vi.mock('./aufruf', () => ({ aufruf: vi.fn() })); +const call = vi.mocked(aufruf); +beforeEach(() => call.mockReset()); +it.each([' Hafenlichter ', 'Ha\u0308fenlichter', ' Ha\u0308fenlichter '])('schreibt Archive mit dem exakten Rust-Projektpräfix %j', async (projekt) => { + call.mockImplementation(async (cmd, _args, optionen) => { + if (cmd === 'archiv_einstellungen') return { aktiv: true }; + if (cmd === 'archiv_info') return { aktiv: true, mitVersionen: false, ort: `/Sicherungen/${projekt}`, name: projekt, namen: [], heuteGesichert: false }; + if (cmd === 'archiv_projekt_lesen') return new TextEncoder().encode('x').buffer; + if (cmd === 'archiv_schreiben') { + const name = decodeURIComponent(new Headers(optionen?.headers).get('plotwerk-name')!); + // Rust verlangt den rohen Ordnernamen als Präfix, ohne NFC oder trim(). + if (!name.startsWith(`${projekt}-`)) throw new Error('Ungültiger ZIP-Name'); + } + return undefined; + }); + const fs = Object.assign(new MemoryFs({ 'projekt.md': 'x' }), { kennung: 1 }); + await expect(erstelleArchivBackend(() => null, () => null).sichern(fs, true)).resolves.toBeUndefined(); + expect(call.mock.calls.some(([cmd]) => cmd === 'archiv_schreiben')).toBe(true); +}); +it('überspringt den heutigen erfolgreichen Tagesstand, manuell aber nicht', async () => { + call.mockImplementation(async (cmd) => { + if (cmd === 'archiv_einstellungen') return { aktiv: true }; + if (cmd === 'archiv_info') return { aktiv: true, mitVersionen: false, ort: '/Sicherungen', name: 'Hafenlichter', namen: [], heuteGesichert: true }; + if (cmd === 'archiv_projekt_lesen') return new TextEncoder().encode('---\ntitel: Hafenlichter\nkapitel: []\n---').buffer; + return undefined; + }); + const fs = Object.assign(new MemoryFs({ 'projekt.md': 'x' }), { kennung: 1 }); + const backend = erstelleArchivBackend(() => null, () => null); + await backend.sichern(fs, true); + expect(call.mock.calls.some(([cmd]) => cmd === 'archiv_schreiben')).toBe(false); + await backend.sichern(fs, false); + expect(call.mock.calls.some(([cmd]) => cmd === 'archiv_schreiben')).toBe(true); +}); +it('markiert fehlgeschlagene Sicherungen nicht als erfolgreich und rotiert sie nicht', async () => { + call.mockImplementation(async (cmd) => { + if (cmd === 'archiv_einstellungen') return { aktiv: true }; + if (cmd === 'archiv_info') return { aktiv: true, mitVersionen: false, ort: '/Sicherungen', name: 'Hafenlichter', namen: [], heuteGesichert: false }; + if (cmd === 'archiv_projekt_lesen') return new TextEncoder().encode('x').buffer; + if (cmd === 'archiv_schreiben') throw new Error('Permission denied'); + return undefined; + }); + const fs = Object.assign(new MemoryFs({ 'projekt.md': 'x' }), { kennung: 1 }); + await expect(erstelleArchivBackend(() => null, () => null).sichern(fs, true)).rejects.toThrow('Permission denied'); + expect(call.mock.calls.some(([cmd]) => cmd === 'archiv_entfernen')).toBe(false); +}); +it('deaktivierte Tages-Sicherung greift nicht auf den Sicherungsordner zu', async () => { + call.mockResolvedValue({ aktiv: false }); + const fs = Object.assign(new MemoryFs(), { kennung: 1 }); + await erstelleArchivBackend(() => null, () => null).sichern(fs, true); + expect(call.mock.calls.map(([cmd]) => cmd)).toEqual(['archiv_einstellungen']); +}); +it('bindet Schreiben und Rotation an den Ort vom Anfang des Laufs', async () => { + call.mockImplementation(async (cmd) => { + if (cmd === 'archiv_info') return { aktiv: true, mitVersionen: false, ort: '/Sicherungen/Hafenlichter', name: 'Hafenlichter', namen: ['Hafenlichter-2026-10-20-1100.zip', 'Hafenlichter-2026-10-20-1200.zip'], heuteGesichert: false }; + if (cmd === 'archiv_projekt_lesen') return new TextEncoder().encode('x').buffer; + return undefined; + }); + const fs = Object.assign(new MemoryFs({ 'projekt.md': 'x' }), { kennung: 1 }); + await erstelleArchivBackend(() => null, () => null).sichern(fs, false); + const schreiben = call.mock.calls.find(([cmd]) => cmd === 'archiv_schreiben'); + expect(schreiben?.[2]?.headers).toMatchObject({ 'plotwerk-ort': encodeURIComponent('/Sicherungen/Hafenlichter') }); + const loeschen = call.mock.calls.find(([cmd]) => cmd === 'archiv_entfernen'); + expect(loeschen?.[1]).toMatchObject({ ort: '/Sicherungen/Hafenlichter' }); +}); diff --git a/src/backend/tauri/archiv.ts b/src/backend/tauri/archiv.ts new file mode 100644 index 0000000..794b82d --- /dev/null +++ b/src/backend/tauri/archiv.ts @@ -0,0 +1,53 @@ +import { archivName, baueArchiv, entpackeArchiv, rotation } from '@core/sicherungen'; +import type { ProjektDateisystem } from '@core/projekt'; +import type { ArchivBackend, SicherungsEinstellungen } from '../types'; +import { aufruf } from './aufruf'; +import type { ProjektInfo, TauriFs } from './tauriFs'; + +interface ArchivInfo extends Omit { + ort: string; + name: string; namen: string[]; heuteGesichert: boolean; +} + +export function erstelleArchivBackend(aktuell: () => TauriFs | null, uebernehmen: (info: ProjektInfo | null) => TauriFs | null): ArchivBackend { + const laeufe = new Map>(); + return { + einstellungen: () => aufruf('archiv_einstellungen'), + einstellen: (wert) => aufruf('archiv_einstellen', { wert }), + ortWaehlen: () => aufruf('archiv_ort_waehlen'), + auflisten: () => aufruf('archiv_liste'), + async sichern(fs: ProjektDateisystem, automatisch: boolean) { + const kennung = (fs as TauriFs).kennung ?? aktuell()?.kennung; + if (kennung === undefined) return; + async function lauf() { + if (automatisch && !(await aufruf('archiv_einstellungen')).aktiv) return; + const info = await aufruf('archiv_info', { kennung }); + if (automatisch && (!info.aktiv || info.heuteGesichert)) return; + const leser = { + lesen: (pfad: string) => fs.lesen(pfad), + existiert: (pfad: string) => fs.existiert(pfad), + auflisten: (ordner: string) => fs.auflisten(ordner), + async lesenBinaer(pfad: string) { + const bytes = await aufruf('archiv_projekt_lesen', { kennung, pfad }); + return bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : Uint8Array.from(bytes); + }, + }; + const daten = await baueArchiv(leser, info.mitVersionen); + const name = archivName(info.name, new Date(), info.namen); + await aufruf('archiv_schreiben', daten, { headers: { 'plotwerk-kennung': String(kennung), 'plotwerk-name': encodeURIComponent(name), 'plotwerk-ort': encodeURIComponent(info.ort) } }); + for (const alt of rotation([...info.namen, name], info.name)) await aufruf('archiv_entfernen', { kennung, name: alt, ort: info.ort }); + } + const vorher = laeufe.get(kennung) ?? Promise.resolve(); + const promise = vorher.catch(() => {}).then(lauf); + laeufe.set(kennung, promise); + try { await promise; } finally { if (laeufe.get(kennung) === promise) laeufe.delete(kennung); } + }, + async wiederherstellen(eintrag) { + const gewaehlt = await aufruf<{ name: string; pfad: string } | null>('archiv_waehlen', { eintrag: eintrag ?? null }); + if (!gewaehlt) return null; + const daten = await aufruf('archiv_lesen', { pfad: gewaehlt.pfad }); + const dateien = await entpackeArchiv(daten instanceof ArrayBuffer ? new Uint8Array(daten) : Uint8Array.from(daten)); + return uebernehmen(await aufruf('archiv_wiederherstellen', { dateien, titel: gewaehlt.name.replace(/\.zip$/, '') })); + }, + }; +} diff --git a/src/backend/tauri/backend.test.ts b/src/backend/tauri/backend.test.ts index bb3fb1a..7c09520 100644 --- a/src/backend/tauri/backend.test.ts +++ b/src/backend/tauri/backend.test.ts @@ -336,6 +336,21 @@ describe('Tauri-Backend', () => { vi.useRealTimers(); } }); + it('laufende ZIP-Sicherung hält Beenden nicht auf und Beenden startet keine ZIP-Sicherung', async () => { + mocks.invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'projekt_letztes') return INFO; + if (cmd === 'archiv_einstellungen') return { aktiv: true }; + if (cmd === 'archiv_info') return new Promise(() => {}); + return undefined; + }); + const backend = await erstelleTauriBackend(); + const fs = await backend.letztesProjekt(); + void backend.archiv!.sichern(fs!, true); + await vi.waitFor(() => expect(mocks.invoke.mock.calls.some(([cmd]) => cmd === 'archiv_info')).toBe(true)); + mocks.invoke.mockClear(); + await mocks.handler.get('app-beenden')!({ payload: null }); + expect(mocks.invoke.mock.calls.map(([cmd]) => cmd)).toEqual(['app_beenden']); + }); it('externe Änderungen und Menübefehle kommen an', async () => { rustImSpeicher(new MemoryFs()); diff --git a/src/backend/tauri/index.ts b/src/backend/tauri/index.ts index bc64e52..f2410f8 100644 --- a/src/backend/tauri/index.ts +++ b/src/backend/tauri/index.ts @@ -14,6 +14,7 @@ import type { KiEinstellungen } from '@core/model/types'; import { parseProjekt } from '@core/projekt'; import type { Backend, GewaehlteDatei } from '../types'; import { aufruf } from './aufruf'; +import { erstelleArchivBackend } from './archiv'; import { legeGrundgeruestAn } from './grundgeruest'; import { DATENBANK, erstelleSqlIndexSpeicher } from './indexSpeicher'; import { erstelleTauriKiTransport, type KiZiel } from './kiTransport'; @@ -91,6 +92,7 @@ export async function erstelleTauriBackend(): Promise { return { art: 'tauri', + archiv: erstelleArchivBackend(() => aktuell, uebernehmen), async projektOeffnen() { return uebernehmen(await aufruf('projekt_waehlen')); diff --git a/src/backend/types.ts b/src/backend/types.ts index ae4b0d7..aef6882 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -9,8 +9,20 @@ export interface GewaehlteDatei { daten: Uint8Array; } +export interface SicherungsEinstellungen { aktiv: boolean; mitVersionen: boolean; ort: string | null } +export interface SicherungsEintrag { ordner: string; name: string } +export interface ArchivBackend { + einstellungen(): Promise; + einstellen(wert: SicherungsEinstellungen): Promise; + ortWaehlen(): Promise; + sichern(fs: ProjektDateisystem, automatisch: boolean): Promise; + auflisten(): Promise; + wiederherstellen(eintrag?: SicherungsEintrag): Promise; +} + export interface Backend { art: 'demo' | 'tauri'; + archiv?: ArchivBackend; /** Ordnerauswahl; null = abgebrochen */ projektOeffnen(): Promise; /** Neuen Projektordner anlegen (mit projekt.md); null = abgebrochen */ diff --git a/src/features/ki/EinstellungenDialog.tsx b/src/features/ki/EinstellungenDialog.tsx index 6fd6d83..b9844cf 100644 --- a/src/features/ki/EinstellungenDialog.tsx +++ b/src/features/ki/EinstellungenDialog.tsx @@ -9,6 +9,7 @@ import type { KiAnbieter, KiEinstellungen, KiModelle, KiOpenAiEinstellungen, KiV import type { DialogKomponentenProps } from '../../app/dialogTypen'; import { f, setzeSprachWahl, texte, useSprachWahl, useTexte, type SprachWahl } from '../../i18n'; import { FunktionenEinstellungen } from '../funktionen/EinstellungenAbschnitt'; +import { SicherungenEinstellungen } from '../sicherungen/ui'; import { kiFehlerAnzeige } from '../../state/slices/ki'; import { useStore } from '../../state/store'; import { Abschnittslabel, Button, Dialog, Feld, Segment, StrangPunkt, Textfeld } from '../../ui'; @@ -305,6 +306,7 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { } > +
diff --git a/src/features/sicherungen/dienst.ts b/src/features/sicherungen/dienst.ts new file mode 100644 index 0000000..2517f70 --- /dev/null +++ b/src/features/sicherungen/dienst.ts @@ -0,0 +1,15 @@ +import { useStore } from '../../state/store'; +import { f, texte } from '../../i18n'; +import type { ProjektDateisystem } from '@core/projekt'; +import type { Backend } from '../../backend/types'; + +/** Absichtlich weder Speicherwarteschlange noch Beenden-Callback: Fehler sperren nie den Editor. */ +export async function sichereProjekt(automatisch: boolean, projekt?: { backend: Backend | null; fs: ProjektDateisystem }): Promise { + const { backend, fs } = projekt ?? useStore.getState(); + if (!backend?.archiv || backend.art !== 'tauri' || !fs) return; + try { + await backend.archiv.sichern(fs, automatisch); + } catch (e) { + useStore.setState({ fehler: f(texte().sicherungen.fehler, { grund: e instanceof Error ? e.message : String(e) }) }); + } +} diff --git a/src/features/sicherungen/sicherungen.test.tsx b/src/features/sicherungen/sicherungen.test.tsx new file mode 100644 index 0000000..efc130a --- /dev/null +++ b/src/features/sicherungen/sicherungen.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryFs } from '@core/projekt'; +import { useStore } from '../../state/store'; +import { erstelleDemoBackend } from '../../backend/demo'; +import { befehlAusfuehren } from '../../app/befehle'; +import { SicherungenEinstellungen, WiederherstellenDialog } from './ui'; +import { sichereProjekt } from './dienst'; +import { festeSprache } from '../../i18n'; + +const fs = new MemoryFs({ 'projekt.md': '---\ntitel: Hafenlichter\nkapitel: []\n---\n' }); +const archiv = { + einstellungen: vi.fn().mockResolvedValue({ aktiv: true, mitVersionen: false, ort: null }), + einstellen: vi.fn().mockResolvedValue(undefined), + ortWaehlen: vi.fn().mockResolvedValue('/Sicherungen'), + sichern: vi.fn().mockResolvedValue(undefined), + auflisten: vi.fn().mockResolvedValue([]), + wiederherstellen: vi.fn().mockResolvedValue(fs), +}; + +beforeEach(() => { + vi.clearAllMocks(); + useStore.setState({ backend: { ...erstelleDemoBackend(), art: 'tauri', archiv }, fs, fehler: null }); +}); + +describe('Sicherung UI und Auslöser', () => { + it('Menübefehl sichert sofort von Hand, auch mehrfach', async () => { + await useStore.getState().projektLaden(fs); + await waitFor(() => expect(archiv.sichern).toHaveBeenCalled()); + archiv.sichern.mockClear(); + await befehlAusfuehren('projekt-archiv'); + await befehlAusfuehren('projekt-archiv'); + expect(archiv.sichern.mock.calls.map((c) => c[1])).toEqual([false, false]); + }); + it('Projektladen startet die Tagesprüfung ohne das Öffnen zu blockieren', async () => { + archiv.sichern.mockImplementationOnce(() => new Promise(() => {})); + await useStore.getState().projektLaden(fs); + await waitFor(() => expect(archiv.sichern).toHaveBeenCalledWith(fs, true)); + expect(useStore.getState().ladeStatus).toBe('bereit'); + }); + it('Fehlerhinweis lässt den Editor schreibfähig', async () => { + archiv.sichern.mockRejectedValueOnce(new Error('Permission denied')); + await sichereProjekt(false); + expect(useStore.getState().fehler).toContain('Permission denied'); + await fs.schreiben('notiz.md', 'Notiz'); + expect(await fs.lesen('notiz.md')).toBe('Notiz'); + }); + it('Fehlerhinweis ist auch auf Englisch verfügbar', async () => { + festeSprache('en'); + try { + archiv.sichern.mockRejectedValueOnce(new Error('Permission denied')); + await sichereProjekt(false); + expect(useStore.getState().fehler).toBe('Backup failed: Permission denied. Writing and autosave continue.'); + } finally { festeSprache('de'); } + }); + it('Einstellungen speichern an/aus, Versionen und Dialogort als App-Einstellung', async () => { + render(); + const aktiv = await screen.findByLabelText('Automatisch täglich sichern'); + fireEvent.click(aktiv); + await waitFor(() => expect(archiv.einstellen).toHaveBeenCalledWith({ aktiv: false, mitVersionen: false, ort: null })); + fireEvent.click(screen.getByLabelText('Schnappschüsse (.versionen) mitsichern')); + await waitFor(() => expect(archiv.einstellen).toHaveBeenLastCalledWith({ aktiv: false, mitVersionen: true, ort: null })); + fireEvent.click(screen.getByText('Ordner wählen …')); + await waitFor(() => expect(screen.getByText('/Sicherungen')).toBeInTheDocument()); + }); + it('leere Liste zeigt einen Hinweis und lässt ein ZIP aus einem anderen Ort wählen', async () => { + render( {}} />); + expect(await screen.findByText('Noch keine Sicherungen vorhanden.')).toBeInTheDocument(); + expect(screen.getByText('ZIP-Datei wählen …')).toBeInTheDocument(); + }); + it('gewählte Sicherung wird in einen neuen Ordner wiederhergestellt und geladen', async () => { + archiv.auflisten.mockResolvedValueOnce([{ ordner: '/Sicherungen', name: 'Hafenlichter-2026-10-20-1200.zip' }]); + const laden = vi.spyOn(useStore.getState(), 'projektLaden'); + render( {}} />); + fireEvent.click(await screen.findByText('Hafenlichter-2026-10-20-1200.zip')); + await waitFor(() => expect(archiv.wiederherstellen).toHaveBeenCalledWith({ ordner: '/Sicherungen', name: 'Hafenlichter-2026-10-20-1200.zip' })); + await waitFor(() => expect(laden).toHaveBeenCalledWith(fs)); + laden.mockRestore(); + }); + it('blendet Einstellungen in der Browser-Demo aus', () => { + useStore.setState({ backend: erstelleDemoBackend() }); + render(); + expect(screen.queryByText('Sicherungen')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/sicherungen/ui.tsx b/src/features/sicherungen/ui.tsx new file mode 100644 index 0000000..a904e64 --- /dev/null +++ b/src/features/sicherungen/ui.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import type { SicherungsEinstellungen, SicherungsEintrag } from '../../backend/types'; +import { f, useTexte } from '../../i18n'; +import { useStore } from '../../state/store'; +import { Abschnittslabel, Button, Dialog } from '../../ui'; + +export function SicherungenEinstellungen() { + const backend = useStore((s) => s.backend); + const archiv = backend?.art === 'tauri' ? backend.archiv : undefined; + const [wert, setWert] = useState(null); + const [fehler, setFehler] = useState(null); + const [busy, setBusy] = useState(false); + const t = useTexte(); + useEffect(() => { if (archiv) void archiv.einstellungen().then(setWert).catch((e) => setFehler(String(e))); }, [archiv]); + if (!archiv) return null; + async function speichern(neu: SicherungsEinstellungen) { + setBusy(true); + try { await archiv!.einstellen(neu); setWert(neu); setFehler(null); } + catch (e) { setFehler(String(e)); } + finally { setBusy(false); } + } + return
+ {t.sicherungen.titel} +

{t.sicherungen.erklaerung}

+ {wert && <> +

+

+

{wert.ort ?? t.sicherungen.standard}

+ + {wert.ort && } + } + {fehler &&

{f(t.sicherungen.fehler, { grund: fehler })}

} +
; +} + +export function WiederherstellenDialog({ onSchliessen }: { onSchliessen(): void }) { + const archiv = useStore((s) => s.backend?.archiv); + const [liste, setListe] = useState(null); + const [fehler, setFehler] = useState(null); + const [busy, setBusy] = useState(false); + const t = useTexte(); + useEffect(() => { if (archiv) void archiv.auflisten().then(setListe).catch((e) => setFehler(String(e))); }, [archiv]); + async function herstellen(eintrag?: SicherungsEintrag) { + if (!archiv) return; + setBusy(true); setFehler(null); + try { + const fs = await archiv.wiederherstellen(eintrag); + if (fs) { await useStore.getState().projektLaden(fs); onSchliessen(); } + } catch (e) { setFehler(f(t.sicherungen.restoreFehler, { grund: e instanceof Error ? e.message : String(e) })); } + finally { setBusy(false); } + } + return { if (!busy) onSchliessen(); }} modalFest={busy}> +

{t.sicherungen.ziel}

+ {liste?.length === 0 &&

{t.sicherungen.leer}

} + {liste?.map((e) =>

)} + + {fehler &&

{fehler}

} +
; +} diff --git a/src/i18n/de/index.ts b/src/i18n/de/index.ts index 9a15ed2..304d2de 100644 --- a/src/i18n/de/index.ts +++ b/src/i18n/de/index.ts @@ -24,6 +24,7 @@ import { recherche } from './recherche'; import { vorschau } from './vorschau'; import { abgabe } from './abgabe'; import { funktionen } from './funktionen'; +import { sicherungen } from './sicherungen'; export const de = { gemeinsam, @@ -51,4 +52,5 @@ export const de = { vorschau, abgabe, funktionen, + sicherungen, }; diff --git a/src/i18n/de/sicherungen.ts b/src/i18n/de/sicherungen.ts new file mode 100644 index 0000000..4c0b145 --- /dev/null +++ b/src/i18n/de/sicherungen.ts @@ -0,0 +1,9 @@ +export const sicherungen = { + titel: 'Sicherungen', aktiv: 'Automatisch täglich sichern', versionen: 'Schnappschüsse (.versionen) mitsichern', + standard: 'Standard: App-Ordner / Sicherungen / Projekt', ort: 'Ordner wählen …', zurueck: 'Standardort verwenden', + erklaerung: 'ZIP außerhalb des Projektordners. Aufbewahrung: 7 Tagesstände und 4 ältere Wochenstände. Recherche-Bilder und .plotwerk werden mitgesichert.', + fehler: 'Sicherung fehlgeschlagen: {grund}. Schreiben und Autosave laufen weiter.', + wiederherstellen: 'Sicherung wiederherstellen …', leer: 'Noch keine Sicherungen vorhanden.', + zip: 'ZIP-Datei wählen …', ziel: 'Die Sicherung wird in einen neuen, leeren Ordner entpackt und geöffnet. Den Zielordner wählen Sie im nächsten Dialog.', + restoreFehler: 'Wiederherstellen fehlgeschlagen: {grund}', +}; diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index 71aff2f..c807f35 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -25,6 +25,7 @@ import { recherche } from './recherche'; import { vorschau } from './vorschau'; import { abgabe } from './abgabe'; import { funktionen } from './funktionen'; +import { sicherungen } from './sicherungen'; export const en: Woerterbuch = { gemeinsam, @@ -52,4 +53,5 @@ export const en: Woerterbuch = { vorschau, abgabe, funktionen, + sicherungen, }; diff --git a/src/i18n/en/sicherungen.ts b/src/i18n/en/sicherungen.ts new file mode 100644 index 0000000..8aba3a2 --- /dev/null +++ b/src/i18n/en/sicherungen.ts @@ -0,0 +1,11 @@ +import type { Form } from '@core/i18n'; +import type { sicherungen as de } from '../de/sicherungen'; +export const sicherungen: Form = { + titel: 'Backups', aktiv: 'Back up automatically each day', versionen: 'Include snapshots (.versionen)', + standard: 'Default: app folder / Sicherungen / project', ort: 'Choose Folder…', zurueck: 'Use default location', + erklaerung: 'ZIP outside the project folder. Retention: 7 daily and 4 older weekly backups. Research images and .plotwerk are included.', + fehler: 'Backup failed: {grund}. Writing and autosave continue.', + wiederherstellen: 'Restore Backup…', leer: 'No backups yet.', zip: 'Choose ZIP File…', + ziel: 'The backup will be extracted into a new, empty folder and opened. Choose the destination in the next dialog.', + restoreFehler: 'Restore failed: {grund}', +}; diff --git a/src/state/store.ts b/src/state/store.ts index bd95781..bb3d6ec 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -577,6 +577,8 @@ const createBasis: StateCreator = (set, get) => { }); indexBerechnen(); // Aufbewahrung der Schnappschüsse (E8) im Hintergrund + const backend = get().backend; + void import('../features/sicherungen/dienst').then(({ sichereProjekt }) => sichereProjekt(true, { backend, fs })); const geschuetzt = new Set((d.fassungen ?? []).flatMap((f) => f.dateien.map((x) => x.version))); void aufbewahrungAnwenden(fs, new Date(), geschuetzt).catch((e: unknown) => console.warn('Alte Schnappschüsse nicht aufgeräumt:', e)); } catch (e) {