"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>
373 lines
14 KiB
TypeScript
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 }),
|
|
};
|