Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve main search design #1273

Merged
merged 29 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d755d4b
Add search match highlighting for event title, description and series
LukasKalbertodt Oct 14, 2024
5c99431
Add search match highlighting for series
LukasKalbertodt Oct 29, 2024
668f0f5
Add search match highlighting for realms
LukasKalbertodt Oct 29, 2024
8ed121a
Improve tab order of text matches in search timeline
LukasKalbertodt Nov 4, 2024
3fc0d76
Improve GraphQL type generation for Search
LukasKalbertodt Nov 4, 2024
e9c6d91
Add our custom series icon and use it instead of "part of series:"
LukasKalbertodt Nov 4, 2024
34d07e4
Add `/path/to/realm/s/<seriesID>` route (for internal and OC IDs)
LukasKalbertodt Nov 5, 2024
ff7795f
Improve `ThumbnailStack` design for series
LukasKalbertodt Nov 5, 2024
b67950f
Rework search page design
LukasKalbertodt Nov 5, 2024
066fff1
Fix missing margin of "search unavailable" card
LukasKalbertodt Nov 6, 2024
80e2cc8
Fix `key` value in `highlightText`
LukasKalbertodt Nov 6, 2024
09d2cd9
Make it possible to navigate search result by arrow keys
LukasKalbertodt Nov 6, 2024
7ffc67b
Hide mobile virtual keyboard when pressing virtual "enter" in search
LukasKalbertodt Nov 7, 2024
0887c36
Refactor duplicate code and add `totalHits` to all `SearchResult<*>`
LukasKalbertodt Nov 7, 2024
107e433
Add `duration` to `SearchResults` API and experimentally show it in m…
LukasKalbertodt Nov 7, 2024
fdda221
Add code for debugging/profiling search performance
LukasKalbertodt Nov 7, 2024
35375b4
Remove second 250ms debounce in search
LukasKalbertodt Nov 7, 2024
a890bfe
Lazily render text match timelines
LukasKalbertodt Nov 7, 2024
55e4cba
Fix search sometimes ignoring some inputs
LukasKalbertodt Nov 7, 2024
4506f9f
Polish search design a bit more
LukasKalbertodt Nov 11, 2024
6a967d9
Change highlight color to neutral-color
LukasKalbertodt Nov 11, 2024
295dc11
Move "rerender route" hack to only rerender the main part
LukasKalbertodt Nov 11, 2024
709b30d
Pass new route information to `onNav` listeners in router
LukasKalbertodt Nov 11, 2024
63cc1ed
Rename function to better match its use
LukasKalbertodt Nov 11, 2024
37feeb6
Improve search menu/field handling on mobile
LukasKalbertodt Nov 11, 2024
676ea42
Respect `preventDefault()` in Link's onClick handler
LukasKalbertodt Nov 11, 2024
22f3765
Increase realm icon/circle size in search results a bit
LukasKalbertodt Nov 12, 2024
b42d436
Change dummy series thumbnail stack pattern to cube-thingy
LukasKalbertodt Nov 14, 2024
267ea28
Address review comments
LukasKalbertodt Nov 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions backend/src/api/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ use crate::{
series::Series,
realm::Realm,
playlist::AuthorizedPlaylist,
search::SearchEvent,
search::{SearchEvent, SearchRealm, SearchSeries},
},
},
prelude::*,
search::Realm as SearchRealm,
search::Series as SearchSeries,
search::Playlist as SearchPlaylist,
db::types::ExtraMetadata,
};
Expand Down
5 changes: 3 additions & 2 deletions backend/src/api/model/known_roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::{
prelude::*,
db::util::{impl_from_db, select},
};
use super::search::{SearchUnavailable, SearchResults, handle_search_result};
use super::search::{handle_search_result, measure_search_duration, SearchResults, SearchUnavailable};


// ===== Groups ===============================================================
Expand Down Expand Up @@ -73,6 +73,7 @@ pub(crate) async fn search_known_users(
query: String,
context: &Context,
) -> ApiResult<KnownUsersSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -127,5 +128,5 @@ pub(crate) async fn search_known_users(
items.extend(results.hits.into_iter().map(|h| h.result));
}

