Files
mtgc/src/App.tsx

1069 lines
42 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
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.
2026-08-07 22:03:49 -05:00
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
import {
ColorDistributionBar,
HoverPreviewProvider,
IconButton,
Icons,
StatTile,
UndoBar,
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { CardSearch } from "./views/CardSearch";
import { DeckView } from "./views/DeckView";
Add a News tab: set releases and links to Wizards' articles The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
import { NewsView } from "./views/NewsView";
Add a News tab: set releases and links to Wizards' articles The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
type Tab = "add" | "library" | "search" | "decks" | "news" | "info";
const svg = (children: ReactNode, size = 20) => (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const ICON_OPEN_LIBRARY = svg(
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
16,
);
const ICON_NEW_LIBRARY = svg(
<>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</>,
16,
);
const ICON_DOWNLOAD = svg(
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>,
16,
);
/**
* mtgc's logomark: an original pirate-ship silhouette in the terracotta brand
* color (a nod to the MTG card "Pirate Ship", not a reproduction of its art).
* `gradientId` must be unique per instance rendered on the page at once.
*/
function ShipLogo({ size = 24, gradientId }: { size?: number; gradientId: string }) {
return (
<svg viewBox="0 0 200 200" width={size} height={size}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="var(--accent-hover)" />
<stop offset="1" stopColor="var(--accent-active)" />
</linearGradient>
</defs>
<g fill={`url(#${gradientId})`}>
<path d="M21,137 L179,137 L159,176 Q100,193 41,176 Z" />
<rect x="21" y="133" width="158" height="6" rx="3" />
<rect x="95" y="7" width="10" height="127" />
<path d="M100,32 C73,40 56,65 59,94 C60,103 71,110 87,113 L100,113 Z" />
<path d="M100,14 C147,26 168,73 160,114 C158,127 140,137 119,140 L100,140 Z" />
<path d="M100,7 L126,13 L109,18 L126,23 L100,30 Z" />
</g>
</svg>
);
}
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
{
id: "library",
label: "Library",
icon: svg(
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</>,
),
},
{
id: "search",
label: "Card Search",
icon: svg(
<>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>,
),
},
{
id: "decks",
label: "Decks",
icon: svg(
<>
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
</>,
),
},
Add a News tab: set releases and links to Wizards' articles The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
{
id: "news",
label: "News",
icon: svg(
<>
<path d="M4 5h11a1 1 0 0 1 1 1v13H5a1 1 0 0 1-1-1z" />
<path d="M16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-2 0V9" />
<line x1="7" y1="9" x2="13" y2="9" />
<line x1="7" y1="13" x2="13" y2="13" />
</>,
),
},
];
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
* on desktop that lives in the topbar instead, so this isn't in `NAV`. */
const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
id: "info",
label: "Info",
icon: svg(
<>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</>,
),
};
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
if (diffSec < 30) return "just now";
const mins = Math.round(diffSec / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.round(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.round(months / 12)}y ago`;
}
/** "Your library" summary: unique/copy/deck counts, color distribution, and
* the reference-db freshness stamp. Shared by the desktop nav rail's footer
* and the mobile Info tab, which otherwise have no equivalent place for it. */
function LibrarySummary({
ready,
libStats,
status,
}: {
ready: boolean;
libStats: LibraryStats | null;
status: Status | null;
}) {
if (!ready) {
return (
<div className="nav-foot-simple">
<span className="status-dot off" />
No card data
</div>
);
}
return (
<div className="lib-stats">
<div className="lib-stats-head">
<span className="status-dot on" />
<span className="lib-stats-title">Your library</span>
</div>
{!libStats ? (
<div className="lib-stats-loading">Loading…</div>
) : (
<>
<div className="stat-tiles-row">
<StatTile value={libStats.unique_cards.toLocaleString()} label="unique" />
<StatTile value={libStats.total_copies.toLocaleString()} label="copies" />
<StatTile value={libStats.deck_count.toLocaleString()} label="decks" />
</div>
<ColorDistributionBar
colorCounts={libStats.color_counts}
colorlessCount={libStats.colorless_count}
/>
</>
)}
<div className="lib-stats-sub">
<div>{status?.card_count.toLocaleString()} cards in database</div>
{status?.reference_updated_at != null && (
<div title={new Date(status.reference_updated_at * 1000).toLocaleString()}>
Updated {formatRelativeTime(status.reference_updated_at)}
</div>
)}
</div>
</div>
);
}
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
for (let i = 0; i < index; i++) start += stages[i].weight;
const end = start + stages[index].weight;
const [startPct, endPct] = [start * 100, end * 100];
if (overallPercent <= startPct) return 0;
if (overallPercent >= endPct) return 100;
if (endPct === startPct) return 100;
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
// This only controls the snackbar's visibility — the underlying undo action
// stays available in the command palette well past this (see `undo` below),
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
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.
2026-08-07 22:03:49 -05:00
/** 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);
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.
2026-08-07 22:03:49 -05:00
// 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.
const gotProgress = useRef(false);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
// so an undo that's missed in the snackbar is still reachable via Alt+/.
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise<void> } | null>(
null,
);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const snackbarTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Bumped once an undo actually runs, so views mounted elsewhere than where
// the undo was offered (e.g. the Decks tab, when the undo came from a
// Library bulk add-to-deck) know to refetch instead of showing stale data
// until the user leaves and comes back.
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
function offerUndo(message: string, run: () => void | Promise<void>) {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setUndo({ message, run });
setSnackbarVisible(true);
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
}
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
function dismissUndo() {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
}
function runUndo() {
if (!undo) return;
const { run } = undo;
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
}
const refreshStatus = useCallback(async () => {
// This is the *only* place status gets fetched — nothing else re-polls it.
// The Android IPC bridge has been observed to silently drop an invoke()
// outright: the promise never resolves *or* rejects (confirmed
// separately for update_card_data — same symptom, no backend log line at
// all, so the call never even reached Rust). A try/catch alone doesn't
// help against that, since nothing ever throws; the race against a
// per-attempt timeout below is what actually turns a hang into a
// retryable failure. Without it, this ran once, hung forever, and the
// app was permanently stuck on "no card data yet" every launch — even
// with a fully loaded reference.db on the backend, since nothing else
// ever asks it again.
for (let attempt = 0; ; attempt++) {
try {
const s = await Promise.race([
api.getStatus(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("get_status timed out")), 5000),
),
]);
setStatus(s);
return;
} catch (err) {
if (attempt >= 4) {
console.error("getStatus failed after retries:", err);
return;
}
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
}, []);
useEffect(() => {
refreshStatus();
const un = listen<IngestProgress>("ingest-progress", (e) => {
gotProgress.current = true;
lastProgress.current = e.payload;
setProgress(e.payload);
});
return () => {
un.then((f) => f());
};
}, [refreshStatus]);
// Force a re-render every minute so the "Updated Xh ago" stamp stays fresh
// over a long session without needing an actual status refetch.
const [, tick] = useState(0);
useEffect(() => {
const interval = setInterval(() => tick((n) => n + 1), 60000);
return () => clearInterval(interval);
}, []);
const ready = status?.reference_ready ?? false;
// On Android the very first IPC call after launch has been observed taking
// several seconds (likely one-time JIT/class-verification warm-up on the
// native bridge, not a hang — it does complete). `!ready` was previously
// used both for "we don't know yet" and "confirmed no card data", so during
// that warm-up the app showed the full "Download card data" prompt — which
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
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.
2026-08-07 22:03:49 -05:00
// 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).
useEffect(() => {
if (!ready) {
setLibStats(null);
return;
}
let cancelled = false;
const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s));
refresh();
const interval = setInterval(refresh, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [ready]);
async function updateCardData() {
setBusy(true);
gotProgress.current = false;
let stallTimer: ReturnType<typeof setTimeout> | undefined;
try {
// Android's Tauri IPC bridge has been observed to silently drop an
// invoke() during a dev-mode webview reload: the promise never settles,
// no ingest-progress event ever arrives, and the button was stuck
// "Downloading…" forever with no way to retry (the backend never even
// logged that it started). A real ingest always emits its first
// progress event within milliseconds — it happens before any network
// I/O — so treat "no progress after a generous window" as a dropped
// call rather than hang indefinitely.
const s = await Promise.race([
api.updateCardData(),
new Promise<never>((_, reject) => {
stallTimer = setTimeout(() => {
if (!gotProgress.current) {
reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app."));
}
}, 8000);
}),
]);
setStatus(s);
setProgress({
stage: "done",
message: `Loaded ${s.card_count.toLocaleString()} cards.`,
overall_percent: 100,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000);
} catch (err) {
const msg = String(err);
if (msg === "cancelled") {
setProgress({
stage: "cancelled",
message: "Cancelled.",
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000);
} else {
setProgress({
stage: "error",
message: `Failed: ${msg}`,
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
}
} finally {
clearTimeout(stallTimer);
setBusy(false);
}
}
async function cancelUpdate() {
await api.cancelUpdateCardData();
}
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.
2026-08-07 22:03:49 -05:00
/** 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 {
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.
2026-08-07 22:03:49 -05:00
setSync(await api.syncStatus());
} catch (err) {
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.
2026-08-07 22:03:49 -05:00
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.
Let Android pick which file in the synced folder is the library Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
//
// Gated on `statusLoaded` as well as the tab: the active tab is persisted, so
// relaunching while on Info makes this fire at mount — before setup() has
// called manage(), which fails with "state not managed for field db".
// `getStatus` retries until the backend is ready, so waiting on it is what
// guarantees the state exists.
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.
2026-08-07 22:03:49 -05:00
useEffect(() => {
Let Android pick which file in the synced folder is the library Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
if (tab === "info" && statusLoaded) refreshSync();
}, [tab, statusLoaded, refreshSync]);
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.
2026-08-07 22:03:49 -05:00
/**
* Point the app at `path` and bring the whole UI into line with it.
*
* Switching documents changes the entire library underneath every view, so
* refreshing the sync panel alone isn't enough: the path bar reads
* `status.library_path`, the sidebar reads `libStats`, and each tab is
* holding rows fetched from the *previous* library. Returns a short " — N
* cards, M decks" suffix so the caller can confirm what actually opened.
*/
async function adoptDocument(path: string): Promise<string> {
setSync(await api.syncSetDocument(path));
// The path bar comes from get_status, which nothing else re-fetches.
setStatus(await api.getStatus());
setDeckRefreshSignal((v) => v + 1);
if (!readyRef.current) return "";
try {
const stats = await api.getLibraryStats();
setLibStats(stats);
return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${
stats.deck_count
} deck${stats.deck_count === 1 ? "" : "s"}`;
} catch {
return "";
}
}
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.
2026-08-07 22:03:49 -05:00
/** 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: ["*"] },
],
});
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.
2026-08-07 22:03:49 -05:00
if (typeof path !== "string") return;
setSyncBusy(true);
const opened = await adoptDocument(path);
setSyncTest({ ok: true, message: `Opened ${path}${opened}.` });
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.
2026-08-07 22:03:49 -05:00
} 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.
const moved = await adoptDocument(path);
setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` });
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.
2026-08-07 22:03:49 -05:00
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
Let Android pick which file in the synced folder is the library Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
/** Android's second step: which file in the granted folder is the library.
* Loaded lazily, since it costs a SAF round trip. */
const [docChoices, setDocChoices] = useState<string[] | null>(null);
useEffect(() => {
if (tab !== "info" || !sync?.picker_based || !sync.granted) return;
api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([]));
}, [tab, sync?.picker_based, sync?.granted, sync?.label]);
async function chooseDocumentName(name: string) {
setSyncBusy(true);
setSyncTest(null);
try {
setSync(await api.syncSetDocumentName(name));
setStatus(await api.getStatus());
setDeckRefreshSignal((v) => v + 1);
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setSyncTest({ ok: true, message: `Using ${name}.` });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
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.
2026-08-07 22:03:49 -05:00
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);
}
}
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
/** Adopt a reference.db built elsewhere (e.g. copied from a desktop install)
* instead of streaming ~740MB to rebuild it on this device. */
async function importReferenceDb() {
const path = await open({
title: "Import a card database (reference.db)",
filters: [{ name: "Card database", extensions: ["db", "sqlite"] }],
});
if (typeof path !== "string") return;
try {
setStatus(await api.importReferenceDb(path));
setProgress(null);
} catch (err) {
setProgress({
stage: "error",
message: `Couldn't import that card database: ${String(err)}`,
overall_percent: 0,
stages: lastProgress.current?.stages ?? [],
});
}
}
return (
<HoverPreviewProvider>
<div className="app">
<nav className="nav-rail">
<div className="brand">
<span className="brand-mark">
<ShipLogo size={20} gradientId="brand-ship-grad" />
</span>
<span className="brand-text">mtgc</span>
</div>
<div className="nav-items">
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.
2026-08-07 22:03:49 -05:00
{/* 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" : ""}`}
onClick={() => setTab(n.id)}
title={n.label}
>
<span className="nav-icon">{n.icon}</span>
<span className="nav-label">{n.label}</span>
</button>
))}
</div>
<div className="nav-foot">
<LibrarySummary ready={ready} libStats={libStats} status={status} />
</div>
</nav>
<div className="main">
<header className="topbar">
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
</span>
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.
2026-08-07 22:03:49 -05:00
{/* Desktop-only: Android grants a folder through the system picker
instead of naming a path, so these live in the Info tab there. */}
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
{status && !status.is_mobile && (
<>
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.
2026-08-07 22:03:49 -05:00
<button onClick={pickLibrary} disabled={busy || syncBusy}>
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
{ICON_OPEN_LIBRARY}
Open Library
</button>
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.
2026-08-07 22:03:49 -05:00
<button onClick={newLibrary} disabled={busy || syncBusy}>
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
{ICON_NEW_LIBRARY}
New Library
</button>
</>
)}
{/* On mobile this lives in the bottom nav's Info tab instead —
see below — rather than crowding the topbar. */}
{status && !status.is_mobile && (
<button onClick={updateCardData} disabled={busy} className="primary">
{ICON_DOWNLOAD}
{ready ? "Update card data" : "Download card data"}
</button>
)}
</header>
{progress && (
<div className={`ingest-bar ingest-bar-${progress.stage}`}>
<div className="ingest-bar-head">
<span className="ingest-bar-message">{progress.message}</span>
<span className="spacer" />
{busy && (
<button className="ingest-bar-cancel" onClick={cancelUpdate}>
Cancel
</button>
)}
{!busy &&
(progress.stage === "done" ||
progress.stage === "error" ||
progress.stage === "cancelled") && (
<IconButton icon={Icons.close} label="Dismiss" onClick={() => setProgress(null)} />
)}
</div>
<div className="ingest-segments">
{progress.stages.map((s, i) => (
<div
key={s.id}
className={`ingest-segment ${s.id === progress.stage ? "active" : ""}`}
style={{ flexGrow: s.weight, flexBasis: 0 }}
>
<div className="ingest-segment-track">
<div
className="ingest-segment-fill"
style={{ width: `${segmentFill(progress.stages, i, progress.overall_percent)}%` }}
/>
</div>
<span className="ingest-segment-label">{s.label}</span>
</div>
))}
</div>
</div>
)}
<div className="content">
{!statusLoaded ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<p className="hint">Loading…</p>
</div>
) : !ready ? (
<div className="empty-state">
<div className="empty-mark">
<ShipLogo size={40} gradientId="empty-ship-grad" />
</div>
<h2>Welcome to mtgc</h2>
<p className="hint">
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
No card database yet. <strong>Download card data</strong> fetches the
latest cards from Scryfall (a few hundred MB
{status?.is_mobile ? " — slow over mobile data" : ", about a minute"}).
The reference data stays on this device — only your library file is meant
to be synced.
</p>
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
{status?.is_mobile && (
<p className="hint">
Already have it on your computer? Copying that{" "}
<strong>reference.db</strong> over is much faster — it's about 73MB
versus the ~740MB the download has to stream.
</p>
)}
<button className="primary big" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Download card data"}
</button>
Add Android support via Tauri The Rust core was already portable (caller-provided db paths, rustls rather than openssl) and the shell already had the mobile entry point, so this is mostly toolchain, provisioning, and platform-gating. Toolchain: - scripts/android-setup.sh installs what Android Studio doesn't: SDK cmdline-tools, a pinned stable NDK, and the Rust Android targets. The NDK is pinned rather than "latest" because sdkmanager's newest is often a beta (30.0.15729638 is r30-beta2) -- not something to compile SQLite with. Prefers Android Studio's bundled JDK 21, since Gradle rejects newer JDKs. - Track src-tauri/gen/android (excluding build output): AndroidManifest.xml is hand-edited and Android permissions have no tauri.conf.json equivalent. - vite: serve on TAURI_DEV_HOST so a device/emulator can reach the dev server. Card data: downloading streams ~740MB to build a ~73MB db, which is rough on a phone. Add import_reference_db to adopt a desktop-built file instead, validated by db::inspect_reference_db first so picking the wrong file is a clean error rather than a broken install. Installs via copy-then-rename so an interrupted copy can't leave a partial db. Ingest hardening (helps desktop too): - Give ureq connect/read timeouts. Cancellation is only polled per parsed record, so a stalled socket previously hung forever with no way to cancel. - Drop the remove_file before rename; POSIX rename already replaces atomically, and the delete-first window could leave no reference.db at all if the process was killed -- much likelier on Android. UI: hide Open/New Library on mobile (scoped storage has no arbitrary-path equivalent, and SQLite can't open a content:// URI), reported via a cfg!(mobile) flag on Status rather than user-agent sniffing. Use 100dvh so the soft keyboard doesn't break the shell. Verified: 70/70 core tests, desktop build clean, and the aarch64 APK builds (bundled SQLite C and ring both cross-compile under NDK 28). Not yet run on a device.
2026-08-02 13:16:34 -05:00
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
) : tab === "add" ? (
<CardEntry />
) : /* Keyed on the library document: switching to a different one
replaces every card, deck and id these views are holding, so
they're remounted rather than left to reconcile state that
belongs to a library that's no longer open. Stable across
ordinary status refreshes, so it costs nothing otherwise. */
tab === "library" ? (
<LibraryBrowser key={status?.library_path} onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
) : tab === "decks" ? (
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
Add a News tab: set releases and links to Wizards' articles The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
) : tab === "news" ? (
// Library-independent, so unlike the others it isn't keyed on the
// document — switching libraries doesn't change the news.
<NewsView />
) : (
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.
2026-08-07 22:03:49 -05:00
// 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" }}>
{status?.library_path}
</div>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
{busy ? "Downloading…" : "Update card data"}
</button>
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
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.
2026-08-07 22:03:49 -05:00
<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>
Let Android pick which file in the synced folder is the library Android grants a *folder* through SAF, not a file, and the document name was hardcoded to "library.json". A library named anything else was therefore never found: the phone created an empty second library beside it and the two devices silently never synced, while appearing to work. The Info tab now lists the .json files in the granted folder and lets you choose one. Picking an existing file adopts it — the working copy is dropped and rebuilt from it, matching what desktop does when opening a document — while a name that isn't there yet keeps the current library and publishes it, matching New Library. The name is validated as a plain filename, since a path would escape the granted folder that the SAF grant exists to bound. Two bugs found while testing this on-device: load_config decided whether a config was the pre-document format by testing whether document_path or legacy_library_path were set. An Android install has neither — its folder is a grant, not a path, and there is no legacy library — so a valid config's document_name was discarded on every launch and the chosen library file silently reverted to the default. It no longer infers the format from which fields happen to be populated; every field defaults, and the legacy library_path is picked up as a separate additive step. Covered by tests, including one built from the exact Android config shape, because a setting that quietly resets itself is not something a careful read catches. The Info tab's sync refresh fired at mount when the app relaunched onto that tab — the active tab is persisted — which is before setup() calls manage(), producing "state not managed for field db". Gated on statusLoaded like the startup refresh already was; get_status retries until the backend is ready, so waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
{/* Android grants a *folder*, so which file in it is the
library is a separate choice. Without this the app
assumes "library.json" and would create an empty second
library beside one named anything else. */}
{sync.picker_based && sync.chosen && sync.granted && (
<div className="field">
<label>Library file in that folder</label>
{docChoices === null ? (
<div className="hint">Looking…</div>
) : (
<>
<select
value={sync.document_name ?? ""}
onChange={(e) => chooseDocumentName(e.target.value)}
disabled={syncBusy}
>
{/* The current name may not exist in the folder
yet (a library this device hasn't published),
so it's always offered. */}
{[
...new Set(
[sync.document_name ?? "library.json", ...docChoices].filter(
Boolean,
),
),
].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{docChoices.length === 0 && (
<span className="hint">
No library files here yet — a new one will be created.
</span>
)}
</>
)}
</div>
)}
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.
2026-08-07 22:03:49 -05:00
{/* 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>
)}
</div>
</div>
{/* Android-customary bottom tab bar — replaces the rail above on
narrow viewports (see the ≤720px CSS breakpoint), rather than the
rail collapsing to an icon strip in place. Hidden while the
initial status fetch is still pending: there's nothing to
navigate to yet, and showing it makes the loading screen look
further along than it is. Carries an extra "Info" tab beyond
`NAV` — card-database management, which lives in the topbar on
desktop instead (see the button below). */}
{statusLoaded && (
<nav className="bottom-nav">
{[...NAV, INFO_TAB].map((n) => (
<button
key={n.id}
className={`bottom-nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
<span>{n.label}</span>
</button>
))}
</nav>
)}
</div>
{undo && snackbarVisible && (
<UndoBar message={undo.message} onUndo={runUndo} onDismiss={dismissUndo} />
)}
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
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.
2026-08-07 22:03:49 -05:00
onOpenLibrary={pickLibrary}
onNewLibrary={newLibrary}
undo={undo}
onRunUndo={runUndo}
/>
</HoverPreviewProvider>
);
}