From a304370df4f998f7054731bac173113f91bf5cb1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 6 Apr 2020 14:43:28 +0200 Subject: implement guild display & filtering options Filtering based on guilds is slow, as it will have to retrieve every guild name from the GW2 API, and it has to parse every log file instead of bailing early. Therefore, guilds are not searched by default, and have to be explicitely turned on with --guilds. In addition, this means that raidgrep will now need network access when --guilds is passed, which was not the case before. --- src/csl.rs | 2 +- src/filters.rs | 23 +++++++++++++-- src/guilds.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 81 ++++++++++++++++++++++++++++++++++++--------------- src/output/formats.rs | 23 ++++++++++++--- src/output/mod.rs | 5 +++- 6 files changed, 181 insertions(+), 32 deletions(-) create mode 100644 src/guilds.rs (limited to 'src') diff --git a/src/csl.rs b/src/csl.rs index 6609dda..e7d84f3 100644 --- a/src/csl.rs +++ b/src/csl.rs @@ -36,7 +36,7 @@ macro_rules! variants { }; } -variants! { SearchField => Account, Character } +variants! { SearchField => Account, Character, Guild } variants! { FightOutcome => Success, Wipe } variants! { Weekday => Mon, Tue, Wed, Thu, Fri, Sat, Sun } variants! { Boss => diff --git a/src/filters.rs b/src/filters.rs index 02334bc..715e4e4 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -4,7 +4,7 @@ use evtclib::statistics::gamedata::Boss; use num_traits::FromPrimitive; -use super::{SearchField, LogResult, Opt}; +use super::{SearchField, LogResult, Opt, guilds}; use chrono::Datelike; @@ -30,7 +30,8 @@ pub fn filter_name(evtc: &PartialEvtc, opt: &Opt) -> bool { _ => (), } } - false + // Don't throw away the log yet if we are searching for guilds + opt.field.contains(&SearchField::Guild) } /// Do filtering based on the boss ID. @@ -64,3 +65,21 @@ pub fn filter_time(result: &LogResult, opt: &Opt) -> bool { after_ok && before_ok } + +/// Do filtering based on the guilds. +pub fn filter_guilds(result: &LogResult, opt: &Opt) -> bool { + if !opt.guilds { + return true; + } + if !opt.field.contains(&SearchField::Guild) { + return true; + } + result.players.iter().any(|player| { + let guild = player.guild_id.as_ref().and_then(|id| guilds::lookup(id)); + if let Some(guild) = guild { + opt.expression.is_match(guild.tag()) || opt.expression.is_match(guild.name()) + } else { + false + } + }) +} diff --git a/src/guilds.rs b/src/guilds.rs new file mode 100644 index 0000000..0eff6ad --- /dev/null +++ b/src/guilds.rs @@ -0,0 +1,79 @@ +//! Guild name retrieval and caching functions. +use std::collections::HashMap; +use std::fs::File; +use std::path::PathBuf; +use std::sync::RwLock; + +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; + +static CACHE: Lazy>> = Lazy::new(|| RwLock::new(HashMap::new())); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Guild { + tag: String, + name: String, +} + +impl Guild { + pub fn tag(&self) -> &str { + &self.tag + } + + pub fn name(&self) -> &str { + &self.name + } +} + +/// Looks up the given guild. +/// +/// This checks the cache first, and if nothing was found, it will hit the API. +pub fn lookup(api_id: &str) -> Option { + { + let cache = CACHE.read().unwrap(); + if let Some(guild) = cache.get(api_id) { + return Some(guild.clone()); + } + } + + let mut cache = CACHE.write().unwrap(); + let url = format!("https://api.guildwars2.com/v2/guild/{}", api_id); + let result = ureq::get(&url) + .call() + .into_json() + .expect("Invalid JSON in API response"); + let name = result["name"].as_str()?; + let tag = result["tag"].as_str()?; + let guild = Guild { + tag: tag.into(), + name: name.into(), + }; + cache.insert(api_id.into(), guild.clone()); + Some(guild) +} + +fn cache_path() -> PathBuf { + let mut cache_path = dirs::cache_dir().unwrap(); + cache_path.push("raidgrep"); + cache_path +} + +/// Loads the cache from the file system. +pub fn prepare_cache() { + let path = cache_path(); + if !path.is_file() { + return; + } + + let file = File::open(path).expect("Unable to read cache file"); + let cache = serde_json::from_reader(file).expect("Cache file has invalid format"); + let mut target = CACHE.write().unwrap(); + *target = cache; +} + +/// Saves the cache to the file system +pub fn save_cache() { + let path = cache_path(); + let file = File::create(path).expect("Cannot open cache for writing"); + serde_json::to_writer(file, &*CACHE.read().unwrap()).unwrap(); +} diff --git a/src/main.rs b/src/main.rs index 838a518..f82e522 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,20 @@ +use std::collections::HashMap; use std::fs::File; use std::io::{BufReader, Read, Seek}; use std::path::PathBuf; use std::str::FromStr; +use anyhow::{anyhow, Result}; use chrono::{Duration, NaiveDateTime, Weekday}; use num_traits::cast::FromPrimitive; use regex::Regex; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; -use anyhow::{anyhow, Result}; use evtclib::{AgentKind, AgentName, EventKind, Log}; +mod guilds; mod output; - mod filters; mod csl; @@ -55,16 +56,11 @@ fn debug_enabled() -> bool { #[structopt(name = "raidgrep")] pub struct Opt { /// Path to the folder with logs. - #[structopt( - short = "d", - long = "dir", - default_value = ".", - parse(from_os_str) - )] + #[structopt(short = "d", long = "dir", default_value = ".", parse(from_os_str))] path: PathBuf, /// The fields which should be searched. - #[structopt(short = "f", long = "fields", default_value = "*")] + #[structopt(short = "f", long = "fields", default_value = "account,character")] field: CommaSeparatedList, /// Only display fights with the given outcome. @@ -109,17 +105,17 @@ pub struct Opt { weekdays: CommaSeparatedList, /// Only show logs from the given encounters. - #[structopt( - short = "e", - long = "bosses", - default_value = "*", - )] + #[structopt(short = "e", long = "bosses", default_value = "*")] bosses: CommaSeparatedList, /// Print more debugging information to stderr. #[structopt(long = "debug")] debug: bool, + /// Load guild information from the API. + #[structopt(long = "guilds")] + guilds: bool, + /// The regular expression to search for. #[structopt(name = "EXPR")] expression: Regex, @@ -132,6 +128,8 @@ enum SearchField { Account, /// Only search the character name. Character, + /// Only search the guild name or tag. + Guild, } impl FromStr for SearchField { @@ -141,6 +139,7 @@ impl FromStr for SearchField { match s { "account" => Ok(SearchField::Account), "character" => Ok(SearchField::Character), + "guild" => Ok(SearchField::Guild), _ => Err("Must be account or character"), } } @@ -172,6 +171,8 @@ pub struct Player { profession: String, /// Subsquad that the player was in. subgroup: u8, + /// Guild ID, ready for API consumption. + guild_id: Option, } /// Outcome of the fight. @@ -209,12 +210,10 @@ fn parse_time_arg(input: &str) -> Result { Err(anyhow!("unknown time format")) } -fn try_from_str_simple_error(input: &str) -> Result -{ +fn try_from_str_simple_error(input: &str) -> Result { T::from_str(input).map_err(|_| format!("'{}' is an invalid value", input)) } - enum ZipWrapper { Raw(Option), Zipped(zip::ZipArchive), @@ -237,7 +236,6 @@ impl ZipWrapper { } } - fn main() { let opt = Opt::from_args(); @@ -250,6 +248,10 @@ fn main() { unsafe { DEBUG_ENABLED = true }; } + if opt.guilds { + guilds::prepare_cache(); + } + let result = grep(&opt); match result { Ok(_) => {} @@ -257,6 +259,10 @@ fn main() { eprintln!("Error: {}", e); } } + + if opt.guilds { + guilds::save_cache(); + } } /// Check if the given entry represents a log file, based on the file name. @@ -311,11 +317,11 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result> { let mut stream = wrapper.get_stream(); let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?; - let early_ok = filters::filter_name(&partial, opt) != opt.invert - && filters::filter_boss(&partial, opt); + let early_ok = + filters::filter_name(&partial, opt) != opt.invert && filters::filter_boss(&partial, opt); if !early_ok { - return Ok(None) + return Ok(None); } let raw = evtclib::raw::parser::finish_parsing(partial, &mut stream)?; @@ -331,7 +337,8 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result> { let take_log = filters::filter_outcome(&info, opt) && filters::filter_weekday(&info, opt) - && filters::filter_time(&info, opt); + && filters::filter_time(&info, opt) + && filters::filter_guilds(&info, opt); if take_log { Ok(Some(info)) @@ -350,7 +357,10 @@ fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { log.boss_id() ); "unknown" - }).into(); + }) + .into(); + + let guild_ids = get_guild_mapping(log); let mut players = log .players() @@ -367,9 +377,11 @@ fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { character_name: character_name.clone(), profession: get_profession_name(*profession, *elite).into(), subgroup: *subgroup, + guild_id: guild_ids.get(p.addr()).cloned(), } }}}} - }).collect::>(); + }) + .collect::>(); players.sort_by_key(|p| p.subgroup); LogResult { @@ -381,6 +393,27 @@ fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { } } +/// Get a mapping of agent IDs to guild API strings. +fn get_guild_mapping(log: &Log) -> HashMap { + log.events() + .iter() + .filter_map(|event| { + if let EventKind::Guild { + source_agent_addr, + ref api_guild_id, + .. + } = event.kind + { + api_guild_id + .as_ref() + .map(|api_id| (source_agent_addr, api_id.clone())) + } else { + None + } + }) + .collect() +} + /// Get the timestamp of the log start time. fn get_start_timestamp(log: &Log) -> u32 { for event in log.events() { diff --git a/src/output/formats.rs b/src/output/formats.rs index 5915069..b697401 100644 --- a/src/output/formats.rs +++ b/src/output/formats.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use super::{LogResult, FightOutcome}; +use super::super::guilds; /// An output format pub trait Format: Sync + Send { @@ -12,7 +13,9 @@ pub trait Format: Sync + Send { /// The human readable, colored format. #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] -pub struct HumanReadable; +pub struct HumanReadable { + pub show_guilds: bool, +} impl Format for HumanReadable { @@ -35,14 +38,26 @@ impl Format for HumanReadable { outcome, ).unwrap(); for player in &item.players { - writeln!( + write!( result, - " {:2} {:20} {:19} {}", + " {:2} {:20} {:19} {:12}", player.subgroup, player.account_name.yellow(), player.character_name.cyan(), - player.profession + player.profession, ).unwrap(); + if self.show_guilds { + let guild = player.guild_id.as_ref().and_then(|id| guilds::lookup(id)); + if let Some(guild) = guild { + write!( + result, + " [{}] {}", + guild.tag().magenta(), + guild.name().magenta(), + ).unwrap(); + } + } + writeln!(result).unwrap(); } result } diff --git a/src/output/mod.rs b/src/output/mod.rs index 73af5ab..84ed0a4 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -17,6 +17,9 @@ pub fn build_pipeline(opt: &Opt) -> Pipeline { if opt.file_name_only { Pipeline::new(stream, formats::FileOnly, aggregator) } else { - Pipeline::new(stream, formats::HumanReadable, aggregator) + let format = formats::HumanReadable { + show_guilds: opt.guilds, + }; + Pipeline::new(stream, format, aggregator) } } -- cgit v1.2.3