web: add searching for topic tags
parent
2238b75c45
commit
7d563ea34a
2
Makefile
2
Makefile
|
@ -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:
|
||||
|
|
|
@ -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::*;
|
||||
|
||||
|
|
|
@ -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..,
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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> {{ r.topics_html() }}{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
|
@ -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)?,
|
||||
))
|
||||
}
|
|
@ -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> {
|
||||
|
|
Loading…
Reference in New Issue