WIP: web: implement search history
Signed-off-by: Manos Pitsidianakis <manos@pitsidianak.is>
parent
88c587485e
commit
b537f22c09
|
@ -2361,6 +2361,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::{
|
||||
|
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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..…</a>
|
||||
{% endif %}
|
||||
{% for date in months %}
|
||||
{{ cal(date, hists) }}
|
||||
{% 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) => {
|
||||
|
|
154
web/src/utils.rs
154
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,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]
|
||||
|
|
Loading…
Reference in New Issue