Prefer Scryfall's representative printing for default card images
Card previews were picking whichever printing happened to load first during ingest (ORDER BY rowid over every printing, promos and foreign versions included), so a card's default image was effectively random. Capture the representative printing's images straight from the Oracle Cards bulk pass instead, and prefer that over the old printings-table guess, falling back gracefully on an older reference.db that predates these columns. Also add docs/printing-tracking-design.md, a design (not yet implemented) for optionally tracking which specific printing/finish a library or deck card is.
This commit is contained in:
@@ -48,6 +48,26 @@ pub fn price_subquery() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// SQL expression selecting `column` ("image_uri" or "back_image_uri") for
|
||||
/// the oracle card aliased `oc`, for use in a `SELECT`.
|
||||
///
|
||||
/// When `has_oracle_images` (i.e. `oracle_cards.image_uri` exists in the
|
||||
/// attached reference db), prefers the representative-printing image
|
||||
/// captured directly onto `oracle_cards` during ingest — Scryfall's own pick
|
||||
/// of a recognizable, English printing — over [`image_subquery`]'s arbitrary
|
||||
/// first-by-rowid printing, so card previews don't show a random
|
||||
/// promo/showcase/foreign-language version. Falls back to the old subquery
|
||||
/// when the oracle card has no image of its own, or unconditionally on an
|
||||
/// older reference.db built before this column shipped.
|
||||
pub fn image_expr(has_oracle_images: bool, column: &str) -> String {
|
||||
let fallback = image_subquery(column);
|
||||
if has_oracle_images {
|
||||
format!("COALESCE(NULLIF(oc.{column}, ''), {fallback})")
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Create (if needed) and open a reference.db, applying the reference schema.
|
||||
/// Used by the ingester and by test fixtures.
|
||||
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {
|
||||
|
||||
@@ -121,8 +121,8 @@ impl Catalog {
|
||||
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_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
|
||||
@@ -103,8 +103,8 @@ impl Catalog {
|
||||
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_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
@@ -163,8 +163,8 @@ impl Catalog {
|
||||
FROM {REF_DB}.oracle_cards oc
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||
WHERE 1=1",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
|
||||
@@ -412,6 +412,12 @@ where
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ScryOracle {
|
||||
/// This entry's own printing id — the "representative" printing Scryfall
|
||||
/// includes in the Oracle Cards bulk (its own doc: "the most up-to-date
|
||||
/// recognizable" version). Captured as `oracle_cards.default_scryfall_id`
|
||||
/// purely for reference; not currently read back.
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
oracle_id: Option<String>,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
@@ -429,6 +435,8 @@ struct ScryOracle {
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
#[serde(default)]
|
||||
card_faces: Option<Vec<ScryFace>>,
|
||||
/// What colors of mana this card can add (lands, mana rocks, dorks). A
|
||||
/// top-level-only Scryfall field — not per-face, even on DFCs.
|
||||
@@ -452,6 +460,8 @@ struct ScryFace {
|
||||
colors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
oracle_text: Option<String>,
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
}
|
||||
|
||||
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
|
||||
@@ -461,8 +471,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
let mut ins_card = conn.prepare(
|
||||
"INSERT OR IGNORE INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
|
||||
image_uri,back_image_uri,default_scryfall_id)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
@@ -560,6 +571,31 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
.any(|w| w == "Battle");
|
||||
let horizontal = is_battle || c.layout == "planar";
|
||||
|
||||
// Representative-printing images: top-level for single-faced
|
||||
// cards, else the front/back faces — same fallback shape as
|
||||
// `load_printings` below, since Oracle Cards entries mirror a
|
||||
// printing's image fields.
|
||||
let image_uri = c
|
||||
.image_uris
|
||||
.as_ref()
|
||||
.and_then(|i| i.normal.clone())
|
||||
.or_else(|| {
|
||||
c.card_faces
|
||||
.as_ref()
|
||||
.and_then(|f| f.first())
|
||||
.and_then(|f| f.image_uris.as_ref())
|
||||
.and_then(|i| i.normal.clone())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let back_image_uri = c
|
||||
.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();
|
||||
let default_scryfall_id = c.id.clone().unwrap_or_default();
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
c.name,
|
||||
@@ -575,6 +611,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
themes,
|
||||
c.edhrec_rank,
|
||||
horizontal as i64,
|
||||
image_uri,
|
||||
back_image_uri,
|
||||
default_scryfall_id,
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
@@ -1072,6 +1111,59 @@ mod tests {
|
||||
assert!(!horizontal("oid-normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_representative_printing_image_from_oracle_cards() {
|
||||
// Oracle Cards entries carry Scryfall's own pick of a recognizable
|
||||
// printing's images — top-level for single-faced cards, per-face for
|
||||
// DFCs (mirrors how `load_printings` reads a `printings` entry).
|
||||
let oracle = r#"[
|
||||
{"id":"scry-normal","oracle_id":"oid-normal","name":"Grizzly Bears",
|
||||
"layout":"normal","cmc":2.0,"type_line":"Creature — Bear",
|
||||
"color_identity":["G"],"keywords":[],
|
||||
"image_uris":{"normal":"http://img/normal"}},
|
||||
{"id":"scry-dfc","oracle_id":"oid-dfc","name":"Delver of Secrets // Insectile Aberration",
|
||||
"layout":"transform","cmc":1.0,"color_identity":["U"],"keywords":[],
|
||||
"card_faces":[
|
||||
{"type_line":"Creature — Human Wizard","image_uris":{"normal":"http://img/front"}},
|
||||
{"type_line":"Creature — Human Insect","image_uris":{"normal":"http://img/back"}}
|
||||
]},
|
||||
{"oracle_id":"oid-noimg","name":"No Image Test","layout":"normal","cmc":0.0,
|
||||
"type_line":"Land","color_identity":[],"keywords":[]}
|
||||
]"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let dest = dir.path().join("reference.db");
|
||||
let conn = open_reference_for_build(&dest).unwrap();
|
||||
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
|
||||
|
||||
let images = |id: &str| -> (String, String, String) {
|
||||
conn.query_row(
|
||||
"SELECT image_uri, back_image_uri, default_scryfall_id FROM oracle_cards WHERE oracle_id=?1",
|
||||
params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
images("oid-normal"),
|
||||
(
|
||||
"http://img/normal".to_string(),
|
||||
"".to_string(),
|
||||
"scry-normal".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
images("oid-dfc"),
|
||||
(
|
||||
"http://img/front".to_string(),
|
||||
"http://img/back".to_string(),
|
||||
"scry-dfc".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(images("oid-noimg"), ("".to_string(), "".to_string(), "".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_back_face_image_for_double_faced_cards() {
|
||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||
|
||||
@@ -39,6 +39,10 @@ pub struct Catalog {
|
||||
/// Cached at open time (one pragma query) since it's consulted by nearly
|
||||
/// every card-hydrating query — see [`Catalog::horizontal_col`].
|
||||
pub(crate) has_horizontal_column: bool,
|
||||
/// Whether `oracle_cards.image_uri` (and its `back_image_uri`/
|
||||
/// `default_scryfall_id` siblings) exist in the attached reference db.
|
||||
/// Cached at open time — see [`Catalog::image_expr`].
|
||||
pub(crate) has_oracle_images: bool,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
@@ -47,7 +51,8 @@ impl Catalog {
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
|
||||
let conn = db::open(library_path, reference_path)?;
|
||||
let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?;
|
||||
Ok(Self { conn, has_horizontal_column })
|
||||
let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?;
|
||||
Ok(Self { conn, has_horizontal_column, has_oracle_images })
|
||||
}
|
||||
|
||||
/// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT`
|
||||
@@ -65,6 +70,12 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// SQL expression selecting `column` ("image_uri" or "back_image_uri")
|
||||
/// for the oracle card aliased `oc`. See [`db::image_expr`].
|
||||
pub(crate) fn image_expr(&self, column: &str) -> String {
|
||||
db::image_expr(self.has_oracle_images, column)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -85,8 +85,8 @@ impl Catalog {
|
||||
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_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
@@ -314,6 +314,36 @@ mod tests {
|
||||
assert_eq!(stats.color_counts, [0, 0, 1, 0, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_cards_image_wins_over_the_printings_pick() {
|
||||
// The bug this guards: `printings` can hold an arbitrary (promo,
|
||||
// showcase, foreign-language...) printing per card. `oracle_cards`'s
|
||||
// own image — Scryfall's representative-printing pick, captured at
|
||||
// ingest — must win when present, rather than whatever `printings`
|
||||
// subquery would otherwise resolve to (first by rowid).
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.connection()
|
||||
.execute(
|
||||
"UPDATE ref.printings SET image_uri = 'http://img/promo-variant' \
|
||||
WHERE oracle_id = 'sol-ring'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
cat.connection()
|
||||
.execute(
|
||||
"UPDATE ref.oracle_cards SET image_uri = 'http://img/normal' \
|
||||
WHERE oracle_id = 'sol-ring'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
cat.add_owned("sol-ring", 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.image_uri, "http://img/normal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_image_uri_is_plumbed_through() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -39,13 +39,17 @@ impl NameIndex {
|
||||
} else {
|
||||
"0"
|
||||
};
|
||||
// See `Catalog::image_expr` / `db::image_expr` — prefer the oracle
|
||||
// card's own representative-printing image, falling back to the old
|
||||
// arbitrary-first-printing subquery for an older reference.db.
|
||||
let has_oracle_images = crate::db::has_reference_column(conn, "oracle_cards", "image_uri")?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT 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}, {horizontal}
|
||||
FROM {REF_DB}.oracle_cards oc",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = crate::db::image_expr(has_oracle_images, "image_uri"),
|
||||
back = crate::db::image_expr(has_oracle_images, "back_image_uri"),
|
||||
price = crate::db::price_subquery()
|
||||
))?;
|
||||
let entries = stmt
|
||||
|
||||
@@ -16,7 +16,15 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
legalities INTEGER NOT NULL DEFAULT 0, -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||
themes INTEGER NOT NULL DEFAULT 0, -- archetype bitmask, see synergy::ARCHETYPES
|
||||
edhrec_rank INTEGER, -- Scryfall EDHREC popularity rank; NULL if unranked
|
||||
horizontal INTEGER NOT NULL DEFAULT 0 -- boolean: printed/displayed sideways (Scryfall layout "battle"/"planar")
|
||||
horizontal INTEGER NOT NULL DEFAULT 0, -- boolean: printed/displayed sideways (Scryfall layout "battle"/"planar")
|
||||
-- Representative printing's images, captured from the Oracle Cards bulk
|
||||
-- (Scryfall's own pick of "the most up-to-date recognizable" English
|
||||
-- printing) — preferred over `printings` for display so previews don't
|
||||
-- show an arbitrary promo/showcase/foreign-language printing. Empty when
|
||||
-- absent (falls back to `printings`).
|
||||
image_uri TEXT NOT NULL DEFAULT '',
|
||||
back_image_uri TEXT NOT NULL DEFAULT '',
|
||||
default_scryfall_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||
|
||||
@@ -317,8 +317,8 @@ impl Catalog {
|
||||
WHERE oc.oracle_id NOT IN (SELECT oracle_id FROM deck_cards WHERE deck_id = ?)
|
||||
AND (oc.color_identity & ?) = 0
|
||||
AND (",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
image = self.image_expr("image_uri"),
|
||||
back = self.image_expr("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user