Files
mtgc/src/api.ts
Garrett Mills 4b651b5108 Library import asks for a location to file the cards under
After picking a file, Import cards… asks where the cards are kept,
pre-filled with the file's name (a ManaBox export is named after its
binder). A location is set on every imported card, replacing any it had,
as Add Cards does; blank leaves locations alone. It's applied inside the
import's single transaction.
2026-10-17 21:58:44 -05:00

524 lines
20 KiB
TypeScript

// Typed wrappers over the Tauri command bridge into mtgc-core.
import { invoke } from "@tauri-apps/api/core";
// --- Types (mirror the serde-serialized core structs) ---------------------
export interface CardRef {
oracle_id: string;
name: string;
mana_value: number;
colors: number; // WUBRG bitmask
color_identity: number; // WUBRG bitmask
type_line: string;
// Printed/displayed sideways (Battles, Planes, Phenomena). The stored
// image itself is portrait-shaped with the content rotated, so viewers
// must rotate it 90° to display right-side up.
horizontal: boolean;
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
back_image_uri: string; // back face for transform/MDFC/reversible cards, else ""
produced_mana: number; // WUBRG bitmask of mana this card can tap for
produces_colorless: boolean; // can produce {C}
price_usd: number; // USD market price, or 0 if unknown
legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS
}
export interface LibraryCard extends CardRef {
quantity_owned: number;
decks: string[];
quantity_assigned: number;
location: string;
}
export interface DeckEntry extends CardRef {
quantity: number;
is_commander: boolean;
/** On the maybeboard: tracked against the deck but excluded from deck
* analysis stats and library assignment totals. */
is_maybeboard: boolean;
quantity_owned: number;
/** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null;
}
/** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef {
scryfall_id: string;
set_code: string; // lowercase, e.g. "2x2"
collector_number: string;
lang: string; // "" on an older card database
finishes: string[]; // "nonfoil" | "foil" | "etched"
rarity: string;
image_uri: string;
back_image_uri: string;
price_usd: number;
released_at: string; // YYYY-MM-DD, "" on an older card database
}
/** Owned copies pinned to one printing + finish. A subset of the card's
* `quantity_owned`; copies in no group are unspecified. */
export interface LibraryCopy {
scryfall_id: string;
finish: string;
quantity: number;
/** Null when the printing has left the card database. */
printing: PrintingRef | null;
}
/**
* A `CardRef` plus whatever ownership context the caller happens to have
* (present on `LibraryCard`/`DeckEntry`, absent on a bare `CardRef`). The
* click-to-preview modal's info tile renders these fields when present and
* omits them otherwise — callers don't need to do anything special, since
* `LibraryCard`/`DeckEntry`/`CardRef` are all structurally assignable here.
*/
export type PreviewCard = CardRef & {
quantity_owned?: number;
decks?: string[];
preferred_scryfall_id?: string | null;
};
/**
* Where a click preview was opened from, when the caller lets it edit
* printings: the info tile then tracks owned copies by printing, and — for a
* deck slot — chooses which printing's art the slot shows.
*/
export interface PreviewContext {
/** Called after any printing change, so the caller can refetch. */
onChanged: () => void;
/** The deck the previewed card is a slot of. */
deckId?: number;
}
export interface Deck {
id: number;
name: string;
format: string;
commander_oracle_id: string | null;
notes: string;
}
export interface DeckImportResult {
deck: Deck;
unresolved: string[];
}
export interface LibraryImportResult {
added: [string, number][];
unresolved: string[];
}
export interface SynergyReason {
category: string; // "Tribal" | "Mechanic" | "Archetype" | "Mana base"
/** The specific thing matched — "Dragon", "Flying", "Landfall". Separate from
* `detail` so the UI can filter on it: a deck usually has several archetypes
* and you want one of them, not all. */
subject: string;
detail: string;
}
export interface CardSuggestion {
card: CardRef;
quantity_owned: number;
/** Copies other decks' main boards already use. */
quantity_assigned: number;
score: number;
reasons: SynergyReason[];
edhrec_rank: number | null;
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
colors_include?: number | null;
commander_identity?: number | null;
cmc_min?: number | null;
cmc_max?: number | null;
types?: string[];
subtypes?: string[];
keywords?: string[];
in_deck?: number | null;
location?: string | null;
oracle_text?: string | null;
/** Hide cards that exist *only* as Universes Beyond (Lord of the Rings,
* Warhammer 40,000, Marvel, …). Ordinary cards reprinted into a UB set are
* kept. Needs card data downloaded after this shipped; inert otherwise. */
exclude_universes_beyond?: boolean;
/** Hide Arena/MTGO-only cards (Alchemy rebalances) — they can't be owned on
* paper. Needs card data downloaded after this shipped; inert otherwise. */
exclude_digital_only?: boolean;
/** Hide Un-set cards (Unglued, Unhinged, Unstable, Unfinity). */
exclude_funny?: boolean;
/** Cheapest known printing in USD; cards with no known price count as 0. */
price_min?: number | null;
price_max?: number | null;
/** Format slug ("commander", "modern", …). Legal and restricted match. */
legal_in?: string | null;
/** Only cards that can head a Commander deck. */
can_be_commander?: boolean;
/** Only creatures with an empty rules box (Adventure/Prepared creature halves count). */
vanilla_only?: boolean;
/** Only cards printed in this set (matched against printings.set_code).
* Lets the release browser reuse the whole criteria pipeline. */
set_code?: string | null;
/** Only cards with an empty color identity — a colorless commander's pool.
* Separate from the color bitmasks, where 0 means "no filter". */
colorless_identity?: boolean;
/** Only cards that are themselves colorless. */
colorless_colors?: boolean;
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
* since those can't be compared numerically. */
power_min?: number | null;
power_max?: number | null;
toughness_min?: number | null;
toughness_max?: number | null;
/** Only cards with zero owned copies. Only honored by `searchReference`
* (`searchLibrary` already returns owned cards exclusively). */
not_owned?: boolean;
/** Only cards with more copies owned than the decks' main boards use. */
spares_only?: boolean;
/** Only cards printed at any of these rarities ("common", "uncommon",
* "rare", "mythic"). Any printing counts; with `set_code`, that set's. */
rarities?: string[];
}
export interface Status {
/** The open library's name and where it syncs: "Main · user @ host", or
* this device only. Changes whenever another library is opened. */
library_label: string;
/** The open library. */
library: { id: string; name: string };
reference_path: string;
reference_ready: boolean;
card_count: number;
reference_updated_at: number | null; // unix seconds
/** Columns the installed card data lacks ("table.column") because an older
* version built it. Non-empty means some features are degraded until the
* user updates card data. */
reference_missing_columns: string[];
/** True on mobile builds. Used to hide affordances that depend on choosing an
* arbitrary filesystem path for library.db — Android scoped storage has no
* equivalent, and SQLite can't be backed by a content:// URI. */
is_mobile: boolean;
}
/** The account a device is signed in to on a sync server. */
export interface SyncAccount {
id: string;
username: string;
display_name: string;
email: string;
/** "local" (username/password) or "oidc" (single sign-on). */
method: string;
}
/** Library sync with a sync server. The library itself always lives on this
* device and works offline; syncing exchanges edits with the other devices. */
export interface SyncStatus {
signed_in: boolean;
/** Kept after signing out, to offer signing back in. */
server_url: string | null;
account: SyncAccount | null;
/** Unix seconds of the last completed sync. */
last_synced_at: number | null;
/** Why the last sync failed, until one succeeds. */
last_error: string | null;
/** The first sync found libraries both here and on the server; waiting on
* `syncConnect` to say whether to combine them or take the server's. */
needs_choice: boolean;
/** Edits made here that the server hasn't acknowledged yet. */
pending_changes: boolean;
/** A sign-in is waiting on the browser. */
signing_in: boolean;
/** A pre-document library.db from an older build, waiting to be imported. */
legacy_library: string | null;
}
/** One of the account's signed-in devices. */
export interface SyncDevice {
id: number;
name: string;
created_at: number;
last_seen_at: number;
/** Whether it's this device. */
current: boolean;
}
export type FirstSync = "combine" | "use_server";
export interface MergeReport {
cards_changed: number;
decks_changed: number;
decks_removed: number;
/** Fields both sides changed, where the server's value was kept. */
conflicts: string[];
}
/** What an import of a library file brought in. */
export interface ImportReport {
cards: number;
decks: number;
report: MergeReport;
}
/** What one sync did. */
export interface SyncReport {
/** Whether the merge altered this device's library. */
changed: boolean;
/** Whether the server's copy was replaced (skipped when there was nothing to add). */
published: boolean;
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
needs_choice: boolean;
report: MergeReport;
/** Libraries were added, renamed, or deleted on the server: re-list them. */
libraries_changed: boolean;
}
/** One of the libraries on this device. */
export interface LibraryInfo {
/** Local only; never shown. */
id: string;
name: string;
/** The one that's open. */
active: boolean;
/** The library the device started with, which can't be deleted. */
is_default: boolean;
/** Whether it should be on the sync server. */
sync: boolean;
/** Whether it's linked to a library on the server yet. */
linked: boolean;
/** Another device deleted it from the server; this copy is all that's left. */
server_deleted: boolean;
}
/** The open library, for `libraryKey`. Set by `App` from each status. */
let openLibraryId = "default";
export function setOpenLibrary(id: string) {
openLibraryId = id;
}
/** A localStorage key for something that belongs to the open library — a
* selected deck, say — so each library remembers its own. The default
* library keeps the bare key it always had. */
export function libraryKey(key: string): string {
return openLibraryId === "default" ? key : `${key}:${openLibraryId}`;
}
/** A published or upcoming Magic set, from Scryfall. */
export interface SetInfo {
code: string;
name: string;
set_type: string;
/** ISO date; null for sets with no announced date, which sort last. */
released_at: string | null;
card_count: number;
icon_uri: string;
scryfall_uri: string;
}
/** A news article — title and link only; the app links out rather than
* reproducing anyone's writing. */
export interface Article {
category: string;
title: string;
url: string;
}
export interface News {
sets: SetInfo[];
articles: Article[];
/** Unix seconds of the last successful fetch; 0 if never. */
fetched_at: number;
/** False when the article source couldn't be reached or its markup changed.
* Only that section is affected — releases stay usable. */
articles_ok: boolean;
}
/** A card in a set. Extends CardRef so it renders with the same CardTile —
* and therefore the same click-to-preview — as every other card in the app. */
export interface SetCard extends CardRef {
collector_number: string;
quantity_owned: number;
}
export interface LibraryStats {
unique_cards: number;
total_copies: number;
deck_count: number;
color_counts: [number, number, number, number, number]; // W, U, B, R, G
colorless_count: number;
}
export interface LibraryFacets {
types: string[];
subtypes: string[];
keywords: string[];
}
export interface IngestStageInfo {
id: string;
label: string;
weight: number; // fraction of total work, 0..1, sums to 1 across all stages
}
export interface IngestProgress {
stage: "index" | "oracle" | "printings" | "optimize" | string;
message: string;
overall_percent: number; // 0..100 cumulative across all stages
stages: IngestStageInfo[];
}
// --- WUBRG bitmask helpers ------------------------------------------------
export const COLORS: { bit: number; code: string; label: string }[] = [
{ bit: 1, code: "W", label: "White" },
{ bit: 2, code: "U", label: "Blue" },
{ bit: 4, code: "B", label: "Black" },
{ bit: 8, code: "R", label: "Red" },
{ bit: 16, code: "G", label: "Green" },
];
export function maskToLetters(mask: number): string {
return COLORS.filter((c) => mask & c.bit)
.map((c) => c.code)
.join("");
}
// --- Legality bitmask helpers ----------------------------------------------
// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per
// format, in this fixed order. Do not reorder.
export const LEGALITY_FORMATS: { key: string; label: string }[] = [
{ key: "standard", label: "Standard" },
{ key: "pioneer", label: "Pioneer" },
{ key: "modern", label: "Modern" },
{ key: "legacy", label: "Legacy" },
{ key: "vintage", label: "Vintage" },
{ key: "pauper", label: "Pauper" },
{ key: "commander", label: "Commander" },
{ key: "brawl", label: "Brawl" },
];
export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted";
const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"];
export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus {
const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel);
if (i < 0) return "not_legal";
const code = (legalities >> (i * 2)) & 0b11;
return LEGAL_STATUS_CODES[code];
}
// --- Commands -------------------------------------------------------------
export const api = {
getStatus: () => invoke<Status>("get_status"),
updateCardData: () => invoke<Status>("update_card_data"),
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
/** Adopt an already-built reference.db from `path` instead of downloading it. */
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
syncStatus: () => invoke<SyncStatus>("sync_status"),
/** Sign in through the browser, then sync. Resolves once the user finishes
* (or abandons) signing in there. */
syncSignIn: (serverUrl: string, deviceName?: string) =>
invoke<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
/** Sign this device out. The library stays here. */
syncSignOut: () => invoke<SyncStatus>("sync_sign_out"),
/** Merge with the server and publish local edits. Resolves with `null` when
* not signed in, or a sync was already running and this call stood down —
* expected, since sync also fires automatically. */
syncNow: () => invoke<SyncReport | null>("sync_now"),
/** Answer the first-sync question. */
syncConnect: (choice: FirstSync) => invoke<SyncReport | null>("sync_connect", { choice }),
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
/** Sign another device out (a lost phone, say); returns the remaining list. */
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
listLibraries: () => invoke<LibraryInfo[]>("list_libraries"),
/** Start a new, empty library and open it. Resolves with the new list. */
createLibrary: (name: string, sync: boolean) =>
invoke<LibraryInfo[]>("create_library", { name, sync }),
/** Open another library. */
switchLibrary: (id: string) => invoke<LibraryInfo[]>("switch_library", { id }),
/** Rename a library, on the sync server too when it's there. */
renameLibrary: (id: string, name: string) => invoke<LibraryInfo[]>("rename_library", { id, name }),
/** Delete a library from this device; with `everywhere`, from the server too. */
deleteLibrary: (id: string, everywhere: boolean) =>
invoke<LibraryInfo[]>("delete_library", { id, everywhere }),
/** Put a library on the sync server, or keep it on this device only. */
setLibrarySync: (id: string, sync: boolean) => invoke<LibraryInfo[]>("set_library_sync", { id, sync }),
/** Merge a library file into the open one: a JSON backup, or an old library.db. */
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
/** Save the whole open library as JSON, as a backup. */
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
/** Set releases and article links. `refresh` forces a fetch past the cache. */
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),
/** Every card in a set, annotated with how many you own. */
getSetCards: (code: string) => invoke<SetCard[]>("get_set_cards", { code }),
searchCards: (query: string, limit = 20) =>
invoke<CardRef[]>("search_cards", { query, limit }),
addOwned: (oracleId: string, delta: number) =>
invoke<number>("add_owned", { oracleId, delta }),
setOwned: (oracleId: string, quantity: number) =>
invoke<number>("set_owned", { oracleId, quantity }),
setLocation: (oracleId: string, location: string) =>
invoke<void>("set_location", { oracleId, location }),
searchLibrary: (filter: LibraryFilter) =>
invoke<LibraryCard[]>("search_library", { filter }),
searchReference: (filter: LibraryFilter, limit: number) =>
invoke<LibraryCard[]>("search_reference", { filter, limit }),
getLibraryStats: () => invoke<LibraryStats>("get_library_stats"),
getLibraryFacets: () => invoke<LibraryFacets>("get_library_facets"),
getReferenceFacets: () => invoke<LibraryFacets>("get_reference_facets"),
listDecks: () => invoke<Deck[]>("list_decks"),
createDeck: (name: string, commanderOracleId: string | null) =>
invoke<Deck>("create_deck", { name, commanderOracleId }),
deleteDeck: (deckId: number) => invoke<void>("delete_deck", { deckId }),
renameDeck: (deckId: number, name: string) => invoke<void>("rename_deck", { deckId, name }),
duplicateDeck: (deckId: number, newName: string) =>
invoke<Deck>("duplicate_deck", { deckId, newName }),
setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }),
setDeckCommander: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_commander", { deckId, oracleId }),
setDeckCard: (
deckId: number,
oracleId: string,
quantity: number,
isCommander = false,
isMaybeboard = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
suggestCards: (deckId: number, offset = 0, options: { unusual?: boolean; owned_only?: boolean } = {}) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
/** Every paper printing of a card, newest first. */
listPrintings: (oracleId: string) => invoke<PrintingRef[]>("list_printings", { oracleId }),
listLibraryCopies: (oracleId: string) =>
invoke<LibraryCopy[]>("list_library_copies", { oracleId }),
/** Set how many owned copies are this printing + finish (0 removes the
* group). Resolves with the card's owned quantity, which grows to fit. */
setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) =>
invoke<number>("set_library_copy", { oracleId, scryfallId, finish, quantity }),
/** Choose which printing a deck slot shows; null restores the default.
* Resolves with the slot afterwards (its art reflecting the choice). */
setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) =>
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }),
exportLibraryCards: (oracleIds: string[], path: string) =>
invoke<void>("export_library_cards", { oracleIds, path }),
/** `location`, unless blank, is set on every imported card. */
importLibraryCards: (path: string, location: string) =>
invoke<LibraryImportResult>("import_library_cards", { path, location }),
};