diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index d93aa5a..98e3c3c 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -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> = None; + let mut quickwit_order: Option> = 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 = 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 = - doc_id_set.unwrap_or_default().into_iter().collect(); + let matching_doc_ids: HashSet = 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 = 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 = set.iter().copied().collect(); + docs_query = docs_query.filter(documents::id.eq_any(ids_vec)); + } + + let docs: Vec = if let Some(order_ids) = quickwit_order.as_ref() { + let relevant_ids: Vec = 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 = docs_query.load(&mut conn)?; + let mut by_id: HashMap = + 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 = 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 = 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> { + 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 { + let tokens: Vec = 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 = 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, +} + +fn extract_document_id(hit: &Value) -> Option { + 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 { + 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 { + 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, Path(folder_id): Path, diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index a09f7d9..f00dc31 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -167,7 +167,7 @@ const DropOverlay = ({ active, folderName }) => ( ); -const LoginView = ({ onSubmit, loading, status }) => ( +const LoginView = ({ onSubmit, status }) => (

Paperless-NEO

@@ -190,9 +190,7 @@ const LoginView = ({ onSubmit, loading, status }) => ( autoComplete="current-password" required /> - +
@@ -209,7 +207,6 @@ const FolderNode = ({ onDragOver, onDragLeave, onDelete, - disableActions, renderChildren, onFolderDragStart, onFolderDragEnd, @@ -257,7 +254,6 @@ const FolderNode = ({
-
@@ -648,7 +643,6 @@ const DetailPanel = ({ onTagAdd, onTagRemove, onRegenerateThumbnails, - loading, previewEntry, onOpenPreview, onBulkTagAdd, @@ -802,7 +796,6 @@ const DetailPanel = ({ type="button" className="secondary" onClick={() => onOpenPreview(singleDoc.id)} - disabled={loading} > Open preview @@ -810,7 +803,6 @@ const DetailPanel = ({ type="button" className="secondary" onClick={() => onRegenerateThumbnails(singleDoc.id)} - disabled={loading} > Re-run analysis @@ -911,9 +903,7 @@ const DetailPanel = ({ }} > - +
-
@@ -945,7 +935,6 @@ const DetailPanel = ({ name="target" defaultValue={defaultMoveTarget || 'root'} onChange={(event) => onBulkMove?.({ target: event.target.value })} - disabled={loading} > {folderOptions.map((option) => (