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

@@ -205,6 +205,39 @@ Marvel Commander — isn't hidden.
> once to enable them; until then they're inert rather than broken. Price,
> format legality and *can be a commander* work immediately.
### Printings
The library counts cards, not printings, but you can say which printing a copy
is when it matters. Click a card's image in the **Library** or a deck to open
its preview. The **Owned** section lists your copies by printing and finish
(nonfoil / foil / etched), with a stepper for each, and **Add a printing…**
picks another. Copies you haven't assigned a printing to stay *unspecified*:
"5 copies · 3 by printing · 2 unspecified". Assigning more copies to printings
than you own raises the owned count to match. Lowering the owned count drops
the most recently added printing entries first.
In a deck, **Shown in this deck → Change…** picks which printing's art that
slot shows, and **Default** goes back to the usual one. This is display only:
it doesn't need to be a copy you own.
Decklist export writes a chosen or tracked printing the way Moxfield and Arena
do, and import reads it back:
```text
1 Sol Ring (2X2) 315 *F*
4 Lightning Bolt (2XM) 129
```
`*F*` marks foil and `*E*` etched. A library export writes one line per
printing, then a plain line for the unspecified copies. A suffix that doesn't
match any printing still imports the card itself.
> The picker's dates and languages need card data from this version. Until you
> update it, printings are listed without them. Whenever your card data is
> missing something the app could use, a notice under the top bar offers
> **Update card data**, and **Import card data…** too on a phone. Dismissing it
> with **Not now** lasts until a later version needs something more.
## Sync
Your library always lives on the device you're using, so the app works the
@@ -235,6 +268,13 @@ either is lost) or **use the server's library** instead of this device's.
The Info tab also lists the account's signed-in devices, so a lost phone can be
signed out from another one.
> **Printings and older versions.** A library with printings in it (see
> [Printings](#printings)) is saved in a newer format. A device or server
> running an older version refuses it and says to update, rather than syncing
> and quietly losing them. Libraries that don't use printings stay readable by
> older versions. Update the sync server before the first device starts using
> printings.
> **Upgrading from folder sync?** Earlier versions kept the library as a JSON
> file in a folder your sync tool watched. Your library is already on each
> device, so signing in to a server picks up from there; the first device to

View File

@@ -18,6 +18,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0003_deck_format.sql"),
include_str!("sql/library/0004_deck_maybeboard.sql"),
include_str!("sql/library/0005_deck_uuid.sql"),
include_str!("sql/library/0006_printings.sql"),
];
/// The attached alias under which the reference db is available at runtime.
@@ -160,6 +161,50 @@ pub fn has_reference_column(conn: &Connection, table: &str, column: &str) -> Res
)? > 0)
}
/// Columns the reference schema this build ships has, but the attached
/// reference db lacks, as `"table.column"` — i.e. what a re-ingest would add.
/// Empty means the card data is current.
///
/// Derived by applying [`REFERENCE_SCHEMA`] to a scratch in-memory db and
/// comparing, so a column added to the schema is picked up here with no list
/// to keep in step. FTS5's internal shadow tables are skipped; they belong to
/// SQLite, not to the schema.
pub fn missing_reference_columns(conn: &Connection) -> Result<Vec<String>> {
let expected = Connection::open_in_memory()?;
expected.execute_batch(REFERENCE_SCHEMA)?;
let all: Vec<(String, String)> = {
let mut stmt = expected.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table'")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
rows.collect::<std::result::Result<_, _>>()?
};
let virtual_tables: Vec<&str> = all
.iter()
.filter(|(_, sql)| sql.starts_with("CREATE VIRTUAL"))
.map(|(name, _)| name.as_str())
.collect();
// Ordinary tables only: neither a virtual table nor one of its
// `<name>_data`/`<name>_idx`/... shadow tables.
let tables = all.iter().map(|(name, _)| name).filter(|name| {
!virtual_tables
.iter()
.any(|v| *name == v || name.starts_with(&format!("{v}_")))
});
let mut missing = Vec::new();
for table in tables {
let columns: Vec<String> = {
let mut stmt = expected.prepare(&format!("SELECT name FROM pragma_table_info('{table}')"))?;
let rows = stmt.query_map([], |r| r.get(0))?;
rows.collect::<std::result::Result<_, _>>()?
};
for column in columns {
if !has_reference_column(conn, table, &column)? {
missing.push(format!("{table}.{column}"));
}
}
}
Ok(missing)
}
/// Apply any library migrations newer than the recorded `user_version`.
pub fn migrate_library(conn: &Connection) -> Result<()> {
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
@@ -219,6 +264,38 @@ mod tests {
assert_eq!(hit, 1);
}
#[test]
fn card_data_built_from_this_schema_is_not_outdated() {
let dir = tempfile::tempdir().unwrap();
let reference = dir.path().join("reference.db");
open_reference_for_build(&reference).unwrap();
let conn = Connection::open_in_memory().unwrap();
attach_reference(&conn, &reference).unwrap();
assert_eq!(missing_reference_columns(&conn).unwrap(), Vec::<String>::new());
}
#[test]
fn card_data_from_an_older_schema_reports_what_it_lacks() {
let dir = tempfile::tempdir().unwrap();
let reference = dir.path().join("reference.db");
{
// As an older ingester left it: printings without the picker's
// newer columns.
let older = REFERENCE_SCHEMA
.replace(" lang TEXT NOT NULL DEFAULT '',\n", "")
.replace(",\n -- Scryfall language", "\n -- Scryfall language")
.replace(" released_at TEXT NOT NULL DEFAULT ''\n", "");
assert_ne!(older, REFERENCE_SCHEMA, "fixture must actually drop the columns");
Connection::open(&reference).unwrap().execute_batch(&older).unwrap();
}
let conn = Connection::open_in_memory().unwrap();
attach_reference(&conn, &reference).unwrap();
assert_eq!(
missing_reference_columns(&conn).unwrap(),
vec!["printings.lang".to_string(), "printings.released_at".to_string()]
);
}
#[test]
fn inspect_reference_db_accepts_a_real_db_and_rejects_bad_ones() {
let dir = tempfile::tempdir().unwrap();

View File

@@ -25,8 +25,21 @@
//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped,
//! never a hard error — see [`parse_deck_text`].
//!
//! A line may name a printing the way Moxfield and Arena do, with an
//! optional finish marker after it (`*F*` foil, `*E*` etched):
//! ```text
//! 1 Sol Ring (2X2) 315 *F*
//! ```
//! Deck export writes the suffix for a slot with a chosen printing; deck
//! import restores that choice. A suffix that doesn't match a printing of the
//! card still imports the card itself.
//!
//! Library export format is the same comment/quantity-name line convention
//! with no sections — see [`parse_library_text`].
//! with no sections — see [`parse_library_text`]. Each tracked copy group gets
//! its own line with a printing suffix, followed by a plain line for any
//! unspecified copies; import adds each line's copies back into its group.
use std::collections::HashMap;
use rusqlite::{params, OptionalExtension};
@@ -40,6 +53,40 @@ use crate::Catalog;
pub struct DecklistLine {
pub quantity: i64,
pub name: String,
/// The printing named by a `(SET) NUM` suffix, if the line had one.
pub printing: Option<LinePrinting>,
}
/// A printing as a decklist names it: set code and collector number, plus a
/// finish marker when one followed.
#[derive(Debug, Clone, PartialEq)]
pub struct LinePrinting {
/// As written (decklists usually uppercase it; Scryfall stores lowercase).
pub set_code: String,
pub collector_number: String,
/// "foil" or "etched" from a `*F*`/`*E*` marker; `None` when unmarked.
pub finish: Option<String>,
}
impl DecklistLine {
/// The name exactly as the line spelled it, suffix included — the
/// fallback for the (rare) card whose real name ends in a parenthesis.
fn unparsed_name(&self) -> Option<String> {
self.printing
.as_ref()
.map(|p| format!("{} ({}) {}", self.name, p.set_code, p.collector_number))
}
}
/// The ` (SET) NUM` suffix for a printing, with the set uppercased as
/// decklist tools write it, plus a finish marker for foil/etched.
fn printing_suffix(set_code: &str, collector_number: &str, finish: Option<&str>) -> String {
let marker = match finish {
Some("foil") => " *F*",
Some("etched") => " *E*",
_ => "",
};
format!(" ({}) {collector_number}{marker}", set_code.to_uppercase())
}
/// The structured result of parsing deck-export text.
@@ -111,10 +158,49 @@ pub fn parse_deck_text(text: &str) -> ParsedDeckText {
parsed
}
/// Split a trailing printing suffix — `(SET) NUM`, optionally followed by
/// `*F*`/`*E*` — off a card name. Returns the name unchanged and `None` when
/// there isn't one.
fn split_printing_suffix(text: &str) -> (&str, Option<LinePrinting>) {
let mut rest = text.trim();
let mut finish = None;
for (marker, f) in [("*F*", "foil"), ("*E*", "etched")] {
if rest.len() >= marker.len() && rest[rest.len() - marker.len()..].eq_ignore_ascii_case(marker) {
rest = rest[..rest.len() - marker.len()].trim_end();
finish = Some(f.to_string());
break;
}
}
let parsed = (|| {
let (before, number) = rest.rsplit_once(char::is_whitespace)?;
if number.contains(['(', ')']) {
return None;
}
let before = before.trim_end().strip_suffix(')')?;
let (name, set) = before.rsplit_once('(')?;
let name = name.trim_end();
let valid_set = (2..=6).contains(&set.len()) && set.chars().all(|c| c.is_ascii_alphanumeric());
(valid_set && !name.is_empty()).then_some((name, set, number))
})();
match parsed {
Some((name, set, number)) => (
name,
Some(LinePrinting {
set_code: set.to_string(),
collector_number: number.to_string(),
finish,
}),
),
// A finish marker with no printing to attach it to means nothing.
None => (rest, None),
}
}
/// Parse a single `<qty> <name>` line. Accepts a bare integer quantity
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`).
/// Returns `None` (skip, don't error) if the line doesn't start with a
/// recognizable positive quantity or has no name after it.
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`), and an
/// optional printing suffix (see the module docs). Returns `None` (skip,
/// don't error) if the line doesn't start with a recognizable positive
/// quantity or has no name after it.
fn parse_card_line(line: &str) -> Option<DecklistLine> {
let (head, rest) = line.split_once(char::is_whitespace)?;
let head_lower = head.to_lowercase();
@@ -126,13 +212,14 @@ fn parse_card_line(line: &str) -> Option<DecklistLine> {
if quantity <= 0 {
return None;
}
let name = rest.trim();
let (name, printing) = split_printing_suffix(rest);
if name.is_empty() {
return None;
}
Some(DecklistLine {
quantity,
name: name.to_string(),
printing,
})
}
@@ -149,7 +236,15 @@ pub fn parse_library_text(text: &str) -> Vec<DecklistLine> {
/// Render a deck (metadata + contents) as export text. Empty sections (no
/// commander, no maybeboard entries) are omitted entirely. The
/// `// Format: X` comment is emitted only when `deck.format` is non-empty.
pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
///
/// `printings` maps a scryfall id to its `(set_code, collector_number)`, for
/// the slots with a chosen printing; a slot whose printing is missing from it
/// is written without a suffix.
pub fn format_deck_text(
deck: &Deck,
contents: &[DeckEntry],
printings: &HashMap<String, (String, String)>,
) -> String {
let mut out = String::from("// mtgc deck export\n");
if !deck.format.is_empty() {
out.push_str(&format!("// Format: {}\n", deck.format));
@@ -162,33 +257,47 @@ pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
.filter(|e| !e.is_commander && !e.is_maybeboard)
.collect();
if !commander.is_empty() {
out.push_str("\nCommander\n");
for e in &commander {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !mainboard.is_empty() {
out.push_str("\nDeck\n");
for e in &mainboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
}
}
if !maybeboard.is_empty() {
out.push_str("\nMaybeboard\n");
for e in &maybeboard {
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
let line = |e: &DeckEntry| {
let suffix = e
.preferred_scryfall_id
.as_ref()
.and_then(|id| printings.get(id))
.map(|(set, num)| printing_suffix(set, num, None))
.unwrap_or_default();
format!("{} {}{suffix}\n", e.quantity, e.card.name)
};
for (header, entries) in [("Commander", &commander), ("Deck", &mainboard), ("Maybeboard", &maybeboard)] {
if !entries.is_empty() {
out.push_str(&format!("\n{header}\n"));
for e in entries {
out.push_str(&line(e));
}
}
}
out
}
/// Render `(name, quantity)` pairs as library-export text.
pub fn format_library_text(entries: &[(String, i64)]) -> String {
/// One line of a library export: a quantity of a card, optionally pinned to
/// a printing + finish.
#[derive(Debug, Clone, PartialEq)]
pub struct LibraryExportLine {
pub name: String,
pub quantity: i64,
/// `(set_code, collector_number, finish)` for a tracked copy group.
pub printing: Option<(String, String, String)>,
}
/// Render library lines as library-export text.
pub fn format_library_text(entries: &[LibraryExportLine]) -> String {
let mut out = String::from("// mtgc library export\n");
for (name, qty) in entries {
out.push_str(&format!("{qty} {name}\n"));
for e in entries {
let suffix = e
.printing
.as_ref()
.map(|(set, num, finish)| printing_suffix(set, num, Some(finish)))
.unwrap_or_default();
out.push_str(&format!("{} {}{suffix}\n", e.quantity, e.name));
}
out
}
@@ -209,6 +318,44 @@ impl Catalog {
.optional()?)
}
/// Resolve a decklist line to `(oracle_id, scryfall_id of its printing)`.
/// Falls back to the whole text as the name when the suffix-stripped name
/// doesn't resolve, in case the "suffix" was part of the card's name.
fn resolve_line(&self, line: &DecklistLine) -> Result<Option<(String, Option<String>)>> {
if let Some(oid) = self.find_oracle_id_by_name(&line.name)? {
let printing = match &line.printing {
Some(p) => self.find_printing(&oid, &p.set_code, &p.collector_number)?,
None => None,
};
return Ok(Some((oid, printing)));
}
match line.unparsed_name() {
Some(full) => Ok(self.find_oracle_id_by_name(&full)?.map(|oid| (oid, None))),
None => Ok(None),
}
}
/// `(set_code, collector_number)` for each of `scryfall_ids` the
/// reference db knows.
fn printing_numbers<'a>(
&self,
scryfall_ids: impl Iterator<Item = &'a String>,
) -> Result<HashMap<String, (String, String)>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT set_code, collector_number FROM {REF_DB}.printings WHERE scryfall_id = ?1"
))?;
let mut out = HashMap::new();
for id in scryfall_ids {
if let Some(pair) = stmt
.query_row(params![id], |r| Ok((r.get(0)?, r.get(1)?)))
.optional()?
{
out.insert(id.clone(), pair);
}
}
Ok(out)
}
/// Import a decklist as a brand-new deck named `name`. Always creates a
/// new deck — never merges into an existing one. Best-effort: card
/// names that don't resolve are skipped (not a hard failure) and
@@ -227,35 +374,37 @@ impl Catalog {
let mut unresolved = Vec::new();
let mut commander_oracle_id: Option<String> = None;
for (i, line) in parsed.commander.iter().enumerate() {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let is_first = i == 0;
self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?;
if is_first {
commander_oracle_id = Some(oid);
}
{
let mut add = |line: &DecklistLine, is_commander: bool, is_maybeboard: bool| -> Result<Option<String>> {
let Some((oid, printing)) = self.resolve_line(line)? else {
unresolved.push(line.name.clone());
return Ok(None);
};
self.set_deck_card(deck.id, &oid, line.quantity, is_commander, is_maybeboard)?;
if let Some(id) = printing {
self.set_deck_card_printing(deck.id, &oid, Some(&id))?;
}
None => unresolved.push(line.name.clone()),
Ok(Some(oid))
};
for (i, line) in parsed.commander.iter().enumerate() {
let is_first = i == 0;
let oid = add(line, is_first, false)?;
if is_first {
commander_oracle_id = oid;
}
}
for line in &parsed.mainboard {
add(line, false, false)?;
}
for line in &parsed.maybeboard {
add(line, false, true)?;
}
}
if let Some(oid) = &commander_oracle_id {
self.set_deck_commander(deck.id, Some(oid))?;
}
for line in &parsed.mainboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?,
None => unresolved.push(line.name.clone()),
}
}
for line in &parsed.maybeboard {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?,
None => unresolved.push(line.name.clone()),
}
}
let deck = self
.get_deck(deck.id)?
.expect("just-imported deck must exist");
@@ -268,33 +417,56 @@ impl Catalog {
.get_deck(deck_id)?
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?;
let contents = self.deck_contents(deck_id)?;
Ok(format_deck_text(&deck, &contents))
let printings =
self.printing_numbers(contents.iter().filter_map(|e| e.preferred_scryfall_id.as_ref()))?;
Ok(format_deck_text(&deck, &contents, &printings))
}
/// Import a plain-text library list, adding each resolved card's
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
/// Returns `(name, new_total_quantity_owned)` per applied line, plus
/// unresolved names.
/// A line naming a printing also adds its copies to that printing's copy
/// group — in the marked finish, else nonfoil when the printing comes in
/// it, else its first finish. A finish the printing doesn't come in
/// leaves those copies unspecified. Returns `(name,
/// new_total_quantity_owned)` per applied line, plus unresolved names.
pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec<String>)> {
let lines = parse_library_text(text);
let mut added = Vec::new();
let mut unresolved = Vec::new();
for line in &lines {
match self.find_oracle_id_by_name(&line.name)? {
Some(oid) => {
let new_qty = self.add_owned(&oid, line.quantity)?;
added.push((line.name.clone(), new_qty));
let Some((oid, printing)) = self.resolve_line(line)? else {
unresolved.push(line.name.clone());
continue;
};
let new_qty = self.add_owned(&oid, line.quantity)?;
if let Some(scryfall_id) = printing {
let offered = self.printing_finishes(&oid, &scryfall_id)?;
let wanted = line.printing.as_ref().and_then(|p| p.finish.clone());
let finish = match wanted {
Some(f) => (offered.is_empty() || offered.contains(&f)).then_some(f),
None if offered.is_empty() || offered.iter().any(|f| f == "nonfoil") => {
Some("nonfoil".to_string())
}
None => offered.first().cloned(),
};
if let Some(finish) = finish {
let current = self
.list_library_copies(&oid)?
.into_iter()
.find(|c| c.scryfall_id == scryfall_id && c.finish == finish)
.map_or(0, |c| c.quantity);
self.set_library_copy(&oid, &scryfall_id, &finish, current + line.quantity)?;
}
None => unresolved.push(line.name.clone()),
}
added.push((line.name.clone(), new_qty));
}
Ok((added, unresolved))
}
/// Render the given owned cards (name + current quantity_owned) as
/// library-export text.
/// Render the given owned cards as library-export text: a line per
/// tracked copy group, then one for the card's unspecified copies.
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
let mut entries = Vec::with_capacity(oracle_ids.len());
let mut cards = Vec::with_capacity(oracle_ids.len());
for oid in oracle_ids {
let row: Option<(String, i64)> = self
.conn
@@ -308,12 +480,31 @@ impl Catalog {
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
if let Some(entry) = row {
entries.push(entry);
if let Some((name, owned)) = row {
cards.push((oid, name, owned));
}
}
entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
Ok(format_library_text(&entries))
cards.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
let mut lines = Vec::new();
for (oid, name, owned) in cards {
let mut unspecified = owned;
for copy in self.list_library_copies(oid)? {
// A group whose printing has left the reference db can't be
// named, so its copies are written as unspecified.
let Some(p) = copy.printing else { continue };
unspecified -= copy.quantity;
lines.push(LibraryExportLine {
name: name.clone(),
quantity: copy.quantity,
printing: Some((p.set_code, p.collector_number, copy.finish)),
});
}
if unspecified > 0 {
lines.push(LibraryExportLine { name, quantity: unspecified, printing: None });
}
}
Ok(format_library_text(&lines))
}
}
@@ -322,19 +513,152 @@ mod tests {
use super::*;
use crate::test_support;
fn line(quantity: i64, name: &str) -> DecklistLine {
DecklistLine { quantity, name: name.into(), printing: None }
}
fn printed(set: &str, num: &str, finish: Option<&str>) -> Option<LinePrinting> {
Some(LinePrinting {
set_code: set.into(),
collector_number: num.into(),
finish: finish.map(str::to_string),
})
}
/// Give Sol Ring a 2X2 printing (nonfoil + foil) and Lightning Bolt a
/// 2XM one (nonfoil only).
fn add_printings(cat: &Catalog) {
cat.connection()
.execute_batch(
"INSERT INTO ref.printings(scryfall_id, oracle_id, set_code, collector_number, finishes) VALUES
('sol-2x2', 'sol-ring', '2x2', '315', 'nonfoil,foil'),
('bolt-2xm', 'lightning-bolt', '2xm', '129', 'nonfoil');",
)
.unwrap();
}
#[test]
fn parse_card_line_reads_printing_suffixes() {
let l = parse_card_line("1 Sol Ring (2X2) 315 *F*").unwrap();
assert_eq!(l.name, "Sol Ring");
assert_eq!(l.printing, printed("2X2", "315", Some("foil")));
let l = parse_card_line("4x Lightning Bolt (2XM) 129").unwrap();
assert_eq!(l.name, "Lightning Bolt");
assert_eq!(l.printing, printed("2XM", "129", None));
let l = parse_card_line("1 Sol Ring (PLST) C21-263 *e*").unwrap();
assert_eq!(l.printing, printed("PLST", "C21-263", Some("etched")));
// A name that merely contains parentheses is left alone.
let l = parse_card_line("1 B.F.M. (Big Furry Monster)").unwrap();
assert_eq!(l.name, "B.F.M. (Big Furry Monster)");
assert_eq!(l.printing, None);
// A finish marker with no printing is dropped.
let l = parse_card_line("1 Sol Ring *F*").unwrap();
assert_eq!((l.name.as_str(), l.printing), ("Sol Ring", None));
}
#[test]
fn a_suffix_that_names_no_printing_still_imports_the_card() {
let fx = test_support::make();
let cat = &fx.catalog;
let (deck, unresolved) = cat.import_deck("Odd", "1 Sol Ring (ZZZ) 1\n").unwrap();
assert!(unresolved.is_empty());
let contents = cat.deck_contents(deck.id).unwrap();
assert_eq!(contents[0].card.oracle_id, "sol-ring");
assert_eq!(contents[0].preferred_scryfall_id, None);
}
#[test]
fn deck_printings_round_trip_through_export_and_import() {
let fx = test_support::make();
let cat = &fx.catalog;
add_printings(cat);
let deck = cat.create_deck("Art", None).unwrap();
cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, true).unwrap();
cat.set_deck_card(deck.id, "serra-angel", 1, false, false).unwrap();
cat.set_deck_card_printing(deck.id, "sol-ring", Some("sol-2x2")).unwrap();
cat.set_deck_card_printing(deck.id, "lightning-bolt", Some("bolt-2xm")).unwrap();
let text = cat.export_deck_text(deck.id).unwrap();
assert!(text.contains("1 Sol Ring (2X2) 315\n"), "{text}");
assert!(text.contains("4 Lightning Bolt (2XM) 129\n"), "{text}");
assert!(text.contains("1 Serra Angel\n"), "{text}");
let (copy, unresolved) = cat.import_deck("Art again", &text).unwrap();
assert!(unresolved.is_empty());
let mut chosen: Vec<_> = cat
.deck_contents(copy.id)
.unwrap()
.into_iter()
.map(|e| (e.card.oracle_id, e.preferred_scryfall_id))
.collect();
chosen.sort();
assert_eq!(
chosen,
vec![
("lightning-bolt".into(), Some("bolt-2xm".into())),
("serra-angel".into(), None),
("sol-ring".into(), Some("sol-2x2".into())),
]
);
}
#[test]
fn library_copies_round_trip_through_export_and_import() {
let fx = test_support::make();
let cat = &fx.catalog;
add_printings(cat);
cat.set_owned("sol-ring", 4).unwrap();
cat.set_library_copy("sol-ring", "sol-2x2", "nonfoil", 1).unwrap();
cat.set_library_copy("sol-ring", "sol-2x2", "foil", 2).unwrap();
let text = cat.export_library_text(&["sol-ring".to_string()]).unwrap();
assert_eq!(
text,
"// mtgc library export\n1 Sol Ring (2X2) 315\n2 Sol Ring (2X2) 315 *F*\n1 Sol Ring\n"
);
// Import into an empty library reproduces it.
cat.set_owned("sol-ring", 0).unwrap();
assert!(cat.list_library_copies("sol-ring").unwrap().is_empty());
let (_, unresolved) = cat.import_library_list(&text).unwrap();
assert!(unresolved.is_empty());
assert_eq!(cat.export_library_text(&["sol-ring".to_string()]).unwrap(), text);
}
#[test]
fn library_import_defaults_and_rejects_finishes_sensibly() {
let fx = test_support::make();
let cat = &fx.catalog;
add_printings(cat);
// Bolt's 2XM printing is nonfoil only: the foil copy is still owned,
// just not pinned to a printing it can't be.
cat.import_library_list("1 Lightning Bolt (2XM) 129 *F*\n2 Lightning Bolt (2xm) 129\n").unwrap();
let copies = cat.list_library_copies("lightning-bolt").unwrap();
assert_eq!(copies.len(), 1);
assert_eq!((copies[0].finish.as_str(), copies[0].quantity), ("nonfoil", 2));
let lib = cat.list_library().unwrap();
let bolt = lib.iter().find(|c| c.card.oracle_id == "lightning-bolt").unwrap();
assert_eq!(bolt.quantity_owned, 3);
}
#[test]
fn parse_card_line_accepts_bare_and_x_prefixed_quantities() {
assert_eq!(
parse_card_line("4 Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
Some(line(4, "Lightning Bolt"))
);
assert_eq!(
parse_card_line("4x Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
Some(line(4, "Lightning Bolt"))
);
assert_eq!(
parse_card_line("4× Lightning Bolt"),
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
Some(line(4, "Lightning Bolt"))
);
assert_eq!(parse_card_line("not a card line"), None);
assert_eq!(parse_card_line("0 Lightning Bolt"), None);
@@ -347,12 +671,12 @@ mod tests {
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }]
vec![line(1, "Krenko, Mob Boss")]
);
assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]);
assert_eq!(parsed.mainboard, vec![line(1, "Sol Ring")]);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Some Card".into() }]
vec![line(1, "Some Card")]
);
}
@@ -373,7 +697,7 @@ mod tests {
commander_oracle_id: None,
notes: String::new(),
};
let text = format_deck_text(&deck, &[]);
let text = format_deck_text(&deck, &[], &HashMap::new());
assert_eq!(text, "// mtgc deck export\n");
}
@@ -432,15 +756,15 @@ mod tests {
assert_eq!(parsed.format.as_deref(), Some("Commander"));
assert_eq!(
parsed.commander,
vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }]
vec![line(1, "Serra Angel")]
);
assert_eq!(
parsed.mainboard,
vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }]
vec![line(4, "Lightning Bolt")]
);
assert_eq!(
parsed.maybeboard,
vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]
vec![line(1, "Sol Ring")]
);
}

