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:
40
README.md
40
README.md
@@ -205,6 +205,39 @@ Marvel Commander — isn't hidden.
|
||||
> once to enable them; until then they're inert rather than broken. Price,
|
||||
> format legality and *can be a commander* work immediately.
|
||||
|
||||
### Printings
|
||||
|
||||
The library counts cards, not printings, but you can say which printing a copy
|
||||
is when it matters. Click a card's image in the **Library** or a deck to open
|
||||
its preview. The **Owned** section lists your copies by printing and finish
|
||||
(nonfoil / foil / etched), with a stepper for each, and **Add a printing…**
|
||||
picks another. Copies you haven't assigned a printing to stay *unspecified*:
|
||||
"5 copies · 3 by printing · 2 unspecified". Assigning more copies to printings
|
||||
than you own raises the owned count to match. Lowering the owned count drops
|
||||
the most recently added printing entries first.
|
||||
|
||||
In a deck, **Shown in this deck → Change…** picks which printing's art that
|
||||
slot shows, and **Default** goes back to the usual one. This is display only:
|
||||
it doesn't need to be a copy you own.
|
||||
|
||||
Decklist export writes a chosen or tracked printing the way Moxfield and Arena
|
||||
do, and import reads it back:
|
||||
|
||||
```text
|
||||
1 Sol Ring (2X2) 315 *F*
|
||||
4 Lightning Bolt (2XM) 129
|
||||
```
|
||||
|
||||
`*F*` marks foil and `*E*` etched. A library export writes one line per
|
||||
printing, then a plain line for the unspecified copies. A suffix that doesn't
|
||||
match any printing still imports the card itself.
|
||||
|
||||
> The picker's dates and languages need card data from this version. Until you
|
||||
> update it, printings are listed without them. Whenever your card data is
|
||||
> missing something the app could use, a notice under the top bar offers
|
||||
> **Update card data**, and **Import card data…** too on a phone. Dismissing it
|
||||
> with **Not now** lasts until a later version needs something more.
|
||||
|
||||
## Sync
|
||||
|
||||
Your library always lives on the device you're using, so the app works the
|
||||
@@ -235,6 +268,13 @@ either is lost) or **use the server's library** instead of this device's.
|
||||
The Info tab also lists the account's signed-in devices, so a lost phone can be
|
||||
signed out from another one.
|
||||
|
||||
> **Printings and older versions.** A library with printings in it (see
|
||||
> [Printings](#printings)) is saved in a newer format. A device or server
|
||||
> running an older version refuses it and says to update, rather than syncing
|
||||
> and quietly losing them. Libraries that don't use printings stay readable by
|
||||
> older versions. Update the sync server before the first device starts using
|
||||
> printings.
|
||||
|
||||
> **Upgrading from folder sync?** Earlier versions kept the library as a JSON
|
||||
> file in a folder your sync tool watched. Your library is already on each
|
||||
> device, so signing in to a server picks up from there; the first device to
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Design: optional per-printing tracking
|
||||
|
||||
Status: **not implemented** — design only, for a future session.
|
||||
Status: **implemented.** The as-built decisions are in
|
||||
[As built](#as-built) at the end, which takes precedence over the original
|
||||
design below wherever the two differ.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -148,3 +150,42 @@ later is a compatible follow-up, not a redesign.
|
||||
`crates/mtgc-core/src/model.rs`, `src-tauri/src/commands.rs`, `src/api.ts`,
|
||||
`src/components.tsx`, `src/views/DeckView.tsx`, `src/views/LibraryBrowser.tsx`,
|
||||
`src/views/CardEntry.tsx`.
|
||||
|
||||
## As built
|
||||
|
||||
Where the implementation settled the open questions, or departed from the
|
||||
design above:
|
||||
|
||||
- **Migration** is `0006_printings.sql` (0005 had gone to deck uuids). It adds
|
||||
`library_card_copies.quantity`, a unique index on
|
||||
`(library_card_id, scryfall_id, finish)`, and `deck_cards.preferred_scryfall_id`.
|
||||
Everything lives in `crates/mtgc-core/src/printings.rs`.
|
||||
- **No `remove_copy(copy_id)`.** `set_library_copy(.., quantity)` with
|
||||
`quantity <= 0` removes a group, mirroring `set_deck_card`. Row ids are local
|
||||
and are rebuilt on every sync, so they would be a poor handle anyway.
|
||||
- **Quantity rules (open question 3).**
|
||||
- *Grow:* setting a group above the unspecified remainder raises
|
||||
`quantity_owned` to fit.
|
||||
- *Clamp:* lowering `quantity_owned` below the tracked total trims the
|
||||
highest-id (most recently added) groups first.
|
||||
- Lowering a group never lowers `quantity_owned`; those copies become
|
||||
unspecified.
|
||||
- All of this is pinned by tests in `printings.rs`.
|
||||
- **Library thumbnails (open question 1)** always show the oracle default.
|
||||
Only deck slots show a chosen printing.
|
||||
- **Decklists (open question 2)** round-trip in this first cut, as
|
||||
`(SET) NUM`, optionally followed by `*F*` / `*E*`. See `decklist.rs`.
|
||||
- **UI.** There is no per-row button. A *Printings* section in the
|
||||
click-preview info tile appears whenever the caller passes a
|
||||
`PreviewContext`, and it opens a `PrintingPicker`. Both are in
|
||||
`src/components.tsx`.
|
||||
- **Sync, which postdates this design.**
|
||||
- `CardEntry.printings` is keyed `"<scryfall_id>/<finish>"`, and
|
||||
`DeckCardEntry.printing` holds the chosen printing.
|
||||
- Cards now merge field by field, and their groups merge per key, so edits
|
||||
to one card from two devices both survive. A merge result is clamped by
|
||||
trimming from the end of key order, which comes out the same on every
|
||||
device.
|
||||
- A document is written as version 2 only once it holds printing data, so
|
||||
older builds refuse it rather than dropping the data and syncing the loss.
|
||||
- **Follow-up:** choosing a printing while entering cards on Add Cards.
|
||||
|
||||
@@ -6,8 +6,8 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use mtgc_core::{
|
||||
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter,
|
||||
LibraryStats, SuggestOptions,
|
||||
CardRef, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets,
|
||||
LibraryFilter, LibraryStats, PrintingRef, SuggestOptions,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
@@ -28,6 +28,10 @@ pub struct Status {
|
||||
/// Unix timestamp (seconds) of reference.db's last modification — i.e.
|
||||
/// when "Update card data" last completed. `None` if unavailable.
|
||||
pub reference_updated_at: Option<i64>,
|
||||
/// Columns this build's card-data schema has that the installed card data
|
||||
/// lacks (`"table.column"`). Non-empty means it was built by an older
|
||||
/// version and should be updated; empty when current or not loaded.
|
||||
pub reference_missing_columns: Vec<String>,
|
||||
/// Whether this is a mobile build. The UI uses it to hide affordances that
|
||||
/// only make sense with an arbitrary-path filesystem. Reported from the build
|
||||
/// target rather than sniffed from the user agent so it can't disagree with
|
||||
@@ -62,6 +66,11 @@ fn status_of(s: &AppState) -> Status {
|
||||
reference_ready: s.catalog.is_some(),
|
||||
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
|
||||
reference_updated_at,
|
||||
reference_missing_columns: s
|
||||
.catalog
|
||||
.as_ref()
|
||||
.map(|c| c.missing_reference_columns().to_vec())
|
||||
.unwrap_or_default(),
|
||||
is_mobile: cfg!(mobile),
|
||||
}
|
||||
}
|
||||
@@ -322,6 +331,54 @@ pub fn set_deck_card(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_printings(db: Db, oracle_id: String) -> Res<Vec<PrintingRef>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?.list_printings(&oracle_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_library_copies(db: Db, oracle_id: String) -> Res<Vec<LibraryCopy>> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.list_library_copies(&oracle_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Returns the card's owned quantity afterwards, which grows when the copy
|
||||
/// groups would otherwise exceed it.
|
||||
#[tauri::command]
|
||||
pub fn set_library_copy(
|
||||
db: Db,
|
||||
oracle_id: String,
|
||||
scryfall_id: String,
|
||||
finish: String,
|
||||
quantity: i64,
|
||||
) -> Res<i64> {
|
||||
let s = db.lock().unwrap();
|
||||
s.catalog()?
|
||||
.set_library_copy(&oracle_id, &scryfall_id, &finish, quantity)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Returns the slot afterwards, so the UI can show the resulting art without
|
||||
/// knowing how the default is chosen.
|
||||
#[tauri::command]
|
||||
pub fn set_deck_card_printing(
|
||||
db: Db,
|
||||
deck_id: i64,
|
||||
oracle_id: String,
|
||||
scryfall_id: Option<String>,
|
||||
) -> Res<Option<DeckEntry>> {
|
||||
let s = db.lock().unwrap();
|
||||
let catalog = s.catalog()?;
|
||||
catalog
|
||||
.set_deck_card_printing(deck_id, &oracle_id, scryfall_id.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let contents = catalog.deck_contents(deck_id).map_err(|e| e.to_string())?;
|
||||
Ok(contents.into_iter().find(|e| e.card.oracle_id == oracle_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
|
||||
let s = db.lock().unwrap();
|
||||
|
||||
@@ -99,6 +99,10 @@ pub fn run() {
|
||||
commands::deck_contents,
|
||||
commands::suggest_cards,
|
||||
commands::card_decks,
|
||||
commands::list_printings,
|
||||
commands::list_library_copies,
|
||||
commands::set_library_copy,
|
||||
commands::set_deck_card_printing,
|
||||
commands::export_deck,
|
||||
commands::import_deck,
|
||||
commands::export_library_cards,
|
||||
|
||||
32
src/App.tsx
32
src/App.tsx
@@ -342,6 +342,14 @@ export default function App() {
|
||||
// invited exactly what happened: a redundant download click before the
|
||||
// real status had even loaded. Distinguish the two explicitly.
|
||||
const statusLoaded = status !== null;
|
||||
// Card data built by an older version lacks columns this one reads, so
|
||||
// some features quietly stay off until it's updated. "Not now" is
|
||||
// remembered against exactly what's missing: the notice comes back once a
|
||||
// later version adds something more.
|
||||
const missingColumns = status?.reference_missing_columns ?? [];
|
||||
const outdatedKey = missingColumns.join(",");
|
||||
const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", "");
|
||||
const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey;
|
||||
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
|
||||
// across renders (re-creating them would re-arm the listeners below and fire
|
||||
// a fresh sync every time card data finished loading).
|
||||
@@ -632,6 +640,30 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOutdated && (
|
||||
<div className="outdated-bar" role="status">
|
||||
<span className="outdated-bar-message">
|
||||
Your card data was built by an older version of mtgc, so some newer features stay off until you
|
||||
update it.
|
||||
</span>
|
||||
<span className="outdated-bar-actions">
|
||||
<button className="primary" onClick={updateCardData} disabled={busy}>
|
||||
{ICON_DOWNLOAD}
|
||||
Update card data
|
||||
</button>
|
||||
{/* A full download is heavy on a phone; copying a fresh
|
||||
reference.db over from a desktop is the cheaper route. */}
|
||||
{status?.is_mobile && (
|
||||
<button onClick={importReferenceDb} disabled={busy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Import card data…
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setDismissedOutdated(outdatedKey)}>Not now</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="content">
|
||||
{!statusLoaded ? (
|
||||
<div className="empty-state">
|
||||
|
||||
56
src/api.ts
56
src/api.ts
@@ -36,6 +36,32 @@ export interface DeckEntry extends CardRef {
|
||||
* analysis stats and library assignment totals. */
|
||||
is_maybeboard: boolean;
|
||||
quantity_owned: number;
|
||||
/** The printing whose art this slot shows; `image_uri` already reflects it. */
|
||||
preferred_scryfall_id: string | null;
|
||||
}
|
||||
|
||||
/** One printing of a card, for the printing picker and copy labels. */
|
||||
export interface PrintingRef {
|
||||
scryfall_id: string;
|
||||
set_code: string; // lowercase, e.g. "2x2"
|
||||
collector_number: string;
|
||||
lang: string; // "" on an older card database
|
||||
finishes: string[]; // "nonfoil" | "foil" | "etched"
|
||||
rarity: string;
|
||||
image_uri: string;
|
||||
back_image_uri: string;
|
||||
price_usd: number;
|
||||
released_at: string; // YYYY-MM-DD, "" on an older card database
|
||||
}
|
||||
|
||||
/** Owned copies pinned to one printing + finish. A subset of the card's
|
||||
* `quantity_owned`; copies in no group are unspecified. */
|
||||
export interface LibraryCopy {
|
||||
scryfall_id: string;
|
||||
finish: string;
|
||||
quantity: number;
|
||||
/** Null when the printing has left the card database. */
|
||||
printing: PrintingRef | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,8 +74,21 @@ export interface DeckEntry extends CardRef {
|
||||
export type PreviewCard = CardRef & {
|
||||
quantity_owned?: number;
|
||||
decks?: string[];
|
||||
preferred_scryfall_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a click preview was opened from, when the caller lets it edit
|
||||
* printings: the info tile then tracks owned copies by printing, and — for a
|
||||
* deck slot — chooses which printing's art the slot shows.
|
||||
*/
|
||||
export interface PreviewContext {
|
||||
/** Called after any printing change, so the caller can refetch. */
|
||||
onChanged: () => void;
|
||||
/** The deck the previewed card is a slot of. */
|
||||
deckId?: number;
|
||||
}
|
||||
|
||||
export interface Deck {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -149,6 +188,10 @@ export interface Status {
|
||||
reference_ready: boolean;
|
||||
card_count: number;
|
||||
reference_updated_at: number | null; // unix seconds
|
||||
/** Columns the installed card data lacks ("table.column") because an older
|
||||
* version built it. Non-empty means some features are degraded until the
|
||||
* user updates card data. */
|
||||
reference_missing_columns: string[];
|
||||
/** True on mobile builds. Used to hide affordances that depend on choosing an
|
||||
* arbitrary filesystem path for library.db — Android scoped storage has no
|
||||
* equivalent, and SQLite can't be backed by a content:// URI. */
|
||||
@@ -407,6 +450,19 @@ export const api = {
|
||||
invoke<CardSuggestion[]>("suggest_cards", { deckId, offset, options }),
|
||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||
|
||||
/** Every paper printing of a card, newest first. */
|
||||
listPrintings: (oracleId: string) => invoke<PrintingRef[]>("list_printings", { oracleId }),
|
||||
listLibraryCopies: (oracleId: string) =>
|
||||
invoke<LibraryCopy[]>("list_library_copies", { oracleId }),
|
||||
/** Set how many owned copies are this printing + finish (0 removes the
|
||||
* group). Resolves with the card's owned quantity, which grows to fit. */
|
||||
setLibraryCopy: (oracleId: string, scryfallId: string, finish: string, quantity: number) =>
|
||||
invoke<number>("set_library_copy", { oracleId, scryfallId, finish, quantity }),
|
||||
/** Choose which printing a deck slot shows; null restores the default.
|
||||
* Resolves with the slot afterwards (its art reflecting the choice). */
|
||||
setDeckCardPrinting: (deckId: number, oracleId: string, scryfallId: string | null) =>
|
||||
invoke<DeckEntry | null>("set_deck_card_printing", { deckId, oracleId, scryfallId }),
|
||||
|
||||
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
|
||||
importDeck: (path: string, name: string) =>
|
||||
invoke<DeckImportResult>("import_deck", { path, name }),
|
||||
|
||||
@@ -9,15 +9,19 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
api,
|
||||
CardRef,
|
||||
COLORS,
|
||||
LEGALITY_FORMATS,
|
||||
LibraryCopy,
|
||||
LibraryFilter,
|
||||
legalStatusFor,
|
||||
maskToLetters,
|
||||
PreviewCard,
|
||||
PreviewContext,
|
||||
PrintingRef,
|
||||
} from "./api";
|
||||
import { useBackButton } from "./backButton";
|
||||
|
||||
@@ -476,7 +480,7 @@ export function ColorPips({ mask }: { mask: number }) {
|
||||
* clicking it opens a larger, centered preview (with a flip button for
|
||||
* double-faced cards).
|
||||
*/
|
||||
export function CardThumb({ card }: { card: CardRef }) {
|
||||
export function CardThumb({ card, preview }: { card: PreviewCard; preview?: PreviewContext }) {
|
||||
const { handlers } = useHoverPreview();
|
||||
const { open } = useClickPreview();
|
||||
if (!card.image_uri) {
|
||||
@@ -494,7 +498,7 @@ export function CardThumb({ card }: { card: CardRef }) {
|
||||
loading="lazy"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
open(card);
|
||||
open(card, preview);
|
||||
}}
|
||||
{...handlers(card)}
|
||||
/>
|
||||
@@ -698,8 +702,9 @@ interface HoverApi {
|
||||
leave: () => void;
|
||||
}
|
||||
interface ClickApi {
|
||||
open: (card: PreviewCard) => void;
|
||||
open: (card: PreviewCard, ctx?: PreviewContext) => void;
|
||||
}
|
||||
type ClickedState = { card: PreviewCard; ctx?: PreviewContext; showBack: boolean };
|
||||
const HoverCtx = createContext<HoverApi | null>(null);
|
||||
const ClickCtx = createContext<ClickApi | null>(null);
|
||||
|
||||
@@ -711,7 +716,7 @@ const ClickCtx = createContext<ClickApi | null>(null);
|
||||
*/
|
||||
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
|
||||
const [hover, setHover] = useState<{ card: CardRef; x: number; y: number } | null>(null);
|
||||
const [clicked, setClicked] = useState<{ card: PreviewCard; showBack: boolean } | null>(null);
|
||||
const [clicked, setClicked] = useState<ClickedState | null>(null);
|
||||
|
||||
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
|
||||
if (card.image_uri) setHover({ card, x: e.clientX, y: e.clientY });
|
||||
@@ -721,10 +726,10 @@ export function HoverPreviewProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
const leave = useCallback(() => setHover(null), []);
|
||||
|
||||
const open = useCallback((card: PreviewCard) => {
|
||||
const open = useCallback((card: PreviewCard, ctx?: PreviewContext) => {
|
||||
if (!card.image_uri) return;
|
||||
setHover(null);
|
||||
setClicked({ card, showBack: false });
|
||||
setClicked({ card, ctx, showBack: false });
|
||||
}, []);
|
||||
const close = useCallback(() => setClicked(null), []);
|
||||
const flip = useCallback(() => setClicked((c) => (c ? { ...c, showBack: !c.showBack } : c)), []);
|
||||
@@ -763,10 +768,11 @@ export function useHoverPreview() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns `open(card)` to trigger the larger click-to-preview modal. */
|
||||
/** Returns `open(card, ctx?)` to trigger the larger click-to-preview modal.
|
||||
* Passing a `ctx` adds the Printings section to its info tile. */
|
||||
export function useClickPreview() {
|
||||
const api = useContext(ClickCtx);
|
||||
return { open: (card: PreviewCard) => api?.open(card) };
|
||||
return { open: (card: PreviewCard, ctx?: PreviewContext) => api?.open(card, ctx) };
|
||||
}
|
||||
|
||||
const PREVIEW_W = 320;
|
||||
@@ -867,7 +873,11 @@ function LegalityPills({ legalities }: { legalities: number }) {
|
||||
* type/mana/color, price, ownership context (when the caller had a
|
||||
* `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format
|
||||
* legality. */
|
||||
const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
|
||||
const CardInfoTile = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ card: PreviewCard; ctx?: PreviewContext; onArtChange: (art: CardArt) => void }
|
||||
>(function CardInfoTile({ card, ctx, onArtChange }, ref) {
|
||||
const printings = !!ctx && card.quantity_owned !== undefined;
|
||||
return (
|
||||
<div className="preview-info" ref={ref}>
|
||||
<div className="preview-info-name">{card.name}</div>
|
||||
@@ -879,13 +889,17 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
|
||||
{card.price_usd > 0 && (
|
||||
<div className="preview-info-price">${card.price_usd.toFixed(2)}</div>
|
||||
)}
|
||||
{card.quantity_owned !== undefined && (
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Owned</span>
|
||||
<span>
|
||||
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</div>
|
||||
{printings ? (
|
||||
<PrintingsSection card={card} ctx={ctx} onArtChange={onArtChange} />
|
||||
) : (
|
||||
card.quantity_owned !== undefined && (
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Owned</span>
|
||||
<span>
|
||||
{card.quantity_owned} cop{card.quantity_owned === 1 ? "y" : "ies"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{card.decks && card.decks.length > 0 && (
|
||||
<div className="preview-info-section">
|
||||
@@ -907,6 +921,254 @@ const CardInfoTile = forwardRef<HTMLDivElement, { card: PreviewCard }>(function
|
||||
);
|
||||
});
|
||||
|
||||
type CardArt = Pick<CardRef, "image_uri" | "back_image_uri">;
|
||||
|
||||
const FINISH_LABELS: Record<string, string> = { nonfoil: "Nonfoil", foil: "Foil", etched: "Etched" };
|
||||
|
||||
/** "2X2 #315", the way decklists and binders name a printing. */
|
||||
function printingLabel(p: PrintingRef): string {
|
||||
return `${p.set_code.toUpperCase()} #${p.collector_number}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The click preview's printing controls (shown when it was opened with a
|
||||
* `PreviewContext`): owned copies broken down by printing + finish, and — for
|
||||
* a deck slot — which printing's art the slot shows. Takes the place of the
|
||||
* plain "Owned" row, since growing a copy group can change that total.
|
||||
*/
|
||||
function PrintingsSection({
|
||||
card,
|
||||
ctx,
|
||||
onArtChange,
|
||||
}: {
|
||||
card: PreviewCard;
|
||||
ctx: PreviewContext;
|
||||
onArtChange: (art: CardArt) => void;
|
||||
}) {
|
||||
const [owned, setOwned] = useState(card.quantity_owned ?? 0);
|
||||
const [copies, setCopies] = useState<LibraryCopy[]>([]);
|
||||
const [printings, setPrintings] = useState<PrintingRef[] | null>(null);
|
||||
const [preferred, setPreferred] = useState(card.preferred_scryfall_id ?? null);
|
||||
const [picking, setPicking] = useState<"deck" | "copy" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api
|
||||
.listLibraryCopies(card.oracle_id)
|
||||
.then((c) => live && setCopies(c))
|
||||
.catch(() => {});
|
||||
api
|
||||
.listPrintings(card.oracle_id)
|
||||
.then((p) => live && setPrintings(p))
|
||||
.catch(() => live && setPrintings([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [card.oracle_id]);
|
||||
|
||||
const tracked = copies.reduce((n, c) => n + c.quantity, 0);
|
||||
const shown = preferred ? printings?.find((p) => p.scryfall_id === preferred) : undefined;
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
ctx.onChanged();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
const setCopy = (scryfallId: string, finish: string, quantity: number) =>
|
||||
run(async () => {
|
||||
setOwned(await api.setLibraryCopy(card.oracle_id, scryfallId, finish, quantity));
|
||||
setCopies(await api.listLibraryCopies(card.oracle_id));
|
||||
});
|
||||
|
||||
const showPrinting = (scryfallId: string | null) =>
|
||||
run(async () => {
|
||||
const entry = await api.setDeckCardPrinting(ctx.deckId!, card.oracle_id, scryfallId);
|
||||
setPreferred(scryfallId);
|
||||
if (entry) onArtChange({ image_uri: entry.image_uri, back_image_uri: entry.back_image_uri });
|
||||
});
|
||||
|
||||
function onPick(p: PrintingRef, finish: string) {
|
||||
const mode = picking;
|
||||
setPicking(null);
|
||||
if (mode === "deck") {
|
||||
showPrinting(p.scryfall_id);
|
||||
} else {
|
||||
const existing = copies.find((c) => c.scryfall_id === p.scryfall_id && c.finish === finish);
|
||||
setCopy(p.scryfall_id, finish, (existing?.quantity ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ctx.deckId !== undefined && (
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Shown in this deck</span>
|
||||
<div className="printing-row">
|
||||
<span className="printing-row-label">
|
||||
{!preferred ? "Default printing" : shown ? printingLabel(shown) : "Unavailable printing"}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<span className="printing-actions">
|
||||
<button onClick={() => setPicking("deck")} disabled={!printings?.length}>
|
||||
Change…
|
||||
</button>
|
||||
{preferred && <button onClick={() => showPrinting(null)}>Default</button>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="preview-info-section">
|
||||
<span className="preview-info-label">Owned</span>
|
||||
<span>
|
||||
{owned} cop{owned === 1 ? "y" : "ies"}
|
||||
{tracked > 0 && (
|
||||
<span className="muted">
|
||||
{" "}
|
||||
· {tracked} by printing · {Math.max(0, owned - tracked)} unspecified
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{copies.map((c) => (
|
||||
<div key={`${c.scryfall_id}/${c.finish}`} className="printing-row">
|
||||
{c.printing?.image_uri ? (
|
||||
<img className="printing-row-thumb" src={c.printing.image_uri} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="printing-row-thumb" />
|
||||
)}
|
||||
<span className="printing-row-label">
|
||||
{c.printing ? printingLabel(c.printing) : "Unavailable printing"}
|
||||
<span className="muted"> · {FINISH_LABELS[c.finish] ?? c.finish}</span>
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
<QtyStepper
|
||||
value={c.quantity}
|
||||
onDecrement={() => setCopy(c.scryfall_id, c.finish, c.quantity - 1)}
|
||||
onIncrement={() => setCopy(c.scryfall_id, c.finish, c.quantity + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span className="printing-actions">
|
||||
<button onClick={() => setPicking("copy")} disabled={!printings?.length}>
|
||||
Add a printing…
|
||||
</button>
|
||||
</span>
|
||||
{error && <div className="dialog-error">{error}</div>}
|
||||
</div>
|
||||
{picking && printings && (
|
||||
<PrintingPicker
|
||||
name={card.name}
|
||||
printings={printings}
|
||||
chooseFinish={picking === "copy"}
|
||||
current={picking === "deck" ? preferred : null}
|
||||
onPick={onPick}
|
||||
onCancel={() => setPicking(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid of a card's printings to pick from. With `chooseFinish`, each
|
||||
* printing also offers its finishes (clicking the art takes the first).
|
||||
*
|
||||
* Opens above the click preview, which closes on any Escape it hears on
|
||||
* `window` — so this listens in the capture phase and stops the event there,
|
||||
* and only the picker closes.
|
||||
*/
|
||||
function PrintingPicker({
|
||||
name,
|
||||
printings,
|
||||
chooseFinish,
|
||||
current,
|
||||
onPick,
|
||||
onCancel,
|
||||
}: {
|
||||
name: string;
|
||||
printings: PrintingRef[];
|
||||
chooseFinish: boolean;
|
||||
current: string | null;
|
||||
onPick: (p: PrintingRef, finish: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [onCancel]);
|
||||
useBackButton(onCancel);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="dialog-backdrop printing-picker-backdrop"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="dialog dialog-wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-title-row">
|
||||
<h3 className="dialog-title">{chooseFinish ? `Add a printing of ${name}` : `Show ${name} as`}</h3>
|
||||
<IconButton icon={Icons.close} label="Close" onClick={onCancel} />
|
||||
</div>
|
||||
<div className="printing-grid">
|
||||
{printings.map((p) => {
|
||||
const finishes = p.finishes.length > 0 ? p.finishes : ["nonfoil"];
|
||||
const meta = [
|
||||
p.released_at.slice(0, 4),
|
||||
p.rarity && p.rarity[0].toUpperCase() + p.rarity.slice(1),
|
||||
p.lang && p.lang !== "en" ? p.lang.toUpperCase() : "",
|
||||
p.price_usd > 0 ? `$${p.price_usd.toFixed(2)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<div
|
||||
key={p.scryfall_id}
|
||||
className={`printing-option ${p.scryfall_id === current ? "selected" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="printing-option-art"
|
||||
onClick={() => onPick(p, finishes[0])}
|
||||
title={chooseFinish ? `Add a ${FINISH_LABELS[finishes[0]] ?? finishes[0]} copy` : "Show this printing"}
|
||||
>
|
||||
{p.image_uri ? (
|
||||
<img src={p.image_uri} alt={printingLabel(p)} loading="lazy" />
|
||||
) : (
|
||||
<div className="tile-noimg">{printingLabel(p)}</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="printing-option-label">{printingLabel(p)}</div>
|
||||
{meta && <div className="printing-option-meta">{meta}</div>}
|
||||
{chooseFinish && finishes.length > 1 && (
|
||||
<div className="printing-actions">
|
||||
{finishes.map((f) => (
|
||||
<button key={f} onClick={() => onPick(p, f)}>
|
||||
{FINISH_LABELS[f] ?? f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
type PreviewPane = "card" | "info";
|
||||
|
||||
// Compact-mode toolbar height and the modal's flex gap — keep in sync with
|
||||
@@ -946,11 +1208,15 @@ function ClickPreviewModal({
|
||||
onClose,
|
||||
onFlip,
|
||||
}: {
|
||||
state: { card: PreviewCard; showBack: boolean };
|
||||
state: ClickedState;
|
||||
onClose: () => void;
|
||||
onFlip: () => void;
|
||||
}) {
|
||||
const { card, showBack } = state;
|
||||
const { ctx, showBack } = state;
|
||||
// A printing just chosen for this deck slot: show its art right away
|
||||
// rather than the art the preview was opened with.
|
||||
const [art, setArt] = useState<CardArt | null>(null);
|
||||
const card: PreviewCard = art ? { ...state.card, ...art } : state.card;
|
||||
const hasBack = !!card.back_image_uri;
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
@@ -1072,7 +1338,7 @@ function ClickPreviewModal({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CardInfoTile ref={infoRef} card={card} />
|
||||
<CardInfoTile ref={infoRef} card={card} ctx={ctx} onArtChange={setArt} />
|
||||
{compact && (
|
||||
<div className="preview-modal-bar">
|
||||
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
|
||||
@@ -1455,8 +1721,9 @@ export function CardTile({
|
||||
footer,
|
||||
selected,
|
||||
rotated,
|
||||
preview,
|
||||
}: {
|
||||
card: CardRef;
|
||||
card: PreviewCard;
|
||||
corner?: ReactNode;
|
||||
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
|
||||
cornerLeft?: ReactNode;
|
||||
@@ -1469,6 +1736,8 @@ export function CardTile({
|
||||
* is also true, so mixed results (e.g. a set with both Sieges and normal
|
||||
* cards) don't spin the ones that are already right-side up. */
|
||||
rotated?: boolean;
|
||||
/** Lets the click preview edit this card's printings. */
|
||||
preview?: PreviewContext;
|
||||
}) {
|
||||
const { open } = useClickPreview();
|
||||
const showRotated = !!rotated && card.horizontal;
|
||||
@@ -1482,7 +1751,7 @@ export function CardTile({
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
loading="lazy"
|
||||
onClick={() => open(card)}
|
||||
onClick={() => open(card, preview)}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
@@ -1490,7 +1759,7 @@ export function CardTile({
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
loading="lazy"
|
||||
onClick={() => open(card)}
|
||||
onClick={() => open(card, preview)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
100
src/styles.css
100
src/styles.css
@@ -672,6 +672,27 @@ input[type="checkbox"] {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Card data from an older version: same strip as the ingest bar, tinted as
|
||||
a warning. Wraps its buttons under the message on narrow screens. */
|
||||
.outdated-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
padding: 8px 18px;
|
||||
background: rgba(224, 163, 74, 0.12);
|
||||
border-bottom: 1px solid rgba(224, 163, 74, 0.35);
|
||||
font-size: 13px;
|
||||
}
|
||||
.outdated-bar-message {
|
||||
flex: 1 1 260px;
|
||||
color: var(--text);
|
||||
}
|
||||
.outdated-bar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.ingest-bar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3299,3 +3320,82 @@ tbody tr:hover {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Printings: the click preview's per-printing controls, and the picker
|
||||
============================================================ */
|
||||
.printing-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.printing-row-thumb {
|
||||
width: 28px;
|
||||
height: 39px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.printing-row-label {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.printing-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.printing-actions button {
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* Above the click preview (450) it opens from. */
|
||||
.printing-picker-backdrop {
|
||||
z-index: 500;
|
||||
}
|
||||
.printing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(130px, 40vw), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.printing-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.printing-option-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-card);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.printing-option-art:hover {
|
||||
border-color: var(--accent-hover);
|
||||
background: none;
|
||||
}
|
||||
.printing-option.selected .printing-option-art {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.printing-option-art img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 488 / 680;
|
||||
object-fit: cover;
|
||||
border-radius: calc(var(--radius-card) - 2px);
|
||||
}
|
||||
.printing-option-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.printing-option-meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
|
||||
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor, PreviewContext } from "../api";
|
||||
import {
|
||||
CardSearchBox,
|
||||
CardThumb,
|
||||
@@ -169,7 +169,15 @@ function computeDeckStats(contents: DeckEntry[]): DeckStats {
|
||||
}
|
||||
|
||||
/** A prominent, art-forward display for a deck's commander (Commander/Brawl). */
|
||||
function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void }) {
|
||||
function CommanderHero({
|
||||
card,
|
||||
onClear,
|
||||
preview,
|
||||
}: {
|
||||
card: DeckEntry;
|
||||
onClear: () => void;
|
||||
preview?: PreviewContext;
|
||||
}) {
|
||||
const { handlers } = useHoverPreview();
|
||||
const { open } = useClickPreview();
|
||||
return (
|
||||
@@ -179,7 +187,7 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
|
||||
className="commander-hero-img"
|
||||
src={card.image_uri}
|
||||
alt={card.name}
|
||||
onClick={() => open(card)}
|
||||
onClick={() => open(card, preview)}
|
||||
{...handlers(card)}
|
||||
/>
|
||||
) : (
|
||||
@@ -740,6 +748,10 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
||||
|
||||
// Lets the click preview track copies and choose each slot's printing.
|
||||
const preview: PreviewContext | undefined =
|
||||
selected === null ? undefined : { deckId: selected, onChanged: loadContents };
|
||||
|
||||
function deckTiles(entries: DeckEntry[]) {
|
||||
return (
|
||||
<div className="tile-grid">
|
||||
@@ -747,6 +759,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
<CardTile
|
||||
key={e.oracle_id}
|
||||
card={e}
|
||||
preview={preview}
|
||||
selected={selectedCards.has(e.oracle_id)}
|
||||
cornerLeft={
|
||||
<span className="tile-select">
|
||||
@@ -803,7 +816,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={e} />
|
||||
<CardThumb card={e} preview={preview} />
|
||||
</td>
|
||||
<td>
|
||||
<ColorPips mask={e.color_identity} />
|
||||
@@ -961,7 +974,7 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
|
||||
<div className="deck-top-grid">
|
||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||
<div className="deck-top-commander">
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||
<CommanderHero card={commanderEntry} onClear={clearCommander} preview={preview} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
|
||||
import {
|
||||
api,
|
||||
Deck,
|
||||
LibraryCard,
|
||||
LibraryFacets,
|
||||
LibraryFilter,
|
||||
LibraryImportResult,
|
||||
PreviewContext,
|
||||
} from "../api";
|
||||
import {
|
||||
CardThumb,
|
||||
CardTile,
|
||||
@@ -330,6 +338,9 @@ export function LibraryBrowser({
|
||||
</span>
|
||||
);
|
||||
|
||||
// Lets the click preview track which printings the copies are.
|
||||
const preview: PreviewContext = { onChanged: () => setRefreshKey((k) => k + 1) };
|
||||
|
||||
function libTable(cards: LibraryCard[]) {
|
||||
return (
|
||||
<table>
|
||||
@@ -356,7 +367,7 @@ export function LibraryBrowser({
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={c} />
|
||||
<CardThumb card={c} preview={preview} />
|
||||
</td>
|
||||
<td>
|
||||
<ColorPips mask={c.color_identity} />
|
||||
@@ -399,6 +410,7 @@ export function LibraryBrowser({
|
||||
<CardTile
|
||||
key={c.oracle_id}
|
||||
card={c}
|
||||
preview={preview}
|
||||
selected={selected.has(c.oracle_id)}
|
||||
cornerLeft={
|
||||
<span className="tile-select">
|
||||
|
||||
Reference in New Issue
Block a user