Ok(KnownUsersSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(KnownUsersSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}
59 changes: 45 additions & 14 deletions backend/src/api/model/search/event.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use chrono::{DateTime, Utc};
use juniper::GraphQLObject;
use meilisearch_sdk::MatchRange;

use crate::{
api::{Context, Id, Node, NodeValue},
db::types::TextAssetType,
search,
};
use super::{field_matches_for, match_ranges_for, ByteSpan, SearchRealm};


#[derive(Debug, GraphQLObject)]
Expand All @@ -25,14 +25,17 @@ pub(crate) struct SearchEvent {
pub end_time: Option<DateTime<Utc>>,
pub is_live: bool,
pub audio_only: bool,
pub host_realms: Vec<search::Realm>,
pub host_realms: Vec<SearchRealm>,
pub text_matches: Vec<TextMatch>,
pub matches: SearchEventMatches,
}

#[derive(Debug, GraphQLObject)]
pub struct ByteSpan {
pub start: i32,
pub len: i32,
#[derive(Debug, GraphQLObject, Default)]
pub struct SearchEventMatches {
title: Vec<ByteSpan>,
description: Vec<ByteSpan>,
series_title: Vec<ByteSpan>,
// TODO: creators
}

/// A match inside an event's texts while searching.
Expand Down Expand Up @@ -62,15 +65,40 @@ impl Node for SearchEvent {
}

impl SearchEvent {
pub(crate) fn new(
src: search::Event,
slide_matches: &[MatchRange],
caption_matches: &[MatchRange],
) -> Self {
pub(crate) fn without_matches(src: search::Event) -> Self {
Self::new_inner(src, vec![], SearchEventMatches::default())
}

pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>) -> Self {
let match_positions = hit.matches_position.as_ref();
let src = hit.result;

let mut text_matches = Vec::new();
src.slide_texts.resolve_matches(slide_matches, &mut text_matches, TextAssetType::SlideText);
src.caption_texts.resolve_matches(caption_matches, &mut text_matches, TextAssetType::Caption);
src.slide_texts.resolve_matches(
match_ranges_for(match_positions, "slide_texts.texts"),
&mut text_matches,
TextAssetType::SlideText,
);
src.caption_texts.resolve_matches(
match_ranges_for(match_positions, "caption_texts.texts"),
&mut text_matches,
TextAssetType::Caption,
);

let matches = SearchEventMatches {
title: field_matches_for(match_positions, "title"),
description: field_matches_for(match_positions, "description"),
series_title: field_matches_for(match_positions, "series_title"),
};

Self::new_inner(src, text_matches, matches)
}

fn new_inner(
src: search::Event,
text_matches: Vec<TextMatch>,
matches: SearchEventMatches,
) -> Self {
Self {
id: Id::search_event(src.id.0),
series_id: src.series_id.map(|id| Id::search_series(id.0)),
Expand All @@ -85,8 +113,11 @@ impl SearchEvent {
end_time: src.end_time,
is_live: src.is_live,
audio_only: src.audio_only,
host_realms: src.host_realms,
host_realms: src.host_realms.into_iter()
.map(|r| SearchRealm::without_matches(r))
.collect(),
text_matches,
matches,
}
}
}
132 changes: 84 additions & 48 deletions backend/src/api/model/search/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use chrono::{DateTime, Utc};
use juniper::GraphQLObject;
use meilisearch_sdk::MatchRange;
use once_cell::sync::Lazy;
use regex::Regex;
use std::{borrow::Cow, fmt};
use std::{borrow::Cow, collections::HashMap, fmt, time::Instant};

use crate::{
api::{
Expand All @@ -22,7 +23,11 @@ mod realm;
mod series;
mod playlist;

pub(crate) use self::event::{SearchEvent, TextMatch, ByteSpan};
pub(crate) use self::{
event::{SearchEvent, TextMatch},
realm::SearchRealm,
series::SearchSeries,
};


/// Marker type to signal that the search functionality is unavailable for some
Expand Down Expand Up @@ -50,37 +55,37 @@ pub(crate) enum SearchOutcome {
pub(crate) struct SearchResults<T> {
pub(crate) items: Vec<T>,
pub(crate) total_hits: usize,
pub(crate) duration: i32,
}

#[juniper::graphql_object(Context = Context)]
impl SearchResults<NodeValue> {
fn items(&self) -> &[NodeValue] {
&self.items
}
fn total_hits(&self) -> i32 {
self.total_hits as i32
}
macro_rules! make_search_results_object {
($name:literal, $ty:ty) => {
#[juniper::graphql_object(Context = Context, name = $name)]
impl SearchResults<$ty> {
fn items(&self) -> &[$ty] {
&self.items
}
fn total_hits(&self) -> i32 {
self.total_hits as i32
}
/// How long searching took in ms.
fn duration(&self) -> i32 {
self.duration
}
}
};
}

#[juniper::graphql_object(Context = Context, name = "EventSearchResults")]
impl SearchResults<SearchEvent> {
fn items(&self) -> &[SearchEvent] {
&self.items
}
}
make_search_results_object!("SearchResults", NodeValue);
make_search_results_object!("EventSearchResults", SearchEvent);
make_search_results_object!("SeriesSearchResults", SearchSeries);
make_search_results_object!("PlaylistSearchResults", search::Playlist);

#[juniper::graphql_object(Context = Context, name = "SeriesSearchResults")]
impl SearchResults<search::Series> {
fn items(&self) -> &[search::Series] {
&self.items
}
}

#[juniper::graphql_object(Context = Context, name = "PlaylistSearchResults")]
impl SearchResults<search::Playlist> {
fn items(&self) -> &[search::Playlist] {
&self.items
}
#[derive(Debug, GraphQLObject)]
pub struct ByteSpan {
pub start: i32,
pub len: i32,
}

#[derive(Debug, Clone, Copy, juniper::GraphQLEnum)]
Expand Down Expand Up @@ -150,6 +155,7 @@ pub(crate) async fn perform(
filters: Filters,
context: &Context,
) -> ApiResult<SearchOutcome> {
let elapsed_time = measure_search_duration();
if user_query.is_empty() {
return Ok(SearchOutcome::EmptyQuery(EmptyQuery));
}
Expand All @@ -166,12 +172,16 @@ pub(crate) async fn perform(
.await?
.map(|row| {
let e = search::Event::from_row_start(&row);
SearchEvent::new(e, &[], &[]).into()
SearchEvent::without_matches(e).into()
})
.into_iter()
.collect();
let total_hits = items.len();
return Ok(SearchOutcome::Results(SearchResults { items, total_hits }));
return Ok(SearchOutcome::Results(SearchResults {
items,
total_hits,
duration: elapsed_time(),
}));
}


Expand Down Expand Up @@ -233,12 +243,16 @@ pub(crate) async fn perform(
// https://github.com/orgs/meilisearch/discussions/489#discussioncomment-6160361
let events = event_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(hit_to_search_event(result)), score)
(NodeValue::from(SearchEvent::new(result)), score)
});
let series = series_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(SearchSeries::new(result, context)), score)
});
let realms = realm_results.hits.into_iter().map(|result| {
let score = result.ranking_score;
(NodeValue::from(SearchRealm::new(result)), score)
});
let series = series_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: Vec<(NodeValue, Option<f64>)> = Vec::new();
let total_hits: usize;
Expand Down Expand Up @@ -274,7 +288,11 @@ pub(crate) async fn perform(
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 }))
Ok(SearchOutcome::Results(SearchResults {
items,
total_hits,
duration: elapsed_time(),
}))
}

fn looks_like_opencast_uuid(query: &str) -> bool {
Expand All @@ -301,6 +319,7 @@ pub(crate) async fn all_events(
writable_only: bool,
context: &Context,
) -> ApiResult<EventSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand All @@ -327,25 +346,26 @@ pub(crate) async fn all_events(
}
let res = query.execute::<search::Event>().await;
let results = handle_search_result!(res, EventSearchOutcome);
let items = results.hits.into_iter().map(|h| hit_to_search_event(h)).collect();
let items = results.hits.into_iter().map(|h| SearchEvent::new(h)).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(EventSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(EventSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

// See `EventSearchOutcome` for additional information.
#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum SeriesSearchOutcome {
SearchUnavailable(SearchUnavailable),
Results(SearchResults<search::Series>),
Results(SearchResults<SearchSeries>),
}

pub(crate) async fn all_series(
user_query: &str,
writable_only: bool,
context: &Context,
) -> ApiResult<SeriesSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -378,10 +398,10 @@ pub(crate) async fn all_series(
}
let res = query.execute::<search::Series>().await;
let results = handle_search_result!(res, SeriesSearchOutcome);
let items = results.hits.into_iter().map(|h| h.result).collect();
let items = results.hits.into_iter().map(|h| SearchSeries::new(h, context)).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(SeriesSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(SeriesSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

#[derive(juniper::GraphQLUnion)]
Expand All @@ -396,6 +416,7 @@ pub(crate) async fn all_playlists(
writable_only: bool,
context: &Context,
) -> ApiResult<PlaylistSearchOutcome> {
let elapsed_time = measure_search_duration();
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}
Expand Down Expand Up @@ -425,7 +446,7 @@ pub(crate) async fn all_playlists(
let items = results.hits.into_iter().map(|h| h.result).collect();
let total_hits = results.estimated_total_hits.unwrap_or(0);

Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits }))
Ok(PlaylistSearchOutcome::Results(SearchResults { items, total_hits, duration: elapsed_time() }))
}

// TODO: replace usages of this and remove this.
Expand Down Expand Up @@ -533,13 +554,28 @@ impl fmt::Display for Filter {
}
}

fn hit_to_search_event(
hit: meilisearch_sdk::SearchResult<search::Event>,
) -> SearchEvent {
let get_matches = |key: &str| hit.matches_position.as_ref()
.and_then(|matches| matches.get(key))

fn match_ranges_for<'a>(
match_positions: Option<&'a HashMap<String, Vec<MatchRange>>>,
field: &str,
) -> &'a [MatchRange] {
match_positions
.and_then(|m| m.get(field))
.map(|v| v.as_slice())
.unwrap_or_default();
.unwrap_or_default()
}

fn field_matches_for(
match_positions: Option<&HashMap<String, Vec<MatchRange>>>,
field: &str,
) -> Vec<ByteSpan> {
match_ranges_for(match_positions, field).iter()
.map(|m| ByteSpan { start: m.start as i32, len: m.length as i32 })
.take(8) // The frontend can only show a limited number anyway
.collect()
}

SearchEvent::new(hit.result, get_matches("slide_texts.texts"), get_matches("caption_texts.texts"))
pub(crate) fn measure_search_duration() -> impl FnOnce() -> i32 {
let start = Instant::now();
move || start.elapsed().as_millis() as i32
}
Loading
Loading