Improve search
This commit is contained in:
115
src/app.rs
115
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<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);
|
||||
|
||||
44
src/fuzzy.rs
44
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<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())
|
||||
}
|
||||
|
||||
@ -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
117
src/query.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
55
src/song.rs
55
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<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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user