From 36a1a3cd2e35030c3038044180daa3c7854942db Mon Sep 17 00:00:00 2001 From: Harald Date: Thu, 8 Oct 2026 17:41:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Ordner=20f=C3=BCr=20Notizb=C3=BCcher=20?= =?UTF-8?q?(Issue=20#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Beliebig verschachtelte Ordner (z. B. 3. Semester › Mathe) im Dokumenten-Manager: Pfadleiste zum Navigieren, Ordner anlegen, umbenennen, löschen (Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben - Neue Notizbücher landen im angezeigten Ordner, importierte PDFs neben dem geöffneten Notizbuch; der Manager öffnet im Ordner des aktuellen Notizbuchs - Ordner sind logisch (library.json im Notizbuch-Basisordner), Notizbuch- Dateien werden nie verschoben; fehlt die Datei, liegt alles oben - Die ganze Titelfläche einer Zeile öffnet Notizbuch bzw. Ordner - Ein ganz neues Standard-Notizbuch wird sofort gespeichert (erschien sonst bis zur ersten Änderung als „beschädigt“) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 + docs/FILE_FORMAT.md | 16 ++ src/lib.rs | 1 + src/library.rs | 376 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 133 ++++++++++++-- src/ui.rs | 435 +++++++++++++++++++++++++++++++++++++------- 6 files changed, 880 insertions(+), 83 deletions(-) create mode 100644 src/library.rs diff --git a/README.md b/README.md index 795d48b..601b47c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Getestet wird vor allem unter Zorin OS 18 / Ubuntu 24.04 (Wayland und X11). ### Notizbücher und Speichern - Mehrere **Notizbücher** anlegen, umbenennen, wechseln und löschen (gelöschte landen zunächst im Papierkorb und lassen sich wiederherstellen). +- **Ordner** für Notizbücher, beliebig verschachtelt (z. B. 3. Semester › Mathe): anlegen, umbenennen, löschen (der Inhalt rückt eine Ebene nach oben) und Notizbücher mit 📂 verschieben. Neue Notizbücher landen im gerade geöffneten Ordner. - **Automatisches Speichern** im Hintergrund: 1,5 Sekunden nach der letzten Änderung, bei ununterbrochenem Schreiben spätestens alle 10 Sekunden. Zeichnen wird dabei nicht unterbrochen. - Jede Datei wird erst vollständig in eine temporäre Datei geschrieben und dann ersetzt – ein Absturz beim Speichern hinterlässt keine halbe Datei. - **Beim Schließen** (Fenster oder `Strg + Q`) und beim Wechsel des Notizbuchs wird alles Ausstehende geschrieben, bevor es weitergeht. Schlägt das fehl, bleibt die App offen und bietet Wiederholen oder eine Notfall-Sicherung an. @@ -162,6 +163,7 @@ src/ ├── document.rs # Datenmodell: Notizbuch, Seiten, Striche, Seitengrößen ├── history.rs # Rückgängig/Wiederholen ├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb +├── library.rs # Ordner der Notizbücher (library.json) ├── autosave.rs # Speichern im Hintergrund ├── export.rs # PDF-Export (printpdf) ├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index e5e9fca..5eba2c7 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -9,6 +9,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die ```text ~/.local/share/ink-notes/ ($XDG_DATA_HOME/ink-notes) ├── notebooks/ +│ ├── library.json Ordner der Notizbücher (siehe unten) │ ├── / │ │ ├── manifest.json Metadaten und Seitenreihenfolge │ │ ├── manifest.json.bak Kopie des vorherigen Manifests @@ -29,6 +30,21 @@ Dieses Dokument beschreibt, wo und wie ink-notes Notizbücher speichert, und die - **Assets** sind nach dem SHA-256-Hash ihres Inhalts benannt; dasselbe Bild wird nur einmal gespeichert. Bilder werden als PNG oder (unverändert) JPEG abgelegt, PDFs unverändert. - Alle Dateien werden mit Rechten `0600`, Ordner mit `0700` angelegt. +### Ordner (`library.json`) +Ordner wie „3. Semester › Mathe“ sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen, `library.json` hält nur fest, welcher Ordner welches Notizbuch enthält. Umbenennen, Verschieben und Löschen von Ordnern ändern deshalb nie eine Notizbuch-Datei. + +```json +{ + "folders": ["3. Semester", "3. Semester/Mathe", "4. Semester"], + "notebooks": { "analysis_1775460000": "3. Semester/Mathe" } +} +``` + +- Ordner sind Pfade mit `/` als Trenner; Namen enthalten selbst kein `/` und höchstens 80 Zeichen. +- Notizbücher ohne Eintrag liegen auf der obersten Ebene. +- Wird ein Ordner gelöscht, rücken seine Notizbücher und Unterordner eine Ebene nach oben. +- Fehlt die Datei oder ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene; fehlende übergeordnete Ordner werden beim Laden ergänzt. + --- ## 2. Manifest (`manifest.json`) diff --git a/src/lib.rs b/src/lib.rs index 3707a17..c0d18fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod filter; pub mod geometry; pub mod history; pub mod image_layer; +pub mod library; pub mod page_export; pub mod pdf_doc; pub mod pdf_merge; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..37d6ea2 --- /dev/null +++ b/src/library.rs @@ -0,0 +1,376 @@ +//! Ordner für Notizbücher (`/library.json`). +//! +//! Ordner sind rein logisch: Die Notizbücher bleiben in ihren Verzeichnissen +//! `//`, `library.json` hält nur fest, welcher Ordner welches Notizbuch +//! enthält. Umbenennen und Verschieben ändern so nie Notizbuch-Dateien. Fehlt die Datei oder +//! ist sie unlesbar, liegen alle Notizbücher auf der obersten Ebene – verloren geht nichts. +//! +//! Ordner werden als Pfade mit `/` als Trenner gespeichert (z. B. `3. Semester/Mathe`); +//! die oberste Ebene ist der leere Pfad. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::storage::{self, StorageError}; + +/// Oberste Ebene. +pub const ROOT: &str = ""; + +/// Längster erlaubter Ordnername (Zeichen). +const MAX_NAME_CHARS: usize = 80; + +/// Ordnerbaum und Zuordnung der Notizbücher. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Library { + /// Alle Ordner als Pfade + pub folders: BTreeSet, + /// Ordner je Notizbuch-Kennung; Notizbücher ohne Eintrag liegen auf oberster Ebene + pub notebooks: BTreeMap, +} + +/// Letzter Teil eines Ordnerpfads (der angezeigte Name). +pub fn folder_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +/// Übergeordneter Ordner (oberste Ebene für Ordner der obersten Ebene). +pub fn parent_folder(path: &str) -> &str { + path.rsplit_once('/').map_or(ROOT, |(parent, _)| parent) +} + +/// Pfad des Unterordners `name` in `parent`. +fn join(parent: &str, name: &str) -> String { + if parent.is_empty() { + name.to_string() + } else { + format!("{parent}/{name}") + } +} + +/// `path` liegt in `folder` oder ist `folder` selbst. +fn is_within(path: &str, folder: &str) -> bool { + path == folder + || path + .strip_prefix(folder) + .is_some_and(|rest| rest.starts_with('/')) +} + +/// Bereinigt einen Ordnernamen; Fehlermeldung für die Oberfläche bei ungültigen Namen. +pub fn validate_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("Der Ordnername ist leer".to_string()); + } + if name.contains('/') { + return Err("Ordnernamen dürfen kein „/“ enthalten".to_string()); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(format!( + "Der Ordnername ist zu lang (höchstens {MAX_NAME_CHARS} Zeichen)" + )); + } + Ok(name.to_string()) +} + +impl Library { + /// Ort von `library.json` im Basisordner der Notizbücher. + pub fn file_path(base_dir: &Path) -> PathBuf { + base_dir.join("library.json") + } + + /// Lädt die Ordner; fehlt die Datei oder ist sie beschädigt, gibt es keine Ordner. + pub fn load(base_dir: &Path) -> Self { + let path = Self::file_path(base_dir); + let mut library: Self = match std::fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { + eprintln!( + "[Ordner] '{}' ist ungültig ({e}), alle Notizbücher auf oberster Ebene.", + path.display() + ); + Self::default() + }), + Err(_) => Self::default(), + }; + library.repair(); + library + } + + /// Speichert die Ordner atomar. + pub fn save(&self, base_dir: &Path) -> Result<(), StorageError> { + storage::atomic_write_json(&Self::file_path(base_dir), self) + } + + /// Macht eine (evtl. von Hand bearbeitete) Datei stimmig: leere oder ungültige Namen + /// fallen weg, jeder benutzte Ordner und seine übergeordneten Ordner existieren. + fn repair(&mut self) { + let valid = |path: &str| { + !path.is_empty() + && path + .split('/') + .all(|name| validate_name(name).as_deref() == Ok(name)) + }; + self.folders.retain(|f| valid(f)); + self.notebooks.retain(|_, f| f.is_empty() || valid(f)); + let used: Vec = self + .folders + .iter() + .chain(self.notebooks.values()) + .cloned() + .collect(); + for mut folder in used { + while !folder.is_empty() { + let parent = parent_folder(&folder).to_string(); + self.folders.insert(folder); + folder = parent; + } + } + } + + /// Ordner, in dem das Notizbuch liegt. + pub fn folder_of(&self, notebook_id: &str) -> &str { + self.notebooks.get(notebook_id).map_or(ROOT, String::as_str) + } + + /// Direkte Unterordner von `parent` (vollständige Pfade, nach Name sortiert). + pub fn subfolders(&self, parent: &str) -> Vec<&str> { + let mut list: Vec<&str> = self + .folders + .iter() + .map(String::as_str) + .filter(|f| parent_folder(f) == parent) + .collect(); + list.sort_by_key(|f| folder_name(f).to_lowercase()); + list + } + + /// Alle Ordner in Baumreihenfolge (Eltern vor Kindern, Geschwister nach Name). + pub fn all_folders(&self) -> Vec<&str> { + let mut out = Vec::with_capacity(self.folders.len()); + let mut stack: Vec<&str> = self.subfolders(ROOT).into_iter().rev().collect(); + while let Some(folder) = stack.pop() { + out.push(folder); + stack.extend(self.subfolders(folder).into_iter().rev()); + } + out + } + + /// Legt in `parent` den Ordner `name` an und gibt seinen Pfad zurück. + pub fn create_folder(&mut self, parent: &str, name: &str) -> Result { + let name = validate_name(name)?; + if !parent.is_empty() && !self.folders.contains(parent) { + return Err("Der übergeordnete Ordner existiert nicht mehr".to_string()); + } + let path = join(parent, &name); + if !self.folders.insert(path.clone()) { + return Err(format!("Den Ordner „{name}“ gibt es hier schon")); + } + Ok(path) + } + + /// Benennt einen Ordner um (mitsamt Unterordnern und Zuordnungen); gibt den neuen Pfad + /// zurück. + pub fn rename_folder(&mut self, path: &str, new_name: &str) -> Result { + let new_name = validate_name(new_name)?; + if !self.folders.contains(path) { + return Err("Den Ordner gibt es nicht mehr".to_string()); + } + let new_path = join(parent_folder(path), &new_name); + if new_path == path { + return Ok(new_path); + } + if self.folders.contains(&new_path) { + return Err(format!("Den Ordner „{new_name}“ gibt es hier schon")); + } + self.move_contents(path, &new_path); + Ok(new_path) + } + + /// Löscht einen Ordner. Seine Notizbücher und Unterordner rücken eine Ebene nach oben + /// (gleichnamige Unterordner werden zusammengelegt) – gelöscht wird kein Notizbuch. + pub fn delete_folder(&mut self, path: &str) { + if path.is_empty() || !self.folders.remove(path) { + return; + } + let parent = parent_folder(path).to_string(); + let prefix = format!("{path}/"); + let moved: Vec = self + .folders + .iter() + .filter(|f| f.starts_with(&prefix)) + .cloned() + .collect(); + for folder in moved { + self.folders.remove(&folder); + self.folders.insert(join(&parent, &folder[prefix.len()..])); + } + for folder in self.notebooks.values_mut() { + if folder == path { + *folder = parent.clone(); + } else if let Some(rest) = folder.strip_prefix(&prefix) { + *folder = join(&parent, rest); + } + } + self.notebooks.retain(|_, f| !f.is_empty()); + } + + /// Verschiebt einen Ordner samt Inhalt nach `new_path`. + fn move_contents(&mut self, path: &str, new_path: &str) { + let renamed = |f: &str| format!("{new_path}{}", &f[path.len()..]); + let moved: Vec = self + .folders + .iter() + .filter(|f| is_within(f, path)) + .cloned() + .collect(); + for folder in moved { + self.folders.remove(&folder); + self.folders.insert(renamed(&folder)); + } + for folder in self.notebooks.values_mut() { + if is_within(folder, path) { + *folder = renamed(folder); + } + } + } + + /// Legt das Notizbuch in `folder` (oberste Ebene: [`ROOT`]). + pub fn move_notebook(&mut self, notebook_id: &str, folder: &str) -> Result<(), String> { + if folder.is_empty() { + self.notebooks.remove(notebook_id); + return Ok(()); + } + if !self.folders.contains(folder) { + return Err("Den Ordner gibt es nicht mehr".to_string()); + } + self.notebooks + .insert(notebook_id.to_string(), folder.to_string()); + Ok(()) + } + + /// Wie viele der Notizbücher `ids` in `folder` liegen, Unterordner eingeschlossen. + pub fn count_within<'a>(&self, folder: &str, ids: impl IntoIterator) -> usize { + ids.into_iter() + .filter(|id| is_within(self.folder_of(id), folder)) + .count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn library() -> Library { + let mut lib = Library::default(); + let sem3 = lib.create_folder(ROOT, "3. Semester").unwrap(); + let mathe = lib.create_folder(&sem3, "Mathe").unwrap(); + lib.create_folder(&sem3, "Physik").unwrap(); + lib.move_notebook("analysis", &mathe).unwrap(); + lib.move_notebook("uebung", &sem3).unwrap(); + lib + } + + #[test] + fn test_paths_and_names() { + assert_eq!(folder_name("3. Semester/Mathe"), "Mathe"); + assert_eq!(folder_name("Mathe"), "Mathe"); + assert_eq!(parent_folder("3. Semester/Mathe"), "3. Semester"); + assert_eq!(parent_folder("Mathe"), ROOT); + assert_eq!(validate_name(" Mathe ").unwrap(), "Mathe"); + assert!(validate_name(" ").is_err()); + assert!(validate_name("a/b").is_err()); + assert!(validate_name(&"x".repeat(81)).is_err()); + } + + #[test] + fn test_create_and_list_folders() { + let mut lib = library(); + assert_eq!(lib.subfolders(ROOT), ["3. Semester"]); + assert_eq!( + lib.subfolders("3. Semester"), + ["3. Semester/Mathe", "3. Semester/Physik"] + ); + assert_eq!( + lib.all_folders(), + ["3. Semester", "3. Semester/Mathe", "3. Semester/Physik"] + ); + assert_eq!(lib.folder_of("analysis"), "3. Semester/Mathe"); + assert_eq!(lib.folder_of("unbekannt"), ROOT); + // Doppelte Namen und fehlende Eltern werden abgelehnt + assert!(lib.create_folder("3. Semester", " Mathe ").is_err()); + assert!(lib.create_folder("Gibt es nicht", "X").is_err()); + // Gleicher Name in einem anderen Ordner ist erlaubt + lib.create_folder(ROOT, "4. Semester").unwrap(); + assert!(lib.create_folder("4. Semester", "Mathe").is_ok()); + assert_eq!( + lib.count_within("3. Semester", ["analysis", "uebung", "lose"]), + 2 + ); + } + + #[test] + fn test_rename_folder_keeps_contents() { + let mut lib = library(); + let new = lib.rename_folder("3. Semester", "WS 25-26").unwrap(); + assert_eq!(new, "WS 25-26"); + assert_eq!(lib.folder_of("analysis"), "WS 25-26/Mathe"); + assert_eq!(lib.folder_of("uebung"), "WS 25-26"); + assert!(lib.folders.contains("WS 25-26/Physik")); + assert!(!lib.folders.iter().any(|f| f.starts_with("3. Semester"))); + // Ein Ordner mit ähnlichem Namen bleibt unberührt + let mut lib = library(); + lib.create_folder(ROOT, "3. Semester alt").unwrap(); + lib.rename_folder("3. Semester", "Drei").unwrap(); + assert!(lib.folders.contains("3. Semester alt")); + // Kollision + assert!(lib.rename_folder("Drei", "3. Semester alt").is_err()); + } + + #[test] + fn test_delete_folder_moves_contents_up() { + let mut lib = library(); + lib.delete_folder("3. Semester"); + assert_eq!(lib.folder_of("analysis"), "Mathe"); + assert_eq!(lib.folder_of("uebung"), ROOT); + assert!(!lib.notebooks.contains_key("uebung")); + assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]); + } + + #[test] + fn test_move_notebook() { + let mut lib = library(); + lib.move_notebook("uebung", "3. Semester/Physik").unwrap(); + assert_eq!(lib.folder_of("uebung"), "3. Semester/Physik"); + lib.move_notebook("uebung", ROOT).unwrap(); + assert_eq!(lib.folder_of("uebung"), ROOT); + assert!(lib.move_notebook("uebung", "Gibt es nicht").is_err()); + } + + #[test] + fn test_load_save_and_repair() { + let dir = tempfile::tempdir().unwrap(); + // Fehlende Datei: keine Ordner + assert_eq!(Library::load(dir.path()), Library::default()); + + let lib = library(); + lib.save(dir.path()).unwrap(); + assert_eq!(Library::load(dir.path()), lib); + + // Beschädigt: alles auf oberster Ebene statt Absturz + std::fs::write(Library::file_path(dir.path()), b"{ kaputt").unwrap(); + assert_eq!(Library::load(dir.path()), Library::default()); + + // Von Hand bearbeitet: fehlende Elternordner werden ergänzt, Unsinn fällt weg + std::fs::write( + Library::file_path(dir.path()), + br#"{"folders": ["A/B", "", "C//D"], "notebooks": {"x": "E/F", "y": "/kaputt"}}"#, + ) + .unwrap(); + let lib = Library::load(dir.path()); + assert_eq!(lib.all_folders(), ["A", "A/B", "E", "E/F"]); + assert_eq!(lib.folder_of("x"), "E/F"); + assert_eq!(lib.folder_of("y"), ROOT); + } +} diff --git a/src/main.rs b/src/main.rs index 7becd37..c17f769 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use ink_notes::document::{Notebook, PageBackground}; use ink_notes::export::{self, export_notebook_to_pdf, ExportStrategy}; use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::image_layer::TextureSource; +use ink_notes::library::Library; use ink_notes::pdf_doc::PdfRenderService; use ink_notes::platform::{self, DisplayBackend}; use ink_notes::selection::{ @@ -34,6 +35,36 @@ use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; use ink_notes::updater::UpdaterController; +/// Liest Notizbücher und Ordner für den Dokumenten-Manager neu ein. +fn refresh_notebooks(base_dir: &Path, ui_state: &mut UiState) { + ui_state.available_notebooks = storage::list_notebooks(base_dir); + ui_state.library = Library::load(base_dir); +} + +/// Ändert die Ordner (`library.json`) und liest sie neu ein; Fehler erscheinen als Meldung. +/// Gibt bei Erfolg das Ergebnis von `change` zurück. +fn change_library( + base_dir: &Path, + ui_state: &mut UiState, + change: impl FnOnce(&mut Library) -> Result, +) -> Option { + let mut library = Library::load(base_dir); + let result = change(&mut library).and_then(|value| { + library + .save(base_dir) + .map(|()| value) + .map_err(|e| format!("Ordner nicht gespeichert: {e}")) + }); + ui_state.library = Library::load(base_dir); + match result { + Ok(value) => Some(value), + Err(message) => { + ui_state.set_export_message(message); + None + } + } +} + /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. /// Speichert die UI-Einstellungen; ein Fehler wird gemeldet, bricht aber nichts ab. fn save_ui_settings(settings: &UiSettings, path: Option<&std::path::Path>, ui_state: &mut UiState) { @@ -1027,10 +1058,17 @@ fn main() -> Result<(), Box> { "[Storage] Erstelle neues Notizbuch in: {}", storage_dir.display() ); - ( - Notebook::new(), - NotebookManifest::new(&initial_id, &initial_title), - ) + let notebook = Notebook::new(); + let mut manifest = NotebookManifest::new(&initial_id, &initial_title); + // Ganz neu (z. B. erster Start): sofort speichern, sonst steht es bis zur ersten + // Änderung ohne Manifest als „beschädigt“ in der Notizbuchliste. Ein vorhandener + // Ordner ohne Manifest bleibt unangetastet. + if !storage_dir.exists() { + if let Err(err) = storage::save_notebook(&storage_dir, ¬ebook, &mut manifest) { + eprintln!("[Storage] Neues Notizbuch nicht gespeichert: {err}"); + } + } + (notebook, manifest) }; // 2. Asynchronen Autosave-Controller mit 1.5s Debounce starten @@ -1078,8 +1116,9 @@ 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; + ui_state.library = Library::load(&storage_base_dir); if let Some(warning) = startup_warning { - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message(warning); } let mut selection_state = SelectionState::new(); @@ -2969,8 +3008,60 @@ fn main() -> Result<(), Box> { window.request_redraw(); } + UiAction::CreateFolder { parent, name } => { + if let Some(path) = change_library(&storage_base_dir, &mut ui_state, |library| { + library.create_folder(&parent, &name) + }) { + ui_state.set_export_message(format!( + "Ordner „{}“ angelegt", + ink_notes::library::folder_name(&path) + )); + } + window.request_redraw(); + } + UiAction::RenameFolder { path, new_name } => { + if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| { + library.rename_folder(&path, &new_name) + }) { + // Der gezeigte Ordner kann darin liegen + let shown = &ui_state.notebook_folder; + if shown == &path || shown.starts_with(&format!("{path}/")) { + ui_state.notebook_folder = format!("{new_path}{}", &shown[path.len()..]); + } + } + window.request_redraw(); + } + UiAction::DeleteFolder(path) => { + if change_library(&storage_base_dir, &mut ui_state, |library| { + library.delete_folder(&path); + Ok(()) + }) + .is_some() + { + ui_state.set_export_message(format!( + "Ordner „{}“ gelöscht, sein Inhalt liegt eine Ebene höher", + ink_notes::library::folder_name(&path) + )); + } + window.request_redraw(); + } + UiAction::MoveNotebook { id, folder } => { + if change_library(&storage_base_dir, &mut ui_state, |library| { + library.move_notebook(&id, &folder) + }) + .is_some() + { + let target = if folder.is_empty() { + "die oberste Ebene".to_string() + } else { + format!("„{}“", ink_notes::library::folder_name(&folder)) + }; + ui_state.set_export_message(format!("Verschoben in {target}")); + } + window.request_redraw(); + } UiAction::RefreshNotebooks => { - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); window.request_redraw(); } UiAction::OpenNotebook(id) => { @@ -3006,7 +3097,7 @@ fn main() -> Result<(), Box> { 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); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.is_document_menu_open = false; canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -3023,6 +3114,8 @@ fn main() -> Result<(), Box> { } } UiAction::ImportPdf(path) => { + // Neben das gerade geöffnete Notizbuch + let folder = ui_state.library.folder_of(&manifest.id).to_string(); let title = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) @@ -3032,6 +3125,10 @@ fn main() -> Result<(), Box> { .and_then(|bytes| storage::create_notebook_from_pdf(&storage_base_dir, &title, bytes)); match result { Ok((new_nb, new_manifest, new_dir)) => { + let id = new_manifest.id.clone(); + change_library(&storage_base_dir, &mut ui_state, |library| { + library.move_notebook(&id, &folder) + }); let _ = autosave.flush(); _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); autosave.set_read_only(_notebook_lock.is_none()); @@ -3042,7 +3139,7 @@ fn main() -> Result<(), Box> { 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); + refresh_notebooks(&storage_base_dir, &mut ui_state); canvas.reset_view(); canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -3059,10 +3156,14 @@ fn main() -> Result<(), Box> { } window.request_redraw(); } - UiAction::CreateNotebook(title) => { + UiAction::CreateNotebook { title, folder } => { let _ = autosave.flush(); match storage::create_new_notebook(&storage_base_dir, &title) { Ok((new_nb, new_manifest, new_dir)) => { + let id = new_manifest.id.clone(); + change_library(&storage_base_dir, &mut ui_state, |library| { + library.move_notebook(&id, &folder) + }); _notebook_lock = storage::NotebookLock::acquire(&new_dir).ok(); autosave.set_read_only(_notebook_lock.is_none()); autosave.switch_notebook_dir(new_dir.clone()); @@ -3072,7 +3173,7 @@ fn main() -> Result<(), Box> { 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); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.is_document_menu_open = false; canvas.sync_page(notebook.current_page_mut()); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -3091,7 +3192,7 @@ fn main() -> Result<(), Box> { match storage::rename_notebook(&target_dir, &mut manifest, &new_title) { Ok(()) => { ui_state.document_title = manifest.title.clone(); - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); manifest.sync_from_notebook(¬ebook); autosave.mark_manifest_dirty(&manifest); update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status()); @@ -3108,7 +3209,7 @@ fn main() -> Result<(), Box> { if let Ok(content) = std::fs::read_to_string(&manifest_path) { if let Ok(mut other_manifest) = serde_json::from_str::(&content) { let _ = storage::rename_notebook(&target_dir, &mut other_manifest, &new_title); - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); window.request_redraw(); } @@ -3133,7 +3234,7 @@ fn main() -> Result<(), Box> { match storage::delete_notebook(&target_dir, &storage_base_dir) { Ok(trash_path) => { ui_state.last_deleted_notebook = Some((trash_path, deleted_title)); - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); if is_current { if let Some(first) = ui_state .available_notebooks @@ -3171,7 +3272,7 @@ fn main() -> Result<(), Box> { 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); + refresh_notebooks(&storage_base_dir, &mut ui_state); 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()); @@ -3191,7 +3292,7 @@ fn main() -> Result<(), Box> { if let Some((trash_path, _title)) = ui_state.last_deleted_notebook.take() { match storage::restore_notebook_from_trash(&storage_base_dir, &trash_path) { Ok(_restored_dir) => { - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message("Notizbuch wiederhergestellt".to_string()); window.request_redraw(); } @@ -3206,7 +3307,7 @@ fn main() -> Result<(), Box> { let target_dir = storage_base_dir.join(&id); match storage::restore_manifest_from_backup(&target_dir) { Ok(()) => { - ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); + refresh_notebooks(&storage_base_dir, &mut ui_state); ui_state.set_export_message("Manifest aus Backup wiederhergestellt".to_string()); window.request_redraw(); } diff --git a/src/ui.rs b/src/ui.rs index 830c6c2..1994d4f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -228,7 +228,26 @@ pub enum UiAction { RotateSelected, // Dokumenten- & Notizbuch-Verwaltung OpenNotebook(String), - CreateNotebook(String), + /// Neues Notizbuch im Ordner `folder` anlegen und öffnen + CreateNotebook { + title: String, + folder: String, + }, + /// Ordnerverwaltung (siehe `library.rs`) + CreateFolder { + parent: String, + name: String, + }, + RenameFolder { + path: String, + new_name: String, + }, + /// Ordner löschen; Inhalt rückt eine Ebene nach oben + DeleteFolder(String), + MoveNotebook { + id: String, + folder: String, + }, RenameNotebook { id: String, new_title: String, @@ -306,6 +325,14 @@ pub struct UiState { pub available_notebooks: Vec, pub new_notebook_title_input: String, pub renaming_notebook_id: Option, + /// Ordner der Notizbücher und der gerade angezeigte Ordner im Dokumenten-Manager + pub library: crate::library::Library, + pub notebook_folder: String, + /// Ordner, der gerade umbenannt wird, bzw. dessen Löschen bestätigt werden soll + pub renaming_folder: Option, + pub confirm_delete_folder: Option, + /// Notizbuch, für das gerade ein Zielordner gewählt wird + pub moving_notebook_id: Option, pub renaming_title_input: String, pub document_modal_rect: Option, /// Gitea Release- & Update-Manager @@ -408,6 +435,11 @@ impl Default for UiState { available_notebooks: Vec::new(), new_notebook_title_input: String::new(), renaming_notebook_id: None, + library: crate::library::Library::default(), + notebook_folder: String::new(), + renaming_folder: None, + confirm_delete_folder: None, + moving_notebook_id: None, renaming_title_input: String::new(), document_modal_rect: None, is_update_dialog_open: false, @@ -1670,6 +1702,216 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option) { + let name = crate::library::folder_name(path); + if state.renaming_folder.as_deref() == Some(path) { + ui.horizontal(|ui| { + let edit = ui.add( + egui::TextEdit::singleline(&mut state.renaming_title_input) + .margin(egui::vec2(8.0, 8.0)) + .desired_width(260.0), + ); + if ui.button("✔").clicked() + || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + actions.push(UiAction::RenameFolder { + path: path.to_string(), + new_name: state.renaming_title_input.trim().to_string(), + }); + state.renaming_folder = None; + } + if ui.button("🗙").clicked() { + state.renaming_folder = None; + } + }); + return; + } + if state.confirm_delete_folder.as_deref() == Some(path) { + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(170, 50, 45, 40)) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.add( + egui::Label::new(format!( + "Ordner „{name}“ löschen? Seine Notizbücher und Unterordner rücken eine \ + Ebene nach oben, gelöscht wird kein Notizbuch." + )) + .wrap(true), + ); + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("Ordner löschen").color(egui::Color32::WHITE), + ) + .clicked() + { + actions.push(UiAction::DeleteFolder(path.to_string())); + state.confirm_delete_folder = None; + } + if ui.button("Abbrechen").clicked() { + state.confirm_delete_folder = None; + } + }); + }); + }); + return; + } + + let ids = state.available_notebooks.iter().map(|s| s.id.as_str()); + let count = state.library.count_within(path, ids); + let mut open = false; + egui::Frame::none() + .fill(egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 16), + )) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| { + // Erst die Knöpfe (rechts), der Name füllt den Rest und öffnet den Ordner + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button( + egui::RichText::new("🗑") + .size(16.0) + .color(egui::Color32::from_rgb(255, 120, 120)), + ) + .on_hover_text("Ordner löschen (die Notizbücher bleiben erhalten)") + .clicked() + { + state.confirm_delete_folder = Some(path.to_string()); + } + if ui + .button(egui::RichText::new("✏").size(16.0)) + .on_hover_text("Ordner umbenennen") + .clicked() + { + state.renaming_folder = Some(path.to_string()); + state.renaming_title_input = name.to_string(); + } + let title_area = ui + .with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.vertical(|ui| { + ui.set_min_width(ui.available_width()); + ui.label( + egui::RichText::new(format!("📁 {name}")) + .strong() + .size(13.0) + .color(egui::Color32::WHITE), + ); + ui.label( + egui::RichText::new(match count { + 1 => "1 Notizbuch".to_string(), + n => format!("{n} Notizbücher"), + }) + .size(11.0) + .color(egui::Color32::from_gray(160)), + ); + }) + }) + .inner; + open = title_area + .response + .interact(egui::Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked(); + }); + }); + if open { + state.notebook_folder = path.to_string(); + } +} + +/// Zielordner für das Verschieben eines Notizbuchs: oberste Ebene und alle Ordner als Baum. +fn move_target_list( + ui: &mut egui::Ui, + state: &mut UiState, + notebook_id: &str, + actions: &mut Vec, +) { + let title = state + .available_notebooks + .iter() + .find(|s| s.id == notebook_id) + .map_or(notebook_id, |s| s.title.as_str()) + .to_string(); + let current = state.library.folder_of(notebook_id).to_string(); + ui.label(egui::RichText::new(format!("„{title}“ verschieben nach:")).strong()); + ui.add_space(4.0); + let mut targets = vec![(String::new(), "📚 Oberste Ebene".to_string(), 0)]; + for path in state.library.all_folders() { + let depth = path.matches('/').count() + 1; + targets.push(( + path.to_string(), + format!("📁 {}", crate::library::folder_name(path)), + depth, + )); + } + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for (path, label, depth) in targets { + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 18.0); + let here = path == current; + if ui + .add_enabled(!here, egui::Button::new(label)) + .on_disabled_hover_text("Liegt bereits hier") + .clicked() + { + actions.push(UiAction::MoveNotebook { + id: notebook_id.to_string(), + folder: path.clone(), + }); + state.moving_notebook_id = None; + } + }); + } + }); + ui.add_space(6.0); + if ui.button("Abbrechen").clicked() { + state.moving_notebook_id = None; + } +} + /// „Seite löschen“ mit Rückfrage im ☰-Menü und „Gelöschte Seite wiederherstellen“. fn delete_page_buttons( ui: &mut egui::Ui, @@ -2288,6 +2530,14 @@ pub fn render_ui( state.close_overlays(); state.is_document_menu_open = open; if open { + // Im Ordner des geöffneten Notizbuchs beginnen + state.notebook_folder = state + .library + .folder_of(&state.current_notebook_id) + .to_string(); + state.renaming_folder = None; + state.confirm_delete_folder = None; + state.moving_notebook_id = None; actions.push(UiAction::RefreshNotebooks); } } @@ -3505,51 +3755,89 @@ pub fn render_ui( state.is_document_menu_open = false; } - // Neues Dokument anlegen - ui.label( - egui::RichText::new("NEUES NOTIZBUCH ERSTELLEN") - .size(10.5) - .strong() - .color(egui::Color32::from_gray(160)), - ); - ui.add_space(4.0); + // Gezeigter Ordner (gibt es ihn nicht mehr, die oberste Ebene) + if !state.notebook_folder.is_empty() + && !state.library.folders.contains(&state.notebook_folder) + { + state.notebook_folder.clear(); + } + let folder = state.notebook_folder.clone(); + folder_breadcrumbs(ui, state, &folder); + ui.add_space(6.0); + + // Neues Notizbuch oder neuer Ordner hier ui.horizontal(|ui| { let text_edit = ui.add( egui::TextEdit::singleline(&mut state.new_notebook_title_input) - .hint_text("Titel des neuen Notizbuchs …") + .hint_text("Name …") .margin(egui::vec2(8.0, 8.0)) - .desired_width(270.0), + .desired_width(190.0), ); - let create_btn = ui.button(egui::RichText::new("➕ Erstellen").strong()); - let submit = create_btn.clicked() - || (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - - if submit { - let title = state.new_notebook_title_input.trim(); - if !title.is_empty() { - actions.push(UiAction::CreateNotebook(title.to_string())); + let create_btn = ui + .button(egui::RichText::new("➕ Notizbuch").strong()) + .on_hover_text("Neues Notizbuch in diesem Ordner anlegen und öffnen"); + let folder_btn = ui + .button(egui::RichText::new("📁 Ordner").strong()) + .on_hover_text("Neuen Ordner hier anlegen (z. B. Semester oder Fach)"); + let title = state.new_notebook_title_input.trim().to_string(); + if !title.is_empty() { + if create_btn.clicked() + || (text_edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + actions.push(UiAction::CreateNotebook { + title, + folder: folder.clone(), + }); + state.new_notebook_title_input.clear(); + } else if folder_btn.clicked() { + actions.push(UiAction::CreateFolder { + parent: folder.clone(), + name: title, + }); state.new_notebook_title_input.clear(); } } }); - ui.add_space(12.0); - ui.separator(); ui.add_space(8.0); + ui.separator(); + ui.add_space(6.0); - // Liste vorhandener Dokumente - ui.label( - egui::RichText::new("VORHANDENE DOKUMENTE") - .size(10.5) - .strong() - .color(egui::Color32::from_gray(160)), - ); - ui.add_space(4.0); + // Zielordner für „Verschieben“ wählen + if let Some(moving) = state.moving_notebook_id.clone() { + move_target_list(ui, state, &moving, &mut actions); + return; + } - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for summary in &state.available_notebooks { + let summaries: Vec = state + .available_notebooks + .iter() + .filter(|s| state.library.folder_of(&s.id) == folder) + .cloned() + .collect(); + let subfolders: Vec = state + .library + .subfolders(&folder) + .into_iter() + .map(str::to_string) + .collect(); + + egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| { + for path in &subfolders { + ui.push_id(path, |ui| { + ui.horizontal(|ui| folder_row(ui, state, path, &mut actions)) + }); + ui.add_space(4.0); + } + if subfolders.is_empty() && summaries.is_empty() { + ui.label( + egui::RichText::new("Dieser Ordner ist leer.") + .color(egui::Color32::from_gray(150)), + ); + } + for summary in &summaries { let is_current = summary.id == state.current_notebook_id; - ui.horizontal(|ui| { + ui.push_id(&summary.id, |ui| ui.horizontal(|ui| { ui.spacing_mut().item_spacing = egui::vec2(6.0, 0.0); if state.renaming_notebook_id.as_deref() == Some(&summary.id) { @@ -3596,39 +3884,9 @@ pub fn render_ui( .rounding(egui::Rounding::same(8.0)) .inner_margin(egui::Margin::symmetric(10.0, 8.0)) .show(ui, |ui| { - ui.horizontal(|ui| { - let title_area = ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&summary.title) - .strong() - .size(13.0) - .color(egui::Color32::WHITE), - ); - if summary.status == crate::storage::NotebookStatus::Damaged { - ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt"); - } else if is_current { - ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv"); - } - }); - let subtext = if let Some(ref err) = summary.error { - format!("Fehler: {err}") - } else { - format!("{} Seite(n)", summary.page_count) - }; - ui.label( - egui::RichText::new(subtext) - .size(11.0) - .color(egui::Color32::from_gray(160)), - ); - }); - open_clicked = title_area - .response - .interact(egui::Sense::click()) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked(); - - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Erst die Knöpfe (rechts), der Titel füllt den Rest: Die ganze + // Titelfläche öffnet das Notizbuch, ohne die Knöpfe zu überdecken + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if summary.status == crate::storage::NotebookStatus::Damaged { if ui .button(egui::RichText::new("🔄 Backup").size(13.0).color(egui::Color32::from_rgb(100, 200, 255))) @@ -3654,6 +3912,14 @@ pub fn render_ui( actions.push(UiAction::DeleteNotebook(summary.id.clone())); } + if ui + .button(egui::RichText::new("📂").size(16.0)) + .on_hover_text("In einen anderen Ordner verschieben") + .clicked() + { + state.moving_notebook_id = Some(summary.id.clone()); + } + if ui.button(egui::RichText::new("✏").size(16.0)) .on_hover_text("Titel umbenennen") .clicked() @@ -3662,7 +3928,42 @@ pub fn render_ui( state.renaming_title_input = summary.title.clone(); } } - }); + + let title_area = ui + .with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.vertical(|ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(&summary.title) + .strong() + .size(13.0) + .color(egui::Color32::WHITE), + ); + if summary.status == crate::storage::NotebookStatus::Damaged { + ui.colored_label(egui::Color32::from_rgb(255, 100, 100), "⚠ Beschädigt"); + } else if is_current { + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "⚫ Aktiv"); + } + }); + let subtext = if let Some(ref err) = summary.error { + format!("Fehler: {err}") + } else { + format!("{} Seite(n)", summary.page_count) + }; + ui.label( + egui::RichText::new(subtext) + .size(11.0) + .color(egui::Color32::from_gray(160)), + ); + }) + }) + .inner; + open_clicked = title_area + .response + .interact(egui::Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked(); }); }); @@ -3674,7 +3975,7 @@ pub fn render_ui( state.is_document_menu_open = false; } } - }); + })); ui.add_space(4.0); } });