WIP: web: implement search history

Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
Manos Pitsidianakis 2023-12-26 11:13:55 +02:00
parent 88c587485e
commit b537f22c09
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
14 changed files with 327 additions and 71 deletions

1
Cargo.lock generated
View File

@ -2361,6 +2361,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

@ -41,7 +41,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()
@ -52,7 +53,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]>>();
@ -79,7 +80,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)
.expect("Could not parse mail");
@ -108,19 +109,21 @@ pub async fn list(
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,
@ -129,7 +132,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,
@ -185,11 +189,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(),
@ -294,11 +294,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(),
@ -673,11 +669,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(),
@ -761,11 +753,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(),
@ -796,3 +784,28 @@ 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,
));
};
Err(ResponseError::new(
format!("{:#?}", query),
StatusCode::NOT_IMPLEMENTED,
))
}

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

@ -91,6 +91,9 @@
{{ 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 %}

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,122 @@ 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 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>,
{
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"))
}
}
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 +380,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,
@ -557,6 +686,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]