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

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