mirror of
https://github.com/readur/readur.git
synced 2026-02-20 05:50:26 -06:00
fix(search): tags not included in facet query and filter deserialization
This commit is contained in:
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<void> => {
|
||||
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');
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Vec<FacetItem>> {
|
||||
let mut query = QueryBuilder::<Postgres>::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<Vec<FacetItem>> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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("))");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
/// Filter by MIME types (e.g., "application/pdf", "image/png")
|
||||
#[serde(default)]
|
||||
pub mime_types: Option<Vec<String>>,
|
||||
/// Maximum number of results to return (default: 25)
|
||||
pub limit: Option<i64>,
|
||||
@@ -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<EnhancedDocumentResponse>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[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<FacetItem>,
|
||||
/// Tag facets with counts
|
||||
pub tags: Vec<FacetItem>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SearchRequest>,
|
||||
) -> Result<Json<SearchResponse>, 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<SearchRequest>,
|
||||
) -> Result<Json<SearchResponse>, 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
|
||||
|
||||
Reference in New Issue
Block a user