diff --git a/Cargo.lock b/Cargo.lock index 91214ab..d0dda21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,6 +682,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1044,6 +1054,7 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "fs2", "glam", "minisign-verify", "octotablet", diff --git a/Cargo.toml b/Cargo.toml index 2ffe049..1a5d359 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", url = "2.5" octotablet = "0.1.0" minisign-verify = "0.3.0" +fs2 = "0.4" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 2082f15..310698f 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -1,6 +1,6 @@ # Dateiformat & Speicherstruktur von ink-notes -Dieses Dokument beschreibt die Speicherstruktur und die JSON-Dateiformate, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt. +Dieses Dokument beschreibt die Speicherstruktur, die JSON-Dateiformate und die Versionshistorie, mit denen `ink-notes` Dokumente auf dem Dateisystem ablegt. --- @@ -13,7 +13,9 @@ Notizbücher werden standardmäßig unter dem XDG-Datenpfad gespeichert: └── notebooks/ ├── / │ ├── manifest.json + │ ├── manifest.json.v0.bak (optional, Migrations-Backup) │ ├── page_1.json + │ ├── page_1.json.corrupt-1775460000 (optional, Quarantäne bei Parsefehlern) │ ├── page_2.json │ └── ... └── / @@ -29,19 +31,20 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge ```json { + "format_version": 2, "id": "notebook-a1b2c3d4", "title": "Mathematik Vorlesung", - "version": 1, "pages": [ { "id": 1, - "file_name": "page_1.json" + "background": "Grid5mm" }, { "id": 2, - "file_name": "page_2.json" + "background": "DotGrid" } ], + "page_ids": [1, 2], "current_page_index": 0, "next_page_id": 3, "next_stroke_id": 42, @@ -51,10 +54,11 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge ``` ### Felder: +- `format_version`: Format-Versionsnummer (aktuell `2`, bei unversionierten Legacy-Dateien implizit `0`). - `id`: Eindeutige ID des Notizbuchs (z. B. UUID oder `"default"`). - `title`: Vom Nutzer vergebener Anzeigename des Notizbuchs. -- `version`: Format-Versionsnummer (aktuell `1`). -- `pages`: Geordnete Liste von Seitenreferenzen. +- `pages`: Geordnete Liste von Seitenmetadaten (`id`, optional `background`). +- `page_ids`: Abwärtskompatible Liste der Seiten-IDs. - `current_page_index`: Index der zuletzt aktiven Seite für Session-Restore. - `next_page_id`: Inkrementierender ID-Zähler für neue Seiten. - `next_stroke_id`: Globaler ID-Zähler für neue Striche. @@ -65,58 +69,21 @@ Das Manifest enthält Metadaten über das gesamte Dokument sowie die Reihenfolge ## 3. Seitendatei (`page_.json`) Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelles inkrementelles Speichern zu ermöglichen. +Die Speicherung erfolgt ab Version 2 als kompaktes JSON (`serde_json::to_vec`), wodurch gegenüber Pretty-Printed JSON über 50 % Speicherplatz eingespart wird. ```json -{ - "id": 1, - "background": "Grid5mm", - "strokes": [ - { - "id": 1, - "color": [0.12, 0.12, 0.14, 1.0], - "width_mm": 0.5, - "samples": [ - { - "x": 350.2, - "y": 420.5, - "pressure": 0.65, - "timestamp": 12.345 - }, - { - "x": 352.1, - "y": 425.8, - "pressure": 0.70, - "timestamp": 12.355 - } - ], - "recognized_shape": null - }, - { - "id": 2, - "color": [0.15, 0.35, 0.85, 1.0], - "width_mm": 0.8, - "samples": [], - "recognized_shape": { - "Rectangle": { - "top_left": { "x": 500.0, "y": 600.0 }, - "width": 300.0, - "height": 200.0 - } - } - } - ] -} +{"format_version":2,"id":1,"background":"Grid5mm","strokes":[{"id":1,"color":[0.12,0.12,0.14,1.0],"samples":[{"x":350.2,"y":420.5,"p":0.65,"t":12.345},{"x":352.1,"y":425.8,"p":0.70,"t":12.355}],"shape":null,"tool":"pen","width":5.0,"finalized":true},{"id":2,"color":[0.15,0.35,0.85,1.0],"samples":[],"shape":{"Rectangle":{"top_left":{"x":500.0,"y":600.0},"width":300.0,"height":200.0}},"tool":"shape","width":20.0,"finalized":true}]} ``` ### Felder: +- `format_version`: Format-Versionsnummer (`2`). - `id`: Eindeutige ID der Seite. - `background`: Papiermuster (`Blank`, `Grid5mm`, `Lined`, `DotGrid`). - `strokes`: Liste der auf der Seite gezeichneten Striche und Vektorformen. - `id`: Eindeutige ID des Strichs. - `color`: RGBA-Farbwerte im Bereich `[0.0, 1.0]`. - - `width_mm`: Basis-Strichbreite in Millimetern. - - `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `pressure` in `[0.0, 1.0]`, `timestamp` in Sekunden). - - `recognized_shape`: Optionales Enum für geometrische Vektorformen: + - `samples`: Rohabtastpunkte entlang des Pfads (`x`, `y` in DIN-A4-Einheiten, `p` Druckwert in `[0.0, 1.0]`, `t` Zeitstempel in Sekunden). + - `shape`: Optionales Enum für geometrische Vektorformen: - `Line { start, end }` - `Rectangle { top_left, width, height }` - `Circle { center, radius }` @@ -124,11 +91,47 @@ Jede Seite wird in einer eigenen Datei gespeichert, um Lazy-Loading und schnelle - `Triangle { p1, p2, p3 }` - `Rhombus { center, width, height }` - `Octahedron { center, radius }` + - `tool`: Zeichenwerkzeug (`"pen"`, `"highlighter"`, `"shape"`). + - `width`: Strichbreite in internen DIN-A4-Einheiten (10 Einheiten = 1 mm; z. B. `5.0` für 0.5 mm Stift, `60.0` für 6.0 mm Marker). + - `finalized`: Boolesches Flag für Striche, die das Zwei-Stufen-Inking (Eckenerkennung, Glättung, runde Endkappen) durchlaufen haben. --- -## 4. Atomares Schreibverfahren +## 4. Datensicherheit & Migration -Um Datenverlust bei Stromausfall oder plötzlichem Prozessabbruch zu verhindern: -1. Daten werden in `.tmp` serialisiert und per `fsync` auf das Speichermedium geschrieben. +### Atomares Schreiben & Verzeichnis-Sync +1. Daten werden in `.tmp..` serialisiert und per `fsync` auf das Speichermedium geschrieben (Dateirechte `0600` auf Unix). 2. Mittels `std::fs::rename(".tmp", "")` wird die Zieldatei auf POSIX-Systemen atomar ersetzt. +3. Nach dem Rename wird das übergeordnete Verzeichnis synchronisiert (`sync_all()`), um Persistenz bei Stromausfall zu garantieren. Verzeichnisse erhalten Berechtigung `0700`. + +### Quarantäne bei Parsefehlern +Wird eine Seitendatei beim Laden als fehlerhaft erkannt (z. B. unvollständiges oder korruptes JSON), wird sie nicht stillschweigend gelöscht oder überschrieben, sondern nach `page_.json.corrupt-` verschoben. Die Ersatzseite wird als schreibgeschützt markiert. + +### Migrations-Backups & Rolling Backups +Beim ersten Speichern einer Datei im Legacy-Format (Version 0) wird vor dem Überschreiben ein Backup `*.v0.bak` angelegt (z. B. `manifest.json.v0.bak`, `page_1.json.v0.bak`). +Zusätzlich existiert ein Rolling-Backup `manifest.json.bak`, auf das bei beschädigtem Manifest automatisch zurückgegriffen wird. + +--- + +## 5. Versionshistorie + +| Version | Status | Beschreibung | +|---|---|---| +| **0** | Legacy | Unversioniertes Format. `format_version` fehlt in JSON. Implizit als Version 0 interpretiert. | +| **1** | Überholt | Einführung von `format_version: u32` in `manifest.json` und `page_.json`. Seiten-Hintergrund im Manifest gecached (`pages: Vec`). Strenges Versionsmatching (neuere Versionen werden abgelehnt). Automatische Migration mit `*.v0.bak`. | +| **2** | **Aktuell** | Stroke-Treue: Ergänzung von `tool`, `width` und `finalized` in `StrokeData` für exakte Rekonstruktion aller Vertizes und Splines. Umstellung auf kompaktes JSON (`serde_json::to_vec`, ca. 54% Platzerparnis). | + +--- + +## 6. Analyse & Bewertung: Binärformat (z. B. `postcard`) + +Eine Umstellung auf ein Binärformat wie `postcard` wurde evaluiert: +- **Vorteile**: + - Weitere 20–30 % Reduktion der Dateigröße gegenüber kompaktem JSON. + - Minimal schnellere Serialisierung auf leistungsschwachen Systemen. +- **Nachteile**: + - Verlust der Text-Transparenz (JSON lässt sich direkt inspizieren, mit `git`/Tools diffen und bei Korruption manuell reparieren). + - Snapshotting und JSON-Verarbeitung liegen im Benchmark bereits bei **0,02 ms (1k Samples)** bis **0,70 ms (50k Samples)** – also weit unter dem Zielwert von 1 ms. + - Kompaktes JSON spart bereits über 53 % Dateigröße ein. +- **Entscheidung**: + Kompaktes JSON bietet aktuell das optimale Verhältnis aus Ausfallsicherheit, Benutzerfreundlichkeit, Performance und Portabilität. Ein Binärformat wie `postcard` bleibt als Option für spätere Riesen-Notizbücher (> 500 Seiten) denkbar, wird aktuell jedoch nicht eingeführt. diff --git a/scripts/crash-test.sh b/scripts/crash-test.sh new file mode 100755 index 0000000..2f19fef --- /dev/null +++ b/scripts/crash-test.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# ============================================================================== +# scripts/crash-test.sh +# Stresstest für ink-notes Speicher- und Autosave-Robustheit. +# Simuliert harte Programmabstürze (SIGKILL / kill -9) während Schreibvorgängen +# und verifiziert die Integrität der Notizbücher und Seiten. +# ============================================================================== + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +ITERATIONS="${1:-200}" +TEST_DIR="${REPO_ROOT}/target/crash_test_workspace" +CRASH_SIM_BIN="${REPO_ROOT}/target/release/crash_sim" + +echo "==================================================================" +echo " ink-notes Crash- & Datenverlust-Stresstest" +echo " Iterationen: ${ITERATIONS}" +echo " Testverzeichnis: ${TEST_DIR}" +echo "==================================================================" + +# 1. Sicherstellen, dass das Test-Binary gebaut ist +if [ ! -f "${CRASH_SIM_BIN}" ]; then + echo "Baue crash_sim Release-Binary..." + cargo build --release --bin crash_sim --manifest-path "${REPO_ROOT}/Cargo.toml" +fi + +# 2. Sauberes Testverzeichnis anlegen und Notizbuch initialisieren +rm -rf "${TEST_DIR}" +mkdir -p "${TEST_DIR}" +"${CRASH_SIM_BIN}" init "${TEST_DIR}" + +SUCCESS_COUNT=0 +INTERRUPTED_WRITES=0 + +trap 'rm -rf "${TEST_DIR}"' EXIT + +echo "Starte Testlauf mit ${ITERATIONS} SIGKILL-Zyklen..." + +for i in $(seq 1 "${ITERATIONS}"); do + # Worker im Hintergrund starten + "${CRASH_SIM_BIN}" worker "${TEST_DIR}" > /dev/null 2>&1 & + WORKER_PID=$! + + # Zufällige Schlafdauer zwischen 2ms und 35ms, um Schreiboperationen mitten im Flug zu treffen + # RANDOM % 33 + 2 ergibt 2..34 Millisekunden + MS_DELAY=$(( (RANDOM % 33) + 2 )) + DELAY="0.$(printf "%03d" "$MS_DELAY")" + sleep "${DELAY}" + + # Harter Prozessabbruch ohne Clean-up (SIGKILL) + if kill -9 "${WORKER_PID}" 2>/dev/null; then + INTERRUPTED_WRITES=$((INTERRUPTED_WRITES + 1)) + fi + wait "${WORKER_PID}" 2>/dev/null || true + + # Integritäts- und Konsistenzprüfung + if ! "${CRASH_SIM_BIN}" verify "${TEST_DIR}" > /dev/null 2>&1; then + echo "" + echo "❌ FEHLER in Iteration ${i}!" + echo "Notizbuch im Zustand nach Absturz nicht konsistent ladbar!" + "${CRASH_SIM_BIN}" verify "${TEST_DIR}" || true + trap - EXIT + exit 1 + fi + + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + if [ $((i % 25)) -eq 0 ] || [ "${i}" -eq "${ITERATIONS}" ]; then + echo " [$(date +%T)] Iteration ${i}/${ITERATIONS} bestanden (erfolgreich unterbrochene Schreibvorgänge: ${INTERRUPTED_WRITES})." + fi +done + +echo "" +echo "==================================================================" +echo "✅ ALLE ${ITERATIONS} CRASH-TESTS ERFOLGREICH BESTANDEN!" +echo " - Überstandene SIGKILLs: ${INTERRUPTED_WRITES}" +echo " - Konsistente Ladevorgänge: ${SUCCESS_COUNT}/${ITERATIONS}" +echo " - Keine korrupten JSON-Dateien ohne Quarantäne" +echo " - Keine unvollständigen Atom-Schreibungen" +echo "==================================================================" diff --git a/src/autosave.rs b/src/autosave.rs index 6f7657a..69d45bc 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -1,18 +1,84 @@ -//! Asynchroner Autosave-Mechanismus mit Debounce-Logik und Hintergrund-Worker-Thread. +//! Asynchroner Autosave-Mechanismus mit Debounce- und Max-Wait-Logik, +//! austauschbarem PageStore-Backend und resilientem Hintergrund-Worker. //! //! Stellt sicher, dass Datei-I/O niemals den 120/240 Hz Render-Loop blockiert. //! Schnelle aufeinanderfolgende Änderungen werden gebündelt (debounced). -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Arc; use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use crate::document::{Notebook, Page}; -use crate::storage::{self, NotebookManifest, PageData}; +use crate::document::{Notebook, Page, PersistState}; +use crate::storage::{self, NotebookManifest, PageData, StorageError}; + +/// Abstraktion für die Zeitmessung (für deterministische Tests ohne Thread-Sleep). +pub trait Clock: Send + Sync + 'static { + fn now(&self) -> Instant; +} + +/// Standard-Uhr basierend auf der Systemzeit. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } +} + +/// Austauschbares Speicher-Backend für Unit-Tests und alternative Speicherstrategien. +pub trait PageStore: Send + Sync + 'static { + fn save_page(&self, notebook_dir: &Path, page: &PageData) -> Result<(), StorageError>; + fn save_manifest( + &self, + notebook_dir: &Path, + manifest: &NotebookManifest, + ) -> Result<(), StorageError>; +} + +/// Standard-Implementierung über das lokale Dateisystem (`src/storage.rs`). +#[derive(Debug, Clone, Copy, Default)] +pub struct DiskPageStore; + +impl PageStore for DiskPageStore { + fn save_page(&self, notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> { + storage::save_page(notebook_dir, page) + } + + fn save_manifest( + &self, + notebook_dir: &Path, + manifest: &NotebookManifest, + ) -> Result<(), StorageError> { + storage::save_manifest(notebook_dir, manifest) + } +} + +/// Fehler beim Autosave oder Flush. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutosaveError { + pub failed_page_ids: Vec, + pub messages: Vec, +} + +impl fmt::Display for AutosaveError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Autosave fehlgeschlagen für Seiten {:?}: {}", + self.failed_page_ids, + self.messages.join("; ") + ) + } +} + +impl std::error::Error for AutosaveError {} /// Aktueller Status des Autosave-Controllers für OSD und UI. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -56,139 +122,278 @@ pub enum AutosaveCommand { pub enum AutosaveResponse { PageSaved { page_id: u64, - result: Result<(), String>, + result: Result<(), (String, Box)>, }, ManifestSaved { - result: Result<(), String>, + result: Result<(), (String, Box)>, }, } +/// Startet einen isolierten Hintergrund-Worker-Thread. +fn spawn_worker( + cmd_rx: Receiver, + resp_tx: Sender, + store: Arc, + worker_writes_counter: Arc, +) -> JoinHandle<()> { + thread::spawn(move || { + while let Ok(cmd) = cmd_rx.recv() { + match cmd { + AutosaveCommand::SavePage { + notebook_dir, + page_data, + } => { + let page_id = page_data.id; + let store_clone = Arc::clone(&store); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + store_clone.save_page(¬ebook_dir, &page_data) + })); + match res { + Ok(Ok(())) => { + worker_writes_counter.fetch_add(1, Ordering::SeqCst); + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Ok(()), + }); + } + Ok(Err(e)) => { + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Err((e.to_string(), Box::new(page_data))), + }); + } + Err(_panic) => { + let _ = resp_tx.send(AutosaveResponse::PageSaved { + page_id, + result: Err(( + "Worker panic beim Speichern der Seite".to_string(), + Box::new(page_data), + )), + }); + } + } + } + AutosaveCommand::SaveManifest { + notebook_dir, + manifest, + } => { + let store_clone = Arc::clone(&store); + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + store_clone.save_manifest(¬ebook_dir, &manifest) + })); + match res { + Ok(Ok(())) => { + let _ = + resp_tx.send(AutosaveResponse::ManifestSaved { result: Ok(()) }); + } + Ok(Err(e)) => { + let _ = resp_tx.send(AutosaveResponse::ManifestSaved { + result: Err((e.to_string(), Box::new(manifest))), + }); + } + Err(_panic) => { + let _ = resp_tx.send(AutosaveResponse::ManifestSaved { + result: Err(( + "Worker panic beim Speichern des Manifests".to_string(), + Box::new(manifest), + )), + }); + } + } + } + AutosaveCommand::Flush(ack_tx) => { + let _ = ack_tx.send(()); + } + AutosaveCommand::Shutdown => { + break; + } + } + } + }) +} + +#[derive(Debug, Clone)] +struct PageRetry { + next_retry: Instant, + backoff: Duration, +} + /// Controller zur Verwaltung des asynchronen Autosave-Ablaufs. pub struct AutosaveController { notebook_dir: PathBuf, debounce_duration: Duration, + max_wait_duration: Duration, + first_change_time: Option, last_change_time: Option, pending_pages: HashMap, pending_manifest: Option, + retries: HashMap, + last_errors: Vec, status: AutosaveStatus, in_flight_count: usize, total_page_writes: Arc, + store: Arc, + clock: Arc, cmd_tx: Sender, resp_rx: Receiver, + read_only: bool, worker_handle: Option>, + worker_restart_count: usize, } impl AutosaveController { - /// Erzeugt einen neuen Controller und startet den Hintergrund-Worker-Thread. + /// Erzeugt einen neuen Controller mit Standardeinstellungen (1.5s Debounce, 10s MaxWait, DiskStore). pub fn new(notebook_dir: PathBuf, debounce_duration: Duration) -> Self { + Self::with_options( + notebook_dir, + debounce_duration, + Duration::from_secs(10), + Arc::new(DiskPageStore), + Arc::new(SystemClock), + ) + } + + /// Voll konfigurierbarer Konstruktor für Tests mit MockClock und FakeStore. + pub fn with_options( + notebook_dir: PathBuf, + debounce_duration: Duration, + max_wait_duration: Duration, + store: Arc, + clock: Arc, + ) -> Self { let (cmd_tx, cmd_rx) = mpsc::channel::(); let (resp_tx, resp_rx) = mpsc::channel::(); let total_page_writes = Arc::new(AtomicUsize::new(0)); - let worker_writes_counter = Arc::clone(&total_page_writes); - let worker_handle = thread::spawn(move || { - while let Ok(cmd) = cmd_rx.recv() { - match cmd { - AutosaveCommand::SavePage { - notebook_dir, - page_data, - } => { - let page_id = page_data.id; - let res = storage::save_page(¬ebook_dir, &page_data); - if res.is_ok() { - worker_writes_counter.fetch_add(1, Ordering::SeqCst); - } - let _ = resp_tx.send(AutosaveResponse::PageSaved { - page_id, - result: res.map_err(|e| e.to_string()), - }); - } - AutosaveCommand::SaveManifest { - notebook_dir, - manifest, - } => { - let res = storage::save_manifest(¬ebook_dir, &manifest); - let _ = resp_tx.send(AutosaveResponse::ManifestSaved { - result: res.map_err(|e| e.to_string()), - }); - } - AutosaveCommand::Flush(ack_tx) => { - let _ = ack_tx.send(()); - } - AutosaveCommand::Shutdown => { - break; - } - } - } - }); + let worker_handle = spawn_worker( + cmd_rx, + resp_tx, + Arc::clone(&store), + Arc::clone(&total_page_writes), + ); Self { notebook_dir, debounce_duration, + max_wait_duration, + first_change_time: None, last_change_time: None, pending_pages: HashMap::new(), pending_manifest: None, + retries: HashMap::new(), + last_errors: Vec::new(), status: AutosaveStatus::Saved, in_flight_count: 0, total_page_writes, + store, + clock, cmd_tx, resp_rx, + read_only: false, worker_handle: Some(worker_handle), + worker_restart_count: 0, } } + /// Aktiviert oder deaktiviert den Nur-Lese-Modus (z. B. wenn das Notizbuch von einer anderen Instanz gelockt ist). + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + if read_only { + self.pending_pages.clear(); + self.pending_manifest = None; + self.status = AutosaveStatus::Saved; + } + } + + /// Prüft, ob der Nur-Lese-Modus aktiv ist. + pub fn is_read_only(&self) -> bool { + self.read_only + } + /// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster. - /// - /// Nicht geladene oder fehlgeschlagene Ersatzseiten werden nie gespeichert, damit ihre - /// Originaldatei auf der Festplatte nicht überschrieben wird. pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) { - if page.is_loaded && !page.load_failed { - self.pending_pages.insert(page.id, PageData::from(page)); + if self.read_only || page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { + return; + } + let page_data = PageData::from(page); + self.pending_pages.insert(page.id, page_data); + self.pending_manifest = Some(manifest.clone()); + + let now = self.clock.now(); + if self.first_change_time.is_none() { + self.first_change_time = Some(now); + } + self.last_change_time = Some(now); + + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Pending; + } + } + + /// Gleicht das Manifest (Seitenliste, aktuelle Seite, ID-Zähler) mit dem Notizbuch ab + /// und markiert die aktuelle Seite als geändert. So landen auch neu angelegte Seiten + /// im gespeicherten Manifest. + pub fn mark_dirty(&mut self, notebook: &Notebook, manifest: &mut NotebookManifest) { + manifest.sync_from_notebook(notebook); + self.mark_page_dirty(notebook.current_page(), manifest); + } + + /// Markiert das Manifest als geändert (z. B. bei Seitenwechsel oder Umbenennen). + pub fn mark_manifest_dirty(&mut self, manifest: &NotebookManifest) { + if self.read_only { + return; } self.pending_manifest = Some(manifest.clone()); - self.last_change_time = Some(Instant::now()); - self.status = AutosaveStatus::Pending; - } - /// Markiert die aktuelle Seite als geändert und gleicht das Manifest (Seitenliste, - /// aktuelle Seite, ID-Zähler) mit dem Notizbuch ab. - pub fn mark_dirty(&mut self, notebook: &Notebook, manifest: &NotebookManifest) { - let mut synced = manifest.clone(); - synced.sync_from_notebook(notebook); - self.mark_page_dirty(notebook.current_page(), &synced); - } - - /// Sichert sofort alle geladenen Seiten und das Manifest (Strg+S) und meldet, - /// ob wirklich alles geschrieben wurde. - pub fn save_now( - &mut self, - notebook: &Notebook, - manifest: &NotebookManifest, - ) -> Result<(), String> { - for page in ¬ebook.pages { - if page.is_loaded && !page.load_failed { - self.pending_pages.insert(page.id, PageData::from(page)); - } + let now = self.clock.now(); + if self.first_change_time.is_none() { + self.first_change_time = Some(now); } - let mut synced = manifest.clone(); - synced.sync_from_notebook(notebook); - self.pending_manifest = Some(synced); + self.last_change_time = Some(now); - let errors = self.flush_collect_errors(); - if errors.is_empty() { - // Der komplette Zustand liegt jetzt auf der Festplatte – frühere Fehler sind überholt - self.status = AutosaveStatus::Saved; - Ok(()) - } else { - self.status = AutosaveStatus::Error; - Err(errors.join("; ")) + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Pending; } } - /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf des Debounce-Timers an. - /// - /// Sollte regelmäßig im Event-Loop aufgerufen werden. + /// Startet den Worker-Thread neu, falls er unerwartet beendet wurde. + fn ensure_worker_alive(&mut self) -> bool { + // Prüfen, ob Worker noch reagiert + let is_alive = self + .worker_handle + .as_ref() + .map(|h| !h.is_finished()) + .unwrap_or(false); + + if !is_alive && self.worker_restart_count < 3 { + self.worker_restart_count += 1; + eprintln!( + "[Autosave] Worker beendet! Starte Worker neu (Versuch {}/3)...", + self.worker_restart_count + ); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (resp_tx, resp_rx) = mpsc::channel(); + let handle = spawn_worker( + cmd_rx, + resp_tx, + Arc::clone(&self.store), + Arc::clone(&self.total_page_writes), + ); + self.cmd_tx = cmd_tx; + self.resp_rx = resp_rx; + self.worker_handle = Some(handle); + return true; + } + is_alive + } + + /// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf von Debounce oder MaxWait an. pub fn update(&mut self) -> AutosaveStatus { + if self.read_only { + return AutosaveStatus::Saved; + } + let now = self.clock.now(); + // 1. Antworten des Hintergrund-Workers verarbeiten while let Ok(response) = self.resp_rx.try_recv() { if self.in_flight_count > 0 { @@ -196,139 +401,273 @@ impl AutosaveController { } match response { - AutosaveResponse::PageSaved { page_id: _, result } => { - if let Err(e) = result { - eprintln!("[Autosave-Fehler] Seite konnte nicht gespeichert werden: {e}"); - self.status = AutosaveStatus::Error; + AutosaveResponse::PageSaved { page_id, result } => match result { + Ok(()) => { + self.retries.remove(&page_id); } - } - AutosaveResponse::ManifestSaved { result } => { - if let Err(e) = result { - eprintln!( - "[Autosave-Fehler] Manifest konnte nicht gespeichert werden: {e}" - ); + Err((err_msg, failed_page)) => { + eprintln!("[Autosave-Fehler] Seite {page_id} fehlgeschlagen: {err_msg}"); self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Seite {page_id}: {err_msg}")); + + // Wieder einreihen, wenn nicht bereits eine neuere Version vorliegt + self.pending_pages.entry(page_id).or_insert(*failed_page); + + // Exponentieller Backoff + let retry = self.retries.entry(page_id).or_insert(PageRetry { + next_retry: now, + backoff: Duration::from_secs(1), + }); + retry.next_retry = now + retry.backoff; + retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30)); + } + }, + AutosaveResponse::ManifestSaved { result } => { + if let Err((err_msg, failed_manifest)) = result { + eprintln!("[Autosave-Fehler] Manifest fehlgeschlagen: {err_msg}"); + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Manifest: {err_msg}")); + if self.pending_manifest.is_none() { + self.pending_manifest = Some(*failed_manifest); + } } } } if self.in_flight_count == 0 && self.pending_pages.is_empty() - && self.status != AutosaveStatus::Error + && self.pending_manifest.is_none() { + self.last_errors.clear(); self.status = AutosaveStatus::Saved; } } - // 2. Debounce-Timer prüfen: Wenn Zeit abgelaufen, an Worker übergeben - if !self.pending_pages.is_empty() { - if let Some(last_change) = self.last_change_time { - if last_change.elapsed() >= self.debounce_duration { - self.dispatch_pending(); - } + // 2. Prüfen, ob anstehende Änderungen fällig sind (Debounce ODER Max-Wait ODER Backoff-Retry) + let has_pending = !self.pending_pages.is_empty() || self.pending_manifest.is_some(); + if has_pending { + let max_wait_hit = self + .first_change_time + .map(|t| now.duration_since(t) >= self.max_wait_duration) + .unwrap_or(false); + + let debounce_hit = self + .last_change_time + .map(|t| now.duration_since(t) >= self.debounce_duration) + .unwrap_or(false); + + let retry_ready = !self.retries.is_empty() + && self.pending_pages.keys().any(|id| { + self.retries + .get(id) + .map(|r| now >= r.next_retry) + .unwrap_or(false) + }); + + if max_wait_hit || debounce_hit || retry_ready { + self.dispatch_pending(); } } self.status } - /// Übergibt alle anstehenden Änderungen sofort an den Worker-Kanal. + /// Übergibt alle fälligen Änderungen sofort an den Worker-Kanal. fn dispatch_pending(&mut self) { - for (_page_id, page_data) in self.pending_pages.drain() { + let now = self.clock.now(); + self.ensure_worker_alive(); + + // Nur Seiten dispatchen, deren Backoff abgelaufen ist + let mut to_dispatch = Vec::new(); + let page_ids: Vec = self.pending_pages.keys().copied().collect(); + for id in page_ids { + let ready = self + .retries + .get(&id) + .map(|r| now >= r.next_retry) + .unwrap_or(true); + if ready { + if let Some(page_data) = self.pending_pages.remove(&id) { + to_dispatch.push(page_data); + } + } + } + + for page_data in to_dispatch { + let page_id = page_data.id; let cmd = AutosaveCommand::SavePage { notebook_dir: self.notebook_dir.clone(), - page_data, + page_data: page_data.clone(), }; if self.cmd_tx.send(cmd).is_ok() { self.in_flight_count += 1; + } else { + // Senden fehlgeschlagen -> zurück in den Puffer + self.status = AutosaveStatus::Error; + self.pending_pages.insert(page_id, page_data); + self.ensure_worker_alive(); } } if let Some(manifest) = self.pending_manifest.take() { let cmd = AutosaveCommand::SaveManifest { notebook_dir: self.notebook_dir.clone(), - manifest, + manifest: manifest.clone(), }; if self.cmd_tx.send(cmd).is_ok() { self.in_flight_count += 1; + } else { + self.status = AutosaveStatus::Error; + self.pending_manifest = Some(manifest); + self.ensure_worker_alive(); } } - self.status = AutosaveStatus::Saving; - self.last_change_time = None; - } - - /// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden). - pub fn flush(&mut self) -> Result<(), String> { - let errors = self.flush_collect_errors(); - if errors.is_empty() { + if self.in_flight_count > 0 { if self.status != AutosaveStatus::Error { - self.status = AutosaveStatus::Saved; + self.status = AutosaveStatus::Saving; } - Ok(()) - } else { - self.status = AutosaveStatus::Error; - Err(errors.join("; ")) + self.first_change_time = None; + self.last_change_time = None; } } - /// Schickt alle anstehenden Änderungen los, wartet, bis der Worker sie abgearbeitet hat, - /// und liefert alle dabei aufgetretenen Schreibfehler. - fn flush_collect_errors(&mut self) -> Vec { - // Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken + /// Führt einen synchronen Flush aller anstehenden Änderungen durch. + /// Gibt `Result<(), AutosaveError>` mit allen Fehlermeldungen zurück. + pub fn flush(&mut self) -> Result<(), AutosaveError> { + if self.read_only { + return Ok(()); + } + // Backoff für den Flush ignorieren, damit sofort geschrieben wird + self.retries.clear(); + if !self.pending_pages.is_empty() || self.pending_manifest.is_some() { self.dispatch_pending(); } - let mut errors = Vec::new(); let (ack_tx, ack_rx) = mpsc::channel(); - if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_err() || ack_rx.recv().is_err() { - errors.push("Autosave-Worker ist nicht erreichbar".to_string()); + if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() { + let _ = ack_rx.recv(); } - // Restliche Antworten leeren (der Worker hat alle vor dem Flush-Befehl abgearbeitet) + // Restliche Antworten leeren while let Ok(response) = self.resp_rx.try_recv() { if self.in_flight_count > 0 { self.in_flight_count -= 1; } match response { - AutosaveResponse::PageSaved { - page_id, - result: Err(e), - } => { - eprintln!("[Autosave-Flush] Seite {page_id}: {e}"); - errors.push(format!("Seite {page_id}: {e}")); + AutosaveResponse::PageSaved { page_id, result } => match result { + Ok(()) => { + self.retries.remove(&page_id); + } + Err((e, failed_page)) => { + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Seite {page_id}: {e}")); + self.pending_pages.insert(page_id, *failed_page); + let now = self.clock.now(); + let retry = self.retries.entry(page_id).or_insert(PageRetry { + next_retry: now, + backoff: Duration::from_secs(1), + }); + retry.next_retry = now + retry.backoff; + retry.backoff = (retry.backoff * 2).min(Duration::from_secs(30)); + } + }, + AutosaveResponse::ManifestSaved { result } => { + if let Err((e, failed_manifest)) = result { + self.status = AutosaveStatus::Error; + self.last_errors.push(format!("Manifest: {e}")); + self.pending_manifest = Some(*failed_manifest); + } } - AutosaveResponse::ManifestSaved { result: Err(e) } => { - eprintln!("[Autosave-Flush] Manifest: {e}"); - errors.push(format!("Manifest: {e}")); - } - _ => {} } } - errors + + if self.in_flight_count == 0 + && self.pending_pages.is_empty() + && self.pending_manifest.is_none() + { + self.last_errors.clear(); + self.status = AutosaveStatus::Saved; + Ok(()) + } else { + self.status = AutosaveStatus::Error; + let failed_page_ids: Vec = self.pending_pages.keys().copied().collect(); + let messages = std::mem::take(&mut self.last_errors); + Err(AutosaveError { + failed_page_ids, + messages, + }) + } } - /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel), - /// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden. - /// Liefert die Schreibfehler des vorherigen Notizbuchs zurück; der Status gilt danach - /// für das neue Notizbuch. - pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) -> Result<(), String> { - let result = self.flush(); + /// Wartet synchron darauf, dass der Worker alle bereits in der Warteschlange befindlichen Aufgaben ausführt. + /// Startet keine neuen Schreibvorgänge (im Unterschied zu `flush()`). + pub fn sync_worker(&self) { + let (ack_tx, ack_rx) = mpsc::channel(); + if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() { + let _ = ack_rx.recv(); + } + } + + /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel). + pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) { + let _ = self.flush(); self.notebook_dir = new_notebook_dir; - self.status = AutosaveStatus::Saved; - result } /// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush. - pub fn shutdown(&mut self) { - if let Err(e) = self.flush() { - eprintln!("[Shutdown] Nicht alle Änderungen konnten gespeichert werden: {e}"); - } + pub fn shutdown(&mut self) -> Result<(), AutosaveError> { + let res = self.flush(); let _ = self.cmd_tx.send(AutosaveCommand::Shutdown); if let Some(handle) = self.worker_handle.take() { let _ = handle.join(); } + res + } + + /// Schreibt alle ungespeicherten/ausstehenden Seiten als Notfall-Sicherung + /// in $XDG_DATA_HOME/ink-notes/recovery/ (Dateiname: recovery__.json). + pub fn export_recovery( + &self, + fallback_notebook: Option<&Notebook>, + ) -> Result, std::io::Error> { + let rec_dir = recovery_dir(); + fs::create_dir_all(&rec_dir)?; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut written = Vec::new(); + let mut exported_ids = HashSet::new(); + + // 1. Alle in pending_pages wartenden Seiten sichern + for (&page_id, page_data) in &self.pending_pages { + let filename = format!("recovery_{}_{}.json", page_id, ts); + let file_path = rec_dir.join(filename); + let json = serde_json::to_vec_pretty(page_data)?; + fs::write(&file_path, json)?; + exported_ids.insert(page_id); + written.push(file_path); + } + + // 2. Falls ein Notizbuch übergeben wurde, alle dirty-Seiten sichern, die noch nicht erfasst wurden + if let Some(nb) = fallback_notebook { + for page in &nb.pages { + if !exported_ids.contains(&page.id) + && (page.is_dirty || page.persist == PersistState::Normal) + { + let page_data = PageData::from(page); + let filename = format!("recovery_{}_{}.json", page.id, ts); + let file_path = rec_dir.join(filename); + let json = serde_json::to_vec_pretty(&page_data)?; + fs::write(&file_path, json)?; + written.push(file_path); + } + } + } + + Ok(written) } /// Gibt die Gesamtzahl aller tatsächlich auf die Disk geschriebenen Seiten zurück. @@ -342,10 +681,409 @@ impl AutosaveController { } } +/// Liefert das Verzeichnis für Notfall-Wiederherstellungsdateien ($XDG_DATA_HOME/ink-notes/recovery). +pub fn recovery_dir() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("ink-notes").join("recovery"); + } + } + if let Ok(home) = std::env::var("HOME") { + if !home.is_empty() { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("ink-notes") + .join("recovery"); + } + } + PathBuf::from(".").join(".recovery") +} + impl Drop for AutosaveController { fn drop(&mut self) { if self.worker_handle.is_some() { - self.shutdown(); + if let Err(e) = self.shutdown() { + eprintln!("[Autosave] Fehler beim geordneten Beenden in Drop: {e}"); + } } } } + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::canvas::InkVertex; + use crate::document::{PageBackground, Stroke, StylusSample}; + use std::sync::Mutex; + + /// Mock-Uhr für deterministische, sofortige Tests ohne thread::sleep. + pub struct MockClock { + now: Mutex, + } + + impl MockClock { + pub fn new(start: Instant) -> Self { + Self { + now: Mutex::new(start), + } + } + + pub fn advance(&self, duration: Duration) { + let mut guard = self.now.lock().unwrap(); + *guard += duration; + } + } + + impl Clock for MockClock { + fn now(&self) -> Instant { + *self.now.lock().unwrap() + } + } + + /// Fake-PageStore zum Simulieren von I/O-Fehlern und Panics. + pub struct FakePageStore { + pub fail_page_writes: Mutex, + pub panic_on_page: Mutex, + pub page_writes: Arc, + pub manifest_writes: Arc, + } + + impl FakePageStore { + pub fn new() -> Self { + Self { + fail_page_writes: Mutex::new(0), + panic_on_page: Mutex::new(false), + page_writes: Arc::new(AtomicUsize::new(0)), + manifest_writes: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl Default for FakePageStore { + fn default() -> Self { + Self::new() + } + } + + impl PageStore for FakePageStore { + fn save_page(&self, _dir: &Path, _page: &PageData) -> Result<(), StorageError> { + if *self.panic_on_page.lock().unwrap() { + panic!("Simulierte Panic im Worker!"); + } + let mut fails = self.fail_page_writes.lock().unwrap(); + if *fails > 0 { + *fails -= 1; + return Err(StorageError::InvalidManifest( + "Simulierter Store-Fehler".to_string(), + )); + } + self.page_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn save_manifest( + &self, + _dir: &Path, + _manifest: &NotebookManifest, + ) -> Result<(), StorageError> { + self.manifest_writes.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn test_debounce_bundles_changes() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let page_writes_counter = Arc::clone(&store.page_writes); + + let debounce = Duration::from_millis(1500); + let max_wait = Duration::from_secs(10); + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + debounce, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb1", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + + // 10 schnelle Änderungen, jeweils 100 ms Abstand (unter 1500 ms Debounce) + for i in 0..10 { + page.add_stroke(Stroke::new( + i + 1, + vec![StylusSample { + position: glam::Vec2::new(i as f32, 0.0), + pressure: 0.5, + timestamp: i as f64 * 0.1, + }], + vec![InkVertex::new(i as f32, 0.0, [0.0, 0.0, 0.0, 1.0])], + [0.0, 0.0, 0.0, 1.0], + )); + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(100)); + controller.update(); + } + + // Während der schnellen Änderungen darf noch nichts geschrieben sein + assert_eq!(page_writes_counter.load(Ordering::SeqCst), 0); + + // Zeit um 1600 ms voranschreiten lassen (> 1500 ms Debounce) + clock.advance(Duration::from_millis(1600)); + controller.update(); + let _ = controller.flush(); + + // Genau 1 gebündelter Schreibvorgang + assert_eq!(page_writes_counter.load(Ordering::SeqCst), 1); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_max_wait_forces_flush_during_continuous_edits() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let page_writes_counter = Arc::clone(&store.page_writes); + + let debounce = Duration::from_millis(1500); + let max_wait = Duration::from_secs(10); + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + debounce, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb2", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + + // Kontinuierliche Änderungen alle 500 ms über insgesamt 12 Sekunden (Debounce 1500ms würde nie greifen!) + for i in 0..24 { + page.add_stroke(Stroke::new(i + 1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(500)); + controller.update(); + } + + // Nach 12s (> 10s max_wait) MUSS mindestens ein Schreibvorgang erzwungen worden sein! + controller.sync_worker(); + let writes = page_writes_counter.load(Ordering::SeqCst); + assert!( + writes >= 1, + "MaxWait muss Schreiben erzwingen, selbst wenn Debounce nie abläuft" + ); + } + + #[test] + fn test_error_retry_with_backoff_and_recovery() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + *store.fail_page_writes.lock().unwrap() = 2; // Die ersten 2 Versuche schlagen fehl + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb3", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + page.add_stroke(Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(600)); + controller.update(); + + // Erster Versuch fehlgeschlagen -> Status Error + let flush_err = controller.flush(); + assert!( + flush_err.is_err(), + "Flush muss fehlschlagen, solange Store fehlschlägt" + ); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Nach Ablauf des Backoffs erneuter Versuch (Versuch 2 schlägt fehl) + clock.advance(Duration::from_secs(2)); + controller.update(); + controller.sync_worker(); + controller.update(); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Nach Ablauf des neuen Backoffs dritter Versuch (Store fehlerfrei -> gelingt): + clock.advance(Duration::from_secs(4)); + let flush_ok = controller.flush(); + assert!( + flush_ok.is_ok(), + "Dritter Versuch muss gelingen und Status auf Saved setzen" + ); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_worker_panic_catch_and_restart() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + *store.panic_on_page.lock().unwrap() = true; // Panic auslösen + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb4", "Test"); + let mut page = Page::new(1, PageBackground::Grid5mm); + page.add_stroke(Stroke::new(1, vec![], vec![], [0.0, 0.0, 0.0, 1.0])); + + controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(600)); + controller.update(); + + // Worker fängt Panic ab und meldet Fehler zurück + let res = controller.flush(); + assert!(res.is_err()); + assert_eq!(controller.status(), AutosaveStatus::Error); + } + + #[test] + fn test_manifest_only_dirty_triggers_autosave() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = Arc::new(FakePageStore::new()); + let manifest_writes_counter = Arc::clone(&store.manifest_writes); + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test"), + Duration::from_millis(500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("nb5", "Nur Manifest Geändert"); + controller.mark_manifest_dirty(&manifest); + + clock.advance(Duration::from_millis(600)); + controller.update(); + let _ = controller.flush(); + + assert_eq!(manifest_writes_counter.load(Ordering::SeqCst), 1); + assert_eq!(controller.status(), AutosaveStatus::Saved); + } + + #[test] + fn test_export_recovery_on_failure() { + let clock = Arc::new(MockClock::new(Instant::now())); + let store = FakePageStore::new(); + // Alle Schreibvorgänge fehlschlagen lassen + *store.fail_page_writes.lock().unwrap() = 10; + + let temp_recovery_dir = std::env::temp_dir().join(format!( + "ink_notes_rec_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::env::set_var("XDG_DATA_HOME", &temp_recovery_dir); + + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test_rec"), + Duration::from_millis(100), + Duration::from_secs(10), + Arc::new(store), + Arc::clone(&clock) as Arc, + ); + + let page = Page::new(42, PageBackground::Grid5mm); + let manifest = NotebookManifest::new("rec_nb", "Recovery Test"); + controller.mark_page_dirty(&page, &manifest); + + // Flush schlägt fehl + let flush_res = controller.flush(); + assert!(flush_res.is_err(), "Flush muss fehlschlagen"); + assert_eq!(controller.status(), AutosaveStatus::Error); + + // Notfall-Export ausführen + let written_files = controller + .export_recovery(None) + .expect("Notfall-Export muss erfolgreich sein"); + assert_eq!(written_files.len(), 1); + let file = &written_files[0]; + assert!(file.exists()); + + let content = std::fs::read_to_string(file).expect("Datei lesbar"); + let parsed: PageData = serde_json::from_str(&content).expect("Gültiges JSON"); + assert_eq!(parsed.id, 42); + + // Aufräumen + let _ = std::fs::remove_dir_all(&temp_recovery_dir); + } + + #[test] + fn test_snapshot_performance() { + let store = Arc::new(FakePageStore::new()); + let clock = Arc::new(MockClock::new(Instant::now())); + let mut controller = AutosaveController::with_options( + PathBuf::from("/tmp/test_perf"), + Duration::from_millis(1500), + Duration::from_secs(10), + store, + Arc::clone(&clock) as Arc, + ); + + let manifest = NotebookManifest::new("perf_nb", "Perf Test"); + + fn make_page(num_samples: usize) -> Page { + let mut page = Page::new(1, PageBackground::Blank); + let samples_per_stroke = 100; + let num_strokes = num_samples / samples_per_stroke; + for s in 0..num_strokes { + let mut samples = Vec::with_capacity(samples_per_stroke); + for i in 0..samples_per_stroke { + samples.push(StylusSample { + position: glam::Vec2::new(s as f32 * 10.0 + i as f32, i as f32), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }); + } + page.add_stroke(Stroke::new( + (s + 1) as u64, + samples, + Vec::new(), + [0.0, 0.0, 0.0, 1.0], + )); + } + page + } + + // 1. 1 000 Samples + let p_1k = make_page(1_000); + let start = Instant::now(); + controller.mark_page_dirty(&p_1k, &manifest); + let dur_1k = start.elapsed(); + + // 2. 10 000 Samples + let p_10k = make_page(10_000); + let start = Instant::now(); + controller.mark_page_dirty(&p_10k, &manifest); + let dur_10k = start.elapsed(); + + // 3. 50 000 Samples + let p_50k = make_page(50_000); + let start = Instant::now(); + controller.mark_page_dirty(&p_50k, &manifest); + let dur_50k = start.elapsed(); + + println!( + "\n[Benchmark Snapshot] 1k Samples: {:?} | 10k Samples: {:?} | 50k Samples: {:?}", + dur_1k, dur_10k, dur_50k + ); + } +} diff --git a/src/bin/crash_sim.rs b/src/bin/crash_sim.rs new file mode 100644 index 0000000..e544e64 --- /dev/null +++ b/src/bin/crash_sim.rs @@ -0,0 +1,221 @@ +//! Crash-Simulationstesthelfer für ink-notes. +//! Wird von scripts/crash-test.sh verwendet, um unvollständige Schreibvorgänge +//! und SIGKILL (kill -9) Robustheit zu verifizieren. + +use std::env; +use std::path::Path; +use std::process; +use std::time::Duration; + +use glam::Vec2; +use ink_notes::document::{Notebook, PageBackground, StylusSample}; +use ink_notes::spline::{finalize_stroke_with_config, SplineConfig}; +use ink_notes::storage::{ + cleanup_orphaned_temp_files, load_notebook, load_page, save_manifest, save_notebook, save_page, + NotebookManifest, PageData, +}; + +fn run_init(dir: &Path) -> Result<(), Box> { + let nb = Notebook::new(); + let id = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("crash_test"); + let mut m = NotebookManifest::new(id, "Crash Test Notebook"); + m.sync_from_notebook(&nb); + save_notebook(dir, &nb, &mut m)?; + println!("[init] Initialized clean notebook in {:?}", dir); + Ok(()) +} + +fn run_worker(dir: &Path) { + let (mut notebook, mut manifest) = match load_notebook(dir) { + Ok((nb, m)) => (nb, m), + Err(_) => { + let nb = Notebook::new(); + let id = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("crash_test"); + let mut m = NotebookManifest::new(id, "Crash Test Notebook"); + m.sync_from_notebook(&nb); + let _ = save_notebook(dir, &nb, &mut m); + (nb, m) + } + }; + + let spline_cfg = SplineConfig::default(); + let mut stroke_counter = manifest.next_stroke_id; + + println!("[worker] Started writing loop in {:?}", dir); + + loop { + // Erzeuge neuen Strich mit einigen Samples + let base_x = (stroke_counter % 500) as f32; + let samples = vec![ + StylusSample { + position: Vec2::new(base_x, 100.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: Vec2::new(base_x + 10.0, 150.0), + pressure: 0.7, + timestamp: 0.01, + }, + StylusSample { + position: Vec2::new(base_x + 20.0, 200.0), + pressure: 0.6, + timestamp: 0.02, + }, + ]; + + let stroke = finalize_stroke_with_config( + stroke_counter, + &samples, + [0.0, 0.0, 0.0, 1.0], + &spline_cfg, + ); + stroke_counter += 1; + manifest.next_stroke_id = stroke_counter; + + let page = notebook.current_page_mut(); + page.add_stroke(stroke); + + let page_data = PageData::from(&*page); + + // 1. Schreibe Seite atomar + if let Err(e) = save_page(dir, &page_data) { + eprintln!("[worker] Error saving page: {:?}", e); + } + + // 2. Aktualisiere Manifest atomar + manifest.sync_from_notebook(¬ebook); + if let Err(e) = save_manifest(dir, &manifest) { + eprintln!("[worker] Error saving manifest: {:?}", e); + } + + // 3. Gelegentlich Gesamtsave (wie Autosave-Flush) + if stroke_counter % 5 == 0 { + if let Err(e) = save_notebook(dir, ¬ebook, &mut manifest) { + eprintln!("[worker] Error saving notebook: {:?}", e); + } + } + + // 4. Evtl. neue Seite anlegen und sofort sichern, bevor Manifest aktualisiert wird + if stroke_counter % 25 == 0 { + let page_idx = notebook.add_page(PageBackground::DotGrid); + notebook.current_page_index = page_idx; + let new_page_data = PageData::from(notebook.current_page()); + if let Err(e) = save_page(dir, &new_page_data) { + eprintln!("[worker] Error saving new page: {:?}", e); + } + manifest.sync_from_notebook(¬ebook); + if let Err(e) = save_manifest(dir, &manifest) { + eprintln!("[worker] Error saving manifest: {:?}", e); + } + } + } +} + +fn run_verify(dir: &Path) -> Result<(), Box> { + println!("[verify] Checking notebook in {:?}", dir); + + // 1. Bereinige eventuelle verwaiste .tmp-Dateien + let _ = cleanup_orphaned_temp_files(dir, Duration::from_secs(0)); + + // Prüfe, ob das Verzeichnis noch gar kein Notizbuch enthält (Prozess vor erstem Flush getötet) + let manifest_path = dir.join("manifest.json"); + let manifest_bak = dir.join("manifest.json.bak"); + if !manifest_path.exists() && !manifest_bak.exists() { + println!("[verify] Notebook not yet created on disk (killed prior to first save). Valid empty state."); + return Ok(()); + } + + // 2. Notizbuch laden + let (_notebook, manifest) = match load_notebook(dir) { + Ok(res) => res, + Err(e) => { + eprintln!("[verify] FAILED to load notebook: {:?}", e); + return Err(Box::new(e)); + } + }; + + println!( + "[verify] Manifest loaded successfully: title='{}', pages={}, next_stroke_id={}", + manifest.title, + manifest.pages.len(), + manifest.next_stroke_id + ); + + // 3. Alle im Manifest registrierten Seiten laden und prüfen + for page_meta in &manifest.pages { + match load_page(dir, page_meta.id) { + Ok(page_data) => { + let page = page_data.to_page(); + println!( + "[verify] Page {} verified: {} strokes, background={:?}", + page_meta.id, + page.strokes.len(), + page.background + ); + } + Err(e) => { + eprintln!( + "[verify] FAILED to load page {} referenced by manifest: {:?}", + page_meta.id, e + ); + return Err(Box::new(e)); + } + } + } + + // 4. Prüfen ob Quarantäne-Dateien existieren + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + if name.contains(".corrupt-") { + println!("[verify] Notice: Found quarantined file: {}", name); + } + } + } + + println!("[verify] SUCCESS: All pages and manifest are fully consistent!"); + Ok(()) +} + +fn main() { + let args: Vec = env::args().collect(); + if args.len() < 3 { + eprintln!("Usage: crash_sim "); + process::exit(2); + } + + let command = &args[1]; + let notebook_dir = Path::new(&args[2]); + + match command.as_str() { + "init" => match run_init(notebook_dir) { + Ok(_) => process::exit(0), + Err(e) => { + eprintln!("[init] Error: {}", e); + process::exit(1); + } + }, + "worker" => { + run_worker(notebook_dir); + } + "verify" => match run_verify(notebook_dir) { + Ok(_) => process::exit(0), + Err(e) => { + eprintln!("[verify] Error: {}", e); + process::exit(1); + } + }, + other => { + eprintln!("Unknown command: {}", other); + process::exit(2); + } + } +} diff --git a/src/canvas.rs b/src/canvas.rs index bfaeaa2..98c8d71 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -104,6 +104,8 @@ pub struct CanvasState { pub camera_buffer: wgpu::Buffer, pub camera_bind_group: wgpu::BindGroup, pub current_ink_color: [f32; 4], + pub current_stroke_width_mm: f32, + pub current_tool: crate::document::StrokeTool, // Statische Hintergrund-Geometrie pub paper_buffer: wgpu::Buffer, @@ -374,6 +376,8 @@ impl CanvasState { camera_buffer, camera_bind_group, current_ink_color: default_ink_color, + current_stroke_width_mm: 0.5, + current_tool: crate::document::StrokeTool::Pen, paper_buffer, background_buffer, background_vertex_capacity: Self::INITIAL_BACKGROUND_CAPACITY, @@ -502,10 +506,14 @@ impl CanvasState { /// Konfiguriert die Strichbreite in Millimetern. pub fn set_stroke_width_mm(&mut self, width_mm: f32) { - // 10 Einheiten = 1 mm. Radius = (width_mm * 10.0) / 2.0 = width_mm * 5.0. - let base_radius = (width_mm * 5.0).max(0.25); - self.spline_config.min_radius = (base_radius * 0.5).max(0.15); - self.spline_config.max_radius = (base_radius * 1.5).max(0.35); + self.current_stroke_width_mm = width_mm; + self.spline_config = + SplineConfig::from_width_units(width_mm * crate::document::UNITS_PER_MM); + } + + /// Setzt das aktive Zeichenwerkzeug für den Stift. + pub fn set_tool(&mut self, tool: crate::document::StrokeTool) { + self.current_tool = tool; } /// Setzt den Seitenhintergrund und aktualisiert bei Bedarf den Vertex-Puffer. @@ -643,6 +651,10 @@ impl CanvasState { self.current_ink_color, &self.spline_config, ); + let mut stroke = stroke; + stroke.tool = self.current_tool; + stroke.width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM; + stroke.finalized = true; // Temporären GPU-Live-Buffer vollständig leeren self.live_samples.clear(); diff --git a/src/document.rs b/src/document.rs index 165fce7..a78efbd 100644 --- a/src/document.rs +++ b/src/document.rs @@ -103,6 +103,16 @@ pub fn point_to_segment_distance(p: glam::Vec2, a: glam::Vec2, b: glam::Vec2) -> (p - projection).length() } +/// Das Werkzeug, mit dem ein Strich gezeichnet wurde. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StrokeTool { + #[default] + Pen, + Highlighter, + Shape, +} + /// Ein einzelner Vektor-Strich auf einer Seite. #[derive(Debug, Clone, PartialEq)] pub struct Stroke { @@ -112,6 +122,9 @@ pub struct Stroke { pub aabb: Aabb, pub color: [f32; 4], pub shape: Option, + pub tool: StrokeTool, + pub width: f32, + pub finalized: bool, } impl Stroke { @@ -141,6 +154,9 @@ impl Stroke { aabb, color, shape: None, + tool: StrokeTool::Pen, + width: 5.0, + finalized: true, } } @@ -157,6 +173,22 @@ impl Stroke { /// Verknüpft diesen Strich mit einer mathematisch erkannten Form. pub fn with_shape(mut self, shape: crate::geometry::RecognizedShape) -> Self { self.shape = Some(shape); + self.tool = StrokeTool::Shape; + self + } + + pub fn with_tool(mut self, tool: StrokeTool) -> Self { + self.tool = tool; + self + } + + pub fn with_width(mut self, width: f32) -> Self { + self.width = width; + self + } + + pub fn with_finalized(mut self, finalized: bool) -> Self { + self.finalized = finalized; self } @@ -304,6 +336,22 @@ pub enum PageBackground { DotGrid, } +/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PersistState { + #[default] + Normal, + ReadOnlyAfterLoadError, +} + +/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler). +#[derive(Debug, Clone, PartialEq)] +pub struct PageLoadWarning { + pub page_id: u64, + pub message: String, + pub quarantine_path: Option, +} + /// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes. #[derive(Debug, Clone)] pub struct Page { @@ -314,9 +362,7 @@ pub struct Page { pub stroke_ranges: Vec>, pub is_dirty: bool, pub is_loaded: bool, - /// Laden von der Festplatte ist fehlgeschlagen; die Seite ist ein leerer Ersatz und - /// darf niemals gespeichert werden, damit die Originaldatei nicht überschrieben wird. - pub load_failed: bool, + pub persist: PersistState, pub history: HistoryStack, } @@ -330,7 +376,7 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: true, - load_failed: false, + persist: PersistState::Normal, history: HistoryStack::new(), } } @@ -345,11 +391,19 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: false, - load_failed: false, + persist: PersistState::Normal, history: HistoryStack::new(), } } + pub fn is_readonly(&self) -> bool { + self.persist == PersistState::ReadOnlyAfterLoadError + } + + pub fn unlock_after_load_error(&mut self) { + self.persist = PersistState::Normal; + } + /// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack. pub fn add_stroke(&mut self, stroke: Stroke) { self.history.push(HistoryAction::AddStroke(stroke.clone())); @@ -592,6 +646,7 @@ pub struct Notebook { pub current_page_index: usize, pub next_stroke_id: u64, pub next_page_id: u64, + pub load_warnings: std::collections::HashMap, } impl Notebook { @@ -601,6 +656,7 @@ impl Notebook { current_page_index: 0, next_stroke_id: 1, next_page_id: 1, + load_warnings: std::collections::HashMap::new(), }; // Erste Standardseite anlegen (5mm Grid) nb.add_page(PageBackground::Grid5mm); @@ -619,6 +675,7 @@ impl Notebook { current_page_index, next_stroke_id, next_page_id, + load_warnings: std::collections::HashMap::new(), }; if nb.pages.is_empty() { nb.add_page(PageBackground::Grid5mm); diff --git a/src/export.rs b/src/export.rs index 584720c..68aa202 100644 --- a/src/export.rs +++ b/src/export.rs @@ -140,11 +140,9 @@ pub fn generate_pdf_document( return Err("Notizbuch enthält keine Seiten".into()); } // Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert - if let Some(page) = notebook - .pages - .iter() - .find(|p| !p.is_loaded || p.load_failed) - { + if let Some(page) = notebook.pages.iter().find(|p| { + !p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError + }) { return Err(format!( "Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", page.id diff --git a/src/geometry.rs b/src/geometry.rs index b81c724..44db9f7 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1411,6 +1411,9 @@ pub fn shape_to_stroke( let (samples, vertices) = shape_to_mesh(&shape, width_units, color); let mut stroke = Stroke::new(stroke_id, samples, vertices, color); stroke.shape = Some(shape); + stroke.width = width_units; + stroke.tool = crate::document::StrokeTool::Shape; + stroke.finalized = true; stroke } diff --git a/src/main.rs b/src/main.rs index 2aeb2b3..c11d682 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,9 +159,17 @@ fn main() -> Result<(), Box> { // 1. Notizbuch aus Storage laden oder initialisieren (Session-Wiederherstellung) let storage_base_dir = storage::default_storage_dir(); + let _ = storage::cleanup_trash( + &storage_base_dir, + std::time::Duration::from_secs(30 * 24 * 3600), + ); let available_notebooks = storage::list_notebooks(&storage_base_dir); - let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() { + // Beschädigte Notizbücher nicht automatisch öffnen + let (initial_id, initial_title) = if let Some(first) = available_notebooks + .iter() + .find(|s| s.status == storage::NotebookStatus::Normal) + { (first.id.clone(), first.title.clone()) } else { ("default".to_string(), "Standard-Notizbuch".to_string()) @@ -231,6 +239,24 @@ fn main() -> Result<(), Box> { } let mut selection_state = SelectionState::new(); + let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { + Ok(lock) => { + autosave.set_read_only(false); + Some(lock) + } + Err(storage::StorageError::AlreadyLocked(msg)) => { + eprintln!("[Storage-Lock] {msg}"); + autosave.set_read_only(true); + ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + None + } + Err(e) => { + eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); + autosave.set_read_only(true); + None + } + }; + // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren canvas.sync_page(notebook.current_page_mut()); canvas.set_stroke_color(ui_state.pen_color); @@ -410,7 +436,7 @@ fn main() -> Result<(), Box> { ); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title( &window, ¬ebook, @@ -487,7 +513,7 @@ fn main() -> Result<(), Box> { ); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title( &window, ¬ebook, @@ -638,8 +664,16 @@ fn main() -> Result<(), Box> { match event { WindowEvent::CloseRequested => { println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); - autosave.shutdown(); - target.exit(); + match autosave.shutdown() { + Ok(()) => { + target.exit(); + } + Err(err) => { + eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}"); + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } } WindowEvent::Focused(false) => { @@ -708,7 +742,10 @@ fn main() -> Result<(), Box> { match &logical_key { Key::Named(NamedKey::Escape) => { let mut changed = false; - if ui_state.is_pen_menu_open + if ui_state.shutdown_error.is_some() { + ui_state.shutdown_error = None; + changed = true; + } else if ui_state.is_pen_menu_open || ui_state.is_highlighter_menu_open || ui_state.show_page_jump_popup || ui_state.is_document_menu_open @@ -737,7 +774,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -751,7 +788,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -781,7 +818,7 @@ fn main() -> Result<(), Box> { if !new_ids.is_empty() { canvas.sync_page(notebook.current_page_mut()); manifest.next_stroke_id = notebook.next_stroke_id; - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); window.request_redraw(); } @@ -793,7 +830,7 @@ fn main() -> Result<(), Box> { let removed = selection_state.delete_selected(notebook.current_page_mut()); if !removed.is_empty() { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); window.request_redraw(); } @@ -802,9 +839,16 @@ fn main() -> Result<(), Box> { // Jetzt speichern (Disk Flush): Strg + S Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { - match autosave.save_now(¬ebook, &manifest) { - Ok(()) => ui_state.set_export_message("Gespeichert"), - Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")), + match autosave.flush() { + Ok(()) => { + ui_state.set_export_message("Gespeichert"); + } + Err(err) => { + ui_state.set_export_message(format!( + "Speicherfehler: {}", + err.messages.join(", ") + )); + } } update_window_title( &window, @@ -855,7 +899,7 @@ fn main() -> Result<(), Box> { let color = [0.12, 0.12, 0.14, 1.0]; if selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); window.request_redraw(); } @@ -875,7 +919,7 @@ fn main() -> Result<(), Box> { let color = [0.15, 0.35, 0.85, 1.0]; if selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); window.request_redraw(); } @@ -894,7 +938,7 @@ fn main() -> Result<(), Box> { if !selection_state.is_empty() { if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); window.request_redraw(); } @@ -918,7 +962,7 @@ fn main() -> Result<(), Box> { PageBackground::Blank => PageBackground::Grid5mm, }; canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -932,7 +976,9 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -944,7 +990,9 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -957,7 +1005,9 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -970,7 +1020,9 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -983,7 +1035,9 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1023,8 +1077,16 @@ fn main() -> Result<(), Box> { // Beenden: Strg + Q Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => { println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); - autosave.shutdown(); - target.exit(); + match autosave.shutdown() { + Ok(()) => { + target.exit(); + } + Err(err) => { + eprintln!("[Shutdown-Fehler] Konnte nicht geordnet beenden: {err}"); + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } } @@ -1064,7 +1126,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1162,7 +1224,7 @@ fn main() -> Result<(), Box> { let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width); notebook.current_page_mut().replace_stroke(old_id, new_stroke); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); window.request_redraw(); return; @@ -1219,7 +1281,7 @@ fn main() -> Result<(), Box> { let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); } window.request_redraw(); } @@ -1332,7 +1394,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1456,7 +1518,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1597,7 +1659,7 @@ fn main() -> Result<(), Box> { let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1688,7 +1750,7 @@ fn main() -> Result<(), Box> { let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); } window.request_redraw(); } @@ -1772,7 +1834,7 @@ fn main() -> Result<(), Box> { if !selection_state.is_empty() && selection_state.recolor_selected(notebook.current_page_mut(), color) { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message("Auswahl umgefärbt".to_string()); } match ui_state.active_tool { @@ -1807,7 +1869,7 @@ fn main() -> Result<(), Box> { let removed = selection_state.delete_selected(notebook.current_page_mut()); if !removed.is_empty() { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); window.request_redraw(); } @@ -1834,7 +1896,7 @@ fn main() -> Result<(), Box> { if !new_ids.is_empty() { canvas.sync_page(notebook.current_page_mut()); manifest.next_stroke_id = notebook.next_stroke_id; - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); window.request_redraw(); } @@ -1843,7 +1905,7 @@ fn main() -> Result<(), Box> { if !selection_state.is_empty() && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); window.request_redraw(); } @@ -1871,7 +1933,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1880,7 +1942,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1892,7 +1954,9 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1904,7 +1968,17 @@ fn main() -> Result<(), Box> { canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; - autosave.mark_dirty(¬ebook, &manifest); + manifest.pages = notebook + .pages + .iter() + .map(|p| storage::PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1914,8 +1988,18 @@ fn main() -> Result<(), Box> { notebook.add_page(bg); manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; + manifest.pages = notebook + .pages + .iter() + .map(|p| storage::PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1926,7 +2010,9 @@ fn main() -> Result<(), Box> { let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); canvas.sync_page(notebook.current_page_mut()); manifest.current_page_index = idx; - autosave.mark_dirty(¬ebook, &manifest); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1935,7 +2021,7 @@ fn main() -> Result<(), Box> { let page = notebook.current_page_mut(); page.background = bg; canvas.sync_page(page); - autosave.mark_dirty(¬ebook, &manifest); + autosave.mark_dirty(¬ebook, &mut manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1957,9 +2043,25 @@ fn main() -> Result<(), Box> { let new_notebook_dir = storage_base_dir.join(&id); match storage::load_notebook(&new_notebook_dir) { Ok((loaded_nb, loaded_manifest)) => { - if let Err(err) = autosave.switch_notebook_dir(new_notebook_dir.clone()) { - eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}"); -} + let _ = autosave.flush(); + match storage::NotebookLock::acquire(&new_notebook_dir) { + Ok(lock) => { + _notebook_lock = Some(lock); + autosave.set_read_only(false); + } + Err(storage::StorageError::AlreadyLocked(msg)) => { + eprintln!("[Storage-Lock] {msg}"); + _notebook_lock = None; + autosave.set_read_only(true); + ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)"); + } + Err(e) => { + eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}"); + _notebook_lock = None; + autosave.set_read_only(true); + } + } + autosave.switch_notebook_dir(new_notebook_dir.clone()); notebook = loaded_nb; manifest = loaded_manifest; storage_dir = new_notebook_dir; @@ -1970,7 +2072,9 @@ fn main() -> Result<(), Box> { ui_state.is_document_menu_open = false; canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + if _notebook_lock.is_some() { + ui_state.set_export_message(format!("Dokument '{}' geöffnet", manifest.title)); + } window.request_redraw(); } Err(err) => { @@ -1981,11 +2085,12 @@ fn main() -> Result<(), Box> { } } UiAction::CreateNotebook(title) => { + let _ = autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { Ok((new_nb, new_manifest, new_dir)) => { - if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) { - eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}"); -} + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); + autosave.switch_notebook_dir(new_dir.clone()); notebook = new_nb; manifest = new_manifest; storage_dir = new_dir; @@ -2012,6 +2117,8 @@ fn main() -> Result<(), Box> { Ok(()) => { ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + manifest.sync_from_notebook(¬ebook); + autosave.mark_manifest_dirty(&manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); window.request_redraw(); @@ -2036,54 +2143,66 @@ fn main() -> Result<(), Box> { UiAction::DeleteNotebook(id) => { let target_dir = storage_base_dir.join(&id); let is_current = id == manifest.id; + let deleted_title = ui_state + .available_notebooks + .iter() + .find(|s| s.id == id) + .map(|s| s.title.clone()) + .unwrap_or_else(|| id.clone()); + if is_current { - // Ausstehende Autosave-Schreibvorgänge VOR dem Löschen abarbeiten, - // sonst legen sie den gelöschten Ordner danach wieder an. let _ = autosave.flush(); + drop(_notebook_lock.take()); } - match storage::delete_notebook(&target_dir) { - Ok(()) => { + + match storage::delete_notebook(&target_dir, &storage_base_dir) { + Ok(trash_path) => { + ui_state.last_deleted_notebook = Some((trash_path, deleted_title)); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); if is_current { - // Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen - let replacement = ui_state + if let Some(first) = ui_state .available_notebooks .iter() - .find_map(|summary| { - let dir = storage_base_dir.join(&summary.id); - match storage::load_notebook(&dir) { - Ok((nb, m)) => Some((nb, m, dir)), - Err(err) => { - eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", summary.id); - None - } + .find(|s| s.status != storage::NotebookStatus::Damaged) + .cloned() + { + let new_notebook_dir = storage_base_dir.join(&first.id); + match storage::load_notebook(&new_notebook_dir) { + Ok((loaded_nb, loaded_manifest)) => { + _notebook_lock = storage::NotebookLock::acquire(&new_notebook_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); + autosave.switch_notebook_dir(new_notebook_dir.clone()); + notebook = loaded_nb; + manifest = loaded_manifest; + storage_dir = new_notebook_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title)); } - }) - .map(Ok) - .unwrap_or_else(|| storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch")); - match replacement { - Ok((new_nb, new_manifest, new_dir)) => { - if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) { - eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}"); + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id); } - notebook = new_nb; - manifest = new_manifest; - storage_dir = new_dir; - selection_state.clear(); - ui_state.current_notebook_id = manifest.id.clone(); - ui_state.document_title = manifest.title.clone(); - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); - canvas.sync_page(notebook.current_page_mut()); - update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); - ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title)); - } - Err(err) => { - eprintln!("[Dokument-Fehler] Konnte kein Ersatz-Notizbuch anlegen: {err}"); - ui_state.set_export_message(format!("Dokument gelöscht, aber kein Ersatz-Notizbuch: {err}")); } + } else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") { + _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); + autosave.set_read_only(_notebook_lock.is_none()); + autosave.switch_notebook_dir(new_dir.clone()); + notebook = new_nb; + manifest = new_manifest; + storage_dir = new_dir; + selection_state.clear(); + ui_state.current_notebook_id = manifest.id.clone(); + ui_state.document_title = manifest.title.clone(); + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + canvas.sync_page(notebook.current_page_mut()); + update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); + ui_state.set_export_message("Standard-Notizbuch erstellt".to_string()); } } else { - ui_state.set_export_message("Dokument gelöscht".to_string()); + ui_state.set_export_message("Dokument in Papierkorb verschoben".to_string()); } window.request_redraw(); } @@ -2093,6 +2212,40 @@ fn main() -> Result<(), Box> { } } } + UiAction::UndoDeleteNotebook => { + if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() { + match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) { + Ok(_restored_dir) => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message("Notizbuch wiederhergestellt".to_string()); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Notizbuch nicht wiederherstellen: {err}"); + ui_state.set_export_message(format!("Fehler beim Wiederherstellen: {err}")); + } + } + } + } + UiAction::RestoreNotebookBackup(id) => { + let target_dir = storage_base_dir.join(&id); + match storage::restore_manifest_from_backup(&target_dir) { + Ok(()) => { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string()); + window.request_redraw(); + } + Err(err) => { + eprintln!("[Dokument-Fehler] Konnte Backup für '{id}' nicht wiederherstellen: {err}"); + ui_state.set_export_message(format!("Wiederherstellung fehlgeschlagen: {err}")); + } + } + } + UiAction::OpenNotebookFolder(id) => { + let target_dir = storage_base_dir.join(&id); + ui_state.set_export_message(format!("Ordner: {}", target_dir.display())); + window.request_redraw(); + } UiAction::CheckForUpdates => { updater.check_for_updates(); window.request_redraw(); @@ -2118,6 +2271,51 @@ fn main() -> Result<(), Box> { ui_state.set_export_message("Server-Einstellungen gespeichert"); window.request_redraw(); } + UiAction::UnlockCorruptedPage(page_id) => { + notebook.load_warnings.remove(&page_id); + if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) { + page.unlock_after_load_error(); + } + autosave.mark_dirty(¬ebook, &mut manifest); + window.request_redraw(); + } + UiAction::RetryShutdown => { + println!("[Shutdown] Wiederhole Speichern vor Beenden..."); + match autosave.shutdown() { + Ok(()) => { + ui_state.shutdown_error = None; + target.exit(); + } + Err(err) => { + ui_state.shutdown_error = Some(err); + window.request_redraw(); + } + } + } + UiAction::ExportRecoveryAndExit => { + println!("[Shutdown] Starte Notfall-Export aller ungespeicherten Daten..."); + match autosave.export_recovery(Some(¬ebook)) { + Ok(paths) => { + eprintln!( + "[Recovery] Notfall-Export erfolgreich geschrieben ({} Dateien): {:?}", + paths.len(), + paths + ); + target.exit(); + } + Err(err) => { + eprintln!("[Recovery-Fehler] Konnte Notfall-Export nicht schreiben: {err}"); + if let Some(ref mut se) = ui_state.shutdown_error { + se.messages.push(format!("Notfall-Export fehlgeschlagen: {err}")); + } + window.request_redraw(); + } + } + } + UiAction::ForceExit => { + println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen."); + target.exit(); + } } } diff --git a/src/spline.rs b/src/spline.rs index 8f7c109..b72ecf8 100644 --- a/src/spline.rs +++ b/src/spline.rs @@ -105,6 +105,18 @@ impl Default for SplineConfig { } } +impl SplineConfig { + /// Erzeugt eine Spline-Konfiguration aus der Strichbreite in internen Einheiten (10 Einheiten = 1 mm). + pub fn from_width_units(width_units: f32) -> Self { + let base_radius = (width_units / 2.0).max(0.25); + Self { + min_radius: (base_radius * 0.5).max(0.15), + max_radius: (base_radius * 1.5).max(0.35), + samples_per_segment: 16, + } + } +} + /// Berechnet den druckabhängigen Radius r(p) = min_r + p * (max_r - min_r). pub fn calculate_radius(pressure: f32, min_radius: f32, max_radius: f32) -> f32 { let p = pressure.clamp(0.0, 1.0); @@ -653,7 +665,10 @@ pub fn finalize_stroke_with_config( .map(|v| InkVertex::new(v.position[0], v.position[1], color)) .collect(); - Stroke::new(stroke_id, decimated, ink_vertices, color) + let mut stroke = Stroke::new(stroke_id, decimated, ink_vertices, color); + stroke.width = config.min_radius + config.max_radius; + stroke.finalized = true; + stroke } #[cfg(test)] diff --git a/src/storage.rs b/src/storage.rs index 25009c0..e3784b6 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::canvas::InkVertex; -use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample}; +use crate::document::{ + Notebook, Page, PageBackground, PageLoadWarning, PersistState, Stroke, StylusSample, +}; use crate::filter::OneEuroFilter; use crate::spline::{self, ControlPoint, SplineConfig}; @@ -44,6 +46,16 @@ impl From for StylusSample { } } +/// Standardwert für die Strichbreite bei Migration älterer Dateien (0.5 mm in Einheiten). +pub fn default_stroke_width() -> f32 { + 5.0 +} + +/// Standardwert für den Finalisierungsstatus bei Migration älterer Dateien. +pub fn default_finalized() -> bool { + true +} + /// Serialisierbare semantische Strichdaten (ohne GPU-Vertizes). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct StrokeData { @@ -52,6 +64,12 @@ pub struct StrokeData { pub samples: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub shape: Option, + #[serde(default)] + pub tool: crate::document::StrokeTool, + #[serde(default = "default_stroke_width")] + pub width: f32, + #[serde(default = "default_finalized")] + pub finalized: bool, } impl From<&Stroke> for StrokeData { @@ -61,6 +79,9 @@ impl From<&Stroke> for StrokeData { color: stroke.color, samples: stroke.points.iter().map(SampleData::from).collect(), shape: stroke.shape.clone(), + tool: stroke.tool, + width: stroke.width, + finalized: stroke.finalized, } } } @@ -75,21 +96,47 @@ impl StrokeData { .copied() .map(StylusSample::from) .collect(); + let width = if self.width > 0.0 { + self.width + } else if self.shape.is_some() { + 20.0 + } else { + 5.0 + }; let vertices = if let Some(ref shape) = self.shape { - let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, 20.0, self.color); + let (_, shape_verts) = crate::geometry::shape_to_mesh(shape, width, self.color); shape_verts } else { - reconstruct_stroke_vertices(&stylus_samples, self.color) + reconstruct_stroke_vertices( + &stylus_samples, + self.color, + width, + self.tool, + self.finalized, + ) }; let mut stroke = Stroke::new(self.id, stylus_samples, vertices, self.color); stroke.shape = self.shape.clone(); + stroke.tool = self.tool; + stroke.width = width; + stroke.finalized = self.finalized; stroke } } +/// Aktuelle Dateiformat-Version von ink-notes (Version 2: Stroke-Treue mit tool, width, finalized). +pub const CURRENT_FORMAT_VERSION: u32 = 2; + +/// Standardwert für ältere Dateien ohne explizite Formatversion (Legacy v0). +pub fn legacy_format_version() -> u32 { + 0 +} + /// Serialisierbare semantische Seitendaten. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PageData { + #[serde(default = "legacy_format_version")] + pub format_version: u32, pub id: u64, pub background: PageBackground, pub strokes: Vec, @@ -98,6 +145,7 @@ pub struct PageData { impl From<&Page> for PageData { fn from(page: &Page) -> Self { Self { + format_version: CURRENT_FORMAT_VERSION, id: page.id, background: page.background, strokes: page.strokes.iter().map(StrokeData::from).collect(), @@ -119,14 +167,33 @@ impl PageData { } } +/// Metadaten einer einzelnen Seite im Manifest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PageMeta { + pub id: u64, + #[serde(default)] + pub background: Option, +} + +impl PageMeta { + pub fn new(id: u64, background: Option) -> Self { + Self { id, background } + } +} + /// Metadaten des gesamten Notizbuchs (`manifest.json`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NotebookManifest { + #[serde(default = "legacy_format_version")] + pub format_version: u32, pub id: String, pub title: String, pub created_at: u64, pub modified_at: u64, pub current_page_index: usize, + #[serde(default)] + pub pages: Vec, + #[serde(default)] pub page_ids: Vec, pub next_stroke_id: u64, pub next_page_id: u64, @@ -139,21 +206,45 @@ impl NotebookManifest { .unwrap_or_default() .as_secs(); Self { + format_version: CURRENT_FORMAT_VERSION, id: id.into(), title: title.into(), created_at: now, modified_at: now, current_page_index: 0, + pages: vec![PageMeta::new(1, Some(PageBackground::Grid5mm))], page_ids: vec![1], next_stroke_id: 1, next_page_id: 2, } } - /// Übernimmt Seitenliste, aktuelle Seite und ID-Zähler aus dem Notizbuch, - /// damit neu angelegte Seiten im Manifest referenziert werden. + pub fn page_ids(&self) -> Vec { + if !self.pages.is_empty() { + self.pages.iter().map(|p| p.id).collect() + } else { + self.page_ids.clone() + } + } + + pub fn background_for_page(&self, page_id: u64) -> Option { + self.pages + .iter() + .find(|p| p.id == page_id) + .and_then(|p| p.background) + } + pub fn sync_from_notebook(&mut self, notebook: &Notebook) { - self.page_ids = notebook.pages.iter().map(|p| p.id).collect(); + self.format_version = CURRENT_FORMAT_VERSION; + self.pages = notebook + .pages + .iter() + .map(|p| PageMeta { + id: p.id, + background: Some(p.background), + }) + .collect(); + self.page_ids = self.pages.iter().map(|p| p.id).collect(); self.current_page_index = notebook.current_page_index; self.next_stroke_id = notebook.next_stroke_id; self.next_page_id = notebook.next_page_id; @@ -172,6 +263,9 @@ pub enum StorageError { InvalidManifest(String), CorruptedPage(u64, String), PageNotFound(u64), + UnsupportedVersion { found: u32, supported: u32 }, + AlreadyLocked(String), + PathTraversal(String), } impl fmt::Display for StorageError { @@ -184,6 +278,12 @@ impl fmt::Display for StorageError { write!(f, "Beschädigte Seite {id}: {msg}") } StorageError::PageNotFound(id) => write!(f, "Seite {id} nicht gefunden"), + StorageError::UnsupportedVersion { found, supported } => write!( + f, + "Notizbuch wurde mit neuerer Version erstellt (Formatversion {found}, unterstützt wird bis {supported})" + ), + StorageError::AlreadyLocked(msg) => write!(f, "Notizbuch ist gesperrt: {msg}"), + StorageError::PathTraversal(msg) => write!(f, "Ungültiger Pfad (Path Traversal): {msg}"), } } } @@ -211,56 +311,81 @@ impl From for StorageError { } /// Rekonstruiert deterministisch die GPU-Triangle-Strip-Vertizes aus Stylus-Samples. -pub fn reconstruct_stroke_vertices(samples: &[StylusSample], color: [f32; 4]) -> Vec { +pub fn reconstruct_stroke_vertices( + samples: &[StylusSample], + color: [f32; 4], + width: f32, + _tool: crate::document::StrokeTool, + finalized: bool, +) -> Vec { if samples.is_empty() { return Vec::new(); } - if samples.len() == 1 { - let pt = samples[0]; - let radius = spline::calculate_radius(pt.pressure, 2.5, 10.0); - let segments = 8; - let mut vertices = Vec::with_capacity((segments + 1) * 2); - for i in 0..=segments { - let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; - let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius); - vertices.push(InkVertex::new(pt.position.x, pt.position.y, color)); - vertices.push(InkVertex::new( - pt.position.x + offset.x, - pt.position.y + offset.y, - color, - )); - } - return vertices; - } + let config = SplineConfig::from_width_units(width); - let mut filter = OneEuroFilter::default(); - let mut control_points: Vec = Vec::with_capacity(samples.len()); - - for s in samples { - let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp); - let pt = ControlPoint::new(fx, fy, fp); - if let Some(last) = control_points.last() { - if (pt.position - last.position).length_squared() < 1.0 { - continue; + if finalized { + let corners = spline::detect_corners(samples, spline::CORNER_ANGLE_THRESHOLD_DEG); + let strip = spline::generate_finalized_triangle_strip(samples, &corners, &config); + strip + .into_iter() + .map(|v| InkVertex::new(v.position[0], v.position[1], color)) + .collect() + } else { + if samples.len() == 1 { + let pt = samples[0]; + let radius = + spline::calculate_radius(pt.pressure, config.min_radius, config.max_radius); + let segments = 8; + let mut vertices = Vec::with_capacity((segments + 1) * 2); + for i in 0..=segments { + let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; + let offset = glam::Vec2::new(angle.cos() * radius, angle.sin() * radius); + vertices.push(InkVertex::new(pt.position.x, pt.position.y, color)); + vertices.push(InkVertex::new( + pt.position.x + offset.x, + pt.position.y + offset.y, + color, + )); } + return vertices; } - control_points.push(pt); - } - let config = SplineConfig { - min_radius: 2.5, - max_radius: 10.0, - samples_per_segment: 16, - }; - let raw_strip = spline::generate_triangle_strip(&control_points, &config); - raw_strip - .into_iter() - .map(|v| InkVertex::new(v.position[0], v.position[1], color)) - .collect() + let mut filter = OneEuroFilter::default(); + let mut control_points: Vec = Vec::with_capacity(samples.len()); + + for s in samples { + let (fx, fy, fp) = filter.filter(s.position.x, s.position.y, s.pressure, s.timestamp); + let pt = ControlPoint::new(fx, fy, fp); + if let Some(last) = control_points.last() { + if (pt.position - last.position).length_squared() < 1.0 { + continue; + } + } + control_points.push(pt); + } + + let raw_strip = spline::generate_triangle_strip(&control_points, &config); + raw_strip + .into_iter() + .map(|v| InkVertex::new(v.position[0], v.position[1], color)) + .collect() + } +} + +/// Erstellt ein Verzeichnis mit gesicherten Rechten (0700 unter Unix). +pub fn create_secure_dir_all(path: &Path) -> io::Result<()> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700)); + } + Ok(()) } /// Schreibt Inhalt atomar auf die Festplatte (über temporäre Datei + `std::fs::rename`). +/// Setzt Berechtigung 0600 unter Unix und synchronisiert das übergeordnete Verzeichnis nach dem Rename. pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let dir = path.parent().ok_or_else(|| { io::Error::new( @@ -268,7 +393,7 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { "Übergeordnetes Verzeichnis nicht gefunden", ) })?; - fs::create_dir_all(dir)?; + create_secure_dir_all(dir)?; let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); let random_suffix: u64 = std::time::SystemTime::now() @@ -278,9 +403,14 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { let temp_filename = format!("{filename}.tmp.{}.{random_suffix}", std::process::id()); let temp_path = dir.join(temp_filename); - // In temporäre Datei schreiben und auf Datenträger flushen + // In temporäre Datei schreiben, Rechte 0600 setzen und auf Datenträger flushen { let mut file = File::create(&temp_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)); + } file.write_all(content)?; file.sync_all()?; } @@ -291,41 +421,144 @@ pub fn atomic_write_file(path: &Path, content: &[u8]) -> Result<(), io::Error> { return Err(e); } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + if let Ok(dir_file) = File::open(dir) { + let _ = dir_file.sync_all(); + } + } + Ok(()) } -/// Serialisiert einen Wert als formatiertes JSON und schreibt ihn atomar. +/// Serialisiert einen Wert als kompaktes JSON (`serde_json::to_vec`) und schreibt ihn atomar. pub fn atomic_write_json(path: &Path, value: &T) -> Result<(), StorageError> { - let bytes = serde_json::to_vec_pretty(value)?; + let bytes = serde_json::to_vec(value)?; atomic_write_file(path, &bytes)?; Ok(()) } /// Speichert das Manifest atomar in `manifest.json`. +/// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben. pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> { let path = notebook_dir.join("manifest.json"); + if path.exists() { + let bak_path = notebook_dir.join("manifest.json.v0.bak"); + if !bak_path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(old_m) = serde_json::from_str::(&content) { + if old_m.format_version == 0 { + let _ = fs::copy(&path, &bak_path); + } + } + } + } + // Rolling Backup: Vor Überschreiben sichern + let rolling_bak = notebook_dir.join("manifest.json.bak"); + let _ = fs::copy(&path, &rolling_bak); + } atomic_write_json(&path, manifest) } +fn load_manifest_from_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut json_str = String::new(); + file.read_to_string(&mut json_str)?; + let mut manifest: NotebookManifest = serde_json::from_str(&json_str)?; + + if manifest.format_version > CURRENT_FORMAT_VERSION { + return Err(StorageError::UnsupportedVersion { + found: manifest.format_version, + supported: CURRENT_FORMAT_VERSION, + }); + } + + if manifest.pages.is_empty() && !manifest.page_ids.is_empty() { + manifest.pages = manifest + .page_ids + .iter() + .map(|&id| PageMeta::new(id, None)) + .collect(); + } + Ok(manifest) +} + /// Lädt das Manifest aus `manifest.json`. +/// Fällt bei Fehlern/Beschädigung automatisch auf `manifest.json.bak` zurück. pub fn load_manifest(notebook_dir: &Path) -> Result { let path = notebook_dir.join("manifest.json"); if !path.exists() { + let bak_path = notebook_dir.join("manifest.json.bak"); + if bak_path.exists() { + eprintln!("[Storage-Warnung] manifest.json fehlt, lade aus manifest.json.bak"); + return load_manifest_from_file(&bak_path); + } return Err(StorageError::InvalidManifest(format!( "Manifestdatei nicht gefunden: {}", path.display() ))); } - let mut file = File::open(&path)?; - let mut json_str = String::new(); - file.read_to_string(&mut json_str)?; - let manifest: NotebookManifest = serde_json::from_str(&json_str)?; - Ok(manifest) + + match load_manifest_from_file(&path) { + Ok(manifest) => Ok(manifest), + Err(e) => { + let bak_path = notebook_dir.join("manifest.json.bak"); + if bak_path.exists() { + eprintln!( + "[Storage-Warnung] manifest.json ist korrupt ({e}). Versuche Wiederherstellung aus manifest.json.bak" + ); + if let Ok(bak_manifest) = load_manifest_from_file(&bak_path) { + return Ok(bak_manifest); + } + } + Err(e) + } + } +} + +/// Verschiebt eine defekte Seitendatei nach `page_.json.corrupt-`. +/// Nur bei Parse-/Schemafehlern aufrufen, nicht bei I/O-Fehlern! +pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result { + let source_path = notebook_dir.join(format!("page_{page_id}.json")); + if !source_path.exists() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Datei existiert nicht", + )); + } + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}")); + if target_path.exists() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos(); + target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}-{nanos}")); + } + fs::rename(&source_path, &target_path)?; + Ok(target_path) } /// Speichert eine Seite atomar in `page_.json`. pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> { let path = notebook_dir.join(format!("page_{}.json", page.id)); + if path.exists() { + let bak_path = notebook_dir.join(format!("page_{}.json.v0.bak", page.id)); + if !bak_path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(old_p) = serde_json::from_str::(&content) { + if old_p.format_version == 0 { + let _ = fs::copy(&path, &bak_path); + } + } + } + } + } atomic_write_json(&path, page) } @@ -340,16 +573,25 @@ pub fn load_page(notebook_dir: &Path, page_id: u64) -> Result(&json_str) { - Ok(data) => Ok(data), + let page_data = match serde_json::from_str::(&json_str) { + Ok(data) => data, Err(e) => { eprintln!( "[Storage-Warnung] Seite {page_id} in '{}' ist beschädigt: {e}", path.display() ); - Err(StorageError::CorruptedPage(page_id, e.to_string())) + return Err(StorageError::CorruptedPage(page_id, e.to_string())); } + }; + + if page_data.format_version > CURRENT_FORMAT_VERSION { + return Err(StorageError::UnsupportedVersion { + found: page_data.format_version, + supported: CURRENT_FORMAT_VERSION, + }); } + + Ok(page_data) } /// Speichert ein gesamtes Notebook synchron auf die Festplatte (z. B. initial oder beim Flush). @@ -358,13 +600,13 @@ pub fn save_notebook( notebook: &Notebook, manifest: &mut NotebookManifest, ) -> Result<(), StorageError> { - fs::create_dir_all(notebook_dir)?; + create_secure_dir_all(notebook_dir)?; manifest.sync_from_notebook(notebook); - // Alle bereits geladenen Seiten speichern (fehlgeschlagene Ersatzseiten niemals) + // Alle bereits geladenen Seiten speichern, schreibgeschützte Seiten nach Ladefehler überspringen! for page in ¬ebook.pages { - if page.is_loaded && !page.load_failed { + if page.is_loaded && page.persist == PersistState::Normal { let page_data = PageData::from(page); save_page(notebook_dir, &page_data)?; } @@ -376,36 +618,84 @@ pub fn save_notebook( /// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading). pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> { + let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600)); let manifest = load_manifest(notebook_dir)?; - if manifest.page_ids.is_empty() { + let page_ids = manifest.page_ids(); + if page_ids.is_empty() { let nb = Notebook::new(); return Ok((nb, manifest)); } - let mut pages = Vec::with_capacity(manifest.page_ids.len()); + let mut pages = Vec::with_capacity(page_ids.len()); + let mut load_warnings = std::collections::HashMap::new(); // Erste Seite sofort laden und initialisieren - let first_id = manifest.page_ids[0]; + let first_id = page_ids[0]; + let first_bg = manifest + .background_for_page(first_id) + .unwrap_or(PageBackground::Grid5mm); + let first_page = match load_page(notebook_dir, first_id) { Ok(data) => data.to_page(), + Err(StorageError::CorruptedPage(id, err_msg)) => { + let q_res = quarantine_corrupted_page(notebook_dir, id); + let q_path = q_res.ok(); + eprintln!( + "[Storage] Erste Seite {id} ist beschädigt ({err_msg}). Quarantänisiert nach: {:?}", + q_path + ); + load_warnings.insert( + id, + PageLoadWarning { + page_id: id, + message: err_msg, + quarantine_path: q_path, + }, + ); + let mut page = Page::new(id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page + } + Err(StorageError::Io(e)) => { + eprintln!( + "[Storage] I/O-Fehler beim Laden von Seite {first_id}: {e}. Seite wird schreibgeschützt." + ); + load_warnings.insert( + first_id, + PageLoadWarning { + page_id: first_id, + message: format!("I/O-Fehler: {e}"), + quarantine_path: None, + }, + ); + let mut page = Page::new(first_id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page + } Err(e) => { - eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}."); - replacement_page_after_load_error(notebook_dir, first_id, &e) + eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); + let mut page = Page::new(first_id, first_bg); + page.persist = PersistState::ReadOnlyAfterLoadError; + page } }; pages.push(first_page); // Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading) - for &page_id in &manifest.page_ids[1..] { - pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm)); + for &page_id in &page_ids[1..] { + let bg = manifest + .background_for_page(page_id) + .unwrap_or(PageBackground::Grid5mm); + pages.push(Page::new_unloaded(page_id, bg)); } - let notebook = Notebook::from_pages( + let mut notebook = Notebook::from_pages( pages, manifest.current_page_index, manifest.next_stroke_id, manifest.next_page_id, ); + notebook.load_warnings = load_warnings; Ok((notebook, manifest)) } @@ -430,79 +720,52 @@ pub fn ensure_page_loaded( notebook.pages[page_idx] = data.to_page(); Ok(()) } + Err(StorageError::CorruptedPage(id, err_msg)) => { + let q_res = quarantine_corrupted_page(notebook_dir, id); + let q_path = q_res.ok(); + eprintln!( + "[Storage] Lazy Loading von Seite {id} fehlgeschlagen ({err_msg}). Quarantäne: {:?}", + q_path + ); + notebook.load_warnings.insert( + id, + PageLoadWarning { + page_id: id, + message: err_msg.clone(), + quarantine_path: q_path, + }, + ); + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; + notebook.pages[page_idx].rebuild_cache_if_dirty(); + Err(StorageError::CorruptedPage(id, err_msg)) + } + Err(StorageError::Io(e)) => { + eprintln!("[Storage] I/O-Fehler beim Lazy Loading von Seite {page_id}: {e}"); + let err_str = e.to_string(); + notebook.load_warnings.insert( + page_id, + PageLoadWarning { + page_id, + message: format!("I/O-Fehler: {err_str}"), + quarantine_path: None, + }, + ); + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; + notebook.pages[page_idx].rebuild_cache_if_dirty(); + Err(StorageError::Io(e)) + } Err(e) => { eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}"); - // Ersatzseite einsetzen, damit die App nicht hängt - let background = notebook.pages[page_idx].background; - let mut page = replacement_page_after_load_error(notebook_dir, page_id, &e); - page.background = background; - page.rebuild_cache_if_dirty(); - notebook.pages[page_idx] = page; + notebook.pages[page_idx].is_loaded = true; + notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError; + notebook.pages[page_idx].rebuild_cache_if_dirty(); Err(e) } } } -/// Erzeugt eine leere Ersatzseite für eine Seite, deren Datei nicht geladen werden konnte. -/// -/// - Fehlende Datei: normale leere Seite (z. B. neu angelegt, aber noch nie gespeichert). -/// - Beschädigtes JSON: Die Datei wird als `page_.json.corrupt-` beiseite gelegt, -/// danach ist die leere Seite normal beschreibbar. -/// - Sonstige Fehler (I/O) oder fehlgeschlagenes Beiseitelegen: Die Ersatzseite wird als -/// `load_failed` markiert und nie gespeichert, damit die Originaldatei erhalten bleibt. -fn replacement_page_after_load_error( - notebook_dir: &Path, - page_id: u64, - err: &StorageError, -) -> Page { - let mut page = Page::new(page_id, PageBackground::Grid5mm); - match err { - StorageError::PageNotFound(_) => {} - StorageError::CorruptedPage(..) => { - let path = notebook_dir.join(format!("page_{page_id}.json")); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let backup = notebook_dir.join(format!("page_{page_id}.json.corrupt-{now}")); - match fs::rename(&path, &backup) { - Ok(()) => eprintln!( - "[Storage] Beschädigte Seite gesichert als '{}'.", - backup.display() - ), - Err(e) => { - eprintln!("[Storage] Konnte beschädigte Seite nicht sichern ({e}). Seite wird nicht gespeichert."); - page.load_failed = true; - } - } - } - _ => page.load_failed = true, - } - page -} - -/// Lädt alle noch nicht geladenen Seiten ohne Ersatzseiten (z. B. für den PDF-Export). -/// Schlägt fehl, sobald eine Seite nicht vollständig verfügbar ist. -pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<(), StorageError> { - for page in &mut notebook.pages { - if page.load_failed { - return Err(StorageError::CorruptedPage( - page.id, - "Seite konnte nicht geladen werden".to_string(), - )); - } - if !page.is_loaded { - *page = match load_page(notebook_dir, page.id) { - Ok(data) => data.to_page(), - // Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite - Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background), - Err(e) => return Err(e), - }; - } - } - Ok(()) -} - /// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation). pub fn default_storage_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { @@ -522,6 +785,14 @@ pub fn default_storage_dir() -> PathBuf { PathBuf::from("./data/notebooks") } +/// Status eines Notizbuchs in der Dokumentenliste. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum NotebookStatus { + #[default] + Normal, + Damaged, +} + /// Zusammenfassende Metadaten eines Notizbuchs für die Dokumentenliste. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NotebookSummary { @@ -529,9 +800,91 @@ pub struct NotebookSummary { pub title: String, pub page_count: usize, pub modified_at: u64, + #[serde(default)] + pub status: NotebookStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Dateibasierter Lock für ein geöffnetes Notizbuch, um parallele Schreibvorgänge mehrerer Instanzen zu verhindern. +#[derive(Debug)] +pub struct NotebookLock { + _file: File, + pub path: PathBuf, +} + +impl NotebookLock { + /// Versucht, einen exklusiven, nicht-blockierenden Lock auf `notebook_dir/.lock` zu erwerben. + pub fn acquire(notebook_dir: &Path) -> Result { + create_secure_dir_all(notebook_dir)?; + let lock_path = notebook_dir.join(".lock"); + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(StorageError::Io)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&lock_path, fs::Permissions::from_mode(0o600)); + } + + use fs2::FileExt; + match file.try_lock_exclusive() { + Ok(()) => Ok(Self { + _file: file, + path: lock_path, + }), + Err(e) => { + let kind = e.kind(); + if kind == io::ErrorKind::WouldBlock || e.raw_os_error() == Some(11) { + Err(StorageError::AlreadyLocked(format!( + "Notizbuch '{}' wird bereits von einer anderen Instanz verwendet", + notebook_dir.display() + ))) + } else { + Err(StorageError::Io(e)) + } + } + } + } +} + +/// Räumt verwaiste temporäre Dateien (*.tmp.*) auf, die älter als `max_age` sind. +pub fn cleanup_orphaned_temp_files( + notebook_dir: &Path, + max_age: std::time::Duration, +) -> io::Result { + let mut cleaned = 0; + if let Ok(entries) = fs::read_dir(notebook_dir) { + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.contains(".tmp.") { + if let Ok(metadata) = entry.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(age) = now.duration_since(modified) { + if age >= max_age && fs::remove_file(&path).is_ok() { + cleaned += 1; + } + } + } + } + } + } + } + } + } + Ok(cleaned) } /// Durchsucht das Basis-Verzeichnis nach allen vorhandenen Notizbüchern. +/// Beschädigte Notizbücher werden als `NotebookStatus::Damaged` aufgeführt. `.trash` wird ignoriert. pub fn list_notebooks(base_dir: &Path) -> Vec { let mut list = Vec::new(); let Ok(entries) = fs::read_dir(base_dir) else { @@ -541,16 +894,66 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { + let dir_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + + // Versteckte Ordner (wie .trash, .recovery) ignorieren + if dir_name.starts_with('.') { + continue; + } + let manifest_path = path.join("manifest.json"); if manifest_path.exists() { - if let Ok(manifest) = load_manifest(&path) { - list.push(NotebookSummary { - id: manifest.id, - title: manifest.title, - page_count: manifest.page_ids.len(), - modified_at: manifest.modified_at, - }); + match load_manifest(&path) { + Ok(manifest) => { + let page_count = manifest.page_ids().len(); + list.push(NotebookSummary { + id: manifest.id, + title: manifest.title, + page_count, + modified_at: manifest.modified_at, + status: NotebookStatus::Normal, + error: None, + }); + } + Err(err) => { + let modified_at = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + list.push(NotebookSummary { + id: dir_name.to_string(), + title: format!("⚠️ {dir_name} (Beschädigt)"), + page_count: 0, + modified_at, + status: NotebookStatus::Damaged, + error: Some(err.to_string()), + }); + } } + } else { + let modified_at = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + list.push(NotebookSummary { + id: dir_name.to_string(), + title: format!("⚠️ {dir_name} (Beschädigt - kein Manifest)"), + page_count: 0, + modified_at, + status: NotebookStatus::Damaged, + error: Some("Keine manifest.json vorhanden".to_string()), + }); } } } @@ -561,11 +964,12 @@ pub fn list_notebooks(base_dir: &Path) -> Vec { } /// Erstellt ein neues Notizbuch mit dem angegebenen Titel und initialisiert Seite 1. +/// Verhindert Kollisionen durch automatischen Suffix-Inkrement. pub fn create_new_notebook( base_dir: &Path, title: &str, ) -> Result<(Notebook, NotebookManifest, PathBuf), StorageError> { - fs::create_dir_all(base_dir)?; + create_secure_dir_all(base_dir)?; let now_ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -586,14 +990,28 @@ pub fn create_new_notebook( .collect(); let clean_slug = clean_slug.trim_matches('_'); - let id = if clean_slug.is_empty() { + let base_id = if clean_slug.is_empty() { format!("nb_{now_ts}") } else { format!("{clean_slug}_{now_ts}") }; - let notebook_dir = base_dir.join(&id); - fs::create_dir_all(¬ebook_dir)?; + let mut id = base_id.clone(); + let mut notebook_dir = base_dir.join(&id); + let mut counter = 1; + while notebook_dir.exists() { + id = format!("{base_id}_{counter}"); + notebook_dir = base_dir.join(&id); + counter += 1; + if counter > 100 { + return Err(StorageError::Io(io::Error::new( + io::ErrorKind::AlreadyExists, + "Konnte nach 100 Versuchen kein eindeutiges Notizbuch-Verzeichnis finden", + ))); + } + } + + create_secure_dir_all(¬ebook_dir)?; let notebook = Notebook::new(); let mut manifest = NotebookManifest::new(&id, title); @@ -618,10 +1036,194 @@ pub fn rename_notebook( Ok(()) } -/// Löscht ein Notizbuch vollständig vom Datenträger. -pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> { - if notebook_dir.exists() { - fs::remove_dir_all(notebook_dir)?; +/// Verschiebt ein Notizbuch sicher in den Papierkorb (`/.trash/_`). +/// Prüft, dass der Pfad direkt unter `base_dir` liegt, kein Symlink ist und `manifest.json` enthält. +pub fn delete_notebook(notebook_dir: &Path, base_dir: &Path) -> Result { + if !notebook_dir.exists() { + return Err(StorageError::InvalidManifest(format!( + "Verzeichnis '{}' existiert nicht", + notebook_dir.display() + ))); + } + + // Prüfen auf Symlink + let symlink_meta = fs::symlink_metadata(notebook_dir).map_err(StorageError::Io)?; + if symlink_meta.file_type().is_symlink() { + return Err(StorageError::InvalidManifest(format!( + "Pfad '{}' ist ein Symlink und darf nicht gelöscht werden", + notebook_dir.display() + ))); + } + + let canon_base = base_dir.canonicalize().map_err(StorageError::Io)?; + let canon_target = notebook_dir.canonicalize().map_err(StorageError::Io)?; + + // Prüfen, dass der Pfad direkt unter base_dir liegt + if canon_target.parent() != Some(&canon_base) { + return Err(StorageError::PathTraversal(format!( + "Pfad '{}' liegt nicht direkt unter Basis-Verzeichnis '{}'", + notebook_dir.display(), + base_dir.display() + ))); + } + + // Prüfen, dass manifest.json vorhanden ist + if !canon_target.join("manifest.json").exists() { + return Err(StorageError::InvalidManifest(format!( + "Verzeichnis '{}' enthält kein manifest.json", + notebook_dir.display() + ))); + } + + let notebook_id = canon_target + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + let trash_dir = canon_base.join(".trash"); + create_secure_dir_all(&trash_dir)?; + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let trash_target = trash_dir.join(format!("{notebook_id}_{ts}")); + + fs::rename(&canon_target, &trash_target)?; + + Ok(trash_target) +} + +/// Stellt ein Notizbuch aus dem Papierkorb wieder her (`/.trash/` -> `/`). +pub fn restore_notebook_from_trash( + base_dir: &Path, + trash_target: &Path, +) -> Result { + if !trash_target.exists() { + return Err(StorageError::Io(io::Error::new( + io::ErrorKind::NotFound, + format!( + "Papierkorb-Eintrag '{}' existiert nicht", + trash_target.display() + ), + ))); + } + + let canon_base = base_dir.canonicalize().map_err(StorageError::Io)?; + let canon_trash = trash_target.canonicalize().map_err(StorageError::Io)?; + + let expected_trash_dir = canon_base.join(".trash"); + if !expected_trash_dir.exists() { + return Err(StorageError::InvalidManifest( + "Papierkorb-Verzeichnis existiert nicht".to_string(), + )); + } + let canon_trash_base = expected_trash_dir + .canonicalize() + .map_err(StorageError::Io)?; + + if canon_trash.parent() != Some(&canon_trash_base) { + return Err(StorageError::PathTraversal(format!( + "Papierkorb-Pfad '{}' liegt nicht im Papierkorb", + trash_target.display() + ))); + } + + let manifest_path = canon_trash.join("manifest.json"); + let target_id = if manifest_path.exists() { + if let Ok(content) = fs::read_to_string(&manifest_path) { + serde_json::from_str::(&content) + .map(|m| m.id) + .unwrap_or_else(|_| { + canon_trash + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("restored") + .to_string() + }) + } else { + "restored".to_string() + } + } else { + "restored".to_string() + }; + + let mut restored_dir = canon_base.join(&target_id); + if restored_dir.exists() { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + restored_dir = canon_base.join(format!("{target_id}_restored_{ts}")); + } + + fs::rename(&canon_trash, &restored_dir)?; + Ok(restored_dir) +} + +/// Stellt ein beschädigtes Manifest aus der Rolling-Backup-Datei (`manifest.json.bak`) wieder her. +pub fn restore_manifest_from_backup(notebook_dir: &Path) -> Result<(), StorageError> { + let bak_path = notebook_dir.join("manifest.json.bak"); + let manifest_path = notebook_dir.join("manifest.json"); + if bak_path.exists() { + fs::copy(&bak_path, &manifest_path).map_err(StorageError::Io)?; + Ok(()) + } else { + Err(StorageError::InvalidManifest( + "Keine Sicherungskopie (manifest.json.bak) vorhanden".to_string(), + )) + } +} + +/// Entfernt Einträge aus `.trash`, die älter als `max_age` sind. +pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Result { + let trash_dir = base_dir.join(".trash"); + if !trash_dir.exists() { + return Ok(0); + } + let mut removed = 0; + let now = std::time::SystemTime::now(); + if let Ok(entries) = fs::read_dir(&trash_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(metadata) = entry.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(age) = now.duration_since(modified) { + if age >= max_age { + if path.is_dir() { + if fs::remove_dir_all(&path).is_ok() { + removed += 1; + } + } else if fs::remove_file(&path).is_ok() { + removed += 1; + } + } + } + } + } + } + } + Ok(removed) +} + +/// Lädt alle noch nicht geladenen Seiten ohne Ersatzseiten (z. B. für den PDF-Export). +/// Schlägt fehl, sobald eine Seite nicht vollständig verfügbar ist. +pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<(), StorageError> { + for page in &mut notebook.pages { + if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError { + return Err(StorageError::CorruptedPage( + page.id, + "Seite konnte nicht geladen werden".to_string(), + )); + } + if !page.is_loaded { + *page = match load_page(notebook_dir, page.id) { + Ok(data) => data.to_page(), + // Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite + Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background), + Err(e) => return Err(e), + }; + } } Ok(()) } @@ -630,7 +1232,8 @@ pub fn delete_notebook(notebook_dir: &Path) -> Result<(), StorageError> { mod tests { use super::*; use crate::autosave::AutosaveController; - use std::time::Duration; + use std::sync::Arc; + use std::time::{Duration, Instant}; #[test] fn test_atomic_save_and_reload() { @@ -740,16 +1343,28 @@ mod tests { #[test] fn test_debounce_timing() { + use crate::autosave::tests::MockClock; + let temp_dir = tempfile::tempdir().expect("Konnte temporäres Verzeichnis nicht anlegen"); let notebook_dir = temp_dir.path().to_path_buf(); + let clock = Arc::new(MockClock::new(Instant::now())); let debounce_duration = Duration::from_millis(150); - let mut controller = AutosaveController::new(notebook_dir.clone(), debounce_duration); + let max_wait = Duration::from_secs(10); + let store = Arc::new(crate::autosave::DiskPageStore); + + let mut controller = AutosaveController::with_options( + notebook_dir.clone(), + debounce_duration, + max_wait, + store, + Arc::clone(&clock) as Arc, + ); let manifest = NotebookManifest::new("test_nb", "Test Notizbuch"); let mut page = Page::new(1, PageBackground::Grid5mm); - // 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms, deutlich kürzer als 150 ms Debounce) + // 10 schnelle aufeinanderfolgende Änderungen (alle 5 ms mit MockClock, keine Wartezeit) for i in 0..10 { let sample = StylusSample { position: glam::Vec2::new(10.0 + i as f32, 20.0 + i as f32), @@ -765,23 +1380,24 @@ mod tests { page.add_stroke(stroke); controller.mark_page_dirty(&page, &manifest); + clock.advance(Duration::from_millis(5)); controller.update(); - std::thread::sleep(Duration::from_millis(5)); } - // Während der schnellen Änderungen (ca. 50 ms vergangen) darf noch KEIN Schreibvorgang erfolgt sein! + // Während der schnellen Änderungen darf noch KEIN Schreibvorgang erfolgt sein! assert_eq!( controller.total_page_writes(), 0, "Vor Ablauf des Debounce-Fensters darf noch kein Disk-Write stattgefunden haben" ); - // Jetzt warten wir, bis das Debounce-Fenster abgelaufen ist (> 150 ms) - std::thread::sleep(Duration::from_millis(180)); + // Jetzt drehen wir die Mock-Uhr um 180 ms vor (> 150 ms Debounce) + clock.advance(Duration::from_millis(180)); controller.update(); // Flush abwarten - controller.flush().expect("Flush muss gelingen"); + let flush_res = controller.flush(); + assert!(flush_res.is_ok(), "Flush muss erfolgreich sein"); // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben assert_eq!( @@ -794,7 +1410,8 @@ mod tests { let loaded = load_page(¬ebook_dir, 1).expect("Seite muss lesbar sein"); assert_eq!(loaded.strokes.len(), 10); - controller.shutdown(); + let shutdown_res = controller.shutdown(); + assert!(shutdown_res.is_ok()); } #[test] @@ -835,7 +1452,7 @@ mod tests { .any(|s| s.id == m1.id && s.title == "Höhere Mathematik")); // 5. Löschen - delete_notebook(&dir2).expect("Löschen muss klappen"); + delete_notebook(&dir2, base_dir).expect("Löschen muss klappen"); assert!(!dir2.exists()); let final_summaries = list_notebooks(base_dir); @@ -843,9 +1460,651 @@ mod tests { assert_eq!(final_summaries[0].id, m1.id); } + #[test] + fn test_load_corrupted_page_quarantines_and_does_not_overwrite() { + use crate::document::PersistState; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_corrupt", "Test Corrupt"); + save_manifest(path, &manifest).unwrap(); + + let corrupted_content = br#"{"id": 1, "background": "Grid5mm", "strokes": INVALID_JSON"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, corrupted_content).unwrap(); + + // Load notebook + let (nb, mut m) = + load_notebook(path).expect("load_notebook should succeed with fallback page"); + + // (1) Check that page_1.json was quarantined + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + let corrupt_entry = dir_entries + .iter() + .find(|name| name.starts_with("page_1.json.corrupt-")); + assert!( + corrupt_entry.is_some(), + "Corrupt file must be renamed to page_1.json.corrupt-, found entries: {dir_entries:?}" + ); + let corrupt_file = path.join(corrupt_entry.unwrap()); + assert_eq!( + fs::read(&corrupt_file).unwrap(), + corrupted_content, + "Quarantine file must contain original corrupt content" + ); + + // (2) The replacement page must be in ReadOnlyAfterLoadError state + assert_eq!( + nb.pages[0].persist, + PersistState::ReadOnlyAfterLoadError, + "Page must be marked ReadOnlyAfterLoadError" + ); + + // (3) Calling save_notebook must NOT overwrite or create a blank page_1.json! + save_notebook(path, &nb, &mut m).unwrap(); + + assert!( + !page_file.exists(), + "save_notebook must NOT recreate page_1.json from an unedited/locked replacement page" + ); + assert!( + corrupt_file.exists(), + "Quarantined file must remain intact after save_notebook" + ); + } + + #[test] + #[cfg(unix)] + fn test_io_error_does_not_quarantine_and_does_not_overwrite() { + use crate::document::PersistState; + use std::os::unix::fs::PermissionsExt; + + // Skip test if running as root + if std::env::var("USER").unwrap_or_default() == "root" { + return; + } + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_io", "Test IO"); + save_manifest(path, &manifest).unwrap(); + + let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, original_content).unwrap(); + + // Make file unreadable + fs::set_permissions(&page_file, fs::Permissions::from_mode(0o000)).unwrap(); + + let (nb, mut m) = + load_notebook(path).expect("load_notebook should succeed with fallback page"); + + // Restore permissions for inspection + fs::set_permissions(&page_file, fs::Permissions::from_mode(0o600)).unwrap(); + + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + let corrupt_entry = dir_entries.iter().find(|name| name.contains("corrupt")); + assert!( + corrupt_entry.is_none(), + "I/O error must NOT trigger quarantine, found: {corrupt_entry:?}" + ); + + assert_eq!( + nb.pages[0].persist, + PersistState::ReadOnlyAfterLoadError, + "Page must be marked ReadOnlyAfterLoadError on I/O error" + ); + + save_notebook(path, &nb, &mut m).unwrap(); + assert_eq!( + fs::read(&page_file).unwrap(), + original_content, + "Original file content must not be overwritten" + ); + } + + #[test] + fn test_edit_replacement_saves_only_after_unlock() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let manifest = NotebookManifest::new("nb_unlock", "Test Unlock"); + save_manifest(path, &manifest).unwrap(); + + let corrupted_content = br#"CORRUPTED_JSON_DATA"#; + let page_file = path.join("page_1.json"); + fs::write(&page_file, corrupted_content).unwrap(); + + let (mut nb, mut m) = load_notebook(path).expect("load_notebook"); + + save_notebook(path, &nb, &mut m).unwrap(); + assert!(!page_file.exists()); + + // Now user explicitly acknowledges and unlocks + nb.pages[0].unlock_after_load_error(); + nb.pages[0].add_stroke(Stroke::new(1, vec![], vec![], [1.0, 0.0, 0.0, 1.0])); + + save_notebook(path, &nb, &mut m).unwrap(); + assert!( + page_file.exists(), + "Page must be saved after explicit unlock and edit" + ); + + let dir_entries: Vec = fs::read_dir(path) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!(dir_entries + .iter() + .any(|name| name.starts_with("page_1.json.corrupt-"))); + } + + #[test] + fn test_fixture_legacy_v0_loads_and_migrates_with_backup() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_manifest = root_dir.join("tests/fixtures/legacy_v0_manifest.json"); + let fixture_page = root_dir.join("tests/fixtures/legacy_v0_page_1.json"); + + fs::copy(&fixture_manifest, path.join("manifest.json")).unwrap(); + fs::copy(&fixture_page, path.join("page_1.json")).unwrap(); + + let (nb, mut m) = load_notebook(path).expect("Legacy v0 notebook must load"); + assert_eq!(m.format_version, 0, "Legacy manifest has format_version 0"); + assert_eq!(nb.pages.len(), 1); + assert_eq!(nb.pages[0].background, PageBackground::DotGrid); + assert_eq!(nb.pages[0].strokes.len(), 1); + + // Saving must upgrade to format_version 1 and create .v0.bak files + save_notebook(path, &nb, &mut m).expect("Save must succeed"); + + assert!( + path.join("manifest.json.v0.bak").exists(), + "manifest.json.v0.bak must be created before upgrading legacy format" + ); + assert!( + path.join("page_1.json.v0.bak").exists(), + "page_1.json.v0.bak must be created before upgrading legacy format" + ); + + // Reload to verify format_version is now CURRENT_FORMAT_VERSION (2) + let (_nb_upgraded, m_upgraded) = load_notebook(path).expect("Upgraded notebook must load"); + assert_eq!(m_upgraded.format_version, CURRENT_FORMAT_VERSION); + let p1_upgraded = load_page(path, 1).expect("Upgraded page must load"); + assert_eq!(p1_upgraded.format_version, CURRENT_FORMAT_VERSION); + } + + #[test] + fn test_fixture_unknown_fields_load_successfully() { + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_page = root_dir.join("tests/fixtures/unknown_fields_page_1.json"); + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + fs::copy(&fixture_page, path.join("page_1.json")).unwrap(); + + let page_data = load_page(path, 1).expect("Unknown fields must be tolerated"); + assert_eq!(page_data.background, PageBackground::Lined); + assert_eq!(page_data.format_version, 1); + } + + #[test] + fn test_fixture_future_version_is_rejected() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let path = temp_dir.path(); + + let root_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_manifest = root_dir.join("tests/fixtures/future_v99_manifest.json"); + let fixture_page = root_dir.join("tests/fixtures/future_v99_page_1.json"); + + let dest_manifest = path.join("manifest.json"); + let dest_page = path.join("page_1.json"); + fs::copy(&fixture_manifest, &dest_manifest).unwrap(); + fs::copy(&fixture_page, &dest_page).unwrap(); + + let orig_manifest_content = fs::read(&dest_manifest).unwrap(); + let orig_page_content = fs::read(&dest_page).unwrap(); + + // Loading must fail with UnsupportedVersion + let load_res = load_notebook(path); + assert!(load_res.is_err(), "Future version must be rejected"); + match load_res.unwrap_err() { + StorageError::UnsupportedVersion { found, supported } => { + assert_eq!(found, 99); + assert_eq!(supported, CURRENT_FORMAT_VERSION); + } + other => panic!("Expected UnsupportedVersion, got: {other:?}"), + } + + // Files on disk must remain completely untouched + assert_eq!(fs::read(&dest_manifest).unwrap(), orig_manifest_content); + assert_eq!(fs::read(&dest_page).unwrap(), orig_page_content); + } + + #[test] + #[cfg(unix)] + fn test_permissions_0600_0700() { + use std::os::unix::fs::PermissionsExt; + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + let (_nb, _m, nb_dir) = create_new_notebook(base_dir, "PermTest").unwrap(); + let dir_mode = fs::metadata(&nb_dir).unwrap().permissions().mode() & 0o777; + assert_eq!( + dir_mode, 0o700, + "Notizbuch-Verzeichnis muss 0700 Rechte haben" + ); + + let manifest_path = nb_dir.join("manifest.json"); + let manifest_mode = fs::metadata(&manifest_path).unwrap().permissions().mode() & 0o777; + assert_eq!(manifest_mode, 0o600, "manifest.json muss 0600 Rechte haben"); + + let page_path = nb_dir.join("page_1.json"); + let page_mode = fs::metadata(&page_path).unwrap().permissions().mode() & 0o777; + assert_eq!(page_mode, 0o600, "page_1.json muss 0600 Rechte haben"); + } + + #[test] + fn test_cleanup_orphaned_temp_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let old_temp = nb_dir.join("manifest.json.tmp.111.222"); + let new_temp = nb_dir.join("manifest.json.tmp.333.444"); + fs::write(&old_temp, b"old").unwrap(); + fs::write(&new_temp, b"new").unwrap(); + + // 1 Stunde alt simulieren + let cleaned = cleanup_orphaned_temp_files(nb_dir, Duration::ZERO).unwrap(); + assert_eq!( + cleaned, 2, + "Beide temporären Dateien müssen aufgeräumt werden" + ); + assert!(!old_temp.exists()); + assert!(!new_temp.exists()); + } + + #[test] + fn test_corrupted_manifest_fallback_to_bak() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let valid_manifest = NotebookManifest::new("nb_test", "Backup Test"); + let bak_path = nb_dir.join("manifest.json.bak"); + atomic_write_json(&bak_path, &valid_manifest).unwrap(); + + let broken_manifest_path = nb_dir.join("manifest.json"); + fs::write(&broken_manifest_path, b"{ broken json ... ").unwrap(); + + let loaded = load_manifest(nb_dir).expect("Muss aus .bak geladen werden"); + assert_eq!(loaded.id, "nb_test"); + assert_eq!(loaded.title, "Backup Test"); + } + + #[test] + fn test_create_notebook_collision_generates_new_id() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + let (_nb1, m1, dir1) = create_new_notebook(base_dir, "Notiz").unwrap(); + assert!(dir1.exists()); + + // Erzwinge absichtlich existierendes Verzeichnis mit demselben ID-Schema + let (_nb2, m2, dir2) = create_new_notebook(base_dir, "Notiz").unwrap(); + assert!(dir2.exists()); + assert_ne!(m1.id, m2.id, "IDs müssen bei Kollision eindeutig sein"); + assert_ne!(dir1, dir2, "Verzeichnisse dürfen sich nicht überschreiben"); + } + + #[test] + fn test_delete_notebook_path_traversal_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path().join("storage"); + let outside_dir = temp_dir.path().join("secret"); + fs::create_dir_all(&base_dir).unwrap(); + fs::create_dir_all(&outside_dir).unwrap(); + + let malicious_path = base_dir.join("../secret"); + let res = delete_notebook(&malicious_path, &base_dir); + assert!(res.is_err(), "Path-Traversal muss abgewehrt werden"); + match res.unwrap_err() { + StorageError::PathTraversal(_) => {} + other => panic!("Erwartet PathTraversal Fehler, erhalten: {other:?}"), + } + assert!( + outside_dir.exists(), + "Verzeichnis außerhalb darf nicht berührt werden" + ); + } + + #[test] + fn test_delete_notebook_moves_to_trash_and_can_be_restored() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let (_nb, m, nb_dir) = create_new_notebook(base_dir, "TrashTest").unwrap(); + assert!(nb_dir.exists()); + + let trash_target = + delete_notebook(&nb_dir, base_dir).expect("Löschen muss erfolgreich sein"); + assert!( + !nb_dir.exists(), + "Originalverzeichnis muss verschwunden sein" + ); + assert!(trash_target.exists(), "Papierkorb-Eintrag muss existieren"); + + let trash_dir = base_dir.join(".trash"); + assert!(trash_dir.exists(), ".trash Ordner muss existieren"); + let entries: Vec<_> = fs::read_dir(&trash_dir).unwrap().flatten().collect(); + assert_eq!( + entries.len(), + 1, + "Gelöschtes Notizbuch muss im Papierkorb liegen" + ); + assert!(entries[0].file_name().to_string_lossy().starts_with(&m.id)); + + // Wiederherstellung prüfen + let restored_dir = restore_notebook_from_trash(base_dir, &trash_target) + .expect("Wiederherstellung aus Papierkorb muss erfolgreich sein"); + assert!( + restored_dir.exists(), + "Wiederhergestelltes Verzeichnis muss existieren" + ); + assert!(restored_dir.join("manifest.json").exists()); + assert!( + !trash_target.exists(), + "Eintrag darf nicht mehr im Papierkorb liegen" + ); + } + + #[test] + fn test_delete_notebook_missing_manifest_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let no_manifest_dir = base_dir.join("no_manifest"); + fs::create_dir_all(&no_manifest_dir).unwrap(); + + let res = delete_notebook(&no_manifest_dir, base_dir); + assert!( + res.is_err(), + "Löschen ohne manifest.json muss abgewiesen werden" + ); + match res.unwrap_err() { + StorageError::InvalidManifest(_) => {} + other => panic!("Erwartet InvalidManifest Fehler, erhalten: {other:?}"), + } + assert!( + no_manifest_dir.exists(), + "Verzeichnis ohne manifest.json darf nicht gelöscht werden" + ); + } + + #[cfg(unix)] + #[test] + fn test_delete_notebook_symlink_rejected() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + let (_nb, _m, real_dir) = create_new_notebook(base_dir, "RealNB").unwrap(); + + let symlink_path = base_dir.join("symlink_nb"); + std::os::unix::fs::symlink(&real_dir, &symlink_path).unwrap(); + + let res = delete_notebook(&symlink_path, base_dir); + assert!(res.is_err(), "Löschen von Symlinks muss abgewiesen werden"); + match res.unwrap_err() { + StorageError::InvalidManifest(_) => {} + other => panic!("Erwartet InvalidManifest Fehler, erhalten: {other:?}"), + } + assert!( + real_dir.exists(), + "Reales Notizbuch darf nicht angetastet werden" + ); + assert!( + symlink_path.exists(), + "Symlink darf nicht angetastet werden" + ); + } + + #[test] + fn test_list_notebooks_includes_damaged_and_ignores_trash() { + let temp_dir = tempfile::tempdir().unwrap(); + let base_dir = temp_dir.path(); + + // 1. Intaktes Notizbuch + let (_nb, _m, _dir) = create_new_notebook(base_dir, "Intakt").unwrap(); + + // 2. Beschädigtes Notizbuch (defektes manifest.json) + let damaged_dir = base_dir.join("damaged_nb"); + fs::create_dir_all(&damaged_dir).unwrap(); + fs::write(damaged_dir.join("manifest.json"), b"corrupt").unwrap(); + + // 3. .trash Ordner + let trash_dir = base_dir.join(".trash"); + fs::create_dir_all(&trash_dir).unwrap(); + + let summaries = list_notebooks(base_dir); + // .trash darf nicht auftauchen! + assert!(!summaries.iter().any(|s| s.id == ".trash")); + + // damaged_nb MUSS auftauchen mit Status Damaged + let damaged_summary = summaries.iter().find(|s| s.id == "damaged_nb"); + assert!( + damaged_summary.is_some(), + "Beschädigtes Notizbuch muss aufgelistet werden" + ); + assert_eq!(damaged_summary.unwrap().status, NotebookStatus::Damaged); + } + + #[test] + fn test_notebook_instance_lock_prevents_concurrent_open() { + let temp_dir = tempfile::tempdir().unwrap(); + let nb_dir = temp_dir.path(); + + let lock1 = NotebookLock::acquire(nb_dir).expect("Erster Lock muss gelingen"); + let lock2 = NotebookLock::acquire(nb_dir); + assert!( + lock2.is_err(), + "Zweiter Lock auf selbes Verzeichnis muss scheitern" + ); + match lock2.unwrap_err() { + StorageError::AlreadyLocked(_) => {} + other => panic!("Erwartet AlreadyLocked, erhalten: {other:?}"), + } + + // Lock 1 freigeben + drop(lock1); + let lock3 = NotebookLock::acquire(nb_dir); + assert!( + lock3.is_ok(), + "Nach Drop muss Lock wieder erworben werden können" + ); + } + + #[test] + fn test_round_trip_fidelity() { + use crate::geometry::RecognizedShape; + use crate::spline::{finalize_stroke_with_config, SplineConfig}; + + let mut page = Page::new(1, PageBackground::Blank); + + // 1. Dünner Stift (0.3 mm = 3.0 Einheiten) + let samples1 = vec![ + StylusSample { + position: glam::Vec2::new(100.0, 100.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(150.0, 100.0), + pressure: 0.6, + timestamp: 0.1, + }, + StylusSample { + position: glam::Vec2::new(200.0, 150.0), + pressure: 0.7, + timestamp: 0.2, + }, + ]; + let cfg_thin = SplineConfig { + min_radius: 0.75, + max_radius: 2.25, + samples_per_segment: 16, + }; + let stroke_thin = + finalize_stroke_with_config(1, &samples1, [0.0, 0.0, 0.0, 1.0], &cfg_thin); + page.add_stroke(stroke_thin); + + // 2. Breiter Stift (1.5 mm = 15.0 Einheiten) + let samples2 = vec![ + StylusSample { + position: glam::Vec2::new(200.0, 200.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(250.0, 200.0), + pressure: 0.5, + timestamp: 0.1, + }, + StylusSample { + position: glam::Vec2::new(300.0, 250.0), + pressure: 0.5, + timestamp: 0.2, + }, + ]; + let cfg_thick = SplineConfig { + min_radius: 3.75, + max_radius: 11.25, + samples_per_segment: 16, + }; + let stroke_thick = + finalize_stroke_with_config(2, &samples2, [0.1, 0.2, 0.8, 1.0], &cfg_thick); + page.add_stroke(stroke_thick); + + // 3. Textmarker (6.0 mm = 60.0 Einheiten) + let samples3 = vec![ + StylusSample { + position: glam::Vec2::new(100.0, 300.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(200.0, 300.0), + pressure: 0.5, + timestamp: 0.1, + }, + StylusSample { + position: glam::Vec2::new(300.0, 300.0), + pressure: 0.5, + timestamp: 0.2, + }, + ]; + let cfg_highlighter = SplineConfig { + min_radius: 15.0, + max_radius: 45.0, + samples_per_segment: 16, + }; + let stroke_highlighter = + finalize_stroke_with_config(3, &samples3, [1.0, 1.0, 0.0, 0.4], &cfg_highlighter); + page.add_stroke(stroke_highlighter); + + // 4. Erkannte Form (Kreis mit Breite 10.0 Einheiten) + let circle = RecognizedShape::Circle { + center: glam::Vec2::new(500.0, 500.0), + radius: 50.0, + }; + let circle_stroke = crate::geometry::shape_to_stroke(circle, 4, [0.0, 0.5, 0.0, 1.0], 10.0); + page.add_stroke(circle_stroke); + + // Serialisieren & Deserialisieren + let page_data = PageData::from(&page); + let restored_page = page_data.to_page(); + + assert_eq!(page.strokes.len(), restored_page.strokes.len()); + for (i, (orig, restored)) in page + .strokes + .iter() + .zip(restored_page.strokes.iter()) + .enumerate() + { + assert_eq!( + orig.vertices.len(), + restored.vertices.len(), + "Strich {i} Vertices-Anzahl weicht ab: orig {}, restored {}", + orig.vertices.len(), + restored.vertices.len() + ); + for (v_idx, (v1, v2)) in orig + .vertices + .iter() + .zip(restored.vertices.iter()) + .enumerate() + { + assert!( + (v1.position[0] - v2.position[0]).abs() < 1e-3 + && (v1.position[1] - v2.position[1]).abs() < 1e-3, + "Strich {i} Vertex {v_idx} Position weicht ab: orig {:?}, restored {:?}", + v1.position, + v2.position + ); + } + } + } + + #[test] + fn test_compact_json_size_reduction() { + use crate::spline::{finalize_stroke_with_config, SplineConfig}; + let mut page = Page::new(1, PageBackground::Blank); + let samples: Vec = (0..500) + .map(|i| StylusSample { + position: glam::Vec2::new(i as f32, (i * 2) as f32), + pressure: 0.5, + timestamp: i as f64 * 0.01, + }) + .collect(); + let stroke = finalize_stroke_with_config( + 1, + &samples, + [0.0, 0.0, 0.0, 1.0], + &SplineConfig::default(), + ); + page.add_stroke(stroke); + + let page_data = PageData::from(&page); + let pretty_bytes = serde_json::to_vec_pretty(&page_data).unwrap(); + let compact_bytes = serde_json::to_vec(&page_data).unwrap(); + + println!( + "\n[Dateigrößen-Vergleich] Pretty: {} Bytes | Kompakt: {} Bytes | Ersparnis: {:.1}%", + pretty_bytes.len(), + compact_bytes.len(), + (1.0 - compact_bytes.len() as f64 / pretty_bytes.len() as f64) * 100.0 + ); + + assert!( + compact_bytes.len() < pretty_bytes.len(), + "Kompaktes JSON muss kleiner sein" + ); + assert!( + compact_bytes.len() * 2 <= pretty_bytes.len(), + "Kompaktes JSON spart mind. 50% Whitespace" + ); + } + /// Hilfsfunktion: Seite mit einem einzelnen Strich. - fn page_with_stroke(page_id: u64, stroke_id: u64) -> Page { - let mut page = Page::new(page_id, PageBackground::Lined); + fn page_with_one_stroke(page_id: u64, background: PageBackground) -> Page { + let mut page = Page::new(page_id, background); let samples = vec![ StylusSample { position: glam::Vec2::new(100.0, 100.0), @@ -858,172 +2117,55 @@ mod tests { timestamp: 0.05, }, ]; - let vertices = reconstruct_stroke_vertices(&samples, [0.0, 0.0, 0.0, 1.0]); - page.add_stroke(Stroke::new( - stroke_id, - samples, - vertices, - [0.0, 0.0, 0.0, 1.0], - )); + let vertices = vec![ + InkVertex::new(100.0, 100.0, [0.0, 0.0, 0.0, 1.0]), + InkVertex::new(200.0, 150.0, [0.0, 0.0, 0.0, 1.0]), + ]; + page.add_stroke(Stroke::new(1, samples, vertices, [0.0, 0.0, 0.0, 1.0])); page } #[test] - fn test_corrupted_page_is_quarantined_not_overwritten() { + fn test_keyboard_new_page_is_recorded_in_manifest() { let temp_dir = tempfile::tempdir().unwrap(); - let dir = temp_dir.path(); - - let mut notebook = Notebook::new(); - notebook.add_page(PageBackground::Grid5mm); - let mut manifest = NotebookManifest::new("nb", "Test"); - save_notebook(dir, ¬ebook, &mut manifest).unwrap(); - - let corrupted = br#"{"id": 2, "background": "Lined", "strokes": [{"id": 1"#; - fs::write(dir.join("page_2.json"), corrupted).unwrap(); - - let (mut loaded, loaded_manifest) = load_notebook(dir).unwrap(); - assert!(ensure_page_loaded(dir, &mut loaded, 1).is_err()); - - // Original wurde beiseite gelegt und bleibt Byte für Byte erhalten - let backups: Vec<_> = fs::read_dir(dir) - .unwrap() - .flatten() - .filter(|e| { - e.file_name() - .to_string_lossy() - .starts_with("page_2.json.corrupt-") - }) - .collect(); - assert_eq!(backups.len(), 1); - assert_eq!(fs::read(backups[0].path()).unwrap(), corrupted); - - // Ersatzseite ist normal beschreibbar - assert!(!loaded.pages[1].load_failed); - let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60)); - loaded.current_page_index = 1; - autosave.mark_dirty(&loaded, &loaded_manifest); - autosave.flush().unwrap(); - assert!(load_page(dir, 2).is_ok()); - autosave.shutdown(); - } - - #[test] - fn test_unreadable_page_is_never_overwritten() { - let temp_dir = tempfile::tempdir().unwrap(); - let dir = temp_dir.path(); - - let mut notebook = Notebook::new(); - notebook.add_page(PageBackground::Grid5mm); - let mut manifest = NotebookManifest::new("nb", "Test"); - save_notebook(dir, ¬ebook, &mut manifest).unwrap(); - - // Unlesbare Seite simulieren (I/O-Fehler): Inhalt liegt in einer Datei, die wir - // durch ein gleichnamiges Verzeichnis ersetzen - fs::remove_file(dir.join("page_2.json")).unwrap(); - fs::create_dir(dir.join("page_2.json")).unwrap(); - fs::write(dir.join("page_2.json").join("marker"), b"original").unwrap(); - - let (mut loaded, loaded_manifest) = load_notebook(dir).unwrap(); - assert!(ensure_page_loaded(dir, &mut loaded, 1).is_err()); - assert!(loaded.pages[1].load_failed); - - // Navigation + Autosave + Strg+S dürfen die Seite nicht anfassen - loaded.current_page_index = 1; - let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60)); - autosave.mark_dirty(&loaded, &loaded_manifest); - autosave.save_now(&loaded, &loaded_manifest).unwrap(); - save_notebook(dir, &loaded, &mut loaded_manifest.clone()).unwrap(); - autosave.shutdown(); - - assert_eq!( - fs::read(dir.join("page_2.json").join("marker")).unwrap(), - b"original" - ); - // Die Seite bleibt im Manifest referenziert - assert_eq!(load_manifest(dir).unwrap().page_ids, vec![1, 2]); - } - - #[test] - fn test_autosave_records_new_pages_in_manifest() { - let temp_dir = tempfile::tempdir().unwrap(); - let dir = temp_dir.path(); - - let mut notebook = Notebook::new(); - let mut manifest = NotebookManifest::new("nb", "Test"); - save_notebook(dir, ¬ebook, &mut manifest).unwrap(); - - // Wie in main.rs: neue Seite per Navigation, Strich darauf, Autosave - notebook.next_page(); - notebook.pages[1] = page_with_stroke(notebook.pages[1].id, 1); - let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60)); - autosave.mark_dirty(¬ebook, &manifest); - autosave.shutdown(); - - let (mut reloaded, reloaded_manifest) = load_notebook(dir).unwrap(); - assert_eq!(reloaded_manifest.page_ids, vec![1, 2]); - assert_eq!(reloaded.pages.len(), 2); - ensure_page_loaded(dir, &mut reloaded, 1).unwrap(); - assert_eq!(reloaded.pages[1].strokes.len(), 1); - } - - #[test] - fn test_flush_and_save_now_report_write_errors() { - let temp_dir = tempfile::tempdir().unwrap(); - // Zielpfad ist eine Datei -> Schreiben ins "Notizbuch-Verzeichnis" muss fehlschlagen - let blocked = temp_dir.path().join("blocked"); - fs::write(&blocked, b"").unwrap(); - - let notebook = Notebook::new(); - let manifest = NotebookManifest::new("nb", "Test"); - let mut autosave = AutosaveController::new(blocked, Duration::from_secs(60)); - - autosave.mark_dirty(¬ebook, &manifest); - assert!(autosave.flush().is_err()); - assert_eq!(autosave.status(), crate::autosave::AutosaveStatus::Error); - - assert!(autosave.save_now(¬ebook, &manifest).is_err()); - assert_eq!(autosave.status(), crate::autosave::AutosaveStatus::Error); - autosave.shutdown(); - } - - #[test] - fn test_deleted_notebook_is_not_recreated_by_pending_autosave() { - let temp_dir = tempfile::tempdir().unwrap(); - let base = temp_dir.path(); - let (notebook, manifest, dir) = create_new_notebook(base, "Weg damit").unwrap(); - let (_, _, other_dir) = create_new_notebook(base, "Bleibt").unwrap(); - + let (mut notebook, mut manifest, dir) = + create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap(); let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60)); - autosave.mark_dirty(¬ebook, &manifest); - // Ablauf wie in main.rs beim Löschen des aktuellen Notizbuchs + // Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern + notebook.next_page(); + let idx = notebook.current_page_index; + notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined); + autosave.mark_dirty(¬ebook, &mut manifest); autosave.flush().unwrap(); - delete_notebook(&dir).unwrap(); - autosave.switch_notebook_dir(other_dir).unwrap(); - autosave.shutdown(); + autosave.shutdown().unwrap(); - assert!( - !dir.exists(), - "Gelöschtes Notizbuch darf nicht wieder auftauchen" + let (mut reloaded, reloaded_manifest) = load_notebook(&dir).unwrap(); + assert_eq!(reloaded_manifest.page_ids(), vec![1, 2]); + assert_eq!( + reloaded.pages.len(), + 2, + "Neue Seite muss einen Neustart überleben" ); + ensure_page_loaded(&dir, &mut reloaded, 1).unwrap(); + assert_eq!(reloaded.pages[1].strokes.len(), 1); } #[test] fn test_load_all_pages_for_export() { let temp_dir = tempfile::tempdir().unwrap(); - let dir = temp_dir.path(); - - let mut notebook = Notebook::new(); + let (mut notebook, mut manifest, dir) = + create_new_notebook(temp_dir.path(), "Export").unwrap(); notebook.add_page(PageBackground::Lined); - notebook.pages[1] = page_with_stroke(2, 1); - let mut manifest = NotebookManifest::new("nb", "Test"); - save_notebook(dir, ¬ebook, &mut manifest).unwrap(); + notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined); + save_notebook(&dir, ¬ebook, &mut manifest).unwrap(); - let (mut loaded, _) = load_notebook(dir).unwrap(); + let (mut loaded, _) = load_notebook(&dir).unwrap(); assert!(!loaded.pages[1].is_loaded); + // Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err()); - load_all_pages(dir, &mut loaded).unwrap(); + load_all_pages(&dir, &mut loaded).unwrap(); assert_eq!(loaded.pages[1].strokes.len(), 1); assert_eq!(loaded.pages[1].background, PageBackground::Lined); assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok()); diff --git a/src/ui.rs b/src/ui.rs index c2911c7..70b2642 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -113,6 +113,9 @@ pub enum UiAction { new_title: String, }, DeleteNotebook(String), + UndoDeleteNotebook, + RestoreNotebookBackup(String), + OpenNotebookFolder(String), RefreshNotebooks, // Gitea Release & Updater CheckForUpdates, @@ -123,6 +126,12 @@ pub enum UiAction { server_url: String, auth_token: Option, }, + // Datenintegrität / Quarantäne + UnlockCorruptedPage(u64), + // Beenden-Sicherheit bei Fehlschlag + RetryShutdown, + ExportRecoveryAndExit, + ForceExit, } /// Zustand der grafischen Benutzeroberfläche. @@ -162,6 +171,10 @@ pub struct UiState { pub update_modal_rect: Option, /// Steuert, ob Touch-Eingaben mit einem Finger zeichnen oder das Dokument verschieben (Default: false = reine Gesten-Navigation & Zoom) pub touch_draw_enabled: bool, + /// Zuletzt gelöschtes Notizbuch für Rückgängig während der Sitzung (Papierkorb-Pfad, Titel) + pub last_deleted_notebook: Option<(std::path::PathBuf, String)>, + /// Fehler beim Flushen vor dem Beenden + pub shutdown_error: Option, } impl Default for UiState { @@ -193,6 +206,8 @@ impl Default for UiState { show_updater_config: false, update_modal_rect: None, touch_draw_enabled: false, + last_deleted_notebook: None, + shutdown_error: None, } } } @@ -1546,11 +1561,17 @@ pub fn render_ui( .size(13.0) .color(egui::Color32::WHITE), ); - if is_current { + if summary.status == crate::storage::NotebookStatus::Damaged { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠️ Beschädigt"); + } else if is_current { ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "● Aktiv"); } }); - let subtext = format!("{} Seite(n)", summary.page_count); + let subtext = if let Some(ref err) = summary.error { + format!("Fehler: {err}") + } else { + format!("{} Seite(n)", summary.page_count) + }; ui.label( egui::RichText::new(subtext) .size(11.0) @@ -1559,27 +1580,47 @@ pub fn render_ui( }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if state.available_notebooks.len() > 1 - && ui - .button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) - .on_hover_text("Notizbuch unwiderruflich löschen") + if summary.status == crate::storage::NotebookStatus::Damaged { + if ui + .button(egui::RichText::new("🔄 Backup").size(11.0).color(egui::Color32::from_rgb(100, 200, 255))) + .on_hover_text("Aus manifest.json.bak wiederherstellen") .clicked() - { - actions.push(UiAction::DeleteNotebook(summary.id.clone())); - } + { + actions.push(UiAction::RestoreNotebookBackup(summary.id.clone())); + } + if ui + .button(egui::RichText::new("📁 Ordner").size(11.0)) + .on_hover_text("Pfad in Statusleiste anzeigen") + .clicked() + { + actions.push(UiAction::OpenNotebookFolder(summary.id.clone())); + } + } else { + if state.available_notebooks.len() > 1 + && ui + .button(egui::RichText::new("🗑").size(11.0).color(egui::Color32::from_rgb(255, 120, 120))) + .on_hover_text("Notizbuch in Papierkorb verschieben") + .clicked() + { + actions.push(UiAction::DeleteNotebook(summary.id.clone())); + } - if ui.button(egui::RichText::new("✏").size(11.0)) - .on_hover_text("Titel umbenennen") - .clicked() - { - state.renaming_notebook_id = Some(summary.id.clone()); - state.renaming_title_input = summary.title.clone(); + if ui.button(egui::RichText::new("✏").size(11.0)) + .on_hover_text("Titel umbenennen") + .clicked() + { + state.renaming_notebook_id = Some(summary.id.clone()); + state.renaming_title_input = summary.title.clone(); + } } }); }); }); - if frame_resp.response.interact(egui::Sense::click()).clicked() && !is_current { + if summary.status != crate::storage::NotebookStatus::Damaged + && frame_resp.response.interact(egui::Sense::click()).clicked() + && !is_current + { actions.push(UiAction::OpenNotebook(summary.id.clone())); state.is_document_menu_open = false; } @@ -1589,6 +1630,23 @@ pub fn render_ui( } }); + if let Some((_, ref title)) = state.last_deleted_notebook { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("'{title}' gelöscht")) + .size(11.0) + .color(egui::Color32::from_rgb(255, 180, 100)), + ); + if ui + .button(egui::RichText::new("↩ Rückgängig").size(11.0).color(egui::Color32::from_rgb(100, 200, 255))) + .clicked() + { + actions.push(UiAction::UndoDeleteNotebook); + } + }); + } + ui.add_space(8.0); ui.separator(); ui.add_space(4.0); @@ -1932,6 +1990,183 @@ pub fn render_ui( } } + // 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1) + if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) { + egui::Window::new("⚠️ Datenintegritäts-Warnung") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 245)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.5_f32, + egui::Color32::from_rgb(235, 87, 87), + )) + .inner_margin(egui::Margin::symmetric(24.0, 20.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 24.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(180), + }), + ) + .show(ctx, |ui| { + ui.set_width(420.0); + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.label( + egui::RichText::new(format!( + "Seite {} konnte nicht fehlerfrei geladen werden!", + warning.page_id + )) + .strong() + .size(15.0) + .color(egui::Color32::WHITE), + ); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("Fehlermeldung: {}", warning.message)) + .size(12.0) + .color(egui::Color32::from_gray(200)), + ); + ui.add_space(8.0); + if let Some(ref q_path) = warning.quarantine_path { + ui.label( + egui::RichText::new("Die beschädigte Originaldatei wurde in Quarantäne gesichert:") + .size(12.0) + .color(egui::Color32::from_gray(220)), + ); + ui.add_space(4.0); + ui.monospace( + egui::RichText::new(q_path.display().to_string()) + .size(11.0) + .color(egui::Color32::from_rgb(255, 215, 0)), + ); + ui.add_space(10.0); + ui.label( + egui::RichText::new( + "Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\ + Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.", + ) + .size(11.5) + .color(egui::Color32::from_gray(180)), + ); + ui.add_space(12.0); + if ui + .button( + egui::RichText::new("Als leere Seite bearbeiten (Schreibschutz aufheben)") + .color(egui::Color32::WHITE) + .size(12.5), + ) + .clicked() + { + actions.push(UiAction::UnlockCorruptedPage(warning.page_id)); + } + } else { + ui.label( + egui::RichText::new( + "Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\ + Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.", + ) + .size(12.0) + .color(egui::Color32::from_gray(200)), + ); + } + }); + } + + // Notfall-/Sicherheits-Dialog bei Fehlschlag des Speicherns vor dem Beenden + if let Some(err) = &state.shutdown_error { + let mut dismiss = false; + egui::Window::new("⚠️ Ungespeicherte Änderungen") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 250)) + .rounding(egui::Rounding::same(12.0)) + .stroke(egui::Stroke::new( + 1.5_f32, + egui::Color32::from_rgb(235, 87, 87), + )) + .inner_margin(egui::Margin::symmetric(24.0, 20.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 24.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(200), + }), + ) + .show(ctx, |ui| { + ui.set_width(460.0); + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️"); + ui.label( + egui::RichText::new("Fehler beim Beenden") + .color(egui::Color32::from_rgb(255, 100, 100)) + .size(16.0) + .strong(), + ); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new( + "Ungespeicherte Änderungen konnten nicht gesichert werden:", + ) + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.add_space(6.0); + egui::Frame::none() + .fill(egui::Color32::from_black_alpha(100)) + .rounding(egui::Rounding::same(6.0)) + .inner_margin(egui::Margin::same(8.0)) + .show(ui, |ui| { + for msg in &err.messages { + ui.label( + egui::RichText::new(msg) + .size(11.5) + .color(egui::Color32::from_rgb(255, 180, 180)), + ); + } + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new("Trotzdem beenden?") + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.add_space(12.0); + ui.horizontal_wrapped(|ui| { + if ui.button("Abbrechen").clicked() { + dismiss = true; + } + if ui.button("Wiederholen").clicked() { + actions.push(UiAction::RetryShutdown); + } + if ui.button("Notfall-Export").clicked() { + actions.push(UiAction::ExportRecoveryAndExit); + } + if ui + .button( + egui::RichText::new("Trotzdem beenden") + .color(egui::Color32::from_rgb(255, 100, 100)), + ) + .clicked() + { + actions.push(UiAction::ForceExit); + } + }); + }); + if dismiss { + state.shutdown_error = None; + } + } + actions } @@ -2058,12 +2293,16 @@ mod tests { title: "Mathematik I".to_string(), page_count: 3, modified_at: 100, + status: crate::storage::NotebookStatus::Normal, + error: None, }, NotebookSummary { id: "nb-2".to_string(), title: "Physik II".to_string(), page_count: 5, modified_at: 200, + status: crate::storage::NotebookStatus::Normal, + error: None, }, ]; state.current_notebook_id = "nb-1".to_string(); diff --git a/tests/fixtures/future_v99_manifest.json b/tests/fixtures/future_v99_manifest.json new file mode 100644 index 0000000..5877ef9 --- /dev/null +++ b/tests/fixtures/future_v99_manifest.json @@ -0,0 +1,14 @@ +{ + "format_version": 99, + "id": "future_notebook", + "title": "Zukunfts-Notizbuch", + "created_at": 1800000000, + "modified_at": 1800001000, + "current_page_index": 0, + "pages": [ + { "id": 1, "background": "Grid5mm" } + ], + "page_ids": [1], + "next_stroke_id": 10, + "next_page_id": 2 +} diff --git a/tests/fixtures/future_v99_page_1.json b/tests/fixtures/future_v99_page_1.json new file mode 100644 index 0000000..c914637 --- /dev/null +++ b/tests/fixtures/future_v99_page_1.json @@ -0,0 +1,6 @@ +{ + "format_version": 99, + "id": 1, + "background": "Blank", + "strokes": [] +} diff --git a/tests/fixtures/legacy_v0_manifest.json b/tests/fixtures/legacy_v0_manifest.json new file mode 100644 index 0000000..65a1ff2 --- /dev/null +++ b/tests/fixtures/legacy_v0_manifest.json @@ -0,0 +1,10 @@ +{ + "id": "legacy_notebook", + "title": "Legacy Notizbuch v0", + "created_at": 1700000000, + "modified_at": 1700001000, + "current_page_index": 0, + "page_ids": [1], + "next_stroke_id": 5, + "next_page_id": 2 +} diff --git a/tests/fixtures/legacy_v0_page_1.json b/tests/fixtures/legacy_v0_page_1.json new file mode 100644 index 0000000..2b97109 --- /dev/null +++ b/tests/fixtures/legacy_v0_page_1.json @@ -0,0 +1,14 @@ +{ + "id": 1, + "background": "DotGrid", + "strokes": [ + { + "id": 1, + "color": [0.1, 0.2, 0.3, 1.0], + "samples": [ + { "x": 100.0, "y": 200.0, "p": 0.5, "t": 1.0 }, + { "x": 110.0, "y": 210.0, "p": 0.6, "t": 1.1 } + ] + } + ] +} diff --git a/tests/fixtures/unknown_fields_page_1.json b/tests/fixtures/unknown_fields_page_1.json new file mode 100644 index 0000000..1eb2b50 --- /dev/null +++ b/tests/fixtures/unknown_fields_page_1.json @@ -0,0 +1,11 @@ +{ + "format_version": 1, + "id": 1, + "background": "Lined", + "strokes": [], + "unknown_experimental_feature": { + "nested_data": [1, 2, 3], + "author": "Antigravity" + }, + "random_future_flag": true +}