diff --git a/Cargo.lock b/Cargo.lock index a8c13e1..0ce03a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1185,6 +1185,7 @@ dependencies = [ "seed", "serde", "serde_json", + "shared", "wasm-bindgen-test", "wasm-timer", "web-sys", @@ -2321,6 +2322,7 @@ dependencies = [ "rocket_cors", "serde", "serde_json", + "shared", "thiserror", "tokio", ] @@ -2355,6 +2357,14 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared" +version = "0.1.0" +dependencies = [ + "notmuch", + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index a49be0f..4428ac1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "server", "notmuch", "procmail2notmuch", + "shared" ] [profile.release] diff --git a/notmuch/src/lib.rs b/notmuch/src/lib.rs index b3d2feb..af9465c 100644 --- a/notmuch/src/lib.rs +++ b/notmuch/src/lib.rs @@ -478,8 +478,19 @@ impl Notmuch { self.run_notmuch(std::iter::empty::<&str>()) } - pub fn search(&self, query: &str) -> Result { - let res = self.run_notmuch(["search", "--format=json", "--limit=20", query])?; + pub fn search( + &self, + query: &str, + offset: usize, + limit: usize, + ) -> Result { + let res = self.run_notmuch([ + "search", + "--format=json", + &format!("--offset={offset}"), + &format!("--limit={limit}"), + query, + ])?; Ok(serde_json::from_slice(&res)?) } diff --git a/server/Cargo.toml b/server/Cargo.toml index 3ba23b1..ccc115f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,6 +9,7 @@ edition = "2021" rocket = { version = "0.5.0-rc.2", features = [ "json" ] } rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", branch = "master" } notmuch = { path = "../notmuch" } +shared = { path = "../shared" } serde_json = "1.0.87" thiserror = "1.0.37" serde = { version = "1.0.147", features = ["derive"] } diff --git a/server/src/main.rs b/server/src/main.rs index 6a1ca25..2d34c4b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -22,17 +22,30 @@ fn hello() -> &'static str { async fn refresh(nm: &State) -> Result, Debug> { Ok(Json(String::from_utf8_lossy(&nm.new()?).to_string())) } + #[get("/search")] -async fn search_all(nm: &State) -> Result, Debug> { - search(nm, "*").await +async fn search_all( + nm: &State, +) -> Result, Debug> { + search(nm, "*", None, None).await } -#[get("/search/")] +#[get("/search/?&")] async fn search( nm: &State, query: &str, -) -> Result, Debug> { - let res = nm.search(query)?; + page: Option, + results_per_page: Option, +) -> Result, Debug> { + let page = page.unwrap_or(0); + let results_per_page = results_per_page.unwrap_or(10); + let res = shared::SearchResult { + summary: nm.search(query, page * results_per_page, results_per_page)?, + query: query.to_string(), + page, + results_per_page, + total: nm.count(query)?, + }; Ok(Json(res)) } diff --git a/shared/Cargo.toml b/shared/Cargo.toml new file mode 100644 index 0000000..7ca8b97 --- /dev/null +++ b/shared/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +notmuch = { path = "../notmuch" } +serde = { version = "1.0.147", features = ["derive"] } diff --git a/shared/src/lib.rs b/shared/src/lib.rs new file mode 100644 index 0000000..d3a12e7 --- /dev/null +++ b/shared/src/lib.rs @@ -0,0 +1,11 @@ +use notmuch::SearchSummary; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchResult { + pub summary: SearchSummary, + pub query: String, + pub page: usize, + pub results_per_page: usize, + pub total: usize, +} diff --git a/web/Cargo.toml b/web/Cargo.toml index 44b7dad..0ae26d3 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -22,6 +22,7 @@ seed = "0.9.2" console_log = {git = "http://git-private.h.xinu.tv/wathiede/console_log.git"} serde = { version = "1.0.147", features = ["derive"] } notmuch = {path = "../notmuch"} +shared = {path = "../shared"} itertools = "0.10.5" serde_json = { version = "1.0.93", features = ["unbounded_depth"] } wasm-timer = "0.2.5" diff --git a/web/index.html b/web/index.html index 66385ea..bdc8a50 100644 --- a/web/index.html +++ b/web/index.html @@ -37,6 +37,7 @@ iframe { } .footer { background-color: #eee; + color: #222; position: fixed; bottom: 0; left: 0; @@ -71,6 +72,12 @@ iframe { padding: 1.5em; } } +input, .input { + color: #000; +} +input::placeholder, .input::placeholder{ + color: #555; +} diff --git a/web/src/lib.rs b/web/src/lib.rs index 0d1cdf2..93d62d1 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -9,7 +9,7 @@ use std::{ use itertools::Itertools; use log::{debug, error, info, warn, Level}; -use notmuch::{Content, Part, SearchSummary, Thread, ThreadNode, ThreadSet}; +use notmuch::{Content, Part, Thread, ThreadNode, ThreadSet}; use seed::{prelude::*, *}; use serde::de::Deserialize; use wasm_timer::Instant; @@ -57,7 +57,7 @@ fn init(url: Url, orders: &mut impl Orders) -> Model { // ------ ------ enum Context { None, - Search(SearchSummary), + Search(shared::SearchResult), Thread(ThreadSet), } @@ -86,11 +86,13 @@ enum Msg { RefreshStart, RefreshDone(Option), SearchRequest(String), - SearchResult(fetch::Result), + SearchResult(fetch::Result), ShowRequest(String), ShowResult(fetch::Result), ShowPrettyRequest(String), ShowPrettyResult(fetch::Result), + NextPage, + PreviousPage, } // `update` describes how to handle each `Msg`. @@ -155,10 +157,38 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { Msg::ShowPrettyResult(Err(fetch_error)) => { error!("fetch failed {:?}", fetch_error); } + Msg::NextPage => { + match &model.context { + Context::Search(sr) => { + orders.send_msg(Msg::SearchRequest(format!( + "{}?page={}&results_per_page={}", + Url::encode_uri_component(&sr.query), + sr.page + 1, + sr.results_per_page + ))); + } + Context::Thread(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) + }; + } + Msg::PreviousPage => { + match &model.context { + Context::Search(sr) => { + orders.send_msg(Msg::SearchRequest(format!( + "{}?page={}&results_per_page={}", + Url::encode_uri_component(&sr.query), + sr.page.saturating_sub(1), + sr.results_per_page + ))); + } + Context::Thread(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) + }; + } } } -async fn search_request(query: &str) -> fetch::Result { +async fn search_request(query: &str) -> fetch::Result { Request::new(api::search(query)) .method(Method::Get) .fetch() @@ -402,13 +432,14 @@ fn pretty_authors(authors: &str) -> impl Iterator> + '_ { ) } -fn view_mobile_search_results(query: &str, search_results: &SearchSummary) -> Node { +fn view_mobile_search_results(query: &str, search_results: &shared::SearchResult) -> Node { if query.is_empty() { set_title("all mail"); } else { set_title(query); } - let rows = search_results.0.iter().map(|r| { + let summaries = &search_results.summary.0; + let rows = summaries.iter().map(|r| { /* let tid = r.thread.clone(); tr![ @@ -437,16 +468,22 @@ fn view_mobile_search_results(query: &str, search_results: &SearchSummary) -> No hr![], ] }); - div![h1!["Search results"], rows] + let first = search_results.page * search_results.results_per_page; + div![ + h1!["Search results"], + view_search_pager(first, summaries.len(), search_results.total), + rows + ] } -fn view_search_results(query: &str, search_results: &SearchSummary) -> Node { +fn view_search_results(query: &str, search_results: &shared::SearchResult) -> Node { if query.is_empty() { set_title("all mail"); } else { set_title(query); } - let rows = search_results.0.iter().map(|r| { + let summaries = &search_results.summary.0; + let rows = summaries.iter().map(|r| { let tid = r.thread.clone(); tr![ td![ @@ -466,22 +503,46 @@ fn view_search_results(query: &str, search_results: &SearchSummary) -> Node td![C!["date"], &r.date_relative] ] }); - div![table![ - C![ - "table", - "index", - "is-fullwidth", - "is-hoverable", - "is-narrow", - "is-striped", + let first = search_results.page * search_results.results_per_page; + div![ + view_search_pager(first, summaries.len(), search_results.total), + table![ + C![ + "table", + "index", + "is-fullwidth", + "is-hoverable", + "is-narrow", + "is-striped", + ], + thead![tr![ + th![C!["from"], "From"], + th![C!["subject"], "Subject"], + th![C!["date"], "Date"] + ]], + tbody![rows] + ] + ] +} + +fn view_search_pager(page: usize, count: usize, total: usize) -> Node { + nav![ + C!["pagination"], + a![ + C!["pagination-previous", "button"], + "<", + ev(Ev::Click, |_| Msg::PreviousPage) ], - thead![tr![ - th![C!["from"], "From"], - th![C!["subject"], "Subject"], - th![C!["date"], "Date"] - ]], - tbody![rows] - ]] + a![ + C!["pagination-next", "button"], + ">", + ev(Ev::Click, |_| Msg::NextPage) + ], + ul![ + C!["pagination-list"], + li![format!("{} - {} of {}", page, page + count, total)], + ], + ] } fn view_thread(thread_set: &ThreadSet) -> Node { @@ -541,7 +602,7 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node { } else { false }; - let query = query.to_string(); + let query = Url::decode_uri_component(query).unwrap_or("".to_string()); nav![ C!["navbar"], attrs! {At::Role=>"navigation"}, @@ -574,10 +635,12 @@ fn view_header(query: &str, refresh_request: &RefreshingState) -> Node { At::AutoFocus => true.as_at_value(); At::Value => query, }, - input_ev(Ev::Input, Msg::SearchRequest), + input_ev(Ev::Input, |q| Msg::SearchRequest( + Url::encode_uri_component(q) + )), // Resend search on enter. keyboard_ev(Ev::KeyUp, move |e| if e.key_code() == 0x0d { - Msg::SearchRequest(query) + Msg::SearchRequest(Url::encode_uri_component(query)) } else { Msg::Noop }),