Files
Oxicloud/src/application/dtos/search_dto.rs
T
2026-07-26 18:18:15 +02:00

712 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::application::dtos::cursor::PageCursor;
use crate::application::dtos::grant_dto::{ResourceContentDto, ResourceTypeDto};
/**
* Data Transfer Object for file search criteria.
*
* This structure represents all possible search parameters that can be used
* to filter files and folders in the system. It supports various filter types
* including name matching, file types, date ranges, and size constraints.
*/
#[derive(Debug, Clone, Hash, Serialize, Deserialize, ToSchema)]
pub struct SearchCriteriaDto {
/// Optional text to search in file/folder names
#[serde(skip_serializing_if = "Option::is_none")]
pub name_contains: Option<String>,
/// Optional list of file extensions to include (e.g., "pdf", "jpg")
#[serde(skip_serializing_if = "Option::is_none")]
pub file_types: Option<Vec<String>>,
/// Optional minimum creation date (seconds since epoch)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_after: Option<u64>,
/// Optional maximum creation date (seconds since epoch)
#[serde(skip_serializing_if = "Option::is_none")]
pub created_before: Option<u64>,
/// Optional minimum modification date (seconds since epoch)
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_after: Option<u64>,
/// Optional maximum modification date (seconds since epoch)
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_before: Option<u64>,
/// Optional minimum file size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub min_size: Option<u64>,
/// Optional maximum file size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub max_size: Option<u64>,
/// Optional folder ID to limit search scope
#[serde(skip_serializing_if = "Option::is_none")]
pub folder_id: Option<String>,
/// Whether to search recursively within subfolders (default: true)
#[serde(default = "default_recursive")]
pub recursive: bool,
/// Maximum number of results to return
#[serde(default = "default_limit")]
pub limit: usize,
/// Offset for pagination
#[serde(default)]
pub offset: usize,
/// Sort dimension. Canonical set: `"relevance"` | `"name"` | `"size"`
/// | `"updated_at"` | `"created_at"`. Direction is `reverse` below —
/// the `_desc` suffix pattern was retired 2026-07-26 in favour of
/// a single boolean, so every consumer treats "which column" and
/// "which direction" as orthogonal concerns.
#[serde(default = "default_sort_by")]
pub sort_by: String,
/// Reverse the sort direction — descending for name/size/date,
/// no-op for `relevance` (a descending relevance sort is meaningless).
#[serde(default)]
pub reverse: bool,
}
/// Default value for recursive search (true)
fn default_recursive() -> bool {
true
}
/// Default limit for search results (100)
fn default_limit() -> usize {
100
}
/// Default sort_by value
fn default_sort_by() -> String {
"relevance".to_string()
}
impl Default for SearchCriteriaDto {
fn default() -> Self {
Self {
name_contains: None,
file_types: None,
created_after: None,
created_before: None,
modified_after: None,
modified_before: None,
min_size: None,
max_size: None,
folder_id: None,
recursive: default_recursive(),
limit: default_limit(),
offset: 0,
sort_by: default_sort_by(),
reverse: false,
}
}
}
/// A file search result enriched with server-computed metadata.
///
/// Phase 1-plus extension (AuthZ-adjacent audit follow-up, 2026-07-26):
/// carries `etag`, `created_by`, `updated_by`, `is_favorite`, `is_shared`
/// through from `FileDto`. Pre-fix these fields were dropped at `enrich_file`
/// time, so the wire-normalised `SearchResourcesDto` handler couldn't
/// reconstruct a full `FileDto` for its `resource` slot — every result
/// looked unfavorited / unshared, and provenance was blank. The extra
/// columns come from `file_blob_read_repository.rs::search_files_paginated`
/// (SELECT'd inline, EXISTS subqueries for the caller-scoped booleans).
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchFileResultDto {
/// File ID
pub id: String,
/// File name
pub name: String,
/// Path to the file (relative)
pub path: String,
/// Size in bytes
pub size: u64,
/// MIME type — `Arc<str>` so enrichment reuses `FileDto`'s interned
/// value (an atomic increment) instead of allocating per result row.
#[schema(value_type = String)]
pub mime_type: Arc<str>,
/// Parent folder ID
pub folder_id: Option<String>,
/// Creation timestamp
pub created_at: u64,
/// Last modification timestamp
pub modified_at: u64,
/// Relevance score (0-100) computed server-side
pub relevance_score: u32,
/// Human-readable file size (e.g., "2.5 MB")
pub size_formatted: String,
/// CSS icon class for the file type (e.g., "fas fa-file-pdf")
#[schema(value_type = String)]
pub icon_class: Arc<str>,
/// Extra CSS class for icon styling (e.g., "pdf-icon", "code-icon js-icon")
#[schema(value_type = String)]
pub icon_special_class: Arc<str>,
/// Content category: "document", "image", "video", "audio", "archive", "code", "other"
#[schema(value_type = String)]
pub category: Arc<str>,
/// Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and
/// `File::compute_etag` when search results are converted to
/// `FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`
/// for backward-compatible deserialisation of cached results
/// that pre-date the column.
#[serde(default)]
pub blob_hash: String,
/// Plain-text fragment around the first content match, present only for
/// hits discovered through the full-text content index.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
/// Where the match came from: "name" (filename matched the query) or
/// "content" (discovered via the full-text content index).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub match_source: Option<String>,
/// HTTP ETag — derived from `blob_hash + modified_at`. Duplicates
/// `FileDto::etag` so the wire handler can hand a client the same
/// token for `If-Match` / `If-None-Match` conditional requests on
/// search results as it would on a folder listing.
#[serde(default)]
pub etag: String,
/// §14 provenance — user that originally created this file. `None`
/// when the referenced user has been deleted or for legacy rows.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance — user that performed the most recent mutation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this file. Populated by an EXISTS subquery in the search SQL —
/// the search repo carries it back as a per-row bool that the
/// service plumbs into this DTO.
#[serde(default)]
pub is_favorite: bool,
/// Resource-scoped: `true` when the file has ANY explicit role-grant.
/// Populated by the sibling EXISTS on `storage.role_grants`.
#[serde(default)]
pub is_shared: bool,
}
/// A folder search result enriched with server-computed metadata.
///
/// See `SearchFileResultDto` for the Phase 1-plus rationale — same
/// story: the six caller/provenance fields are carried through so the
/// wire-normalised handler can hand the frontend a complete `FolderDto`.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchFolderResultDto {
/// Folder ID
pub id: String,
/// Folder name
pub name: String,
/// Path to the folder (relative)
pub path: String,
/// Parent folder ID
pub parent_id: Option<String>,
/// Drive that owns this folder. Same column as `storage.folders.drive_id`,
/// carried through so downstream callers (e.g. the NC search REPORT
/// handler) can populate `FolderDto::drive_id` without a fallback sentinel.
pub drive_id: Uuid,
/// Creation timestamp
pub created_at: u64,
/// Last modification timestamp
pub modified_at: u64,
/// Whether it is a root folder
pub is_root: bool,
/// Relevance score (0-100) computed server-side
pub relevance_score: u32,
/// HTTP ETag — folders derive theirs from the tree-etag propagator.
/// Duplicating it here keeps the wire handler's `FolderDto`
/// reconstruction complete.
#[serde(default)]
pub etag: String,
/// §14 provenance — creator user id. `None` for legacy folders.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
/// §14 provenance — last-mutator user id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this folder. EXISTS on `auth.user_favorites`.
#[serde(default)]
pub is_favorite: bool,
/// Resource-scoped: `true` when the folder has any explicit
/// role-grant. EXISTS on `storage.role_grants`.
#[serde(default)]
pub is_shared: bool,
}
/**
* Data Transfer Object for search results.
*
* This structure encapsulates the results of a search operation, including
* both files and folders that match the search criteria, along with pagination
* information and server-computed metadata.
*/
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchResultsDto {
/// Files matching the search criteria (enriched with metadata)
pub files: Vec<SearchFileResultDto>,
/// Folders matching the search criteria (enriched with metadata)
pub folders: Vec<SearchFolderResultDto>,
/// Total count of matching items (for pagination)
pub total_count: Option<usize>,
/// Limit used in the search
pub limit: usize,
/// Offset used in the search
pub offset: usize,
/// Whether there are more results available
pub has_more: bool,
/// Query execution time in milliseconds (server-side)
pub query_time_ms: u64,
/// Sort order used
pub sort_by: String,
}
impl SearchResultsDto {
/// Creates a new empty search results object
pub fn empty() -> Self {
Self {
files: Vec::new(),
folders: Vec::new(),
total_count: None,
limit: 0,
offset: 0,
has_more: false,
query_time_ms: 0,
sort_by: "relevance".to_string(),
}
}
/// Creates a new search results object from files and folders
pub fn new(
files: Vec<SearchFileResultDto>,
folders: Vec<SearchFolderResultDto>,
limit: usize,
offset: usize,
total_count: Option<usize>,
query_time_ms: u64,
sort_by: String,
) -> Self {
let has_more = match total_count {
Some(total) => (offset + files.len() + folders.len()) < total,
None => false,
};
Self {
files,
folders,
total_count,
limit,
offset,
has_more,
query_time_ms,
sort_by,
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// New wire shape — normalised to the `/*/resources` envelope
// (`items[] { resource_type, resource, meta }` + `next_cursor` + optional
// `total`/`query_time_ms`). Phase 1-plus: internal service still speaks
// `SearchCriteriaDto`/`SearchResultsDto`; the REST handler translates.
// ═══════════════════════════════════════════════════════════════════════════
/// Query parameters for `GET /api/search`.
///
/// Mirrors `FolderResourcesQuery` for the shared axes (`limit`, `cursor`,
/// `order_by`, `resource_types`, `reverse`) then adds search-specific
/// filters (`query`, `folder_id`, `recursive`, `file_types`,
/// `created_after`/`before`, `modified_after`/`before`, `min_size`/
/// `max_size`). `serde_urlencoded` doesn't support `#[serde(flatten)]`,
/// so the paging fields are inlined rather than composed from
/// `CursorQuery`.
#[derive(Debug, Deserialize, IntoParams)]
pub struct SearchResourcesQuery {
/// Search phrase (matched against name; optionally content when the
/// full-text index is enabled). Absent = "match everything," so
/// callers can page through with just a folder scope + filters.
pub query: Option<String>,
/// Maximum items per page (1–200, default 50).
#[serde(default = "SearchResourcesQuery::default_limit")]
pub limit: u32,
/// Opaque cursor from a previous response. Absent = first page.
/// Encodes the current offset — Phase 1-plus still uses the
/// existing offset-based service internals under the hood.
pub cursor: Option<String>,
/// Sort dimension. Supported: `"relevance"` (default), `"name"`,
/// `"size"`, `"updated_at"`, `"created_at"`. Direction is the
/// separate `reverse` flag — the historical `_desc` suffix pattern
/// (`name_desc`, `date_desc`, `size_desc`) was retired 2026-07-26
/// in favour of a single boolean, and `"date"` was renamed to the
/// more explicit `"updated_at"` alongside the new `"created_at"`.
pub order_by: Option<String>,
/// Comma-separated resource types to include, e.g. `"file,folder"`.
/// Absent = both. Matches the `FolderResourcesQuery` idiom.
pub resource_types: Option<String>,
/// Reverse the sort order. Default `false`.
#[serde(default)]
pub reverse: bool,
/// Comma-separated file extensions filter, e.g. `"pdf,docx"`.
#[serde(rename = "type")]
pub type_filter: Option<String>,
/// Restrict search to this folder.
pub folder_id: Option<String>,
/// Recursive traversal below `folder_id`. Default `true`.
#[serde(default = "SearchResourcesQuery::default_recursive")]
pub recursive: bool,
/// Minimum creation timestamp (seconds since epoch).
pub created_after: Option<u64>,
/// Maximum creation timestamp (seconds since epoch).
pub created_before: Option<u64>,
/// Minimum modification timestamp (seconds since epoch).
pub modified_after: Option<u64>,
/// Maximum modification timestamp (seconds since epoch).
pub modified_before: Option<u64>,
/// Minimum file size in bytes.
pub min_size: Option<u64>,
/// Maximum file size in bytes.
pub max_size: Option<u64>,
}
impl SearchResourcesQuery {
pub fn default_limit() -> u32 {
50
}
pub fn default_recursive() -> bool {
true
}
pub fn limit_clamped(&self) -> usize {
self.limit.clamp(1, 200) as usize
}
pub fn decode_cursor(&self) -> Option<SearchResourceCursor> {
self.cursor
.as_deref()
.and_then(SearchResourceCursor::decode)
}
/// Convert to the internal `SearchCriteriaDto` the service consumes.
/// `limit` / `offset` come from the decoded cursor (or the query's
/// `limit` on the first page). `sort_by` + `reverse` pass through as
/// two orthogonal fields — every downstream consumer (SQL builders,
/// in-memory folder sort) reads both.
pub fn to_criteria(&self) -> SearchCriteriaDto {
let offset = self.decode_cursor().map(|c| c.offset).unwrap_or(0);
let file_types = self.type_filter.as_deref().map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
});
SearchCriteriaDto {
name_contains: self.query.clone(),
file_types,
created_after: self.created_after,
created_before: self.created_before,
modified_after: self.modified_after,
modified_before: self.modified_before,
min_size: self.min_size,
max_size: self.max_size,
folder_id: self.folder_id.clone(),
recursive: self.recursive,
limit: self.limit_clamped(),
offset,
sort_by: self.order_by.clone().unwrap_or_else(default_sort_by),
reverse: self.reverse,
}
}
/// Which resource kinds to include. `None` = both. Anything else
/// selects the intersection.
pub fn include_files(&self) -> bool {
match self.resource_types.as_deref() {
None => true,
Some(s) => s.split(',').any(|t| t.trim() == "file"),
}
}
pub fn include_folders(&self) -> bool {
match self.resource_types.as_deref() {
None => true,
Some(s) => s.split(',').any(|t| t.trim() == "folder"),
}
}
}
/// Opaque cursor for `/api/search`. Encodes the offset the underlying
/// service still uses, plus the sort dimension so a page fetched with
/// a different `order_by` than the previous one cannot silently drift
/// into a broken keyset. Phase 2 (service rewrite) would replace this
/// with a true keyset cursor over `(sort_key, id)`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResourceCursor {
pub offset: usize,
pub order_by: String,
}
impl PageCursor for SearchResourceCursor {}
/// Search-specific per-item metadata (relevance score, snippet, hit
/// source). Sits inline on each `SearchResourceItem` so consumers get
/// data locality — no keyed lookup. `ResourceList` ignores the field.
///
/// Wire keys are shortened (`meta.score`, `meta.via`) vs the internal
/// `SearchFileResultDto` field names (`relevance_score`, `match_source`)
/// to keep the envelope compact on large result pages.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchMeta {
/// Relevance 0-100. Higher = better match.
pub score: u32,
/// Plain-text fragment around the first content-index hit. Absent
/// for name-only matches and for folder results.
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
/// Where the hit came from: `"name"` or `"content"`. Absent when the
/// origin is ambiguous (empty query → everything matches).
#[serde(skip_serializing_if = "Option::is_none")]
pub via: Option<String>,
}
/// One search result — same `resource_type + resource` shape as the
/// `/*/resources` envelopes so `ResourceList` consumes it as-is, plus
/// the inline `meta` for search-specific enrichment.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResourceItem {
pub resource_type: ResourceTypeDto,
/// Full resource details (untagged: `FileDto | FolderDto | DriveDto`).
/// Shape determined by `resource_type`.
pub resource: ResourceContentDto,
/// Search-specific metadata for this row.
pub meta: SearchMeta,
}
/// Response envelope for `GET /api/search` — cursor-paginated + search
/// metadata. `total` is an approximate caller-visible count (permission-
/// filtered) when the service can compute it cheaply, absent otherwise —
/// matches sibling envelope endpoints, which all serialise counts as
/// integers (see `app_password_dto`, `plugin_dto`, `pagination`).
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResourcesDto {
pub items: Vec<SearchResourceItem>,
/// Opaque cursor for the next page. Absent on the last page.
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
/// Server-side query time. UI shows "Found N in Xms" and admins use
/// it as a health signal.
pub query_time_ms: u64,
/// Approximate caller-visible total match count. Never leaks a count
/// for rows the caller cannot see. Omitted when unknown.
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<usize>,
}
impl SearchResourcesDto {
/// Build the envelope from the service's existing offset-paginated
/// result plus the request's cursor position. The service returns
/// `SearchResultsDto` with `total_count` + `has_more` derived from
/// COUNT(*) OVER(); we translate:
/// - `has_more` → derive `next_cursor` (encoding `offset + returned`).
/// - `total_count` → pass through as `Some(42)` when known, else `None`.
///
/// Ownership: consumes the service result so the enriched DTOs move
/// into the `resource` slot without cloning.
pub fn from_service_result(
results: crate::application::dtos::search_dto::SearchResultsDto,
query: &SearchResourcesQuery,
) -> Self {
let order_by = query.order_by.clone().unwrap_or_else(default_sort_by);
let current_offset = query.decode_cursor().map(|c| c.offset).unwrap_or(0);
let returned = results.files.len() + results.folders.len();
let next_cursor = if results.has_more {
Some(
SearchResourceCursor {
offset: current_offset + returned,
order_by: order_by.clone(),
}
.encode(),
)
} else {
None
};
let total = results.total_count;
// Build items in an order the UI expects: folders first (like the
// legacy split-shape) unless a specific sort is requested. When
// ordering by relevance / date / size the caller almost always
// wants interleaved output; when ordering by name the folders-
// first convention matches file managers. Splitting the choice
// by sort dimension keeps folder browsing intuitive.
let mut items: Vec<SearchResourceItem> = Vec::with_capacity(returned);
let query_lower = query
.query
.as_deref()
.map(|s| s.to_lowercase())
.unwrap_or_default();
let folders_first = matches!(order_by.as_str(), "name" | "name_desc");
if folders_first {
append_folders(&mut items, results.folders);
append_files(&mut items, results.files, &query_lower);
} else {
// Interleave by relevance_score (or the natural service order for
// date/size — the service already returns rows in the requested
// dimension, but folders and files come as two separate arrays
// that we merge here by score for `relevance`, or just append
// for size/date since the two arrays are individually ordered.
append_folders(&mut items, results.folders);
append_files(&mut items, results.files, &query_lower);
if order_by == "relevance" {
items.sort_by_key(|item| std::cmp::Reverse(item.meta.score));
}
}
Self {
items,
next_cursor,
query_time_ms: results.query_time_ms,
total,
}
}
}
fn append_files(
items: &mut Vec<SearchResourceItem>,
files: Vec<SearchFileResultDto>,
_query_lower: &str,
) {
for f in files {
let meta = SearchMeta {
score: f.relevance_score,
snippet: f.snippet.clone(),
via: f.match_source.clone(),
};
// Reconstruct FileDto from the enriched search result. `size_formatted`
// and display fields were already computed by `enrich_file`; the
// Phase 1-plus extensions (etag / created_by / updated_by /
// is_favorite / is_shared) carry through so the DTO is complete.
let file_dto = crate::application::dtos::file_dto::FileDto {
id: f.id,
name: f.name,
path: f.path,
size: f.size,
mime_type: f.mime_type,
folder_id: f.folder_id,
created_at: f.created_at,
modified_at: f.modified_at,
icon_class: f.icon_class,
icon_special_class: f.icon_special_class,
category: f.category,
size_formatted: f.size_formatted,
content_hash: f.blob_hash,
etag: f.etag,
created_by: f.created_by,
updated_by: f.updated_by,
is_favorite: f.is_favorite,
is_shared: f.is_shared,
sort_date: None,
};
items.push(SearchResourceItem {
resource_type: ResourceTypeDto::File,
resource: ResourceContentDto::File(file_dto),
meta,
});
}
}
fn append_folders(items: &mut Vec<SearchResourceItem>, folders: Vec<SearchFolderResultDto>) {
for f in folders {
let meta = SearchMeta {
score: f.relevance_score,
snippet: None,
via: None,
};
let folder_dto = crate::application::dtos::folder_dto::FolderDto {
id: f.id.clone(),
name: f.name,
path: f.path,
parent_id: f.parent_id,
drive_id: f.drive_id,
created_at: f.created_at,
modified_at: f.modified_at,
is_root: f.is_root,
// Folders carry closed-set display fields — always the
// same three static strings. Cheap to build via `Arc::from`
// (interning-worthy but not on the search hot path).
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
etag: f.etag,
created_by: f.created_by,
updated_by: f.updated_by,
is_favorite: f.is_favorite,
is_shared: f.is_shared,
};
items.push(SearchResourceItem {
resource_type: ResourceTypeDto::Folder,
resource: ResourceContentDto::Folder(folder_dto),
meta,
});
}
}
// `search_meta` map form was explored earlier and rejected in favour of
// inline `meta` per item (Ed 2026-07-26): data locality wins, no
// keyed-lookup step for consumers, matches the extensibility other
// `/*/resources` endpoints will want later.
#[allow(dead_code)]
fn _keep_hashmap_import_alive_for_future(_: HashMap<String, SearchMeta>) {}
/// DTO for search suggestion results (quick prefix search)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchSuggestionsDto {
/// Suggested file/folder names matching the query prefix
pub suggestions: Vec<SearchSuggestionItem>,
/// Query execution time in milliseconds
pub query_time_ms: u64,
}
/// Individual search suggestion item
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SearchSuggestionItem {
/// The suggested name
pub name: String,
/// Type: "file" or "folder"
pub item_type: String,
/// Item ID for navigation
pub id: String,
/// Path for context
pub path: String,
/// CSS icon class
#[schema(value_type = String)]
pub icon_class: Arc<str>,
/// Extra CSS class for icon styling
#[schema(value_type = String)]
pub icon_special_class: Arc<str>,
/// Relevance score
pub relevance_score: u32,
}