diff --git a/src/export.rs b/src/export.rs index 5a65657..6e59ca0 100644 --- a/src/export.rs +++ b/src/export.rs @@ -12,7 +12,7 @@ use std::error::Error; use std::fs::File; use std::io::BufWriter; -use std::path::Path; +use std::path::{Path, PathBuf}; use printpdf::path::{PaintMode, WindingOrder}; use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb}; @@ -22,6 +22,70 @@ use crate::document::{ Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH, }; +/// Bereinigt einen Titel für die Verwendung in Dateinamen (entfernt illegale Zeichen). +pub fn sanitize_filename_component(title: &str) -> String { + let sanitized: String = title + .chars() + .map(|c| match c { + '/' | '\\' | '\0' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c => c, + }) + .collect(); + let trimmed = sanitized.trim(); + if trimmed.is_empty() { + "Notizbuch".to_string() + } else { + trimmed.to_string() + } +} + +/// Ermittelt das Standard-Dokumentenverzeichnis ($XDG_DOCUMENTS_DIR mit Fallback ~/Documents). +pub fn default_documents_dir() -> PathBuf { + if let Ok(dir) = std::env::var("XDG_DOCUMENTS_DIR") { + if !dir.trim().is_empty() { + return PathBuf::from(dir); + } + } + + if let Ok(home) = std::env::var("HOME") { + if !home.trim().is_empty() { + return PathBuf::from(home).join("Documents"); + } + } + + PathBuf::from(".") +} + +/// Ermittelt den kollisionsfreien Export-Pfad für ein Notizbuch im übergebenen Verzeichnis. +/// Existiert die Datei bereits, wird ein Zähler angehängt (`_1`, `_2`, etc.). +pub fn resolve_pdf_export_path_in_dir(dir: &Path, title: &str) -> PathBuf { + let sanitized_title = sanitize_filename_component(title); + let base_name = format!("{}.pdf", sanitized_title); + let primary_path = dir.join(&base_name); + + if !primary_path.exists() { + return primary_path; + } + + let mut counter = 1; + loop { + let candidate_name = format!("{}_{}.pdf", sanitized_title, counter); + let candidate_path = dir.join(candidate_name); + if !candidate_path.exists() { + return candidate_path; + } + counter += 1; + } +} + +/// Ermittelt das Ziel für den PDF-Export unter Berücksichtigung von XDG_DOCUMENTS_DIR +/// und automatischer Namenskollisionsvermeidung. Erstellt das Verzeichnis, falls nötig. +pub fn resolve_pdf_export_path(title: &str) -> PathBuf { + let docs_dir = default_documents_dir(); + let _ = std::fs::create_dir_all(&docs_dir); + resolve_pdf_export_path_in_dir(&docs_dir, title) +} + /// Wählbare Exportstrategie für Vektorstriche. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ExportStrategy { @@ -819,4 +883,43 @@ mod tests { assert!(bytes.len() > 1024); assert!(bytes.starts_with(b"%PDF-1.")); } + + #[test] + fn test_sanitize_filename_component() { + assert_eq!(sanitize_filename_component("Notizbuch 1"), "Notizbuch 1"); + assert_eq!( + sanitize_filename_component("Projekt/2026: Entwurf"), + "Projekt_2026_ Entwurf" + ); + assert_eq!(sanitize_filename_component(" "), "Notizbuch"); + assert_eq!(sanitize_filename_component(""), "Notizbuch"); + assert_eq!( + sanitize_filename_component("Mathe * Vorlesung?"), + "Mathe _ Vorlesung_" + ); + } + + #[test] + fn test_resolve_pdf_export_path_collision_avoidance() { + let temp_dir = tempfile::tempdir().expect("Konnte tempdir nicht erstellen"); + let dir = temp_dir.path(); + + // 1. Erstmaliger Export: keine Kollision + let path1 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung"); + assert_eq!(path1, dir.join("Mathe Vorlesung.pdf")); + + // Datei anlegen + std::fs::write(&path1, b"dummy pdf 1").expect("Konnte Datei nicht schreiben"); + + // 2. Zweiter Export: Kollision -> _1 + let path2 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung"); + assert_eq!(path2, dir.join("Mathe Vorlesung_1.pdf")); + + // Zweite Datei anlegen + std::fs::write(&path2, b"dummy pdf 2").expect("Konnte Datei nicht schreiben"); + + // 3. Dritter Export: Kollision -> _2 + let path3 = resolve_pdf_export_path_in_dir(dir, "Mathe Vorlesung"); + assert_eq!(path3, dir.join("Mathe Vorlesung_2.pdf")); + } } diff --git a/src/main.rs b/src/main.rs index 358024a..76c5736 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use glam::Vec2; use ink_notes::autosave::{AutosaveController, AutosaveStatus}; use ink_notes::canvas::CanvasState; use ink_notes::document::{Notebook, PageBackground}; -use ink_notes::export::{export_notebook_to_pdf, ExportStrategy}; +use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::selection::{ render_selection_overlay, DragMode, SelectionHandleHit, SelectionState, @@ -181,8 +181,10 @@ fn main() -> Result<(), Box> { println!(" Strg + 0 : Zoom zurücksetzen (100% zentriert)"); println!(" Draw-and-Hold : Form am Strichende >400ms halten zum automatischen Einrasten"); println!(" Strg + Z / Y : Transaktionales Undo / Redo für alle Aktionen"); - println!(" Strg + E / S : Vektor-PDF-Export (nach ./export.pdf)"); - println!(" ESC / Q : Beenden (mit sicherem Disk-Flush)"); + println!(" Strg + E : Vektor-PDF-Export (~/Documents/.pdf)"); + println!(" Strg + S : Jetzt speichern (Disk Flush)"); + println!(" ESC : Dialoge / Menüs schließen oder Selektion aufheben"); + println!(" Strg + Q : Beenden (mit sicherem Disk-Flush)"); event_loop.run(move |event, target| { match event { @@ -310,9 +312,29 @@ fn main() -> Result<(), Box> { match &logical_key { Key::Named(NamedKey::Escape) => { - println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); - autosave.shutdown(); - target.exit(); + let mut changed = false; + if ui_state.is_pen_menu_open + || ui_state.is_highlighter_menu_open + || ui_state.show_page_jump_popup + || ui_state.is_document_menu_open + || ui_state.is_update_dialog_open + || ui_state.show_updater_config + { + ui_state.is_pen_menu_open = false; + ui_state.is_highlighter_menu_open = false; + ui_state.show_page_jump_popup = false; + ui_state.is_document_menu_open = false; + ui_state.is_update_dialog_open = false; + ui_state.show_updater_config = false; + changed = true; + } + else if !selection_state.is_empty() { + selection_state.clear(); + changed = true; + } + if changed { + window.request_redraw(); + } } // Undo: Strg + Z @@ -383,45 +405,58 @@ fn main() -> Result<(), Box> { } } - // PDF-Export: Strg + E oder Strg + S - Key::Character(c) - if (c.eq_ignore_ascii_case("e") || c.eq_ignore_ascii_case("s")) - && ctrl => - { + // 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"); + update_window_title( + &window, + ¬ebook, + &manifest, + ui_state.active_tool, + autosave.status(), + ); + window.request_redraw(); + } + + // PDF-Export: Strg + E + Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { let notebook_clone = notebook.clone(); - let export_path = "export.pdf".to_string(); + 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 + export_path_str ); - ui_state.set_export_message("Exportiere PDF..."); + 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, + &export_path_thread, ExportStrategy::FilledPolygon, ) { Ok(()) => { - let elapsed = start.elapsed(); - println!( - "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", - export_path, - elapsed, - notebook_clone.pages.len() - ); - window_ref.request_redraw(); - } - Err(err) => { - eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"); - } - } - }); - ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!"); - window.request_redraw(); + 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(); } + // Radierer umschalten: E Key::Character(c) if c.eq_ignore_ascii_case("e") && !ctrl => { ui_state.active_tool = match ui_state.active_tool { @@ -618,13 +653,14 @@ fn main() -> Result<(), Box> { window.request_redraw(); } - // Beenden: Q - Key::Character(c) if c.eq_ignore_ascii_case("q") && !ctrl => { + // Beenden: Strg + Q + Key::Character(c) if c.eq_ignore_ascii_case("q") && ctrl => { println!("[Shutdown] Sichere alle ungespeicherten Daten auf Festplatte..."); autosave.shutdown(); target.exit(); } + _ => {} } } @@ -1498,24 +1534,27 @@ fn main() -> Result<(), Box> { } UiAction::ExportPdf => { let notebook_clone = notebook.clone(); - let export_path = "export.pdf".to_string(); + 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 + 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, + &export_path_thread, ExportStrategy::FilledPolygon, ) { Ok(()) => { let elapsed = start.elapsed(); println!( "[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)", - export_path, + export_path_thread.display(), elapsed, notebook_clone.pages.len() ); @@ -1526,9 +1565,9 @@ fn main() -> Result<(), Box> { } } }); - ui_state.set_export_message("PDF erfolgreich als export.pdf exportiert!"); window.request_redraw(); } + UiAction::RefreshNotebooks => { ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); window.request_redraw();