#![feature(lazy_cell)] use actix_web::{get, http::header, web, App, Either, HttpResponse, HttpServer}; use data::{Card, CardInfo, Set}; use filter::SearchCard; use itertools::Itertools; use regex::{Captures, Regex}; use serde::Deserialize; use std::{ collections::HashMap, fmt::Write, fs::File, io::BufReader, net::Ipv4Addr, sync::{ atomic::{AtomicUsize, Ordering}, LazyLock, }, time::Instant, }; use time::Date; mod data; mod filter; mod parser; type AnyResult = Result>; // The yearly tins have ~250 cards in them. // I want to be higher than that so the page is usable as a set list. const RESULT_LIMIT: usize = 300; static CARDS: LazyLock> = LazyLock::new(|| { let mut cards = serde_json::from_reader::<_, CardInfo>(BufReader::new(File::open("cards.json").expect("cards.json not found"))) .expect("Could not deserialize cards") .data; cards.iter_mut().for_each(|c| { c.card_sets.sort_unstable_by_key(|s| SETS_BY_NAME.get(&s.set_name.to_lowercase()).and_then(|s| s.tcg_date).unwrap_or(Date::MAX)) }); cards }); static CARDS_BY_ID: LazyLock> = LazyLock::new(|| { CARDS .iter() .map(|c| { let text = PENDULUM_SEPARATOR .replacen(&c.text.replace('\r', ""), 1, |caps: &Captures| { format!("


[ {} ]

", caps.iter().flatten().last().map_or_else(|| "Monster Effect", |g| g.as_str())) }) .replace('\n', "
"); (c.id, Card { text, ..c.clone() }) }) .collect() }); static SEARCH_CARDS: LazyLock> = LazyLock::new(|| CARDS.iter().map(SearchCard::from).collect()); static SETS_BY_NAME: LazyLock> = LazyLock::new(|| { serde_json::from_reader::<_, Vec>(BufReader::new(File::open("sets.json").expect("sets.json not found"))) .expect("Could not deserialize sets") .into_iter() .map(|s| (s.set_name.to_lowercase(), s)) .collect() }); static PENDULUM_SEPARATOR: LazyLock = LazyLock::new(|| Regex::new("(\\n-+)?\\n\\[\\s?(Monster Effect|Flavor Text)\\s?\\]\\n?").unwrap()); static IMG_HOST: LazyLock = LazyLock::new(|| std::env::var("IMG_HOST").unwrap_or_else(|_| String::new())); #[actix_web::main] async fn main() -> std::io::Result<()> { let now = Instant::now(); println!("Starting server"); // tap these so they’re initialized let num_cards = (CARDS_BY_ID.len() + SEARCH_CARDS.len()) / 2; println!("Read {num_cards} cards in {:?}", now.elapsed()); HttpServer::new(|| App::new().service(search).service(card_info).service(help)) .bind((Ipv4Addr::from([127, 0, 0, 1]), 1961))? .run() .await } #[derive(Debug, Deserialize)] struct Query { q: String, } #[derive(Debug)] struct PageData { description: String, title: String, query: Option, body: String, } const HEADER: &str = include_str!("../static/header.html"); const HELP_CONTENT: &str = include_str!("../static/help.html"); static VIEW_COUNT: AtomicUsize = AtomicUsize::new(0); fn footer() -> String { format!( r#"

{}       Home       Query Syntax
"#, VIEW_COUNT.fetch_add(1, Ordering::Relaxed) ) } #[get("/")] async fn search(q: Option, web::Form>>) -> AnyResult { let q = match q { Some(Either::Left(web::Query(Query { q }))) => Some(q), Some(Either::Right(web::Form(Query { q }))) => Some(q), None => None, } .filter(|s| !s.is_empty()); let mut res = String::with_capacity(10_000); let data = match q { Some(q) => compute_results(q)?, None => PageData { title: "YGO card search".to_owned(), description: "Enter a query above to search".to_owned(), query: None, body: "

Welcome to my cheap Scryfall clone for Yugioh.

\

Enter a query above to search or read the query syntax for more information.

" .to_owned(), }, }; add_data(&mut res, &data)?; Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } #[get("/card/{id}")] async fn card_info(card_id: web::Path) -> AnyResult { let mut res = String::with_capacity(2_000); let data = match CARDS_BY_ID.get(&card_id) { Some(card) => PageData { title: format!("{} - YGO Card Database", card.name), description: card.short_info()?, query: None, body: format!( r#"
Card Image: {}{card}
{}
"#, card.name, IMG_HOST.as_str(), card.id, card.extended_info().unwrap_or_else(|_| String::new()), ), }, None => PageData { description: "Card not found - YGO Card Database".to_owned(), title: "Card not found - YGO Card Database".to_owned(), query: None, body: "Card not found".to_owned(), }, }; add_data(&mut res, &data)?; Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } #[get("/help")] async fn help() -> AnyResult { let mut res = String::with_capacity(HEADER.len() + HELP_CONTENT.len() + 500); let data = PageData { query: None, title: "Query Syntax - YGO Card Database".to_owned(), body: HELP_CONTENT.to_owned(), description: String::new(), }; add_data(&mut res, &data)?; Ok(HttpResponse::Ok().insert_header(header::ContentType::html()).body(res)) } fn add_searchbox(res: &mut String, query: &Option) -> std::fmt::Result { write!( res, r#"
"#, match &query { Some(q) => q.replace('"', """), None => String::new(), } ) } fn compute_results(raw_query: String) -> AnyResult { let mut body = String::with_capacity(10_000); let (raw_filters, query) = match parser::parse_filters(raw_query.trim()) { Ok(q) => q, Err(e) => { let s = format!("Could not parse query: {e:?}"); return Ok(PageData { description: s.clone(), query: Some(raw_query), body: s, title: "YGO Card Database".to_owned(), }); } }; let now = Instant::now(); let matches: Vec<&Card> = SEARCH_CARDS .iter() .filter(|card| query.iter().all(|q| q(card))) .map(|c| CARDS_BY_ID.get(&c.id).unwrap()) .take(RESULT_LIMIT) .collect(); let readable_query = format!("Showing {} results where {}", matches.len(), raw_filters.iter().map(|f| f.to_string()).join(" and "),); write!(body, "{readable_query} (took {:?})", now.elapsed())?; if matches.is_empty() { return Ok(PageData { description: readable_query, query: Some(raw_query), body, title: "No results - YGO Card Database".to_owned(), }); } body.push_str("
"); for card in &matches { write!( body, r#"Card Image: {}{card}"#, card.id, card.name, IMG_HOST.as_str(), card.id )?; } body.push_str("
"); Ok(PageData { description: readable_query, query: Some(raw_query), body, title: format!("{} results - YGO Card Database", matches.len()), }) } fn add_data(res: &mut String, pd: &PageData) -> AnyResult<()> { res.push_str( &HEADER.replacen("{DESCRIPTION}", &pd.description, 2).replacen("{IMG_HOST}", &IMG_HOST, 1).replacen("{TITLE}", &pd.title, 2), ); add_searchbox(res, &pd.query)?; res.push_str(&pd.body); res.push_str(&footer()); Ok(()) }