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

Plotwerk

{t.app.start.text}

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

{t.sicherungen.erklaerung}

+ {wert && <> +

+

+

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

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

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

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

{t.sicherungen.ziel}

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

{t.sicherungen.leer}

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

)} + + {fehler &&

{fehler}

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