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