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
Manos Pitsidianakis 2023-08-19 14:57:28 +03:00
parent 62b8465f2c
commit 23c15261e7
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
8 changed files with 672 additions and 313 deletions

View File

@ -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

View File

@ -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'),

View File

@ -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)]

View File

@ -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

View File

@ -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
}
}

View File

@ -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()

View File

@ -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)]

View File

@ -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,
}
}