Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them from the top bar or Info → Libraries. Each can sync to the account's other devices or stay on this one (a scratch library to try things in). The sync server now keeps several libraries per user under /api/libraries; a migration makes each existing library the user's default "Main", which /api/library keeps serving for older app builds. Every sync lines the device's libraries up with the account's: new ones elsewhere are downloaded, new ones here created on the server, ones deleted elsewhere flagged rather than thrown away. The existing library keeps its files and merge base, so upgrading asks nothing.
This commit is contained in:
15
README.md
15
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.
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<Vec<RemoteLibrary>> {
|
||||
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<RemoteLibrary> {
|
||||
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<RemoteLibrary> {
|
||||
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<()> {
|
||||
|
||||
@@ -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<String>,
|
||||
#[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<SyncBase> {
|
||||
(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<SyncBase> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<RemoteDoc> {
|
||||
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<PutOutcome> {
|
||||
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<RemoteDoc> {
|
||||
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();
|
||||
|
||||
@@ -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<bool> {
|
||||
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<Vec<(User, i64)>> {
|
||||
/// Every account, for `mtgc-server user list`: (user, device count, library
|
||||
/// count).
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<(User, i64, i64)>> {
|
||||
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<Option<(i64, String)>> {
|
||||
/// 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<Library> {
|
||||
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<rusqlite::Error> 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<Vec<Library>> {
|
||||
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<Library> {
|
||||
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<Library, LibraryError> {
|
||||
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<Library, LibraryError> {
|
||||
// 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<Option<Library>> {
|
||||
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<String>)> {
|
||||
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<String>),
|
||||
}
|
||||
|
||||
/// 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<PutResult> {
|
||||
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<Vec<i64>> {
|
||||
pub fn history_versions(conn: &Connection, library_id: i64) -> rusqlite::Result<Vec<i64>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>) -> 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()),
|
||||
}))
|
||||
|
||||
@@ -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<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
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<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
let libs = db::list_libraries(&state.db(), auth.user.id)?;
|
||||
Ok(Json(libs.iter().map(describe).collect::<Vec<_>>()).into_response())
|
||||
}
|
||||
|
||||
pub async fn put_library(
|
||||
#[derive(Deserialize)]
|
||||
pub struct NameBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
auth: Authed,
|
||||
Json(body): Json<NameBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<NameBody>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<AppState>,
|
||||
auth: Authed,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<AppState>, auth: Authed) -> Result<Response, ApiError> {
|
||||
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<AppState>,
|
||||
auth: Authed,
|
||||
headers: HeaderMap,
|
||||
body: String,
|
||||
) -> Result<Response, ApiError> {
|
||||
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<Library, ApiError> {
|
||||
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<String, ApiError> {
|
||||
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<Response, ApiError> {
|
||||
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<Value, ApiError> {
|
||||
/// `{version, doc}`, with an empty library for one nothing was stored in yet.
|
||||
fn wire((version, stored): (i64, Option<String>)) -> Result<Value, ApiError> {
|
||||
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() }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <name>`.");
|
||||
}
|
||||
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}"),
|
||||
|
||||
@@ -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<Body> {
|
||||
self.get_at("/api/library", token).await
|
||||
}
|
||||
|
||||
async fn get_at(&self, uri: &str, token: &str) -> Response<Body> {
|
||||
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<Body> {
|
||||
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<Body> {
|
||||
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<Value>) -> Request<Body> {
|
||||
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<Body> {
|
||||
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<Value> = 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);
|
||||
}
|
||||
|
||||
@@ -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<mtgc_core::sync::remote::CycleReport> {
|
||||
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::<Vec<_>>(), [("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();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,11 @@ type Res<T> = Result<T, String>;
|
||||
/// 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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
190
src-tauri/src/libraries.rs
Normal file
190
src-tauri/src/libraries.rs
Normal file
@@ -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<LibraryInfo> {
|
||||
s.libraries().iter().map(|l| info(s, l)).collect()
|
||||
}
|
||||
|
||||
/// The signed-in account's server, if any.
|
||||
fn account_remote(s: &AppState) -> Option<HttpRemote> {
|
||||
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<Vec<LibraryInfo>, 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<Vec<LibraryInfo>, 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<Vec<LibraryInfo>, 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<Vec<LibraryInfo>, 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<Vec<LibraryInfo>, String> {
|
||||
let libs = blocking(move || {
|
||||
let (remote_id, remote) = {
|
||||
let s = lock(&app);
|
||||
let lib = s.library(&id).ok_or("There's no such library on this device.")?;
|
||||
(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<Vec<LibraryInfo>, 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)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and
|
||||
//! the sync account.
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths,
|
||||
//! the device's libraries, and the sync account.
|
||||
//!
|
||||
//! The user's library lives in an internal SQLite working copy under the app
|
||||
//! data dir, which works fully offline. When the device is signed in to a sync
|
||||
//! Each library lives in an internal SQLite working copy under the app data
|
||||
//! dir, which works fully offline. One of them is open at a time — the active
|
||||
//! library — and every command works on that one. When the device is signed in to a sync
|
||||
//! server, that copy is reconciled with the server's whenever it can be reached
|
||||
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
|
||||
|
||||
@@ -28,6 +29,69 @@ struct PersistedConfig {
|
||||
/// The sync server this device is (or was last) signed in to.
|
||||
#[serde(default)]
|
||||
sync: Option<SyncAccount>,
|
||||
/// The libraries on this device, the default one first. Never empty once
|
||||
/// loaded: a config from before named libraries gets the default.
|
||||
#[serde(default)]
|
||||
libraries: Vec<LocalLibrary>,
|
||||
/// The id of the library that's open.
|
||||
#[serde(default)]
|
||||
active_library: String,
|
||||
/// Server libraries removed from this device, which syncing therefore
|
||||
/// doesn't download again.
|
||||
#[serde(default)]
|
||||
dismissed_remote: Vec<String>,
|
||||
}
|
||||
|
||||
/// The id of the library every device starts with. Its files stay where the
|
||||
/// one library always lived, so upgrading moves nothing.
|
||||
pub const DEFAULT_LIBRARY: &str = "default";
|
||||
|
||||
/// One of the libraries on this device.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct LocalLibrary {
|
||||
/// Local only: the folder it lives in. Never shown.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// The server's id for it, once linked to a library there.
|
||||
#[serde(default)]
|
||||
pub remote_id: Option<String>,
|
||||
/// Whether it should be on the server. Off for a library kept on this
|
||||
/// device only — a scratch library to try things in, say.
|
||||
#[serde(default = "yes")]
|
||||
pub sync: bool,
|
||||
/// It was linked, and another device deleted it from the server. The copy
|
||||
/// here stays until the user removes it or puts it back on the server.
|
||||
#[serde(default)]
|
||||
pub server_deleted: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl LocalLibrary {
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.id == DEFAULT_LIBRARY
|
||||
}
|
||||
}
|
||||
|
||||
fn default_library() -> LocalLibrary {
|
||||
LocalLibrary {
|
||||
id: DEFAULT_LIBRARY.into(),
|
||||
name: "Main".into(),
|
||||
remote_id: None,
|
||||
sync: true,
|
||||
server_deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh id for a library's folder.
|
||||
fn new_library_id() -> String {
|
||||
mtgc_core::sync::account::random_token()
|
||||
.chars()
|
||||
.filter(char::is_ascii_alphanumeric)
|
||||
.take(16)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// This device's sync server account.
|
||||
@@ -52,6 +116,18 @@ fn config_path(data_dir: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn load_config(data_dir: &Path) -> PersistedConfig {
|
||||
let mut cfg = read_config(data_dir);
|
||||
// Before named libraries there was one, which is the default now.
|
||||
if !cfg.libraries.iter().any(LocalLibrary::is_default) {
|
||||
cfg.libraries.insert(0, default_library());
|
||||
}
|
||||
if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) {
|
||||
cfg.active_library = DEFAULT_LIBRARY.into();
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
fn read_config(data_dir: &Path) -> PersistedConfig {
|
||||
let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else {
|
||||
return PersistedConfig::default();
|
||||
};
|
||||
@@ -105,14 +181,23 @@ fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
f.sync_all()
|
||||
}
|
||||
|
||||
/// The folder a library's files live in.
|
||||
fn library_dir(data_dir: &Path, id: &str) -> PathBuf {
|
||||
if id == DEFAULT_LIBRARY {
|
||||
data_dir.to_path_buf()
|
||||
} else {
|
||||
data_dir.join("libraries").join(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
||||
pub struct AppState {
|
||||
data_dir: PathBuf,
|
||||
/// Held whole so that writing one preference can't drop the others — the
|
||||
/// setters below persist this rather than reconstructing a fresh config.
|
||||
config: PersistedConfig,
|
||||
/// Internal SQLite working copy: the library itself, on this device. Never
|
||||
/// chosen by, shown to, or moved by the user.
|
||||
/// Internal SQLite working copy of the active library: the library itself,
|
||||
/// on this device. Never chosen by, shown to, or moved by the user.
|
||||
pub working_db_path: PathBuf,
|
||||
pub reference_path: PathBuf,
|
||||
pub catalog: Option<Catalog>,
|
||||
@@ -141,7 +226,7 @@ impl AppState {
|
||||
let config = load_config(&data_dir);
|
||||
let mut s = Self {
|
||||
reference_path: data_dir.join("reference.db"),
|
||||
working_db_path: data_dir.join("working.db"),
|
||||
working_db_path: library_dir(&data_dir, &config.active_library).join("working.db"),
|
||||
config,
|
||||
data_dir,
|
||||
catalog: None,
|
||||
@@ -152,9 +237,13 @@ impl AppState {
|
||||
sync_needs_choice: false,
|
||||
sign_in_cancel: None,
|
||||
};
|
||||
// A library whose folder vanished would otherwise be recreated empty
|
||||
// by opening it, which is fine — but its folder must exist first.
|
||||
let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone()));
|
||||
crate::mlog!(
|
||||
"AppState::new: sync={:?} working={} reference={} (exists={})",
|
||||
"AppState::new: sync={:?} library={} working={} reference={} (exists={})",
|
||||
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
|
||||
s.config.active_library,
|
||||
s.working_db_path.display(),
|
||||
s.reference_path.display(),
|
||||
s.reference_path.exists()
|
||||
@@ -170,10 +259,6 @@ impl AppState {
|
||||
s
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
/// The sync account, signed in or not.
|
||||
pub fn sync_account(&self) -> Option<&SyncAccount> {
|
||||
self.config.sync.as_ref()
|
||||
@@ -192,6 +277,151 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget every link to a server library. For signing in to a different
|
||||
/// account or server, where the old ids mean nothing.
|
||||
pub fn forget_remote_links(&mut self) {
|
||||
for lib in &mut self.config.libraries {
|
||||
lib.remote_id = None;
|
||||
lib.server_deleted = false;
|
||||
}
|
||||
self.config.dismissed_remote.clear();
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
// --- libraries ------------------------------------------------------------
|
||||
|
||||
pub fn libraries(&self) -> &[LocalLibrary] {
|
||||
&self.config.libraries
|
||||
}
|
||||
|
||||
pub fn library(&self, id: &str) -> Option<&LocalLibrary> {
|
||||
self.config.libraries.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
pub fn active_library(&self) -> &LocalLibrary {
|
||||
self.library(&self.config.active_library).expect("load_config keeps the active library listed")
|
||||
}
|
||||
|
||||
/// The folder a library's files live in.
|
||||
pub fn library_dir(&self, id: &str) -> PathBuf {
|
||||
library_dir(&self.data_dir, id)
|
||||
}
|
||||
|
||||
pub fn working_path(&self, id: &str) -> PathBuf {
|
||||
self.library_dir(id).join("working.db")
|
||||
}
|
||||
|
||||
/// Where a library's sync merge base is kept. The default library keeps
|
||||
/// the name it had before there were others.
|
||||
pub fn base_path(&self, id: &str) -> PathBuf {
|
||||
if id == DEFAULT_LIBRARY {
|
||||
self.data_dir.join("sync-server-base.json")
|
||||
} else {
|
||||
self.library_dir(id).join("sync-base.json")
|
||||
}
|
||||
}
|
||||
|
||||
/// Server libraries removed from this device.
|
||||
pub fn dismissed_remote(&self) -> &[String] {
|
||||
&self.config.dismissed_remote
|
||||
}
|
||||
|
||||
/// Start a new, empty library. It isn't opened.
|
||||
pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option<String>) -> Result<LocalLibrary, String> {
|
||||
let name = self.check_name(name, None)?;
|
||||
let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false };
|
||||
let dir = self.library_dir(&lib.id);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?;
|
||||
// Created now, so the library exists on disk even before anything
|
||||
// is added to it.
|
||||
mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?;
|
||||
if let Some(r) = &lib.remote_id {
|
||||
self.config.dismissed_remote.retain(|d| d != r);
|
||||
}
|
||||
self.config.libraries.push(lib.clone());
|
||||
save_config(&self.data_dir, &self.config);
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// A trimmed name, if no other library here already has it.
|
||||
pub fn check_name(&self, name: &str, except: Option<&str>) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err("A library needs a name.".into());
|
||||
}
|
||||
let taken = self
|
||||
.config
|
||||
.libraries
|
||||
.iter()
|
||||
.any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name));
|
||||
if taken {
|
||||
return Err(format!("There's already a library called {name}."));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// Change a library in place and persist it. A no-op for an unknown id.
|
||||
pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) {
|
||||
if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) {
|
||||
f(lib);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop downloading a server library to this device.
|
||||
pub fn dismiss_remote(&mut self, remote_id: &str) {
|
||||
if !self.config.dismissed_remote.iter().any(|d| d == remote_id) {
|
||||
self.config.dismissed_remote.push(remote_id.to_string());
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undismiss_remote(&mut self, remote_id: &str) {
|
||||
self.config.dismissed_remote.retain(|d| d != remote_id);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
/// Open another library.
|
||||
pub fn switch_library(&mut self, id: &str) -> Result<(), String> {
|
||||
if self.library(id).is_none() {
|
||||
return Err("There's no such library on this device.".into());
|
||||
}
|
||||
if self.config.active_library == id {
|
||||
return Ok(());
|
||||
}
|
||||
// A sync works on paths it took when it started.
|
||||
if self.sync_running {
|
||||
return Err("Wait for the sync to finish, then try again.".into());
|
||||
}
|
||||
let _ = std::fs::create_dir_all(self.library_dir(id));
|
||||
self.config.active_library = id.to_string();
|
||||
save_config(&self.data_dir, &self.config);
|
||||
self.working_db_path = self.working_path(id);
|
||||
self.reopen_catalog()
|
||||
}
|
||||
|
||||
/// Delete a library and its files from this device. The active one is
|
||||
/// left by switching to the default first; the default itself stays.
|
||||
pub fn delete_library(&mut self, id: &str) -> Result<LocalLibrary, String> {
|
||||
let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?;
|
||||
if lib.is_default() {
|
||||
return Err("The main library can't be deleted.".into());
|
||||
}
|
||||
if self.sync_running {
|
||||
return Err("Wait for the sync to finish, then try again.".into());
|
||||
}
|
||||
if self.config.active_library == id {
|
||||
self.switch_library(DEFAULT_LIBRARY)?;
|
||||
}
|
||||
self.config.libraries.retain(|l| l.id != id);
|
||||
save_config(&self.data_dir, &self.config);
|
||||
let dir = self.library_dir(id);
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir) {
|
||||
crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display());
|
||||
}
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
/// A pre-document `library.db` still waiting to be converted.
|
||||
pub fn legacy_library_path(&self) -> Option<&Path> {
|
||||
self.config.legacy_library_path.as_deref()
|
||||
@@ -228,6 +458,21 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reopen just the catalog, on the active library's working copy. The name
|
||||
/// index only reads card data, so it survives a switch of library.
|
||||
fn reopen_catalog(&mut self) -> Result<(), String> {
|
||||
if self.index.is_none() {
|
||||
return self.reopen();
|
||||
}
|
||||
self.catalog = None;
|
||||
if self.reference_path.exists() {
|
||||
self.catalog = Some(
|
||||
Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn catalog(&self) -> Result<&Catalog, String> {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
@@ -271,15 +516,28 @@ mod tests {
|
||||
#[test]
|
||||
fn every_setting_round_trips() {
|
||||
let dir = scratch("round-trip");
|
||||
let scratch_lib = LocalLibrary {
|
||||
id: "abc".into(),
|
||||
name: "Scratch".into(),
|
||||
remote_id: Some("r1".into()),
|
||||
sync: false,
|
||||
server_deleted: true,
|
||||
};
|
||||
let cfg = PersistedConfig {
|
||||
legacy_library_path: Some(PathBuf::from("/tmp/old.db")),
|
||||
sync: Some(account()),
|
||||
libraries: vec![default_library(), scratch_lib],
|
||||
active_library: "abc".into(),
|
||||
dismissed_remote: vec!["r2".into()],
|
||||
};
|
||||
save_config(&dir, &cfg);
|
||||
|
||||
let back = load_config(&dir);
|
||||
assert_eq!(back.legacy_library_path, cfg.legacy_library_path);
|
||||
assert_eq!(back.sync, cfg.sync);
|
||||
assert_eq!(back.libraries, cfg.libraries);
|
||||
assert_eq!(back.active_library, "abc");
|
||||
assert_eq!(back.dismissed_remote, cfg.dismissed_remote);
|
||||
assert!(!dir.join("config.json.tmp").exists());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -289,7 +547,7 @@ mod tests {
|
||||
fn the_config_holding_the_token_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = scratch("private");
|
||||
save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) });
|
||||
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
||||
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
@@ -346,4 +604,54 @@ mod tests {
|
||||
assert!(load_config(&dir).sync.is_none(), "truncated file");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_from_before_named_libraries_has_just_the_default() {
|
||||
let dir = scratch("pre-libraries");
|
||||
save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() });
|
||||
let cfg = load_config(&dir);
|
||||
assert_eq!(cfg.libraries, vec![default_library()]);
|
||||
assert_eq!(cfg.active_library, DEFAULT_LIBRARY);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_library_keeps_the_files_it_always_had() {
|
||||
let dir = scratch("paths");
|
||||
let s = AppState::new(dir.clone());
|
||||
assert_eq!(s.working_db_path, dir.join("working.db"));
|
||||
assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json"));
|
||||
assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db"));
|
||||
assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libraries_can_be_created_switched_to_and_deleted() {
|
||||
let dir = scratch("libraries");
|
||||
let mut s = AppState::new(dir.clone());
|
||||
let lib = s.create_library(" Scratch ", false, None).unwrap();
|
||||
assert_eq!(lib.name, "Scratch");
|
||||
assert!(s.working_path(&lib.id).exists());
|
||||
assert!(s.create_library("scratch", true, None).is_err(), "names are unique");
|
||||
assert!(s.create_library(" ", true, None).is_err());
|
||||
|
||||
s.switch_library(&lib.id).unwrap();
|
||||
assert_eq!(s.working_db_path, s.working_path(&lib.id));
|
||||
// Remembered across restarts.
|
||||
let mut s = AppState::new(dir.clone());
|
||||
assert_eq!(s.active_library().id, lib.id);
|
||||
|
||||
s.sync_running = true;
|
||||
assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync");
|
||||
assert!(s.delete_library(&lib.id).is_err(), "not mid-sync");
|
||||
s.sync_running = false;
|
||||
|
||||
assert!(s.delete_library(DEFAULT_LIBRARY).is_err());
|
||||
s.delete_library(&lib.id).unwrap();
|
||||
assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away");
|
||||
assert!(!s.library_dir(&lib.id).exists());
|
||||
assert_eq!(s.libraries().len(), 1);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>> {
|
||||
pub(crate) fn app_state(app: &AppHandle) -> tauri::State<'_, Mutex<AppState>> {
|
||||
app.state::<Mutex<AppState>>()
|
||||
}
|
||||
|
||||
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<T: Send + 'static>(
|
||||
pub(crate) async fn blocking<T: Send + 'static>(
|
||||
f: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
tauri::async_runtime::spawn_blocking(f)
|
||||
@@ -62,39 +77,63 @@ async fn blocking<T: Send + 'static>(
|
||||
|
||||
// --- 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<SyncBase> {
|
||||
let text = std::fs::read_to_string(base_path(data_dir)).ok()?;
|
||||
serde_json::from_str::<AccountBase>(&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<SyncBase> {
|
||||
let text = std::fs::read_to_string(&self.path).ok()?;
|
||||
serde_json::from_str::<AccountBase>(&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<String>,
|
||||
}
|
||||
|
||||
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<SyncStatus, String> {
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<FirstSync>) -> Result<Option<Sy
|
||||
s.sync_running = true;
|
||||
}
|
||||
let app2 = app.clone();
|
||||
let result = blocking(move || sync_inner(&app2, choice)).await;
|
||||
let result = blocking(move || sync_all(&app2, choice)).await;
|
||||
|
||||
let mut s = lock(&app);
|
||||
s.sync_running = false;
|
||||
match &result {
|
||||
Ok(r) => {
|
||||
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<FirstSync>) -> Result<Option<Sy
|
||||
crate::logged("sync_now", result).map(Some)
|
||||
}
|
||||
|
||||
fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport, String> {
|
||||
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<FirstSync>) -> Result<SyncReport, String> {
|
||||
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<LocalLibrary> = 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<FirstSync>,
|
||||
) -> Result<OneReport, String> {
|
||||
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<FirstSync>) -> Result<SyncReport,
|
||||
local_doc(&working)?
|
||||
};
|
||||
match first_sync_plan(&ours, &theirs.doc, choice) {
|
||||
None => 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<FirstSync>) -> Result<SyncReport,
|
||||
// The state lock is held only while the working copy is read, merged and
|
||||
// rewritten — every edit command takes the same lock, so none can land in
|
||||
// between and be overwritten. The network calls happen without it.
|
||||
let result = sync_cycle(&remote, &mut base, |base_doc, theirs| {
|
||||
let result = sync_cycle(remote, &mut base, |base_doc, theirs| {
|
||||
let _s = lock(app);
|
||||
let conn = mtgc_core::db::open_library(&working)?;
|
||||
core_sync::merge_into(&conn, base_doc, theirs)
|
||||
});
|
||||
// Saved whether or not the cycle finished: it only ever records what is
|
||||
// known to be on the server, so a partial run's base is still sound.
|
||||
write_base(&data_dir, &account, &base);
|
||||
base_file.write(&base);
|
||||
let cycle = result.map_err(|e| e.to_string())?;
|
||||
|
||||
crate::mlog!(
|
||||
"sync_now: changed={} published={} version={} conflicts={}",
|
||||
"sync_now: {} changed={} published={} version={} conflicts={}",
|
||||
lib.name,
|
||||
!cycle.report.is_empty(),
|
||||
cycle.published,
|
||||
cycle.version,
|
||||
cycle.report.conflicts.len()
|
||||
);
|
||||
Ok(SyncReport {
|
||||
Ok(OneReport {
|
||||
changed: !cycle.report.is_empty(),
|
||||
published: cycle.published,
|
||||
needs_choice: false,
|
||||
@@ -599,6 +734,168 @@ fn sync_inner(app: &AppHandle, choice: Option<FirstSync>) -> Result<SyncReport,
|
||||
})
|
||||
}
|
||||
|
||||
// --- lining up libraries ----------------------------------------------------------
|
||||
|
||||
/// How to bring this device's libraries in line with the account's.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub(crate) struct LibraryPlan {
|
||||
/// (local id, server id): link these, combining their contents.
|
||||
link: Vec<(String, String)>,
|
||||
/// Server libraries to download as new libraries here.
|
||||
download: Vec<RemoteLibrary>,
|
||||
/// Local ids whose server library is gone.
|
||||
deleted: Vec<String>,
|
||||
/// (local id, name): take the server's name.
|
||||
rename: Vec<(String, String)>,
|
||||
/// Local ids to create on the server.
|
||||
create: Vec<String>,
|
||||
}
|
||||
|
||||
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<bool, String> {
|
||||
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<ImportR
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Save the whole library as a JSON document — a backup that
|
||||
/// Save the whole open library as a JSON document — a backup that
|
||||
/// [`import_library_file`] reads back.
|
||||
#[tauri::command]
|
||||
pub async fn export_library_document(app: AppHandle, path: String) -> 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() {
|
||||
|
||||
85
src/App.tsx
85
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<SyncStatus | null>(null);
|
||||
const [libraries, setLibraries] = useState<LibraryInfo[]>([]);
|
||||
// A sync is in flight, for the top-bar indicator.
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const lastProgress = useRef<IngestProgress | null>(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 (
|
||||
<HoverPreviewProvider>
|
||||
<div className="app">
|
||||
@@ -589,9 +631,17 @@ export default function App() {
|
||||
|
||||
<div className="main">
|
||||
<header className="topbar">
|
||||
<span className="path" title={status?.library_label ?? ""}>
|
||||
{status?.library_label ?? ""}
|
||||
</span>
|
||||
{status ? (
|
||||
<LibrarySwitcher
|
||||
label={status.library_label}
|
||||
libraries={libraries}
|
||||
signedIn={sync?.signed_in ?? false}
|
||||
onChanged={librariesChanged}
|
||||
onManage={() => setTab("info")}
|
||||
/>
|
||||
) : (
|
||||
<span className="path" />
|
||||
)}
|
||||
<SyncIndicator sync={sync} syncing={syncing} onClick={() => 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() {
|
||||
</button>
|
||||
</div>
|
||||
) : tab === "add" ? (
|
||||
<CardEntry />
|
||||
) : /* 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. */
|
||||
<CardEntry key={status?.library_label} />
|
||||
) : /* 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" ? (
|
||||
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
|
||||
) : tab === "search" ? (
|
||||
@@ -741,6 +792,12 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
<LibrariesPanel
|
||||
libraries={libraries}
|
||||
signedIn={sync?.signed_in ?? false}
|
||||
onChanged={librariesChanged}
|
||||
/>
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
{sync === null ? (
|
||||
<>
|
||||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
})),
|
||||
|
||||
388
src/Libraries.tsx
Normal file
388
src/Libraries.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<DialogFrame title="New library" onCancel={onCancel}>
|
||||
<label className="dialog-field">
|
||||
<span className="muted">Name</span>
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="e.g. Scratch, Cube, Trades"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && create()}
|
||||
/>
|
||||
</label>
|
||||
<label className="library-sync-check">
|
||||
<input type="checkbox" checked={sync} onChange={(e) => setSync(e.target.checked)} />
|
||||
<span>
|
||||
Sync it to your other devices
|
||||
<span className="hint">
|
||||
{" "}
|
||||
— leave this off for a library just for trying things out on this device.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{error && <p className="dialog-error">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button onClick={onCancel}>Cancel</button>
|
||||
<button className="primary" onClick={create} disabled={!name.trim() || busy}>
|
||||
Create and open
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string | null>(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 (
|
||||
<DialogFrame title={`Delete ${lib.name}?`} onCancel={onCancel}>
|
||||
{lib.linked ? (
|
||||
<p>
|
||||
<strong>Remove from this device</strong> keeps it on the sync server for your other
|
||||
devices; this one stops downloading it. <strong>Delete everywhere</strong> also removes
|
||||
it from the server, and your other devices keep only the copy they already have.
|
||||
</p>
|
||||
) : (
|
||||
<p>Its cards and decks will be deleted from this device. This can't be undone.</p>
|
||||
)}
|
||||
{error && <p className="dialog-error">{error}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
{lib.linked && (
|
||||
<button onClick={() => remove(false)} disabled={busy}>
|
||||
Remove from this device
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => remove(lib.linked)} disabled={busy}>
|
||||
{lib.linked ? "Delete everywhere" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div className="path library-switcher" ref={ref}>
|
||||
<button
|
||||
className="library-switcher-button"
|
||||
title={`${label} — switch library`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<span className="library-switcher-label">{label}</span>
|
||||
<span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="library-menu" role="menu">
|
||||
{libraries.map((lib) => (
|
||||
<button key={lib.id} role="menuitemradio" aria-checked={lib.active} onClick={() => choose(lib)}>
|
||||
<span className="library-menu-check">{lib.active ? "✓" : ""}</span>
|
||||
<span className="library-menu-name">{lib.name}</span>
|
||||
<span className="hint">{syncState(lib, signedIn)}</span>
|
||||
</button>
|
||||
))}
|
||||
{error && <div className="sync-bad library-menu-error">✕ {error}</div>}
|
||||
<hr />
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
<span className="library-menu-check" />
|
||||
New library…
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onManage();
|
||||
}}
|
||||
>
|
||||
<span className="library-menu-check" />
|
||||
Manage libraries…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{creating && (
|
||||
<NewLibraryDialog
|
||||
signedIn={signedIn}
|
||||
onCancel={() => setCreating(false)}
|
||||
onChanged={(libs) => {
|
||||
setCreating(false);
|
||||
onChanged(libs);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function act(work: () => Promise<LibraryInfo[]>) {
|
||||
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 (
|
||||
<>
|
||||
<h2 style={{ margin: 0 }}>Libraries</h2>
|
||||
<div className="hint">
|
||||
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.
|
||||
</div>
|
||||
<ul className="library-list">
|
||||
{libraries.map((lib) => (
|
||||
<li key={lib.id} className={lib.active ? "active" : ""}>
|
||||
<div className="library-list-main">
|
||||
<span className="sync-device-name">
|
||||
{lib.name}
|
||||
{lib.active && <span className="badge">Open</span>}
|
||||
</span>
|
||||
<span className={lib.server_deleted ? "hint library-warn" : "hint"}>
|
||||
{syncState(lib, signedIn)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="library-list-actions">
|
||||
{!lib.active && (
|
||||
<button onClick={() => act(() => api.switchLibrary(lib.id))} disabled={busy}>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{lib.server_deleted ? (
|
||||
<button onClick={() => act(() => api.setLibrarySync(lib.id, true))} disabled={busy}>
|
||||
Put back on the server
|
||||
</button>
|
||||
) : (
|
||||
<label className="library-sync-check" title="Sync this library to your other devices">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lib.sync}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
e.target.checked
|
||||
? act(() => api.setLibrarySync(lib.id, true))
|
||||
: lib.linked
|
||||
? setDialog({ kind: "unsync", lib })
|
||||
: act(() => api.setLibrarySync(lib.id, false))
|
||||
}
|
||||
/>
|
||||
Sync
|
||||
</label>
|
||||
)}
|
||||
<IconButton
|
||||
icon={Icons.pencil}
|
||||
label={`Rename ${lib.name}`}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setDialog({ kind: "rename", lib });
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
{!lib.is_default && (
|
||||
<IconButton
|
||||
icon={Icons.trash}
|
||||
label={`Delete ${lib.name}`}
|
||||
danger
|
||||
onClick={() => setDialog({ kind: "delete", lib })}
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && !dialog && <div className="sync-bad">✕ {error}</div>}
|
||||
<div className="row">
|
||||
<button onClick={() => setDialog({ kind: "new" })} disabled={busy}>
|
||||
New library…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dialog?.kind === "new" && (
|
||||
<NewLibraryDialog signedIn={signedIn} onCancel={() => setDialog(null)} onChanged={done} />
|
||||
)}
|
||||
{dialog?.kind === "rename" && (
|
||||
<PromptDialog
|
||||
title={`Rename ${dialog.lib.name}`}
|
||||
label="Name"
|
||||
defaultValue={dialog.lib.name}
|
||||
confirmLabel="Rename"
|
||||
error={error}
|
||||
onCancel={() => setDialog(null)}
|
||||
onConfirm={(name) => act(() => api.renameLibrary(dialog.lib.id, name))}
|
||||
/>
|
||||
)}
|
||||
{dialog?.kind === "delete" && (
|
||||
<DeleteLibraryDialog lib={dialog.lib} onCancel={() => setDialog(null)} onChanged={done} />
|
||||
)}
|
||||
{dialog?.kind === "unsync" && (
|
||||
<ConfirmDialog
|
||||
title={`Stop syncing ${dialog.lib.name}?`}
|
||||
message="It stays on the server for your other devices, and this device keeps its own copy, which no longer syncs. Turning sync back on combines them again."
|
||||
confirmLabel="Stop syncing"
|
||||
onCancel={() => setDialog(null)}
|
||||
onConfirm={() => act(() => api.setLibrarySync(dialog.lib.id, false))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
src/api.ts
55
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<SyncDevice[]>("sync_devices"),
|
||||
/** Sign another device out (a lost phone, say); returns the remaining list. */
|
||||
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
|
||||
/** Merge a library file into this one: a JSON backup, or an old library.db. */
|
||||
listLibraries: () => invoke<LibraryInfo[]>("list_libraries"),
|
||||
/** Start a new, empty library and open it. Resolves with the new list. */
|
||||
createLibrary: (name: string, sync: boolean) =>
|
||||
invoke<LibraryInfo[]>("create_library", { name, sync }),
|
||||
/** Open another library. */
|
||||
switchLibrary: (id: string) => invoke<LibraryInfo[]>("switch_library", { id }),
|
||||
/** Rename a library, on the sync server too when it's there. */
|
||||
renameLibrary: (id: string, name: string) => invoke<LibraryInfo[]>("rename_library", { id, name }),
|
||||
/** Delete a library from this device; with `everywhere`, from the server too. */
|
||||
deleteLibrary: (id: string, everywhere: boolean) =>
|
||||
invoke<LibraryInfo[]>("delete_library", { id, everywhere }),
|
||||
/** Put a library on the sync server, or keep it on this device only. */
|
||||
setLibrarySync: (id: string, sync: boolean) => invoke<LibraryInfo[]>("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<ImportReport>("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<void>("export_library_document", { path }),
|
||||
|
||||
/** Set releases and article links. `refresh` forces a fetch past the cache. */
|
||||
|
||||
137
src/styles.css
137
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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLInputElement>(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
|
||||
|
||||
@@ -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<Deck[]>([]);
|
||||
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
|
||||
const [selected, setSelected] = usePersistedState<number | null>(libraryKey("deck-selected"), null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [contents, setContents] = useState<DeckEntry[]>([]);
|
||||
const [view, setView] = useViewMode("deck-view");
|
||||
|
||||
Reference in New Issue
Block a user