web: add searching for topic tags

axum-login-upgrade
Manos Pitsidianakis 2023-06-09 16:36:23 +03:00
parent 2238b75c45
commit 7d563ea34a
7 changed files with 218 additions and 15 deletions

View File

@ -11,7 +11,7 @@ check:
fmt:
@cargo +nightly fmt --all || cargo fmt --all
@cargo sort -w || printf "cargo-sort binary not found in PATH.\n"
@djhtml -i $(HTML_FILES) || printf "djhtml binary not found in PATH.\n"
@djhtml $(HTML_FILES) || printf "djhtml binary not found in PATH.\n"
.PHONY: lint
lint:

View File

@ -87,6 +87,7 @@ pub mod help;
pub mod lists;
pub mod minijinja_utils;
pub mod settings;
pub mod topics;
pub mod typed_paths;
pub mod utils;
@ -96,6 +97,7 @@ pub use help::*;
pub use lists::*;
pub use minijinja_utils::*;
pub use settings::*;
pub use topics::*;
pub use typed_paths::{tsr::RouterExt, *};
pub use utils::*;

View File

@ -55,6 +55,7 @@ fn create_app(shared_state: Arc<AppState>) -> Router {
.typed_get(list)
.typed_get(list_post)
.typed_get(list_post_raw)
.typed_get(list_topics)
.typed_get(list_post_eml)
.typed_get(list_edit.layer(RequireAuth::login_with_role_or_redirect(
Role::User..,

View File

@ -22,6 +22,7 @@
use std::fmt::Write;
use mailpot::models::ListOwner;
use percent_encoding::utf8_percent_encode;
use super::*;
@ -42,6 +43,7 @@ lazy_static::lazy_static! {
strip_carets,
urlize,
heading,
topics,
login_path,
logout_path,
settings_path,
@ -178,20 +180,7 @@ impl Object for MailingList {
"unsubscription_mailto" => Ok(Value::from_serializable(
&self.inner.unsubscription_mailto(),
)),
"topics" => {
let mut ul = String::new();
write!(&mut ul, r#"<ul class="tags inline">"#)?;
for topic in &self.topics {
write!(
&mut ul,
r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name">"#
)?;
write!(&mut ul, "{}", topic)?;
write!(&mut ul, r#"</span></li>"#)?;
}
write!(&mut ul, r#"</ul>"#)?;
Ok(Value::from_safe_string(ul))
}
"topics" => topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
@ -596,6 +585,62 @@ pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Res
}
}
/// Make an array of topic strings into html badges.
///
/// # Example
/// ```rust
/// use mailpot_web::minijinja_utils::topics;
/// use minijinja::value::Value;
///
/// let v: Value = topics(Value::from_serializable(&vec![
/// "a".to_string(),
/// "aab".to_string(),
/// "aaab".to_string(),
/// ]))
/// .unwrap();
/// assert_eq!(
/// "<ul class=\"tags inline\"><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=a\">a</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aab\">aab</a></span></li><li class=\"tag\" \
/// style=\"--red:110;--green:120;--blue:180;\"><span class=\"tag-name\"><a \
/// href=\"/topics/?query=aaab\">aaab</a></span></li></ul>",
/// &v.to_string()
/// );
/// ```
pub fn topics(topics: Value) -> std::result::Result<Value, Error> {
topics.try_iter()?;
let topics: Vec<String> = topics
.try_iter()?
.map(|v| v.to_string())
.collect::<Vec<String>>();
topics_common(&topics)
}
pub(crate) fn topics_common(topics: &[String]) -> std::result::Result<Value, Error> {
let mut ul = String::new();
write!(&mut ul, r#"<ul class="tags inline">"#)?;
for topic in topics {
write!(
&mut ul,
r#"<li class="tag" style="--red:110;--green:120;--blue:180;"><span class="tag-name"><a href=""#
)?;
write!(&mut ul, "{}", TopicsPath)?;
write!(&mut ul, r#"?query="#)?;
write!(
&mut ul,
"{}",
utf8_percent_encode(topic, crate::typed_paths::PATH_SEGMENT)
)?;
write!(&mut ul, r#"">"#)?;
write!(&mut ul, "{}", topic)?;
write!(&mut ul, r#"</a></span></li>"#)?;
}
write!(&mut ul, r#"</ul>"#)?;
Ok(Value::from_safe_string(ul))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -0,0 +1,12 @@
{% include "header.html" %}
<div class="body">
<p>Results for <em>{{ term }}</em></p>
<div class="entry">
<ul>
{% for r in results %}
<li><a href="{{ list_path(r.pk) }}">{{ r.id }}</a>. {% if r.topics|length > 0 %}<br aria-hidden="true"><br aria-hidden="true"><span><em>Topics</em>:</span>&nbsp;{{ r.topics_html() }}{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% include "footer.html" %}

139
web/src/topics.rs 100644
View File

@ -0,0 +1,139 @@
/*
* This file is part of mailpot
*
* Copyright 2020 - Manos Pitsidianakis
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use super::*;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct SearchTerm {
query: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct SearchResult {
pk: i64,
id: String,
topics: Vec<String>,
}
impl std::fmt::Display for SearchResult {
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(fmt, "{:?}", self)
}
}
impl Object for SearchResult {
fn kind(&self) -> minijinja::value::ObjectKind {
minijinja::value::ObjectKind::Struct(self)
}
fn call_method(
&self,
_state: &minijinja::State,
name: &str,
_args: &[Value],
) -> std::result::Result<Value, Error> {
match name {
"topics_html" => crate::minijinja_utils::topics_common(&self.topics),
_ => Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
format!("object has no method named {name}"),
)),
}
}
}
impl minijinja::value::StructObject for SearchResult {
fn get_field(&self, name: &str) -> Option<Value> {
match name {
"pk" => Some(Value::from_serializable(&self.pk)),
"id" => Some(Value::from_serializable(&self.id)),
"topics" => Some(Value::from_serializable(&self.topics)),
_ => None,
}
}
fn static_fields(&self) -> Option<&'static [&'static str]> {
Some(&["pk", "id", "topics"][..])
}
}
pub async fn list_topics(
_: TopicsPath,
mut session: WritableSession,
Query(SearchTerm { query: term }): Query<SearchTerm>,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let db = Connection::open_db(state.conf.clone())?.trusted();
let results: Vec<Value> = {
if let Some(term) = term.as_ref() {
let mut stmt = db.connection.prepare(
"SELECT DISTINCT list.pk, list.id, list.topics FROM list, json_each(list.topics) \
WHERE json_each.value IS ?;",
)?;
let iter = stmt.query_map([&term], |row| {
let pk = row.get(0)?;
let id = row.get(1)?;
let topics = mailpot::models::MailingList::topics_from_json_value(row.get(2)?)?;
Ok(Value::from_object(SearchResult { pk, id, topics }))
})?;
let mut ret = vec![];
for el in iter {
let el = el?;
ret.push(el);
}
ret
} else {
db.lists()?
.into_iter()
.map(DbVal::into_inner)
.map(|l| SearchResult {
pk: l.pk,
id: l.id,
topics: l.topics,
})
.map(Value::from_object)
.collect()
}
};
let crumbs = vec![
Crumb {
label: "Home".into(),
url: "/".into(),
},
Crumb {
label: "Search for topics".into(),
url: TopicsPath.to_crumb(),
},
];
let context = minijinja::context! {
canonical_url => TopicsPath.to_crumb(),
term,
results,
page_title => "Topic Search Results",
description => "",
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs,
};
Ok(Html(
TEMPLATES.get_template("topics.html")?.render(context)?,
))
}

View File

@ -130,6 +130,10 @@ pub struct SettingsPath;
#[typed_path("/help/")]
pub struct HelpPath;
#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize, TypedPath)]
#[typed_path("/topics/")]
pub struct TopicsPath;
macro_rules! unit_impl {
($ident:ident, $ty:expr) => {
pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {