aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2024-08-28 22:08:03 +0200
committerDaniel Schadt <kingdread@gmx.de>2024-08-28 22:08:03 +0200
commit0cb54858732ec6db0ef1de3f937ea41bec5ec7ae (patch)
tree674589dabecc7515595b8f29164f6ccc24e0e097 /src
parente30da60a5122791bf6101a31eb5a01969644daef (diff)
downloadezau-0cb54858732ec6db0ef1de3f937ea41bec5ec7ae.tar.gz
ezau-0cb54858732ec6db0ef1de3f937ea41bec5ec7ae.tar.bz2
ezau-0cb54858732ec6db0ef1de3f937ea41bec5ec7ae.zip
implement sorting of logs
(not available as a setting yet)
Diffstat (limited to 'src')
-rw-r--r--src/categories.rs101
-rw-r--r--src/logbag.rs278
-rw-r--r--src/matrix.rs2
3 files changed, 301 insertions, 80 deletions
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/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/matrix.rs b/src/matrix.rs
index 8be0e38..7835ddd 100644
--- a/src/matrix.rs
+++ b/src/matrix.rs
@@ -377,7 +377,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();