Skip to content

Commit

Permalink
Add basic search filters (experimental) (#1080)
Browse files Browse the repository at this point in the history
This adds a first iteration of basic filters for item type ("all",
"videos", "pages") and time frame.
There are some open questions regarding how to deal with pages while the
time filter is active, since pages do not have a timestamp. This means
they are not affected by the filter.

Lukas and I decided to split to reworking of the search into multiple
PRs with incremental improvements and features added. This is now only
laying the foundation for these work packages, and will not change
anything visible for now.
Filters are included but hidden behind the `tobiraExperimentalFeatures`
flag (this is mainly information for developers).

Part of #1063
  • Loading branch information
LukasKalbertodt committed Mar 12, 2024
2 parents f16d1d3 + fbf74da commit fe112e6
Show file tree
Hide file tree
Showing 10 changed files with 355 additions and 66 deletions.
78 changes: 54 additions & 24 deletions backend/src/api/model/search/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use chrono::Utc;
use chrono::{DateTime, Utc};
use once_cell::sync::Lazy;
use regex::Regex;
use std::{fmt, borrow::Cow};
Expand Down Expand Up @@ -71,6 +71,18 @@ impl SearchResults<search::Series> {
}
}

#[derive(Debug, Clone, Copy, juniper::GraphQLEnum)]
enum ItemType {
Event,
Realm,
}

#[derive(juniper::GraphQLInputObject)]
pub(crate) struct Filters {
item_type: Option<ItemType>,
start: Option<DateTime<Utc>>,
end: Option<DateTime<Utc>>,
}

macro_rules! handle_search_result {
($res:expr, $return_type:ty) => {{
Expand Down Expand Up @@ -114,6 +126,7 @@ pub(crate) use handle_search_result;
/// Main entry point for the main search (including all items).
pub(crate) async fn perform(
user_query: &str,
filters: Filters,
context: &Context,
) -> ApiResult<SearchOutcome> {
if user_query.is_empty() {
Expand Down Expand Up @@ -147,27 +160,27 @@ pub(crate) async fn perform(
Filter::Leaf("is_live = false ".into()),
Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()),
].into())])
.chain(filters.start.map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into())))
.chain(filters.end.map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into())))
.collect()
).to_string();
let mut event_query = context.search.event_index.search();
event_query.with_query(user_query);
event_query.with_limit(15);
event_query.with_show_matches_position(true);
event_query.with_filter(&filter);
event_query.with_show_ranking_score(true);

let event_query = context.search.event_index.search()
.with_query(user_query)
.with_limit(15)
.with_show_matches_position(true)
.with_filter(&filter)
.with_show_ranking_score(true)
.build();


// Prepare the realm search
let realm_query = {
let mut query = context.search.realm_index.search();
query.with_query(user_query);
query.with_limit(10);
query.with_filter("is_user_realm = false");
query.with_show_matches_position(true);
query.with_show_ranking_score(true);
query
};
let realm_query = context.search.realm_index.search()
.with_query(user_query)
.with_limit(10)
.with_filter("is_user_realm = false")
.with_show_matches_position(true)
.with_show_ranking_score(true)
.build();


// Perform the searches
Expand All @@ -179,21 +192,38 @@ pub(crate) async fn perform(

// Merge results according to Meilis score.
//
// TODO: Comparing scores of differen indices is not well defined right now.
// TODO: Comparing scores of different indices is not well defined right now.
// We can either use score details or adding dummy searchable fields to the
// realm index. See this discussion for more info:
// https://github.com/orgs/meilisearch/discussions/489#discussioncomment-6160361
let events = event_results.hits.into_iter()
.map(|result| (NodeValue::from(result.result), result.ranking_score));
let realms = realm_results.hits.into_iter()
.map(|result| (NodeValue::from(result.result), result.ranking_score));
let mut merged = realms.chain(events).collect::<Vec<_>>();
merged.sort_unstable_by(|(_, score0), (_, score1)| score1.unwrap().total_cmp(&score0.unwrap()));

let total_hits: usize = [event_results.estimated_total_hits, realm_results.estimated_total_hits]
.iter()
.filter_map(|&x| x)
.sum();
let mut merged: Vec<(NodeValue, Option<f64>)> = Vec::new();
let total_hits: usize;

match filters.item_type {
Some(ItemType::Event) => {
merged.extend(events);
total_hits = event_results.estimated_total_hits.unwrap_or(0);
},
Some(ItemType::Realm) => {
merged.extend(realms);
total_hits = realm_results.estimated_total_hits.unwrap_or(0);
},
None => {
merged.extend(events);
merged.extend(realms);
total_hits = [event_results.estimated_total_hits, realm_results.estimated_total_hits]
.iter()
.filter_map(|&x| x)
.sum();
},
}

merged.sort_unstable_by(|(_, score0), (_, score1)| score1.unwrap().total_cmp(&score0.unwrap()));

let items = merged.into_iter().map(|(node, _)| node).collect();
Ok(SearchOutcome::Results(SearchResults { items, total_hits }))
Expand Down
18 changes: 9 additions & 9 deletions backend/src/api/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ use juniper::graphql_object;
use crate::auth::{AuthContext, User};

use super::{
Context,
Id,
NodeValue,
err::ApiResult,
jwt::{jwt, JwtService},
model::{
realm::Realm,
event::{AuthorizedEvent, Event},
known_roles::{self, KnownGroup, KnownUsersSearchOutcome},
realm::Realm,
search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome},
series::Series,
search::{self, SearchOutcome, EventSearchOutcome, SeriesSearchOutcome},
known_roles::{self, KnownUsersSearchOutcome, KnownGroup},
},
jwt::{JwtService, jwt},
Context,
Id,
NodeValue,
};


