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
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/`.

View File

@@ -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<Vec<LibraryInfo>, 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) {

View File

@@ -29,8 +29,9 @@ struct PersistedConfig {
/// The sync server this device is (or was last) signed in to.
#[serde(default)]
sync: Option<SyncAccount>,
/// 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<LocalLibrary>,
/// 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<LocalLibrary, String> {
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();
}
}

View File

@@ -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"))];

View File

@@ -87,6 +87,8 @@ function DeleteLibraryDialog({
}) {
const [error, setError] = useState<string | null>(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 (
<DialogFrame title={`Delete ${lib.name}?`} onCancel={onCancel}>
{lib.linked ? (
{offerEverywhere ? (
<p>
<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
it from the server, and your other devices keep only the copy they already have.
</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>
)}
@@ -115,13 +122,13 @@ function DeleteLibraryDialog({
<button onClick={onCancel} disabled={busy}>
Cancel
</button>
{lib.linked && (
{offerEverywhere && (
<button onClick={() => remove(false)} disabled={busy}>
Remove from this device
</button>
)}
<button className="danger" onClick={() => remove(lib.linked)} disabled={busy}>
{lib.linked ? "Delete everywhere" : "Delete"}
<button className="danger" onClick={() => remove(offerEverywhere)} disabled={busy}>
{offerEverywhere ? "Delete everywhere" : lib.linked ? "Remove from this device" : "Delete"}
</button>
</div>
</DialogFrame>
@@ -337,7 +344,7 @@ export function LibrariesPanel({
}}
disabled={busy}
/>
{!lib.is_default && (
{lib.can_delete && (
<IconButton
icon={Icons.trash}
label={`Delete ${lib.name}`}

View File

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