Author: Manos Pitsidianakis [manos@pitsidianak.is]
Hash: d07a0487e6886bdde2a0f76779eba66c9bf6af2a
Timestamp: Tue, 26 Dec 2023 09:13:55 +0000 (8 months ago)

+522 -153 +/-14 browse
WIP: web: implement search history
WIP: web: implement search history

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
1diff --git a/Cargo.lock b/Cargo.lock
2index 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
14index 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
27index 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
40index 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
257index 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
269index 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
330index 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
350index 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
404index 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
417index 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..&mldr;</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>&nbsp;<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 %}&nbsp;<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">👤&nbsp;</span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
616- {% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</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">&#x1F493;&nbsp;</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
622index 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
634index 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
670index 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
776index 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]