Capture back-face printing images for double-faced cards
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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,16 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
|||||||
/// The attached alias under which the reference db is available at runtime.
|
/// The attached alias under which the reference db is available at runtime.
|
||||||
pub const REF_DB: &str = "ref";
|
pub const REF_DB: &str = "ref";
|
||||||
|
|
||||||
/// Correlated subquery selecting one image URL for the oracle card aliased `oc`.
|
/// Correlated subquery selecting one column from the same printing (the first,
|
||||||
/// Returns NULL when no printing has an image. Read it as `Option<String>`.
|
/// by `rowid`, with a non-empty front image) for the oracle card aliased `oc`.
|
||||||
pub fn image_subquery() -> String {
|
/// 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<String>`.
|
||||||
|
pub fn image_subquery(column: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"(SELECT p.image_uri FROM {REF_DB}.printings p
|
"(SELECT p.{column} FROM {REF_DB}.printings p
|
||||||
WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' LIMIT 1)"
|
WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> ''
|
||||||
|
ORDER BY p.rowid LIMIT 1)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,14 @@ impl Catalog {
|
|||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
"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.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
|
FROM deck_cards dc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
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 library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||||
WHERE dc.deck_id = ?1
|
WHERE dc.deck_id = ?1
|
||||||
ORDER BY oc.name COLLATE NOCASE",
|
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
|
let rows = stmt
|
||||||
.query_map(params![deck_id], |r| {
|
.query_map(params![deck_id], |r| {
|
||||||
@@ -104,6 +105,7 @@ impl Catalog {
|
|||||||
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
|
||||||
type_line: r.get(8)?,
|
type_line: r.get(8)?,
|
||||||
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||||
|
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ impl Catalog {
|
|||||||
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
|
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
|
||||||
let mut sql = format!(
|
let mut sql = format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"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
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0",
|
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<Value> = Vec::new();
|
let mut vals: Vec<Value> = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -599,8 +599,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
|||||||
{
|
{
|
||||||
let mut ins = conn.prepare(
|
let mut ins = conn.prepare(
|
||||||
"INSERT OR IGNORE INTO printings
|
"INSERT OR IGNORE INTO printings
|
||||||
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri)
|
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7)",
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||||
)?;
|
)?;
|
||||||
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
|
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
|
||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
@@ -621,6 +621,16 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
|||||||
.and_then(|i| i.normal.clone())
|
.and_then(|i| i.normal.clone())
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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![
|
ins.execute(params![
|
||||||
p.id,
|
p.id,
|
||||||
oracle_id,
|
oracle_id,
|
||||||
@@ -629,6 +639,7 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
|||||||
p.finishes.join(","),
|
p.finishes.join(","),
|
||||||
p.rarity,
|
p.rarity,
|
||||||
image,
|
image,
|
||||||
|
back_image,
|
||||||
])?;
|
])?;
|
||||||
count += 1;
|
count += 1;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -785,4 +796,51 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(g as u8, crate::model::G);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use crate::model::{CardRef, ColorSet, LibraryCard};
|
|||||||
use crate::Catalog;
|
use crate::Catalog;
|
||||||
|
|
||||||
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
||||||
/// colors, color_identity, type_line, image_uri)` into its library parts. Shared
|
/// colors, color_identity, type_line, image_uri, back_image_uri)` into its
|
||||||
/// by [`Catalog::list_library`] and the criteria search.
|
/// library parts. Shared by [`Catalog::list_library`] and the criteria search.
|
||||||
pub(crate) fn row_to_library_parts(
|
pub(crate) fn row_to_library_parts(
|
||||||
r: &rusqlite::Row,
|
r: &rusqlite::Row,
|
||||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
) -> 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),
|
color_identity: ColorSet(r.get::<_, i64>(6)? as u8),
|
||||||
type_line: r.get(7)?,
|
type_line: r.get(7)?,
|
||||||
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
|
||||||
|
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -69,12 +70,13 @@ impl Catalog {
|
|||||||
|
|
||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"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
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0
|
WHERE lc.quantity_owned > 0
|
||||||
ORDER BY oc.name COLLATE NOCASE",
|
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
|
let rows = stmt
|
||||||
.query_map([], row_to_library_parts)?
|
.query_map([], row_to_library_parts)?
|
||||||
@@ -155,6 +157,29 @@ mod tests {
|
|||||||
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
|
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]
|
#[test]
|
||||||
fn location_is_stored_and_listed() {
|
fn location_is_stored_and_listed() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ pub struct CardRef {
|
|||||||
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
||||||
/// or empty if none is available.
|
/// or empty if none is available.
|
||||||
pub image_uri: String,
|
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.
|
/// A library row: a card the user owns, with quantity and its deck memberships.
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ impl NameIndex {
|
|||||||
pub fn build(conn: &Connection) -> Result<Self> {
|
pub fn build(conn: &Connection) -> Result<Self> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
"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",
|
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
|
let entries = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], |r| {
|
||||||
@@ -49,6 +50,7 @@ impl NameIndex {
|
|||||||
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
|
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
|
||||||
type_line: r.get(5)?,
|
type_line: r.get(5)?,
|
||||||
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
||||||
|
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ CREATE TABLE IF NOT EXISTS printings (
|
|||||||
collector_number TEXT NOT NULL DEFAULT '',
|
collector_number TEXT NOT NULL DEFAULT '',
|
||||||
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
|
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
|
||||||
rarity TEXT NOT NULL DEFAULT '',
|
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);
|
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user