allow filtering by price

This commit is contained in:
kageru 2024-05-18 16:55:53 +02:00
parent 213b7ed7cd
commit a41e042ef6
Signed by: kageru
GPG Key ID: 8282A2BEA4ADA3D2
5 changed files with 52 additions and 14 deletions

@ -7,10 +7,10 @@ edition = "2021"
serde_json = "1.0" serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
nom = "7.1" nom = "7.1"
actix-web = { version = "4.5", default_features = false, features = ["macros"] } actix-web = { version = "4.5", default-features = false, features = ["macros"] }
itertools = "0.12" itertools = "0.12"
time = { version = "0.3", features = ["serde", "serde-human-readable"] } time = { version = "0.3", features = ["serde", "serde-human-readable"] }
regex = { version = "1.10", default_features = false, features = ["std"] } regex = { version = "1.10", default-features = false, features = ["std"] }
[dev-dependencies] [dev-dependencies]
test-case = "3.3" test-case = "3.3"

@ -67,8 +67,8 @@ pub struct Set {
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)] #[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)]
pub struct CardPrice { pub struct CardPrice {
cardmarket_price: String, pub cardmarket_price: String,
tcgplayer_price: String, pub tcgplayer_price: String,
} }
impl Card { impl Card {
@ -205,6 +205,12 @@ pub mod tests {
"set_rarity_code": "(C)", "set_rarity_code": "(C)",
"set_price": "2.07" "set_price": "2.07"
} }
],
"card_prices": [
{
"cardmarket_price": "0.05",
"tcgplayer_price": "0.22"
}
] ]
}"#; }"#;
@ -241,6 +247,12 @@ pub mod tests {
"image_url_small": "https://images.ygoprodeck.com/images/cards_small/49202162.jpg", "image_url_small": "https://images.ygoprodeck.com/images/cards_small/49202162.jpg",
"image_url_cropped": "https://images.ygoprodeck.com/images/cards_cropped/49202162.jpg" "image_url_cropped": "https://images.ygoprodeck.com/images/cards_cropped/49202162.jpg"
} }
],
"card_prices": [
{
"cardmarket_price": "3.70",
"tcgplayer_price": "3.30"
}
] ]
} }
"#; "#;
@ -294,6 +306,7 @@ pub mod tests {
}, },
CardSet { set_name: "Gold Series".to_owned(), set_code: "GLD1-EN010".to_owned(), set_rarity: "Common".to_owned() } CardSet { set_name: "Gold Series".to_owned(), set_code: "GLD1-EN010".to_owned(), set_rarity: "Common".to_owned() }
], ],
card_prices: vec![CardPrice { tcgplayer_price: "0.22".to_owned(), cardmarket_price: "0.05".to_owned() }],
..Default::default() ..Default::default()
}, },
) )

