diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index ea1d239..92cfd0e 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -10,12 +10,14 @@ use axum::response::IntoResponse; use chrono::{DateTime, NaiveDateTime, Utc}; use diesel::dsl::exists; use diesel::{prelude::*, result::DatabaseErrorKind, select, PgConnection}; +use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use tracing::{error, info, warn}; use uuid::Uuid; +use super::folders::gather_descendant_folder_ids; use crate::auth::AuthenticatedUser; use crate::error::{AppError, AppResult}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; @@ -30,6 +32,7 @@ use crate::schema::{ use crate::state::AppState; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; +const QUICKWIT_MAX_HITS: usize = 200; pub const CORRESPONDENT_ROLES: &[&str] = &["sender", "receiver", "other"]; fn normalize_role(value: &str) -> String { @@ -66,6 +69,11 @@ pub struct DocumentListQuery { pub folder_id: Option, #[serde(default)] pub include_deleted: bool, + #[serde(default)] + pub include_descendants: Option, + pub query: Option, + pub tags: Option, + pub correspondents: Option, } #[derive(Deserialize)] @@ -328,29 +336,226 @@ pub struct AssignTagsRequest { pub async fn list_documents( State(state): State, - Query(query): Query, + Query(params): Query, user: AuthenticatedUser, ) -> AppResult>> { let mut conn = state.db()?; - let mut base_query = documents::table.into_boxed(); + let DocumentListQuery { + folder_id, + include_deleted, + include_descendants, + query, + tags, + correspondents, + } = params; - if !query.include_deleted { - base_query = base_query.filter(documents::deleted_at.is_null()); + let mut docs_query = documents::table.into_boxed(); + + if !include_deleted { + docs_query = docs_query.filter(documents::deleted_at.is_null()); } - match query.folder_id { - Some(folder_id) => { - base_query = base_query.filter(documents::folder_id.eq(Some(folder_id))); + let search_text = query + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let tags_param = tags + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let correspondents_param = correspondents + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + + let mut include_descendants = include_descendants.unwrap_or_else(|| folder_id.is_some()); + if search_text.is_some() || tags_param.is_some() || correspondents_param.is_some() { + include_descendants = true; + } + + match (folder_id, include_descendants) { + (Some(folder_id), true) => { + let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?; + docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); } - None => { - base_query = base_query.filter(documents::folder_id.is_null()); + (Some(folder_id), false) => { + docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); + } + (None, false) => { + docs_query = docs_query.filter(documents::folder_id.is_null()); + } + (None, true) => {} + } + + let mut filter_ids: Option> = None; + let mut quickwit_order: Option> = None; + + if let Some(query_str) = search_text.as_ref() { + 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_str) + .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) = tags_param.as_ref() { + let tag_ids: Result, _> = tags_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = tag_ids { + if !ids.is_empty() { + let mut doc_id_set: Option> = None; + for tag_id in &ids { + let docs_for_tag: Vec = document_tags::table + .filter(document_tags::tag_id.eq(*tag_id)) + .select(document_tags::document_id) + .load(&mut conn)?; + let docs_set: HashSet = docs_for_tag.into_iter().collect(); + doc_id_set = Some(match doc_id_set { + Some(existing) => existing.intersection(&docs_set).cloned().collect(), + None => docs_set, + }); + + if let Some(ref set) = doc_id_set { + if set.is_empty() { + break; + } + } + } + + let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); + + if matching_doc_ids.is_empty() { + return Ok(Json(vec![])); + } + + 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 = base_query - .order(documents::uploaded_at.desc()) - .load(&mut conn)?; + if let Some(correspondents_param) = correspondents_param.as_ref() { + let correspondent_ids: Result, _> = correspondents_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = correspondent_ids { + if !ids.is_empty() { + let mut doc_id_set: Option> = None; + for correspondent_id in &ids { + let docs_for_correspondent: Vec = document_correspondents::table + .filter(document_correspondents::correspondent_id.eq(*correspondent_id)) + .select(document_correspondents::document_id) + .load(&mut conn)?; + + let docs_set: HashSet = docs_for_correspondent.into_iter().collect(); + doc_id_set = Some(match doc_id_set { + Some(existing) => existing.intersection(&docs_set).cloned().collect(), + None => docs_set, + }); + + if let Some(ref set) = doc_id_set { + if set.is_empty() { + break; + } + } + } + + let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); + + if matching_doc_ids.is_empty() { + return Ok(Json(vec![])); + } + + 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); + } + } + } + + 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)?; @@ -358,7 +563,6 @@ pub async fn list_documents( drop(conn); let primary_versions = load_primary_assets(&state, &docs).await?; - let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); @@ -1738,3 +1942,136 @@ async fn load_asset_responses( pub(crate) fn to_iso(dt: NaiveDateTime) -> String { DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() } + +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::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 +} diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 836b225..9592c34 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -1,17 +1,13 @@ -use anyhow::anyhow; use axum::{ - extract::{Json, Path, Query, State}, + extract::{Json, Path, State}, http::StatusCode, }; use diesel::{dsl::exists, prelude::*, PgConnection}; -use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::{HashMap, HashSet}; use uuid::Uuid; use crate::models::{Document, Folder, NewFolder}; -use crate::schema::{document_correspondents, document_tags, documents, folders}; +use crate::schema::{documents, folders}; use crate::state::AppState; use crate::{ auth::AuthenticatedUser, @@ -23,8 +19,6 @@ use super::documents::{ to_document_response, to_iso, DocumentResponse, }; -const QUICKWIT_MAX_HITS: usize = 200; - #[derive(Deserialize)] pub struct CreateFolderRequest { pub name: String, @@ -56,13 +50,6 @@ pub struct FolderContentsResponse { pub documents: Vec, } -#[derive(Deserialize)] -pub struct DocumentSearchQuery { - pub query: Option, - pub tags: Option, - pub correspondents: Option, -} - #[derive(Serialize)] pub struct FolderInfo { pub id: Uuid, @@ -244,370 +231,6 @@ pub async fn list_folder_contents( })) } -pub async fn search_documents( - State(state): State, - Path(folder_identifier): Path, - Query(params): Query, - user: AuthenticatedUser, -) -> AppResult>> { - let mut conn = state.db()?; - - let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { - None - } else { - Some( - Uuid::parse_str(&folder_identifier) - .map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?, - ) - }; - - let mut docs_query = documents::table - .filter(documents::deleted_at.is_null()) - .into_boxed(); - - if let Some(folder_id) = folder_id { - let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?; - 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 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 - .tags - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - { - let tag_ids: Result, _> = tags_param - .split(',') - .map(|s| Uuid::parse_str(s.trim())) - .collect(); - - if let Ok(ids) = tag_ids { - if !ids.is_empty() { - let mut doc_id_set: Option> = None; - for tag_id in &ids { - let docs_for_tag: Vec = document_tags::table - .filter(document_tags::tag_id.eq(*tag_id)) - .select(document_tags::document_id) - .load(&mut conn)?; - let docs_set: HashSet = docs_for_tag.into_iter().collect(); - doc_id_set = Some(match doc_id_set { - Some(existing) => existing.intersection(&docs_set).cloned().collect(), - None => docs_set, - }); - - if let Some(ref set) = doc_id_set { - if set.is_empty() { - break; - } - } - } - - let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); - - if matching_doc_ids.is_empty() { - return Ok(Json(vec![])); - } - - 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); - } - } - } - - if let Some(correspondents_param) = params - .correspondents - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - { - let correspondent_ids: Result, _> = correspondents_param - .split(',') - .map(|s| Uuid::parse_str(s.trim())) - .collect(); - - if let Ok(ids) = correspondent_ids { - if !ids.is_empty() { - let mut doc_id_set: Option> = None; - for correspondent_id in &ids { - let docs_for_correspondent: Vec = document_correspondents::table - .filter(document_correspondents::correspondent_id.eq(*correspondent_id)) - .select(document_correspondents::document_id) - .load(&mut conn)?; - - let docs_set: HashSet = docs_for_correspondent.into_iter().collect(); - doc_id_set = Some(match doc_id_set { - Some(existing) => existing.intersection(&docs_set).cloned().collect(), - None => docs_set, - }); - - if let Some(ref set) = doc_id_set { - if set.is_empty() { - break; - } - } - } - - let matching_doc_ids: HashSet = doc_id_set.unwrap_or_default(); - - if matching_doc_ids.is_empty() { - return Ok(Json(vec![])); - } - - 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); - } - } - } - - 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)?; - let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?; - drop(conn); - - let primary_versions = load_primary_assets(&state, &docs).await?; - let mut response = Vec::with_capacity(doc_ids.len()); - for doc in docs { - let tags = tags_map.get(&doc.id).cloned(); - let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default(); - let current_version = primary_versions.get(&doc.id).cloned(); - response.push(to_document_response( - &state, - user.user_id, - doc, - tags, - correspondents, - current_version, - )?); - } - - 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, @@ -771,7 +394,10 @@ fn folder_to_info(folder: Folder) -> FolderInfo { } } -fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> AppResult> { +pub(super) fn gather_descendant_folder_ids( + conn: &mut PgConnection, + folder_id: Uuid, +) -> AppResult> { let mut ids = vec![folder_id]; let mut queue = vec![folder_id]; diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6b1f539..bde1038 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -102,8 +102,7 @@ pub fn create_router(state: AppState) -> Router<()> { "/:id", delete(folders::delete_folder).patch(folders::update_folder), ) - .route("/:id/contents", get(folders::list_folder_contents)) - .route("/:id/documents", get(folders::search_documents)); + .route("/:id/contents", get(folders::list_folder_contents)); let tags_routes = Router::new() .route("/", get(tags::list_tags).post(tags::create_tag)) diff --git a/docs/api.txt b/docs/api.txt index 3e59e81..0e8a690 100644 --- a/docs/api.txt +++ b/docs/api.txt @@ -16,7 +16,7 @@ Health Documents --------- -- GET /api/documents - List documents, optionally filtered by `folder_id` and `include_deleted`; each entry includes tags, correspondent assignments, and current version info. +- GET /api/documents - List or search documents. Optional filters: `folder_id` (defaults to root when omitted), `include_deleted`, `include_descendants` (defaults to true when a `folder_id` is provided and no other override is supplied), `query` (Quickwit full-text), `tags` (comma-separated tag UUIDs), and `correspondents` (comma-separated correspondent UUIDs). Each entry includes tags, correspondent assignments, and current version info. - POST /api/documents - Upload a document via multipart form-data (`file`, optional metadata/folder fields). - POST /api/documents/reanalyze - Queue re-analysis for every non-deleted document. - POST /api/documents/bulk/move - Move multiple documents to a target folder. @@ -48,7 +48,6 @@ Folders - POST /api/folders - Create a folder (optionally under a parent). - POST /api/folders/path - Ensure a nested folder path exists, creating missing segments. - GET /api/folders/:id/contents - List subfolders and documents inside a folder; use `root` for the workspace root. -- GET /api/folders/:id/documents - Search within a folder tree with optional `query` and `tags` filters. - DELETE /api/folders/:id - Soft-delete a folder. - PATCH /api/folders/:id - Update a folder's parent (`parent_id`) and/or rename it (`name`). diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 3a5b0a1..49370c3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -310,8 +310,8 @@ const PreviewWorkspace = ({ ); }; -const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => ( -
+const DocumentsLayout = ({ sidebarProps, children }) => ( +
{children}
@@ -400,9 +400,12 @@ const AppLayout = () => { const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]); const documentsRouteMatch = useMatch('/documents'); const documentsFolderRouteMatch = useMatch('/documents/folder/:folderId'); + const documentsDetailRouteMatch = useMatch('/documents/:documentId'); const tagsRouteMatch = useMatch('/tags'); const correspondentsRouteMatch = useMatch('/correspondents'); - const isDocumentsRoute = Boolean(documentsRouteMatch || documentsFolderRouteMatch); + const isDocumentsRoute = Boolean( + documentsRouteMatch || documentsFolderRouteMatch || documentsDetailRouteMatch, + ); const isTagsRoute = Boolean(tagsRouteMatch); const isCorrespondentsRoute = Boolean(correspondentsRouteMatch); const toggleTagFilter = useCallback((tagId) => { @@ -428,6 +431,15 @@ const AppLayout = () => { setActiveTagFilters([]); setActiveCorrespondentFilters([]); }, []); + + const handleSearchSubmit = useCallback(() => { + if (!navigate) return; + const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root'; + const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`; + if (!isDocumentsRoute || location.pathname !== targetPath) { + navigate(targetPath, { replace: false }); + } + }, [navigate, selectedFolder, isDocumentsRoute, location.pathname]); const [draggedDocumentIds, setDraggedDocumentIds] = useState([]); const [draggedFolderId, setDraggedFolderId] = useState(null); const [dropOverlayState, setDropOverlayState] = useState({ @@ -3653,10 +3665,11 @@ const AppLayout = () => { if (activeCorrespondentFilters.length) { params.correspondents = activeCorrespondentFilters.join(','); } - const folderIdentifier = selectedFolder === 'root' ? 'root' : selectedFolder; - const { data } = await api.get(`/folders/${folderIdentifier}/documents`, { - params, - }); + const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder; + if (folderIdentifier) { + params.folder_id = folderIdentifier; + } + const { data } = await api.get('/documents', { params }); if (cancelled) return; const results = assetManager.hydrateDocuments(data || []); @@ -4483,6 +4496,12 @@ const AppLayout = () => { onChange={(event) => setSearchQuery(event.target.value)} placeholder="Search documents" aria-label="Search documents" + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSearchSubmit(); + } + }} /> {isFilterActive && (