diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 81355e1..8447e2e 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -17,6 +17,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[ include_str!("sql/library/0002_location.sql"), include_str!("sql/library/0003_deck_format.sql"), include_str!("sql/library/0004_deck_maybeboard.sql"), + include_str!("sql/library/0005_deck_uuid.sql"), ]; /// The attached alias under which the reference db is available at runtime. @@ -114,6 +115,19 @@ pub fn inspect_reference_db(path: &Path) -> Result { Ok(count) } +/// Open (and migrate) just the library db, with no reference db attached. +/// +/// Sync needs this: it only ever touches the user's own tables, and must work +/// on a device that hasn't downloaded card data yet — where [`open`] can't be +/// used at all, since ATTACHing a non-existent reference file would create an +/// empty one and make every later query fail. +pub fn open_library(library_path: &Path) -> Result { + let conn = Connection::open(library_path)?; + conn.pragma_update(None, "foreign_keys", true)?; + migrate_library(&conn)?; + Ok(conn) +} + /// Open the library db at `library_path`, run migrations, and ATTACH the /// reference db at `reference_path` under the [`REF_DB`] alias. pub fn open(library_path: &Path, reference_path: &Path) -> Result { diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index bcda226..308d304 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -10,8 +10,11 @@ use crate::Catalog; impl Catalog { /// Create a deck. `commander_oracle_id` is optional. pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result { + // Every deck needs a `uuid` from the moment it exists — it's the + // identity sync matches on across devices (see migration 0005). self.conn.execute( - "INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)", + "INSERT INTO decks(name, commander_oracle_id, uuid) + VALUES (?1, ?2, lower(hex(randomblob(16))))", params![name, commander_oracle_id], )?; let id = self.conn.last_insert_rowid(); @@ -63,9 +66,11 @@ impl Catalog { let src = self .get_deck(id)? .ok_or_else(|| crate::error::CoreError::Other(format!("deck {id} not found")))?; + // A duplicate is a genuinely new deck, so it gets its own identity + // rather than inheriting the source's. self.conn.execute( - "INSERT INTO decks(name, format, commander_oracle_id, notes) - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO decks(name, format, commander_oracle_id, notes, uuid) + VALUES (?1, ?2, ?3, ?4, lower(hex(randomblob(16))))", params![new_name, src.format, src.commander_oracle_id, src.notes], )?; let new_id = self.conn.last_insert_rowid(); diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 6dcb024..8344012 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -13,7 +13,73 @@ use crate::Catalog; /// [`Catalog::search_reference`] — everything except `in_deck`/`location`, /// which only make sense against the owned library. Caller has already /// written the `FROM ... WHERE ` prefix. -fn push_card_filters(sql: &mut String, vals: &mut Vec, filter: &LibraryFilter) { +fn push_card_filters( + sql: &mut String, + vals: &mut Vec, + filter: &LibraryFilter, + has_universes_beyond: bool, +) { + // The four card-origin flags all arrived in the same reference-schema + // revision, so one capability check covers them. Skipped entirely against + // an older db — referencing a missing column would make every search fail + // rather than merely ignoring one filter. + if has_universes_beyond { + if filter.exclude_universes_beyond { + sql.push_str(" AND oc.universes_beyond = 0"); + } + if filter.exclude_digital_only { + sql.push_str(" AND oc.digital_only = 0"); + } + if filter.exclude_funny { + sql.push_str(" AND oc.funny = 0"); + } + // NULL power/toughness means "not a plain number" (or not a creature); + // such cards drop out rather than being compared as 0. + for (col, bound, op) in [ + ("power", filter.power_min, ">="), + ("power", filter.power_max, "<="), + ("toughness", filter.toughness_min, ">="), + ("toughness", filter.toughness_max, "<="), + ] { + if let Some(v) = bound { + sql.push_str(&format!(" AND oc.{col} IS NOT NULL AND oc.{col} {op} ?")); + vals.push(Value::Real(v)); + } + } + } + // Cheapest known printing. Cards with no price at all read as 0 here, the + // same as they display, so an "under $5" filter includes them. + for (bound, op) in [(filter.price_min, ">="), (filter.price_max, "<=")] { + if let Some(v) = bound { + sql.push_str(&format!( + " AND COALESCE({price}, 0) {op} ?", + price = crate::db::price_subquery() + )); + vals.push(Value::Real(v)); + } + } + if let Some(slug) = filter.legal_in.as_ref().filter(|s| !s.is_empty()) { + if let Some(i) = crate::model::LEGALITY_FORMATS + .iter() + .position(|(key, _)| key == slug) + { + // 2 bits per format; 1 = legal, 3 = restricted (playable as a + // single copy, so it counts). 0 = not legal, 2 = banned. + sql.push_str(&format!( + " AND ((oc.legalities >> {}) & 3) IN (1, 3)", + i * 2 + )); + } + } + if filter.can_be_commander { + // Legendary creatures, plus anything whose rules text grants it (some + // planeswalkers, Grist). `Legendary%Creature` rather than a prefix + // match so "Legendary Artifact Creature" is included. + sql.push_str( + " AND (oc.type_line LIKE 'Legendary%Creature%' + OR LOWER(oc.oracle_text) LIKE '%can be your commander%')", + ); + } if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { sql.push_str(" AND oc.name_lower LIKE ?"); vals.push(Value::Text(format!("%{}%", text.to_lowercase()))); @@ -109,7 +175,7 @@ impl Catalog { horizontal = self.horizontal_col() ); let mut vals: Vec = Vec::new(); - push_card_filters(&mut sql, &mut vals, filter); + push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond); if let Some(deck_id) = filter.in_deck { sql.push_str( @@ -169,7 +235,7 @@ impl Catalog { horizontal = self.horizontal_col() ); let mut vals: Vec = Vec::new(); - push_card_filters(&mut sql, &mut vals, filter); + push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond); sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); vals.push(Value::Integer(limit as i64)); @@ -211,6 +277,154 @@ mod tests { assert_eq!(all.len(), 5); } + #[test] + fn excluding_universes_beyond_hides_flagged_cards_only_when_asked() { + let fx = owned_fixture(); + // Stand in for what the ingest derives: a card that exists only as + // Universes Beyond. + fx.catalog + .conn + .execute( + "UPDATE ref.oracle_cards SET universes_beyond = 1 WHERE oracle_id = 'serra-angel'", + [], + ) + .unwrap(); + + let f = LibraryFilter { + exclude_universes_beyond: true, + ..Default::default() + }; + let got = names(&fx.catalog.search_library(&f).unwrap()); + assert!(!got.contains(&"Serra Angel".to_string()), "flagged card is hidden"); + assert!(got.contains(&"Lightning Bolt".to_string()), "others are kept"); + + // Off unless asked for — this must not quietly change existing searches. + let all = names(&fx.catalog.search_library(&LibraryFilter::default()).unwrap()); + assert!(all.contains(&"Serra Angel".to_string())); + } + + #[test] + fn price_range_treats_an_unknown_price_as_free() { + let fx = owned_fixture(); + // Fixture cards have no printings, so no known price at all. + let cheap = names( + &fx.catalog + .search_library(&LibraryFilter { + price_max: Some(5.0), + ..Default::default() + }) + .unwrap(), + ); + assert!( + cheap.contains(&"Sol Ring".to_string()), + "priceless cards read as $0, so a max filter keeps them" + ); + + let pricey = fx + .catalog + .search_library(&LibraryFilter { + price_min: Some(1.0), + ..Default::default() + }) + .unwrap(); + assert!(pricey.is_empty(), "and a min filter drops them"); + } + + #[test] + fn legality_filter_accepts_legal_and_restricted_but_not_banned() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + // commander is index 6 of LEGALITY_FORMATS, so bits 12-13. + let set = |oid: &str, status: i64| { + conn.execute( + "UPDATE ref.oracle_cards SET legalities = ?2 WHERE oracle_id = ?1", + rusqlite::params![oid, status << 12], + ) + .unwrap(); + }; + set("llanowar-elves", 1); // legal + set("sol-ring", 3); // restricted — still playable as one copy + set("lightning-bolt", 2); // banned + set("serra-angel", 0); // not legal + + let got = names( + &fx.catalog + .search_library(&LibraryFilter { + legal_in: Some("commander".into()), + ..Default::default() + }) + .unwrap(), + ); + assert!(got.contains(&"Llanowar Elves".to_string())); + assert!(got.contains(&"Sol Ring".to_string())); + assert!(!got.contains(&"Lightning Bolt".to_string()), "banned is excluded"); + assert!(!got.contains(&"Serra Angel".to_string()), "not-legal is excluded"); + } + + #[test] + fn commander_filter_matches_legendary_creatures_and_granted_text() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + conn.execute( + "UPDATE ref.oracle_cards SET type_line = 'Legendary Artifact Creature — Golem' + WHERE oracle_id = 'sol-ring'", + [], + ) + .unwrap(); + conn.execute( + "UPDATE ref.oracle_cards SET oracle_text = 'Grist can be your commander.' + WHERE oracle_id = 'lightning-bolt'", + [], + ) + .unwrap(); + + let got = names( + &fx.catalog + .search_library(&LibraryFilter { + can_be_commander: true, + ..Default::default() + }) + .unwrap(), + ); + assert!( + got.contains(&"Sol Ring".to_string()), + "\"Legendary Artifact Creature\" must count, not just a prefix match" + ); + assert!(got.contains(&"Lightning Bolt".to_string()), "granted by rules text"); + assert!(!got.contains(&"Serra Angel".to_string()), "plain creature is not"); + } + + #[test] + fn a_power_range_drops_cards_whose_power_isnt_a_number() { + let fx = owned_fixture(); + let conn = &fx.catalog.conn; + conn.execute( + "UPDATE ref.oracle_cards SET power = 6 WHERE oracle_id = 'serra-angel'", + [], + ) + .unwrap(); + // A Tarmogoyf-alike: printed "*", so stored NULL. + conn.execute( + "UPDATE ref.oracle_cards SET power = NULL WHERE oracle_id = 'llanowar-elves'", + [], + ) + .unwrap(); + + let got = names( + &fx.catalog + .search_library(&LibraryFilter { + power_min: Some(5.0), + ..Default::default() + }) + .unwrap(), + ); + assert_eq!( + got, + vec!["Serra Angel".to_string()], + "a '*' power can't be compared, so it must not be treated as 0 or matched" + ); + } + #[test] fn commander_identity_subset() { let fx = owned_fixture(); diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index fa5e095..cd6d5fe 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -456,6 +456,12 @@ struct ScryOracle { /// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`. #[serde(default)] edhrec_rank: Option, + /// Printed power/toughness. Strings because Magic allows non-numeric + /// values ("*", "1+*", "X"); those parse to None and stay unfilterable. + #[serde(default)] + power: Option, + #[serde(default)] + toughness: Option, } #[derive(Deserialize)] @@ -463,6 +469,10 @@ struct ScryFace { #[serde(default)] type_line: Option, #[serde(default)] + power: Option, + #[serde(default)] + toughness: Option, + #[serde(default)] colors: Option>, #[serde(default)] oracle_text: Option, @@ -470,6 +480,16 @@ struct ScryFace { image_uris: Option, } +/// Parse a printed power/toughness into a number, or `None` when it isn't one. +/// +/// Magic prints values like `*`, `1+*` and `X` whose real value depends on game +/// state. Coercing those to 0 would quietly file every Tarmogoyf under "power +/// 0"; leaving them NULL drops them from range filters instead, which is the +/// honest answer to "power >= 5". +fn numeric_pt(raw: Option<&str>) -> Option { + raw?.trim().parse::().ok() +} + fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result { conn.execute_batch("BEGIN")?; let mut count = 0usize; @@ -478,8 +498,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) "INSERT OR IGNORE INTO oracle_cards (oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text, produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal, - image_uri,back_image_uri,default_scryfall_id) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)", + image_uri,back_image_uri,default_scryfall_id,power,toughness) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; @@ -601,6 +621,15 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) .and_then(|i| i.normal.clone()) .unwrap_or_default(); let default_scryfall_id = c.id.clone().unwrap_or_default(); + // Fall back to the front face: a transforming creature carries its + // P/T per-face rather than at the top level. + let front = c.card_faces.as_ref().and_then(|f| f.first()); + let power = numeric_pt( + c.power.as_deref().or_else(|| front.and_then(|f| f.power.as_deref())), + ); + let toughness = numeric_pt( + c.toughness.as_deref().or_else(|| front.and_then(|f| f.toughness.as_deref())), + ); let changed = ins_card.execute(params![ oracle_id, @@ -620,6 +649,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) image_uri, back_image_uri, default_scryfall_id, + power, + toughness, ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { @@ -690,6 +721,19 @@ struct ScryPrinting { card_faces: Option>, #[serde(default)] prices: Option, + /// Scryfall's per-printing markers. Carries `"universesbeyond"` on cards + /// from another IP — the only field that reliably identifies them; there is + /// no Universes Beyond `set_type`, and the security stamp is absent at low + /// rarities. + #[serde(default)] + promo_types: Vec, + /// Where this printing can be played. Missing "paper" means it exists only + /// on Arena/MTGO and can never be part of a physical collection. + #[serde(default)] + games: Vec, + /// Scryfall's set category; `"funny"` covers the Un-sets. + #[serde(default)] + set_type: String, } #[derive(Deserialize)] @@ -716,8 +760,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> { let mut ins = conn.prepare( "INSERT OR IGNORE INTO printings - (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", + (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", )?; stream_jsonl::<_, ScryPrinting, _>(reader, |p| { if cancel.is_cancelled() { @@ -754,6 +798,11 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> .and_then(|pr| pr.usd.as_ref()) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); + let universes_beyond = p.promo_types.iter().any(|t| t == "universesbeyond"); + // An empty `games` (some tokens/oddities) is treated as paper — + // better to leave a card visible than to hide a real one. + let digital = !p.games.is_empty() && !p.games.iter().any(|g| g == "paper"); + let funny = p.set_type == "funny"; ins.execute(params![ p.id, oracle_id, @@ -764,11 +813,32 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> image, back_image, price_usd, + universes_beyond as i64, + digital as i64, + funny as i64, ])?; count += 1; Ok(()) })?; } + + // Roll the per-printing flags up to the card. `MIN(...) = 1` means *every* + // printing carries the flag, which is what distinguishes a card that only + // exists as another IP's character (Frodo) from an ordinary card that + // happens to have been reprinted into such a set (Lightning Bolt appears in + // Marvel Commander). Using "any printing" instead would hide staples — and + // the same reasoning applies to digital and funny printings, since paper + // cards do get Arena versions and Un-set reprints. + conn.execute_batch( + "UPDATE oracle_cards SET + universes_beyond = COALESCE((SELECT MIN(p.universes_beyond) FROM printings p + WHERE p.oracle_id = oracle_cards.oracle_id), 0), + digital_only = COALESCE((SELECT MIN(p.digital) FROM printings p + WHERE p.oracle_id = oracle_cards.oracle_id), 0), + funny = COALESCE((SELECT MIN(p.funny) FROM printings p + WHERE p.oracle_id = oracle_cards.oracle_id), 0) + WHERE EXISTS (SELECT 1 FROM printings p WHERE p.oracle_id = oracle_cards.oracle_id)", + )?; conn.execute_batch("COMMIT")?; Ok(count) } @@ -904,6 +974,56 @@ mod tests { assert!(printings_w > oracle_w); } + #[test] + fn only_cards_whose_every_printing_is_universes_beyond_are_flagged() { + // The distinction that matters: Frodo exists only as Lord of the Rings, + // while Lightning Bolt is an ordinary card that happens to have been + // reprinted into a Universes Beyond set. Flagging on "any printing is + // UB" would hide staples from anyone filtering them out. + let oracle = r#"[ + {"oracle_id":"oid-frodo","name":"Frodo, Adventurous Hobbit","cmc":2.0, + "colors":["W"],"color_identity":["W"],"type_line":"Legendary Creature — Halfling Scout","keywords":[]}, + {"oracle_id":"oid-bolt","name":"Lightning Bolt","cmc":1.0, + "colors":["R"],"color_identity":["R"],"type_line":"Instant","keywords":[]}, + {"oracle_id":"oid-plain","name":"Serra Angel","cmc":5.0, + "colors":["W"],"color_identity":["W"],"type_line":"Creature — Angel","keywords":[]} + ]"#; + let printings = r#"[ + {"id":"p-frodo-1","oracle_id":"oid-frodo","set":"ltr","collector_number":"1", + "rarity":"rare","promo_types":["universesbeyond"]}, + {"id":"p-frodo-2","oracle_id":"oid-frodo","set":"ltc","collector_number":"2", + "rarity":"rare","promo_types":["surgefoil","universesbeyond"]}, + {"id":"p-bolt-1","oracle_id":"oid-bolt","set":"lea","collector_number":"161", + "rarity":"common"}, + {"id":"p-bolt-2","oracle_id":"oid-bolt","set":"msc","collector_number":"99", + "rarity":"rare","promo_types":["universesbeyond"]}, + {"id":"p-plain-1","oracle_id":"oid-plain","set":"m19","collector_number":"34", + "rarity":"uncommon"} + ]"#; + + let dir = tempfile::tempdir().unwrap(); + let conn = open_reference_for_build(&dir.path().join("reference.db")).unwrap(); + load_oracle_cards(&conn, jsonl(oracle).as_bytes(), &CancelToken::new()).unwrap(); + load_printings(&conn, jsonl(printings).as_bytes(), &CancelToken::new()).unwrap(); + + let flagged = |oid: &str| -> i64 { + conn.query_row( + "SELECT universes_beyond FROM oracle_cards WHERE oracle_id = ?1", + [oid], + |r| r.get(0), + ) + .unwrap() + }; + + assert_eq!(flagged("oid-frodo"), 1, "every printing is UB"); + assert_eq!( + flagged("oid-bolt"), + 0, + "a normal card reprinted into a UB set must not be flagged" + ); + assert_eq!(flagged("oid-plain"), 0, "no UB printings at all"); + } + #[test] fn oracle_and_printing_load_from_fixture_json() { // Minimal Scryfall-shaped fixtures exercising the insert paths. diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 2dc5ca1..34b4cdb 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ingest; pub mod library; pub mod model; pub mod search; +pub mod sync; pub mod synergy; use std::path::Path; @@ -43,6 +44,10 @@ pub struct Catalog { /// `default_scryfall_id` siblings) exist in the attached reference db. /// Cached at open time — see [`Catalog::image_expr`]. pub(crate) has_oracle_images: bool, + /// Whether `oracle_cards.universes_beyond` exists in the attached reference + /// db. Cached at open time so the filter can be skipped (rather than + /// failing every query) against a db built before it shipped. + pub(crate) has_universes_beyond: bool, } impl Catalog { @@ -52,7 +57,14 @@ impl Catalog { let conn = db::open(library_path, reference_path)?; let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?; let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?; - Ok(Self { conn, has_horizontal_column, has_oracle_images }) + let has_universes_beyond = + db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?; + Ok(Self { + conn, + has_horizontal_column, + has_oracle_images, + has_universes_beyond, + }) } /// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT` diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 1ada813..5a95397 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -287,6 +287,40 @@ pub struct LibraryFilter { pub location: Option, /// Case-insensitive substring match against the card's oracle rules text. pub oracle_text: Option, + /// Hide cards that exist only as Universes Beyond (Lord of the Rings, + /// Warhammer 40,000, Marvel, ...). Ordinary cards reprinted into a UB set + /// are kept — see `oracle_cards.universes_beyond`. + /// + /// Silently inert against a `reference.db` built before that column + /// existed, the same way the other reference-schema additions degrade. + #[serde(default)] + pub exclude_universes_beyond: bool, + /// Hide cards that exist only on Arena/MTGO (Alchemy rebalances and the + /// like) and so can never be in a physical collection. + #[serde(default)] + pub exclude_digital_only: bool, + /// Hide cards that exist only in "funny" sets (Unglued, Unhinged, Unstable, + /// Unfinity). + #[serde(default)] + pub exclude_funny: bool, + /// Cheapest known printing, in USD. Cards with no known price count as 0, + /// matching how they're displayed. + pub price_min: Option, + pub price_max: Option, + /// Only cards legal in this format (a [`LEGALITY_FORMATS`] slug, e.g. + /// "commander"). Excludes banned and not-legal; restricted still counts as + /// legal, since a restricted card is playable as a single copy. + pub legal_in: Option, + /// Only cards that can head a Commander deck. + #[serde(default)] + pub can_be_commander: bool, + /// Printed power/toughness range. Cards whose printed value isn't a plain + /// number ("*", "1+*") are excluded from these, since they can't be + /// compared — see `oracle_cards.power`. + pub power_min: Option, + pub power_max: Option, + pub toughness_min: Option, + pub toughness_max: Option, } #[cfg(test)] diff --git a/crates/mtgc-core/src/sql/library/0005_deck_uuid.sql b/crates/mtgc-core/src/sql/library/0005_deck_uuid.sql new file mode 100644 index 0000000..acd2510 --- /dev/null +++ b/crates/mtgc-core/src/sql/library/0005_deck_uuid.sql @@ -0,0 +1,21 @@ +-- v5 — a stable, device-independent identity for decks. +-- +-- Sync merges two copies of the library by matching entities across them. +-- `library_cards` and `deck_cards` already have global keys (`oracle_id`, and +-- `(deck, oracle_id)`), but `decks.id` is per-device autoincrement: two devices +-- both hand out id 3 to different decks. Matching on `name` instead would work +-- until someone renames a deck, at which point the merge sees a delete plus an +-- unrelated create and the deck's contents are stranded. +-- +-- This is the *only* schema sync needs. Change tracking is not stored here: +-- the merge derives what changed (and what was deleted) by comparing against +-- the last-synced document, so there are no timestamp columns, no tombstones, +-- and no triggers. + +ALTER TABLE decks ADD COLUMN uuid TEXT; + +-- Existing decks predate the column. `randomblob` is SQLite's own CSPRNG, so +-- this needs no help from Rust and no new dependency. +UPDATE decks SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL; + +CREATE UNIQUE INDEX idx_decks_uuid ON decks(uuid); diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 99d6bd6..1374fa4 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -24,7 +24,27 @@ CREATE TABLE IF NOT EXISTS oracle_cards ( -- absent (falls back to `printings`). image_uri TEXT NOT NULL DEFAULT '', back_image_uri TEXT NOT NULL DEFAULT '', - default_scryfall_id TEXT NOT NULL DEFAULT '' + default_scryfall_id TEXT NOT NULL DEFAULT '', + -- Boolean: this card exists *only* as Universes Beyond (Lord of the Rings, + -- Warhammer 40,000, Marvel, ...). Derived after the printings pass from + -- `printings.universes_beyond`, deliberately as "every printing is UB" + -- rather than "some printing is UB" — ordinary cards get reprinted into UB + -- sets (Lightning Bolt is in Marvel Commander) and must not be caught by + -- a filter meant to exclude other IPs' characters. + universes_beyond INTEGER NOT NULL DEFAULT 0, + -- Boolean: exists only on Arena/MTGO (Alchemy rebalances and the like), so + -- it can never be part of a physical collection. Same every-printing + -- rollup as `universes_beyond`. + digital_only INTEGER NOT NULL DEFAULT 0, + -- Boolean: exists only in "funny" sets — Unglued, Unhinged, Unstable, + -- Unfinity. Not legal in any normal format. + funny INTEGER NOT NULL DEFAULT 0, + -- Printed power/toughness, NULL when the card isn't a creature *or* when + -- the value isn't a plain number ("*", "1+*", "X"). NULL therefore means + -- "can't be compared numerically", and such cards drop out of a + -- power/toughness range filter rather than being coerced to 0. + power REAL, + toughness REAL ); CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower); CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity); @@ -62,7 +82,13 @@ CREATE TABLE IF NOT EXISTS printings ( rarity TEXT NOT NULL DEFAULT '', image_uri TEXT NOT NULL DEFAULT '', back_image_uri TEXT NOT NULL DEFAULT '', -- transform/MDFC/reversible back face - price_usd REAL NOT NULL DEFAULT 0 -- Scryfall "prices.usd", 0 if unknown + price_usd REAL NOT NULL DEFAULT 0, -- Scryfall "prices.usd", 0 if unknown + -- Boolean: Scryfall lists "universesbeyond" in this printing's promo_types. + universes_beyond INTEGER NOT NULL DEFAULT 0, + -- Boolean: this printing isn't available on paper (Scryfall `games`). + digital INTEGER NOT NULL DEFAULT 0, + -- Boolean: this printing comes from a "funny" set (Scryfall `set_type`). + funny INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id); diff --git a/crates/mtgc-core/src/sync.rs b/crates/mtgc-core/src/sync.rs new file mode 100644 index 0000000..f0d7180 --- /dev/null +++ b/crates/mtgc-core/src/sync.rs @@ -0,0 +1,789 @@ +//! The library as a portable JSON document, and the merge that reconciles two +//! copies of it. +//! +//! The document is what the user actually keeps: it lives in whatever folder +//! they sync, and it is the only file they ever see. The SQLite database is an +//! internal working copy that can always be rebuilt by merging a document into +//! an empty one — which is why "first run", "cache was cleared", and "the +//! document changed elsewhere" are all the same code path. +//! +//! # Why a three-way merge +//! +//! Reconciling two edited copies needs to know *who changed what*, and — the +//! hard part — to tell "deleted over there" apart from "never existed over +//! there". Given a common ancestor, both fall straight out of comparison. That +//! is the whole reason this module needs no timestamp columns, no tombstone +//! table, and no triggers: the ancestor carries the information they would have +//! had to record. +//! +//! # The one subtle rule +//! +//! The stored ancestor must be **the remote document as last read**, never the +//! result we last published. If a publish is lost — the sync tool dropped it, +//! the process died, the drive was unplugged — the remote is still what we last +//! read, so the next merge sees our edits as ours and republishes them. +//! Recording the merged result instead would make us believe those edits were +//! already shared, and the next merge would read their absence from the remote +//! as a deletion and destroy them. +//! +//! # Conflicts +//! +//! When both sides changed the same field, [`merge3`] takes *theirs*. That is +//! forced by convergence, not taste: "prefer ours" has each device re-asserting +//! its own value forever, so the two never agree. Preferring theirs settles +//! immediately, because after merging we hold the value they published. In +//! practice it behaves as roughly last-published-wins. Every such choice is +//! counted in [`MergeReport`] so the UI can tell the user what was overruled. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +/// Bumped only for a breaking change to the document shape. Readers reject +/// anything newer rather than silently misreading it. +pub const DOC_VERSION: u32 = 1; + +/// One owned card. Keyed by `oracle_id` in [`LibraryDoc::cards`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CardEntry { + pub quantity: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub location: String, +} + +/// One card in a deck. Keyed by `oracle_id` in [`DeckDoc::cards`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DeckCardEntry { + pub quantity: i64, + #[serde(default, skip_serializing_if = "is_false")] + pub commander: bool, + #[serde(default, skip_serializing_if = "is_false")] + pub maybeboard: bool, +} + +/// One deck. Keyed by its uuid in [`LibraryDoc::decks`] — see migration 0005 +/// for why the integer id can't be used. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct DeckDoc { + pub name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub format: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commander: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub notes: String, + #[serde(default)] + pub cards: BTreeMap, +} + +/// The user's whole library. +/// +/// `BTreeMap` throughout so serialisation is deterministic: the same library +/// always produces byte-identical JSON, which keeps sync tools from seeing +/// spurious changes and makes the file diffable by hand. +/// +/// `library_card_copies` is deliberately absent — that table is unused by any +/// code in the project. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LibraryDoc { + pub version: u32, + #[serde(default)] + pub cards: BTreeMap, + #[serde(default)] + pub decks: BTreeMap, +} + +impl Default for LibraryDoc { + fn default() -> Self { + Self { + version: DOC_VERSION, + cards: BTreeMap::new(), + decks: BTreeMap::new(), + } + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(b: &bool) -> bool { + !*b +} + +impl LibraryDoc { + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string_pretty(self)?) + } + + /// Parse a document, rejecting one written by a newer version of the app + /// rather than silently dropping fields it doesn't understand. + pub fn from_json(text: &str) -> Result { + let doc: Self = serde_json::from_str(text)?; + if doc.version > DOC_VERSION { + return Err(crate::error::CoreError::Other(format!( + "this library file was written by a newer version of mtgc \ + (format {}, this build understands {DOC_VERSION}) — update the app first", + doc.version + ))); + } + Ok(doc) + } + + pub fn is_empty(&self) -> bool { + self.cards.is_empty() && self.decks.is_empty() + } +} + +/// What a merge did, for reporting to the user. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct MergeReport { + pub cards_changed: usize, + pub decks_changed: usize, + pub decks_removed: usize, + /// Fields both sides changed differently, where theirs was taken. Described + /// in user terms (a deck or card name), not field paths. + pub conflicts: Vec, +} + +impl MergeReport { + /// Whether anything at all changed, so callers can stay quiet about the + /// no-op merges that make up almost every sync. + pub fn is_empty(&self) -> bool { + self.cards_changed == 0 && self.decks_changed == 0 && self.decks_removed == 0 + } +} + +/// Reconcile our copy with theirs, given the common ancestor both descend from. +/// +/// A pure function — no database, no filesystem, no clock — which is what makes +/// the interesting cases (concurrent edits, deletes racing edits, idempotency) +/// directly testable. +/// +/// Per key, across `base`/`ours`/`theirs`: +/// - unchanged on one side → take the other side's version +/// - changed identically → no conflict, either will do +/// - changed differently → take theirs, and record it +/// - present in base, gone from one side → that side deleted it; the deletion +/// wins unless the other side also *changed* it, in which case the edit does +/// (destroying edited work is worse than resurrecting a deleted deck, which +/// the user can simply delete again) +pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (LibraryDoc, MergeReport) { + let mut report = MergeReport::default(); + let mut out = LibraryDoc::default(); + + for id in keys(&base.cards, &ours.cards, &theirs.cards) { + let (b, o, t) = (base.cards.get(&id), ours.cards.get(&id), theirs.cards.get(&id)); + if let Some(entry) = merge_entry(b, o, t, &mut || { + report.conflicts.push(format!("owned quantity for card {id}")); + }) { + if Some(&entry) != o { + report.cards_changed += 1; + } + out.cards.insert(id, entry); + } else if o.is_some() { + // Cards are never deleted by the app (quantity drops to zero + // instead), so this only happens if a document was hand-edited. + report.cards_changed += 1; + } + } + + for id in keys(&base.decks, &ours.decks, &theirs.decks) { + let (b, o, t) = (base.decks.get(&id), ours.decks.get(&id), theirs.decks.get(&id)); + match (b, o, t) { + // Deleted on one side; kept and untouched on the other. + (Some(bd), Some(od), None) if od == bd => { + report.decks_removed += 1; + continue; + } + (Some(bd), None, Some(td)) if td == bd => { + report.decks_removed += 1; + continue; + } + // Deleted on both. + (Some(_), None, None) => { + report.decks_removed += 1; + continue; + } + _ => {} + } + + let name = o.or(t).or(b).map(|d| d.name.clone()).unwrap_or_default(); + let merged = merge_deck(b, o, t, &name, &mut report); + if Some(&merged) != o { + report.decks_changed += 1; + } + out.decks.insert(id, merged); + } + + (out, report) +} + +/// Union of the keys of three maps, in deterministic order. +fn keys( + base: &BTreeMap, + ours: &BTreeMap, + theirs: &BTreeMap, +) -> BTreeSet { + base.keys() + .chain(ours.keys()) + .chain(theirs.keys()) + .cloned() + .collect() +} + +/// Three-way merge of a single value. `None` means "not present on this side". +/// Returns `None` when the entry should not exist in the result. +fn merge_entry( + base: Option<&T>, + ours: Option<&T>, + theirs: Option<&T>, + on_conflict: &mut dyn FnMut(), +) -> Option { + match (base, ours, theirs) { + (_, None, None) => None, + // Only one side has it: either it was just added there, or the other + // side deleted it. Deletion only wins if the surviving side left it + // exactly as the ancestor had it. + (Some(b), Some(o), None) => (o != b).then(|| o.clone()), + (Some(b), None, Some(t)) => (t != b).then(|| t.clone()), + (None, Some(o), None) => Some(o.clone()), + (None, None, Some(t)) => Some(t.clone()), + (base, Some(o), Some(t)) => { + if o == t { + return Some(o.clone()); + } + match base { + Some(b) if o == b => Some(t.clone()), // only they changed it + Some(b) if t == b => Some(o.clone()), // only we changed it + _ => { + // Both changed it, differently — or there's no ancestor and + // they disagree. Theirs wins, for convergence. + on_conflict(); + Some(t.clone()) + } + } + } + } +} + +fn merge_deck( + base: Option<&DeckDoc>, + ours: Option<&DeckDoc>, + theirs: Option<&DeckDoc>, + name: &str, + report: &mut MergeReport, +) -> DeckDoc { + // Present on only one side. Reaching here means the other side deleted it + // *and* this side changed it, so the surviving version is taken whole — an + // absent deck must not be treated as an empty one, or its fields would lose + // a field-by-field merge against a deck full of empty strings. + match (ours, theirs) { + (Some(o), None) => return o.clone(), + (None, Some(t)) => return t.clone(), + (None, None) => return DeckDoc::default(), // filtered out by merge3 + _ => {} + } + let empty = DeckDoc::default(); + let o = ours.unwrap_or(&empty); + let t = theirs.unwrap_or(&empty); + let b = base.unwrap_or(&empty); + + // Scalar fields merge independently, so renaming a deck here while changing + // its format there keeps both. + let mut merged = DeckDoc { + name: merge_field(&b.name, &o.name, &t.name, || { + report.conflicts.push(format!("name of deck \"{name}\"")) + }), + format: merge_field(&b.format, &o.format, &t.format, || { + report.conflicts.push(format!("format of deck \"{name}\"")) + }), + commander: merge_field(&b.commander, &o.commander, &t.commander, || { + report.conflicts.push(format!("commander of deck \"{name}\"")) + }), + notes: merge_field(&b.notes, &o.notes, &t.notes, || { + report.conflicts.push(format!("notes of deck \"{name}\"")) + }), + cards: BTreeMap::new(), + }; + + for id in keys(&b.cards, &o.cards, &t.cards) { + let entry = merge_entry( + b.cards.get(&id), + o.cards.get(&id), + t.cards.get(&id), + &mut || { + report + .conflicts + .push(format!("card {id} in deck \"{name}\"")) + }, + ); + if let Some(entry) = entry { + merged.cards.insert(id, entry); + } + } + merged +} + +/// Three-way merge of an always-present field. +fn merge_field(base: &T, ours: &T, theirs: &T, on_conflict: impl FnOnce()) -> T { + if ours == theirs || theirs == base { + return ours.clone(); + } + if ours == base { + return theirs.clone(); + } + on_conflict(); + theirs.clone() +} + +/// Serialise the working database into a document. +pub fn read_doc(conn: &Connection) -> Result { + let mut doc = LibraryDoc::default(); + + let mut stmt = conn.prepare( + "SELECT oracle_id, quantity_owned, location FROM library_cards + WHERE quantity_owned <> 0 OR location <> ''", + )?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + doc.cards.insert( + row.get(0)?, + CardEntry { + quantity: row.get(1)?, + location: row.get(2)?, + }, + ); + } + + let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?; + let mut rows = stmt.query([])?; + let mut by_id: BTreeMap = BTreeMap::new(); + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let uuid: String = row.get(1)?; + by_id.insert(id, uuid.clone()); + doc.decks.insert( + uuid, + DeckDoc { + name: row.get(2)?, + format: row.get(3)?, + commander: row.get(4)?, + notes: row.get(5)?, + cards: BTreeMap::new(), + }, + ); + } + + let mut stmt = conn + .prepare("SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards")?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let deck_id: i64 = row.get(0)?; + let Some(uuid) = by_id.get(&deck_id) else { + continue; // Orphan row; the foreign key should make this impossible. + }; + if let Some(deck) = doc.decks.get_mut(uuid) { + deck.cards.insert( + row.get(1)?, + DeckCardEntry { + quantity: row.get(2)?, + commander: row.get::<_, i64>(3)? != 0, + maybeboard: row.get::<_, i64>(4)? != 0, + }, + ); + } + } + + Ok(doc) +} + +/// Reconcile the working database to `doc`, in one transaction. +/// +/// Rows absent from the document are removed, so this is a true "make the +/// database equal this document" — which is what lets the database be treated +/// as a rebuildable cache rather than a second source of truth. +pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> { + let tx = conn.unchecked_transaction()?; + + for (oracle_id, entry) in &doc.cards { + tx.execute( + "INSERT INTO library_cards(oracle_id, quantity_owned, location) VALUES (?1, ?2, ?3) + ON CONFLICT(oracle_id) DO UPDATE SET + quantity_owned = excluded.quantity_owned, + location = excluded.location", + rusqlite::params![oracle_id, entry.quantity, entry.location], + )?; + } + // Cards the document doesn't mention are zeroed rather than deleted, so any + // deck still referencing them keeps working. + { + let keep = json_array(doc.cards.keys())?; + tx.execute( + "UPDATE library_cards SET quantity_owned = 0, location = '' + WHERE oracle_id NOT IN (SELECT value FROM json_each(?1))", + [&keep], + )?; + } + + // Decks are matched by uuid; anything else is a deck the document no longer + // has. ON DELETE CASCADE clears its cards. + { + let keep = json_array(doc.decks.keys())?; + tx.execute( + "DELETE FROM decks WHERE uuid NOT IN (SELECT value FROM json_each(?1))", + [&keep], + )?; + } + + for (uuid, deck) in &doc.decks { + tx.execute( + "INSERT INTO decks(uuid, name, format, commander_oracle_id, notes) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(uuid) DO UPDATE SET + name = excluded.name, + format = excluded.format, + commander_oracle_id = excluded.commander_oracle_id, + notes = excluded.notes", + rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes], + )?; + let deck_id: i64 = + tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?; + + for (oracle_id, card) in &deck.cards { + tx.execute( + "INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(deck_id, oracle_id) DO UPDATE SET + quantity = excluded.quantity, + is_commander = excluded.is_commander, + is_maybeboard = excluded.is_maybeboard", + rusqlite::params![ + deck_id, + oracle_id, + card.quantity, + card.commander as i64, + card.maybeboard as i64 + ], + )?; + } + let keep = json_array(deck.cards.keys())?; + tx.execute( + "DELETE FROM deck_cards + WHERE deck_id = ?1 AND oracle_id NOT IN (SELECT value FROM json_each(?2))", + rusqlite::params![deck_id, keep], + )?; + } + + tx.commit()?; + Ok(()) +} + +/// Render keys as a JSON array, for `json_each` to expand inside a query — +/// SQLite has no array parameter, and this avoids building SQL by +/// concatenation. +fn json_array<'a>(keys: impl Iterator) -> Result { + Ok(serde_json::to_string(&keys.collect::>())?) +} + +/// Read a library database written before the document format existed (schema +/// v1–v4, where `library.db` was the file the user kept) and produce a document. +/// +/// Migrations run on open, so the file is upgraded to the current schema first; +/// that's what gives its decks the uuids the document needs. +pub fn convert_legacy_library(path: &Path) -> Result { + let conn = crate::db::open_library(path)?; + read_doc(&conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + + fn doc(cards: &[(&str, i64)], decks: &[(&str, &str, &[(&str, i64)])]) -> LibraryDoc { + let mut d = LibraryDoc::default(); + for (id, qty) in cards { + d.cards.insert( + (*id).into(), + CardEntry { quantity: *qty, location: String::new() }, + ); + } + for (uuid, name, cards) in decks { + let mut deck = DeckDoc { name: (*name).into(), ..Default::default() }; + for (id, qty) in *cards { + deck.cards.insert( + (*id).into(), + DeckCardEntry { quantity: *qty, ..Default::default() }, + ); + } + d.decks.insert((*uuid).into(), deck); + } + d + } + + fn qty(d: &LibraryDoc, id: &str) -> Option { + d.cards.get(id).map(|c| c.quantity) + } + + // --- the merge, as a pure function ------------------------------------ + + #[test] + fn edits_to_different_cards_on_each_side_both_survive() { + // The scenario this whole design exists for: edit offline on the phone, + // edit something else on the desktop, then sync. + let base = doc(&[("a", 1), ("b", 1)], &[]); + let ours = doc(&[("a", 3), ("b", 1)], &[]); + let theirs = doc(&[("a", 1), ("b", 5)], &[]); + + let (merged, report) = merge3(&base, &ours, &theirs); + + assert_eq!(qty(&merged, "a"), Some(3)); + assert_eq!(qty(&merged, "b"), Some(5)); + assert!(report.conflicts.is_empty(), "these edits don't collide"); + } + + #[test] + fn the_same_card_changed_on_both_sides_takes_theirs_and_reports_it() { + let base = doc(&[("a", 1)], &[]); + let ours = doc(&[("a", 3)], &[]); + let theirs = doc(&[("a", 9)], &[]); + + let (merged, report) = merge3(&base, &ours, &theirs); + + assert_eq!(qty(&merged, "a"), Some(9)); + assert_eq!(report.conflicts.len(), 1, "the user must be told"); + } + + #[test] + fn preferring_theirs_converges_rather_than_ping_ponging() { + // Both devices resolve the same clash, then exchange again. If the rule + // were "prefer ours" they would swap values forever; this pins down + // that a second exchange is a no-op. + let base = doc(&[("a", 1)], &[]); + let phone = doc(&[("a", 3)], &[]); + let desktop = doc(&[("a", 9)], &[]); + + let (phone_after, _) = merge3(&base, &phone, &desktop); + let (desktop_after, _) = merge3(&base, &desktop, &phone_after); + assert_eq!(qty(&phone_after, "a"), qty(&desktop_after, "a")); + + let (phone_again, _) = merge3(&phone_after, &phone_after, &desktop_after); + assert_eq!(phone_again, phone_after, "a second exchange must settle"); + } + + #[test] + fn merging_the_same_document_twice_changes_nothing() { + let base = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]); + let ours = doc(&[("a", 3)], &[("u1", "Fub", &[("a", 2)])]); + let theirs = doc(&[("a", 1), ("b", 4)], &[("u1", "Fub", &[("a", 1)])]); + + let (once, _) = merge3(&base, &ours, &theirs); + let (twice, report) = merge3(&theirs, &once, &theirs); + + assert_eq!(once, twice, "merge must be idempotent"); + assert!(report.is_empty(), "a re-merge should report no changes"); + } + + #[test] + fn a_deck_deleted_on_one_side_stays_deleted() { + let base = doc(&[], &[("u1", "Fub", &[])]); + let ours = doc(&[], &[("u1", "Fub", &[])]); + let theirs = doc(&[], &[]); + + let (merged, report) = merge3(&base, &ours, &theirs); + + assert!(merged.decks.is_empty()); + assert_eq!(report.decks_removed, 1); + } + + #[test] + fn a_deck_edited_on_one_side_survives_deletion_on_the_other() { + // Losing edited work is worse than resurrecting a deck the user can + // simply delete again. + let base = doc(&[], &[("u1", "Fub", &[])]); + let ours = doc(&[], &[("u1", "Renamed", &[])]); + let theirs = doc(&[], &[]); + + let (merged, _) = merge3(&base, &ours, &theirs); + + assert_eq!(merged.decks.get("u1").map(|d| d.name.as_str()), Some("Renamed")); + } + + #[test] + fn a_deck_we_deleted_comes_back_if_they_edited_it() { + // The mirror of the case above: the rule has to hold whichever side + // did the deleting, or it would depend on who happens to sync first. + let base = doc(&[], &[("u1", "Fub", &[])]); + let ours = doc(&[], &[]); + let theirs = doc(&[], &[("u1", "Fub", &[("a", 2)])]); + + let (merged, _) = merge3(&base, &ours, &theirs); + + let deck = merged.decks.get("u1").expect("their edit should win"); + assert_eq!(deck.name, "Fub"); + assert_eq!(deck.cards["a"].quantity, 2); + } + + #[test] + fn an_empty_base_never_deletes_anything() { + // First sync, or after the working copy was rebuilt: with no ancestor, + // nothing may be interpreted as a deletion. + let base = LibraryDoc::default(); + let ours = doc(&[("a", 1)], &[("u1", "Mine", &[])]); + let theirs = doc(&[("b", 2)], &[("u2", "Theirs", &[])]); + + let (merged, report) = merge3(&base, &ours, &theirs); + + assert_eq!(qty(&merged, "a"), Some(1)); + assert_eq!(qty(&merged, "b"), Some(2)); + assert_eq!(merged.decks.len(), 2); + assert_eq!(report.decks_removed, 0); + } + + #[test] + fn a_lost_publish_is_re_detected_on_the_next_sync() { + // Base is the remote *as read*, so if our publish never landed the + // remote is unchanged and our edits are still recognisably ours. + let remote = doc(&[("a", 1)], &[]); + let ours = doc(&[("a", 3)], &[]); + + let (published, _) = merge3(&remote, &ours, &remote); + assert_eq!(qty(&published, "a"), Some(3)); + + // The publish is dropped; the remote still reads as it did before. + let (retry, _) = merge3(&remote, &published, &remote); + assert_eq!(qty(&retry, "a"), Some(3), "our edit must survive to be republished"); + } + + #[test] + fn deck_fields_merge_independently() { + let mut base = doc(&[], &[("u1", "Fub", &[])]); + let mut ours = base.clone(); + let mut theirs = base.clone(); + ours.decks.get_mut("u1").unwrap().name = "Renamed".into(); + theirs.decks.get_mut("u1").unwrap().format = "Commander".into(); + base.decks.get_mut("u1").unwrap().notes = String::new(); + + let (merged, report) = merge3(&base, &ours, &theirs); + let deck = merged.decks.get("u1").unwrap(); + + assert_eq!(deck.name, "Renamed", "our rename survives"); + assert_eq!(deck.format, "Commander", "their format change survives"); + assert!(report.conflicts.is_empty()); + } + + // --- document <-> database -------------------------------------------- + + fn catalog(dir: &Path, name: &str) -> Catalog { + let reference = dir.join("reference.db"); + if !reference.exists() { + crate::db::open_reference_for_build(&reference).unwrap(); + } + Catalog::open(&dir.join(format!("{name}.db")), &reference).unwrap() + } + + #[test] + fn a_populated_library_round_trips_through_a_document() { + let tmp = tempfile::tempdir().unwrap(); + let c = catalog(tmp.path(), "lib"); + let deck = c.create_deck("Fub", None).unwrap(); + c.set_deck_format(deck.id, "Commander").unwrap(); + c.set_deck_card(deck.id, "card-a", 2, false, false).unwrap(); + c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap(); + c.set_owned("card-a", 4).unwrap(); + c.set_location("card-a", "Box 1").unwrap(); + + let before = read_doc(&c.conn).unwrap(); + // Round-trip through JSON too, so the serde attributes are exercised. + let text = before.to_json().unwrap(); + let parsed = LibraryDoc::from_json(&text).unwrap(); + assert_eq!(before, parsed); + + // Applying it to a *different*, empty library reproduces it exactly. + let other = catalog(tmp.path(), "other"); + apply_doc(&other.conn, &parsed).unwrap(); + assert_eq!(read_doc(&other.conn).unwrap(), before); + } + + #[test] + fn applying_a_document_removes_what_it_no_longer_contains() { + let tmp = tempfile::tempdir().unwrap(); + let c = catalog(tmp.path(), "lib"); + let keep = c.create_deck("Keep", None).unwrap(); + let drop = c.create_deck("Drop", None).unwrap(); + c.set_deck_card(keep.id, "card-a", 1, false, false).unwrap(); + c.set_deck_card(keep.id, "card-b", 1, false, false).unwrap(); + c.set_owned("card-a", 3).unwrap(); + + let mut d = read_doc(&c.conn).unwrap(); + let drop_uuid = d + .decks + .iter() + .find(|(_, v)| v.name == "Drop") + .map(|(k, _)| k.clone()) + .unwrap(); + d.decks.remove(&drop_uuid); + let keep_uuid = d.decks.keys().next().unwrap().clone(); + d.decks.get_mut(&keep_uuid).unwrap().cards.remove("card-b"); + d.cards.remove("card-a"); + + apply_doc(&c.conn, &d).unwrap(); + + assert_eq!(c.list_decks().unwrap().len(), 1); + assert_eq!(c.deck_contents(drop.id).unwrap().len(), 0); + assert_eq!(read_doc(&c.conn).unwrap(), d); + } + + #[test] + fn every_deck_gets_a_uuid_whether_created_or_duplicated() { + let tmp = tempfile::tempdir().unwrap(); + let c = catalog(tmp.path(), "lib"); + let a = c.create_deck("Fub", None).unwrap(); + c.duplicate_deck(a.id, "Fub copy").unwrap(); + + let d = read_doc(&c.conn).unwrap(); + assert_eq!(d.decks.len(), 2, "both decks must appear in the document"); + assert!( + d.decks.keys().all(|u| u.len() == 32), + "uuids should be 16 random bytes as hex" + ); + } + + #[test] + fn a_document_from_a_newer_app_version_is_refused() { + let text = r#"{"version": 99, "cards": {}, "decks": {}}"#; + let err = LibraryDoc::from_json(text).unwrap_err(); + assert!( + err.to_string().contains("newer version"), + "should explain the real problem, got: {err}" + ); + } + + #[test] + fn converts_a_pre_document_library_file() { + // A library from before this format existed: schema v4, no uuids. + let tmp = tempfile::tempdir().unwrap(); + let legacy = tmp.path().join("Decks-v1.db"); + { + let c = catalog(tmp.path(), "Decks-v1"); + let deck = c.create_deck("Old Deck", None).unwrap(); + c.set_deck_format(deck.id, "Modern").unwrap(); + c.set_deck_card(deck.id, "card-a", 3, false, false).unwrap(); + c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap(); + c.set_owned("card-a", 7).unwrap(); + c.set_location("card-a", "Binder A").unwrap(); + } + + let d = convert_legacy_library(&legacy).unwrap(); + + assert_eq!(d.cards["card-a"].quantity, 7); + assert_eq!(d.cards["card-a"].location, "Binder A"); + let deck = d.decks.values().next().expect("the deck should convert"); + assert_eq!(deck.name, "Old Deck"); + assert_eq!(deck.format, "Modern"); + assert_eq!(deck.cards["card-a"].quantity, 3); + assert!(deck.cards["card-b"].maybeboard); + } +} diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index c9769f6..0f54159 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,11 @@ + + + diff --git a/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt new file mode 100644 index 0000000..5d979e6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/garrettmills/mtgc/SyncPlugin.kt @@ -0,0 +1,295 @@ +package com.garrettmills.mtgc + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.activity.result.ActivityResult +import app.tauri.annotation.ActivityCallback +import app.tauri.annotation.Command +import app.tauri.annotation.InvokeArg +import app.tauri.annotation.TauriPlugin +import app.tauri.plugin.Invoke +import app.tauri.plugin.JSArray +import app.tauri.plugin.JSObject +import app.tauri.plugin.Plugin +import java.io.File + +@InvokeArg +class FileArgs { + /** File name inside the chosen folder, e.g. "library.json". */ + lateinit var name: String + + /** App-private staging path the bytes are copied to/from. */ + lateinit var path: String +} + +@InvokeArg +class NameArgs { + lateinit var name: String +} + +/** + * Bridges the Storage Access Framework to Rust for library sync. + * + * The user picks one folder through the system picker; we take a *persistable* + * URI permission on it so the grant survives restarts. That folder is a real + * directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool + * like Syncthing can see it, but this app reaches it only through SAF and + * therefore needs no storage permission in the manifest. + * + * SAF hands back `content://` URIs, which SQLite cannot open — so nothing here + * exposes a URI to Rust. Instead every operation copies bytes between the + * chosen folder and an app-private staging path that Rust picks, and Rust only + * ever does plain file I/O on its own private storage. That is sufficient + * because the synced copy is never opened as a database; it is only ever + * written whole or read whole. + */ +@TauriPlugin +class SyncPlugin(private val activity: Activity) : Plugin(activity) { + companion object { + private const val PREFS = "mtgc-sync" + private const val KEY_TREE_URI = "tree-uri" + + /** Generic type: a SQLite file has no useful registered MIME type. */ + private const val MIME = "application/octet-stream" + + private const val NO_FOLDER = + "No synced folder is set up yet — choose one first." + } + + private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + /** + * The chosen folder, but only when the persisted grant is still held. + * + * A saved URI isn't proof of access: the user can revoke it in system + * settings, and clearing app data drops it. Checking the live grant list + * keeps us from failing later with an opaque SecurityException. + */ + private fun grantedTree(): Uri? { + val saved = prefs().getString(KEY_TREE_URI, null) ?: return null + val uri = Uri.parse(saved) + val held = activity.contentResolver.persistedUriPermissions.any { + it.uri == uri && it.isReadPermission && it.isWritePermission + } + return if (held) uri else null + } + + /** Best-effort human-readable name for the folder, for display in the UI. */ + private fun label(uri: Uri): String = try { + val docId = DocumentsContract.getTreeDocumentId(uri) + val parts = docId.split(":", limit = 2) + if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) { + "Internal storage/${parts[1]}" + } else { + docId + } + } catch (_: Exception) { + uri.toString() + } + + private fun statusObject(): JSObject { + val saved = prefs().getString(KEY_TREE_URI, null) + val granted = grantedTree() + return JSObject().apply { + // "chosen but not granted" is a distinct, recoverable state worth + // telling the user about — it means re-pick, not first-time setup. + put("chosen", saved != null) + put("granted", granted != null) + put("label", saved?.let { label(Uri.parse(it)) }) + } + } + + @Command + fun status(invoke: Invoke) { + invoke.resolve(statusObject()) + } + + @Command + fun pickFolder(invoke: Invoke) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + ) + } + startActivityForResult(invoke, intent, "folderPicked") + } + + @ActivityCallback + fun folderPicked(invoke: Invoke, result: ActivityResult) { + val uri = result.data?.data + if (result.resultCode != Activity.RESULT_OK || uri == null) { + // Backing out of the picker is a normal choice, not a failure. + invoke.resolve(statusObject().apply { put("cancelled", true) }) + return + } + try { + // Without this the grant dies with the activity. + activity.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (e: Exception) { + invoke.reject("Couldn't keep access to that folder: ${e.message}") + return + } + prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply() + invoke.resolve(statusObject().apply { put("cancelled", false) }) + } + + @Command + fun forgetFolder(invoke: Invoke) { + prefs().getString(KEY_TREE_URI, null)?.let { + try { + activity.contentResolver.releasePersistableUriPermission( + Uri.parse(it), + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (_: Exception) { + // Already gone; clearing our own record below is what matters. + } + } + prefs().edit().remove(KEY_TREE_URI).apply() + invoke.resolve(statusObject()) + } + + /** Locate a direct child of the chosen folder by display name. */ + private fun findChild(tree: Uri, name: String): Uri? { + val children = DocumentsContract.buildChildDocumentsUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree) + ) + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + activity.contentResolver.query(children, projection, null, null, null) + ?.use { c: Cursor -> + while (c.moveToNext()) { + if (c.getString(1) == name) { + return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0)) + } + } + } + return null + } + + /** + * Names of the files directly inside the chosen folder. Rust uses this to + * discover the other devices' snapshots; which names matter is entirely its + * business, so everything is returned unfiltered. + */ + @Command + fun list(invoke: Invoke) { + val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) + try { + val names = JSArray() + val children = DocumentsContract.buildChildDocumentsUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree) + ) + val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + activity.contentResolver.query(children, projection, null, null, null) + ?.use { c: Cursor -> + while (c.moveToNext()) { + c.getString(0)?.let { names.put(it) } + } + } + invoke.resolve(JSObject().apply { put("names", names) }) + } catch (e: Exception) { + invoke.reject("Couldn't list the synced folder: ${e.message}") + } + } + + /** + * Remove a file from the chosen folder. + * + * Used only to clear away conflict copies once their contents have been + * merged and republished — never to delete the library document itself. + */ + @Command + fun deleteFile(invoke: Invoke) { + val args = invoke.parseArgs(NameArgs::class.java) + val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) + val doc = findChild(tree, args.name) + if (doc == null) { + invoke.resolve(JSObject().apply { put("removed", false) }) + return + } + try { + val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc) + invoke.resolve(JSObject().apply { put("removed", removed) }) + } catch (e: Exception) { + invoke.reject("Couldn't remove ${args.name}: ${e.message}") + } + } + + /** Copy `name` out of the chosen folder into the app-private `path`. */ + @Command + fun readInto(invoke: Invoke) { + val args = invoke.parseArgs(FileArgs::class.java) + val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) + val doc = findChild(tree, args.name) + if (doc == null) { + // A missing file is an expected state (nothing synced here yet), not an + // error — the caller decides what to do about it. + invoke.resolve(JSObject().apply { + put("found", false) + put("bytes", 0L) + }) + return + } + try { + val dest = File(args.path) + dest.parentFile?.mkdirs() + val copied = activity.contentResolver.openInputStream(doc)!!.use { input -> + dest.outputStream().use { out -> input.copyTo(out) } + } + invoke.resolve(JSObject().apply { + put("found", true) + put("bytes", copied) + }) + } catch (e: Exception) { + invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}") + } + } + + /** Copy the app-private `path` into the chosen folder as `name`. */ + @Command + fun writeFrom(invoke: Invoke) { + val args = invoke.parseArgs(FileArgs::class.java) + val tree = grantedTree() ?: return invoke.reject(NO_FOLDER) + val src = File(args.path) + if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}") + try { + // Overwrite the existing document when there is one, rather than + // delete-and-recreate: a sync tool then sees one modified file instead + // of a removal followed by an unrelated addition. + val doc = findChild(tree, args.name) + ?: DocumentsContract.createDocument( + activity.contentResolver, + DocumentsContract.buildDocumentUriUsingTree( + tree, + DocumentsContract.getTreeDocumentId(tree) + ), + MIME, + args.name + ) + ?: return invoke.reject("Couldn't create ${args.name} in the synced folder") + // "wt" truncates first. Plain "w" leaves any trailing bytes of a longer + // previous file in place, which would silently corrupt a database that + // got smaller. + val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out -> + src.inputStream().use { input -> input.copyTo(out) } + } + invoke.resolve(JSObject().apply { put("bytes", copied) }) + } catch (e: Exception) { + invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}") + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 273db46..f712c09 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -42,7 +42,11 @@ fn status_of(s: &AppState) -> Status { .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64); Status { - library_path: s.library_path.display().to_string(), + // Where the library document lives, not the internal working database — + // that one is an implementation detail the user never sees. Always + // populated: with nothing chosen it's the default in private storage, + // so the path bar never goes blank. + library_path: s.effective_document_path().display().to_string(), 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), @@ -66,18 +70,6 @@ pub fn get_status(db: Db) -> Res { Ok(status) } -#[tauri::command] -pub fn set_library_path(db: Db, path: String) -> Res { - let mut s = db.lock().unwrap(); - s.set_library_path(PathBuf::from(path)); - // Best-effort reopen: the chosen path must stick (and be reported back) - // even if the reference db can't currently be attached (e.g. stale - // schema pending an "Update card data" run) — that's a separate concern - // from which library file is open. - let _ = s.reopen(); - Ok(status_of(&s)) -} - /// Download Scryfall bulk data and (re)build reference.db, then reopen. /// Runs on a blocking thread so the UI stays responsive and progress events flow. #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa04eb4..3ef0d99 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod commands; mod state; +mod sync; use std::sync::Mutex; @@ -31,10 +32,27 @@ pub(crate) fn logged(what: &str, result: Result) -> Result tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("mtgc-sync") + .setup(|app, api| { + let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?; + app.manage(sync::SyncBridge(handle)); + Ok(()) + }) + .build() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) + let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init()); + #[cfg(target_os = "android")] + let builder = builder.plugin(sync_plugin()); + + builder .setup(|app| { let data_dir = app .path() @@ -47,10 +65,15 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_status, - commands::set_library_path, commands::update_card_data, commands::cancel_update_card_data, commands::import_reference_db, + sync::sync_status, + sync::sync_pick_folder, + sync::sync_set_document, + sync::sync_forget_document, + sync::sync_now, + sync::convert_legacy_library, commands::search_cards, commands::add_owned, commands::set_owned, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 3f378a5..8fb920b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,15 +1,28 @@ -//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths. +//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths. +//! +//! The user's library is a JSON *document* in a folder they sync. The SQLite +//! database behind it is an internal working copy under the app data dir that +//! they never see, name, or move — it can always be rebuilt by merging the +//! document into an empty one (see [`mtgc_core::sync`]). use std::path::{Path, PathBuf}; use mtgc_core::{Catalog, NameIndex}; use serde::{Deserialize, Serialize}; -/// Small app-shell preferences persisted across restarts (distinct from the -/// user's library data) — currently just which library file was last open. -#[derive(Default, Serialize, Deserialize)] +/// Small app-shell preferences persisted across restarts, distinct from the +/// user's library data. +#[derive(Default, Serialize, Deserialize, Clone)] struct PersistedConfig { - library_path: Option, + /// The library document the user chose. Desktop only — Android reaches its + /// folder through a SAF grant held on the Java side rather than by path, + /// so there is no path to persist there (see `sync.rs`). + #[serde(default)] + document_path: Option, + /// A pre-document `library.db` inherited from an older build, kept until + /// it has been converted so the app can offer to do that once. + #[serde(default)] + legacy_library_path: Option, } fn config_path(data_dir: &Path) -> PathBuf { @@ -17,10 +30,32 @@ fn config_path(data_dir: &Path) -> PathBuf { } fn load_config(data_dir: &Path) -> PersistedConfig { - std::fs::read_to_string(config_path(data_dir)) + let text = match std::fs::read_to_string(config_path(data_dir)) { + Ok(t) => t, + Err(_) => return PersistedConfig::default(), + }; + if let Ok(cfg) = serde_json::from_str::(&text) { + // Nothing to carry over if it already parses as the current shape, + // unless it's an old config that only had `library_path`. + if cfg.document_path.is_some() || cfg.legacy_library_path.is_some() { + return cfg; + } + } + // Older builds stored `library_path` pointing at a SQLite file the user + // managed themselves. Remember it so the UI can offer to convert it, but + // never open it as the working copy. + #[derive(Deserialize)] + struct OldConfig { + library_path: Option, + } + let legacy = serde_json::from_str::(&text) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + .and_then(|c| c.library_path) + .filter(|p| p.extension().is_some_and(|e| e != "json")); + PersistedConfig { + document_path: None, + legacy_library_path: legacy, + } } fn save_config(data_dir: &Path, cfg: &PersistedConfig) { @@ -32,35 +67,41 @@ fn save_config(data_dir: &Path, cfg: &PersistedConfig) { /// Managed application state, guarded by a `Mutex` in Tauri's state store. pub struct AppState { data_dir: PathBuf, - pub library_path: 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. Derived from the document; never chosen + /// by, shown to, or moved by the user. + pub working_db_path: PathBuf, pub reference_path: PathBuf, pub catalog: Option, pub index: Option, /// Set while `update_card_data` is running; `cancel_update_card_data` /// flips it to abort the in-progress ingest. pub ingest_cancel: Option, + /// Set while a sync is in flight. Sync fires automatically on launch and on + /// every foreground/background transition, so overlapping calls are easy to + /// provoke; a second one would merge the same document concurrently. + pub sync_running: bool, } impl AppState { - /// Initialize with the last-opened library path (or the default under the - /// app data dir, on first run) and open the catalog if a reference db - /// already exists. pub fn new(data_dir: PathBuf) -> Self { - let cfg = load_config(&data_dir); - let library_path = cfg - .library_path - .unwrap_or_else(|| data_dir.join("library.db")); + let config = load_config(&data_dir); let mut s = Self { reference_path: data_dir.join("reference.db"), - library_path, + working_db_path: data_dir.join("working.db"), + config, data_dir, catalog: None, index: None, ingest_cancel: None, + sync_running: false, }; crate::mlog!( - "AppState::new: library={} reference={} (exists={})", - s.library_path.display(), + "AppState::new: document={:?} working={} reference={} (exists={})", + s.config.document_path, + s.working_db_path.display(), s.reference_path.display(), s.reference_path.exists() ); @@ -75,16 +116,44 @@ impl AppState { s } - /// Point at a different library file and persist it so the next launch - /// reopens the same one. - pub fn set_library_path(&mut self, path: PathBuf) { - self.library_path = path.clone(); - save_config( - &self.data_dir, - &PersistedConfig { - library_path: Some(path), - }, - ); + /// The library document the user explicitly chose, if they have. + pub fn document_path(&self) -> Option<&Path> { + self.config.document_path.as_deref() + } + + /// Where the library document actually lives right now. + /// + /// Falls back to app-private storage so there is *always* a document. The + /// alternative — no document until the user picks one — meant edits piled + /// up in the working copy with nowhere to be saved, and choosing a + /// destination later would discard them. Picking a location is a + /// preference, not a precondition for using the app. + pub fn effective_document_path(&self) -> PathBuf { + self.config + .document_path + .clone() + .unwrap_or_else(|| self.default_document_path()) + } + + pub fn default_document_path(&self) -> PathBuf { + self.data_dir.join("library.json") + } + + /// Point at (or, with `None`, forget) the library document. + pub fn set_document_path(&mut self, path: Option) { + self.config.document_path = path; + save_config(&self.data_dir, &self.config); + } + + /// 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() + } + + /// Forget the legacy library, once it's been converted or dismissed. + pub fn clear_legacy_library(&mut self) { + self.config.legacy_library_path = None; + save_config(&self.data_dir, &self.config); } /// Drop the open catalog (and its name index), releasing the handles on both @@ -100,7 +169,7 @@ impl AppState { /// db and make every query fail. pub fn reopen(&mut self) -> Result<(), String> { if self.reference_path.exists() { - let catalog = Catalog::open(&self.library_path, &self.reference_path) + let catalog = Catalog::open(&self.working_db_path, &self.reference_path) .map_err(|e| e.to_string())?; let index = catalog.build_name_index().map_err(|e| e.to_string())?; self.index = Some(index); diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs new file mode 100644 index 0000000..bcd7a96 --- /dev/null +++ b/src-tauri/src/sync.rs @@ -0,0 +1,744 @@ +//! Reading and writing the user's library document, wherever they keep it. +//! +//! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function. +//! This module is only the plumbing: getting the document in and out of a +//! folder that differs by platform, and running the cycle in the right order. +//! +//! Desktop points at an ordinary file. Android can't: the working database must +//! stay in app-private storage (SQLite can't open a `content://` URI), and the +//! user's folder is reachable only through the Storage Access Framework. So on +//! Android every transfer is staged through an app-private file that Rust owns +//! and the Kotlin side copies to or from — deliberately avoiding +//! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume +//! just to reach one folder. [`Folder`] hides that split. + +use std::path::{Path, PathBuf}; + +use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport}; +use serde::Serialize; +#[cfg(target_os = "android")] +use serde::Deserialize; +use tauri::{AppHandle, Manager}; + +use crate::state::AppState; + +type Db<'a> = tauri::State<'a, std::sync::Mutex>; + +/// Document name used on Android, where the user grants a folder rather than +/// choosing a file. Desktop uses whatever they named their file. +#[cfg(target_os = "android")] +const ANDROID_DOC_NAME: &str = "library.json"; + +/// Where transfers are staged across the SAF boundary. Inside the app data dir, +/// so it inherits the same private-storage guarantees as the working copy. +/// Android-only: desktop reads and writes the document directly. +#[cfg(target_os = "android")] +fn staging_path(data_dir: &Path, name: &str) -> PathBuf { + data_dir.join("sync-staging").join(name) +} + +fn base_path(data_dir: &Path) -> PathBuf { + data_dir.join("sync-base.json") +} + +/// State of the library document, as shown in the UI. +#[derive(Serialize, Default)] +pub struct SyncStatus { + /// Whether a document has been chosen at all. + pub chosen: bool, + /// Whether it's currently reachable. Distinct from `chosen`: an Android SAF + /// grant can be revoked and a desktop folder can be deleted or unmounted, + /// both of which need re-picking rather than first-time setup. + pub granted: bool, + pub label: Option, + /// True where the folder is chosen through the system picker (Android) + /// rather than by path — the one thing the UI branches on. + pub picker_based: bool, + /// A pre-document `library.db` waiting to be converted, if any. + pub legacy_library: Option, +} + +#[cfg(target_os = "android")] +mod bridge { + use super::*; + + /// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store + /// during plugin setup (see `lib.rs`). + pub struct SyncBridge(pub tauri::plugin::PluginHandle); + + #[derive(Serialize)] + struct FileArgs<'a> { + name: &'a str, + path: &'a str, + } + + #[derive(Serialize)] + struct NameArgs<'a> { + name: &'a str, + } + + #[derive(Deserialize)] + struct ReadResult { + found: bool, + } + + #[derive(Deserialize)] + struct ListResult { + #[serde(default)] + names: Vec, + } + + #[derive(Deserialize, Default)] + pub struct FolderStatus { + #[serde(default)] + pub chosen: bool, + #[serde(default)] + pub granted: bool, + #[serde(default)] + pub label: Option, + } + + fn call( + app: &AppHandle, + command: &str, + payload: impl Serialize, + ) -> Result { + let bridge = app + .try_state::() + .ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?; + bridge + .0 + .run_mobile_plugin::(command, payload) + .map_err(|e| e.to_string()) + } + + pub fn status(app: &AppHandle) -> Result { + call(app, "status", ()) + } + + pub fn pick_folder(app: &AppHandle) -> Result { + call(app, "pickFolder", ()) + } + + pub fn forget_folder(app: &AppHandle) -> Result { + call(app, "forgetFolder", ()) + } + + pub fn list(app: &AppHandle) -> Result, String> { + Ok(call::(app, "list", ())?.names) + } + + pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result { + let res: ReadResult = call( + app, + "readInto", + FileArgs { name, path: &path.display().to_string() }, + )?; + Ok(res.found) + } + + pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> { + let _: serde_json::Value = call( + app, + "writeFrom", + FileArgs { name, path: &path.display().to_string() }, + )?; + Ok(()) + } + + pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> { + let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?; + Ok(()) + } +} + +#[cfg(target_os = "android")] +pub use bridge::SyncBridge; + +/// The folder as an ordinary directory — desktop's whole implementation. Free +/// functions so they stay callable, and testable, without a running app. +mod plain { + use std::path::Path; + + pub fn list(dir: &Path) -> Result, String> { + let entries = + std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?; + let mut names = Vec::new(); + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_file()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) + } + + pub fn read(dir: &Path, name: &str) -> Result, String> { + let path = dir.join(name); + if !path.is_file() { + return Ok(None); + } + std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| format!("Can't read {}: {e}", path.display())) + } + + pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + // Write beside the target and rename, so whatever is watching the + // folder never observes a half-written document. + let tmp = dir.join(format!("{name}.tmp")); + std::fs::write(&tmp, text) + .map_err(|e| format!("Can't write in {}: {e}", dir.display()))?; + std::fs::rename(&tmp, dir.join(name)).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("Can't save {name} in {}: {e}", dir.display()) + }) + } + + pub fn delete(dir: &Path, name: &str) -> Result<(), String> { + std::fs::remove_file(dir.join(name)) + .map_err(|e| format!("Can't remove {name}: {e}")) + } +} + +/// Where the library document lives, however this platform reaches it. +struct Location { + folder: Folder, + doc_name: String, +} + +enum Folder { + #[cfg(target_os = "android")] + Picker, + Path(PathBuf), +} + +impl Location { + fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result, String> { + match &self.folder { + #[cfg(target_os = "android")] + Folder::Picker => bridge::list(app), + Folder::Path(dir) => plain::list(dir), + } + } + + /// Read a document by name. `Ok(None)` means it isn't there — a normal + /// state (nothing published yet), not a failure. + fn read( + &self, + #[allow(unused_variables)] app: &AppHandle, + #[allow(unused_variables)] data_dir: &Path, + name: &str, + ) -> Result, String> { + match &self.folder { + #[cfg(target_os = "android")] + Folder::Picker => { + let staged = staging_path(data_dir, "incoming.json"); + if !bridge::read_into(app, name, &staged)? { + return Ok(None); + } + let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(&staged); + Ok(Some(text)) + } + Folder::Path(dir) => plain::read(dir, name), + } + } + + fn write( + &self, + #[allow(unused_variables)] app: &AppHandle, + #[allow(unused_variables)] data_dir: &Path, + text: &str, + ) -> Result<(), String> { + match &self.folder { + #[cfg(target_os = "android")] + Folder::Picker => { + let staged = staging_path(data_dir, "outgoing.json"); + if let Some(parent) = staged.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(&staged, text).map_err(|e| e.to_string())?; + let result = bridge::write_from(app, &self.doc_name, &staged); + let _ = std::fs::remove_file(&staged); + result + } + Folder::Path(dir) => plain::write(dir, &self.doc_name, text), + } + } + + fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> { + match &self.folder { + #[cfg(target_os = "android")] + Folder::Picker => bridge::delete(app, name), + Folder::Path(dir) => plain::delete(dir, name), + } + } + + /// Names of files that look like conflict copies of our document — what + /// sync tools leave behind when two devices publish while offline. + /// + /// Matched by shape (same stem, `.json`) rather than by any tool's naming + /// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest + /// without knowing about any of them. + fn conflict_copies(&self, app: &AppHandle) -> Vec { + conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default()) + } +} + +/// Pick the conflict copies of `doc_name` out of a directory listing. +/// +/// Split out from [`Location::conflict_copies`] so the matching rule — the part +/// with all the edge cases — is testable without a running app. +fn conflict_copies_of(doc_name: &str, names: Vec) -> Vec { + let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name); + let mut found: Vec = names + .into_iter() + .filter(|n| { + n != doc_name + && n.starts_with(stem) + && n.ends_with(".json") + // Our own atomic-write temporary, if a previous run died + // between writing and renaming. + && !n.ends_with(".json.tmp") + }) + .collect(); + found.sort(); + found +} + +/// Resolve where the document lives. +/// +/// Never fails for want of a choice: with nothing picked, the document sits in +/// app-private storage, so the library is always saved somewhere. Choosing a +/// synced location is a preference on top of that. +fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result { + #[cfg(target_os = "android")] + { + let status = bridge::status(app)?; + if status.chosen && !status.granted { + // Silently falling back to private storage here would look like the + // library had been emptied, so this is worth surfacing. + return Err("Access to your library folder was lost — choose it again.".into()); + } + if status.granted { + return Ok(Location { + folder: Folder::Picker, + doc_name: ANDROID_DOC_NAME.into(), + }); + } + } + let path = db.lock().unwrap().effective_document_path(); + let dir = path + .parent() + .ok_or_else(|| format!("{} has no folder.", path.display()))? + .to_path_buf(); + let doc_name = path + .file_name() + .ok_or_else(|| format!("{} isn't a file name.", path.display()))? + .to_string_lossy() + .into_owned(); + Ok(Location { folder: Folder::Path(dir), doc_name }) +} + +#[tauri::command] +pub fn sync_status( + #[allow(unused_variables)] app: AppHandle, + db: Db, +) -> Result { + let legacy_library = db + .lock() + .unwrap() + .legacy_library_path() + .map(|p| p.display().to_string()); + + #[cfg(target_os = "android")] + { + let s = bridge::status(&app)?; + if s.chosen { + return Ok(SyncStatus { + chosen: true, + granted: s.granted, + label: s.label, + picker_based: true, + legacy_library, + }); + } + // No folder picked: the library is in private storage and works, it + // just isn't synced anywhere yet. + return Ok(SyncStatus { + chosen: false, + granted: true, + label: Some( + db.lock() + .unwrap() + .effective_document_path() + .display() + .to_string(), + ), + picker_based: true, + legacy_library, + }); + } + #[cfg(not(target_os = "android"))] + { + let s = db.lock().unwrap(); + let path = s.effective_document_path(); + Ok(SyncStatus { + chosen: s.document_path().is_some(), + // The folder can be gone — deleted, or on a drive that isn't + // mounted. The document itself needn't exist: sync creates it. + granted: path.parent().map(Path::is_dir).unwrap_or(false), + label: Some(path.display().to_string()), + picker_based: false, + legacy_library, + }) + } +} + +/// Android only: grant access to a folder through the system picker. +#[tauri::command] +pub fn sync_pick_folder( + #[allow(unused_variables)] app: AppHandle, + db: Db, +) -> Result { + #[cfg(target_os = "android")] + { + crate::logged("sync_pick_folder", bridge::pick_folder(&app))?; + sync_status(app, db) + } + #[cfg(not(target_os = "android"))] + { + let _ = (app, db); + Err("This platform chooses the library file by path.".into()) + } +} + +/// Desktop only: adopt a library document the frontend picked. +#[tauri::command] +pub fn sync_set_document( + app: AppHandle, + db: Db, + #[allow(unused_variables)] path: String, +) -> Result { + #[cfg(target_os = "android")] + { + let _ = (&app, &db, path); + Err("This platform chooses the library folder through the system picker.".into()) + } + #[cfg(not(target_os = "android"))] + { + let mut path = PathBuf::from(path); + if path.extension().is_none() { + path.set_extension("json"); + } + match path.parent() { + Some(dir) if dir.is_dir() => {} + _ => return Err(format!("{} isn't in a folder that exists.", path.display())), + } + + // Two very different intents share this one command, told apart by + // whether the target already holds a library: + // + // * an existing document — "open that library", so the working copy + // is dropped and rebuilt from it; + // * a new file — "keep my library here from now on", so the working + // copy is *kept* and published into it. + // + // Getting this wrong destroys data: always resetting would discard + // everything the user had whenever they pointed at a fresh file. + let adopting = path.is_file(); + db.lock().unwrap().set_document_path(Some(path)); + if adopting { + reset_working_copy(&app, &db)?; + } else { + // Clear only the merge base, so the new file is seen as empty and + // the whole current library gets published into it. + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(base_path(&data_dir)); + } + // Either way, get the document and the working copy agreeing now rather + // than at some later trigger. + if let Err(e) = sync_now(app.clone(), db.clone()) { + crate::mlog!("sync_set_document: initial sync failed: {e}"); + } + sync_status(app, db) + } +} + +#[tauri::command] +pub fn sync_forget_document(app: AppHandle, db: Db) -> Result { + #[cfg(target_os = "android")] + { + crate::logged("sync_forget_document", bridge::forget_folder(&app))?; + } + #[cfg(not(target_os = "android"))] + { + db.lock().unwrap().set_document_path(None); + } + sync_status(app, db) +} + +/// Empty the working copy and forget the merge base, so the next sync adopts +/// whatever the document says from scratch. +fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(base_path(&data_dir)); + + let mut s = db.lock().unwrap(); + let working = s.working_db_path.clone(); + // Close first: the file can't be replaced while the catalog holds it open. + s.close(); + let _ = std::fs::remove_file(&working); + if let Err(e) = s.reopen() { + crate::mlog!("reset_working_copy: reopen failed: {e}"); + } + Ok(()) +} + +/// Outcome of one sync. +#[derive(Serialize, Default)] +pub struct SyncReport { + pub changed: bool, + pub published: bool, + /// Conflict copies absorbed and cleaned up, if any. + pub absorbed: usize, + pub report: MergeReport, +} + +/// Reconcile the working copy with the library document. +/// +/// `Ok(None)` when a sync was already in flight and this call stood down. Sync +/// runs automatically on launch and on every foreground/background transition, +/// so overlap is routine rather than exceptional. +#[tauri::command] +pub fn sync_now(app: AppHandle, db: Db) -> Result, String> { + { + let mut state = db.lock().unwrap(); + if state.sync_running { + return Ok(None); + } + state.sync_running = true; + } + let result = sync_inner(&app, &db); + db.lock().unwrap().sync_running = false; + result.map(Some) +} + +fn sync_inner(app: &AppHandle, db: &Db) -> Result { + let location = locate(app, db)?; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let working = db.lock().unwrap().working_db_path.clone(); + + // 1. Read the document. Absent simply means nothing has been written yet — + // the file the user named gets created by the first sync. + let existing = location.read(app, &data_dir, &location.doc_name)?; + let doc_exists = existing.is_some(); + let mut theirs = match existing { + Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?, + None => LibraryDoc::default(), + }; + + // 2. Absorb any conflict copies, so nothing is stranded in one. + let base = read_base(&data_dir); + let copies = location.conflict_copies(app); + let mut absorbed = Vec::new(); + for name in &copies { + let Ok(Some(text)) = location.read(app, &data_dir, name) else { + continue; + }; + let Ok(copy) = LibraryDoc::from_json(&text) else { + continue; // Not one of ours, or truncated mid-sync; leave it alone. + }; + let (merged, _) = core_sync::merge3(&base, &theirs, ©); + theirs = merged; + absorbed.push(name.clone()); + } + + // 3-4. Merge our edits with theirs and make the working copy match. + let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; + let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; + let (merged, report) = core_sync::merge3(&base, &ours, &theirs); + if merged != ours { + core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; + } + + // 5. Publish only when we have something to contribute, or the file the + // user named doesn't exist yet — otherwise every sync would look like a + // modification to whatever is watching the folder. + let published = merged != theirs || !doc_exists; + if published { + let text = merged.to_json().map_err(|e| e.to_string())?; + location.write(app, &data_dir, &text)?; + } + + // Only now that their content is safely in the published document is it + // safe to remove the copies it came from. + if published { + for name in &absorbed { + if let Err(e) = location.delete(app, name) { + crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}"); + } + } + } + + // 6. Remember the document *as read*, never the merged result. If the write + // above was lost, the remote still matches this base, so the next sync sees + // our edits as ours and republishes them instead of reading their absence + // from the document as a deletion. + write_base(&data_dir, &theirs); + + crate::mlog!( + "sync_now: changed={} published={} absorbed={} conflicts={}", + !report.is_empty(), + published, + absorbed.len(), + report.conflicts.len() + ); + + Ok(SyncReport { + changed: !report.is_empty(), + published, + absorbed: absorbed.len(), + report, + }) +} + +/// The document as last read. Missing or unreadable yields an empty document, +/// which is the safe default: with no ancestor, a merge never deletes anything. +fn read_base(data_dir: &Path) -> LibraryDoc { + std::fs::read_to_string(base_path(data_dir)) + .ok() + .and_then(|t| LibraryDoc::from_json(&t).ok()) + .unwrap_or_default() +} + +fn write_base(data_dir: &Path, doc: &LibraryDoc) { + if let Ok(text) = doc.to_json() { + let _ = std::fs::write(base_path(data_dir), text); + } +} + +/// Convert a pre-document `library.db` into the current format, merging it into +/// the working copy so the next sync publishes it. +#[tauri::command] +pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result { + let legacy = PathBuf::from(&path); + if !legacy.is_file() { + return Err(format!("{} isn't there.", legacy.display())); + } + let doc = crate::logged( + "convert_legacy_library", + mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()), + )?; + + let working = db.lock().unwrap().working_db_path.clone(); + let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; + let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; + // Merged rather than applied outright, so converting doesn't discard + // anything already in the working copy. With an empty ancestor nothing is + // treated as a deletion. + let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); + core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; + + db.lock().unwrap().clear_legacy_library(); + crate::mlog!( + "convert_legacy_library: {} cards, {} decks from {}", + doc.cards.len(), + doc.decks.len(), + legacy.display() + ); + + // Publish immediately when a document is already configured, so the + // converted library reaches the other devices without waiting. + if locate(&app, &db).is_ok() { + if let Some(r) = sync_now(app, db)? { + return Ok(r); + } + } + Ok(SyncReport { + changed: !report.is_empty(), + published: false, + absorbed: 0, + report, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn writing_replaces_the_document_and_leaves_no_temporary() { + // A stray ".tmp" would be picked up as a conflict copy and, worse, + // synced as a partial document. + let dir = scratch("write"); + plain::write(&dir, "library.json", "{\"version\":1}").unwrap(); + plain::write(&dir, "library.json", "{}").unwrap(); + + assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}"); + assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn reading_a_document_that_isnt_there_is_not_an_error() { + // Normal before anything has been published. + let dir = scratch("missing"); + assert_eq!(plain::read(&dir, "library.json").unwrap(), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn conflict_copies_are_matched_by_shape_not_by_tool() { + let dir = scratch("conflicts"); + for name in [ + "library.json", + "library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing + "library (SFConflict garrettmills 2026-08-03).json", // Seafile + "library (conflicted copy 2026-08-03).json", // Dropbox + "library.json.tmp", // our own, mid-write + "notes.json", // unrelated + "reference.db", // unrelated + ] { + std::fs::write(dir.join(name), "{}").unwrap(); + } + + let found = conflict_copies_of("library.json", plain::list(&dir).unwrap()); + + assert_eq!( + found, + vec![ + "library (SFConflict garrettmills 2026-08-03).json", + "library (conflicted copy 2026-08-03).json", + "library.sync-conflict-20260803-120000-ABCDEFG.json", + ], + "should catch every tool's naming, and nothing else" + ); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_differently_named_document_only_claims_its_own_copies() { + // The user names their own file on desktop, so the rule has to key off + // that name rather than assuming "library". + let found = conflict_copies_of( + "Decks.json", + vec![ + "Decks.json".into(), + "Decks (conflicted copy).json".into(), + "library.sync-conflict-1.json".into(), + "Decks-backup.txt".into(), + ], + ); + + assert_eq!(found, vec!["Decks (conflicted copy).json"]); + } +} diff --git a/src/App.tsx b/src/App.tsx index 19bad46..3e67076 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, useCallback, ReactNode } from "react"; import { listen } from "@tauri-apps/api/event"; import { open, save } from "@tauri-apps/plugin-dialog"; -import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api"; +import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api"; import { ColorDistributionBar, HoverPreviewProvider, @@ -218,12 +218,25 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n // so missing the snackbar isn't the last chance to undo. const UNDO_SNACKBAR_MS = 8000; +/** How often an open, visible window re-syncs. Long enough not to churn the + * synced folder, short enough that a second device sees edits without being + * restarted. Launch and foreground/background transitions sync too. */ +const AUTO_SYNC_MS = 3 * 60 * 1000; + export default function App() { const [status, setStatus] = useState(null); const [libStats, setLibStats] = useState(null); const [tab, setTab] = usePersistedState("active-tab", "add"); const [progress, setProgress] = useState(null); const [busy, setBusy] = useState(false); + // Synced-folder grant for library sync. Only fetched when the Info tab is + // open: the grant can be revoked from system settings while the app is + // backgrounded, so a value cached at startup would be stale exactly when the + // user comes back to check it. + const [sync, setSync] = useState(null); + const [syncBusy, setSyncBusy] = useState(false); + // Result of the last round-trip self-test, shown until the next action. + const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null); const lastProgress = useRef(null); // Set by the ingest-progress listener below; reset before each // updateCardData() call so it can detect a stalled invoke — see there. @@ -328,6 +341,13 @@ export default function App() { // invited exactly what happened: a redundant download click before the // real status had even loaded. Distinguish the two explicitly. const statusLoaded = status !== null; + // Mirrors `ready` for the automatic-sync callbacks, which must stay stable + // across renders (re-creating them would re-arm the listeners below and fire + // a fresh sync every time card data finished loading). + const readyRef = useRef(ready); + useEffect(() => { + readyRef.current = ready; + }, [ready]); // Keep the sidebar's library summary fresh: fetch once card data is ready, // then poll (cards/decks can change from any tab without a shared event bus). @@ -405,16 +425,215 @@ export default function App() { await api.cancelUpdateCardData(); } - async function switchLibrary(path: string) { + + /** Refresh the synced-folder grant state. Never throws: "no folder chosen" is + * a normal state, reported in the panel rather than as an error. */ + const refreshSync = useCallback(async () => { + setSyncBusy(true); try { - setStatus(await api.setLibraryPath(path)); + setSync(await api.syncStatus()); } catch (err) { - setProgress({ - stage: "error", - message: `Couldn't open that library: ${String(err)}`, - overall_percent: 0, - stages: lastProgress.current?.stages ?? [], + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + }, []); + + // Once at startup: the automatic sync below needs to know whether a folder is + // configured at all. + useEffect(() => { + if (statusLoaded) refreshSync(); + }, [statusLoaded, refreshSync]); + + // And whenever the Info tab is opened, which is also when the user gets back + // from the folder picker, and when a revoked grant would first matter. + useEffect(() => { + if (tab === "info") refreshSync(); + }, [tab, refreshSync]); + + + /** Choose where the library lives. Android grants a folder through the system + * picker (SAF has no path to hand back); desktop picks the JSON file itself. */ + async function pickLibrary() { + setSyncTest(null); + try { + if (sync?.picker_based) { + setSyncBusy(true); + setSync(await api.syncPickFolder()); + return; + } + const path = await open({ + title: "Choose your library file", + // Start where the library currently lives, so the dialog opens + // somewhere useful rather than at an arbitrary default. + defaultPath: sync?.label ?? undefined, + filters: [ + { name: "mtgc library", extensions: ["json"] }, + { name: "All files", extensions: ["*"] }, + ], }); + if (typeof path !== "string") return; + setSyncBusy(true); + setSync(await api.syncSetDocument(path)); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + } + + /** Desktop: start a new library file, e.g. inside a synced folder. */ + async function newLibrary() { + setSyncTest(null); + try { + const path = await save({ + title: "Save your library here (put it in a synced folder)", + defaultPath: "mtgc-library.json", + filters: [{ name: "mtgc library", extensions: ["json"] }], + }); + if (typeof path !== "string") return; + setSyncBusy(true); + // Pointing at a file that doesn't exist yet moves the current library + // there rather than starting empty — see sync_set_document. + setSync(await api.syncSetDocument(path)); + setSyncTest({ ok: true, message: `Your library is now kept at ${path}.` }); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + } + + async function forgetLibrary() { + setSyncBusy(true); + setSyncTest(null); + try { + setSync(await api.syncForgetDocument()); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + } + + /** Convert a library.db from an older build into the current format. */ + async function convertLegacy(path: string) { + setSyncBusy(true); + setSyncTest(null); + try { + const r = await api.convertLegacyLibrary(path); + const n = r.report.cards_changed + r.report.decks_changed; + const status = await api.syncStatus(); + setSync(status); + // Say where it landed — otherwise a successful conversion looks like + // nothing happened. + setSyncTest({ + ok: true, + message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`, + }); + if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); + setDeckRefreshSignal((v) => v + 1); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + } + + /** Merge the library document with local edits and publish the result. + * + * Shared by the Info tab's button and the automatic triggers below. Returns + * the report so the caller can decide whether to say anything; throws only + * what the backend threw. */ + const runSync = useCallback(async () => { + const report = await api.syncNow(); + if (!report) return null; // Another sync had it in hand. + const changed = report.changed; + if (changed) { + // Library and deck views hold fetched copies; a merge changes rows + // underneath them, so pull them back into line rather than waiting for + // the summary's 5s poll. Needs card data loaded, hence the guard. + if (readyRef.current) { + api.getLibraryStats().then(setLibStats).catch(() => {}); + } + setDeckRefreshSignal((v) => v + 1); + } + return { report, changed }; + }, []); + + // Automatic sync. Runs once the folder is usable, then on every trip to or + // from the background: leaving publishes what changed here, returning picks + // up what changed elsewhere. Keyed on `granted` alone so merely re-checking + // the folder's status doesn't re-arm the listeners and resync. + const syncGranted = sync?.granted ?? false; + useEffect(() => { + if (!syncGranted) return; + let cancelled = false; + + // Failures here are deliberately silent: the folder can be temporarily + // unavailable (drive unmounted, grant revoked), and nagging on every app + // switch would be worse than the Info tab reporting it on demand. Nothing + // is lost by a skipped run — the merge base only advances to a document we + // actually read, so a lost publish is simply re-detected next time. + const run = () => { + if (cancelled) return; + runSync().catch(() => {}); + }; + + run(); + document.addEventListener("visibilitychange", run); + // Visibility changes alone would strand a desktop session that simply stays + // open: edits made after launch would never reach the other devices. Poll + // while visible so an always-open window still publishes. Skipped while + // hidden — the transition back handles that case. + const timer = setInterval(() => { + if (!document.hidden) run(); + }, AUTO_SYNC_MS); + return () => { + cancelled = true; + clearInterval(timer); + document.removeEventListener("visibilitychange", run); + }; + }, [syncGranted, runSync]); + + /** The Info tab's explicit "Sync now" — same work, but reports the outcome. */ + async function syncNow() { + setSyncBusy(true); + setSyncTest(null); + try { + const result = await runSync(); + if (!result) { + setSyncTest({ ok: true, message: "A sync was already running." }); + return; + } + const { report, changed } = result; + const parts: string[] = []; + if (changed) { + const r = report.report; + const bits = [ + r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`, + r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`, + r.decks_removed && `${r.decks_removed} removed`, + ].filter(Boolean); + parts.push(`Merged ${bits.join(", ")}.`); + } else { + parts.push(report.published ? "Published your changes." : "Already up to date."); + } + if (report.absorbed > 0) { + parts.push( + `Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`, + ); + } + // Conflicts are the one thing worth spelling out — the user may want to + // correct a value that lost. + if (report.report.conflicts.length > 0) { + parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`); + } + setSyncTest({ ok: true, message: parts.join(" ") }); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); } } @@ -439,23 +658,6 @@ export default function App() { } } - async function openLibrary() { - const path = await open({ - title: "Open an existing library.db", - filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], - }); - if (typeof path === "string") await switchLibrary(path); - } - - async function newLibrary() { - const path = await save({ - title: "Create a new library.db (put it in a synced folder)", - defaultPath: "library.db", - filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }], - }); - if (typeof path === "string") await switchLibrary(path); - } - return (
@@ -468,7 +670,9 @@ export default function App() {
- {NAV.map((n) => ( + {/* Info carries library sync, which desktop needs too — it's no + longer the mobile-only card-data page it started as. */} + {[...NAV, INFO_TAB].map((n) => ( - @@ -599,11 +802,12 @@ export default function App() { ) : tab === "decks" ? ( ) : ( - // Mobile-only tab (see INFO_TAB) — the desktop equivalent is - // split between the topbar's Update card data button (hidden - // here on mobile) and the nav rail's footer summary (which - // has nowhere to live once the rail collapses to the bottom - // nav), so both land here together. + // The per-install status page: card data, library sync, and the + // collection summary. On mobile it's the only home for the + // card-data buttons (no topbar room) and the summary (the nav + // rail it normally sits in is replaced by the bottom nav); + // on desktop those also appear in the topbar and rail, but sync + // lives only here.

Card database

@@ -620,6 +824,85 @@ export default function App() {

+

Your library

+ {sync === null ? ( +
Checking…
+ ) : ( + <> +
+ Your collection is kept in a single file. Put it in a folder you sync and + every device stays in step — edits made in two places are combined, not + overwritten. +
+ {sync.label && ( +
+ {sync.label} +
+ )} + {sync.chosen && !sync.granted && ( +
+ ✕{" "} + {sync.picker_based + ? "Access to that folder was lost — choose it again." + : "That folder isn't there right now — is the drive mounted?"} +
+ )} + {syncTest && ( +
+ {syncTest.ok ? "✓ " : "✕ "} + {syncTest.message} +
+ )} +
+ {sync.granted && ( + + )} + + {sync.chosen && ( + + )} +
+ {/* Only shown while an old library.db is still waiting; the + offer disappears for good once it's been converted. */} + {sync.legacy_library && ( + <> +
+ An older library from a previous version is still on this device. Convert + it to bring your collection across. +
+
+ {sync.legacy_library} +
+
+ +
+ + )} + + )} +
)} @@ -655,7 +938,7 @@ export default function App() { invoke("get_status"), - setLibraryPath: (path: string) => invoke("set_library_path", { path }), updateCardData: () => invoke("update_card_data"), cancelUpdateCardData: () => invoke("cancel_update_card_data"), /** Adopt an already-built reference.db from `path` instead of downloading it. */ importReferenceDb: (path: string) => invoke("import_reference_db", { path }), + syncStatus: () => invoke("sync_status"), + /** Merge the library document with local edits and publish the result. + * Resolves with `null` when a sync was already running and this call stood + * down — expected, since sync also fires automatically. */ + syncNow: () => invoke("sync_now"), + /** Android: grant access to a folder through the system picker. */ + syncPickFolder: () => invoke("sync_pick_folder"), + /** Desktop: adopt a library document chosen with the file dialog. */ + syncSetDocument: (path: string) => invoke("sync_set_document", { path }), + syncForgetDocument: () => invoke("sync_forget_document"), + /** Convert a pre-document library.db into the current format. */ + convertLegacyLibrary: (path: string) => invoke("convert_legacy_library", { path }), searchCards: (query: string, limit = 20) => invoke("search_cards", { query, limit }), diff --git a/src/components.tsx b/src/components.tsx index 7c6e8bc..5befb66 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -8,7 +8,16 @@ import { useRef, useState, } from "react"; -import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api"; +import { + api, + CardRef, + COLORS, + LEGALITY_FORMATS, + LibraryFilter, + legalStatusFor, + maskToLetters, + PreviewCard, +} from "./api"; // --- Icons ------------------------------------------------------------ @@ -1386,3 +1395,296 @@ export function ColorToggles({ ); } + +// --- Shared card criteria ------------------------------------------------- + +/** Ringed planet — a card from another world/property (Universes Beyond). */ +const ICON_CROSSOVER = icon( + <> + + + , +); +/** Monitor — exists only on Arena/MTGO. */ +const ICON_SCREEN = icon( + <> + + + + , +); +/** Smiley — the joke sets. */ +const ICON_SMILEY = icon( + <> + + + + + , +); +/** Crown — can head a Commander deck. */ +const ICON_CROWN = icon( + <> + + + , +); + +/** + * Card-origin and numeric criteria shared by the Library and Card Search + * filter panels. Kept here rather than duplicated in both views, since they + * must stay in step: a filter that behaves differently between browsing what + * you own and searching everything would be a bug, not a feature. + * + * Numeric fields are strings so the inputs can be empty (meaning "no bound") + * rather than coercing a blank to 0. + */ +export type CardCriteria = { + excludeUb: boolean; + excludeDigital: boolean; + excludeFunny: boolean; + priceMin: string; + priceMax: string; + legalIn: string; + canBeCommander: boolean; + powerMin: string; + powerMax: string; + toughMin: string; + toughMax: string; +}; + +export const EMPTY_CRITERIA: CardCriteria = { + excludeUb: false, + excludeDigital: false, + excludeFunny: false, + priceMin: "", + priceMax: "", + legalIn: "", + canBeCommander: false, + powerMin: "", + powerMax: "", + toughMin: "", + toughMax: "", +}; + +export function useCardCriteria(key: string): [CardCriteria, (patch: Partial) => void] { + const [value, setValue] = usePersistedState(key, EMPTY_CRITERIA); + // Merged over the defaults so a stored object written before a field existed + // doesn't leave it undefined. + const merged = { ...EMPTY_CRITERIA, ...value }; + const patch = useCallback( + (p: Partial) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }), + [setValue, value], + ); + return [merged, patch]; +} + +const num = (s: string): number | null => (s.trim() === "" ? null : Number(s)); + +/** Fold criteria into the filter sent to the backend. */ +export function applyCardCriteria(c: CardCriteria): Partial { + return { + exclude_universes_beyond: c.excludeUb, + exclude_digital_only: c.excludeDigital, + exclude_funny: c.excludeFunny, + price_min: num(c.priceMin), + price_max: num(c.priceMax), + legal_in: c.legalIn || null, + can_be_commander: c.canBeCommander, + power_min: num(c.powerMin), + power_max: num(c.powerMax), + toughness_min: num(c.toughMin), + toughness_max: num(c.toughMax), + }; +} + +/** + * Whether these criteria amount to a search in their own right. + * + * The three "exclude" toggles deliberately don't count: they refine a result + * set rather than select one, so ticking "hide Un-sets" on its own shouldn't + * make Card Search pull every card in the database. Choosing a format or a + * power range is a real query, and does. + */ +export function criteriaActive(c: CardCriteria): boolean { + return ( + c.canBeCommander || + c.legalIn !== "" || + [c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some( + (v) => v.trim() !== "", + ) + ); +} + +function RangePair({ + label, + min, + max, + onMin, + onMax, + step, + prefix, +}: { + label: string; + min: string; + max: string; + onMin: (v: string) => void; + onMax: (v: string) => void; + step?: string; + prefix?: string; +}) { + return ( +
+ +
+ {prefix && {prefix}} + onMin(e.target.value)} + /> + – + {prefix && {prefix}} + onMax(e.target.value)} + /> +
+
+ ); +} + +export function CardCriteriaFields({ + value: c, + onChange, +}: { + value: CardCriteria; + onChange: (patch: Partial) => void; +}) { + return ( + <> +
+ + +
+ + onChange({ priceMin: v })} + onMax={(v) => onChange({ priceMax: v })} + /> + onChange({ powerMin: v })} + onMax={(v) => onChange({ powerMax: v })} + /> + onChange({ toughMin: v })} + onMax={(v) => onChange({ toughMax: v })} + /> + +
+ +
+ {/* Exclusions read as "no X" — tinted with the danger colour when + active, so removing things looks different from selecting them. */} + onChange({ excludeUb: !c.excludeUb })} + /> + onChange({ excludeDigital: !c.excludeDigital })} + /> + onChange({ excludeFunny: !c.excludeFunny })} + /> + + onChange({ canBeCommander: !c.canBeCommander })} + /> +
+
+ + ); +} + +/** + * A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when + * off, lit with a ring when on. `exclude` marks a filter that *removes* cards + * rather than selecting them, which is worth distinguishing visually since + * both live in the same row. + */ +function IconToggle({ + icon, + label, + hint, + on, + exclude, + onToggle, +}: { + icon: ReactNode; + label: string; + hint: string; + on: boolean; + exclude?: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/src/styles.css b/src/styles.css index de4864e..7ffde9f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -784,6 +784,28 @@ input[type="checkbox"] { gap: var(--space-3); max-width: 480px; } +/* The currently chosen synced folder. */ +.sync-folder { + font-size: var(--fs-body); + font-weight: 600; + color: var(--text-strong); + overflow-wrap: anywhere; +} +/* Outcome of the synced-folder self-test. Not a `.hint`: whether sync works is + the point of the section, so it reads at body size rather than as + de-emphasized supporting text. */ +.sync-ok, +.sync-bad { + font-size: var(--fs-body); + line-height: 1.45; + overflow-wrap: anywhere; +} +.sync-ok { + color: var(--success); +} +.sync-bad { + color: var(--danger); +} /* ============================================================ Typography helpers @@ -1695,6 +1717,65 @@ tbody tr:hover { font-weight: 600; color: var(--muted); } +/* Compact icon filter toggles, following .color-toggles' visual grammar: dim + when off, lit with an accent ring when on. Replaces a stack of labelled + checkboxes that ate most of the filter panel's height — the explanations + moved into tooltips. */ +.icon-toggles { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} +.icon-toggle { + position: relative; + width: 28px; + height: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: var(--bg-elev); + color: var(--text); + opacity: 0.4; + cursor: pointer; + transition: opacity 0.1s, box-shadow 0.1s, color 0.1s; +} +.icon-toggle:hover { + opacity: 0.75; +} +.icon-toggle.on { + opacity: 1; + box-shadow: 0 0 0 2px var(--accent-hover); +} +/* Removing cards should not look like selecting them. */ +.icon-toggle.exclude.on { + color: var(--danger); + box-shadow: 0 0 0 2px var(--danger); +} +/* Diagonal bar across an active exclusion, so "hidden" reads without the + tooltip. */ +.icon-toggle-slash { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + to bottom right, + transparent calc(50% - 1px), + currentColor calc(50% - 1px), + currentColor calc(50% + 1px), + transparent calc(50% + 1px) + ); +} +/* Separates the exclusions from the selecting filter beside them. */ +.icon-toggles-sep { + width: 1px; + align-self: stretch; + margin: 2px 3px; + background: var(--border-soft); +} + .location-input { padding: 4px 8px; font-size: 13px; diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx index 58a8ef5..9377c5d 100644 --- a/src/views/CardSearch.tsx +++ b/src/views/CardSearch.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api"; import { CardThumb, @@ -15,8 +15,12 @@ import { SortMode, TagInput, ViewToggle, + CardCriteriaFields, + applyCardCriteria, + criteriaActive, groupCards, sortCards, + useCardCriteria, usePersistedState, useViewMode, } from "../components"; @@ -50,6 +54,7 @@ export function CardSearch({ const [subtypes, setSubtypes] = useState([]); const [keywords, setKeywords] = useState([]); const [oracleText, setOracleText] = useState(""); + const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria"); // Collapsed by default (relevant on narrow/mobile screens only — see // .filters-more's CSS, which is always expanded above that breakpoint // regardless of this). @@ -62,6 +67,10 @@ export function CardSearch({ const [error, setError] = useState(null); const [busy, setBusy] = useState>(new Set()); const [refreshKey, setRefreshKey] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [bulkBusy, setBulkBusy] = useState(false); + const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false); + const selectAllRef = useRef(null); const [view, setView] = useViewMode("cardsearch-view"); const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none"); @@ -85,7 +94,8 @@ export function CardSearch({ types.length > 0 || subtypes.length > 0 || keywords.length > 0 || - oracleText.trim() !== ""; + oracleText.trim() !== "" || + criteriaActive(criteria); useEffect(() => { if (!hasActiveFilter) { @@ -103,6 +113,7 @@ export function CardSearch({ subtypes, keywords, oracle_text: oracleText.trim() || null, + ...applyCardCriteria(criteria), }; const t = setTimeout(() => { api @@ -128,9 +139,82 @@ export function CardSearch({ subtypes, keywords, oracleText, + criteria, refreshKey, ]); + // Drop selections that fall out of the result set when the filter changes, + // so a hidden card can't be silently added to a deck later. + useEffect(() => { + setSelected((s) => { + const visible = new Set(rows.map((r) => r.oracle_id)); + const next = new Set([...s].filter((id) => visible.has(id))); + return next.size === s.size ? s : next; + }); + }, [rows]); + + useEffect(() => { + if (selectAllRef.current) { + selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length; + } + }, [selected, rows]); + + function toggleOne(oracleId: string) { + setSelected((s) => { + const next = new Set(s); + if (next.has(oracleId)) next.delete(oracleId); + else next.add(oracleId); + return next; + }); + } + + function toggleSelectAll() { + setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id)))); + } + + /** + * Add every selected card to the deck chosen in the header, at one copy each. + * + * Mirrors the Library tab's bulk add, including that it *sets* the quantity + * rather than incrementing — so re-running it is idempotent — and offers a + * single undo restoring every card's prior deck entry at once. + */ + async function bulkAddToDeck() { + if (deckId === "") return; + const deck = decks.find((d) => d.id === deckId); + if (!deck) return; + setBulkBusy(true); + try { + const targets = rows.filter((r) => selected.has(r.oracle_id)); + const before = await api.deckContents(deck.id); + const prevByOracle = new Map(before.map((e) => [e.oracle_id, e])); + await Promise.all( + targets.map((r) => api.setDeckCard(deck.id, r.oracle_id, 1, false, bulkToMaybeboard)), + ); + setRefreshKey((k) => k + 1); + onOfferUndo( + `Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`, + async () => { + await Promise.all( + targets.map((r) => { + const prev = prevByOracle.get(r.oracle_id); + return api.setDeckCard( + deck.id, + r.oracle_id, + prev?.quantity ?? 0, + prev?.is_commander ?? false, + prev?.is_maybeboard ?? false, + ); + }), + ); + setRefreshKey((k) => k + 1); + }, + ); + } finally { + setBulkBusy(false); + } + } + async function adjust(oracleId: string, delta: number) { const qty = await api.addOwned(oracleId, delta); setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c))); @@ -176,6 +260,7 @@ export function CardSearch({ + @@ -189,6 +274,13 @@ export function CardSearch({ {cards.map((c) => ( + @@ -235,6 +327,14 @@ export function CardSearch({ toggleOne(c.oracle_id)} + /> + } corner={ + {selected.size > 0 && ( +
+ {selected.size} selected +
+ {/* Bound to the same deck as the header picker rather than a + separate one — two controls that disagreed about the target + deck would be worse than the redundancy. It's repeated here + because a disabled button whose reason lives elsewhere on the + page reads as broken. */} + + + + +
+
+ )} +
@@ -355,6 +510,7 @@ export function CardSearch({ placeholder="e.g. landfall" />
+
Colors Name
+ toggleOne(c.oracle_id)} + /> +