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.
This commit is contained in:
2026-10-30 20:47:08 -05:00
parent 349c1957f1
commit 58ff41a4c3
6 changed files with 170 additions and 23 deletions

View File

@@ -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 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 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 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 Libraries' files live in the app-data dir: **Main** where the one library
always lived, the others under `libraries/`. always lived, the others under `libraries/`.

View File

@@ -21,8 +21,11 @@ pub struct LibraryInfo {
pub name: String, pub name: String,
/// The one that's open. /// The one that's open.
pub active: bool, 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, 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. /// Whether it should be on the sync server.
pub sync: bool, pub sync: bool,
/// Whether it's linked to a library on the server yet. /// 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(), name: lib.name.clone(),
active: s.active_library().id == lib.id, active: s.active_library().id == lib.id,
is_default: lib.is_default(), is_default: lib.is_default(),
can_delete: s.delete_refusal(lib).is_none(),
sync: lib.sync, sync: lib.sync,
linked: lib.remote_id.is_some(), linked: lib.remote_id.is_some(),
server_deleted: lib.server_deleted, 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 /// 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 /// 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] #[tauri::command]
pub async fn delete_library(app: AppHandle, id: String, everywhere: bool) -> Result<Vec<LibraryInfo>, String> { pub async fn delete_library(app: AppHandle, id: String, everywhere: bool) -> Result<Vec<LibraryInfo>, String> {
let libs = blocking(move || { let libs = blocking(move || {
let (remote_id, remote) = { let (remote_id, remote) = {
let s = lock(&app); let s = lock(&app);
let lib = s.library(&id).ok_or("There's no such library on this device.")?; 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)) (lib.remote_id.clone(), account_remote(&s))
}; };
if let (true, Some(rid)) = (everywhere, &remote_id) { if let (true, Some(rid)) = (everywhere, &remote_id) {

View File

@@ -29,8 +29,9 @@ struct PersistedConfig {
/// The sync server this device is (or was last) signed in to. /// The sync server this device is (or was last) signed in to.
#[serde(default)] #[serde(default)]
sync: Option<SyncAccount>, sync: Option<SyncAccount>,
/// The libraries on this device, the default one first. Never empty once /// The libraries on this device, the default one first unless it's been
/// loaded: a config from before named libraries gets the default. /// deleted. Never empty once loaded: a config from before named libraries
/// gets the default.
#[serde(default)] #[serde(default)]
libraries: Vec<LocalLibrary>, libraries: Vec<LocalLibrary>,
/// The id of the library that's open. /// 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 { fn load_config(data_dir: &Path) -> PersistedConfig {
let mut cfg = read_config(data_dir); let mut cfg = read_config(data_dir);
// Before named libraries there was one, which is the default now. // Before named libraries there was one, which is the default now. A list
if !cfg.libraries.iter().any(LocalLibrary::is_default) { // without it is different: the user deleted it.
cfg.libraries.insert(0, default_library()); if cfg.libraries.is_empty() {
cfg.libraries.push(default_library());
} }
if !cfg.libraries.iter().any(|l| l.id == cfg.active_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 cfg
} }
@@ -400,25 +402,60 @@ impl AppState {
self.reopen_catalog() 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 /// 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<LocalLibrary, String> { pub fn delete_library(&mut self, id: &str) -> Result<LocalLibrary, String> {
let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?; let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?;
if lib.is_default() { if let Some(refusal) = self.delete_refusal(&lib) {
return Err("The main library can't be deleted.".into()); return Err(refusal.into());
} }
if self.sync_running { if self.sync_running {
return Err("Wait for the sync to finish, then try again.".into()); return Err("Wait for the sync to finish, then try again.".into());
} }
if self.config.active_library == id { 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); self.config.libraries.retain(|l| l.id != id);
save_config(&self.data_dir, &self.config); save_config(&self.data_dir, &self.config);
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); let dir = self.library_dir(id);
if let Err(e) = std::fs::remove_dir_all(&dir) { if let Err(e) = std::fs::remove_dir_all(&dir) {
crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display()); crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display());
} }
}
Ok(lib) Ok(lib)
} }
@@ -715,11 +752,87 @@ mod tests {
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync"); assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
s.sync_running = false; 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(); s.delete_library(&lib.id).unwrap();
assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away"); assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away");
assert!(!s.library_dir(&lib.id).exists()); assert!(!s.library_dir(&lib.id).exists());
assert_eq!(s.libraries().len(), 1); assert_eq!(s.libraries().len(), 1);
std::fs::remove_dir_all(&dir).unwrap(); 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();
}
} }

