Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and
|
||||
//! the sync account.
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths,
|
||||
//! the device's libraries, and the sync account.
|
||||
//!
|
||||
//! The user's library lives in an internal SQLite working copy under the app
|
||||
//! data dir, which works fully offline. When the device is signed in to a sync
|
||||
//! Each library lives in an internal SQLite working copy under the app data
|
||||
//! dir, which works fully offline. One of them is open at a time — the active
|
||||
//! library — and every command works on that one. When the device is signed in to a sync
|
||||
//! server, that copy is reconciled with the server's whenever it can be reached
|
||||
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
|
||||
|
||||
@@ -28,6 +29,69 @@ 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.
|
||||
#[serde(default)]
|
||||
libraries: Vec<LocalLibrary>,
|
||||
/// The id of the library that's open.
|
||||
#[serde(default)]
|
||||
active_library: String,
|
||||
/// Server libraries removed from this device, which syncing therefore
|
||||
/// doesn't download again.
|
||||
#[serde(default)]
|
||||
dismissed_remote: Vec<String>,
|
||||
}
|
||||
|
||||
/// The id of the library every device starts with. Its files stay where the
|
||||
/// one library always lived, so upgrading moves nothing.
|
||||
pub const DEFAULT_LIBRARY: &str = "default";
|
||||
|
||||
/// One of the libraries on this device.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct LocalLibrary {
|
||||
/// Local only: the folder it lives in. Never shown.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The server's id for it, once linked to a library there.
|
||||
#[serde(default)]
|
||||
pub remote_id: Option<String>,
|
||||
/// Whether it should be on the server. Off for a library kept on this
|
||||
/// device only — a scratch library to try things in, say.
|
||||
#[serde(default = "yes")]
|
||||
pub sync: bool,
|
||||
/// It was linked, and another device deleted it from the server. The copy
|
||||
/// here stays until the user removes it or puts it back on the server.
|
||||
#[serde(default)]
|
||||
pub server_deleted: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl LocalLibrary {
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.id == DEFAULT_LIBRARY
|
||||
}
|
||||
}
|
||||
|
||||
fn default_library() -> LocalLibrary {
|
||||
LocalLibrary {
|
||||
id: DEFAULT_LIBRARY.into(),
|
||||
name: "Main".into(),
|
||||
remote_id: None,
|
||||
sync: true,
|
||||
server_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh id for a library's folder.
|
||||
fn new_library_id() -> String {
|
||||
mtgc_core::sync::account::random_token()
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(16)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// This device's sync server account.
|
||||
@@ -52,6 +116,18 @@ 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());
|
||||
}
|
||||
if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) {
|
||||
cfg.active_library = DEFAULT_LIBRARY.into();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn read_config(data_dir: &Path) -> PersistedConfig {
|
||||
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
|
||||
return PersistedConfig::default();
|
||||
};
|
||||
@@ -105,14 +181,23 @@ fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// The folder a library's files live in.
|
||||
fn library_dir(data_dir: &Path, id: &str) -> PathBuf {
|
||||
if id == DEFAULT_LIBRARY {
|
||||
data_dir.to_path_buf()
|
||||
} else {
|
||||
data_dir.join("libraries").join(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
||||
pub struct AppState {
|
||||
data_dir: PathBuf,
|
||||
/// Held whole so that writing one preference can't drop the others — the
|
||||
/// setters below persist this rather than reconstructing a fresh config.
|
||||
config: PersistedConfig,
|
||||
/// Internal SQLite working copy: the library itself, on this device. Never
|
||||
/// chosen by, shown to, or moved by the user.
|
||||
/// Internal SQLite working copy of the active library: the library itself,
|
||||
/// on this device. Never chosen by, shown to, or moved by the user.
|
||||
pub working_db_path: PathBuf,
|
||||
pub reference_path: PathBuf,
|
||||
pub catalog: Option<Catalog>,
|
||||
@@ -141,7 +226,7 @@ impl AppState {
|
||||
let config = load_config(&data_dir);
|
||||
let mut s = Self {
|
||||
reference_path: data_dir.join("reference.db"),
|
||||
working_db_path: data_dir.join("working.db"),
|
||||
working_db_path: library_dir(&data_dir, &config.active_library).join("working.db"),
|
||||
config,
|
||||
data_dir,
|
||||
catalog: None,
|
||||
@@ -152,9 +237,13 @@ impl AppState {
|
||||
sync_needs_choice: false,
|
||||
sign_in_cancel: None,
|
||||
};
|
||||
// A library whose folder vanished would otherwise be recreated empty
|
||||
// by opening it, which is fine — but its folder must exist first.
|
||||
let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone()));
|
||||
crate::mlog!(
|
||||
"AppState::new: sync={:?} working={} reference={} (exists={})",
|
||||
"AppState::new: sync={:?} library={} working={} reference={} (exists={})",
|
||||
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
|
||||
s.config.active_library,
|
||||
s.working_db_path.display(),
|
||||
s.reference_path.display(),
|
||||
s.reference_path.exists()
|
||||
@@ -170,10 +259,6 @@ impl AppState {
|
||||
s
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
/// The sync account, signed in or not.
|
||||
pub fn sync_account(&self) -> Option<&SyncAccount> {
|
||||
self.config.sync.as_ref()
|
||||
@@ -192,6 +277,151 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget every link to a server library. For signing in to a different
|
||||
/// account or server, where the old ids mean nothing.
|
||||
pub fn forget_remote_links(&mut self) {
|
||||
for lib in &mut self.config.libraries {
|
||||
lib.remote_id = None;
|
||||
lib.server_deleted = false;
|
||||
}
|
||||
self.config.dismissed_remote.clear();
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
// --- libraries ------------------------------------------------------------
|
||||
|
||||
pub fn libraries(&self) -> &[LocalLibrary] {
|
||||
&self.config.libraries
|
||||
}
|
||||
|
||||
pub fn library(&self, id: &str) -> Option<&LocalLibrary> {
|
||||
self.config.libraries.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
pub fn active_library(&self) -> &LocalLibrary {
|
||||
self.library(&self.config.active_library).expect("load_config keeps the active library listed")
|
||||
}
|
||||
|
||||
/// The folder a library's files live in.
|
||||
pub fn library_dir(&self, id: &str) -> PathBuf {
|
||||
library_dir(&self.data_dir, id)
|
||||
}
|
||||
|
||||
pub fn working_path(&self, id: &str) -> PathBuf {
|
||||
self.library_dir(id).join("working.db")
|
||||
}
|
||||
|
||||
/// Where a library's sync merge base is kept. The default library keeps
|
||||
/// the name it had before there were others.
|
||||
pub fn base_path(&self, id: &str) -> PathBuf {
|
||||
if id == DEFAULT_LIBRARY {
|
||||
self.data_dir.join("sync-server-base.json")
|
||||
} else {
|
||||
self.library_dir(id).join("sync-base.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Server libraries removed from this device.
|
||||
pub fn dismissed_remote(&self) -> &[String] {
|
||||
&self.config.dismissed_remote
|
||||
}
|
||||
|
||||
/// Start a new, empty library. It isn't opened.
|
||||
pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option<String>) -> Result<LocalLibrary, String> {
|
||||
let name = self.check_name(name, None)?;
|
||||
let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false };
|
||||
let dir = self.library_dir(&lib.id);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?;
|
||||
// Created now, so the library exists on disk even before anything
|
||||
// is added to it.
|
||||
mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?;
|
||||
if let Some(r) = &lib.remote_id {
|
||||
self.config.dismissed_remote.retain(|d| d != r);
|
||||
}
|
||||
self.config.libraries.push(lib.clone());
|
||||
save_config(&self.data_dir, &self.config);
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// A trimmed name, if no other library here already has it.
|
||||
pub fn check_name(&self, name: &str, except: Option<&str>) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("A library needs a name.".into());
|
||||
}
|
||||
let taken = self
|
||||
.config
|
||||
.libraries
|
||||
.iter()
|
||||
.any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name));
|
||||
if taken {
|
||||
return Err(format!("There's already a library called {name}."));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Change a library in place and persist it. A no-op for an unknown id.
|
||||
pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) {
|
||||
if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) {
|
||||
f(lib);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop downloading a server library to this device.
|
||||
pub fn dismiss_remote(&mut self, remote_id: &str) {
|
||||
if !self.config.dismissed_remote.iter().any(|d| d == remote_id) {
|
||||
self.config.dismissed_remote.push(remote_id.to_string());
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undismiss_remote(&mut self, remote_id: &str) {
|
||||
self.config.dismissed_remote.retain(|d| d != remote_id);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
/// Open another library.
|
||||
pub fn switch_library(&mut self, id: &str) -> Result<(), String> {
|
||||
if self.library(id).is_none() {
|
||||
return Err("There's no such library on this device.".into());
|
||||
}
|
||||
if self.config.active_library == id {
|
||||
return Ok(());
|
||||
}
|
||||
// A sync works on paths it took when it started.
|
||||
if self.sync_running {
|
||||
return Err("Wait for the sync to finish, then try again.".into());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(self.library_dir(id));
|
||||
self.config.active_library = id.to_string();
|
||||
save_config(&self.data_dir, &self.config);
|
||||
self.working_db_path = self.working_path(id);
|
||||
self.reopen_catalog()
|
||||
}
|
||||
|
||||
/// Delete a library and its files from this device. The active one is
|
||||
/// left by switching to the default first; the default itself stays.
|
||||
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 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)?;
|
||||
}
|
||||
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());
|
||||
}
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// A pre-document `library.db` still waiting to be converted.
|
||||
pub fn legacy_library_path(&self) -> Option<&Path> {
|
||||
self.config.legacy_library_path.as_deref()
|
||||
@@ -228,6 +458,21 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reopen just the catalog, on the active library's working copy. The name
|
||||
/// index only reads card data, so it survives a switch of library.
|
||||
fn reopen_catalog(&mut self) -> Result<(), String> {
|
||||
if self.index.is_none() {
|
||||
return self.reopen();
|
||||
}
|
||||
self.catalog = None;
|
||||
if self.reference_path.exists() {
|
||||
self.catalog = Some(
|
||||
Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> Result<&Catalog, String> {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
@@ -271,15 +516,28 @@ mod tests {
|
||||
#[test]
|
||||
fn every_setting_round_trips() {
|
||||
let dir = scratch("round-trip");
|
||||
let scratch_lib = LocalLibrary {
|
||||
id: "abc".into(),
|
||||
name: "Scratch".into(),
|
||||
remote_id: Some("r1".into()),
|
||||
sync: false,
|
||||
server_deleted: true,
|
||||
};
|
||||
let cfg = PersistedConfig {
|
||||
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
|
||||
sync: Some(account()),
|
||||
libraries: vec![default_library(), scratch_lib],
|
||||
active_library: "abc".into(),
|
||||
dismissed_remote: vec!["r2".into()],
|
||||
};
|
||||
save_config(&dir, &cfg);
|
||||
|
||||
let back = load_config(&dir);
|
||||
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
|
||||
assert_eq!(back.sync, cfg.sync);
|
||||
assert_eq!(back.libraries, cfg.libraries);
|
||||
assert_eq!(back.active_library, "abc");
|
||||
assert_eq!(back.dismissed_remote, cfg.dismissed_remote);
|
||||
assert!(!dir.join("config.json.tmp").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -289,7 +547,7 @@ mod tests {
|
||||
fn the_config_holding_the_token_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = scratch("private");
|
||||
save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) });
|
||||
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
||||
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
@@ -346,4 +604,54 @@ mod tests {
|
||||
assert!(load_config(&dir).sync.is_none(), "truncated file");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_from_before_named_libraries_has_just_the_default() {
|
||||
let dir = scratch("pre-libraries");
|
||||
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
||||
let cfg = load_config(&dir);
|
||||
assert_eq!(cfg.libraries, vec![default_library()]);
|
||||
assert_eq!(cfg.active_library, DEFAULT_LIBRARY);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_library_keeps_the_files_it_always_had() {
|
||||
let dir = scratch("paths");
|
||||
let s = AppState::new(dir.clone());
|
||||
assert_eq!(s.working_db_path, dir.join("working.db"));
|
||||
assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json"));
|
||||
assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db"));
|
||||
assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_can_be_created_switched_to_and_deleted() {
|
||||
let dir = scratch("libraries");
|
||||
let mut s = AppState::new(dir.clone());
|
||||
let lib = s.create_library(" Scratch ", false, None).unwrap();
|
||||
assert_eq!(lib.name, "Scratch");
|
||||
assert!(s.working_path(&lib.id).exists());
|
||||
assert!(s.create_library("scratch", true, None).is_err(), "names are unique");
|
||||
assert!(s.create_library(" ", true, None).is_err());
|
||||
|
||||
s.switch_library(&lib.id).unwrap();
|
||||
assert_eq!(s.working_db_path, s.working_path(&lib.id));
|
||||
// Remembered across restarts.
|
||||
let mut s = AppState::new(dir.clone());
|
||||
assert_eq!(s.active_library().id, lib.id);
|
||||
|
||||
s.sync_running = true;
|
||||
assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync");
|
||||
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
|
||||
s.sync_running = false;
|
||||
|
||||
assert!(s.delete_library(DEFAULT_LIBRARY).is_err());
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user