humanize dates
This commit is contained in:
parent
c6116531b0
commit
424706d9a0
9 changed files with 156 additions and 42 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
@ -297,6 +297,15 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-humanize"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8164ae3089baf04ff71f32aeb70213283dcd236dce8bc976d00b17a458f5f71c"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.33.3"
|
||||
|
@ -1396,6 +1405,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"cfg-if 1.0.0",
|
||||
"chrono",
|
||||
"chrono-humanize",
|
||||
"crossterm 0.19.0",
|
||||
"fern",
|
||||
"format-bytes",
|
||||
|
|
|
@ -35,6 +35,7 @@ toml = "0.5.8"
|
|||
tui = { version = "0.14.0", default-features = false, features = ["crossterm"] }
|
||||
webpki-roots = "0.21.0"
|
||||
xdg = "2.2.0"
|
||||
chrono-humanize = "0.1.2"
|
||||
|
||||
[dependencies.panorama-imap]
|
||||
path = "imap"
|
||||
|
|
|
@ -191,13 +191,9 @@ where
|
|||
{
|
||||
let codec = ImapCodec::default();
|
||||
let mut framed = FramedRead::new(conn, codec);
|
||||
// let mut reader = BufReader::new(conn);
|
||||
let mut greeting_tx = Some(greeting_tx);
|
||||
let mut curr_cmd: Option<Command2> = None;
|
||||
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 {
|
||||
// let mut next_line = String::new();
|
||||
|
@ -220,12 +216,14 @@ where
|
|||
break;
|
||||
}
|
||||
|
||||
// read a command from the command list
|
||||
cmd = cmd_fut => {
|
||||
if curr_cmd.is_none() {
|
||||
curr_cmd = cmd;
|
||||
}
|
||||
}
|
||||
|
||||
// got a response from the server connection
|
||||
resp = read_fut => {
|
||||
let resp = match resp {
|
||||
Some(Ok(v)) => v,
|
||||
|
@ -234,7 +232,7 @@ where
|
|||
|
||||
// if this is the very first response, then it's a greeting
|
||||
if let Some(greeting_tx) = greeting_tx.take() {
|
||||
greeting_tx.send(());
|
||||
greeting_tx.send(()).unwrap();
|
||||
}
|
||||
|
||||
if let Response::Done(_) = resp {
|
||||
|
|
|
@ -39,6 +39,10 @@ mod inner;
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::{
|
||||
future::{self, FutureExt},
|
||||
stream::{Stream, StreamExt},
|
||||
};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::{
|
||||
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
|
||||
pub async fn list(&mut self) -> Result<Vec<String>> {
|
||||
let cmd = Command::List {
|
||||
|
@ -201,24 +213,22 @@ impl ClientAuthenticated {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
uids: uids.to_vec(),
|
||||
items: FetchItems::All,
|
||||
};
|
||||
debug!("uid fetch: {}", cmd);
|
||||
let stream = self.execute(cmd).await?;
|
||||
let (done, data) = stream.wait().await?;
|
||||
Ok(data
|
||||
.into_iter()
|
||||
.filter_map(|resp| match resp {
|
||||
Response::Fetch(n, attrs) => attrs.into_iter().find_map(|attr| match attr {
|
||||
AttributeValue::Envelope(envelope) => Some(envelope),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
// let (done, data) = stream.wait().await?;
|
||||
Ok(stream.filter_map(|resp| match resp {
|
||||
Response::Fetch(n, attrs) => future::ready(Some((n, attrs))).boxed(),
|
||||
Response::Done(_) => future::ready(None).boxed(),
|
||||
_ => future::pending().boxed(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Runs the IDLE command
|
||||
|
|
|
@ -13,11 +13,13 @@ impl Decoder for ImapCodec {
|
|||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
let s = std::str::from_utf8(src)?;
|
||||
trace!("codec parsing {:?}", s);
|
||||
match parse_streamed_response(s) {
|
||||
Ok((resp, len)) => {
|
||||
src.advance(len);
|
||||
return Ok(Some(resp));
|
||||
}
|
||||
// TODO: distinguish between incomplete data and a parse error
|
||||
Err(e) => {}
|
||||
};
|
||||
|
||||
|
|
|
@ -41,8 +41,6 @@ char8 = @{ '\x01'..'\xff' }
|
|||
continue_req = { "+" ~ sp ~ (resp_text | base64) ~ crlf }
|
||||
date_day_fixed = { (sp ~ digit) | digit{2} }
|
||||
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_year = @{ digit{4} }
|
||||
digit_nz = @{ '\x31'..'\x39' }
|
||||
|
|
|
@ -11,7 +11,7 @@ use panorama_imap::{
|
|||
ClientBuilder, ClientConfig,
|
||||
},
|
||||
command::Command as ImapCommand,
|
||||
response::Envelope,
|
||||
response::{AttributeValue, Envelope},
|
||||
};
|
||||
use tokio::{
|
||||
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)
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum MailCommand {
|
||||
/// Refresh the list
|
||||
Refresh,
|
||||
|
@ -33,12 +34,19 @@ pub enum MailCommand {
|
|||
|
||||
/// Possible events returned from the server that should be sent to the UI
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum MailEvent {
|
||||
/// Got the list of folders
|
||||
FolderList(Vec<String>),
|
||||
|
||||
/// Got the current list of messages
|
||||
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.
|
||||
|
@ -145,9 +153,19 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender<MailEven
|
|||
|
||||
let message_uids = authed.uid_search().await?;
|
||||
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::MessageList(message_list));
|
||||
let _ = mail2ui_tx.send(MailEvent::MessageUids(message_uids.clone()));
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// check if IDLE is supported
|
||||
let supports_idle = authed.has_capability("IDLE").await?;
|
||||
if supports_idle {
|
||||
let mut idle_stream = authed.idle().await?;
|
||||
|
||||
loop {
|
||||
|
@ -158,6 +176,12 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender<MailEven
|
|||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
|
||||
debug!("heartbeat");
|
||||
}
|
||||
}
|
||||
|
||||
if false {
|
||||
break;
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Local, Datelike};
|
||||
use chrono_humanize::HumanTime;
|
||||
use panorama_imap::response::Envelope;
|
||||
use tui::{
|
||||
buffer::Buffer,
|
||||
|
@ -12,10 +16,32 @@ use super::FrameType;
|
|||
#[derive(Default)]
|
||||
pub struct MailTabState {
|
||||
pub folders: Vec<String>,
|
||||
pub message_uids: Vec<u32>,
|
||||
pub message_map: HashMap<u32, EmailMetadata>,
|
||||
pub messages: Vec<Envelope>,
|
||||
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 {
|
||||
pub fn render(&mut self, f: &mut FrameType, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
|
@ -39,16 +65,34 @@ impl MailTabState {
|
|||
|
||||
// message list table
|
||||
let rows = self
|
||||
.messages
|
||||
.message_uids
|
||||
.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<_>>();
|
||||
|
||||
let table = Table::new(rows)
|
||||
.style(Style::default().fg(Color::White))
|
||||
.widths(&[Constraint::Max(5000)])
|
||||
.header(Row::new(vec!["Subject"]).style(Style::default().add_modifier(Modifier::BOLD)))
|
||||
.highlight_style(Style::default().fg(Color::Black).bg(Color::LightBlue));
|
||||
.widths(&[
|
||||
Constraint::Length(1),
|
||||
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_stateful_widget(table, chunks[1], &mut self.message_list);
|
||||
|
|
|
@ -7,12 +7,14 @@ use std::mem;
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Local, TimeZone};
|
||||
use crossterm::{
|
||||
cursor,
|
||||
event::{self, Event, KeyCode, KeyEvent},
|
||||
style, terminal,
|
||||
};
|
||||
use futures::{future::FutureExt, select, stream::StreamExt};
|
||||
use panorama_imap::response::{AttributeValue, Envelope};
|
||||
use tokio::{sync::mpsc, time};
|
||||
use tui::{
|
||||
backend::CrosstermBackend,
|
||||
|
@ -25,7 +27,7 @@ use tui::{
|
|||
|
||||
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>>;
|
||||
|
||||
|
@ -73,16 +75,8 @@ pub async fn run_ui(
|
|||
// table.update(&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 {
|
||||
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) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue