From f264b7230d4fdcc47cd14f10b873877966d4f5b3 Mon Sep 17 00:00:00 2001 From: FliegendeWurst <2012gdwu+github@posteo.de> Date: Thu, 1 Apr 2021 12:03:46 +0200 Subject: [PATCH] list: thread tree listing --- Cargo.lock | 27 ++++++++++++++++ Cargo.toml | 1 + src/bin/list.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 54 ++++++++++++++++++++++++++++--- 4 files changed, 160 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1826c6a..d499b3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fixedbitset" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ab347416e802de484e4d03c7316c48f1ecb56574dfd4a46a80f173ce1de04d" + [[package]] name = "fs2" version = "0.4.3" @@ -281,12 +287,23 @@ dependencies = [ "mailproc", "mime2ext", "moins", + "petgraph", "rusqlite", "rustls-connector", "rustyline", "subprocess", ] +[[package]] +name = "indexmap" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" +dependencies = [ + "autocfg", + "hashbrown", +] + [[package]] name = "itertools" version = "0.10.0" @@ -493,6 +510,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" +[[package]] +name = "petgraph" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "467d164a6de56270bd7c4d070df81d07beace25012d5103ced4e9ff08d6afdb7" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pkg-config" version = "0.3.19" diff --git a/Cargo.toml b/Cargo.toml index ad93709..538ee46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ anyhow = "1.0.40" mailproc = { git = "https://github.com/FliegendeWurst/mailproc.git", branch = "master" } subprocess = "0.2.6" mime2ext = "0.1.2" +petgraph = "0.5.1" diff --git a/src/bin/list.rs b/src/bin/list.rs index 40606da..bd41c25 100644 --- a/src/bin/list.rs +++ b/src/bin/list.rs @@ -1,9 +1,10 @@ -use std::{array::IntoIter, collections::HashSet, env, fs}; +use std::{array::IntoIter, cell::RefCell, cmp, collections::{HashMap, HashSet}, env, fs}; use ascii_table::{Align, AsciiTable, Column}; use inboxid::*; use itertools::Itertools; use mailparse::ParsedMail; +use petgraph::{EdgeDirection, dot::Dot, graph::{DiGraph, NodeIndex}, visit::{Dfs, IntoNodeReferences, Walker}}; use rustyline::{Editor, error::ReadlineError}; fn main() -> Result<()> { @@ -43,6 +44,87 @@ fn show_listing(mailbox: &str) -> Result<()> { rows.push(IntoIter::new([(mails.len() - i).to_string(), flags_display, mail.from.clone(), mail.subject.clone(), mail.date_iso.clone()])); } + let mut mails_by_id = HashMap::new(); + let mut threads: HashMap<_, Vec<_>> = HashMap::new(); + for mail in &mails { + let mid = mail.get_header("Message-ID"); + threads.entry(mid.clone()).or_default().push(mail); + if mails_by_id.insert(mid, mail).is_some() { + println!("error: missing/duplicate Message-ID"); + return Ok(()); + } + for value in mail.get_header_values("References") { + for mid in value.split(' ').map(ToOwned::to_owned) { + threads.entry(mid).or_default().push(mail); + } + } + for value in mail.get_header_values("In-Reply-To") { + for mid in value.split(' ').map(ToOwned::to_owned) { + threads.entry(mid).or_default().push(mail); + } + } + } + let mut threads = threads.into_iter().collect_vec(); + threads.sort_unstable_by_key(|(_, mails)| mails.len()); + threads.reverse(); + let mut graph = DiGraph::new(); + let mut nodes = HashMap::new(); + let mut nodes_inv = HashMap::new(); + for mail in &mails { + let node = graph.add_node(mail); + nodes.insert(mail, node); + nodes_inv.insert(node, mail); + } + for mail in &mails { + for value in mail.get_header_values("In-Reply-To") { + for mid in value.split(' ').map(ToOwned::to_owned) { + if let Some(other_mail) = mails_by_id.get(&mid) { + graph.add_edge(nodes[other_mail], nodes[mail], ()); + } else { + let pseudomail = Box::leak(Box::new(EasyMail::new_pseudo(mid.clone()))); + let node = graph.add_node(pseudomail); + nodes.insert(pseudomail, node); + nodes_inv.insert(node, pseudomail); + graph.add_edge(node, nodes[mail], ()); + mails_by_id.insert(mid, pseudomail); + } + } + } + } + let mut roots = graph.node_references().filter(|x| graph.neighbors_directed(x.0, EdgeDirection::Incoming).count() == 0).collect_vec(); + roots.sort_unstable_by_key(|x| x.1.date); + let mails_printed = RefCell::new(HashSet::new()); + + struct PrintThread<'a> { + f: &'a dyn Fn(&PrintThread, NodeIndex, usize) + } + let print_thread = |this: &PrintThread, node, depth| { + let mail = nodes_inv[&node]; + if mails_printed.borrow().contains(mail) && depth == 0 { + return; + } + println!("{}{}", " ".repeat(depth), mail.subject); + mails_printed.borrow_mut().insert(mail); + let mut replies = graph.neighbors_directed(node, EdgeDirection::Outgoing).collect_vec(); + replies.sort_unstable_by_key(|&idx| { + let mut maximum = &nodes_inv[&idx].date; + let mut dfs = Dfs::new(&graph, idx); + while let Some(idx) = dfs.next(&graph) { + let other = nodes_inv[&idx]; + maximum = cmp::max(maximum, &other.date); + } + maximum + }); + for r in replies { + (this.f)(this, r, depth + 1); + } + }; + let print_thread = PrintThread { f: &print_thread }; + + for root in roots { + (print_thread.f)(&print_thread, root.0, 0); + } + let mut ascii_table = AsciiTable::default(); ascii_table.draw_lines = false; ascii_table.max_width = usize::MAX; diff --git a/src/lib.rs b/src/lib.rs index c06fd6b..b00eb32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, convert::{TryFrom, TryInto}, env, fs, io, net::TcpStream, ops::Deref}; +use std::{borrow::Cow, convert::{TryFrom, TryInto}, env, fmt::Debug, fs, hash::Hash, io, net::TcpStream, ops::Deref}; use anyhow::Context; use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; @@ -52,7 +52,7 @@ pub fn gen_id(uid_validity: u32, uid: u32) -> String { format!("{}_{}", uid_validity, uid) } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct MaildirID { uid_validity: u32, pub uid: u32, @@ -92,7 +92,7 @@ impl MaildirID { } pub struct EasyMail<'a> { - pub mail: ParsedMail<'a>, + mail: Option>, pub id: MaildirID, pub flags: String, pub from: String, @@ -101,11 +101,55 @@ pub struct EasyMail<'a> { pub date_iso: String, } +impl EasyMail<'_> { + pub fn new_pseudo(subject: String) -> Self { + Self { + mail: None, + id: MaildirID::new(0, 0), + flags: "S".to_owned(), + from: String::new(), + subject, + date: Local.from_utc_datetime(&NaiveDateTime::from_timestamp(0, 0)), + date_iso: "????-??-??".to_owned() + } + } + + pub fn get_header(&self, header: &str) -> String { + self.get_headers().get_all_values(header).join(" ") + } + + pub fn get_header_values(&self, header: &str) -> Vec { + self.get_headers().get_all_values(header) + } +} + +impl Debug for EasyMail<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Mail[ID={},Subject={:?}]", self.id.uid, self.subject) + } +} + +impl PartialEq for EasyMail<'_> { + fn eq(&self, other: &Self) -> bool { + self.id == other.id && self.from == other.from && self.subject == other.subject + } +} + +impl Eq for EasyMail<'_> {} + +impl Hash for EasyMail<'_> { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.from.hash(state); + self.subject.hash(state); + } +} + impl<'a> Deref for EasyMail<'a> { type Target = ParsedMail<'a>; fn deref(&self) -> &Self::Target { - &self.mail + &self.mail.as_ref().unwrap() } } @@ -169,7 +213,7 @@ impl MaildirExtension for Maildir { Local.from_utc_datetime(&NaiveDateTime::from_timestamp(x, 0)) )?; mails.push(EasyMail { - mail, + mail: Some(mail), flags, id, from,