Expand Down Expand Up @@ -92,8 +92,8 @@ impl Query {
}

/// Returns `null` if the query is too short.
async fn search(query: String, context: &Context) -> ApiResult<SearchOutcome> {
search::perform(&query, context).await
async fn search(query: String, filters: Filters, context: &Context) -> ApiResult<SearchOutcome> {
search::perform(&query, filters, context).await
}

/// Searches through all events that the user has write access to
Expand Down
7 changes: 5 additions & 2 deletions backend/src/search/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) struct Event {
pub(crate) thumbnail: Option<String>,
pub(crate) duration: i64,
pub(crate) created: DateTime<Utc>,
pub(crate) created_timestamp: i64,
pub(crate) start_time: Option<DateTime<Utc>>,
pub(crate) end_time: Option<DateTime<Utc>>,
pub(crate) end_time_timestamp: Option<i64>,
Expand Down Expand Up @@ -67,6 +68,7 @@ impl_from_db!(
|row| {
let host_realms = row.host_realms::<Vec<Realm>>();
let end_time = row.end_time();
let created = row.created();
Self {
id: row.id(),
series_id: row.series(),
Expand All @@ -78,7 +80,8 @@ impl_from_db!(
duration: row.duration(),
is_live: row.is_live(),
audio_only: row.audio_only(),
created: row.created(),
created,
created_timestamp: created.timestamp(),
start_time: row.start_time(),
end_time,
end_time_timestamp: end_time.map(|date_time| date_time.timestamp()),
Expand Down Expand Up @@ -116,6 +119,6 @@ pub(super) async fn prepare_index(index: &Index) -> Result<()> {
index,
"event",
&["title", "creators", "description", "series_title"],
&["listed", "read_roles", "write_roles", "is_live", "end_time_timestamp"],
&["listed", "read_roles", "write_roles", "is_live", "end_time_timestamp", "created_timestamp"],
).await
}
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,16 @@ realm:

search:
input-label: Suche
title: Suchergebnisse für {{query}} ({{hits}} Treffer)
title: Suchergebnisse für <i>{{query}}</i> ({{count}} Treffer)
no-query: Suchergebnisse
no-results: Keine Ergebnisse
too-few-characters: Tippen Sie weitere Zeichen, um die Suche zu starten.
unavailable: Die Suchfunktion ist zurzeit nicht verfügbar. Versuchen Sie es später erneut.
filter-all: Alle
filter-event: Videos
filter-realm: Seiten
select-time-frame: Zeitraum auswählen
clear-time-frame: Zeitraum zurücksetzen

upload:
title: Video hochladen
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,16 @@ realm:

search:
input-label: Search
title: Search results for {{query}} ({{hits}} hits)
title: Search results for <i>{{query}}</i> ({{count}} hits)
no-query: Search results
no-results: No results
too-few-characters: Please type more characters to start the search.
unavailable: The search is currently unavailable. Try again later.
filter-all: All
filter-event: Videos
filter-realm: Pages
select-time-frame: Select time frame
clear-time-frame: Clear time frame

upload:
title: Upload video
Expand Down
34 changes: 25 additions & 9 deletions frontend/src/layout/header/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import React, { useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { HiOutlineSearch } from "react-icons/hi";
import { LuX } from "react-icons/lu";
import { useRouter } from "../../router";
import { handleNavigation, SearchRoute, isSearchActive } from "../../routes/Search";
import {
handleNavigation,
SearchRoute,
isSearchActive,
isValidSearchItemType,
} from "../../routes/Search";
import { focusStyle } from "../../ui";
import { Spinner } from "../../ui/Spinner";
import { currentRef } from "../../util";
import { currentRef, useDebounce } from "../../util";

import { BREAKPOINT as NAV_BREAKPOINT } from "../Navigation";
import { COLORS } from "../../color";
Expand All @@ -21,6 +26,7 @@ export const SearchField: React.FC<SearchFieldProps> = ({ variant }) => {
const { t } = useTranslation();
const router = useRouter();
const ref = useRef<HTMLInputElement>(null);
const { debounce } = useDebounce();

// Register global shortcut to focus search bar
useEffect(() => {
Expand Down Expand Up @@ -62,13 +68,23 @@ export const SearchField: React.FC<SearchFieldProps> = ({ variant }) => {
useEffect(() => () => clearTimeout(lastTimeout.current));

const onSearchRoute = isSearchActive();
const defaultValue = onSearchRoute
? new URLSearchParams(document.location.search).get("q") ?? undefined
: undefined;

const search = (expression: string) => {
router.goto(SearchRoute.url({ query: expression }), onSearchRoute);
const getSearchParam = (searchParameter: string) => {
const searchParams = new URLSearchParams(document.location.search);
return onSearchRoute
? searchParams.get(searchParameter) ?? undefined
: undefined;
};
const defaultValue = getSearchParam("q");


const search = useCallback(debounce((expression: string) => {
const filters = {
itemType: isValidSearchItemType(getSearchParam("f")),
start: getSearchParam("start"),
end: getSearchParam("end"),
};
router.goto(SearchRoute.url({ query: expression, ...filters }), onSearchRoute);
}, 250), []);

return (
<div css={{
Expand Down

0 comments on commit fe112e6

Please sign in to comment.