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

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