From 82a297f3954d741afbe3ff2c3b4d9637db0ebb80 Mon Sep 17 00:00:00 2001 From: Harald Date: Thu, 8 Oct 2026 18:46:51 +0200 Subject: [PATCH] feat: Ordner in andere Ordner verschieben (Issue #18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 📂 auch bei Ordnern: verschiebt den Ordner samt Unterordnern und NotizbĂŒchern in einen anderen Ordner oder auf die oberste Ebene - Nicht in sich selbst oder einen eigenen Unterordner; gibt es am Ziel schon einen gleichnamigen Ordner, kommt eine Meldung statt einer Zusammenlegung Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/FILE_FORMAT.md | 2 +- src/library.rs | 65 +++++++++++++++++++++++++++++++++++++ src/main.rs | 21 ++++++++++++ src/ui.rs | 79 +++++++++++++++++++++++++++++++++------------ 5 files changed, 146 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 601b47c..46d7324 100644 --- a/README.md +++ b/README.md @@ -53,7 +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. +- **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 wie Ordner (samt Inhalt) 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. diff --git a/docs/FILE_FORMAT.md b/docs/FILE_FORMAT.md index 5eba2c7..a8cdcc1 100644 --- a/docs/FILE_FORMAT.md +++ b/docs/FILE_FORMAT.md @@ -31,7 +31,7 @@ Dieses Dokument beschreibt, wo und wie ink-notes NotizbĂŒcher speichert, und die - 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. +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 (samt Inhalt) und Löschen von Ordnern Ă€ndern deshalb nie eine Notizbuch-Datei. ```json { diff --git a/src/library.rs b/src/library.rs index 37d6ea2..824acb8 100644 --- a/src/library.rs +++ b/src/library.rs @@ -236,6 +236,39 @@ impl Library { } } + /// Verschiebt einen Ordner samt Inhalt nach `new_parent` (oberste Ebene: [`ROOT`]) und + /// gibt seinen neuen Pfad zurĂŒck. Nicht in sich selbst oder einen eigenen Unterordner. + pub fn move_folder(&mut self, path: &str, new_parent: &str) -> Result { + if !self.folders.contains(path) { + return Err("Den Ordner gibt es nicht mehr".to_string()); + } + if !new_parent.is_empty() && !self.folders.contains(new_parent) { + return Err("Den Zielordner gibt es nicht mehr".to_string()); + } + if is_within(new_parent, path) { + return Err("Ein Ordner kann nicht in sich selbst verschoben werden".to_string()); + } + let name = folder_name(path); + let new_path = join(new_parent, name); + if new_path == path { + return Ok(new_path); + } + if self.folders.contains(&new_path) { + return Err(format!("Im Zielordner gibt es schon einen Ordner „{name}“")); + } + self.move_contents(path, &new_path); + Ok(new_path) + } + + /// Mögliche Ziele fĂŒr das Verschieben des Ordners `path`: alle Ordner außer ihm selbst und + /// seinen Unterordnern (die oberste Ebene ist immer ein Ziel). + pub fn folder_move_targets(&self, path: &str) -> Vec<&str> { + self.all_folders() + .into_iter() + .filter(|f| !is_within(f, path)) + .collect() + } + /// 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() { @@ -338,6 +371,38 @@ mod tests { assert_eq!(lib.subfolders(ROOT), ["Mathe", "Physik"]); } + #[test] + fn test_move_folder() { + let mut lib = library(); + lib.create_folder(ROOT, "Mathe-Sammlung").unwrap(); + // Ordner samt Inhalt in einen anderen Ordner schieben + let new = lib + .move_folder("3. Semester/Mathe", "Mathe-Sammlung") + .unwrap(); + assert_eq!(new, "Mathe-Sammlung/Mathe"); + assert_eq!(lib.folder_of("analysis"), "Mathe-Sammlung/Mathe"); + assert!(!lib.folders.contains("3. Semester/Mathe")); + // Auf die oberste Ebene und zurĂŒck + assert_eq!( + lib.move_folder("Mathe-Sammlung/Mathe", ROOT).unwrap(), + "Mathe" + ); + assert_eq!( + lib.move_folder("Mathe", "3. Semester").unwrap(), + "3. Semester/Mathe" + ); + // Nicht in sich selbst oder in einen eigenen Unterordner + assert!(lib.move_folder("3. Semester", "3. Semester").is_err()); + assert!(lib.move_folder("3. Semester", "3. Semester/Mathe").is_err()); + assert_eq!(lib.folder_move_targets("3. Semester"), ["Mathe-Sammlung"]); + // Namenskollision am Ziel + lib.create_folder("Mathe-Sammlung", "Physik").unwrap(); + assert!(lib + .move_folder("3. Semester/Physik", "Mathe-Sammlung") + .is_err()); + assert_eq!(lib.folder_of("uebung"), "3. Semester"); + } + #[test] fn test_move_notebook() { let mut lib = library(); diff --git a/src/main.rs b/src/main.rs index c17f769..961f710 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3045,6 +3045,27 @@ fn main() -> Result<(), Box> { } window.request_redraw(); } + UiAction::MoveFolder { path, new_parent } => { + if let Some(new_path) = change_library(&storage_base_dir, &mut ui_state, |library| { + library.move_folder(&path, &new_parent) + }) { + // 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()..]); + } + let target = if new_parent.is_empty() { + "die oberste Ebene".to_string() + } else { + format!("„{}“", ink_notes::library::folder_name(&new_parent)) + }; + ui_state.set_export_message(format!( + "Ordner „{}“ verschoben in {target}", + 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) diff --git a/src/ui.rs b/src/ui.rs index 1994d4f..fce4114 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -171,6 +171,14 @@ impl LongPress { } } +/// Was im Dokumenten-Manager gerade verschoben wird. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MoveSubject { + Notebook(String), + /// Pfad des Ordners + Folder(String), +} + /// KontextmenĂŒ nach langem DrĂŒcken auf das Blatt. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ContextMenu { @@ -248,6 +256,11 @@ pub enum UiAction { id: String, folder: String, }, + /// Ordner samt Inhalt nach `new_parent` verschieben + MoveFolder { + path: String, + new_parent: String, + }, RenameNotebook { id: String, new_title: String, @@ -331,8 +344,8 @@ pub struct UiState { /// 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, + /// Notizbuch oder Ordner, fĂŒr das gerade ein Zielordner gewĂ€hlt wird + pub moving: Option, pub renaming_title_input: String, pub document_modal_rect: Option, /// Gitea Release- & Update-Manager @@ -439,7 +452,7 @@ impl Default for UiState { notebook_folder: String::new(), renaming_folder: None, confirm_delete_folder: None, - moving_notebook_id: None, + moving: None, renaming_title_input: String::new(), document_modal_rect: None, is_update_dialog_open: false, @@ -1819,6 +1832,13 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut { state.confirm_delete_folder = Some(path.to_string()); } + if ui + .button(egui::RichText::new("📂").size(16.0)) + .on_hover_text("Ordner samt Inhalt in einen anderen Ordner verschieben") + .clicked() + { + state.moving = Some(MoveSubject::Folder(path.to_string())); + } if ui .button(egui::RichText::new("✏").size(16.0)) .on_hover_text("Ordner umbenennen") @@ -1860,24 +1880,35 @@ fn folder_row(ui: &mut egui::Ui, state: &mut UiState, path: &str, actions: &mut } } -/// Zielordner fĂŒr das Verschieben eines Notizbuchs: oberste Ebene und alle Ordner als Baum. +/// Zielordner fĂŒr das Verschieben eines Notizbuchs oder Ordners: oberste Ebene und alle +/// Ordner als Baum (ein Ordner nicht in sich selbst oder seine Unterordner). fn move_target_list( ui: &mut egui::Ui, state: &mut UiState, - notebook_id: &str, + subject: &MoveSubject, 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(); + let (title, current, folders) = match subject { + MoveSubject::Notebook(id) => ( + state + .available_notebooks + .iter() + .find(|s| &s.id == id) + .map_or(id.as_str(), |s| s.title.as_str()) + .to_string(), + state.library.folder_of(id).to_string(), + state.library.all_folders(), + ), + MoveSubject::Folder(path) => ( + format!("📁 {}", crate::library::folder_name(path)), + crate::library::parent_folder(path).to_string(), + state.library.folder_move_targets(path), + ), + }; 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() { + for path in folders { let depth = path.matches('/').count() + 1; targets.push(( path.to_string(), @@ -1897,18 +1928,24 @@ fn move_target_list( .on_disabled_hover_text("Liegt bereits hier") .clicked() { - actions.push(UiAction::MoveNotebook { - id: notebook_id.to_string(), - folder: path.clone(), + actions.push(match subject { + MoveSubject::Notebook(id) => UiAction::MoveNotebook { + id: id.clone(), + folder: path.clone(), + }, + MoveSubject::Folder(folder) => UiAction::MoveFolder { + path: folder.clone(), + new_parent: path.clone(), + }, }); - state.moving_notebook_id = None; + state.moving = None; } }); } }); ui.add_space(6.0); if ui.button("Abbrechen").clicked() { - state.moving_notebook_id = None; + state.moving = None; } } @@ -2537,7 +2574,7 @@ pub fn render_ui( .to_string(); state.renaming_folder = None; state.confirm_delete_folder = None; - state.moving_notebook_id = None; + state.moving = None; actions.push(UiAction::RefreshNotebooks); } } @@ -3804,7 +3841,7 @@ pub fn render_ui( ui.add_space(6.0); // Zielordner fĂŒr „Verschieben“ wĂ€hlen - if let Some(moving) = state.moving_notebook_id.clone() { + if let Some(moving) = state.moving.clone() { move_target_list(ui, state, &moving, &mut actions); return; } @@ -3917,7 +3954,7 @@ pub fn render_ui( .on_hover_text("In einen anderen Ordner verschieben") .clicked() { - state.moving_notebook_id = Some(summary.id.clone()); + state.moving = Some(MoveSubject::Notebook(summary.id.clone())); } if ui.button(egui::RichText::new("✏").size(16.0))