From cf2c25df79cd55d78f081ac6d77ad2f0ba9970de Mon Sep 17 00:00:00 2001 From: marcus hinz Date: Mon, 20 Jul 2026 13:48:49 +0200 Subject: [PATCH] Archiv-Wechsel mit Migrations-Option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei gesetztem Archiv-Home fragt ein Dialog nach: Dokumente ins neue Archiv verschieben (Checkbox, standardmäßig aus — nichts wandert implizit) oder liegen lassen. change_archive_home_cmd setzt das neue Home, zieht auf Wunsch die Einträge um (gleiche Platte rename, sonst Kopie + Löschen; Kollision im Ziel bricht laut ab) und nimmt die Rechte des alten Ordners zurück. --- src-tauri/build.rs | 1 + src-tauri/capabilities/main.json | 3 +- .../change_archive_home_cmd.toml | 11 +++ src-tauri/src/app.rs | 1 + src-tauri/src/commands.rs | 12 +++ src-tauri/src/domain/archive.rs | 89 +++++++++++++++++++ src/components/ProjectList.vue | 53 ++++++++++- src/i18n.ts | 12 +++ 8 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 src-tauri/permissions/autogenerated/change_archive_home_cmd.toml diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 9caf6db..3e5a45a 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -44,6 +44,7 @@ fn main() { "panel_archive_dir_cmd", "panel_archive_cmd", "set_archive_home_cmd", + "change_archive_home_cmd", "clear_archive_home_cmd", "archive_docs_cmd", "reveal_path_cmd", diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index d6a5354..33b7447 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -53,6 +53,7 @@ "allow-clear-archive-home-cmd", "allow-archive-docs-cmd", "allow-delete-preview", - "allow-delete-project-scoped" + "allow-delete-project-scoped", + "allow-change-archive-home-cmd" ] } diff --git a/src-tauri/permissions/autogenerated/change_archive_home_cmd.toml b/src-tauri/permissions/autogenerated/change_archive_home_cmd.toml new file mode 100644 index 0000000..2f3db97 --- /dev/null +++ b/src-tauri/permissions/autogenerated/change_archive_home_cmd.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-change-archive-home-cmd" +description = "Enables the change_archive_home_cmd command without any pre-configured scope." +commands.allow = ["change_archive_home_cmd"] + +[[permission]] +identifier = "deny-change-archive-home-cmd" +description = "Denies the change_archive_home_cmd command without any pre-configured scope." +commands.deny = ["change_archive_home_cmd"] diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 0f5eddd..2190e41 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -282,6 +282,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + commands::panel_archive_dir_cmd, commands::panel_archive_cmd, commands::set_archive_home_cmd, + commands::change_archive_home_cmd, commands::clear_archive_home_cmd, commands::archive_docs_cmd, commands::reveal_path_cmd, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8d58224..9205c22 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -366,6 +366,18 @@ pub(crate) fn set_archive_home_cmd(project: String, dir: String) -> Result<(), S crate::domain::archive::set_project_archive_home(&project, &dir) } +/// Wechselt das Archiv-Home (Einstellungsdialog, wenn schon eins gesetzt +/// ist): optional ziehen die Dokumente mit um; die Rechte des alten Ordners +/// werden zurückgenommen. +#[tauri::command] +pub(crate) fn change_archive_home_cmd( + project: String, + dir: String, + migrate: bool, +) -> Result<(), String> { + crate::domain::archive::change_project_archive_home(&project, &dir, migrate) +} + /// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions /// weg, der Ordner bleibt liegen. #[tauri::command] diff --git a/src-tauri/src/domain/archive.rs b/src-tauri/src/domain/archive.rs index 1be3b8b..77e3f61 100644 --- a/src-tauri/src/domain/archive.rs +++ b/src-tauri/src/domain/archive.rs @@ -95,6 +95,73 @@ pub(crate) fn add_archive_permission( crate::domain::write_atomic(&sp, &(raw + "\n")) } +/// Wechselt das Archiv-Home: neues Home setzen (validieren, anlegen, Rechte, +/// Config), auf Wunsch die Dokumente aus dem alten Home hinüberziehen, dann +/// die Rechte des alten zurücknehmen. Ohne `migrate` bleibt das alte Archiv +/// unverändert liegen — nichts wird implizit verschoben; der Umzug ist eine +/// bewusste Option im Dialog. +pub(crate) fn change_project_archive_home( + project: &str, + dir: &str, + migrate: bool, +) -> Result<(), String> { + let paths = Paths::real(); + let old = read_project_config_in(&paths, project)?.archive_home; + set_project_archive_home(project, dir)?; + let new_home = expand_home(&paths, dir); + if let Some(old_c) = old { + let old_home = expand_home(&paths, &old_c); + if old_home == new_home { + return Ok(()); + } + if migrate && old_home.is_dir() { + move_dir_contents(&old_home, &new_home)?; + } + remove_archive_permission(&paths, project, &old_c)?; + } + Ok(()) +} + +/// Zieht alle Einträge von `from` nach `to` um. Gleiche Platte per rename, +/// über Dateisystemgrenzen als Kopie + Löschen. Ein im Ziel schon vorhandener +/// Name bricht laut ab — nichts wird überschrieben. +fn move_dir_contents(from: &std::path::Path, to: &std::path::Path) -> Result<(), String> { + for entry in fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))? { + let entry = entry.map_err(|e| format!("{}: {e}", from.display()))?; + let src = entry.path(); + let dest = to.join(entry.file_name()); + if dest.exists() { + return Err(format!("existiert schon im neuen Archiv: {}", dest.display())); + } + match fs::rename(&src, &dest) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { + copy_recursive(&src, &dest)?; + if src.is_dir() { + fs::remove_dir_all(&src).map_err(|e| format!("{}: {e}", src.display()))?; + } else { + fs::remove_file(&src).map_err(|e| format!("{}: {e}", src.display()))?; + } + } + Err(e) => return Err(format!("{}: {e}", src.display())), + } + } + Ok(()) +} + +fn copy_recursive(src: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { + if src.is_dir() { + fs::create_dir_all(dest).map_err(|e| format!("{}: {e}", dest.display()))?; + for entry in fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))? { + let entry = entry.map_err(|e| format!("{}: {e}", src.display()))?; + copy_recursive(&entry.path(), &dest.join(entry.file_name()))?; + } + } else { + fs::copy(src, dest).map_err(|e| format!("{}: {e}", src.display()))?; + } + Ok(()) +} + /// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die /// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus /// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen. @@ -451,6 +518,28 @@ mod tests { assert_eq!(parse_tag_list(&map["tags"]), vec!["adr", "infra"]); } + /// Umzug: Einträge wandern komplett, Namenskollision bricht laut ab. + #[test] + fn archiv_umzug_verschiebt_und_kollidiert_laut() { + let home = crate::domain::testutil::tmp_paths().home; + let (alt, neu) = (home.join("alt"), home.join("neu")); + fs::create_dir_all(alt.join("ordner")).unwrap(); + fs::write(alt.join("doc.md"), "inhalt").unwrap(); + fs::write(alt.join("ordner/tief.md"), "tief").unwrap(); + fs::create_dir_all(&neu).unwrap(); + + move_dir_contents(&alt, &neu).unwrap(); + assert_eq!(fs::read_to_string(neu.join("doc.md")).unwrap(), "inhalt"); + assert_eq!(fs::read_to_string(neu.join("ordner/tief.md")).unwrap(), "tief"); + assert!(fs::read_dir(&alt).unwrap().next().is_none()); + + // Kollision: gleicher Name im Ziel → Fehler, nichts überschrieben + fs::write(alt.join("doc.md"), "neuer").unwrap(); + let err = move_dir_contents(&alt, &neu).unwrap_err(); + assert!(err.contains("existiert schon")); + assert_eq!(fs::read_to_string(neu.join("doc.md")).unwrap(), "inhalt"); + } + #[test] fn check_folder_relativ_ohne_punktpunkt() { assert!(check_folder("konzepte/panel").is_ok()); diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue index 21ad331..910529c 100644 --- a/src/components/ProjectList.vue +++ b/src/components/ProjectList.vue @@ -238,11 +238,22 @@ async function removeWorkDir(dir: string) { // Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions); // ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem -// nächsten Session-Start. +// nächsten Session-Start. Ist schon eins gesetzt, fragt ein Dialog nach — +// mit der Option, die Dokumente mitzunehmen (nichts wird implizit verschoben). +interface PendingArchiveChange { + dir: string; + migrate: boolean; +} +const pendingArchive = ref(null); + async function chooseArchive() { const s = settings.value!; const dir = await open({ directory: true, multiple: false }); if (typeof dir !== "string") return; + if (s.archiveHome && s.archiveHome !== dir) { + pendingArchive.value = { dir, migrate: false }; + return; + } try { await invoke("set_archive_home_cmd", { project: s.id, dir }); s.archiveHome = await invoke("panel_archive_dir_cmd", { @@ -253,6 +264,21 @@ async function chooseArchive() { } } +async function confirmArchiveChange() { + const s = settings.value!; + const a = pendingArchive.value!; + try { + await invoke("change_archive_home_cmd", { project: s.id, dir: a.dir, migrate: a.migrate }); + s.archiveHome = await invoke("panel_archive_dir_cmd", { + project: s.id, + }); + pendingArchive.value = null; + } catch (e) { + error.value = String(e); + pendingArchive.value = null; + } +} + async function clearArchive() { const s = settings.value!; try { @@ -812,6 +838,31 @@ onUnmounted(() => window.clearInterval(timer)); +
+
+

