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

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
import {
CardThumb,
@@ -15,8 +15,12 @@ import {
SortMode,
TagInput,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
criteriaActive,
groupCards,
sortCards,
useCardCriteria,
usePersistedState,
useViewMode,
} from "../components";
@@ -50,6 +54,7 @@ export function CardSearch({
const [subtypes, setSubtypes] = useState<string[]>([]);
const [keywords, setKeywords] = useState<string[]>([]);
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
@@ -62,6 +67,10 @@ export function CardSearch({
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<Set<string>>(new Set());
const [refreshKey, setRefreshKey] = useState(0);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
const selectAllRef = useRef<HTMLInputElement>(null);
const [view, setView] = useViewMode("cardsearch-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
@@ -85,7 +94,8 @@ export function CardSearch({
types.length > 0 ||
subtypes.length > 0 ||
keywords.length > 0 ||
oracleText.trim() !== "";
oracleText.trim() !== "" ||
criteriaActive(criteria);
useEffect(() => {
if (!hasActiveFilter) {
@@ -103,6 +113,7 @@ export function CardSearch({
subtypes,
keywords,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api
@@ -128,9 +139,82 @@ export function CardSearch({
subtypes,
keywords,
oracleText,
criteria,
refreshKey,
]);
// Drop selections that fall out of the result set when the filter changes,
// so a hidden card can't be silently added to a deck later.
useEffect(() => {
setSelected((s) => {
const visible = new Set(rows.map((r) => r.oracle_id));
const next = new Set([...s].filter((id) => visible.has(id)));
return next.size === s.size ? s : next;
});
}, [rows]);
useEffect(() => {
if (selectAllRef.current) {
selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length;
}
}, [selected, rows]);
function toggleOne(oracleId: string) {
setSelected((s) => {
const next = new Set(s);
if (next.has(oracleId)) next.delete(oracleId);
else next.add(oracleId);
return next;
});
}
function toggleSelectAll() {
setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id))));
}
/**
* Add every selected card to the deck chosen in the header, at one copy each.
*
* Mirrors the Library tab's bulk add, including that it *sets* the quantity
* rather than incrementing — so re-running it is idempotent — and offers a
* single undo restoring every card's prior deck entry at once.
*/
async function bulkAddToDeck() {
if (deckId === "") return;
const deck = decks.find((d) => d.id === deckId);
if (!deck) return;
setBulkBusy(true);
try {
const targets = rows.filter((r) => selected.has(r.oracle_id));
const before = await api.deckContents(deck.id);
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
await Promise.all(
targets.map((r) => api.setDeckCard(deck.id, r.oracle_id, 1, false, bulkToMaybeboard)),
);
setRefreshKey((k) => k + 1);
onOfferUndo(
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
async () => {
await Promise.all(
targets.map((r) => {
const prev = prevByOracle.get(r.oracle_id);
return api.setDeckCard(
deck.id,
r.oracle_id,
prev?.quantity ?? 0,
prev?.is_commander ?? false,
prev?.is_maybeboard ?? false,
);
}),
);
setRefreshKey((k) => k + 1);
},
);
} finally {
setBulkBusy(false);
}
}
async function adjust(oracleId: string, delta: number) {
const qty = await api.addOwned(oracleId, delta);
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
@@ -176,6 +260,7 @@ export function CardSearch({
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>Colors</th>
<th>Name</th>
@@ -189,6 +274,13 @@ export function CardSearch({
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td className="cell-check">
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} />
</td>
@@ -235,6 +327,14 @@ export function CardSearch({
<CardTile
key={c.oracle_id}
card={c}
selected={selected.has(c.oracle_id)}
cornerLeft={
<input
type="checkbox"
checked={selected.has(c.oracle_id)}
onChange={() => toggleOne(c.oracle_id)}
/>
}
corner={
<QtyStepper
overlay
@@ -290,6 +390,61 @@ export function CardSearch({
</div>
</div>
{selected.size > 0 && (
<div className="bulk-bar">
<span className="bulk-bar-count">{selected.size} selected</span>
<div className="bulk-bar-actions">
{/* Bound to the same deck as the header picker rather than a
separate one — two controls that disagreed about the target
deck would be worse than the redundancy. It's repeated here
because a disabled button whose reason lives elsewhere on the
page reads as broken. */}
<select
value={deckId}
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
disabled={bulkBusy}
>
<option value="">Choose a deck…</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
<button
className="primary"
onClick={bulkAddToDeck}
disabled={bulkBusy || deckId === ""}
title={
deckId === ""
? "Choose a deck first"
: "Set each selected card to 1 copy in the deck"
}
>
Add to deck
</button>
<label
className="row"
style={{ gap: 4, cursor: "pointer" }}
title="Send to the maybeboard instead of the mainboard"
>
<input
type="checkbox"
checked={bulkToMaybeboard}
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
disabled={bulkBusy}
/>
<span className="muted" style={{ fontSize: 12 }}>
Maybeboard
</span>
</label>
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
Clear
</button>
</div>
</div>
)}
<div className="panel workspace">
<div className="filters">
<div className="field">
@@ -355,6 +510,7 @@ export function CardSearch({
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
</div>
</div>
<button
@@ -365,6 +521,17 @@ export function CardSearch({
</button>
<div className="workspace-bar">
{rows.length > 0 && (
<label className="workspace-bar-check" title="Select all matching cards">
<input
ref={selectAllRef}
type="checkbox"
checked={rows.length > 0 && selected.size === rows.length}
onChange={toggleSelectAll}
/>
Select all
</label>
)}
{hasActiveFilter && (
<span className="workspace-bar-count">
{rows.length} result{rows.length === 1 ? "" : "s"}

View File

@@ -17,8 +17,11 @@ import {
SortMode,
TagInput,
ViewToggle,
CardCriteriaFields,
applyCardCriteria,
groupCards,
sortCards,
useCardCriteria,
usePersistedState,
useViewMode,
} from "../components";
@@ -54,6 +57,7 @@ export function LibraryBrowser({
const [inDeck, setInDeck] = useState<number | "">("");
const [location, setLocation] = useState("");
const [oracleText, setOracleText] = useState("");
const [criteria, patchCriteria] = useCardCriteria("library-criteria");
// Collapsed by default (relevant on narrow/mobile screens only — see
// .filters-more's CSS, which is always expanded above that breakpoint
// regardless of this).
@@ -123,6 +127,7 @@ export function LibraryBrowser({
in_deck: inDeck === "" ? null : Number(inDeck),
location: location.trim() || null,
oracle_text: oracleText.trim() || null,
...applyCardCriteria(criteria),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
@@ -140,6 +145,7 @@ export function LibraryBrowser({
inDeck,
location,
oracleText,
criteria,
refreshKey,
]);
@@ -607,6 +613,7 @@ export function LibraryBrowser({
placeholder="e.g. landfall"
/>
</div>
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
</div>
</div>
<button