Refactor, moving API and notmuch code into submodule.
This is on the path to make the UI more simple and move email parsing to server side.
This commit is contained in:
parent
f8e86dc5cc
commit
fc83d56c0c
@ -9,3 +9,11 @@ pub struct SearchResult {
|
||||
pub results_per_page: usize,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ShowResult {
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum Message {}
|
||||
|
||||
19
web/src/api.rs
Normal file
19
web/src/api.rs
Normal file
@ -0,0 +1,19 @@
|
||||
use seed::Url;
|
||||
|
||||
const BASE_URL: &str = "/api";
|
||||
pub fn refresh() -> String {
|
||||
format!("{BASE_URL}/refresh")
|
||||
}
|
||||
pub fn search(query: &str, page: usize, results_per_page: usize) -> String {
|
||||
let query = Url::encode_uri_component(query);
|
||||
format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}")
|
||||
}
|
||||
pub fn show(tid: &str) -> String {
|
||||
format!("{BASE_URL}/show/{tid}")
|
||||
}
|
||||
pub fn show_pretty(tid: &str) -> String {
|
||||
format!("{BASE_URL}/show/{tid}/pretty")
|
||||
}
|
||||
pub fn original(message_id: &str) -> String {
|
||||
format!("{BASE_URL}/original/{message_id}")
|
||||
}
|
||||
246
web/src/lib.rs
246
web/src/lib.rs
@ -1,7 +1,6 @@
|
||||
// (Lines like the one below ignore selected Clippy rules
|
||||
// - it's useful when you want to check your code with `cargo make verify`
|
||||
// but some rules are too "annoying" or are not applicable for your case.)
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
mod api;
|
||||
mod nm;
|
||||
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
@ -9,9 +8,9 @@ use std::{
|
||||
|
||||
use itertools::Itertools;
|
||||
use log::{debug, error, info, Level};
|
||||
use nm::view_thread;
|
||||
use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet};
|
||||
use seed::{prelude::*, *};
|
||||
use serde::de::Deserialize;
|
||||
use wasm_timer::Instant;
|
||||
|
||||
const SEARCH_RESULTS_PER_PAGE: usize = 20;
|
||||
@ -117,7 +116,7 @@ enum RefreshingState {
|
||||
|
||||
// (Remove the line below once any of your `Msg` variants doesn't implement `Copy`.)
|
||||
// `Msg` describes the different events you can modify state with.
|
||||
enum Msg {
|
||||
pub enum Msg {
|
||||
Noop,
|
||||
RefreshStart,
|
||||
RefreshDone(Option<FetchError>),
|
||||
@ -127,8 +126,6 @@ enum Msg {
|
||||
results_per_page: usize,
|
||||
},
|
||||
SearchResult(fetch::Result<shared::SearchResult>),
|
||||
ShowRequest(String),
|
||||
ShowResult(fetch::Result<ThreadSet>),
|
||||
ShowPrettyRequest(String),
|
||||
ShowPrettyResult(fetch::Result<ThreadSet>),
|
||||
NextPage,
|
||||
@ -147,6 +144,16 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
model.refreshing_state = if let Some(err) = err {
|
||||
RefreshingState::Error(format!("{:?}", err))
|
||||
} else {
|
||||
// If looking at search page, refresh the search to view update on the server side.
|
||||
if let Context::Search(sr) = &model.context {
|
||||
let query = sr.query.clone();
|
||||
let page = sr.page;
|
||||
let results_per_page = sr.results_per_page;
|
||||
orders.perform_cmd(async move {
|
||||
Msg::SearchResult(search_request(&query, page, results_per_page).await)
|
||||
});
|
||||
}
|
||||
|
||||
RefreshingState::None
|
||||
};
|
||||
}
|
||||
@ -170,19 +177,6 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||
error!("fetch failed {:?}", fetch_error);
|
||||
}
|
||||
|
||||
Msg::ShowRequest(tid) => {
|
||||
orders
|
||||
.skip()
|
||||
.perform_cmd(async move { Msg::ShowResult(show_request(&tid).await) });
|
||||
}
|
||||
Msg::ShowResult(Ok(response_data)) => {
|
||||
debug!("fetch ok {:#?}", response_data);
|
||||
model.context = Context::Thread(response_data);
|
||||
}
|
||||
Msg::ShowResult(Err(fetch_error)) => {
|
||||
error!("fetch failed {:?}", fetch_error);
|
||||
}
|
||||
|
||||
Msg::ShowPrettyRequest(tid) => {
|
||||
orders
|
||||
.skip()
|
||||
@ -230,28 +224,6 @@ async fn search_request(
|
||||
.await
|
||||
}
|
||||
|
||||
mod api {
|
||||
use seed::Url;
|
||||
|
||||
const BASE_URL: &str = "/api";
|
||||
pub fn refresh() -> String {
|
||||
format!("{BASE_URL}/refresh")
|
||||
}
|
||||
pub fn search(query: &str, page: usize, results_per_page: usize) -> String {
|
||||
let query = Url::encode_uri_component(query);
|
||||
format!("{BASE_URL}/search/{query}?page={page}&results_per_page={results_per_page}")
|
||||
}
|
||||
pub fn show(tid: &str) -> String {
|
||||
format!("{BASE_URL}/show/{tid}")
|
||||
}
|
||||
pub fn show_pretty(tid: &str) -> String {
|
||||
format!("{BASE_URL}/show/{tid}/pretty")
|
||||
}
|
||||
pub fn original(message_id: &str) -> String {
|
||||
format!("{BASE_URL}/original/{message_id}")
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_request() -> fetch::Result<()> {
|
||||
let t = Request::new(api::refresh())
|
||||
.method(Method::Get)
|
||||
@ -264,20 +236,6 @@ async fn refresh_request() -> fetch::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn show_request(tid: &str) -> fetch::Result<ThreadSet> {
|
||||
let b = Request::new(api::show(tid))
|
||||
.method(Method::Get)
|
||||
.fetch()
|
||||
.await?
|
||||
.check_status()?
|
||||
.bytes()
|
||||
.await?;
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(&b);
|
||||
deserializer.disable_recursion_limit();
|
||||
Ok(ThreadSet::deserialize(&mut deserializer)
|
||||
.map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?)
|
||||
}
|
||||
|
||||
async fn show_pretty_request(tid: &str) -> fetch::Result<ThreadSet> {
|
||||
Request::new(api::show_pretty(tid))
|
||||
.method(Method::Get)
|
||||
@ -292,131 +250,6 @@ async fn show_pretty_request(tid: &str) -> fetch::Result<ThreadSet> {
|
||||
// View
|
||||
// ------ ------
|
||||
|
||||
// <subject>
|
||||
// <tags>
|
||||
//
|
||||
// <from1> <date>
|
||||
// <to1>
|
||||
// <content1>
|
||||
// <zippy>
|
||||
// <children1>
|
||||
// </zippy>
|
||||
//
|
||||
// <from2> <date>
|
||||
// <to2>
|
||||
// <body2>
|
||||
fn view_message(thread: &ThreadNode) -> Node<Msg> {
|
||||
let message = thread.0.as_ref().expect("ThreadNode missing Message");
|
||||
let children = &thread.1;
|
||||
div![
|
||||
C!["message"],
|
||||
/* TODO(wathiede): collect all the tags and show them here. */
|
||||
/* TODO(wathiede): collect all the attachments from all the subparts */
|
||||
div![C!["header"], "From: ", &message.headers.from],
|
||||
div![C!["header"], "Date: ", &message.headers.date],
|
||||
div![C!["header"], "To: ", &message.headers.to],
|
||||
hr![],
|
||||
div![
|
||||
C!["body"],
|
||||
match &message.body {
|
||||
Some(body) => view_body(body.as_slice()),
|
||||
None => div!["<no body>"],
|
||||
},
|
||||
],
|
||||
children.iter().map(view_message)
|
||||
]
|
||||
}
|
||||
|
||||
fn view_body(body: &[Part]) -> Node<Msg> {
|
||||
div![body.iter().map(view_part)]
|
||||
}
|
||||
|
||||
fn view_text_plain(content: &Option<Content>) -> Node<Msg> {
|
||||
match &content {
|
||||
Some(Content::String(content)) => p![C!["view-part-text-plain"], content],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for text/plain"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn view_part(part: &Part) -> Node<Msg> {
|
||||
match part.content_type.as_str() {
|
||||
"text/plain" => view_text_plain(&part.content),
|
||||
"text/html" => {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
|
||||
return div![C!["view-part-text-html"], div!["TEST"], raw![&inlined]];
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/MIME#alternative
|
||||
// RFC1341 states: In general, user agents that compose multipart/alternative entities
|
||||
// should place the body parts in increasing order of preference, that is, with the
|
||||
// preferred format last.
|
||||
"multipart/alternative" => {
|
||||
if let Some(Content::Multipart(parts)) = &part.content {
|
||||
for part in parts.iter().rev() {
|
||||
if part.content_type == "text/html" {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
return div![Node::from_html(None, &inlined)];
|
||||
}
|
||||
}
|
||||
if part.content_type == "text/plain" {
|
||||
return view_text_plain(&part.content);
|
||||
}
|
||||
}
|
||||
div!["No known multipart/alternative parts"]
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("multipart/alternative with non-multipart content"),
|
||||
]
|
||||
}
|
||||
}
|
||||
"multipart/mixed" => match &part.content {
|
||||
Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
],
|
||||
},
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content type: {}", part.content_type)
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn first_subject(thread: &ThreadNode) -> Option<String> {
|
||||
if let Some(msg) = &thread.0 {
|
||||
return Some(msg.headers.subject.clone());
|
||||
} else {
|
||||
for tn in &thread.1 {
|
||||
if let Some(s) = first_subject(&tn) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn set_title(title: &str) {
|
||||
seed::document().set_title(&format!("lb: {}", title));
|
||||
}
|
||||
@ -598,55 +431,6 @@ fn view_search_pager(start: usize, count: usize, total: usize) -> Node<Msg> {
|
||||
]
|
||||
}
|
||||
|
||||
fn view_thread(thread_set: &ThreadSet) -> Node<Msg> {
|
||||
assert_eq!(thread_set.0.len(), 1);
|
||||
let thread = &thread_set.0[0];
|
||||
assert_eq!(thread.0.len(), 1);
|
||||
let thread_node = &thread.0[0];
|
||||
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
|
||||
set_title(&subject);
|
||||
div![
|
||||
h1![subject],
|
||||
a![
|
||||
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
||||
"Original"
|
||||
],
|
||||
view_message(&thread_node),
|
||||
div![
|
||||
C!["debug"],
|
||||
"Add zippy for debug dump",
|
||||
view_debug_thread_set(thread_set)
|
||||
] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */
|
||||
]
|
||||
}
|
||||
|
||||
fn view_debug_thread_set(thread_set: &ThreadSet) -> Node<Msg> {
|
||||
ul![thread_set
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })]
|
||||
}
|
||||
fn view_debug_thread(thread: &Thread) -> Node<Msg> {
|
||||
ul![thread
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tn)| { li!["tn", i, ": ", view_debug_thread_node(tn),] })]
|
||||
}
|
||||
|
||||
fn view_debug_thread_node(thread_node: &ThreadNode) -> Node<Msg> {
|
||||
ul![
|
||||
IF!(thread_node.0.is_some()=>li!["tn id:", &thread_node.0.as_ref().unwrap().id]),
|
||||
thread_node.1.iter().enumerate().map(|(i, tn)| li![
|
||||
"tn",
|
||||
i,
|
||||
": ",
|
||||
view_debug_thread_node(tn)
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
fn view_header(query: &str, refresh_request: &RefreshingState) -> Node<Msg> {
|
||||
let is_loading = refresh_request == &RefreshingState::Loading;
|
||||
let is_error = if let RefreshingState::Error(err) = refresh_request {
|
||||
|
||||
193
web/src/nm.rs
Normal file
193
web/src/nm.rs
Normal file
@ -0,0 +1,193 @@
|
||||
use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet};
|
||||
use seed::{prelude::*, *};
|
||||
use serde::de::Deserialize;
|
||||
|
||||
use crate::{api, set_title, Msg};
|
||||
|
||||
async fn show_request(tid: &str) -> fetch::Result<ThreadSet> {
|
||||
let b = Request::new(api::show(tid))
|
||||
.method(Method::Get)
|
||||
.fetch()
|
||||
.await?
|
||||
.check_status()?
|
||||
.bytes()
|
||||
.await?;
|
||||
let mut deserializer = serde_json::Deserializer::from_slice(&b);
|
||||
deserializer.disable_recursion_limit();
|
||||
Ok(ThreadSet::deserialize(&mut deserializer)
|
||||
.map_err(|_| FetchError::JsonError(fetch::JsonError::Serde(JsValue::NULL)))?)
|
||||
}
|
||||
|
||||
pub fn view_thread(thread_set: &ThreadSet) -> Node<Msg> {
|
||||
assert_eq!(thread_set.0.len(), 1);
|
||||
let thread = &thread_set.0[0];
|
||||
assert_eq!(thread.0.len(), 1);
|
||||
let thread_node = &thread.0[0];
|
||||
let subject = first_subject(&thread_node).unwrap_or("<No subject>".to_string());
|
||||
set_title(&subject);
|
||||
div![
|
||||
h1![subject],
|
||||
a![
|
||||
attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)},
|
||||
"Original"
|
||||
],
|
||||
view_message(&thread_node),
|
||||
div![
|
||||
C!["debug"],
|
||||
"Add zippy for debug dump",
|
||||
view_debug_thread_set(thread_set)
|
||||
] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */
|
||||
]
|
||||
}
|
||||
|
||||
// <subject>
|
||||
// <tags>
|
||||
//
|
||||
// <from1> <date>
|
||||
// <to1>
|
||||
// <content1>
|
||||
// <zippy>
|
||||
// <children1>
|
||||
// </zippy>
|
||||
//
|
||||
// <from2> <date>
|
||||
// <to2>
|
||||
// <body2>
|
||||
fn view_message(thread: &ThreadNode) -> Node<Msg> {
|
||||
let message = thread.0.as_ref().expect("ThreadNode missing Message");
|
||||
let children = &thread.1;
|
||||
div![
|
||||
C!["message"],
|
||||
/* TODO(wathiede): collect all the tags and show them here. */
|
||||
/* TODO(wathiede): collect all the attachments from all the subparts */
|
||||
div![C!["header"], "From: ", &message.headers.from],
|
||||
div![C!["header"], "Date: ", &message.headers.date],
|
||||
div![C!["header"], "To: ", &message.headers.to],
|
||||
hr![],
|
||||
div![
|
||||
C!["body"],
|
||||
match &message.body {
|
||||
Some(body) => view_body(body.as_slice()),
|
||||
None => div!["<no body>"],
|
||||
},
|
||||
],
|
||||
children.iter().map(view_message)
|
||||
]
|
||||
}
|
||||
|
||||
fn view_body(body: &[Part]) -> Node<Msg> {
|
||||
div![body.iter().map(view_part)]
|
||||
}
|
||||
|
||||
fn view_text_plain(content: &Option<Content>) -> Node<Msg> {
|
||||
match &content {
|
||||
Some(Content::String(content)) => p![C!["view-part-text-plain"], content],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for text/plain"),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn view_part(part: &Part) -> Node<Msg> {
|
||||
match part.content_type.as_str() {
|
||||
"text/plain" => view_text_plain(&part.content),
|
||||
"text/html" => {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
|
||||
return div![C!["view-part-text-html"], div!["TEST"], raw![&inlined]];
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/MIME#alternative
|
||||
// RFC1341 states: In general, user agents that compose multipart/alternative entities
|
||||
// should place the body parts in increasing order of preference, that is, with the
|
||||
// preferred format last.
|
||||
"multipart/alternative" => {
|
||||
if let Some(Content::Multipart(parts)) = &part.content {
|
||||
for part in parts.iter().rev() {
|
||||
if part.content_type == "text/html" {
|
||||
if let Some(Content::String(html)) = &part.content {
|
||||
let inliner = css_inline::CSSInliner::options()
|
||||
.load_remote_stylesheets(false)
|
||||
.remove_style_tags(true)
|
||||
.build();
|
||||
let inlined = inliner.inline(html).expect("failed to inline CSS");
|
||||
return div![Node::from_html(None, &inlined)];
|
||||
}
|
||||
}
|
||||
if part.content_type == "text/plain" {
|
||||
return view_text_plain(&part.content);
|
||||
}
|
||||
}
|
||||
div!["No known multipart/alternative parts"]
|
||||
} else {
|
||||
div![
|
||||
C!["error"],
|
||||
format!("multipart/alternative with non-multipart content"),
|
||||
]
|
||||
}
|
||||
}
|
||||
"multipart/mixed" => match &part.content {
|
||||
Some(Content::Multipart(parts)) => div![parts.iter().map(view_part)],
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content enum for multipart/mixed"),
|
||||
],
|
||||
},
|
||||
_ => div![
|
||||
C!["error"],
|
||||
format!("Unhandled content type: {}", part.content_type)
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn first_subject(thread: &ThreadNode) -> Option<String> {
|
||||
if let Some(msg) = &thread.0 {
|
||||
return Some(msg.headers.subject.clone());
|
||||
} else {
|
||||
for tn in &thread.1 {
|
||||
if let Some(s) = first_subject(&tn) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn view_debug_thread_set(thread_set: &ThreadSet) -> Node<Msg> {
|
||||
ul![thread_set
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| { li!["t", i, ": ", view_debug_thread(t),] })]
|
||||
}
|
||||
fn view_debug_thread(thread: &Thread) -> Node<Msg> {
|
||||
ul![thread
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tn)| { li!["tn", i, ": ", view_debug_thread_node(tn),] })]
|
||||
}
|
||||
|
||||
fn view_debug_thread_node(thread_node: &ThreadNode) -> Node<Msg> {
|
||||
ul![
|
||||
IF!(thread_node.0.is_some()=>li!["tn id:", &thread_node.0.as_ref().unwrap().id]),
|
||||
thread_node.1.iter().enumerate().map(|(i, tn)| li![
|
||||
"tn",
|
||||
i,
|
||||
": ",
|
||||
view_debug_thread_node(tn)
|
||||
])
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user