Files
mtgc/src/api.ts
garrettmills df2cbb372a Add a "not owned" quick filter and horizontal tile view to Card Search
"Not owned" moves into the shared Quick filters icon-toggle row (hidden on
the Library tab, where it'd always be a no-op since that view is owned-only
already) instead of a standalone button.

Tiles view gains a "Horizontal" toggle that rotates actually-horizontal
cards (Battles, Planes, Phenomena) into their natural landscape orientation
via a container-query-sized wrapper, spanning two grid columns so the
rotated card isn't squeezed into a single portrait column's width and left
too small to read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 19:14:27 -05:00

373 lines
14 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;
}
/**
* 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[];
};
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;
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;
}
export interface Status {
library_path: string;
reference_path: string;
reference_ready: boolean;
card_count: number;
reference_updated_at: number | null; // unix seconds
/** 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;
}
/** State of the user's library document — the JSON file they keep in whatever
* folder they sync. The SQLite database behind it is internal and never shown. */
export interface SyncStatus {
/** Whether a document has been chosen. */
chosen: boolean;
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
* can be revoked and a desktop folder can be deleted or unmounted — both need
* re-picking rather than first-time setup. */
granted: boolean;
label: string | null;
/** True where the folder is chosen through the system picker (Android) rather
* than by path — the one thing the UI branches on. */
picker_based: boolean;
/** A pre-document library.db from an older build, waiting to be converted. */
legacy_library: string | null;
/** Which file in the granted folder is the library (Android). Null on
* desktop, where the whole path is chosen at once. */
document_name: string | null;
}
/** What one sync did. */
export interface SyncReport {
/** Whether the merge altered the local library. */
changed: boolean;
/** Whether the document was rewritten (skipped when we had nothing to add). */
published: boolean;
/** Conflict copies absorbed and cleaned up. */
absorbed: number;
report: {
cards_changed: number;
decks_changed: number;
decks_removed: number;
/** Fields both sides changed differently, described in user terms. */
conflicts: string[];
};
}
/** 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"),
/** Merge the library document with local edits and publish the result.
* Resolves with `null` when a sync was already running and this call stood
* down — expected, since sync also fires automatically. */
syncNow: () => invoke<SyncReport | null>("sync_now"),
/** Android: grant access to a folder through the system picker. */
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
/** Desktop: adopt a library document chosen with the file dialog. */
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
/** Library files already in the chosen folder, for Android's second step. */
syncListDocuments: () => invoke<string[]>("sync_list_documents"),
/** Android: choose which file in the granted folder is the library. */
syncSetDocumentName: (name: string) =>
invoke<SyncStatus>("sync_set_document_name", { name }),
/** Convert a pre-document library.db into the current format. */
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { 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, unusual = false) =>
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, unusual }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
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 }),
importLibraryCards: (path: string) =>
invoke<LibraryImportResult>("import_library_cards", { path }),
};