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 47103e4..dfffbcb 100644 --- a/src-tauri/src/menue.rs +++ b/src-tauri/src/menue.rs @@ -92,6 +92,15 @@ 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 40fed0b..f3ca065 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'; @@ -26,6 +28,7 @@ export type Befehl = | 'pruefen' | 'buch-pruefen' | 'projekt-oeffnen' + | 'projekt-archiv' | 'vorschau' // Werkzeuge G1–G8 | 'zeit-vorschlaege' @@ -59,6 +62,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 'suchen': case 'ersetzen': if (s.daten && s.bereich === 'schreiben') s.volltext.oeffneKapitel(true); 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 ca40dea..b3ea293 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'; @@ -308,6 +309,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 1d4915e..299cd8a 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'; import { suche } from './suche'; export const de = { @@ -53,4 +54,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 d98e743..8c7be20 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'; import { suche } from './suche'; export const en: Woerterbuch = { @@ -54,4 +55,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 4546eac..856f45e 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 { @@ -148,6 +149,7 @@ export interface SpeicherStatus { export interface Basis { backend: Backend | null; + sicherungsHinweis: string | null; fs: ProjektDateisystem | null; daten: ProjektDaten | null; index: Index; @@ -547,6 +549,7 @@ const createBasis: StateCreator = (set, get) => { return { backend: null, + sicherungsHinweis: null, fs: null, daten: null, index: LEERER_INDEX, @@ -620,11 +623,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) }); }