Skip to content

Add Card and Variants

Pablo Martínez López edited this page Oct 26, 2026 · 6 revisions

Add Card and Variants

English · Español

The dialogs in src/features/variants/ that put a single card into the deck from Scryfall ("Add card"), swap the printing of a card already in the deck ("Change variant"), and link an own-image card to its real Scryfall card ("Link with a card", one at a time or in bulk). They all share one results grid and one fetch helper, and talk to Rust through card_search, card_prints, card_fetch and friends.

Screenshots: Add card Variants

Files

File Responsibility
src/features/variants/add-card-dialog.tsx AddCardDialog: search (name or Scryfall syntax, autocomplete), pick a printing, copies, add
src/features/variants/variant-dialog.tsx VariantDialog: all printings of a card, replace this copy or all copies
src/features/variants/rejilla-cartas.tsx RejillaCartas: the shared thumbnail grid (also used by Set Browser and the token dialog of the Decklist Panel)
src/features/variants/filtros.tsx FiltrosBusqueda (language + "high resolution only"; the language options are "any" plus IDIOMAS_SCRYFALL of src/lib/idiomas.ts, by their own names), idiomaParaRust
src/features/variants/traer.ts traerCarta: summary -> DeckCard, choosing the Scryfall or the external-source command
src/features/variants/vincular.ts vincular, desvincular, propiasPorImagen, estaVinculada
src/features/variants/vincular-dialog.tsx VincularDialog: link one own card (search or OCR)
src/features/variants/vincular-lote.tsx VincularLoteDialog: bulk link with review
src/features/variants/ampliar-cartas.tsx AmpliarCartas: side-by-side comparison of the own image and the proposed Scryfall card
src/features/variants/__tests__/vincular.test.ts Vitest for vincular.ts

All dialogs are mounted once in src/App.tsx and opened through the dialogo field of useUi (src/stores/ui-store.ts): { tipo: "anadir"; en?: number; hueco?: string; cambiar?: string }, { tipo: "variante"; id }, { tipo: "vincular"; id }, { tipo: "vincularLote"; ids?; soloSeleccion? }. Each dialog component renders its content only while open, so its local state (query, results, selection) resets every time.

Rust commands used

All in src-tauri/src/commands/cards.rs unless noted. Arguments are camelCase on the JS side (highresOnly, scryfallId).

Command Args Returns What it does
card_autocomplete q string[] Scryfall autocomplete. Returns [] without a call if q.trim().len() < 2.
card_search q, lang?, highresOnly CardSummary[] Free-text / Scryfall-syntax search, capped at 50. Appends Cockatrice tokens when lang is empty or en and not high-res-only (fuentes::buscar_cockatrice, merged by juntar_externas: after the Scryfall results, or before them when an external token matches the query name exactly and Scryfall has none with that name).
card_prints name, lang?, highresOnly CardSummary[] Every printing of an exact name (!"name", quotes stripped from the name), unique=prints with include_multilingual, up to 175. A Scryfall failure is swallowed (unwrap_or_default): a token Scryfall lacks is not an error. Adds Cockatrice entries under the same language rule.
card_fetch scryfallId DeckCard Downloads one printing and returns it as a deck card (see below).
card_fetch_externa fuente (FuenteCarta), id DeckCard Same for a card from another source: an own card with its image and the link (fuentes::carta_cockatrice).
card_link (commands/identify.rs) scryfallId CardMeta Card data for linking: from the local cache if present, otherwise Scryfall (and stored in the cache so statistics work offline).
card_link_externa (commands/identify.rs) fuente, id CardMeta The same for an external token (NotFound if the catalog lacks it).
card_identify (commands/identify.rs) blob, hint?, ocr CardIdentify Propose cards for an own image (see Card Identification and OCR).
identify_batch (commands/identify.rs) items[{blob, hint}], ocr u32 job id Bulk version with identify://progress / identify://done events.

CardSummary (commands/cards.rs, exported to src/bindings/CardSummary.ts) is the light row used by every grid: id, name, printedName?, lang, set, setName, collectorNumber, thumb?, highres, imageStatus?, isDfc, typeLine?, fuente?. fuente is set only for cards from another source; id is then that source's id.

Thumbnails

resumir(state, cartas) builds the summaries. For each card it takes the front face's normal (or small) image URL, hashes it with SHA-256 (huella_url) and registers huella -> url in AppState.miniaturas (a mutex-guarded map that is cleared when it exceeds 5,000 entries). The grid then loads urlBusqueda(c.thumb) = pbimg://…/t/<huella>. The protocol handler (src-tauri/src/protocol.rs, Ruta::Miniatura) serves it from app_cache_dir/renders/t/<huella>.jpg if present, otherwise looks the URL up in miniaturas, downloads it (Scryfall URLs via the Scryfall client; any other URL only through fuentes::Externo::bajar and limpiar_imagen), writes it to the cache and returns it. Why: the web view never touches the network (project rule: all network is Rust). A hash that is not registered (for example after the 5,000-entry reset) gives 404 and the card shows without image until searched again.

