From ca6a8b0969d9886a717ca23308683da423900fef Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 4 Jan 2022 14:58:55 +0100 Subject: [PATCH] Improve search --- src/app.rs | 115 +++++++++++++++++++++++++++++-------- src/fuzzy.rs | 44 +++----------- src/lib.rs | 1 + src/query.rs | 117 ++++++++++++++++++++++++++++++++++++++ src/song.rs | 55 ++++++++++++++++-- static/images/flag.svg | 21 +++++++ static/images/note.svg | 28 +++++++++ static/styles/common.scss | 24 +++++--- 8 files changed, 332 insertions(+), 73 deletions(-) create mode 100644 src/query.rs create mode 100644 static/images/flag.svg create mode 100644 static/images/note.svg diff --git a/src/app.rs b/src/app.rs index ea9c250..d290b06 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::css::C; +use crate::query::ParsedQuery; use crate::song::Song; use anyhow::anyhow; use rand::seq::SliceRandom; @@ -8,11 +9,14 @@ use seed::browser::util::document; use seed::prelude::*; use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF}; use std::cmp::Reverse; +use web_sys::Element; pub struct Model { songs: Vec, + query: String, filtered_songs: Vec, - show_elements: usize, + hidden_songs: usize, + shown_songs: usize, filter_video: bool, filter_duets: bool, } @@ -20,7 +24,6 @@ pub struct Model { const SCROLL_THRESHOLD: usize = 50; const INITIAL_ELEM_COUNT: usize = 100; -//#[derive(Clone, Debug)] pub enum Msg { Search(String), ToggleVideo, @@ -37,8 +40,10 @@ pub fn init(_url: Url, _orders: &mut impl Orders) -> Model { Model { songs, + query: String::new(), filtered_songs, - show_elements: INITIAL_ELEM_COUNT, + hidden_songs: 0, + shown_songs: INITIAL_ELEM_COUNT, filter_video: false, filter_duets: false, } @@ -46,18 +51,57 @@ pub fn init(_url: Url, _orders: &mut impl Orders) -> Model { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { match msg { - Msg::Search(query) if query.is_empty() => update(Msg::Shuffle, model, orders), Msg::Search(query) => { - model.filtered_songs.sort_by_cached_key(|&i| { - let song = &model.songs[i]; - let top_score = Reverse(song.fuzzy_compare(&query)); + model.hidden_songs = 0; + model.shown_songs = INITIAL_ELEM_COUNT; + scroll_to_top(); - (top_score, &song.title, &song.artist, &song.song_hash) - }); + model.query = query; + + if model.query.is_empty() { + model.filter_duets = false; + model.filter_video = false; + update(Msg::Shuffle, model, orders) + } else { + let query = ParsedQuery::parse(&model.query); + model.filtered_songs.sort_by_cached_key(|&i| { + let song = &model.songs[i]; + let score = song.fuzzy_compare(&query); + if score < Default::default() { + model.hidden_songs += 1; + } + + let top_score = Reverse(score); + + (top_score, &song.title, &song.artist, &song.song_hash) + }); + model.filter_duets = query.duet == Some(true); + model.filter_video = query.video == Some(true); + } + } + Msg::ToggleVideo => { + let mut query = ParsedQuery::parse(&model.query); + query.video = match query.video { + Some(true) => None, + None | Some(false) => Some(true), + }; + update(Msg::Search(query.to_string()), model, orders); + } + Msg::ToggleDuets => { + let mut query = ParsedQuery::parse(&model.query); + query.duet = match query.duet { + Some(true) => None, + None | Some(false) => Some(true), + }; + update(Msg::Search(query.to_string()), model, orders); + } + Msg::Shuffle => { + model.hidden_songs = 0; + model.shown_songs = INITIAL_ELEM_COUNT; + scroll_to_top(); + model.query.clear(); + model.filtered_songs.shuffle(&mut thread_rng()); } - Msg::ToggleVideo => model.filter_video = !model.filter_video, - Msg::ToggleDuets => model.filter_duets = !model.filter_duets, - Msg::Shuffle => model.filtered_songs.shuffle(&mut thread_rng()), Msg::Scroll => { let (scroll, max_scroll) = match get_scroll() { Ok(v) => v, @@ -73,8 +117,8 @@ pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { const ELEMENT_HEIGHT: i32 = 48; if scroll_left < ELEMENT_HEIGHT * SCROLL_THRESHOLD as i32 { - model.show_elements += 1; - orders.perform_cmd(timeout(32, || Msg::Scroll)); + model.shown_songs += 1; + orders.perform_cmd(timeout(32 /* ms */, || Msg::Scroll)); } } } @@ -100,7 +144,7 @@ pub fn view(model: &Model) -> Vec> { C![C.song_gizmos], match (&song.duet_singer_1, &song.duet_singer_2) { (Some(p1), Some(p2)) => div![ - C![C.duet_icon, C.tooltip], + C![C.gizmo, C.duet_icon, C.tooltip], span![ C![C.tooltiptext], "Duet", @@ -113,12 +157,26 @@ pub fn view(model: &Model) -> Vec> { _ => empty![], }, IF![song.video.is_some() => div![ - C![C.video_icon, C.tooltip], + C![C.gizmo, C.video_icon, C.tooltip], span![ C![C.tooltiptext], "Musikvideo", ], ]], + match &song.language { + Some(language) => div![ + C![C.gizmo, C.flag_icon, C.tooltip], + span![C![C.tooltiptext], language], + ], + None => empty![], + }, + match &song.genre { + Some(genre) => div![ + C![C.gizmo, C.note_icon, C.tooltip], + span![C![C.tooltiptext], genre], + ], + None => empty![], + }, ], ] }; @@ -129,7 +187,8 @@ pub fn view(model: &Model) -> Vec> { input![ input_ev(Ev::Input, Msg::Search), attrs! { - At::Placeholder => "Search", + At::Placeholder => "Sök", + At::Value => model.query, }, C![C.song_search_field], ], @@ -163,21 +222,29 @@ pub fn view(model: &Model) -> Vec> { .filtered_songs .iter() .map(|&i| &model.songs[i]) - .filter(|song| !model.filter_video || song.video.is_some()) - .filter(|song| !model.filter_duets || song.duet().is_some()) .map(song_card) - .take(model.show_elements), - //IF![model.show_elements < model.songs.len() => div![C![C.center, C.penguin]]], + .take(model.filtered_songs.len() - model.hidden_songs) + .take(model.shown_songs), ], ] } const SONG_LIST_ID: &str = "song_list"; -fn get_scroll() -> anyhow::Result<(i32, i32)> { - let list = document() +fn get_song_list_element() -> anyhow::Result { + document() .get_element_by_id(SONG_LIST_ID) - .ok_or(anyhow!("Failed to access song list element"))?; + .ok_or(anyhow!("Failed to access song list element")) +} + +fn scroll_to_top() { + if let Ok(elem) = get_song_list_element() { + elem.scroll_to_with_x_and_y(0.0, 0.0); + } +} + +fn get_scroll() -> anyhow::Result<(i32, i32)> { + let list = get_song_list_element()?; let scroll = list.scroll_top(); let height = list.client_height(); let max = (list.scroll_height() - height).max(0); diff --git a/src/fuzzy.rs b/src/fuzzy.rs index 3d22889..69233a5 100644 --- a/src/fuzzy.rs +++ b/src/fuzzy.rs @@ -1,28 +1,4 @@ -use std::cmp::{Ord, Ordering, PartialOrd}; - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct FuzzyScore { - pub score: i32, - pub matches: Vec, -} - -#[derive(Copy, Clone, Default, Debug, Eq, PartialEq)] -pub struct FuzzyCharMatch { - pub base_str_index: usize, - pub search_str_index: usize, -} - -impl PartialOrd for FuzzyScore { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for FuzzyScore { - fn cmp(&self, other: &Self) -> Ordering { - self.score.cmp(&other.score) - } -} +pub type FuzzyScore = i32; /// Compare a base string to a user-input search /// @@ -39,21 +15,13 @@ where //let mut score = -(search.len() as i32); let mut score = 0; - // Vector of which char index in s maps to which char index in self.name - let mut matches = vec![]; - - for (i, sc) in search.into_iter().enumerate() { + for (_i, sc) in search.into_iter().enumerate() { let sc = sc.to_ascii_lowercase(); let mut add = 3; let mut base_tmp = base.clone(); - while let Some((j, bc)) = base_tmp.next() { + while let Some((_j, bc)) = base_tmp.next() { let bc = bc.to_ascii_lowercase(); if bc == sc { - matches.push(FuzzyCharMatch { - search_str_index: i, - base_str_index: j, - }); - score += add; base = base_tmp; break; @@ -63,5 +31,9 @@ where } } - FuzzyScore { score, matches } + score +} + +pub fn max_score(query: &str) -> FuzzyScore { + compare(query.chars(), query.chars()) } diff --git a/src/lib.rs b/src/lib.rs index 0adfc8b..ecb7e0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod app; mod css; mod fuzzy; +mod query; mod song; use seed::prelude::wasm_bindgen; diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..ddff95f --- /dev/null +++ b/src/query.rs @@ -0,0 +1,117 @@ +use std::borrow::Cow; +use std::fmt::{self, Display, Formatter}; +use std::ops::Not; + +#[derive(Default)] +pub struct ParsedQuery<'a> { + /// Unspecified query. + pub plain: Option>, + + /// Query a specific title + pub title: Option<&'a str>, + + /// Query a specific artist + pub artist: Option<&'a str>, + + /// Whether the song is a duet + pub duet: Option, + + /// Whether the song has a video + pub video: Option, + + /// Query a specific language + pub language: Option<&'a str>, + + /// Query a specific genre + pub genre: Option<&'a str>, + + /// Query from a specifc year + pub year: Option<&'a str>, +} + +impl<'a> ParsedQuery<'a> { + pub fn parse(s: &'a str) -> Self { + let mut parsed = ParsedQuery { + plain: extract_plain(s), + ..Default::default() + }; + + let kvs = extract_key_values(s); + + for (k, v) in kvs { + match k { + "title" => parsed.title = Some(v), + "artist" => parsed.artist = Some(v), + "duet" => parsed.duet = parse_bool(v), + "video" => parsed.video = parse_bool(v), + "lang" => parsed.language = Some(v), + "genre" => parsed.genre = Some(v), + "year" => parsed.year = Some(v), + _ => {} + } + } + + parsed + } +} + +fn parse_bool(s: &str) -> Option { + match s { + "true" | "yes" => Some(true), + "false" | "no" => Some(false), + _ => None, + } +} + +fn extract_plain(s: &str) -> Option> { + let plain: String = + s.split(' ') + .filter(|word| !word.contains(':')) + .fold(String::new(), |mut a, b| { + if !a.is_empty() { + a.push(' '); + } + a.push_str(b); + a + }); + + plain.is_empty().not().then(|| Cow::Owned(plain)) +} + +fn extract_key_values(s: &str) -> impl Iterator { + s.split_whitespace().filter_map(|s| s.split_once(':')) +} + +impl Display for ParsedQuery<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let mut first = true; + let mut w = |prefix: &str, display: Option<&dyn Display>| -> fmt::Result { + match display { + Some(display) => { + if first { + first = false; + } else { + write!(f, " ")?; + } + write!(f, "{}{}", prefix, display) + } + None => Ok(()), + } + }; + + fn display(v: &Option) -> Option<&dyn Display> { + v.as_ref().map(|s| s as &dyn Display) + } + + w("", display(&self.plain))?; + w("title:", display(&self.title))?; + w("artist:", display(&self.artist))?; + w("duet:", display(&self.duet))?; + w("video:", display(&self.video))?; + w("lang:", display(&self.language))?; + w("genre:", display(&self.genre))?; + w("year:", display(&self.year))?; + + Ok(()) + } +} diff --git a/src/song.rs b/src/song.rs index d2c6b54..05d5f06 100644 --- a/src/song.rs +++ b/src/song.rs @@ -1,4 +1,5 @@ use crate::fuzzy::{self, FuzzyScore}; +use crate::query::ParsedQuery; use serde::Deserialize; use std::cmp::max; @@ -26,10 +27,56 @@ impl Song { .zip(self.duet_singer_2.as_deref()) } - pub fn fuzzy_compare(&self, query: &str) -> FuzzyScore { - let title_score = fuzzy::compare(self.title.chars(), query.chars()); - let artist_score = fuzzy::compare(self.artist.chars(), query.chars()); + pub fn fuzzy_compare(&self, query: &ParsedQuery) -> FuzzyScore { + let bad = || -1; - max(title_score, artist_score) + let filter_strs = |query: Option<&str>, item: Option<&str>| { + if let Some(query) = query { + match item { + Some(item) => { + let score = fuzzy::compare(item.chars(), query.chars()); + if score < fuzzy::max_score(query) / 2 { + return false; + } + } + None => return false, + } + } + true + }; + + let filter_bool = + |query: Option, item| !matches!(query, Some(query) if query != item); + + let filters: &[&dyn Fn() -> bool] = &[ + &|| filter_bool(query.duet, self.duet().is_some()), + &|| filter_bool(query.video, self.video.is_some()), + &|| filter_strs(query.language, self.language.as_deref()), + &|| filter_strs(query.genre, self.genre.as_deref()), + &|| filter_strs(query.year, self.year.as_deref()), + ]; + + if !filters.iter().all(|f| f()) { + return bad(); + } + + let mut score = FuzzyScore::default(); + if let Some(plain) = &query.plain { + let title_score = fuzzy::compare(self.title.chars(), plain.chars()); + let artist_score = fuzzy::compare(self.artist.chars(), plain.chars()); + score = max(title_score, artist_score); + } + + if let Some(title) = query.title { + let new_score = fuzzy::compare(self.title.chars(), title.chars()); + score = max(score, new_score); + } + + if let Some(artist) = query.artist { + let new_score = fuzzy::compare(self.artist.chars(), artist.chars()); + score = max(score, new_score); + } + + score } } diff --git a/static/images/flag.svg b/static/images/flag.svg new file mode 100644 index 0000000..9efc73a --- /dev/null +++ b/static/images/flag.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/static/images/note.svg b/static/images/note.svg new file mode 100644 index 0000000..5998893 --- /dev/null +++ b/static/images/note.svg @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/static/styles/common.scss b/static/styles/common.scss index 08874a7..327cd6f 100644 --- a/static/styles/common.scss +++ b/static/styles/common.scss @@ -33,15 +33,14 @@ body { bottom: 0; left: 1em; right: 1em; - padding-right: 1em; /* Leave space for scroll bar */ } .song_search_bar { position: relative; - padding: 1em 1em .5em; - max-width: 35em; + max-width: 38em; width: 80%; margin: auto; + margin-top: 1em; } .song_search_field { @@ -169,20 +168,27 @@ body { margin-right: 1em; } -.duet_icon { - background-image: url("/images/duet.svg"); +.gizmo { background-size: contain; background-repeat: no-repeat; width: 2em; height: 2em; } +.duet_icon { + background-image: url("/images/duet.svg"); +} + .video_icon { background-image: url("/images/video.svg"); - background-size: contain; - background-repeat: no-repeat; - width: 2em; - height: 2em; +} + +.flag_icon { + background-image: url("/images/flag.svg"); +} + +.note_icon { + background-image: url("/images/note.svg"); } .hidden {