diff options
| -rw-r--r-- | database/src/kv/index.rs | 116 | ||||
| -rw-r--r-- | server/src/routes/mod.rs | 8 | ||||
| -rw-r--r-- | server/src/routes/search.rs | 76 |
3 files changed, 150 insertions, 50 deletions
diff --git a/database/src/kv/index.rs b/database/src/kv/index.rs index 0881cb7..469a6e8 100644 --- a/database/src/kv/index.rs +++ b/database/src/kv/index.rs @@ -15,7 +15,7 @@ use crate::{ }; use anyhow::{Result, bail}; use jellyobject::Object; -use std::iter::empty; +use std::{collections::HashSet, iter::empty}; pub fn update_index( txn: &mut dyn jellykv::Transaction, @@ -31,11 +31,7 @@ pub fn update_index( SortKey::None => { for mut k in ks { k.extend(row.to_be_bytes()); - if remove { - txn.del(&k)?; - } else { - txn.set(&k, &[])?; - } + index_marker(txn, &k, remove)?; } } SortKey::Random => { @@ -50,13 +46,7 @@ pub fn update_index( } SortKey::Count => { for k in ks { - let mut c = read_counter(txn, &k, 0)?; - if remove { - c -= 1; - } else { - c += 1; - } - write_counter(txn, &k, c)?; + index_counter(txn, &k, remove)?; } } SortKey::Value(path, multi_behaviour) => { @@ -65,19 +55,54 @@ pub fn update_index( for mut k in ks { k.extend(value); k.extend(row.to_be_bytes()); - if remove { - txn.del(&k)?; - } else { - txn.set(&k, &[])?; - } + index_marker(txn, &k, remove)?; + } + } + } + SortKey::Text(path) => { + let mut tokens = HashSet::new(); + for val in path.get_matching_values(ob) { + for tok in text_tokenizer(val) { + tokens.insert(tok); + } + } + for &tok in &tokens { + for mut k in ks.clone() { + k.push(0); + k.extend(tok); + index_counter(txn, &k, remove)?; + } + for mut k in ks.clone() { + k.push(1); + k.extend(tok); + k.extend(row.to_be_bytes()); + index_marker(txn, &k, remove)?; } } } - SortKey::Text(_) => todo!(), } Ok(()) } +pub fn index_counter(txn: &mut dyn jellykv::Transaction, k: &[u8], remove: bool) -> Result<()> { + let mut c = read_counter(txn, &k, 0)?; + if remove && c > 0 { + c -= 1; + } else { + c += 1; + } + write_counter(txn, &k, c)?; + Ok(()) +} + +pub fn index_marker(txn: &mut dyn jellykv::Transaction, k: &[u8], remove: bool) -> Result<()> { + if remove { + txn.del(&k) + } else { + txn.set(&k, &[]) + } +} + pub fn read_count_index(txn: &dyn jellykv::Transaction, prefix: Vec<u8>) -> Result<u64> { read_counter(txn, &prefix, 0) } @@ -155,10 +180,61 @@ pub fn iter_index<'a>( }), ) } - Sort::TextSearch(_, _) => todo!(), + Sort::TextSearch(_, text) => { + let search_tokens = text_tokenizer(text.as_bytes()) + .map(|e| e.to_owned()) + .collect::<Vec<_>>(); + let mut min_tok = Vec::new(); + let mut min_count = u64::MAX; + for token in &search_tokens { + let mut k = prefix.clone(); + k.push(0); + k.extend(token); + let count = read_counter(txn, &k, 0)?; + if count < min_count { + min_count = count; + min_tok = token.to_owned() + } + } + let mut min_token_prefix = prefix.clone(); + min_token_prefix.push(1); + min_token_prefix.extend(&min_tok); + Box::new( + PrefixIterator { + inner: txn.iter(&min_token_prefix, false)?, + prefix: min_token_prefix.into(), + } + .flat_map(move |k| { + let k = match k { + Ok(k) => k, + Err(e) => return Some(Err(e)), + }; + let rn = RowNum::from_be_bytes(k[k.len() - 8..].try_into().unwrap()); + for token in &search_tokens { + let mut k = prefix.clone(); + k.push(1); + k.extend(token); + k.extend(rn.to_be_bytes()); + let v = match txn.get(&k) { + Ok(v) => v, + Err(e) => return Some(Err(e)), + }; + if v.is_none() { + return None; + } + } + Some(anyhow::Ok((rn, Vec::new()))) + }), + ) + } }) } +fn text_tokenizer(text: &[u8]) -> impl Iterator<Item = &[u8]> { + text.split(|x| matches!(*x, b' ' | b',' | b':' | b'/' | b'+' | b'&')) + .filter(|x| !x.is_empty()) +} + fn inc_key(mut k: Vec<u8>) -> Vec<u8> { for v in k.iter_mut().rev() { let (nv, carry) = v.overflowing_add(1); diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 959971a..05b8025 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -16,6 +16,7 @@ pub mod items; pub mod node; pub mod player; pub mod playersync; +pub mod search; pub mod stream; pub mod style; pub mod userdata; @@ -44,7 +45,7 @@ use self::{ stream::r_stream, style::{r_assets_css, r_assets_font, r_assets_js, r_assets_js_map}, }; -use crate::State; +use crate::{State, routes::search::r_search}; use rocket::{ Build, Config, Rocket, catchers, fairing::AdHoc, fs::FileServer, http::Header, routes, shield::Shield, @@ -102,9 +103,9 @@ pub(super) fn build_rocket(state: Arc<State>) -> Rocket<Build> { r_admin_log_stream, r_admin_log, r_admin_new_user, - r_admin_users, - r_admin_user, r_admin_user_remove, + r_admin_user, + r_admin_users, r_api_root, r_assets_css, r_assets_font, @@ -119,6 +120,7 @@ pub(super) fn build_rocket(state: Arc<State>) -> Rocket<Build> { r_node, r_player, r_playersync, + r_search, r_stream, r_version, // Compat diff --git a/server/src/routes/search.rs b/server/src/routes/search.rs index 8ec2697..8726b8e 100644 --- a/server/src/routes/search.rs +++ b/server/src/routes/search.rs @@ -3,35 +3,57 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2026 metamuffin <metamuffin.org> */ + +use std::{borrow::Cow, time::Instant}; + use super::error::MyResult; use crate::request_info::RequestInfo; -use anyhow::anyhow; -use rocket::{Either, get, response::content::RawHtml, serde::json::Json}; +use jellycommon::{ + jellyobject::{EMPTY, Path}, + *, +}; +use jellydb::{Filter, Query, Sort}; +use jellyui::components::items::Items; +use log::info; +use rocket::{get, response::content::RawHtml}; + +#[get("/search?<q>")] +pub async fn r_search(ri: RequestInfo<'_>, q: Option<&str>) -> MyResult<RawHtml<String>> { + ri.require_user()?; + + let q = q.unwrap(); + + let mut items = Vec::new(); + let t = Instant::now(); + ri.state.database.transaction(&mut |txn| { + let rows = txn + .query(Query { + filter: Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()), + sort: Sort::TextSearch(Path(vec![NO_TITLE.0]), q.to_owned()), + ..Default::default() + })? + .take(64) + .collect::<Result<Vec<_>, _>>()?; -#[get("/search?<query>&<page>")] -pub async fn r_search( - ri: RequestInfo<'_>, - query: Option<&str>, - page: Option<usize>, -) -> MyResult<RawHtml<String>> { - // let r = query - // .map(|query| search(&ri.session, query, page)) - // .transpose()?; + items.clear(); + for (r, _is) in rows { + let node = txn.get(r)?.unwrap(); + items.push(node); + } + Ok(()) + })?; + info!("search {q:?} took {:?}", t.elapsed()); - // Ok(if ri.accept.is_json() { - // let Some(r) = r else { - // Err(anyhow!("no query"))? - // }; - // Either::Right(Json(r)) - // } else { - // Either::Left(RawHtml(render_page( - // &SearchPage { - // lang: &ri.lang, - // query: &query.map(|s| s.to_string()), - // r, - // }, - // ri.render_info(), - // ))) - // }) - todo!() + Ok(ri.respond_ui(&Items { + items: &items + .iter() + .map(|node| Nku { + node: Cow::Borrowed(node), + userdata: Cow::Borrowed(EMPTY), + role: None, + }) + .collect::<Vec<_>>(), + ri: &ri.render_info(), + cont: None, + })) } |