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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user