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:
2026-07-22 20:55:17 -05:00
parent 3c625a9830
commit 2b361c4a85
19 changed files with 1076 additions and 102 deletions

View File

@@ -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.