Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: 7d563ea34a611f0142093d67b2102c27daf95e94
Timestamp: Fri, 09 Jun 2023 13:36:23 +0000 (1 year ago)

+218 -15 +/-7 browse
web: add searching for topic tags
1diff --git a/Makefile b/Makefile
2index 30457cd..d8135c9 100644
3--- a/Makefile
4+++ b/Makefile
5 @@ -11,7 +11,7 @@ check:
6 fmt:
7 @cargo +nightly fmt --all || cargo fmt --all
8 @cargo sort -w || printf "cargo-sort binary not found in PATH.\n"
9- @djhtml -i $(HTML_FILES) || printf "djhtml binary not found in PATH.\n"
10+ @djhtml $(HTML_FILES) || printf "djhtml binary not found in PATH.\n"
11
12 .PHONY: lint
13 lint:
14 diff --git a/web/src/lib.rs b/web/src/lib.rs
15index e8d4c61..869e5d7 100644
16--- a/web/src/lib.rs
17+++ b/web/src/lib.rs
18 @@ -87,6 +87,7 @@ pub mod help;
19 pub mod lists;
20 pub mod minijinja_utils;
21 pub mod settings;
22+ pub mod topics;
23 pub mod typed_paths;
24 pub mod utils;
25
26 @@ -96,6 +97,7 @@ pub use help::*;
27 pub use lists::*;
28 pub use minijinja_utils::*;
29 pub use settings::*;
30+ pub use topics::*;
31 pub use typed_paths::{tsr::RouterExt, *};
32 pub use utils::*;
33
34 diff --git a/web/src/main.rs b/web/src/main.rs
35index e80c06d..6cdb656 100644
36--- a/web/src/main.rs
37+++ b/web/src/main.rs
38 @@ -55,6 +55,7 @@ fn create_app(shared_state: Arc<AppState>) -> Router {
39 .typed_get(list)
40 .typed_get(list_post)
41 .typed_get(list_post_raw)
42+ .typed_get(list_topics)
43 .typed_get(list_post_eml)
44 .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
45 Role::User..,
46 diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs
47index b7a3d65..d333d2e 100644
48--- a/web/src/minijinja_utils.rs
49+++ b/web/src/minijinja_utils.rs
50 @@ -22,6 +22,7 @@
51 use std::fmt::Write;
52
53 use mailpot::models::ListOwner;
54+ use percent_encoding::utf8_percent_encode;
55
56 use super::*;
57
58 @@ -42,6 +43,7 @@ lazy_static::lazy_static! {
59 strip_carets,
60 urlize,
61 heading,
62+ topics,
63 login_path,
64 logout_path,
65 settings_path,
66 @@ -178,20 +180,7 @@ impl Object for MailingList {
67 "unsubscription_mailto" => Ok(Value::from_serializable(
68 &self.inner.unsubscription_mailto(),
69 )),
70- "topics" => {
71- let mut ul = String::new();
72- write!(&mut ul, r#"<ul class="tags inline">"#)?;
73- for topic in &self.topics {
74- write!(
75- &mut ul,
76- r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name">"#
77- )?;
78- write!(&mut ul, "{}", topic)?;
79- write!(&mut ul, r#"</span></li>"#)?;
80- }
81- write!(&mut ul, r#"</ul>"#)?;
82- Ok(Value::from_safe_string(ul))
83- }
84+ "topics" => topics_common(&self.topics),
85 _ => Err(Error::new(
86 minijinja::ErrorKind::UnknownMethod,
87 format!("object has no method named {name}"),
88 @@ -596,6 +585,62 @@ pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Res
89 }
90 }
91
92+ /// Make an array of topic strings into html badges.
93+ ///
94+ /// # Example
95+ /// ```rust
96+ /// use mailpot_web::minijinja_utils::topics;
97+ /// use minijinja::value::Value;
98+ ///
99+ /// let v: Value = topics(Value::from_serializable(&vec![
100+ /// "a".to_string(),
101+ /// "aab".to_string(),
102+ /// "aaab".to_string(),
103+ /// ]))
104+ /// .unwrap();
105+ /// assert_eq!(
106+ /// "<ul class=\"tags inline\"><li class=\"tag\" \
107+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
108+ /// href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
109+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
110+ /// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
111+ /// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
112+ /// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
113+ /// &v.to_string()
114+ /// );
115+ /// ```
116+ pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
117+ topics.try_iter()?;
118+ let topics: Vec<String> = topics
119+ .try_iter()?
120+ .map(|v| v.to_string())
121+ .collect::<Vec<String>>();
122+ topics_common(&topics)
123+ }
124+
125+ pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
126+ let mut ul = String::new();
127+ write!(&mut ul, r#"<ul class="tags inline">"#)?;
128+ for topic in topics {
129+ write!(
130+ &mut ul,
131+ r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
132+ )?;
133+ write!(&mut ul, "{}", TopicsPath)?;
134+ write!(&mut ul, r#"?query="#)?;
135+ write!(
136+ &mut ul,
137+ "{}",
138+ utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
139+ )?;
140+ write!(&mut ul, r#"">"#)?;
141+ write!(&mut ul, "{}", topic)?;
142+ write!(&mut ul, r#"</a></span></li>"#)?;
143+ }
144+ write!(&mut ul, r#"</ul>"#)?;
145+ Ok(Value::from_safe_string(ul))
146+ }
147+
148 #[cfg(test)]
149 mod tests {
150 use super::*;
151 diff --git a/web/src/templates/topics.html b/web/src/templates/topics.html
152new file mode 100644
153index 0000000..cb5e38a
154--- /dev/null
155+++ b/web/src/templates/topics.html
156 @@ -0,0 +1,12 @@
157+ {% include "header.html" %}
158+ <div class="body">
159+ <p>Results for <em>{{ term }}</em></p>
160+ <div class="entry">
161+ <ul>
162+ {% for r in results %}
163+ <li><a href="{{ list_path(r.pk) }}">{{ r.id }}</a>. {% if r.topics|length > 0 %}<br aria-hidden="true"><br aria-hidden="true"><span><em>Topics</em>:</span>&nbsp;{{ r.topics_html() }}{% endif %}
164+ {% endfor %}
165+ </ul>
166+ </div>
167+ </div>
168+ {% include "footer.html" %}
169 diff --git a/web/src/topics.rs b/web/src/topics.rs
170new file mode 100644
171index 0000000..5047e1b
172--- /dev/null
173+++ b/web/src/topics.rs
174 @@ -0,0 +1,139 @@
175+ /*
176+ * This file is part of mailpot
177+ *
178+ * Copyright 2020 - Manos Pitsidianakis
179+ *
180+ * This program is free software: you can redistribute it and/or modify
181+ * it under the terms of the GNU Affero General Public License as
182+ * published by the Free Software Foundation, either version 3 of the
183+ * License, or (at your option) any later version.
184+ *
185+ * This program is distributed in the hope that it will be useful,
186+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
187+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
188+ * GNU Affero General Public License for more details.
189+ *
190+ * You should have received a copy of the GNU Affero General Public License
191+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
192+ */
193+
194+ use super::*;
195+
196+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
197+ pub struct SearchTerm {
198+ query: Option<String>,
199+ }
200+
201+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
202+ pub struct SearchResult {
203+ pk: i64,
204+ id: String,
205+ topics: Vec<String>,
206+ }
207+
208+ impl std::fmt::Display for SearchResult {
209+ fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
210+ write!(fmt, "{:?}", self)
211+ }
212+ }
213+
214+ impl Object for SearchResult {
215+ fn kind(&self) -> minijinja::value::ObjectKind {
216+ minijinja::value::ObjectKind::Struct(self)
217+ }
218+
219+ fn call_method(
220+ &self,
221+ _state: &minijinja::State,
222+ name: &str,
223+ _args: &[Value],
224+ ) -> std::result::Result<Value, Error> {
225+ match name {
226+ "topics_html" => crate::minijinja_utils::topics_common(&self.topics),
227+ _ => Err(Error::new(
228+ minijinja::ErrorKind::UnknownMethod,
229+ format!("object has no method named {name}"),
230+ )),
231+ }
232+ }
233+ }
234+
235+ impl minijinja::value::StructObject for SearchResult {
236+ fn get_field(&self, name: &str) -> Option<Value> {
237+ match name {
238+ "pk" => Some(Value::from_serializable(&self.pk)),
239+ "id" => Some(Value::from_serializable(&self.id)),
240+ "topics" => Some(Value::from_serializable(&self.topics)),
241+ _ => None,
242+ }
243+ }
244+
245+ fn static_fields(&self) -> Option<&'static [&'static str]> {
246+ Some(&["pk", "id", "topics"][..])
247+ }
248+ }
249+ pub async fn list_topics(
250+ _: TopicsPath,
251+ mut session: WritableSession,
252+ Query(SearchTerm { query: term }): Query<SearchTerm>,
253+ auth: AuthContext,
254+ State(state): State<Arc<AppState>>,
255+ ) -> Result<Html<String>, ResponseError> {
256+ let db = Connection::open_db(state.conf.clone())?.trusted();
257+
258+ let results: Vec<Value> = {
259+ if let Some(term) = term.as_ref() {
260+ let mut stmt = db.connection.prepare(
261+ "SELECT DISTINCT list.pk, list.id, list.topics FROM list, json_each(list.topics) \
262+ WHERE json_each.value IS ?;",
263+ )?;
264+ let iter = stmt.query_map([&term], |row| {
265+ let pk = row.get(0)?;
266+ let id = row.get(1)?;
267+ let topics = mailpot::models::MailingList::topics_from_json_value(row.get(2)?)?;
268+ Ok(Value::from_object(SearchResult { pk, id, topics }))
269+ })?;
270+ let mut ret = vec![];
271+ for el in iter {
272+ let el = el?;
273+ ret.push(el);
274+ }
275+ ret
276+ } else {
277+ db.lists()?
278+ .into_iter()
279+ .map(DbVal::into_inner)
280+ .map(|l| SearchResult {
281+ pk: l.pk,
282+ id: l.id,
283+ topics: l.topics,
284+ })
285+ .map(Value::from_object)
286+ .collect()
287+ }
288+ };
289+
290+ let crumbs = vec![
291+ Crumb {
292+ label: "Home".into(),
293+ url: "/".into(),
294+ },
295+ Crumb {
296+ label: "Search for topics".into(),
297+ url: TopicsPath.to_crumb(),
298+ },
299+ ];
300+ let context = minijinja::context! {
301+ canonical_url => TopicsPath.to_crumb(),
302+ term,
303+ results,
304+ page_title => "Topic Search Results",
305+ description => "",
306+ current_user => auth.current_user,
307+ messages => session.drain_messages(),
308+ crumbs,
309+ };
310+ Ok(Html(
311+ TEMPLATES.get_template("topics.html")?.render(context)?,
312+ ))
313+ }
314 diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs
315index 17524cc..e51d801 100644
316--- a/web/src/typed_paths.rs
317+++ b/web/src/typed_paths.rs
318 @@ -130,6 +130,10 @@ pub struct SettingsPath;
319 #[typed_path("/help/")]
320 pub struct HelpPath;
321
322+ #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
323+ #[typed_path("/topics/")]
324+ pub struct TopicsPath;
325+
326 macro_rules! unit_impl {
327 ($ident:ident, $ty:expr) => {
328 pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {