From d755d4bf257617ee0e5fd9b340d12c11060dc989 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Mon, 14 Oct 2024 12:42:39 +0200 Subject: [PATCH 01/29] Add search match highlighting for event title, description and series `creators` is still missing, as it is an array and Meili does not allow to retrieve the exact match position there yet. --- backend/src/api/model/search/event.rs | 64 ++++++++++++++++++++++----- backend/src/api/model/search/mod.rs | 25 +++++------ frontend/src/routes/Search.tsx | 51 ++++++++++++++++++--- frontend/src/schema.graphql | 7 +++ frontend/src/ui/Blocks/VideoList.tsx | 11 +++-- frontend/src/ui/metadata.tsx | 2 +- 6 files changed, 124 insertions(+), 36 deletions(-) diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index 570512ea6..2f2926fee 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -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::ByteSpan; #[derive(Debug, GraphQLObject)] @@ -27,12 +27,15 @@ pub(crate) struct SearchEvent { pub audio_only: bool, pub host_realms: Vec, pub text_matches: Vec, + pub matches: SearchEventMatches, } -#[derive(Debug, GraphQLObject)] -pub struct ByteSpan { - pub start: i32, - pub len: i32, +#[derive(Debug, GraphQLObject, Default)] +pub struct SearchEventMatches { + title: Vec, + description: Vec, + series_title: Vec, + // TODO: creators } /// A match inside an event's texts while searching. @@ -62,15 +65,51 @@ 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) -> Self { + let match_positions = hit.matches_position.as_ref(); + let get_matches = |key: &str| match_positions + .and_then(|m| m.get(key)) + .map(|v| v.as_slice()) + .unwrap_or_default(); + + let field_matches = |key: &str| get_matches(key).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(); + + 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( + &get_matches("slide_texts.texts"), + &mut text_matches, + TextAssetType::SlideText, + ); + src.caption_texts.resolve_matches( + &get_matches("caption_texts.texts"), + &mut text_matches, + TextAssetType::Caption, + ); + let matches = SearchEventMatches { + title: field_matches("title"), + description: field_matches("description"), + series_title: field_matches("series_title"), + }; + + Self::new_inner(src, text_matches, matches) + } + + fn new_inner( + src: search::Event, + text_matches: Vec, + matches: SearchEventMatches, + ) -> Self { Self { id: Id::search_event(src.id.0), series_id: src.series_id.map(|id| Id::search_series(id.0)), @@ -87,6 +126,7 @@ impl SearchEvent { audio_only: src.audio_only, host_realms: src.host_realms, text_matches, + matches, } } } diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index 4297a397b..9da58cd35 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -22,7 +22,7 @@ mod realm; mod series; mod playlist; -pub(crate) use self::event::{SearchEvent, TextMatch, ByteSpan}; +pub(crate) use self::event::{SearchEvent, TextMatch}; /// Marker type to signal that the search functionality is unavailable for some @@ -83,6 +83,12 @@ impl SearchResults { } } +#[derive(Debug, GraphQLObject)] +pub struct ByteSpan { + pub start: i32, + pub len: i32, +} + #[derive(Debug, Clone, Copy, juniper::GraphQLEnum)] enum ItemType { Event, @@ -166,7 +172,7 @@ 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(); @@ -233,7 +239,7 @@ 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| (NodeValue::from(result.result), result.ranking_score)); @@ -327,7 +333,7 @@ pub(crate) async fn all_events( } let res = query.execute::().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 })) @@ -532,14 +538,3 @@ impl fmt::Display for Filter { } } } - -fn hit_to_search_event( - hit: meilisearch_sdk::SearchResult, -) -> SearchEvent { - let get_matches = |key: &str| hit.matches_position.as_ref() - .and_then(|matches| matches.get(key)) - .map(|v| v.as_slice()) - .unwrap_or_default(); - - SearchEvent::new(hit.result, get_matches("slide_texts.texts"), get_matches("caption_texts.texts")) -} diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 4a4439eed..f3251c110 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -157,6 +157,11 @@ const query = graphql` ty highlights { start len } } + matches { + title { start len } + description { start len } + seriesTitle { start len } + } } ... on SearchSeries { title @@ -402,6 +407,7 @@ const SearchResults: React.FC = ({ items }) => ( endTime: unwrapUndefined(item.endTime), hostRealms: unwrapUndefined(item.hostRealms), textMatches: unwrapUndefined(item.textMatches), + matches: unwrapUndefined(item.matches), }} />; } else if (item.__typename === "SearchSeries") { return ; + matches: NonNullable; }; const SearchEvent: React.FC = ({ @@ -498,6 +505,7 @@ const SearchEvent: React.FC = ({ endTime, hostRealms, textMatches, + matches, }) => { // TODO: decide what to do in the case of more than two host realms. Direct // link should be avoided. @@ -518,11 +526,13 @@ const SearchEvent: React.FC = ({ }}>

{title}

+ mark: highlightCss(COLORS.primary2, COLORS.neutral15), + }}>{highlightText(title, matches.title)} = ({ }, }} /> {description && } - {seriesTitle && seriesId && } + {seriesTitle && seriesId && } {/* Show timeline with matches if there are any */} {textMatches.length > 0 && ( @@ -991,17 +1009,39 @@ const byteSlice = (s: string, start: number, len: number): readonly [string, str ] as const; }; +/** + * Inserts `` elements inside `s` to highlight parts of text, as specified + * by `spans`. If `maxUnmarkedSectionLen` is specified, this function makes + * sure that all sections without any highlight (except the last one) is at + * most that many characters¹ long. If the a section is longer, its middle is + * replaced by " … " to stay within the limit. + * + * ¹ Well, technically UTF-16 code points, and this is important, but in our + * case it's a loosy goosy business anyway, since the number only approximates + * the available space for rendering anyway. + */ const highlightText = ( s: string, spans: readonly { start: number; len: number }[], + maxUnmarkedSectionLen = Infinity, ) => { const textParts = []; let remainingText = s; let offset = 0; for (const span of spans) { const highlightStart = span.start - offset; - const [prefix, middle, rest] + const [prefix_, middle, rest] = byteSlice(remainingText, highlightStart, span.len); + let prefix = prefix_; + + // If the first part (without a match) is too long, we truncate its + // middle. + if (prefix.length > maxUnmarkedSectionLen) { + const halfLen = maxUnmarkedSectionLen / 2 - 2; + const start = prefix.substring(0, halfLen); + const end = prefix.substring(prefix.length - halfLen); + prefix = `${start} … ${end}`; + } textParts.push({prefix}); textParts.push( @@ -1011,6 +1051,7 @@ const highlightText = ( offset = span.start + span.len; } textParts.push(remainingText); + return textParts; }; diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 190113f20..9465a2eff 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -98,6 +98,7 @@ type SearchEvent implements Node { audioOnly: Boolean! hostRealms: [SearchRealm!]! textMatches: [TextMatch!]! + matches: SearchEventMatches! } input ChildIndex { @@ -864,6 +865,12 @@ type Segment { startTime: Float! } +type SearchEventMatches { + title: [ByteSpan!]! + description: [ByteSpan!]! + seriesTitle: [ByteSpan!]! +} + "Services a user can be pre-authenticated for using a JWT" enum JwtService { UPLOAD diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index 937878bbd..f965b9f88 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -1065,13 +1065,18 @@ const Item: React.FC = ({ }; type PartOfSeriesLinkProps = { - seriesTitle: string; + seriesTitle: React.ReactNode; seriesId: string; + className?: string; } -export const PartOfSeriesLink: React.FC = ({ seriesTitle, seriesId }) => { +export const PartOfSeriesLink: React.FC = ({ + seriesTitle, + seriesId, + className, +}) => { const { t } = useTranslation(); - return
= ({ children }) ); type SmallDescriptionProps = { - text?: string | null; + text?: ReactNode | null; lines?: number; className?: string; }; From 5c994319668a6cc482e4cc26d104de857cab6523 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Tue, 29 Oct 2024 10:54:39 +0100 Subject: [PATCH 02/29] Add search match highlighting for series --- backend/src/api/common.rs | 3 +- backend/src/api/model/search/mod.rs | 19 ++++-- backend/src/api/model/search/series.rs | 90 +++++++++++++++++--------- frontend/src/routes/Search.tsx | 22 +++++-- frontend/src/schema.graphql | 12 +++- 5 files changed, 99 insertions(+), 47 deletions(-) diff --git a/backend/src/api/common.rs b/backend/src/api/common.rs index a0f60b4db..06bc8caaa 100644 --- a/backend/src/api/common.rs +++ b/backend/src/api/common.rs @@ -11,12 +11,11 @@ use crate::{ series::Series, realm::Realm, playlist::AuthorizedPlaylist, - search::SearchEvent, + search::{SearchEvent, SearchSeries}, }, }, prelude::*, search::Realm as SearchRealm, - search::Series as SearchSeries, search::Playlist as SearchPlaylist, db::types::ExtraMetadata, }; diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index 9da58cd35..b2ca24d15 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -22,7 +22,10 @@ mod realm; mod series; mod playlist; -pub(crate) use self::event::{SearchEvent, TextMatch}; +pub(crate) use self::{ + event::{SearchEvent, TextMatch}, + series::SearchSeries, +}; /// Marker type to signal that the search functionality is unavailable for some @@ -70,8 +73,8 @@ impl SearchResults { } #[juniper::graphql_object(Context = Context, name = "SeriesSearchResults")] -impl SearchResults { - fn items(&self) -> &[search::Series] { +impl SearchResults { + fn items(&self) -> &[SearchSeries] { &self.items } } @@ -241,8 +244,10 @@ pub(crate) async fn perform( let score = result.ranking_score; (NodeValue::from(SearchEvent::new(result)), score) }); - let series = series_results.hits.into_iter() - .map(|result| (NodeValue::from(result.result), result.ranking_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| (NodeValue::from(result.result), result.ranking_score)); @@ -344,7 +349,7 @@ pub(crate) async fn all_events( #[graphql(Context = Context)] pub(crate) enum SeriesSearchOutcome { SearchUnavailable(SearchUnavailable), - Results(SearchResults), + Results(SearchResults), } pub(crate) async fn all_series( @@ -384,7 +389,7 @@ pub(crate) async fn all_series( } let res = query.execute::().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 })) diff --git a/backend/src/api/model/search/series.rs b/backend/src/api/model/search/series.rs index d7fa54d57..8fb712095 100644 --- a/backend/src/api/model/search/series.rs +++ b/backend/src/api/model/search/series.rs @@ -1,48 +1,76 @@ +use juniper::GraphQLObject; + use crate::{ api::{Context, Id, Node, NodeValue}, search, HasRoles, }; -use super::ThumbnailInfo; +use super::{ByteSpan, ThumbnailInfo}; -impl Node for search::Series { - fn id(&self) -> Id { - Id::search_series(self.id.0) - } +#[derive(Debug, GraphQLObject)] +#[graphql(Context = Context, impl = NodeValue)] +pub(crate) struct SearchSeries { + id: Id, + opencast_id: String, + title: String, + description: Option, + host_realms: Vec, + thumbnails: Vec, + matches: SearchSeriesMatches, } -#[juniper::graphql_object(Context = Context, impl = NodeValue, name = "SearchSeries")] -impl search::Series { - fn id(&self) -> Id { - Node::id(self) - } - fn opencast_id(&self) -> &str { - &self.opencast_id - } +#[derive(Debug, GraphQLObject, Default)] +pub struct SearchSeriesMatches { + title: Vec, + description: Vec, +} - fn title(&self) -> &str { - &self.title +impl Node for SearchSeries { + fn id(&self) -> Id { + self.id } +} - fn description(&self) -> Option<&str> { - self.description.as_deref() - } +impl SearchSeries { + pub(crate) fn new( + hit: meilisearch_sdk::SearchResult, + context: &Context, + ) -> Self { + let match_positions = hit.matches_position.as_ref(); + let get_matches = |key: &str| match_positions + .and_then(|m| m.get(key)) + .map(|v| v.as_slice()) + .unwrap_or_default(); - fn host_realms(&self) -> &[search::Realm] { - &self.host_realms - } + let field_matches = |key: &str| get_matches(key).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(); + + let matches = SearchSeriesMatches { + title: field_matches("title"), + description: field_matches("description"), + }; - fn thumbnails(&self, context: &Context) -> Vec { - self.thumbnails.iter() - .filter(|info| context.auth.overlaps_roles(&info.read_roles)) - .map(|info| ThumbnailInfo { - thumbnail: info.url.clone(), - audio_only: info.audio_only, - is_live: info.live, - }) - .take(3) - .collect() + let src = hit.result; + Self { + id: Id::search_series(src.id.0), + opencast_id: src.opencast_id, + title: src.title, + description: src.description, + host_realms: src.host_realms, + thumbnails: src.thumbnails.iter() + .filter(|info| context.auth.overlaps_roles(&info.read_roles)) + .map(|info| ThumbnailInfo { + thumbnail: info.url.clone(), + audio_only: info.audio_only, + is_live: info.live, + }) + .take(3) + .collect(), + matches, + } } } diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index f3251c110..15c9d5022 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -167,6 +167,10 @@ const query = graphql` title description thumbnails { thumbnail isLive audioOnly } + matches { + title { start len } + description { start len } + } } ... on SearchRealm { name path ancestorNames } } @@ -415,6 +419,7 @@ const SearchResults: React.FC = ({ items }) => ( title: unwrapUndefined(item.title), description: unwrapUndefined(item.description), thumbnails: unwrapUndefined(item.thumbnails), + matches: unwrapUndefined(item.matches), }} />; } else if (item.__typename === "SearchRealm") { return ; } -const SearchSeries: React.FC = ({ id, title, description, thumbnails }) => +const SearchSeries: React.FC = ({ + id, title, description, thumbnails, matches, +}) =>
= ({ id, title, description, thu }}>

{title}

+ }}>{highlightText(title, matches.title)} {description && }
diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 9465a2eff..700f29449 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -251,9 +251,9 @@ type SearchPlaylist implements Node { union KnownUsersSearchOutcome = SearchUnavailable | KnownUserSearchResults -input UpdatedPermissions { - moderatorRoles: [String!] - adminRoles: [String!] +type SearchSeriesMatches { + title: [ByteSpan!]! + description: [ByteSpan!]! } type NotAllowed { @@ -264,6 +264,11 @@ type NotAllowed { dummy: Boolean } +input UpdatedPermissions { + moderatorRoles: [String!] + adminRoles: [String!] +} + input UpdateVideoBlock { event: ID showTitle: Boolean @@ -803,6 +808,7 @@ type SearchSeries implements Node { description: String hostRealms: [SearchRealm!]! thumbnails: [ThumbnailInfo!]! + matches: SearchSeriesMatches! } type ThumbnailInfo { From 668f0f5d6c16c22b9662101a82ce0ab770eec8c6 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Tue, 29 Oct 2024 11:46:36 +0100 Subject: [PATCH 03/29] Add search match highlighting for realms --- backend/src/api/common.rs | 3 +- backend/src/api/model/search/event.rs | 29 +++++--------- backend/src/api/model/search/mod.rs | 31 ++++++++++++-- backend/src/api/model/search/playlist.rs | 8 ++-- backend/src/api/model/search/realm.rs | 51 ++++++++++++++++++------ backend/src/api/model/search/series.rs | 22 ++++------ backend/src/search/realm.rs | 2 +- frontend/src/routes/Search.tsx | 30 +++++++++++--- frontend/src/schema.graphql | 11 +++-- 9 files changed, 123 insertions(+), 64 deletions(-) diff --git a/backend/src/api/common.rs b/backend/src/api/common.rs index 06bc8caaa..e45e7f179 100644 --- a/backend/src/api/common.rs +++ b/backend/src/api/common.rs @@ -11,11 +11,10 @@ use crate::{ series::Series, realm::Realm, playlist::AuthorizedPlaylist, - search::{SearchEvent, SearchSeries}, + search::{SearchEvent, SearchRealm, SearchSeries}, }, }, prelude::*, - search::Realm as SearchRealm, search::Playlist as SearchPlaylist, db::types::ExtraMetadata, }; diff --git a/backend/src/api/model/search/event.rs b/backend/src/api/model/search/event.rs index 2f2926fee..c77e4774d 100644 --- a/backend/src/api/model/search/event.rs +++ b/backend/src/api/model/search/event.rs @@ -6,7 +6,7 @@ use crate::{ db::types::TextAssetType, search, }; -use super::ByteSpan; +use super::{field_matches_for, match_ranges_for, ByteSpan, SearchRealm}; #[derive(Debug, GraphQLObject)] @@ -25,7 +25,7 @@ pub(crate) struct SearchEvent { pub end_time: Option>, pub is_live: bool, pub audio_only: bool, - pub host_realms: Vec, + pub host_realms: Vec, pub text_matches: Vec, pub matches: SearchEventMatches, } @@ -71,35 +71,24 @@ impl SearchEvent { pub(crate) fn new(hit: meilisearch_sdk::SearchResult) -> Self { let match_positions = hit.matches_position.as_ref(); - let get_matches = |key: &str| match_positions - .and_then(|m| m.get(key)) - .map(|v| v.as_slice()) - .unwrap_or_default(); - - let field_matches = |key: &str| get_matches(key).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(); - let src = hit.result; - let mut text_matches = Vec::new(); src.slide_texts.resolve_matches( - &get_matches("slide_texts.texts"), + match_ranges_for(match_positions, "slide_texts.texts"), &mut text_matches, TextAssetType::SlideText, ); src.caption_texts.resolve_matches( - &get_matches("caption_texts.texts"), + match_ranges_for(match_positions, "caption_texts.texts"), &mut text_matches, TextAssetType::Caption, ); let matches = SearchEventMatches { - title: field_matches("title"), - description: field_matches("description"), - series_title: field_matches("series_title"), + 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) @@ -124,7 +113,9 @@ 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, } diff --git a/backend/src/api/model/search/mod.rs b/backend/src/api/model/search/mod.rs index b2ca24d15..0e8577277 100644 --- a/backend/src/api/model/search/mod.rs +++ b/backend/src/api/model/search/mod.rs @@ -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}; use crate::{ api::{ @@ -24,6 +25,7 @@ mod playlist; pub(crate) use self::{ event::{SearchEvent, TextMatch}, + realm::SearchRealm, series::SearchSeries, }; @@ -248,8 +250,10 @@ pub(crate) async fn perform( let score = result.ranking_score; (NodeValue::from(SearchSeries::new(result, context)), score) }); - let realms = realm_results.hits.into_iter() - .map(|result| (NodeValue::from(result.result), result.ranking_score)); + let realms = realm_results.hits.into_iter().map(|result| { + let score = result.ranking_score; + (NodeValue::from(SearchRealm::new(result)), score) + }); let mut merged: Vec<(NodeValue, Option)> = Vec::new(); let total_hits: usize; @@ -543,3 +547,24 @@ impl fmt::Display for Filter { } } } + + +fn match_ranges_for<'a>( + match_positions: Option<&'a HashMap>>, + field: &str, +) -> &'a [MatchRange] { + match_positions + .and_then(|m| m.get(field)) + .map(|v| v.as_slice()) + .unwrap_or_default() +} + +fn field_matches_for( + match_positions: Option<&HashMap>>, + field: &str, +) -> Vec { + 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() +} diff --git a/backend/src/api/model/search/playlist.rs b/backend/src/api/model/search/playlist.rs index e3819e164..4970e5651 100644 --- a/backend/src/api/model/search/playlist.rs +++ b/backend/src/api/model/search/playlist.rs @@ -1,5 +1,5 @@ use crate::{ - api::{Context, Node, Id, NodeValue}, + api::{model::search::SearchRealm, Context, Id, Node, NodeValue}, search, }; @@ -28,7 +28,9 @@ impl search::Playlist { self.description.as_deref() } - fn host_realms(&self) -> &[search::Realm] { - &self.host_realms + fn host_realms(&self) -> Vec { + self.host_realms.iter() + .map(|r| SearchRealm::without_matches(r.clone())) + .collect() } } diff --git a/backend/src/api/model/search/realm.rs b/backend/src/api/model/search/realm.rs index d6e9d75c0..5276c2d7b 100644 --- a/backend/src/api/model/search/realm.rs +++ b/backend/src/api/model/search/realm.rs @@ -1,30 +1,55 @@ +use juniper::GraphQLObject; + use crate::{ api::{Context, Node, Id, NodeValue}, search, }; +use super::{field_matches_for, ByteSpan}; -impl Node for search::Realm { - fn id(&self) -> Id { - Id::search_realm(self.id.0) - } +#[derive(Debug, GraphQLObject)] +#[graphql(Context = Context, impl = NodeValue)] +pub(crate) struct SearchRealm { + id: Id, + name: Option, + path: String, + ancestor_names: Vec>, + matches: SearchRealmMatches, +} + + +#[derive(Debug, GraphQLObject, Default)] +pub struct SearchRealmMatches { + name: Vec, } -#[juniper::graphql_object(Context = Context, impl = NodeValue, name = "SearchRealm")] -impl search::Realm { +impl Node for SearchRealm { fn id(&self) -> Id { - Node::id(self) + self.id } +} + - fn name(&self) -> Option<&str> { - self.name.as_deref() +impl SearchRealm { + pub(crate) fn without_matches(src: search::Realm) -> Self { + Self::new_inner(src, SearchRealmMatches::default()) } - fn path(&self) -> &str { - if self.full_path.is_empty() { "/" } else { &self.full_path } + pub(crate) fn new(hit: meilisearch_sdk::SearchResult) -> Self { + let match_positions = hit.matches_position.as_ref(); + let matches = SearchRealmMatches { + name: field_matches_for(match_positions, "name"), + }; + Self::new_inner(hit.result, matches) } - fn ancestor_names(&self) -> &[Option] { - &self.ancestor_names + fn new_inner(src: search::Realm, matches: SearchRealmMatches) -> Self { + Self { + id: Id::search_realm(src.id.0), + name: src.name, + path: if src.full_path.is_empty() { "/".into() } else { src.full_path }, + ancestor_names: src.ancestor_names, + matches, + } } } diff --git a/backend/src/api/model/search/series.rs b/backend/src/api/model/search/series.rs index 8fb712095..94097cabe 100644 --- a/backend/src/api/model/search/series.rs +++ b/backend/src/api/model/search/series.rs @@ -5,7 +5,7 @@ use crate::{ search, HasRoles, }; -use super::{ByteSpan, ThumbnailInfo}; +use super::{field_matches_for, ByteSpan, SearchRealm, ThumbnailInfo}; #[derive(Debug, GraphQLObject)] @@ -15,7 +15,7 @@ pub(crate) struct SearchSeries { opencast_id: String, title: String, description: Option, - host_realms: Vec, + host_realms: Vec, thumbnails: Vec, matches: SearchSeriesMatches, } @@ -39,19 +39,9 @@ impl SearchSeries { context: &Context, ) -> Self { let match_positions = hit.matches_position.as_ref(); - let get_matches = |key: &str| match_positions - .and_then(|m| m.get(key)) - .map(|v| v.as_slice()) - .unwrap_or_default(); - - let field_matches = |key: &str| get_matches(key).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(); - let matches = SearchSeriesMatches { - title: field_matches("title"), - description: field_matches("description"), + title: field_matches_for(match_positions, "title"), + description: field_matches_for(match_positions, "description"), }; let src = hit.result; @@ -60,7 +50,9 @@ impl SearchSeries { opencast_id: src.opencast_id, title: src.title, description: src.description, - host_realms: src.host_realms, + host_realms: src.host_realms.into_iter() + .map(|r| SearchRealm::without_matches(r)) + .collect(), thumbnails: src.thumbnails.iter() .filter(|info| context.auth.overlaps_roles(&info.read_roles)) .map(|info| ThumbnailInfo { diff --git a/backend/src/search/realm.rs b/backend/src/search/realm.rs index dff5f0937..d238cfd22 100644 --- a/backend/src/search/realm.rs +++ b/backend/src/search/realm.rs @@ -9,7 +9,7 @@ use super::{util::{self, FieldAbilities}, IndexItem, IndexItemKind, SearchId}; /// Representation of realms in the search index. -#[derive(Serialize, Deserialize, Debug, FromSql)] +#[derive(Clone, Serialize, Deserialize, Debug, FromSql)] #[postgres(name = "search_realms")] pub(crate) struct Realm { pub(crate) id: SearchId, diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 15c9d5022..0fdd51456 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -172,7 +172,14 @@ const query = graphql` description { start len } } } - ... on SearchRealm { name path ancestorNames } + ... on SearchRealm { + name + path + ancestorNames + matches { + name { start len } + } + } } totalHits } @@ -377,6 +384,8 @@ const CenteredNote: React.FC<{ children: ReactNode }> = ({ children }) => ( ); type Results = Extract; +type RawMatches = NonNullable; +type Matches = Required>; type SearchResultsProps = { items: Results["items"]; @@ -427,6 +436,9 @@ const SearchResults: React.FC = ({ items }) => ( name: unwrapUndefined(item.name), fullPath: unwrapUndefined(item.path), ancestorNames: unwrapUndefined(item.ancestorNames), + matches: { + name: unwrapUndefined(item.matches?.name), + }, }} />; } else { // eslint-disable-next-line no-console @@ -491,7 +503,7 @@ type SearchEventProps = { endTime: string | null; hostRealms: readonly { readonly path: string }[]; textMatches: NonNullable; - matches: NonNullable; + matches: Matches<"title" | "description" | "seriesTitle">; }; const SearchEvent: React.FC = ({ @@ -799,7 +811,7 @@ type SearchSeriesProps = { title: string; description: string | null; thumbnails: readonly ThumbnailInfo[] | undefined; - matches: NonNullable; + matches: Matches<"title" | "description">; } const SearchSeries: React.FC = ({ @@ -912,9 +924,12 @@ type SearchRealmProps = { name: string | null; ancestorNames: readonly (string | null | undefined)[]; fullPath: string; + matches: Matches<"name">; }; -const SearchRealm: React.FC = ({ id, name, ancestorNames, fullPath }) => ( +const SearchRealm: React.FC = ({ + id, name, ancestorNames, fullPath, matches, +}) => (
@@ -924,7 +939,12 @@ const SearchRealm: React.FC = ({ id, name, ancestorNames, full )} -

{name ?? }

+

+ {name ? highlightText(name, matches.name) : } +

diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index 700f29449..b4f878157 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -755,6 +755,7 @@ type SearchRealm implements Node { name: String path: String! ancestorNames: [String]! + matches: SearchRealmMatches! } "Defines the sort order for events." @@ -861,9 +862,8 @@ input NewRealm { pathSegment: String! } -enum SortDirection { - ASCENDING - DESCENDING +type SearchRealmMatches { + name: [ByteSpan!]! } type Segment { @@ -871,6 +871,11 @@ type Segment { startTime: Float! } +enum SortDirection { + ASCENDING + DESCENDING +} + type SearchEventMatches { title: [ByteSpan!]! description: [ByteSpan!]! From 8ed121aec6e53a43ea03da3a0c02672a177de127 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Mon, 4 Nov 2024 10:42:07 +0100 Subject: [PATCH 04/29] Improve tab order of text matches in search timeline --- frontend/src/routes/Search.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 0fdd51456..5d02f8762 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -641,6 +641,10 @@ const TextMatchTimeline: React.FC = ({ const [queryRef, loadQuery] = useQueryLoader(slidePreviewQuery); + // For a more useful tab order. + const sortedMatches = [...textMatches]; + sortedMatches.sort((a, b) => a.start - b.start); + // We load the query once the user hovers over the parent container. This // seems like it would send a query every time the mouse enters, but relay // caches the results, so it is only sent once. @@ -664,7 +668,7 @@ const TextMatchTimeline: React.FC = ({ borderTop: `1.5px solid ${COLORS.neutral50}`, }} /> - {textMatches.map((m, i) => ( + {sortedMatches.map((m, i) => ( Date: Mon, 4 Nov 2024 11:39:06 +0100 Subject: [PATCH 05/29] Improve GraphQL type generation for Search The `id` field that was requested for all items unfortunately made the types worse. By just requesting it individually for each item, we get better types and can get rid of lots of boilerplate code. --- frontend/src/routes/Search.tsx | 100 ++++++--------------------------- 1 file changed, 18 insertions(+), 82 deletions(-) diff --git a/frontend/src/routes/Search.tsx b/frontend/src/routes/Search.tsx index 5d02f8762..1e3d21afe 100644 --- a/frontend/src/routes/Search.tsx +++ b/frontend/src/routes/Search.tsx @@ -134,9 +134,9 @@ const query = graphql` ... on SearchUnavailable { dummy } ... on SearchResults { items { - id __typename ... on SearchEvent { + id title description thumbnail @@ -164,6 +164,7 @@ const query = graphql` } } ... on SearchSeries { + id title description thumbnails { thumbnail isLive audioOnly } @@ -173,6 +174,7 @@ const query = graphql` } } ... on SearchRealm { + id name path ancestorNames @@ -384,17 +386,15 @@ const CenteredNote: React.FC<{ children: ReactNode }> = ({ children }) => ( ); type Results = Extract; -type RawMatches = NonNullable; -type Matches = Required>; +type Item = Results["items"][number]; +type EventItem = Omit, "__typename">; +type SeriesItem = Omit, "__typename">; +type RealmItem = Omit, "__typename">; type SearchResultsProps = { items: Results["items"]; }; -const unwrapUndefined = (value: T | undefined): T => typeof value === "undefined" - ? unreachable("type dependent field for search item is not set") - : value; - const SearchResults: React.FC = ({ items }) => (
    = ({ items }) => ( }}> {items.map(item => { if (item.__typename === "SearchEvent") { - return ; + return ; } else if (item.__typename === "SearchSeries") { - return ; + return ; } else if (item.__typename === "SearchRealm") { - return ; + return ; } else { // eslint-disable-next-line no-console console.warn("Unknown search item type: ", item.__typename); @@ -487,26 +456,7 @@ const WithIcon: React.FC = ({ Icon, iconSize = 30, children, hide
); -type SearchEventProps = { - id: string; - title: string; - description: string | null; - thumbnail: string | null; - duration: number; - creators: readonly string[]; - seriesTitle: string | null; - seriesId: string | null; - isLive: boolean; - audioOnly: boolean; - created: string; - startTime: string | null; - endTime: string | null; - hostRealms: readonly { readonly path: string }[]; - textMatches: NonNullable; - matches: Matches<"title" | "description" | "seriesTitle">; -}; - -const SearchEvent: React.FC = ({ +const SearchEvent: React.FC = ({ id, title, description, @@ -617,7 +567,7 @@ const thumbnailCss = { }, }; -type TextMatchTimelineProps = Pick & { +type TextMatchTimelineProps = Pick & { link: string; }; @@ -718,7 +668,7 @@ const TextMatchTimeline: React.FC = ({ type TextMatchTooltipWithMaybeImageProps = { queryRef: PreloadedQuery; - textMatch: SearchEventProps["textMatches"][number]; + textMatch: EventItem["textMatches"][number]; }; const TextMatchTooltipWithMaybeImage: React.FC = ({ @@ -751,7 +701,7 @@ const TextMatchTooltipWithMaybeImage: React.FC = ({ previewImage, textMatch }) => { @@ -810,15 +760,8 @@ type ThumbnailInfo = { readonly isLive: boolean; readonly thumbnail: string | null | undefined; } -type SearchSeriesProps = { - id: string; - title: string; - description: string | null; - thumbnails: readonly ThumbnailInfo[] | undefined; - matches: Matches<"title" | "description">; -} -const SearchSeries: React.FC = ({ +const SearchSeries: React.FC = ({ id, title, description, thumbnails, matches, }) => @@ -855,7 +798,7 @@ const SearchSeries: React.FC = ({ ; -type ThumbnailStackProps = Pick +type ThumbnailStackProps = Pick const ThumbnailStack: React.FC = ({ thumbnails, title }) => (
= ({ info, title }) => { ; }; -type SearchRealmProps = { - id: string; - name: string | null; - ancestorNames: readonly (string | null | undefined)[]; - fullPath: string; - matches: Matches<"name">; -}; -const SearchRealm: React.FC = ({ - id, name, ancestorNames, fullPath, matches, +const SearchRealm: React.FC = ({ + id, name, ancestorNames, path: fullPath, matches, }) => ( From e9c6d91043e33990a3c849e019ce12bbc802e942 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Mon, 4 Nov 2024 16:26:13 +0100 Subject: [PATCH 06/29] Add our custom series icon and use it instead of "part of series:" --- frontend/src/icons/series.svg | 2 ++ frontend/src/ui/Blocks/VideoList.tsx | 21 ++++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 frontend/src/icons/series.svg diff --git a/frontend/src/icons/series.svg b/frontend/src/icons/series.svg new file mode 100644 index 000000000..a61722615 --- /dev/null +++ b/frontend/src/icons/series.svg @@ -0,0 +1,2 @@ + + diff --git a/frontend/src/ui/Blocks/VideoList.tsx b/frontend/src/ui/Blocks/VideoList.tsx index f965b9f88..32f3c1ffa 100644 --- a/frontend/src/ui/Blocks/VideoList.tsx +++ b/frontend/src/ui/Blocks/VideoList.tsx @@ -29,6 +29,7 @@ import { import { PlaylistBlockPlaylistData$data } from "./__generated__/PlaylistBlockPlaylistData.graphql"; import { keyOfId } from "../../util"; import { Link } from "../../router"; +import SeriesIcon from "../../icons/series.svg"; import { BaseThumbnailReplacement, isPastLiveEvent, isUpcomingLiveEvent, Thumbnail, ThumbnailOverlayContainer, @@ -1012,6 +1013,7 @@ const Item: React.FC = ({ {showSeries && item.series?.id && item.series.title && } }
@@ -1074,23 +1076,24 @@ export const PartOfSeriesLink: React.FC = ({ seriesTitle, seriesId, className, -}) => { - const { t } = useTranslation(); - return
( +
- {t("video.part-of-series") + ": "} + = ({ }, }, }}>{seriesTitle} -
; -}; +
+); From 34d07e4c50f8d9d441ee8f1fc0da2a6cd5e1ae76 Mon Sep 17 00:00:00 2001 From: Lukas Kalbertodt Date: Tue, 5 Nov 2024 12:30:58 +0100 Subject: [PATCH 07/29] Add `/path/to/realm/s/` route (for internal and OC IDs) I will use this in an upcoming commit, and it also seems like an obvious addition, given our other video/series routes. --- frontend/src/router.tsx | 9 +- frontend/src/routes/Series.tsx | 199 ++++++++++++++++++++++++++++++--- 2 files changed, 192 insertions(+), 16 deletions(-) diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 86d39631f..a7cb6cf41 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -13,7 +13,12 @@ import { OpencastVideoRoute, VideoRoute, } from "./routes/Video"; -import { DirectSeriesOCRoute, DirectSeriesRoute } from "./routes/Series"; +import { + DirectSeriesOCRoute, + DirectSeriesRoute, + OpencastSeriesRoute, + SeriesRoute, +} from "./routes/Series"; import { ManageVideosRoute } from "./routes/manage/Video"; import { UploadRoute } from "./routes/Upload"; import { SearchRoute } from "./routes/Search"; @@ -45,6 +50,8 @@ const { SearchRoute, OpencastVideoRoute, VideoRoute, + OpencastSeriesRoute, + SeriesRoute, DirectVideoRoute, DirectOpencastVideoRoute, DirectSeriesRoute, diff --git a/frontend/src/routes/Series.tsx b/frontend/src/routes/Series.tsx index 9c1a9011c..12d3f62cd 100644 --- a/frontend/src/routes/Series.tsx +++ b/frontend/src/routes/Series.tsx @@ -4,20 +4,159 @@ import { useTranslation } from "react-i18next"; import { loadQuery } from "../relay"; import { makeRoute } from "../rauta"; import { SeriesBlockFromSeries } from "../ui/Blocks/Series"; -import { RootLoader } from "../layout/Root"; +import { InitialLoading, RootLoader } from "../layout/Root"; import { Nav } from "../layout/Navigation"; import { PageTitle } from "../layout/header/ui"; import { WaitingPage } from "../ui/Waiting"; import { isSynced, keyOfId, seriesId } from "../util"; import { NotFound } from "./NotFound"; -import { SeriesByOpencastIdQuery } from "./__generated__/SeriesByOpencastIdQuery.graphql"; import { b64regex } from "./util"; import { SeriesByIdQuery } from "./__generated__/SeriesByIdQuery.graphql"; import { SeriesRouteData$key } from "./__generated__/SeriesRouteData.graphql"; import { Breadcrumbs } from "../ui/Breadcrumbs"; +import { isValidRealmPath } from "./Realm"; +import { useRouter } from "../router"; +import { useEffect } from "react"; +import { SeriesPageRealmData$key } from "./__generated__/SeriesPageRealmData.graphql"; +import { realmBreadcrumbs } from "../util/realm"; +import { + SeriesDirectByOpencastIdQuery, +} from "./__generated__/SeriesDirectByOpencastIdQuery.graphql"; +import { SeriesDirectByIdQuery } from "./__generated__/SeriesDirectByIdQuery.graphql"; +import { SeriesByOcIdQuery } from "./__generated__/SeriesByOcIdQuery.graphql"; +export const SeriesRoute = makeRoute({ + url: ({ realmPath, seriesId }: { realmPath: string; seriesId: string }) => + `${realmPath === "/" ? "" : realmPath}/s/${keyOfId(seriesId)}`, + match: url => { + const params = checkSerieRealmPath(url, b64regex); + if (params == null) { + return null; + } + const query = graphql` + query SeriesByIdQuery($id: ID!, $realmPath: String!) { + ... UserData + series: seriesById(id: $id) { + ...SeriesRouteData + isReferencedByRealm(path: $realmPath) + } + realm: realmByPath(path: $realmPath) { + ... NavigationData + ... SeriesPageRealmData + } + } + `; + const queryRef = loadQuery(query, { + id: seriesId(params.seriesId), + realmPath: params.realmPath, + }); + + + return { + render: () => data.realm ?