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::css::C;
use crate::query::ParsedQuery;
use crate::song::Song; use crate::song::Song;
use anyhow::anyhow; use anyhow::anyhow;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
@ -8,11 +9,14 @@ use seed::browser::util::document;
use seed::prelude::*; use seed::prelude::*;
use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF}; use seed::{attrs, button, div, empty, error, img, input, p, span, C, IF};
use std::cmp::Reverse; use std::cmp::Reverse;
use web_sys::Element;
pub struct Model { pub struct Model {
songs: Vec<Song>, songs: Vec<Song>,
query: String,
filtered_songs: Vec<usize>, filtered_songs: Vec<usize>,
show_elements: usize, hidden_songs: usize,
shown_songs: usize,
filter_video: bool, filter_video: bool,
filter_duets: bool, filter_duets: bool,
} }
@ -20,7 +24,6 @@ pub struct Model {
const SCROLL_THRESHOLD: usize = 50; const SCROLL_THRESHOLD: usize = 50;
const INITIAL_ELEM_COUNT: usize = 100; const INITIAL_ELEM_COUNT: usize = 100;
//#[derive(Clone, Debug)]
pub enum Msg { pub enum Msg {
Search(String), Search(String),
ToggleVideo, ToggleVideo,
@ -37,8 +40,10 @@ pub fn init(_url: Url, _orders: &mut impl Orders<Msg>) -> Model {
Model { Model {
songs, songs,
query: String::new(),
filtered_songs, filtered_songs,
show_elements: INITIAL_ELEM_COUNT, hidden_songs: 0,
shown_songs: INITIAL_ELEM_COUNT,
filter_video: false, filter_video: false,
filter_duets: 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>) { pub fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders<Msg>) {
match msg { match msg {
Msg::Search(query) if query.is_empty() => update(Msg::Shuffle, model, orders),
Msg::Search(query) => { Msg::Search(query) => {
model.filtered_songs.sort_by_cached_key(|&i| { model.hidden_songs = 0;
let song = &model.songs[i]; model.shown_songs = INITIAL_ELEM_COUNT;
let top_score = Reverse(song.fuzzy_compare(&query)); 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 => { Msg::Scroll => {
let (scroll, max_scroll) = match get_scroll() { let (scroll, max_scroll) = match get_scroll() {
Ok(v) => v, 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; const ELEMENT_HEIGHT: i32 = 48;
if scroll_left < ELEMENT_HEIGHT * SCROLL_THRESHOLD as i32 { if scroll_left < ELEMENT_HEIGHT * SCROLL_THRESHOLD as i32 {
model.show_elements += 1; model.shown_songs += 1;
orders.perform_cmd(timeout(32, || Msg::Scroll)); orders.perform_cmd(timeout(32 /* ms */, || Msg::Scroll));
} }
} }
} }
@ -100,7 +144,7 @@ pub fn view(model: &Model) -> Vec<Node<Msg>> {
C![C.song_gizmos], C![C.song_gizmos],
match (&song.duet_singer_1, &song.duet_singer_2) { match (&song.duet_singer_1, &song.duet_singer_2) {
(Some(p1), Some(p2)) => div![ (Some(p1), Some(p2)) => div![
C![C.duet_icon, C.tooltip], C![C.gizmo, C.duet_icon, C.tooltip],
span![ span![
C![C.tooltiptext], C![C.tooltiptext],
"Duet", "Duet",
@ -113,12 +157,26 @@ pub fn view(model: &Model) -> Vec<Node<Msg>> {
_ => empty![], _ => empty![],
}, },
IF![song.video.is_some() => div![ IF![song.video.is_some() => div![
C![C.video_icon, C.tooltip], C![C.gizmo, C.video_icon, C.tooltip],
span![ span![
C![C.tooltiptext], C![C.tooltiptext],
"Musikvideo", "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![
input_ev(Ev::Input, Msg::Search), input_ev(Ev::Input, Msg::Search),
attrs! { attrs! {
At::Placeholder => "Search", At::Placeholder => "Sök",
At::Value => model.query,
}, },
C![C.song_search_field], C![C.song_search_field],
], ],
@ -163,21 +222,29 @@ pub fn view(model: &Model) -> Vec<Node<Msg>> {
.filtered_songs .filtered_songs
.iter() .iter()
.map(|&i| &model.songs[i]) .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) .map(song_card)
.take(model.show_elements), .take(model.filtered_songs.len() - model.hidden_songs)
//IF![model.show_elements < model.songs.len() => div![C![C.center, C.penguin]]], .take(model.shown_songs),
], ],
] ]
} }
const SONG_LIST_ID: &str = "song_list"; const SONG_LIST_ID: &str = "song_list";
fn get_scroll() -> anyhow::Result<(i32, i32)> { fn get_song_list_element() -> anyhow::Result<Element> {
let list = document() document()
.get_element_by_id(SONG_LIST_ID) .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 scroll = list.scroll_top();
let height = list.client_height(); let height = list.client_height();
let max = (list.scroll_height() - height).max(0); let max = (list.scroll_height() - height).max(0);

View File

@ -1,28 +1,4 @@
use std::cmp::{Ord, Ordering, PartialOrd}; pub type FuzzyScore = i32;
#[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)
}
}
/// Compare a base string to a user-input search /// Compare a base string to a user-input search
/// ///
@ -39,21 +15,13 @@ where
//let mut score = -(search.len() as i32); //let mut score = -(search.len() as i32);
let mut score = 0; let mut score = 0;
// Vector of which char index in s maps to which char index in self.name for (_i, sc) in search.into_iter().enumerate() {
let mut matches = vec![];
for (i, sc) in search.into_iter().enumerate() {
let sc = sc.to_ascii_lowercase(); let sc = sc.to_ascii_lowercase();
let mut add = 3; let mut add = 3;
let mut base_tmp = base.clone(); 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(); let bc = bc.to_ascii_lowercase();
if bc == sc { if bc == sc {
matches.push(FuzzyCharMatch {
search_str_index: i,
base_str_index: j,
});
score += add; score += add;
base = base_tmp; base = base_tmp;
break; 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 app;
mod css; mod css;
mod fuzzy; mod fuzzy;
mod query;
mod song; mod song;
use seed::prelude::wasm_bindgen; 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::fuzzy::{self, FuzzyScore};
use crate::query::ParsedQuery;
use serde::Deserialize; use serde::Deserialize;
use std::cmp::max; use std::cmp::max;
@ -26,10 +27,56 @@ impl Song {
.zip(self.duet_singer_2.as_deref()) .zip(self.duet_singer_2.as_deref())
} }
pub fn fuzzy_compare(&self, query: &str) -> FuzzyScore { pub fn fuzzy_compare(&self, query: &ParsedQuery) -> FuzzyScore {
let title_score = fuzzy::compare(self.title.chars(), query.chars()); let bad = || -1;
let artist_score = fuzzy::compare(self.artist.chars(), query.chars());
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
} }
} }

21
static/images/flag.svg Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<g
id="layer1">
<path
style="fill:#ffffff;stroke-width:0.0172872"
d="M 2.0431764,16.908998 C 1.9602187,16.872757 1.876554,16.793057 1.8422936,16.717632 1.8116739,16.650224 1.8105455,16.401051 1.810179,9.6279558 L 1.8097869,2.6081437 1.6641359,2.5092064 C 1.2472056,2.2259971 1.0391181,1.8036281 1.0713576,1.3060066 1.0844166,1.1044411 1.1224816,0.96380799 1.2142435,0.77810771 1.3901797,0.42206407 1.7319615,0.15177983 2.1468873,0.0405643 2.3323772,-0.00915361 2.7302473,-0.01417349 2.8988814,0.03107665 3.3528542,0.15289318 3.7091938,0.44111518 3.8848869,0.82859726 4.0597307,1.2142068 4.0366627,1.7326461 3.8292893,2.0781429 3.7421304,2.2233555 3.57164,2.3977544 3.4085243,2.5085543 l -0.1466114,0.099589 -3.921e-4,7.0198121 c -3.92e-4,6.7912266 -0.00142,7.0221046 -0.032342,7.0901846 -0.038333,0.0844 -0.1349174,0.168073 -0.2252432,0.195135 -0.097123,0.0291 -0.8924887,0.02556 -0.960784,-0.0043 z M 4.4373436,12.716063 C 4.2495943,12.658952 4.0830255,12.448031 4.030713,12.201158 3.9938784,12.027325 3.9933603,3.8105555 4.0301903,3.6368753 4.0755058,3.4230858 4.1697878,3.2812388 4.3715252,3.1233409 5.3165223,2.3836996 6.3178698,1.8281179 7.1314687,1.5920287 7.5769935,1.4627473 7.8698032,1.4201238 8.3098088,1.4205014 c 0.489238,3.92e-4 0.8716048,0.059099 1.2965409,0.198971 0.4326523,0.1424116 1.0852773,0.4785873 1.8454873,0.9506357 0.373219,0.2317482 0.431206,0.2606372 0.620161,0.3089634 0.378486,0.096801 0.849505,0.00191 1.427559,-0.2876026 0.334418,-0.1674874 0.481383,-0.2591206 0.80027,-0.4989694 0.380455,-0.286156 0.773889,-0.5542429 0.917678,-0.6253073 0.105568,-0.052175 0.136868,-0.060373 0.191991,-0.050288 0.203687,0.037265 0.387579,0.2559054 0.435924,0.5183002 0.0255,0.1384181 0.02682,8.6933686 0.0014,8.8276256 -0.04196,0.221249 -0.146314,0.34086 -0.54429,0.62386 -1.367655,0.972536 -2.434688,1.331821 -3.326394,1.120042 -0.379668,-0.09017 -0.901986,-0.356434 -1.323138,-0.6745 C 10.371145,11.619406 10.208111,11.5187 9.9025808,11.368732 9.1628643,11.005644 8.5121596,10.936609 7.6874652,11.133724 7.3017711,11.225912 6.8336043,11.405483 6.3827127,11.63418 5.8966468,11.880716 5.6917131,12.0075 4.8136497,12.604888 4.6413416,12.722118 4.5479619,12.749707 4.4373579,12.716063 Z"
id="path836" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

28
static/images/note.svg Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 16.933333 16.933334"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<g
id="layer1">
<path
style="fill:#ffffff;stroke:#000000;stroke-width:0.027706"
d="M 4.5551369,13.73784 C 4.4712159,13.713848 4.2942016,13.631736 4.1617718,13.555369 3.9569689,13.437269 3.8864653,13.375379 3.6899365,13.141189 3.4947882,12.908643 3.4405963,12.821296 3.3412101,12.579103 3.1831538,12.193936 3.1341667,11.913692 3.1494268,11.481958 3.1792799,10.637362 3.5697022,9.9484817 4.2213186,9.5906614 4.5074657,9.4335284 4.6608076,9.3951632 5.0001725,9.3957953 c 0.3135708,5.704e-4 0.5150999,0.048388 0.7563296,0.1794085 0.3300738,0.1792741 0.6561763,0.5253422 0.8331234,0.8841312 l 0.1088097,0.220629 0.027707,-0.189904 C 6.9470115,8.9761896 6.6960377,6.9399561 6.0763141,5.2177993 5.9357403,4.8271574 5.6389438,4.1214756 5.5058847,3.8615112 5.4508509,3.7539882 5.4112872,3.6595299 5.4179658,3.6516022 5.4567654,3.6055488 6.6007883,3.373365 7.2253505,3.2847856 8.1011111,3.1605811 8.324241,3.1472014 9.5268205,3.1467839 c 1.3500215,-4.564e-4 1.7915365,0.034262 2.9118045,0.2290499 0.55905,0.097205 0.525804,0.083369 0.591628,0.2462207 0.101089,0.2501087 0.329321,1.1059901 0.448665,1.6825274 0.117824,0.5691893 0.214134,1.2008098 0.273853,1.795992 0.04477,0.4461473 0.04513,2.154298 5.77e-4,2.5807942 -0.109067,1.0434349 -0.269326,1.8555249 -0.512069,2.5948459 -0.234881,0.715378 -0.62845,1.181088 -1.191885,1.410356 -0.277845,0.113057 -0.722659,0.133043 -1.014105,0.04557 -1.3979076,-0.419587 -1.8940712,-2.441281 -0.900268,-3.668278 0.536302,-0.6621431 1.349345,-0.8627349 2.060124,-0.508268 0.350035,0.1745629 0.723984,0.571198 0.892645,0.946793 0.04324,0.09629 0.08236,0.175083 0.08692,0.175083 0.01834,0 0.06685,-0.49438 0.09118,-0.9293692 C 13.317443,8.8268273 13.202847,7.6012054 12.980897,6.7000545 12.921884,6.4604495 12.940482,6.4769175 12.667531,6.422634 11.506786,6.1917919 10.005801,6.0828451 8.8237205,6.1436358 8.1898052,6.1762364 7.3817978,6.2496667 7.2414036,6.2874331 c -0.055361,0.014892 -0.05885,0.026539 -0.04285,0.1430145 0.2622878,1.9092854 0.1127805,4.0985924 -0.3905402,5.7188654 -0.2725808,0.877484 -0.7067678,1.380715 -1.3604851,1.576831 -0.2413187,0.0724 -0.6608065,0.07789 -0.8923609,0.01169 z"
id="path836" />
<ellipse
style="fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:1.00874;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.938225;stop-color:#000000"
id="path958"
cx="8.4408598"
cy="8.4666681"
rx="7.9622936"
ry="7.9622946" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -33,15 +33,14 @@ body {
bottom: 0; bottom: 0;
left: 1em; left: 1em;
right: 1em; right: 1em;
padding-right: 1em; /* Leave space for scroll bar */
} }
.song_search_bar { .song_search_bar {
position: relative; position: relative;
padding: 1em 1em .5em; max-width: 38em;
max-width: 35em;
width: 80%; width: 80%;
margin: auto; margin: auto;
margin-top: 1em;
} }
.song_search_field { .song_search_field {
@ -169,20 +168,27 @@ body {
margin-right: 1em; margin-right: 1em;
} }
.duet_icon { .gizmo {
background-image: url("/images/duet.svg");
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
width: 2em; width: 2em;
height: 2em; height: 2em;
} }
.duet_icon {
background-image: url("/images/duet.svg");
}
.video_icon { .video_icon {
background-image: url("/images/video.svg"); background-image: url("/images/video.svg");
background-size: contain; }
background-repeat: no-repeat;
width: 2em; .flag_icon {
height: 2em; background-image: url("/images/flag.svg");
}
.note_icon {
background-image: url("/images/note.svg");
} }
.hidden { .hidden {