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

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