View File

@@ -60,7 +60,7 @@ impl Catalog {
}
/// Duplicate a deck under a new name: same format/commander/notes, plus a
/// copy of every deck_cards row (quantity + commander flag). Fails if
/// copy of every deck_cards row (quantity, flags, chosen printing). Fails if
/// `new_name` is already taken.
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
let src = self
@@ -75,8 +75,9 @@ impl Catalog {
)?;
let new_id = self.conn.last_insert_rowid();
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards WHERE deck_id = ?2",
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id)
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id
FROM deck_cards WHERE deck_id = ?2",
params![new_id, id],
)?;
self.get_deck(new_id)
@@ -84,7 +85,8 @@ impl Catalog {
}
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes
/// the card from the deck. Upserts on (deck_id, oracle_id).
/// the card from the deck. Upserts on (deck_id, oracle_id), leaving any
/// chosen printing in place.
pub fn set_deck_card(
&self,
deck_id: i64,
@@ -115,15 +117,23 @@ impl Catalog {
/// All cards in a deck — owned or not — with their in-deck quantity and how
/// many copies are owned (0 if the card isn't in the library). Callers split
/// this into "in your library" vs "not owned" groups.
///
/// A slot with a chosen printing shows that printing's art — both faces
/// from it, so a double-faced card never mixes two printings. An id that
/// no longer resolves (or has no image) falls back to the default.
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}, dc.is_maybeboard, {horizontal}
oc.type_line,
COALESCE(NULLIF(pp.image_uri, ''), {image}),
CASE WHEN pp.image_uri <> '' THEN pp.back_image_uri ELSE {back} END,
oc.produced_mana, oc.produces_colorless,
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
LEFT JOIN {REF_DB}.printings pp ON pp.scryfall_id = dc.preferred_scryfall_id
WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE",
image = self.image_expr("image_uri"),
@@ -153,6 +163,7 @@ impl Catalog {
horizontal: r.get::<_, i64>(16)? != 0,
},
is_maybeboard: r.get::<_, i64>(15)? != 0,
preferred_scryfall_id: r.get(17)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;

View File

@@ -732,6 +732,10 @@ struct ScryPrinting {
/// Scryfall's set category; `"funny"` covers the Un-sets.
#[serde(default)]
set_type: String,
#[serde(default)]
lang: String,
#[serde(default)]
released_at: String,
}
#[derive(Deserialize)]
@@ -758,8 +762,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny,lang,released_at)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
)?;
stream_jsonl::<_, ScryPrinting, _>(reader, |p| {
if cancel.is_cancelled() {
@@ -814,6 +818,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
universes_beyond as i64,
digital as i64,
funny as i64,
p.lang,
p.released_at,
])?;
count += 1;
Ok(())
@@ -1036,6 +1042,7 @@ mod tests {
let printings = r#"[
{"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310",
"finishes":["nonfoil","foil"],"rarity":"common",
"lang":"en","released_at":"2018-07-13",
"image_uris":{"normal":"http://img/1"}}
]"#;
@@ -1067,6 +1074,16 @@ mod tests {
)
.unwrap();
assert_eq!(g as u8, crate::model::G);
// Picker labels: language and release date.
let (lang, released): (String, String) = conn
.query_row(
"SELECT lang, released_at FROM printings WHERE scryfall_id='p-1'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!((lang.as_str(), released.as_str()), ("en", "2018-07-13"));
}
#[test]

View File

@@ -15,6 +15,7 @@ pub mod ingest;
pub mod library;
pub mod model;
pub mod news;
pub mod printings;
pub mod search;
pub mod sync;
pub mod synergy;
@@ -25,8 +26,8 @@ use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets,
LibraryFilter, LibraryStats,
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryCopy,
LibraryFacets, LibraryFilter, LibraryStats, PrintingRef,
};
pub use search::NameIndex;
pub use synergy::{CardSuggestion, SuggestOptions, SynergyReason};
@@ -54,6 +55,14 @@ pub struct Catalog {
/// [`Self::has_universes_beyond`]: it shipped in a later reference-schema
/// revision, so a db can have one without the other.
pub(crate) has_vanilla: bool,
/// Whether `printings.released_at` (and its `lang` sibling) exist — the
/// printing picker's labels and ordering. Another later reference-schema
/// revision; without it the picker still works, just less informatively.
pub(crate) has_printing_details: bool,
/// What a re-ingest would add to the attached reference db — see
/// [`db::missing_reference_columns`]. Cached at open time; the reference db
/// only changes by being replaced, which reopens the catalog.
missing_reference_columns: Vec<String>,
}
impl Catalog {
@@ -66,12 +75,16 @@ impl Catalog {
let has_universes_beyond =
db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?;
let has_vanilla = db::has_reference_column(&conn, "oracle_cards", "vanilla")?;
let has_printing_details = db::has_reference_column(&conn, "printings", "released_at")?;
let missing_reference_columns = db::missing_reference_columns(&conn)?;
Ok(Self {
conn,
has_horizontal_column,
has_oracle_images,
has_universes_beyond,
has_vanilla,
has_printing_details,
missing_reference_columns,
})
}
@@ -96,6 +109,13 @@ impl Catalog {
db::image_expr(self.has_oracle_images, column)
}
/// Reference-schema columns the attached card data lacks (`"table.column"`),
/// i.e. it was built by an older version and some features are degraded
/// until the user updates it. Empty when it's current.
pub fn missing_reference_columns(&self) -> &[String] {
&self.missing_reference_columns
}
/// Build (or rebuild) the in-memory fuzzy name index from the attached
/// reference db. Cheap enough to hold in app state and rebuild after a
/// reference-data update.

View File

@@ -60,14 +60,18 @@ impl Catalog {
}
/// Set the absolute owned quantity for a card (0 removes ownership but keeps
/// any deck assignments referencing it).
/// any deck assignments referencing it). Copy groups that no longer fit
/// are trimmed, newest first — see [`Catalog::clamp_copies`].
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
let q = quantity.max(0);
self.conn.execute(
let tx = self.conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
params![oracle_id, q],
)?;
self.clamp_copies(oracle_id)?;
tx.commit()?;
Ok(q)
}

