//! Build `reference.db` from Scryfall bulk data. //! //! Two passes: the *Oracle Cards* bulk (one entry per `oracle_id`) fills //! `oracle_cards` + the normalized type/subtype/keyword tables + the name FTS; //! the *Default Cards* bulk (one entry per printing) fills `printings`. //! //! Both bulk files are large JSON arrays, so we stream them element-by-element //! rather than loading the whole array into memory. use std::io::{BufReader, Read}; use std::marker::PhantomData; use std::path::Path; use rusqlite::{params, Connection}; use serde::de::{DeserializeOwned, SeqAccess, Visitor}; use serde::Deserialize; use crate::db::open_reference_for_build; use crate::error::{CoreError, Result}; use crate::model::ColorSet; const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data"; const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)"; /// Row counts from a build, for reporting/verification. #[derive(Debug, Default, Clone, Copy)] pub struct IngestStats { pub oracle_cards: usize, pub printings: usize, } /// Download the latest Scryfall bulk data and (re)build `reference.db` at `dest`. /// /// `progress` is called with human-readable status lines. pub fn build_reference_db(dest: &Path, mut progress: impl FnMut(&str)) -> Result { let (oracle_uri, default_uri) = fetch_bulk_uris(&mut progress)?; if dest.exists() { std::fs::remove_file(dest)?; } let conn = open_reference_for_build(dest)?; // Bulk-load fast; this db is disposable/rebuildable so durability is moot. conn.pragma_update(None, "journal_mode", "MEMORY")?; conn.pragma_update(None, "synchronous", "OFF")?; progress("Loading oracle cards…"); let oracle_cards = load_oracle_cards(&conn, http_reader(&oracle_uri)?)?; progress("Loading printings…"); let printings = load_printings(&conn, http_reader(&default_uri)?)?; progress("Optimizing…"); conn.execute_batch("INSERT INTO oracle_names_fts(oracle_names_fts) VALUES('optimize');")?; Ok(IngestStats { oracle_cards, printings, }) } // ---- Scryfall bulk index ------------------------------------------------- #[derive(Deserialize)] struct BulkIndex { data: Vec, } #[derive(Deserialize)] struct BulkEntry { #[serde(rename = "type")] kind: String, download_uri: String, } fn fetch_bulk_uris(progress: &mut impl FnMut(&str)) -> Result<(String, String)> { progress("Fetching Scryfall bulk index…"); let index: BulkIndex = ureq::get(BULK_INDEX_URL) .set("User-Agent", USER_AGENT) .set("Accept", "application/json") .call()? .into_json()?; let find = |kind: &str| { index .data .iter() .find(|e| e.kind == kind) .map(|e| e.download_uri.clone()) .ok_or_else(|| CoreError::Other(format!("Scryfall bulk index missing '{kind}'"))) }; Ok((find("oracle_cards")?, find("default_cards")?)) } fn http_reader(url: &str) -> Result { let resp = ureq::get(url) .set("User-Agent", USER_AGENT) .call()?; Ok(BufReader::with_capacity(1 << 20, resp.into_reader())) } // ---- Streaming array parse ---------------------------------------------- /// Deserialize a top-level JSON array element-by-element, invoking `f` per item /// without holding the whole array in memory. fn stream_json_array(reader: R, f: F) -> Result<()> where R: Read, T: DeserializeOwned, F: FnMut(T) -> Result<()>, { struct SeqVisitor { f: F, _marker: PhantomData T>, } impl<'de, T, F> Visitor<'de> for SeqVisitor where T: DeserializeOwned, F: FnMut(T) -> Result<()>, { type Value = (); fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { fmt.write_str("a JSON array of objects") } fn visit_seq(mut self, mut seq: A) -> std::result::Result<(), A::Error> where A: SeqAccess<'de>, { while let Some(item) = seq.next_element::()? { (self.f)(item).map_err(serde::de::Error::custom)?; } Ok(()) } } let mut de = serde_json::Deserializer::from_reader(reader); serde::de::Deserializer::deserialize_seq( &mut de, SeqVisitor { f, _marker: PhantomData, }, )?; Ok(()) } // ---- Oracle pass --------------------------------------------------------- #[derive(Deserialize)] struct ScryOracle { oracle_id: Option, name: String, #[serde(default)] cmc: f64, #[serde(default)] colors: Option>, #[serde(default)] color_identity: Vec, #[serde(default)] type_line: Option, #[serde(default)] oracle_text: Option, #[serde(default)] keywords: Vec, #[serde(default)] card_faces: Option>, } #[derive(Deserialize)] struct ScryFace { #[serde(default)] type_line: Option, #[serde(default)] colors: Option>, } fn load_oracle_cards(conn: &Connection, reader: impl Read) -> Result { conn.execute_batch("BEGIN")?; let mut count = 0usize; { 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) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", )?; let mut ins_fts = conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?; let mut ins_type = conn.prepare("INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)")?; let mut ins_sub = conn.prepare("INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)")?; let mut ins_kw = conn.prepare("INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)")?; stream_json_array::<_, ScryOracle, _>(reader, |c| { let Some(oracle_id) = c.oracle_id.clone() else { return Ok(()); // rare face-only records without a top-level oracle_id }; // Colors: prefer top-level, else union of faces. let colors = match &c.colors { Some(cs) => ColorSet::from_letters(cs), None => { let mut m = 0u8; if let Some(faces) = &c.card_faces { for f in faces { if let Some(cs) = &f.colors { m |= ColorSet::from_letters(cs).0; } } } ColorSet(m) } }; let identity = ColorSet::from_letters(&c.color_identity); // Type line: top-level, else join faces. let type_line = c.type_line.clone().unwrap_or_else(|| { c.card_faces .as_ref() .map(|faces| { faces .iter() .filter_map(|f| f.type_line.clone()) .collect::>() .join(" // ") }) .unwrap_or_default() }); let changed = ins_card.execute(params![ oracle_id, c.name, c.name.to_lowercase(), c.cmc, colors.0 as i64, identity.0 as i64, type_line, c.oracle_text.clone().unwrap_or_default(), ])?; // Only emit satellite rows the first time we see this oracle_id. if changed == 1 { ins_fts.execute(params![c.name, oracle_id])?; let (types, subtypes) = parse_type_line(&type_line); for t in types { ins_type.execute(params![oracle_id, t])?; } for s in subtypes { ins_sub.execute(params![oracle_id, s])?; } let mut kws = c.keywords.clone(); kws.sort(); kws.dedup(); for k in kws { ins_kw.execute(params![oracle_id, k])?; } count += 1; } Ok(()) })?; } conn.execute_batch("COMMIT")?; Ok(count) } /// Split a Scryfall type line into (types+supertypes, subtypes). Handles split / /// double-faced lines joined by " // ". The separator is the em dash '—'. pub fn parse_type_line(type_line: &str) -> (Vec, Vec) { let mut types = Vec::new(); let mut subtypes = Vec::new(); for part in type_line.split("//") { let part = part.trim(); let mut halves = part.splitn(2, '—'); let left = halves.next().unwrap_or("").trim(); let right = halves.next().unwrap_or("").trim(); for t in left.split_whitespace() { types.push(t.to_string()); } for s in right.split_whitespace() { subtypes.push(s.to_string()); } } types.sort(); types.dedup(); subtypes.sort(); subtypes.dedup(); (types, subtypes) } // ---- Printings pass ------------------------------------------------------ #[derive(Deserialize)] struct ScryPrinting { id: String, oracle_id: Option, #[serde(default)] set: String, #[serde(default)] collector_number: String, #[serde(default)] finishes: Vec, #[serde(default)] rarity: String, #[serde(default)] image_uris: Option, #[serde(default)] card_faces: Option>, } #[derive(Deserialize)] struct ImageUris { #[serde(default)] normal: Option, } #[derive(Deserialize)] struct PrintFace { #[serde(default)] image_uris: Option, } fn load_printings(conn: &Connection, reader: impl Read) -> Result { conn.execute_batch("BEGIN")?; let mut count = 0usize; { let mut ins = conn.prepare( "INSERT OR IGNORE INTO printings (scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri) VALUES (?1,?2,?3,?4,?5,?6,?7)", )?; stream_json_array::<_, ScryPrinting, _>(reader, |p| { let Some(oracle_id) = p.oracle_id.clone() else { return Ok(()); }; let image = p .image_uris .as_ref() .and_then(|i| i.normal.clone()) .or_else(|| { p.card_faces .as_ref() .and_then(|f| f.first()) .and_then(|f| f.image_uris.as_ref()) .and_then(|i| i.normal.clone()) }) .unwrap_or_default(); ins.execute(params![ p.id, oracle_id, p.set, p.collector_number, p.finishes.join(","), p.rarity, image, ])?; count += 1; Ok(()) })?; } conn.execute_batch("COMMIT")?; Ok(count) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_type_lines() { let (t, s) = parse_type_line("Legendary Creature — Elf Druid"); assert_eq!(t, vec!["Creature", "Legendary"]); assert_eq!(s, vec!["Druid", "Elf"]); let (t, s) = parse_type_line("Instant"); assert_eq!(t, vec!["Instant"]); assert!(s.is_empty()); // Split card. let (t, s) = parse_type_line("Sorcery // Sorcery"); assert_eq!(t, vec!["Sorcery"]); assert!(s.is_empty()); } #[test] fn streams_array_of_objects() { #[derive(serde::Deserialize)] struct Item { n: i32, } let json = br#"[{"n":1},{"n":2},{"n":3}]"#; let mut sum = 0; stream_json_array::<_, Item, _>(&json[..], |it| { sum += it.n; Ok(()) }) .unwrap(); assert_eq!(sum, 6); } #[test] fn oracle_and_printing_load_from_fixture_json() { // Minimal Scryfall-shaped fixtures exercising the insert paths. let oracle = r#"[ {"oracle_id":"oid-1","name":"Llanowar Elves","cmc":1.0, "colors":["G"],"color_identity":["G"], "type_line":"Creature — Elf Druid","keywords":[]}, {"oracle_id":"oid-2","name":"Serra Angel","cmc":5.0, "colors":["W"],"color_identity":["W"], "type_line":"Creature — Angel","keywords":["Flying","Vigilance"]} ]"#; let printings = r#"[ {"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310", "finishes":["nonfoil","foil"],"rarity":"common", "image_uris":{"normal":"http://img/1"}} ]"#; let dir = tempfile::tempdir().unwrap(); let dest = dir.path().join("reference.db"); let conn = open_reference_for_build(&dest).unwrap(); let oc = load_oracle_cards(&conn, oracle.as_bytes()).unwrap(); let pc = load_printings(&conn, printings.as_bytes()).unwrap(); assert_eq!(oc, 2); assert_eq!(pc, 1); // Keyword table populated. let flyers: i64 = conn .query_row( "SELECT count(*) FROM card_keywords WHERE keyword='Flying'", [], |r| r.get(0), ) .unwrap(); assert_eq!(flyers, 1); // Bitmask colors stored. let g: i64 = conn .query_row( "SELECT colors FROM oracle_cards WHERE oracle_id='oid-1'", [], |r| r.get(0), ) .unwrap(); assert_eq!(g as u8, crate::model::G); } }