Archiv-Wechsel mit Migrations-Option
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.
This commit is contained in:
@@ -44,6 +44,7 @@ fn main() {
|
|||||||
"panel_archive_dir_cmd",
|
"panel_archive_dir_cmd",
|
||||||
"panel_archive_cmd",
|
"panel_archive_cmd",
|
||||||
"set_archive_home_cmd",
|
"set_archive_home_cmd",
|
||||||
|
"change_archive_home_cmd",
|
||||||
"clear_archive_home_cmd",
|
"clear_archive_home_cmd",
|
||||||
"archive_docs_cmd",
|
"archive_docs_cmd",
|
||||||
"reveal_path_cmd",
|
"reveal_path_cmd",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"allow-clear-archive-home-cmd",
|
"allow-clear-archive-home-cmd",
|
||||||
"allow-archive-docs-cmd",
|
"allow-archive-docs-cmd",
|
||||||
"allow-delete-preview",
|
"allow-delete-preview",
|
||||||
"allow-delete-project-scoped"
|
"allow-delete-project-scoped",
|
||||||
|
"allow-change-archive-home-cmd"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -282,6 +282,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
|||||||
commands::panel_archive_dir_cmd,
|
commands::panel_archive_dir_cmd,
|
||||||
commands::panel_archive_cmd,
|
commands::panel_archive_cmd,
|
||||||
commands::set_archive_home_cmd,
|
commands::set_archive_home_cmd,
|
||||||
|
commands::change_archive_home_cmd,
|
||||||
commands::clear_archive_home_cmd,
|
commands::clear_archive_home_cmd,
|
||||||
commands::archive_docs_cmd,
|
commands::archive_docs_cmd,
|
||||||
commands::reveal_path_cmd,
|
commands::reveal_path_cmd,
|
||||||
|
|||||||
@@ -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)
|
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
|
/// Wählt das Archiv ab (Einstellungsdialog): Config-Eintrag und Permissions
|
||||||
/// weg, der Ordner bleibt liegen.
|
/// weg, der Ordner bleibt liegen.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -95,6 +95,73 @@ pub(crate) fn add_archive_permission(
|
|||||||
crate::domain::write_atomic(&sp, &(raw + "\n"))
|
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
|
/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die
|
||||||
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
|
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
|
||||||
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
|
/// 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"]);
|
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]
|
#[test]
|
||||||
fn check_folder_relativ_ohne_punktpunkt() {
|
fn check_folder_relativ_ohne_punktpunkt() {
|
||||||
assert!(check_folder("konzepte/panel").is_ok());
|
assert!(check_folder("konzepte/panel").is_ok());
|
||||||
|
|||||||
@@ -238,11 +238,22 @@ async function removeWorkDir(dir: string) {
|
|||||||
|
|
||||||
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
|
// Archiv-Home schreibt wie die Arbeitsordner direkt (Config + Permissions);
|
||||||
// ohne Archiv zeigt das Panel nur Befehle und Dokument. Greift ab dem
|
// 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<PendingArchiveChange | null>(null);
|
||||||
|
|
||||||
async function chooseArchive() {
|
async function chooseArchive() {
|
||||||
const s = settings.value!;
|
const s = settings.value!;
|
||||||
const dir = await open({ directory: true, multiple: false });
|
const dir = await open({ directory: true, multiple: false });
|
||||||
if (typeof dir !== "string") return;
|
if (typeof dir !== "string") return;
|
||||||
|
if (s.archiveHome && s.archiveHome !== dir) {
|
||||||
|
pendingArchive.value = { dir, migrate: false };
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await invoke("set_archive_home_cmd", { project: s.id, dir });
|
await invoke("set_archive_home_cmd", { project: s.id, dir });
|
||||||
s.archiveHome = await invoke<string | null>("panel_archive_dir_cmd", {
|
s.archiveHome = await invoke<string | null>("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<string | null>("panel_archive_dir_cmd", {
|
||||||
|
project: s.id,
|
||||||
|
});
|
||||||
|
pendingArchive.value = null;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = String(e);
|
||||||
|
pendingArchive.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function clearArchive() {
|
async function clearArchive() {
|
||||||
const s = settings.value!;
|
const s = settings.value!;
|
||||||
try {
|
try {
|
||||||
@@ -812,6 +838,31 @@ onUnmounted(() => window.clearInterval(timer));
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pendingArchive && settings" class="overlay" @click.self="pendingArchive = null">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>{{ $t("projects.archiveChangeTitle") }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ $t("projects.archiveChangeText", {
|
||||||
|
old: contractHome(settings.archiveHome ?? ""),
|
||||||
|
neu: contractHome(pendingArchive.dir),
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<label class="checkline">
|
||||||
|
<input v-model="pendingArchive.migrate" type="checkbox" />
|
||||||
|
{{ $t("projects.archiveMigrate") }}
|
||||||
|
</label>
|
||||||
|
<p class="hint">{{ $t("projects.archiveMigrateHint") }}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" @click="pendingArchive = null">
|
||||||
|
{{ $t("projects.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button class="primary" @click="confirmArchiveChange">
|
||||||
|
{{ $t("projects.archiveChangeConfirm") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="pending" class="overlay">
|
<div v-if="pending" class="overlay">
|
||||||
<div class="dialog">
|
<div class="dialog">
|
||||||
<h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3>
|
<h3>{{ $t("projects.stillRunning", { name: pending.name }) }}</h3>
|
||||||
|
|||||||
+12
@@ -93,6 +93,12 @@ const de = {
|
|||||||
artWorkDir: "Arbeitsordner {path}",
|
artWorkDir: "Arbeitsordner {path}",
|
||||||
todo: "Todoliste",
|
todo: "Todoliste",
|
||||||
todoDesc: "OFFENE-PUNKTE.md bei jedem Sessionstart einspielen",
|
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",
|
groupAppearance: "Darstellung",
|
||||||
groupFolders: "Ordner",
|
groupFolders: "Ordner",
|
||||||
groupSession: "Session",
|
groupSession: "Session",
|
||||||
@@ -236,6 +242,12 @@ const en: typeof de = {
|
|||||||
artWorkDir: "working folder {path}",
|
artWorkDir: "working folder {path}",
|
||||||
todo: "Todo list",
|
todo: "Todo list",
|
||||||
todoDesc: "Inject OFFENE-PUNKTE.md at every session start",
|
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",
|
groupAppearance: "Appearance",
|
||||||
groupFolders: "Folders",
|
groupFolders: "Folders",
|
||||||
groupSession: "Session",
|
groupSession: "Session",
|
||||||
|
|||||||
Reference in New Issue
Block a user