+522 -153 +/-14 browse
1 | diff --git a/Cargo.lock b/Cargo.lock |
2 | index 256ce4c..7f37f67 100644 |
3 | --- a/Cargo.lock |
4 | +++ b/Cargo.lock |
5 | @@ -2401,6 +2401,7 @@ dependencies = [ |
6 | "memo-map", |
7 | "self_cell", |
8 | "serde", |
9 | + "serde_json", |
10 | ] |
11 | |
12 | [[package]] |
13 | diff --git a/web/Cargo.toml b/web/Cargo.toml |
14 | index 8cf5eec..724e2f0 100644 |
15 | --- a/web/Cargo.toml |
16 | +++ b/web/Cargo.toml |
17 | @@ -34,7 +34,7 @@ http = "0.2" |
18 | indexmap = { version = "1.9" } |
19 | lazy_static = "^1.4" |
20 | mailpot = { version = "^0.1", path = "../core" } |
21 | - minijinja = { version = "0.31.0", features = ["source", ] } |
22 | + minijinja = { version = "0.31.0", features = ["source", "builtins", "json"] } |
23 | percent-encoding = { version = "^2.1" } |
24 | rand = { version = "^0.8", features = ["min_const_gen"] } |
25 | serde = { version = "^1", features = ["derive", ] } |
26 | diff --git a/web/src/lib.rs b/web/src/lib.rs |
27 | index a7c35bd..82b06e4 100644 |
28 | --- a/web/src/lib.rs |
29 | +++ b/web/src/lib.rs |
30 | @@ -96,7 +96,7 @@ pub use cal::{calendarize, *}; |
31 | pub use help::*; |
32 | pub use lists::{ |
33 | list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw, |
34 | - list_subscribers, PostPolicySettings, SubscriptionPolicySettings, |
35 | + list_search_query_GET, list_subscribers, PostPolicySettings, SubscriptionPolicySettings, |
36 | }; |
37 | pub use minijinja_utils::*; |
38 | pub use settings::{ |
39 | diff --git a/web/src/lists.rs b/web/src/lists.rs |
40 | index 82b3bba..7265dae 100644 |
41 | --- a/web/src/lists.rs |
42 | +++ b/web/src/lists.rs |
43 | @@ -42,7 +42,8 @@ pub async fn list( |
44 | }; |
45 | let post_policy = db.list_post_policy(list.pk)?; |
46 | let subscription_policy = db.list_subscription_policy(list.pk)?; |
47 | - let months = db.months(list.pk)?; |
48 | + let mut months = db.months(list.pk)?; |
49 | + months.sort(); |
50 | let user_context = auth |
51 | .current_user |
52 | .as_ref() |
53 | @@ -53,7 +54,7 @@ pub async fn list( |
54 | .iter() |
55 | .map(|p| (p.message_id.as_str(), p)) |
56 | .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>(); |
57 | - let mut hist = months |
58 | + let mut hists = months |
59 | .iter() |
60 | .map(|m| (m.to_string(), [0usize; 31])) |
61 | .collect::<HashMap<String, [usize; 31]>>(); |
62 | @@ -90,7 +91,7 @@ pub async fn list( |
63 | .ok() |
64 | .map(|d| d.day()) |
65 | { |
66 | - hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1; |
67 | + hists.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1; |
68 | } |
69 | let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None).ok()?; |
70 | let mut msg_id = &post.message_id[1..]; |
71 | @@ -118,19 +119,21 @@ pub async fn list( |
72 | Some(ret) |
73 | }) |
74 | .collect::<Vec<_>>(); |
75 | - let crumbs = vec![ |
76 | - Crumb { |
77 | - label: "Home".into(), |
78 | - url: "/".into(), |
79 | - }, |
80 | - Crumb { |
81 | - label: list.name.clone().into(), |
82 | - url: ListPath(list.id.to_string().into()).to_crumb(), |
83 | - }, |
84 | - ]; |
85 | + |
86 | + let crumbs = crumbs![Crumb { |
87 | + label: list.name.clone().into(), |
88 | + url: ListPath(list.id.to_string().into()).to_crumb(), |
89 | + },]; |
90 | let list_owners = db.list_owners(list.pk)?; |
91 | let mut list_obj = MailingList::from(list.clone()); |
92 | list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators); |
93 | + let has_more_months = if months.len() > 2 { |
94 | + let len = months.len(); |
95 | + months.drain(0..(len - 2)); |
96 | + true |
97 | + } else { |
98 | + false |
99 | + }; |
100 | let context = minijinja::context! { |
101 | canonical_url => ListPath::from(&list).to_crumb(), |
102 | page_title => &list.name, |
103 | @@ -139,7 +142,8 @@ pub async fn list( |
104 | subscription_policy, |
105 | preamble => true, |
106 | months, |
107 | - hists => &hist, |
108 | + has_more_months, |
109 | + hists, |
110 | posts => posts_ctx, |
111 | list => Value::from_object(list_obj), |
112 | current_user => auth.current_user, |
113 | @@ -208,11 +212,7 @@ pub async fn list_post( |
114 | { |
115 | subject_ref = subject_ref[2 + list.id.len()..].trim(); |
116 | } |
117 | - let crumbs = vec![ |
118 | - Crumb { |
119 | - label: "Home".into(), |
120 | - url: "/".into(), |
121 | - }, |
122 | + let crumbs = crumbs![ |
123 | Crumb { |
124 | label: list.name.clone().into(), |
125 | url: ListPath(list.id.to_string().into()).to_crumb(), |
126 | @@ -317,11 +317,7 @@ pub async fn list_edit( |
127 | .unwrap_or(0) |
128 | }; |
129 | |
130 | - let crumbs = vec![ |
131 | - Crumb { |
132 | - label: "Home".into(), |
133 | - url: "/".into(), |
134 | - }, |
135 | + let crumbs = crumbs![ |
136 | Crumb { |
137 | label: list.name.clone().into(), |
138 | url: ListPath(list.id.to_string().into()).to_crumb(), |
139 | @@ -696,11 +692,7 @@ pub async fn list_subscribers( |
140 | ret |
141 | }; |
142 | |
143 | - let crumbs = vec![ |
144 | - Crumb { |
145 | - label: "Home".into(), |
146 | - url: "/".into(), |
147 | - }, |
148 | + let crumbs = crumbs![ |
149 | Crumb { |
150 | label: list.name.clone().into(), |
151 | url: ListPath(list.id.to_string().into()).to_crumb(), |
152 | @@ -784,11 +776,7 @@ pub async fn list_candidates( |
153 | ret |
154 | }; |
155 | |
156 | - let crumbs = vec![ |
157 | - Crumb { |
158 | - label: "Home".into(), |
159 | - url: "/".into(), |
160 | - }, |
161 | + let crumbs = crumbs![ |
162 | Crumb { |
163 | label: list.name.clone().into(), |
164 | url: ListPath(list.id.to_string().into()).to_crumb(), |
165 | @@ -819,3 +807,90 @@ pub async fn list_candidates( |
166 | .render(context)?, |
167 | )) |
168 | } |
169 | + |
170 | + /// Mailing list post search. |
171 | + #[allow(non_snake_case)] |
172 | + pub async fn list_search_query_GET( |
173 | + ListSearchPath(id): ListSearchPath, |
174 | + mut session: WritableSession, |
175 | + Query(query): Query<DateQueryParameter>, |
176 | + auth: AuthContext, |
177 | + State(state): State<Arc<AppState>>, |
178 | + ) -> Result<Html<String>, ResponseError> { |
179 | + let db = Connection::open_db(state.conf.clone())?.trusted(); |
180 | + let Some(list) = (match id { |
181 | + ListPathIdentifier::Pk(id) => db.list(id)?, |
182 | + ListPathIdentifier::Id(id) => db.list_by_id(id)?, |
183 | + }) else { |
184 | + return Err(ResponseError::new( |
185 | + "List not found".to_string(), |
186 | + StatusCode::NOT_FOUND, |
187 | + )); |
188 | + }; |
189 | + match query { |
190 | + DateQueryParameter::Month { ref month } => { |
191 | + let mut stmt = db.connection.prepare( |
192 | + "SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS \ |
193 | + month_year FROM post WHERE list = ? AND month_year = ? ORDER BY timestamp DESC;", |
194 | + )?; |
195 | + let iter = stmt.query_map(rusqlite::params![&list.pk, &month], |row| { |
196 | + let pk = row.get("pk")?; |
197 | + Ok(DbVal( |
198 | + Post { |
199 | + pk, |
200 | + list: row.get("list")?, |
201 | + envelope_from: row.get("envelope_from")?, |
202 | + address: row.get("address")?, |
203 | + message_id: row.get("message_id")?, |
204 | + message: row.get("message")?, |
205 | + timestamp: row.get("timestamp")?, |
206 | + datetime: row.get("datetime")?, |
207 | + month_year: row.get("month_year")?, |
208 | + }, |
209 | + pk, |
210 | + )) |
211 | + })?; |
212 | + let mut ret = vec![]; |
213 | + for post in iter { |
214 | + let post = post?; |
215 | + ret.push(post); |
216 | + } |
217 | + |
218 | + let crumbs = crumbs![ |
219 | + Crumb { |
220 | + label: list.name.clone().into(), |
221 | + url: ListPath(list.id.to_string().into()).to_crumb(), |
222 | + }, |
223 | + Crumb { |
224 | + label: query.to_string().into(), |
225 | + url: ListSearchPath(ListPathIdentifier::Id(list.id.to_string())).to_crumb(), |
226 | + } |
227 | + ]; |
228 | + let list_owners = db.list_owners(list.pk)?; |
229 | + let mut list_obj = MailingList::from(list.clone()); |
230 | + list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators); |
231 | + let user_context = auth |
232 | + .current_user |
233 | + .as_ref() |
234 | + .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok()); |
235 | + let context = minijinja::context! { |
236 | + canonical_url => ListSearchPath(ListPathIdentifier::Id(list.id.to_string())), |
237 | + search_term => query, |
238 | + page_title => &list.name, |
239 | + description => &list.description, |
240 | + preamble => false, |
241 | + posts => ret, |
242 | + list => Value::from_object(list_obj), |
243 | + current_user => auth.current_user, |
244 | + user_context, |
245 | + messages => session.drain_messages(), |
246 | + crumbs, |
247 | + }; |
248 | + Ok(Html( |
249 | + TEMPLATES.get_template("lists/list.html")?.render(context)?, |
250 | + )) |
251 | + } |
252 | + DateQueryParameter::Date { date } => todo!(), |
253 | + DateQueryParameter::Range { start, end } => todo!(), |
254 | + } |
255 | + } |
256 | diff --git a/web/src/main.rs b/web/src/main.rs |
257 | index 0882abc..6b90dd8 100644 |
258 | --- a/web/src/main.rs |
259 | +++ b/web/src/main.rs |
260 | @@ -57,6 +57,7 @@ fn create_app(shared_state: Arc<AppState>) -> Router { |
261 | .typed_get(list_post_raw) |
262 | .typed_get(list_topics) |
263 | .typed_get(list_post_eml) |
264 | + .typed_get(list_search_query_GET) |
265 | .typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect( |
266 | Role::User.., |
267 | Arc::clone(&login_url), |
268 | diff --git a/web/src/minijinja_utils.rs b/web/src/minijinja_utils.rs |
269 | index 5238343..038b823 100644 |
270 | --- a/web/src/minijinja_utils.rs |
271 | +++ b/web/src/minijinja_utils.rs |
272 | @@ -49,13 +49,16 @@ lazy_static::lazy_static! { |
273 | settings_path, |
274 | help_path, |
275 | list_path, |
276 | + list_calendar_path, |
277 | + list_search_query, |
278 | list_settings_path, |
279 | list_edit_path, |
280 | list_subscribers_path, |
281 | list_candidates_path, |
282 | list_post_path, |
283 | post_raw_path, |
284 | - post_eml_path |
285 | + post_eml_path, |
286 | + year_month_to_query, |
287 | ); |
288 | add!(filter pluralize); |
289 | // Load compressed templates. They are constructed in build.rs. See |
290 | @@ -243,31 +246,33 @@ pub fn calendarize( |
291 | } |
292 | }}; |
293 | } |
294 | - let month = args.as_str().unwrap(); |
295 | + let year_month = args.as_str().unwrap(); |
296 | let hist = hists |
297 | - .get_item(&Value::from(month))? |
298 | + .get_item(&Value::from(year_month))? |
299 | .as_seq() |
300 | .unwrap() |
301 | .iter() |
302 | .map(|v| usize::try_from(v).unwrap()) |
303 | .collect::<Vec<usize>>(); |
304 | let sum: usize = hists |
305 | - .get_item(&Value::from(month))? |
306 | + .get_item(&Value::from(year_month))? |
307 | .as_seq() |
308 | .unwrap() |
309 | .iter() |
310 | .map(|v| usize::try_from(v).unwrap()) |
311 | .sum(); |
312 | - let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap(); |
313 | + let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", year_month), "%F").unwrap(); |
314 | // Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun] |
315 | Ok(minijinja::context! { |
316 | + date, |
317 | month_name => month!(date.month()), |
318 | - month => month, |
319 | + month => year_month, |
320 | month_int => date.month() as usize, |
321 | year => date.year(), |
322 | weeks => cal::calendarize_with_offset(date, 1), |
323 | hist => hist, |
324 | sum, |
325 | + unknown => date.year() == 1970, |
326 | }) |
327 | } |
328 | |
329 | diff --git a/web/src/templates/calendar.html b/web/src/templates/calendar.html |
330 | index 8eccf8f..f8a8eb9 100644 |
331 | --- a/web/src/templates/calendar.html |
332 | +++ b/web/src/templates/calendar.html |
333 | @@ -3,9 +3,12 @@ |
334 | {% if c.sum > 0 %} |
335 | <table> |
336 | <caption align="top"> |
337 | - <!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">--> |
338 | - <a href="#" style="color: GrayText;"> |
339 | - {{ c.month_name }} {{ c.year }} |
340 | + <a href="{{ list_search_query(list.id) }}?month={{ year_month_to_query(date) }}" style="color: GrayText;"> |
341 | + {% if c.unknown %} |
342 | + Unknown |
343 | + {% else %} |
344 | + {{ c.month_name }} {{ c.year }} |
345 | + {% endif %} |
346 | </a> |
347 | </caption> |
348 | <thead> |
349 | diff --git a/web/src/templates/css.html b/web/src/templates/css.html |
350 | index f644210..2fbde20 100644 |
351 | --- a/web/src/templates/css.html |
352 | +++ b/web/src/templates/css.html |
353 | @@ -129,6 +129,11 @@ |
354 | code { |
355 | font-family: var(--monospace-system-stack); |
356 | overflow-wrap: anywhere; |
357 | + background: var(--color-tan); |
358 | + color: black; |
359 | + border-radius: 4px; |
360 | + padding-left: 4px; |
361 | + padding-right: 4px; |
362 | } |
363 | |
364 | pre { |
365 | @@ -162,6 +167,7 @@ |
366 | --code-background: #8fbcbb; |
367 | --a-visited-text: var(--a-normal-text); |
368 | --tag-border-color: black; |
369 | + --color-tan: #fef6e9; |
370 | } |
371 | |
372 | @media (prefers-color-scheme: light) { |
373 | @@ -445,18 +451,6 @@ |
374 | font-size: small; |
375 | } |
376 | |
377 | - /* If only the root crumb is visible, hide it to avoid unnecessary visual clutter */ |
378 | - li.crumb:only-child>span[aria-current="page"] { |
379 | - --secs: 150ms; |
380 | - transition: all var(--secs) linear; |
381 | - color: transparent; |
382 | - } |
383 | - |
384 | - li.crumb:only-child>span[aria-current="page"]:hover { |
385 | - transition: all var(--secs) linear; |
386 | - color: revert; |
387 | - } |
388 | - |
389 | .crumb, .crumb>a { |
390 | display: inline; |
391 | } |
392 | @@ -595,6 +589,10 @@ |
393 | opacity: 0.2; |
394 | } |
395 | |
396 | + div.calendar a { |
397 | + place-self: center; |
398 | + } |
399 | + |
400 | div.calendar { |
401 | display: flex; |
402 | flex-wrap: wrap; |
403 | diff --git a/web/src/templates/header.html b/web/src/templates/header.html |
404 | index 6d2ab7d..f3006f0 100644 |
405 | --- a/web/src/templates/header.html |
406 | +++ b/web/src/templates/header.html |
407 | @@ -16,7 +16,7 @@ |
408 | {% endif %} |
409 | {% include "menu.html" %} |
410 | <div class="page-header"> |
411 | - {% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs"> |
412 | + {% if crumbs|length > 0 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs"> |
413 | <ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ urlize(crumb.url) }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol> |
414 | </nav>{% endif %} |
415 | {% if page_title %} |
416 | diff --git a/web/src/templates/lists/list.html b/web/src/templates/lists/list.html |
417 | index 7eb868e..eb062b2 100644 |
418 | --- a/web/src/templates/lists/list.html |
419 | +++ b/web/src/templates/lists/list.html |
420 | @@ -4,107 +4,118 @@ |
421 | <br aria-hidden="true"> |
422 | <br aria-hidden="true"> |
423 | {% endif %} |
424 | - {% if list.description %} |
425 | - <p title="mailing list description">{{ list.description }}</p> |
426 | - {% else %} |
427 | - <p title="mailing list description">No list description.</p> |
428 | - {% endif %} |
429 | - <br aria-hidden="true"> |
430 | - {% if current_user and subscription_policy and subscription_policy.open %} |
431 | - {% if user_context %} |
432 | - <form method="post" action="{{ settings_path() }}" class="settings-form"> |
433 | - <input type="hidden" name="type", value="unsubscribe"> |
434 | - <input type="hidden" name="list_pk", value="{{ list.pk }}"> |
435 | - <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}"> |
436 | - </form> |
437 | - <br /> |
438 | + {% if not search_term %} |
439 | + {% if list.description %} |
440 | + <p title="mailing list description">{{ list.description }}</p> |
441 | {% else %} |
442 | - <form method="post" action="{{ settings_path() }}" class="settings-form"> |
443 | - <input type="hidden" name="type", value="subscribe"> |
444 | - <input type="hidden" name="list_pk", value="{{ list.pk }}"> |
445 | - <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}"> |
446 | - </form> |
447 | - <br /> |
448 | + <p title="mailing list description">No list description.</p> |
449 | {% endif %} |
450 | - {% endif %} |
451 | - {% if preamble %} |
452 | - <section id="preamble" class="preamble" aria-label="mailing list instructions"> |
453 | - {% if preamble.custom %} |
454 | - {{ preamble.custom|safe }} |
455 | + <br aria-hidden="true"> |
456 | + {% if current_user and subscription_policy and subscription_policy.open %} |
457 | + {% if user_context %} |
458 | + <form method="post" action="{{ settings_path() }}" class="settings-form"> |
459 | + <input type="hidden" name="type", value="unsubscribe"> |
460 | + <input type="hidden" name="list_pk", value="{{ list.pk }}"> |
461 | + <input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}"> |
462 | + </form> |
463 | + <br /> |
464 | {% else %} |
465 | - {% if subscription_policy %} |
466 | - {% if subscription_policy.open or subscription_policy.request %} |
467 | - {{ heading(3, "Subscribe") }} |
468 | - {% set subscription_mailto=list.subscription_mailto() %} |
469 | - {% if subscription_mailto %} |
470 | - {% if subscription_mailto.subject %} |
471 | - <p> |
472 | - <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code> |
473 | - </p> |
474 | + <form method="post" action="{{ settings_path() }}" class="settings-form"> |
475 | + <input type="hidden" name="type", value="subscribe"> |
476 | + <input type="hidden" name="list_pk", value="{{ list.pk }}"> |
477 | + <input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}"> |
478 | + </form> |
479 | + <br /> |
480 | + {% endif %} |
481 | + {% endif %} |
482 | + {% if preamble %} |
483 | + <section id="preamble" class="preamble" aria-label="mailing list instructions"> |
484 | + {% if preamble.custom %} |
485 | + {{ preamble.custom|safe }} |
486 | + {% else %} |
487 | + {% if subscription_policy %} |
488 | + {% if subscription_policy.open or subscription_policy.request %} |
489 | + {{ heading(3, "Subscribe") }} |
490 | + {% set subscription_mailto=list.subscription_mailto() %} |
491 | + {% if subscription_mailto %} |
492 | + {% if subscription_mailto.subject %} |
493 | + <p> |
494 | + <a href="mailto:{{ subscription_mailto.address|safe }}?subject={{ subscription_mailto.subject|safe }}"><code>{{ subscription_mailto.address }}</code></a> with the following subject: <code>{{ subscription_mailto.subject}}</code> |
495 | + </p> |
496 | + {% else %} |
497 | + <p> |
498 | + <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a> |
499 | + </p> |
500 | + {% endif %} |
501 | {% else %} |
502 | - <p> |
503 | - <a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a> |
504 | - </p> |
505 | + <p>List is not open for subscriptions.</p> |
506 | {% endif %} |
507 | - {% else %} |
508 | - <p>List is not open for subscriptions.</p> |
509 | - {% endif %} |
510 | |
511 | - {% set unsubscription_mailto=list.unsubscription_mailto() %} |
512 | - {% if unsubscription_mailto %} |
513 | - {{ heading(3, "Unsubscribe") }} |
514 | - {% if unsubscription_mailto.subject %} |
515 | - <p> |
516 | - <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code> |
517 | - </p> |
518 | - {% else %} |
519 | - <p> |
520 | - <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> |
521 | - </p> |
522 | + {% set unsubscription_mailto=list.unsubscription_mailto() %} |
523 | + {% if unsubscription_mailto %} |
524 | + {{ heading(3, "Unsubscribe") }} |
525 | + {% if unsubscription_mailto.subject %} |
526 | + <p> |
527 | + <a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code> |
528 | + </p> |
529 | + {% else %} |
530 | + <p> |
531 | + <a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> |
532 | + </p> |
533 | + {% endif %} |
534 | {% endif %} |
535 | {% endif %} |
536 | {% endif %} |
537 | - {% endif %} |
538 | |
539 | - {% if post_policy %} |
540 | - {{ heading(3, "Post") }} |
541 | - {% if post_policy.announce_only %} |
542 | - <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p> |
543 | - {% elif post_policy.subscription_only %} |
544 | - <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p> |
545 | - <p>If you are subscribed, you can send new posts to: |
546 | - <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a> |
547 | - </p> |
548 | - {% elif post_policy.approval_needed or post_policy.no_subscriptions %} |
549 | - <p>List is open to all posts <em>after approval</em> by the list owners.</p> |
550 | - <p>You can send new posts to: |
551 | - <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a> |
552 | - </p> |
553 | - {% else %} |
554 | - <p>List is not open for submissions.</p> |
555 | + {% if post_policy %} |
556 | + {{ heading(3, "Post") }} |
557 | + {% if post_policy.announce_only %} |
558 | + <p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p> |
559 | + {% elif post_policy.subscription_only %} |
560 | + <p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p> |
561 | + <p>If you are subscribed, you can send new posts to: |
562 | + <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a> |
563 | + </p> |
564 | + {% elif post_policy.approval_needed or post_policy.no_subscriptions %} |
565 | + <p>List is open to all posts <em>after approval</em> by the list owners.</p> |
566 | + <p>You can send new posts to: |
567 | + <a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a> |
568 | + </p> |
569 | + {% else %} |
570 | + <p>List is not open for submissions.</p> |
571 | + {% endif %} |
572 | {% endif %} |
573 | {% endif %} |
574 | - {% endif %} |
575 | - </section> |
576 | + </section> |
577 | + {% endif %} |
578 | + {% if months %} |
579 | + <section class="list" aria-hidden="true"> |
580 | + {{ heading(3, "Calendar") }} |
581 | + <div class="calendar"> |
582 | + {%- from "calendar.html" import cal %} |
583 | + {% if has_more_months %} |
584 | + <a href="{{ list_calendar_path(list.id) }}">See all history..…</a> |
585 | + {% endif %} |
586 | + {% for date in months %} |
587 | + {{ cal(date, hists) }} |
588 | + {% endfor %} |
589 | + </div> |
590 | + </section> |
591 | + {% endif %} |
592 | {% endif %} |
593 | - <section class="list" aria-hidden="true"> |
594 | - {{ heading(3, "Calendar") }} |
595 | - <div class="calendar"> |
596 | - {%- from "calendar.html" import cal %} |
597 | - {% for date in months %} |
598 | - {{ cal(date, hists) }} |
599 | - {% endfor %} |
600 | - </div> |
601 | - </section> |
602 | <section aria-label="mailing list posts"> |
603 | - {{ heading(3, "Posts") }} |
604 | + {% if search_term %} |
605 | + {{ heading(3, "Results for " ~ search_term) }} |
606 | + {% else %} |
607 | + {{ heading(3, "Posts") }} |
608 | + {% endif %} |
609 | <div class="posts entries" role="list" aria-label="list of mailing list posts"> |
610 | <p>{{ posts | length }} post{{ posts|length|pluralize }}</p> |
611 | {% for post in posts %} |
612 | <div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}"> |
613 | - <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a> <span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span> |
614 | + <span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>{% if post.replies %} <span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span>{% endif %}</span> |
615 | <span class="metadata"><span aria-hidden="true">👤 </span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆 </span><span class="date" title="post date">{{ post.datetime }}</span></span> |
616 | - {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">💓 </span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %} |
617 | + {% if post.replies and post.replies > 0 %}<span class="metadata"><span aria-hidden="true">💓 </span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %} |
618 | <span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span> |
619 | </div> |
620 | {% endfor %} |
621 | diff --git a/web/src/templates/topics.html b/web/src/templates/topics.html |
622 | index a34216c..4df33e5 100644 |
623 | --- a/web/src/templates/topics.html |
624 | +++ b/web/src/templates/topics.html |
625 | @@ -1,6 +1,6 @@ |
626 | {% include "header.html" %} |
627 | <div class="body"> |
628 | - <p style="margin-block-end: 1rem;">Results for <em>{{ term }}</em></p> |
629 | + {% if term %}<p style="margin-block-end: 1rem;">Results for <em>{{ term }}</em></p>{% endif %} |
630 | <div class="entry"> |
631 | <dl class="lists" aria-label="list of mailing lists"> |
632 | {% for list in results %} |
633 | diff --git a/web/src/topics.rs b/web/src/topics.rs |
634 | index 13c2b9a..f9ba3fd 100644 |
635 | --- a/web/src/topics.rs |
636 | +++ b/web/src/topics.rs |
637 | @@ -127,21 +127,20 @@ pub async fn list_topics( |
638 | } |
639 | }; |
640 | |
641 | - let crumbs = vec![ |
642 | - Crumb { |
643 | - label: "Home".into(), |
644 | - url: "/".into(), |
645 | - }, |
646 | - Crumb { |
647 | - label: "Search for topics".into(), |
648 | - url: TopicsPath.to_crumb(), |
649 | - }, |
650 | - ]; |
651 | + let crumbs = crumbs![Crumb { |
652 | + label: if term.is_some() { |
653 | + "Search for topics" |
654 | + } else { |
655 | + "Topics" |
656 | + } |
657 | + .into(), |
658 | + url: TopicsPath.to_crumb(), |
659 | + },]; |
660 | let context = minijinja::context! { |
661 | canonical_url => TopicsPath.to_crumb(), |
662 | term, |
663 | results, |
664 | - page_title => "Topic Search Results", |
665 | + page_title => if term.is_some() { "Topic Search Results" } else { "Topics" }, |
666 | description => "", |
667 | current_user => auth.current_user, |
668 | messages => session.drain_messages(), |
669 | diff --git a/web/src/typed_paths.rs b/web/src/typed_paths.rs |
670 | index c21656d..310c41b 100644 |
671 | --- a/web/src/typed_paths.rs |
672 | +++ b/web/src/typed_paths.rs |
673 | @@ -83,6 +83,29 @@ impl From<&DbVal<mailpot::models::MailingList>> for ListPath { |
674 | } |
675 | |
676 | #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
677 | + #[typed_path("/list/:id/calendar/")] |
678 | + pub struct ListCalendarPath(pub ListPathIdentifier); |
679 | + |
680 | + impl From<&DbVal<mailpot::models::MailingList>> for ListCalendarPath { |
681 | + fn from(val: &DbVal<mailpot::models::MailingList>) -> Self { |
682 | + Self(ListPathIdentifier::Id(val.id.clone())) |
683 | + } |
684 | + } |
685 | + |
686 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)] |
687 | + pub struct YearMonth(pub chrono::NaiveDate); |
688 | + |
689 | + impl std::fmt::Display for YearMonth { |
690 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
691 | + write!(f, "{}-{}", self.0.year(), self.0.month()) |
692 | + } |
693 | + } |
694 | + |
695 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
696 | + #[typed_path("/list/:id/search")] |
697 | + pub struct ListSearchPath(pub ListPathIdentifier); |
698 | + |
699 | + #[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)] |
700 | #[typed_path("/list/:id/posts/:msgid/")] |
701 | pub struct ListPostPath(pub ListPathIdentifier, pub String); |
702 | |
703 | @@ -161,6 +184,38 @@ macro_rules! list_id_impl { |
704 | ) |
705 | } |
706 | }; |
707 | + ($ident:ident, $ty:tt, $arg:path, $($arg_fn:tt)*) => { |
708 | + pub fn $ident(state: &minijinja::State, id: Value, arg: Value) -> std::result::Result<Value, Error> { |
709 | + let arg: $arg = if let Some(arg) = arg.as_str() { |
710 | + #[allow(clippy::redundant_closure_call)] |
711 | + ($($arg_fn)*)(arg) |
712 | + .map_err(|err| { |
713 | + Error::new( |
714 | + minijinja::ErrorKind::InvalidOperation, |
715 | + err.to_string() |
716 | + ) |
717 | + })? |
718 | + } else { |
719 | + return Err(Error::new( |
720 | + minijinja::ErrorKind::UnknownMethod, |
721 | + "Second argument of list_post_path must be a string.", |
722 | + )); |
723 | + }; |
724 | + urlize( |
725 | + state, |
726 | + if let Some(id) = id.as_str() { |
727 | + Value::from( |
728 | + $ty(ListPathIdentifier::Id(id.to_string()), arg) |
729 | + .to_crumb() |
730 | + .to_string(), |
731 | + ) |
732 | + } else { |
733 | + let pk = id.try_into()?; |
734 | + Value::from($ty(ListPathIdentifier::Pk(pk), arg).to_crumb().to_string()) |
735 | + }, |
736 | + ) |
737 | + } |
738 | + }; |
739 | } |
740 | |
741 | list_id_impl!(list_path, ListPath); |
742 | @@ -168,6 +223,32 @@ list_id_impl!(list_settings_path, ListSettingsPath); |
743 | list_id_impl!(list_edit_path, ListEditPath); |
744 | list_id_impl!(list_subscribers_path, ListEditSubscribersPath); |
745 | list_id_impl!(list_candidates_path, ListEditCandidatesPath); |
746 | + list_id_impl!(list_calendar_path, ListCalendarPath); |
747 | + list_id_impl!(list_search_query, ListSearchPath); |
748 | + |
749 | + pub fn year_month_to_query( |
750 | + _state: &minijinja::State, |
751 | + arg: Value, |
752 | + ) -> std::result::Result<Value, Error> { |
753 | + if let Some(arg) = arg.as_str() { |
754 | + Ok(Value::from_safe_string( |
755 | + utf8_percent_encode( |
756 | + chrono::NaiveDate::parse_from_str(&format!("{}-01", arg), "%F") |
757 | + .map_err(|err| { |
758 | + Error::new(minijinja::ErrorKind::InvalidOperation, err.to_string()) |
759 | + }) |
760 | + .map(|_| arg)?, |
761 | + crate::typed_paths::PATH_SEGMENT, |
762 | + ) |
763 | + .to_string(), |
764 | + )) |
765 | + } else { |
766 | + Err(Error::new( |
767 | + minijinja::ErrorKind::UnknownMethod, |
768 | + "Second argument of list_post_path must be a string.", |
769 | + )) |
770 | + } |
771 | + } |
772 | |
773 | macro_rules! list_post_impl { |
774 | ($ident:ident, $ty:tt) => { |
775 | diff --git a/web/src/utils.rs b/web/src/utils.rs |
776 | index 60217ee..47fd431 100644 |
777 | --- a/web/src/utils.rs |
778 | +++ b/web/src/utils.rs |
779 | @@ -38,6 +38,19 @@ pub struct Crumb { |
780 | pub url: Cow<'static, str>, |
781 | } |
782 | |
783 | + #[macro_export] |
784 | + macro_rules! crumbs { |
785 | + ($($var:expr),*$(,)?) => { |
786 | + vec![ |
787 | + Crumb { |
788 | + label: "Home".into(), |
789 | + url: "/".into(), |
790 | + }, |
791 | + $($var),* |
792 | + ] |
793 | + } |
794 | + } |
795 | + |
796 | /// Message urgency level or info. |
797 | #[derive( |
798 | Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq, |
799 | @@ -236,6 +249,165 @@ impl<'de> serde::Deserialize<'de> for BoolPOST { |
800 | } |
801 | } |
802 | |
803 | + #[derive(Debug, Eq, PartialEq, Clone)] |
804 | + pub enum DateQueryParameter { |
805 | + Month { |
806 | + month: String, |
807 | + }, |
808 | + Date { |
809 | + date: String, |
810 | + }, |
811 | + /// A (half-open) range bounded inclusively below and exclusively above |
812 | + /// (start..end). |
813 | + Range { |
814 | + start: String, |
815 | + end: String, |
816 | + }, |
817 | + // |
818 | + // /// A range only bounded inclusively below (start..). |
819 | + // RangeFrom { |
820 | + // start: String, |
821 | + // }, |
822 | + // /// A range bounded inclusively below and above (start..=end). |
823 | + // RangeInclusive { |
824 | + // start: String, |
825 | + // end: String, |
826 | + // }, |
827 | + // /// A range only bounded exclusively above (..end). |
828 | + // RangeTo { |
829 | + // end: String, |
830 | + // }, |
831 | + // /// A range only bounded inclusively above (..=end). |
832 | + // RangeToInclusive { |
833 | + // end: String, |
834 | + // }, |
835 | + } |
836 | + |
837 | + impl std::fmt::Display for DateQueryParameter { |
838 | + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { |
839 | + match self { |
840 | + Self::Month { month: v } | Self::Date { date: v } => write!(fmt, "{}", v), |
841 | + Self::Range { start, end } => write!(fmt, "{}..{}", start, end), |
842 | + //Self::RangeFrom { start } => serializer.serialize_str(&format!("{start}..")), |
843 | + //Self::RangeInclusive { start, end } => { |
844 | + // serializer.serialize_str(&format!("{start}..={end}")) |
845 | + //} |
846 | + //Self::RangeTo { end } => serializer.serialize_str(&format!("..{end}")), |
847 | + //Self::RangeToInclusive { end } => serializer.serialize_str(&format!("..={end}")), |
848 | + } |
849 | + } |
850 | + } |
851 | + |
852 | + impl serde::Serialize for DateQueryParameter { |
853 | + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> |
854 | + where |
855 | + S: serde::Serializer, |
856 | + { |
857 | + match self { |
858 | + Self::Month { month: v } | Self::Date { date: v } => serializer.serialize_str(v), |
859 | + Self::Range { start, end } => serializer.serialize_str(&format!("{start}..{end}")), |
860 | + //Self::RangeFrom { start } => serializer.serialize_str(&format!("{start}..")), |
861 | + //Self::RangeInclusive { start, end } => { |
862 | + // serializer.serialize_str(&format!("{start}..={end}")) |
863 | + //} |
864 | + //Self::RangeTo { end } => serializer.serialize_str(&format!("..{end}")), |
865 | + //Self::RangeToInclusive { end } => serializer.serialize_str(&format!("..={end}")), |
866 | + } |
867 | + } |
868 | + } |
869 | + |
870 | + impl<'de> serde::Deserialize<'de> for DateQueryParameter { |
871 | + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> |
872 | + where |
873 | + D: serde::Deserializer<'de>, |
874 | + { |
875 | + #[derive(Clone, Copy)] |
876 | + struct DateVisitor; |
877 | + |
878 | + impl<'de> serde::de::Visitor<'de> for DateVisitor { |
879 | + type Value = DateQueryParameter; |
880 | + |
881 | + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { |
882 | + f.write_str("Date as YYYY-MM or YYYY-MM-DD or YYYY-MM..YYYY-MM.") |
883 | + } |
884 | + |
885 | + fn visit_str<E>(self, s: &str) -> Result<Self::Value, E> |
886 | + where |
887 | + E: serde::de::Error, |
888 | + { |
889 | + let is_month = |m: &str| { |
890 | + chrono::NaiveDate::parse_from_str(&format!("{}-01", m), "%F") |
891 | + .map(|_| ()) |
892 | + .map_err(|err| serde::de::Error::custom(format!("Invalid date: {err}."))) |
893 | + }; |
894 | + const RANGE: &str = r".."; |
895 | + if s.contains(RANGE) && !s.starts_with(RANGE) && !s.ends_with(RANGE) { |
896 | + let mut v: Vec<&str> = s.splitn(2, RANGE).collect(); |
897 | + if v.len() != 2 || (v.len() > 2 && v[2].contains(RANGE)) { |
898 | + return Err(serde::de::Error::custom("Invalid date range.")); |
899 | + } |
900 | + is_month(v[0])?; |
901 | + is_month(v[1])?; |
902 | + return Ok(DateQueryParameter::Range { |
903 | + end: v.pop().unwrap().to_string(), |
904 | + start: v.pop().unwrap().to_string(), |
905 | + }); |
906 | + } else if is_month(s).is_ok() { |
907 | + return Ok(DateQueryParameter::Month { |
908 | + month: s.to_string(), |
909 | + }); |
910 | + } else if chrono::NaiveDate::parse_from_str(s, "%F").is_ok() { |
911 | + return Ok(DateQueryParameter::Date { |
912 | + date: s.to_string(), |
913 | + }); |
914 | + } |
915 | + |
916 | + Err(serde::de::Error::custom("invalid")) |
917 | + } |
918 | + |
919 | + fn visit_map<A>( |
920 | + self, |
921 | + mut map: A, |
922 | + ) -> Result<Self::Value, <A as serde::de::MapAccess<'de>>::Error> |
923 | + where |
924 | + A: serde::de::MapAccess<'de>, |
925 | + { |
926 | + let mut params = vec![]; |
927 | + while let Some((key, value)) = map.next_entry()? { |
928 | + match key { |
929 | + "datetime" | "date" | "month" => params.push(self.visit_str(value)?), |
930 | + _ => { |
931 | + return Err(serde::de::Error::invalid_type( |
932 | + serde::de::Unexpected::Map, |
933 | + &self, |
934 | + )) |
935 | + } |
936 | + } |
937 | + } |
938 | + |
939 | + if params.len() > 1 { |
940 | + return Err(serde::de::Error::invalid_length(params.len(), &self)); |
941 | + } |
942 | + |
943 | + Ok(params.pop().unwrap()) |
944 | + } |
945 | + } |
946 | + deserializer.deserialize_any(DateVisitor) |
947 | + } |
948 | + } |
949 | + |
950 | + #[derive(Debug, Clone, serde::Deserialize)] |
951 | + pub struct ListPostQuery { |
952 | + #[serde(default, deserialize_with = "empty_string_as_none")] |
953 | + pub month: Option<String>, |
954 | + #[serde(default, deserialize_with = "empty_string_as_none")] |
955 | + pub from: Option<String>, |
956 | + #[serde(default, deserialize_with = "empty_string_as_none")] |
957 | + pub cc: Option<String>, |
958 | + #[serde(default, deserialize_with = "empty_string_as_none")] |
959 | + pub msg_id: Option<String>, |
960 | + } |
961 | + |
962 | #[derive(Debug, Clone, serde::Deserialize)] |
963 | pub struct Next { |
964 | #[serde(default, deserialize_with = "empty_string_as_none")] |
965 | @@ -251,7 +423,7 @@ impl Next { |
966 | } |
967 | |
968 | /// Serde deserialization decorator to map empty Strings to None, |
969 | - fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error> |
970 | + pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error> |
971 | where |
972 | D: serde::Deserializer<'de>, |
973 | T: std::str::FromStr, |
974 | @@ -445,6 +617,29 @@ mod tests { |
975 | serde_json::from_str::<BoolPOST>("\"true\"").unwrap() |
976 | ); |
977 | assert_eq!(&json! { BoolPOST(false) }.to_string(), "false"); |
978 | + |
979 | + assert_eq!( |
980 | + DateQueryParameter::Month { |
981 | + month: "2023-12".into() |
982 | + }, |
983 | + serde_json::from_str::<DateQueryParameter>("\"2023-12\"").unwrap() |
984 | + ); |
985 | + assert_eq!( |
986 | + DateQueryParameter::Range { |
987 | + start: "2023-12".into(), |
988 | + end: "2023-12".into() |
989 | + }, |
990 | + serde_json::from_str::<DateQueryParameter>("\"2023-12..2023-12\"").unwrap() |
991 | + ); |
992 | + assert_eq!( |
993 | + &json! { DateQueryParameter::Month{ month: "2023-12".into()} }.to_string(), |
994 | + "\"2023-12\"" |
995 | + ); |
996 | + assert_eq!( |
997 | + &json! { DateQueryParameter::Range{ start: "2023-12".into(), end: "2023-12".into() } } |
998 | + .to_string(), |
999 | + "\"2023-12..2023-12\"" |
1000 | + ); |
1001 | } |
1002 | |
1003 | #[test] |