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,7 +1,7 @@
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
import {
ColorDistributionBar,
HoverPreviewProvider,
@@ -218,12 +218,25 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
/** How often an open, visible window re-syncs. Long enough not to churn the
* synced folder, short enough that a second device sees edits without being
* restarted. Launch and foreground/background transitions sync too. */
const AUTO_SYNC_MS = 3 * 60 * 1000;
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
// Synced-folder grant for library sync. Only fetched when the Info tab is
// open: the grant can be revoked from system settings while the app is
// backgrounded, so a value cached at startup would be stale exactly when the
// user comes back to check it.
const [sync, setSync] = useState<SyncStatus | null>(null);
const [syncBusy, setSyncBusy] = useState(false);
// Result of the last round-trip self-test, shown until the next action.
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
const lastProgress = useRef<IngestProgress | null>(null);
// Set by the ingest-progress listener below; reset before each
// updateCardData() call so it can detect a stalled invoke — see there.
@@ -328,6 +341,13 @@ export default function App() {
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
// across renders (re-creating them would re-arm the listeners below and fire
// a fresh sync every time card data finished loading).
const readyRef = useRef(ready);
useEffect(() => {
readyRef.current = ready;
}, [ready]);
// Keep the sidebar's library summary fresh: fetch once card data is ready,
// then poll (cards/decks can change from any tab without a shared event bus).
@@ -405,16 +425,215 @@ export default function App() {
await api.cancelUpdateCardData();
}
async function switchLibrary(path: string) {
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
* a normal state, reported in the panel rather than as an error. */
const refreshSync = useCallback(async () => {
setSyncBusy(true);
try {
setStatus(await api.setLibraryPath(path));
setSync(await api.syncStatus());
} catch (err) {
setProgress({
stage: "error",
message: `Couldn't open that library: ${String(err)}`,
overall_percent: 0,
stages: lastProgress.current?.stages ?? [],
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}, []);
// Once at startup: the automatic sync below needs to know whether a folder is
// configured at all.
useEffect(() => {
if (statusLoaded) refreshSync();
}, [statusLoaded, refreshSync]);
// And whenever the Info tab is opened, which is also when the user gets back
// from the folder picker, and when a revoked grant would first matter.
useEffect(() => {
if (tab === "info") refreshSync();
}, [tab, refreshSync]);
/** Choose where the library lives. Android grants a folder through the system
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
async function pickLibrary() {
setSyncTest(null);
try {
if (sync?.picker_based) {
setSyncBusy(true);
setSync(await api.syncPickFolder());
return;
}
const path = await open({
title: "Choose your library file",
// Start where the library currently lives, so the dialog opens
// somewhere useful rather than at an arbitrary default.
defaultPath: sync?.label ?? undefined,
filters: [
{ name: "mtgc library", extensions: ["json"] },
{ name: "All files", extensions: ["*"] },
],
});
if (typeof path !== "string") return;
setSyncBusy(true);
setSync(await api.syncSetDocument(path));
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Desktop: start a new library file, e.g. inside a synced folder. */
async function newLibrary() {
setSyncTest(null);
try {
const path = await save({
title: "Save your library here (put it in a synced folder)",
defaultPath: "mtgc-library.json",
filters: [{ name: "mtgc library", extensions: ["json"] }],
});
if (typeof path !== "string") return;
setSyncBusy(true);
// Pointing at a file that doesn't exist yet moves the current library
// there rather than starting empty — see sync_set_document.
setSync(await api.syncSetDocument(path));
setSyncTest({ ok: true, message: `Your library is now kept at ${path}.` });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
async function forgetLibrary() {
setSyncBusy(true);
setSyncTest(null);
try {
setSync(await api.syncForgetDocument());
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Convert a library.db from an older build into the current format. */
async function convertLegacy(path: string) {
setSyncBusy(true);
setSyncTest(null);
try {
const r = await api.convertLegacyLibrary(path);
const n = r.report.cards_changed + r.report.decks_changed;
const status = await api.syncStatus();
setSync(status);
// Say where it landed — otherwise a successful conversion looks like
// nothing happened.
setSyncTest({
ok: true,
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
});
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setDeckRefreshSignal((v) => v + 1);
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Merge the library document with local edits and publish the result.
*
* Shared by the Info tab's button and the automatic triggers below. Returns
* the report so the caller can decide whether to say anything; throws only
* what the backend threw. */
const runSync = useCallback(async () => {
const report = await api.syncNow();
if (!report) return null; // Another sync had it in hand.
const changed = report.changed;
if (changed) {
// Library and deck views hold fetched copies; a merge changes rows
// underneath them, so pull them back into line rather than waiting for
// the summary's 5s poll. Needs card data loaded, hence the guard.
if (readyRef.current) {
api.getLibraryStats().then(setLibStats).catch(() => {});
}
setDeckRefreshSignal((v) => v + 1);
}
return { report, changed };
}, []);
// Automatic sync. Runs once the folder is usable, then on every trip to or
// from the background: leaving publishes what changed here, returning picks
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
// the folder's status doesn't re-arm the listeners and resync.
const syncGranted = sync?.granted ?? false;
useEffect(() => {
if (!syncGranted) return;
let cancelled = false;
// Failures here are deliberately silent: the folder can be temporarily
// unavailable (drive unmounted, grant revoked), and nagging on every app
// switch would be worse than the Info tab reporting it on demand. Nothing
// is lost by a skipped run — the merge base only advances to a document we
// actually read, so a lost publish is simply re-detected next time.
const run = () => {
if (cancelled) return;
runSync().catch(() => {});
};
run();
document.addEventListener("visibilitychange", run);
// Visibility changes alone would strand a desktop session that simply stays
// open: edits made after launch would never reach the other devices. Poll
// while visible so an always-open window still publishes. Skipped while
// hidden — the transition back handles that case.
const timer = setInterval(() => {
if (!document.hidden) run();
}, AUTO_SYNC_MS);
return () => {
cancelled = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", run);
};
}, [syncGranted, runSync]);
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
async function syncNow() {
setSyncBusy(true);
setSyncTest(null);
try {
const result = await runSync();
if (!result) {
setSyncTest({ ok: true, message: "A sync was already running." });
return;
}
const { report, changed } = result;
const parts: string[] = [];
if (changed) {
const r = report.report;
const bits = [
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
r.decks_removed && `${r.decks_removed} removed`,
].filter(Boolean);
parts.push(`Merged ${bits.join(", ")}.`);
} else {
parts.push(report.published ? "Published your changes." : "Already up to date.");
}
if (report.absorbed > 0) {
parts.push(
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
);
}
// Conflicts are the one thing worth spelling out — the user may want to
// correct a value that lost.
if (report.report.conflicts.length > 0) {
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
}
setSyncTest({ ok: true, message: parts.join(" ") });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
@@ -439,23 +658,6 @@ export default function App() {
}
}
async function openLibrary() {
const path = await open({
title: "Open an existing library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") await switchLibrary(path);
}
async function newLibrary() {
const path = await save({
title: "Create a new library.db (put it in a synced folder)",
defaultPath: "library.db",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") await switchLibrary(path);
}
return (
<HoverPreviewProvider>
<div className="app">
@@ -468,7 +670,9 @@ export default function App() {
</div>
<div className="nav-items">
{NAV.map((n) => (
{/* Info carries library sync, which desktop needs too — it's no
longer the mobile-only card-data page it started as. */}
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
@@ -491,16 +695,15 @@ export default function App() {
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
</span>
{/* Choosing where library.db lives is desktop-only: on Android the
live db must stay in app-private storage (SQLite can't open a
content:// URI), so these would only ever fail there. */}
{/* Desktop-only: Android grants a folder through the system picker
instead of naming a path, so these live in the Info tab there. */}
{status && !status.is_mobile && (
<>
<button onClick={openLibrary} disabled={busy}>
<button onClick={pickLibrary} disabled={busy || syncBusy}>
{ICON_OPEN_LIBRARY}
Open Library
</button>
<button onClick={newLibrary} disabled={busy}>
<button onClick={newLibrary} disabled={busy || syncBusy}>
{ICON_NEW_LIBRARY}
New Library
</button>
@@ -599,11 +802,12 @@ export default function App() {
) : tab === "decks" ? (
<DeckView refreshSignal={deckRefreshSignal} />
) : (
// Mobile-only tab (see INFO_TAB) — the desktop equivalent is
// split between the topbar's Update card data button (hidden
// here on mobile) and the nav rail's footer summary (which
// has nowhere to live once the rail collapses to the bottom
// nav), so both land here together.
// The per-install status page: card data, library sync, and the
// collection summary. On mobile it's the only home for the
// card-data buttons (no topbar room) and the summary (the nav
// rail it normally sits in is replaced by the bottom nav);
// on desktop those also appear in the topbar and rail, but sync
// lives only here.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
@@ -620,6 +824,85 @@ export default function App() {
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<h2 style={{ margin: 0 }}>Your library</h2>
{sync === null ? (
<div className="hint">Checking…</div>
) : (
<>
<div className="hint">
Your collection is kept in a single file. Put it in a folder you sync and
every device stays in step — edits made in two places are combined, not
overwritten.
</div>
{sync.label && (
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.label}
</div>
)}
{sync.chosen && !sync.granted && (
<div className="sync-bad">
✕{" "}
{sync.picker_based
? "Access to that folder was lost — choose it again."
: "That folder isn't there right now — is the drive mounted?"}
</div>
)}
{syncTest && (
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
{syncTest.ok ? "✓ " : "✕ "}
{syncTest.message}
</div>
)}
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
{sync.granted && (
<button className="primary" onClick={syncNow} disabled={syncBusy}>
{syncBusy ? "Syncing…" : "Sync now"}
</button>
)}
<button
className={sync.granted ? "" : "primary"}
onClick={pickLibrary}
disabled={syncBusy}
>
{sync.chosen
? sync.picker_based
? "Change folder…"
: "Change file…"
: sync.picker_based
? "Choose folder…"
: "Choose file…"}
</button>
{sync.chosen && (
<button onClick={forgetLibrary} disabled={syncBusy}>
Forget
</button>
)}
</div>
{/* Only shown while an old library.db is still waiting; the
offer disappears for good once it's been converted. */}
{sync.legacy_library && (
<>
<div className="hint">
An older library from a previous version is still on this device. Convert
it to bring your collection across.
</div>
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.legacy_library}
</div>
<div className="row">
<button
className="primary"
onClick={() => convertLegacy(sync.legacy_library!)}
disabled={syncBusy}
>
Convert old library
</button>
</div>
</>
)}
</>
)}
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
)}
@@ -655,7 +938,7 @@ export default function App() {
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={openLibrary}
onOpenLibrary={pickLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}