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

@@ -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();