Skip to content
Draft
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
54 changes: 54 additions & 0 deletions core/sicherungen/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
84 changes: 84 additions & 0 deletions core/sicherungen/index.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array | null>;
}
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<string>();
const wochen = new Set<string>();
const behalten = new Set<string>();
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<Uint8Array> {
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<ArchivDatei[]> {
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'));
}
102 changes: 102 additions & 0 deletions docs/issue-12-handover.md
Original file line number Diff line number Diff line change
@@ -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 `-<Zahl>`) 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/<Projektordnername>/`.
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
`<Projekt>-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.
11 changes: 11 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 Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/menue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ pub fn erstellen<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
.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",
Expand Down
Loading
Loading