What card_fetch returns

traer_carta (commands/cards.rs, generic over CardApi so tests use a fake Scryfall):

  1. Fetches the card (NotFound with carta = id if missing) and stores it in the Scryfall cache (cache::guardar).
  2. Downloads the front face PNG (c.print_url(0); if the card has no PNG it uses the large JPEG: ImageOrigin::ScryfallPng vs ScryfallLarge) and puts it in the blob store (state.blobs.put_bytes).
  3. For a double-faced card (c.is_dfc()) downloads face 1 and sets back = BackSpec::Dfc { image }; otherwise BackSpec::DeckDefault.
  4. Builds the DeckCard: new UUID id; source = CardSource::Token { scryfall_id } if the layout is token or double_faced_token, else CardSource::Scryfall { scryfall_id }; default edits, locked: false, meta from resolve::meta_de, design: None, marca: None. A card without image enters with front: None and downloads nothing.

The command holds the busy marker (state.trabajo()), the same marker that blob_gc consults through state.ocupado(), so the garbage collector will not run mid-download.

Add card dialog

Opened from any empty cell of the last sheet (CeldaVacia in src/features/preview/page-view.tsx), from the empty states in preview-pane.tsx, and from a blank slot: a plain left click or the "Add card from Scryfall…" item of its context menu (anadirEnHueco in acciones-carta.ts). The empty cells and the toolbar add at the end. A blank slot sets en (its position) and hueco (its id): on adding, the blank is removed (quitar) and the cards are inserted in its place, so the new card takes the slot instead of pushing it along.

Behavior (add-card-dialog.tsx):

  • Search box: 200 ms after typing (when at least 2 characters) it requests card_autocomplete and shows up to 8 suggestions. Enter runs card_search with the raw query (so t:dragon c:r works); clicking a suggestion runs card_prints with the exact name, so the grid lists printings, not different cards.
  • Filters (FiltrosBusqueda): language (default: the deck language useUi.lang, but any when the deck language is English, so the first search is not restricted) and "Only high resolution". idiomaParaRust converts "any" to null.
  • Results: RejillaCartas; single click selects, double click selects and adds. If exactly one result comes back it is auto-selected. A NotFound error is shown as "No results", every other error as a toast.
  • Footer: the selected printing, a "Copies" number (1-99) and "Add". anadir calls traerCarta(c) once; copies after the first are clones with a fresh nuevoId() (same images, no extra download). They are inserted with useDeck.getState().setCartas((cs) => insertar(hueco ? quitar(cs, [hueco]) : cs, copias, en)) (one undo step) and announce "N x card added".
  • The dialog refuses to close while adding (ocupado={anadiendo}).

"Change card" mode

The same dialog opened with cambiar (the id of a deck card) is "Change card": the context-menu item "Change card…" (slot-menu.tsx, only for a single selected card with source.kind scryfall or token, like "Change variant"). It replaces a card with a different one, where "Change variant" only swaps the printing of the same card. The title and description change, the "Copies" field disappears, and the footer has "Change" (this copy; also what a double click does) and, when the deck has more copies of the same card (scryfallDe equal), "Change all copies". sustituir calls traerCarta(c) and keeps for each replaced copy its id, locked, marca and a custom back (back.kind === "custom"); everything else (edits, design) belonged to the old card and comes from the new one. One setCartas call, so a single undo reverts it. It announces "Changed to {card}".

Variant dialog ("Change variant")

Opened from the card context menu (src/features/preview/slot-menu.tsx), enabled only for a single selected card whose source.kind is scryfall or token. Custom images and designed cards cannot change variant.

  • On open (and whenever name, language or high-res changes) it calls card_prints { name: carta.meta.name, lang, highresOnly }. The language filter starts at the card's own meta.lang (or any). A vigente flag discards stale responses.
  • A large preview of the chosen printing appears on the right (urlBusqueda(elegida.thumb)), with set name, collector number and language.
  • Two actions, each calling traerCarta(elegida) once:
    • "Replace only this" replaces the card with the same id as the one the dialog was opened for.
    • "Replace the N copies" (only shown when copias > 1): replaces every card whose meta.name equals the name. Matching is by name, not by Scryfall id, so copies of different printings of the same card are all replaced.
  • reemplazar(vieja, nueva) keeps what belongs to the slot, not to the printing: id, locked, edits (rotation, filters, etc.) and a custom back (back.kind === "custom"); everything else (images, meta, source, DFC back) comes from the new card.
  • Applied through one setCartas call, so one Ctrl+Z reverts it. Announces "Variant changed" or "Variant changed in all copies of X".

