Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
@@ -8,7 +8,16 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api";
|
||||
import {
|
||||
api,
|
||||
CardRef,
|
||||
COLORS,
|
||||
LEGALITY_FORMATS,
|
||||
LibraryFilter,
|
||||
legalStatusFor,
|
||||
maskToLetters,
|
||||
PreviewCard,
|
||||
} from "./api";
|
||||
|
||||
// --- Icons ------------------------------------------------------------
|
||||
|
||||
@@ -1386,3 +1395,296 @@ export function ColorToggles({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared card criteria -------------------------------------------------
|
||||
|
||||
/** Ringed planet — a card from another world/property (Universes Beyond). */
|
||||
const ICON_CROSSOVER = icon(
|
||||
<>
|
||||
<circle cx="12" cy="11" r="6" />
|
||||
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
|
||||
</>,
|
||||
);
|
||||
/** Monitor — exists only on Arena/MTGO. */
|
||||
const ICON_SCREEN = icon(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M9 20h6" />
|
||||
<path d="M12 16v4" />
|
||||
</>,
|
||||
);
|
||||
/** Smiley — the joke sets. */
|
||||
const ICON_SMILEY = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
||||
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
|
||||
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
|
||||
</>,
|
||||
);
|
||||
/** Crown — can head a Commander deck. */
|
||||
const ICON_CROWN = icon(
|
||||
<>
|
||||
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
|
||||
<line x1="5" y1="21" x2="19" y2="21" />
|
||||
</>,
|
||||
);
|
||||
|
||||
/**
|
||||
* Card-origin and numeric criteria shared by the Library and Card Search
|
||||
* filter panels. Kept here rather than duplicated in both views, since they
|
||||
* must stay in step: a filter that behaves differently between browsing what
|
||||
* you own and searching everything would be a bug, not a feature.
|
||||
*
|
||||
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
|
||||
* rather than coercing a blank to 0.
|
||||
*/
|
||||
export type CardCriteria = {
|
||||
excludeUb: boolean;
|
||||
excludeDigital: boolean;
|
||||
excludeFunny: boolean;
|
||||
priceMin: string;
|
||||
priceMax: string;
|
||||
legalIn: string;
|
||||
canBeCommander: boolean;
|
||||
powerMin: string;
|
||||
powerMax: string;
|
||||
toughMin: string;
|
||||
toughMax: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CRITERIA: CardCriteria = {
|
||||
excludeUb: false,
|
||||
excludeDigital: false,
|
||||
excludeFunny: false,
|
||||
priceMin: "",
|
||||
priceMax: "",
|
||||
legalIn: "",
|
||||
canBeCommander: false,
|
||||
powerMin: "",
|
||||
powerMax: "",
|
||||
toughMin: "",
|
||||
toughMax: "",
|
||||
};
|
||||
|
||||
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
|
||||
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
|
||||
// Merged over the defaults so a stored object written before a field existed
|
||||
// doesn't leave it undefined.
|
||||
const merged = { ...EMPTY_CRITERIA, ...value };
|
||||
const patch = useCallback(
|
||||
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
|
||||
[setValue, value],
|
||||
);
|
||||
return [merged, patch];
|
||||
}
|
||||
|
||||
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
|
||||
|
||||
/** Fold criteria into the filter sent to the backend. */
|
||||
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
||||
return {
|
||||
exclude_universes_beyond: c.excludeUb,
|
||||
exclude_digital_only: c.excludeDigital,
|
||||
exclude_funny: c.excludeFunny,
|
||||
price_min: num(c.priceMin),
|
||||
price_max: num(c.priceMax),
|
||||
legal_in: c.legalIn || null,
|
||||
can_be_commander: c.canBeCommander,
|
||||
power_min: num(c.powerMin),
|
||||
power_max: num(c.powerMax),
|
||||
toughness_min: num(c.toughMin),
|
||||
toughness_max: num(c.toughMax),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether these criteria amount to a search in their own right.
|
||||
*
|
||||
* The three "exclude" toggles deliberately don't count: they refine a result
|
||||
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
||||
* make Card Search pull every card in the database. Choosing a format or a
|
||||
* power range is a real query, and does.
|
||||
*/
|
||||
export function criteriaActive(c: CardCriteria): boolean {
|
||||
return (
|
||||
c.canBeCommander ||
|
||||
c.legalIn !== "" ||
|
||||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
||||
(v) => v.trim() !== "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RangePair({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
onMin,
|
||||
onMax,
|
||||
step,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
min: string;
|
||||
max: string;
|
||||
onMin: (v: string) => void;
|
||||
onMax: (v: string) => void;
|
||||
step?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<div className="row">
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="min"
|
||||
value={min}
|
||||
onChange={(e) => onMin(e.target.value)}
|
||||
/>
|
||||
<span className="muted">–</span>
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="max"
|
||||
value={max}
|
||||
onChange={(e) => onMax(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardCriteriaFields({
|
||||
value: c,
|
||||
onChange,
|
||||
}: {
|
||||
value: CardCriteria;
|
||||
onChange: (patch: Partial<CardCriteria>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>Legal in</label>
|
||||
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
|
||||
<option value="">Any format</option>
|
||||
{LEGALITY_FORMATS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<RangePair
|
||||
label="Price (USD)"
|
||||
prefix="$"
|
||||
step="0.01"
|
||||
min={c.priceMin}
|
||||
max={c.priceMax}
|
||||
onMin={(v) => onChange({ priceMin: v })}
|
||||
onMax={(v) => onChange({ priceMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Power"
|
||||
min={c.powerMin}
|
||||
max={c.powerMax}
|
||||
onMin={(v) => onChange({ powerMin: v })}
|
||||
onMax={(v) => onChange({ powerMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Toughness"
|
||||
min={c.toughMin}
|
||||
max={c.toughMax}
|
||||
onMin={(v) => onChange({ toughMin: v })}
|
||||
onMax={(v) => onChange({ toughMax: v })}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Quick filters</label>
|
||||
<div className="icon-toggles">
|
||||
{/* Exclusions read as "no X" — tinted with the danger colour when
|
||||
active, so removing things looks different from selecting them. */}
|
||||
<IconToggle
|
||||
icon={ICON_CROSSOVER}
|
||||
label="Hide Universes Beyond"
|
||||
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
|
||||
exclude
|
||||
on={c.excludeUb}
|
||||
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SCREEN}
|
||||
label="Hide digital-only"
|
||||
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
|
||||
exclude
|
||||
on={c.excludeDigital}
|
||||
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SMILEY}
|
||||
label="Hide Un-set cards"
|
||||
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
|
||||
exclude
|
||||
on={c.excludeFunny}
|
||||
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
|
||||
/>
|
||||
<span className="icon-toggles-sep" />
|
||||
<IconToggle
|
||||
icon={ICON_CROWN}
|
||||
label="Only cards that can be a commander"
|
||||
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
|
||||
on={c.canBeCommander}
|
||||
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
|
||||
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
|
||||
* rather than selecting them, which is worth distinguishing visually since
|
||||
* both live in the same row.
|
||||
*/
|
||||
function IconToggle({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
on,
|
||||
exclude,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
on: boolean;
|
||||
exclude?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
|
||||
// Title carries the full explanation the old inline hint text did — the
|
||||
// whole point of this row is to stop that prose eating the panel.
|
||||
title={`${label}\n\n${hint}`}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{icon}
|
||||
{/* A slash over the glyph when an exclusion is active, so "hidden" is
|
||||
legible without reading the tooltip. */}
|
||||
{exclude && on && <span className="icon-toggle-slash" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user