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

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