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:
@@ -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/`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))];
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user