Files
mtgc/crates/mtgc-core/src/printings.rs
Garrett Mills 214a535588 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.
2026-10-16 21:06:17 -05:00

526 lines
20 KiB
Rust

//! Per-printing detail layered over the oracle-level library: which printings
//! a card has, which printing + finish owned copies are, and which printing's
//! art a deck slot shows.
//!
//! `library_cards.quantity_owned` stays the authoritative total. Copy groups
//! (`library_card_copies`) annotate a subset of it, and two rules keep them
//! consistent with that total:
//!
//! - **Grow:** tracking more copies of a printing than are unspecified raises
//! `quantity_owned` to fit — recording a copy means you own it.
//! - **Clamp:** lowering `quantity_owned` below the tracked total trims the
//! most recently added groups first ([`Catalog::clamp_copies`]). Lowering a
//! group never lowers `quantity_owned`; those copies become unspecified.
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::{CoreError, Result};
use crate::model::{LibraryCopy, PrintingRef};
use crate::Catalog;
/// The finishes a copy can be tracked as — Scryfall's `finishes` values.
pub const FINISHES: [&str; 3] = ["nonfoil", "foil", "etched"];
/// Map the ten printing columns starting at `at` (`scryfall_id, set_code,
/// collector_number, finishes, rarity, image_uri, back_image_uri, price_usd,
/// lang, released_at`) into a [`PrintingRef`], or `None` when `scryfall_id`
/// is NULL (an unmatched LEFT JOIN).
fn row_to_printing(r: &rusqlite::Row, at: usize) -> rusqlite::Result<Option<PrintingRef>> {
let Some(scryfall_id) = r.get::<_, Option<String>>(at)? else {
return Ok(None);
};
let finishes: String = r.get(at + 3)?;
Ok(Some(PrintingRef {
scryfall_id,
set_code: r.get(at + 1)?,
collector_number: r.get(at + 2)?,
finishes: split_finishes(&finishes),
rarity: r.get(at + 4)?,
image_uri: r.get(at + 5)?,
back_image_uri: r.get(at + 6)?,
price_usd: r.get(at + 7)?,
lang: r.get(at + 8)?,
released_at: r.get(at + 9)?,
}))
}
fn split_finishes(joined: &str) -> Vec<String> {
joined
.split(',')
.filter(|f| !f.is_empty())
.map(str::to_string)
.collect()
}
impl Catalog {
/// The printing columns [`row_to_printing`] reads, for the printing
/// aliased `p`. `lang`/`released_at` read as empty on a reference.db
/// built before they shipped.
fn printing_columns(&self) -> &'static str {
if self.has_printing_details {
"p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity,
p.image_uri, p.back_image_uri, p.price_usd, p.lang, p.released_at"
} else {
"p.scryfall_id, p.set_code, p.collector_number, p.finishes, p.rarity,
p.image_uri, p.back_image_uri, p.price_usd, '', ''"
}
}
/// Every paper printing of a card, newest first — the printing picker.
/// Arena/MTGO-only printings are left out, since no physical copy can be
/// one (and a deck's art should be something you could own).
pub fn list_printings(&self, oracle_id: &str) -> Result<Vec<PrintingRef>> {
// `digital` predates `released_at`, so a db can have the filter but
// not the ordering. Checked here rather than cached on open: only the
// picker needs it.
let filter = if crate::db::has_reference_column(&self.conn, "printings", "digital")? {
"AND p.digital = 0"
} else {
""
};
let order = if self.has_printing_details {
"p.released_at DESC, p.rowid"
} else {
"p.rowid"
};
let mut stmt = self.conn.prepare(&format!(
"SELECT {cols} FROM {REF_DB}.printings p
WHERE p.oracle_id = ?1 {filter}
ORDER BY {order}",
cols = self.printing_columns()
))?;
let rows = stmt
.query_map(params![oracle_id], |r| row_to_printing(r, 0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows.into_iter().flatten().collect())
}
/// The printing of `oracle_id` with this set code (case-insensitive) and
/// collector number — how decklists name a printing (`(2X2) 315`).
pub fn find_printing(
&self,
oracle_id: &str,
set_code: &str,
collector_number: &str,
) -> Result<Option<String>> {
Ok(self
.conn
.query_row(
&format!(
"SELECT scryfall_id FROM {REF_DB}.printings
WHERE oracle_id = ?1 AND lower(set_code) = lower(?2) AND collector_number = ?3
LIMIT 1"
),
params![oracle_id, set_code, collector_number],
|r| r.get(0),
)
.optional()?)
}
/// A card's tracked copy groups, oldest first.
pub fn list_library_copies(&self, oracle_id: &str) -> Result<Vec<LibraryCopy>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT c.scryfall_id, c.finish, c.quantity, {cols}
FROM library_card_copies c
JOIN library_cards lc ON lc.id = c.library_card_id
LEFT JOIN {REF_DB}.printings p ON p.scryfall_id = c.scryfall_id
WHERE lc.oracle_id = ?1
ORDER BY c.id",
cols = self.printing_columns()
))?;
let rows = stmt
.query_map(params![oracle_id], |r| {
Ok(LibraryCopy {
scryfall_id: r.get(0)?,
finish: r.get(1)?,
quantity: r.get(2)?,
printing: row_to_printing(r, 3)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Check that `scryfall_id` is a printing of `oracle_id` and return the
/// finishes it comes in (empty when Scryfall lists none).
pub(crate) fn printing_finishes(&self, oracle_id: &str, scryfall_id: &str) -> Result<Vec<String>> {
let finishes: Option<String> = self
.conn
.query_row(
&format!(
"SELECT finishes FROM {REF_DB}.printings WHERE scryfall_id = ?1 AND oracle_id = ?2"
),
params![scryfall_id, oracle_id],
|r| r.get(0),
)
.optional()?;
finishes
.map(|f| split_finishes(&f))
.ok_or_else(|| CoreError::Other("that isn't a printing of this card".into()))
}
/// Set how many owned copies of a card are `scryfall_id` in `finish`.
/// `quantity <= 0` removes the group (never validated against the
/// reference db, so a group whose printing has since vanished can still
/// be removed). Grows `quantity_owned` when the groups would otherwise
/// exceed it. Returns the card's owned quantity afterwards.
pub fn set_library_copy(
&self,
oracle_id: &str,
scryfall_id: &str,
finish: &str,
quantity: i64,
) -> Result<i64> {
if quantity > 0 {
if !FINISHES.contains(&finish) {
return Err(CoreError::Other(format!("unknown finish \"{finish}\"")));
}
let finishes = self.printing_finishes(oracle_id, scryfall_id)?;
if !finishes.is_empty() && !finishes.iter().any(|f| f == finish) {
return Err(CoreError::Other(format!(
"that printing doesn't come in {finish}"
)));
}
}
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO library_cards(oracle_id) VALUES (?1) ON CONFLICT(oracle_id) DO NOTHING",
params![oracle_id],
)?;
let card_id: i64 = tx.query_row(
"SELECT id FROM library_cards WHERE oracle_id = ?1",
params![oracle_id],
|r| r.get(0),
)?;
if quantity <= 0 {
tx.execute(
"DELETE FROM library_card_copies
WHERE library_card_id = ?1 AND scryfall_id = ?2 AND finish = ?3",
params![card_id, scryfall_id, finish],
)?;
} else {
tx.execute(
"INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(library_card_id, scryfall_id, finish)
DO UPDATE SET quantity = excluded.quantity",
params![card_id, scryfall_id, finish, quantity],
)?;
}
tx.execute(
"UPDATE library_cards SET quantity_owned = MAX(quantity_owned,
(SELECT COALESCE(SUM(quantity), 0) FROM library_card_copies WHERE library_card_id = ?1))
WHERE id = ?1",
params![card_id],
)?;
let owned: i64 = tx.query_row(
"SELECT quantity_owned FROM library_cards WHERE id = ?1",
params![card_id],
|r| r.get(0),
)?;
tx.commit()?;
Ok(owned)
}
/// Trim a card's copy groups until they fit within `quantity_owned`,
/// most recently added first: the newest annotation is the likeliest to
/// be the copy that just left, and older entries are kept intact.
pub(crate) fn clamp_copies(&self, oracle_id: &str) -> Result<()> {
let Some((card_id, owned)) = self
.conn
.query_row(
"SELECT id, quantity_owned FROM library_cards WHERE oracle_id = ?1",
params![oracle_id],
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
)
.optional()?
else {
return Ok(());
};
let groups: Vec<(i64, i64)> = {
let mut stmt = self.conn.prepare(
"SELECT id, quantity FROM library_card_copies WHERE library_card_id = ?1 ORDER BY id",
)?;
let rows = stmt.query_map(params![card_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<std::result::Result<_, _>>()?
};
let mut excess = groups.iter().map(|(_, q)| q).sum::<i64>() - owned;
for (id, qty) in groups.iter().rev() {
if excess <= 0 {
break;
}
let cut = (*qty).min(excess);
if cut == *qty {
self.conn
.execute("DELETE FROM library_card_copies WHERE id = ?1", params![id])?;
} else {
self.conn.execute(
"UPDATE library_card_copies SET quantity = ?1 WHERE id = ?2",
params![qty - cut, id],
)?;
}
excess -= cut;
}
Ok(())
}
/// Choose which printing's art a deck slot shows, or `None` for the
/// card's default. Fails if the card isn't in the deck.
pub fn set_deck_card_printing(
&self,
deck_id: i64,
oracle_id: &str,
scryfall_id: Option<&str>,
) -> Result<()> {
if let Some(id) = scryfall_id {
self.printing_finishes(oracle_id, id)?;
}
let changed = self.conn.execute(
"UPDATE deck_cards SET preferred_scryfall_id = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
params![scryfall_id, deck_id, oracle_id],
)?;
if changed == 0 {
return Err(CoreError::Other("that card isn't in this deck".into()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
use crate::Catalog;
/// Give Sol Ring two extra printings alongside the fixture's own
/// ("print-sol-ring", no finishes listed, no date).
fn add_sol_ring_printings(cat: &Catalog) {
cat.connection()
.execute_batch(
"INSERT INTO ref.printings
(scryfall_id, oracle_id, set_code, collector_number, finishes, image_uri, back_image_uri, released_at, digital)
VALUES
('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil', 'http://img/2x2', '', '2022-07-08', 0),
('sol-c21', 'sol-ring', 'c21', '263', 'nonfoil', 'http://img/c21', 'http://img/c21-back', '2021-04-23', 0),
('sol-arena', 'sol-ring', 'ymid', '1', 'nonfoil', 'http://img/arena', '', '2023-01-01', 1);",
)
.unwrap();
}
#[test]
fn list_printings_is_newest_first_and_paper_only() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
let ids: Vec<_> = cat
.list_printings("sol-ring")
.unwrap()
.into_iter()
.map(|p| p.scryfall_id)
.collect();
// The fixture printing has no date, so it sorts last.
assert_eq!(ids, vec!["sol-2x2", "sol-c21", "print-sol-ring"]);
let p = &cat.list_printings("sol-ring").unwrap()[0];
assert_eq!(p.finishes, vec!["nonfoil", "foil"]);
assert_eq!(p.set_code, "2x2");
}
#[test]
fn find_printing_matches_set_case_insensitively() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
assert_eq!(
cat.find_printing("sol-ring", "2X2", "315").unwrap().as_deref(),
Some("sol-2x2")
);
assert_eq!(cat.find_printing("sol-ring", "2X2", "999").unwrap(), None);
// Right set and number, wrong card.
assert_eq!(cat.find_printing("lightning-bolt", "2X2", "315").unwrap(), None);
}
#[test]
fn copy_groups_track_several_printings_of_one_card() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
cat.set_owned("sol-ring", 5).unwrap();
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap();
cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap();
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap();
let copies = cat.list_library_copies("sol-ring").unwrap();
let summary: Vec<_> = copies
.iter()
.map(|c| (c.scryfall_id.as_str(), c.finish.as_str(), c.quantity))
.collect();
assert_eq!(
summary,
vec![("sol-2x2", "nonfoil", 2), ("sol-2x2", "foil", 1), ("sol-c21", "nonfoil", 1)]
);
assert_eq!(copies[0].printing.as_ref().unwrap().set_code, "2x2");
// Tracking within the owned total doesn't change it.
let lib = cat.list_library().unwrap();
assert_eq!(lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap().quantity_owned, 5);
// Setting a group again updates it in place; zero removes it.
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 3).unwrap();
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap();
let copies = cat.list_library_copies("sol-ring").unwrap();
assert_eq!(copies.len(), 2);
assert_eq!(copies[0].quantity, 3);
}
#[test]
fn tracking_more_copies_than_owned_grows_ownership() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
cat.set_owned("sol-ring", 1).unwrap();
assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 3).unwrap(), 3);
// Even for a card not yet in the library at all.
assert_eq!(cat.set_library_copy("lightning-bolt", "print-lightning-bolt", "nonfoil", 2).unwrap(), 2);
// Lowering a group never lowers ownership.
assert_eq!(cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap(), 3);
}
#[test]
fn lowering_ownership_trims_the_newest_groups_first() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
cat.set_owned("sol-ring", 5).unwrap();
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 2).unwrap(); // oldest
cat.set_library_copy("sol-ring", "sol-2x2", "foil", 1).unwrap();
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 2).unwrap(); // newest
// 5 tracked, now 3 owned: dropping the newest group (2) is enough.
cat.set_owned("sol-ring", 3).unwrap();
let q: Vec<_> = cat
.list_library_copies("sol-ring")
.unwrap()
.into_iter()
.map(|c| (c.scryfall_id, c.finish, c.quantity))
.collect();
assert_eq!(
q,
vec![("sol-2x2".into(), "nonfoil".into(), 2), ("sol-2x2".into(), "foil".into(), 1)]
);
// Through the decrement path too, partially trimming a group.
cat.add_owned("sol-ring", -2).unwrap();
let q: Vec<_> = cat
.list_library_copies("sol-ring")
.unwrap()
.into_iter()
.map(|c| (c.finish, c.quantity))
.collect();
assert_eq!(q, vec![("nonfoil".into(), 1)]);
}
#[test]
fn copies_are_validated_against_the_card_and_its_finishes() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
// A printing of a different card.
assert!(cat.set_library_copy("sol-ring", "print-lightning-bolt", "nonfoil", 1).is_err());
// A finish this printing doesn't come in.
assert!(cat.set_library_copy("sol-ring", "sol-c21", "foil", 1).is_err());
// Not a finish at all.
assert!(cat.set_library_copy("sol-ring", "sol-2x2", "shiny", 1).is_err());
// A printing that lists no finishes accepts any real one.
cat.set_library_copy("sol-ring", "print-sol-ring", "etched", 1).unwrap();
}
#[test]
fn a_copy_whose_printing_vanished_is_listed_and_removable() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 1).unwrap();
// A re-ingest that no longer has it.
cat.connection()
.execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", [])
.unwrap();
let copies = cat.list_library_copies("sol-ring").unwrap();
assert_eq!(copies.len(), 1);
assert!(copies[0].printing.is_none());
cat.set_library_copy("sol-ring", "sol-c21", "nonfoil", 0).unwrap();
assert!(cat.list_library_copies("sol-ring").unwrap().is_empty());
}
#[test]
fn a_deck_slot_shows_its_chosen_printing() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
let d = cat.create_deck("Art", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
let sol = |cat: &Catalog| {
cat.deck_contents(d.id)
.unwrap()
.into_iter()
.find(|e| e.card.oracle_id == "sol-ring")
.unwrap()
};
// Unset: the default image.
assert_eq!(sol(cat).card.image_uri, "http://img/sol-ring");
assert_eq!(sol(cat).preferred_scryfall_id, None);
// Set: both faces come from the chosen printing.
cat.set_deck_card_printing(d.id, "sol-ring", Some("sol-c21")).unwrap();
let e = sol(cat);
assert_eq!(e.preferred_scryfall_id.as_deref(), Some("sol-c21"));
assert_eq!(e.card.image_uri, "http://img/c21");
assert_eq!(e.card.back_image_uri, "http://img/c21-back");
// Survives a quantity/board change.
cat.set_deck_card(d.id, "sol-ring", 2, false, true).unwrap();
assert_eq!(sol(cat).card.image_uri, "http://img/c21");
// Kept by a duplicate.
let copy = cat.duplicate_deck(d.id, "Art copy").unwrap();
let dup = cat.deck_contents(copy.id).unwrap();
assert_eq!(dup[0].preferred_scryfall_id.as_deref(), Some("sol-c21"));
// Dangling after a re-ingest: falls back to the default.
cat.connection()
.execute("DELETE FROM ref.printings WHERE scryfall_id = 'sol-c21'", [])
.unwrap();
let e = sol(cat);
assert_eq!(e.card.image_uri, "http://img/sol-ring");
assert_eq!(e.card.back_image_uri, "");
// Cleared.
cat.set_deck_card_printing(d.id, "sol-ring", None).unwrap();
assert_eq!(sol(cat).preferred_scryfall_id, None);
}
#[test]
fn deck_printing_is_validated() {
let fx = test_support::make();
let cat = &fx.catalog;
add_sol_ring_printings(cat);
let d = cat.create_deck("Art", None).unwrap();
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
// Not a printing of this card.
assert!(cat.set_deck_card_printing(d.id, "sol-ring", Some("print-lightning-bolt")).is_err());
// Card not in the deck.
assert!(cat.set_deck_card_printing(d.id, "lightning-bolt", Some("print-lightning-bolt")).is_err());
}
}