View File

@@ -210,6 +210,43 @@ pub struct DeckEntry {
pub is_maybeboard: bool,
/// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64,
/// The printing whose art this deck slot shows, if one was chosen. The
/// card's `image_uri`/`back_image_uri` already reflect it (falling back
/// to the default when the id no longer resolves).
pub preferred_scryfall_id: Option<String>,
}
/// One printing of a card, for the printing picker and copy labels.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PrintingRef {
pub scryfall_id: String,
/// Scryfall's set code, lowercase (e.g. "2x2").
pub set_code: String,
pub collector_number: String,
/// Language code ("en", "ja", ...); empty on an older reference.db.
pub lang: String,
/// The finishes this printing exists in: "nonfoil", "foil", "etched".
pub finishes: Vec<String>,
pub rarity: String,
pub image_uri: String,
pub back_image_uri: String,
/// Scryfall's `prices.usd` for this printing, 0 if unknown.
pub price_usd: f64,
/// Release date as YYYY-MM-DD; empty on an older reference.db.
pub released_at: String,
}
/// A group of identical owned copies pinned to one printing + finish. Groups
/// are a subset of the card's `quantity_owned`; copies not in any group are
/// simply unspecified.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LibraryCopy {
pub scryfall_id: String,
pub finish: String,
pub quantity: i64,
/// The printing's details, or `None` when the id no longer resolves in
/// the reference db (e.g. after a re-ingest dropped it).
pub printing: Option<PrintingRef>,
}
impl LibraryCard {

View File

@@ -0,0 +1,525 @@
//! 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());
}
}

