diff --git a/docs/guide/search.md b/docs/guide/search.md index 023816fe..2d47c953 100644 --- a/docs/guide/search.md +++ b/docs/guide/search.md @@ -27,8 +27,8 @@ additionally restricted to administrators — see [Result Caching](#result-cachi | `modified_after` / `modified_before` | Filter by modification time | | `min_size` / `max_size` | Filter by file size in bytes | | `resource_types` | Comma-separated: `file`, `folder` (both by default) | -| `order_by` | `relevance` (default), `name`, `name_desc`, `date`, `date_desc`, `size`, `size_desc` | -| `reverse` | Reverse the sort order | +| `order_by` | Sort dimension: `relevance` (default), `name`, `size`, `updated_at`, `created_at` | +| `reverse` | Reverse sort direction (no-op for `relevance`) | | `limit` | Page size (1–200, default 50) | | `cursor` | Opaque cursor returned by the previous page | diff --git a/frontend/src/lib/api/endpoints/search.test.ts b/frontend/src/lib/api/endpoints/search.test.ts index cc53844c..6fb4fd1e 100644 --- a/frontend/src/lib/api/endpoints/search.test.ts +++ b/frontend/src/lib/api/endpoints/search.test.ts @@ -16,12 +16,12 @@ it('builds search requests including filters', async () => { fileTypes: ['mp3', 'wav'], minSize: 1, maxSize: 9, - sortBy: 'date' + sortBy: 'updated_at' }).catch(() => {}); expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything()); // Sort dimension is sent on the wire as `order_by`, matching the // backend's `SearchResourcesQuery` (post-normalization). - expect(j).toHaveBeenCalledWith(expect.stringContaining('order_by=date'), expect.anything()); + expect(j).toHaveBeenCalledWith(expect.stringContaining('order_by=updated_at'), expect.anything()); await searchSuggest('q').catch(() => {}); await clearSearchCache().catch(() => {}); expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 9e735d09..38efe888 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -248,14 +248,14 @@ export interface AuthResponse { expires_in: number; } -export type SortBy = - | 'relevance' - | 'name' - | 'name_desc' - | 'date' - | 'date_desc' - | 'size' - | 'size_desc'; +/** + * Sort dimension for `GET /api/search`. Wire-matches the backend's + * `SearchResourcesQuery.order_by` — 5 canonical values, direction is + * a separate `reverse` boolean (the `_desc` suffix pattern was + * retired 2026-07-26; `date` was renamed to the more explicit + * `updated_at` alongside the new `created_at`). + */ +export type SortBy = 'relevance' | 'name' | 'size' | 'updated_at' | 'created_at'; /** * Per-item search metadata inline on every hit in the normalized diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte index 6a184f45..4866c7cb 100644 --- a/frontend/src/routes/search/+page.svelte +++ b/frontend/src/routes/search/+page.svelte @@ -235,8 +235,14 @@ { key: 'modifiedAt', label: t('groupby.modifiedAt', 'Modified date'), - orderBy: 'date', + orderBy: 'updated_at', bucketOf: (item) => dateBucket(item.modified_at) + }, + { + key: 'createdAt', + label: t('groupby.createdAt', 'Created date'), + orderBy: 'created_at', + bucketOf: (item) => dateBucket(item.created_at) } ]; diff --git a/src/application/dtos/search_dto.rs b/src/application/dtos/search_dto.rs index 7cd49fe0..5c0b9c94 100644 --- a/src/application/dtos/search_dto.rs +++ b/src/application/dtos/search_dto.rs @@ -64,9 +64,18 @@ pub struct SearchCriteriaDto { #[serde(default)] pub offset: usize, - /// Sort order for results: "relevance", "name", "name_desc", "date", "date_desc", "size", "size_desc" + /// 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) @@ -100,6 +109,7 @@ impl Default for SearchCriteriaDto { limit: default_limit(), offset: 0, sort_by: default_sort_by(), + reverse: false, } } } @@ -345,10 +355,11 @@ pub struct SearchResourcesQuery { pub cursor: Option, /// Sort dimension. Supported: `"relevance"` (default), `"name"`, - /// `"name_desc"`, `"date"` (= `modified_at`), `"date_desc"`, - /// `"size"`, `"size_desc"`. Names match the pre-normalisation - /// values `SearchCriteriaDto.sort_by` accepted so cached results - /// remain reachable. + /// `"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, /// Comma-separated resource types to include, e.g. `"file,folder"`. @@ -399,10 +410,11 @@ impl SearchResourcesQuery { .as_deref() .and_then(SearchResourceCursor::decode) } - /// Convert to the internal `SearchCriteriaDto` the service still - /// consumes. `limit` / `offset` come from the decoded cursor (or the - /// query's `limit` on the first page). Sort names pass through - /// verbatim — the service's `sort_by` matcher accepts the same set. + /// 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| { @@ -425,6 +437,7 @@ impl SearchResourcesQuery { 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 diff --git a/src/application/services/search_service.rs b/src/application/services/search_service.rs index e322d9f3..0196aeae 100644 --- a/src/application/services/search_service.rs +++ b/src/application/services/search_service.rs @@ -201,14 +201,23 @@ fn content_relevance(score: f32, max_score: f32) -> u32 { /// Re-sort the merged file list with the same semantics the folder list /// uses. Only invoked when content hits were merged into a SQL-ordered page. -fn sort_enriched_files(files: &mut [SearchFileResultDto], sort_by: &str) { +/// +/// Sort dimension + direction are orthogonal (matches the wire +/// `SearchResourcesQuery` / internal `SearchCriteriaDto` split): 5 +/// canonical `sort_by` values (`relevance | name | size | updated_at | +/// created_at`) × the boolean `reverse`. +fn sort_enriched_files(files: &mut [SearchFileResultDto], sort_by: &str, reverse: bool) { match sort_by { + "name" if reverse => files.sort_by_cached_key(|f| Reverse(f.name.to_lowercase())), "name" => files.sort_by_cached_key(|f| f.name.to_lowercase()), - "name_desc" => files.sort_by_cached_key(|f| Reverse(f.name.to_lowercase())), - "date" => files.sort_by_key(|f| f.modified_at), - "date_desc" => files.sort_by_key(|f| Reverse(f.modified_at)), + "updated_at" if reverse => files.sort_by_key(|f| Reverse(f.modified_at)), + "updated_at" => files.sort_by_key(|f| f.modified_at), + "created_at" if reverse => files.sort_by_key(|f| Reverse(f.created_at)), + "created_at" => files.sort_by_key(|f| f.created_at), + "size" if reverse => files.sort_by_key(|f| Reverse(f.size)), "size" => files.sort_by_key(|f| f.size), - "size_desc" => files.sort_by_key(|f| Reverse(f.size)), + // `relevance` (default) — reverse is a no-op; descending + // relevance would be "least-relevant first," meaningless. _ => files.sort_by_key(|f| Reverse(f.relevance_score)), } } @@ -519,7 +528,7 @@ impl SearchService { added += 1; } if added > 0 { - sort_enriched_files(enriched_files, &criteria.sort_by); + sort_enriched_files(enriched_files, &criteria.sort_by, criteria.reverse); } Ok(added) } @@ -734,20 +743,33 @@ impl SearchUseCase for SearchService { }) .collect(); - // Sort folders (cached_key avoids O(N log N) temporary String allocations) - match criteria.sort_by.as_str() { - "name" => { + // Sort folders (cached_key avoids O(N log N) temporary String allocations). + // 5-dimension model (`relevance | name | size | updated_at | created_at`) + // × the boolean `reverse` — matches the file-side `sort_enriched_files` + // + the SQL match in `file_blob_read_repository`. `size` isn't + // meaningful for folders (no size column), so it falls through + // to the relevance default. + match (criteria.sort_by.as_str(), criteria.reverse) { + ("name", false) => { enriched_folders.sort_by_cached_key(|f| f.name.to_lowercase()); } - "name_desc" => { + ("name", true) => { enriched_folders.sort_by_cached_key(|f| Reverse(f.name.to_lowercase())); } - "date" => { + ("updated_at", false) => { enriched_folders.sort_by_key(|f| f.modified_at); } - "date_desc" => { + ("updated_at", true) => { enriched_folders.sort_by_key(|f| Reverse(f.modified_at)); } + ("created_at", false) => { + enriched_folders.sort_by_key(|f| f.created_at); + } + ("created_at", true) => { + enriched_folders.sort_by_key(|f| Reverse(f.created_at)); + } + // `relevance` (default) + `size` (N/A for folders) + + // anything unrecognised — all land on relevance-desc. _ => { enriched_folders.sort_by_key(|f| Reverse(f.relevance_score)); } @@ -1147,17 +1169,20 @@ mod tests { dto("b-content.txt", 30, 10, 200), dto("a-name.txt", 80, 99, 100), ]; - sort_enriched_files(&mut files, "relevance"); + sort_enriched_files(&mut files, "relevance", false); assert_eq!( files[0].name, "a-name.txt", "name match must outrank content match" ); - sort_enriched_files(&mut files, "size_desc"); + // 5-dimension sort model (2026-07-26): direction is a separate + // boolean, `_desc` suffixes retired. `size + reverse=true` = old + // `size_desc`, `updated_at + reverse=false` = old `date`, etc. + sort_enriched_files(&mut files, "size", true); assert_eq!(files[0].name, "a-name.txt"); - sort_enriched_files(&mut files, "date"); + sort_enriched_files(&mut files, "updated_at", false); assert_eq!(files[0].name, "a-name.txt"); - sort_enriched_files(&mut files, "name_desc"); + sort_enriched_files(&mut files, "name", true); assert_eq!(files[0].name, "b-content.txt"); } } diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 3656e19a..d29481ed 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -1216,16 +1216,23 @@ impl FileReadPort for FileBlobReadRepository { let offset = criteria.offset as i64; let limit = criteria.limit as i64; - // Determine sort order - let (order_column, order_dir) = match criteria.sort_by.as_str() { - "name" => ("fi.name", "ASC"), - "name_desc" => ("fi.name", "DESC"), - "date" => ("fi.updated_at", "ASC"), - "date_desc" => ("fi.updated_at", "DESC"), - "size" => ("fi.size", "ASC"), - "size_desc" => ("fi.size", "DESC"), - _ => ("fi.name", "ASC"), + // Determine sort order. Canonical `sort_by` set (matches the wire + // `SearchResourcesQuery.order_by` 1:1 — no rename at any layer): + // `relevance | name | size | updated_at | created_at`. Direction + // comes from `criteria.reverse`; the old `_desc`-suffix pattern + // was retired 2026-07-26. + let order_column = match criteria.sort_by.as_str() { + "name" => "fi.name", + "updated_at" => "fi.updated_at", + "created_at" => "fi.created_at", + "size" => "fi.size", + // `relevance` (or anything unrecognised) has no dedicated + // column here — the recursive/non-recursive services blend + // in content-index hits and re-sort in memory. Falling back + // to name keeps the SQL page stable. + _ => "fi.name", }; + let order_dir = if criteria.reverse { "DESC" } else { "ASC" }; // ── Build dynamic WHERE + bind indices ─────────────────────────── let mut conditions: Vec = vec![ @@ -1376,16 +1383,23 @@ impl FileReadPort for FileBlobReadRepository { let offset = criteria.offset as i64; let limit = criteria.limit as i64; - // Determine sort order - let (order_column, order_dir) = match criteria.sort_by.as_str() { - "name" => ("fi.name", "ASC"), - "name_desc" => ("fi.name", "DESC"), - "date" => ("fi.updated_at", "ASC"), - "date_desc" => ("fi.updated_at", "DESC"), - "size" => ("fi.size", "ASC"), - "size_desc" => ("fi.size", "DESC"), - _ => ("fi.name", "ASC"), + // Determine sort order. Canonical `sort_by` set (matches the wire + // `SearchResourcesQuery.order_by` 1:1 — no rename at any layer): + // `relevance | name | size | updated_at | created_at`. Direction + // comes from `criteria.reverse`; the old `_desc`-suffix pattern + // was retired 2026-07-26. + let order_column = match criteria.sort_by.as_str() { + "name" => "fi.name", + "updated_at" => "fi.updated_at", + "created_at" => "fi.created_at", + "size" => "fi.size", + // `relevance` (or anything unrecognised) has no dedicated + // column here — the recursive/non-recursive services blend + // in content-index hits and re-sort in memory. Falling back + // to name keeps the SQL page stable. + _ => "fi.name", }; + let order_dir = if criteria.reverse { "DESC" } else { "ASC" }; // ── Build dynamic WHERE clauses ── let mut conditions = Vec::new(); diff --git a/src/interfaces/api/handlers/search_handler.rs b/src/interfaces/api/handlers/search_handler.rs index 5e3834d0..128fa150 100644 --- a/src/interfaces/api/handlers/search_handler.rs +++ b/src/interfaces/api/handlers/search_handler.rs @@ -257,7 +257,7 @@ pub struct SuggestParams { ("query" = Option, Query, description = "Text to search in names / content"), ("limit" = Option, Query, description = "Max items per page (1–200, default 50)"), ("cursor" = Option, Query, description = "Opaque cursor from a previous response"), - ("order_by" = Option, Query, description = "Sort dimension: relevance (default) | name | name_desc | date | date_desc | size | size_desc"), + ("order_by" = Option, Query, description = "Sort dimension: relevance (default) | name | size | updated_at | created_at"), ("resource_types" = Option, Query, description = "Comma-separated: file, folder (both by default)"), ("reverse" = Option, Query, description = "Reverse the sort order"), ("type" = Option, Query, description = "Filter by file extensions (comma-separated)"),