From 63a14ab2ef5298509463fa5e93a39fad59dab53a Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Fri, 5 Aug 2022 02:47:57 +0200 Subject: [PATCH] Start work on Telegram card searching --- Cargo.lock | 2 +- Cargo.toml | 12 +++++- src/lib.rs | 3 ++ src/search/botindex.rs | 30 ++++++++++++++ src/search/botsearch.rs | 86 +++++++++++++++++++++++++++++++++++++++++ src/search/card.rs | 14 +++---- src/search/mod.rs | 2 + src/telegram/bin.rs | 3 ++ src/telegram/display.rs | 82 +++++++++++++++++++++++++++++++++++++++ src/telegram/mod.rs | 2 + 10 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 src/search/botindex.rs create mode 100644 src/search/botsearch.rs create mode 100644 src/telegram/bin.rs create mode 100644 src/telegram/display.rs create mode 100644 src/telegram/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9199bff..980471c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,7 +1032,7 @@ dependencies = [ [[package]] name = "patched-porobot" -version = "0.1.0" +version = "0.2.0" dependencies = [ "glob", "itertools 0.10.3", diff --git a/Cargo.toml b/Cargo.toml index 1282ccd..a3316de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "patched-porobot" -version = "0.1.0" +version = "0.2.0" authors = ["Stefano Pigozzi "] edition = "2021" description = "Legends of Runeterra card database utilities and bots" @@ -33,4 +33,12 @@ tokio = { version = "1.20.1", features = ["rt-multi-thread", "macros"], optiona search = ["tantivy"] telegram = ["search", "teloxide", "reqwest", "tokio"] # discord = ["search"] -# matrix = ["search"] \ No newline at end of file +# matrix = ["search"] + +[lib] +name = "patched_porobot" +path = "src/lib.rs" + +[[bin]] +name = "patched_porobot_telegram" +path = "src/telegram/bin.rs" diff --git a/src/lib.rs b/src/lib.rs index 549afec..e668f94 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,3 +3,6 @@ pub mod load; #[cfg(feature = "search")] pub mod search; + +#[cfg(feature = "telegram")] +pub mod telegram; diff --git a/src/search/botindex.rs b/src/search/botindex.rs new file mode 100644 index 0000000..8393e91 --- /dev/null +++ b/src/search/botindex.rs @@ -0,0 +1,30 @@ +//! This module provides functions to manage [tantivy] [Index]es for internal bot usage. + +use tantivy::{Index, IndexReader, IndexWriter, LeasedItem, ReloadPolicy, Searcher}; +use crate::search::card::card_schema; + + +/// Build a new [Index] for [crate::schena::setbundle::Card] documents, based on [card_schema]. +pub fn card_index() -> Index { + Index::create_in_ram( + card_schema() + ) +} + + +/// Build a [IndexWriter] with the optimal configuration for [crate::schena::setbundle::Card] documents. +pub fn card_writer(index: &Index) -> IndexWriter { + index + .writer(4_000_000) + .expect("to be able to allocate 4 MB for a IndexWriter") +} + + +/// Build a [IndexReader] with the optimal configuration for [crate::schena::setbundle::Card] documents. +pub fn card_reader(index: &Index) -> IndexReader { + index + .reader_builder() + .reload_policy(ReloadPolicy::Manual) + .try_into() + .expect("to be able to create a IndexReader") +} diff --git a/src/search/botsearch.rs b/src/search/botsearch.rs new file mode 100644 index 0000000..10caa74 --- /dev/null +++ b/src/search/botsearch.rs @@ -0,0 +1,86 @@ +//! This module provides functions to perform queries for internal bot usage. + + +use tantivy::{Index, IndexReader, TantivyError}; +use tantivy::collector::TopDocs; +use tantivy::query::{QueryParser, QueryParserError}; +use tantivy::schema::Schema; +use itertools::Itertools; +use crate::search::botsearch::QueryError::Parsing; + + +pub fn card_query_parser(index: &Index) -> QueryParser { + let schema = index.schema(); + + let f_code = schema.get_field("code").expect("schema to have a 'code' field"); + let f_name = schema.get_field("name").expect("schema to have a 'name' field"); + let f_type = schema.get_field("type").expect("schema to have a 'type' field"); + let f_set = schema.get_field("set").expect("schema to have a 'set' field"); + let f_rarity = schema.get_field("rarity").expect("schema to have a 'rarity' field"); + let f_collectible = schema.get_field("collectible").expect("schema to have a 'collectible' field"); + let f_regions = schema.get_field("regions").expect("schema to have a 'regions' field"); + let f_attack = schema.get_field("attack").expect("schema to have a 'attack' field"); + let f_cost = schema.get_field("cost").expect("schema to have a 'cost' field"); + let f_health = schema.get_field("health").expect("schema to have a 'health' field"); + let f_spellspeed = schema.get_field("spellspeed").expect("schema to have a 'spellspeed' field"); + let f_keywords = schema.get_field("keywords").expect("schema to have a 'keywords' field"); + let f_description = schema.get_field("description").expect("schema to have a 'description' field"); + let f_levelup = schema.get_field("levelup").expect("schema to have a 'levelup' field"); + let f_associated = schema.get_field("associated").expect("schema to have a 'associated' field"); + let f_flavor = schema.get_field("flavor").expect("schema to have a 'flavor' field"); + let f_artist = schema.get_field("artist").expect("schema to have a 'artist' field"); + let f_subtypes = schema.get_field("subtypes").expect("schema to have a 'subtypes' field"); + let f_supertype = schema.get_field("supertype").expect("schema to have a 'supertype' field"); + + QueryParser::for_index( + &index, + vec![ + f_code, + f_name, + f_type, + f_set, + f_rarity, + f_collectible, + f_regions, + f_attack, + f_cost, + f_health, + f_spellspeed, + f_keywords, + f_description, + f_levelup, + f_associated, + f_flavor, + f_artist, + f_subtypes, + f_supertype, + ] + ) +} + + +pub enum QueryError { + Parsing(QueryParserError), + Search(TantivyError), +} + + +pub fn card_query(schema: &Schema, reader: &IndexReader, parser: &QueryParser, query: &str, amount: usize) -> Result, QueryError> { + log::debug!("Searching for `{}`...", &query); + + let searcher = reader.searcher(); + let query = parser.parse_query(query) + .map_err(QueryError::Parsing)?; + let search = searcher.search(&*query, &TopDocs::with_limit(amount)) + .map_err(QueryError::Search)?; + + let f_code = schema.get_field("code").expect("schema to have a 'code' field"); + + let results = search.iter() + .filter_map(|(_score, address)| searcher.doc(address.to_owned()).ok()) + .filter_map(|doc| doc.get_first(f_code).cloned()) + .filter_map(|field| field.as_text().map(String::from)) + .collect_vec(); + + Ok(results) +} diff --git a/src/search/card.rs b/src/search/card.rs index c49823b..492ec92 100644 --- a/src/search/card.rs +++ b/src/search/card.rs @@ -26,12 +26,12 @@ pub fn cardcode_options() -> tantivy::schema::TextOptions { /// Create a new [tantivy::schema::TextOptions] for card keywords, using the given tokenizer. -pub fn cardkeyword_options(tokenizer_name: &'static str) -> tantivy::schema::TextOptions { +pub fn cardkeyword_options() -> tantivy::schema::TextOptions { use tantivy::schema::*; TextOptions::default() .set_indexing_options(TextFieldIndexing::default() - .set_tokenizer(tokenizer_name) + .set_tokenizer("card") .set_fieldnorms(false) .set_index_option(IndexRecordOption::Basic) ) @@ -39,12 +39,12 @@ pub fn cardkeyword_options(tokenizer_name: &'static str) -> tantivy::schema::Tex /// Create a new [tantivy::schema::TextOptions] for card text fields, using the given tokenizer. -pub fn cardtext_options(tokenizer_name: &'static str) -> tantivy::schema::TextOptions { +pub fn cardtext_options() -> tantivy::schema::TextOptions { use tantivy::schema::*; TextOptions::default() .set_indexing_options(TextFieldIndexing::default() - .set_tokenizer(tokenizer_name) + .set_tokenizer("card") .set_fieldnorms(true) .set_index_option(IndexRecordOption::WithFreqsAndPositions) ) @@ -52,14 +52,14 @@ pub fn cardtext_options(tokenizer_name: &'static str) -> tantivy::schema::TextOp /// Create a new [tantivy::schema::Schema] using [Card]s as documents. -pub fn card_schema(tokenizer_name: &'static str) -> tantivy::schema::Schema { +pub fn card_schema() -> tantivy::schema::Schema { use tantivy::schema::*; let mut schema_builder = Schema::builder(); let cardcode: TextOptions = cardcode_options(); - let cardkeyword: TextOptions = cardkeyword_options(tokenizer_name); - let cardtext: TextOptions = cardtext_options(tokenizer_name); + let cardkeyword: TextOptions = cardkeyword_options(); + let cardtext: TextOptions = cardtext_options(); schema_builder.add_text_field("code", cardcode); schema_builder.add_text_field("name", cardtext.clone()); diff --git a/src/search/mod.rs b/src/search/mod.rs index ff18471..74bb9d8 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -1,3 +1,5 @@ //! This module implements full-text search on Legends of Runeterra data. pub mod card; +pub(crate) mod botindex; +pub(crate) mod botsearch; \ No newline at end of file diff --git a/src/telegram/bin.rs b/src/telegram/bin.rs new file mode 100644 index 0000000..cf74f49 --- /dev/null +++ b/src/telegram/bin.rs @@ -0,0 +1,3 @@ +fn main() { + +} \ No newline at end of file diff --git a/src/telegram/display.rs b/src/telegram/display.rs new file mode 100644 index 0000000..4cd849d --- /dev/null +++ b/src/telegram/display.rs @@ -0,0 +1,82 @@ +//! This module defines functions to convert [patched-porobot] structs to [String]s formatted with [Telegram Bot HTML](https://core.telegram.org/bots/api#html-style). +//! +//! TODO: Add support for non-latin languages. +//! +//! TODO: Preferably refactor everything in here, as the code is poor quality. + +use std::collections::HashMap; +use itertools::Itertools; +use teloxide::utils::html::escape; +use crate::load::corebundle::MappedGlobals; +use crate::schema::corebundle::{CoreRegion, CoreSet}; +use crate::schema::setbundle::{Card, CardRegion, CardSet, CardType}; + + +/// Render a [Card] to a [String] formatted with [Telegram Bot HTML](https://core.telegram.org/bots/api#html-style). +pub fn display_card(card: &Card, mg: &MappedGlobals) -> String { + let title = format!(r#"{}"#, &card.main_art().card_png, escape(&card.name)); + + let stats = match &card.r#type { + CardType::Spell => format!("{} mana", escape(&card.cost.to_string())), + CardType::Unit => format!("{} mana {}|{}", escape(&card.cost.to_string()), escape(&card.attack.to_string()), escape(&card.health.to_string())), + CardType::Ability => "".to_string(), + CardType::Landmark => format!("{} mana", &card.cost), + CardType::Trap => "".to_string(), + CardType::Unsupported => "".to_string(), + }; + + let set = format!("{}", escape(&display_set(card.set, &mg.sets))); + let regions = format!("{}", escape(&display_regions(&card.regions, &mg.regions))); + let r#type = format!("{}", escape(&display_type(card.r#type))); + + let breadcrumbs = format!("{} › {} › {}", &set, ®ions, &r#type); + + let description = card.localized_description_text.clone(); + let flavor = format!("{}", &card.localized_flavor_text); + let artist = format!(r#"Art by {}"#, &card.main_art().full_png, &card.artist_name); + + format!( + "{title} {stats}\n{breadcrumbs}\n\n{description}\n\n-----\n{flavor}\n\n{artist}", + title=title, + stats=stats, + breadcrumbs=breadcrumbs, + description=description, + flavor=flavor, + artist=artist, + ) +} + + +/// Render a [CardSet] to a [String]. +fn display_set(set: CardSet, hm: &HashMap) -> String { + set + .localized(&hm) + .map(|o| o.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()) +} + + +/// Render a slice of [CardRegion]s to a [String]. +fn display_regions(regions: &[CardRegion], hm: &HashMap) -> String { + regions + .iter() + .map(|region| region + .localized(&hm) + .map(|o| o.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()) + ) + .join(", ") +} + + +/// Render a [CardType] to a [String]. +fn display_type(r#type: CardType) -> String { + match r#type { + CardType::Spell => "Spell", + CardType::Unit => "Unit", + CardType::Ability => "Ability", + CardType::Landmark => "Landmark", + CardType::Trap => "Trap", + CardType::Unsupported => "Unknown", + }.to_string() +} diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs new file mode 100644 index 0000000..a874901 --- /dev/null +++ b/src/telegram/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod display; +mod bin;