WIP: web: implement search history

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
web-calendar-search
Manos Pitsidianakis 2023-12-26 11:13:55 +02:00
parent f7039e1997
commit d07a0487e6
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
14 changed files with 532 additions and 163 deletions

1
Cargo.lock generated
View File

@ -2401,6 +2401,7 @@ dependencies = [
"memo-map",
"self_cell",
"serde",
"serde_json",
]
[[package]]

View File

@ -34,7 +34,7 @@ http = "0.2"
indexmap = { version = "1.9" }
lazy_static = "^1.4"
mailpot = { version = "^0.1", path = "../core" }
minijinja = { version = "0.31.0", features = ["source", ] }
minijinja = { version = "0.31.0", features = ["source", "builtins", "json"] }
percent-encoding = { version = "^2.1" }
rand = { version = "^0.8", features = ["min_const_gen"] }
serde = { version = "^1", features = ["derive", ] }

View File

@ -96,7 +96,7 @@ pub use cal::{calendarize, *};
pub use help::*;
pub use lists::{
list, list_candidates, list_edit, list_edit_POST, list_post, list_post_eml, list_post_raw,
list_subscribers, PostPolicySettings, SubscriptionPolicySettings,
list_search_query_GET, list_subscribers, PostPolicySettings, SubscriptionPolicySettings,
};
pub use minijinja_utils::*;
pub use settings::{

View File

@ -42,7 +42,8 @@ pub async fn list(
};
let post_policy = db.list_post_policy(list.pk)?;
let subscription_policy = db.list_subscription_policy(list.pk)?;
let months = db.months(list.pk)?;
let mut months = db.months(list.pk)?;
months.sort();
let user_context = auth
.current_user
.as_ref()
@ -53,7 +54,7 @@ pub async fn list(
.iter()
.map(|p| (p.message_id.as_str(), p))
.collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
let mut hist = months
let mut hists = months
.iter()
.map(|m| (m.to_string(), [0usize; 31]))
.collect::<HashMap<String, [usize; 31]>>();
@ -90,7 +91,7 @@ pub async fn list(
.ok()
.map(|d| d.day())
{
hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
hists.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
}
let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None).ok()?;
let mut msg_id = &post.message_id[1..];
@ -118,19 +119,21 @@ pub async fn list(
Some(ret)
})
.collect::<Vec<_>>();
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
},
];
let crumbs = crumbs![Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
},];
let list_owners = db.list_owners(list.pk)?;
let mut list_obj = MailingList::from(list.clone());
list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
let has_more_months = if months.len() > 2 {
let len = months.len();
months.drain(0..(len - 2));
true
} else {
false
};
let context = minijinja::context! {
canonical_url => ListPath::from(&list).to_crumb(),
page_title => &list.name,
@ -139,7 +142,8 @@ pub async fn list(
subscription_policy,
preamble => true,
months,
hists => &hist,
has_more_months,
hists,
posts => posts_ctx,
list => Value::from_object(list_obj),
current_user => auth.current_user,
@ -208,11 +212,7 @@ pub async fn list_post(
{
subject_ref = subject_ref[2 + list.id.len()..].trim();
}
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
let crumbs = crumbs![
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
@ -317,11 +317,7 @@ pub async fn list_edit(
.unwrap_or(0)
};
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
let crumbs = crumbs![
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
@ -696,11 +692,7 @@ pub async fn list_subscribers(
ret
};
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
let crumbs = crumbs![
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
@ -784,11 +776,7 @@ pub async fn list_candidates(
ret
};
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
let crumbs = crumbs![
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
@ -819,3 +807,90 @@ pub async fn list_candidates(
.render(context)?,
))
}
/// Mailing list post search.
#[allow(non_snake_case)]
pub async fn list_search_query_GET(
ListSearchPath(id): ListSearchPath,
mut session: WritableSession,
Query(query): Query<DateQueryParameter>,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?.trusted();
let Some(list) = (match id {
ListPathIdentifier::Pk(id) => db.list(id)?,
ListPathIdentifier::Id(id) => db.list_by_id(id)?,
}) else {
return Err(ResponseError::new(
"List not found".to_string(),
StatusCode::NOT_FOUND,
));
};
match query {
DateQueryParameter::Month { ref month } => {
let mut stmt = db.connection.prepare(
"SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS \
month_year FROM post WHERE list = ? AND month_year = ? ORDER BY timestamp DESC;",
)?;
let iter = stmt.query_map(rusqlite::params![&list.pk, &month], |row| {
let pk = row.get("pk")?;
Ok(DbVal(
Post {
pk,
list: row.get("list")?,
envelope_from: row.get("envelope_from")?,
address: row.get("address")?,
message_id: row.get("message_id")?,
message: row.get("message")?,
timestamp: row.get("timestamp")?,
datetime: row.get("datetime")?,
month_year: row.get("month_year")?,
},
pk,
))
})?;
let mut ret = vec![];
for post in iter {
let post = post?;
ret.push(post);
}
let crumbs = crumbs![
Crumb {
label: list.name.clone().into(),
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: query.to_string().into(),
url: ListSearchPath(ListPathIdentifier::Id(list.id.to_string())).to_crumb(),
}
];
let list_owners = db.list_owners(list.pk)?;
let mut list_obj = MailingList::from(list.clone());
list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
let user_context = auth
.current_user
.as_ref()
.map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
let context = minijinja::context! {
canonical_url => ListSearchPath(ListPathIdentifier::Id(list.id.to_string())),
search_term => query,
page_title => &list.name,
description => &list.description,
preamble => false,
posts => ret,
list => Value::from_object(list_obj),
current_user => auth.current_user,
user_context,
messages => session.drain_messages(),
crumbs,
};
Ok(Html(
TEMPLATES.get_template("lists/list.html")?.render(context)?,
))
}
DateQueryParameter::Date { date } => todo!(),
DateQueryParameter::Range { start, end } => todo!(),
}
}

View File

@ -57,6 +57,7 @@ fn create_app(shared_state: Arc<AppState>) -> Router {
.typed_get(list_post_raw)
.typed_get(list_topics)
.typed_get(list_post_eml)
.typed_get(list_search_query_GET)
.typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,
Arc::clone(&login_url),

View File

@ -49,13 +49,16 @@ lazy_static::lazy_static! {
settings_path,
help_path,
list_path,
list_calendar_path,
list_search_query,
list_settings_path,
list_edit_path,
list_subscribers_path,
list_candidates_path,
list_post_path,
post_raw_path,
post_eml_path
post_eml_path,
year_month_to_query,
);
add!(filter pluralize);
// Load compressed templates. They are constructed in build.rs. See
@ -243,31 +246,33 @@ pub fn calendarize(
}
}};
}
let month = args.as_str().unwrap();
let year_month = args.as_str().unwrap();
let hist = hists
.get_item(&Value::from(month))?
.get_item(&Value::from(year_month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.collect::<Vec<usize>>();
let sum: usize = hists
.get_item(&Value::from(month))?
.get_item(&Value::from(year_month))?
.as_seq()
.unwrap()
.iter()
.map(|v| usize::try_from(v).unwrap())
.sum();
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", month), "%F").unwrap();
let date = chrono::NaiveDate::parse_from_str(&format!("{}-01", year_month), "%F").unwrap();
// Week = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
Ok(minijinja::context! {
date,
month_name => month!(date.month()),
month => month,
month => year_month,
month_int => date.month() as usize,
year => date.year(),
weeks => cal::calendarize_with_offset(date, 1),
hist => hist,
sum,
unknown => date.year() == 1970,
})
}

View File

@ -3,9 +3,12 @@
{% if c.sum > 0 %}
<table>
<caption align="top">
<!--<a href="{{ root_url_prefix|safe }}/list/{{pk}}/{{ c.month }}">-->
<a href="#" style="color: GrayText;">
{{ c.month_name }} {{ c.year }}
<a href="{{ list_search_query(list.id) }}?month={{ year_month_to_query(date) }}" style="color: GrayText;">
{% if c.unknown %}
Unknown
{% else %}
{{ c.month_name }} {{ c.year }}
{% endif %}
</a>
</caption>
<thead>

View File

@ -129,6 +129,11 @@
code {
font-family: var(--monospace-system-stack);
overflow-wrap: anywhere;
background: var(--color-tan);
color: black;
border-radius: 4px;
padding-left: 4px;
padding-right: 4px;
}
pre {
@ -162,6 +167,7 @@
--code-background: #8fbcbb;
--a-visited-text: var(--a-normal-text);
--tag-border-color: black;
--color-tan: #fef6e9;
}
@media (prefers-color-scheme: light) {
@ -445,18 +451,6 @@
font-size: small;
}
/* If only the root crumb is visible, hide it to avoid unnecessary visual clutter */
li.crumb:only-child>span[aria-current="page"] {
--secs: 150ms;
transition: all var(--secs) linear;
color: transparent;
}
li.crumb:only-child>span[aria-current="page"]:hover {
transition: all var(--secs) linear;
color: revert;
}
.crumb, .crumb>a {
display: inline;
}
@ -595,6 +589,10 @@
opacity: 0.2;
}
div.calendar a {
place-self: center;
}
div.calendar {
display: flex;
flex-wrap: wrap;

View File

@ -16,7 +16,7 @@
{% endif %}
{% include "menu.html" %}
<div class="page-header">
{% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
{% if crumbs|length > 0 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
<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>
</nav>{% endif %}
{% if page_title %}

View File

@ -4,107 +4,118 @@
<br aria-hidden="true">
<br aria-hidden="true">
{% endif %}
{% if list.description %}
<p title="mailing list description">{{ list.description }}</p>
{% else %}
<p title="mailing list description">No list description.</p>
{% endif %}
<br aria-hidden="true">
{% if current_user and subscription_policy and subscription_policy.open %}
{% if user_context %}
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="unsubscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
</form>
<br />
{% if not search_term %}
{% if list.description %}
<p title="mailing list description">{{ list.description }}</p>
{% else %}
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="subscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
</form>
<br />
<p title="mailing list description">No list description.</p>
{% endif %}
<br aria-hidden="true">
{% if current_user and subscription_policy and subscription_policy.open %}
{% if user_context %}
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="unsubscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
</form>
<br />
{% else %}
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="subscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
</form>
<br />
{% endif %}
{% endif %}
{% if preamble %}
<section id="preamble" class="preamble" aria-label="mailing list instructions">
{% if preamble.custom %}
{{ preamble.custom|safe }}
{% else %}
{% if subscription_policy %}
{% if subscription_policy.open or subscription_policy.request %}
{{ heading(3, "Subscribe") }}
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
<p>
<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>
</p>
{% else %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
</p>
{% endif %}
{% else %}
<p>List is not open for subscriptions.</p>
{% endif %}
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
{{ heading(3, "Unsubscribe") }}
{% if unsubscription_mailto.subject %}
<p>
<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>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% if post_policy %}
{{ heading(3, "Post") }}
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscription_only %}
<p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
<p>If you are subscribed, you can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% elif post_policy.approval_needed or post_policy.no_subscriptions %}
<p>List is open to all posts <em>after approval</em> by the list owners.</p>
<p>You can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% else %}
<p>List is not open for submissions.</p>
{% endif %}
{% endif %}
{% endif %}
</section>
{% endif %}
{% if months %}
<section class="list" aria-hidden="true">
{{ heading(3, "Calendar") }}
<div class="calendar">
{%- from "calendar.html" import cal %}
{% if has_more_months %}
<a href="{{ list_calendar_path(list.id) }}">See all history..&mldr;</a>
{% endif %}
{% for date in months %}
{{ cal(date, hists) }}
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
{% if preamble %}
<section id="preamble" class="preamble" aria-label="mailing list instructions">
{% if preamble.custom %}
{{ preamble.custom|safe }}
{% else %}
{% if subscription_policy %}
{% if subscription_policy.open or subscription_policy.request %}
{{ heading(3, "Subscribe") }}
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
<p>
<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>
</p>
{% else %}
<p>
<a href="mailto:{{ subscription_mailto.address|safe }}"><code>{{ subscription_mailto.address }}</code></a>
</p>
{% endif %}
{% else %}
<p>List is not open for subscriptions.</p>
{% endif %}
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
{{ heading(3, "Unsubscribe") }}
{% if unsubscription_mailto.subject %}
<p>
<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>
</p>
{% else %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}"><code>{{ unsubscription_mailto.address }}</code></a>
</p>
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% if post_policy %}
{{ heading(3, "Post") }}
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscription_only %}
<p>List is <em>subscription-only</em>, i.e. you can only post if you are subscribed.</p>
<p>If you are subscribed, you can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% elif post_policy.approval_needed or post_policy.no_subscriptions %}
<p>List is open to all posts <em>after approval</em> by the list owners.</p>
<p>You can send new posts to:
<a href="mailto:{{ list.address| safe }}"><code>{{ list.address }}</code></a>
</p>
{% else %}
<p>List is not open for submissions.</p>
{% endif %}
{% endif %}
{% endif %}
</section>
{% endif %}
<section class="list" aria-hidden="true">
{{ heading(3, "Calendar") }}
<div class="calendar">
{%- from "calendar.html" import cal %}
{% for date in months %}
{{ cal(date, hists) }}
{% endfor %}
</div>
</section>
<section aria-label="mailing list posts">
{{ heading(3, "Posts") }}
{% if search_term %}
{{ heading(3, "Results for " ~ search_term) }}
{% else %}
{{ heading(3, "Posts") }}
{% endif %}
<div class="posts entries" role="list" aria-label="list of mailing list posts">
<p>{{ posts | length }} post{{ posts|length|pluralize }}</p>
{% for post in posts %}
<div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
<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>
<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>
<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>
{% 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 %}
{% 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 %}
<span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
</div>
{% endfor %}

