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

@@ -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();