View File

@@ -0,0 +1,15 @@
-- v6 — optional per-printing tracking (see docs/printing-tracking-design.md).
--
-- `library_card_copies` has existed since v1 but was never written. Each row now
-- means "`quantity` of my copies of this card are this printing + finish": a
-- group of identical copies rather than one row per physical card. Groups are
-- a subset of `library_cards.quantity_owned`, which stays authoritative for
-- totals; any copies not covered by a group are simply unspecified.
ALTER TABLE library_card_copies ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1;
CREATE UNIQUE INDEX idx_copies_printing
ON library_card_copies(library_card_id, scryfall_id, finish);
-- Which printing's art to show for a deck slot. A pure display preference:
-- printings live in the rebuildable reference.db, so there's no foreign key,
-- and an id that no longer resolves just falls back to the default image.
ALTER TABLE deck_cards ADD COLUMN preferred_scryfall_id TEXT;

View File

@@ -94,7 +94,11 @@ CREATE TABLE IF NOT EXISTS printings (
-- Boolean: this printing isn't available on paper (Scryfall `games`).
digital INTEGER NOT NULL DEFAULT 0,
-- Boolean: this printing comes from a "funny" set (Scryfall `set_type`).
funny INTEGER NOT NULL DEFAULT 0
funny INTEGER NOT NULL DEFAULT 0,
-- Scryfall language code ("en", "ja", ...) and release date (YYYY-MM-DD),
-- for labelling and ordering printings in the printing picker.
lang TEXT NOT NULL DEFAULT '',
released_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);

View File

