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

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