@ -24,6 +24,7 @@ pub struct SearchCard {
sets: Vec<String>, sets: Vec<String>,
original_year: Option<i32>, original_year: Option<i32>,
legal_copies: i32, legal_copies: i32,
price: Option<i32>,
} }
impl From<&Card> for SearchCard { impl From<&Card> for SearchCard {
@ -48,27 +49,35 @@ impl From<&Card> for SearchCard {
.map(Date::year) .map(Date::year)
.min(), .min(),
legal_copies: card.banlist_info.map(|bi| bi.ban_tcg).unwrap_or(BanlistStatus::Unlimited) as i32, legal_copies: card.banlist_info.map(|bi| bi.ban_tcg).unwrap_or(BanlistStatus::Unlimited) as i32,
price: card
.card_prices
.iter()
.flat_map(|p| vec![p.cardmarket_price.parse::<f32>().ok(), p.tcgplayer_price.parse().ok()])
.flatten()
.map(|p| (p * 100.0) as i32)
.min(),
} }
} }
} }
pub type CardFilter = Box<dyn Fn(&SearchCard) -> bool>; pub type CardFilter = Box<dyn Fn(&SearchCard) -> bool>;
fn get_field_value(card: &SearchCard, field: Field) -> Value { fn get_field_value(card: &SearchCard, field: Field) -> Option<Value> {
match field { Some(match field {
Field::Atk => card.atk.map(Value::Numerical).unwrap_or_default(), Field::Atk => Value::Numerical(card.atk?),
Field::Def => card.def.map(Value::Numerical).unwrap_or_default(), Field::Def => Value::Numerical(card.def?),
Field::Legal => Value::Numerical(card.legal_copies), Field::Legal => Value::Numerical(card.legal_copies),
Field::Level => card.level.map(Value::Numerical).unwrap_or_default(), Field::Level => Value::Numerical(card.level?),
Field::LinkRating => card.link_rating.map(Value::Numerical).unwrap_or_default(), Field::LinkRating => Value::Numerical(card.link_rating?),
Field::Year => card.original_year.map(Value::Numerical).unwrap_or_default(), Field::Year => Value::Numerical(card.original_year?),
Field::Set => Value::Multiple(card.sets.clone().into_iter().map(Value::String).collect()), Field::Set => Value::Multiple(card.sets.clone().into_iter().map(Value::String).collect()),
Field::Type => Value::String(card.r#type.clone()), Field::Type => Value::String(card.r#type.clone()),
Field::Attribute => Value::String(card.attribute.clone().unwrap_or_default()), Field::Attribute => Value::String(card.attribute.clone().unwrap_or_default()),
Field::Class => Value::String(card.card_type.clone()), Field::Class => Value::String(card.card_type.clone()),
Field::Name => Value::String(card.name.clone()), Field::Name => Value::String(card.name.clone()),
Field::Text => Value::String(card.text.clone()), Field::Text => Value::String(card.text.clone()),
} Field::Price => Value::Numerical(card.price?),
})
} }
fn filter_value(op: &Operator, field_value: &Value, query_value: &Value) -> bool { fn filter_value(op: &Operator, field_value: &Value, query_value: &Value) -> bool {
@ -100,11 +109,11 @@ fn filter_value(op: &Operator, field_value: &Value, query_value: &Value) -> bool
pub fn build_filter(RawCardFilter(field, op, value): RawCardFilter) -> Result<CardFilter, String> { pub fn build_filter(RawCardFilter(field, op, value): RawCardFilter) -> Result<CardFilter, String> {
Ok(match value { Ok(match value {
Value::Multiple(values) => Box::new(move |card: &SearchCard| { Value::Multiple(values) => Box::new(move |card: &SearchCard| {
let field_value = get_field_value(card, field); let field_value = get_field_value(card, field).unwrap_or_default();
values.iter().any(|query_value| filter_value(&op, &field_value, query_value)) values.iter().any(|query_value| filter_value(&op, &field_value, query_value))
}), }),
single_value => Box::new(move |card: &SearchCard| { single_value => Box::new(move |card: &SearchCard| {
let field_value = get_field_value(card, field); let field_value = get_field_value(card, field).unwrap_or_default();
filter_value(&op, &field_value, &single_value) filter_value(&op, &field_value, &single_value)
}), }),
}) })
@ -166,4 +175,15 @@ mod tests {
assert!(draw_filter[0](&lacooda)); assert!(draw_filter[0](&lacooda));
assert!(!draw_filter[0](&bls)); assert!(!draw_filter[0](&bls));
} }
#[test]
fn price_filter_test() {
let lacooda = SearchCard::from(&serde_json::from_str::<Card>(RAW_MONSTER).unwrap());
let bls = SearchCard::from(&serde_json::from_str::<Card>(RAW_LINK_MONSTER).unwrap());
let price_filter = parse_filters("p>300").unwrap().1;
assert!(!price_filter[0](&lacooda));
assert!(price_filter[0](&bls));
let price_filter_2 = parse_filters("p<350").unwrap().1;
assert!(price_filter_2[0](&bls), "Should filter by the cheaper version");
}
} }

@ -125,6 +125,7 @@ pub enum Field {
Level = 4, Level = 4,
LinkRating = 6, LinkRating = 6,
Year = 8, Year = 8,
Price = 9,
Set = 10, Set = 10,
Type = 12, Type = 12,
Attribute = 14, Attribute = 14,
@ -148,6 +149,7 @@ impl Display for Field {
Self::Set => "set", Self::Set => "set",
Self::Year => "year", Self::Year => "year",
Self::Legal => "allowed copies", Self::Legal => "allowed copies",
Self::Price => "price",
}) })
} }
} }
@ -168,6 +170,7 @@ impl FromStr for Field {
"set" | "s" => Self::Set, "set" | "s" => Self::Set,
"year" | "y" => Self::Year, "year" | "y" => Self::Year,
"legal" | "copies" => Self::Legal, "legal" | "copies" => Self::Legal,
"price" | "p" => Self::Price,
_ => Err(s.to_string())?, _ => Err(s.to_string())?,
}) })
} }
@ -296,6 +299,7 @@ mod tests {
#[test_case("l=10" => Ok(("", RawCardFilter(Field::Level, Operator::Equal, Value::Numerical(10)))))] #[test_case("l=10" => Ok(("", RawCardFilter(Field::Level, Operator::Equal, Value::Numerical(10)))))]
#[test_case("Ib" => Ok(("", RawCardFilter(Field::Name, Operator::Equal, Value::String("ib".to_owned())))))] #[test_case("Ib" => Ok(("", RawCardFilter(Field::Name, Operator::Equal, Value::String("ib".to_owned())))))]
#[test_case("c!=synchro" => Ok(("", RawCardFilter(Field::Class, Operator::NotEqual, Value::String("synchro".to_owned())))))] #[test_case("c!=synchro" => Ok(("", RawCardFilter(Field::Class, Operator::NotEqual, Value::String("synchro".to_owned())))))]
#[test_case("p<150" => Ok(("", RawCardFilter(Field::Price, Operator::Less, Value::Numerical(150)))))]
fn successful_parsing_test(input: &str) -> IResult<&str, RawCardFilter> { fn successful_parsing_test(input: &str) -> IResult<&str, RawCardFilter> {
parse_raw_filter(input) parse_raw_filter(input)
} }

@ -15,6 +15,7 @@ Currently supported search fields are:
<li>The <code>text</code> (or <code>effect</code>, <code>eff</code>, <code>e</code>, or <code>o</code>) of a card. This is either the effect or flavor text (for normal monsters). For pendulum cards, this searches in both pendulum and monster effects. The <code>o</code> alias is to help my muscle memory coming from Scryfall.</li> <li>The <code>text</code> (or <code>effect</code>, <code>eff</code>, <code>e</code>, or <code>o</code>) of a card. This is either the effect or flavor text (for normal monsters). For pendulum cards, this searches in both pendulum and monster effects. The <code>o</code> alias is to help my muscle memory coming from Scryfall.</li>
<li>The <code>set</code> (or <code>s</code>) a card was printed in. This considers all printings, not just the original, and uses the set code (e.g. <code>ioc</code> for Invasion of Chaos or <code>pote</code> for Power of the Elements).</li> <li>The <code>set</code> (or <code>s</code>) a card was printed in. This considers all printings, not just the original, and uses the set code (e.g. <code>ioc</code> for Invasion of Chaos or <code>pote</code> for Power of the Elements).</li>
<li>The <code>copies</code> (or <code>legal</code>) you’re allowed to play according to the current banlist.</li> <li>The <code>copies</code> (or <code>legal</code>) you’re allowed to play according to the current banlist.</li>
<li>The <code>price</code> (or <code>p</code>) of the cheapest version of the card <em>in cents</em>. This will use tcgplayer or cardmarket, whichever is lower. Results can be off because of OCG cards on the market.</li>
</ul> </ul>
Anything not associated with a search field is interpreted as a search in the card name, so <a href="/?q=l%3A4+utopia"><code>l:4 utopia</code></a> will show all level/rank 4 monsters with “Utopia” in their name.<br/> Anything not associated with a search field is interpreted as a search in the card name, so <a href="/?q=l%3A4+utopia"><code>l:4 utopia</code></a> will show all level/rank 4 monsters with “Utopia” in their name.<br/>
If your search contains spaces (e.g. searching for an effect that says “destroy that target”), the text must be quoted like <code>effect:"destroy that target"</code>. If your search contains spaces (e.g. searching for an effect that says “destroy that target”), the text must be quoted like <code>effect:"destroy that target"</code>.