mail/view: abstract envelope view filters away
Modularize an envelope view by introducing a stack of "view filters". Example uses: - html email can have a view on top of it that is plain text conversion - selecting and viewing text/* attachments is just appending a new filter at the stack Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>pull/312/head
parent
62b8465f2c
commit
23c15261e7
|
@ -1261,6 +1261,9 @@ See
|
|||
.Xr meli 1 FILES
|
||||
for the mailcap file locations.
|
||||
.Pq Em m \" default value
|
||||
.It Ic open_html
|
||||
Opens html attachment in the default browser.
|
||||
.Pq Em v \" default value
|
||||
.It Ic reply
|
||||
Reply to envelope.
|
||||
.Pq Em R \" default value
|
||||
|
|
|
@ -245,6 +245,7 @@ shortcut_key_values! { "envelope-view",
|
|||
go_to_url |> "Go to url of given index." |> Key::Char('g'),
|
||||
open_attachment |> "Opens selected attachment with xdg-open." |> Key::Char('a'),
|
||||
open_mailcap |> "Opens selected attachment according to its mailcap entry." |> Key::Char('m'),
|
||||
open_html |> "Opens html attachment in the default browser." |> Key::Char('v'),
|
||||
reply |> "Reply to envelope." |> Key::Char('R'),
|
||||
reply_to_author |> "Reply to author." |> Key::Ctrl('r'),
|
||||
reply_to_all |> "Reply to all/Reply to list/Follow up." |> Key::Ctrl('g'),
|
||||
|
|
|
@ -39,8 +39,6 @@ use crate::{accounts::JobRequest, jobs::JobId};
|
|||
mod utils;
|
||||
pub use utils::*;
|
||||
|
||||
mod html;
|
||||
pub use html::*;
|
||||
mod thread;
|
||||
pub use thread::*;
|
||||
mod types;
|
||||
|
@ -51,6 +49,9 @@ use state::*;
|
|||
pub mod envelope;
|
||||
pub use envelope::EnvelopeView;
|
||||
|
||||
pub mod filters;
|
||||
pub use filters::*;
|
||||
|
||||
/// Contains an Envelope view, with sticky headers, a pager for the body, and
|
||||
/// subviews for more menus
|
||||
#[derive(Debug)]
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use linkify::LinkFinder;
|
||||
use melib::utils::xdg::query_default_app;
|
||||
|
||||
use super::*;
|
||||
|
@ -36,15 +35,17 @@ use crate::ThreadEvent;
|
|||
#[derive(Debug)]
|
||||
pub struct EnvelopeView {
|
||||
pub pager: Pager,
|
||||
pub subview: Option<Box<dyn Component>>,
|
||||
pub subview: Option<Box<EnvelopeView>>,
|
||||
pub dirty: bool,
|
||||
pub initialised: bool,
|
||||
pub force_draw_headers: bool,
|
||||
pub mode: ViewMode,
|
||||
pub options: ViewOptions,
|
||||
pub mail: Mail,
|
||||
pub body: Box<Attachment>,
|
||||
pub display: Vec<AttachmentDisplay>,
|
||||
pub body_text: String,
|
||||
pub html_filter: Option<Result<ViewFilter>>,
|
||||
pub filters: Vec<ViewFilter>,
|
||||
pub links: Vec<Link>,
|
||||
pub attachment_tree: String,
|
||||
pub attachment_paths: Vec<Vec<usize>>,
|
||||
|
@ -80,7 +81,7 @@ impl EnvelopeView {
|
|||
pub fn new(
|
||||
mail: Mail,
|
||||
pager: Option<Pager>,
|
||||
subview: Option<Box<dyn Component>>,
|
||||
subview: Option<Box<Self>>,
|
||||
view_settings: Option<ViewSettings>,
|
||||
main_loop_handler: MainLoopHandler,
|
||||
) -> Self {
|
||||
|
@ -92,7 +93,7 @@ impl EnvelopeView {
|
|||
dirty: true,
|
||||
initialised: false,
|
||||
force_draw_headers: false,
|
||||
mode: ViewMode::Normal,
|
||||
options: ViewOptions::default(),
|
||||
force_charset: ForceCharset::None,
|
||||
attachment_tree: String::new(),
|
||||
attachment_paths: vec![],
|
||||
|
@ -100,6 +101,8 @@ impl EnvelopeView {
|
|||
display: vec![],
|
||||
links: vec![],
|
||||
body_text: String::new(),
|
||||
html_filter: None,
|
||||
filters: vec![],
|
||||
view_settings,
|
||||
headers_no: 5,
|
||||
headers_cursor: 0,
|
||||
|
@ -719,7 +722,7 @@ impl Component for EnvelopeView {
|
|||
let hdr_area_theme = crate::conf::value(context, "mail.view.headers_area");
|
||||
|
||||
let y: usize = {
|
||||
if self.mode.is_source() {
|
||||
if self.options.contains(ViewOptions::SOURCE) {
|
||||
grid.clear_area(area, self.view_settings.theme_default);
|
||||
context.dirty_areas.push_back(area);
|
||||
0
|
||||
|
@ -987,223 +990,114 @@ impl Component for EnvelopeView {
|
|||
}
|
||||
};
|
||||
|
||||
if self.filters.is_empty() || self.body_text.is_empty() {
|
||||
let body = self.mail.body();
|
||||
if body.is_html() {
|
||||
let attachment = if let Some(sub) = match body.content_type {
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} => parts.iter().find(|p| p.is_html()),
|
||||
_ => None,
|
||||
} {
|
||||
sub
|
||||
} else {
|
||||
&body
|
||||
};
|
||||
if let Ok(filter) = ViewFilter::new_html(attachment, context) {
|
||||
self.filters.push(filter);
|
||||
}
|
||||
} else if self.view_settings.auto_choose_multipart_alternative
|
||||
&& match body.content_type {
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} => parts
|
||||
.iter()
|
||||
.all(|p| p.is_html() || (p.is_text() && p.body().trim().is_empty())),
|
||||
_ => false,
|
||||
}
|
||||
{
|
||||
if let Ok(filter) = ViewFilter::new_html(
|
||||
body.content_type
|
||||
.parts()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a.is_html())
|
||||
.unwrap_or(&body),
|
||||
context,
|
||||
) {
|
||||
self.filters.push(filter);
|
||||
}
|
||||
} else if let Ok(filter) = ViewFilter::new_attachment(&body, context) {
|
||||
self.filters.push(filter);
|
||||
}
|
||||
self.body_text = String::from_utf8_lossy(
|
||||
&body.decode(Option::<Charset>::from(&self.force_charset).into()),
|
||||
)
|
||||
.to_string();
|
||||
}
|
||||
if !self.initialised {
|
||||
self.initialised = true;
|
||||
let body = self.mail.body();
|
||||
match self.mode {
|
||||
ViewMode::Attachment(aidx) if body.attachments()[aidx].is_html() => {
|
||||
let attachment = &body.attachments()[aidx];
|
||||
self.subview = Some(Box::new(HtmlView::new(attachment, context)));
|
||||
}
|
||||
ViewMode::Attachment(aidx) => {
|
||||
let mut text = format!(
|
||||
"Viewing attachment. Press {} to return \n",
|
||||
self.shortcuts(context)
|
||||
.get(Shortcuts::ENVELOPE_VIEW)
|
||||
.and_then(|m| m.get("return_to_normal_view"))
|
||||
.unwrap_or(
|
||||
&context
|
||||
.settings
|
||||
.shortcuts
|
||||
.envelope_view
|
||||
.return_to_normal_view
|
||||
)
|
||||
);
|
||||
let attachment = &body.attachments()[aidx];
|
||||
text.push_str(&attachment.text());
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
Some(0),
|
||||
None,
|
||||
self.view_settings.theme_default,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
}
|
||||
self.subview = None;
|
||||
}
|
||||
ViewMode::Normal if body.is_html() => {
|
||||
self.subview = Some(Box::new(HtmlView::new(&body, context)));
|
||||
self.mode = ViewMode::Subview;
|
||||
}
|
||||
ViewMode::Normal
|
||||
if self.view_settings.auto_choose_multipart_alternative
|
||||
&& match body.content_type {
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} => parts.iter().all(|p| {
|
||||
p.is_html() || (p.is_text() && p.body().trim().is_empty())
|
||||
}),
|
||||
_ => false,
|
||||
} =>
|
||||
{
|
||||
let subview = Box::new(HtmlView::new(
|
||||
body.content_type
|
||||
.parts()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a.is_html())
|
||||
.unwrap_or(&body),
|
||||
context,
|
||||
));
|
||||
self.subview = Some(subview);
|
||||
self.mode = ViewMode::Subview;
|
||||
}
|
||||
ViewMode::Subview => {}
|
||||
ViewMode::Source(Source::Raw) => {
|
||||
let text = String::from_utf8_lossy(self.mail.bytes()).into_owned();
|
||||
self.view_settings.body_theme = crate::conf::value(context, "mail.view.body");
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
None,
|
||||
None,
|
||||
self.view_settings.body_theme,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
}
|
||||
}
|
||||
ViewMode::Source(Source::Decoded) => {
|
||||
let text = {
|
||||
/* Decode each header value */
|
||||
let mut ret = String::new();
|
||||
match melib::email::parser::headers::headers(self.mail.bytes())
|
||||
.map(|(_, v)| v)
|
||||
{
|
||||
Ok(headers) => {
|
||||
for (h, v) in headers {
|
||||
_ = match melib::email::parser::encodings::phrase(v, true) {
|
||||
Ok((_, v)) => ret.write_fmt(format_args!(
|
||||
"{h}: {}\n",
|
||||
String::from_utf8_lossy(&v)
|
||||
)),
|
||||
Err(err) => ret.write_fmt(format_args!("{h}: {err}\n")),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
_ = write!(&mut ret, "{err}");
|
||||
}
|
||||
}
|
||||
if !ret.ends_with("\n\n") {
|
||||
if ret.ends_with('\n') {
|
||||
ret.pop();
|
||||
}
|
||||
ret.push_str("\n\n");
|
||||
}
|
||||
ret.push_str(&self.body_text);
|
||||
if !ret.ends_with("\n\n") {
|
||||
if ret.ends_with('\n') {
|
||||
ret.pop();
|
||||
}
|
||||
ret.push_str("\n\n");
|
||||
}
|
||||
// ret.push_str(&self.attachment_tree);
|
||||
ret
|
||||
};
|
||||
self.view_settings.body_theme = crate::conf::value(context, "mail.view.body");
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
None,
|
||||
None,
|
||||
self.view_settings.body_theme,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
}
|
||||
}
|
||||
ViewMode::Url => {
|
||||
let mut text = self.body_text.clone();
|
||||
if self.links.is_empty() {
|
||||
let finder = LinkFinder::new();
|
||||
self.links = finder
|
||||
.links(&text)
|
||||
.filter_map(|l| {
|
||||
if *l.kind() == linkify::LinkKind::Url {
|
||||
Some(Link {
|
||||
start: l.start(),
|
||||
end: l.end(),
|
||||
kind: LinkKind::Url,
|
||||
})
|
||||
} else if *l.kind() == linkify::LinkKind::Email {
|
||||
Some(Link {
|
||||
start: l.start(),
|
||||
end: l.end(),
|
||||
kind: LinkKind::Email,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Link>>();
|
||||
}
|
||||
for (lidx, l) in self.links.iter().enumerate().rev() {
|
||||
text.insert_str(l.start, &format!("[{}]", lidx));
|
||||
}
|
||||
if !text.ends_with("\n\n") {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&self.attachment_tree);
|
||||
|
||||
let cursor_pos = self.pager.cursor_pos();
|
||||
self.view_settings.body_theme = crate::conf::value(context, "mail.view.body");
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
Some(cursor_pos),
|
||||
None,
|
||||
self.view_settings.body_theme,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
}
|
||||
self.subview = None;
|
||||
}
|
||||
_ => {
|
||||
let mut text = self.body_text.clone();
|
||||
if !text.ends_with("\n\n") {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&self.attachment_tree);
|
||||
let cursor_pos = if self.mode.is_attachment() {
|
||||
0
|
||||
let mut text = if let Some(ViewFilter {
|
||||
filter_invocation,
|
||||
body_text,
|
||||
notice,
|
||||
..
|
||||
}) = self.filters.last()
|
||||
{
|
||||
let mut text = if self.filters.len() == 1 {
|
||||
if filter_invocation.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.pager.cursor_pos()
|
||||
};
|
||||
self.view_settings.body_theme = crate::conf::value(context, "mail.view.body");
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
Some(cursor_pos),
|
||||
None,
|
||||
self.view_settings.body_theme,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
format!("Text piped through `{filter_invocation}`\n\n")
|
||||
}
|
||||
self.subview = None;
|
||||
}
|
||||
} else {
|
||||
notice
|
||||
.as_ref()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
if filter_invocation.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("Text piped through `{filter_invocation}`\n\n"))
|
||||
}
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
text.push_str(&self.options.convert(&mut self.links, &self.body, body_text));
|
||||
text
|
||||
} else {
|
||||
self.options
|
||||
.convert(&mut self.links, &self.body, &self.body_text)
|
||||
};
|
||||
self.dirty = false;
|
||||
if !text.trim().is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&self.attachment_tree);
|
||||
let cursor_pos = self.pager.cursor_pos();
|
||||
self.view_settings.body_theme = crate::conf::value(context, "mail.view.body");
|
||||
self.pager = Pager::from_string(
|
||||
text,
|
||||
Some(context),
|
||||
Some(cursor_pos),
|
||||
None,
|
||||
self.view_settings.body_theme,
|
||||
);
|
||||
if let Some(ref filter) = self.view_settings.pager_filter {
|
||||
self.pager.filter(filter);
|
||||
}
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
ViewMode::Subview if self.subview.is_some() => {
|
||||
if let Some(s) = self.subview.as_mut() {
|
||||
if !s.is_dirty() {
|
||||
s.set_dirty(true);
|
||||
}
|
||||
s.draw(grid, area.skip_rows(y), context);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.pager.draw(grid, area.skip_rows(y), context);
|
||||
if let Some(s) = self.subview.as_mut() {
|
||||
if !s.is_dirty() {
|
||||
s.set_dirty(true);
|
||||
}
|
||||
s.draw(grid, area.skip_rows(y), context);
|
||||
} else {
|
||||
self.pager.draw(grid, area.skip_rows(y), context);
|
||||
}
|
||||
if let ForceCharset::Dialog(ref mut s) = self.force_charset {
|
||||
s.draw(grid, area, context);
|
||||
|
@ -1259,8 +1153,16 @@ impl Component for EnvelopeView {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
let shortcuts = &self.shortcuts(context);
|
||||
|
||||
if let Some(ref mut sub) = self.subview {
|
||||
if sub.process_event(event, context) {
|
||||
if matches!(event, UIEvent::Input(ref key) if shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"]
|
||||
)) {
|
||||
if sub.process_event(event, context) && !sub.filters.is_empty() {
|
||||
return true;
|
||||
}
|
||||
} else if sub.process_event(event, context) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
|
@ -1289,13 +1191,17 @@ impl Component for EnvelopeView {
|
|||
}
|
||||
}
|
||||
|
||||
if self.pager.process_event(event, context) {
|
||||
if self.pager.process_event(event, context)
|
||||
|| self
|
||||
.filters
|
||||
.last_mut()
|
||||
.map(|f| f.process_event(event, context))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let shortcuts = &self.shortcuts(context);
|
||||
|
||||
match *event {
|
||||
UIEvent::Resize | UIEvent::VisibilityChange(true) => {
|
||||
self.set_dirty(true);
|
||||
|
@ -1315,44 +1221,46 @@ impl Component for EnvelopeView {
|
|||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if matches!(
|
||||
self.mode,
|
||||
ViewMode::Normal
|
||||
| ViewMode::Subview
|
||||
| ViewMode::Source(Source::Decoded)
|
||||
| ViewMode::Source(Source::Raw)
|
||||
) && shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["view_raw_source"]
|
||||
) =>
|
||||
if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["view_raw_source"]) =>
|
||||
{
|
||||
self.mode = match self.mode {
|
||||
ViewMode::Source(Source::Decoded) => ViewMode::Source(Source::Raw),
|
||||
_ => ViewMode::Source(Source::Decoded),
|
||||
};
|
||||
if self.options.contains(ViewOptions::SOURCE) {
|
||||
self.options.toggle(ViewOptions::SOURCE_RAW);
|
||||
} else {
|
||||
self.options.toggle(ViewOptions::SOURCE);
|
||||
}
|
||||
self.set_dirty(true);
|
||||
self.initialised = false;
|
||||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if matches!(
|
||||
self.mode,
|
||||
ViewMode::Attachment(_)
|
||||
| ViewMode::Subview
|
||||
| ViewMode::Url
|
||||
| ViewMode::Source(Source::Decoded)
|
||||
| ViewMode::Source(Source::Raw)
|
||||
) && shortcut!(
|
||||
if self.options != ViewOptions::DEFAULT
|
||||
&& shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"]
|
||||
) =>
|
||||
{
|
||||
self.options.remove(ViewOptions::SOURCE | ViewOptions::URL);
|
||||
self.set_dirty(true);
|
||||
self.initialised = false;
|
||||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"]
|
||||
) =>
|
||||
{
|
||||
self.mode = ViewMode::Normal;
|
||||
if self.subview.take().is_some() {
|
||||
self.initialised = false;
|
||||
} else if self.filters.is_empty() {
|
||||
return false;
|
||||
} else {
|
||||
self.filters.pop();
|
||||
self.initialised = false;
|
||||
}
|
||||
self.set_dirty(true);
|
||||
self.initialised = false;
|
||||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview)
|
||||
&& !self.cmd_buf.is_empty()
|
||||
if !self.cmd_buf.is_empty()
|
||||
&& shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["open_mailcap"]) =>
|
||||
{
|
||||
let lidx = self.cmd_buf.parse::<usize>().unwrap();
|
||||
|
@ -1491,8 +1399,7 @@ impl Component for EnvelopeView {
|
|||
}
|
||||
UIEvent::Input(ref key)
|
||||
if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["open_attachment"])
|
||||
&& !self.cmd_buf.is_empty()
|
||||
&& (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) =>
|
||||
&& !self.cmd_buf.is_empty() =>
|
||||
{
|
||||
let lidx = self.cmd_buf.parse::<usize>().unwrap();
|
||||
self.cmd_buf.clear();
|
||||
|
@ -1504,7 +1411,6 @@ impl Component for EnvelopeView {
|
|||
ContentType::MessageRfc822 => {
|
||||
match Mail::new(attachment.body().to_vec(), Some(Flag::SEEN)) {
|
||||
Ok(wrapper) => {
|
||||
self.mode = ViewMode::Subview;
|
||||
self.subview = Some(Box::new(EnvelopeView::new(
|
||||
wrapper,
|
||||
None,
|
||||
|
@ -1521,19 +1427,15 @@ impl Component for EnvelopeView {
|
|||
}
|
||||
}
|
||||
}
|
||||
ContentType::Text { .. }
|
||||
ContentType::Multipart { .. }
|
||||
| ContentType::Text { .. }
|
||||
| ContentType::PGPSignature
|
||||
| ContentType::CMSSignature => {
|
||||
self.mode = ViewMode::Attachment(lidx);
|
||||
if let Ok(filter) = ViewFilter::new_attachment(attachment, context) {
|
||||
self.filters.push(filter);
|
||||
}
|
||||
self.initialised = false;
|
||||
self.dirty = true;
|
||||
}
|
||||
ContentType::Multipart { .. } => {
|
||||
context.replies.push_back(UIEvent::StatusEvent(
|
||||
StatusEvent::DisplayMessage(
|
||||
"Multipart attachments are not supported yet.".to_string(),
|
||||
),
|
||||
));
|
||||
self.set_dirty(true);
|
||||
}
|
||||
ContentType::Other { .. } => {
|
||||
let attachment_type = attachment.mime_type();
|
||||
|
@ -1598,8 +1500,9 @@ impl Component for EnvelopeView {
|
|||
} => {
|
||||
context.replies.push_back(UIEvent::StatusEvent(
|
||||
StatusEvent::DisplayMessage(format!(
|
||||
"Failed to open {}. application/octet-stream isn't supported \
|
||||
yet",
|
||||
"Failed to open {}. application/octet-stream is a stream of \
|
||||
bytes of unknown type. Try saving it as a file and opening \
|
||||
it manually.",
|
||||
name.as_ref().map(|n| n.as_str()).unwrap_or("file")
|
||||
)),
|
||||
));
|
||||
|
@ -1609,10 +1512,9 @@ impl Component for EnvelopeView {
|
|||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if (self.mode == ViewMode::Normal || self.mode == ViewMode::Url)
|
||||
&& shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_expand_headers"]
|
||||
) =>
|
||||
if shortcut!(
|
||||
key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_expand_headers"]
|
||||
) =>
|
||||
{
|
||||
self.view_settings.expand_headers = !self.view_settings.expand_headers;
|
||||
self.set_dirty(true);
|
||||
|
@ -1620,7 +1522,7 @@ impl Component for EnvelopeView {
|
|||
}
|
||||
UIEvent::Input(ref key)
|
||||
if !self.cmd_buf.is_empty()
|
||||
&& self.mode == ViewMode::Url
|
||||
&& self.options.contains(ViewOptions::URL)
|
||||
&& shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["go_to_url"]) =>
|
||||
{
|
||||
let lidx = self.cmd_buf.parse::<usize>().unwrap();
|
||||
|
@ -1675,14 +1577,9 @@ impl Component for EnvelopeView {
|
|||
return true;
|
||||
}
|
||||
UIEvent::Input(ref key)
|
||||
if (self.mode == ViewMode::Normal || self.mode == ViewMode::Url)
|
||||
&& shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_url_mode"]) =>
|
||||
if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_url_mode"]) =>
|
||||
{
|
||||
match self.mode {
|
||||
ViewMode::Normal => self.mode = ViewMode::Url,
|
||||
ViewMode::Url => self.mode = ViewMode::Normal,
|
||||
_ => {}
|
||||
}
|
||||
self.options.toggle(ViewOptions::URL);
|
||||
self.initialised = false;
|
||||
self.dirty = true;
|
||||
return true;
|
||||
|
@ -1846,20 +1743,16 @@ impl Component for EnvelopeView {
|
|||
|
||||
let mut our_map = self.view_settings.env_view_shortcuts.clone();
|
||||
|
||||
if !(self.mode.is_attachment()
|
||||
|| self.mode == ViewMode::Subview
|
||||
|| self.mode == ViewMode::Source(Source::Decoded)
|
||||
|| self.mode == ViewMode::Source(Source::Raw)
|
||||
|| self.mode == ViewMode::Url)
|
||||
{
|
||||
our_map.remove("return_to_normal_view");
|
||||
}
|
||||
if self.mode != ViewMode::Url {
|
||||
//if !self
|
||||
// .options
|
||||
// .contains(ViewOptions::SOURCE | ViewOptions::URL)
|
||||
// || self.filters.is_empty()
|
||||
//{
|
||||
// our_map.remove("return_to_normal_view");
|
||||
//}
|
||||
if !self.options.contains(ViewOptions::URL) {
|
||||
our_map.remove("go_to_url");
|
||||
}
|
||||
if !(self.mode == ViewMode::Normal || self.mode == ViewMode::Url) {
|
||||
our_map.remove("toggle_url_mode");
|
||||
}
|
||||
map.insert(Shortcuts::ENVELOPE_VIEW, our_map);
|
||||
|
||||
map
|
||||
|
|
|
@ -0,0 +1,394 @@
|
|||
/*
|
||||
* meli
|
||||
*
|
||||
* Copyright 2023 Manos Pitsidianakis
|
||||
*
|
||||
* This file is part of meli.
|
||||
*
|
||||
* meli is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* meli is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with meli. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::Write,
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
type ProcessEventFn = fn(&mut ViewFilter, &mut UIEvent, &mut Context) -> bool;
|
||||
|
||||
use melib::{
|
||||
attachment_types::{ContentType, MultipartType, Text},
|
||||
error::*,
|
||||
parser::BytesExt,
|
||||
text_processing::Truncate,
|
||||
utils::xdg::query_default_app,
|
||||
Attachment, Result,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
components::*,
|
||||
desktop_exec_to_command,
|
||||
terminal::{Area, CellBuffer},
|
||||
Context, ErrorKind, File, StatusEvent, UIEvent,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ViewFilter {
|
||||
pub filter_invocation: String,
|
||||
pub content_type: ContentType,
|
||||
pub notice: Option<Cow<'static, str>>,
|
||||
pub body_text: String,
|
||||
pub unfiltered: Vec<u8>,
|
||||
pub event_handler: Option<ProcessEventFn>,
|
||||
pub id: ComponentId,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ViewFilter {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
fmt.debug_struct(stringify!(ViewFilter))
|
||||
.field("filter_invocation", &self.filter_invocation)
|
||||
.field("content_type", &self.content_type)
|
||||
.field("notice", &self.notice)
|
||||
.field("body_text", &self.body_text.trim_at_boundary(18))
|
||||
.field("body_text_len", &self.body_text.len())
|
||||
.field("event_handler", &self.event_handler.is_some())
|
||||
.field("id", &self.id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ViewFilter {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(fmt, "{}", self.filter_invocation.trim_at_boundary(5))
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewFilter {
|
||||
pub fn new_html(body: &Attachment, context: &mut Context) -> Result<Self> {
|
||||
fn run(cmd: &str, args: &[&str], bytes: &[u8]) -> Result<String> {
|
||||
let mut html_filter = Command::new(cmd)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
html_filter
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or("Failed to write to html filter stdin")?
|
||||
.write_all(bytes)
|
||||
.chain_err_summary(|| "Failed to write to html filter stdin")?;
|
||||
Ok(String::from_utf8_lossy(
|
||||
&html_filter
|
||||
.wait_with_output()
|
||||
.chain_err_summary(|| "Could not wait for process output")?
|
||||
.stdout,
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
let mut att = body;
|
||||
let mut stack = vec![body];
|
||||
while let Some(a) = stack.pop() {
|
||||
match a.content_type {
|
||||
ContentType::Text {
|
||||
kind: Text::Html, ..
|
||||
} => {
|
||||
att = a;
|
||||
break;
|
||||
}
|
||||
ContentType::Text { .. }
|
||||
| ContentType::PGPSignature
|
||||
| ContentType::CMSSignature => {
|
||||
continue;
|
||||
}
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Related,
|
||||
ref parts,
|
||||
ref parameters,
|
||||
..
|
||||
} => {
|
||||
if let Some(main_attachment) = parameters
|
||||
.iter()
|
||||
.find_map(|(k, v)| if k == b"type" { Some(v) } else { None })
|
||||
.and_then(|t| parts.iter().find(|a| a.content_type == t.as_slice()))
|
||||
{
|
||||
stack.push(main_attachment);
|
||||
} else {
|
||||
for a in parts {
|
||||
if let ContentType::Text {
|
||||
kind: Text::Html, ..
|
||||
} = a.content_type
|
||||
{
|
||||
att = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stack.extend(parts);
|
||||
}
|
||||
}
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} => {
|
||||
for a in parts {
|
||||
if let ContentType::Text {
|
||||
kind: Text::Html, ..
|
||||
} = a.content_type
|
||||
{
|
||||
att = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stack.extend(parts);
|
||||
}
|
||||
ContentType::Multipart {
|
||||
kind: _, ref parts, ..
|
||||
} => {
|
||||
for a in parts {
|
||||
if let ContentType::Text {
|
||||
kind: Text::Html, ..
|
||||
} = a.content_type
|
||||
{
|
||||
att = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
stack.extend(parts);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bytes: Vec<u8> = att.decode(Default::default());
|
||||
|
||||
let settings = &context.settings;
|
||||
if let Some(filter_invocation) = settings.pager.html_filter.as_ref() {
|
||||
match run("sh", &["-c", filter_invocation], &bytes) {
|
||||
Err(err) => {
|
||||
return Err(Error::new(format!(
|
||||
"Failed to start html filter process `{}`",
|
||||
filter_invocation,
|
||||
))
|
||||
.set_source(Some(Arc::new(err)))
|
||||
.set_kind(ErrorKind::External));
|
||||
}
|
||||
Ok(body_text) => {
|
||||
let notice =
|
||||
Some(format!("Text piped through `{}`.\n\n", filter_invocation).into());
|
||||
return Ok(Self {
|
||||
filter_invocation: filter_invocation.clone(),
|
||||
content_type: att.content_type.clone(),
|
||||
notice,
|
||||
body_text,
|
||||
unfiltered: bytes,
|
||||
event_handler: Some(Self::html_process_event),
|
||||
id: ComponentId::default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(body_text) = run("w3m", &["-I", "utf-8", "-T", "text/html"], &bytes) {
|
||||
return Ok(Self {
|
||||
filter_invocation: "w3m -I utf-8 -T text/html".into(),
|
||||
content_type: att.content_type.clone(),
|
||||
notice: Some("Text piped through `w3m -I utf-8 -T text/html`.\n\n".into()),
|
||||
body_text,
|
||||
unfiltered: bytes,
|
||||
event_handler: Some(Self::html_process_event),
|
||||
id: ComponentId::default(),
|
||||
});
|
||||
}
|
||||
|
||||
Err(
|
||||
Error::new("Failed to find any application to use as html filter")
|
||||
.set_kind(ErrorKind::Configuration),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_attachment(att: &Attachment, context: &mut Context) -> Result<Self> {
|
||||
if matches!(
|
||||
att.content_type,
|
||||
ContentType::Other { .. } | ContentType::OctetStream { .. }
|
||||
) {
|
||||
return Err(Error::new(format!(
|
||||
"Cannot view {} attachment as text.",
|
||||
att.content_type,
|
||||
))
|
||||
.set_kind(ErrorKind::ValueError));
|
||||
}
|
||||
if let ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} = att.content_type
|
||||
{
|
||||
if let Some(Ok(v)) = parts
|
||||
.iter()
|
||||
.find(|p| p.is_text() && !p.body().trim().is_empty())
|
||||
.map(|p| Self::new_attachment(p, context))
|
||||
{
|
||||
return Ok(v);
|
||||
}
|
||||
} else if let ContentType::Multipart {
|
||||
kind: MultipartType::Related,
|
||||
ref parts,
|
||||
..
|
||||
} = att.content_type
|
||||
{
|
||||
if let Some(v @ Ok(_)) = parts.iter().find_map(|p| {
|
||||
if let v @ Ok(_) = Self::new_attachment(p, context) {
|
||||
Some(v)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if att.is_html() {
|
||||
return Self::new_html(att, context);
|
||||
}
|
||||
if matches!(
|
||||
att.content_type,
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Digest,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return Ok(Self {
|
||||
filter_invocation: String::new(),
|
||||
content_type: att.content_type.clone(),
|
||||
notice: None,
|
||||
body_text: String::new(),
|
||||
unfiltered: vec![],
|
||||
event_handler: None,
|
||||
id: ComponentId::default(),
|
||||
});
|
||||
}
|
||||
if let ContentType::Multipart {
|
||||
kind: MultipartType::Mixed,
|
||||
ref parts,
|
||||
..
|
||||
} = att.content_type
|
||||
{
|
||||
if let Some(Ok(res)) =
|
||||
parts
|
||||
.iter()
|
||||
.find_map(|part| match Self::new_attachment(part, context) {
|
||||
v @ Ok(_) => Some(v),
|
||||
Err(_) => None,
|
||||
})
|
||||
{
|
||||
return Ok(res);
|
||||
}
|
||||
}
|
||||
let notice = Some("Viewing attachment.\n\n".into());
|
||||
Ok(Self {
|
||||
filter_invocation: String::new(),
|
||||
content_type: att.content_type.clone(),
|
||||
notice,
|
||||
body_text: att.text(),
|
||||
unfiltered: att.decode(Default::default()),
|
||||
event_handler: None,
|
||||
id: ComponentId::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn html_process_event(
|
||||
_self: &mut ViewFilter,
|
||||
event: &mut UIEvent,
|
||||
context: &mut Context,
|
||||
) -> bool {
|
||||
if matches!(event, UIEvent::Input(key) if *key == context.settings.shortcuts.envelope_view.open_html)
|
||||
{
|
||||
let command = context
|
||||
.settings
|
||||
.pager
|
||||
.html_open
|
||||
.as_ref()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| query_default_app("text/html").ok());
|
||||
let command = if cfg!(target_os = "macos") {
|
||||
command.or_else(|| Some("open".into()))
|
||||
} else if cfg!(target_os = "linux") {
|
||||
command.or_else(|| Some("xdg-open".into()))
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if let Some(command) = command {
|
||||
match File::create_temp_file(&_self.unfiltered, None, None, Some("html"), true)
|
||||
.and_then(|p| {
|
||||
let exec_cmd = desktop_exec_to_command(
|
||||
&command,
|
||||
p.path().display().to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
Ok((
|
||||
p,
|
||||
Command::new("sh")
|
||||
.args(["-c", &exec_cmd])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?,
|
||||
))
|
||||
}) {
|
||||
Ok((p, child)) => {
|
||||
context.replies.push_back(UIEvent::StatusEvent(
|
||||
StatusEvent::UpdateSubStatus(command.to_string()),
|
||||
));
|
||||
context.temp_files.push(p);
|
||||
context.children.push(child);
|
||||
}
|
||||
Err(err) => {
|
||||
context.replies.push_back(UIEvent::StatusEvent(
|
||||
StatusEvent::DisplayMessage(format!(
|
||||
"Failed to start `{command}`: {err}",
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
context
|
||||
.replies
|
||||
.push_back(UIEvent::StatusEvent(StatusEvent::DisplayMessage(
|
||||
"Couldn't find a default application for html files.".to_string(),
|
||||
)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ViewFilter {
|
||||
fn draw(&mut self, _: &mut CellBuffer, _: Area, _: &mut Context) {}
|
||||
fn process_event(&mut self, event: &mut UIEvent, context: &mut Context) -> bool {
|
||||
if let Some(ref mut f) = self.event_handler {
|
||||
return f(self, event, context);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_dirty(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn set_dirty(&mut self, _: bool) {}
|
||||
|
||||
fn id(&self) -> ComponentId {
|
||||
self.id
|
||||
}
|
||||
}
|
|
@ -49,7 +49,7 @@ impl std::fmt::Debug for HtmlView {
|
|||
impl HtmlView {
|
||||
pub fn new(body: &Attachment, context: &mut Context) -> Self {
|
||||
let id = ComponentId::default();
|
||||
let bytes: Vec<u8> = body.decode_rec(Default::default());
|
||||
let bytes: Vec<u8> = body.decode(Default::default());
|
||||
|
||||
let settings = &context.settings;
|
||||
let mut display_text = if let Some(filter_invocation) = settings.pager.html_filter.as_ref()
|
||||
|
|
|
@ -19,7 +19,9 @@
|
|||
* along with meli. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use melib::{attachment_types::Charset, pgp::DecryptionMetadata, Attachment, Error, Result};
|
||||
use std::fmt::Write as IoWrite;
|
||||
|
||||
use melib::{attachment_types::Charset, error::*, pgp::DecryptionMetadata, Attachment, Result};
|
||||
|
||||
use crate::{
|
||||
conf::shortcuts::EnvelopeViewShortcuts,
|
||||
|
@ -101,31 +103,97 @@ pub enum Source {
|
|||
Raw,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug, Default)]
|
||||
pub enum ViewMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Url,
|
||||
Attachment(usize),
|
||||
Source(Source),
|
||||
Subview,
|
||||
bitflags::bitflags! {
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ViewOptions: u8 {
|
||||
const DEFAULT = 0;
|
||||
const URL = 1;
|
||||
const SOURCE = Self::URL.bits() << 1;
|
||||
const SOURCE_RAW = Self::SOURCE.bits() << 1;
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! is_variant {
|
||||
($n:ident, $($var:tt)+) => {
|
||||
#[inline]
|
||||
pub fn $n(&self) -> bool {
|
||||
matches!(self, Self::$($var)*)
|
||||
impl Default for ViewOptions {
|
||||
fn default() -> Self {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewOptions {
|
||||
pub fn convert(
|
||||
&self,
|
||||
links: &mut Vec<Link>,
|
||||
attachment: &melib::Attachment,
|
||||
text: &str,
|
||||
) -> String {
|
||||
let mut text = if self.contains(Self::SOURCE) {
|
||||
if self.contains(Self::SOURCE_RAW) {
|
||||
String::from_utf8_lossy(attachment.raw()).into_owned()
|
||||
} else {
|
||||
/* Decode each header value */
|
||||
let mut ret = String::new();
|
||||
match melib::email::parser::headers::headers(attachment.raw()).map(|(_, v)| v) {
|
||||
Ok(headers) => {
|
||||
for (h, v) in headers {
|
||||
_ = match melib::email::parser::encodings::phrase(v, true) {
|
||||
Ok((_, v)) => ret.write_fmt(format_args!(
|
||||
"{h}: {}\n",
|
||||
String::from_utf8_lossy(&v)
|
||||
)),
|
||||
Err(err) => ret.write_fmt(format_args!("{h}: {err}\n")),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
_ = write!(&mut ret, "{err}");
|
||||
}
|
||||
}
|
||||
if !ret.ends_with("\n\n") {
|
||||
ret.push_str("\n\n");
|
||||
}
|
||||
ret.push_str(text);
|
||||
ret
|
||||
}
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
|
||||
while text.ends_with("\n\n") {
|
||||
text.pop();
|
||||
text.pop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl ViewMode {
|
||||
is_variant! { is_normal, Normal }
|
||||
is_variant! { is_url, Url }
|
||||
is_variant! { is_attachment, Attachment(_) }
|
||||
is_variant! { is_source, Source(_) }
|
||||
is_variant! { is_subview, Subview }
|
||||
if self.contains(Self::URL) {
|
||||
if links.is_empty() {
|
||||
let finder = linkify::LinkFinder::new();
|
||||
*links = finder
|
||||
.links(&text)
|
||||
.filter_map(|l| {
|
||||
if *l.kind() == linkify::LinkKind::Url {
|
||||
Some(Link {
|
||||
start: l.start(),
|
||||
end: l.end(),
|
||||
kind: LinkKind::Url,
|
||||
})
|
||||
} else if *l.kind() == linkify::LinkKind::Email {
|
||||
Some(Link {
|
||||
start: l.start(),
|
||||
end: l.end(),
|
||||
kind: LinkKind::Email,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<Link>>();
|
||||
}
|
||||
for (lidx, l) in links.iter().enumerate().rev() {
|
||||
text.insert_str(l.start, &format!("[{}]", lidx));
|
||||
}
|
||||
}
|
||||
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
|
@ -711,16 +711,15 @@ impl Attachment {
|
|||
ContentType::Text {
|
||||
kind: Text::Plain, ..
|
||||
} => false,
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Digest,
|
||||
..
|
||||
} => false,
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Alternative,
|
||||
ref parts,
|
||||
..
|
||||
} => parts.iter().all(Self::is_html),
|
||||
ContentType::Multipart {
|
||||
kind: MultipartType::Related,
|
||||
..
|
||||
} => false,
|
||||
ContentType::Multipart { ref parts, .. } => parts.iter().any(Self::is_html),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue