diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index 7730e06..8c05780 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -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 { + 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))?; diff --git a/crates/mtgc-core/src/decks.rs b/crates/mtgc-core/src/decks.rs index 852c40b..0d6d86e 100644 --- a/crates/mtgc-core/src/decks.rs +++ b/crates/mtgc-core/src/decks.rs @@ -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>(14)?.unwrap_or_default(), + horizontal: r.get::<_, i64>(16)? != 0, }, is_maybeboard: r.get::<_, i64>(15)? != 0, }) diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index ec787d3..887608b 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -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 ` prefix. +fn push_card_filters(sql: &mut String, vals: &mut Vec, 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> { + 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 = 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::, _>>()?; - // 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> { + // `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 = 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::, _>>()?; + + 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())); + } } diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index 2d74457..26b5695 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -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. diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index a8a3397..a8cac4f 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -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 { 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 diff --git a/crates/mtgc-core/src/library.rs b/crates/mtgc-core/src/library.rs index 80643d7..0815fd3 100644 --- a/crates/mtgc-core/src/library.rs +++ b/crates/mtgc-core/src/library.rs @@ -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>(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 { + 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> { @@ -174,6 +190,17 @@ impl Catalog { Ok(rows.collect::, _>>()?) } + /// Same as [`Catalog::distinct_owned_facet`] but unfiltered by ownership. + fn distinct_facet(&self, table: &str, column: &str) -> Result> { + 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::, _>>()?) + } + /// 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(); diff --git a/crates/mtgc-core/src/model.rs b/crates/mtgc-core/src/model.rs index 7f75551..1ada813 100644 --- a/crates/mtgc-core/src/model.rs +++ b/crates/mtgc-core/src/model.rs @@ -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, diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index c58d25f..d3e6c93 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -32,10 +32,17 @@ pub struct NameIndex { impl NameIndex { /// Load every oracle card from the attached reference db. pub fn build(conn: &Connection) -> Result { + // 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>(11)?.unwrap_or_default(), + horizontal: r.get::<_, i64>(12)? != 0, }, }) })? diff --git a/crates/mtgc-core/src/sql/reference_schema.sql b/crates/mtgc-core/src/sql/reference_schema.sql index 06e2aac..26ad7e7 100644 --- a/crates/mtgc-core/src/sql/reference_schema.sql +++ b/crates/mtgc-core/src/sql/reference_schema.sql @@ -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); diff --git a/crates/mtgc-core/src/synergy.rs b/crates/mtgc-core/src/synergy.rs index 10efe1e..8a36d42 100644 --- a/crates/mtgc-core/src/synergy.rs +++ b/crates/mtgc-core/src/synergy.rs @@ -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 = 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>(11)?.unwrap_or_default(), + horizontal: r.get::<_, i64>(15)? != 0, }, themes: r.get(12)?, edhrec_rank: r.get(13)?, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3cf9c19..fa74a0b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -147,6 +147,14 @@ pub fn search_library(db: Db, filter: LibraryFilter) -> Res> { s.catalog()?.search_library(&filter).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn search_reference(db: Db, filter: LibraryFilter, limit: usize) -> Res> { + let s = db.lock().unwrap(); + s.catalog()? + .search_reference(&filter, limit) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn get_library_stats(db: Db) -> Res { let s = db.lock().unwrap(); @@ -159,6 +167,12 @@ pub fn get_library_facets(db: Db) -> Res { s.catalog()?.library_facets().map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_reference_facets(db: Db) -> Res { + let s = db.lock().unwrap(); + s.catalog()?.reference_facets().map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_decks(db: Db) -> Res> { let s = db.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce1fb6f..61e5bb5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,8 +30,10 @@ pub fn run() { commands::set_owned, commands::set_location, commands::search_library, + commands::search_reference, commands::get_library_stats, commands::get_library_facets, + commands::get_reference_facets, commands::list_decks, commands::create_deck, commands::delete_deck, diff --git a/src/App.tsx b/src/App.tsx index 1063081..c695886 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,9 +14,10 @@ import { import { CommandPalette } from "./CommandPalette"; import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; +import { CardSearch } from "./views/CardSearch"; import { DeckView } from "./views/DeckView"; -type Tab = "add" | "library" | "decks"; +type Tab = "add" | "library" | "search" | "decks"; const svg = (children: ReactNode, size = 20) => ( , ), }, + { + id: "search", + label: "Card Search", + icon: svg( + <> + + + , + ), + }, { id: "decks", label: "Decks", @@ -439,6 +450,8 @@ export default function App() { ) : tab === "library" ? ( + ) : tab === "search" ? ( + ) : ( )} diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index f2adcfe..baa9d77 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { api, CardRef, Deck } from "./api"; import { DialogFrame, useClickPreview } from "./components"; -type Tab = "add" | "library" | "decks"; +type Tab = "add" | "library" | "search" | "decks"; interface PaletteItem { key: string; @@ -93,6 +93,7 @@ export function CommandPalette({ ...(undo ? [{ key: "undo", label: `Undo: ${undo.message}`, run: onRunUndo }] : []), { key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") }, { key: "nav-library", label: "Go to Library", run: () => onNavigate("library") }, + { key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") }, { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, { key: "update-card-data", label: "Update card data", run: onUpdateCardData }, { key: "open-library", label: "Switch library file…", run: onOpenLibrary }, diff --git a/src/api.ts b/src/api.ts index 72b12c8..7ffa6bb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,6 +10,10 @@ export interface CardRef { colors: number; // WUBRG bitmask color_identity: number; // WUBRG bitmask type_line: string; + // Printed/displayed sideways (Battles, Planes, Phenomena). The stored + // image itself is portrait-shaped with the content rotated, so viewers + // must rotate it 90° to display right-side up. + horizontal: boolean; image_uri: string; // Scryfall "normal" image URL, or "" if unavailable back_image_uri: string; // back face for transform/MDFC/reversible cards, else "" produced_mana: number; // WUBRG bitmask of mana this card can tap for @@ -177,8 +181,11 @@ export const api = { searchLibrary: (filter: LibraryFilter) => invoke("search_library", { filter }), + searchReference: (filter: LibraryFilter, limit: number) => + invoke("search_reference", { filter, limit }), getLibraryStats: () => invoke("get_library_stats"), getLibraryFacets: () => invoke("get_library_facets"), + getReferenceFacets: () => invoke("get_reference_facets"), listDecks: () => invoke("list_decks"), createDeck: (name: string, commanderOracleId: string | null) => diff --git a/src/components.tsx b/src/components.tsx index 3295ece..5cbbf37 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -756,20 +756,62 @@ const GAP = 24; const PAD = 12; function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) { + // Battles/Planes/Phenomena store their image portrait-shaped with the + // content itself rotated, so the box footprint swaps (landscape) and the + // image inside gets rotated 90° back into place — see `CardRef.horizontal`. + const boxW = card.horizontal ? PREVIEW_H : PREVIEW_W; + const boxH = card.horizontal ? PREVIEW_W : PREVIEW_H; // Prefer the right of the cursor; flip left if it would overflow. let left = x + GAP; - if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP; - const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD)); + if (left + boxW + PAD > window.innerWidth) left = x - boxW - GAP; + const top = Math.max(PAD, Math.min(y - boxH / 2, window.innerHeight - boxH - PAD)); return ( - {card.name} +
+ {card.name} +
); } +// Landscape (post-rotation) aspect ratio of a Scryfall "normal" card image — +// the raw file is portrait (488x680); rotated 90° it reads as 680x488. +const ROTATED_ASPECT = 680 / 488; + +/** The largest landscape box (matching `.preview-modal-img`'s own + * `max-width: min(720px, 90vw)` / `max-height: 90vh` bounds) for a rotated + * horizontal-card preview. Recomputed on resize so it stays live like the + * CSS-only sizing used for normal (portrait) cards. */ +function useRotatedPreviewBox() { + const compute = () => { + const boundW = Math.min(720, window.innerWidth * 0.9); + const boundH = window.innerHeight * 0.9; + const w = Math.min(boundH, boundW * ROTATED_ASPECT); + return { w, h: w / ROTATED_ASPECT }; + }; + const [box, setBox] = useState(compute); + useEffect(() => { + const onResize = () => setBox(compute()); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + return box; +} + function ClickPreviewModal({ state, onClose, @@ -780,6 +822,7 @@ function ClickPreviewModal({ onFlip: () => void; }) { const { card, showBack } = state; + const rotatedBox = useRotatedPreviewBox(); const hasBack = !!card.back_image_uri; // Both faces are always in the DOM when the card has one; a wide-viewport // media query shows them side by side (no flip needed), while a narrow one @@ -791,11 +834,28 @@ function ClickPreviewModal({ onClick={(e) => e.stopPropagation()} >
- {card.name} + {/* Only the front face can be horizontal (Battles) — a transformed + Battle's back face is always a normal, correctly-oriented + creature card, per `CardRef.horizontal`'s doc comment. */} + {card.horizontal ? ( +
+ {card.name} +
+ ) : ( + {card.name} + )} {hasBack && ( ( const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs })); if (mode === "type") { - const rank = (l: string) => { - const i = TYPE_PRIORITY.indexOf(l); - return i === -1 ? TYPE_PRIORITY.length : i; - }; - groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label)); + groups.sort((a, b) => typeRank(a.label) - typeRank(b.label) || a.label.localeCompare(b.label)); } else if (mode === "cmc") { groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value); } else { @@ -922,6 +985,86 @@ export function GroupBySelect({ ); } +// --- Sorting ---------------------------------------------------------- + +export type SortMode = "name" | "mv" | "color" | "type" | "qty" | "price"; + +export const SORT_OPTIONS: { value: SortMode; label: string }[] = [ + { value: "name", label: "Name" }, + { value: "mv", label: "Mana value" }, + { value: "color", label: "Color identity" }, + { value: "type", label: "Card type" }, + { value: "qty", label: "Quantity owned" }, + { value: "price", label: "Price" }, +]; + +export type SortDir = "asc" | "desc"; + +/** Sort cards by the chosen attribute; ties always break by name so the + * order stays stable and predictable regardless of `mode`. Grouping (see + * [`groupCards`]) determines section order independently — sort this array + * first, then group it, and each group keeps this order internally. */ +export function sortCards( + cards: T[], + mode: SortMode, + dir: SortDir, +): T[] { + const sorted = [...cards].sort((a, b) => { + const cmp = + mode === "name" + ? a.name.localeCompare(b.name) + : mode === "mv" + ? a.mana_value - b.mana_value || a.name.localeCompare(b.name) + : mode === "color" + ? colorLabel(a.color_identity).localeCompare(colorLabel(b.color_identity)) || + a.name.localeCompare(b.name) + : mode === "type" + ? typeRank(primaryType(a.type_line)) - typeRank(primaryType(b.type_line)) || + a.name.localeCompare(b.name) + : mode === "qty" + ? a.quantity_owned - b.quantity_owned || a.name.localeCompare(b.name) + : a.price_usd - b.price_usd || a.name.localeCompare(b.name); + return dir === "asc" ? cmp : -cmp; + }); + return sorted; +} + +/** Dropdown + ascending/descending toggle for choosing a [`SortMode`]. */ +export function SortBySelect({ + value, + dir, + onChangeValue, + onToggleDir, +}: { + value: SortMode; + dir: SortDir; + onChangeValue: (m: SortMode) => void; + onToggleDir: () => void; +}) { + return ( + + + + + ); +} + // --- Stats widgets (sidebar "Your library" panel, per-deck analysis) ------ /** A single numeric stat in a small panel tile, e.g. "42 / unique". */ diff --git a/src/styles.css b/src/styles.css index c2332a5..fd6600c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1481,10 +1481,17 @@ tbody tr:hover { behind the dialog it's in. */ z-index: 450; border-radius: 14px; + overflow: hidden; box-shadow: var(--shadow-lg); border: 1px solid rgba(255, 255, 255, 0.08); pointer-events: none; } +.hover-card-img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} .preview-modal-backdrop { position: fixed; @@ -1527,6 +1534,29 @@ tbody tr:hover { .preview-modal-img.active { display: block; } +/* Horizontal cards (Battles/Planes/Phenomena, see `CardRef.horizontal`): the + stored image is portrait-shaped with the content itself rotated. The + wrapper's width/height (landscape) and the inner img's width/height + (the same box transposed, pre-rotation) are computed in JS — + `useRotatedPreviewBox` — and passed in as inline styles; this just + positions/rotates the img to fill the wrapper exactly. */ +.preview-modal-img-rotated { + position: relative; + overflow: hidden; + /* Override `.preview-modal-img`'s portrait-oriented max-width/max-height — + they'd otherwise clamp this wrapper's JS-computed (correctly landscape) + inline width/height down, while the un-clamped rotated img inside stays + at its full computed size and overflows, getting cut off by the + `overflow: hidden` above instead of shrinking to fit. */ + max-width: none; + max-height: none; +} +.preview-modal-rotate-img { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(90deg); +} /* Wide enough to show a double-faced card's front and back side by side — no flip needed. */ @media (min-width: 860px) { @@ -1538,6 +1568,14 @@ tbody tr:hover { display: block; max-width: min(430px, 43vw); } + /* A horizontal front face (e.g. a transformed Battle) shown side-by-side + with its back face: keep using the JS-computed inline width/height + (see `.preview-modal-img-rotated` above) rather than the narrower DFC + column width, which would clamp the wrapper and crop the rotated image + the same way the base max-width/max-height would. */ + .preview-modal-dfc .preview-modal-img-rotated { + max-width: none; + } .preview-modal-dfc .preview-modal-flip { display: none; } @@ -1707,6 +1745,16 @@ tbody tr:hover { font-size: 13px; color: var(--muted); } +.sort-dir-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 30px; + height: 30px; + padding: 0; + font-size: 15px; +} .view-toggle { display: inline-flex; background: var(--bg-input); diff --git a/src/views/CardSearch.tsx b/src/views/CardSearch.tsx new file mode 100644 index 0000000..a1b6410 --- /dev/null +++ b/src/views/CardSearch.tsx @@ -0,0 +1,391 @@ +import { useEffect, useState } from "react"; +import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api"; +import { + CardThumb, + CardTile, + ColorPips, + ColorToggles, + GroupBySelect, + GroupMode, + IconButton, + Icons, + QtyStepper, + SortBySelect, + SortDir, + SortMode, + TagInput, + ViewToggle, + groupCards, + sortCards, + usePersistedState, + useViewMode, +} from "../components"; + +// Server-side cap on results — this browses the whole ~30k-card reference +// db, not just what's owned, so an unbounded/loose filter needs a limit. +const CAP = 300; + +const NO_FACETS: LibraryFacets = { types: [], subtypes: [], keywords: [] }; + +/** + * Browse the whole downloaded card universe (every card in reference.db, + * owned or not) — the Library tab's sibling, reusing its filter/group/sort/ + * list/tiles machinery. Results are annotated with ownership: an "Owned ×N" + * stepper and any decks the card is already in. + * + * `onOfferUndo` is threaded in from `App`, same as `LibraryBrowser`, so the + * "Add to deck" undo is also reachable from the command palette. + */ +export function CardSearch({ + onOfferUndo, +}: { + onOfferUndo: (message: string, run: () => void | Promise) => void; +}) { + const [text, setText] = useState(""); + const [commander, setCommander] = useState(0); + const [colorsInclude, setColorsInclude] = useState(0); + const [cmcMin, setCmcMin] = useState(""); + const [cmcMax, setCmcMax] = useState(""); + const [types, setTypes] = useState([]); + const [subtypes, setSubtypes] = useState([]); + const [keywords, setKeywords] = useState([]); + const [oracleText, setOracleText] = useState(""); + const [facets, setFacets] = useState(NO_FACETS); + + const [decks, setDecks] = useState([]); + const [deckId, setDeckId] = useState(""); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const [busy, setBusy] = useState>(new Set()); + const [refreshKey, setRefreshKey] = useState(0); + + const [view, setView] = useViewMode("cardsearch-view"); + const [groupBy, setGroupBy] = usePersistedState("cardsearch-group", "none"); + const [sortBy, setSortBy] = usePersistedState("cardsearch-sort", "name"); + const [sortDir, setSortDir] = usePersistedState("cardsearch-sort-dir", "asc"); + + useEffect(() => { + api.listDecks().then(setDecks); + }, []); + + useEffect(() => { + api.getReferenceFacets().then(setFacets).catch(() => setFacets(NO_FACETS)); + }, []); + + const hasActiveFilter = + text.trim() !== "" || + commander !== 0 || + colorsInclude !== 0 || + cmcMin !== "" || + cmcMax !== "" || + types.length > 0 || + subtypes.length > 0 || + keywords.length > 0 || + oracleText.trim() !== ""; + + useEffect(() => { + if (!hasActiveFilter) { + setRows([]); + setError(null); + return; + } + const filter: LibraryFilter = { + text: text.trim() || null, + commander_identity: commander || null, + colors_include: colorsInclude || null, + cmc_min: cmcMin === "" ? null : Number(cmcMin), + cmc_max: cmcMax === "" ? null : Number(cmcMax), + types, + subtypes, + keywords, + oracle_text: oracleText.trim() || null, + }; + const t = setTimeout(() => { + api + .searchReference(filter, CAP) + .then((r) => { + setRows(r); + setError(null); + }) + .catch((e) => { + setRows([]); + setError(String(e)); + }); + }, 120); + return () => clearTimeout(t); + }, [ + hasActiveFilter, + text, + commander, + colorsInclude, + cmcMin, + cmcMax, + types, + subtypes, + keywords, + oracleText, + refreshKey, + ]); + + async function adjust(oracleId: string, delta: number) { + const qty = await api.addOwned(oracleId, delta); + setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c))); + } + + /** + * Add one copy of `c` to the deck picked in the toolbar. Snapshots the + * card's prior deck_cards row (or absence of one) first and offers an + * "Undo" that restores exactly that prior state — same pattern as + * `LibraryBrowser.bulkAddToDeck`. + */ + async function addToDeck(c: LibraryCard) { + if (deckId === "") return; + const deck = decks.find((d) => d.id === deckId); + if (!deck) return; + setBusy((b) => new Set(b).add(c.oracle_id)); + try { + const before = await api.deckContents(deck.id); + const prev = before.find((e) => e.oracle_id === c.oracle_id); + await api.setDeckCard(deck.id, c.oracle_id, 1, false, false); + setRefreshKey((k) => k + 1); + onOfferUndo(`Added ${c.name} to ${deck.name}.`, async () => { + await api.setDeckCard( + deck.id, + c.oracle_id, + prev?.quantity ?? 0, + prev?.is_commander ?? false, + prev?.is_maybeboard ?? false, + ); + setRefreshKey((k) => k + 1); + }); + } finally { + setBusy((b) => { + const next = new Set(b); + next.delete(c.oracle_id); + return next; + }); + } + } + + function refTable(cards: LibraryCard[]) { + return ( + + + + + + + + + + + + + + + {cards.map((c) => ( + + + + + + + + + + + ))} + +
ColorsNameTypeMVOwnedIn decks
+ + + + {c.name}{c.type_line}{c.mana_value} + adjust(c.oracle_id, -1)} + onIncrement={() => adjust(c.oracle_id, 1)} + /> + + {c.decks.map((d) => ( + + {d} + + ))} + + +
+ ); + } + + function refTiles(cards: LibraryCard[]) { + return ( +
+ {cards.map((c) => ( + adjust(c.oracle_id, -1)} + onIncrement={() => adjust(c.oracle_id, 1)} + /> + } + cornerBottomLeft={ + addToDeck(c)} + disabled={deckId === "" || busy.has(c.oracle_id)} + overlay + /> + } + footer={ + c.decks.length > 0 + ? c.decks.map((d) => ( + + {d} + + )) + : undefined + } + /> + ))} +
+ ); + } + + const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy); + + return ( +
+
+

+ Card Search {hasActiveFilter && {rows.length}} +

+
+ + + setSortDir(sortDir === "asc" ? "desc" : "asc")} + /> + +
+
+ +
+
+ + setText(e.target.value)} placeholder="e.g. bolt" /> +
+
+ + +
+
+ + +
+
+ +
+ setCmcMin(e.target.value)} + /> + – + setCmcMax(e.target.value)} + /> +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setOracleText(e.target.value)} + placeholder="e.g. landfall" + /> +
+
+ + {!hasActiveFilter ? ( +

+ Search the full card database — type a name or choose filters below to get started. +

+ ) : error ? ( +

{error}

+ ) : rows.length === 0 ? ( +

No cards match your filters.

+ ) : ( + <> + {rows.length === CAP && ( +

+ Showing the first {CAP} matches — refine your search to narrow it down. +

+ )} + {groups.map((g) => ( +
+ {g.label && ( +

+ {g.label} {g.cards.length} +

+ )} + {view === "list" ? refTable(g.cards) : refTiles(g.cards)} +
+ ))} + + )} +
+ ); +} diff --git a/src/views/LibraryBrowser.tsx b/src/views/LibraryBrowser.tsx index d6cc9e9..8abcda4 100644 --- a/src/views/LibraryBrowser.tsx +++ b/src/views/LibraryBrowser.tsx @@ -12,9 +12,13 @@ import { LocationField, PromptDialog, QtyStepper, + SortBySelect, + SortDir, + SortMode, TagInput, ViewToggle, groupCards, + sortCards, usePersistedState, useViewMode, } from "../components"; @@ -56,6 +60,8 @@ export function LibraryBrowser({ const [rows, setRows] = useState([]); const [view, setView] = useViewMode("library-view"); const [groupBy, setGroupBy] = usePersistedState("library-group", "none"); + const [sortBy, setSortBy] = usePersistedState("library-sort", "name"); + const [sortDir, setSortDir] = usePersistedState("library-sort-dir", "asc"); const [refreshKey, setRefreshKey] = useState(0); const [pendingRemoval, setPendingRemoval] = useState>(new Set()); const removalTimers = useRef>>(new Map()); @@ -389,7 +395,7 @@ export function LibraryBrowser({ ); } - const groups = groupCards(rows, groupBy); + const groups = groupCards(sortCards(rows, sortBy, sortDir), groupBy); return (
@@ -414,6 +420,12 @@ export function LibraryBrowser({
+ setSortDir(sortDir === "asc" ? "desc" : "asc")} + />