Printing tracking for owned copies and deck art, and an outdated-card-data notice

Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.

A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.

UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.

Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.

Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.

reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
This commit is contained in:
2026-10-16 21:06:17 -05:00
parent 1c5e045591
commit 214a535588
23 changed files with 2092 additions and 138 deletions

View File

@@ -6,8 +6,8 @@ use std::path::PathBuf;
use std::sync::Mutex;
use mtgc_core::{
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter,
LibraryStats, SuggestOptions,
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets,
LibraryFilter, LibraryStats, PrintingRef, SuggestOptions,
};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
@@ -28,6 +28,10 @@ pub struct Status {
/// Unix timestamp (seconds) of reference.db's last modification — i.e.
/// when "Update card data" last completed. `None` if unavailable.
pub reference_updated_at: Option<i64>,
/// Columns this build's card-data schema has that the installed card data
/// lacks (`"table.column"`). Non-empty means it was built by an older
/// version and should be updated; empty when current or not loaded.
pub reference_missing_columns: Vec<String>,
/// Whether this is a mobile build. The UI uses it to hide affordances that
/// only make sense with an arbitrary-path filesystem. Reported from the build
/// target rather than sniffed from the user agent so it can't disagree with
@@ -62,6 +66,11 @@ fn status_of(s: &AppState) -> Status {
reference_ready: s.catalog.is_some(),
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
reference_updated_at,
reference_missing_columns: s
.catalog
.as_ref()
.map(|c| c.missing_reference_columns().to_vec())
.unwrap_or_default(),
is_mobile: cfg!(mobile),
}
}
@@ -322,6 +331,54 @@ pub fn set_deck_card(
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_printings(db: Db, oracle_id: String) -> Res<Vec<PrintingRef>> {
let s = db.lock().unwrap();
s.catalog()?.list_printings(&oracle_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_library_copies(db: Db, oracle_id: String) -> Res<Vec<LibraryCopy>> {
let s = db.lock().unwrap();
s.catalog()?
.list_library_copies(&oracle_id)
.map_err(|e| e.to_string())
}
/// Returns the card's owned quantity afterwards, which grows when the copy
/// groups would otherwise exceed it.
#[tauri::command]
pub fn set_library_copy(
db: Db,
oracle_id: String,
scryfall_id: String,
finish: String,
quantity: i64,
) -> Res<i64> {
let s = db.lock().unwrap();
s.catalog()?
.set_library_copy(&oracle_id, &scryfall_id, &finish, quantity)
.map_err(|e| e.to_string())
}
/// Returns the slot afterwards, so the UI can show the resulting art without
/// knowing how the default is chosen.
#[tauri::command]
pub fn set_deck_card_printing(
db: Db,
deck_id: i64,
oracle_id: String,
scryfall_id: Option<String>,
) -> Res<Option<DeckEntry>> {
let s = db.lock().unwrap();
let catalog = s.catalog()?;
catalog
.set_deck_card_printing(deck_id, &oracle_id, scryfall_id.as_deref())
.map_err(|e| e.to_string())?;
let contents = catalog.deck_contents(deck_id).map_err(|e| e.to_string())?;
Ok(contents.into_iter().find(|e| e.card.oracle_id == oracle_id))
}
#[tauri::command]
pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
let s = db.lock().unwrap();

View File

@@ -99,6 +99,10 @@ pub fn run() {
commands::deck_contents,
commands::suggest_cards,
commands::card_decks,
commands::list_printings,
commands::list_library_copies,
commands::set_library_copy,
commands::set_deck_card_printing,
commands::export_deck,
commands::import_deck,
commands::export_library_cards,