From 58ff41a4c30612a4d6031f4641fbf91bc83b90e6 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 30 Oct 2026 20:47:08 -0500 Subject: [PATCH] Libraries: let the main library be deleted from a device Main can go once another library is synced to take over. The server keeps its default library, so this only removes the device's copy, and only Main's own files: its folder is the app data folder. A deleted Main stays deleted across launches, and a device always keeps at least one library, which is now checked before anything is deleted on the server. --- README.md | 4 +- src-tauri/src/libraries.rs | 17 ++++- src-tauri/src/state.rs | 141 +++++++++++++++++++++++++++++++++---- src-tauri/src/sync.rs | 13 +++- src/Libraries.tsx | 17 +++-- src/api.ts | 1 + 6 files changed, 170 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 33f5e75..7e2ccf2 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,9 @@ Deleting a synced library asks whether to **remove it from this device** (it stays on the server and on your other devices, and this one stops downloading it) or **delete it everywhere**. Other devices keep their copy of a library deleted everywhere, marked as deleted from the server, until you remove it or -put it back. **Main** can be renamed but not deleted. +put it back. **Main** can be renamed, and deleted from a device once another +library there is synced; the sync server always keeps it, so it stays on your +other devices. A device always keeps at least one library. Libraries' files live in the app-data dir: **Main** where the one library always lived, the others under `libraries/`. diff --git a/src-tauri/src/libraries.rs b/src-tauri/src/libraries.rs index 4b93cd7..ff755ef 100644 --- a/src-tauri/src/libraries.rs +++ b/src-tauri/src/libraries.rs @@ -21,8 +21,11 @@ pub struct LibraryInfo { pub name: String, /// The one that's open. pub active: bool, - /// The library the device started with, which can't be deleted. + /// The library the device started with, which the server won't delete: it + /// can only be removed from this device. pub is_default: bool, + /// Whether it can be deleted from this device right now. + pub can_delete: bool, /// Whether it should be on the sync server. pub sync: bool, /// Whether it's linked to a library on the server yet. @@ -37,6 +40,7 @@ fn info(s: &AppState, lib: &LocalLibrary) -> LibraryInfo { name: lib.name.clone(), active: s.active_library().id == lib.id, is_default: lib.is_default(), + can_delete: s.delete_refusal(lib).is_none(), sync: lib.sync, linked: lib.remote_id.is_some(), server_deleted: lib.server_deleted, @@ -124,13 +128,22 @@ pub async fn rename_library(app: AppHandle, id: String, name: String) -> Result< /// Delete a library from this device. With `everywhere`, from the sync /// server too — and so, at their next sync, from every other device's list -/// of what's on the server (they keep their own copies until removed). +/// of what's on the server (they keep their own copies until removed). The +/// default library stays on the server, which refuses to delete it. #[tauri::command] pub async fn delete_library(app: AppHandle, id: String, everywhere: bool) -> Result, String> { let libs = blocking(move || { let (remote_id, remote) = { let s = lock(&app); let lib = s.library(&id).ok_or("There's no such library on this device.")?; + // Before touching the server, so a refusal here can't leave a + // library deleted there and still present on this device. + if let Some(refusal) = s.delete_refusal(lib) { + return Err(refusal.into()); + } + if everywhere && lib.is_default() { + return Err("The main library can't be deleted from the sync server.".into()); + } (lib.remote_id.clone(), account_remote(&s)) }; if let (true, Some(rid)) = (everywhere, &remote_id) { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9a124f4..188fe9f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -29,8 +29,9 @@ struct PersistedConfig { /// The sync server this device is (or was last) signed in to. #[serde(default)] sync: Option, - /// The libraries on this device, the default one first. Never empty once - /// loaded: a config from before named libraries gets the default. + /// The libraries on this device, the default one first unless it's been + /// deleted. Never empty once loaded: a config from before named libraries + /// gets the default. #[serde(default)] libraries: Vec, /// The id of the library that's open. @@ -117,12 +118,13 @@ fn config_path(data_dir: &Path) -> PathBuf { fn load_config(data_dir: &Path) -> PersistedConfig { let mut cfg = read_config(data_dir); - // Before named libraries there was one, which is the default now. - if !cfg.libraries.iter().any(LocalLibrary::is_default) { - cfg.libraries.insert(0, default_library()); + // Before named libraries there was one, which is the default now. A list + // without it is different: the user deleted it. + if cfg.libraries.is_empty() { + cfg.libraries.push(default_library()); } if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) { - cfg.active_library = DEFAULT_LIBRARY.into(); + cfg.active_library = cfg.libraries[0].id.clone(); } cfg } @@ -400,24 +402,59 @@ impl AppState { self.reopen_catalog() } + /// Why `lib` can't be deleted from this device, if it can't. A device + /// always keeps a library, and the default one goes only when a synced one + /// can take over from it. + pub fn delete_refusal(&self, lib: &LocalLibrary) -> Option<&'static str> { + let mut others = self.config.libraries.iter().filter(|l| l.id != lib.id); + if lib.is_default() && !others.clone().any(|l| l.remote_id.is_some()) { + return Some("The main library can only be deleted once another library is synced."); + } + others.next().is_none().then_some("This is the only library on this device.") + } + /// Delete a library and its files from this device. The active one is - /// left by switching to the default first; the default itself stays. + /// left by switching to another first: a synced one when that's what let + /// the default library go. pub fn delete_library(&mut self, id: &str) -> Result { let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?; - if lib.is_default() { - return Err("The main library can't be deleted.".into()); + if let Some(refusal) = self.delete_refusal(&lib) { + return Err(refusal.into()); } if self.sync_running { return Err("Wait for the sync to finish, then try again.".into()); } if self.config.active_library == id { - self.switch_library(DEFAULT_LIBRARY)?; + let next = self + .config + .libraries + .iter() + .find(|l| l.id != id && (!lib.is_default() || l.remote_id.is_some())) + .map(|l| l.id.clone()) + .ok_or("There's no other library to open.")?; + self.switch_library(&next)?; } self.config.libraries.retain(|l| l.id != id); save_config(&self.data_dir, &self.config); - let dir = self.library_dir(id); - if let Err(e) = std::fs::remove_dir_all(&dir) { - crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display()); + if lib.is_default() { + // Only the default library could ask this, and it's gone. + self.sync_needs_choice = false; + // Its folder is the data folder itself, holding the app's own + // files and the other libraries, so only its files go. + let working = self.working_path(id); + for path in [working.with_extension("db-journal"), working, self.base_path(id)] { + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + crate::mlog!("delete_library: couldn't remove {}: {e}", path.display()); + } + _ => {} + } + } + } else { + let dir = self.library_dir(id); + if let Err(e) = std::fs::remove_dir_all(&dir) { + crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display()); + } } Ok(lib) } @@ -715,11 +752,87 @@ mod tests { assert!(s.delete_library(&lib.id).is_err(), "not mid-sync"); s.sync_running = false; - assert!(s.delete_library(DEFAULT_LIBRARY).is_err()); + assert!(s.delete_library(DEFAULT_LIBRARY).is_err(), "nothing synced to take over"); s.delete_library(&lib.id).unwrap(); assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away"); assert!(!s.library_dir(&lib.id).exists()); assert_eq!(s.libraries().len(), 1); std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn the_default_library_goes_only_when_a_synced_one_can_take_over() { + let dir = scratch("delete-default"); + let mut s = AppState::new(dir.clone()); + let unsynced = s.create_library("Scratch", false, None).unwrap(); + let waiting = s.create_library("Trades", true, None).unwrap(); + assert!(s.delete_library(DEFAULT_LIBRARY).is_err(), "not on the server yet"); + assert_eq!(s.libraries().len(), 3); + + let linked = s.create_library("Cube", true, Some("r1".into())).unwrap(); + std::fs::write(dir.join("sync-server-base.json"), "{}").unwrap(); + reference_with(&dir.join("reference.db"), "Card"); + s.sync_needs_choice = true; + s.delete_library(DEFAULT_LIBRARY).unwrap(); + + assert_eq!(s.active_library().id, linked.id, "the synced one takes over"); + assert_eq!(s.working_db_path, s.working_path(&linked.id)); + assert!(s.library(DEFAULT_LIBRARY).is_none()); + assert!(!s.sync_needs_choice); + assert!(!dir.join("working.db").exists()); + assert!(!dir.join("sync-server-base.json").exists()); + // Everything around it in the data folder survives. + assert!(dir.join("reference.db").exists()); + assert!(config_path(&dir).exists()); + assert!(s.working_path(&linked.id).exists()); + assert!(s.working_path(&unsynced.id).exists()); + assert!(s.working_path(&waiting.id).exists()); + + // It stays gone, and the libraries left can still be opened. + let mut s = AppState::new(dir.clone()); + assert_eq!(s.active_library().id, linked.id); + assert!(s.library(DEFAULT_LIBRARY).is_none()); + s.switch_library(&unsynced.id).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn the_last_library_on_a_device_stays() { + let dir = scratch("delete-last"); + let mut s = AppState::new(dir.clone()); + let lib = s.create_library("Cube", true, Some("r1".into())).unwrap(); + let other = s.create_library("Trades", false, None).unwrap(); + s.delete_library(DEFAULT_LIBRARY).unwrap(); + + s.delete_library(&lib.id).unwrap(); + assert_eq!(s.active_library().id, other.id, "with no default to fall back on"); + + assert!(s.delete_library(&other.id).is_err()); + assert_eq!(s.libraries().len(), 1); + assert!(s.working_path(&other.id).exists()); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_config_without_the_default_library_keeps_it_deleted() { + let dir = scratch("default-gone"); + let lib = LocalLibrary { + id: "abc".into(), + name: "Cube".into(), + remote_id: Some("r1".into()), + sync: true, + server_deleted: false, + }; + let cfg = PersistedConfig { + libraries: vec![lib.clone()], + active_library: DEFAULT_LIBRARY.into(), + ..Default::default() + }; + save_config(&dir, &cfg); + + let back = load_config(&dir); + assert_eq!(back.libraries, vec![lib]); + assert_eq!(back.active_library, "abc", "an active library that's gone falls to one that's here"); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 8d2a7b9..a3db0be 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -762,7 +762,8 @@ impl LibraryPlan { /// /// - a linked library whose server library is gone is flagged as deleted /// there, and keeps its contents; -/// - the default library links to the account's default; +/// - the default library links to the account's default, which is never +/// downloaded: this device has either kept it off the server or deleted it; /// - a server library not on this device links to a not-yet-linked library /// here with the same name, or is downloaded — unless it was removed from /// this device; @@ -1100,6 +1101,16 @@ mod tests { assert_eq!(plan, LibraryPlan::default()); } + #[test] + fn a_default_library_deleted_here_isnt_downloaded_back() { + let plan = plan_libraries( + &[local("abc", "Cube", Some("c"))], + &[], + &[server("m", "Main", true), server("c", "Cube", false)], + ); + assert_eq!(plan, LibraryPlan::default()); + } + #[test] fn libraries_started_elsewhere_are_downloaded_unless_removed_here() { let libs = [local("default", "Main", Some("m"))]; diff --git a/src/Libraries.tsx b/src/Libraries.tsx index bf0df6e..750b42f 100644 --- a/src/Libraries.tsx +++ b/src/Libraries.tsx @@ -87,6 +87,8 @@ function DeleteLibraryDialog({ }) { const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + // The server keeps the main library, so this device can only let go of it. + const offerEverywhere = lib.linked && !lib.is_default; async function remove(everywhere: boolean) { setBusy(true); @@ -101,12 +103,17 @@ function DeleteLibraryDialog({ return ( - {lib.linked ? ( + {offerEverywhere ? (

Remove from this device keeps it on the sync server for your other devices; this one stops downloading it. Delete everywhere also removes it from the server, and your other devices keep only the copy they already have.

+ ) : lib.linked ? ( +

+ This device's copy will be deleted and it stops syncing here. It stays on the sync server + for your other devices, since the main library can't be deleted from there. +

) : (

Its cards and decks will be deleted from this device. This can't be undone.

)} @@ -115,13 +122,13 @@ function DeleteLibraryDialog({ - {lib.linked && ( + {offerEverywhere && ( )} -
@@ -337,7 +344,7 @@ export function LibrariesPanel({ }} disabled={busy} /> - {!lib.is_default && ( + {lib.can_delete && (