2023-01-27 14:18:33 +01:00
#![ feature(option_result_contains, once_cell) ]
use actix_web ::{ get , http ::header , web , App , Either , HttpResponse , HttpServer } ;
2023-02-02 11:34:58 +01:00
use data ::{ Card , CardInfo , Set } ;
2023-01-26 23:07:16 +01:00
use filter ::SearchCard ;
2023-01-27 15:48:07 +01:00
use itertools ::Itertools ;
2023-01-27 14:18:33 +01:00
use serde ::Deserialize ;
use std ::{ collections ::HashMap , fmt ::Write , fs ::File , io ::BufReader , net ::Ipv4Addr , sync ::LazyLock , time ::Instant } ;
2023-02-02 11:34:58 +01:00
use time ::Date ;
2023-01-26 23:07:16 +01:00
mod data ;
mod filter ;
mod parser ;
2023-01-26 15:04:39 +01:00
2023-02-13 11:13:07 +01:00
type AnyResult < T > = Result < T , Box < dyn std ::error ::Error > > ;
2023-02-10 09:49:11 +01:00
// 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 ;
2023-01-27 14:45:17 +01:00
2023-01-27 14:18:33 +01:00
static CARDS : LazyLock < Vec < Card > > = LazyLock ::new ( | | {
2023-02-02 11:34:58 +01:00
let mut cards = serde_json ::from_reader ::< _ , CardInfo > ( BufReader ::new ( File ::open ( " cards.json " ) . expect ( " cards.json not found " ) ) )
2023-01-27 14:18:33 +01:00
. expect ( " Could not deserialize cards " )
2023-02-02 11:34:58 +01:00
. 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
2023-01-27 14:18:33 +01:00
} ) ;
static CARDS_BY_ID : LazyLock < HashMap < usize , Card > > =
2023-01-31 11:47:59 +01:00
LazyLock ::new ( | | CARDS . iter ( ) . map ( | c | ( c . id , Card { text : c . text . replace ( '\r' , " " ) . replace ( '\n' , " <br/> " ) , .. c . clone ( ) } ) ) . collect ( ) ) ;
2023-01-27 14:18:33 +01:00
static SEARCH_CARDS : LazyLock < Vec < SearchCard > > = LazyLock ::new ( | | CARDS . iter ( ) . map ( SearchCard ::from ) . collect ( ) ) ;
2023-02-02 11:34:58 +01:00
static SETS_BY_NAME : LazyLock < HashMap < String , Set > > = LazyLock ::new ( | | {
serde_json ::from_reader ::< _ , Vec < Set > > ( 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 ( )
} ) ;
2023-01-27 14:18:33 +01:00
2023-01-31 17:08:37 +01:00
static IMG_HOST : LazyLock < String > = LazyLock ::new ( | | std ::env ::var ( " IMG_HOST " ) . unwrap_or_else ( | _ | String ::new ( ) ) ) ;
2023-01-27 14:18:33 +01:00
#[ actix_web::main ]
async fn main ( ) -> std ::io ::Result < ( ) > {
2023-01-27 00:03:00 +01:00
let now = Instant ::now ( ) ;
2023-01-27 14:18:33 +01:00
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 ( ) ) ;
2023-01-30 17:27:44 +01:00
HttpServer ::new ( | | App ::new ( ) . service ( search ) . service ( card_info ) . service ( help ) )
2023-01-30 18:00:46 +01:00
. bind ( ( Ipv4Addr ::from ( [ 127 , 0 , 0 , 1 ] ) , 1961 ) ) ?
2023-01-30 17:27:44 +01:00
. run ( )
. await
2023-01-27 14:18:33 +01:00
}
#[ derive(Debug, Deserialize) ]
struct Query {
q : String ,
}
2023-02-13 11:13:07 +01:00
#[ derive(Debug) ]
struct PageData {
2023-02-13 11:55:05 +01:00
description : String ,
title : String ,
query : Option < String > ,
body : String ,
2023-02-13 11:13:07 +01:00
}
2023-01-30 11:39:42 +01:00
const HEADER : & str = include_str! ( " ../static/header.html " ) ;
2023-01-30 17:27:44 +01:00
const HELP_CONTENT : & str = include_str! ( " ../static/help.html " ) ;
const FOOTER : & str = r #" <div id= " bottom " >
< a href = " / " > Home < / a >
& nbsp ; & nbsp ; & nbsp ; & nbsp ; & nbsp ;
< a href = " /help " > Query Syntax < / a >
< / div >
< / body > < / html > " #;
2023-01-30 11:39:42 +01:00
2023-01-27 14:18:33 +01:00
#[ get( " / " ) ]
2023-02-13 11:13:07 +01:00
async fn search ( q : Option < Either < web ::Query < Query > , web ::Form < Query > > > ) -> AnyResult < HttpResponse > {
2023-01-27 14:18:33 +01:00
let q = match q {
Some ( Either ::Left ( web ::Query ( Query { q } ) ) ) = > Some ( q ) ,
Some ( Either ::Right ( web ::Form ( Query { q } ) ) ) = > Some ( q ) ,
None = > None ,
2023-01-30 15:57:08 +01:00
}
. filter ( | s | ! s . is_empty ( ) ) ;
2023-01-27 14:18:33 +01:00
let mut res = String ::with_capacity ( 10_000 ) ;
2023-02-13 11:13:07 +01:00
let data = match q {
Some ( q ) = > compute_results ( q ) ? ,
2023-02-13 11:55:05 +01:00
None = > PageData {
title : " YGO card search " . to_owned ( ) ,
description : " Enter a query above to search " . to_owned ( ) ,
query : None ,
body : " Enter a query above to search " . to_owned ( ) ,
} ,
2023-02-13 11:13:07 +01:00
} ;
add_data ( & mut res , & data ) ? ;
2023-01-30 15:57:08 +01:00
Ok ( HttpResponse ::Ok ( ) . insert_header ( header ::ContentType ::html ( ) ) . body ( res ) )
}
2023-01-30 17:27:44 +01:00
#[ get( " /card/{id} " ) ]
2023-02-13 11:13:07 +01:00
async fn card_info ( card_id : web ::Path < usize > ) -> AnyResult < HttpResponse > {
2023-01-30 15:57:08 +01:00
let mut res = String ::with_capacity ( 2_000 ) ;
2023-02-13 11:13:07 +01:00
let data = match CARDS_BY_ID . get ( & card_id ) {
Some ( card ) = > PageData {
2023-02-13 11:55:05 +01:00
title : format ! ( " {} - YGO Card Database " , card . name ) ,
description : card . short_info ( ) ? ,
query : None ,
body : format ! (
r # "<div> <img alt="Card Image: {}" class="fullimage" src="{}/static/full/{}.jpg"/>{card} {} </div>"# ,
card . name ,
2023-01-31 17:08:37 +01:00
IMG_HOST . as_str ( ) ,
2023-01-30 15:57:08 +01:00
card . id ,
2023-02-01 18:47:27 +01:00
card . extended_info ( ) . unwrap_or_else ( | _ | String ::new ( ) ) ,
2023-02-13 11:13:07 +01:00
) ,
} ,
2023-02-13 11:55:05 +01:00
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 ( ) ,
} ,
2023-02-13 11:13:07 +01:00
} ;
add_data ( & mut res , & data ) ? ;
2023-01-30 15:57:08 +01:00
Ok ( HttpResponse ::Ok ( ) . insert_header ( header ::ContentType ::html ( ) ) . body ( res ) )
}
2023-01-30 17:27:44 +01:00
#[ get( " /help " ) ]
2023-02-13 11:13:07 +01:00
async fn help ( ) -> AnyResult < HttpResponse > {
2023-01-30 17:27:44 +01:00
let mut res = String ::with_capacity ( HEADER . len ( ) + HELP_CONTENT . len ( ) + FOOTER . len ( ) + 250 ) ;
2023-02-13 11:55:05 +01:00
let data = PageData {
query : None ,
title : " Query Syntax - YGO Card Database " . to_owned ( ) ,
body : HELP_CONTENT . to_owned ( ) ,
description : String ::new ( ) ,
} ;
2023-02-13 11:13:07 +01:00
add_data ( & mut res , & data ) ? ;
2023-01-30 17:27:44 +01:00
Ok ( HttpResponse ::Ok ( ) . insert_header ( header ::ContentType ::html ( ) ) . body ( res ) )
}
2023-02-13 11:13:07 +01:00
fn add_searchbox ( res : & mut String , query : & Option < String > ) -> std ::fmt ::Result {
2023-01-27 14:18:33 +01:00
write! (
res ,
r #"
< form action = " / " >
2023-01-30 11:39:42 +01:00
< input type = " text " name = " q " id = " searchbox " placeholder = " Enter query (e.g. l:5 c:synchro atk>2000) " value = " {} " > < input type = " submit " id = " submit " value = " 🔍 " >
2023-02-13 11:55:05 +01:00
< / form >
" #,
2023-01-30 15:57:08 +01:00
match & query {
2023-01-31 11:47:59 +01:00
Some ( q ) = > q . replace ( '"' , " " " ) ,
None = > String ::new ( ) ,
2023-01-27 14:18:33 +01:00
}
2023-01-30 15:57:08 +01:00
)
}
2023-02-13 11:13:07 +01:00
fn compute_results ( raw_query : String ) -> AnyResult < PageData > {
let mut body = String ::with_capacity ( 10_000 ) ;
2023-02-13 13:55:56 +01:00
let ( raw_filters , query ) = match parser ::parse_filters ( & raw_query . trim ( ) ) {
2023-01-30 15:57:08 +01:00
Ok ( q ) = > q ,
Err ( e ) = > {
2023-02-13 11:13:07 +01:00
let s = format! ( " Could not parse query: {e:?} " ) ;
2023-02-13 11:55:05 +01:00
return Ok ( PageData {
description : s . clone ( ) ,
query : Some ( raw_query ) ,
body : s ,
title : " YGO Card Database " . to_owned ( ) ,
} ) ;
2023-01-30 15:57:08 +01:00
}
} ;
let now = Instant ::now ( ) ;
let matches : Vec < & Card > = SEARCH_CARDS
. iter ( )
2023-01-31 11:47:59 +01:00
. filter ( | card | query . iter ( ) . all ( | q | q ( card ) ) )
2023-01-30 15:57:08 +01:00
. map ( | c | CARDS_BY_ID . get ( & c . id ) . unwrap ( ) )
. take ( RESULT_LIMIT )
. collect ( ) ;
2023-02-13 11:13:07 +01:00
let readable_query = format! ( " Showing {} results where {} " , matches . len ( ) , raw_filters . iter ( ) . map ( | f | f . to_string ( ) ) . join ( " and " ) , ) ;
write! ( body , " <span class= \" meta \" >{readable_query} (took {:?})</span> " , now . elapsed ( ) ) ? ;
2023-01-30 15:57:08 +01:00
if matches . is_empty ( ) {
2023-02-13 11:55:05 +01:00
return Ok ( PageData {
description : readable_query ,
query : Some ( raw_query ) ,
body ,
title : " No results - YGO Card Database " . to_owned ( ) ,
} ) ;
2023-01-30 15:57:08 +01:00
}
2023-02-13 11:13:07 +01:00
body . push_str ( " <div style= \" display: flex; flex-wrap: wrap; \" > " ) ;
2023-02-13 11:55:05 +01:00
for card in & matches {
2023-01-27 15:48:07 +01:00
write! (
2023-02-13 11:13:07 +01:00
body ,
2023-02-13 11:55:05 +01:00
r # "<a class="cardresult" href="/card/{}"><img alt="Card Image: {}" src="{}/static/thumb/{}.jpg" class="thumb"/>{card}</a>"# ,
2023-01-31 17:08:37 +01:00
card . id ,
2023-02-13 11:55:05 +01:00
card . name ,
2023-01-31 17:08:37 +01:00
IMG_HOST . as_str ( ) ,
card . id
2023-01-27 15:48:07 +01:00
) ? ;
2023-01-26 15:04:39 +01:00
}
2023-02-13 11:13:07 +01:00
body . push_str ( " </div> " ) ;
2023-02-13 11:55:05 +01:00
Ok ( PageData {
description : readable_query ,
query : Some ( raw_query ) ,
body ,
title : format ! ( " {} results - YGO Card Database " , matches . len ( ) ) ,
} )
2023-01-30 15:57:08 +01:00
}
2023-02-13 11:13:07 +01:00
fn add_data ( res : & mut String , pd : & PageData ) -> AnyResult < ( ) > {
2023-02-13 11:55:05 +01:00
res . push_str (
& HEADER . replacen ( " {DESCRIPTION} " , & pd . description , 2 ) . replacen ( " {IMG_HOST} " , & IMG_HOST , 1 ) . replacen ( " {TITLE} " , & pd . title , 2 ) ,
) ;
2023-02-13 11:13:07 +01:00
add_searchbox ( res , & pd . query ) ? ;
res . push_str ( & pd . body ) ;
res . push_str ( FOOTER ) ;
Ok ( ( ) )
2022-10-05 17:57:01 +02:00
}