Rework library sync around a JSON document; add card filters

Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -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<i64> {
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<Connection> {
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<Connection> {

View File

@@ -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<Deck> {
// 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();

View File

@@ -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 <ownership predicate>` prefix.
fn push_card_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
fn push_card_filters(
sql: &mut String,
vals: &mut Vec<Value>,
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<Value> = 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<Value> = 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();

View File

@@ -456,6 +456,12 @@ struct ScryOracle {
/// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`.
#[serde(default)]
edhrec_rank: Option<i64>,
/// Printed power/toughness. Strings because Magic allows non-numeric
/// values ("*", "1+*", "X"); those parse to None and stay unfilterable.
#[serde(default)]
power: Option<String>,
#[serde(default)]
toughness: Option<String>,
}
#[derive(Deserialize)]
@@ -463,6 +469,10 @@ struct ScryFace {
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
power: Option<String>,
#[serde(default)]
toughness: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
#[serde(default)]
oracle_text: Option<String>,
@@ -470,6 +480,16 @@ struct ScryFace {
image_uris: Option<ImageUris>,
}
/// 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<f64> {
raw?.trim().parse::<f64>().ok()
}
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
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<Vec<PrintFace>>,
#[serde(default)]
prices: Option<ScryPrices>,
/// 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<String>,
/// 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<String>,
/// 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::<f64>().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.

View File

@@ -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`

View File

@@ -287,6 +287,40 @@ pub struct LibraryFilter {
pub location: Option<String>,
/// Case-insensitive substring match against the card's oracle rules text.
pub oracle_text: Option<String>,
/// 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<f64>,
pub price_max: Option<f64>,
/// 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<String>,
/// 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<f64>,
pub power_max: Option<f64>,
pub toughness_min: Option<f64>,
pub toughness_max: Option<f64>,
}
#[cfg(test)]

View File

@@ -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);

View File

@@ -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);

View File

@@ -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<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
#[serde(default)]
pub cards: BTreeMap<String, DeckCardEntry>,
}
/// 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<String, CardEntry>,
#[serde(default)]
pub decks: BTreeMap<String, DeckDoc>,
}
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<String> {
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<Self> {
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<String>,
}
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<V>(
base: &BTreeMap<String, V>,
ours: &BTreeMap<String, V>,
theirs: &BTreeMap<String, V>,
) -> BTreeSet<String> {
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<T: Clone + PartialEq>(
base: Option<&T>,
ours: Option<&T>,
theirs: Option<&T>,
on_conflict: &mut dyn FnMut(),
) -> Option<T> {
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<T: Clone + PartialEq>(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<LibraryDoc> {
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<i64, String> = 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<Item = &'a String>) -> Result<String> {
Ok(serde_json::to_string(&keys.collect::<Vec<_>>())?)
}
/// 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<LibraryDoc> {
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<i64> {
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);
}
}

View File

@@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Note: library sync needs no storage permission. The user grants access
to one folder through the system folder picker (SAF), and that grant is
persisted across restarts — see SyncPlugin.kt. -->
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@@ -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}")
}
}
}

View File

@@ -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<Status> {
Ok(status)
}
#[tauri::command]
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
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]

View File

@@ -1,5 +1,6 @@
mod commands;
mod state;
mod sync;
use std::sync::Mutex;
@@ -31,10 +32,27 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
result
}
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
/// goes through a Java plugin rather than plain `std::fs`.
#[cfg(target_os = "android")]
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
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,

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
}
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::<PersistedConfig>(&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<PathBuf>,
}
let legacy = serde_json::from_str::<OldConfig>(&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<Catalog>,
pub index: Option<NameIndex>,
/// Set while `update_card_data` is running; `cancel_update_card_data`
/// flips it to abort the in-progress ingest.
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
/// 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<PathBuf>) {
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);

744
src-tauri/src/sync.rs Normal file
View File

@@ -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<AppState>>;
/// 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<String>,
/// 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<String>,
}
#[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<tauri::Wry>);
#[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<String>,
}
#[derive(Deserialize, Default)]
pub struct FolderStatus {
#[serde(default)]
pub chosen: bool,
#[serde(default)]
pub granted: bool,
#[serde(default)]
pub label: Option<String>,
}
fn call<T: serde::de::DeserializeOwned>(
app: &AppHandle,
command: &str,
payload: impl Serialize,
) -> Result<T, String> {
let bridge = app
.try_state::<SyncBridge>()
.ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?;
bridge
.0
.run_mobile_plugin::<T>(command, payload)
.map_err(|e| e.to_string())
}
pub fn status(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "status", ())
}
pub fn pick_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "pickFolder", ())
}
pub fn forget_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "forgetFolder", ())
}
pub fn list(app: &AppHandle) -> Result<Vec<String>, String> {
Ok(call::<ListResult>(app, "list", ())?.names)
}
pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result<bool, String> {
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<Vec<String>, 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<Option<String>, 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<Vec<String>, 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<Option<String>, 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<String> {
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<String>) -> Vec<String> {
let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name);
let mut found: Vec<String> = 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<Location, String> {
#[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<SyncStatus, String> {
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<SyncStatus, String> {
#[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<SyncStatus, String> {
#[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<SyncStatus, String> {
#[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<Option<SyncReport>, 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<SyncReport, String> {
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, &copy);
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<SyncReport, String> {
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 "<name>.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"]);
}
}

View File

@@ -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<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<IngestProgress | null>(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<SyncStatus | null>(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<IngestProgress | null>(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 (
<HoverPreviewProvider>
<div className="app">
@@ -468,7 +670,9 @@ export default function App() {
</div>
<div className="nav-items">
{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) => (
<button
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
@@ -491,16 +695,15 @@ export default function App() {
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
</span>
{/* Choosing where library.db lives is desktop-only: on Android the
live db must stay in app-private storage (SQLite can't open a
content:// URI), so these would only ever fail there. */}
{/* Desktop-only: Android grants a folder through the system picker
instead of naming a path, so these live in the Info tab there. */}
{status && !status.is_mobile && (
<>
<button onClick={openLibrary} disabled={busy}>
<button onClick={pickLibrary} disabled={busy || syncBusy}>
{ICON_OPEN_LIBRARY}
Open Library
</button>
<button onClick={newLibrary} disabled={busy}>
<button onClick={newLibrary} disabled={busy || syncBusy}>
{ICON_NEW_LIBRARY}
New Library
</button>
@@ -599,11 +802,12 @@ export default function App() {
) : tab === "decks" ? (
<DeckView refreshSignal={deckRefreshSignal} />
) : (
// 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.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
@@ -620,6 +824,85 @@ export default function App() {
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<h2 style={{ margin: 0 }}>Your library</h2>
{sync === null ? (
<div className="hint">Checking…</div>
) : (
<>
<div className="hint">
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.
</div>
{sync.label && (
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.label}
</div>
)}
{sync.chosen && !sync.granted && (
<div className="sync-bad">
✕{" "}
{sync.picker_based
? "Access to that folder was lost — choose it again."
: "That folder isn't there right now — is the drive mounted?"}
</div>
)}
{syncTest && (
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
{syncTest.ok ? "✓ " : "✕ "}
{syncTest.message}
</div>
)}
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
{sync.granted && (
<button className="primary" onClick={syncNow} disabled={syncBusy}>
{syncBusy ? "Syncing…" : "Sync now"}
</button>
)}
<button
className={sync.granted ? "" : "primary"}
onClick={pickLibrary}
disabled={syncBusy}
>
{sync.chosen
? sync.picker_based
? "Change folder…"
: "Change file…"
: sync.picker_based
? "Choose folder…"
: "Choose file…"}
</button>
{sync.chosen && (
<button onClick={forgetLibrary} disabled={syncBusy}>
Forget
</button>
)}
</div>
{/* Only shown while an old library.db is still waiting; the
offer disappears for good once it's been converted. */}
{sync.legacy_library && (
<>
<div className="hint">
An older library from a previous version is still on this device. Convert
it to bring your collection across.
</div>
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.legacy_library}
</div>
<div className="row">
<button
className="primary"
onClick={() => convertLegacy(sync.legacy_library!)}
disabled={syncBusy}
>
Convert old library
</button>
</div>
</>
)}
</>
)}
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
)}
@@ -655,7 +938,7 @@ export default function App() {
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onOpenLibrary={pickLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}

View File

@@ -94,6 +94,28 @@ export interface LibraryFilter {
in_deck?: number | null;
location?: string | null;
oracle_text?: string | null;
/** 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. Needs card data downloaded after this shipped; inert otherwise. */
exclude_universes_beyond?: boolean;
/** Hide Arena/MTGO-only cards (Alchemy rebalances) — they can't be owned on
* paper. Needs card data downloaded after this shipped; inert otherwise. */
exclude_digital_only?: boolean;
/** Hide Un-set cards (Unglued, Unhinged, Unstable, Unfinity). */
exclude_funny?: boolean;
/** Cheapest known printing in USD; cards with no known price count as 0. */
price_min?: number | null;
price_max?: number | null;
/** Format slug ("commander", "modern", …). Legal and restricted match. */
legal_in?: string | null;
/** Only cards that can head a Commander deck. */
can_be_commander?: boolean;
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
* since those can't be compared numerically. */
power_min?: number | null;
power_max?: number | null;
toughness_min?: number | null;
toughness_max?: number | null;
}
export interface Status {
@@ -108,6 +130,40 @@ export interface Status {
is_mobile: boolean;
}
/** State of the user's library document — the JSON file they keep in whatever
* folder they sync. The SQLite database behind it is internal and never shown. */
export interface SyncStatus {
/** Whether a document has been chosen. */
chosen: boolean;
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
* can be revoked and a desktop folder can be deleted or unmounted — both need
* re-picking rather than first-time setup. */
granted: boolean;
label: string | null;
/** True where the folder is chosen through the system picker (Android) rather
* than by path — the one thing the UI branches on. */
picker_based: boolean;
/** A pre-document library.db from an older build, waiting to be converted. */
legacy_library: string | null;
}
/** What one sync did. */
export interface SyncReport {
/** Whether the merge altered the local library. */
changed: boolean;
/** Whether the document was rewritten (skipped when we had nothing to add). */
published: boolean;
/** Conflict copies absorbed and cleaned up. */
absorbed: number;
report: {
cards_changed: number;
decks_changed: number;
decks_removed: number;
/** Fields both sides changed differently, described in user terms. */
conflicts: string[];
};
}
export interface LibraryStats {
unique_cards: number;
total_copies: number;
@@ -181,11 +237,22 @@ export function legalStatusFor(legalities: number, formatLabel: string): LegalSt
export const api = {
getStatus: () => invoke<Status>("get_status"),
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
updateCardData: () => invoke<Status>("update_card_data"),
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
/** Adopt an already-built reference.db from `path` instead of downloading it. */
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
syncStatus: () => invoke<SyncStatus>("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<SyncReport | null>("sync_now"),
/** Android: grant access to a folder through the system picker. */
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
/** Desktop: adopt a library document chosen with the file dialog. */
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
/** Convert a pre-document library.db into the current format. */
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
searchCards: (query: string, limit = 20) =>
invoke<CardRef[]>("search_cards", { query, limit }),

View File

@@ -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({
</span>
);
}
// --- Shared card criteria -------------------------------------------------
/** Ringed planet — a card from another world/property (Universes Beyond). */
const ICON_CROSSOVER = icon(
<>
<circle cx="12" cy="11" r="6" />
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
</>,
);
/** Monitor — exists only on Arena/MTGO. */
const ICON_SCREEN = icon(
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6" />
<path d="M12 16v4" />
</>,
);
/** Smiley — the joke sets. */
const ICON_SMILEY = icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
</>,
);
/** Crown — can head a Commander deck. */
const ICON_CROWN = icon(
<>
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
);
/**
* 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<CardCriteria>) => void] {
const [value, setValue] = usePersistedState<CardCriteria>(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<CardCriteria>) => 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<LibraryFilter> {
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 (
<div className="field">
<label>{label}</label>
<div className="row">
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="min"
value={min}
onChange={(e) => onMin(e.target.value)}
/>
<span className="muted">–</span>
{prefix && <span className="muted">{prefix}</span>}
<input
type="number"
step={step}
style={{ width: 70 }}
placeholder="max"
value={max}
onChange={(e) => onMax(e.target.value)}
/>
</div>
</div>
);
}
export function CardCriteriaFields({
value: c,
onChange,
}: {
value: CardCriteria;
onChange: (patch: Partial<CardCriteria>) => void;
}) {
return (
<>
<div className="field">
<label>Legal in</label>
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
<option value="">Any format</option>
{LEGALITY_FORMATS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
</div>
<RangePair
label="Price (USD)"
prefix="$"
step="0.01"
min={c.priceMin}
max={c.priceMax}
onMin={(v) => onChange({ priceMin: v })}
onMax={(v) => onChange({ priceMax: v })}
/>
<RangePair
label="Power"
min={c.powerMin}
max={c.powerMax}
onMin={(v) => onChange({ powerMin: v })}
onMax={(v) => onChange({ powerMax: v })}
/>
<RangePair
label="Toughness"
min={c.toughMin}
max={c.toughMax}
onMin={(v) => onChange({ toughMin: v })}
onMax={(v) => onChange({ toughMax: v })}
/>
<div className="field">
<label>Quick filters</label>
<div className="icon-toggles">
{/* Exclusions read as "no X" — tinted with the danger colour when
active, so removing things looks different from selecting them. */}
<IconToggle
icon={ICON_CROSSOVER}
label="Hide Universes Beyond"
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
exclude
on={c.excludeUb}
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
/>
<IconToggle
icon={ICON_SCREEN}
label="Hide digital-only"
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
exclude
on={c.excludeDigital}
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
/>
<IconToggle
icon={ICON_SMILEY}
label="Hide Un-set cards"
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
exclude
on={c.excludeFunny}
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
/>
<span className="icon-toggles-sep" />
<IconToggle
icon={ICON_CROWN}
label="Only cards that can be a commander"
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
on={c.canBeCommander}
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
/>
</div>
</div>
</>
);
}
/**
* 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 (
<button
type="button"
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
// Title carries the full explanation the old inline hint text did — the
// whole point of this row is to stop that prose eating the panel.
title={`${label}\n\n${hint}`}
aria-label={label}
aria-pressed={on}
onClick={onToggle}
>
{icon}
{/* A slash over the glyph when an exclusion is active, so "hidden" is
legible without reading the tooltip. */}
{exclude && on && <span className="icon-toggle-slash" />}
</button>
);
}

View File

@@ -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;

View File

@@ -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<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
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<string | null>(null);
const [busy, setBusy] = useState<Set<string>>(new Set());
const [refreshKey, setRefreshKey] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const [view, setView] = useViewMode("cardsearch-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("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({
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
@@ -189,6 +274,13 @@ export function CardSearch({
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td className="cell-check">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} />
</td>
@@ -235,6 +327,14 @@ export function CardSearch({
<CardTile
key={c.oracle_id}
card={c}
selected={selected.has(c.oracle_id)}
cornerLeft={
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
}
corner={
<QtyStepper
overlay
@@ -290,6 +390,61 @@ export function CardSearch({
</div>
</div>
{selected.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selected.size} selected</span>
<div className="bulk-bar-actions">
{/* 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. */}
<select
value={deckId}
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
disabled={bulkBusy}
>
<option value="">Choose a deck…</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<button
className="primary"
onClick={bulkAddToDeck}
disabled={bulkBusy || deckId === ""}
title={
deckId === ""
? "Choose a deck first"
: "Set each selected card to 1 copy in the deck"
}
>
Add to deck
</button>
<label
className="row"
style={{ gap: 4, cursor: "pointer" }}
title="Send to the maybeboard instead of the mainboard"
>
<input
type="checkbox"
checked={bulkToMaybeboard}
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
disabled={bulkBusy}
/>
<span className="muted" style={{ fontSize: 12 }}>
Maybeboard
</span>
</label>
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
</div>
)}
<div className="panel workspace">
<div className="filters">
<div className="field">
@@ -355,6 +510,7 @@ export function CardSearch({
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
</div>
</div>
<button
@@ -365,6 +521,17 @@ export function CardSearch({
</button>
<div className="workspace-bar">
{rows.length > 0 && (
<label className="workspace-bar-check" title="Select all matching cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
/>
Select all
</label>
)}
{hasActiveFilter && (
<span className="workspace-bar-count">
{rows.length} result{rows.length === 1 ? "" : "s"}

View File

@@ -17,8 +17,11 @@ import {
SortMode,
TagInput,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
groupCards,
sortCards,
useCardCriteria,
usePersistedState,
useViewMode,
} from "../components";
@@ -54,6 +57,7 @@ export function LibraryBrowser({
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("library-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).
@@ -123,6 +127,7 @@ export function LibraryBrowser({
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
@@ -140,6 +145,7 @@ export function LibraryBrowser({
inDeck,
location,
oracleText,
criteria,
refreshKey,
]);
@@ -607,6 +613,7 @@ export function LibraryBrowser({
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
</div>
</div>
<button