basic search
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use diesel::{dsl::exists, prelude::*, PgConnection};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
@@ -17,6 +20,8 @@ use super::documents::{
|
||||
DocumentResponse,
|
||||
};
|
||||
|
||||
const QUICKWIT_MAX_HITS: usize = 200;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateFolderRequest {
|
||||
pub name: String,
|
||||
@@ -250,14 +255,40 @@ pub async fn search_documents(
|
||||
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
|
||||
}
|
||||
|
||||
let mut filter_ids: Option<HashSet<Uuid>> = None;
|
||||
let mut quickwit_order: Option<Vec<Uuid>> = None;
|
||||
|
||||
if let Some(query) = params
|
||||
.query
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let pattern = format!("%{}%", query);
|
||||
docs_query = docs_query.filter(documents::original_name.ilike(pattern));
|
||||
let endpoint = state
|
||||
.config
|
||||
.quickwit_endpoint
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?;
|
||||
let index = state
|
||||
.config
|
||||
.quickwit_index
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal("quickwit index not configured"))?;
|
||||
|
||||
let ids = quickwit_search(endpoint, index, query)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("quickwit search failed: {err}")))?;
|
||||
|
||||
if ids.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
quickwit_order = Some(ids.clone());
|
||||
let set: HashSet<Uuid> = ids.into_iter().collect();
|
||||
filter_ids = Some(match &filter_ids {
|
||||
Some(existing) => existing.intersection(&set).copied().collect(),
|
||||
None => set,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tags_param) = params
|
||||
@@ -292,21 +323,69 @@ pub async fn search_documents(
|
||||
}
|
||||
}
|
||||
|
||||
let matching_doc_ids: Vec<Uuid> =
|
||||
doc_id_set.unwrap_or_default().into_iter().collect();
|
||||
let matching_doc_ids: HashSet<Uuid> = doc_id_set.unwrap_or_default();
|
||||
|
||||
if matching_doc_ids.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
docs_query = docs_query.filter(documents::id.eq_any(matching_doc_ids));
|
||||
let new_filter = match &filter_ids {
|
||||
Some(existing) => existing.intersection(&matching_doc_ids).copied().collect(),
|
||||
None => matching_doc_ids.clone(),
|
||||
};
|
||||
|
||||
filter_ids = Some(new_filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let docs: Vec<Document> = docs_query
|
||||
.order(documents::uploaded_at.desc())
|
||||
.load(&mut conn)?;
|
||||
if let Some(ref set) = filter_ids {
|
||||
if set.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
let ids_vec: Vec<Uuid> = set.iter().copied().collect();
|
||||
docs_query = docs_query.filter(documents::id.eq_any(ids_vec));
|
||||
}
|
||||
|
||||
let docs: Vec<Document> = if let Some(order_ids) = quickwit_order.as_ref() {
|
||||
let relevant_ids: Vec<Uuid> = if let Some(filter_set) = filter_ids.as_ref() {
|
||||
order_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| filter_set.contains(id))
|
||||
.collect()
|
||||
} else {
|
||||
order_ids.clone()
|
||||
};
|
||||
|
||||
if relevant_ids.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
let fetched: Vec<Document> = docs_query.load(&mut conn)?;
|
||||
let mut by_id: HashMap<Uuid, Document> =
|
||||
fetched.into_iter().map(|doc| (doc.id, doc)).collect();
|
||||
|
||||
let mut ordered = Vec::with_capacity(by_id.len());
|
||||
for id in relevant_ids {
|
||||
if let Some(doc) = by_id.remove(&id) {
|
||||
ordered.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
if !by_id.is_empty() {
|
||||
let mut remaining: Vec<Document> = by_id.into_values().collect();
|
||||
remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
|
||||
ordered.extend(remaining);
|
||||
}
|
||||
|
||||
ordered
|
||||
} else {
|
||||
docs_query
|
||||
.order(documents::uploaded_at.desc())
|
||||
.load(&mut conn)?
|
||||
};
|
||||
|
||||
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
@@ -325,6 +404,139 @@ pub async fn search_documents(
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn quickwit_search(endpoint: &str, index: &str, query: &str) -> anyhow::Result<Vec<Uuid>> {
|
||||
let quickwit_query = match build_quickwit_query(query) {
|
||||
Some(q) => q,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let client = Client::new();
|
||||
let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index);
|
||||
|
||||
let payload = json!({
|
||||
"query": quickwit_query,
|
||||
"max_hits": QUICKWIT_MAX_HITS,
|
||||
});
|
||||
|
||||
let response = client.post(url).json(&payload).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"quickwit search failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
|
||||
let data: QuickwitSearchResponse = response.json().await?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut doc_ids = Vec::new();
|
||||
|
||||
for hit in data.hits {
|
||||
if let Some(doc_id) = extract_document_id(&hit) {
|
||||
if seen.insert(doc_id) {
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(doc_ids)
|
||||
}
|
||||
|
||||
fn build_quickwit_query(input: &str) -> Option<String> {
|
||||
let tokens: Vec<String> = input
|
||||
.split_whitespace()
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(|token| {
|
||||
let normalized = token.to_lowercase();
|
||||
escape_quickwit_token(&normalized)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tokens.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<String> = tokens
|
||||
.into_iter()
|
||||
.map(|token| format!("(title:{token} OR text:{token})"))
|
||||
.collect();
|
||||
|
||||
Some(parts.join(" AND "))
|
||||
}
|
||||
|
||||
fn escape_quickwit_token(token: &str) -> String {
|
||||
let mut escaped = String::with_capacity(token.len());
|
||||
for ch in token.chars() {
|
||||
match ch {
|
||||
'+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~'
|
||||
| '*' | '?' | ':' | '\\' | '/' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QuickwitSearchResponse {
|
||||
#[serde(default)]
|
||||
hits: Vec<Value>,
|
||||
}
|
||||
|
||||
fn extract_document_id(hit: &Value) -> Option<Uuid> {
|
||||
for key in ["_source", "source", "fields", "stored_fields"] {
|
||||
if let Some(value) = hit.get(key) {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(value) = hit.get("document_id") {
|
||||
if let Some(uuid) = extract_uuid_from_value(value) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_uuid_from_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(obj) = value.as_object() {
|
||||
if let Some(inner) = obj.get("document_id") {
|
||||
return parse_uuid_value(inner);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = extract_uuid_from_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parse_uuid_value(value)
|
||||
}
|
||||
|
||||
fn parse_uuid_value(value: &Value) -> Option<Uuid> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Uuid::parse_str(s).ok();
|
||||
}
|
||||
|
||||
if let Some(arr) = value.as_array() {
|
||||
for item in arr {
|
||||
if let Some(uuid) = parse_uuid_value(item) {
|
||||
return Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn delete_folder(
|
||||
State(state): State<AppState>,
|
||||
Path(folder_id): Path<Uuid>,
|
||||
|
||||
Reference in New Issue
Block a user