use super::categories::Category; 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. /// /// This is similar to hash map mapping a category to a list of logs, but the [`LogBag`] saves its /// insertion order. The logs are saved as a line, that way we don't have to re-parse or re-upload /// them and we can just handle arbitrary data. #[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] pub struct LogBag { data: Vec<(Category, Vec)>, } // Conditional compilation makes it hard to really use all the code, so we just allow dead code // here locally. #[allow(dead_code)] impl LogBag { /// Construct a new, empty [`LogBag`]. pub fn new() -> Self { LogBag { data: Vec::new() } } /// Return an iterator over all available categories. pub fn categories(&self) -> impl Iterator + '_ { self.data.iter().map(|x| x.0) } /// Return an iterator over (category, items). pub fn iter(&self) -> impl Iterator)> { self.data .iter() .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: Category, line: String) { for (cat, lines) in self.data.iter_mut() { 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((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`]. pub fn parse_plain(input: &str) -> Option { input.parse().ok() } pub fn parse_markdown(input: &str) -> Option { let plain = input .split('\n') .map(|line| line.trim_matches('*')) .join("\n"); LogBag::parse_plain(&plain) } /// Renders the contents of this [`LogBag`] as plain text. /// /// 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, mut lines)| category.to_string() + "\n" + &lines.join("\n")) .join("\n\n") } /// Renders the contents of this [`LogBag`] as HTML. /// /// Useful for posting to Matrix chats. pub fn render_html(&self) -> String { self.iter() .map(|(category, mut lines)| { format!("{}
\n{}", category, lines.join("
\n")) }) .join("
\n
\n") } /// Renders the contents of this [`LogBag`] as Markdown. /// /// Useful for posting to Discord chats. pub fn render_markdown(&self) -> String { self.iter() .map(|(category, mut lines)| format!("**{}**\n{}", category, lines.join("\n"))) .join("\n\n") } } impl FromStr for LogBag { type Err = (); fn from_str(s: &str) -> Result { let data = s .trim() .split("\n\n") .map(|chunk| { let mut lines = chunk.split('\n'); let category = lines.next().unwrap(); ( category.parse::().unwrap_or_default(), lines.map(ToString::to_string).collect::>(), ) }) .filter(|(_, lines)| !lines.is_empty()) .collect(); Ok(LogBag { data }) } } impl From)>> for LogBag { fn from(data: Vec<(Category, Vec)>) -> Self { LogBag { data } } } /// A helper function to return the right emoji for a given log. pub fn state_emoji(log: &Log) -> &'static str { let outcome = log.analyzer().and_then(|a| a.outcome()); match outcome { Some(Outcome::Success) => "✅", Some(Outcome::Failure) => "❌", None => "❓", } } // 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, Option<(u32, u32)>, &str) { static RE: OnceLock = OnceLock::new(); let url_re = RE.get_or_init(|| { Regex::new("http(s?)://[^ ]+(?P\\d{8})-(?P