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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user