Enable hashtag search

This commit is contained in:
silverpill 2022-09-19 00:45:56 +00:00
parent c488d5b5d4
commit a8dae2a621
7 changed files with 80 additions and 17 deletions

View file

@ -887,6 +887,10 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/Status' $ref: '#/components/schemas/Status'
hashtags:
type: array
items:
$ref: '#/components/schemas/Tag'
components: components:
securitySchemes: securitySchemes:

View file

@ -14,6 +14,7 @@ use crate::errors::{ValidationError, HttpError};
use crate::ethereum::identity::DidPkh; use crate::ethereum::identity::DidPkh;
use crate::mastodon_api::accounts::types::Account; use crate::mastodon_api::accounts::types::Account;
use crate::mastodon_api::statuses::helpers::build_status_list; use crate::mastodon_api::statuses::helpers::build_status_list;
use crate::mastodon_api::statuses::types::Tag;
use crate::models::posts::helpers::can_view_post; use crate::models::posts::helpers::can_view_post;
use crate::models::posts::types::Post; use crate::models::posts::types::Post;
use crate::models::profiles::queries::{ use crate::models::profiles::queries::{
@ -22,12 +23,14 @@ use crate::models::profiles::queries::{
search_profile_by_wallet_address, search_profile_by_wallet_address,
}; };
use crate::models::profiles::types::DbActorProfile; use crate::models::profiles::types::DbActorProfile;
use crate::models::tags::queries::search_tags;
use crate::models::users::types::User; use crate::models::users::types::User;
use crate::utils::currencies::{validate_wallet_address, Currency}; use crate::utils::currencies::{validate_wallet_address, Currency};
use super::types::SearchResults; use super::types::SearchResults;
enum SearchQuery { enum SearchQuery {
ProfileQuery(String, Option<String>), ProfileQuery(String, Option<String>),
TagQuery(String),
Url(String), Url(String),
WalletAddress(String), WalletAddress(String),
Did(DidPkh), Did(DidPkh),
@ -38,17 +41,28 @@ fn parse_profile_query(query: &str) ->
Result<(String, Option<String>), ValidationError> Result<(String, Option<String>), ValidationError>
{ {
// See also: USERNAME_RE in models::profiles::validators // See also: USERNAME_RE in models::profiles::validators
let acct_regexp = Regex::new(r"^(@|!)?(?P<user>[\w\.-]+)(@(?P<instance>[\w\.-]+))?$").unwrap(); let acct_query_re =
let acct_caps = acct_regexp.captures(query) Regex::new(r"^(@|!)?(?P<user>[\w\.-]+)(@(?P<instance>[\w\.-]+))?$").unwrap();
let acct_query_caps = acct_query_re.captures(query)
.ok_or(ValidationError("invalid profile query"))?; .ok_or(ValidationError("invalid profile query"))?;
let username = acct_caps.name("user") let username = acct_query_caps.name("user")
.ok_or(ValidationError("invalid profile query"))? .ok_or(ValidationError("invalid profile query"))?
.as_str().to_string(); .as_str().to_string();
let maybe_instance = acct_caps.name("instance") let maybe_instance = acct_query_caps.name("instance")
.map(|val| val.as_str().to_string()); .map(|val| val.as_str().to_string());
Ok((username, maybe_instance)) Ok((username, maybe_instance))
} }
fn parse_tag_query(query: &str) -> Result<String, ValidationError> {
let tag_query_re = Regex::new(r"^#(?P<tag>\w+)$").unwrap();
let tag_query_caps = tag_query_re.captures(query)
.ok_or(ValidationError("invalid tag query"))?;
let tag = tag_query_caps.name("tag")
.ok_or(ValidationError("invalid tag query"))?
.as_str().to_string();
Ok(tag)
}
fn parse_search_query(search_query: &str) -> SearchQuery { fn parse_search_query(search_query: &str) -> SearchQuery {
let search_query = search_query.trim(); let search_query = search_query.trim();
// DID is a valid URI so it should be tried before Url::parse // DID is a valid URI so it should be tried before Url::parse
@ -65,14 +79,13 @@ fn parse_search_query(search_query: &str) -> SearchQuery {
).is_ok() { ).is_ok() {
return SearchQuery::WalletAddress(search_query.to_string()); return SearchQuery::WalletAddress(search_query.to_string());
}; };
match parse_profile_query(search_query) { if let Ok(tag) = parse_tag_query(search_query) {
Ok((username, instance)) => { return SearchQuery::TagQuery(tag);
SearchQuery::ProfileQuery(username, instance) };
}, if let Ok((username, maybe_instance)) = parse_profile_query(search_query) {
Err(_) => { return SearchQuery::ProfileQuery(username, maybe_instance);
SearchQuery::Unknown };
}, SearchQuery::Unknown
}
} }
async fn search_profiles_or_import( async fn search_profiles_or_import(
@ -146,13 +159,21 @@ pub async fn search(
) -> Result<SearchResults, HttpError> { ) -> Result<SearchResults, HttpError> {
let mut profiles = vec![]; let mut profiles = vec![];
let mut posts = vec![]; let mut posts = vec![];
let mut tags = vec![];
match parse_search_query(search_query) { match parse_search_query(search_query) {
SearchQuery::ProfileQuery(username, instance) => { SearchQuery::ProfileQuery(username, maybe_instance) => {
profiles = search_profiles_or_import( profiles = search_profiles_or_import(
config, config,
db_client, db_client,
username, username,
instance, maybe_instance,
limit,
).await?;
},
SearchQuery::TagQuery(tag) => {
tags = search_tags(
db_client,
&tag,
limit, limit,
).await?; ).await?;
}, },
@ -192,7 +213,10 @@ pub async fn search(
Some(current_user), Some(current_user),
posts, posts,
).await?; ).await?;
Ok(SearchResults { accounts, statuses }) let hashtags = tags.into_iter()
.map(Tag::from_tag_name)
.collect();
Ok(SearchResults { accounts, statuses, hashtags })
} }
pub async fn search_profiles_only( pub async fn search_profiles_only(
@ -236,4 +260,12 @@ mod tests {
assert_eq!(username, "group"); assert_eq!(username, "group");
assert_eq!(maybe_instance.as_deref(), Some("example.com")); assert_eq!(maybe_instance.as_deref(), Some("example.com"));
} }
#[test]
fn test_parse_tag_query() {
let query = "#Activity";
let tag = parse_tag_query(query).unwrap();
assert_eq!(tag, "Activity");
}
} }

View file

@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::mastodon_api::accounts::types::Account; use crate::mastodon_api::accounts::types::Account;
use crate::mastodon_api::statuses::types::Status; use crate::mastodon_api::statuses::types::{Status, Tag};
fn default_limit() -> i64 { 20 } fn default_limit() -> i64 { 20 }
@ -18,4 +18,5 @@ pub struct SearchQueryParams {
pub struct SearchResults { pub struct SearchResults {
pub accounts: Vec<Account>, pub accounts: Vec<Account>,
pub statuses: Vec<Status>, pub statuses: Vec<Status>,
pub hashtags: Vec<Tag>,
} }

View file

@ -38,7 +38,7 @@ pub struct Tag {
} }
impl Tag { impl Tag {
fn from_tag_name(tag_name: String) -> Self { pub fn from_tag_name(tag_name: String) -> Self {
Tag { Tag {
name: tag_name, name: tag_name,
// TODO: add link to tag page // TODO: add link to tag page

View file

@ -9,4 +9,5 @@ pub mod profiles;
pub mod reactions; pub mod reactions;
pub mod relationships; pub mod relationships;
pub mod subscriptions; pub mod subscriptions;
pub mod tags;
pub mod users; pub mod users;

1
src/models/tags/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod queries;

View file

@ -0,0 +1,24 @@
use tokio_postgres::GenericClient;
use crate::errors::DatabaseError;
pub async fn search_tags(
db_client: &impl GenericClient,
search_query: &str,
limit: i64,
) -> Result<Vec<String>, DatabaseError> {
let db_search_query = format!("%{}%", search_query);
let rows = db_client.query(
"
SELECT tag_name
FROM tag
WHERE tag_name ILIKE $1
LIMIT $2
",
&[&db_search_query, &limit],
).await?;
let tags: Vec<String> = rows.iter()
.map(|row| row.try_get("tag_name"))
.collect::<Result<_, _>>()?;
Ok(tags)
}