diff --git a/README.md b/README.md index af2177c..d8cd3a9 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,17 @@ in a platform-agnostic Rust core, so both platforms share it. src/ React + TypeScript UI (swappable presentation) src-tauri/ Tauri v2 shell — exposes the core as typed commands crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester, sync -crates/mtgc-server/ Optional sync server: one library per user, OIDC and/or passwords +crates/mtgc-server/ Optional sync server: each user's libraries, OIDC and/or passwords ``` Two kinds of data, both in the OS app-data dir (e.g. `~/.local/share/com.garrettmills.mtgc/`): -- **your library** — owned cards, decks, deck contents. Lives on each device - and works fully offline; sign in to a sync server to share it between - devices (see [Sync](#sync)). **Export library…** in the Info tab saves it as - a JSON backup. +- **your libraries** — owned cards, decks, deck contents. Each lives on the + device and works fully offline; sign in to a sync server to share them + between devices (see [Sync](#sync)). You can keep several and switch between + them (see [Libraries](#libraries)). **Export library…** in the Info tab saves + the open one as a JSON backup. - **`reference.db`** — read-only card data built from Scryfall bulk. Bulky and re-downloaded per device, so it's never synced. @@ -261,6 +262,10 @@ stands (**Synced**, **Offline — changes will sync**, …); **Sync now** in the Info tab forces a pass. Offline, edits simply wait on the device: nothing is lost, and they go out with the next sync that reaches the server. +Each library syncs on its own: a library's edits never reach another one. An +older sync server keeps just one library per account, which **Main** syncs +with; update the server to sync the others. + The first time a device signs in to an account that already has a library, and the device has one too, it asks whether to **combine** them (nothing from either is lost) or **use the server's library** instead of this device's. diff --git a/crates/mtgc-core/src/sync/account.rs b/crates/mtgc-core/src/sync/account.rs index 5466516..a5037e6 100644 --- a/crates/mtgc-core/src/sync/account.rs +++ b/crates/mtgc-core/src/sync/account.rs @@ -1,5 +1,5 @@ //! The account side of the sync server: checking an address, finishing a -//! sign-in, and managing the account's devices. +//! sign-in, and managing the account's devices and libraries. //! //! The sign-in itself happens in the browser, on the server's own pages (see //! the server's `auth` module); the app only starts it and redeems the @@ -25,6 +25,10 @@ pub struct ServerInfo { /// The identity provider's display name, when single sign-on is on. #[serde(default)] pub oidc: Option, + /// Whether an account can keep several named libraries. Servers from + /// before then keep one, at `/api/library`. + #[serde(default)] + pub libraries: bool, } /// The newest server API this build speaks. @@ -190,6 +194,18 @@ pub struct DeviceInfo { pub current: bool, } +/// One of an account's libraries on the server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteLibrary { + pub id: String, + pub name: String, + /// The library `/api/library` serves — the one the account started with. + #[serde(default)] + pub default: bool, + #[serde(default)] + pub version: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Me { pub user: AccountUser, @@ -209,6 +225,36 @@ impl HttpRemote { Ok(()) } + /// The account's libraries, the default first. + pub fn list_libraries(&self) -> Result> { + Ok(self.request("GET", "/api/libraries").call().map_err(super::remote::http_error)?.into_json()?) + } + + /// Start a new, empty library on the server. + pub fn create_library(&self, name: &str) -> Result { + Ok(self + .request("POST", "/api/libraries") + .send_json(serde_json::json!({ "name": name })) + .map_err(super::remote::http_error)? + .into_json()?) + } + + pub fn rename_library(&self, id: &str, name: &str) -> Result { + Ok(self + .request("PATCH", &format!("/api/libraries/{}", super::remote::url_path_segment(id))) + .send_json(serde_json::json!({ "name": name })) + .map_err(super::remote::http_error)? + .into_json()?) + } + + /// Delete a library from the server. Devices keep their own copies. + pub fn delete_library(&self, id: &str) -> Result<()> { + self.request("DELETE", &format!("/api/libraries/{}", super::remote::url_path_segment(id))) + .call() + .map_err(super::remote::http_error)?; + Ok(()) + } + /// Sign this device out on the server, so its token stops working even if /// a copy of it survives somewhere. pub fn logout(&self) -> Result<()> { diff --git a/crates/mtgc-core/src/sync/remote.rs b/crates/mtgc-core/src/sync/remote.rs index ca3e270..41d757e 100644 --- a/crates/mtgc-core/src/sync/remote.rs +++ b/crates/mtgc-core/src/sync/remote.rs @@ -1,7 +1,7 @@ //! Syncing the library with a server that stores it, without ever needing one. //! -//! The server is deliberately dumb: it keeps one versioned document per user -//! and accepts a write only when the writer names the version it is replacing +//! The server is deliberately dumb: it keeps versioned documents — each of a +//! user's libraries is one — and accepts a write only when the writer names the version it is replacing //! (compare-and-swap). It never merges. All reconciliation happens here, with //! the same [`merge3`](super::merge3) the rest of the app already trusts, so //! the working copy stays fully usable offline and simply catches up the next @@ -23,8 +23,8 @@ //! on, made enforceable by the server's version numbers. //! //! A base is only meaningful against the library it was read from, so it is -//! stored together with the server and account it belongs to ([`AccountBase`]) -//! and ignored anywhere else. Pointing a device at a different account then +//! stored together with the server, account and library it belongs to +//! ([`AccountBase`]) and ignored anywhere else. Pointing a device at a different account then //! starts from an empty ancestor — which never deletes anything — instead of //! reading every difference between two unrelated libraries as an edit. @@ -69,16 +69,42 @@ pub struct SyncBase { pub struct AccountBase { pub server_url: String, pub user_id: String, + /// The server's id for the library. `None` for the account's only library + /// on a server from before named libraries — which became its default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_id: Option, #[serde(flatten)] pub base: SyncBase, } impl AccountBase { - /// The base, if it belongs to this server and account. `None` means this - /// device has never synced with that library, which callers treat as a - /// first connection. + /// The base, if it belongs to this server and account's only library (on + /// a server without named libraries). `None` means this device has never + /// synced with that library, which callers treat as a first connection. pub fn for_account(self, server_url: &str, user_id: &str) -> Option { - (self.server_url == server_url && self.user_id == user_id).then_some(self.base) + self.for_library(server_url, user_id, None, true) + } + + /// The base, if it belongs to this server, account and library. + /// + /// `library_id` is `None` when talking to a server without named + /// libraries. A base without an id was saved against one of those, and + /// holds for the account's default library (`is_default`), which is what + /// that one library became when the server was upgraded — and the other + /// way round. + pub fn for_library( + self, + server_url: &str, + user_id: &str, + library_id: Option<&str>, + is_default: bool, + ) -> Option { + let same_library = match (self.library_id.as_deref(), library_id) { + (Some(a), Some(b)) => a == b, + (None, None) => true, + _ => is_default, + }; + (self.server_url == server_url && self.user_id == user_id && same_library).then_some(self.base) } } @@ -180,8 +206,14 @@ pub fn first_sync_plan( pub struct HttpRemote { base_url: String, token: String, + /// Which of the account's libraries [`Remote`] reads and writes. `None` is + /// the default one, at the address servers without named libraries use. + library_id: Option, } +/// The library isn't on the server (any more): another device deleted it. +pub const NOT_FOUND: &str = "That library is no longer on the sync server."; + /// The server no longer accepts this device's token: it was revoked, or the /// account removed. Distinguished so the app can show "signed out" instead of /// a generic failure that retrying will never fix. @@ -189,7 +221,25 @@ pub const UNAUTHORIZED: &str = "This device is no longer signed in to the sync s impl HttpRemote { pub fn new(server_url: &str, token: &str) -> Self { - Self { base_url: server_url.trim_end_matches('/').to_string(), token: token.to_string() } + Self { + base_url: server_url.trim_end_matches('/').to_string(), + token: token.to_string(), + library_id: None, + } + } + + /// The same server, reading and writing one of the account's named + /// libraries. + pub fn for_library(server_url: &str, token: &str, library_id: &str) -> Self { + Self { library_id: Some(library_id.to_string()), ..Self::new(server_url, token) } + } + + /// Where [`Remote`] reads and writes. + fn library_path(&self) -> String { + match &self.library_id { + Some(id) => format!("/api/libraries/{}", url_path_segment(id)), + None => "/api/library".to_string(), + } } pub fn server_url(&self) -> &str { @@ -208,14 +258,14 @@ impl HttpRemote { impl Remote for HttpRemote { fn get(&self) -> Result { - let resp = self.request("GET", "/api/library").call().map_err(http_error)?; + let resp = self.request("GET", &self.library_path()).call().map_err(http_error)?; parse_remote(&resp.into_string()?) } fn put(&self, if_match: u64, doc: &LibraryDoc) -> Result { let body = serde_json::to_string(doc)?; let result = self - .request("PUT", "/api/library") + .request("PUT", &self.library_path()) .set("Content-Type", "application/json") .set("If-Match", &format!("\"{if_match}\"")) .send_string(&body); @@ -248,9 +298,21 @@ fn parse_remote(text: &str) -> Result { Ok(RemoteDoc { version: wire.version, doc: LibraryDoc::from_json(&wire.doc.to_string())? }) } +/// Ids are the server's random hex, but nothing should be able to steer a +/// request elsewhere on the server if one ever isn't. +pub(crate) fn url_path_segment(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (b as char).to_string(), + _ => format!("%{b:02X}"), + }) + .collect() +} + pub(crate) fn http_error(e: ureq::Error) -> CoreError { match e { ureq::Error::Status(401, _) => CoreError::Other(UNAUTHORIZED.into()), + ureq::Error::Status(404, _) => CoreError::Other(NOT_FOUND.into()), ureq::Error::Status(code, resp) => { let detail = resp.into_string().unwrap_or_default(); let detail = detail.trim(); @@ -465,6 +527,7 @@ mod tests { let stored = AccountBase { server_url: "https://sync.example".into(), user_id: "u1".into(), + library_id: None, base: SyncBase { version: 3, doc: doc(&[("a", 1)]) }, }; @@ -473,11 +536,46 @@ mod tests { assert!(stored.for_account("https://other.example", "u1").is_none()); } + #[test] + fn a_base_is_only_used_for_the_library_it_came_from() { + let stored = AccountBase { + server_url: "https://sync.example".into(), + user_id: "u1".into(), + library_id: Some("lib1".into()), + base: SyncBase { version: 3, doc: doc(&[("a", 1)]) }, + }; + let url = "https://sync.example"; + assert!(stored.clone().for_library(url, "u1", Some("lib1"), false).is_some()); + assert!(stored.clone().for_library(url, "u1", Some("lib2"), false).is_none()); + assert!(stored.clone().for_library(url, "u1", Some("lib2"), true).is_none()); + assert!(stored.clone().for_library(url, "u2", Some("lib1"), false).is_none()); + } + + #[test] + fn a_base_from_before_named_libraries_holds_for_the_default_one() { + // Saved by an older build, which had no library id to save. + let text = r#"{"server_url":"https://sync.example","user_id":"u1","version":3,"doc":{"version":1,"cards":{},"decks":{}}}"#; + let stored: AccountBase = serde_json::from_str(text).unwrap(); + assert_eq!(stored.library_id, None); + let url = "https://sync.example"; + assert!(stored.clone().for_library(url, "u1", Some("main"), true).is_some()); + assert!(stored.clone().for_library(url, "u1", Some("scratch"), false).is_none()); + assert!(stored.for_account(url, "u1").is_some()); + } + + #[test] + fn library_ids_cannot_steer_the_request_path() { + let r = HttpRemote::for_library("https://s.example/", "t", "../me?x"); + assert_eq!(r.library_path(), "/api/libraries/..%2Fme%3Fx"); + assert_eq!(HttpRemote::new("https://s.example", "t").library_path(), "/api/library"); + } + #[test] fn an_account_base_round_trips_through_json() { let stored = AccountBase { server_url: "https://sync.example".into(), user_id: "u1".into(), + library_id: None, base: SyncBase { version: 3, doc: doc(&[("a", 1)]) }, }; let text = serde_json::to_string(&stored).unwrap(); diff --git a/crates/mtgc-server/src/db.rs b/crates/mtgc-server/src/db.rs index f5ac69e..82b6a54 100644 --- a/crates/mtgc-server/src/db.rs +++ b/crates/mtgc-server/src/db.rs @@ -1,5 +1,5 @@ //! The server's SQLite database: accounts, devices, sign-ins in progress, and -//! each user's library. +//! each user's libraries. //! //! Everything here is synchronous and runs under the one connection mutex in //! [`crate::AppState`]. Every statement is a quick indexed lookup or a @@ -69,6 +69,40 @@ const MIGRATIONS: &[&str] = &[ saved_at INTEGER NOT NULL, PRIMARY KEY (user_id, version) );", + // 2: named libraries. A user can have several; the one they already had + // becomes their default, which `/api/library` keeps serving. Rebuilt + // rather than altered: SQLite can't change a primary key in place. + "ALTER TABLE libraries RENAME TO libraries_v1; + ALTER TABLE library_history RENAME TO library_history_v1; + CREATE TABLE libraries ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + public_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + -- 0, with no doc, until the first write. + version INTEGER NOT NULL DEFAULT 0, + doc TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX libraries_name ON libraries (user_id, name COLLATE NOCASE); + CREATE UNIQUE INDEX libraries_default ON libraries (user_id) WHERE is_default; + CREATE TABLE library_history ( + library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + doc TEXT NOT NULL, + saved_at INTEGER NOT NULL, + PRIMARY KEY (library_id, version) + ); + INSERT INTO libraries (user_id, public_id, name, is_default, version, doc, created_at, updated_at) + SELECT user_id, lower(hex(randomblob(16))), 'Main', 1, version, doc, updated_at, updated_at + FROM libraries_v1; + INSERT INTO library_history (library_id, version, doc, saved_at) + SELECT l.id, h.version, h.doc, h.saved_at + FROM library_history_v1 h JOIN libraries l ON l.user_id = h.user_id AND l.is_default; + DROP TABLE library_history_v1; + DROP TABLE libraries_v1;", ]; /// How many superseded versions of each library are kept. A safety net for @@ -195,19 +229,21 @@ pub fn set_local_password( )? > 0) } -/// Delete a local account along with its devices and library. Returns whether +/// Delete a local account along with its devices and libraries. Returns whether /// it existed. pub fn delete_local_user(conn: &Connection, username: &str) -> rusqlite::Result { Ok(conn.execute("DELETE FROM users WHERE iss = 'local' AND sub = ?1", [username])? > 0) } -/// Every account, for `mtgc-server user list`: (user, device count). -pub fn list_users(conn: &Connection) -> rusqlite::Result> { +/// Every account, for `mtgc-server user list`: (user, device count, library +/// count). +pub fn list_users(conn: &Connection) -> rusqlite::Result> { let mut stmt = conn.prepare(&format!( - "SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id) + "SELECT {USER_COLS}, (SELECT COUNT(*) FROM devices d WHERE d.user_id = users.id), + (SELECT COUNT(*) FROM libraries l WHERE l.user_id = users.id) FROM users ORDER BY iss, sub" ))?; - let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?)))?; + let rows = stmt.query_map([], |r| Ok((user_row(r)?, r.get(6)?, r.get(7)?)))?; rows.collect() } @@ -388,63 +424,275 @@ pub fn take_auth_code( // --- libraries -------------------------------------------------------------- -/// A user's library: (version, document JSON). Version 0 with no document -/// means nothing has been stored yet. -pub fn get_library(conn: &Connection, user_id: i64) -> rusqlite::Result> { +/// One of a user's libraries, without its document. +#[derive(Debug, Clone, PartialEq)] +pub struct Library { + pub id: i64, + /// What clients see as the library's id. Random, like `users.public_id`. + pub public_id: String, + pub name: String, + /// The library `/api/library` serves: the one every account started with, + /// and the one app builds from before named libraries sync. + pub is_default: bool, + /// 0 until the first document is stored. + pub version: i64, + pub updated_at: i64, +} + +const LIBRARY_COLS: &str = "id, public_id, name, is_default, version, updated_at"; + +fn library_row(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Library { + id: r.get(0)?, + public_id: r.get(1)?, + name: r.get(2)?, + is_default: r.get(3)?, + version: r.get(4)?, + updated_at: r.get(5)?, + }) +} + +/// Why a library couldn't be created, renamed or deleted. +#[derive(Debug)] +pub enum LibraryError { + /// The user already has a library by that name (ignoring case). + NameTaken, + /// The default library can't be deleted. + IsDefault, + Db(rusqlite::Error), +} + +impl From for LibraryError { + fn from(e: rusqlite::Error) -> Self { + Self::Db(e) + } +} + +fn is_unique_violation(e: &rusqlite::Error) -> bool { + matches!( + e, + rusqlite::Error::SqliteFailure(f, _) if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + ) +} + +/// A user's libraries, the default first. Creates the default if the user +/// has none yet, so every account always has somewhere to sync to. +pub fn list_libraries(conn: &Connection, user_id: i64) -> rusqlite::Result> { + default_library(conn, user_id)?; + let mut stmt = conn.prepare(&format!( + "SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1 + ORDER BY is_default DESC, name COLLATE NOCASE, id" + ))?; + let rows = stmt.query_map([user_id], library_row)?; + rows.collect() +} + +/// The user's default library, created (empty, at version 0) if needed. +pub fn default_library(conn: &Connection, user_id: i64) -> rusqlite::Result { + let found = conn + .query_row( + &format!("SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1 AND is_default"), + [user_id], + library_row, + ) + .optional()?; + if let Some(lib) = found { + return Ok(lib); + } + // "Main", unless the user already took that name for another library. + let mut name = DEFAULT_LIBRARY_NAME.to_string(); + let mut n = 1; + loop { + match insert_library(conn, user_id, &name, true) { + Err(LibraryError::NameTaken) => { + n += 1; + name = format!("{DEFAULT_LIBRARY_NAME} {n}"); + } + Err(LibraryError::Db(e)) => return Err(e), + Err(LibraryError::IsDefault) => unreachable!(), + Ok(lib) => return Ok(lib), + } + } +} + +/// What the default library is called until someone renames it. +pub const DEFAULT_LIBRARY_NAME: &str = "Main"; + +fn insert_library( + conn: &Connection, + user_id: i64, + name: &str, + is_default: bool, +) -> Result { + let t = now(); + let inserted = conn.execute( + "INSERT INTO libraries (user_id, public_id, name, is_default, version, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 0, ?5, ?5)", + params![user_id, crate::random_hex(16), name, is_default, t], + ); + match inserted { + Err(e) if is_unique_violation(&e) => return Err(LibraryError::NameTaken), + r => r?, + }; + Ok(conn.query_row( + &format!("SELECT {LIBRARY_COLS} FROM libraries WHERE id = ?1"), + [conn.last_insert_rowid()], + library_row, + )?) +} + +/// Create a new, empty library for a user. +pub fn create_library(conn: &Connection, user_id: i64, name: &str) -> Result { + // The default comes first, so a brand-new account creating its second + // library doesn't end up with that one as its only library. + default_library(conn, user_id)?; + insert_library(conn, user_id, name, false) +} + +/// One of a user's libraries by its public id. Scoped to the user, so nobody +/// can reach someone else's. +pub fn library_for(conn: &Connection, user_id: i64, public_id: &str) -> rusqlite::Result> { conn.query_row( - "SELECT version, doc FROM libraries WHERE user_id = ?1", - [user_id], - |r| Ok((r.get(0)?, r.get(1)?)), + &format!("SELECT {LIBRARY_COLS} FROM libraries WHERE user_id = ?1 AND public_id = ?2"), + params![user_id, public_id], + library_row, ) .optional() } +pub fn rename_library(conn: &Connection, library_id: i64, name: &str) -> Result<(), LibraryError> { + match conn.execute("UPDATE libraries SET name = ?2 WHERE id = ?1", params![library_id, name]) { + Err(e) if is_unique_violation(&e) => Err(LibraryError::NameTaken), + r => r.map(|_| ()).map_err(Into::into), + } +} + +/// Delete a library and its history. The default library stays. +pub fn delete_library(conn: &Connection, library: &Library) -> Result<(), LibraryError> { + if library.is_default { + return Err(LibraryError::IsDefault); + } + conn.execute("DELETE FROM libraries WHERE id = ?1", [library.id])?; + Ok(()) +} + +/// A library's document: (version, document JSON). No document means nothing +/// has been stored yet. +pub fn get_library(conn: &Connection, library_id: i64) -> rusqlite::Result<(i64, Option)> { + conn.query_row( + "SELECT version, doc FROM libraries WHERE id = ?1", + [library_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) +} + pub enum PutResult { Stored(i64), /// Someone else wrote first: the current version and document. Conflict(i64, Option), } -/// Replace a user's library, but only if it is still at `if_match`. +/// Replace a library's document, but only if it is still at `if_match`. pub fn put_library( conn: &Connection, - user_id: i64, + library_id: i64, if_match: i64, doc: &str, ) -> rusqlite::Result { let tx = conn.unchecked_transaction()?; - let current = get_library(&tx, user_id)?; - let current_version = current.as_ref().map(|(v, _)| *v).unwrap_or(0); + let (current_version, current) = get_library(&tx, library_id)?; if current_version != if_match { - return Ok(PutResult::Conflict(current_version, current.map(|(_, d)| d))); + return Ok(PutResult::Conflict(current_version, current)); } let t = now(); - if let Some((v, old)) = ¤t { + if let Some(old) = ¤t { tx.execute( - "INSERT OR REPLACE INTO library_history (user_id, version, doc, saved_at) + "INSERT OR REPLACE INTO library_history (library_id, version, doc, saved_at) VALUES (?1, ?2, ?3, ?4)", - params![user_id, v, old, t], + params![library_id, current_version, old, t], )?; tx.execute( - "DELETE FROM library_history WHERE user_id = ?1 AND version <= ?2", - params![user_id, v - HISTORY_KEEP], + "DELETE FROM library_history WHERE library_id = ?1 AND version <= ?2", + params![library_id, current_version - HISTORY_KEEP], )?; } let version = current_version + 1; tx.execute( - "INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (?1, ?2, ?3, ?4) - ON CONFLICT (user_id) DO UPDATE SET - version = excluded.version, doc = excluded.doc, updated_at = excluded.updated_at", - params![user_id, version, doc, t], + "UPDATE libraries SET version = ?2, doc = ?3, updated_at = ?4 WHERE id = ?1", + params![library_id, version, doc, t], )?; tx.commit()?; Ok(PutResult::Stored(version)) } -pub fn history_versions(conn: &Connection, user_id: i64) -> rusqlite::Result> { +pub fn history_versions(conn: &Connection, library_id: i64) -> rusqlite::Result> { let mut stmt = conn - .prepare("SELECT version FROM library_history WHERE user_id = ?1 ORDER BY version")?; - let rows = stmt.query_map([user_id], |r| r.get(0))?; + .prepare("SELECT version FROM library_history WHERE library_id = ?1 ORDER BY version")?; + let rows = stmt.query_map([library_id], |r| r.get(0))?; rows.collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_library_a_user_already_had_becomes_their_default() { + // A database as the first release left it: one library per user. + let conn = Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + conn.execute_batch(MIGRATIONS[0]).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + conn.execute_batch( + "INSERT INTO users (id, public_id, iss, sub, created_at) VALUES (1, 'u1', 'local', 'alice', 0); + INSERT INTO users (id, public_id, iss, sub, created_at) VALUES (2, 'u2', 'local', 'bob', 0); + INSERT INTO libraries (user_id, version, doc, updated_at) VALUES (1, 7, '{\"a\":1}', 100); + INSERT INTO library_history (user_id, version, doc, saved_at) VALUES (1, 6, '{\"old\":1}', 90);", + ) + .unwrap(); + + let conn = init(conn).unwrap(); + + let libs = list_libraries(&conn, 1).unwrap(); + assert_eq!(libs.len(), 1); + let main = &libs[0]; + assert!(main.is_default); + assert_eq!(main.name, DEFAULT_LIBRARY_NAME); + assert_eq!(main.public_id.len(), 32); + assert_eq!(get_library(&conn, main.id).unwrap(), (7, Some("{\"a\":1}".to_string()))); + assert_eq!(history_versions(&conn, main.id).unwrap(), vec![6]); + + // Bob never stored anything: he gets an empty default when he asks. + let bobs = default_library(&conn, 2).unwrap(); + assert_eq!(get_library(&conn, bobs.id).unwrap(), (0, None)); + } + + #[test] + fn names_are_unique_per_user_ignoring_case() { + let conn = open_in_memory().unwrap(); + let a = create_local_user(&conn, "alice", "", "h").unwrap(); + let b = create_local_user(&conn, "bob", "", "h").unwrap(); + create_library(&conn, a.id, "Scratch").unwrap(); + assert!(matches!(create_library(&conn, a.id, "scratch"), Err(LibraryError::NameTaken))); + create_library(&conn, b.id, "Scratch").unwrap(); + + let main = default_library(&conn, a.id).unwrap(); + assert!(matches!(rename_library(&conn, main.id, "SCRATCH"), Err(LibraryError::NameTaken))); + assert!(matches!(delete_library(&conn, &main), Err(LibraryError::IsDefault))); + } + + #[test] + fn deleting_a_user_deletes_their_libraries_and_history() { + let conn = open_in_memory().unwrap(); + let a = create_local_user(&conn, "alice", "", "h").unwrap(); + let lib = create_library(&conn, a.id, "Scratch").unwrap(); + put_library(&conn, lib.id, 0, "{}").unwrap(); + put_library(&conn, lib.id, 1, "{}").unwrap(); + assert!(delete_local_user(&conn, "alice").unwrap()); + let left: i64 = conn + .query_row("SELECT (SELECT COUNT(*) FROM libraries) + (SELECT COUNT(*) FROM library_history)", [], |r| r.get(0)) + .unwrap(); + assert_eq!(left, 0); + } +} diff --git a/crates/mtgc-server/src/lib.rs b/crates/mtgc-server/src/lib.rs index 15a78a2..06223a2 100644 --- a/crates/mtgc-server/src/lib.rs +++ b/crates/mtgc-server/src/lib.rs @@ -1,8 +1,8 @@ //! mtgc-server — keeps each user's library so their devices can sync it. //! -//! The server is deliberately dumb about libraries: it stores one versioned -//! document per user and accepts a write only when the writer names the -//! version it is replacing. All merging happens on the devices (see +//! The server is deliberately dumb about libraries: it stores versioned +//! documents — a user can have several, by name — and accepts a write only +//! when the writer names the version it is replacing. All merging happens on the devices (see //! `mtgc_core::sync::remote`), which is what lets them keep working offline and //! keeps this side small enough to trust. //! @@ -71,7 +71,12 @@ pub fn router(state: AppState) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) .route("/api/info", get(info)) - .route("/api/library", get(library::get_library).put(library::put_library)) + .route("/api/library", get(library::get_default).put(library::put_default)) + .route("/api/libraries", get(library::list).post(library::create)) + .route( + "/api/libraries/{id}", + get(library::get_doc).put(library::put_doc).patch(library::rename).delete(library::remove), + ) .route("/api/me", get(auth::me)) .route("/api/devices/{id}", delete(auth::revoke_device)) .route("/auth/start", get(auth::start)) @@ -91,6 +96,9 @@ async fn info(axum::extract::State(state): axum::extract::State) -> Js Json(serde_json::json!({ "server": "mtgc", "api": 1, + // Named libraries at /api/libraries. Advertised rather than bumping + // `api`, which older apps take as "this server is too new for me". + "libraries": true, "local_auth": c.local_auth, "oidc": c.oidc.as_ref().map(|o| o.display_name.clone()), })) diff --git a/crates/mtgc-server/src/library.rs b/crates/mtgc-server/src/library.rs index 30a7c45..eb97a17 100644 --- a/crates/mtgc-server/src/library.rs +++ b/crates/mtgc-server/src/library.rs @@ -1,62 +1,184 @@ -//! The one resource: the signed-in user's library document. +//! The signed-in user's libraries. //! -//! `GET` returns `{version, doc}`. `PUT` replaces the document only when its -//! `If-Match` names the current version, answering `412` with the current -//! `{version, doc}` otherwise, so the device can merge against it at once. +//! `GET /api/libraries` lists them and `POST` creates one; each is then a +//! versioned document at `/api/libraries/{id}`. `GET` returns `{version, doc}`. +//! `PUT` replaces the document only when its `If-Match` names the current +//! version, answering `412` with the current `{version, doc}` otherwise, so the +//! device can merge against it at once. +//! +//! `/api/library` is the user's default library under its old address, for +//! app builds from before there could be more than one. -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use mtgc_core::sync::LibraryDoc; +use serde::Deserialize; use serde_json::{json, Value}; use crate::auth::Authed; -use crate::db::{self, PutResult}; +use crate::db::{self, Library, LibraryError, PutResult}; use crate::{ApiError, AppState}; -pub async fn get_library(State(state): State, auth: Authed) -> Result { - let stored = db::get_library(&state.db(), auth.user.id)?; - Ok(Json(wire(stored)?).into_response()) +/// Longest library name accepted, in characters. +const MAX_NAME_CHARS: usize = 100; + +pub async fn list(State(state): State, auth: Authed) -> Result { + let libs = db::list_libraries(&state.db(), auth.user.id)?; + Ok(Json(libs.iter().map(describe).collect::>()).into_response()) } -pub async fn put_library( +#[derive(Deserialize)] +pub struct NameBody { + name: String, +} + +pub async fn create( + State(state): State, + auth: Authed, + Json(body): Json, +) -> Result { + let name = valid_name(&body.name)?; + let lib = db::create_library(&state.db(), auth.user.id, &name).map_err(library_error)?; + Ok((StatusCode::CREATED, Json(describe(&lib))).into_response()) +} + +pub async fn rename( + State(state): State, + auth: Authed, + Path(id): Path, + Json(body): Json, +) -> Result { + let name = valid_name(&body.name)?; + let conn = state.db(); + let lib = find(&conn, &auth, &id)?; + db::rename_library(&conn, lib.id, &name).map_err(library_error)?; + Ok(Json(describe(&Library { name, ..lib })).into_response()) +} + +pub async fn remove( + State(state): State, + auth: Authed, + Path(id): Path, +) -> Result { + let conn = state.db(); + let lib = find(&conn, &auth, &id)?; + db::delete_library(&conn, &lib).map_err(library_error)?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn get_doc( + State(state): State, + auth: Authed, + Path(id): Path, +) -> Result { + let conn = state.db(); + let lib = find(&conn, &auth, &id)?; + Ok(Json(wire(db::get_library(&conn, lib.id)?)?).into_response()) +} + +pub async fn put_doc( + State(state): State, + auth: Authed, + Path(id): Path, + headers: HeaderMap, + body: String, +) -> Result { + let (if_match, text) = parse_put(&headers, &body)?; + let conn = state.db(); + let lib = find(&conn, &auth, &id)?; + stored(db::put_library(&conn, lib.id, if_match, &text)?) +} + +/// `GET /api/library`: the default library, for older app builds. +pub async fn get_default(State(state): State, auth: Authed) -> Result { + let conn = state.db(); + let lib = db::default_library(&conn, auth.user.id)?; + Ok(Json(wire(db::get_library(&conn, lib.id)?)?).into_response()) +} + +/// `PUT /api/library`: the default library, for older app builds. +pub async fn put_default( State(state): State, auth: Authed, headers: HeaderMap, body: String, ) -> Result { - let if_match = if_match(&headers)?; + let (if_match, text) = parse_put(&headers, &body)?; + let conn = state.db(); + let lib = db::default_library(&conn, auth.user.id)?; + stored(db::put_library(&conn, lib.id, if_match, &text)?) +} + +fn describe(lib: &Library) -> Value { + json!({ + "id": lib.public_id, + "name": lib.name, + "default": lib.is_default, + "version": lib.version, + "updated_at": lib.updated_at, + }) +} + +fn find(conn: &rusqlite::Connection, auth: &Authed, id: &str) -> Result { + db::library_for(conn, auth.user.id, id)? + .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "no such library")) +} + +fn valid_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(ApiError::new(StatusCode::BAD_REQUEST, "a library needs a name")); + } + if name.chars().count() > MAX_NAME_CHARS { + return Err(ApiError::new(StatusCode::BAD_REQUEST, "that name is too long")); + } + Ok(name.to_string()) +} + +fn library_error(e: LibraryError) -> ApiError { + match e { + LibraryError::NameTaken => ApiError::new(StatusCode::CONFLICT, "you already have a library by that name"), + LibraryError::IsDefault => ApiError::new(StatusCode::CONFLICT, "the default library can't be deleted"), + LibraryError::Db(e) => e.into(), + } +} + +/// The version a write replaces, and the document normalised for storing. +fn parse_put(headers: &HeaderMap, body: &str) -> Result<(i64, String), ApiError> { + let if_match = if_match(headers)?; // Validate with the app's own parser, so the server never stores anything // a device would refuse to read back — including a format newer than // this server understands. - let doc = LibraryDoc::from_json(&body) + let doc = LibraryDoc::from_json(body) .map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, format!("not a library: {e}")))?; // Store it normalised rather than as sent: same content, same bytes. let text = serde_json::to_string(&doc).map_err(|e| ApiError::new(StatusCode::BAD_REQUEST, e.to_string()))?; + Ok((if_match, text)) +} - let result = db::put_library(&state.db(), auth.user.id, if_match, &text)?; +fn stored(result: PutResult) -> Result { Ok(match result { PutResult::Stored(version) => Json(json!({ "version": version })).into_response(), PutResult::Conflict(version, doc) => { - (StatusCode::PRECONDITION_FAILED, Json(wire(doc.map(|d| (version, d)))?)).into_response() + (StatusCode::PRECONDITION_FAILED, Json(wire((version, doc))?)).into_response() } }) } -/// `{version, doc}`, with an empty library at version 0 for a user who has -/// never stored one. -fn wire(stored: Option<(i64, String)>) -> Result { +/// `{version, doc}`, with an empty library for one nothing was stored in yet. +fn wire((version, stored): (i64, Option)) -> Result { Ok(match stored { - Some((version, text)) => { + Some(text) => { let doc: Value = serde_json::from_str(&text).map_err(|e| { tracing::error!("stored library is unreadable: {e}"); ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "stored library is unreadable") })?; json!({ "version": version, "doc": doc }) } - None => json!({ "version": 0, "doc": LibraryDoc::default() }), + None => json!({ "version": version, "doc": LibraryDoc::default() }), }) } diff --git a/crates/mtgc-server/src/main.rs b/crates/mtgc-server/src/main.rs index 1092a1e..2de3a50 100644 --- a/crates/mtgc-server/src/main.rs +++ b/crates/mtgc-server/src/main.rs @@ -152,7 +152,7 @@ fn user(args: &[String]) -> anyhow::Result<()> { if !args.iter().any(|a| a == "--yes") { bail!( "this deletes {username}'s account, signs out their devices, and deletes \ - the library stored on this server (devices keep their own copies). \ + the libraries stored on this server (devices keep their own copies). \ Re-run with --yes to confirm." ); } @@ -167,10 +167,13 @@ fn user(args: &[String]) -> anyhow::Result<()> { if users.is_empty() { println!("No accounts yet. Add one with `mtgc-server user add `."); } - for (u, devices) in users { + for (u, devices, libraries) in users { let kind = if u.iss == db::LOCAL_ISS { "local".to_string() } else { format!("oidc {}", u.iss) }; let who = if u.email.is_empty() { u.display_name } else { format!("{} <{}>", u.display_name, u.email) }; - println!("{:<24} {:<8} devices: {:<3} {}", u.sub, kind, devices, who); + println!( + "{:<24} {:<8} devices: {:<3} libraries: {:<3} {}", + u.sub, kind, devices, libraries, who + ); } } _ => bail!("{USAGE}"), diff --git a/crates/mtgc-server/tests/api.rs b/crates/mtgc-server/tests/api.rs index 3642e48..330cc1f 100644 --- a/crates/mtgc-server/tests/api.rs +++ b/crates/mtgc-server/tests/api.rs @@ -1,5 +1,5 @@ //! The server driven through its router in-process: sign-in end to end, the -//! library's compare-and-swap, and device revocation. +//! libraries and their compare-and-swap, and device revocation. use axum::body::Body; use axum::http::{header, Request, Response, StatusCode}; @@ -110,8 +110,12 @@ impl Server { } async fn get_library(&self, token: &str) -> Response { + self.get_at("/api/library", token).await + } + + async fn get_at(&self, uri: &str, token: &str) -> Response { self.send( - Request::get("/api/library") + Request::get(uri) .header(header::AUTHORIZATION, format!("Bearer {token}")) .body(Body::empty()) .unwrap(), @@ -120,7 +124,11 @@ impl Server { } async fn put_library(&self, token: &str, if_match: Option<&str>, doc: Value) -> Response { - let mut req = Request::put("/api/library") + self.put_at("/api/library", token, if_match, doc).await + } + + async fn put_at(&self, uri: &str, token: &str, if_match: Option<&str>, doc: Value) -> Response { + let mut req = Request::put(uri) .header(header::AUTHORIZATION, format!("Bearer {token}")) .header(header::CONTENT_TYPE, "application/json"); if let Some(v) = if_match { @@ -130,6 +138,16 @@ impl Server { } } +/// An authenticated JSON request. +fn authed(method: &str, uri: &str, token: &str, body: Option) -> Request { + let req = Request::builder() + .method(method) + .uri(uri) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json"); + req.body(body.map(|b| Body::from(b.to_string())).unwrap_or_else(Body::empty)).unwrap() +} + fn get(uri: &str) -> Request { Request::get(uri).body(Body::empty()).unwrap() } @@ -341,6 +359,7 @@ async fn the_server_describes_itself() { assert_eq!(info["server"], "mtgc"); assert_eq!(info["local_auth"], true); assert_eq!(info["oidc"], Value::Null); + assert_eq!(info["libraries"], true); } // --- devices ------------------------------------------------------------------ @@ -469,7 +488,116 @@ async fn old_versions_are_kept_up_to_a_limit() { } let user_id: i64 = s.state.db().query_row("SELECT id FROM users WHERE sub = 'alice'", [], |r| r.get(0)).unwrap(); - let kept = db::history_versions(&s.state.db(), user_id).unwrap(); + let main = db::default_library(&s.state.db(), user_id).unwrap(); + let kept = db::history_versions(&s.state.db(), main.id).unwrap(); assert_eq!(kept.len() as i64, db::HISTORY_KEEP); assert_eq!(*kept.last().unwrap(), total - 1, "the newest superseded version is kept"); } + +// --- named libraries ------------------------------------------------------------ + +#[tokio::test] +async fn every_account_starts_with_a_default_library() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await; + let list = list.as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["name"], "Main"); + assert_eq!(list[0]["default"], true); + assert_eq!(list[0]["version"], 0); +} + +#[tokio::test] +async fn libraries_can_be_created_renamed_and_deleted() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + + let resp = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " Scratch " })))).await; + assert_eq!(resp.status(), StatusCode::CREATED); + let scratch = body_json(resp).await; + assert_eq!(scratch["name"], "Scratch"); + assert_eq!(scratch["default"], false); + let id = scratch["id"].as_str().unwrap().to_string(); + + let dup = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "scratch" })))).await; + assert_eq!(dup.status(), StatusCode::CONFLICT); + let blank = s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": " " })))).await; + assert_eq!(blank.status(), StatusCode::BAD_REQUEST); + + let resp = s.send(authed("PATCH", &format!("/api/libraries/{id}"), &t, Some(json!({ "name": "Testing" })))).await; + assert_eq!(resp.status(), StatusCode::OK); + let names: Vec = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await) + .await + .as_array() + .unwrap() + .iter() + .map(|l| l["name"].clone()) + .collect(); + assert_eq!(names, vec![json!("Main"), json!("Testing")]); + + let main_id = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await[0]["id"] + .as_str() + .unwrap() + .to_string(); + let resp = s.send(authed("DELETE", &format!("/api/libraries/{main_id}"), &t, None)).await; + assert_eq!(resp.status(), StatusCode::CONFLICT, "the default library stays"); + + let resp = s.send(authed("DELETE", &format!("/api/libraries/{id}"), &t, None)).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let resp = s.get_at(&format!("/api/libraries/{id}"), &t).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn each_library_has_its_own_versions() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + let scratch = body_json(s.send(authed("POST", "/api/libraries", &t, Some(json!({ "name": "Scratch" })))).await).await; + let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap()); + + assert_eq!(s.put_library(&t, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::OK); + assert_eq!(s.put_library(&t, Some("1"), library(&[("a", 2)])).await.status(), StatusCode::OK); + + // Scratch is still at 0, whatever the default library is at. + assert_eq!(s.put_at(&uri, &t, Some("2"), library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_FAILED); + assert_eq!(s.put_at(&uri, &t, Some("0"), library(&[("x", 1)])).await.status(), StatusCode::OK); + assert_eq!(s.put_at(&uri, &t, None, library(&[("x", 1)])).await.status(), StatusCode::PRECONDITION_REQUIRED); + + let got = body_json(s.get_at(&uri, &t).await).await; + assert_eq!(got["version"], 1); + assert_eq!(got["doc"]["cards"], json!({ "x": { "quantity": 1 } })); + let main = body_json(s.get_library(&t).await).await; + assert_eq!(main["version"], 2); + assert_eq!(main["doc"]["cards"]["a"]["quantity"], 2); +} + +#[tokio::test] +async fn the_old_address_is_the_default_library() { + let s = Server::local(); + let t = s.sign_in("alice", "correct horse battery").await; + s.put_library(&t, Some("0"), library(&[("a", 1)])).await; + + let list = body_json(s.send(authed("GET", "/api/libraries", &t, None)).await).await; + let main = &list.as_array().unwrap()[0]; + assert_eq!(main["version"], 1); + let got = body_json(s.get_at(&format!("/api/libraries/{}", main["id"].as_str().unwrap()), &t).await).await; + assert_eq!(got["doc"]["cards"]["a"]["quantity"], 1); +} + +#[tokio::test] +async fn nobody_can_reach_someone_elses_library() { + let s = Server::local(); + s.add_user("bob", "another fine password"); + let alice = s.sign_in("alice", "correct horse battery").await; + let bob = s.sign_in("bob", "another fine password").await; + let scratch = body_json(s.send(authed("POST", "/api/libraries", &alice, Some(json!({ "name": "Scratch" })))).await).await; + let uri = format!("/api/libraries/{}", scratch["id"].as_str().unwrap()); + + assert_eq!(s.get_at(&uri, &bob).await.status(), StatusCode::NOT_FOUND); + assert_eq!(s.put_at(&uri, &bob, Some("0"), library(&[("a", 1)])).await.status(), StatusCode::NOT_FOUND); + let rename = s.send(authed("PATCH", &uri, &bob, Some(json!({ "name": "Mine" })))).await; + assert_eq!(rename.status(), StatusCode::NOT_FOUND); + assert_eq!(s.send(authed("DELETE", &uri, &bob, None)).await.status(), StatusCode::NOT_FOUND); + assert_eq!(s.get_at(&uri, &alice).await.status(), StatusCode::OK); +} diff --git a/crates/mtgc-server/tests/sync_e2e.rs b/crates/mtgc-server/tests/sync_e2e.rs index 0575797..e1ff76c 100644 --- a/crates/mtgc-server/tests/sync_e2e.rs +++ b/crates/mtgc-server/tests/sync_e2e.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce}; -use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED}; +use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, NOT_FOUND, UNAUTHORIZED}; use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry}; use mtgc_server::auth::local::hash_password; use mtgc_server::{db, router, AppState, Config}; @@ -64,6 +64,16 @@ impl Device { } } + /// A second working copy on the same device, for one of the account's + /// named libraries. + fn for_library(dir: &Path, name: &str, server: &str, token: &str, library_id: &str) -> Self { + Self { + db: dir.join(format!("{name}.db")), + base: SyncBase::default(), + remote: HttpRemote::for_library(server, token, library_id), + } + } + fn sync(&mut self) -> mtgc_core::Result { let path = self.db.clone(); sync_cycle(&self.remote, &mut self.base, |base, theirs| { @@ -197,3 +207,53 @@ fn an_address_that_is_not_an_mtgc_server_is_rejected() { let err = server_info(&format!("http://{addr}")).unwrap_err(); assert!(err.to_string().contains("doesn't look like an mtgc sync server"), "{err}"); } + +#[test] +fn a_named_library_syncs_without_touching_the_default_one() { + let (server, state) = spawn_server(); + let hash = hash_password("correct horse battery").unwrap(); + db::create_local_user(&state.db(), "carol", "Carol", &hash).unwrap(); + let dir = std::env::temp_dir().join(format!("mtgc-e2e-named-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + assert!(server_info(&server).unwrap().libraries); + let phone_login = sign_in(&server, "carol", "correct horse battery"); + let desktop_login = sign_in(&server, "carol", "correct horse battery"); + + let mut phone_main = Device::new(&dir, "phone-main", &server, &phone_login.token); + phone_main.set("a", 1); + phone_main.sync().unwrap(); + + // The phone starts a scratch library; the desktop finds it in the list. + let scratch = phone_main.remote.create_library("Scratch").unwrap(); + let mut phone_scratch = Device::for_library(&dir, "phone-scratch", &server, &phone_login.token, &scratch.id); + phone_scratch.set("x", 5); + assert!(phone_scratch.sync().unwrap().published); + + let desktop_account = HttpRemote::new(&server, &desktop_login.token); + let listed = desktop_account.list_libraries().unwrap(); + assert_eq!(listed.iter().map(|l| (l.name.as_str(), l.default)).collect::>(), [("Main", true), ("Scratch", false)]); + let mut desktop_scratch = Device::for_library(&dir, "desktop-scratch", &server, &desktop_login.token, &listed[1].id); + desktop_scratch.sync().unwrap(); + assert_eq!(desktop_scratch.qty("x"), Some(5)); + assert_eq!(desktop_scratch.qty("a"), None, "nothing from Main"); + + desktop_scratch.set("x", 6); + desktop_scratch.sync().unwrap(); + phone_scratch.sync().unwrap(); + assert_eq!(phone_scratch.qty("x"), Some(6)); + + let mut desktop_main = Device::new(&dir, "desktop-main", &server, &desktop_login.token); + desktop_main.sync().unwrap(); + assert_eq!(desktop_main.qty("a"), Some(1)); + assert_eq!(desktop_main.qty("x"), None, "nothing from Scratch"); + + // Deleted from one device: the other's next sync says so distinctly. + desktop_account.delete_library(&scratch.id).unwrap(); + let err = phone_scratch.sync().unwrap_err(); + assert_eq!(err.to_string(), NOT_FOUND); + assert_eq!(phone_scratch.qty("x"), Some(6), "the local copy is kept"); + + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 838e66c..33bf4d4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,8 +20,11 @@ type Res = Result; /// High-level status for the top bar / first-run gating. #[derive(Serialize)] pub struct Status { - /// Where the library syncs: the account and server, or this device only. + /// The open library's name and where it syncs: the account and server, or + /// this device only. Changes whenever another library is opened. pub library_label: String, + /// The open library. + pub library: OpenLibrary, pub reference_path: String, pub reference_ready: bool, pub card_count: usize, @@ -39,18 +42,25 @@ pub struct Status { pub is_mobile: bool, } -/// Where the library syncs, for the top bar — never the internal working -/// database, which is an implementation detail the user never sees. +#[derive(Serialize)] +pub struct OpenLibrary { + pub id: String, + pub name: String, +} + +/// The open library and where it syncs, for the top bar — never the internal +/// working database, which is an implementation detail the user never sees. fn library_label(s: &AppState) -> String { + let lib = s.active_library(); match s.sync_account() { - Some(a) if a.token.is_some() => { + Some(a) if a.token.is_some() && lib.sync => { let host = url::Url::parse(&a.server_url) .ok() .and_then(|u| u.host_str().map(str::to_string)) .unwrap_or_else(|| a.server_url.clone()); - format!("{} @ {host}", a.user.username) + format!("{} · {} @ {host}", lib.name, a.user.username) } - _ => "This device only (not syncing)".to_string(), + _ => format!("{} · this device only", lib.name), } } @@ -60,8 +70,10 @@ fn status_of(s: &AppState) -> Status { .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64); + let lib = s.active_library(); Status { library_label: library_label(s), + library: OpenLibrary { id: lib.id.clone(), name: lib.name.clone() }, reference_path: s.reference_path.display().to_string(), reference_ready: s.catalog.is_some(), card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 425719b..c5e3109 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod libraries; mod news; mod state; mod sync; @@ -76,6 +77,12 @@ pub fn run() { sync::sync_devices, sync::sync_revoke_device, sync::import_library_file, + libraries::list_libraries, + libraries::create_library, + libraries::switch_library, + libraries::rename_library, + libraries::delete_library, + libraries::set_library_sync, sync::export_library_document, news::get_news, news::get_set_cards, diff --git a/src-tauri/src/libraries.rs b/src-tauri/src/libraries.rs new file mode 100644 index 0000000..4b93cd7 --- /dev/null +++ b/src-tauri/src/libraries.rs @@ -0,0 +1,190 @@ +//! The libraries on this device: listing, starting, opening, renaming and +//! deleting them. +//! +//! Each is a separate working copy (see [`AppState`]), and one is open at a +//! time. Linking them up with the account's libraries on a sync server is +//! `sync.rs`'s job; the commands here only touch the server for what the user +//! asks for directly — a rename, or deleting a library everywhere — and +//! otherwise leave it to the next sync. + +use mtgc_core::sync::remote::{HttpRemote, NOT_FOUND}; +use serde::Serialize; +use tauri::AppHandle; + +use crate::state::{AppState, LocalLibrary}; +use crate::sync::{blocking, lock, sync_now}; + +/// A library, as the UI lists it. +#[derive(Serialize)] +pub struct LibraryInfo { + pub id: String, + pub name: String, + /// The one that's open. + pub active: bool, + /// The library the device started with, which can't be deleted. + pub is_default: bool, + /// Whether it should be on the sync server. + pub sync: bool, + /// Whether it's linked to a library on the server yet. + pub linked: bool, + /// Another device deleted it from the server; this copy is all that's left. + pub server_deleted: bool, +} + +fn info(s: &AppState, lib: &LocalLibrary) -> LibraryInfo { + LibraryInfo { + id: lib.id.clone(), + name: lib.name.clone(), + active: s.active_library().id == lib.id, + is_default: lib.is_default(), + sync: lib.sync, + linked: lib.remote_id.is_some(), + server_deleted: lib.server_deleted, + } +} + +fn list(s: &AppState) -> Vec { + s.libraries().iter().map(|l| info(s, l)).collect() +} + +/// The signed-in account's server, if any. +fn account_remote(s: &AppState) -> Option { + let a = s.sync_account()?; + Some(HttpRemote::new(&a.server_url, a.token.as_deref()?)) +} + +/// Let the next sync put a change on the server now rather than at the next +/// trigger, without making the user wait for it. +fn sync_soon(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = sync_now(app).await; + }); +} + +#[tauri::command] +pub async fn list_libraries(app: AppHandle) -> Result, String> { + blocking(move || Ok(list(&lock(&app)))).await +} + +/// Start a new, empty library and open it. With `sync`, the next sync puts it +/// on the server. +#[tauri::command] +pub async fn create_library(app: AppHandle, name: String, sync: bool) -> Result, String> { + let app2 = app.clone(); + let libs = blocking(move || { + let mut s = lock(&app2); + let lib = s.create_library(&name, sync, None)?; + s.switch_library(&lib.id)?; + crate::mlog!("create_library: {} (sync={sync})", lib.name); + Ok(list(&s)) + }) + .await; + let libs = crate::logged("create_library", libs)?; + if sync { + sync_soon(&app); + } + Ok(libs) +} + +/// Open another library. +#[tauri::command] +pub async fn switch_library(app: AppHandle, id: String) -> Result, String> { + let libs = blocking(move || { + let mut s = lock(&app); + s.switch_library(&id)?; + crate::mlog!("switch_library: {}", s.active_library().name); + Ok(list(&s)) + }) + .await; + crate::logged("switch_library", libs) +} + +/// Rename a library — on the server too when it's there, so every device +/// picks the new name up. +#[tauri::command] +pub async fn rename_library(app: AppHandle, id: String, name: String) -> Result, String> { + let libs = blocking(move || { + let (name, remote_id, remote) = { + let s = lock(&app); + let lib = s.library(&id).ok_or("There's no such library on this device.")?; + (s.check_name(&name, Some(&id))?, lib.remote_id.clone(), account_remote(&s)) + }; + if let Some(rid) = &remote_id { + let remote = remote.ok_or("Sign in to the sync server to rename a library that's on it.")?; + remote.rename_library(rid, &name).map_err(|e| format!("Couldn't rename it on the server: {e}"))?; + } + let mut s = lock(&app); + s.update_library(&id, |l| l.name = name); + Ok(list(&s)) + }) + .await; + crate::logged("rename_library", libs) +} + +/// 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). +#[tauri::command] +pub async fn delete_library(app: AppHandle, id: String, everywhere: bool) -> Result, 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.")?; + (lib.remote_id.clone(), account_remote(&s)) + }; + if let (true, Some(rid)) = (everywhere, &remote_id) { + let remote = remote.ok_or("Sign in to the sync server to delete a library from it.")?; + match remote.delete_library(rid) { + Ok(()) => {} + // Someone got there first. + Err(e) if e.to_string() == NOT_FOUND => {} + Err(e) => return Err(format!("Couldn't delete it from the server: {e}")), + } + } + let mut s = lock(&app); + let lib = s.delete_library(&id)?; + if let (false, Some(rid)) = (everywhere, &remote_id) { + // Still on the server: don't download it straight back. + s.dismiss_remote(rid); + } + crate::mlog!("delete_library: {} (everywhere={everywhere})", lib.name); + Ok(list(&s)) + }) + .await; + crate::logged("delete_library", libs) +} + +/// Start or stop syncing a library. Stopping leaves the server's copy where +/// it is, for the other devices; starting again links back up with it if it's +/// still there (by name), or puts this one on the server. +#[tauri::command] +pub async fn set_library_sync(app: AppHandle, id: String, sync: bool) -> Result, String> { + let app2 = app.clone(); + let libs = blocking(move || { + let mut s = lock(&app2); + let lib = s.library(&id).cloned().ok_or("There's no such library on this device.")?; + if s.sync_running { + return Err("Wait for the sync to finish, then try again.".into()); + } + if !sync { + if let Some(rid) = &lib.remote_id { + s.dismiss_remote(rid); + } + } + s.update_library(&id, |l| { + l.sync = sync; + l.server_deleted = false; + if !sync { + l.remote_id = None; + } + }); + Ok(list(&s)) + }) + .await; + let libs = crate::logged("set_library_sync", libs)?; + if sync { + sync_soon(&app); + } + Ok(libs) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8f52fb4..fd66f28 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -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, + /// 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, + /// 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, +} + +/// 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, + /// 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, @@ -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) -> Result { + 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 { + 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 { + 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(); + } } diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index b9280c7..a40555c 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -1,4 +1,5 @@ -//! Library sync with an mtgc sync server: signing in, and running the cycle. +//! Library sync with an mtgc sync server: signing in, matching this device's +//! libraries up with the account's, and running the cycle for each. //! //! The reconciliation lives in [`mtgc_core::sync::remote`], as a cycle over a //! [`Remote`](mtgc_core::sync::remote::Remote) that is tested without any @@ -6,7 +7,7 @@ //! kept, holding the state lock while the working copy is rewritten (and only //! then), and the platform-specific half of signing in. //! -//! The library never depends on the server being reachable. The working copy +//! A library never depends on the server being reachable. Its working copy //! is the library on this device; every edit lands there, and a sync is just a //! chance to exchange edits with the other devices. A failed sync loses //! nothing and is retried on the next trigger. @@ -20,30 +21,44 @@ //! - desktop listens on a one-shot loopback port (RFC 8252 §7.3); //! - Android registers the `mtgc://` scheme, and the deep-link plugin delivers //! the callback to [`deliver_callback`]. +//! +//! # Libraries +//! +//! An account can keep several named libraries, and each library on this +//! device is linked to one of them (or kept here only). Every sync first lines +//! the two lists up — [`plan_libraries`] — so a library another device +//! started is downloaded here, one started here is created on the server, and +//! one deleted elsewhere is flagged rather than silently thrown away. Then it +//! runs the cycle for each linked library, the open one first. +//! +//! The library the device started with is linked to the account's default +//! library, which is what a server from before named libraries keeps as its +//! only one. Against such a server just that one syncs. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce}; +use mtgc_core::sync::account::{self, AccountUser, DeviceInfo, Pkce, RemoteLibrary}; use mtgc_core::sync::remote::{ - first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, UNAUTHORIZED, + first_sync_plan, sync_cycle, AccountBase, FirstSync, HttpRemote, SyncBase, NOT_FOUND, + UNAUTHORIZED, }; use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport}; use serde::Serialize; use tauri::{AppHandle, Manager}; -use crate::state::{AppState, SyncAccount}; +use crate::state::{AppState, LocalLibrary, SyncAccount}; /// How long to wait for the user to finish signing in in the browser. const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60); -fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex> { +pub(crate) fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex> { app.state::>() } -fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> { +pub(crate) fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> { // Every holder leaves the state consistent between statements, so a panic // elsewhere is no reason to stop syncing. app_state(app).inner().lock().unwrap_or_else(|e| e.into_inner()) @@ -52,7 +67,7 @@ fn lock(app: &AppHandle) -> std::sync::MutexGuard<'_, AppState> { /// Run blocking work (network, SQLite) off the main thread. Tauri runs /// non-async commands on the main thread, where a slow server would freeze /// the whole UI. -async fn blocking( +pub(crate) async fn blocking( f: impl FnOnce() -> Result + Send + 'static, ) -> Result { tauri::async_runtime::spawn_blocking(f) @@ -62,39 +77,63 @@ async fn blocking( // --- the merge base ----------------------------------------------------------- -/// A new name rather than the folder-sync era's `sync-base.json`, whose -/// contents mean nothing to a server: an old file is simply never read. -fn base_path(data_dir: &Path) -> PathBuf { - data_dir.join("sync-server-base.json") +/// Where one library's base is kept, and what it belongs to on the server. +struct BaseFile<'a> { + path: PathBuf, + account: &'a SyncAccount, + /// The server's id for the library; `None` against a server without + /// named libraries. + library_id: Option<&'a str>, + is_default: bool, } -/// The stored base, if it belongs to this server and account. Missing, -/// unreadable, or someone else's all mean the same thing: no ancestor, which -/// makes the next sync a first sync. -fn read_base(data_dir: &Path, account: &SyncAccount) -> Option { - let text = std::fs::read_to_string(base_path(data_dir)).ok()?; - serde_json::from_str::(&text) - .ok()? - .for_account(&account.server_url, &account.user.id) -} - -fn write_base(data_dir: &Path, account: &SyncAccount, base: &SyncBase) { - let stored = AccountBase { - server_url: account.server_url.clone(), - user_id: account.user.id.clone(), - base: base.clone(), - }; - let path = base_path(data_dir); - let tmp = path.with_extension("json.tmp"); - let result = serde_json::to_string(&stored) - .map_err(|e| e.to_string()) - .and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string())) - .and_then(|_| std::fs::rename(&tmp, &path).map_err(|e| e.to_string())); - if let Err(e) = result { - // Not fatal: without a base the next sync is a first sync, which can - // ask a question but never deletes anything. - crate::mlog!("sync: couldn't save the merge base: {e}"); +impl BaseFile<'_> { + /// The stored base, if it belongs to this server, account and library. + /// Missing, unreadable, or someone else's all mean the same thing: no + /// ancestor, which makes the next sync a first sync. + fn read(&self) -> Option { + let text = std::fs::read_to_string(&self.path).ok()?; + serde_json::from_str::(&text).ok()?.for_library( + &self.account.server_url, + &self.account.user.id, + self.library_id, + self.is_default, + ) } + + fn write(&self, base: &SyncBase) { + let stored = AccountBase { + server_url: self.account.server_url.clone(), + user_id: self.account.user.id.clone(), + library_id: self.library_id.map(str::to_string), + base: base.clone(), + }; + let tmp = self.path.with_extension("json.tmp"); + let result = serde_json::to_string(&stored) + .map_err(|e| e.to_string()) + .and_then(|t| std::fs::write(&tmp, t).map_err(|e| e.to_string())) + .and_then(|_| std::fs::rename(&tmp, &self.path).map_err(|e| e.to_string())); + if let Err(e) = result { + // Not fatal: without a base the next sync is a first sync, which can + // ask a question but never deletes anything. + crate::mlog!("sync: couldn't save the merge base: {e}"); + } + } +} + +fn base_file<'a>(s: &AppState, account: &'a SyncAccount, lib: &'a LocalLibrary) -> BaseFile<'a> { + BaseFile { + path: s.base_path(&lib.id), + account, + library_id: lib.remote_id.as_deref(), + is_default: lib.is_default(), + } +} + +/// Whether a library takes part in syncing: linked to a server library, or +/// the default one, which a server without named libraries syncs unlinked. +fn syncs(lib: &LocalLibrary) -> bool { + lib.remote_id.is_some() || (lib.is_default() && lib.sync) } /// The working copy as a document. @@ -123,18 +162,19 @@ pub struct SyncStatus { pub legacy_library: Option, } -fn status(app: &AppHandle) -> SyncStatus { +pub(crate) fn status(app: &AppHandle) -> SyncStatus { let s = lock(app); let account = s.sync_account().cloned(); - let pending_changes = account - .as_ref() - .filter(|a| a.token.is_some()) - .map(|a| match read_base(s.data_dir(), a) { - Some(base) => local_doc(&s.working_db_path).map(|d| d != base.doc).unwrap_or(false), - // Never synced with this account: anything here is unsent. - None => local_doc(&s.working_db_path).map(|d| !d.is_empty()).unwrap_or(false), + let pending_changes = account.as_ref().filter(|a| a.token.is_some()).is_some_and(|a| { + s.libraries().iter().filter(|l| syncs(l)).any(|lib| { + let local = local_doc(&s.working_path(&lib.id)); + match base_file(&s, a, lib).read() { + Some(base) => local.map(|d| d != base.doc).unwrap_or(false), + // Never synced with this account: anything here is unsent. + None => local.map(|d| !d.is_empty()).unwrap_or(false), + } }) - .unwrap_or(false); + }); SyncStatus { signed_in: account.as_ref().is_some_and(|a| a.token.is_some()), server_url: account.as_ref().map(|a| a.server_url.clone()), @@ -213,6 +253,10 @@ pub async fn sync_sign_in( let switching = previous .as_ref() .is_some_and(|p| p.server_url != signed_in.0 || p.user.id != signed_in.1.user.id); + if switching { + // Links to the other account's libraries mean nothing here. + s.forget_remote_links(); + } s.set_sync_account(Some(SyncAccount { server_url: signed_in.0.clone(), token: Some(signed_in.1.token), @@ -478,15 +522,22 @@ pub async fn sync_sign_out(app: AppHandle) -> Result { /// Outcome of one sync. #[derive(Serialize, Default)] pub struct SyncReport { - /// Whether this device's library changed. + /// Whether the open library changed. pub changed: bool, pub published: bool, /// The first sync is waiting on the user: see [`sync_connect`]. pub needs_choice: bool, + /// What changed in the open library. pub report: MergeReport, + /// Libraries were added, renamed or flagged as deleted on the server, so + /// the list of them is out of date. + pub libraries_changed: bool, + /// What went wrong with any one library. The others still synced. + #[serde(skip)] + error: Option, } -/// Reconcile the working copy with the server. +/// Reconcile this device's libraries with the server. /// /// `Ok(None)` when there is nothing to do — not signed in — or a sync was /// already in flight and this call stood down. Sync runs automatically on @@ -513,15 +564,15 @@ async fn run_sync(app: AppHandle, choice: Option) -> Result { - s.sync_error = None; + s.sync_error = r.error.clone(); s.sync_needs_choice = r.needs_choice; - if !r.needs_choice { + if !r.needs_choice && r.error.is_none() { s.update_sync_account(|a| a.last_synced_at = Some(unix_now())); } } @@ -537,20 +588,103 @@ async fn run_sync(app: AppHandle, choice: Option) -> Result) -> Result { - let (account, data_dir, working) = { - let s = lock(app); - let account = s.sync_account().cloned().ok_or("Not signed in.")?; - (account, s.data_dir().to_path_buf(), s.working_db_path.clone()) - }; - let token = account.token.as_deref().ok_or("Not signed in.")?; - let remote = HttpRemote::new(&account.server_url, token); +fn sync_all(app: &AppHandle, choice: Option) -> Result { + let account = lock(app).sync_account().cloned().ok_or("Not signed in.")?; + let token = account.token.clone().ok_or("Not signed in.")?; + let remote = HttpRemote::new(&account.server_url, &token); + let mut report = SyncReport::default(); - let mut base = match read_base(&data_dir, &account) { + // A server without named libraries answers the list with a 404: it keeps + // one library, and only the default one syncs with it. + let named = match remote.list_libraries() { + Ok(list) => { + report.libraries_changed = line_up_libraries(app, &remote, &list, &mut report)?; + true + } + Err(e) if e.to_string() == NOT_FOUND => false, + Err(e) => return Err(e.to_string()), + }; + + let (libs, active) = { + let s = lock(app); + let active = s.active_library().id.clone(); + let mut libs: Vec = s.libraries().iter().filter(|l| syncs(l)).cloned().collect(); + if !named { + if s.libraries().iter().any(|l| !l.is_default() && l.sync) { + report.error.get_or_insert_with(|| { + "That sync server keeps only one library — update it to sync the others.".into() + }); + } + libs.retain(LocalLibrary::is_default); + } + // The open library first: it's the one the user is waiting on. + libs.sort_by_key(|l| l.id != active); + (libs, active) + }; + + for lib in libs { + let remote = match (&lib.remote_id, named) { + (Some(id), true) => HttpRemote::for_library(&account.server_url, &token, id), + (_, false) => HttpRemote::new(&account.server_url, &token), + // The default library, not linked because it's kept off the server. + (None, true) => continue, + }; + // Only the default library's first sync can ask: the others are + // either new on one side or matched by name, and combine. + let choice = if lib.is_default() { choice } else { Some(FirstSync::Combine) }; + match sync_one(app, &account, &lib, &remote, choice) { + Ok(one) => { + report.needs_choice |= one.needs_choice; + if lib.id == active { + report.changed = one.changed; + report.published = one.published; + report.report = one.report; + } + } + Err(e) if e == UNAUTHORIZED => return Err(e), + Err(e) if e == NOT_FOUND => { + // Deleted on the server since the list was read. + lock(app).update_library(&lib.id, |l| { + l.remote_id = None; + l.server_deleted = true; + }); + report.libraries_changed = true; + } + Err(e) => { + crate::mlog!("sync: {} failed: {e}", lib.name); + report.error.get_or_insert_with(|| format!("{}: {e}", lib.name)); + } + } + } + Ok(report) +} + +/// One library's sync. +#[derive(Default)] +struct OneReport { + changed: bool, + published: bool, + needs_choice: bool, + report: MergeReport, +} + +fn sync_one( + app: &AppHandle, + account: &SyncAccount, + lib: &LocalLibrary, + remote: &HttpRemote, + choice: Option, +) -> Result { + let (base_file, working) = { + let s = lock(app); + (base_file(&s, account, lib), s.working_path(&lib.id)) + }; + + let mut base = match base_file.read() { Some(base) => base, None => { - // Never synced with this account's library from here, so there is - // no ancestor to tell edits from deletions with. + // Never synced with this library on the server from here, so + // there is no ancestor to tell edits from deletions with. use mtgc_core::sync::remote::Remote; let theirs = remote.get().map_err(|e| e.to_string())?; let ours = { @@ -558,7 +692,7 @@ fn sync_inner(app: &AppHandle, choice: Option) -> Result return Ok(SyncReport { needs_choice: true, ..Default::default() }), + None => return Ok(OneReport { needs_choice: true, ..Default::default() }), // An empty ancestor: nothing on either side reads as deleted. Some(FirstSync::Combine) => SyncBase::default(), Some(FirstSync::UseServer) => { @@ -574,24 +708,25 @@ fn sync_inner(app: &AppHandle, choice: Option) -> Result) -> Result, + /// Server libraries to download as new libraries here. + download: Vec, + /// Local ids whose server library is gone. + deleted: Vec, + /// (local id, name): take the server's name. + rename: Vec<(String, String)>, + /// Local ids to create on the server. + create: Vec, +} + +impl LibraryPlan { + fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +/// Line this device's libraries up with the server's list. Pure, so the +/// rules are tested without a server: +/// +/// - 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; +/// - 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; +/// - a library here that should be on the server but isn't linked is created +/// there. +pub(crate) fn plan_libraries( + local: &[LocalLibrary], + dismissed: &[String], + server: &[RemoteLibrary], +) -> LibraryPlan { + let mut plan = LibraryPlan::default(); + let mut linked: Vec<&str> = Vec::new(); + let mut matched: Vec<&str> = Vec::new(); + let link = |plan: &mut LibraryPlan, lib: &LocalLibrary, remote: &RemoteLibrary| { + plan.link.push((lib.id.clone(), remote.id.clone())); + if lib.name != remote.name { + plan.rename.push((lib.id.clone(), remote.name.clone())); + } + }; + + for lib in local { + let Some(rid) = &lib.remote_id else { continue }; + match server.iter().find(|r| &r.id == rid) { + Some(r) => { + linked.push(&r.id); + if r.name != lib.name { + plan.rename.push((lib.id.clone(), r.name.clone())); + } + } + None => plan.deleted.push(lib.id.clone()), + } + } + + let unlinked = |l: &&LocalLibrary| l.remote_id.is_none() && l.sync && !l.server_deleted; + for r in server { + if linked.contains(&r.id.as_str()) { + continue; + } + if r.default { + if let Some(lib) = local.iter().filter(unlinked).find(|l| l.is_default()) { + link(&mut plan, lib, r); + matched.push(&lib.id); + } + // Otherwise the default library is kept off the server here. + continue; + } + let same_name = local + .iter() + .filter(unlinked) + .filter(|l| !l.is_default() && !matched.contains(&l.id.as_str())) + .find(|l| l.name.eq_ignore_ascii_case(&r.name)); + if let Some(lib) = same_name { + link(&mut plan, lib, r); + matched.push(&lib.id); + } else if !dismissed.contains(&r.id) { + plan.download.push(r.clone()); + } + } + + for lib in local.iter().filter(unlinked) { + if !lib.is_default() && !matched.contains(&lib.id.as_str()) { + plan.create.push(lib.id.clone()); + } + } + plan +} + +/// Carry out [`plan_libraries`] for this sync. Returns whether the list of +/// libraries changed. +fn line_up_libraries( + app: &AppHandle, + remote: &HttpRemote, + server: &[RemoteLibrary], + report: &mut SyncReport, +) -> Result { + let plan = { + let s = lock(app); + plan_libraries(s.libraries(), s.dismissed_remote(), server) + }; + if plan.is_empty() { + return Ok(false); + } + crate::mlog!("sync: lining up libraries: {plan:?}"); + + // Creating on the server happens without the lock; the rest is local. + let mut created = Vec::new(); + for id in &plan.create { + let Some(name) = lock(app).library(id).map(|l| l.name.clone()) else { continue }; + match remote.create_library(&name) { + Ok(r) => created.push((id.clone(), r.id)), + Err(e) => { + crate::mlog!("sync: couldn't create {name} on the server: {e}"); + report.error.get_or_insert_with(|| format!("{name}: {e}")); + } + } + } + + let mut s = lock(app); + for (id, rid) in plan.link.iter().chain(&created) { + s.update_library(id, |l| l.remote_id = Some(rid.clone())); + s.undismiss_remote(rid); + } + for (id, name) in &plan.rename { + let name = unique_name(&s, name, Some(id)); + s.update_library(id, |l| l.name = name); + } + for id in &plan.deleted { + s.update_library(id, |l| { + l.remote_id = None; + l.server_deleted = true; + }); + } + for r in &plan.download { + let name = unique_name(&s, &r.name, None); + if let Err(e) = s.create_library(&name, true, Some(r.id.clone())) { + crate::mlog!("sync: couldn't download {}: {e}", r.name); + report.error.get_or_insert_with(|| format!("{}: {e}", r.name)); + } + } + Ok(true) +} + +/// `name`, or `name (2)` and so on if a library here already has it. +fn unique_name(s: &AppState, name: &str, except: Option<&str>) -> String { + let mut candidate = name.to_string(); + let mut n = 1; + while s.check_name(&candidate, except).is_err() { + n += 1; + candidate = format!("{name} ({n})"); + } + candidate +} + fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -646,7 +943,7 @@ pub struct ImportReport { pub report: MergeReport, } -/// Merge a library file into this device's library: a JSON library document +/// Merge a library file into the open library: a JSON library document /// (a backup, or the file a folder-synced build kept), or a `library.db` from /// before documents existed. Nothing already here is lost — it's merged with /// an empty ancestor — and the next sync shares the result. @@ -689,7 +986,7 @@ pub async fn import_library_file(app: AppHandle, path: String) -> Result Result<(), String> { @@ -731,29 +1028,114 @@ mod tests { dir } + fn base<'a>(dir: &Path, account: &'a SyncAccount, library_id: Option<&'a str>, is_default: bool) -> BaseFile<'a> { + BaseFile { path: dir.join("sync-server-base.json"), account, library_id, is_default } + } + #[test] - fn a_base_is_only_used_with_the_account_it_came_from() { + fn a_base_is_only_used_with_the_account_and_library_it_came_from() { let dir = scratch("base"); let mine = account("https://a.example", "u1"); - let base = SyncBase { version: 4, ..Default::default() }; - write_base(&dir, &mine, &base); + let b = SyncBase { version: 4, ..Default::default() }; + base(&dir, &mine, Some("lib1"), false).write(&b); - assert_eq!(read_base(&dir, &mine), Some(base)); - assert_eq!(read_base(&dir, &account("https://a.example", "u2")), None); - assert_eq!(read_base(&dir, &account("https://b.example", "u1")), None); + assert_eq!(base(&dir, &mine, Some("lib1"), false).read(), Some(b)); + assert_eq!(base(&dir, &mine, Some("lib2"), false).read(), None); + assert_eq!(base(&dir, &account("https://a.example", "u2"), Some("lib1"), false).read(), None); + assert_eq!(base(&dir, &account("https://b.example", "u1"), Some("lib1"), false).read(), None); assert!(!dir.join("sync-server-base.json.tmp").exists()); std::fs::remove_dir_all(&dir).unwrap(); } #[test] - fn a_folder_sync_era_base_is_never_read() { - // That build's file held a bare document under a different name. - let dir = scratch("old-base"); - std::fs::write(dir.join("sync-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap(); - assert_eq!(read_base(&dir, &account("https://a.example", "u1")), None); + fn the_base_from_before_named_libraries_carries_over_to_the_default_one() { + let dir = scratch("upgrade-base"); + let mine = account("https://a.example", "u1"); + let b = SyncBase { version: 9, ..Default::default() }; + // What the previous build saved: no library id. + base(&dir, &mine, None, true).write(&b); + assert_eq!(base(&dir, &mine, Some("main"), true).read(), Some(b), "no first-sync question after upgrading"); std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn a_folder_sync_era_base_is_never_read() { + // That build's file held a bare document. + let dir = scratch("old-base"); + std::fs::write(dir.join("sync-server-base.json"), r#"{"version":1,"cards":{},"decks":{}}"#).unwrap(); + assert_eq!(base(&dir, &account("https://a.example", "u1"), None, true).read(), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn local(id: &str, name: &str, remote: Option<&str>) -> LocalLibrary { + LocalLibrary { + id: id.into(), + name: name.into(), + remote_id: remote.map(str::to_string), + sync: true, + server_deleted: false, + } + } + + fn server(id: &str, name: &str, default: bool) -> RemoteLibrary { + RemoteLibrary { id: id.into(), name: name.into(), default, version: 1 } + } + + #[test] + fn the_default_library_links_to_the_accounts_default() { + let plan = plan_libraries(&[local("default", "Main", None)], &[], &[server("m", "Main", true)]); + assert_eq!(plan, LibraryPlan { link: vec![("default".into(), "m".into())], ..Default::default() }); + + // Linked already, and nothing else on either side: nothing to do. + let plan = plan_libraries(&[local("default", "Main", Some("m"))], &[], &[server("m", "Main", true)]); + assert_eq!(plan, LibraryPlan::default()); + } + + #[test] + fn libraries_started_elsewhere_are_downloaded_unless_removed_here() { + let libs = [local("default", "Main", Some("m"))]; + let list = [server("m", "Main", true), server("s", "Scratch", false), server("t", "Trades", false)]; + let plan = plan_libraries(&libs, &["t".into()], &list); + assert_eq!(plan.download, vec![list[1].clone()]); + assert!(plan.link.is_empty() && plan.create.is_empty()); + } + + #[test] + fn libraries_started_here_are_created_on_the_server_or_matched_by_name() { + let mut kept_here = local("k", "Kept here", None); + kept_here.sync = false; + let libs = [ + local("default", "Main", Some("m")), + local("a", "scratch", None), + local("b", "Cube", None), + kept_here, + ]; + // Matched even when removed here before: it's what the user means. + let list = [server("m", "Main", true), server("s", "Scratch", false)]; + let plan = plan_libraries(&libs, &["s".into()], &list); + assert_eq!(plan.link, vec![("a".into(), "s".into())]); + assert_eq!(plan.rename, vec![("a".into(), "Scratch".into())]); + assert_eq!(plan.create, vec!["b".to_string()]); + assert!(plan.download.is_empty()); + } + + #[test] + fn libraries_deleted_or_renamed_elsewhere_are_noticed() { + let mut gone_before = local("g", "Old", None); + gone_before.server_deleted = true; + let libs = [ + local("default", "Main", Some("m")), + local("a", "Scratch", Some("s")), + local("b", "Cube", Some("c")), + gone_before, + ]; + let list = [server("m", "Collection", true), server("c", "Cube", false)]; + let plan = plan_libraries(&libs, &[], &list); + assert_eq!(plan.deleted, vec!["a".to_string()]); + assert_eq!(plan.rename, vec![("default".into(), "Collection".into())]); + assert!(plan.create.is_empty(), "one deleted on the server isn't put back unasked"); + } + #[cfg(not(target_os = "android"))] #[test] fn the_loopback_listener_takes_only_the_callback() { diff --git a/src/App.tsx b/src/App.tsx index 4cbd90b..0bb840d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,16 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; -import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api"; +import { + api, + IngestProgress, + IngestStageInfo, + LibraryInfo, + LibraryStats, + setOpenLibrary, + Status, + SyncStatus, +} from "./api"; import { ColorDistributionBar, formatRelativeTime, @@ -13,6 +22,7 @@ import { usePersistedState, } from "./components"; import { CommandPalette } from "./CommandPalette"; +import { LibrariesPanel, LibrarySwitcher } from "./Libraries"; import { SyncIndicator, SyncPanel } from "./SyncPanel"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; @@ -236,6 +246,7 @@ export default function App() { // Library sync state: signed in or not, and how the last sync went. Fetched // at startup, after every sync, and whenever the Info tab opens. const [sync, setSync] = useState(null); + const [libraries, setLibraries] = useState([]); // A sync is in flight, for the top-bar indicator. const [syncing, setSyncing] = useState(false); const lastProgress = useRef(null); @@ -462,16 +473,37 @@ export default function App() { /** * Bring every view into line after the library may have changed wholesale: - * signing in or out, adopting the server's copy, importing a file. The top - * bar's label comes from get_status, which nothing else re-fetches; the - * sidebar reads `libStats`; and each tab holds rows it fetched earlier. + * signing in or out, opening another library, adopting the server's copy, + * importing a file. The top bar's label comes from get_status, which nothing + * else re-fetches; the sidebar reads `libStats`; each tab holds rows it + * fetched earlier; and signing in can bring libraries from other devices. */ const refreshEverything = useCallback(() => { api.getStatus().then(setStatus).catch(() => {}); + api.listLibraries().then(setLibraries).catch(() => {}); if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); setDeckRefreshSignal((v) => v + 1); }, []); + const refreshLibraries = useCallback(() => { + api.listLibraries().then(setLibraries).catch(() => {}); + }, []); + + useEffect(() => { + if (statusLoaded) refreshLibraries(); + }, [statusLoaded, refreshLibraries]); + + /** Another library was opened, or the list changed: the open library may + * be a different one now, so everything refetches. */ + const librariesChanged = useCallback( + (libs: LibraryInfo[]) => { + setLibraries(libs); + refreshEverything(); + api.syncStatus().then(setSync).catch(() => {}); + }, + [refreshEverything], + ); + /** Merge with the server and publish local edits. * * Shared by the Info tab's button and the automatic triggers below. Resolves @@ -490,12 +522,18 @@ export default function App() { } setDeckRefreshSignal((v) => v + 1); } + if (report?.libraries_changed) { + // Downloaded, renamed or deleted elsewhere — the open one's name in + // the top bar included. + refreshLibraries(); + api.getStatus().then(setStatus).catch(() => {}); + } return report; } finally { setSyncing(false); api.syncStatus().then(setSync).catch(() => {}); } - }, []); + }, [refreshLibraries]); // Automatic sync, whenever signed in (and not waiting on the first-sync // choice): once now, on every trip to or from the background — leaving @@ -555,6 +593,10 @@ export default function App() { } } + // Before any view renders: they read per-library settings (`libraryKey`) + // as they mount. + if (status) setOpenLibrary(status.library.id); + return (
@@ -589,9 +631,17 @@ export default function App() {
- - {status?.library_label ?? ""} - + {status ? ( + setTab("info")} + /> + ) : ( + + )} setTab("info")} /> {/* On mobile this lives in the bottom nav's Info tab instead — see below — rather than crowding the topbar. */} @@ -701,12 +751,13 @@ export default function App() {
) : tab === "add" ? ( - - ) : /* Keyed on the sync account: switching to a different one can - replace every card, deck and id these views are holding, so - they're remounted rather than left to reconcile state that - belongs to a library that's no longer open. Stable across - ordinary status refreshes, so it costs nothing otherwise. */ + + ) : /* Keyed on the open library and its sync account: switching + either can replace every card, deck and id these views are + holding, so they're remounted rather than left to reconcile + state that belongs to a library that's no longer open. + Stable across ordinary status refreshes, so it costs + nothing otherwise. */ tab === "library" ? ( ) : tab === "search" ? ( @@ -741,6 +792,12 @@ export default function App() {

+ +
{sync === null ? ( <>

Your library

diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index c3746bc..2a81a44 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { api, CardRef, Deck } from "./api"; +import { api, CardRef, Deck, libraryKey } from "./api"; import { DialogFrame, useClickPreview } from "./components"; // Kept in step with App.tsx's Tab union by hand — it isn't exported. @@ -111,11 +111,11 @@ export function CommandPalette({ hint: "Deck", run: () => { // DeckView reads its initial selection from this same - // localStorage key on mount (usePersistedState("deck-selected"), + // localStorage key on mount (usePersistedState(libraryKey("deck-selected")), // src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on // tab switch, so writing here before navigating is enough for it // to pick up the selection on its next mount. - localStorage.setItem("deck-selected", JSON.stringify(d.id)); + localStorage.setItem(libraryKey("deck-selected"), JSON.stringify(d.id)); onNavigate("decks"); }, })), diff --git a/src/Libraries.tsx b/src/Libraries.tsx new file mode 100644 index 0000000..bf0df6e --- /dev/null +++ b/src/Libraries.tsx @@ -0,0 +1,388 @@ +import { useEffect, useRef, useState } from "react"; +import { api, LibraryInfo } from "./api"; +import { ConfirmDialog, DialogFrame, IconButton, Icons, PromptDialog } from "./components"; + +/** Called with the new list after anything that changes it — including which + * library is open, so every view should refetch. */ +type OnChanged = (libraries: LibraryInfo[]) => void; + +/** Where a library stands with the sync server, in a few words. */ +function syncState(lib: LibraryInfo, signedIn: boolean): string { + if (lib.server_deleted) return "Deleted from the server on another device"; + if (!lib.sync) return "This device only"; + if (!signedIn) return "Syncs once you sign in"; + return lib.linked ? "Synced" : "Not on the server yet"; +} + +/** Start a library: a name, and whether it goes on the sync server. */ +function NewLibraryDialog({ + signedIn, + onChanged, + onCancel, +}: { + signedIn: boolean; + onChanged: OnChanged; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [sync, setSync] = useState(signedIn); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function create() { + if (!name.trim() || busy) return; + setBusy(true); + setError(null); + try { + onChanged(await api.createLibrary(name.trim(), sync)); + } catch (err) { + setError(String(err)); + setBusy(false); + } + } + + return ( + + + + {error &&

{error}

} +
+ + +
+
+ ); +} + +/** Delete a library: from this device, or — when it's on the server — from + * everywhere. */ +function DeleteLibraryDialog({ + lib, + onChanged, + onCancel, +}: { + lib: LibraryInfo; + onChanged: OnChanged; + onCancel: () => void; +}) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function remove(everywhere: boolean) { + setBusy(true); + setError(null); + try { + onChanged(await api.deleteLibrary(lib.id, everywhere)); + } catch (err) { + setError(String(err)); + setBusy(false); + } + } + + return ( + + {lib.linked ? ( +

+ Remove from this device keeps it on the sync server for your other + devices; this one stops downloading it. Delete everywhere also removes + it from the server, and your other devices keep only the copy they already have. +

+ ) : ( +

Its cards and decks will be deleted from this device. This can't be undone.

+ )} + {error &&

{error}

} +
+ + {lib.linked && ( + + )} + +
+
+ ); +} + +/** + * The top bar's library label, which opens a menu for switching libraries or + * starting a new one. Hidden on narrow screens with the rest of `.path`; the + * Info tab's {@link LibrariesPanel} does the same job there. + */ +export function LibrarySwitcher({ + label, + libraries, + signedIn, + onChanged, + onManage, +}: { + label: string; + libraries: LibraryInfo[]; + signedIn: boolean; + onChanged: OnChanged; + onManage: () => void; +}) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open]); + + async function choose(lib: LibraryInfo) { + if (lib.active) { + setOpen(false); + return; + } + setError(null); + try { + onChanged(await api.switchLibrary(lib.id)); + setOpen(false); + } catch (err) { + setError(String(err)); + } + } + + return ( +
+ + {open && ( +
+ {libraries.map((lib) => ( + + ))} + {error &&
✕ {error}
} +
+ + +
+ )} + {creating && ( + setCreating(false)} + onChanged={(libs) => { + setCreating(false); + onChanged(libs); + }} + /> + )} +
+ ); +} + +/** + * The Info tab's list of libraries: open, rename, sync or keep on this device, + * delete, and start new ones. + */ +export function LibrariesPanel({ + libraries, + signedIn, + onChanged, +}: { + libraries: LibraryInfo[]; + signedIn: boolean; + onChanged: OnChanged; +}) { + const [dialog, setDialog] = useState< + | { kind: "new" } + | { kind: "rename"; lib: LibraryInfo } + | { kind: "delete"; lib: LibraryInfo } + | { kind: "unsync"; lib: LibraryInfo } + | null + >(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function act(work: () => Promise) { + setBusy(true); + setError(null); + try { + onChanged(await work()); + setDialog(null); + } catch (err) { + setError(String(err)); + } finally { + setBusy(false); + } + } + + const done: OnChanged = (libs) => { + setDialog(null); + onChanged(libs); + }; + + return ( + <> +

Libraries

+
+ Each library has its own cards and decks. Libraries you sync appear on all your signed-in + devices; one kept on this device only is a safe place to try things out. +
+
    + {libraries.map((lib) => ( +
  • +
    + + {lib.name} + {lib.active && Open} + + + {syncState(lib, signedIn)} + +
    +
    + {!lib.active && ( + + )} + {lib.server_deleted ? ( + + ) : ( + + )} + { + setError(null); + setDialog({ kind: "rename", lib }); + }} + disabled={busy} + /> + {!lib.is_default && ( + setDialog({ kind: "delete", lib })} + disabled={busy} + /> + )} +
    +
  • + ))} +
+ {error && !dialog &&
✕ {error}
} +
+ +
+ + {dialog?.kind === "new" && ( + setDialog(null)} onChanged={done} /> + )} + {dialog?.kind === "rename" && ( + setDialog(null)} + onConfirm={(name) => act(() => api.renameLibrary(dialog.lib.id, name))} + /> + )} + {dialog?.kind === "delete" && ( + setDialog(null)} onChanged={done} /> + )} + {dialog?.kind === "unsync" && ( + setDialog(null)} + onConfirm={() => act(() => api.setLibrarySync(dialog.lib.id, false))} + /> + )} + + ); +} diff --git a/src/api.ts b/src/api.ts index acd87ff..65288c9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -182,8 +182,11 @@ export interface LibraryFilter { } export interface Status { - /** Where the library syncs: "user @ host", or this device only. */ + /** The open library's name and where it syncs: "Main · user @ host", or + * this device only. Changes whenever another library is opened. */ library_label: string; + /** The open library. */ + library: { id: string; name: string }; reference_path: string; reference_ready: boolean; card_count: number; @@ -266,6 +269,39 @@ export interface SyncReport { /** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */ needs_choice: boolean; report: MergeReport; + /** Libraries were added, renamed, or deleted on the server: re-list them. */ + libraries_changed: boolean; +} + +/** One of the libraries on this device. */ +export interface LibraryInfo { + /** Local only; never shown. */ + id: string; + name: string; + /** The one that's open. */ + active: boolean; + /** The library the device started with, which can't be deleted. */ + is_default: boolean; + /** Whether it should be on the sync server. */ + sync: boolean; + /** Whether it's linked to a library on the server yet. */ + linked: boolean; + /** Another device deleted it from the server; this copy is all that's left. */ + server_deleted: boolean; +} + +/** The open library, for `libraryKey`. Set by `App` from each status. */ +let openLibraryId = "default"; + +export function setOpenLibrary(id: string) { + openLibraryId = id; +} + +/** A localStorage key for something that belongs to the open library — a + * selected deck, say — so each library remembers its own. The default + * library keeps the bare key it always had. */ +export function libraryKey(key: string): string { + return openLibraryId === "default" ? key : `${key}:${openLibraryId}`; } /** A published or upcoming Magic set, from Scryfall. */ @@ -399,9 +435,22 @@ export const api = { syncDevices: () => invoke("sync_devices"), /** Sign another device out (a lost phone, say); returns the remaining list. */ syncRevokeDevice: (id: number) => invoke("sync_revoke_device", { id }), - /** Merge a library file into this one: a JSON backup, or an old library.db. */ + listLibraries: () => invoke("list_libraries"), + /** Start a new, empty library and open it. Resolves with the new list. */ + createLibrary: (name: string, sync: boolean) => + invoke("create_library", { name, sync }), + /** Open another library. */ + switchLibrary: (id: string) => invoke("switch_library", { id }), + /** Rename a library, on the sync server too when it's there. */ + renameLibrary: (id: string, name: string) => invoke("rename_library", { id, name }), + /** Delete a library from this device; with `everywhere`, from the server too. */ + deleteLibrary: (id: string, everywhere: boolean) => + invoke("delete_library", { id, everywhere }), + /** Put a library on the sync server, or keep it on this device only. */ + setLibrarySync: (id: string, sync: boolean) => invoke("set_library_sync", { id, sync }), + /** Merge a library file into the open one: a JSON backup, or an old library.db. */ importLibraryFile: (path: string) => invoke("import_library_file", { path }), - /** Save the whole library as JSON, as a backup. */ + /** Save the whole open library as JSON, as a backup. */ exportLibraryDocument: (path: string) => invoke("export_library_document", { path }), /** Set releases and article links. `refresh` forces a fetch past the cache. */ diff --git a/src/styles.css b/src/styles.css index 3f83762..b16c0d1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3428,3 +3428,140 @@ tbody tr:hover { font-size: 12px; color: var(--muted); } + +/* Library switcher: the top bar's library label, as a menu button. Keeps + `.path`'s look (and its hiding on narrow screens). */ +.library-switcher { + position: relative; + overflow: visible; + padding: 0; + display: flex; +} +@media (max-width: 720px) { + /* Like `.path`: the Info tab's library list stands in for it on phones. */ + .library-switcher { + display: none; + } +} +.library-switcher-button { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: var(--space-2); + background: none; + border: none; + color: inherit; + font: inherit; + padding: 4px 10px; + text-align: left; + cursor: pointer; +} +.library-switcher-button:hover { + color: var(--text); +} +.library-switcher-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.library-menu { + position: absolute; + top: calc(100% + var(--space-1)); + left: 0; + z-index: 50; + min-width: 280px; + max-width: min(420px, 90vw); + display: flex; + flex-direction: column; + padding: var(--space-1); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + font-family: inherit; + white-space: normal; +} +.library-menu > button { + display: flex; + align-items: baseline; + gap: var(--space-2); + background: none; + border: none; + border-radius: var(--radius-sm); + padding: var(--space-2); + color: var(--text); + text-align: left; + font-family: system-ui, sans-serif; +} +.library-menu > button:hover { + background: var(--bg-hover); +} +.library-menu hr { + border: none; + border-top: 1px solid var(--border); + margin: var(--space-1) 0; + width: 100%; +} +.library-menu-check { + width: 1em; + flex-shrink: 0; + color: var(--accent-quiet); +} +.library-menu-name { + font-weight: 600; + color: var(--text-strong); +} +.library-menu .hint { + margin-left: auto; + padding-left: var(--space-3); +} +.library-menu-error { + padding: var(--space-2); + font-family: system-ui, sans-serif; +} + +/* The Info tab's list of libraries. */ +.library-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.library-list li { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.library-list li.active { + border-color: var(--accent-quiet); +} +.library-list-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 160px; +} +.library-list-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} +.library-warn { + color: var(--warn); +} +.library-sync-check { + display: inline-flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; +} diff --git a/src/views/CardEntry.tsx b/src/views/CardEntry.tsx index 8d268c8..faa00e6 100644 --- a/src/views/CardEntry.tsx +++ b/src/views/CardEntry.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { api, CardRef } from "../api"; +import { api, CardRef, libraryKey } from "../api"; import { CardSearchBox, QtyStepper } from "../components"; /** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */ @@ -10,11 +10,12 @@ export function CardEntry() { { oracleId: string; name: string; qty: number; location: string }[] >([]); // Persisted so a "box" location sticks while entering a stack of cards. - const [location, setLocation] = useState(() => localStorage.getItem("add-location") || ""); + // Each library has its own boxes. + const [location, setLocation] = useState(() => localStorage.getItem(libraryKey("add-location")) || ""); const searchInputRef = useRef(null); useEffect(() => { - localStorage.setItem("add-location", location); + localStorage.setItem(libraryKey("add-location"), location); }, [location]); // Typing anywhere on this tab — without having clicked into another field diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index e3eb21c..18ef79e 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -1,6 +1,6 @@ import { open, save } from "@tauri-apps/plugin-dialog"; import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api"; +import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, libraryKey, PreviewContext } from "../api"; import { CardSearchBox, CardThumb, @@ -361,7 +361,7 @@ function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[ */ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { const [decks, setDecks] = useState([]); - const [selected, setSelected] = usePersistedState("deck-selected", null); + const [selected, setSelected] = usePersistedState(libraryKey("deck-selected"), null); const [newName, setNewName] = useState(""); const [contents, setContents] = useState([]); const [view, setView] = useViewMode("deck-view");