View File

@ -1,6 +1,6 @@
{% include "header.html" %}
<div class="body">
<p style="margin-block-end: 1rem;">Results for <em>{{ term }}</em></p>
{% if term %}<p style="margin-block-end: 1rem;">Results for <em>{{ term }}</em></p>{% endif %}
<div class="entry">
<dl class="lists" aria-label="list of mailing lists">
{% for list in results %}

View File

@ -127,21 +127,20 @@ pub async fn list_topics(
}
};
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
Crumb {
label: "Search for topics".into(),
url: TopicsPath.to_crumb(),
},
];
let crumbs = crumbs![Crumb {
label: if term.is_some() {
"Search for topics"
} else {
"Topics"
}
.into(),
url: TopicsPath.to_crumb(),
},];
let context = minijinja::context! {
canonical_url => TopicsPath.to_crumb(),
term,
results,
page_title => "Topic Search Results",
page_title => if term.is_some() { "Topic Search Results" } else { "Topics" },
description => "",
current_user => auth.current_user,
messages => session.drain_messages(),

View File

@ -82,6 +82,29 @@ impl From<&DbVal<mailpot::models::MailingList>> for ListPath {
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/calendar/")]
pub struct ListCalendarPath(pub ListPathIdentifier);
impl From<&DbVal<mailpot::models::MailingList>> for ListCalendarPath {
fn from(val: &DbVal<mailpot::models::MailingList>) -> Self {
Self(ListPathIdentifier::Id(val.id.clone()))
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)]
pub struct YearMonth(pub chrono::NaiveDate);
impl std::fmt::Display for YearMonth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}-{}", self.0.year(), self.0.month())
}
}
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/search")]
pub struct ListSearchPath(pub ListPathIdentifier);
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/list/:id/posts/:msgid/")]
pub struct ListPostPath(pub ListPathIdentifier, pub String);
@ -161,6 +184,38 @@ macro_rules! list_id_impl {
)
}
};
($ident:ident, $ty:tt, $arg:path, $($arg_fn:tt)*) => {
pub fn $ident(state: &minijinja::State, id: Value, arg: Value) -> std::result::Result<Value, Error> {
let arg: $arg = if let Some(arg) = arg.as_str() {
#[allow(clippy::redundant_closure_call)]
($($arg_fn)*)(arg)
.map_err(|err| {
Error::new(
minijinja::ErrorKind::InvalidOperation,
err.to_string()
)
})?
} else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string.",
));
};
urlize(
state,
if let Some(id) = id.as_str() {
Value::from(
$ty(ListPathIdentifier::Id(id.to_string()), arg)
.to_crumb()
.to_string(),
)
} else {
let pk = id.try_into()?;
Value::from($ty(ListPathIdentifier::Pk(pk), arg).to_crumb().to_string())
},
)
}
};
}
list_id_impl!(list_path, ListPath);
@ -168,6 +223,32 @@ list_id_impl!(list_settings_path, ListSettingsPath);
list_id_impl!(list_edit_path, ListEditPath);
list_id_impl!(list_subscribers_path, ListEditSubscribersPath);
list_id_impl!(list_candidates_path, ListEditCandidatesPath);
list_id_impl!(list_calendar_path, ListCalendarPath);
list_id_impl!(list_search_query, ListSearchPath);
pub fn year_month_to_query(
_state: &minijinja::State,
arg: Value,
) -> std::result::Result<Value, Error> {
if let Some(arg) = arg.as_str() {
Ok(Value::from_safe_string(
utf8_percent_encode(
chrono::NaiveDate::parse_from_str(&format!("{}-01", arg), "%F")
.map_err(|err| {
Error::new(minijinja::ErrorKind::InvalidOperation, err.to_string())
})
.map(|_| arg)?,
crate::typed_paths::PATH_SEGMENT,
)
.to_string(),
))
} else {
Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string.",
))
}
}
macro_rules! list_post_impl {
($ident:ident, $ty:tt) => {

View File

@ -38,6 +38,19 @@ pub struct Crumb {
pub url: Cow<'static, str>,
}
#[macro_export]
macro_rules! crumbs {
($($var:expr),*$(,)?) => {
vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
$($var),*
]
}
}
/// Message urgency level or info.
#[derive(
Debug, Default, Hash, Copy, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq,
@ -236,6 +249,165 @@ impl<'de> serde::Deserialize<'de> for BoolPOST {
}
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum DateQueryParameter {
Month {
month: String,
},
Date {
date: String,
},
/// A (half-open) range bounded inclusively below and exclusively above
/// (start..end).
Range {
start: String,
end: String,
},
//
// /// A range only bounded inclusively below (start..).
// RangeFrom {
// start: String,
// },
// /// A range bounded inclusively below and above (start..=end).
// RangeInclusive {
// start: String,
// end: String,
// },
// /// A range only bounded exclusively above (..end).
// RangeTo {
// end: String,
// },
// /// A range only bounded inclusively above (..=end).
// RangeToInclusive {
// end: String,
// },
}
impl std::fmt::Display for DateQueryParameter {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Month { month: v } | Self::Date { date: v } => write!(fmt, "{}", v),
Self::Range { start, end } => write!(fmt, "{}..{}", start, end),
//Self::RangeFrom { start } => serializer.serialize_str(&format!("{start}..")),
//Self::RangeInclusive { start, end } => {
// serializer.serialize_str(&format!("{start}..={end}"))
//}
//Self::RangeTo { end } => serializer.serialize_str(&format!("..{end}")),
//Self::RangeToInclusive { end } => serializer.serialize_str(&format!("..={end}")),
}
}
}
impl serde::Serialize for DateQueryParameter {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Month { month: v } | Self::Date { date: v } => serializer.serialize_str(v),
Self::Range { start, end } => serializer.serialize_str(&format!("{start}..{end}")),
//Self::RangeFrom { start } => serializer.serialize_str(&format!("{start}..")),
//Self::RangeInclusive { start, end } => {
// serializer.serialize_str(&format!("{start}..={end}"))
//}
//Self::RangeTo { end } => serializer.serialize_str(&format!("..{end}")),
//Self::RangeToInclusive { end } => serializer.serialize_str(&format!("..={end}")),
}
}
}
impl<'de> serde::Deserialize<'de> for DateQueryParameter {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Clone, Copy)]
struct DateVisitor;
impl<'de> serde::de::Visitor<'de> for DateVisitor {
type Value = DateQueryParameter;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("Date as YYYY-MM or YYYY-MM-DD or YYYY-MM..YYYY-MM.")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let is_month = |m: &str| {
chrono::NaiveDate::parse_from_str(&format!("{}-01", m), "%F")
.map(|_| ())
.map_err(|err| serde::de::Error::custom(format!("Invalid date: {err}.")))
};
const RANGE: &str = r"..";
if s.contains(RANGE) && !s.starts_with(RANGE) && !s.ends_with(RANGE) {
let mut v: Vec<&str> = s.splitn(2, RANGE).collect();
if v.len() != 2 || (v.len() > 2 && v[2].contains(RANGE)) {
return Err(serde::de::Error::custom("Invalid date range."));
}
is_month(v[0])?;
is_month(v[1])?;
return Ok(DateQueryParameter::Range {
end: v.pop().unwrap().to_string(),
start: v.pop().unwrap().to_string(),
});
} else if is_month(s).is_ok() {
return Ok(DateQueryParameter::Month {
month: s.to_string(),
});
} else if chrono::NaiveDate::parse_from_str(s, "%F").is_ok() {
return Ok(DateQueryParameter::Date {
date: s.to_string(),
});
}
Err(serde::de::Error::custom("invalid"))
}
fn visit_map<A>(
self,
mut map: A,
) -> Result<Self::Value, <A as serde::de::MapAccess<'de>>::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut params = vec![];
while let Some((key, value)) = map.next_entry()? {
match key {
"datetime" | "date" | "month" => params.push(self.visit_str(value)?),
_ => {
return Err(serde::de::Error::invalid_type(
serde::de::Unexpected::Map,
&self,
))
}
}
}
if params.len() > 1 {
return Err(serde::de::Error::invalid_length(params.len(), &self));
}
Ok(params.pop().unwrap())
}
}
deserializer.deserialize_any(DateVisitor)
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ListPostQuery {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub month: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub from: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub cc: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub msg_id: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Next {
#[serde(default, deserialize_with = "empty_string_as_none")]
@ -251,7 +423,7 @@ impl Next {
}
/// Serde deserialization decorator to map empty Strings to None,
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
pub fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
@ -445,6 +617,29 @@ mod tests {
serde_json::from_str::<BoolPOST>("\"true\"").unwrap()
);
assert_eq!(&json! { BoolPOST(false) }.to_string(), "false");
assert_eq!(
DateQueryParameter::Month {
month: "2023-12".into()
},
serde_json::from_str::<DateQueryParameter>("\"2023-12\"").unwrap()
);
assert_eq!(
DateQueryParameter::Range {
start: "2023-12".into(),
end: "2023-12".into()
},
serde_json::from_str::<DateQueryParameter>("\"2023-12..2023-12\"").unwrap()
);
assert_eq!(
&json! { DateQueryParameter::Month{ month: "2023-12".into()} }.to_string(),
"\"2023-12\""
);
assert_eq!(
&json! { DateQueryParameter::Range{ start: "2023-12".into(), end: "2023-12".into() } }
.to_string(),
"\"2023-12..2023-12\""
);
}
#[test]