Add a Card Search tab and fix horizontal-card (Battle/Plane) preview orientation
Card Search browses the whole downloaded card universe (not just owned cards), reusing the Library tab's filter/group/sort/list/tiles UI; results show ownership and offer quick "own" and "add to deck" actions. Battles, Planes, and Phenomena store their Scryfall image portrait-shaped with the content itself rotated, so hover/click previews now detect this (a new `horizontal` flag threaded through ingest and every card query, with graceful fallback for a reference.db built before the column existed) and rotate the image back into place. Single-faced and non-DFC cases are verified fixed; a double-faced Battle's front face still needs another pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,19 @@ pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `column` exists on `table` in the attached reference db.
|
||||
/// reference.db is rebuilt (not migrated) on every ingest, so a db built
|
||||
/// before a given column shipped simply lacks it — this lets callers degrade
|
||||
/// gracefully (or, for narrowly-used columns, fail with a clear "update card
|
||||
/// data" message) instead of a raw "no such column" SQL error.
|
||||
pub fn has_reference_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
&format!("SELECT COUNT(*) FROM pragma_table_info('{table}', '{REF_DB}') WHERE name = ?1"),
|
||||
[column],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// 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))?;
|
||||
|
||||
@@ -115,7 +115,7 @@ impl Catalog {
|
||||
"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
|
||||
oc.legalities, {price}, dc.is_maybeboard, {horizontal}
|
||||
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
|
||||
@@ -123,7 +123,8 @@ impl Catalog {
|
||||
ORDER BY oc.name COLLATE NOCASE",
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
price = crate::db::price_subquery()
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![deck_id], |r| {
|
||||
@@ -144,6 +145,7 @@ impl Catalog {
|
||||
produces_colorless: r.get::<_, i64>(12)? != 0,
|
||||
legalities: Legalities(r.get::<_, i64>(13)?),
|
||||
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
|
||||
horizontal: r.get::<_, i64>(16)? != 0,
|
||||
},
|
||||
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||
})
|
||||
|
||||
@@ -1,13 +1,93 @@
|
||||
//! Criteria search over the owned library, compiled to SQL across the library
|
||||
//! and attached reference dbs.
|
||||
//! Criteria search over the owned library, and its sibling over the whole
|
||||
//! reference db (every downloaded card, owned or not), compiled to SQL across
|
||||
//! the library and attached reference dbs.
|
||||
|
||||
use rusqlite::types::Value;
|
||||
|
||||
use crate::db::REF_DB;
|
||||
use crate::error::Result;
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::{LibraryCard, LibraryFilter};
|
||||
use crate::Catalog;
|
||||
|
||||
/// Append the card-data predicates shared by [`Catalog::search_library`] and
|
||||
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`,
|
||||
/// which only make sense against the owned library. Caller has already
|
||||
/// written the `FROM ... WHERE <ownership predicate>` prefix.
|
||||
fn push_card_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
|
||||
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
}
|
||||
if let Some(c) = filter.colors_exact {
|
||||
sql.push_str(" AND oc.colors = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(c) = filter.colors_include {
|
||||
sql.push_str(" AND (oc.colors & ?) = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(id) = filter.commander_identity {
|
||||
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
|
||||
sql.push_str(" AND (oc.color_identity & ?) = 0");
|
||||
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
|
||||
}
|
||||
if let Some(min) = filter.cmc_min {
|
||||
sql.push_str(" AND oc.mana_value >= ?");
|
||||
vals.push(Value::Real(min));
|
||||
}
|
||||
if let Some(max) = filter.cmc_max {
|
||||
sql.push_str(" AND oc.mana_value <= ?");
|
||||
vals.push(Value::Real(max));
|
||||
}
|
||||
for t in &filter.types {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(t.clone()));
|
||||
}
|
||||
for s in &filter.subtypes {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(s.clone()));
|
||||
}
|
||||
for k in &filter.keywords {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(k.clone()));
|
||||
}
|
||||
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", txt.to_lowercase())));
|
||||
}
|
||||
}
|
||||
|
||||
/// Enrich raw `(oracle_id, quantity_owned, location, CardRef)` rows with deck
|
||||
/// context, shared by [`Catalog::search_library`], [`Catalog::search_reference`],
|
||||
/// and [`Catalog::list_library`].
|
||||
fn enrich_with_deck_context(
|
||||
cat: &Catalog,
|
||||
rows: Vec<(String, i64, String, crate::model::CardRef)>,
|
||||
) -> Result<Vec<LibraryCard>> {
|
||||
let deck_names = cat.deck_names_by_oracle()?;
|
||||
let assigned = cat.assigned_totals_by_oracle()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(oid, qty, location, card)| LibraryCard {
|
||||
quantity_owned: qty,
|
||||
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
||||
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
||||
location,
|
||||
card,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Search the owned library by the given criteria. An empty filter returns
|
||||
/// the whole owned library (same set as [`Catalog::list_library`]).
|
||||
@@ -19,63 +99,18 @@ impl Catalog {
|
||||
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}, {back},
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
|
||||
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"),
|
||||
price = crate::db::price_subquery()
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
let mut vals: Vec<Value> = Vec::new();
|
||||
push_card_filters(&mut sql, &mut vals, filter);
|
||||
|
||||
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
}
|
||||
if let Some(c) = filter.colors_exact {
|
||||
sql.push_str(" AND oc.colors = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(c) = filter.colors_include {
|
||||
sql.push_str(" AND (oc.colors & ?) = ?");
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
vals.push(Value::Integer(c.0 as i64));
|
||||
}
|
||||
if let Some(id) = filter.commander_identity {
|
||||
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
|
||||
sql.push_str(" AND (oc.color_identity & ?) = 0");
|
||||
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
|
||||
}
|
||||
if let Some(min) = filter.cmc_min {
|
||||
sql.push_str(" AND oc.mana_value >= ?");
|
||||
vals.push(Value::Real(min));
|
||||
}
|
||||
if let Some(max) = filter.cmc_max {
|
||||
sql.push_str(" AND oc.mana_value <= ?");
|
||||
vals.push(Value::Real(max));
|
||||
}
|
||||
for t in &filter.types {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(t.clone()));
|
||||
}
|
||||
for s in &filter.subtypes {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(s.clone()));
|
||||
}
|
||||
for k in &filter.keywords {
|
||||
sql.push_str(&format!(
|
||||
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
|
||||
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
|
||||
));
|
||||
vals.push(Value::Text(k.clone()));
|
||||
}
|
||||
if let Some(deck_id) = filter.in_deck {
|
||||
sql.push_str(
|
||||
" AND EXISTS (SELECT 1 FROM deck_cards dc
|
||||
@@ -87,10 +122,6 @@ impl Catalog {
|
||||
sql.push_str(" AND LOWER(lc.location) LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", loc.to_lowercase())));
|
||||
}
|
||||
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", txt.to_lowercase())));
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
|
||||
|
||||
@@ -102,19 +133,56 @@ impl Catalog {
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
// Enrich with deck context (shared with list_library).
|
||||
let deck_names = self.deck_names_by_oracle()?;
|
||||
let assigned = self.assigned_totals_by_oracle()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(oid, qty, location, card)| LibraryCard {
|
||||
quantity_owned: qty,
|
||||
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
|
||||
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
|
||||
location,
|
||||
card,
|
||||
})
|
||||
.collect())
|
||||
enrich_with_deck_context(self, rows)
|
||||
}
|
||||
|
||||
/// Search the *whole* reference db (every downloaded card, owned or not)
|
||||
/// by the same card-data criteria as [`Catalog::search_library`], minus
|
||||
/// `in_deck`/`location` (library-specific — silently ignored if set).
|
||||
/// Results are still [`LibraryCard`]s so ownership/deck context is
|
||||
/// visible (`quantity_owned` is 0 and `decks` is empty for unowned
|
||||
/// cards), capped at `limit` and ordered by Scryfall's EDHREC popularity
|
||||
/// rank (unranked last) so a broad filter surfaces the most-played
|
||||
/// matches rather than an arbitrary slice.
|
||||
pub fn search_reference(&self, filter: &LibraryFilter, limit: usize) -> Result<Vec<LibraryCard>> {
|
||||
// `edhrec_rank` only exists in a reference.db built after the synergy
|
||||
// feature shipped; an older ingest lacks it (reference.db is rebuilt,
|
||||
// not migrated). Detect that and return a clear, actionable message
|
||||
// instead of a raw "no such column" error — same guard as
|
||||
// `Catalog::suggest_cards` in synergy.rs.
|
||||
if !crate::db::has_reference_column(&self.conn, "oracle_cards", "edhrec_rank")? {
|
||||
return Err(CoreError::Other(
|
||||
"Card search needs updated card data — click \"Update card data\" to enable it.".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut sql = format!(
|
||||
"SELECT oc.oracle_id, COALESCE(lc.quantity_owned, 0), COALESCE(lc.location, ''),
|
||||
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
|
||||
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"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
let mut vals: Vec<Value> = Vec::new();
|
||||
push_card_filters(&mut sql, &mut vals, filter);
|
||||
|
||||
sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
|
||||
vals.push(Value::Integer(limit as i64));
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt
|
||||
.query_map(
|
||||
rusqlite::params_from_iter(vals),
|
||||
crate::library::row_to_library_parts,
|
||||
)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
enrich_with_deck_context(self, rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,4 +373,149 @@ mod tests {
|
||||
}).unwrap();
|
||||
assert_eq!(all.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_finds_unowned_cards_search_library_does_not() {
|
||||
// No ownership at all — the whole fixture is unowned.
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
|
||||
let ref_bolt = names(&cat.search_reference(&LibraryFilter {
|
||||
text: Some("bolt".into()),
|
||||
..Default::default()
|
||||
}, 50).unwrap());
|
||||
assert_eq!(ref_bolt, vec!["Lightning Bolt"]);
|
||||
|
||||
// search_library sees nothing — nothing is owned.
|
||||
let lib_bolt = cat.search_library(&LibraryFilter {
|
||||
text: Some("bolt".into()),
|
||||
..Default::default()
|
||||
}).unwrap();
|
||||
assert!(lib_bolt.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_reports_ownership_for_owned_cards() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
cat.add_owned("sol-ring", 3).unwrap();
|
||||
|
||||
let all = cat.search_reference(&LibraryFilter::default(), 50).unwrap();
|
||||
let sol = all.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
|
||||
assert_eq!(sol.quantity_owned, 3);
|
||||
let bolt = all.iter().find(|c| c.card.oracle_id == "lightning-bolt").unwrap();
|
||||
assert_eq!(bolt.quantity_owned, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_respects_limit() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let capped = cat.search_reference(&LibraryFilter::default(), 2).unwrap();
|
||||
assert_eq!(capped.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_ignores_in_deck_and_location() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let deck = cat.create_deck("Some Deck", None).unwrap();
|
||||
cat.set_deck_card(deck.id, "lightning-bolt", 1, false, false).unwrap();
|
||||
|
||||
// Both library-only fields are set but should have no effect — the
|
||||
// whole (unowned) fixture is still returned.
|
||||
let got = cat.search_reference(&LibraryFilter {
|
||||
in_deck: Some(deck.id),
|
||||
location: Some("Box 1".into()),
|
||||
..Default::default()
|
||||
}, 50).unwrap();
|
||||
assert_eq!(got.len(), 5);
|
||||
}
|
||||
|
||||
/// `horizontal` is read by nearly every card query (see
|
||||
/// `Catalog::horizontal_col`), so a reference.db built before that column
|
||||
/// shipped must not break `search_library`/`search_reference`/
|
||||
/// `list_library`/`deck_contents`/name search — they should all degrade
|
||||
/// to treating every card as non-horizontal, not raw-SQL-error.
|
||||
#[test]
|
||||
fn card_queries_degrade_gracefully_without_the_horizontal_column() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ref_path = dir.path().join("reference.db");
|
||||
let lib_path = dir.path().join("library.db");
|
||||
|
||||
// Minimal schema mirroring reference_schema.sql as it existed before
|
||||
// `horizontal` was added (themes/edhrec_rank present, since those
|
||||
// shipped earlier — horizontal deliberately absent).
|
||||
{
|
||||
let rconn = rusqlite::Connection::open(&ref_path).unwrap();
|
||||
rconn.execute_batch(
|
||||
"CREATE TABLE oracle_cards (
|
||||
oracle_id TEXT PRIMARY KEY, name TEXT NOT NULL, name_lower TEXT NOT NULL,
|
||||
mana_value REAL NOT NULL DEFAULT 0, colors INTEGER NOT NULL DEFAULT 0,
|
||||
color_identity INTEGER NOT NULL DEFAULT 0, type_line TEXT NOT NULL DEFAULT '',
|
||||
oracle_text TEXT NOT NULL DEFAULT '', produced_mana INTEGER NOT NULL DEFAULT 0,
|
||||
produces_colorless INTEGER NOT NULL DEFAULT 0, legalities INTEGER NOT NULL DEFAULT 0,
|
||||
themes INTEGER NOT NULL DEFAULT 0, edhrec_rank INTEGER
|
||||
);
|
||||
CREATE TABLE card_types (oracle_id TEXT NOT NULL, type TEXT NOT NULL);
|
||||
CREATE TABLE card_subtypes (oracle_id TEXT NOT NULL, subtype TEXT NOT NULL);
|
||||
CREATE TABLE card_keywords (oracle_id TEXT NOT NULL, keyword TEXT NOT NULL);
|
||||
CREATE TABLE printings (
|
||||
scryfall_id TEXT PRIMARY KEY, oracle_id TEXT NOT NULL,
|
||||
image_uri TEXT NOT NULL DEFAULT '', back_image_uri TEXT NOT NULL DEFAULT '',
|
||||
price_usd REAL NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
rconn
|
||||
.execute(
|
||||
"INSERT INTO oracle_cards (oracle_id,name,name_lower,type_line) \
|
||||
VALUES ('sol-ring','Sol Ring','sol ring','Artifact')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let cat = crate::Catalog::open(&lib_path, &ref_path).unwrap();
|
||||
cat.add_owned("sol-ring", 1).unwrap();
|
||||
|
||||
let lib = cat.search_library(&LibraryFilter::default()).unwrap();
|
||||
assert_eq!(lib.len(), 1);
|
||||
assert!(!lib[0].card.horizontal);
|
||||
|
||||
let listed = cat.list_library().unwrap();
|
||||
assert!(!listed[0].card.horizontal);
|
||||
|
||||
let referenced = cat.search_reference(&LibraryFilter::default(), 50).unwrap();
|
||||
assert_eq!(referenced.len(), 1);
|
||||
assert!(!referenced[0].card.horizontal);
|
||||
|
||||
let deck = cat.create_deck("Test", None).unwrap();
|
||||
cat.set_deck_card(deck.id, "sol-ring", 1, false, false).unwrap();
|
||||
let contents = cat.deck_contents(deck.id).unwrap();
|
||||
assert!(!contents[0].card.horizontal);
|
||||
|
||||
let index = cat.build_name_index().unwrap();
|
||||
let found = index.search("Sol Ring", 5);
|
||||
assert!(!found[0].horizontal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_reference_applies_the_same_card_criteria_as_search_library() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
|
||||
let elves = names(&cat.search_reference(&LibraryFilter {
|
||||
subtypes: vec!["Elf".into()],
|
||||
..Default::default()
|
||||
}, 50).unwrap());
|
||||
assert_eq!(elves, vec!["Deathrite Shaman", "Llanowar Elves"]);
|
||||
|
||||
let mono_g = names(&cat.search_reference(&LibraryFilter {
|
||||
commander_identity: Some(ColorSet::parse("G")),
|
||||
..Default::default()
|
||||
}, 50).unwrap());
|
||||
assert!(mono_g.contains(&"Llanowar Elves".to_string()));
|
||||
assert!(!mono_g.contains(&"Serra Angel".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,8 +461,8 @@ 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)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
|
||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
@@ -543,6 +543,10 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
|
||||
let legalities = Legalities::from_scryfall(&c.legalities);
|
||||
let themes = crate::synergy::compute_themes(&oracle_text);
|
||||
// Battles and Planes/Phenomena store their card image portrait-shaped
|
||||
// with the content itself rotated — viewers must rotate it 90° to
|
||||
// read it right-side up (verified against real Scryfall images).
|
||||
let horizontal = matches!(c.layout.as_str(), "battle" | "planar");
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
@@ -558,6 +562,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
legalities.0,
|
||||
themes,
|
||||
c.edhrec_rank,
|
||||
horizontal as i64,
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
@@ -1012,6 +1017,41 @@ mod tests {
|
||||
assert_eq!(van_rank, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_horizontal_flag_for_battle_and_planar_layouts_only() {
|
||||
let oracle = r#"[
|
||||
{"oracle_id":"oid-battle","name":"Invasion of Zendikar","layout":"battle","cmc":4.0,
|
||||
"type_line":"Battle — Siege","color_identity":["G"],"keywords":[]},
|
||||
{"oracle_id":"oid-plane","name":"Academy at Tolaria West","layout":"planar","cmc":0.0,
|
||||
"type_line":"Plane — Dominaria","color_identity":[],"keywords":[]},
|
||||
{"oracle_id":"oid-scheme","name":"A Display of My Dark Power","layout":"scheme","cmc":0.0,
|
||||
"type_line":"Scheme","color_identity":[],"keywords":[]},
|
||||
{"oracle_id":"oid-normal","name":"Grizzly Bears","layout":"normal","cmc":2.0,
|
||||
"type_line":"Creature — Bear","color_identity":["G"],"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 horizontal = |id: &str| -> bool {
|
||||
conn.query_row(
|
||||
"SELECT horizontal FROM oracle_cards WHERE oracle_id=?1",
|
||||
params![id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap()
|
||||
!= 0
|
||||
};
|
||||
|
||||
assert!(horizontal("oid-battle"));
|
||||
assert!(horizontal("oid-plane"));
|
||||
// Schemes are oversized but not rotated — only battle/planar layouts are.
|
||||
assert!(!horizontal("oid-scheme"));
|
||||
assert!(!horizontal("oid-normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captures_back_face_image_for_double_faced_cards() {
|
||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||
|
||||
@@ -35,6 +35,10 @@ pub use synergy::{CardSuggestion, SynergyReason};
|
||||
/// so the caller can cache it in app state.
|
||||
pub struct Catalog {
|
||||
pub(crate) conn: Connection,
|
||||
/// Whether `oracle_cards.horizontal` exists in the attached reference db.
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
@@ -42,7 +46,23 @@ impl Catalog {
|
||||
/// reference db at `reference_path` attached.
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
|
||||
let conn = db::open(library_path, reference_path)?;
|
||||
Ok(Self { conn })
|
||||
let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?;
|
||||
Ok(Self { conn, has_horizontal_column })
|
||||
}
|
||||
|
||||
/// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT`
|
||||
/// aliased `oc`. Degrades to the literal `0` (treat as non-horizontal)
|
||||
/// when an older reference.db — built before this column shipped —
|
||||
/// lacks it, rather than erroring: unlike `themes`/`edhrec_rank` (which
|
||||
/// only gate the one Suggest Cards feature), `horizontal` is read by
|
||||
/// almost every card query in the app, so a hard failure here would
|
||||
/// break far more than one feature until the user re-ingests.
|
||||
pub(crate) fn horizontal_col(&self) -> &'static str {
|
||||
if self.has_horizontal_column {
|
||||
"oc.horizontal"
|
||||
} else {
|
||||
"0"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build (or rebuild) the in-memory fuzzy name index from the attached
|
||||
|
||||
@@ -11,8 +11,11 @@ use crate::Catalog;
|
||||
|
||||
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
||||
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
||||
/// produces_colorless, legalities, price_usd)` into its library parts. Shared
|
||||
/// by [`Catalog::list_library`] and the criteria search.
|
||||
/// produces_colorless, legalities, price_usd, horizontal)` into its library
|
||||
/// parts. Shared by [`Catalog::list_library`] and the criteria search. The
|
||||
/// caller's `SELECT` must put `horizontal` (or [`Catalog::horizontal_col`]'s
|
||||
/// `0` fallback) last, since it's not part of the base reference schema on
|
||||
/// every db (see `Catalog::has_horizontal_column`).
|
||||
pub(crate) fn row_to_library_parts(
|
||||
r: &rusqlite::Row,
|
||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
||||
@@ -34,6 +37,7 @@ pub(crate) fn row_to_library_parts(
|
||||
produces_colorless: r.get::<_, i64>(11)? != 0,
|
||||
legalities: Legalities(r.get::<_, i64>(12)?),
|
||||
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
||||
horizontal: r.get::<_, i64>(14)? != 0,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -76,14 +80,15 @@ 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}, {back},
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}, {horizontal}
|
||||
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_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
price = crate::db::price_subquery()
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col()
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map([], row_to_library_parts)?
|
||||
@@ -161,6 +166,17 @@ impl Catalog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Distinct type/subtype/keyword values across the *whole* reference db
|
||||
/// (every downloaded card, owned or not) — the Card Search tab's
|
||||
/// equivalent of [`Catalog::library_facets`].
|
||||
pub fn reference_facets(&self) -> Result<LibraryFacets> {
|
||||
Ok(LibraryFacets {
|
||||
types: self.distinct_facet("card_types", "type")?,
|
||||
subtypes: self.distinct_facet("card_subtypes", "subtype")?,
|
||||
keywords: self.distinct_facet("card_keywords", "keyword")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `table`/`column` are always one of the three hardcoded call sites above
|
||||
/// (never user input), so interpolating them into the query is safe.
|
||||
fn distinct_owned_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
|
||||
@@ -174,6 +190,17 @@ impl Catalog {
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// Same as [`Catalog::distinct_owned_facet`] but unfiltered by ownership.
|
||||
fn distinct_facet(&self, table: &str, column: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = self.conn.prepare(&format!(
|
||||
"SELECT DISTINCT x.{column}
|
||||
FROM {REF_DB}.{table} x
|
||||
ORDER BY x.{column} COLLATE NOCASE"
|
||||
))?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
}
|
||||
|
||||
/// Set the free-text storage location for a card (creates the library row if
|
||||
/// absent). Empty string clears it.
|
||||
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
|
||||
@@ -256,6 +283,18 @@ mod tests {
|
||||
assert!(facets.keywords.is_empty()); // Flying/Vigilance only on the unowned Serra Angel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_facets_include_unowned_cards() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
// Nothing owned at all — reference_facets should still see everything
|
||||
// in the fixture, unlike library_facets.
|
||||
let facets = cat.reference_facets().unwrap();
|
||||
assert_eq!(facets.types, vec!["Artifact", "Creature", "Instant"]);
|
||||
assert!(facets.keywords.contains(&"Flying".to_string()));
|
||||
assert!(facets.keywords.contains(&"Vigilance".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_stats_aggregates_counts_and_colors() {
|
||||
let fx = test_support::make();
|
||||
|
||||
@@ -152,6 +152,11 @@ pub struct CardRef {
|
||||
pub colors: ColorSet,
|
||||
pub color_identity: ColorSet,
|
||||
pub type_line: String,
|
||||
/// Whether this card is printed/displayed sideways (Scryfall layout
|
||||
/// "battle" or "planar" — Battles, Planes, and Phenomena). Its stored
|
||||
/// image is itself portrait-shaped with the content rotated, so viewers
|
||||
/// must rotate it 90° to display right-side up.
|
||||
pub horizontal: bool,
|
||||
/// URL of a card image (Scryfall "normal" size) for one of its printings,
|
||||
/// or empty if none is available.
|
||||
pub image_uri: String,
|
||||
|
||||
@@ -32,10 +32,17 @@ pub struct NameIndex {
|
||||
impl NameIndex {
|
||||
/// Load every oracle card from the attached reference db.
|
||||
pub fn build(conn: &Connection) -> Result<Self> {
|
||||
// See `Catalog::horizontal_col` — degrade to `0` on an older
|
||||
// reference.db that predates this column, rather than erroring.
|
||||
let horizontal = if crate::db::has_reference_column(conn, "oracle_cards", "horizontal")? {
|
||||
"oc.horizontal"
|
||||
} else {
|
||||
"0"
|
||||
};
|
||||
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}
|
||||
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"),
|
||||
@@ -57,6 +64,7 @@ impl NameIndex {
|
||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||
horizontal: r.get::<_, i64>(12)? != 0,
|
||||
},
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -15,7 +15,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
||||
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
|
||||
edhrec_rank INTEGER, -- Scryfall EDHREC popularity rank; NULL if unranked
|
||||
horizontal INTEGER NOT NULL DEFAULT 0 -- boolean: printed/displayed sideways (Scryfall layout "battle"/"planar")
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -311,7 +311,7 @@ impl Catalog {
|
||||
let mut sql = 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},
|
||||
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0)
|
||||
oc.themes, oc.edhrec_rank, COALESCE(lc.quantity_owned, 0), {horizontal}
|
||||
FROM {REF_DB}.oracle_cards oc
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||
WHERE oc.oracle_id NOT IN (SELECT oracle_id FROM deck_cards WHERE deck_id = ?)
|
||||
@@ -320,6 +320,7 @@ impl Catalog {
|
||||
image = crate::db::image_subquery("image_uri"),
|
||||
back = crate::db::image_subquery("back_image_uri"),
|
||||
price = crate::db::price_subquery(),
|
||||
horizontal = self.horizontal_col(),
|
||||
);
|
||||
let mut vals: Vec<Value> = vec![
|
||||
Value::Integer(deck_id),
|
||||
@@ -372,6 +373,7 @@ impl Catalog {
|
||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||
horizontal: r.get::<_, i64>(15)? != 0,
|
||||
},
|
||||
themes: r.get(12)?,
|
||||
edhrec_rank: r.get(13)?,
|
||||
|
||||
Reference in New Issue
Block a user