diff options
-rw-r--r-- | Cargo.lock | 156 | ||||
-rw-r--r-- | Cargo.toml | 3 | ||||
-rw-r--r-- | README.md | 46 | ||||
-rw-r--r-- | ezau-sample.toml | 18 | ||||
-rw-r--r-- | src/categories.rs | 101 | ||||
-rw-r--r-- | src/config.rs | 7 | ||||
-rw-r--r-- | src/discord.rs | 14 | ||||
-rw-r--r-- | src/logbag.rs | 278 | ||||
-rw-r--r-- | src/main.rs | 80 | ||||
-rw-r--r-- | src/matrix.rs | 11 |
10 files changed, 392 insertions, 322 deletions
@@ -304,7 +304,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if 1.0.0", - "constant_time_eq 0.3.0", + "constant_time_eq", ] [[package]] @@ -408,10 +408,6 @@ name = "cc" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "324c74f2155653c90b04f25b2a47a8a631360cb908f92a772695f430c7e31052" -dependencies = [ - "jobserver", - "libc", -] [[package]] name = "cfg-if" @@ -549,12 +545,6 @@ checksum = "6051f239ecec86fde3410901ab7860d458d160371533842974fc61f96d15879b" [[package]] name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[package]] -name = "constant_time_eq" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" @@ -759,15 +749,6 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] - -[[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -924,15 +905,14 @@ dependencies = [ [[package]] name = "evtclib" version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5e15d67f82f9d739ec853cc3022a5d4eaf749dace4020e820ff8463f01d4ccb" +source = "git+https://gitlab.com/dunj3/evtclib?rev=b80279d4fb8a8a106ceb3290886c4a98b20aa42c#b80279d4fb8a8a106ceb3290886c4a98b20aa42c" dependencies = [ "byteorder", "getset", "num-derive", "num-traits", "thiserror", - "zip 0.5.13", + "zip", ] [[package]] @@ -983,7 +963,6 @@ dependencies = [ "tokio", "toml 0.5.11", "url", - "zip 0.6.6", ] [[package]] @@ -1656,15 +1635,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] -name = "jobserver" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b099aaa34a9751c5bf0878add70444e1ed2dd73f347be99003d4577277de6e" -dependencies = [ - "libc", -] - -[[package]] name = "js-sys" version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1962,7 +1932,7 @@ dependencies = [ "hmac", "itertools 0.12.1", "matrix-sdk-common", - "pbkdf2 0.12.2", + "pbkdf2", "rand 0.8.5", "rmp-serde", "ruma", @@ -2040,7 +2010,7 @@ dependencies = [ "displaydoc", "getrandom 0.2.15", "hmac", - "pbkdf2 0.12.2", + "pbkdf2", "rand 0.8.5", "rmp-serde", "serde", @@ -2198,12 +2168,6 @@ dependencies = [ ] [[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] name = "num-derive" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2323,17 +2287,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" [[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2341,18 +2294,6 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest 0.10.7", - "hmac", - "password-hash", - "sha2", -] - -[[package]] -name = "pbkdf2" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" @@ -2438,12 +2379,6 @@ dependencies = [ ] [[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] name = "ppv-lite86" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3215,17 +3150,6 @@ dependencies = [ ] [[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if 1.0.0", - "cpufeatures", - "digest 0.10.7", -] - -[[package]] name = "sha2" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3431,25 +3355,6 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" - -[[package]] name = "tinyvec" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4303,54 +4208,5 @@ dependencies = [ "crc32fast", "flate2", "thiserror", - "time 0.1.45", -] - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq 0.1.5", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2 0.11.0", - "sha1", - "time 0.3.36", - "zstd", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.12+zstd.1.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4e40c320c3cb459d9a9ff6de98cff88f4751ee9275d140e2be94a2b74e4c13" -dependencies = [ - "cc", - "pkg-config", + "time", ] @@ -16,14 +16,13 @@ clap = { version = "3.1.8", features = ["derive"] } log = "0.4.16" pretty_env_logger = "0.4.0" anyhow = "1.0.56" -evtclib = "0.7.3" +evtclib = { git = "https://gitlab.com/dunj3/evtclib", rev = "b80279d4fb8a8a106ceb3290886c4a98b20aa42c" } reqwest = { version = "0.11.10", features = ["json", "blocking", "multipart"] } url = { version = "2.2.2", features = ["serde"] } serde = { version = "1.0.136", features = ["derive"] } chrono = "0.4.19" notify = "4.0.17" regex = "1.5.5" -zip = "0.6.1" toml = "0.5.8" itertools = "0.10.3" # Optional features for IM integration @@ -1,17 +1,19 @@ -EVTC Zipper And Uploader -======================== +EVTC (Zipper And) Uploader +========================== ![MIT license](https://img.shields.io/badge/license-MIT-green) ![Rustlang](https://img.shields.io/badge/language-Rust-green) -`ezau` is a tool which watches a directory for new EVTC log files and can zip them, upload them to [dps.report](https://dps.report) and post a message to a Discord channel. +`ezau` is a tool which watches a directory for new EVTC log files, uploads them to [dps.report](https://dps.report) and posts the link to a Discord or Matrix channel. Motivation ---------- -EVTC logs generated by arcpds can get quite big, so having them zipped is a huge space saving (80-90%). -Though while arcdps itself works fine on Linux/Wine, the built-in zip functionality relies on PowerShell and is not available outside of Windows 10. -As such, `ezau` can be used to automatically zip newly created log files in order to save disk space. -In addition to that, `ezau` can also optionally upload new logs to dps.report, and post a link to the uploaded report to a Discord channel. +The zipping functionality of arcdps used to be limited to Windows. +As a result, Linux users ended up with huge unzipped evtc log files. +`ezau` zipped those for you, and optionally also uploaded them to dps.report. + +However, with new versions of arcdps, the logs are always zipped and this functionality works even on Linux. +As such, `ezau` is simply a log uploader now (but `eu` is even worse of a name than `ezau`). `ezau` was inspired by [`evtc-watch`](https://gitlab.com/networkjanitor/evtc-watch/-/blob/master/evtc-watch) from Xyoz, with the difference that the file watching logic is included in the Rust part. This makes it cross-platform and fixes some bugs that were present in the `inotifywait` version. @@ -57,9 +59,24 @@ A full example configuration is provided here or alternatively as `ezau-sample.t ```toml # Whether logs should be uploaded. -# If this is false, ezau will only zip new logs and do no further processing. +# For legacy reasons, uploading can be disabled, but you probably want to set +# it to true (otherwise, ezau has no purpose). # (mandatory) -upload = false +upload = true + +# Whether logs should be sorted when posting to Discord/Matrix. +# If disabled, logs will be posted on a first-come-first-posted basis within +# their group. +# If enabled, categories will be sorted and bosses will be grouped. +# (optional) +sort_logs = false + +# Where to upload the logs. +# By default, ezau will attempt to upload to https://dps.report/uploadContent, +# but depending on service availability you might want to use a different domain, +# like b.dps.report instead. +# (optional) +dps_report_upload_url = "https://dps.report/uploadContent" # Whether logs with an unknown boss should be uploaded. # By default, ezau only uploads logs with bosses known to evtclib. @@ -77,12 +94,6 @@ minimum_duration = 0 # (optional) retries = 0 -# Zip freshly created (unzipped) logs. -# Deactivate this if you use arcDPS's built-in zip functionality to prevent any -# weird interactions. -# (optional) -zip = true - # Discord messaging section. # If this section is missing, ezau will not do Discord notifications for log uploads. # Mandatory keys in this section are only mandatory if the section is present, as the whole Discord functionality is optional. @@ -121,9 +132,10 @@ room_id = ["!room123456:homeserver.org"] Usage ----- -`ezau watch <dirname>`: Watch the given directory for new log files, zip them and optionally upload them. +`ezau watch <dirname>`: Watch the given directory for new log files and upload them, posting a link to Discord/Matrix. -`ezau upload <filename>`: Upload a single log and do a Discord notification. +`ezau upload <filename>`: Upload a single log. +Prints the URL to the console, and (if configured) posts it to Discord/Matrix. Note that this bypasses the `upload`/`upload_unknown` settings. See `ezau help` and `ezau help <subcommand>` for more information. diff --git a/ezau-sample.toml b/ezau-sample.toml index 512511c..99348cf 100644 --- a/ezau-sample.toml +++ b/ezau-sample.toml @@ -1,7 +1,15 @@ # Whether logs should be uploaded. -# If this is false, ezau will only zip new logs and do no further processing. +# For legacy reasons, uploading can be disabled, but you probably want to set +# it to true (otherwise, ezau has no purpose). # (mandatory) -upload = false +upload = true + +# Whether logs should be sorted when posting to Discord/Matrix. +# If disabled, logs will be posted on a first-come-first-posted basis within +# their group. +# If enabled, categories will be sorted and bosses will be grouped. +# (optional) +sort_logs = false # Where to upload the logs. # By default, ezau will attempt to upload to https://dps.report/uploadContent, @@ -26,12 +34,6 @@ minimum_duration = 0 # (optional) retries = 0 -# Zip freshly created (unzipped) logs. -# Deactivate this if you use arcDPS's built-in zip functionality to prevent any -# weird interactions. -# (optional) -zip = true - # Discord messaging section. # If this section is missing, ezau will not do Discord notifications for log uploads. # Mandatory keys in this section are only mandatory if the section is present, as the whole Discord functionality is optional. diff --git a/src/categories.rs b/src/categories.rs index de4f5e6..1713d87 100644 --- a/src/categories.rs +++ b/src/categories.rs @@ -1,48 +1,115 @@ use evtclib::{Encounter, GameMode, Log}; +// There is no canonical order of "categories", so we'll do +// * WvW +// * Raid wings ascending +// * Strike +// * Fractals descending +// * Training area +// * Unknown +// Subject to change, it's only used to sort the message (if enabled). +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Debug)] +pub enum Category { + WvW, + Wing1, + Wing2, + Wing3, + Wing4, + Wing5, + Wing6, + Wing7, + Strike, + SunquaPeak, + ShatteredObservatory, + Nightmare, + SpecialForcesTrainingArea, + #[default] + Unknown, +} + +macro_rules! category_strings { + ($(($item:path, $str:literal),)*) => { + impl std::fmt::Display for Category { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let repr = match *self { + $($item => $str),* + }; + write!(f, "{}", repr) + } + } + + impl std::str::FromStr for Category { + type Err = (); + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + $($str => Ok($item),)* + _ => Err(()), + } + } + } + } +} + +category_strings! { + (Category::WvW, "World versus World"), + (Category::Wing1, "Wing 1 (Spirit Vale)"), + (Category::Wing2, "Wing 2 (Salvation Pass)"), + (Category::Wing3, "Wing 3 (Stronghold of the Faithful)"), + (Category::Wing4, "Wing 4 (Bastion of the Penitent)"), + (Category::Wing5, "Wing 5 (Hall of Chains)"), + (Category::Wing6, "Wing 6 (Mythwright Gambit)"), + (Category::Wing7, "Wing 7 (Key of Ahdashim)"), + (Category::SunquaPeak, "100 CM (Sunqua Peak)"), + (Category::ShatteredObservatory, "99 CM (Shattered Observatory)"), + (Category::Nightmare, "98 CM (Nightmare)"), + (Category::Strike, "Strike Mission"), + (Category::SpecialForcesTrainingArea, "Special Forces Training Area"), + (Category::Unknown, "Unknown"), +} + pub trait Categorizable { - fn category(&self) -> &'static str; + fn category(&self) -> Category; } impl Categorizable for Log { - fn category(&self) -> &'static str { + fn category(&self) -> Category { if self.game_mode() == Some(GameMode::WvW) { - return "World versus World"; + return Category::WvW; } if let Some(encounter) = self.encounter() { match encounter { Encounter::ValeGuardian | Encounter::Gorseval | Encounter::Sabetha => { - "Wing 1 (Spirit Vale)" + Category::Wing1 } Encounter::Slothasor | Encounter::BanditTrio | Encounter::Matthias => { - "Wing 2 (Salvation Pass)" + Category::Wing2 } Encounter::KeepConstruct | Encounter::TwistedCastle | Encounter::Xera => { - "Wing 3 (Stronghold of the Faithful)" + Category::Wing3 } Encounter::Cairn | Encounter::MursaatOverseer | Encounter::Samarog - | Encounter::Deimos => "Wing 4 (Bastion of the Penitent)", + | Encounter::Deimos => Category::Wing4, Encounter::SoullessHorror | Encounter::RiverOfSouls | Encounter::BrokenKing | Encounter::EaterOfSouls | Encounter::StatueOfDarkness - | Encounter::VoiceInTheVoid => "Wing 5 (Hall of Chains)", + | Encounter::VoiceInTheVoid => Category::Wing5, Encounter::ConjuredAmalgamate | Encounter::TwinLargos | Encounter::Qadim => { - "Wing 6 (Mythwright Gambit)" + Category::Wing6 } Encounter::CardinalAdina | Encounter::CardinalSabir - | Encounter::QadimThePeerless => "Wing 7 (Key of Ahdashim)", + | Encounter::QadimThePeerless => Category::Wing7, - Encounter::Ai => "100 CM (Sunqua Peak)", + Encounter::Ai => Category::SunquaPeak, Encounter::Skorvald | Encounter::Artsariiv | Encounter::Arkk => { - "99 CM (Shattered Observatory)" + Category::ShatteredObservatory } - Encounter::MAMA | Encounter::Siax | Encounter::Ensolyss => "98 CM (Nightmare)", + Encounter::MAMA | Encounter::Siax | Encounter::Ensolyss => Category::Nightmare, Encounter::IcebroodConstruct | Encounter::SuperKodanBrothers @@ -52,16 +119,16 @@ impl Categorizable for Log { | Encounter::CaptainMaiTrin | Encounter::Ankka | Encounter::MinisterLi - | Encounter::Dragonvoid => "Strike Mission", + | Encounter::Dragonvoid => Category::Strike, Encounter::StandardKittyGolem | Encounter::MediumKittyGolem - | Encounter::LargeKittyGolem => "Special Forces Training Area", + | Encounter::LargeKittyGolem => Category::SpecialForcesTrainingArea, - _ => "Unknown", + _ => Category::Unknown, } } else { - "Unknown" + Category::Unknown } } } diff --git a/src/config.rs b/src/config.rs index ad2ee22..6c2a035 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,9 @@ use serde_with::{serde_as, OneOrMany}; pub struct Config { /// Flag indicating whether logs should be uploaded or not. pub upload: bool, + /// Whether to sort the logs when posting them. + #[serde(default)] + pub sort_logs: bool, /// Where to upload the logs. #[serde(default = "default_dps_report_upload_url")] pub dps_report_upload_url: String, @@ -23,6 +26,8 @@ pub struct Config { pub retries: u32, /// Whether ezau should zip non-zipped logs. #[serde(default = "default_zip")] + // We don't use this anymore, but we keep it so old configs can be parsed and we can properly + // inform the user. pub zip: bool, /// Option Discord information for bot postings. pub discord: Option<Discord>, @@ -63,7 +68,7 @@ pub fn load<P: AsRef<Path>>(path: P) -> Result<Config> { } fn default_zip() -> bool { - true + false } fn default_dps_report_upload_url() -> String { diff --git a/src/discord.rs b/src/discord.rs index 3ab849c..eeb4210 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -10,6 +10,7 @@ use tokio::runtime::Runtime; use log::info; +use super::config::Config; use super::categories::Categorizable; use super::logbag::{state_emoji, LogBag}; @@ -33,6 +34,7 @@ struct Handler { channel_id: u64, log: Log, link: String, + sort_logs: bool, } impl Handler { @@ -59,14 +61,14 @@ impl Handler { } if let Some(mut m) = messages.pop() { - let new_text = insert_link(&m.content, &self.log, &self.link); + let new_text = insert_link(&m.content, &self.log, &self.link, self.sort_logs); if new_text.len() <= MAX_MESSAGE_LENGTH { m.edit(ctx, |m| m.content(new_text)).await?; return Ok(()); } } - let new_text = insert_link("", &self.log, &self.link); + let new_text = insert_link("", &self.log, &self.link, false); ChannelId(self.channel_id).say(ctx, new_text).await?; Ok(()) @@ -88,7 +90,7 @@ impl EventHandler for Handler { } } -pub fn post_link(discord_token: &str, channel_id: u64, log: &Log, link: &str) -> Result<()> { +pub fn post_link(config: &Config, discord_token: &str, channel_id: u64, log: &Log, link: &str) -> Result<()> { let link = link.to_owned(); let log = log.clone(); let rt = Runtime::new()?; @@ -99,6 +101,7 @@ pub fn post_link(discord_token: &str, channel_id: u64, log: &Log, link: &str) -> channel_id, log, link, + sort_logs: config.sort_logs, }) .await?; { @@ -112,9 +115,12 @@ pub fn post_link(discord_token: &str, channel_id: u64, log: &Log, link: &str) -> }) } -fn insert_link(text: &str, log: &Log, link: &str) -> String { +fn insert_link(text: &str, log: &Log, link: &str, sort_logs: bool) -> String { let mut logbag = LogBag::parse_markdown(text).unwrap(); let line = format!("{} {}", state_emoji(log), link); logbag.insert(log.category(), line); + if sort_logs { + logbag.sort(); + } logbag.render_markdown() } diff --git a/src/logbag.rs b/src/logbag.rs index 362bee5..f173d6e 100644 --- a/src/logbag.rs +++ b/src/logbag.rs @@ -1,8 +1,10 @@ -use std::iter; -use std::str::FromStr; +use super::categories::Category; -use evtclib::{Log, Outcome}; +use std::{cmp::Ordering, sync::OnceLock, str::FromStr}; + +use evtclib::{Encounter, Log, Outcome}; use itertools::Itertools; +use regex::Regex; /// A [`LogBag`] is a struct that holds multiple logs in their categories. /// @@ -11,7 +13,7 @@ use itertools::Itertools; /// them and we can just handle arbitrary data. #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] pub struct LogBag { - data: Vec<(String, Vec<String>)>, + data: Vec<(Category, Vec<String>)>, } // Conditional compilation makes it hard to really use all the code, so we just allow dead code @@ -24,30 +26,54 @@ impl LogBag { } /// Return an iterator over all available categories. - pub fn categories(&self) -> impl Iterator<Item = &str> { - self.data.iter().map(|x| &x.0 as &str) + pub fn categories(&self) -> impl Iterator<Item = Category> + '_ { + self.data.iter().map(|x| x.0) } /// Return an iterator over (category, items). - pub fn iter(&self) -> impl Iterator<Item = (&str, impl Iterator<Item = &str>)> { + pub fn iter(&self) -> impl Iterator<Item = (Category, impl Iterator<Item = &str>)> { self.data .iter() - .map(|(cat, lines)| (cat as &str, lines.iter().map(|l| l as &str))) + .map(|(cat, lines)| (*cat, lines.iter().map(|l| l as &str))) } /// Insert an item into the given category. /// /// If the category does not exist yet, it will be appended at the bottom. - pub fn insert(&mut self, category: &str, line: String) { + pub fn insert(&mut self, category: Category, line: String) { for (cat, lines) in self.data.iter_mut() { - if cat == category { + if *cat == category { lines.push(line); return; } } // When we reach here, we don't have the category yet, so we gotta insert it. - self.data.push((String::from(category), vec![line])); + self.data.push((category, vec![line])); + } + + /// Sort the categories and logs. + pub fn sort(&mut self) { + // First sort the categories, the Ord impl of `Category` takes care here + self.data.sort(); + // Then sort the lines within the categories + for (_, ref mut lines) in self.data.iter_mut() { + lines.sort_by(|a, b| { + let (encounter_a, date_a, url_a) = info_from_line(&a); + let (encounter_b, date_b, url_b) = info_from_line(&b); + match (encounter_a, encounter_b) { + (None, None) => date_a.cmp(&date_b), + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(encounter_a), Some(encounter_b)) => encounter_a.partial_cmp(&encounter_b) + .or_else(|| order_strikes(encounter_a, encounter_b)) + // at this point, just give us a stable order + .unwrap_or((encounter_a as u16).cmp(&(encounter_b as u16))) + .then(date_a.cmp(&date_b)) + .then(url_a.cmp(&url_b)), + } + }); + } } /// Tries to parse the given text as a plain [`LogBag`]. @@ -68,7 +94,7 @@ impl LogBag { /// The output of this can be fed back into [`LogBag::parse_plain`] to round-trip. pub fn render_plain(&self) -> String { self.iter() - .map(|(category, lines)| iter::once(category).chain(lines).join("\n")) + .map(|(category, mut lines)| category.to_string() + "\n" + &lines.join("\n")) .join("\n\n") } @@ -104,18 +130,18 @@ impl FromStr for LogBag { let mut lines = chunk.split('\n'); let category = lines.next().unwrap(); ( - category.to_string(), + category.parse::<Category>().unwrap_or_default(), lines.map(ToString::to_string).collect::<Vec<_>>(), ) }) - .filter(|(cat, lines)| !cat.is_empty() && !lines.is_empty()) + .filter(|(_, lines)| !lines.is_empty()) .collect(); Ok(LogBag { data }) } } -impl From<Vec<(String, Vec<String>)>> for LogBag { - fn from(data: Vec<(String, Vec<String>)>) -> Self { +impl From<Vec<(Category, Vec<String>)>> for LogBag { + fn from(data: Vec<(Category, Vec<String>)>) -> Self { LogBag { data } } } @@ -130,6 +156,100 @@ pub fn state_emoji(log: &Log) -> &'static str { } } +// I don't want to pull in something like `chrono` just to represent a local datetime. Therefore, +// we simply use two integers: The date in the format YYYYMMDD (which automatically sorts +// correctly), and the time in HHMMSS (which does as well). +fn info_from_line(line: &str) -> (Option<Encounter>, Option<(u32, u32)>, &str) { + static RE: OnceLock<Regex> = OnceLock::new(); + let url_re = RE.get_or_init(|| { + Regex::new("http(s?)://[^ ]+(?P<date>\\d{8})-(?P<time>\\d{6})_(?P<slug>[a-z]+)").unwrap() + }); + let Some(caps) = url_re.captures(line) else { return (None, None, line); }; + let date = caps + .name("date") + .expect("date group must be present") + .as_str() + .parse() + .expect("matched only digits, parsing should succeeed"); + let time = caps + .name("time") + .expect("time group must be present") + .as_str() + .parse() + .expect("matched only digits, parsing should succeeed"); + + let encounter = match caps.name("slug").expect("slug group must be present").as_str() { + "vg" => Some(Encounter::ValeGuardian), + "gors" => Some(Encounter::Gorseval), + "sab" => Some(Encounter::Sabetha), + "sloth" => Some(Encounter::Slothasor), + "trio" => Some(Encounter::BanditTrio), + "matt" => Some(Encounter::Matthias), + "kc" => Some(Encounter::KeepConstruct), + "tc" => Some(Encounter::TwistedCastle), + "xera" => Some(Encounter::Xera), + "cairn" => Some(Encounter::Cairn), + "mo" => Some(Encounter::MursaatOverseer), + "sam" => Some(Encounter::Samarog), + "dei" => Some(Encounter::Deimos), + "sh" => Some(Encounter::SoullessHorror), + "rr" => Some(Encounter::RiverOfSouls), + "bk" => Some(Encounter::BrokenKing), + "se" => Some(Encounter::EaterOfSouls), + "eyes" => Some(Encounter::StatueOfDarkness), + "dhuum" => Some(Encounter::VoiceInTheVoid), + "ca" => Some(Encounter::ConjuredAmalgamate), + "twins" => Some(Encounter::TwinLargos), + "qadim" => Some(Encounter::Qadim), + "adina" => Some(Encounter::CardinalAdina), + "sabir" => Some(Encounter::CardinalSabir), + "qpeer" => Some(Encounter::QadimThePeerless), + // ambiguous, but it doesn't matter because we map them all to + // Category::SpecialForcesTrainingArea + "golem" => Some(Encounter::LargeKittyGolem), + "ai" => Some(Encounter::Ai), + "skor" => Some(Encounter::Skorvald), + "arriv" => Some(Encounter::Artsariiv), + "arkk" => Some(Encounter::Arkk), + "mama" => Some(Encounter::MAMA), + "siax" => Some(Encounter::Siax), + "enso" => Some(Encounter::Ensolyss), + "ice" => Some(Encounter::IcebroodConstruct), + "frae" => Some(Encounter::FraenirOfJormag), + "falln" => Some(Encounter::SuperKodanBrothers), + "whisp" => Some(Encounter::WhisperOfJormag), + "bone" => Some(Encounter::Boneskinner), + "trin" => Some(Encounter::CaptainMaiTrin), + "ankka" => Some(Encounter::Ankka), + "li" => Some(Encounter::MinisterLi), + "void" => Some(Encounter::Dragonvoid), + _ => None, + }; + + (encounter, Some((date, time)), caps.get(0).unwrap().as_str()) +} + +fn order_strikes(left: Encounter, right: Encounter) -> Option<Ordering> { + // Order according to the wiki at https://wiki.guildwars2.com/wiki/Strike_Mission + let strikes = &[ + Encounter::IcebroodConstruct, + Encounter::SuperKodanBrothers, + Encounter::FraenirOfJormag, + Encounter::Boneskinner, + Encounter::WhisperOfJormag, + Encounter::CaptainMaiTrin, + Encounter::Ankka, + Encounter::MinisterLi, + Encounter::Dragonvoid, + ]; + if let Some(pos_a) = strikes.iter().position(|x| *x == left) { + if let Some(pos_b) = strikes.iter().position(|x| *x == right) { + return Some(pos_a.cmp(&pos_b)); + } + } + None +} + #[cfg(test)] mod test { use super::*; @@ -137,16 +257,16 @@ mod test { #[test] fn insert() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); assert_eq!(logbag.categories().count(), 1); - logbag.insert("cat 1", "line 2".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!(logbag.categories().count(), 1); - logbag.insert("cat 2", "line 1".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); assert_eq!(logbag.categories().count(), 2); assert_eq!( logbag.categories().collect::<Vec<_>>(), - vec!["cat 1", "cat 2"] + vec![Category::WvW, Category::Strike] ); } @@ -158,13 +278,13 @@ mod test { #[test] fn parse_single() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( LogBag::parse_plain( "\ -cat 1 +World versus World line 1 line 2" ), @@ -175,19 +295,19 @@ line 2" #[test] fn parse_multi() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); - logbag.insert("cat 2", "line 1".to_string()); - logbag.insert("cat 2", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); assert_eq!( LogBag::parse_plain( "\ -cat 1 +World versus World line 1 line 2 -cat 2 +Strike Mission line 1 line 2" ), @@ -198,19 +318,19 @@ line 2" #[test] fn parse_markdown() { let mut logbag = LogBag::new(); - logbag.insert("cat 1", "line 1".to_string()); - logbag.insert("cat 1", "line 2".to_string()); - logbag.insert("cat 2", "line 1".to_string()); - logbag.insert("cat 2", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); assert_eq!( LogBag::parse_markdown( "\ -**cat 1** +**World versus World** line 1 line 2 -**cat 2** +**Strike Mission** line 1 line 2" ), @@ -221,13 +341,13 @@ line 2" #[test] fn render_plain_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::Unknown, "line 1".to_string()); + logbag.insert(Category::Unknown, "line 2".to_string()); assert_eq!( logbag.render_plain(), "\ -category +Unknown line 1 line 2" ); @@ -236,19 +356,19 @@ line 2" #[test] fn render_plain_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 1".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 2".to_string()); assert_eq!( logbag.render_plain(), "\ -category 1 +World versus World line 1 line 2 -category 2 +Special Forces Training Area enil 1 enil 2" ); @@ -257,13 +377,13 @@ enil 2" #[test] fn render_html_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( logbag.render_html(), "\ -<b>category</b><br> +<b>World versus World</b><br> line 1<br> line 2" ); @@ -272,19 +392,19 @@ line 2" #[test] fn render_html_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); + logbag.insert(Category::Unknown, "enil 1".to_string()); + logbag.insert(Category::Unknown, "enil 2".to_string()); assert_eq!( logbag.render_html(), "\ -<b>category 1</b><br> +<b>World versus World</b><br> line 1<br> line 2<br> <br> -<b>category 2</b><br> +<b>Unknown</b><br> enil 1<br> enil 2" ); @@ -293,13 +413,13 @@ enil 2" #[test] fn render_markdown_single() { let mut logbag = LogBag::new(); - logbag.insert("category", "line 1".to_string()); - logbag.insert("category", "line 2".to_string()); + logbag.insert(Category::WvW, "line 1".to_string()); + logbag.insert(Category::WvW, "line 2".to_string()); assert_eq!( logbag.render_markdown(), "\ -**category** +**World versus World** line 1 line 2" ); @@ -308,21 +428,55 @@ line 2" #[test] fn render_markdown_multi() { let mut logbag = LogBag::new(); - logbag.insert("category 1", "line 1".to_string()); - logbag.insert("category 1", "line 2".to_string()); - logbag.insert("category 2", "enil 1".to_string()); - logbag.insert("category 2", "enil 2".to_string()); + logbag.insert(Category::Strike, "line 1".to_string()); + logbag.insert(Category::Strike, "line 2".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 1".to_string()); + logbag.insert(Category::SpecialForcesTrainingArea, "enil 2".to_string()); assert_eq!( logbag.render_markdown(), "\ -**category 1** +**Strike Mission** line 1 line 2 -**category 2** +**Special Forces Training Area** enil 1 enil 2" ); } + + #[test] + fn test_info_from_line() { + assert_eq!( + info_from_line("✅ https://dps.report/O514-20240827-214630_dhuum"), + (Some(Encounter::VoiceInTheVoid), Some((20240827, 214630)), "https://dps.report/O514-20240827-214630_dhuum") + ); + } + + #[test] + fn test_sort_logbag() { + let mut logbag = LogBag::new(); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-120000_matt")); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-115500_matt")); + logbag.insert(Category::Wing2, String::from("https://dps.report/abcd-20240101-130000_sloth")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_gors")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_vg")); + logbag.insert(Category::Wing1, String::from("https://dps.report/abcd-20240101-120000_sab")); + + logbag.sort(); + + assert_eq!(logbag.data, vec![ + (Category::Wing1, vec![ + String::from("https://dps.report/abcd-20240101-120000_vg"), + String::from("https://dps.report/abcd-20240101-120000_gors"), + String::from("https://dps.report/abcd-20240101-120000_sab"), + ]), + (Category::Wing2, vec![ + String::from("https://dps.report/abcd-20240101-130000_sloth"), + String::from("https://dps.report/abcd-20240101-115500_matt"), + String::from("https://dps.report/abcd-20240101-120000_matt"), + ]), + ]); + } } diff --git a/src/main.rs b/src/main.rs index 3e84e2d..482ec53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,17 @@ use std::{ - fs::{self, File}, - io::{BufReader, BufWriter, Read, Write}, path::{Path, PathBuf}, sync::mpsc::channel, thread, time::Duration, }; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use evtclib::{Compression, Encounter, Log}; use log::{debug, error, info, warn}; use notify::{self, DebouncedEvent, RecursiveMode, Watcher}; use regex::Regex; use serde::Deserialize; -use zip::{CompressionMethod, ZipArchive, ZipWriter}; mod categories; use categories::Categorizable; @@ -48,7 +45,7 @@ enum SubCommand { /// Use the watch mode to automatically handle new logs. /// -/// This watches the given directory for new files and then zips and uploads them. +/// This watches the given directory for new files and then uploads them. #[derive(Parser, Debug, Clone, PartialEq, Eq, Hash)] struct Watch { /// The directory to watch. @@ -87,13 +84,13 @@ fn inner_main(opts: &Opts) -> Result<()> { let log = load_log(&u.path)?; if let Some(d) = &config.discord { - discord::post_link(&d.auth_token, d.channel_id, &log, &permalink) + discord::post_link(&config, &d.auth_token, d.channel_id, &log, &permalink) .context("Could not post link to Discord")?; } if let Some(m) = &config.matrix { for room_id in &m.room_id { - matrix::post_link(m.clone().into(), room_id, &log, &permalink).context( + matrix::post_link(&config, m.clone().into(), room_id, &log, &permalink).context( format!("Could not post link to Matrix (room_id: {})", &room_id), )?; } @@ -104,8 +101,7 @@ fn inner_main(opts: &Opts) -> Result<()> { } fn watch(watch: &Watch, config: &Config) -> Result<()> { - let raw_evtc_re = Regex::new(r"\d{8}-\d{6}(\.evtc)?$").unwrap(); - let zip_evtc_re = Regex::new(r"(\.zip|\.zevtc)$").unwrap(); + let zip_evtc_re = Regex::new(r"(\.evtc\.zip|\.zevtc)$").unwrap(); let (tx, rx) = channel(); let mut watcher = notify::watcher(tx, Duration::from_secs(WATCH_DELAY_SECONDS))?; @@ -118,54 +114,13 @@ fn watch(watch: &Watch, config: &Config) -> Result<()> { debug!("Event: {:?}", event); if let DebouncedEvent::Create(path) = event { let path_str = path.to_str().unwrap(); - // Check if we need to zip it first. - if config.zip && raw_evtc_re.is_match(path_str) { - info!("Zipping up {}", path_str); - zip_file(&path)?; - } else if zip_evtc_re.is_match(path_str) { + if zip_evtc_re.is_match(path_str) { handle_file(config, &path)?; } } } } -fn zip_file(filepath: &Path) -> Result<()> { - let evtc_content = fs::read(filepath)?; - - let filename = filepath - .file_name() - .ok_or_else(|| anyhow!("Path does not have a file name"))? - .to_str() - .ok_or_else(|| anyhow!("Filename is invalid utf-8"))?; - let outname = filepath.with_extension("zevtc"); - let outfile = BufWriter::new(File::create(&outname)?); - let mut zip = ZipWriter::new(outfile); - let options = - zip::write::FileOptions::default().compression_method(CompressionMethod::Deflated); - - zip.start_file(filename, options)?; - zip.write_all(&evtc_content)?; - zip.finish()?.flush()?; - - if !verify_zip(filepath, &outname)? { - warn!("ZIP content mismatch, keeping original file"); - return Ok(()); - } - - fs::remove_file(filepath)?; - Ok(()) -} - -fn verify_zip(original: &Path, zip_path: &Path) -> Result<bool> { - let expected_content = fs::read(original)?; - let mut archive = ZipArchive::new(BufReader::new(File::open(zip_path)?))?; - let mut inner = archive.by_index(0)?; - let mut actual_content = Vec::new(); - inner.read_to_end(&mut actual_content)?; - - Ok(expected_content == actual_content) -} - fn handle_file(config: &Config, filename: &Path) -> Result<()> { if !config.upload { return Ok(()); @@ -199,14 +154,14 @@ fn handle_file(config: &Config, filename: &Path) -> Result<()> { info!("Uploaded log, available at {}", permalink); if let Some(d) = &config.discord { - discord::post_link(&d.auth_token, d.channel_id, &log, &permalink) + discord::post_link(config, &d.auth_token, d.channel_id, &log, &permalink) .context("Could not post link to Discord")?; info!("Posted link to Discord"); } if let Some(m) = &config.matrix { for room_id in &m.room_id { - matrix::post_link(m.clone().into(), room_id, &log, &permalink).context(format!( + matrix::post_link(config, m.clone().into(), room_id, &log, &permalink).context(format!( "Could not post link to Matrix (room_id: {})", &room_id ))?; @@ -259,6 +214,17 @@ fn upload_log(file: &Path, url: &str) -> Result<String> { } fn sanity_check(config: &Config, opts: &Opts) -> Result<()> { + if config.zip { + warn!( + "You have zipping enabled, but zipping is no longer part of ezau. \ + Arcdps automatically zips logs now." + ); + } + if matches!(opts.subcmd, SubCommand::Watch(_)) + && !config.upload + { + bail!("Watching but not uploading. What am I even doing here?"); + } if matches!(opts.subcmd, SubCommand::Watch(_)) && config.discord.is_none() && config.matrix.is_none() @@ -289,18 +255,18 @@ fn sanity_check(config: &Config, opts: &Opts) -> Result<()> { // Dummy modules for when the features are disabled #[cfg(not(feature = "im-discord"))] mod discord { - use super::{Log, Result}; + use super::{config::Config, Log, Result}; use anyhow::bail; /// Stub, enable the `im-discord` feature to use this function. - pub fn post_link(_: &str, _: u64, _: &Log, _: &str) -> Result<()> { + pub fn post_link(_: &Config, _: &str, _: u64, _: &Log, _: &str) -> Result<()> { bail!("Discord feature is disabled in this build!") } } #[cfg(not(feature = "im-matrix"))] mod matrix { - use super::{Log, Result}; + use super::{config::Config, Log, Result}; use anyhow::bail; pub struct MatrixUser; impl From<super::config::Matrix> for MatrixUser { @@ -309,7 +275,7 @@ mod matrix { } } /// Stub, enable the `im-matrix` feature to use this function. - pub fn post_link(_: MatrixUser, _: &str, _: &Log, _: &str) -> Result<()> { + pub fn post_link(_: &Config, _: MatrixUser, _: &str, _: &Log, _: &str) -> Result<()> { bail!("Matrix feature is disabled in this build!") } } diff --git a/src/matrix.rs b/src/matrix.rs index 8be0e38..d75381d 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -95,7 +95,7 @@ const MESSAGE_CHUNK_COUNT: u16 = 3; /// /// This function blocks until all API calls have been made, that is until the message has reached /// the homeserver. -pub fn post_link(user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Result<()> { +pub fn post_link(config: &config::Config, user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Result<()> { let rt = Runtime::new()?; let room_id = RoomId::parse(room_id)?; @@ -112,7 +112,7 @@ pub fn post_link(user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Resu }; sync(&client, sync_token, &session_file).await?; - + info!("Matrix connected as {:?}", client.user_id()); let old_msg = find_message(&client, &client.user_id().unwrap(), &room_id).await?; @@ -125,7 +125,10 @@ pub fn post_link(user: MatrixUser, room_id: &str, log: &Log, link: &str) -> Resu Some((old_id, old_text)) => { debug!("Updating message {:?}", old_id); debug!("Updating message body {:?}", old_text); - let logbag = insert_log(&old_text, log, link); + let mut logbag = insert_log(&old_text, log, link); + if config.sort_logs { + logbag.sort(); + } let new_text = logbag.render_plain(); debug!("New message body {:?}", new_text); let new_html = logbag.render_html(); @@ -377,7 +380,7 @@ async fn find_message( /// Post a new message to the given Matrix channel. async fn post_new(client: &Client, room_id: OwnedRoomId, log: &Log, link: &str) -> Result<()> { let line = format!("{} {}", state_emoji(log), link); - let logbag: LogBag = vec![(log.category().to_string(), vec![line])].into(); + let logbag: LogBag = vec![(log.category(), vec![line])].into(); let body = logbag.render_plain(); let html = logbag.render_html(); |