Shared grid (RejillaCartas)

A CSS grid (repeat(auto-fill, minmax(120px, 1fr)), p-1.5 so rings are not clipped) of Tarjeta buttons. Rules it implements:

  • Decision: thumbnails only exist while the card is near the viewport. Why: a search can return 175 cards and the project mounts images only for visible items. Tarjeta uses useVisible(el) from src/features/preview/visibilidad; the enclosing scroll area must publish its viewport through RaizScroll.Provider (dialogs pass onViewportChange={setViewport}). Without the provider, visibility cannot be computed.
  • Badges: language code, COCKATRICE (when fuente is set: a token Scryfall does not have) and LOWRES (when not highres and not external).
  • Selection is a Set<string> of ids owned by the parent; the grid only reports onElegir(c, event) and onDoble(c).

Linking own cards to Scryfall

Linking says which real card an own image is, without changing the image: the card gets name, set, type and prices for statistics and info. It is stored as CardSource::Custom { scryfallId?, externa? } (externa is RefExterna { fuente, id } for a token Scryfall lacks). CardMeta.imageLang stays the language of the image itself. See Deck Model.

vincular(id, destino, idiomaImagen?) (vincular.ts):

  1. Calls card_link (or card_link_externa) to get the CardMeta.
  2. Sets the new source and meta on the card and on every own copy that has the same front blob (c.front?.blob === blob), so one link applies to all copies of that image. The meta overrides imageStatus: null, langFallback: false, keeps the card's highres, and sets imageLang to the OCR-read language or keeps the previous one.
  3. If Rust fails nothing changes (the store is only touched after the await).
  4. desvincular resets source to { kind: "custom" } for the card and its same-image copies. propiasPorImagen(ids?) returns one own card per image (optionally within a set of ids); estaVinculada(c) is true for a custom card with scryfallId or externa.

VincularDialog (opened from the card menu "Link with a card…" / "Change the link…"): the search box starts with the card's current name and runs card_identify with hint and ocr: false; the "Detect with OCR" button runs it with ocr: true, which may download the OCR models first, and shows the lines read plus the language read from the footer. The best candidate is preselected; "Remove the link" calls desvincular.

VincularLoteDialog: phases elegir -> leyendo -> revisar -> aplicando. It launches identify_batch through lanzarTrabajo("identify://progress", "identify://done", …) with one { blob, hint: c.meta.name } per own image, receives one result per image (p.result: CardIdentify, confidence: exact | probable | none), and shows rows with the proposal pre-checked for exact and probable matches. The user reviews, can change the candidate, compare in AmpliarCartas, then "apply" runs vincular sequentially for every checked row (cancellable). With a selection it can work on the selection or the whole deck (soloSeleccion, ids from useUi). Cancelling the read uses job_cancel.

Failure modes

Situation Behavior
Scryfall unreachable Network error -> toast; the grid shows "No results" with empty resultados
Scryfall rate limit RateLimited after the client's retries
card_fetch for an id Scryfall does not know NotFound with param carta
Card without image on Scryfall Enters with front: null (the MISSING / PLACEHOLDER badge comes from meta.imageStatus)
Unregistered thumbnail hash 404 from pbimg://…/t/…, empty tile
No results for a variant search "There are no printings with those filters."

Tests

  • src/features/variants/__tests__/vincular.test.ts: linking applies to same-image copies, takes info but not image state, keeps image and edits, own card without image only links itself, unlinking, relinking, Rust failure leaves the deck unchanged, propiasPorImagen, estaVinculada.
  • src-tauri/src/commands/cards.rs (mod tests): language filter, search and print query builders (quotes stripped), thumbnail registration and its 5,000 cap, traer_carta for a normal card (PNG + deck back), no-PNG fallback to large, DFC (two faces), tokens enter as CardSource::Token, card without image, unknown card is NotFound.

Where to start when extending

  • A new search filter: extend FiltrosBusqueda and the query builders consulta_busqueda / consulta_impresiones in commands/cards.rs, then pass the new argument in add-card-dialog.tsx and variant-dialog.tsx.
  • A new external source of cards: add a FuenteCarta variant and handle it in card_fetch_externa, card_link_externa and traerCarta (see External Sources).
  • Anything that must look at images in a dialog grid: reuse RejillaCartas and provide RaizScroll.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally