From e8f854b06278446e03f293467c8b736b78e8f9a1 Mon Sep 17 00:00:00 2001 From: leonatwork Date: Tue, 20 Oct 2026 15:10:41 +0000 Subject: [PATCH 1/3] =?UTF-8?q?agent:=20Bearbeite=20GitHub-Issue=20#12=20v?= =?UTF-8?q?ollst=C3=A4ndig=20und=20liefere=20eine=20reviewbare=20L=C3=B6su?= =?UTF-8?q?ng.?= 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: CHANGES_REQUESTED after 3 round(s). Generated by dev-loop (not pushed). Signed-off-by: leonatwork --- core/sicherungen/index.test.ts | 54 ++ core/sicherungen/index.ts | 84 +++ docs/issue-12-handover.md | 102 +++ src-tauri/src/lib.rs | 11 + src-tauri/src/menue.rs | 6 + src-tauri/src/projekt.rs | 15 +- src-tauri/src/sicherungen.rs | 652 ++++++++++++++++++ src/app/App.tsx | 2 + src/app/Startbildschirm.tsx | 4 + src/app/befehle.ts | 12 + src/backend/tauri/index.ts | 2 + src/backend/tauri/sicherungen.test.ts | 47 ++ src/backend/tauri/sicherungen.ts | 34 + src/backend/types.ts | 20 + src/features/ki/EinstellungenDialog.tsx | 2 + src/features/sicherungen/Einstellungen.tsx | 42 ++ src/features/sicherungen/Wiederherstellen.tsx | 33 + src/features/sicherungen/ablauf.ts | 27 + src/features/sicherungen/sicherungen.test.tsx | 148 ++++ src/i18n/de/index.ts | 2 + src/i18n/de/sicherungen.ts | 15 + src/i18n/en/index.ts | 2 + src/i18n/en/sicherungen.ts | 17 + src/state/store.ts | 17 +- 24 files changed, 1344 insertions(+), 6 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/sicherungen.test.ts create mode 100644 src/backend/tauri/sicherungen.ts create mode 100644 src/features/sicherungen/Einstellungen.tsx create mode 100644 src/features/sicherungen/Wiederherstellen.tsx create mode 100644 src/features/sicherungen/ablauf.ts create mode 100644 src/features/sicherungen/sicherungen.test.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..4a503a6 --- /dev/null +++ b/core/sicherungen/index.test.ts @@ -0,0 +1,54 @@ +import JSZip from 'jszip'; +import { MemoryFs } from '../projekt/memoryFs'; +import { archivAufbauen, archivLesen, dateiAuswahl, sicherungsName, rotation } from './index'; + +describe('Externe Projektsicherung', () => { + it('sichert Metadaten und Bilder bytegetreu ohne Schreibzugriff und schließt temporäre Dateien aus', async () => { + const fs = new MemoryFs({ 'projekt.md': 'Hafenlichter', '.plotwerk/hinweise.json': '[]', '.versionen/manuskript/a/alt.md': 'alt', 'manuskript/a.md': 'Text', '.DS_Store': 'x', 'manuskript/.a.md.tmp': 'x', 'a.tmp-123': 'x', 'a~': 'x' }); + await fs.schreibenBinaer('recherche/dateien/bild.png', new Uint8Array([0, 255, 128])); + const vorher = fs.alleDateien(); + const zip = await archivAufbauen(fs, false); + const dateien = await archivLesen(zip); + expect(dateien.map((d) => d.pfad)).toEqual(['.plotwerk/hinweise.json', 'manuskript/a.md', 'projekt.md', 'recherche/dateien/bild.png']); + expect(dateien.at(-1)?.daten).toEqual(new Uint8Array([0, 255, 128])); + expect(fs.alleDateien()).toEqual(vorher); + expect(dateiAuswahl(Object.keys(vorher), true)).toContain('.versionen/manuskript/a/alt.md'); + }); + + it('weist ZIP-Slip, Links und Archive ohne projekt.md zurück', async () => { + for (const name of ['../fremd.md', '/fremd.md', 'a\\b.md', 'a/../b.md', 'C:/fremd.md']) { + const zip = new JSZip().file('projekt.md', 'Projekt').file(name, 'x'); + await expect(archivLesen(await zip.generateAsync({ type: 'uint8array' }))).rejects.toThrow(); + } + const link = new JSZip().file('projekt.md', 'Projekt').file('link', 'ziel', { unixPermissions: 0o120777 }); + await expect(archivLesen(await link.generateAsync({ type: 'uint8array', platform: 'UNIX' }))).rejects.toThrow(); + await expect(archivLesen(await new JSZip().file('text.md', 'x').generateAsync({ type: 'uint8array' }))).rejects.toThrow(); + }); + + it('behält auch BOM und unbekannte Binärdateien exakt', async () => { + const fs = new MemoryFs(); + const bytes = new Uint8Array([239, 187, 191, 65, 0, 255]); + await fs.schreibenBinaer('projekt.md', bytes); + await fs.schreibenBinaer('anhaenge/unbekannt.bin', bytes); + const dateien = await archivLesen(await archivAufbauen(fs, true)); + expect(dateien).toEqual([{ pfad: 'anhaenge/unbekannt.bin', daten: bytes }, { pfad: 'projekt.md', daten: bytes }]); + }); + + it('verwendet einen sprachunabhängigen sicheren Namen und lokale Kalenderdaten', () => { + expect(sicherungsName('Hafen: /Lichter\\..', new Date(2026, 9, 20, 9, 5))).toBe('Hafen-Lichter-2026-10-20-0905.zip'); + expect(sicherungsName('é 中文', new Date(2026, 9, 20, 9, 5))).toBe('Projekt-2026-10-20-0905.zip'); + }); + + it('behält sieben neueste Tagesstände und vier ältere ISO-Wochen; fremde und ungültige Namen bleiben', () => { + const namen = Array.from({ length: 60 }, (_, i) => sicherungsName('Hafenlichter', new Date(2026, 9, 20 - i, 9, 0))); + const extra = ['notizen.txt', 'Andere-2026-10-01-0900.zip', 'Hafenlichter-2026-02-30-0900.zip']; + const altHeute = 'Hafenlichter-2026-10-20-0800.zip'; + const loeschen = rotation([...namen, ...extra, altHeute], 'Hafenlichter'); + const behalten = namen.filter((n) => !loeschen.includes(n)); + expect(behalten).toHaveLength(11); + expect(behalten.slice(0, 7)).toEqual(namen.slice(0, 7)); + expect(behalten.slice(7)).toEqual([namen[7], namen[9], namen[16], namen[23]]); + expect(loeschen).toContain(altHeute); + expect(loeschen.some((n) => extra.includes(n))).toBe(false); + }); +}); diff --git a/core/sicherungen/index.ts b/core/sicherungen/index.ts new file mode 100644 index 0000000..9f0f09e --- /dev/null +++ b/core/sicherungen/index.ts @@ -0,0 +1,84 @@ +/** ZIP-Logik ohne Plattformzugriff. Rohbytes erhalten auch BOM und unbekannte Projektdateien. */ +import JSZip from 'jszip'; +import type { ProjektLeser } from '../projekt/fs'; +import { isoWoche } from '../versionen'; + +export interface ArchivLeser extends ProjektLeser { + lesenBinaer?(pfad: string): Promise; +} +export interface ArchivDatei { pfad: string; daten: Uint8Array } + +export function dateiAuswahl(pfade: string[], mitVersionen: boolean): string[] { + return pfade.filter((p) => (mitVersionen || !p.startsWith('.versionen/')) && + !p.split('/').some((n) => n === '.DS_Store' || n.endsWith('~') || /\.(tmp|temp|swp|swo)(-\d+)?$/i.test(n))).sort(); +} + +/** Gemeinsame Rust/TS-Regel: ASCII, höchstens 80 Zeichen; ungültige Gruppen → Bindestrich. */ +export function projektName(projekt: string): string { + return projekt.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '') || 'Projekt'; +} +const zwei = (n: number) => String(n).padStart(2, '0'); +export function sicherungsName(projekt: string, zeit: Date): string { + return `${projektName(projekt)}-${zeit.getFullYear()}-${zwei(zeit.getMonth() + 1)}-${zwei(zeit.getDate())}-${zwei(zeit.getHours())}${zwei(zeit.getMinutes())}.zip`; +} + +export function sicherungsZeit(name: string, projekt: string): Date | null { + const prefix = projektName(projekt) + '-'; + 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.slice(1).map(Number) as [number, number, number, number, number]; + const d = new Date(jahr, monat - 1, tag, stunde, minute); + return sicherungsName(projekt, d) === name ? d : null; +} + +/** Sieben jüngste unterschiedliche Tage, danach die jüngsten vier vertretenen ISO-Wochen. */ +export function rotation(namen: string[], projekt: string): string[] { + const gueltig = namen.flatMap((name) => { const zeit = sicherungsZeit(name, projekt); return zeit ? [{ name, zeit }] : []; }) + .sort((a, b) => b.name.localeCompare(a.name)); + const tage = new Set(); + const wochen = new Set(); + const behalten = new Set(); + for (const { name, zeit } of gueltig) { + const tag = name.slice(-19, -9); + if (tage.has(tag)) continue; + tage.add(tag); + if (tage.size <= 7) behalten.add(name); + else { + const woche = isoWoche(zeit); + if (!wochen.has(woche) && wochen.size < 4) { wochen.add(woche); behalten.add(name); } + } + } + return gueltig.filter((x) => !behalten.has(x.name)).map((x) => x.name); +} + +export async function archivAufbauen(fs: ArchivLeser, mitVersionen: boolean): Promise { + const zip = new JSZip(); + for (const pfad of dateiAuswahl(await fs.auflisten(''), mitVersionen)) { + const daten = fs.lesenBinaer ? await fs.lesenBinaer(pfad) : await fs.lesen(pfad); + if (daten === null) throw new Error(`Datei fehlt / File missing: ${pfad}`); + zip.file(pfad, daten, { createFolders: false }); + } + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' }); +} + +/** Vor jedem Schreiben alle Einträge prüfen. JSZip normalisiert '..', daher unsafeOriginalName prüfen. */ +export async function archivLesen(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 pfad = (datei as JSZip.JSZipObject & { unsafeOriginalName?: string }).unsafeOriginalName ?? datei.name; + const teile = pfad.replace(/\/$/, '').split('/'); + const modus = Number(datei.unixPermissions ?? 0) & 0o170000; + if (!pfad || pfad.startsWith('/') || /[\\:\u0000]/.test(pfad) || teile.some((p) => !p || p === '..' || p === '.') || + (modus !== 0 && modus !== 0o100000 && modus !== 0o040000)) throw new Error('Ungültiger Archivpfad / Invalid archive path'); + if (datei.dir) continue; + const bytes = await datei.async('uint8array'); + groesse += bytes.length; + if (groesse > 2 * 1024 ** 3 || dateien.length >= 100_000) throw new Error('Archiv zu groß / Archive too large'); + dateien.push({ pfad, daten: bytes }); + } + if (!dateien.some((d) => d.pfad === 'projekt.md')) throw new Error('projekt.md fehlt / projekt.md missing'); + return dateien.sort((a, b) => a.pfad.localeCompare(b.pfad, 'en')); +} diff --git a/docs/issue-12-handover.md b/docs/issue-12-handover.md new file mode 100644 index 0000000..4ebd3a7 --- /dev/null +++ b/docs/issue-12-handover.md @@ -0,0 +1,102 @@ +# Issue #12 – Externe ZIP-Sicherung + +## Grundlage und Prüflücke + +Arbeitsbasis ist unverändert Commit `8eee912c8e00e702d2a2b6e5554fa30ea1819849`, identisch mit dem lokalen +`origin/main`. Keine fremden PR-Stände verwendet. Keine Branches, Commits, Pushes oder PRs angelegt. +Gelesen: gelieferte vollständige Issue-Beschreibung, `CLAUDE.md`, `CONTRIBUTING.md`, `docs/architektur.md`, +Design-Referenz und relevante Persistenz-/Backend-/UI-Dateien. Die Wissensbasis hatte keine passende Notiz. + +**Der vollständige GitHub-Thread einschließlich zusätzlicher Kommentare sowie offene PRs konnten nicht +abgerufen werden:** Die Umgebung verweigert `gh`. Daher gelten hier die im Auftrag gelieferten Entscheidungen; +zusätzliche Entscheidungen oder Abhängigkeiten aus GitHub sind nicht verifiziert. + +## Verbindliche Akzeptanzkriterien und Umsetzung + +| Kriterium | Umsetzung / Nachweis | +| --- | --- | +| AC-1 | Erster erfolgreicher Projektladevorgang pro lokalem Kalendertag startet automatisch eine externe ZIP. Vorhandene Tagesarchive verhindern Wiederholung; nach Fehler wird beim nächsten Öffnen erneut versucht. Eigene Sicherungslogik schreibt nie ins Projekt. Store-Test mit MemoryFs, täglicher Wiederholung und manuellem Menü. Native Ablage auf Mac noch prüfen. | +| AC-2 | Alle Dateien inklusive `.plotwerk/`, Recherche-Bildern und unbekannten Binärdateien; bytegetreu einschließlich BOM. Ausschluss `.DS_Store`, `.tmp`, `.temp`, `.swp`, `.swo` (auch `-`) und `~`; `.versionen/` optional. Core- und Tauri-Adapter-Tests. | +| AC-3 | Neuester Stand je der sieben jüngsten vertretenen Tage; danach jeweils neuester Stand aus vier älteren vertretenen ISO-Wochen. Nur gültige Namen dieses Projekts werden rotiert; fremde Dateien und unmögliche Kalenderdaten bleiben. Test mit 60 festen Tagen und mehreren Sicherungen am selben Tag. | +| AC-4 | Natives DE/EN-Menü „Ablage → Projekt als Archiv sichern …“; Eingaben vorher speichern, anschließend unabhängig von Automatik und vorhandenem Tagesstand sichern. UI-Test. | +| AC-5 | Startbildschirm: Liste im aktuellen Sicherungsort, Leerhinweis und zusätzlicher ZIP-Dateidialog. Ziel ausschließlich aus nativem Dialog, neu oder vollständig leer. ZIP-Pfade/Links werden vor Übergabe geprüft, Rust prüft relative Pfade erneut und schreibt zunächst in eine neue Staging-Ablage. Projekt erst nach erfolgreichem Abschluss öffnen. Tests für leere UI, Abbruch, unsichere ZIP und bytegetreue Wiederherstellung einschließlich Bildern über gemocktes Rust. Nativen Abschluss auf Mac noch prüfen. | +| AC-6 | Neue Commands akzeptieren für Schreiben/Löschen nur einen Dateinamen im festen ZIP-Muster unter einem freigegebenen Sicherungsordner. Keine absoluten Namen, `..`, anderen Endungen oder ZIP-/Projekt-Unterordner-Symlinks. Eigener Ort nur über nativen Dialog; direkte Änderung der Freigabe via `einstellung_schreiben` gesperrt. Rust-Tests ergänzt, hier nicht ausgeführt. `pfad.rs` bleibt unverändert. | +| AC-7 | DE/EN-Hinweis mit Fehlergrund, eigener Hinweiszustand; `ladeStatus` und Autosave-Fehler bleiben unberührt. Tests in beiden Sprachen mit EACCES und anschließendem Speichern. Native Dateirechte auf Mac noch prüfen. | +| AC-8 | Keine Sicherung beim Beenden, kein Beenden-Callback und kein Warten auf Archivläufe in der Autosave-Warteschlange. Rohdatei-I/O und ZIP-Schreiben im Rust-Blocking-Pool. Hängender Sicherungslauf blockiert Laden/Speicher-Warten im Test nicht. Vorhandene 3-s-Rust-Frist unverändert; reale Zeitmessung auf Mac offen. | + +## Präzisierungen und Grenzen + +- Standard aktiviert, ohne `.versionen/`. Standardort über Tauri `app_data_dir()`: + macOS `~/Library/Application Support/app.plotwerk.mac/Sicherungen//`. + Auch ein selbst gewählter Basisordner erhält einen Projekt-Unterordner. Einstellungen liegen nur im App-Ordner. +- **Minutengenauer Name versus mehrfache manuelle Sicherung:** Das feste Muster bleibt + `-YYYY-MM-DD-HHmm.zip`. Bei Kollision wird die nächste freie Minute im Namen verwendet, niemals + ein vorhandenes Archiv überschrieben. Der Namenszeitpunkt kann dadurch wenige Minuten vorausliegen. +- Gemeinsame Rust/TS-Namensregel: ASCII-Buchstaben, Ziffern, `_`, `-`, maximal 80 Zeichen; + ungültige Zeichengruppen → `-`, äußere Bindestriche entfernt, leer → `Projekt`. + Identisch benannte Projektordner teilen einen Sicherungs-Unterordner. Die bestehende Vorgabe ohne Projekt-ID + erlaubt keine eindeutige Trennung solcher Projekte; bei Bedarf verschiedene Basisorte wählen. +- **AC-1 „Projektordner unverändert“ und E8:** Die Sicherung selbst verändert nichts. Die bereits vorhandene + E8-Schnappschuss-Aufbewahrung beim Öffnen kann unabhängig davon alte `.versionen/`-Dateien löschen. + Sie wurde gemäß Out-of-Scope nicht geändert; eine absolut unveränderte Projektablage bei jedem Öffnen + wäre mit dieser bestehenden Politik nicht vereinbar. +- Große ZIPs werden im Speicher gebaut/gelesen. Wiederherstellung ist auf 2 GiB entpackte Daten und + 100.000 Dateien begrenzt. Keine Verschlüsselung/Cloud. Browser-Demo zeigt die neue Funktion nicht. +- Es gibt keine spezielle Sicherungs-Designvorlage; die UI verwendet vorhandene Dialog-, Button-, + Abschnitts- und Fehlerbausteine mit bestehenden Token-basierten Styles. + +## Testgetriebener Nachweis + +1. Neue Core-/UI-Tests vor Implementierung ausgeführt: zunächst fehlende Module, anschließend + explizite Platzhalter und rote Assertions für Archivinhalt, Namen, Rotation, Einstellungen und Wiederherstellung. +2. Der erste UI-Lauf enthielt zusätzlich einen falschen Fixture-Import; dieser wurde vor Implementierung + korrigiert. Fehlende Einstellungs-/Wiederherstellungs-UI wurde durch rote Assertions nachgewiesen; + die Tages-/Fehlerfälle wurden erst mit der Implementierung erfolgreich ausgeführt. + ISO-Wochen-Erwartung auf den korrekten Sonntag als jüngsten Wochenstand korrigiert. +3. Nach Implementierung zielgerichtete Suite zuletzt: **40 Tests in fünf Dateien grün**, einschließlich BOM-Test. +4. `npm test`: **1.251 Tests in 123 Dateien grün** (vor zusätzlichem BOM-Test). +5. `npm run typecheck`: grün. `npm run build`: grün; bestehender Chunkgrößen-Hinweis. +6. `npm run sperrliste`: erfolgreich beendet, aber **keine lokale Sperrliste vorhanden; nichts geprüft**. +7. Abschließender zielgerichteter Testlauf, `npm run typecheck` und `git diff --check`: grün. + +Rust wurde entsprechend Servervorgabe weder gebaut noch ausgeführt. **Vor Freigabe auf macOS erforderlich:** +`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`; +gegebenenfalls Formatierung mit `cargo fmt` übernehmen. Keine grüne Rust-CI behauptet. + +### Nachprüfung der drei Review-Befunde + +- Rust-Formatierung: `sicherungen.rs` manuell in mehrzeilige Rust-Formatierung überführt, + Imports geordnet sowie Menüeintrag und `projekt::oeffnen`-Signatur umgebrochen. + **`cargo fmt --check` bleibt unbestätigt:** Weder `cargo` noch `rustfmt` sind auf diesem Server + verfügbar. Auf macOS `cargo fmt` und anschließend die oben genannten Rust-Prüfungen ausführen. +- ZIP-Schreiben: Hardlink bleibt der erste, atomare Versuch. Bei `Unsupported` oder `PermissionDenied` + wird das Ziel mit `create_new(true)` exklusiv angelegt, aus der bereits synchronisierten Temp-Datei + kopiert und synchronisiert. Bestehende Dateien werden auch im Fallback nie überschrieben; + bei Kopier-/Sync-Fehler wird die neu angelegte ZIP entfernt. Vier Rust-Regressionstests ergänzen + beide Fehlerarten, Kollisionen, Fehlerbereinigung und den normalen Hardlink-Pfad; hier nicht ausgeführt. + Der Fallback macht die ZIP während des Kopierens sichtbar; ein Prozessabsturz kann dabei eine + unvollständige ZIP hinterlassen. Ein Rename mit vorherigem Existenzcheck wurde wegen der + Überschreib-Race vermieden. +- Reihenfolge: Tages-Sicherung beginnt erst nach Abschluss der E8-Aufbewahrung, auch nach einem + protokollierten Aufräumfehler. Laden, Autosave-Warten und Beenden warten weiterhin nicht darauf. + Zwei neue UI-Regressionstests mit angehaltener realer MemoryFs-Löschung schlugen vor dem Fix + an der vorzeitig gestarteten Sicherung fehl. Danach prüfen sie ZIP-Inhalt und Fehlerhinweis. +- Nach Korrektur: zielgerichtet **29 Tests in vier Dateien grün**; vollständig `npm test`: + **1.254 Tests in 123 Dateien grün**. `npm run typecheck`, `npm run build` und + `git diff --check` grün. Build mit bekanntem Chunkgrößen-Hinweis. + `npm run sperrliste` erfolgreich beendet, weiterhin ohne lokale Sperrliste und daher ohne Inhaltsprüfung. +- Keine Commits oder Pushes. Arbeitsbasis weiterhin identisch mit lokalem `origin/main`. + GitHub-Thread-/PR-Prüfung weiterhin durch gesperrten `gh`-Zugriff nicht möglich. + +### Manuelle Mac-Abnahme (offen) + +- Demo-Projekt aus `fixtures/` kopieren, öffnen: externe Tages-ZIP, am selben Tag erneut öffnen: keine zweite + automatische ZIP; am nächsten Tag neue ZIP. Menü zweimal direkt hintereinander: beide Stände erhalten. +- Eigener Sicherungsort, App-Neustart, gesicherte Freigabe; Ort innerhalb des Projekts muss abgewiesen werden. +- Eigener Ort auf exFAT/FAT bzw. NAS ohne Hardlinks: ZIP vollständig lesbar; Namenskollision überschreibt nichts. +- `.versionen/` umschalten; ZIP mit `.plotwerk/`, Bildern und unbekannten Dateien prüfen. +- Startbildschirm: Liste/Leerhinweis, externe ZIP, Abbruch, Wiederherstellung in neuen/leeren Ordner, + byteweiser Vergleich; belegten Ordner ablehnen. +- Nicht beschreibbarer Ort in DE/EN: Hinweis mit Grund, Schreiben/Autosave weiter nutzbar. +- Großes Projekt während laufender Sicherung schließen: 3-s-Frist messen; kein neuer Sicherungslauf beim Beenden. +- Screenshot der nativen Einstellungen und Wiederherstellung für Review ergänzen. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index afd4430..1c757ad 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; @@ -83,6 +84,16 @@ pub fn run() { projekt::fs_schreiben_binaer, projekt::einstellung_lesen, projekt::einstellung_schreiben, + sicherungen::sicherung_einstellungen, + sicherungen::sicherung_einstellen, + sicherungen::sicherung_ordner_waehlen, + sicherungen::sicherung_auflisten, + sicherungen::sicherung_schreiben, + sicherungen::sicherung_entfernen, + sicherungen::sicherung_projekt_lesen, + sicherungen::sicherung_zip_waehlen, + sicherungen::sicherung_lesen, + sicherungen::sicherung_wiederherstellen, extern_dateien::datei_waehlen, extern_dateien::datei_lesen_extern, extern_dateien::datei_speichern, diff --git a/src-tauri/src/menue.rs b/src-tauri/src/menue.rs index 58845b7..e300d56 100644 --- a/src-tauri/src/menue.rs +++ b/src-tauri/src/menue.rs @@ -92,6 +92,12 @@ pub fn erstellen(app: &AppHandle) -> tauri::Result> { .build()?; let ablage = SubmenuBuilder::new(app, t("Ablage", "File")) + .item(&befehl( + app, + "projekt-archiv", + t("Projekt als Archiv sichern …", "Back Up Project as Archive…"), + None, + )?) .item(&befehl( app, "projekt-oeffnen", diff --git a/src-tauri/src/projekt.rs b/src-tauri/src/projekt.rs index db1dce4..eb6d429 100644 --- a/src-tauri/src/projekt.rs +++ b/src-tauri/src/projekt.rs @@ -22,6 +22,9 @@ pub struct Projekte { } impl Projekte { + pub(crate) fn enthaelt_pfad(&self, pfad: &Path) -> bool { + self.liste.values().any(|w| pfad.starts_with(w.pfad())) + } /// Registriert einen Ordner (gleicher Ordner = gleiche Kennung) und macht ihn aktuell. pub fn oeffnen(&mut self, wurzel: Wurzel) -> u32 { let kennung = match self.liste.iter().find(|(_, w)| w.pfad() == wurzel.pfad()) { @@ -74,7 +77,11 @@ 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,10 +421,10 @@ pub async fn einstellung_schreiben( schluessel: String, wert: serde_json::Value, ) -> Result<(), String> { - if schluessel == einstellungen::LETZTES_PROJEKT { + if schluessel == einstellungen::LETZTES_PROJEKT || schluessel == "sicherungsOrt" { return Err(crate::sprache::t( - "„letztesProjekt“ wird nur beim Öffnen eines Projekts gesetzt.", - "“letztesProjekt” is only set when a project is opened.", + "Diese Einstellung wird nur über den zuständigen nativen Dialog gesetzt.", + "This setting is only set through its dedicated native dialog.", ) .into()); } diff --git a/src-tauri/src/sicherungen.rs b/src-tauri/src/sicherungen.rs new file mode 100644 index 0000000..373fd37 --- /dev/null +++ b/src-tauri/src/sicherungen.rs @@ -0,0 +1,652 @@ +//! Enge Freigabe außerhalb des Projekts. fs_* und deren Pfadwächter bleiben unverändert. +use crate::projekt::sperre; +use crate::{einstellungen, projekt, Zustand}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tauri::ipc::{InvokeBody, Request, Response}; +use tauri::{AppHandle, Manager, State, WebviewWindow}; +use tauri_plugin_dialog::DialogExt; + +fn fehler(e: impl std::fmt::Display) -> String { + crate::tf!("Sicherung fehlgeschlagen: {e}", "Backup failed: {e}") +} +fn projekt_name(text: &str) -> String { + let mut name = String::new(); + let mut trennzeichen = false; + for c in text.chars() { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + if trennzeichen && !name.is_empty() { + name.push('-'); + } + trennzeichen = false; + name.push(c); + } else { + trennzeichen = true; + } + } + let name = name.trim_matches('-').chars().take(80).collect::(); + let name = name.trim_end_matches('-'); + if name.is_empty() { + "Projekt".into() + } else { + name.into() + } +} +fn name_gueltig(name: &str) -> bool { + if name.len() < 20 || !name.is_ascii() { + return false; + } + let (projekt, suffix) = name.split_at(name.len() - 20); + if projekt.is_empty() || projekt_name(projekt) != projekt || !suffix.starts_with('-') { + return false; + } + let s = &suffix[1..]; + if s.len() != 19 + || &s[4..5] != "-" + || &s[7..8] != "-" + || &s[10..11] != "-" + || &s[15..] != ".zip" + { + return false; + } + let zahl = |a, b| s[a..b].parse::().ok(); + let (Some(y), Some(m), Some(d), Some(h), Some(min)) = ( + zahl(0, 4), + zahl(5, 7), + zahl(8, 10), + zahl(11, 13), + zahl(13, 15), + ) else { + return false; + }; + let schaltjahr = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let tage = match m { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if schaltjahr { + 29 + } else { + 28 + } + } + _ => 0, + }; + y >= 1000 && d > 0 && d <= tage && h < 24 && min < 60 +} +fn archiv_pfad(ordner: &Path, name: &str) -> Result { + if !name_gueltig(name) { + return Err(fehler(crate::sprache::t( + "Ungültiger ZIP-Name", + "Invalid ZIP name", + ))); + } + let root = ordner.canonicalize().map_err(fehler)?; + let ziel = root.join(name); + if let Ok(meta) = std::fs::symlink_metadata(&ziel) { + if !meta.is_file() || meta.file_type().is_symlink() { + return Err(fehler("ZIP symlink/directory")); + } + if ziel.canonicalize().map_err(fehler)?.parent() != Some(root.as_path()) { + return Err(fehler("ZIP outside backup folder")); + } + } + Ok(ziel) +} + +#[derive(Serialize, Deserialize)] +pub struct Einstellung { + aktiv: bool, + #[serde(rename = "mitVersionen")] + mit_versionen: bool, + ordner: Option, +} +fn einstellung(app: &AppHandle, zustand: &Zustand) -> Result { + let _s = sperre(&zustand.einstellungen); + let datei = projekt::einstellungsdatei(app)?; + let wert = einstellungen::lesen(&datei, "sicherungen"); + Ok(Einstellung { + aktiv: wert + .as_ref() + .and_then(|w| w.get("aktiv")) + .and_then(|v| v.as_bool()) + .unwrap_or(true), + mit_versionen: wert + .as_ref() + .and_then(|w| w.get("mitVersionen")) + .and_then(|v| v.as_bool()) + .unwrap_or(false), + ordner: einstellungen::lesen(&datei, "sicherungsOrt") + .and_then(|v| v.as_str().map(String::from)), + }) +} +#[tauri::command] +pub async fn sicherung_einstellungen( + app: AppHandle, + zustand: State<'_, Zustand>, +) -> Result { + einstellung(&app, &zustand) +} +#[tauri::command] +pub async fn sicherung_einstellen( + app: AppHandle, + zustand: State<'_, Zustand>, + wert: Einstellung, +) -> Result<(), String> { + let alt = einstellung(&app, &zustand)?; + if wert.ordner.is_some() && wert.ordner != alt.ordner { + return Err(fehler("Folder requires native dialog")); + } + let _s = sperre(&zustand.einstellungen); + let datei = projekt::einstellungsdatei(&app)?; + einstellungen::schreiben( + &datei, + "sicherungen", + serde_json::json!({"aktiv": wert.aktiv, "mitVersionen": wert.mit_versionen}), + )?; + if wert.ordner.is_none() { + einstellungen::schreiben(&datei, "sicherungsOrt", serde_json::Value::Null)?; + } + Ok(()) +} +fn ausserhalb_projekt(zustand: &Zustand, root: &Path) -> Result<(), String> { + if sperre(&zustand.projekte).enthaelt_pfad(root) { + return Err(fehler(crate::sprache::t( + "Sicherungsordner muss außerhalb des Projekts liegen", + "Backup folder must be outside the project", + ))); + } + Ok(()) +} +fn basis(app: &AppHandle, zustand: &Zustand) -> Result { + let root = match einstellung(app, zustand)?.ordner { + Some(p) => { + let gespeichert = PathBuf::from(p); + let kanonisch = gespeichert.canonicalize().map_err(fehler)?; + if kanonisch != gespeichert { + return Err(fehler("Backup folder changed (symlink)")); + } + kanonisch + } + None => { + let p = app + .path() + .app_data_dir() + .map_err(fehler)? + .join("Sicherungen"); + ausserhalb_projekt(zustand, &p)?; + std::fs::create_dir_all(&p).map_err(fehler)?; + p.canonicalize().map_err(fehler)? + } + }; + ausserhalb_projekt(zustand, &root)?; + Ok(root) +} +fn ordner(app: &AppHandle, zustand: &Zustand, kennung: u32) -> Result { + let wurzel = sperre(&zustand.projekte).wurzel(Some(kennung))?; + let name = projekt_name( + &wurzel + .pfad() + .file_name() + .unwrap_or_default() + .to_string_lossy(), + ); + let root = basis(app, zustand)?; + let ziel = root.join(name); + if let Ok(meta) = std::fs::symlink_metadata(&ziel) { + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err(fehler("Backup folder symlink/file")); + } + } + std::fs::create_dir_all(&ziel).map_err(fehler)?; + let ziel = ziel.canonicalize().map_err(fehler)?; + if ziel.parent() != Some(root.as_path()) { + return Err(fehler("Backup folder outside root")); + } + ausserhalb_projekt(zustand, &ziel)?; + Ok(ziel) +} +#[tauri::command] +pub async fn sicherung_ordner_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", + )) + .blocking_pick_folder() + else { + return Ok(None); + }; + let p = p + .into_path() + .map_err(fehler)? + .canonicalize() + .map_err(fehler)?; + ausserhalb_projekt(&zustand, &p)?; + let text = p.to_string_lossy().into_owned(); + let _s = sperre(&zustand.einstellungen); + einstellungen::schreiben( + &projekt::einstellungsdatei(&app)?, + "sicherungsOrt", + serde_json::Value::String(text.clone()), + )?; + Ok(Some(text)) +} +#[derive(Serialize)] +pub struct Archiv { + name: String, + pfad: String, +} +fn liste(root: &Path) -> Result, String> { + let mut liste = Vec::new(); + for eintrag in std::fs::read_dir(root).map_err(fehler)? { + let eintrag = eintrag.map_err(fehler)?; + let name = eintrag.file_name().to_string_lossy().into_owned(); + if name_gueltig(&name) && archiv_pfad(root, &name).is_ok() { + liste.push(Archiv { + name, + pfad: eintrag.path().to_string_lossy().into_owned(), + }); + } + } + Ok(liste) +} +#[tauri::command] +pub async fn sicherung_auflisten( + app: AppHandle, + zustand: State<'_, Zustand>, + kennung: Option, +) -> Result, String> { + let mut archive = if let Some(k) = kennung { + liste(&ordner(&app, &zustand, k)?)? + } else { + let root = basis(&app, &zustand)?; + let mut result = liste(&root)?; + for e in std::fs::read_dir(&root).map_err(fehler)? { + let e = e.map_err(fehler)?; + if e.file_type().map_err(fehler)?.is_dir() { + ausserhalb_projekt(&zustand, &e.path())?; + result.extend(liste(&e.path())?); + } + } + result + }; + archive.sort_by(|a, b| b.name.cmp(&a.name)); + Ok(archive) +} + +fn archiv_veroeffentlichen( + temp: &Path, + ziel: &Path, + hard_link: impl FnOnce(&Path, &Path) -> std::io::Result<()>, +) -> std::io::Result<()> { + match hard_link(temp, ziel) { + Ok(()) => return Ok(()), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied + ) => {} + Err(e) => return Err(e), + } + // exFAT/FAT und manche NAS erlauben keine Hardlinks. create_new verhindert + // auch im Fallback atomar das Überschreiben vorhandener Dateien (inklusive Links). + let mut datei = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(ziel)?; + let result = (|| { + let mut quelle = std::fs::File::open(temp)?; + std::io::copy(&mut quelle, &mut datei)?; + datei.sync_all() + })(); + drop(datei); + if result.is_err() { + let _ = std::fs::remove_file(ziel); + } + result +} + +#[tauri::command] +pub async fn sicherung_schreiben( + app: AppHandle, + zustand: State<'_, Zustand>, + request: Request<'_>, +) -> Result<(), String> { + let InvokeBody::Raw(daten) = request.body() else { + return Err(fehler("Binary data expected")); + }; + let kopf = |name: &str| request.headers().get(name).and_then(|v| v.to_str().ok()); + let kennung = kopf("plotwerk-kennung") + .ok_or_else(|| fehler("ID missing"))? + .parse::() + .map_err(fehler)?; + let name = kopf("plotwerk-name").ok_or_else(|| fehler("Name missing"))?; + let ziel = archiv_pfad(&ordner(&app, &zustand, kennung)?, name)?; + let daten = daten.clone(); + // Nie den Tauri-Ereignisthread blockieren; kein Beenden-Wait und keine Projekt-Schreibsperre. + tauri::async_runtime::spawn_blocking(move || { + let temp = ziel.with_extension("zip.tmp"); + let mut angelegt = false; + let result = (|| { + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp) + .map_err(fehler)?; + angelegt = true; + f.write_all(&daten).map_err(fehler)?; + f.sync_all().map_err(fehler)?; + drop(f); + archiv_veroeffentlichen(&temp, &ziel, |a, b| std::fs::hard_link(a, b)).map_err(fehler) + })(); + if angelegt { + let _ = std::fs::remove_file(&temp); + } + result + }) + .await + .map_err(fehler)? +} +#[tauri::command] +pub async fn sicherung_entfernen( + app: AppHandle, + zustand: State<'_, Zustand>, + kennung: u32, + name: String, +) -> Result<(), String> { + std::fs::remove_file(archiv_pfad(&ordner(&app, &zustand, kennung)?, &name)?).map_err(fehler) +} +#[tauri::command] +pub async fn sicherung_projekt_lesen( + zustand: State<'_, Zustand>, + kennung: u32, + pfad: String, +) -> Result { + let wurzel = sperre(&zustand.projekte).wurzel(Some(kennung))?; + let p = wurzel.aufloesen(&pfad, false)?; + let daten = tauri::async_runtime::spawn_blocking(move || std::fs::read(p).map_err(fehler)) + .await + .map_err(fehler)??; + Ok(Response::new(daten)) +} +#[tauri::command] +pub async fn sicherung_zip_waehlen( + app: AppHandle, + fenster: WebviewWindow, + zustand: State<'_, Zustand>, +) -> Result, String> { + let Some(p) = app + .dialog() + .file() + .set_parent(&fenster) + .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(|s| s.to_str()) != Some("zip") { + return Err(fehler("ZIP required")); + } + sperre(&zustand.extern_lesen).insert(p.clone()); + Ok(Some(p.to_string_lossy().into_owned())) +} +#[tauri::command] +pub async fn sicherung_lesen( + app: AppHandle, + zustand: State<'_, Zustand>, + pfad: String, +) -> Result { + let p = PathBuf::from(&pfad); + if p.components().any(|c| c == std::path::Component::ParentDir) { + return Err(fehler("Invalid path")); + } + let p = p.canonicalize().map_err(fehler)?; + if p.extension().and_then(|s| s.to_str()) != Some("zip") { + return Err(fehler("ZIP required")); + } + let dialog = sperre(&zustand.extern_lesen).remove(&p); + if !dialog { + let root = basis(&app, &zustand)?; + let parent = p.parent().ok_or_else(|| fehler("Folder missing"))?; + if parent != root && parent.parent() != Some(root.as_path()) { + return Err(fehler("ZIP outside backup folder")); + } + let name = p + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| fehler("Name invalid"))?; + archiv_pfad(parent, name)?; + } + if std::fs::metadata(&p).map_err(fehler)?.len() > 2 * 1024 * 1024 * 1024 { + return Err(fehler("ZIP exceeds 2 GB")); + } + let daten = tauri::async_runtime::spawn_blocking(move || std::fs::read(p).map_err(fehler)) + .await + .map_err(fehler)??; + Ok(Response::new(daten)) +} +#[derive(Deserialize)] +pub struct ArchivDatei { + pfad: String, + daten: Vec, +} +fn projekt_pfad_gueltig(p: &str) -> bool { + !p.is_empty() + && !p.contains(['\\', ':', '\0']) + && p.split('/').all(|t| !t.is_empty() && t != "." && t != "..") +} +#[tauri::command] +pub async fn sicherung_wiederherstellen( + app: AppHandle, + fenster: WebviewWindow, + zustand: State<'_, Zustand>, + dateien: Vec, +) -> Result, String> { + let mut gesehen = std::collections::HashSet::new(); + let mut groesse = 0u64; + for d in &dateien { + groesse += d.daten.len() as u64; + if !projekt_pfad_gueltig(&d.pfad) || !gesehen.insert(d.pfad.clone()) { + return Err(fehler("Invalid/duplicate archive path")); + } + } + if !gesehen.contains("projekt.md") + || groesse > 2 * 1024 * 1024 * 1024 + || dateien.len() > 100_000 + { + return Err(fehler("Invalid project archive")); + } + let Some(p) = app + .dialog() + .file() + .set_parent(&fenster) + .set_title(crate::sprache::t( + "In neuen, leeren Ordner wiederherstellen", + "Restore into New, Empty Folder", + )) + .set_file_name("Projekt") + .set_can_create_directories(true) + .blocking_save_file() + else { + return Ok(None); + }; + let ziel = p.into_path().map_err(fehler)?; + if ziel.exists() + && (!ziel.is_dir() || std::fs::read_dir(&ziel).map_err(fehler)?.next().is_some()) + { + return Err(fehler(crate::sprache::t( + "Zielordner ist nicht leer", + "Target folder is not empty", + ))); + } + let parent = ziel + .parent() + .ok_or_else(|| fehler("Parent missing"))? + .canonicalize() + .map_err(fehler)?; + let ziel = parent.join(ziel.file_name().ok_or_else(|| fehler("Name missing"))?); + if std::fs::symlink_metadata(&ziel).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(fehler("Target symlink")); + } + ausserhalb_projekt(&zustand, &ziel)?; + let ziel_kopie = ziel.clone(); + tauri::async_runtime::spawn_blocking(move || { + // Neue private Staging-Ablage: kein Archivpfad kann auf bestehende Dateien zeigen. + let temp = parent.join(format!( + ".plotwerk-wiederherstellen-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(fehler)? + .as_nanos() + )); + std::fs::create_dir(&temp).map_err(fehler)?; + let result = (|| { + for d in dateien { + let p = temp.join(&d.pfad); + std::fs::create_dir_all(p.parent().ok_or_else(|| fehler("Parent missing"))?) + .map_err(fehler)?; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(p) + .map_err(fehler)?; + f.write_all(&d.daten).map_err(fehler)?; + f.sync_all().map_err(fehler)?; + } + std::fs::rename(&temp, &ziel_kopie).map_err(fehler) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&temp); + } + result + }) + .await + .map_err(fehler)??; + projekt::oeffnen(&app, &zustand, &ziel).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn veroeffentlichen_ohne_hardlinks() { + for art in [ + std::io::ErrorKind::Unsupported, + std::io::ErrorKind::PermissionDenied, + ] { + let tmp = tempfile::tempdir().unwrap(); + let temp = tmp.path().join("Hafen-2026-10-20-0905.zip.tmp"); + let ziel = tmp.path().join("Hafen-2026-10-20-0905.zip"); + std::fs::write(&temp, b"ZIP-Inhalt").unwrap(); + archiv_veroeffentlichen(&temp, &ziel, |_, _| Err(std::io::Error::from(art))).unwrap(); + assert_eq!(std::fs::read(&ziel).unwrap(), b"ZIP-Inhalt"); + std::fs::write(&temp, b"Neuer Stand").unwrap(); + let e = archiv_veroeffentlichen(&temp, &ziel, |_, _| Err(std::io::Error::from(art))) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&ziel).unwrap(), b"ZIP-Inhalt"); + } + } + + #[test] + fn veroeffentlichen_raeumt_fehlgeschlagene_kopie_auf() { + let tmp = tempfile::tempdir().unwrap(); + let temp = tmp.path().join("fehlt.zip.tmp"); + let ziel = tmp.path().join("Hafen-2026-10-20-0905.zip"); + let e = archiv_veroeffentlichen(&temp, &ziel, |_, _| { + Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::NotFound); + assert!(!ziel.exists()); + } + + #[test] + fn veroeffentlichen_gibt_andere_fehler_weiter() { + let tmp = tempfile::tempdir().unwrap(); + let temp = tmp.path().join("Hafen-2026-10-20-0905.zip.tmp"); + let ziel = tmp.path().join("Hafen-2026-10-20-0905.zip"); + std::fs::write(&temp, b"ZIP-Inhalt").unwrap(); + let e = archiv_veroeffentlichen(&temp, &ziel, |_, _| { + Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists)) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert!(!ziel.exists()); + } + + #[test] + fn veroeffentlichen_mit_hardlink_ueberschreibt_nie() { + let tmp = tempfile::tempdir().unwrap(); + let temp = tmp.path().join("Hafen-2026-10-20-0905.zip.tmp"); + let ziel = tmp.path().join("Hafen-2026-10-20-0905.zip"); + std::fs::write(&temp, b"ZIP-Inhalt").unwrap(); + archiv_veroeffentlichen(&temp, &ziel, |a, b| std::fs::hard_link(a, b)).unwrap(); + std::fs::remove_file(&temp).unwrap(); + std::fs::write(&temp, b"Neuer Stand").unwrap(); + assert!(archiv_veroeffentlichen(&temp, &ziel, |a, b| std::fs::hard_link(a, b)).is_err()); + assert_eq!(std::fs::read(&ziel).unwrap(), b"ZIP-Inhalt"); + } + + #[test] + fn namen_entsprechen_typescript() { + assert_eq!(projekt_name("Hafen: /Lichter\\.."), "Hafen-Lichter"); + assert_eq!(projekt_name("é 中文"), "Projekt"); + for name in [ + "Hafenlichter-2026-10-20-0905.zip", + "Projekt-2024-02-29-2359.zip", + ] { + assert!(name_gueltig(name)); + } + for name in [ + "../Hafen-2026-10-20-0905.zip", + "/Hafen-2026-10-20-0905.zip", + "Hafen-2026-02-30-0905.zip", + "Hafen-2026-10-20-2400.zip", + "Hafen-2026-10-20-0905.txt", + "Hafen-2026-10-20-0905.ZIP", + ] { + assert!(!name_gueltig(name)); + } + } + + #[test] + fn nur_zip_im_freigegebenen_ordner() { + let tmp = tempfile::tempdir().unwrap(); + let name = "Hafen-2026-10-20-0905.zip"; + assert!(archiv_pfad(tmp.path(), name).is_ok()); + assert!(archiv_pfad(tmp.path(), &format!("../{name}")).is_err()); + assert!(archiv_pfad(tmp.path(), "text.txt").is_err()); + #[cfg(unix)] + { + let fremd = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(fremd.path().join(name), tmp.path().join(name)).unwrap(); + assert!(archiv_pfad(tmp.path(), name).is_err()); + } + } + + #[test] + fn wiederherstellung_weist_unsichere_pfade_ab() { + for name in ["../x", "/x", "a/../x", "a\\x", "a//x", "C:/x", "./x"] { + assert!(!projekt_pfad_gueltig(name)); + } + assert!(projekt_pfad_gueltig(".plotwerk/hinweise.json")); + assert!(projekt_pfad_gueltig("recherche/dateien/büro.png")); + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 83b64d4..9bd4796 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -16,6 +16,7 @@ const Assistent = lazy(() => import('../features/funktionen/Assistent').then((m) export function App() { const ladeStatus = useStore((s) => s.ladeStatus); const fehler = useStore((s) => s.fehler); + const sicherungsHinweis = useStore((s) => s.sicherungsHinweis); const initialisieren = useStore((s) => s.initialisieren); const assistentOffen = useFunktionsStore((s) => s.assistentOffen); const t = useTexte(); @@ -42,6 +43,7 @@ export function App() {
)} + {sicherungsHinweis &&
{sicherungsHinweis}
} {assistentOffen && ( diff --git a/src/app/Startbildschirm.tsx b/src/app/Startbildschirm.tsx index 6f42894..c9512bf 100644 --- a/src/app/Startbildschirm.tsx +++ b/src/app/Startbildschirm.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { Wiederherstellen } from '../features/sicherungen/Wiederherstellen'; import { demoGeaendert, demoZuruecksetzen } from '../backend/demoSpeicher'; import { useTexte } from '../i18n'; import { useStore } from '../state/store'; @@ -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 [wiederherstellen, setWiederherstellen] = useState(false); const [titel, setTitel] = useState(''); const [beschaeftigt, setBeschaeftigt] = useState(false); const [meldung, setMeldung] = useState(null); @@ -66,6 +68,8 @@ export function Startbildschirm() { {t.app.start.importieren}
+ {backend?.art === 'tauri' && backend.sicherungen && } + {wiederherstellen && setWiederherstellen(false)} />} {(meldung || fehler) && (

{meldung ?? fehler} diff --git a/src/app/befehle.ts b/src/app/befehle.ts index d37c8a6..3145fd5 100644 --- a/src/app/befehle.ts +++ b/src/app/befehle.ts @@ -3,6 +3,8 @@ * befehlAusfuehren auf oder sendet window.dispatchEvent(new CustomEvent('plotwerk:befehl', {detail: 'export'}))). */ import { oeffneVorschau, schliesseVorschau, vorschauOffen } from '../features/vorschau'; +import { sichereProjekt } from '../features/sicherungen/ablauf'; +import { f, texte } from '../i18n'; import { useStore, type Bereich } from '../state/store'; import { BEREICHE } from './bereiche'; import { funktionFuerBefehl, funktionSicherstellen } from './funktionen'; @@ -18,6 +20,7 @@ export type Befehl = | 'pruefen' | 'buch-pruefen' | 'projekt-oeffnen' + | 'projekt-archiv' | 'vorschau' // Werkzeuge G1–G8 | 'zeit-vorschlaege' @@ -51,6 +54,15 @@ 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.fs || s.backend?.art !== 'tauri') break; + try { + await s.editorSichern(); + await s.alleSpeichernAbwarten(); + await sichereProjekt(s.backend, s.fs, s.daten.projekt.titel, true); + useStore.setState({ sicherungsHinweis: null }); + } catch (e) { useStore.setState({ sicherungsHinweis: f(texte().sicherungen.fehler, { grund: String(e) }) }); } + break; case 'schnellsuche': if (s.daten) s.oeffneDialog('schnellsuche'); break; diff --git a/src/backend/tauri/index.ts b/src/backend/tauri/index.ts index bc64e52..17befb8 100644 --- a/src/backend/tauri/index.ts +++ b/src/backend/tauri/index.ts @@ -18,6 +18,7 @@ import { legeGrundgeruestAn } from './grundgeruest'; import { DATENBANK, erstelleSqlIndexSpeicher } from './indexSpeicher'; import { erstelleTauriKiTransport, type KiZiel } from './kiTransport'; import { TauriFs, type ProjektInfo } from './tauriFs'; +import { erstelleSicherungsBackend } from './sicherungen'; export { TauriFs, type ProjektInfo } from './tauriFs'; export { TauriFehler } from './aufruf'; @@ -91,6 +92,7 @@ export async function erstelleTauriBackend(): Promise { return { art: 'tauri', + sicherungen: erstelleSicherungsBackend(uebernehmen), async projektOeffnen() { return uebernehmen(await aufruf('projekt_waehlen')); diff --git a/src/backend/tauri/sicherungen.test.ts b/src/backend/tauri/sicherungen.test.ts new file mode 100644 index 0000000..8e846a6 --- /dev/null +++ b/src/backend/tauri/sicherungen.test.ts @@ -0,0 +1,47 @@ +import JSZip from 'jszip'; +import { archivAufbauen } from '@core/sicherungen'; +import { demoFs } from '../../../fixtures/demoProjekt'; +import { TauriFs } from './tauriFs'; +import { erstelleSicherungsBackend } from './sicherungen'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: mocks.invoke })); +const info = { kennung: 7, pfad: '/Projekte/Hafenlichter', name: 'Hafenlichter' }; +beforeEach(() => mocks.invoke.mockReset()); + +it('sichert Rohbytes über den engen Lesebefehl und schreibt mit Projektkennung', async () => { + const api = erstelleSicherungsBackend(() => null); + const fs = new TauriFs(info); + const bytes = new Uint8Array([239, 187, 191, 65, 255]); + mocks.invoke.mockResolvedValue(bytes.buffer); + expect(await api.leser!(fs).lesenBinaer!('unbekannt.bin')).toEqual(bytes); + expect(mocks.invoke).toHaveBeenCalledWith('sicherung_projekt_lesen', { kennung: 7, pfad: 'unbekannt.bin' }, undefined); + await api.schreiben(fs, 'Hafenlichter-2026-10-20-0905.zip', bytes); + expect(mocks.invoke).toHaveBeenLastCalledWith('sicherung_schreiben', bytes, { headers: { 'plotwerk-kennung': '7', 'plotwerk-name': 'Hafenlichter-2026-10-20-0905.zip' } }); +}); + +it('stellt einschließlich Recherche-Bild bytegetreu in den nativen Zielordner wieder her', async () => { + const fs = demoFs(); + const bild = new Uint8Array([0, 128, 255]); + await fs.schreibenBinaer('recherche/dateien/bild.png', bild); + const zip = await archivAufbauen(fs, true); + const uebernehmen = vi.fn((i) => new TauriFs(i)); + mocks.invoke.mockImplementation(async (cmd: string) => cmd === 'sicherung_lesen' ? zip.buffer : cmd === 'sicherung_wiederherstellen' ? info : null); + const api = erstelleSicherungsBackend(uebernehmen); + expect(await api.wiederherstellen({ name: 'Archiv.zip', pfad: '/Sicherungen/Archiv.zip' })).toBeInstanceOf(TauriFs); + const args = mocks.invoke.mock.calls.find(([cmd]) => cmd === 'sicherung_wiederherstellen')![1]; + expect(args.dateien.find((d: { pfad: string }) => d.pfad === 'recherche/dateien/bild.png').daten).toEqual([0, 128, 255]); + expect(args.dateien).toHaveLength((await fs.auflisten('')).length); + expect(uebernehmen).toHaveBeenCalledWith(info); +}); + +it('öffnet bei ungültigem ZIP oder Dialogabbruch keinen Zielordner', async () => { + const api = erstelleSicherungsBackend(() => null); + mocks.invoke.mockResolvedValue(null); + expect(await api.wiederherstellen(null)).toBeNull(); + expect(mocks.invoke).not.toHaveBeenCalledWith('sicherung_wiederherstellen', expect.anything(), undefined); + const zip = await new JSZip().file('projekt.md', 'x').file('../fremd', 'x').generateAsync({ type: 'uint8array' }); + mocks.invoke.mockImplementation(async (cmd: string) => cmd === 'sicherung_zip_waehlen' ? '/Archiv.zip' : zip.buffer); + await expect(api.wiederherstellen(null)).rejects.toThrow('archive path'); + expect(mocks.invoke.mock.calls.some(([cmd]) => cmd === 'sicherung_wiederherstellen')).toBe(false); +}); diff --git a/src/backend/tauri/sicherungen.ts b/src/backend/tauri/sicherungen.ts new file mode 100644 index 0000000..6d9148d --- /dev/null +++ b/src/backend/tauri/sicherungen.ts @@ -0,0 +1,34 @@ +import { archivLesen } from '@core/sicherungen'; +import type { ProjektDateisystem } from '@core/projekt/fs'; +import type { SicherungsBackend, SicherungsEinstellung } from '../types'; +import { aufruf } from './aufruf'; +import { TauriFs, type ProjektInfo } from './tauriFs'; + +function kennung(fs: ProjektDateisystem): number { + if (!(fs instanceof TauriFs)) throw new Error('TauriFs erwartet'); + return fs.kennung; +} +export function erstelleSicherungsBackend(uebernehmen: (info: ProjektInfo | null) => TauriFs | null): SicherungsBackend { + return { + einstellungen: () => aufruf('sicherung_einstellungen'), + einstellen: (wert) => aufruf('sicherung_einstellen', { wert }), + ordnerWaehlen: () => aufruf('sicherung_ordner_waehlen'), + auflisten: (fs) => aufruf('sicherung_auflisten', { kennung: fs ? kennung(fs) : null }), + schreiben: (fs, name, daten) => aufruf('sicherung_schreiben', daten, { headers: { 'plotwerk-kennung': String(kennung(fs)), 'plotwerk-name': name } }), + entfernen: (fs, name) => aufruf('sicherung_entfernen', { kennung: kennung(fs), name }), + leser: (fs) => ({ + lesen: (p) => fs.lesen(p), existiert: (p) => fs.existiert(p), auflisten: (p) => fs.auflisten(p), + async lesenBinaer(pfad) { return new Uint8Array(await aufruf('sicherung_projekt_lesen', { kennung: kennung(fs), pfad })); }, + }), + async wiederherstellen(archiv) { + // Native Freigabe zum Lesen: Listenpfad erneut prüfen oder ZIP aus Dateidialog wählen. + const pfad = archiv?.pfad ?? await aufruf('sicherung_zip_waehlen'); + if (!pfad) return null; + const daten = new Uint8Array(await aufruf('sicherung_lesen', { pfad })); + const dateien = await archivLesen(daten); + return uebernehmen(await aufruf('sicherung_wiederherstellen', { + dateien: dateien.map((d) => ({ pfad: d.pfad, daten: Array.from(d.daten) })), + })); + }, + }; +} diff --git a/src/backend/types.ts b/src/backend/types.ts index ae4b0d7..605afe2 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -4,6 +4,25 @@ import type { KiTransport } from '@core/ki/index'; import type { KiAnbieter } from '@core/model/types'; import type { ProjektDateisystem } from '@core/projekt/fs'; +export interface SicherungsEinstellung { + aktiv: boolean; + mitVersionen: boolean; + /** null = App-Standard, eigener Ort wird ausschließlich vom Rust-Dialog freigegeben */ + ordner: string | null; +} +export interface SicherungsArchiv { name: string; pfad: string } +export interface SicherungsBackend { + einstellungen(): Promise; + einstellen(wert: SicherungsEinstellung): Promise; + ordnerWaehlen(): Promise; + auflisten(fs: ProjektDateisystem | null): Promise; + schreiben(fs: ProjektDateisystem, name: string, daten: Uint8Array): Promise; + entfernen(fs: ProjektDateisystem, name: string): Promise; + /** null = ZIP-Dateidialog; Ziel immer ein neuer/leerer Ordner, nie das offene Projekt. */ + wiederherstellen(archiv: SicherungsArchiv | null): Promise; + leser?(fs: ProjektDateisystem): import('@core/sicherungen').ArchivLeser; +} + export interface GewaehlteDatei { name: string; daten: Uint8Array; @@ -11,6 +30,7 @@ export interface GewaehlteDatei { export interface Backend { art: 'demo' | 'tauri'; + sicherungen?: SicherungsBackend; /** 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..cd93d49 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 { SicherungsEinstellungen } from '../sicherungen/Einstellungen'; import { kiFehlerAnzeige } from '../../state/slices/ki'; import { useStore } from '../../state/store'; import { Abschnittslabel, Button, Dialog, Feld, Segment, StrangPunkt, Textfeld } from '../../ui'; @@ -306,6 +307,7 @@ export function EinstellungenDialog({ schliessen }: DialogKomponentenProps) { > +

diff --git a/src/features/sicherungen/Einstellungen.tsx b/src/features/sicherungen/Einstellungen.tsx new file mode 100644 index 0000000..e4789d5 --- /dev/null +++ b/src/features/sicherungen/Einstellungen.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; +import type { SicherungsEinstellung } from '../../backend/types'; +import { useStore } from '../../state/store'; +import { useTexte } from '../../i18n'; +import { Abschnittslabel, Button } from '../../ui'; + +export function SicherungsEinstellungen() { + const backend = useStore((s) => s.backend); + const api = backend?.art === 'tauri' ? backend.sicherungen : undefined; + const [wert, setWert] = useState(null); + const [fehler, setFehler] = useState(null); + const [laeuft, setLaeuft] = useState(false); + const t = useTexte(); + useEffect(() => { + let aktiv = true; + if (api) void api.einstellungen().then((w) => { if (aktiv) setWert(w); }).catch((e) => { if (aktiv) setFehler(String(e)); }); + return () => { aktiv = false; }; + }, [api]); + if (!api) return null; + async function aendern(neu: SicherungsEinstellung) { + setLaeuft(true); setFehler(null); + try { await api!.einstellen(neu); setWert(neu); } + catch (e) { setFehler(String(e)); } + finally { setLaeuft(false); } + } + return
+ {t.sicherungen.titel} + {wert && <> +

+

+

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

+ + {wert.ordner && } + } +

{t.sicherungen.erklaerung}

+ {fehler &&

{fehler}

} +
; +} diff --git a/src/features/sicherungen/Wiederherstellen.tsx b/src/features/sicherungen/Wiederherstellen.tsx new file mode 100644 index 0000000..67ed627 --- /dev/null +++ b/src/features/sicherungen/Wiederherstellen.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import type { SicherungsArchiv } from '../../backend/types'; +import { useStore } from '../../state/store'; +import { useTexte } from '../../i18n'; +import { Button, Dialog } from '../../ui'; + +export function Wiederherstellen({ schliessen }: { schliessen(): void }) { + const backend = useStore((s) => s.backend); + const [liste, setListe] = useState(null); + const [fehler, setFehler] = useState(null); + const [laeuft, setLaeuft] = useState(false); + const t = useTexte(); + useEffect(() => { + let aktiv = true; + void backend?.sicherungen?.auflisten(null).then((l) => { if (aktiv) setListe(l); }).catch((e) => { if (aktiv) setFehler(String(e)); }); + return () => { aktiv = false; }; + }, [backend]); + async function herstellen(archiv: SicherungsArchiv | null) { + setLaeuft(true); setFehler(null); + try { + const fs = await backend?.sicherungen?.wiederherstellen(archiv); + if (fs) { await useStore.getState().projektLaden(fs); schliessen(); } + } catch (e) { setFehler(String(e)); } + finally { setLaeuft(false); } + } + return { if (!laeuft) schliessen(); }} modalFest={laeuft}> +

{t.sicherungen.ziel}

+ {liste === null ?

{t.sicherungen.laden}

: liste.length === 0 ?

{t.sicherungen.leer}

: +
    {liste.map((a) =>
  • )}
} + + {fehler &&

{fehler}

} +
; +} diff --git a/src/features/sicherungen/ablauf.ts b/src/features/sicherungen/ablauf.ts new file mode 100644 index 0000000..8af5075 --- /dev/null +++ b/src/features/sicherungen/ablauf.ts @@ -0,0 +1,27 @@ +import type { Backend } from '../../backend/types'; +import type { ProjektDateisystem } from '@core/projekt/fs'; +import { archivAufbauen, rotation, sicherungsName, sicherungsZeit } from '@core/sicherungen'; + +/** Pro Dateisystem serialisiert; kein Beenden-Hook und kein Eintrag in der Autosave-Warteschlange. */ +const laeufe = new WeakMap>(); +export async function sichereProjekt(backend: Backend, fs: ProjektDateisystem, titel: string, manuell: boolean): Promise { + if (backend.art !== 'tauri' || !backend.sicherungen) return; + const api = backend.sicherungen; + const lauf = (laeufe.get(fs) ?? Promise.resolve()).catch(() => {}).then(async () => { + const einstellung = await api.einstellungen(); + if (!manuell && !einstellung.aktiv) return; + const zeit = new Date(); + const name = sicherungsName(titel, zeit); + const archive = await api.auflisten(fs); + const namen = archive.map((a) => a.name); + const heute = name.slice(0, -8); + if (!manuell && namen.some((n) => sicherungsZeit(n, titel) && n.startsWith(heute))) return; + // Minutenmuster bleibt verbindlich: bei Kollision nächste freie Minute verwenden, nie überschreiben. + while (namen.includes(sicherungsName(titel, zeit))) zeit.setMinutes(zeit.getMinutes() + 1); + const neu = sicherungsName(titel, zeit); + await api.schreiben(fs, neu, await archivAufbauen(api.leser?.(fs) ?? fs, einstellung.mitVersionen)); + for (const alt of rotation([...namen, neu], titel)) await api.entfernen(fs, alt); + }); + laeufe.set(fs, lauf); + try { await lauf; } finally { if (laeufe.get(fs) === lauf) laeufe.delete(fs); } +} diff --git a/src/features/sicherungen/sicherungen.test.tsx b/src/features/sicherungen/sicherungen.test.tsx new file mode 100644 index 0000000..237356f --- /dev/null +++ b/src/features/sicherungen/sicherungen.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { demoFs } from '../../../fixtures/demoProjekt'; +import { erstelleDemoBackend } from '../../backend/demo'; +import { erstelleStore, useStore } from '../../state/store'; +import { befehlAusfuehren } from '../../app/befehle'; +import { Startbildschirm } from '../../app/Startbildschirm'; +import { SicherungsEinstellungen } from './Einstellungen'; +import type { SicherungsBackend } from '../../backend/types'; +import { sichereProjekt } from './ablauf'; +import { festeSprache } from '../../i18n'; +import JSZip from 'jszip'; + +function backend() { + const sicherungen: SicherungsBackend = { + einstellungen: vi.fn(async () => ({ aktiv: true, mitVersionen: false, ordner: null })), + einstellen: vi.fn(async () => {}), + ordnerWaehlen: vi.fn(async () => '/Sicherungen'), + auflisten: vi.fn(async () => []), + schreiben: vi.fn(async () => {}), + entfernen: vi.fn(async () => {}), + wiederherstellen: vi.fn(async () => demoFs()), + }; + return { ...erstelleDemoBackend(), art: 'tauri' as const, sicherungen }; +} + +afterEach(() => festeSprache('de')); + +it('öffnet täglich automatisch, manuell auch erneut, ohne Beenden-Callback oder Projektänderung', async () => { + const b = backend(); + const fs = demoFs(); + const vorher = fs.alleDateien(); + const store = erstelleStore(); + store.setState({ backend: b }); + await store.getState().projektLaden(fs); + await waitFor(() => expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(1)); + const name = vi.mocked(b.sicherungen.schreiben).mock.calls[0]![1]; + vi.mocked(b.sicherungen.auflisten).mockResolvedValue([{ name, pfad: '/Sicherungen/' + name }]); + await sichereProjekt(b, fs, 'Hafenlichter', false); + expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(1); + useStore.setState({ backend: b, fs, daten: store.getState().daten }); + await befehlAusfuehren('projekt-archiv'); + expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(2); + expect(fs.alleDateien()).toEqual(vorher); +}); + +it('überspringt ausgeschaltete Automatik, manuelle Sicherung bleibt verfügbar', async () => { + const b = backend(); + vi.mocked(b.sicherungen.einstellungen).mockResolvedValue({ aktiv: false, mitVersionen: true, ordner: null }); + const fs = demoFs(); + await sichereProjekt(b, fs, 'Hafenlichter', false); + expect(b.sicherungen.schreiben).not.toHaveBeenCalled(); + await sichereProjekt(b, fs, 'Hafenlichter', true); + expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(1); +}); + +it('wartet beim Laden nicht auf eine hängende Sicherung und kann weiter speichern', async () => { + const b = backend(); + let fertig!: () => void; + vi.mocked(b.sicherungen.schreiben).mockImplementation(() => new Promise((ok) => { fertig = ok; })); + const store = erstelleStore(); + store.setState({ backend: b }); + await store.getState().projektLaden(demoFs()); + await waitFor(() => expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(1)); + expect(store.getState().ladeStatus).toBe('bereit'); + await store.getState().alleSpeichernAbwarten(); + fertig(); +}); + +it.each([false, true])('sichert erst nach der E8-Aufbewahrung, auch bei Aufräumfehler (%s)', async (fehler) => { + const b = backend(); + vi.mocked(b.sicherungen.einstellungen).mockResolvedValue({ aktiv: true, mitVersionen: true, ordner: null }); + const fs = demoFs(); + const alt = '.versionen/manuskript/ebbe.md/2020-01-06T09-00-00.md'; + const neu = '.versionen/manuskript/ebbe.md/2020-01-07T09-00-00.md'; + await fs.schreiben(alt, 'Älterer Stand'); + await fs.schreiben(neu, 'Wochenstand'); + let freigeben!: () => void; + const warten = new Promise((ok) => { freigeben = ok; }); + const entfernen = fs.entfernen.bind(fs); + const loeschen = vi.spyOn(fs, 'entfernen').mockImplementation(async (pfad) => { + await warten; + if (fehler) throw new Error('EACCES'); + await entfernen(pfad); + }); + const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = erstelleStore(); + store.setState({ backend: b }); + try { + await store.getState().projektLaden(fs); + await waitFor(() => expect(loeschen).toHaveBeenCalledWith(alt)); + expect(store.getState().ladeStatus).toBe('bereit'); + expect(b.sicherungen.einstellungen).not.toHaveBeenCalled(); + await store.getState().alleSpeichernAbwarten(); + freigeben(); + await waitFor(() => expect(b.sicherungen.schreiben).toHaveBeenCalledTimes(1)); + const zip = await JSZip.loadAsync(vi.mocked(b.sicherungen.schreiben).mock.calls[0]![2]); + expect(zip.file(alt) !== null).toBe(fehler); + expect(await zip.file(neu)!.async('string')).toBe('Wochenstand'); + expect(store.getState().sicherungsHinweis).toBeNull(); + expect(warnung).toHaveBeenCalledTimes(fehler ? 1 : 0); + } finally { + freigeben(); + warnung.mockRestore(); + } +}); + +it.each(['de', 'en'] as const)('zeigt Sicherungsfehler mit Grund (%s) und lässt den Store schreibbereit', async (sprache) => { + festeSprache(sprache); + const b = backend(); + vi.mocked(b.sicherungen.schreiben).mockRejectedValue(new Error('EACCES')); + const store = erstelleStore(); + store.setState({ backend: b }); + await store.getState().projektLaden(demoFs()); + await waitFor(() => expect(store.getState().sicherungsHinweis).toContain('EACCES')); + expect(store.getState().sicherungsHinweis).toContain(sprache === 'de' ? 'Sicherung' : 'Backup'); + expect(store.getState().ladeStatus).toBe('bereit'); + expect(store.getState().speichern.fehler).toBeNull(); + await store.getState().speichereKapitel({ ...store.getState().daten!.kapitel[store.getState().daten!.projekt.kapitel[0]!]!, text: 'Neu' }); + expect(store.getState().speichern.fehler).toBeNull(); +}); + +it('speichert App-Einstellungen und wählt den Ort über das Backend; Demo blendet sie aus', async () => { + const b = backend(); + useStore.setState({ backend: b }); + const { unmount } = render(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('checkbox', { name: 'Automatisch täglich sichern' })); + await waitFor(() => expect(b.sicherungen.einstellen).toHaveBeenCalledWith({ aktiv: false, mitVersionen: false, ordner: null })); + await user.click(screen.getByRole('button', { name: 'Ordner wählen …' })); + expect(await screen.findByText('/Sicherungen')).toBeInTheDocument(); + unmount(); + useStore.setState({ backend: erstelleDemoBackend() }); + render(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); +}); + +it('zeigt die leere Wiederherstellungsliste und öffnet einen wiederhergestellten Stand', async () => { + const b = backend(); + useStore.setState({ backend: b, daten: null, fehler: null }); + render(); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Sicherung wiederherstellen …' })); + expect(await screen.findByText('Noch keine Sicherungen vorhanden.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'ZIP-Datei wählen …' })); + await waitFor(() => expect(useStore.getState().ladeStatus).toBe('bereit')); + expect(b.sicherungen.wiederherstellen).toHaveBeenCalledWith(null); +}); 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..029a407 --- /dev/null +++ b/src/i18n/de/sicherungen.ts @@ -0,0 +1,15 @@ +export const sicherungen = { + titel: 'Externe Sicherungen', + aktiv: 'Automatisch täglich sichern', + versionen: 'Schnappschüsse (.versionen/) mitsichern', + standard: 'Standard: App-Ordner / Sicherungen / Projekt', + ordner: 'Ordner wählen …', + zuruecksetzen: 'Standardort verwenden', + erklaerung: 'Beim ersten Öffnen pro Tag. Aufbewahrung: 7 Tagesstände und 4 ältere Wochenstände. Recherche-Bilder und .plotwerk/ sind enthalten.', + fehler: 'Sicherung fehlgeschlagen: {grund}. Schreiben und Autosave laufen weiter.', + wiederherstellen: 'Sicherung wiederherstellen …', + leer: 'Noch keine Sicherungen vorhanden.', + zipWaehlen: 'ZIP-Datei wählen …', + ziel: 'Die Sicherung wird in einen neuen, leeren Ordner entpackt und geöffnet.', + laden: 'Sicherungen werden geladen …', +}; 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..d9c268c --- /dev/null +++ b/src/i18n/en/sicherungen.ts @@ -0,0 +1,17 @@ +import type { Form } from '@core/i18n'; +import type { sicherungen as de } from '../de/sicherungen'; +export const sicherungen: Form = { + titel: 'External backups', + aktiv: 'Back up automatically every day', + versionen: 'Include snapshots (.versionen/)', + standard: 'Default: app folder / Sicherungen / project', + ordner: 'Choose Folder…', + zuruecksetzen: 'Use Default Location', + erklaerung: 'On the first opening each day. Retention: 7 daily and 4 older weekly copies. Research images and .plotwerk/ are included.', + fehler: 'Backup failed: {grund}. Writing and autosave continue.', + wiederherstellen: 'Restore Backup…', + leer: 'No backups yet.', + zipWaehlen: 'Choose ZIP File…', + ziel: 'The backup will be extracted into a new, empty folder and opened.', + laden: 'Loading backups…', +}; diff --git a/src/state/store.ts b/src/state/store.ts index bd95781..b2f475e 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -7,6 +7,7 @@ * (300 ms entprellt). Fehler beim Schreiben werden geworfen und in `speichern.fehler` vermerkt. */ import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand'; +import { sichereProjekt } from '../features/sicherungen/ablauf'; import { figurenZusammenfuehren as zusammenfuehren } from '@core/figuren/zusammenfuehren'; import { berechneIndex, LEERER_INDEX, type Index } from '@core/index/index'; import type { @@ -146,6 +147,7 @@ export interface SpeicherStatus { export interface Basis { backend: Backend | null; + sicherungsHinweis: string | null; fs: ProjektDateisystem | null; daten: ProjektDaten | null; index: Index; @@ -501,6 +503,7 @@ const createBasis: StateCreator = (set, get) => { return { backend: null, + sicherungsHinweis: null, fs: null, daten: null, index: LEERER_INDEX, @@ -574,11 +577,21 @@ const createBasis: StateCreator = (set, get) => { auswahl: { kapitel: d.projekt.kapitel[0] }, markierung: null, externeAenderung: null, + sicherungsHinweis: null, }); indexBerechnen(); - // Aufbewahrung der Schnappschüsse (E8) im Hintergrund + const backend = get().backend; + // E8 zuerst abschließen, damit die ZIP keine gerade gelöschten Schnappschüsse liest. + // Beide Läufe bleiben im Hintergrund und außerhalb der Speicher-/Beenden-Warteschlange. 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)); + void aufbewahrungAnwenden(fs, new Date(), geschuetzt) + .catch((e: unknown) => console.warn('Alte Schnappschüsse nicht aufgeräumt:', e)) + .then(async () => { + if (backend) await sichereProjekt(backend, fs, d.projekt.titel, false); + }) + .catch((e: unknown) => { + if (get().fs === fs) set({ sicherungsHinweis: f(texte().sicherungen.fehler, { grund: fehlertext(e) }) }); + }); } catch (e) { set({ ladeStatus: 'fehler', fehler: fehlertext(e) }); } From deb6d9c26905f4e4add2c89144441f2c42c3d158 Mon Sep 17 00:00:00 2001 From: leonatwork Date: Tue, 20 Oct 2026 15:13:16 +0000 Subject: [PATCH 2/3] =?UTF-8?q?agent:=20Nacharbeit=20zu=20Issue=20#12=20au?= =?UTF-8?q?f=20dem=20bestehenden=20Branch.=20Der=20erste=20Durchlauf=20ist?= =?UTF-8?q?=20mit=20offenen=20Review-Befunden=20zu=20Ende=20gegangen.=20Be?= =?UTF-8?q?hebe=20ausschlie=C3=9Flich=20diese=20offenen=20Befunde=20vollst?= =?UTF-8?q?=C3=A4ndig=20(pr=C3=BCfe=20zuerst=20am=20Code,=20ob=20jeder=20z?= =?UTF-8?q?utrifft),=20halte=20dich=20an=20CLAUDE.md/AGENTS.md=20und=20f?= =?UTF-8?q?=C3=BChre=20die=20passenden=20Tests=20aus.=20Lies=20den=20Issue?= =?UTF-8?q?-Thread=20nur,=20wenn=20ein=20Befund=20ohne=20ihn=20nicht=20kla?= =?UTF-8?q?r=20ist.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offene Befunde: - `src-tauri/src/sicherungen.rs:80-83`: rustfmt ist noch nicht vollständig nachgezogen. Die zwei Zeilen aus der vorigen Runde sind behoben (`:467-470` und `:488-490` sind jetzt korrekt im Binärausdruck-Stil umbrochen). Das damals genannte Beispiel `fehler(crate::sprache::t("Ungültiger …` ist aber weiter auf vier Zeilen verteilt. Einzeilig hätte ` return Err(fehler(crate::sprache::t("Ungültiger ZIP-Name", "Invalid ZIP name")));` nur 89 Zeichen. `Err` und `fehler` haben je nur ein Argument, und die Argumente von `t` sind 41 Zeichen breit, also unter `fn_call_width` 60. Deshalb würde rustfmt die Ze - `src-tauri/src/sicherungen.rs:220-223`: Gleiches Problem in der Kette von `sicherung_ordner_waehlen`. ` .set_title(crate::sprache::t("Sicherungsordner wählen", "Choose Backup Folder"))` hätte nur 88 Zeichen, mit einem Argument für `set_title` und 49 Zeichen Argumentbreite für `t`. rustfmt würde das auf einer Zeile lassen, die aktuelle Aufteilung auf vier Zeilen fällt bei `cargo fmt --check` durch. Fix: auf eine Zeile zusammenziehen bzw. `cargo fmt` übernehmen. Reviewer verdict: APPROVE after 1 round(s). Generated by dev-loop (not pushed). Signed-off-by: leonatwork --- src-tauri/src/sicherungen.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/sicherungen.rs b/src-tauri/src/sicherungen.rs index 373fd37..7b843e6 100644 --- a/src-tauri/src/sicherungen.rs +++ b/src-tauri/src/sicherungen.rs @@ -77,10 +77,7 @@ fn name_gueltig(name: &str) -> bool { } fn archiv_pfad(ordner: &Path, name: &str) -> Result { if !name_gueltig(name) { - return Err(fehler(crate::sprache::t( - "Ungültiger ZIP-Name", - "Invalid ZIP name", - ))); + return Err(fehler(crate::sprache::t("Ungültiger ZIP-Name", "Invalid ZIP name"))); } let root = ordner.canonicalize().map_err(fehler)?; let ziel = root.join(name); @@ -217,10 +214,7 @@ pub async fn sicherung_ordner_waehlen( .dialog() .file() .set_parent(&fenster) - .set_title(crate::sprache::t( - "Sicherungsordner wählen", - "Choose Backup Folder", - )) + .set_title(crate::sprache::t("Sicherungsordner wählen", "Choose Backup Folder")) .blocking_pick_folder() else { return Ok(None); From 32acf8f128a4a84e6238476e065c387738eacd09 Mon Sep 17 00:00:00 2001 From: leonatwork Date: Fri, 23 Oct 2026 17:10:00 +0000 Subject: [PATCH 3/3] style: cargo fmt Signed-off-by: leonatwork --- src-tauri/src/menue.rs | 5 ++++- src-tauri/src/sicherungen.rs | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/menue.rs b/src-tauri/src/menue.rs index e300d56..cbe0e53 100644 --- a/src-tauri/src/menue.rs +++ b/src-tauri/src/menue.rs @@ -95,7 +95,10 @@ pub fn erstellen(app: &AppHandle) -> tauri::Result> { .item(&befehl( app, "projekt-archiv", - t("Projekt als Archiv sichern …", "Back Up Project as Archive…"), + t( + "Projekt als Archiv sichern …", + "Back Up Project as Archive…", + ), None, )?) .item(&befehl( diff --git a/src-tauri/src/sicherungen.rs b/src-tauri/src/sicherungen.rs index 7b843e6..373fd37 100644 --- a/src-tauri/src/sicherungen.rs +++ b/src-tauri/src/sicherungen.rs @@ -77,7 +77,10 @@ fn name_gueltig(name: &str) -> bool { } fn archiv_pfad(ordner: &Path, name: &str) -> Result { if !name_gueltig(name) { - return Err(fehler(crate::sprache::t("Ungültiger ZIP-Name", "Invalid ZIP name"))); + return Err(fehler(crate::sprache::t( + "Ungültiger ZIP-Name", + "Invalid ZIP name", + ))); } let root = ordner.canonicalize().map_err(fehler)?; let ziel = root.join(name); @@ -214,7 +217,10 @@ pub async fn sicherung_ordner_waehlen( .dialog() .file() .set_parent(&fenster) - .set_title(crate::sprache::t("Sicherungsordner wählen", "Choose Backup Folder")) + .set_title(crate::sprache::t( + "Sicherungsordner wählen", + "Choose Backup Folder", + )) .blocking_pick_folder() else { return Ok(None);