Skip to content

Commit fe112e6

Browse files
Add basic search filters (experimental) (#1080)
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
2 parents f16d1d3 + fbf74da commit fe112e6

File tree

10 files changed

+355
-66
lines changed

10 files changed

+355
-66
lines changed

backend/src/api/model/search/mod.rs

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use chrono::Utc;
1+
use chrono::{DateTime, Utc};
22
use once_cell::sync::Lazy;
33
use regex::Regex;
44
use std::{fmt, borrow::Cow};
@@ -71,6 +71,18 @@ impl SearchResults<search::Series> {
7171
}
7272
}
7373

74+
#[derive(Debug, Clone, Copy, juniper::GraphQLEnum)]
75+
enum ItemType {
76+
Event,
77+
Realm,
78+
}
79+
80+
#[derive(juniper::GraphQLInputObject)]
81+
pub(crate) struct Filters {
82+
item_type: Option<ItemType>,
83+
start: Option<DateTime<Utc>>,
84+
end: Option<DateTime<Utc>>,
85+
}
7486

7587
macro_rules! handle_search_result {
7688
($res:expr, $return_type:ty) => {{
@@ -114,6 +126,7 @@ pub(crate) use handle_search_result;
114126
/// Main entry point for the main search (including all items).
115127
pub(crate) async fn perform(
116128
user_query: &str,
129+
filters: Filters,
117130
context: &Context,
118131
) -> ApiResult<SearchOutcome> {
119132
if user_query.is_empty() {
@@ -147,27 +160,27 @@ pub(crate) async fn perform(
147160
Filter::Leaf("is_live = false ".into()),
148161
Filter::Leaf(format!("end_time_timestamp >= {}", Utc::now().timestamp()).into()),
149162
].into())])
163+
.chain(filters.start.map(|start| Filter::Leaf(format!("created_timestamp >= {}", start.timestamp()).into())))
164+
.chain(filters.end.map(|end| Filter::Leaf(format!("created_timestamp <= {}", end.timestamp()).into())))
150165
.collect()
151166
).to_string();
152-
let mut event_query = context.search.event_index.search();
153-
event_query.with_query(user_query);
154-
event_query.with_limit(15);
155-
event_query.with_show_matches_position(true);
156-
event_query.with_filter(&filter);
157-
event_query.with_show_ranking_score(true);
158-
167+
let event_query = context.search.event_index.search()
168+
.with_query(user_query)
169+
.with_limit(15)
170+
.with_show_matches_position(true)
171+
.with_filter(&filter)
172+
.with_show_ranking_score(true)
173+
.build();
159174

160175

161176
// Prepare the realm search
162-
let realm_query = {
163-
let mut query = context.search.realm_index.search();
164-
query.with_query(user_query);
165-
query.with_limit(10);
166-
query.with_filter("is_user_realm = false");
167-
query.with_show_matches_position(true);
168-
query.with_show_ranking_score(true);
169-
query
170-
};
177+
let realm_query = context.search.realm_index.search()
178+
.with_query(user_query)
179+
.with_limit(10)
180+
.with_filter("is_user_realm = false")
181+
.with_show_matches_position(true)
182+
.with_show_ranking_score(true)
183+
.build();
171184

172185

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

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

193-
let total_hits: usize = [event_results.estimated_total_hits, realm_results.estimated_total_hits]
194-
.iter()
195-
.filter_map(|&x| x)
196-
.sum();
204+
let mut merged: Vec<(NodeValue, Option<f64>)> = Vec::new();
205+
let total_hits: usize;
206+
207+
match filters.item_type {
208+
Some(ItemType::Event) => {
209+
merged.extend(events);
210+
total_hits = event_results.estimated_total_hits.unwrap_or(0);
211+
},
212+
Some(ItemType::Realm) => {
213+
merged.extend(realms);
214+
total_hits = realm_results.estimated_total_hits.unwrap_or(0);
215+
},
216+
None => {
217+
merged.extend(events);
218+
merged.extend(realms);
219+
total_hits = [event_results.estimated_total_hits, realm_results.estimated_total_hits]
220+
.iter()
221+
.filter_map(|&x| x)
222+
.sum();
223+
},
224+
}
225+
226+
merged.sort_unstable_by(|(_, score0), (_, score1)| score1.unwrap().total_cmp(&score0.unwrap()));
197227

198228
let items = merged.into_iter().map(|(node, _)| node).collect();
199229
Ok(SearchOutcome::Results(SearchResults { items, total_hits }))

backend/src/api/query.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ use juniper::graphql_object;
44
use crate::auth::{AuthContext, User};
55

66
use super::{
7-
Context,
8-
Id,
9-
NodeValue,
107
err::ApiResult,
8+
jwt::{jwt, JwtService},
119
model::{
12-
realm::Realm,
1310
event::{AuthorizedEvent, Event},
11+
known_roles::{self, KnownGroup, KnownUsersSearchOutcome},
12+
realm::Realm,
13+
search::{self, EventSearchOutcome, Filters, SearchOutcome, SeriesSearchOutcome},
1414
series::Series,
15-
search::{self, SearchOutcome, EventSearchOutcome, SeriesSearchOutcome},
16-
known_roles::{self, KnownUsersSearchOutcome, KnownGroup},
1715
},
18-
jwt::{JwtService, jwt},
16+
Context,
17+
Id,
18+
NodeValue,
1919
};
2020

2121

@@ -92,8 +92,8 @@ impl Query {
9292
}
9393

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

9999
/// Searches through all events that the user has write access to

backend/src/search/event.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub(crate) struct Event {
2323
pub(crate) thumbnail: Option<String>,
2424
pub(crate) duration: i64,
2525
pub(crate) created: DateTime<Utc>,
26+
pub(crate) created_timestamp: i64,
2627
pub(crate) start_time: Option<DateTime<Utc>>,
2728
pub(crate) end_time: Option<DateTime<Utc>>,
2829
pub(crate) end_time_timestamp: Option<i64>,
@@ -67,6 +68,7 @@ impl_from_db!(
6768
|row| {
6869
let host_realms = row.host_realms::<Vec<Realm>>();
6970
let end_time = row.end_time();
71+
let created = row.created();
7072
Self {
7173
id: row.id(),
7274
series_id: row.series(),
@@ -78,7 +80,8 @@ impl_from_db!(
7880
duration: row.duration(),
7981
is_live: row.is_live(),
8082
audio_only: row.audio_only(),
81-
created: row.created(),
83+
created,
84+
created_timestamp: created.timestamp(),
8285
start_time: row.start_time(),
8386
end_time,
8487
end_time_timestamp: end_time.map(|date_time| date_time.timestamp()),
@@ -116,6 +119,6 @@ pub(super) async fn prepare_index(index: &Index) -> Result<()> {
116119
index,
117120
"event",
118121
&["title", "creators", "description", "series_title"],
119-
&["listed", "read_roles", "write_roles", "is_live", "end_time_timestamp"],
122+
&["listed", "read_roles", "write_roles", "is_live", "end_time_timestamp", "created_timestamp"],
120123
).await
121124
}

frontend/src/i18n/locales/de.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,16 @@ realm:
228228

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

237242
upload:
238243
title: Video hochladen

frontend/src/i18n/locales/en.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,16 @@ realm:
226226

227227
search:
228228
input-label: Search
229-
title: Search results for {{query}} ({{hits}} hits)
229+
title: Search results for <i>{{query}}</i> ({{count}} hits)
230230
no-query: Search results
231231
no-results: No results
232232
too-few-characters: Please type more characters to start the search.
233233
unavailable: The search is currently unavailable. Try again later.
234+
filter-all: All
235+
filter-event: Videos
236+
filter-realm: Pages
237+
select-time-frame: Select time frame
238+
clear-time-frame: Clear time frame
234239

235240
upload:
236241
title: Upload video

frontend/src/layout/header/Search.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import React, { useEffect, useRef } from "react";
1+
import React, { useCallback, useEffect, useRef } from "react";
22
import { useTranslation } from "react-i18next";
33
import { HiOutlineSearch } from "react-icons/hi";
44
import { LuX } from "react-icons/lu";
55
import { useRouter } from "../../router";
6-
import { handleNavigation, SearchRoute, isSearchActive } from "../../routes/Search";
6+
import {
7+
handleNavigation,
8+
SearchRoute,
9+
isSearchActive,
10+
isValidSearchItemType,
11+
} from "../../routes/Search";
712
import { focusStyle } from "../../ui";
813
import { Spinner } from "../../ui/Spinner";
9-
import { currentRef } from "../../util";
14+
import { currentRef, useDebounce } from "../../util";
1015

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

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

6470
const onSearchRoute = isSearchActive();
65-
const defaultValue = onSearchRoute
66-
? new URLSearchParams(document.location.search).get("q") ?? undefined
67-
: undefined;
68-
69-
const search = (expression: string) => {
70-
router.goto(SearchRoute.url({ query: expression }), onSearchRoute);
71+
const getSearchParam = (searchParameter: string) => {
72+
const searchParams = new URLSearchParams(document.location.search);
73+
return onSearchRoute
74+
? searchParams.get(searchParameter) ?? undefined
75+
: undefined;
7176
};
77+
const defaultValue = getSearchParam("q");
78+
79+
80+
const search = useCallback(debounce((expression: string) => {
81+
const filters = {
82+
itemType: isValidSearchItemType(getSearchParam("f")),
83+
start: getSearchParam("start"),
84+
end: getSearchParam("end"),
85+
};
86+
router.goto(SearchRoute.url({ query: expression, ...filters }), onSearchRoute);
87+
}, 250), []);
7288

7389
return (
7490
<div css={{

0 commit comments

Comments
 (0)