fix(storage): Datenverlust bei Ladefehlern, neuen Seiten und Löschen verhindern

- Beschädigte Seiten werden als page_<id>.json.corrupt-<zeit> 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:43:16 +02:00
co-authored by Claude Opus 5.5
parent f9b39c6fe3
commit d61e1819ed
5 changed files with 527 additions and 172 deletions
+85 -14
View File
@@ -11,7 +11,7 @@ use std::sync::Arc;
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::document::Page; use crate::document::{Notebook, Page};
use crate::storage::{self, NotebookManifest, PageData}; use crate::storage::{self, NotebookManifest, PageData};
/// Aktueller Status des Autosave-Controllers für OSD und UI. /// 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. /// 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) { pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
let page_data = PageData::from(page); if page.is_loaded && !page.load_failed {
self.pending_pages.insert(page.id, page_data); self.pending_pages.insert(page.id, PageData::from(page));
}
self.pending_manifest = Some(manifest.clone()); self.pending_manifest = Some(manifest.clone());
self.last_change_time = Some(Instant::now()); self.last_change_time = Some(Instant::now());
self.status = AutosaveStatus::Pending; 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 &notebook.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. /// 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. /// 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). /// 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<String> {
// Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken // Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() { if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
self.dispatch_pending(); self.dispatch_pending();
} }
let mut errors = Vec::new();
let (ack_tx, ack_rx) = mpsc::channel(); let (ack_tx, ack_rx) = mpsc::channel();
if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() { if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_err() || ack_rx.recv().is_err() {
let _ = ack_rx.recv(); 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() { while let Ok(response) = self.resp_rx.try_recv() {
if self.in_flight_count > 0 { if self.in_flight_count > 0 {
self.in_flight_count -= 1; self.in_flight_count -= 1;
} }
if let AutosaveResponse::PageSaved { result: Err(e), .. } = response { match response {
eprintln!("[Autosave-Flush] Fehler: {e}"); 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}"));
}
_ => {}
} }
} }
errors
self.status = AutosaveStatus::Saved;
} }
/// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel), /// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel),
/// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden. /// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden.
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) { /// Liefert die Schreibfehler des vorherigen Notizbuchs zurück; der Status gilt danach
self.flush(); /// 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.notebook_dir = new_notebook_dir;
self.status = AutosaveStatus::Saved;
result
} }
/// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush. /// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush.
pub fn shutdown(&mut self) { 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); let _ = self.cmd_tx.send(AutosaveCommand::Shutdown);
if let Some(handle) = self.worker_handle.take() { if let Some(handle) = self.worker_handle.take() {
let _ = handle.join(); let _ = handle.join();
+5
View File
@@ -314,6 +314,9 @@ pub struct Page {
pub stroke_ranges: Vec<Range<u32>>, pub stroke_ranges: Vec<Range<u32>>,
pub is_dirty: bool, pub is_dirty: bool,
pub is_loaded: 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, pub history: HistoryStack,
} }
@@ -327,6 +330,7 @@ impl Page {
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
is_loaded: true, is_loaded: true,
load_failed: false,
history: HistoryStack::new(), history: HistoryStack::new(),
} }
} }
@@ -341,6 +345,7 @@ impl Page {
stroke_ranges: Vec::new(), stroke_ranges: Vec::new(),
is_dirty: false, is_dirty: false,
is_loaded: false, is_loaded: false,
load_failed: false,
history: HistoryStack::new(), history: HistoryStack::new(),
} }
} }
+12
View File
@@ -139,6 +139,18 @@ pub fn generate_pdf_document(
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into()); 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 // 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook"; let title = "ink-notes Notebook";
+154 -142
View File
@@ -1,6 +1,8 @@
//! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar, //! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar,
//! Seitenverwaltung, Undo/Redo, Vektor-PDF-Export und asynchroner Notizbuch-Persistenz (Autosave). //! 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::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use winit::{ use winit::{
@@ -52,6 +54,44 @@ fn update_window_title(
window.set_title(&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<Result<PathBuf, String>>,
window: Arc<Window>,
) {
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(&notebook_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) /// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein. /// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook( fn commit_stroke_to_notebook(
@@ -83,7 +123,7 @@ fn commit_stroke_to_notebook(
page.add_stroke(stroke); page.add_stroke(stroke);
canvas.sync_page(page); canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), manifest); autosave.mark_dirty(notebook, manifest);
update_window_title( update_window_title(
window, window,
notebook, notebook,
@@ -128,16 +168,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
let mut storage_dir = storage_base_dir.join(&initial_id); let mut storage_dir = storage_base_dir.join(&initial_id);
let mut startup_warning: Option<String> = None;
let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() { let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() {
println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display()); println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display());
match storage::load_notebook(&storage_dir) { match storage::load_notebook(&storage_dir) {
Ok((nb, m)) => (nb, m), Ok((nb, m)) => (nb, m),
Err(err) => { Err(err) => {
eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch."); // Den Ordner des nicht ladbaren Notizbuchs NICHT wiederverwenden, sonst würde
( // der Autosave dessen Manifest überschreiben und alle Seiten verwaist zurücklassen.
Notebook::new(), eprintln!(
NotebookManifest::new(&initial_id, &initial_title), "[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 { } else {
@@ -159,6 +207,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
egui_ctx.set_visuals(egui::Visuals::dark()); egui_ctx.set_visuals(egui::Visuals::dark());
// 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen // 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::<Result<PathBuf, String>>();
let mut updater = UpdaterController::new(); let mut updater = UpdaterController::new();
updater.check_for_updates(); updater.check_for_updates();
@@ -174,6 +225,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks; 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(); let mut selection_state = SelectionState::new();
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren // Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
@@ -265,6 +320,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, new_status); update_window_title(&window, &notebook, &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(); let prev_upd = updater.status().clone();
updater.update(); updater.update();
if *updater.status() != prev_upd { if *updater.status() != prev_upd {
@@ -347,10 +410,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty( autosave.mark_dirty(&notebook, &manifest);
notebook.current_page(),
&manifest,
);
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -427,10 +487,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty( autosave.mark_dirty(&notebook, &manifest);
notebook.current_page(),
&manifest,
);
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -680,7 +737,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() { if notebook.current_page_mut().undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -694,7 +751,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() { if notebook.current_page_mut().redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -724,7 +781,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() { if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -736,7 +793,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut()); let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -745,8 +802,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Jetzt speichern (Disk Flush): Strg + S // Jetzt speichern (Disk Flush): Strg + S
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
autosave.flush(); match autosave.save_now(&notebook, &manifest) {
ui_state.set_export_message("Gespeichert"); Ok(()) => ui_state.set_export_message("Gespeichert"),
Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")),
}
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -759,38 +818,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// PDF-Export: Strg + E // PDF-Export: Strg + E
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
let notebook_clone = notebook.clone(); start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
let export_path = export::resolve_pdf_export_path(&manifest.title); ui_state.set_export_message("PDF-Export läuft…");
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(
&notebook_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}");
}
}
});
window.request_redraw(); window.request_redraw();
} }
@@ -826,7 +855,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.12, 0.12, 0.14, 1.0]; let color = [0.12, 0.12, 0.14, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) { if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -846,7 +875,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.15, 0.35, 0.85, 1.0]; let color = [0.15, 0.35, 0.85, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) { if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -865,7 +894,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() { if !selection_state.is_empty() {
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -889,7 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
PageBackground::Blank => PageBackground::Grid5mm, PageBackground::Blank => PageBackground::Grid5mm,
}; };
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -903,7 +932,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -915,7 +944,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -928,7 +957,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -941,7 +970,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -954,7 +983,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1035,7 +1064,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1133,7 +1162,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width); let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width);
notebook.current_page_mut().replace_stroke(old_id, new_stroke); notebook.current_page_mut().replace_stroke(old_id, new_stroke);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
window.request_redraw(); window.request_redraw();
return; return;
@@ -1190,7 +1219,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); 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 { if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(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(&notebook, &manifest);
} }
window.request_redraw(); window.request_redraw();
} }
@@ -1303,7 +1332,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1427,7 +1456,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1568,7 +1597,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1659,7 +1688,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); 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 { if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(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(&notebook, &manifest);
} }
window.request_redraw(); window.request_redraw();
} }
@@ -1743,7 +1772,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.recolor_selected(notebook.current_page_mut(), color) { && selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message("Auswahl umgefärbt".to_string()); ui_state.set_export_message("Auswahl umgefärbt".to_string());
} }
match ui_state.active_tool { match ui_state.active_tool {
@@ -1778,7 +1807,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut()); let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -1805,7 +1834,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() { if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -1814,7 +1843,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -1842,7 +1871,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() { if notebook.current_page_mut().undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1851,7 +1880,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() { if notebook.current_page_mut().redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1863,7 +1892,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1875,7 +1904,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1886,7 +1915,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1897,7 +1926,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = idx; manifest.current_page_index = idx;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1906,43 +1935,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
page.background = bg; page.background = bg;
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::ExportPdf => { UiAction::ExportPdf => {
let notebook_clone = notebook.clone(); start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
let export_path = export::resolve_pdf_export_path(&manifest.title); ui_state.set_export_message("PDF-Export läuft…");
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(
&notebook_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}");
}
}
});
window.request_redraw(); window.request_redraw();
} }
@@ -1958,7 +1957,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_notebook_dir = storage_base_dir.join(&id); let new_notebook_dir = storage_base_dir.join(&id);
match storage::load_notebook(&new_notebook_dir) { match storage::load_notebook(&new_notebook_dir) {
Ok((loaded_nb, loaded_manifest)) => { 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; notebook = loaded_nb;
manifest = loaded_manifest; manifest = loaded_manifest;
storage_dir = new_notebook_dir; storage_dir = new_notebook_dir;
@@ -1980,10 +1981,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
UiAction::CreateNotebook(title) => { UiAction::CreateNotebook(title) => {
autosave.flush();
match storage::create_new_notebook(&storage_base_dir, &title) { match storage::create_new_notebook(&storage_base_dir, &title) {
Ok((new_nb, new_manifest, new_dir)) => { 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; notebook = new_nb;
manifest = new_manifest; manifest = new_manifest;
storage_dir = new_dir; storage_dir = new_dir;
@@ -2034,41 +2036,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
UiAction::DeleteNotebook(id) => { UiAction::DeleteNotebook(id) => {
let target_dir = storage_base_dir.join(&id); let target_dir = storage_base_dir.join(&id);
let is_current = id == manifest.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) { match storage::delete_notebook(&target_dir) {
Ok(()) => { Ok(()) => {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
if is_current { if is_current {
if let Some(first) = ui_state.available_notebooks.first().cloned() { // Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen
let new_notebook_dir = storage_base_dir.join(&first.id); let replacement = ui_state
match storage::load_notebook(&new_notebook_dir) { .available_notebooks
Ok((loaded_nb, loaded_manifest)) => { .iter()
autosave.switch_notebook_dir(new_notebook_dir.clone()); .find_map(|summary| {
notebook = loaded_nb; let dir = storage_base_dir.join(&summary.id);
manifest = loaded_manifest; match storage::load_notebook(&dir) {
storage_dir = new_notebook_dir; Ok((nb, m)) => Some((nb, m, dir)),
selection_state.clear(); Err(err) => {
ui_state.current_notebook_id = manifest.id.clone(); eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", summary.id);
ui_state.document_title = manifest.title.clone(); None
canvas.sync_page(notebook.current_page_mut()); }
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
} }
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, &notebook, &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, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
} }
} else { } else {
ui_state.set_export_message("Dokument gelöscht".to_string()); ui_state.set_export_message("Dokument gelöscht".to_string());
+271 -16
View File
@@ -149,6 +149,19 @@ impl NotebookManifest {
next_page_id: 2, 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. /// Mögliche Fehler bei Speicher- und Ladeoperationen.
@@ -347,18 +360,11 @@ pub fn save_notebook(
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
fs::create_dir_all(notebook_dir)?; fs::create_dir_all(notebook_dir)?;
manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect(); manifest.sync_from_notebook(notebook);
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();
// Alle bereits geladenen Seiten speichern // Alle bereits geladenen Seiten speichern (fehlgeschlagene Ersatzseiten niemals)
for page in &notebook.pages { for page in &notebook.pages {
if page.is_loaded { if page.is_loaded && !page.load_failed {
let page_data = PageData::from(page); let page_data = PageData::from(page);
save_page(notebook_dir, &page_data)?; 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) { let first_page = match load_page(notebook_dir, first_id) {
Ok(data) => data.to_page(), Ok(data) => data.to_page(),
Err(e) => { Err(e) => {
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite."); eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}.");
Page::new(first_id, PageBackground::Grid5mm) replacement_page_after_load_error(notebook_dir, first_id, &e)
} }
}; };
pages.push(first_page); pages.push(first_page);
@@ -426,14 +432,77 @@ pub fn ensure_page_loaded(
} }
Err(e) => { Err(e) => {
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}"); eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
// Ersatzseite als geladen markieren, damit die App nicht hängt // Ersatzseite einsetzen, damit die App nicht hängt
notebook.pages[page_idx].is_loaded = true; let background = notebook.pages[page_idx].background;
notebook.pages[page_idx].rebuild_cache_if_dirty(); 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) 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_<id>.json.corrupt-<zeit>` 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). /// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
pub fn default_storage_dir() -> PathBuf { pub fn default_storage_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
@@ -712,7 +781,7 @@ mod tests {
controller.update(); controller.update();
// Flush abwarten // Flush abwarten
controller.flush(); controller.flush().expect("Flush muss gelingen");
// Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben // Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
assert_eq!( assert_eq!(
@@ -773,4 +842,190 @@ mod tests {
assert_eq!(final_summaries.len(), 1); assert_eq!(final_summaries.len(), 1);
assert_eq!(final_summaries[0].id, m1.id); 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, &notebook, &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, &notebook, &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, &notebook, &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(&notebook, &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(&notebook, &manifest);
assert!(autosave.flush().is_err());
assert_eq!(autosave.status(), crate::autosave::AutosaveStatus::Error);
assert!(autosave.save_now(&notebook, &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(&notebook, &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, &notebook, &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());
}
} }