+218 -15 +/-7 browse
1 | diff --git a/Makefile b/Makefile |
2 | index 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 |
15 | index 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 |
35 | index 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 |
47 | index 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 |
152 | new file mode 100644 |
153 | index 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> {{ 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 |
170 | new file mode 100644 |
171 | index 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 |
315 | index 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> { |