Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions core/sicherungen/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
101 changes: 101 additions & 0 deletions core/sicherungen/index.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array | null>;
}

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<Uint8Array> {
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<string>();
const wochen = new Set<string>();
const behalten = new Set<string>();
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<ArchivDatei[]> {
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;
}
63 changes: 63 additions & 0 deletions docs/issue-12-handover.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod pfad;
mod projekt;
mod recherche;
mod schluessel;
mod sicherungen;
mod sprache;
mod ueberwachung;
mod vorlesen;
Expand All @@ -28,6 +29,8 @@ pub struct Zustand {
einstellungen: Mutex<()>,
extern_lesen: Mutex<HashSet<PathBuf>>,
extern_schreiben: Mutex<HashSet<PathBuf>>,
archiv_lesen: Mutex<HashSet<PathBuf>>,
archiv_schreiben: Mutex<()>,
/// Vom Öffnen-/Neu-Dialog gelieferte Projektordner
erlaubt: Mutex<HashSet<PathBuf>>,
ki: OnceLock<plotwerk_ki::KiKlient>,
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/menue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ pub fn erstellen<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
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,
Expand Down
Loading
Loading