Skip to content

Add Card and Variants

Pablo Martínez López edited this page Oct 21, 2026 · 8 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 }, { 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}).

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

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally