humanize dates

This commit is contained in:
Michael Zhang 2021-03-09 05:21:23 -06:00
parent c6116531b0
commit 424706d9a0
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
9 changed files with 156 additions and 42 deletions

10
Cargo.lock generated
View file

@ -297,6 +297,15 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "chrono-humanize"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8164ae3089baf04ff71f32aeb70213283dcd236dce8bc976d00b17a458f5f71c"
dependencies = [
"chrono",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "2.33.3" version = "2.33.3"
@ -1396,6 +1405,7 @@ dependencies = [
"async-trait", "async-trait",
"cfg-if 1.0.0", "cfg-if 1.0.0",
"chrono", "chrono",
"chrono-humanize",
"crossterm 0.19.0", "crossterm 0.19.0",
"fern", "fern",
"format-bytes", "format-bytes",

View file

@ -35,6 +35,7 @@ toml = "0.5.8"
tui = { version = "0.14.0", default-features = false, features = ["crossterm"] } tui = { version = "0.14.0", default-features = false, features = ["crossterm"] }
webpki-roots = "0.21.0" webpki-roots = "0.21.0"
xdg = "2.2.0" xdg = "2.2.0"
chrono-humanize = "0.1.2"
[dependencies.panorama-imap] [dependencies.panorama-imap]
path = "imap" path = "imap"

View file

@ -191,13 +191,9 @@ where
{ {
let codec = ImapCodec::default(); let codec = ImapCodec::default();
let mut framed = FramedRead::new(conn, codec); let mut framed = FramedRead::new(conn, codec);
// let mut reader = BufReader::new(conn);
let mut greeting_tx = Some(greeting_tx); let mut greeting_tx = Some(greeting_tx);
let mut curr_cmd: Option<Command2> = None; let mut curr_cmd: Option<Command2> = None;
let mut exit_rx = exit_rx.map_err(|_| ()).shared(); let mut exit_rx = exit_rx.map_err(|_| ()).shared();
// let mut exit_fut = Some(exit_rx.fuse());
// let mut fut1 = None;
let cache = String::new();
loop { loop {
// let mut next_line = String::new(); // let mut next_line = String::new();
@ -220,12 +216,14 @@ where
break; break;
} }
// read a command from the command list
cmd = cmd_fut => { cmd = cmd_fut => {
if curr_cmd.is_none() { if curr_cmd.is_none() {
curr_cmd = cmd; curr_cmd = cmd;
} }
} }
// got a response from the server connection
resp = read_fut => { resp = read_fut => {
let resp = match resp { let resp = match resp {
Some(Ok(v)) => v, Some(Ok(v)) => v,
@ -234,7 +232,7 @@ where
// if this is the very first response, then it's a greeting // if this is the very first response, then it's a greeting
if let Some(greeting_tx) = greeting_tx.take() { if let Some(greeting_tx) = greeting_tx.take() {
greeting_tx.send(()); greeting_tx.send(()).unwrap();
} }
if let Response::Done(_) = resp { if let Response::Done(_) = resp {

View file

@ -39,6 +39,10 @@ mod inner;
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use futures::{
future::{self, FutureExt},
stream::{Stream, StreamExt},
};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_rustls::{ use tokio_rustls::{
client::TlsStream, rustls::ClientConfig as RustlsConfig, webpki::DNSNameRef, TlsConnector, client::TlsStream, rustls::ClientConfig as RustlsConfig, webpki::DNSNameRef, TlsConnector,
@ -148,6 +152,14 @@ impl ClientAuthenticated {
} }
} }
/// Checks if the server that the client is talking to has support for the given capability.
pub async fn has_capability(&mut self, cap: impl AsRef<str>) -> Result<bool> {
match self {
ClientAuthenticated::Encrypted(e) => e.has_capability(cap).await,
ClientAuthenticated::Unencrypted(e) => e.has_capability(cap).await,
}
}
/// Runs the LIST command /// Runs the LIST command
pub async fn list(&mut self) -> Result<Vec<String>> { pub async fn list(&mut self) -> Result<Vec<String>> {
let cmd = Command::List { let cmd = Command::List {
@ -201,24 +213,22 @@ impl ClientAuthenticated {
} }
/// Runs the UID FETCH command /// Runs the UID FETCH command
pub async fn uid_fetch(&mut self, uids: &[u32]) -> Result<Vec<Envelope>> { pub async fn uid_fetch(
&mut self,
uids: &[u32],
) -> Result<impl Stream<Item = (u32, Vec<AttributeValue>)>> {
let cmd = Command::UidFetch { let cmd = Command::UidFetch {
uids: uids.to_vec(), uids: uids.to_vec(),
items: FetchItems::All, items: FetchItems::All,
}; };
debug!("uid fetch: {}", cmd); debug!("uid fetch: {}", cmd);
let stream = self.execute(cmd).await?; let stream = self.execute(cmd).await?;
let (done, data) = stream.wait().await?; // let (done, data) = stream.wait().await?;
Ok(data Ok(stream.filter_map(|resp| match resp {
.into_iter() Response::Fetch(n, attrs) => future::ready(Some((n, attrs))).boxed(),
.filter_map(|resp| match resp { Response::Done(_) => future::ready(None).boxed(),
Response::Fetch(n, attrs) => attrs.into_iter().find_map(|attr| match attr { _ => future::pending().boxed(),
AttributeValue::Envelope(envelope) => Some(envelope), }))
_ => None,
}),
_ => None,
})
.collect())
} }
/// Runs the IDLE command /// Runs the IDLE command

View file

@ -13,11 +13,13 @@ impl Decoder for ImapCodec {
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> { fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
let s = std::str::from_utf8(src)?; let s = std::str::from_utf8(src)?;
trace!("codec parsing {:?}", s);
match parse_streamed_response(s) { match parse_streamed_response(s) {
Ok((resp, len)) => { Ok((resp, len)) => {
src.advance(len); src.advance(len);
return Ok(Some(resp)); return Ok(Some(resp));
} }
// TODO: distinguish between incomplete data and a parse error
Err(e) => {} Err(e) => {}
}; };

View file

@ -41,8 +41,6 @@ char8 = @{ '\x01'..'\xff' }
continue_req = { "+" ~ sp ~ (resp_text | base64) ~ crlf } continue_req = { "+" ~ sp ~ (resp_text | base64) ~ crlf }
date_day_fixed = { (sp ~ digit) | digit{2} } date_day_fixed = { (sp ~ digit) | digit{2} }
date_month = { "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" } date_month = { "Jan" | "Feb" | "Mar" | "Apr" | "May" | "Jun" | "Jul" | "Aug" | "Sep" | "Oct" | "Nov" | "Dec" }
// TODO: date_time is a real date time
// date_time = ${ string }
date_time = { dquote_ ~ date_day_fixed ~ "-" ~ date_month ~ "-" ~ date_year ~ sp ~ time ~ sp ~ zone ~ dquote_ } date_time = { dquote_ ~ date_day_fixed ~ "-" ~ date_month ~ "-" ~ date_year ~ sp ~ time ~ sp ~ zone ~ dquote_ }
date_year = @{ digit{4} } date_year = @{ digit{4} }
digit_nz = @{ '\x31'..'\x39' } digit_nz = @{ '\x31'..'\x39' }

View file

@ -11,7 +11,7 @@ use panorama_imap::{
ClientBuilder, ClientConfig, ClientBuilder, ClientConfig,
}, },
command::Command as ImapCommand, command::Command as ImapCommand,
response::Envelope, response::{AttributeValue, Envelope},
}; };
use tokio::{ use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender}, sync::mpsc::{UnboundedReceiver, UnboundedSender},
@ -23,6 +23,7 @@ use crate::config::{Config, ConfigWatcher, ImapAuth, MailAccountConfig, TlsMetho
/// Command sent to the mail thread by something else (i.e. UI) /// Command sent to the mail thread by something else (i.e. UI)
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive]
pub enum MailCommand { pub enum MailCommand {
/// Refresh the list /// Refresh the list
Refresh, Refresh,
@ -33,12 +34,19 @@ pub enum MailCommand {
/// Possible events returned from the server that should be sent to the UI /// Possible events returned from the server that should be sent to the UI
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive]
pub enum MailEvent { pub enum MailEvent {
/// Got the list of folders /// Got the list of folders
FolderList(Vec<String>), FolderList(Vec<String>),
/// Got the current list of messages /// Got the current list of messages
MessageList(Vec<Envelope>), MessageList(Vec<Envelope>),
/// A list of the UIDs in the current mail view
MessageUids(Vec<u32>),
/// Update the given UID with the given attribute list
UpdateUid(u32, Vec<AttributeValue>),
} }
/// Main entrypoint for the mail listener. /// Main entrypoint for the mail listener.
@ -145,17 +153,33 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender<MailEven
let message_uids = authed.uid_search().await?; let message_uids = authed.uid_search().await?;
let message_uids = message_uids.into_iter().take(20).collect::<Vec<_>>(); let message_uids = message_uids.into_iter().take(20).collect::<Vec<_>>();
let message_list = authed.uid_fetch(&message_uids).await?; let _ = mail2ui_tx.send(MailEvent::MessageUids(message_uids.clone()));
let _ = mail2ui_tx.send(MailEvent::MessageList(message_list));
let mut idle_stream = authed.idle().await?; // TODO: make this happen concurrently with the main loop?
let mut message_list = authed.uid_fetch(&message_uids).await.unwrap();
while let Some((uid, attrs)) = message_list.next().await {
let evt = MailEvent::UpdateUid(uid, attrs);
debug!("sent {:?}", evt);
mail2ui_tx.send(evt);
}
loop { // check if IDLE is supported
let evt = idle_stream.next().await; let supports_idle = authed.has_capability("IDLE").await?;
debug!("got an event: {:?}", evt); if supports_idle {
let mut idle_stream = authed.idle().await?;
if false { loop {
break; let evt = idle_stream.next().await;
debug!("got an event: {:?}", evt);
if false {
break;
}
}
} else {
loop {
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
debug!("heartbeat");
} }
} }

View file

@ -1,3 +1,7 @@
use std::collections::HashMap;
use chrono::{DateTime, Duration, Local, Datelike};
use chrono_humanize::HumanTime;
use panorama_imap::response::Envelope; use panorama_imap::response::Envelope;
use tui::{ use tui::{
buffer::Buffer, buffer::Buffer,
@ -12,10 +16,32 @@ use super::FrameType;
#[derive(Default)] #[derive(Default)]
pub struct MailTabState { pub struct MailTabState {
pub folders: Vec<String>, pub folders: Vec<String>,
pub message_uids: Vec<u32>,
pub message_map: HashMap<u32, EmailMetadata>,
pub messages: Vec<Envelope>, pub messages: Vec<Envelope>,
pub message_list: TableState, pub message_list: TableState,
} }
#[derive(Debug)]
pub struct EmailMetadata {
pub date: DateTime<Local>,
pub from: String,
pub subject: String,
}
fn humanize_timestamp(date: DateTime<Local>) -> String {
let now = Local::now();
let diff = now - date;
if diff < Duration::days(1) {
HumanTime::from(date).to_string()
}else if date.year() == now.year() {
date.format("%b %e %T").to_string()
} else {
date.to_rfc2822()
}
}
impl MailTabState { impl MailTabState {
pub fn render(&mut self, f: &mut FrameType, area: Rect) { pub fn render(&mut self, f: &mut FrameType, area: Rect) {
let chunks = Layout::default() let chunks = Layout::default()
@ -39,16 +65,34 @@ impl MailTabState {
// message list table // message list table
let rows = self let rows = self
.messages .message_uids
.iter() .iter()
.map(|s| Row::new(vec![s.subject.clone().unwrap_or_default()])) .map(|id| {
let meta = self.message_map.get(id);
Row::new(vec![
"".to_owned(),
id.to_string(),
meta.map(|m| humanize_timestamp(m.date)).unwrap_or_default(),
"".to_owned(),
meta.map(|m| m.subject.clone()).unwrap_or_default(),
])
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let table = Table::new(rows) let table = Table::new(rows)
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
.widths(&[Constraint::Max(5000)]) .widths(&[
.header(Row::new(vec!["Subject"]).style(Style::default().add_modifier(Modifier::BOLD))) Constraint::Length(1),
.highlight_style(Style::default().fg(Color::Black).bg(Color::LightBlue)); Constraint::Max(3),
Constraint::Min(25),
Constraint::Min(20),
Constraint::Max(5000),
])
.header(
Row::new(vec!["", "UID", "Date", "From", "Subject"])
.style(Style::default().add_modifier(Modifier::BOLD)),
)
.highlight_style(Style::default().bg(Color::DarkGray));
f.render_widget(dirlist, chunks[0]); f.render_widget(dirlist, chunks[0]);
f.render_stateful_widget(table, chunks[1], &mut self.message_list); f.render_stateful_widget(table, chunks[1], &mut self.message_list);

View file

@ -7,12 +7,14 @@ use std::mem;
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use chrono::{Local, TimeZone};
use crossterm::{ use crossterm::{
cursor, cursor,
event::{self, Event, KeyCode, KeyEvent}, event::{self, Event, KeyCode, KeyEvent},
style, terminal, style, terminal,
}; };
use futures::{future::FutureExt, select, stream::StreamExt}; use futures::{future::FutureExt, select, stream::StreamExt};
use panorama_imap::response::{AttributeValue, Envelope};
use tokio::{sync::mpsc, time}; use tokio::{sync::mpsc, time};
use tui::{ use tui::{
backend::CrosstermBackend, backend::CrosstermBackend,
@ -25,7 +27,7 @@ use tui::{
use crate::mail::MailEvent; use crate::mail::MailEvent;
use self::mail_tab::MailTabState; use self::mail_tab::{EmailMetadata, MailTabState};
pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>; pub(crate) type FrameType<'a, 'b> = Frame<'a, CrosstermBackend<&'b mut Stdout>>;
@ -73,16 +75,8 @@ pub async fn run_ui(
// table.update(&event); // table.update(&event);
if let Event::Key(KeyEvent { code, .. }) = event { if let Event::Key(KeyEvent { code, .. }) = event {
let selected = mail_tab.message_list.selected();
let len = mail_tab.messages.len();
let seln = selected
.map(|x| if x < len - 1 { x + 1 } else { x })
.unwrap_or(0);
let selp = selected.map(|x| if x > 0 { x - 1 } else { 0 }).unwrap_or(0);
match code { match code {
KeyCode::Char('q') => break, KeyCode::Char('q') => break,
KeyCode::Char('j') => mail_tab.message_list.select(Some(seln)),
KeyCode::Char('k') => mail_tab.message_list.select(Some(selp)),
_ => {} _ => {}
} }
} }
@ -105,6 +99,39 @@ pub async fn run_ui(
MailEvent::MessageList(new_messages) => { MailEvent::MessageList(new_messages) => {
mail_tab.messages = new_messages; mail_tab.messages = new_messages;
} }
MailEvent::MessageUids(new_uids) => {
mail_tab.message_uids = new_uids;
}
MailEvent::UpdateUid(_, attrs) => {
let mut uid = None;
let mut date = None;
let mut from = String::new();
let mut subject = String::new();
for attr in attrs {
match attr {
AttributeValue::Uid(new_uid) => uid = Some(new_uid),
AttributeValue::InternalDate(new_date) => {
date = Some(new_date.with_timezone(&Local));
}
AttributeValue::Envelope(Envelope {
subject: new_subject, from: new_from, ..
}) => {
// if let Some(new_from) = new_from {
// from = new_from;
// }
if let Some(new_subject) = new_subject {
subject = new_subject;
}
}
_ => {}
}
}
if let (Some(uid), Some(date)) = (uid, date) {
let meta = EmailMetadata {date ,from, subject };
mail_tab.message_map.insert(uid, meta);
}
}
_ => {}
} }
} }