WIP: web: implement search history
Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>web-calendar-search
parent
f7039e1997
commit
d07a0487e6
|
@ -2401,6 +2401,7 @@ dependencies = [
|
|||
"memo-map",
|
||||
"self_cell",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -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", ] }
|
||||
|
|
|
@ -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::{
|
||||
|
|
143
web/src/lists.rs
143
web/src/lists.rs
|
@ -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!(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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..…</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> <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 %} <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">👤 </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>
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
<span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) => {
|
||||
|
|
197
web/src/utils.rs
197
web/src/utils.rs
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue