diff --git a/Cargo.lock b/Cargo.lock index 6c86e9b..fc00013 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1396,7 +1396,6 @@ dependencies = [ [[package]] name = "email-lib" version = "0.24.1" -source = "git+https://git.sr.ht/~soywod/pimalaya#033ba2a2e193769e1272c9493aa1d6c975346eb5" dependencies = [ "advisory-lock", "async-ctrlc", @@ -1425,6 +1424,7 @@ dependencies = [ "once_cell", "ouroboros", "paste", + "petgraph", "pgp-lib", "process-lib", "rayon", @@ -2074,6 +2074,7 @@ dependencies = [ "mml-lib", "oauth-lib", "once_cell", + "petgraph", "process-lib", "secret-lib", "serde", @@ -2267,7 +2268,6 @@ dependencies = [ [[package]] name = "imap-client" version = "0.1.0" -source = "git+https://github.com/soywod/imap-flow.git?branch=session#599cedbf9facd7d04eaacef2ec6710ee7f7f9eff" dependencies = [ "imap-flow", "once_cell", @@ -2297,7 +2297,6 @@ dependencies = [ [[package]] name = "imap-flow" version = "0.1.0" -source = "git+https://github.com/soywod/imap-flow.git?branch=session#599cedbf9facd7d04eaacef2ec6710ee7f7f9eff" dependencies = [ "bounded-static", "bytes", @@ -4577,7 +4576,6 @@ dependencies = [ [[package]] name = "tag-generator" version = "0.1.0" -source = "git+https://github.com/soywod/imap-flow.git?branch=session#599cedbf9facd7d04eaacef2ec6710ee7f7f9eff" dependencies = [ "imap-types", "rand", @@ -4592,7 +4590,6 @@ checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" [[package]] name = "tasks" version = "0.1.0" -source = "git+https://github.com/soywod/imap-flow.git?branch=session#599cedbf9facd7d04eaacef2ec6710ee7f7f9eff" dependencies = [ "imap-flow", "imap-types", diff --git a/Cargo.toml b/Cargo.toml index 0d2c31a..95a07de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ md5 = "0.7" mml-lib = { version = "=1.0.12", default-features = false, features = ["derive"] } oauth-lib = "=0.1.1" once_cell = "1.16" +petgraph = "0.6" process-lib = { version = "=0.4.2", features = ["derive"] } secret-lib = { version = "=0.4.4", features = ["derive"] } serde = { version = "1", features = ["derive"] } @@ -86,8 +87,11 @@ uuid = { version = "0.8", features = ["v4"] } [patch.crates-io] # WIP: transition from `imap` to `imap-codec` -email-lib = { git = "https://git.sr.ht/~soywod/pimalaya" } -imap-client = { git = "https://github.com/soywod/imap-flow.git", branch = "session" } -tasks = { git = "https://github.com/soywod/imap-flow.git", branch = "session" } +email-lib = { path = "/home/soywod/sourcehut/pimalaya/email" } +imap-client = { path = "/home/soywod/code/imap-flow/client" } +tasks = { path = "/home/soywod/code/imap-flow/tasks" } +# email-lib = { git = "https://git.sr.ht/~soywod/pimalaya" } +# imap-client = { git = "https://github.com/soywod/imap-flow.git", branch = "session" } +# tasks = { git = "https://github.com/soywod/imap-flow.git", branch = "session" } imap-codec = { git = "https://github.com/duesee/imap-codec.git" } imap-types = { git = "https://github.com/duesee/imap-codec.git" } diff --git a/src/account/config.rs b/src/account/config.rs index ded830f..23abca7 100644 --- a/src/account/config.rs +++ b/src/account/config.rs @@ -142,6 +142,14 @@ impl TomlAccountConfig { .or(self.backend.as_ref()) } + pub fn thread_envelopes_kind(&self) -> Option<&BackendKind> { + self.envelope + .as_ref() + .and_then(|envelope| envelope.thread.as_ref()) + .and_then(|thread| thread.backend.as_ref()) + .or(self.backend.as_ref()) + } + pub fn watch_envelopes_kind(&self) -> Option<&BackendKind> { self.envelope .as_ref() diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d5dab7c..f45f4c2 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod wizard; use async_trait::async_trait; use color_eyre::Result; +use petgraph::graphmap::DiGraphMap; use std::{fmt::Display, ops::Deref, sync::Arc}; #[cfg(feature = "imap")] @@ -23,6 +24,7 @@ use email::{ envelope::{ get::GetEnvelope, list::{ListEnvelopes, ListEnvelopesOptions}, + thread::ThreadEnvelopes, watch::WatchEnvelopes, Id, SingleId, }, @@ -337,6 +339,23 @@ impl email::backend::context::BackendContextBuilder for BackendContextBuilder { } } + fn thread_envelopes(&self) -> Option> { + match self.toml_account_config.thread_envelopes_kind() { + #[cfg(feature = "imap")] + Some(BackendKind::Imap) => self.thread_envelopes_with_some(&self.imap), + #[cfg(all(feature = "imap", feature = "account-sync"))] + Some(BackendKind::ImapCache) => { + let f = self.imap_cache.as_ref()?.thread_envelopes()?; + Some(Arc::new(move |ctx| f(ctx.imap_cache.as_ref()?))) + } + #[cfg(feature = "maildir")] + Some(BackendKind::Maildir) => self.thread_envelopes_with_some(&self.maildir), + #[cfg(feature = "notmuch")] + Some(BackendKind::Notmuch) => self.thread_envelopes_with_some(&self.notmuch), + _ => None, + } + } + fn watch_envelopes(&self) -> Option> { match self.toml_account_config.watch_envelopes_kind() { #[cfg(feature = "imap")] @@ -687,6 +706,19 @@ impl Backend { Ok(envelopes) } + pub async fn thread_envelopes( + &self, + folder: &str, + opts: ListEnvelopesOptions, + ) -> Result> { + let backend_kind = self.toml_account_config.thread_envelopes_kind(); + let id_mapper = self.build_id_mapper(folder, backend_kind)?; + let envelopes = self.backend.thread_envelopes(folder, opts).await?; + // let envelopes = + // Envelopes::from_backend(&self.backend.account_config, &id_mapper, envelopes)?; + Ok(envelopes) + } + pub async fn add_flags(&self, folder: &str, ids: &[usize], flags: &Flags) -> Result<()> { let backend_kind = self.toml_account_config.add_flags_kind(); let id_mapper = self.build_id_mapper(folder, backend_kind)?; diff --git a/src/config/mod.rs b/src/config/mod.rs index 24ec9c4..fe0474a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -244,6 +244,7 @@ impl TomlConfig { }), envelope: config.envelope.map(|c| EnvelopeConfig { list: c.list.map(|c| c.remote), + thread: c.thread.map(|c| c.remote), watch: c.watch.map(|c| c.remote), #[cfg(feature = "account-sync")] sync: c.sync, diff --git a/src/email/envelope/command/mod.rs b/src/email/envelope/command/mod.rs index 3de79a6..975effc 100644 --- a/src/email/envelope/command/mod.rs +++ b/src/email/envelope/command/mod.rs @@ -1,12 +1,15 @@ pub mod list; +pub mod thread; pub mod watch; -use color_eyre::Result; use clap::Subcommand; +use color_eyre::Result; use crate::{config::TomlConfig, printer::Printer}; -use self::{list::ListEnvelopesCommand, watch::WatchEnvelopesCommand}; +use self::{ + list::ListEnvelopesCommand, thread::ThreadEnvelopesCommand, watch::WatchEnvelopesCommand, +}; /// Manage envelopes. /// @@ -19,6 +22,9 @@ pub enum EnvelopeSubcommand { #[command(alias = "lst")] List(ListEnvelopesCommand), + #[command()] + Thread(ThreadEnvelopesCommand), + #[command()] Watch(WatchEnvelopesCommand), } @@ -28,6 +34,7 @@ impl EnvelopeSubcommand { pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { match self { Self::List(cmd) => cmd.execute(printer, config).await, + Self::Thread(cmd) => cmd.execute(printer, config).await, Self::Watch(cmd) => cmd.execute(printer, config).await, } } diff --git a/src/email/envelope/command/thread.rs b/src/email/envelope/command/thread.rs new file mode 100644 index 0000000..22f7ed9 --- /dev/null +++ b/src/email/envelope/command/thread.rs @@ -0,0 +1,330 @@ +use ariadne::{Color, Label, Report, ReportKind, Source}; +use clap::Parser; +use color_eyre::Result; +use email::{ + backend::feature::BackendFeatureSource, + email::search_query, + envelope::list::ListEnvelopesOptions, + search_query::{filter::SearchEmailsFilterQuery, SearchEmailsQuery}, +}; +use petgraph::{graphmap::DiGraphMap, visit::IntoNodeIdentifiers, Direction}; +use std::{ + collections::{HashMap, HashSet}, + io::Write, + process::exit, +}; +use tracing::info; + +#[cfg(feature = "account-sync")] +use crate::cache::arg::disable::CacheDisableFlag; +use crate::{ + account::arg::name::AccountNameFlag, backend::Backend, config::TomlConfig, + folder::arg::name::FolderNameOptionalFlag, printer::Printer, +}; + +/// Thread all envelopes. +/// +/// This command allows you to thread all envelopes included in the +/// given folder. +#[derive(Debug, Parser)] +pub struct ThreadEnvelopesCommand { + #[command(flatten)] + pub folder: FolderNameOptionalFlag, + + /// The page number. + /// + /// The page number starts from 1 (which is the default). Giving a + /// page number to big will result in a out of bound error. + #[arg(long, short, value_name = "NUMBER", default_value = "1")] + pub page: usize, + + /// The page size. + /// + /// Determine the amount of envelopes a page should contain. + #[arg(long, short = 's', value_name = "NUMBER")] + pub page_size: Option, + + #[cfg(feature = "account-sync")] + #[command(flatten)] + pub cache: CacheDisableFlag, + + #[command(flatten)] + pub account: AccountNameFlag, + + /// The maximum width the table should not exceed. + /// + /// This argument will force the table not to exceed the given + /// width in pixels. Columns may shrink with ellipsis in order to + /// fit the width. + #[arg(long = "max-width", short = 'w')] + #[arg(name = "table_max_width", value_name = "PIXELS")] + pub table_max_width: Option, + + /// The thread envelopes filter and sort query. + /// + /// The query can be a filter query, a sort query or both + /// together. + /// + /// A filter query is composed of operators and conditions. There + /// is 3 operators and 8 conditions: + /// + /// • not → filter envelopes that do not match the + /// condition + /// + /// • and → filter envelopes that match + /// both conditions + /// + /// • or → filter envelopes that match + /// one of the conditions + /// + /// ◦ date → filter envelopes that match the given + /// date + /// + /// ◦ before → filter envelopes with date strictly + /// before the given one + /// + /// ◦ after → filter envelopes with date stricly + /// after the given one + /// + /// ◦ from → filter envelopes with senders matching the + /// given pattern + /// + /// ◦ to → filter envelopes with recipients matching + /// the given pattern + /// + /// ◦ subject → filter envelopes with subject matching + /// the given pattern + /// + /// ◦ body → filter envelopes with text bodies matching + /// the given pattern + /// + /// ◦ flag → filter envelopes matching the given flag + /// + /// A sort query starts by "order by", and is composed of kinds + /// and orders. There is 4 kinds and 2 orders: + /// + /// • date [order] → sort envelopes by date + /// + /// • from [order] → sort envelopes by sender + /// + /// • to [order] → sort envelopes by recipient + /// + /// • subject [order] → sort envelopes by subject + /// + /// ◦ asc → sort envelopes by the given kind in ascending + /// order + /// + /// ◦ desc → sort envelopes by the given kind in + /// descending order + /// + /// Examples: + /// + /// subject foo and body bar → filter envelopes containing "foo" + /// in their subject and "bar" in their text bodies + /// + /// order by date desc subject → sort envelopes by descending date + /// (most recent first), then by ascending subject + /// + /// subject foo and body bar order by date desc subject → + /// combination of the 2 previous examples + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + pub query: Option>, +} + +impl Default for ThreadEnvelopesCommand { + fn default() -> Self { + Self { + folder: Default::default(), + page: 1, + page_size: Default::default(), + #[cfg(feature = "account-sync")] + cache: Default::default(), + account: Default::default(), + query: Default::default(), + table_max_width: Default::default(), + } + } +} + +impl ThreadEnvelopesCommand { + pub async fn execute(self, printer: &mut impl Printer, config: &TomlConfig) -> Result<()> { + info!("executing thread envelopes command"); + + let (toml_account_config, account_config) = config.clone().into_account_configs( + self.account.name.as_deref(), + #[cfg(feature = "account-sync")] + self.cache.disable, + )?; + + let folder = &self.folder.name; + let page = 1.max(self.page) - 1; + let page_size = self + .page_size + .unwrap_or_else(|| account_config.get_envelope_thread_page_size()); + + let thread_envelopes_kind = toml_account_config.thread_envelopes_kind(); + + let backend = Backend::new( + toml_account_config.clone(), + account_config.clone(), + thread_envelopes_kind, + |builder| builder.set_thread_envelopes(BackendFeatureSource::Context), + ) + .await?; + + // let query = self + // .query + // .map(|query| query.join(" ").parse::()); + // let query = match query { + // None => None, + // Some(Ok(query)) => Some(query), + // Some(Err(main_err)) => { + // let source = "query"; + // let search_query::error::Error::ParseError(errs, query) = &main_err; + // for err in errs { + // Report::build(ReportKind::Error, source, err.span().start) + // .with_message(main_err.to_string()) + // .with_label( + // Label::new((source, err.span().into_range())) + // .with_message(err.reason().to_string()) + // .with_color(Color::Red), + // ) + // .finish() + // .eprint((source, Source::from(&query))) + // .unwrap(); + // } + + // exit(0) + // } + // }; + + let opts = ListEnvelopesOptions { + page, + page_size, + query: None, + }; + + let graph = backend.thread_envelopes(folder, opts).await?; + + println!("graph: {graph:#?}"); + + let mut stdout = std::io::stdout(); + write_tree(&mut stdout, &graph, 0, String::new(), 0)?; + stdout.flush()?; + + // printer.print_table(envelopes, self.table_max_width)?; + + Ok(()) + } +} + +pub fn write_tree( + w: &mut impl std::io::Write, + graph: &DiGraphMap, + parent: u32, + pad: String, + weight: u32, +) -> std::io::Result<()> { + let edges = graph + .all_edges() + .filter_map(|(a, b, w)| { + if a == parent && *w == weight { + Some(b) + } else { + None + } + }) + .collect::>(); + + writeln!(w, "{parent}")?; + + let edges_count = edges.len(); + for (i, b) in edges.into_iter().enumerate() { + let is_last = edges_count == i + 1; + let (x, y) = if is_last { + (' ', '└') + } else { + ('│', '├') + }; + write!(w, "{pad}{y}─ ")?; + let pad = format!("{pad}{x} "); + write_tree(w, graph, b, pad, weight + 1)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use petgraph::graphmap::DiGraphMap; + + use super::write_tree; + + #[test] + fn tree_1() { + let mut buf = Vec::new(); + let mut graph = DiGraphMap::new(); + graph.add_edge(0, 1, 0); + graph.add_edge(0, 2, 0); + graph.add_edge(0, 3, 0); + + write_tree(&mut buf, &graph, 0, String::new(), 0).unwrap(); + let buf = String::from_utf8_lossy(&buf); + + let expected = " +0 +├─ 1 +├─ 2 +└─ 3 +"; + assert_eq!(expected.trim_start(), buf) + } + + #[test] + fn tree_2() { + let mut buf = Vec::new(); + let mut graph = DiGraphMap::new(); + graph.add_edge(0, 1, 0); + graph.add_edge(1, 2, 1); + graph.add_edge(1, 3, 1); + + write_tree(&mut buf, &graph, 0, String::new(), 0).unwrap(); + let buf = String::from_utf8_lossy(&buf); + + let expected = " +0 +└─ 1 + ├─ 2 + └─ 3 +"; + assert_eq!(expected.trim_start(), buf) + } + + #[test] + fn tree_3() { + let mut buf = Vec::new(); + let mut graph = DiGraphMap::new(); + graph.add_edge(0, 1, 0); + graph.add_edge(1, 2, 1); + graph.add_edge(2, 22, 2); + graph.add_edge(1, 3, 1); + graph.add_edge(0, 4, 0); + graph.add_edge(4, 5, 1); + graph.add_edge(5, 6, 2); + + write_tree(&mut buf, &graph, 0, String::new(), 0).unwrap(); + let buf = String::from_utf8_lossy(&buf); + + let expected = " +0 +├─ 1 +│ ├─ 2 +│ │ └─ 22 +│ └─ 3 +└─ 4 + └─ 5 + └─ 6 +"; + assert_eq!(expected.trim_start(), buf) + } +} diff --git a/src/email/envelope/config.rs b/src/email/envelope/config.rs index 6575f40..4b5e53b 100644 --- a/src/email/envelope/config.rs +++ b/src/email/envelope/config.rs @@ -8,6 +8,7 @@ use crate::backend::BackendKind; #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] pub struct EnvelopeConfig { pub list: Option, + pub thread: Option, pub watch: Option, pub get: Option, #[cfg(feature = "account-sync")] @@ -54,6 +55,26 @@ impl ListEnvelopesConfig { } } +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct ThreadEnvelopesConfig { + pub backend: Option, + + #[serde(flatten)] + pub remote: email::envelope::thread::config::EnvelopeThreadConfig, +} + +impl ThreadEnvelopesConfig { + pub fn get_used_backends(&self) -> HashSet<&BackendKind> { + let mut kinds = HashSet::default(); + + if let Some(kind) = &self.backend { + kinds.insert(kind); + } + + kinds + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] pub struct WatchEnvelopesConfig { pub backend: Option,