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