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:
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<_>, _>>()?;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
525
crates/mtgc-core/src/printings.rs
Normal file
525
crates/mtgc-core/src/printings.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
15
crates/mtgc-core/src/sql/library/0006_printings.sql
Normal file
15
crates/mtgc-core/src/sql/library/0006_printings.sql
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user