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.
524 lines
20 KiB
TypeScript
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 }),
|
|
};
|