WIP: improve search #1

Closed
rob wants to merge 26 commits from improve-search into master
6 changed files with 150 additions and 21 deletions

View File

@ -24,6 +24,33 @@ use serde_json::Value;
const DEFAULT_SIZE: usize = 25; const DEFAULT_SIZE: usize = 25;
#[derive(Copy, Clone, Debug, Deserialize)]
enum SortKey {
Name,
Size,
Seeds,
Leeches,
Scraped,
}
impl Default for SortKey {
fn default() -> Self {
Self::Scraped
}
}
#[derive(Copy, Clone, Debug, Deserialize)]
enum SortDirection {
Asc,
Desc,
}
impl Default for SortDirection {
fn default() -> Self {
Self::Desc
}
}
#[actix_rt::main] #[actix_rt::main]
async fn main() -> io::Result<()> { async fn main() -> io::Result<()> {
println!("Access me at {}", endpoint()); println!("Access me at {}", endpoint());
@ -69,6 +96,8 @@ struct SearchQuery {
q: String, q: String,
page: Option<usize>, page: Option<usize>,
size: Option<usize>, size: Option<usize>,
sort_key: Option<SortKey>,
sort_dir: Option<SortDirection>,
type_: Option<String>, type_: Option<String>,
} }
@ -101,7 +130,6 @@ async fn new_torrents(
db: web::Data<r2d2::Pool<SqliteConnectionManager>>, db: web::Data<r2d2::Pool<SqliteConnectionManager>>,
query: web::Query<NewQuery>, query: web::Query<NewQuery>,
) -> Result<HttpResponse, actix_web::Error> { ) -> Result<HttpResponse, actix_web::Error> {
let res = web::block(move || { let res = web::block(move || {
let conn = db.get().unwrap(); let conn = db.get().unwrap();
new_query(query, conn) new_query(query, conn)
@ -126,20 +154,22 @@ fn search_query(
} }
let page = query.page.unwrap_or(1); let page = query.page.unwrap_or(1);
let sort_key = query.sort_key.unwrap_or_default();
let sort_dir = query.sort_dir.unwrap_or_default();
let size = cmp::min(100, query.size.unwrap_or(DEFAULT_SIZE)); let size = cmp::min(100, query.size.unwrap_or(DEFAULT_SIZE));
let type_ = query.type_.as_ref().map_or("torrent", String::deref); let type_ = query.type_.as_ref().map_or("torrent", String::deref);
let offset = size * (page - 1); let offset = size * (page - 1);
println!( println!(
"query = {}, type = {}, page = {}, size = {}", "query = {}, type = {}, page = {}, size = {}, sort_key = {:?}, sort_dir = {:?}",
q, type_, page, size q, type_, page, size, sort_key, sort_dir
); );
let res = if type_ == "file" { let res = if type_ == "file" {
let results = torrent_file_search(conn, q, size, offset)?; let results = torrent_file_search(conn, q, sort_key, sort_dir, size, offset)?;
serde_json::to_value(&results).unwrap() serde_json::to_value(&results).unwrap()
} else { } else {
let results = torrent_search(conn, q, size, offset)?; let results = torrent_search(conn, q, sort_key, sort_dir, size, offset)?;
serde_json::to_value(&results).unwrap() serde_json::to_value(&results).unwrap()
}; };
@ -187,10 +217,14 @@ struct Torrent {
fn torrent_search( fn torrent_search(
conn: r2d2::PooledConnection<SqliteConnectionManager>, conn: r2d2::PooledConnection<SqliteConnectionManager>,
query: &str, query: &str,
sort_key: SortKey,
sort_dir: SortDirection,
size: usize, size: usize,
offset: usize, offset: usize,
) -> Result<Vec<Torrent>, Error> { ) -> Result<Vec<Torrent>, Error> {
let stmt_str = "select * from torrents where name like '%' || ?1 || '%' limit ?2, ?3"; let order_clause = build_order_clause("torrent", sort_key, sort_dir);
// `order_clause` is already sanitized and should not be escaped:
let stmt_str = format!("select * from torrents where name like '%' || ?1 || '%' order by {} limit ?2, ?3", order_clause);
let mut stmt = conn.prepare(&stmt_str)?; let mut stmt = conn.prepare(&stmt_str)?;
let torrent_iter = stmt.query_map( let torrent_iter = stmt.query_map(
params![ params![
@ -270,10 +304,14 @@ struct File {
fn torrent_file_search( fn torrent_file_search(
conn: r2d2::PooledConnection<SqliteConnectionManager>, conn: r2d2::PooledConnection<SqliteConnectionManager>,
query: &str, query: &str,
sort_key: SortKey,
sort_dir: SortDirection,
size: usize, size: usize,
offset: usize, offset: usize,
) -> Result<Vec<File>, Error> { ) -> Result<Vec<File>, Error> {
let stmt_str = "select * from files where path like '%' || ?1 || '%' limit ?2, ?3"; let order_clause = build_order_clause("file", sort_key, sort_dir);
// `order_clause` is already sanitized and should not be escaped:
let stmt_str = format!("select * from files where path like '%' || ?1 || '%' order by {} limit ?2, ?3", order_clause);
let mut stmt = conn.prepare(&stmt_str).unwrap(); let mut stmt = conn.prepare(&stmt_str).unwrap();
let file_iter = stmt.query_map( let file_iter = stmt.query_map(
params![ params![
@ -337,6 +375,29 @@ fn torrent_file_new(
Ok(files) Ok(files)
} }
fn build_order_clause(type_: &str, sort_key: SortKey, sort_dir: SortDirection) -> String {
let dir = match sort_dir {
SortDirection::Asc => "asc",
SortDirection::Desc => "desc",
};
let column = match sort_key {
SortKey::Name => {
if type_ == "file" {
"path"
} else {
"name"
}
},
SortKey::Size => "size_bytes",
SortKey::Seeds => "seeders",
SortKey::Leeches => "leechers",
SortKey::Scraped => "scraped_date",
};
format!("{} {}", column, dir)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
@ -346,7 +407,7 @@ mod tests {
let manager = SqliteConnectionManager::file(super::torrents_db_file()); let manager = SqliteConnectionManager::file(super::torrents_db_file());
let pool = r2d2::Pool::builder().max_size(15).build(manager).unwrap(); let pool = r2d2::Pool::builder().max_size(15).build(manager).unwrap();
let conn = pool.get().unwrap(); let conn = pool.get().unwrap();
let results = super::torrent_search(conn, "sherlock", 10, 0); let results = super::torrent_search(conn, "sherlock", super::SortKey::Name, super::SortDirection::Desc, 10, 0);
assert!(results.unwrap().len() > 2); assert!(results.unwrap().len() > 2);
// println!("Query took {:?} seconds.", end - start); // println!("Query took {:?} seconds.", end - start);
} }

View File

@ -79,6 +79,13 @@ a::-moz-focus-inner {
align-self: center; align-self: center;
} }
th .icon {
color: #666666;
position: relative;
top: 0.125em;
left: 0.125em;
}
.spinner { .spinner {
animation: spin 2s linear infinite; animation: spin 2s linear infinite;
width: 20vw; width: 20vw;

View File

@ -8,11 +8,12 @@ interface State {
} }
export class Navbar extends Component<any, State> { export class Navbar extends Component<any, State> {
state: State = { state: State = {
searchParams: { searchParams: {
page: 1, page: 1,
q: "", q: "",
sort_key: 'Name',
sort_dir: 'Desc',
type_: "torrent" type_: "torrent"
} }
} }
@ -70,13 +71,15 @@ export class Navbar extends Component<any, State> {
search(i: Navbar, event) { search(i: Navbar, event) {
event.preventDefault(); event.preventDefault();
i.context.router.history.push(`/search/${i.state.searchParams.type_}/${i.state.searchParams.q}/${i.state.searchParams.page}`); i.context.router.history.push(`/search/${i.state.searchParams.type_}/${i.state.searchParams.q}/${i.state.searchParams.page}/${i.state.searchParams.sort_key}/${i.state.searchParams.sort_dir}`);
} }
searchChange(i: Navbar, event) { searchChange(i: Navbar, event) {
let searchParams: SearchParams = { let searchParams: SearchParams = {
q: event.target.value, q: event.target.value,
page: 1, page: 1,
sort_key: i.state.searchParams.sort_key,
sort_dir: i.state.searchParams.sort_dir,
type_: i.state.searchParams.type_ type_: i.state.searchParams.type_
} }
i.setState({ searchParams: searchParams }); i.setState({ searchParams: searchParams });
@ -86,16 +89,21 @@ export class Navbar extends Component<any, State> {
let searchParams: SearchParams = { let searchParams: SearchParams = {
q: i.state.searchParams.q, q: i.state.searchParams.q,
page: 1, page: 1,
sort_key: i.state.searchParams.sort_key,
sort_dir: i.state.searchParams.sort_dir,
type_: event.target.value type_: event.target.value
} }
i.setState({ searchParams: searchParams }); i.setState({ searchParams: searchParams });
} }
fillSearchField() { fillSearchField() {
let splitPath: Array<string> = this.context.router.route.location.pathname.split("/"); let splitPath: Array<string> = this.context.router.route.location.pathname.split("/");
if (splitPath.length == 5 && splitPath[1] == 'search') if (splitPath.length == 7 && splitPath[1] == 'search')
this.state.searchParams = { this.state.searchParams = {
page: Number(splitPath[4]), page: Number(splitPath[4]),
q: splitPath[3], q: splitPath[3],
sort_key: splitPath[5],
sort_dir: splitPath[6],
type_: splitPath[2] type_: splitPath[2]
}; };
} }

View File

@ -12,7 +12,6 @@ interface State {
} }
export class Search extends Component<any, State> { export class Search extends Component<any, State> {
state: State = { state: State = {
results: { results: {
torrents: [] torrents: []
@ -20,6 +19,8 @@ export class Search extends Component<any, State> {
searchParams: { searchParams: {
q: "", q: "",
page: 1, page: 1,
sort_key: 'Name',
sort_dir: 'Desc',
type_: 'torrent' type_: 'torrent'
}, },
searching: false searching: false
@ -33,6 +34,8 @@ export class Search extends Component<any, State> {
this.state.searchParams = { this.state.searchParams = {
page: Number(this.props.match.params.page), page: Number(this.props.match.params.page),
q: this.props.match.params.q, q: this.props.match.params.q,
sort_key: this.props.match.params.sort_key,
sort_dir: this.props.match.params.sort_dir,
type_: this.props.match.params.type_ type_: this.props.match.params.type_
} }
this.search(); this.search();
@ -44,6 +47,8 @@ export class Search extends Component<any, State> {
this.state.searchParams = { this.state.searchParams = {
page: Number(this.props.match.params.page), page: Number(this.props.match.params.page),
q: this.props.match.params.q, q: this.props.match.params.q,
sort_key: this.props.match.params.sort_key,
sort_dir: this.props.match.params.sort_dir,
type_: this.props.match.params.type_ type_: this.props.match.params.type_
} }
this.search(); this.search();
@ -73,7 +78,7 @@ export class Search extends Component<any, State> {
fetchData(searchParams: SearchParams): Promise<Array<Torrent>> { fetchData(searchParams: SearchParams): Promise<Array<Torrent>> {
let q = encodeURI(searchParams.q); let q = encodeURI(searchParams.q);
return fetch(`${endpoint}/service/search?q=${q}&page=${searchParams.page}&type_=${searchParams.type_}`) return fetch(`${endpoint}/service/search?q=${q}&page=${searchParams.page}&sort_key=${searchParams.sort_key}&sort_dir=${searchParams.sort_dir}&type_=${searchParams.type_}`)
.then(data => data.json()); .then(data => data.json());
} }
@ -106,17 +111,57 @@ export class Search extends Component<any, State> {
) )
} }
sortLink(sortKey: string) {
return (
<span>
<a href={this.buildSortURL(sortKey)}>
{sortKey}
{this.sortLinkIcon(sortKey)}
</a>
</span>
)
}
buildSortURL(sortKey: string) {
const searchParams = this.state.searchParams;
let page, sortDir;
if (sortKey === searchParams.sort_key) {
page = searchParams.page;
sortDir = searchParams.sort_dir === "Asc" ? "Desc" : "Asc";
} else {
page = 1;
sortDir = "Desc";
}
return `/#/search/${searchParams.type_}/${searchParams.q}/${page}/${sortKey}/${sortDir}`;
}
sortLinkIcon(sortKey: string) {
const searchParams = this.state.searchParams;
if (searchParams.sort_key !== sortKey) {
return "";
}
if (searchParams.sort_dir === "Asc") {
return (
<svg class="icon icon-arrow-up2"><use xlinkHref="#icon-arrow-up2"></use></svg>
)
} else {
return (
<svg class="icon icon-arrow-down2"><use xlinkHref="#icon-arrow-down2"></use></svg>
)
}
}
torrentsTable() { torrentsTable() {
return ( return (
<div> <div>
<table class="table table-fixed table-hover table-sm table-striped table-hover-purple table-padding"> <table class="table table-fixed table-hover table-sm table-striped table-hover-purple table-padding">
<thead> <thead>
<tr> <tr>
<th class="search-name-col">Name</th> <th class="search-name-col">{this.sortLink("Name")}</th>
<th class="text-right">Size</th> <th class="text-right">{this.sortLink("Size")}</th>
<th class="text-right">Seeds</th> <th class="text-right">{this.sortLink("Seeds")}</th>
<th class="text-right d-none d-md-table-cell">Leeches</th> <th class="text-right d-none d-md-table-cell">{this.sortLink("Leeches")}</th>
<th class="text-right d-none d-md-table-cell">Scraped</th> <th class="text-right d-none d-md-table-cell">{this.sortLink("Scraped")}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -207,7 +252,7 @@ export class Search extends Component<any, State> {
switchPage(a: { i: Search, nextPage: boolean }) { switchPage(a: { i: Search, nextPage: boolean }) {
let newSearch = a.i.state.searchParams; let newSearch = a.i.state.searchParams;
newSearch.page += (a.nextPage) ? 1 : -1; newSearch.page += (a.nextPage) ? 1 : -1;
a.i.props.history.push(`/search/${newSearch.type_}/${newSearch.q}/${newSearch.page}`); a.i.props.history.push(`/search/${newSearch.type_}/${newSearch.q}/${newSearch.page}/${newSearch.sort_key}/${newSearch.sort_dir}`);
} }
copyLink(evt) { copyLink(evt) {

View File

@ -17,7 +17,7 @@ class Index extends Component<any, any> {
<div class="mt-3 p-0"> <div class="mt-3 p-0">
<Switch> <Switch>
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route path={`/search/:type_/:q/:page`} component={Search} /> <Route path={`/search/:type_/:q/:page/:sort_key/:sort_dir`} component={Search} />
</Switch> </Switch>
{this.symbols()} {this.symbols()}
</div> </div>
@ -49,6 +49,12 @@ class Index extends Component<any, any> {
<title>arrow-up</title> <title>arrow-up</title>
<path d="M16 1l-15 15h9v16h12v-16h9z"></path> <path d="M16 1l-15 15h9v16h12v-16h9z"></path>
</symbol> </symbol>
<symbol id="icon-arrow-up2" viewBox="0 0 32 32">
<path d="M27.414 12.586l-10-10c-0.781-0.781-2.047-0.781-2.828 0l-10 10c-0.781 0.781-0.781 2.047 0 2.828s2.047 0.781 2.828 0l6.586-6.586v19.172c0 1.105 0.895 2 2 2s2-0.895 2-2v-19.172l6.586 6.586c0.39 0.39 0.902 0.586 1.414 0.586s1.024-0.195 1.414-0.586c0.781-0.781 0.781-2.047 0-2.828z"></path>
</symbol>
<symbol id="icon-arrow-down2" viewBox="0 0 32 32">
<path d="M27.414 19.414l-10 10c-0.781 0.781-2.047 0.781-2.828 0l-10-10c-0.781-0.781-0.781-2.047 0-2.828s2.047-0.781 2.828 0l6.586 6.586v-19.172c0-1.105 0.895-2 2-2s2 0.895 2 2v19.172l6.586-6.586c0.39-0.39 0.902-0.586 1.414-0.586s1.024 0.195 1.414 0.586c0.781 0.781 0.781 2.047 0 2.828z"></path>
</symbol>
<symbol id="icon-search" viewBox="0 0 32 32"> <symbol id="icon-search" viewBox="0 0 32 32">
<title>search</title> <title>search</title>
<path d="M31.008 27.231l-7.58-6.447c-0.784-0.705-1.622-1.029-2.299-0.998 1.789-2.096 2.87-4.815 2.87-7.787 0-6.627-5.373-12-12-12s-12 5.373-12 12 5.373 12 12 12c2.972 0 5.691-1.081 7.787-2.87-0.031 0.677 0.293 1.515 0.998 2.299l6.447 7.58c1.104 1.226 2.907 1.33 4.007 0.23s0.997-2.903-0.23-4.007zM12 20c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"></path> <path d="M31.008 27.231l-7.58-6.447c-0.784-0.705-1.622-1.029-2.299-0.998 1.789-2.096 2.87-4.815 2.87-7.787 0-6.627-5.373-12-12-12s-12 5.373-12 12 5.373 12 12 12c2.972 0 5.691-1.081 7.787-2.87-0.031 0.677 0.293 1.515 0.998 2.299l6.447 7.58c1.104 1.226 2.907 1.33 4.007 0.23s0.997-2.903-0.23-4.007zM12 20c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"></path>

View File

@ -3,6 +3,8 @@ export interface SearchParams {
page: number; page: number;
size?: number; size?: number;
type_: string; type_: string;
sort_key: string;
sort_dir: string;
} }
export interface Results { export interface Results {