diff --git a/Cargo.lock b/Cargo.lock index 0480bff..b99ab88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,31 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "serde_html_form", + "serde_path_to_error", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16ct" version = "0.1.1" @@ -3714,6 +3739,7 @@ dependencies = [ "aws-sdk-s3", "aws-types", "axum", + "axum-extra", "base64ct", "bcrypt", "chrono", @@ -4296,6 +4322,19 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap 2.9.0", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -4907,7 +4946,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.7", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5746,7 +5785,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2d93a11..b64ed74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ path = "src/bin/analyze-webdav-performance.rs" [dependencies] tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["multipart", "ws"] } +axum-extra = { version = "0.10", features = ["query"] } tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["cors", "fs"] } serde = { version = "1", features = ["derive"] } diff --git a/frontend/src/pages/SearchPage.tsx b/frontend/src/pages/SearchPage.tsx index 288d2c4..8e3ac4c 100644 --- a/frontend/src/pages/SearchPage.tsx +++ b/frontend/src/pages/SearchPage.tsx @@ -62,7 +62,7 @@ import { TrendingUp as TrendingIcon, TextFormat as TextFormatIcon, } from '@mui/icons-material'; -import { documentService, SearchRequest } from '../services/api'; +import { documentService, SearchRequest, api } from '../services/api'; import SearchGuidance from '../components/SearchGuidance'; import EnhancedSearchGuide from '../components/EnhancedSearchGuide'; import MimeTypeFacetFilter from '../components/MimeTypeFacetFilter'; @@ -250,7 +250,9 @@ const SearchPage: React.FC = () => { }, []); const performSearch = useCallback(async (query: string, filters: SearchFilters = {}, page: number = 1): Promise => { - if (!query.trim()) { + const hasFilters = (filters.tags?.length ?? 0) > 0 || + (filters.mimeTypes?.length ?? 0) > 0; + if (!query.trim() && !hasFilters) { setSearchResults([]); setTotalResults(0); setQueryTime(0); @@ -321,11 +323,7 @@ const SearchPage: React.FC = () => { setTotalResults(response.data.total || results.length); setQueryTime(response.data.query_time_ms || 0); setSuggestions(response.data.suggestions || []); - - // Extract unique tags for filter options - const tags = [...new Set(results.flatMap(doc => doc.tags || []))].filter(tag => typeof tag === 'string'); - setAvailableTags(tags); - + // Clear progress after a brief delay setTimeout(() => setSearchProgress(0), 500); @@ -356,6 +354,20 @@ const SearchPage: React.FC = () => { [generateQuickSuggestions] ); + // Load available tags from labels API on component mount + useEffect(() => { + const loadLabels = async () => { + try { + const response = await api.get('/labels?include_counts=true'); + const labelNames = (response.data || []).map((label: any) => label.name); + setAvailableTags(labelNames); + } catch (error) { + console.error('Failed to load labels:', error); + } + }; + loadLabels(); + }, []); + // Handle URL search params useEffect(() => { const queryFromUrl = searchParams.get('q'); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 322b3ef..bc9e78b 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -5,6 +5,19 @@ const api = axios.create({ headers: { 'Content-Type': 'application/json', }, + paramsSerializer: { + serialize: (params) => { + const searchParams = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => searchParams.append(key, String(v))) + } else if (value !== undefined && value !== null) { + searchParams.append(key, String(value)) + } + }) + return searchParams.toString() + } + } }) export { api } diff --git a/src/db/documents/management.rs b/src/db/documents/management.rs index aad1728..1259744 100644 --- a/src/db/documents/management.rs +++ b/src/db/documents/management.rs @@ -166,20 +166,25 @@ impl Database { }).collect()) } - /// Gets tag facets (aggregated counts by tag) - pub async fn get_tag_facets(&self, user_id: Uuid, user_role: UserRole) -> Result> { - let mut query = QueryBuilder::::new( - "SELECT unnest(tags) as value, COUNT(*) as count FROM documents WHERE 1=1" - ); + /// Gets tag facets (aggregated counts by label) + pub async fn get_tag_facets(&self, user_id: Uuid, _user_role: UserRole) -> Result> { + let query = sqlx::query_as::<_, (String, i64)>( + r#" + SELECT l.name as value, COUNT(DISTINCT dl.document_id) as count + FROM labels l + LEFT JOIN document_labels dl ON l.id = dl.label_id + WHERE (l.user_id = $1 OR l.is_system = true) + GROUP BY l.id, l.name + ORDER BY count DESC, l.name + "# + ) + .bind(user_id); - apply_role_based_filter(&mut query, user_id, user_role); - query.push(" GROUP BY unnest(tags) ORDER BY count DESC, value"); + let rows = query.fetch_all(&self.pool).await?; - let rows = query.build().fetch_all(&self.pool).await?; - - Ok(rows.into_iter().map(|row| FacetItem { - value: row.get("value"), - count: row.get("count"), + Ok(rows.into_iter().map(|(value, count)| FacetItem { + value, + count, }).collect()) } diff --git a/src/db/documents/search.rs b/src/db/documents/search.rs index 711f58d..a5599cc 100644 --- a/src/db/documents/search.rs +++ b/src/db/documents/search.rs @@ -23,11 +23,12 @@ impl Database { query.push("))"); } - // Add tag filtering + // Add label filtering (tags param contains label names) if let Some(ref tags) = search_request.tags { if !tags.is_empty() { - query.push(" AND tags && "); + query.push(" AND documents.id IN (SELECT dl.document_id FROM document_labels dl JOIN labels l ON dl.label_id = l.id WHERE l.name = ANY("); query.push_bind(tags); + query.push("))"); } } @@ -128,11 +129,12 @@ impl Database { } } - // Add filtering + // Add label filtering (tags param contains label names) if let Some(ref tags) = search_request.tags { if !tags.is_empty() { - query.push(" AND tags && "); + query.push(" AND documents.id IN (SELECT dl.document_id FROM document_labels dl JOIN labels l ON dl.label_id = l.id WHERE l.name = ANY("); query.push_bind(tags); + query.push("))"); } } diff --git a/src/models/search.rs b/src/models/search.rs index 2d276aa..6cd7506 100644 --- a/src/models/search.rs +++ b/src/models/search.rs @@ -6,10 +6,13 @@ use super::responses::EnhancedDocumentResponse; #[derive(Debug, Serialize, Deserialize, ToSchema, IntoParams)] pub struct SearchRequest { /// Search query text (searches both document content and OCR-extracted text) + #[serde(default)] pub query: String, - /// Filter by specific tags + /// Filter by specific tags (label names) + #[serde(default)] pub tags: Option>, /// Filter by MIME types (e.g., "application/pdf", "image/png") + #[serde(default)] pub mime_types: Option>, /// Maximum number of results to return (default: 25) pub limit: Option, @@ -48,28 +51,20 @@ impl Default for SearchMode { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchResponse { - /// List of matching documents with enhanced metadata and snippets pub documents: Vec, - /// Total number of documents matching the search criteria pub total: i64, - /// Time taken to execute the search in milliseconds pub query_time_ms: u64, - /// Search suggestions for query improvement pub suggestions: Vec, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct FacetItem { - /// The facet value (e.g., mime type or tag) pub value: String, - /// Number of documents with this value pub count: i64, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchFacetsResponse { - /// MIME type facets with counts pub mime_types: Vec, - /// Tag facets with counts pub tags: Vec, -} \ No newline at end of file +} diff --git a/src/routes/search.rs b/src/routes/search.rs index 0f4317e..0e3404f 100644 --- a/src/routes/search.rs +++ b/src/routes/search.rs @@ -1,10 +1,11 @@ use axum::{ - extract::{Query, State}, + extract::State, http::StatusCode, response::Json, routing::get, Router, }; +use axum_extra::extract::Query; use std::sync::Arc; use crate::{ @@ -43,8 +44,10 @@ async fn search_documents( auth_user: AuthUser, Query(search_request): Query, ) -> Result, SearchError> { - // Validate query length - if search_request.query.len() < 2 { + // Validate query length (allow empty query if filters are present) + let has_filters = search_request.tags.as_ref().map_or(false, |t| !t.is_empty()) + || search_request.mime_types.as_ref().map_or(false, |m| !m.is_empty()); + if search_request.query.len() < 2 && !has_filters { return Err(SearchError::query_too_short(search_request.query.len(), 2)); } if search_request.query.len() > 1000 { @@ -118,9 +121,16 @@ async fn enhanced_search_documents( auth_user: AuthUser, Query(search_request): Query, ) -> Result, StatusCode> { + // Validate query length (allow empty query if filters are present) + let has_filters = search_request.tags.as_ref().map_or(false, |t| !t.is_empty()) + || search_request.mime_types.as_ref().map_or(false, |m| !m.is_empty()); + if search_request.query.len() < 2 && !has_filters { + return Err(StatusCode::BAD_REQUEST); + } + // Generate suggestions before moving search_request let suggestions = generate_search_suggestions(&search_request.query); - + let start_time = std::time::Instant::now(); let documents = state .db