From d61e1819ed2515c318e861d120fd23bf26049fc5 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 19:43:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(storage):=20Datenverlust=20bei=20Ladefehler?= =?UTF-8?q?n,=20neuen=20Seiten=20und=20L=C3=B6schen=20verhindern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beschädigte Seiten werden als page_.json.corrupt- gesichert statt leer überschrieben; bei I/O-Fehlern wird die Ersatzseite als load_failed markiert und nie gespeichert - Autosave gleicht das Manifest (page_ids, Zähler) mit dem Notizbuch ab, damit neu angelegte Seiten nach einem Neustart nicht verloren gehen - Start: nicht ladbares Notizbuch bleibt unverändert, stattdessen wird ein neues angelegt - Aktuelles Notizbuch löschen: ausstehende Schreibvorgänge vorher abarbeiten, damit der Ordner nicht wieder angelegt wird - PDF-Export lädt nicht geöffnete Seiten nach und verweigert unvollständige Exporte; Ergebnis wird per Kanal an die UI gemeldet - Strg+S speichert alle geladenen Seiten und meldet Schreibfehler; flush() liefert Fehler zurück statt immer "Gespeichert" Co-Authored-By: Claude Opus 5.5 --- src/autosave.rs | 99 +++++++++++++--- src/document.rs | 5 + src/export.rs | 12 ++ src/main.rs | 296 +++++++++++++++++++++++++----------------------- src/storage.rs | 287 +++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 527 insertions(+), 172 deletions(-) diff --git a/src/autosave.rs b/src/autosave.rs index 6e4a9f9..6f7657a 100644 --- a/src/autosave.rs +++ b/src/autosave.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use crate::document::Page; +use crate::document::{Notebook, Page}; use crate::storage::{self, NotebookManifest, PageData}; /// Aktueller Status des Autosave-Controllers für OSD und UI. @@ -138,14 +138,53 @@ impl AutosaveController { } /// 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) { - let page_data = PageData::from(page); - self.pending_pages.insert(page.id, page_data); + if page.is_loaded && !page.load_failed { + self.pending_pages.insert(page.id, PageData::from(page)); + } 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 mut synced = manifest.clone(); + synced.sync_from_notebook(notebook); + self.pending_manifest = Some(synced); + + 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("; ")) + } + } + /// 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. @@ -220,40 +259,72 @@ impl AutosaveController { } /// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden). - pub fn flush(&mut self) { + pub fn flush(&mut self) -> Result<(), String> { + let errors = self.flush_collect_errors(); + if errors.is_empty() { + if self.status != AutosaveStatus::Error { + self.status = AutosaveStatus::Saved; + } + Ok(()) + } else { + self.status = AutosaveStatus::Error; + Err(errors.join("; ")) + } + } + + /// 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 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_ok() { - let _ = ack_rx.recv(); + 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()); } - // Restliche Antworten leeren + // Restliche Antworten leeren (der Worker hat alle vor dem Flush-Befehl abgearbeitet) while let Ok(response) = self.resp_rx.try_recv() { if self.in_flight_count > 0 { self.in_flight_count -= 1; } - if let AutosaveResponse::PageSaved { result: Err(e), .. } = response { - eprintln!("[Autosave-Flush] Fehler: {e}"); + match response { + AutosaveResponse::PageSaved { + page_id, + result: Err(e), + } => { + eprintln!("[Autosave-Flush] Seite {page_id}: {e}"); + errors.push(format!("Seite {page_id}: {e}")); + } + AutosaveResponse::ManifestSaved { result: Err(e) } => { + eprintln!("[Autosave-Flush] Manifest: {e}"); + errors.push(format!("Manifest: {e}")); + } + _ => {} } } - - self.status = AutosaveStatus::Saved; + errors } /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel), /// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden. - pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) { - self.flush(); + /// 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(); self.notebook_dir = new_notebook_dir; + self.status = AutosaveStatus::Saved; + result } /// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush. pub fn shutdown(&mut self) { - self.flush(); + if let Err(e) = self.flush() { + eprintln!("[Shutdown] Nicht alle Änderungen konnten gespeichert werden: {e}"); + } let _ = self.cmd_tx.send(AutosaveCommand::Shutdown); if let Some(handle) = self.worker_handle.take() { let _ = handle.join(); diff --git a/src/document.rs b/src/document.rs index 996285a..165fce7 100644 --- a/src/document.rs +++ b/src/document.rs @@ -314,6 +314,9 @@ 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 history: HistoryStack, } @@ -327,6 +330,7 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: true, + load_failed: false, history: HistoryStack::new(), } } @@ -341,6 +345,7 @@ impl Page { stroke_ranges: Vec::new(), is_dirty: false, is_loaded: false, + load_failed: false, history: HistoryStack::new(), } } diff --git a/src/export.rs b/src/export.rs index 6e59ca0..584720c 100644 --- a/src/export.rs +++ b/src/export.rs @@ -139,6 +139,18 @@ pub fn generate_pdf_document( if notebook.pages.is_empty() { 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) + { + return Err(format!( + "Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)", + page.id + ) + .into()); + } // 1. Erstes Dokument mit Seite 1 erstellen let title = "ink-notes Notebook"; diff --git a/src/main.rs b/src/main.rs index b3580e1..2aeb2b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ //! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar, //! Seitenverwaltung, Undo/Redo, Vektor-PDF-Export und asynchroner Notizbuch-Persistenz (Autosave). +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Sender}; use std::sync::Arc; use std::time::{Duration, Instant}; use winit::{ @@ -52,6 +54,44 @@ fn update_window_title( window.set_title(&title); } +/// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher +/// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück. +fn start_pdf_export( + notebook: &Notebook, + storage_dir: &Path, + title: &str, + result_tx: Sender>, + window: Arc, +) { + let mut notebook_clone = notebook.clone(); + let storage_dir = storage_dir.to_path_buf(); + let export_path = export::resolve_pdf_export_path(title); + println!( + "[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...", + export_path.display() + ); + std::thread::spawn(move || { + let start = Instant::now(); + let result = storage::load_all_pages(&storage_dir, &mut notebook_clone) + .map_err(|err| err.to_string()) + .and_then(|()| { + export_notebook_to_pdf(¬ebook_clone, &export_path, ExportStrategy::FilledPolygon) + .map_err(|err| err.to_string()) + }); + match &result { + Ok(()) => println!( + "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", + export_path.display(), + start.elapsed(), + notebook_clone.pages.len() + ), + Err(err) => eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"), + } + let _ = result_tx.send(result.map(|()| export_path)); + window.request_redraw(); + }); +} + /// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap) /// und fügt ihn in die aktuelle Seite des Notizbuchs ein. fn commit_stroke_to_notebook( @@ -83,7 +123,7 @@ fn commit_stroke_to_notebook( page.add_stroke(stroke); canvas.sync_page(page); manifest.next_stroke_id = notebook.next_stroke_id; - autosave.mark_page_dirty(notebook.current_page(), manifest); + autosave.mark_dirty(notebook, manifest); update_window_title( window, notebook, @@ -128,16 +168,24 @@ fn main() -> Result<(), Box> { }; let mut storage_dir = storage_base_dir.join(&initial_id); + let mut startup_warning: Option = None; let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() { println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display()); match storage::load_notebook(&storage_dir) { Ok((nb, m)) => (nb, m), Err(err) => { - eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch."); - ( - Notebook::new(), - NotebookManifest::new(&initial_id, &initial_title), - ) + // Den Ordner des nicht ladbaren Notizbuchs NICHT wiederverwenden, sonst würde + // der Autosave dessen Manifest überschreiben und alle Seiten verwaist zurücklassen. + eprintln!( + "[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). '{}' bleibt unverändert, erstelle neues Notizbuch.", + storage_dir.display() + ); + startup_warning = Some(format!( + "Notizbuch '{initial_title}' konnte nicht geladen werden – neues Notizbuch angelegt" + )); + let (nb, m, dir) = storage::create_new_notebook(&storage_base_dir, &initial_title)?; + storage_dir = dir; + (nb, m) } } } else { @@ -159,6 +207,9 @@ fn main() -> Result<(), Box> { egui_ctx.set_visuals(egui::Visuals::dark()); // 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen + // Ergebnisse der PDF-Exporte aus dem Hintergrundthread + let (export_tx, export_rx) = mpsc::channel::>(); + let mut updater = UpdaterController::new(); updater.check_for_updates(); @@ -174,6 +225,10 @@ fn main() -> Result<(), Box> { ui_state.current_notebook_id = manifest.id.clone(); ui_state.document_title = manifest.title.clone(); ui_state.available_notebooks = available_notebooks; + if let Some(warning) = startup_warning { + ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + ui_state.set_export_message(warning); + } let mut selection_state = SelectionState::new(); // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren @@ -265,6 +320,14 @@ fn main() -> Result<(), Box> { update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status); } + while let Ok(result) = export_rx.try_recv() { + match result { + Ok(path) => ui_state.set_export_message(format!("PDF gespeichert: {}", path.display())), + Err(err) => ui_state.set_export_message(format!("PDF-Export fehlgeschlagen: {err}")), + } + window.request_redraw(); + } + let prev_upd = updater.status().clone(); updater.update(); if *updater.status() != prev_upd { @@ -347,10 +410,7 @@ fn main() -> Result<(), Box> { ); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_page_dirty( - notebook.current_page(), - &manifest, - ); + autosave.mark_dirty(¬ebook, &manifest); update_window_title( &window, ¬ebook, @@ -427,10 +487,7 @@ fn main() -> Result<(), Box> { ); if !removed.is_empty() { canvas.sync_page(page); - autosave.mark_page_dirty( - notebook.current_page(), - &manifest, - ); + autosave.mark_dirty(¬ebook, &manifest); update_window_title( &window, ¬ebook, @@ -680,7 +737,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -694,7 +751,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -724,7 +781,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); window.request_redraw(); } @@ -736,7 +793,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); window.request_redraw(); } @@ -745,8 +802,10 @@ fn main() -> Result<(), Box> { // Jetzt speichern (Disk Flush): Strg + S Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { - autosave.flush(); - ui_state.set_export_message("Gespeichert"); + match autosave.save_now(¬ebook, &manifest) { + Ok(()) => ui_state.set_export_message("Gespeichert"), + Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")), + } update_window_title( &window, ¬ebook, @@ -759,38 +818,8 @@ fn main() -> Result<(), Box> { // PDF-Export: Strg + E Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { - let notebook_clone = notebook.clone(); - let export_path = export::resolve_pdf_export_path(&manifest.title); - let export_path_str = export_path.display().to_string(); - let window_ref = window.clone(); - println!( - "[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...", - export_path_str - ); - ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display())); - let export_path_thread = export_path.clone(); - std::thread::spawn(move || { - let start = std::time::Instant::now(); - match export_notebook_to_pdf( - ¬ebook_clone, - &export_path_thread, - ExportStrategy::FilledPolygon, - ) { - Ok(()) => { - let elapsed = start.elapsed(); - println!( - "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", - export_path_thread.display(), - elapsed, - notebook_clone.pages.len() - ); - window_ref.request_redraw(); - } - Err(err) => { - eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"); - } - } - }); + start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); + ui_state.set_export_message("PDF-Export läuft…"); window.request_redraw(); } @@ -826,7 +855,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); window.request_redraw(); } @@ -846,7 +875,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); window.request_redraw(); } @@ -865,7 +894,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); window.request_redraw(); } @@ -889,7 +918,7 @@ fn main() -> Result<(), Box> { PageBackground::Blank => PageBackground::Grid5mm, }; canvas.sync_page(page); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -903,7 +932,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -915,7 +944,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -928,7 +957,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -941,7 +970,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -954,7 +983,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1035,7 +1064,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1133,7 +1162,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); window.request_redraw(); return; @@ -1190,7 +1219,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); } window.request_redraw(); } @@ -1303,7 +1332,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1427,7 +1456,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1568,7 +1597,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1659,7 +1688,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); } window.request_redraw(); } @@ -1743,7 +1772,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message("Auswahl umgefärbt".to_string()); } match ui_state.active_tool { @@ -1778,7 +1807,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); window.request_redraw(); } @@ -1805,7 +1834,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); window.request_redraw(); } @@ -1814,7 +1843,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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); window.request_redraw(); } @@ -1842,7 +1871,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().undo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1851,7 +1880,7 @@ fn main() -> Result<(), Box> { if notebook.current_page_mut().redo() { selection_state.clear(); canvas.sync_page(notebook.current_page_mut()); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1863,7 +1892,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1875,7 +1904,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1886,7 +1915,7 @@ fn main() -> Result<(), Box> { manifest.current_page_index = notebook.current_page_index; manifest.next_page_id = notebook.next_page_id; canvas.sync_page(notebook.current_page_mut()); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1897,7 +1926,7 @@ 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_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } @@ -1906,43 +1935,13 @@ fn main() -> Result<(), Box> { let page = notebook.current_page_mut(); page.background = bg; canvas.sync_page(page); - autosave.mark_page_dirty(notebook.current_page(), &manifest); + autosave.mark_dirty(¬ebook, &manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); window.request_redraw(); } UiAction::ExportPdf => { - let notebook_clone = notebook.clone(); - let export_path = export::resolve_pdf_export_path(&manifest.title); - let export_path_str = export_path.display().to_string(); - let window_ref = window.clone(); - println!( - "[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...", - export_path_str - ); - ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display())); - let export_path_thread = export_path.clone(); - std::thread::spawn(move || { - let start = std::time::Instant::now(); - match export_notebook_to_pdf( - ¬ebook_clone, - &export_path_thread, - ExportStrategy::FilledPolygon, - ) { - Ok(()) => { - let elapsed = start.elapsed(); - println!( - "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", - export_path_thread.display(), - elapsed, - notebook_clone.pages.len() - ); - window_ref.request_redraw(); - } - Err(err) => { - eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"); - } - } - }); + start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone()); + ui_state.set_export_message("PDF-Export läuft…"); window.request_redraw(); } @@ -1958,7 +1957,9 @@ 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)) => { - autosave.switch_notebook_dir(new_notebook_dir.clone()); + if let Err(err) = autosave.switch_notebook_dir(new_notebook_dir.clone()) { + eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}"); +} notebook = loaded_nb; manifest = loaded_manifest; storage_dir = new_notebook_dir; @@ -1980,10 +1981,11 @@ fn main() -> Result<(), Box> { } } UiAction::CreateNotebook(title) => { - autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { Ok((new_nb, new_manifest, new_dir)) => { - autosave.switch_notebook_dir(new_dir.clone()); + if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) { + eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}"); +} notebook = new_nb; manifest = new_manifest; storage_dir = new_dir; @@ -2034,41 +2036,51 @@ fn main() -> Result<(), Box> { UiAction::DeleteNotebook(id) => { let target_dir = storage_base_dir.join(&id); let is_current = id == manifest.id; + 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(); + } match storage::delete_notebook(&target_dir) { Ok(()) => { ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); if is_current { - if let Some(first) = ui_state.available_notebooks.first().cloned() { - let new_notebook_dir = storage_base_dir.join(&first.id); - match storage::load_notebook(&new_notebook_dir) { - Ok((loaded_nb, loaded_manifest)) => { - 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)); + // Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen + let replacement = 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 + } } - Err(err) => { - eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id); + }) + .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}"); } + 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") { - 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()); diff --git a/src/storage.rs b/src/storage.rs index 4271ec8..25009c0 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -149,6 +149,19 @@ impl NotebookManifest { 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 sync_from_notebook(&mut self, notebook: &Notebook) { + self.page_ids = notebook.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; + self.modified_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + } } /// Mögliche Fehler bei Speicher- und Ladeoperationen. @@ -347,18 +360,11 @@ pub fn save_notebook( ) -> Result<(), StorageError> { fs::create_dir_all(notebook_dir)?; - manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect(); - manifest.current_page_index = notebook.current_page_index; - manifest.next_stroke_id = notebook.next_stroke_id; - manifest.next_page_id = notebook.next_page_id; - manifest.modified_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + manifest.sync_from_notebook(notebook); - // Alle bereits geladenen Seiten speichern + // Alle bereits geladenen Seiten speichern (fehlgeschlagene Ersatzseiten niemals) for page in ¬ebook.pages { - if page.is_loaded { + if page.is_loaded && !page.load_failed { let page_data = PageData::from(page); save_page(notebook_dir, &page_data)?; } @@ -383,8 +389,8 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest) let first_page = match load_page(notebook_dir, first_id) { Ok(data) => data.to_page(), Err(e) => { - eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); - Page::new(first_id, PageBackground::Grid5mm) + eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}."); + replacement_page_after_load_error(notebook_dir, first_id, &e) } }; pages.push(first_page); @@ -426,14 +432,77 @@ pub fn ensure_page_loaded( } Err(e) => { eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}"); - // Ersatzseite als geladen markieren, damit die App nicht hängt - notebook.pages[page_idx].is_loaded = true; - notebook.pages[page_idx].rebuild_cache_if_dirty(); + // 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; 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") { @@ -712,7 +781,7 @@ mod tests { controller.update(); // Flush abwarten - controller.flush(); + controller.flush().expect("Flush muss gelingen"); // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben assert_eq!( @@ -773,4 +842,190 @@ mod tests { assert_eq!(final_summaries.len(), 1); assert_eq!(final_summaries[0].id, m1.id); } + + /// 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); + let samples = vec![ + StylusSample { + position: glam::Vec2::new(100.0, 100.0), + pressure: 0.5, + timestamp: 0.0, + }, + StylusSample { + position: glam::Vec2::new(200.0, 150.0), + pressure: 0.5, + 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], + )); + page + } + + #[test] + fn test_corrupted_page_is_quarantined_not_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(); + + 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 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 + autosave.flush().unwrap(); + delete_notebook(&dir).unwrap(); + autosave.switch_notebook_dir(other_dir).unwrap(); + autosave.shutdown(); + + assert!( + !dir.exists(), + "Gelöschtes Notizbuch darf nicht wieder auftauchen" + ); + } + + #[test] + fn test_load_all_pages_for_export() { + let temp_dir = tempfile::tempdir().unwrap(); + let dir = temp_dir.path(); + + let mut notebook = Notebook::new(); + 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(); + + let (mut loaded, _) = load_notebook(dir).unwrap(); + assert!(!loaded.pages[1].is_loaded); + assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err()); + + 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()); + } }