View File

@@ -762,7 +762,8 @@ impl LibraryPlan {
/// ///
/// - a linked library whose server library is gone is flagged as deleted /// - a linked library whose server library is gone is flagged as deleted
/// there, and keeps its contents; /// 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 /// - 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 /// here with the same name, or is downloaded — unless it was removed from
/// this device; /// this device;
@@ -1100,6 +1101,16 @@ mod tests {
assert_eq!(plan, LibraryPlan::default()); 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] #[test]
fn libraries_started_elsewhere_are_downloaded_unless_removed_here() { fn libraries_started_elsewhere_are_downloaded_unless_removed_here() {
let libs = [local("default", "Main", Some("m"))]; let libs = [local("default", "Main", Some("m"))];

View File

@@ -87,6 +87,8 @@ function DeleteLibraryDialog({
}) { }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); 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) { async function remove(everywhere: boolean) {
setBusy(true); setBusy(true);
@@ -101,12 +103,17 @@ function DeleteLibraryDialog({
return ( return (
<DialogFrame title={`Delete ${lib.name}?`} onCancel={onCancel}> <DialogFrame title={`Delete ${lib.name}?`} onCancel={onCancel}>
{lib.linked ? ( {offerEverywhere ? (
<p> <p>
<strong>Remove from this device</strong> keeps it on the sync server for your other <strong>Remove from this device</strong> keeps it on the sync server for your other
devices; this one stops downloading it. <strong>Delete everywhere</strong> also removes devices; this one stops downloading it. <strong>Delete everywhere</strong> also removes
it from the server, and your other devices keep only the copy they already have. it from the server, and your other devices keep only the copy they already have.
</p> </p>
) : lib.linked ? (
<p>
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.
</p>
) : ( ) : (
<p>Its cards and decks will be deleted from this device. This can't be undone.</p> <p>Its cards and decks will be deleted from this device. This can't be undone.</p>
)} )}
@@ -115,13 +122,13 @@ function DeleteLibraryDialog({
<button onClick={onCancel} disabled={busy}> <button onClick={onCancel} disabled={busy}>
Cancel Cancel
</button> </button>
{lib.linked && ( {offerEverywhere && (
<button onClick={() => remove(false)} disabled={busy}> <button onClick={() => remove(false)} disabled={busy}>
Remove from this device Remove from this device
</button> </button>
)} )}
<button className="danger" onClick={() => remove(lib.linked)} disabled={busy}> <button className="danger" onClick={() => remove(offerEverywhere)} disabled={busy}>
{lib.linked ? "Delete everywhere" : "Delete"} {offerEverywhere ? "Delete everywhere" : lib.linked ? "Remove from this device" : "Delete"}
</button> </button>
</div> </div>
</DialogFrame> </DialogFrame>
@@ -337,7 +344,7 @@ export function LibrariesPanel({
}} }}
disabled={busy} disabled={busy}
/> />
{!lib.is_default && ( {lib.can_delete && (
<IconButton <IconButton
icon={Icons.trash} icon={Icons.trash}
label={`Delete ${lib.name}`} label={`Delete ${lib.name}`}

View File

@@ -351,6 +351,7 @@ export interface LibraryInfo {
active: boolean; active: boolean;
/** The library the device started with, which can't be deleted. */ /** The library the device started with, which can't be deleted. */
is_default: boolean; is_default: boolean;
can_delete: boolean;
/** Whether it should be on the sync server. */ /** Whether it should be on the sync server. */
sync: boolean; sync: boolean;
/** Whether it's linked to a library on the server yet. */ /** Whether it's linked to a library on the server yet. */