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

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