@@ -48,9 +48,18 @@ use crate::error::Result;
pub mod account;
pub mod remote;
/// Bumped only for a breaking change to the document shape. Readers reject
/// anything newer rather than silently misreading it.
pub const DOC_VERSION: u32 = 1;
/// The newest document format this build reads. Readers reject anything newer
/// rather than silently misreading it.
///
/// A document is written at the *lowest* version that can hold it (see
/// [`LibraryDoc::required_version`]), not at this one. A reader that silently
/// dropped printing data would publish its loss as a deletion on the next
/// sync, so documents that carry any are marked 2 and older builds refuse
/// them — while libraries that don't use printings stay readable everywhere.
pub const DOC_VERSION: u32 = 2;
/// The format of a document with no printing data in it.
const BASE_VERSION: u32 = 1;
/// One owned card. Keyed by `oracle_id` in [`LibraryDoc::cards`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -58,6 +67,10 @@ pub struct CardEntry {
pub quantity: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub location: String,
/// Copies pinned to a printing + finish, keyed by [`printing_key`]. A
/// subset of `quantity`; whatever they don't cover is unspecified.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub printings: BTreeMap<String, i64>,
}
/// One card in a deck. Keyed by `oracle_id` in [`DeckDoc::cards`].
@@ -68,6 +81,20 @@ pub struct DeckCardEntry {
pub commander: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub maybeboard: bool,
/// Scryfall id of the printing whose art this slot shows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub printing: Option<String>,
}
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
/// Scryfall ids are UUIDs, so the separator can't be ambiguous.
pub fn printing_key(scryfall_id: &str, finish: &str) -> String {
format!("{scryfall_id}/{finish}")
}
/// Split a [`printing_key`] back into `(scryfall_id, finish)`.
pub fn parse_printing_key(key: &str) -> Option<(&str, &str)> {
key.rsplit_once('/')
}
/// One deck. Keyed by its uuid in [`LibraryDoc::decks`] — see migration 0005
@@ -91,8 +118,6 @@ pub struct DeckDoc {
/// always produces byte-identical JSON, which keeps sync tools from seeing
/// spurious changes and makes the file diffable by hand.
///
/// `library_card_copies` is deliberately absent — that table is unused by any
/// code in the project.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LibraryDoc {
pub version: u32,
@@ -105,7 +130,7 @@ pub struct LibraryDoc {
impl Default for LibraryDoc {
fn default() -> Self {
Self {
version: DOC_VERSION,
version: BASE_VERSION,
cards: BTreeMap::new(),
decks: BTreeMap::new(),
}
@@ -139,6 +164,22 @@ impl LibraryDoc {
pub fn is_empty(&self) -> bool {
self.cards.is_empty() && self.decks.is_empty()
}
/// The lowest format that can hold this document: 2 once it carries any
/// printing data, else 1. See [`DOC_VERSION`].
pub fn required_version(&self) -> u32 {
let has_copies = self.cards.values().any(|c| !c.printings.is_empty());
let has_deck_printings = self
.decks
.values()
.flat_map(|d| d.cards.values())
.any(|c| c.printing.is_some());
if has_copies || has_deck_printings {
2
} else {
BASE_VERSION
}
}
}
/// What a merge did, for reporting to the user.
@@ -180,9 +221,7 @@ pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (Lib
for id in keys(&base.cards, &ours.cards, &theirs.cards) {
let (b, o, t) = (base.cards.get(&id), ours.cards.get(&id), theirs.cards.get(&id));
if let Some(entry) = merge_entry(b, o, t, &mut || {
report.conflicts.push(format!("owned quantity for card {id}"));
}) {
if let Some(entry) = merge_card(b, o, t, &id, &mut report) {
if Some(&entry) != o {
report.cards_changed += 1;
}
@@ -222,6 +261,7 @@ pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (Lib
out.decks.insert(id, merged);
}
out.version = out.required_version();
(out, report)
}
@@ -273,6 +313,71 @@ fn merge_entry<T: Clone + PartialEq>(
}
}
/// Three-way merge of one owned card. Whether it exists at all is decided
/// exactly as for any entry ([`merge_entry`]); when both sides kept it, its
/// fields merge independently and its copy groups per printing, so a quantity
/// change on one device and a printing recorded on another both survive.
fn merge_card(
base: Option<&CardEntry>,
ours: Option<&CardEntry>,
theirs: Option<&CardEntry>,
id: &str,
report: &mut MergeReport,
) -> Option<CardEntry> {
let (Some(o), Some(t)) = (ours, theirs) else {
return merge_entry(base, ours, theirs, &mut || {
report.conflicts.push(format!("owned quantity for card {id}"))
});
};
if o == t {
return Some(o.clone());
}
let empty = CardEntry::default();
let b = base.unwrap_or(&empty);
let mut merged = CardEntry {
quantity: merge_field(&b.quantity, &o.quantity, &t.quantity, || {
report.conflicts.push(format!("owned quantity for card {id}"))
}),
location: merge_field(&b.location, &o.location, &t.location, || {
report.conflicts.push(format!("location of card {id}"))
}),
printings: BTreeMap::new(),
};
for key in keys(&b.printings, &o.printings, &t.printings) {
let qty = merge_entry(
b.printings.get(&key),
o.printings.get(&key),
t.printings.get(&key),
&mut || report.conflicts.push(format!("tracked printing of card {id}")),
);
if let Some(qty) = qty {
merged.printings.insert(key, qty);
}
}
clamp_printings(&mut merged);
Some(merged)
}
/// Trim copy groups until they fit within the owned quantity — the merge's
/// counterpart to the database's own clamp. Trims from the end of key order
/// rather than "newest first", because a document has no edit order and the
/// result must come out the same on every device.
fn clamp_printings(card: &mut CardEntry) {
let mut excess = card.printings.values().sum::<i64>() - card.quantity.max(0);
while excess > 0 {
let Some(mut last) = card.printings.last_entry() else {
break;
};
let cut = (*last.get()).min(excess);
if cut == *last.get() {
last.remove();
} else {
*last.get_mut() -= cut;
}
excess -= cut;
}
}
fn merge_deck(
base: Option<&DeckDoc>,
ours: Option<&DeckDoc>,
@@ -358,10 +463,26 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
CardEntry {
quantity: row.get(1)?,
location: row.get(2)?,
printings: BTreeMap::new(),
},
);
}
let mut stmt = conn.prepare(
"SELECT lc.oracle_id, c.scryfall_id, c.finish, c.quantity
FROM library_card_copies c JOIN library_cards lc ON lc.id = c.library_card_id
WHERE c.quantity > 0 AND c.scryfall_id IS NOT NULL AND c.finish IS NOT NULL",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let oracle_id: String = row.get(0)?;
let key = printing_key(&row.get::<_, String>(1)?, &row.get::<_, String>(2)?);
// Copies are clamped to quantity_owned, so the card is always listed.
if let Some(card) = doc.cards.get_mut(&oracle_id) {
card.printings.insert(key, row.get(3)?);
}
}
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?;
let mut rows = stmt.query([])?;
let mut by_id: BTreeMap<i64, String> = BTreeMap::new();
@@ -381,8 +502,10 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
);
}
let mut stmt = conn
.prepare("SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards")?;
let mut stmt = conn.prepare(
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id
FROM deck_cards",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let deck_id: i64 = row.get(0)?;
@@ -396,11 +519,13 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
quantity: row.get(2)?,
commander: row.get::<_, i64>(3)? != 0,
maybeboard: row.get::<_, i64>(4)? != 0,
printing: row.get(5)?,
},
);
}
}
doc.version = doc.required_version();
Ok(doc)
}
@@ -432,6 +557,22 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
)?;
}
// Copy groups are rebuilt wholesale: their row ids are local, and the
// document is the whole truth about them.
tx.execute("DELETE FROM library_card_copies", [])?;
for (oracle_id, entry) in &doc.cards {
for (key, qty) in &entry.printings {
let Some((scryfall_id, finish)) = parse_printing_key(key) else {
continue; // Hand-edited nonsense; nothing sensible to store.
};
tx.execute(
"INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity)
SELECT id, ?2, ?3, ?4 FROM library_cards WHERE oracle_id = ?1",
rusqlite::params![oracle_id, scryfall_id, finish, qty],
)?;
}
}
// Decks are matched by uuid; anything else is a deck the document no longer
// has. ON DELETE CASCADE clears its cards.
{
@@ -458,18 +599,20 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
for (oracle_id, card) in &deck.cards {
tx.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
VALUES (?1, ?2, ?3, ?4, ?5)
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander,
is_maybeboard = excluded.is_maybeboard",
is_maybeboard = excluded.is_maybeboard,
preferred_scryfall_id = excluded.preferred_scryfall_id",
rusqlite::params![
deck_id,
oracle_id,
card.quantity,
card.commander as i64,
card.maybeboard as i64
card.maybeboard as i64,
card.printing
],
)?;
}
@@ -529,7 +672,7 @@ mod tests {
for (id, qty) in cards {
d.cards.insert(
(*id).into(),
CardEntry { quantity: *qty, location: String::new() },
CardEntry { quantity: *qty, ..Default::default() },
);
}
for (uuid, name, cards) in decks {
@@ -696,6 +839,87 @@ mod tests {
assert!(report.conflicts.is_empty());
}
#[test]
fn a_quantity_edit_and_a_printing_added_elsewhere_both_survive() {
// Cards used to merge as one unit, so these two edits would have
// collided and one would have been lost.
let base = doc(&[("a", 2)], &[]);
let ours = doc(&[("a", 4)], &[]);
let mut theirs = base.clone();
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
let (merged, report) = merge3(&base, &ours, &theirs);
let a = &merged.cards["a"];
assert_eq!(a.quantity, 4);
assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1));
assert!(report.conflicts.is_empty(), "different fields, no conflict");
}
#[test]
fn printings_recorded_on_each_side_merge_side_by_side() {
let base = doc(&[("a", 3)], &[]);
let mut ours = base.clone();
let mut theirs = base.clone();
ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "nonfoil"), 1);
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 2);
let (merged, report) = merge3(&base, &ours, &theirs);
assert_eq!(merged.cards["a"].printings.len(), 2);
assert!(report.conflicts.is_empty());
}
#[test]
fn merged_printings_are_clamped_the_same_way_on_every_device() {
// Each side's own edit fits, but together they track 4 of 2 copies.
let base = doc(&[("a", 3)], &[]);
let mut ours = doc(&[("a", 2)], &[]);
ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
let mut theirs = base.clone();
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 3);
let (phone, _) = merge3(&base, &ours, &theirs);
let (desktop, _) = merge3(&base, &theirs, &ours);
assert_eq!(phone.cards["a"], desktop.cards["a"], "both devices must agree");
let a = &phone.cards["a"];
assert_eq!(a.quantity, 2);
assert_eq!(a.printings.values().sum::<i64>(), 2);
// Trimmed from the end of key order: p1 kept whole.
assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1));
assert_eq!(a.printings.get(&printing_key("p2", "foil")), Some(&1));
}
#[test]
fn version_2_is_used_only_once_printing_data_exists() {
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
assert_eq!(d.required_version(), 1);
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into());
assert_eq!(d.required_version(), 2);
let mut d = doc(&[("a", 1)], &[]);
d.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
assert_eq!(d.required_version(), 2);
// A merge result is stamped with what it needs.
let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default());
assert_eq!(merged.version, 2);
// And this build reads it back, printing data intact.
let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap();
assert_eq!(back, merged);
}
#[test]
fn a_version_1_document_carries_no_printing_fields() {
// Older builds must see exactly what they always saw.
let d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
let text = d.to_json().unwrap();
assert!(text.contains("\"version\": 1"));
assert!(!text.contains("printing"));
}
// --- document <-> database --------------------------------------------
fn catalog(dir: &Path, name: &str) -> Catalog {
@@ -729,6 +953,40 @@ mod tests {
assert_eq!(read_doc(&other.conn).unwrap(), before);
}
#[test]
fn printings_round_trip_through_a_document() {
let tmp = tempfile::tempdir().unwrap();
let c = catalog(tmp.path(), "lib");
c.conn
.execute_batch(
"INSERT INTO ref.printings(scryfall_id, oracle_id, finishes) VALUES
('p1', 'card-a', 'nonfoil,foil'), ('p2', 'card-a', 'nonfoil');",
)
.unwrap();
c.set_owned("card-a", 5).unwrap();
c.set_library_copy("card-a", "p1", "foil", 2).unwrap();
c.set_library_copy("card-a", "p2", "nonfoil", 1).unwrap();
let deck = c.create_deck("Fub", None).unwrap();
c.set_deck_card(deck.id, "card-a", 1, false, false).unwrap();
c.set_deck_card_printing(deck.id, "card-a", Some("p2")).unwrap();
let before = read_doc(&c.conn).unwrap();
assert_eq!(before.version, 2);
assert_eq!(before.cards["card-a"].printings.len(), 2);
let parsed = LibraryDoc::from_json(&before.to_json().unwrap()).unwrap();
let other = catalog(tmp.path(), "other");
apply_doc(&other.conn, &parsed).unwrap();
assert_eq!(read_doc(&other.conn).unwrap(), before);
assert_eq!(other.list_library_copies("card-a").unwrap().len(), 2);
// Applying a document without them clears them.
let mut without = before.clone();
without.cards.get_mut("card-a").unwrap().printings.clear();
apply_doc(&other.conn, &without).unwrap();
assert!(other.list_library_copies("card-a").unwrap().is_empty());
}
#[test]
fn applying_a_document_removes_what_it_no_longer_contains() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -343,14 +343,14 @@ mod tests {
fn set(&mut self, id: &str, qty: i64) {
self.local
.cards
.insert(id.into(), CardEntry { quantity: qty, location: String::new() });
.insert(id.into(), CardEntry { quantity: qty, ..Default::default() });
}
}
fn doc(cards: &[(&str, i64)]) -> LibraryDoc {
let mut d = LibraryDoc::default();
for (id, qty) in cards {
d.cards.insert((*id).into(), CardEntry { quantity: *qty, location: String::new() });
d.cards.insert((*id).into(), CardEntry { quantity: *qty, ..Default::default() });
}
d
}

View File

@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use mtgc_core::sync::account::{redeem_code, server_info, start_url, Pkce};
use mtgc_core::sync::remote::{sync_cycle, HttpRemote, SyncBase, UNAUTHORIZED};
use mtgc_core::sync::{apply_doc, merge_into, read_doc, CardEntry};
use mtgc_core::sync::{apply_doc, merge_into, printing_key, read_doc, CardEntry};
use mtgc_server::auth::local::hash_password;
use mtgc_server::{db, router, AppState, Config};
@@ -75,16 +75,54 @@ impl Device {
fn set(&self, id: &str, qty: i64) {
let conn = mtgc_core::db::open_library(&self.db).unwrap();
let mut doc = read_doc(&conn).unwrap();
doc.cards.insert(id.into(), CardEntry { quantity: qty, location: String::new() });
doc.cards.insert(id.into(), CardEntry { quantity: qty, ..Default::default() });
apply_doc(&conn, &doc).unwrap();
}
fn doc(&self) -> mtgc_core::sync::LibraryDoc {
read_doc(&mtgc_core::db::open_library(&self.db).unwrap()).unwrap()
}
fn qty(&self, id: &str) -> Option<i64> {
let conn = mtgc_core::db::open_library(&self.db).unwrap();
read_doc(&conn).unwrap().cards.get(id).map(|c| c.quantity)
}
}
#[test]
fn printing_data_reaches_the_other_device_intact() {
// A version-2 document must be accepted by the server and come back out
// exactly as stored, or one device's tracked printings would vanish.
let (server, state) = spawn_server();
let hash = hash_password("correct horse battery").unwrap();
db::create_local_user(&state.db(), "bob", "Bob", &hash).unwrap();
let dir = std::env::temp_dir().join(format!("mtgc-e2e-printings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let phone_login = sign_in(&server, "bob", "correct horse battery");
let desktop_login = sign_in(&server, "bob", "correct horse battery");
let mut phone = Device::new(&dir, "phone", &server, &phone_login.token);
let mut desktop = Device::new(&dir, "desktop", &server, &desktop_login.token);
{
let conn = mtgc_core::db::open_library(&phone.db).unwrap();
let mut doc = read_doc(&conn).unwrap();
let mut card = CardEntry { quantity: 3, ..Default::default() };
card.printings.insert(printing_key("p1", "foil"), 2);
doc.cards.insert("a".into(), card);
apply_doc(&conn, &doc).unwrap();
}
assert_eq!(phone.doc().version, 2);
assert!(phone.sync().unwrap().published);
desktop.sync().unwrap();
assert_eq!(desktop.doc(), phone.doc());
assert_eq!(desktop.doc().cards["a"].printings[&printing_key("p1", "foil")], 2);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn two_devices_converge_through_the_real_server() {
let (server, state) = spawn_server();

View File

@@ -1,6 +1,8 @@
# Design: optional per-printing tracking
Status: **not implemented** — design only, for a future session.
Status: **implemented.** The as-built decisions are in
[As built](#as-built) at the end, which takes precedence over the original
design below wherever the two differ.
## Context
@@ -148,3 +150,42 @@ later is a compatible follow-up, not a redesign.
`crates/mtgc-core/src/model.rs`, `src-tauri/src/commands.rs`, `src/api.ts`,
`src/components.tsx`, `src/views/DeckView.tsx`, `src/views/LibraryBrowser.tsx`,
`src/views/CardEntry.tsx`.
## As built
Where the implementation settled the open questions, or departed from the
design above:
- **Migration** is `0006_printings.sql` (0005 had gone to deck uuids). It adds
`library_card_copies.quantity`, a unique index on
`(library_card_id, scryfall_id, finish)`, and `deck_cards.preferred_scryfall_id`.
Everything lives in `crates/mtgc-core/src/printings.rs`.
- **No `remove_copy(copy_id)`.** `set_library_copy(.., quantity)` with
`quantity <= 0` removes a group, mirroring `set_deck_card`. Row ids are local
and are rebuilt on every sync, so they would be a poor handle anyway.
- **Quantity rules (open question 3).**
- *Grow:* setting a group above the unspecified remainder raises
`quantity_owned` to fit.
- *Clamp:* lowering `quantity_owned` below the tracked total trims the
highest-id (most recently added) groups first.
- Lowering a group never lowers `quantity_owned`; those copies become
unspecified.
- All of this is pinned by tests in `printings.rs`.
- **Library thumbnails (open question 1)** always show the oracle default.
Only deck slots show a chosen printing.
- **Decklists (open question 2)** round-trip in this first cut, as
`(SET) NUM`, optionally followed by `*F*` / `*E*`. See `decklist.rs`.
- **UI.** There is no per-row button. A *Printings* section in the
click-preview info tile appears whenever the caller passes a
`PreviewContext`, and it opens a `PrintingPicker`. Both are in
`src/components.tsx`.
- **Sync, which postdates this design.**
- `CardEntry.printings` is keyed `"<scryfall_id>/<finish>"`, and
`DeckCardEntry.printing` holds the chosen printing.
- Cards now merge field by field, and their groups merge per key, so edits
to one card from two devices both survive. A merge result is clamped by
trimming from the end of key order, which comes out the same on every
device.
- A document is written as version 2 only once it holds printing data, so
older builds refuse it rather than dropping the data and syncing the loss.
- **Follow-up:** choosing a printing while entering cards on Add Cards.

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,

View File

@@ -342,6 +342,14 @@ export default function App() {
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Card data built by an older version lacks columns this one reads, so
// some features quietly stay off until it's updated. "Not now" is
// remembered against exactly what's missing: the notice comes back once a
// later version adds something more.
const missingColumns = status?.reference_missing_columns ?? [];
const outdatedKey = missingColumns.join(",");
const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", "");
const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey;
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
// across renders (re-creating them would re-arm the listeners below and fire
// a fresh sync every time card data finished loading).
@@ -632,6 +640,30 @@ export default function App() {
</div>
)}
{showOutdated && (
<div className="outdated-bar" role="status">
<span className="outdated-bar-message">
Your card data was built by an older version of mtgc, so some newer features stay off until you
update it.
</span>
<span className="outdated-bar-actions">
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
Update card data
</button>
{/* A full download is heavy on a phone; copying a fresh
reference.db over from a desktop is the cheaper route. */}
{status?.is_mobile && (
<button onClick={importReferenceDb} disabled={busy}>
{ICON_OPEN_LIBRARY}
Import card data…
</button>
)}
<button onClick={() => setDismissedOutdated(outdatedKey)}>Not now</button>
</span>
</div>
)}
<div className="content">
{!statusLoaded ? (
<div className="empty-state">

View File

@@ -36,6 +36,32 @@ export interface DeckEntry extends CardRef {
* analysis stats and library assignment totals. */
is_maybeboard: boolean;
quantity_owned: number;
/** The printing whose art this slot shows; `image_uri` already reflects it. */
preferred_scryfall_id: string | null;
}
/** One printing of a card, for the printing picker and copy labels. */
export interface PrintingRef {
scryfall_id: string;
set_code: string; // lowercase, e.g. "2x2"
collector_number: string;
lang: string; // "" on an older card database
finishes: string[]; // "nonfoil" | "foil" | "etched"
rarity: string;
image_uri: string;
back_image_uri: string;
price_usd: number;
released_at: string; // YYYY-MM-DD, "" on an older card database
}
/** Owned copies pinned to one printing + finish. A subset of the card's
* `quantity_owned`; copies in no group are unspecified. */
export interface LibraryCopy {
scryfall_id: string;
finish: string;
quantity: number;
/** Null when the printing has left the card database. */
printing: PrintingRef | null;
}
/**
@@ -48,8 +74,21 @@ export interface DeckEntry extends CardRef {
export type PreviewCard = CardRef & {
quantity_owned?: number;
decks?: string[];
preferred_scryfall_id?: string | null;
};
/**
* Where a click preview was opened from, when the caller lets it edit
* printings: the info tile then tracks owned copies by printing, and — for a
* deck slot — chooses which printing's art the slot shows.
*/
export interface PreviewContext {
/** Called after any printing change, so the caller can refetch. */
onChanged: () => void;
/** The deck the previewed card is a slot of. */
deckId?: number;
}
export interface Deck {
id: number;
name: string;
@@ -149,6 +188,10 @@ export interface Status {
reference_ready: boolean;
card_count: number;
reference_updated_at: number | null; // unix seconds
/** Columns the installed card data lacks ("table.column") because an older
* version built it. Non-empty means some features are degraded until the
* user updates card data. */
reference_missing_columns: string[];
/** 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. */
@@ -407,6 +450,19 @@ export const api = {
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
/** Every paper printing of a card, newest first. */
listPrintings: (oracleId: string) => invoke<PrintingRef[]>("list_printings", { oracleId }),
listLibraryCopies: (oracleId: string) =>
invoke<LibraryCopy[]>("list_library_copies", { oracleId }),
/** Set how many owned copies are this printing + finish (0 removes the
* group). Resolves with the card's owned quantity, which grows to fit. */
setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) =>
invoke<number>("set_library_copy", { oracleId, scryfallId, finish, quantity }),
/** Choose which printing a deck slot shows; null restores the default.
* Resolves with the slot afterwards (its art reflecting the choice). */
setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) =>
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
importDeck: (path: string, name: string) =>
invoke<DeckImportResult>("import_deck", { path, name }),

View File

@@ -9,15 +9,19 @@ import {
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
api,
CardRef,
COLORS,
LEGALITY_FORMATS,
LibraryCopy,
LibraryFilter,
legalStatusFor,
maskToLetters,
PreviewCard,
PreviewContext,
PrintingRef,
} from "./api";
import { useBackButton } from "./backButton";
@@ -476,7 +480,7 @@ export function ColorPips({ mask }: { mask: number }) {
* clicking it opens a larger, centered preview (with a flip button for
* double-faced cards).
*/
export function CardThumb({ card }: { card: CardRef }) {
export function CardThumb({ card, preview }: { card: PreviewCard; preview?: PreviewContext }) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
if (!card.image_uri) {
@@ -494,7 +498,7 @@ export function CardThumb({ card }: { card: CardRef }) {
loading="lazy"
onClick={(e) => {
e.stopPropagation();
open(card);
open(card, preview);
}}
{...handlers(card)}
/>
@@ -698,8 +702,9 @@ interface HoverApi {
leave: () => void;
}
interface ClickApi {
open: (card: PreviewCard) => void;
open: (card: PreviewCard, ctx?: PreviewContext) => void;
}
type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean };
const HoverCtx = createContext<HoverApi | null>(null);
const ClickCtx = createContext<ClickApi | null>(null);
@@ -711,7 +716,7 @@ const ClickCtx = createContext<ClickApi | null>(null);
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null);
const [clicked, setClicked] = useState<ClickedState | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
@@ -721,10 +726,10 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) {
}, []);
const leave = useCallback(() => setHover(null), []);
const open = useCallback((card: PreviewCard) => {
const open = useCallback((card: PreviewCard, ctx?: PreviewContext) => {
if (!card.image_uri) return;
setHover(null);
setClicked({ card, showBack: false });
setClicked({ card, ctx, showBack: false });
}, []);
const close = useCallback(() => setClicked(null), []);
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
@@ -763,10 +768,11 @@ export function useHoverPreview() {
};
}
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
/** Returns `open(card, ctx?)` to trigger the larger click-to-preview modal.
* Passing a `ctx` adds the Printings section to its info tile. */
export function useClickPreview() {
const api = useContext(ClickCtx);
return { open: (card: PreviewCard) => api?.open(card) };
return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) };
}
const PREVIEW_W = 320;
@@ -867,7 +873,11 @@ function LegalityPills({ legalities }: { legalities: number }) {
* type/mana/color, price, ownership context (when the caller had a
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
* legality. */
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
const CardInfoTile = forwardRef<
HTMLDivElement,
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void }
>(function CardInfoTile({ card, ctx, onArtChange }, ref) {
const printings = !!ctx && card.quantity_owned !== undefined;
return (
<div className="preview-info" ref={ref}>
<div className="preview-info-name">{card.name}</div>
@@ -879,13 +889,17 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
{card.price_usd > 0 && (
<div className="preview-info-price">${card.price_usd.toFixed(2)}</div>
)}
{card.quantity_owned !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
</span>
</div>
{printings ? (
<PrintingsSection card={card} ctx={ctx} onArtChange={onArtChange} />
) : (
card.quantity_owned !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
</span>
</div>
)
)}
{card.decks && card.decks.length > 0 && (
<div className="preview-info-section">
@@ -907,6 +921,254 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
);
});
type CardArt = Pick<CardRef, "image_uri" | "back_image_uri">;
const FINISH_LABELS: Record<string, string> = { nonfoil: "Nonfoil", foil: "Foil", etched: "Etched" };
/** "2X2 #315", the way decklists and binders name a printing. */
function printingLabel(p: PrintingRef): string {
return `${p.set_code.toUpperCase()} #${p.collector_number}`;
}
/**
* The click preview's printing controls (shown when it was opened with a
* `PreviewContext`): owned copies broken down by printing + finish, and — for
* a deck slot — which printing's art the slot shows. Takes the place of the
* plain "Owned" row, since growing a copy group can change that total.
*/
function PrintingsSection({
card,
ctx,
onArtChange,
}: {
card: PreviewCard;
ctx: PreviewContext;
onArtChange: (art: CardArt) => void;
}) {
const [owned, setOwned] = useState(card.quantity_owned ?? 0);
const [copies, setCopies] = useState<LibraryCopy[]>([]);
const [printings, setPrintings] = useState<PrintingRef[] | null>(null);
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
api
.listLibraryCopies(card.oracle_id)
.then((c) => live && setCopies(c))
.catch(() => {});
api
.listPrintings(card.oracle_id)
.then((p) => live && setPrintings(p))
.catch(() => live && setPrintings([]));
return () => {
live = false;
};
}, [card.oracle_id]);
const tracked = copies.reduce((n, c) => n + c.quantity, 0);
const shown = preferred ? printings?.find((p) => p.scryfall_id === preferred) : undefined;
async function run(action: () => Promise<void>) {
setError(null);
try {
await action();
ctx.onChanged();
} catch (e) {
setError(String(e));
}
}
const setCopy = (scryfallId: string, finish: string, quantity: number) =>
run(async () => {
setOwned(await api.setLibraryCopy(card.oracle_id, scryfallId, finish, quantity));
setCopies(await api.listLibraryCopies(card.oracle_id));
});
const showPrinting = (scryfallId: string | null) =>
run(async () => {
const entry = await api.setDeckCardPrinting(ctx.deckId!, card.oracle_id, scryfallId);
setPreferred(scryfallId);
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
});
function onPick(p: PrintingRef, finish: string) {
const mode = picking;
setPicking(null);
if (mode === "deck") {
showPrinting(p.scryfall_id);
} else {
const existing = copies.find((c) => c.scryfall_id === p.scryfall_id && c.finish === finish);
setCopy(p.scryfall_id, finish, (existing?.quantity ?? 0) + 1);
}
}
return (
<>
{ctx.deckId !== undefined && (
<div className="preview-info-section">
<span className="preview-info-label">Shown in this deck</span>
<div className="printing-row">
<span className="printing-row-label">
{!preferred ? "Default printing" : shown ? printingLabel(shown) : "Unavailable printing"}
</span>
<span className="spacer" />
<span className="printing-actions">
<button onClick={() => setPicking("deck")} disabled={!printings?.length}>
Change…
</button>
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
</span>
</div>
</div>
)}
<div className="preview-info-section">
<span className="preview-info-label">Owned</span>
<span>
{owned} cop{owned === 1 ? "y" : "ies"}
{tracked > 0 && (
<span className="muted">
{" "}
· {tracked} by printing · {Math.max(0, owned - tracked)} unspecified
</span>
)}
</span>
{copies.map((c) => (
<div key={`${c.scryfall_id}/${c.finish}`} className="printing-row">
{c.printing?.image_uri ? (
<img className="printing-row-thumb" src={c.printing.image_uri} alt="" loading="lazy" />
) : (
<span className="printing-row-thumb" />
)}
<span className="printing-row-label">
{c.printing ? printingLabel(c.printing) : "Unavailable printing"}
<span className="muted"> · {FINISH_LABELS[c.finish] ?? c.finish}</span>
</span>
<span className="spacer" />
<QtyStepper
value={c.quantity}
onDecrement={() => setCopy(c.scryfall_id, c.finish, c.quantity - 1)}
onIncrement={() => setCopy(c.scryfall_id, c.finish, c.quantity + 1)}
/>
</div>
))}
<span className="printing-actions">
<button onClick={() => setPicking("copy")} disabled={!printings?.length}>
Add a printing…
</button>
</span>
{error && <div className="dialog-error">{error}</div>}
</div>
{picking && printings && (
<PrintingPicker
name={card.name}
printings={printings}
chooseFinish={picking === "copy"}
current={picking === "deck" ? preferred : null}
onPick={onPick}
onCancel={() => setPicking(null)}
/>
)}
</>
);
}
/**
* A grid of a card's printings to pick from. With `chooseFinish`, each
* printing also offers its finishes (clicking the art takes the first).
*
* Opens above the click preview, which closes on any Escape it hears on
* `window` — so this listens in the capture phase and stops the event there,
* and only the picker closes.
*/
function PrintingPicker({
name,
printings,
chooseFinish,
current,
onPick,
onCancel,
}: {
name: string;
printings: PrintingRef[];
chooseFinish: boolean;
current: string | null;
onPick: (p: PrintingRef, finish: string) => void;
onCancel: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
onCancel();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [onCancel]);
useBackButton(onCancel);
return createPortal(
<div
className="dialog-backdrop printing-picker-backdrop"
onClick={(e) => {
e.stopPropagation();
onCancel();
}}
>
<div className="dialog dialog-wide" onClick={(e) => e.stopPropagation()}>
<div className="dialog-title-row">
<h3 className="dialog-title">{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}</h3>
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
</div>
<div className="printing-grid">
{printings.map((p) => {
const finishes = p.finishes.length > 0 ? p.finishes : ["nonfoil"];
const meta = [
p.released_at.slice(0, 4),
p.rarity && p.rarity[0].toUpperCase() + p.rarity.slice(1),
p.lang && p.lang !== "en" ? p.lang.toUpperCase() : "",
p.price_usd > 0 ? `$${p.price_usd.toFixed(2)}` : "",
]
.filter(Boolean)
.join(" · ");
return (
<div
key={p.scryfall_id}
className={`printing-option ${p.scryfall_id === current ? "selected" : ""}`}
>
<button
className="printing-option-art"
onClick={() => onPick(p, finishes[0])}
title={chooseFinish ? `Add a ${FINISH_LABELS[finishes[0]] ?? finishes[0]} copy` : "Show this printing"}
>
{p.image_uri ? (
<img src={p.image_uri} alt={printingLabel(p)} loading="lazy" />
) : (
<div className="tile-noimg">{printingLabel(p)}</div>
)}
</button>
<div className="printing-option-label">{printingLabel(p)}</div>
{meta && <div className="printing-option-meta">{meta}</div>}
{chooseFinish && finishes.length > 1 && (
<div className="printing-actions">
{finishes.map((f) => (
<button key={f} onClick={() => onPick(p, f)}>
{FINISH_LABELS[f] ?? f}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</div>
</div>,
document.body,
);
}
type PreviewPane = "card" | "info";
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
@@ -946,11 +1208,15 @@ function ClickPreviewModal({
onClose,
onFlip,
}: {
state: { card: PreviewCard; showBack: boolean };
state: ClickedState;
onClose: () => void;
onFlip: () => void;
}) {
const { card, showBack } = state;
const { ctx, showBack } = state;
// A printing just chosen for this deck slot: show its art right away
// rather than the art the preview was opened with.
const [art, setArt] = useState<CardArt | null>(null);
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
const hasBack = !!card.back_image_uri;
const backdropRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -1072,7 +1338,7 @@ function ClickPreviewModal({
</button>
)}
</div>
<CardInfoTile ref={infoRef} card={card} />
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
@@ -1455,8 +1721,9 @@ export function CardTile({
footer,
selected,
rotated,
preview,
}: {
card: CardRef;
card: PreviewCard;
corner?: ReactNode;
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
cornerLeft?: ReactNode;
@@ -1469,6 +1736,8 @@ export function CardTile({
* is also true, so mixed results (e.g. a set with both Sieges and normal
* cards) don't spin the ones that are already right-side up. */
rotated?: boolean;
/** Lets the click preview edit this card's printings. */
preview?: PreviewContext;
}) {
const { open } = useClickPreview();
const showRotated = !!rotated && card.horizontal;
@@ -1482,7 +1751,7 @@ export function CardTile({
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
onClick={() => open(card, preview)}
/>
) : (
<img
@@ -1490,7 +1759,7 @@ export function CardTile({
src={card.image_uri}
alt={card.name}
loading="lazy"
onClick={() => open(card)}
onClick={() => open(card, preview)}
/>
)
) : (

View File

@@ -672,6 +672,27 @@ input[type="checkbox"] {
border-bottom: 1px solid var(--border-soft);
font-size: 13px;
}
/* Card data from an older version: same strip as the ingest bar, tinted as
a warning. Wraps its buttons under the message on narrow screens. */
.outdated-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding: 8px 18px;
background: rgba(224, 163, 74, 0.12);
border-bottom: 1px solid rgba(224, 163, 74, 0.35);
font-size: 13px;
}
.outdated-bar-message {
flex: 1 1 260px;
color: var(--text);
}
.outdated-bar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ingest-bar-head {
display: flex;
align-items: center;
@@ -3299,3 +3320,82 @@ tbody tr:hover {
font-size: 22px;
}
}
/* ============================================================
Printings: the click preview's per-printing controls, and the picker
============================================================ */
.printing-row {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.printing-row-thumb {
width: 28px;
height: 39px;
flex-shrink: 0;
object-fit: cover;
border-radius: 3px;
background: var(--bg-elev);
}
.printing-row-label {
min-width: 0;
font-size: 13px;
overflow-wrap: anywhere;
}
.printing-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.printing-actions button {
padding: 2px 10px;
font-size: 12px;
}
/* Above the click preview (450) it opens from. */
.printing-picker-backdrop {
z-index: 500;
}
.printing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(130px, 40vw), 1fr));
gap: var(--space-3);
}
.printing-option {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.printing-option-art {
display: block;
width: 100%;
padding: 0;
border: 2px solid transparent;
border-radius: var(--radius-card);
background: none;
cursor: pointer;
}
.printing-option-art:hover {
border-color: var(--accent-hover);
background: none;
}
.printing-option.selected .printing-option-art {
border-color: var(--accent);
}
.printing-option-art img {
display: block;
width: 100%;
aspect-ratio: 488 / 680;
object-fit: cover;
border-radius: calc(var(--radius-card) - 2px);
}
.printing-option-label {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
.printing-option-meta {
font-size: 12px;
color: var(--muted);
}

View File

@@ -1,6 +1,6 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api";
import {
CardSearchBox,
CardThumb,
@@ -169,7 +169,15 @@ function computeDeckStats(contents: DeckEntry[]): DeckStats {
}
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
function CommanderHero({
card,
onClear,
preview,
}: {
card: DeckEntry;
onClear: () => void;
preview?: PreviewContext;
}) {
const { handlers } = useHoverPreview();
const { open } = useClickPreview();
return (
@@ -179,7 +187,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
className="commander-hero-img"
src={card.image_uri}
alt={card.name}
onClick={() => open(card)}
onClick={() => open(card, preview)}
{...handlers(card)}
/>
) : (
@@ -740,6 +748,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
// Lets the click preview track copies and choose each slot's printing.
const preview: PreviewContext | undefined =
selected === null ? undefined : { deckId: selected, onChanged: loadContents };
function deckTiles(entries: DeckEntry[]) {
return (
<div className="tile-grid">
@@ -747,6 +759,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<CardTile
key={e.oracle_id}
card={e}
preview={preview}
selected={selectedCards.has(e.oracle_id)}
cornerLeft={
<span className="tile-select">
@@ -803,7 +816,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
/>
</td>
<td className="cell-thumb">
<CardThumb card={e} />
<CardThumb card={e} preview={preview} />
</td>
<td>
<ColorPips mask={e.color_identity} />
@@ -961,7 +974,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
<div className="deck-top-grid">
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
<div className="deck-top-commander">
<CommanderHero card={commanderEntry} onClear={clearCommander} />
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
</div>
)}

View File

@@ -1,6 +1,14 @@
import { open, save } from "@tauri-apps/plugin-dialog";
import { useEffect, useRef, useState } from "react";
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
import {
api,
Deck,
LibraryCard,
LibraryFacets,
LibraryFilter,
LibraryImportResult,
PreviewContext,
} from "../api";
import {
CardThumb,
CardTile,
@@ -330,6 +338,9 @@ export function LibraryBrowser({
</span>
);
// Lets the click preview track which printings the copies are.
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
function libTable(cards: LibraryCard[]) {
return (
<table>
@@ -356,7 +367,7 @@ export function LibraryBrowser({
/>
</td>
<td className="cell-thumb">
<CardThumb card={c} />
<CardThumb card={c} preview={preview} />
</td>
<td>
<ColorPips mask={c.color_identity} />
@@ -399,6 +410,7 @@ export function LibraryBrowser({
<CardTile
key={c.oracle_id}
card={c}
preview={preview}
selected={selected.has(c.oracle_id)}
cornerLeft={
<span className="tile-select">