{{ $t("projects.archiveChangeTitle") }}

+

+ {{ $t("projects.archiveChangeText", { + old: contractHome(settings.archiveHome ?? ""), + neu: contractHome(pendingArchive.dir), + }) }} +

+ +

{{ $t("projects.archiveMigrateHint") }}

+
+ + +
+
+
+

{{ $t("projects.stillRunning", { name: pending.name }) }}

diff --git a/src/i18n.ts b/src/i18n.ts index a73809b..4f8a334 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -93,6 +93,12 @@ const de = { artWorkDir: "Arbeitsordner {path}", todo: "Todoliste", todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen", + archiveChangeTitle: "Archiv wechseln", + archiveChangeText: "Das Archiv wechselt von {old} nach {neu}.", + archiveMigrate: "Dokumente ins neue Archiv verschieben", + archiveMigrateHint: + "Ohne Haken bleibt das bisherige Archiv unverändert liegen; die App verweist künftig nur noch auf das neue.", + archiveChangeConfirm: "Wechseln", groupAppearance: "Darstellung", groupFolders: "Ordner", groupSession: "Session", @@ -236,6 +242,12 @@ const en: typeof de = { artWorkDir: "working folder {path}", todo: "Todo list", todoDesc: "Inject OFFENE-PUNKTE.md at every session start", + archiveChangeTitle: "Change archive", + archiveChangeText: "The archive changes from {old} to {neu}.", + archiveMigrate: "Move documents to the new archive", + archiveMigrateHint: + "Unchecked, the previous archive stays untouched; the app just points to the new one.", + archiveChangeConfirm: "Change", groupAppearance: "Appearance", groupFolders: "Folders", groupSession: "Session",