Improve search

This commit is contained in:
2022-01-04 14:58:55 +01:00
parent 1ad30399cf
commit ca6a8b0969
8 changed files with 332 additions and 73 deletions

View File

@ -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<Song>,
query: String,
filtered_songs: Vec<usize>,
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<Msg>) -> 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<Msg>) -> Model {
pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
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<Msg>) {
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<Node<Msg>> {
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<Node<Msg>> {
_ => 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<Node<Msg>> {
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<Node<Msg>> {
.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<Element> {
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);

View File

@ -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<FuzzyCharMatch>,
}
#[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<Ordering> {
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())
}

View File

@ -1,6 +1,7 @@
mod app;
mod css;
mod fuzzy;
mod query;
mod song;
use seed::prelude::wasm_bindgen;

117
src/query.rs Normal file
View File

@ -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<Cow<'a, str>>,
/// 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<bool>,
/// Whether the song has a video
pub video: Option<bool>,
/// 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<bool> {
match s {
"true" | "yes" => Some(true),
"false" | "no" => Some(false),
_ => None,
}
}
fn extract_plain(s: &str) -> Option<Cow<str>> {
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<Item = (&str, &str)> {
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<T: Display>(v: &Option<T>) -> 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(())
}
}

View File

@ -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<bool>, 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
}
}