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",
|
"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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) => {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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' }
|
||||||
|
|
|
@ -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,9 +153,19 @@ 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));
|
|
||||||
|
|
||||||
|
// 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?;
|
let mut idle_stream = authed.idle().await?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
@ -158,6 +176,12 @@ async fn imap_main(acct: MailAccountConfig, mail2ui_tx: UnboundedSender<MailEven
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(20)).await;
|
||||||
|
debug!("heartbeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if false {
|
if false {
|
||||||
break;
|
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 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);
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue