From 2ea3c565111cf10f7db3a676c8982cf48fca1e64 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Tue, 7 Jul 2026 20:54:55 -0500 Subject: [PATCH] Capture back-face printing images for double-faced cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a back_image_uri column to printings, populated during ingest from a card's second card_faces image (only present for transform/MDFC/ reversible cards — split/adventure cards share one combined image and correctly get ""). Plumb it through CardRef across every query path (search, library, filters, deck contents) via the same printing row, using a shared deterministic ORDER BY so front/back always come from one printing. Co-Authored-By: Claude Sonnet 5 --- crates/mtgc-core/src/db.rs | 14 +++-- crates/mtgc-core/src/decks.rs | 6 +- crates/mtgc-core/src/filters.rs | 5 +- crates/mtgc-core/src/ingest.rs | 62 ++++++++++++++++++- crates/mtgc-core/src/library.rs | 33 ++++++++-- crates/mtgc-core/src/model.rs | 3 + crates/mtgc-core/src/search.rs | 6 +- crates/mtgc-core/src/sql/reference_schema.sql | 3 +- 8 files changed, 114 insertions(+), 18 deletions(-) diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 18475a2..984058e 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -21,12 +21,16 @@ const LIBRARY_MIGRATIONS: &[&str] = &[ /// The attached alias under which the reference db is available at runtime. pub const REF_DB: &str = "ref"; -/// Correlated subquery selecting one image URL for the oracle card aliased `oc`. -/// Returns NULL when no printing has an image. Read it as `Option`. -pub fn image_subquery() -> String { +/// Correlated subquery selecting one column from the same printing (the first, +/// by `rowid`, with a non-empty front image) for the oracle card aliased `oc`. +/// Call once per desired column (e.g. `"image_uri"`, `"back_image_uri"`) — the +/// shared `ORDER BY p.rowid` ensures they all resolve to the *same* printing +/// row. Returns NULL when no printing has an image; read as `Option`. +pub fn image_subquery(column: &str) -> String { format!( - "(SELECT p.image_uri FROM {REF_DB}.printings p - WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' LIMIT 1)" + "(SELECT p.{column} FROM {REF_DB}.printings p + WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' + ORDER BY p.rowid LIMIT 1)" ) } diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 249969b..b072d3a 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -82,13 +82,14 @@ impl Catalog { 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} + oc.type_line, {image}, {back} 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 WHERE dc.deck_id = ?1 ORDER BY oc.name COLLATE NOCASE", - image = crate::db::image_subquery() + image = crate::db::image_subquery("image_uri"), + back = crate::db::image_subquery("back_image_uri") ))?; let rows = stmt .query_map(params![deck_id], |r| { @@ -104,6 +105,7 @@ impl Catalog { color_identity: ColorSet(r.get::<_, i64>(7)? as u8), type_line: r.get(8)?, image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), + back_image_uri: r.get::<_, Option>(10)?.unwrap_or_default(), }, }) })? diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index dd5723e..3704a4b 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -18,11 +18,12 @@ impl Catalog { pub fn search_library(&self, filter: &LibraryFilter) -> Result> { let mut sql = format!( "SELECT lc.oracle_id, lc.quantity_owned, lc.location, - oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image} + oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back} FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0", - image = crate::db::image_subquery() + image = crate::db::image_subquery("image_uri"), + back = crate::db::image_subquery("back_image_uri") ); let mut vals: Vec = Vec::new(); diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 0938193..e2b95ea 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -599,8 +599,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) - VALUES (?1,?2,?3,?4,?5,?6,?7)", + (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", )?; stream_json_array::<_, ScryPrinting, _>(reader, |p| { if cancel.is_cancelled() { @@ -621,6 +621,16 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> .and_then(|i| i.normal.clone()) }) .unwrap_or_default(); + // Only transform/modal-DFC/reversible cards carry per-face images + // with a distinct second face; split/adventure cards share one + // combined top-level image and have no per-face `image_uris`. + let back_image = p + .card_faces + .as_ref() + .and_then(|f| f.get(1)) + .and_then(|f| f.image_uris.as_ref()) + .and_then(|i| i.normal.clone()) + .unwrap_or_default(); ins.execute(params![ p.id, oracle_id, @@ -629,6 +639,7 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> p.finishes.join(","), p.rarity, image, + back_image, ])?; count += 1; Ok(()) @@ -785,4 +796,51 @@ mod tests { .unwrap(); assert_eq!(g as u8, crate::model::G); } + + #[test] + fn captures_back_face_image_for_double_faced_cards() { + // Transform/MDFC cards: two `card_faces`, each with its own image. + let dfc = r#"[ + {"id":"p-dfc","oracle_id":"oid-dfc","set":"znr","collector_number":"1", + "rarity":"rare", + "card_faces":[ + {"image_uris":{"normal":"http://img/front"}}, + {"image_uris":{"normal":"http://img/back"}} + ]} + ]"#; + // Split card: two `card_faces` for oracle text, but no per-face images — + // the whole card is one combined top-level image. + let split = r#"[ + {"id":"p-split","oracle_id":"oid-split","set":"grn","collector_number":"2", + "rarity":"common","image_uris":{"normal":"http://img/split-combined"}, + "card_faces":[{"image_uris":null},{"image_uris":null}]} + ]"#; + + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("reference.db"); + let conn = open_reference_for_build(&dest).unwrap(); + + load_printings(&conn, dfc.as_bytes(), &CancelToken::new()).unwrap(); + load_printings(&conn, split.as_bytes(), &CancelToken::new()).unwrap(); + + let (front, back): (String, String) = conn + .query_row( + "SELECT image_uri, back_image_uri FROM printings WHERE scryfall_id='p-dfc'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(front, "http://img/front"); + assert_eq!(back, "http://img/back"); + + let (front, back): (String, String) = conn + .query_row( + "SELECT image_uri, back_image_uri FROM printings WHERE scryfall_id='p-split'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(front, "http://img/split-combined"); + assert_eq!(back, "", "split cards have no distinct back face"); + } } diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index eb6dfd1..a795880 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -10,8 +10,8 @@ use crate::model::{CardRef, ColorSet, LibraryCard}; use crate::Catalog; /// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value, -/// colors, color_identity, type_line, image_uri)` into its library parts. Shared -/// by [`Catalog::list_library`] and the criteria search. +/// colors, color_identity, type_line, image_uri, back_image_uri)` into its +/// library parts. Shared by [`Catalog::list_library`] and the criteria search. pub(crate) fn row_to_library_parts( r: &rusqlite::Row, ) -> rusqlite::Result<(String, i64, String, CardRef)> { @@ -28,6 +28,7 @@ pub(crate) fn row_to_library_parts( color_identity: ColorSet(r.get::<_, i64>(6)? as u8), type_line: r.get(7)?, image_uri: r.get::<_, Option>(8)?.unwrap_or_default(), + back_image_uri: r.get::<_, Option>(9)?.unwrap_or_default(), }, )) } @@ -69,12 +70,13 @@ impl Catalog { let mut stmt = self.conn.prepare(&format!( "SELECT lc.oracle_id, lc.quantity_owned, lc.location, - oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image} + oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back} FROM library_cards lc JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id WHERE lc.quantity_owned > 0 ORDER BY oc.name COLLATE NOCASE", - image = crate::db::image_subquery() + image = crate::db::image_subquery("image_uri"), + back = crate::db::image_subquery("back_image_uri") ))?; let rows = stmt .query_map([], row_to_library_parts)? @@ -155,6 +157,29 @@ mod tests { assert_eq!(sol.card.image_uri, "http://img/sol-ring"); } + #[test] + fn back_image_uri_is_plumbed_through() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.connection() + .execute( + "UPDATE ref.printings SET back_image_uri = 'http://img/back' \ + WHERE oracle_id = 'sol-ring'", + [], + ) + .unwrap(); + cat.add_owned("sol-ring", 1).unwrap(); + + cat.add_owned("llanowar-elves", 1).unwrap(); + let lib = cat.list_library().unwrap(); + let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap(); + assert_eq!(sol.card.back_image_uri, "http://img/back"); + + // Single-faced cards have no back image. + let elves = lib.iter().find(|c| c.card.oracle_id == "llanowar-elves").unwrap(); + assert_eq!(elves.card.back_image_uri, ""); + } + #[test] fn location_is_stored_and_listed() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index de02dcb..fef5f95 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -88,6 +88,9 @@ pub struct CardRef { /// URL of a card image (Scryfall "normal" size) for one of its printings, /// or empty if none is available. pub image_uri: String, + /// URL of the *back face* image for the same printing (transform / modal + /// double-faced / reversible cards), or empty for single-faced cards. + pub back_image_uri: String, } /// A library row: a card the user owns, with quantity and its deck memberships. diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index 3790a41..3c0762e 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -34,9 +34,10 @@ impl NameIndex { pub fn build(conn: &Connection) -> Result { let mut stmt = conn.prepare(&format!( "SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity, - oc.type_line, {image} + oc.type_line, {image}, {back} FROM {REF_DB}.oracle_cards oc", - image = crate::db::image_subquery() + image = crate::db::image_subquery("image_uri"), + back = crate::db::image_subquery("back_image_uri") ))?; let entries = stmt .query_map([], |r| { @@ -49,6 +50,7 @@ impl NameIndex { color_identity: ColorSet(r.get::<_, i64>(4)? as u8), type_line: r.get(5)?, image_uri: r.get::<_, Option>(6)?.unwrap_or_default(), + back_image_uri: r.get::<_, Option>(7)?.unwrap_or_default(), }, }) })? diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 4145ace..9f535e0 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -46,7 +46,8 @@ CREATE TABLE IF NOT EXISTS printings ( collector_number TEXT NOT NULL DEFAULT '', finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil" rarity TEXT NOT NULL DEFAULT '', - image_uri TEXT NOT NULL DEFAULT '' + image_uri TEXT NOT NULL DEFAULT '', + back_image_uri TEXT NOT NULL DEFAULT '' -- transform/MDFC/reversible back face ); CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);