Merge pull request #645 from EdouardVanbelle/refactor/search
This commit is contained in:
+68
-31
@@ -1,69 +1,106 @@
|
||||
# Search
|
||||
|
||||
OxiCloud provides authenticated file and folder search with simple query parameters, advanced JSON criteria, pagination, recursive traversal, and in-memory result caching.
|
||||
OxiCloud provides authenticated file and folder search with a
|
||||
cursor-paginated response, filter/sort query parameters, recursive
|
||||
traversal, and in-memory result caching.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/search/` | Simple search using query parameters |
|
||||
| `POST` | `/api/search/advanced` | Advanced search with a JSON body |
|
||||
| `GET` | `/api/search` | Cursor-paginated search |
|
||||
| `GET` | `/api/search/suggest` | Lightweight autocomplete suggestions |
|
||||
| `DELETE` | `/api/admin/search/cache` | Flush the shared search results cache (admin only) |
|
||||
|
||||
All search endpoints require authentication. The cache flush is
|
||||
additionally restricted to administrators — see [Result Caching](#result-caching).
|
||||
|
||||
## Simple Search Parameters
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `query` | Text to search in file and folder names |
|
||||
| `type` | Comma-separated file extensions |
|
||||
| `created_after` / `created_before` | Filter by creation time |
|
||||
| `modified_after` / `modified_before` | Filter by modification time |
|
||||
| `min_size` / `max_size` | Filter by file size in bytes |
|
||||
| `query` | Text to search in file/folder names (and file content when the Tantivy index is enabled) |
|
||||
| `type` | Comma-separated file extensions (files only) |
|
||||
| `folder_id` | Restrict search scope to one folder |
|
||||
| `recursive` | Search subfolders, defaults to `true` |
|
||||
| `limit` | Maximum results, defaults to `100` |
|
||||
| `offset` | Pagination offset |
|
||||
| `sort_by` | `relevance`, `name`, `name_desc`, `date`, `date_desc`, `size`, or `size_desc` |
|
||||
| `created_after` / `created_before` | Filter by creation time (unix seconds) |
|
||||
| `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` | 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 |
|
||||
|
||||
## Response Shape
|
||||
|
||||
`/api/search` returns the same envelope as every other `/*/resources`
|
||||
listing (folders, favorites, recent, trash, shared) so a single
|
||||
client component can render all of them:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"resource_type": "file",
|
||||
"resource": { "id": "…", "name": "report.pdf", "size": 12345, "…": "…" },
|
||||
"meta": {
|
||||
"score": 0.82,
|
||||
"snippet": "…quarterly <mark>report</mark>…",
|
||||
"via": "content"
|
||||
}
|
||||
},
|
||||
{
|
||||
"resource_type": "folder",
|
||||
"resource": { "id": "…", "name": "Reports", "…": "…" },
|
||||
"meta": { "score": 0.31, "via": "name" }
|
||||
}
|
||||
],
|
||||
"next_cursor": "eyJvZmZzZXQiOjUwLCJvcmRlcl9ieSI6InJlbGV2YW5jZSJ9",
|
||||
"query_time_ms": 12,
|
||||
"total": 137
|
||||
}
|
||||
```
|
||||
|
||||
- `resource_type`: `"file"` or `"folder"` — tells the client which
|
||||
variant of `resource` to render.
|
||||
- `resource`: the same `FileDto` / `FolderDto` shape any other
|
||||
endpoint would emit.
|
||||
- `meta.score`: relevance in `[0, 1]`.
|
||||
- `meta.snippet`: optional HTML-safe excerpt from the content index
|
||||
(present only when the match came from file content).
|
||||
- `meta.via`: `"name"`, `"content"`, or `"path"` — where the match
|
||||
fired.
|
||||
- `next_cursor`: opaque; present iff another page exists. Pass it
|
||||
verbatim as `?cursor=…` for the next call.
|
||||
- `total`: integer, approximate — reflects the caller-visible match
|
||||
count (permission-filtered). Omitted when unknown; never leaks a
|
||||
count for rows the caller cannot see.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://oxicloud.example.com/api/search/?query=report&type=pdf,docx&recursive=true&limit=20"
|
||||
```
|
||||
|
||||
## Advanced Search
|
||||
|
||||
```json
|
||||
{
|
||||
"name_contains": "report",
|
||||
"file_types": ["pdf", "docx"],
|
||||
"min_size": 1024,
|
||||
"folder_id": "folder-uuid",
|
||||
"recursive": true,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
"https://oxicloud.example.com/api/search?query=report&type=pdf,docx&limit=20"
|
||||
```
|
||||
|
||||
## Suggestions
|
||||
|
||||
Use `/api/search/suggest?query=rep&limit=10` for quick autocomplete-style results. Suggestions can also be scoped to a folder with `folder_id`.
|
||||
Use `/api/search/suggest?query=rep&limit=10` for quick
|
||||
autocomplete-style results. Suggestions can also be scoped to a
|
||||
folder with `folder_id`.
|
||||
|
||||
## Result Caching
|
||||
|
||||
Search results are cached in memory using the search criteria and user ID as the cache key.
|
||||
Search results are cached in memory using the search criteria and
|
||||
user ID as the cache key.
|
||||
|
||||
- Cache TTL: 5 minutes
|
||||
- Max entries: 1000
|
||||
- Manual invalidation: `DELETE /api/admin/search/cache` — admin-only.
|
||||
The endpoint calls `invalidate_all()` on the shared moka cache, so
|
||||
one call cold-starts every subsequent search for every tenant; it's
|
||||
an operator debug lever, not a per-user affordance.
|
||||
one call cold-starts every subsequent search for every tenant;
|
||||
it's an operator debug lever, not a per-user affordance.
|
||||
|
||||
## Feature Flag
|
||||
|
||||
|
||||
@@ -145,6 +145,11 @@ fn synth_entry(idx: u64) -> Arc<SearchResultsDto> {
|
||||
blob_hash: pseudo_hex(&mut rng, 64),
|
||||
snippet: content_hit.then(|| SNIPPET.to_string()),
|
||||
match_source: Some(match_source.to_string()),
|
||||
etag: pseudo_hex(&mut rng, 16),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
is_favorite: false,
|
||||
is_shared: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,12 +208,20 @@ struct PhaseReport {
|
||||
/// Insert the full corpus, settle the cache, then measure retention and
|
||||
/// hot-key read latency. Identical for both variants — only the cache
|
||||
/// configuration differs.
|
||||
async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> PhaseReport {
|
||||
async fn run_phase(
|
||||
cache: &moka::future::Cache<(uuid::Uuid, u64), Arc<SearchResultsDto>>,
|
||||
) -> PhaseReport {
|
||||
let hwm_start_kb = status_kb("VmHWM");
|
||||
let rss_start_kb = status_kb("VmRSS");
|
||||
|
||||
// Cache key changed to `(Uuid, u64)` in the per-user invalidation
|
||||
// refactor (2026-07-26). Bench uses one fixed user across all keys —
|
||||
// varying the u64 part exercises the same cardinality the pre-refactor
|
||||
// benchmark did (one entry per query variant).
|
||||
let bench_user = uuid::Uuid::nil();
|
||||
|
||||
for i in 0..ENTRIES {
|
||||
cache.insert(i, synth_entry(i)).await;
|
||||
cache.insert((bench_user, i), synth_entry(i)).await;
|
||||
// Let eviction run as it would under live traffic, so evicted pages
|
||||
// are actually freed instead of piling up in moka's pending queue.
|
||||
if i % 64 == 0 {
|
||||
@@ -226,7 +239,7 @@ async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> P
|
||||
.sum();
|
||||
|
||||
// Hot-key read latency: p50 over GETS reads of one resident key.
|
||||
let hot: u64 = *cache.iter().next().expect("cache is empty after fill").0;
|
||||
let hot: (uuid::Uuid, u64) = *cache.iter().next().expect("cache is empty after fill").0;
|
||||
for _ in 0..1_000 {
|
||||
black_box(cache.get(&hot).await); // warmup
|
||||
}
|
||||
@@ -257,7 +270,10 @@ async fn run_phase(cache: &moka::future::Cache<u64, Arc<SearchResultsDto>>) -> P
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let entry_weight = u64::from(search_results_entry_weight(&0, &synth_entry(0)));
|
||||
let entry_weight = u64::from(search_results_entry_weight(
|
||||
&(uuid::Uuid::nil(), 0),
|
||||
&synth_entry(0),
|
||||
));
|
||||
println!("\n###########################################################");
|
||||
println!("# Search-results cache: entry-count bound vs byte bound");
|
||||
println!(
|
||||
@@ -274,7 +290,10 @@ async fn main() {
|
||||
println!("###########################################################\n");
|
||||
|
||||
// --- Phase 1: BEFORE (entry-count bound, exactly the old wiring) ---
|
||||
let before_cache: moka::future::Cache<u64, Arc<SearchResultsDto>> =
|
||||
// Key type mirrors production's post-2026-07-26 tuple key so both
|
||||
// phases exercise the same `Cache<(Uuid, u64), _>` shape; only the
|
||||
// capacity bound differs (entry-count here vs weigher below).
|
||||
let before_cache: moka::future::Cache<(uuid::Uuid, u64), Arc<SearchResultsDto>> =
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(BEFORE_MAX_ENTRIES)
|
||||
.time_to_live(Duration::from_secs(TTL_SECS))
|
||||
|
||||
@@ -2,24 +2,39 @@ import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { searchFiles, searchSuggest, clearSearchCache } from './search';
|
||||
import { searchResources, searchSuggest, clearSearchCache } from './search';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue({ files: [], folders: [] });
|
||||
j.mockResolvedValue({ items: [], query_time_ms: 0 });
|
||||
});
|
||||
it('builds search requests including filters', async () => {
|
||||
await searchFiles('q', {
|
||||
await searchResources('q', {
|
||||
recursive: true,
|
||||
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=updated_at'), expect.anything());
|
||||
await searchSuggest('q').catch(() => {});
|
||||
await clearSearchCache().catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('forwards cursor pagination and resource-type filter', async () => {
|
||||
await searchResources('q', {
|
||||
cursor: 'abc',
|
||||
resourceTypes: ['file'],
|
||||
limit: 25
|
||||
}).catch(() => {});
|
||||
const call = j.mock.calls.at(-1)?.[0] as string;
|
||||
expect(call).toContain('cursor=abc');
|
||||
expect(call).toContain('resource_types=file');
|
||||
expect(call).toContain('limit=25');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Search endpoint — ported from features/files/search.js. */
|
||||
// Search endpoint — hits the normalized `/*/resources` envelope shape.
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import type { SearchResults, SortBy } from '$lib/api/types';
|
||||
import type { ItemType, SearchResourcesResponse, SortBy } from '$lib/api/types';
|
||||
|
||||
export interface SearchOptions {
|
||||
folderId?: string;
|
||||
@@ -16,14 +16,33 @@ export interface SearchOptions {
|
||||
modifiedAfter?: number;
|
||||
/** Unix-seconds upper bound on modified time. */
|
||||
modifiedBefore?: number;
|
||||
/** Page size (1–200 server-side; default 50). */
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/**
|
||||
* Cursor from a previous response's `next_cursor`. Absent → first page.
|
||||
* The wire uses cursor pagination now; the old `offset` param is gone.
|
||||
*/
|
||||
cursor?: string;
|
||||
/** Sort dimension; maps to backend `order_by`. */
|
||||
sortBy?: SortBy;
|
||||
/** Reverse sort direction; maps to backend `reverse`. */
|
||||
reverse?: boolean;
|
||||
/** Restrict to files, folders, or both (default). */
|
||||
resourceTypes?: ItemType[];
|
||||
/** Abort the request when a newer search supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
|
||||
/**
|
||||
* Cursor-paginated search. Returns the shared envelope
|
||||
* `{ items[], next_cursor?, query_time_ms, total? }` — same shape as
|
||||
* favorites / recent / trash / folder listings so `ResourceList`
|
||||
* consumes the items without a demux step.
|
||||
*/
|
||||
export function searchResources(
|
||||
query: string,
|
||||
opts: SearchOptions = {}
|
||||
): Promise<SearchResourcesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
@@ -37,10 +56,12 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
|
||||
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
|
||||
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
|
||||
params.append('limit', String(opts.limit ?? 100));
|
||||
params.append('offset', String(opts.offset ?? 0));
|
||||
params.append('sort_by', opts.sortBy ?? 'relevance');
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, {
|
||||
if (opts.resourceTypes?.length) params.append('resource_types', opts.resourceTypes.join(','));
|
||||
if (opts.limit != null) params.append('limit', String(opts.limit));
|
||||
if (opts.cursor) params.append('cursor', opts.cursor);
|
||||
if (opts.sortBy) params.append('order_by', opts.sortBy);
|
||||
if (opts.reverse) params.append('reverse', 'true');
|
||||
return apiJson<SearchResourcesResponse>(`/api/search?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
@@ -248,40 +248,52 @@ 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';
|
||||
|
||||
export interface SearchCriteria {
|
||||
sort_by: SortBy;
|
||||
recursive: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
name_contains?: string;
|
||||
file_types?: string[];
|
||||
folder_id?: string;
|
||||
min_size?: number;
|
||||
max_size?: number;
|
||||
created_before?: number;
|
||||
created_after?: number;
|
||||
modified_before?: number;
|
||||
modified_after?: number;
|
||||
/**
|
||||
* Per-item search metadata inline on every hit in the normalized
|
||||
* `/api/search` envelope. Mirrors backend `SearchMeta` — see
|
||||
* `application/dtos/search_dto.rs`.
|
||||
*/
|
||||
export interface SearchMeta {
|
||||
/** Relevance in [0, 1]; the higher the better. */
|
||||
score: number;
|
||||
/** Optional HTML-safe excerpt when the match fired via content index. */
|
||||
snippet?: string;
|
||||
/** Where the match fired. */
|
||||
via?: 'name' | 'content' | 'path';
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
files: FileItem[];
|
||||
folders: FolderItem[];
|
||||
total_count: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
/**
|
||||
* Single hit in the `/api/search` envelope. `resource_type` disambiguates
|
||||
* `resource`'s union so the shared `ResourceList` component can render it
|
||||
* exactly like a folders/favorites/recent/trash row.
|
||||
*/
|
||||
export interface SearchResourceItem {
|
||||
resource_type: ItemType;
|
||||
resource: FileItem | FolderItem;
|
||||
meta: SearchMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire response of `GET /api/search`. Same envelope shape as the other
|
||||
* "resources" listing endpoints (`items[]` + optional `next_cursor`),
|
||||
* plus two search-specific top-level fields: `query_time_ms` (health
|
||||
* signal for admins, "Found N in Xms" for users) and `total` (approximate,
|
||||
* caller-visible; never leaks a count for rows the caller can't see).
|
||||
*/
|
||||
export interface SearchResourcesResponse {
|
||||
items: SearchResourceItem[];
|
||||
next_cursor?: string;
|
||||
query_time_ms: number;
|
||||
sort_by: string;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export type DriveKind = 'personal' | 'shared';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { logout } from '$lib/api/endpoints/auth';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
|
||||
import { deleteFolder } from '$lib/api/endpoints/folders';
|
||||
import { addFavorite } from '$lib/api/endpoints/favorites';
|
||||
@@ -17,6 +17,7 @@
|
||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { dialogs } from '$lib/stores/dialogs.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||
@@ -255,11 +256,22 @@
|
||||
|
||||
function goToResults() {
|
||||
const q = searchQuery.trim();
|
||||
if (q) {
|
||||
suggestOpen = false;
|
||||
searchActive = false;
|
||||
goto(resolve(`/search?q=${encodeURIComponent(q)}`));
|
||||
if (!q) return;
|
||||
suggestOpen = false;
|
||||
searchActive = false;
|
||||
// Carry the currently-open folder into the search URL as `?in=<uuid>`
|
||||
// so a hard refresh, a shared link, or a bookmark all restore the
|
||||
// "This folder" scope. Trash section is always global — skip. See
|
||||
// `/search/+page.svelte` for the receiver side.
|
||||
//
|
||||
// Built by hand instead of via `URLSearchParams` because the Svelte
|
||||
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
|
||||
// variant; the two params here don't need reactivity anyway.
|
||||
const parts = [`q=${encodeURIComponent(q)}`];
|
||||
if (filesStore.currentFolder && filesStore.section !== 'trash') {
|
||||
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
|
||||
}
|
||||
goto(resolve(`/search?${parts.join('&')}`));
|
||||
}
|
||||
|
||||
function onSearch(e: SubmitEvent) {
|
||||
@@ -285,12 +297,20 @@
|
||||
suggestInflight = ctl;
|
||||
suggestBusy = true;
|
||||
try {
|
||||
const r = await searchFiles(q, { recursive: true, limit: 6, signal: ctl.signal });
|
||||
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal });
|
||||
if (seq !== suggestSeq) return; // superseded while awaiting
|
||||
suggestions = [
|
||||
...r.folders.slice(0, 3).map((item) => ({ kind: 'folder' as const, item })),
|
||||
...r.files.slice(0, 6).map((item) => ({ kind: 'file' as const, item }))
|
||||
];
|
||||
// The wire is ordered — folders first, then files — but slice
|
||||
// per kind explicitly so the header preview stays a folder-heavy
|
||||
// list even when files dominate the result set.
|
||||
const folders = r.items
|
||||
.filter((it) => it.resource_type === 'folder')
|
||||
.slice(0, 3)
|
||||
.map((it) => ({ kind: 'folder' as const, item: it.resource as FolderItem }));
|
||||
const files = r.items
|
||||
.filter((it) => it.resource_type === 'file')
|
||||
.slice(0, 6)
|
||||
.map((it) => ({ kind: 'file' as const, item: it.resource as FileItem }));
|
||||
suggestions = [...folders, ...files];
|
||||
suggestOpen = suggestions.length > 0;
|
||||
} catch {
|
||||
if (seq !== suggestSeq || ctl.signal.aborted) return;
|
||||
|
||||
@@ -9,7 +9,9 @@ const { goto, pageState } = vi.hoisted(() => ({
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({
|
||||
searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 }))
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { logout } from '$lib/api/endpoints/auth';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { logout } from '$lib/api/endpoints/auth';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
@@ -193,24 +193,33 @@
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await searchFiles(q, { recursive: true, limit: 5 });
|
||||
const folders: Command[] = r.folders.slice(0, 3).map((f) => ({
|
||||
id: `fld-${f.id}`,
|
||||
label: f.name,
|
||||
icon: 'folder',
|
||||
hint: t('files.folder', 'Folder'),
|
||||
run: nav(`/files/${f.id}`)
|
||||
}));
|
||||
const files: Command[] = r.files.slice(0, 5).map((f) => ({
|
||||
id: `fil-${f.id}`,
|
||||
label: f.name,
|
||||
icon: 'file',
|
||||
hint: t('files.file', 'File'),
|
||||
run: () => {
|
||||
close();
|
||||
window.open(fileInlineUrl(f.id), '_blank', 'noopener');
|
||||
}
|
||||
}));
|
||||
const r = await searchResources(q, { recursive: true, limit: 8 });
|
||||
// Wire items are ordered folders-first-then-files, but demux
|
||||
// explicitly so the palette keeps the two-section layout even
|
||||
// when file hits dominate the result set.
|
||||
const folders: Command[] = r.items
|
||||
.filter((it) => it.resource_type === 'folder')
|
||||
.slice(0, 3)
|
||||
.map((it) => ({
|
||||
id: `fld-${it.resource.id}`,
|
||||
label: it.resource.name,
|
||||
icon: 'folder',
|
||||
hint: t('files.folder', 'Folder'),
|
||||
run: nav(`/files/${it.resource.id}`)
|
||||
}));
|
||||
const files: Command[] = r.items
|
||||
.filter((it) => it.resource_type === 'file')
|
||||
.slice(0, 5)
|
||||
.map((it) => ({
|
||||
id: `fil-${it.resource.id}`,
|
||||
label: it.resource.name,
|
||||
icon: 'file',
|
||||
hint: t('files.file', 'File'),
|
||||
run: () => {
|
||||
close();
|
||||
window.open(fileInlineUrl(it.resource.id), '_blank', 'noopener');
|
||||
}
|
||||
}));
|
||||
fileMatches = [...folders, ...files];
|
||||
} catch {
|
||||
fileMatches = [];
|
||||
|
||||
@@ -4,11 +4,13 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
const { goto } = vi.hoisted(() => ({ goto: vi.fn() }));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$lib/api/endpoints/auth', () => ({ logout: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn(async () => ({ items: [] })) }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({
|
||||
searchResources: vi.fn(async () => ({ items: [], query_time_ms: 0 }))
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog: vi.fn() }));
|
||||
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import CommandPalette from './CommandPalette.svelte';
|
||||
|
||||
@@ -42,6 +44,6 @@ it('searches files as the query is typed', async () => {
|
||||
await openPalette();
|
||||
const input = await screen.findByTestId('command-palette-input');
|
||||
await fireEvent.input(input, { target: { value: 'report' } });
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
|
||||
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||
expect(m(searchResources).mock.calls[0][0]).toBe('report');
|
||||
});
|
||||
|
||||
@@ -370,6 +370,15 @@ export const OxiIcons: Record<string, IconEntry> = {
|
||||
512,
|
||||
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
|
||||
],
|
||||
// Font Awesome Free 6.7.2 `ranking-star` (podium with a star crown).
|
||||
// Hand-added — /search's group-by dropdown uses this glyph for the
|
||||
// "Relevance" option (Ed's 2026-07-26 UX call). Registry header says
|
||||
// "regenerate from the source" but no generator lives in-repo, so
|
||||
// hand-inserting matches the shape of every other entry.
|
||||
"ranking-star": [
|
||||
640,
|
||||
"M353.8 54.1L330.2 6.3c-3.9-8.3-16.1-8.3-20 0L286.6 54.1l-52.9 7.7c-9.2 1.3-12.9 12.7-6.2 19.3l38.3 37.3-9.1 52.7c-1.6 9.2 8.1 16.2 16.3 11.9l47.3-24.9 47.3 24.9c8.2 4.3 17.9-2.7 16.3-11.9l-9-52.7 38.3-37.3c6.7-6.5 3-17.9-6.2-19.3L353.8 54.1zM256 256c-17.7 0-32 14.3-32 32l0 192c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-192c0-17.7-14.3-32-32-32l-128 0zM32 320c-17.7 0-32 14.3-32 32L0 480c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32L32 320zm416 96l0 64c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-128 0c-17.7 0-32 14.3-32 32z"
|
||||
],
|
||||
"repeat": [
|
||||
512,
|
||||
"M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z"
|
||||
|
||||
@@ -197,13 +197,33 @@
|
||||
|
||||
.list-header-checkbox input[type="checkbox"],
|
||||
.file-item .checkbox-cell input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
/* Bumped from 17px to 20px so the row-selection checkbox reads at
|
||||
a similar visual weight to the 28x28 action buttons that sit at
|
||||
the other end of the row (Ed's 2026-07-26 UX note). 20px is the
|
||||
upper end of the browser-native checkbox range — beyond that
|
||||
platforms start rendering an oversized-and-blurry glyph. */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Orange outline on hover for the row-selection checkbox (Ed's UX ask
|
||||
2026-07-26). `outline` (not `border`) because native checkboxes in
|
||||
list view honor `accent-color` but their border rendering is
|
||||
platform-inconsistent — `outline` is drawn OUTSIDE the box and
|
||||
doesn't reflow the layout. Grid view uses a custom-drawn checkbox
|
||||
(see `.files-grid-view … input[type="checkbox"]` below) which
|
||||
accepts real border styling; its hover override lives with that
|
||||
block and beats this via specificity. Matches the "everything
|
||||
hovers to accent" convention shared with row-action buttons. */
|
||||
.list-header-checkbox input[type="checkbox"]:hover,
|
||||
.file-item .checkbox-cell input[type="checkbox"]:hover {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.list-header.selection-mode {
|
||||
grid-template-columns: 36px 1fr;
|
||||
background-color: var(--color-multiselect-bg);
|
||||
@@ -496,30 +516,58 @@
|
||||
|
||||
.files-list-view .file-item .action-cell button:not(.btn-action):hover {
|
||||
background: var(--color-border-subtle);
|
||||
color: var(--color-text-dark);
|
||||
/* Accent-orange hover — matches the grid view's `.file-actions:hover`
|
||||
and the shared `.btn-action:hover` rule. Semantic overrides on
|
||||
`.favorite-star:hover` / `.shared-button:hover` (gold / blue) take
|
||||
precedence via more-specific selectors below. */
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Fav-star + shared-button share the same visibility rule: hidden on
|
||||
quiet rows, visible on row hover, and — crucially — always visible
|
||||
when their `.active` class is set. That's what lets a favorited or
|
||||
shared row be discoverable at a glance in list view without the
|
||||
user having to mouse over it.
|
||||
/* List-view resting/active state colors for favorite (gold) and shared
|
||||
(blue). Hover is intentionally NOT overridden here — the generic
|
||||
`.action-cell button:not(.btn-action):hover → --color-accent` rule
|
||||
above owns the orange hover for every button (kebab / favorite /
|
||||
shared) per Ed's 2026-07-26 spec. This block only paints the
|
||||
at-rest state on `.active` rows so the star / shared chip stays
|
||||
discoverable on a quiet row. */
|
||||
.files-list-view .file-item .action-cell button.favorite-star.active {
|
||||
color: var(--color-star-text-hover);
|
||||
}
|
||||
|
||||
We use `visibility: hidden` (not `display: none`) so a hidden
|
||||
button still reserves its slot in the action cell. Otherwise a row
|
||||
that's shared-but-not-favorited would slide its shared icon into
|
||||
the fav-star's column, breaking vertical alignment across rows. */
|
||||
.files-list-view .file-item .action-cell button.shared-button.active {
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
|
||||
/* Fav-star + shared-button — hidden on quiet rows, visible on row hover,
|
||||
and always visible when their `.active` class is set (so a favorited /
|
||||
shared row is discoverable without mousing over).
|
||||
|
||||
`opacity: 0` + `pointer-events: none` (instead of `visibility: hidden`)
|
||||
so the hide transitions match the kebab's fade — Ed's 2026-07-26 UX
|
||||
note: pre-refactor kebab faded over motion-fast while star/shared
|
||||
snapped out instantly (visibility flips have no animatable value),
|
||||
producing a staggered exit when the pointer left a row. The slot
|
||||
still reserves layout because the button geometry is unchanged; only
|
||||
its paint is toggled. */
|
||||
.files-list-view .file-item .action-cell button.favorite-star,
|
||||
.files-list-view .file-item .action-cell button.shared-button {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: none;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Strict reveal: star + shared appear ONLY on `.active` (row is
|
||||
favorited / shared) or pointer hover — Ed's 2026-07-26 spec. No
|
||||
`:focus-within` reveal, so keyboard focus on the row-body (name /
|
||||
path cells) does NOT flash the actions cluster. Quiet rows stay
|
||||
quiet at rest. */
|
||||
.files-list-view .file-item:hover .action-cell button.favorite-star,
|
||||
.files-list-view .file-item:hover .action-cell button.shared-button,
|
||||
.files-list-view .file-item .action-cell button.favorite-star.active,
|
||||
.files-list-view .file-item .action-cell button.shared-button.active {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable
|
||||
@@ -668,8 +716,11 @@
|
||||
position: absolute;
|
||||
top: calc(var(--space-3) + 8px);
|
||||
left: calc(var(--space-3) + 8px);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
/* 30x30 matches the action-cell chip pills (`.file-actions`, star,
|
||||
shared, `.btn-action`) so the checkbox and the corner-cluster
|
||||
buttons read as siblings of one visual size (Ed 2026-07-26). */
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-scrim-control);
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -699,8 +750,10 @@
|
||||
.files-grid-view .file-item .checkbox-cell input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
/* Custom-drawn glyph sized proportionally to the 30x30 chip pill —
|
||||
bumped from 18x18 to match the list-view checkbox's 20x20. */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
border: 2px solid var(--color-border-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -713,6 +766,17 @@
|
||||
border-color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Grid view uses a custom-drawn checkbox (`appearance: none`) so we can
|
||||
swap the real border color on hover — cleaner than the list-view
|
||||
`outline` trick, and no double-ring on this variant. Specificity
|
||||
(0,4,1 + 0,0,1) beats the shared `input[type="checkbox"]:hover`
|
||||
outline rule above so the grid-view chip doesn't get both a border
|
||||
AND an outline. */
|
||||
.files-grid-view .file-item .checkbox-cell input[type="checkbox"]:hover {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .checkbox-cell input[type="checkbox"]::after {
|
||||
content: "";
|
||||
width: 5px;
|
||||
@@ -783,9 +847,11 @@
|
||||
markup still has shared, favorite, itemActions, kebab in that
|
||||
sequence so list-view's inline right-aligned flow is unchanged. */
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) {
|
||||
/* Start right after the checkbox column (26px chip + inline gap)
|
||||
so the left group aligns visually with the checkbox row. */
|
||||
left: calc(var(--space-3) + 8px + 26px + var(--space-2));
|
||||
/* Start right after the checkbox column (30px chip + inline gap)
|
||||
so the left group aligns visually with the checkbox row. Kept in
|
||||
sync with `.checkbox-cell` width above — the two share this
|
||||
constant. */
|
||||
left: calc(var(--space-3) + 8px + 30px + var(--space-2));
|
||||
right: calc(var(--space-3) + 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -826,26 +892,36 @@
|
||||
|
||||
/* Per-button visibility (mirrors list view): each button is independently
|
||||
gated by its own `.active` flag OR row-hover. This prevents a favorited
|
||||
row from also lighting up the shared button (and vice versa) — the cell
|
||||
used to reveal all its children together via a single opacity toggle. */
|
||||
row from also lighting up the shared button (and vice versa).
|
||||
`opacity + pointer-events` so the fade timing matches the kebab and
|
||||
every `.btn-action--hover` — see the list-view block above for why. */
|
||||
.files-grid-view .file-item .action-cell .favorite-star,
|
||||
.files-grid-view .file-item .action-cell .shared-button {
|
||||
visibility: hidden;
|
||||
transition: visibility var(--motion-fast) var(--ease-standard);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Strict reveal: same rule as the list view — `.active` OR pointer
|
||||
hover, no `:focus-within` (Ed's 2026-07-26 spec). */
|
||||
.files-grid-view .file-item:hover .action-cell .favorite-star,
|
||||
.files-grid-view .file-item:hover .action-cell .shared-button,
|
||||
.files-grid-view .file-item:focus-within .action-cell .favorite-star,
|
||||
.files-grid-view .file-item:focus-within .action-cell .shared-button,
|
||||
.files-grid-view .file-item .action-cell .favorite-star.active,
|
||||
.files-grid-view .file-item .action-cell .shared-button.active {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
|
||||
the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they
|
||||
line up in the flex row. */
|
||||
line up in the flex row.
|
||||
NOTE: no `opacity: 1` here. Per-button reveal rules above own the
|
||||
opacity (star/shared: `.active` + `:hover`; kebab + btn-action:
|
||||
`:hover`). Pre-refactor this block force-set `opacity: 1` on the
|
||||
assumption that reveal lived on the parent `.action-cell` — that
|
||||
assumption is gone (Ed 2026-07-26), and the leftover made star +
|
||||
shared appear always-visible in grid view regardless of hover /
|
||||
`.active` state. */
|
||||
.files-grid-view .file-item .action-cell .file-actions,
|
||||
.files-grid-view .file-item .action-cell .favorite-star,
|
||||
.files-grid-view .file-item .action-cell .shared-button,
|
||||
@@ -871,11 +947,25 @@
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-md);
|
||||
cursor: pointer;
|
||||
/* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .action-cell .file-actions:hover {
|
||||
/* Unified row-action hover: every button in the grid-view corner cluster
|
||||
(kebab, star, shared, btn-action*) turns accent-orange on hover.
|
||||
Semantic states are conveyed by the `.active` class, not by hover
|
||||
color, so favorite = gold when starred, shared = blue when shared,
|
||||
both regardless of pointer position (see `.active` rules below).
|
||||
Ed's 2026-07-26 UX call: "orange for mouse over on all buttons;
|
||||
blue only for active shared."
|
||||
|
||||
Selector specificity (0,4,1 + 0,0,1 = high) beats the chip-visual
|
||||
base rule at ~line 852 (`.files-grid-view .file-item .action-cell
|
||||
.btn-action { color: var(--color-text) }`, 0,4,0), which is why the
|
||||
simpler `.btn-action:hover` didn't take effect inside the corner
|
||||
cluster. */
|
||||
.files-grid-view .file-item .action-cell .file-actions:hover,
|
||||
.files-grid-view .file-item .action-cell .btn-action:hover,
|
||||
.files-grid-view .file-item .action-cell .favorite-star:hover,
|
||||
.files-grid-view .file-item .action-cell .shared-button:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -884,13 +974,20 @@
|
||||
border: 2px dashed var(--color-warning-border);
|
||||
}
|
||||
|
||||
/* Favorite star + shared button — visual overrides only. Position,
|
||||
hover-reveal, chip geometry all come from the shared corner-cluster
|
||||
rule on `.files-grid-view .file-item .action-cell`. What's left
|
||||
here is just the per-state colour: subtle at rest, saturated when
|
||||
the item's flag is set. `.active` on either button also bumps the
|
||||
parent cluster's opacity (via `:has()` above) so an unhovered card
|
||||
still shows its favorited/shared state. */
|
||||
/* Favorite star + shared button — resting/active state colors only.
|
||||
HOVER color for both lives in the unified `.action-cell button:hover
|
||||
→ --color-accent` rule above; the per-state palette here only fires
|
||||
when the button is NOT hovered. Convention (Ed 2026-07-26):
|
||||
• hover → orange (accent) — every row action
|
||||
• star.active (not hover) → gold
|
||||
• shared.active (not hover) → blue
|
||||
|
||||
`.active` retains its color even on hover for `favorite-star` (star
|
||||
users expect gold-on-gold on the currently-starred item; losing the
|
||||
glyph mid-click reads as broken) but yields to orange for `shared`
|
||||
per Ed's explicit "blue only for active shared" — pointer-over on
|
||||
an already-shared row should still communicate "you're about to
|
||||
toggle something." */
|
||||
.files-grid-view .file-item button.favorite-star,
|
||||
.files-grid-view .file-item button.shared-button {
|
||||
color: var(--color-text-subtle);
|
||||
@@ -898,19 +995,10 @@
|
||||
line-height: var(--leading-none);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.favorite-star:hover {
|
||||
color: var(--color-star-text);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.favorite-star.active {
|
||||
color: var(--color-star-text-hover);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.favorite-star.active:hover {
|
||||
color: var(--color-star-active);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.shared-button:hover,
|
||||
.files-grid-view .file-item button.shared-button.active {
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
@@ -1274,25 +1362,41 @@
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--color-border-subtle);
|
||||
color: var(--color-text-dark);
|
||||
/* Accent (orange) hover matches `.file-actions` (kebab) and the
|
||||
favorite / shared button semantics — the pre-refactor grey-only
|
||||
tint left `/recent`'s broom, `/trash`'s restore and `/search`'s
|
||||
"open parent" reading as inert on hover next to the accented
|
||||
kebab. Ed's 2026-07-26 UX ask: "all row buttons should change
|
||||
color on hover, not just kebab and favorite." Section-specific
|
||||
variants (`.btn-action--delete` in trash, `--on` in ShareDialog)
|
||||
still win via more-specific selectors so red / etc. semantics
|
||||
are preserved. */
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Opt-in modifier: hide the button until the row is hovered / focused.
|
||||
Used by `/recent`'s per-row broom (a history-management action that
|
||||
shouldn't distract from the row content at rest). Trash's Restore /
|
||||
Delete stay on the plain `.btn-action` — those are the reason the
|
||||
user opened trash, and hiding them would fail Fitts' law. */
|
||||
Used by `/recent`'s per-row broom and `/search`'s open-parent
|
||||
(history-management / navigational actions that shouldn't distract
|
||||
from the row content at rest). Trash's Restore / Delete stay on the
|
||||
plain `.btn-action` — those are the reason the user opened trash,
|
||||
and hiding them would fail Fitts' law.
|
||||
`opacity + pointer-events` (not `visibility`) so the fade timing
|
||||
matches the kebab + star + shared button — Ed's 2026-07-26 UX note:
|
||||
pre-refactor each reveal mechanism was different, producing a
|
||||
staggered exit when the pointer left a row. */
|
||||
.files-list-view .file-item .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item .action-cell .btn-action--hover {
|
||||
visibility: hidden;
|
||||
transition: visibility var(--motion-fast) var(--ease-standard);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-list-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-list-view .file-item:focus-within .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:focus-within .action-cell .btn-action--hover {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
type Playlist,
|
||||
type PlaylistItem
|
||||
} from '$lib/api/endpoints/music';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import type { FileItem } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
@@ -450,17 +450,25 @@
|
||||
async function runAddSearch(query = '') {
|
||||
addSearching = true;
|
||||
try {
|
||||
const res = await searchFiles(query.trim(), {
|
||||
const res = await searchResources(query.trim(), {
|
||||
recursive: true,
|
||||
fileTypes: AUDIO_TYPES,
|
||||
resourceTypes: ['file'],
|
||||
limit: 200
|
||||
});
|
||||
// Belt-and-braces: keep only audio mime types.
|
||||
addResults = res.files.filter(
|
||||
(f) =>
|
||||
(f.mime_type ?? '').startsWith('audio/') ||
|
||||
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
|
||||
);
|
||||
// Belt-and-braces: keep only audio mime types. The envelope
|
||||
// items are `{resource_type, resource}` — resourceTypes:['file']
|
||||
// already restricts to files, but re-narrow here so the
|
||||
// downstream `FileItem[]` cast is honest even if the wire
|
||||
// ordering ever surfaces a folder.
|
||||
addResults = res.items
|
||||
.filter((it) => it.resource_type === 'file')
|
||||
.map((it) => it.resource as FileItem)
|
||||
.filter(
|
||||
(f) =>
|
||||
(f.mime_type ?? '').startsWith('audio/') ||
|
||||
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
|
||||
);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
addResults = [];
|
||||
|
||||
@@ -1,32 +1,170 @@
|
||||
<script lang="ts">
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import ResourceList, {
|
||||
isFile,
|
||||
type ContextAction,
|
||||
type GroupByDef
|
||||
} from '$lib/components/ResourceList.svelte';
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
import type { FileItem, FolderItem, SearchResults, SortBy } from '$lib/api/types';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||
import { renameFolder, deleteFolder, getFolder, getFolderName } from '$lib/api/endpoints/folders';
|
||||
import {
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
dateBucket,
|
||||
sizeBucket
|
||||
} from '$lib/api/endpoints/favorites';
|
||||
import type { FileItem, FolderItem, SearchResourceItem, SortBy } from '$lib/api/types';
|
||||
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
||||
import { folderAccessCached, probeFolderAccess } from '$lib/utils/folderAccess';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display';
|
||||
|
||||
const query = $derived(page.url.searchParams.get('q') ?? '');
|
||||
|
||||
let results = $state<SearchResults | null>(null);
|
||||
// Folder scope encoded in the URL as `?in=<uuid>` so refresh survives —
|
||||
// pre-refactor the scope lived only in `filesStore.currentFolder`, which
|
||||
// resets to null on a hard reload. Ed hit this 2026-07-26: refresh
|
||||
// silently switched to "Everywhere" and disabled "This folder".
|
||||
// `AppShell` also encodes this param when the user searches from a
|
||||
// specific `/files/<uuid>`, so the round-trip is symmetric.
|
||||
//
|
||||
// `?in=` is *sticky* — it stays in the URL even after the user picks
|
||||
// "Everywhere" — so they can toggle back to "This folder" without
|
||||
// losing the reference. The active-scope flip is signalled separately
|
||||
// by `?scope=all` (absent = default to folder when `in` is present).
|
||||
const scopeFolderId = $derived(page.url.searchParams.get('in') ?? null);
|
||||
const scopeOverride = $derived(page.url.searchParams.get('scope'));
|
||||
// A concrete folder for the scope: prefer the URL param (durable across
|
||||
// refresh), fall back to whatever folder the Files view has open in
|
||||
// this session (the pre-URL-param behaviour).
|
||||
const effectiveFolder = $derived(scopeFolderId ?? filesStore.currentFolder ?? null);
|
||||
|
||||
// Breadcrumb — resolves the scope folder's display name so the sticky
|
||||
// header can show WHICH directory the results come from ("we have no
|
||||
// clue on which directory the search was done" — Ed 2026-07-26).
|
||||
// `getFolderName` is a sync cache peek populated by prior /files
|
||||
// listings; on a cold /search deep-link we fall back to `getFolder`
|
||||
// once, cache the result, and re-render. `$state<string | null>`
|
||||
// with a `$effect` primer avoids blocking the initial render.
|
||||
let scopeFolderName = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
if (!scopeFolderId) {
|
||||
scopeFolderName = null;
|
||||
return;
|
||||
}
|
||||
const cached = getFolderName(scopeFolderId);
|
||||
if (cached) {
|
||||
scopeFolderName = cached;
|
||||
return;
|
||||
}
|
||||
// Cold deep-link — fire once, populate on resolve. If it fails
|
||||
// (folder was deleted, caller lost Read), keep name null so the
|
||||
// breadcrumb just falls back to a short UUID.
|
||||
const id = scopeFolderId;
|
||||
void getFolder(id)
|
||||
.then((f) => {
|
||||
if (scopeFolderId === id) scopeFolderName = f.name;
|
||||
})
|
||||
.catch(() => {
|
||||
if (scopeFolderId === id) scopeFolderName = id.slice(0, 8);
|
||||
});
|
||||
});
|
||||
|
||||
// Rendered as `<h1 class="page-title">` inside ResourceList. Bakes the
|
||||
// query time / result count into the title string because ResourceList
|
||||
// doesn't (yet) expose a subtitle slot, and lifting the "Xms" affordance
|
||||
// into the title is small enough not to warrant one. The bare "Search"
|
||||
// label is the empty-query fallback for the browser-tab title path
|
||||
// (`<svelte:head>`); when there IS a query, ResourceList never mounts
|
||||
// with this string — the `else` branch below owns the render.
|
||||
const resultsTitle = $derived.by(() => {
|
||||
// No query yet — the fresh-page tab title.
|
||||
if (!query) return t('search.title', 'Search');
|
||||
// Still loading the first response — show just the "Results for X"
|
||||
// half. `queryTimeMs` becomes non-null the moment the backend answers.
|
||||
if (queryTimeMs == null) return t('search.results_for', { q: query }, 'Results for “{{q}}”');
|
||||
// Full summary keys concatenate the whole title in one string so
|
||||
// translators can rearrange the `·` join, punctuation, and word
|
||||
// order (Ed's 2026-07-26 i18n pass — pre-refactor the JS
|
||||
// concatenated the "Results for" head and "N results in Xms" tail
|
||||
// with a hard-coded ` · ` separator, which is awkward for RTL
|
||||
// locales and CJK spacing).
|
||||
if (total != null) {
|
||||
return t(
|
||||
'search.results_summary',
|
||||
{ q: query, n: total, ms: queryTimeMs },
|
||||
'Results for “{{q}}” · {{n}} results in {{ms}} ms'
|
||||
);
|
||||
}
|
||||
return t(
|
||||
'search.results_summary_no_total',
|
||||
{ q: query, ms: queryTimeMs },
|
||||
'Results for “{{q}}” · {{ms}} ms'
|
||||
);
|
||||
});
|
||||
|
||||
// Accumulated pages of results. Cursor pagination — a filter/sort/query
|
||||
// change resets to page 1, infinite scroll appends via `loadMore()`.
|
||||
let raw = $state<SearchResourceItem[]>([]);
|
||||
let cursor = $state<string | undefined>(undefined);
|
||||
let queryTimeMs = $state<number | null>(null);
|
||||
let total = $state<number | undefined>(undefined);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let sortBy = $state<SortBy>('relevance');
|
||||
// Scope: search everywhere, or within the folder last open in the files view.
|
||||
// Default to the current folder when one is set (and we're not in the trash
|
||||
// section), mirroring the legacy searchView behaviour.
|
||||
let scope = $state<'all' | 'folder'>(
|
||||
filesStore.currentFolder && filesStore.section !== 'trash' ? 'folder' : 'all'
|
||||
// Sort dimension + direction are surfaced through ResourceList's
|
||||
// built-in group-by selector (DisplayModeControls) rather than a
|
||||
// standalone sort `<select>`, so /search matches /favorites /
|
||||
// /recent / /trash. `groupBy` = active group-by key from the
|
||||
// `groupBys` list below; `reversed` = the asc/desc toggle.
|
||||
let groupBy = $state('');
|
||||
let reversed = $state(false);
|
||||
// Derived from the URL — URL is the single source of truth so refresh,
|
||||
// bookmarks and shared links all restore the same scope. Rules:
|
||||
// `?scope=all` → 'all' (explicit "Everywhere" toggle;
|
||||
// `in=` may still be present as
|
||||
// a sticky fallback for the
|
||||
// "This folder" button)
|
||||
// `?in=<uuid>` (no override) → 'folder'
|
||||
// neither → 'all'
|
||||
const scope = $derived<'all' | 'folder'>(
|
||||
scopeOverride === 'all' ? 'all' : effectiveFolder && scopeFolderId ? 'folder' : 'all'
|
||||
);
|
||||
function setScope(next: 'all' | 'folder') {
|
||||
// Build the query string by hand — Svelte's lint flags mutating a
|
||||
// stdlib `URLSearchParams`, and we don't need reactivity here.
|
||||
//
|
||||
// Key point (Ed's 2026-07-26 UX ask): the `in=` param is preserved
|
||||
// even when switching to "Everywhere" so "This folder" stays
|
||||
// clickable and remembers WHICH folder. The active-scope flip
|
||||
// rides on `scope=all` instead.
|
||||
const parts: string[] = [];
|
||||
if (query) parts.push(`q=${encodeURIComponent(query)}`);
|
||||
// Sticky `in=`: keep whatever's already in the URL, or seed it
|
||||
// from filesStore when the user first pins "This folder" from a
|
||||
// fresh /search visit.
|
||||
const stickyFolder = scopeFolderId ?? (next === 'folder' ? filesStore.currentFolder : null);
|
||||
if (stickyFolder) {
|
||||
parts.push(`in=${encodeURIComponent(stickyFolder)}`);
|
||||
}
|
||||
if (next === 'all' && stickyFolder) {
|
||||
// Only meaningful when there's a folder to override — otherwise
|
||||
// the URL is "everywhere by default" and the flag would be noise.
|
||||
parts.push('scope=all');
|
||||
}
|
||||
const target = resolve(parts.length ? `/search?${parts.join('&')}` : '/search');
|
||||
// `replaceState: true` keeps the browser back-button meaningful —
|
||||
// scope changes are UI state, not navigation. `keepFocus: true`
|
||||
// keeps focus on whatever button the user just clicked.
|
||||
void goto(target, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Filters
|
||||
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
|
||||
@@ -115,31 +253,90 @@
|
||||
dateFilter = 'all';
|
||||
}
|
||||
|
||||
const SORTS: { v: SortBy; l: string }[] = [
|
||||
{ v: 'relevance', l: t('search.sort.relevance', 'Relevance') },
|
||||
{ v: 'name', l: t('search.sort.name_asc', 'Name A-Z') },
|
||||
{ v: 'name_desc', l: t('search.sort.name_desc', 'Name Z-A') },
|
||||
{ v: 'date_desc', l: t('search.sort.newest', 'Newest') },
|
||||
{ v: 'date', l: t('search.sort.oldest', 'Oldest') },
|
||||
{ v: 'size_desc', l: t('search.sort.largest', 'Largest') },
|
||||
{ v: 'size', l: t('search.sort.smallest', 'Smallest') }
|
||||
// ── Group / sort dimensions (shown in the DisplayModeControls dropdown) ──
|
||||
// Ed's 2026-07-26 spec: 4 options total —
|
||||
// • Relevance (default, flat) — search's native ranking
|
||||
// • Name (flat) — A-Z with the asc/desc toggle for Z-A
|
||||
// • Size (grouped) — bucketed via the shared `sizeBucket`
|
||||
// • Modified (grouped) — bucketed via the shared `dateBucket`
|
||||
// Omitting `bucketOf` = flat list (see the ResourceList interface).
|
||||
// `orderBy` values map 1:1 to the backend `SearchResourcesQuery.order_by`.
|
||||
// The asc/desc button binds to `reversed` and passes through to the
|
||||
// backend `reverse` flag.
|
||||
const groupBys: GroupByDef[] = [
|
||||
{
|
||||
key: '',
|
||||
label: t('search.sort.relevance', 'Relevance'),
|
||||
orderBy: 'relevance',
|
||||
icon: 'ranking-star'
|
||||
},
|
||||
{ key: 'name', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
||||
{
|
||||
key: 'size',
|
||||
label: t('groupby.size', 'Size'),
|
||||
orderBy: 'size',
|
||||
bucketOf: (item) => sizeBucket(isFile(item) ? item.size : null)
|
||||
},
|
||||
{
|
||||
key: 'modifiedAt',
|
||||
label: t('groupby.modifiedAt', 'Modified 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)
|
||||
}
|
||||
];
|
||||
|
||||
function orderByForGroup(): string {
|
||||
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'relevance';
|
||||
}
|
||||
|
||||
// Stale-response guard: rapid-fire query/filter/sort changes each start a
|
||||
// full recursive backend search; without the token a SLOW earlier response
|
||||
// could resolve after (and clobber) a newer one, and the superseded server
|
||||
// work ran to completion. The seq token keeps only the latest result; the
|
||||
// AbortController cancels the superseded request outright.
|
||||
// AbortController cancels the superseded request outright. The same token
|
||||
// invalidates any in-flight `loadMore()` when the query/filter changes so
|
||||
// its rows never append to a fresh result set.
|
||||
let runSeq = 0;
|
||||
let inflight: AbortController | null = null;
|
||||
|
||||
function currentQueryParams() {
|
||||
// Trash section searches are always global — there is no folder to
|
||||
// scope to. Otherwise the folder comes from the URL (`?in=<uuid>`)
|
||||
// via `scopeFolderId`, which survives a hard refresh and shared
|
||||
// links unlike `filesStore.currentFolder` (session-only, resets on
|
||||
// reload).
|
||||
const folderId =
|
||||
scope === 'folder' && filesStore.section !== 'trash'
|
||||
? (effectiveFolder ?? undefined)
|
||||
: undefined;
|
||||
return {
|
||||
recursive: true,
|
||||
sortBy: orderByForGroup() as SortBy,
|
||||
reverse: reversed,
|
||||
folderId,
|
||||
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
|
||||
...sizeBounds(sizeFilter),
|
||||
modifiedAfter: dateBound(dateFilter)
|
||||
};
|
||||
}
|
||||
|
||||
async function run(q: string) {
|
||||
const seq = ++runSeq;
|
||||
inflight?.abort();
|
||||
inflight = null;
|
||||
if (!q) {
|
||||
results = null;
|
||||
raw = [];
|
||||
cursor = undefined;
|
||||
queryTimeMs = null;
|
||||
total = undefined;
|
||||
loading = false;
|
||||
error = null;
|
||||
return;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
@@ -147,22 +344,12 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Trash section searches are always global — there is no folder to scope to.
|
||||
const folderId =
|
||||
scope === 'folder' && filesStore.section !== 'trash'
|
||||
? (filesStore.currentFolder ?? undefined)
|
||||
: undefined;
|
||||
const fresh = await searchFiles(q, {
|
||||
recursive: true,
|
||||
sortBy,
|
||||
folderId,
|
||||
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
|
||||
...sizeBounds(sizeFilter),
|
||||
modifiedAfter: dateBound(dateFilter),
|
||||
signal: ctl.signal
|
||||
});
|
||||
const fresh = await searchResources(q, { ...currentQueryParams(), signal: ctl.signal });
|
||||
if (seq !== runSeq) return; // superseded while awaiting
|
||||
results = fresh;
|
||||
raw = fresh.items;
|
||||
cursor = fresh.next_cursor;
|
||||
queryTimeMs = fresh.query_time_ms;
|
||||
total = fresh.total;
|
||||
} catch (e) {
|
||||
// An aborted request is not an error — a newer run owns the UI.
|
||||
if (seq !== runSeq || ctl.signal.aborted) return;
|
||||
@@ -172,31 +359,225 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(resolve(`/files/${folder.id}`));
|
||||
async function loadMore() {
|
||||
if (!cursor || loading) return;
|
||||
// Snapshot the current seq — if a filter/sort/query change bumps
|
||||
// `runSeq` while we're awaiting, we drop this page on the floor
|
||||
// (its rows belong to a stale filter set).
|
||||
const seq = runSeq;
|
||||
const ctl = new AbortController();
|
||||
// Don't overwrite `inflight` — that belongs to `run()` and lets a
|
||||
// query change abort a page-1 fetch. A concurrent loadMore is
|
||||
// harmless: at most one appends because of the seq guard.
|
||||
loading = true;
|
||||
try {
|
||||
const nextPage = await searchResources(query, {
|
||||
...currentQueryParams(),
|
||||
cursor,
|
||||
signal: ctl.signal
|
||||
});
|
||||
if (seq !== runSeq) return;
|
||||
raw = [...raw, ...nextPage.items];
|
||||
cursor = nextPage.next_cursor;
|
||||
// total/query_time refresh — the server recomputes both per page.
|
||||
total = nextPage.total ?? total;
|
||||
} catch (e) {
|
||||
if (seq !== runSeq || ctl.signal.aborted) return;
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
if (seq === runSeq) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFile(file: FileItem) {
|
||||
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
|
||||
// Feed ResourceList the raw resource objects — that's the shared
|
||||
// `{FileItem | FolderItem}[]` shape every other /*/resources page
|
||||
// uses. Search-specific meta (score/snippet/via) is not surfaced
|
||||
// today; a follow-up commit will extend ResourceList with an
|
||||
// optional per-row meta slot for the snippet + a "matched: content"
|
||||
// chip. Score isn't worth surfacing to end users.
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
|
||||
// ── Per-row actions ──────────────────────────────────────────────────
|
||||
// Match the shape of `/favorites` + `/recent`: files open inline in
|
||||
// the shared FileViewer (was new-tab pre-refactor — a discontinuity
|
||||
// with the rest of the app), folders navigate. Share + move + delete
|
||||
// all reuse the same lazy dialogs.
|
||||
let viewerOpen = $state(false);
|
||||
let viewerFile = $state<FileItem | null>(null);
|
||||
let moveOpen = $state(false);
|
||||
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
|
||||
let shareOpen = $state(false);
|
||||
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
|
||||
const fileViewer = lazyComponent(() => import('$lib/components/FileViewer.svelte'));
|
||||
const moveDialog = lazyComponent(() => import('$lib/components/MoveDialog.svelte'));
|
||||
const shareDialog = lazyComponent(() => import('$lib/components/ShareDialog.svelte'));
|
||||
$effect(() => {
|
||||
if (viewerOpen) void fileViewer.load();
|
||||
if (moveOpen) void moveDialog.load();
|
||||
if (shareOpen) void shareDialog.load();
|
||||
});
|
||||
|
||||
function kindOf(item: FileItem | FolderItem): 'file' | 'folder' {
|
||||
return isFile(item) ? 'file' : 'folder';
|
||||
}
|
||||
|
||||
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
|
||||
function open(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) {
|
||||
goto(resolve(`/files/${item.id}`));
|
||||
return;
|
||||
}
|
||||
viewerFile = item;
|
||||
viewerOpen = true;
|
||||
}
|
||||
|
||||
// Flatten folders + files into one list so the results render through a single
|
||||
// windowed list (only the visible rows hit the DOM, even for 100s of hits).
|
||||
type SearchEntry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
|
||||
const entries = $derived<SearchEntry[]>(
|
||||
results
|
||||
? [
|
||||
...results.folders.map((folder) => ({ kind: 'folder' as const, folder })),
|
||||
...results.files.map((file) => ({ kind: 'file' as const, file }))
|
||||
]
|
||||
: []
|
||||
);
|
||||
// Files carry `folder_id`, folders carry `parent_id`; both are nullable
|
||||
// at drive roots. Null → no meaningful parent to open. Mirrors the
|
||||
// same helper in /favorites and /recent.
|
||||
function parentFolderId(item: FileItem | FolderItem): string | null {
|
||||
return isFile(item) ? item.folder_id : item.parent_id;
|
||||
}
|
||||
|
||||
async function toggleFavorite(item: FileItem | FolderItem) {
|
||||
const kind = kindOf(item);
|
||||
try {
|
||||
if (item.is_favorite) {
|
||||
await removeFavorite(kind, item.id);
|
||||
} else {
|
||||
await addFavorite(kind, item.id);
|
||||
}
|
||||
// Optimistic in-place update — a full reload would jump the
|
||||
// user out of their scroll position on an infinite-scroll
|
||||
// page. Mutating the raw envelope entry updates the derived
|
||||
// `items` array and ResourceList's star widget flips.
|
||||
const idx = raw.findIndex((it) => it.resource.id === item.id);
|
||||
if (idx !== -1) {
|
||||
raw[idx] = {
|
||||
...raw[idx],
|
||||
resource: { ...raw[idx].resource, is_favorite: !item.is_favorite }
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function openShareDialog(item: FileItem | FolderItem) {
|
||||
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
shareOpen = true;
|
||||
}
|
||||
|
||||
function openMoveDialog(item: FileItem | FolderItem) {
|
||||
moveTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
moveOpen = true;
|
||||
}
|
||||
|
||||
function downloadItem(item: FileItem | FolderItem) {
|
||||
if (!isFile(item)) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = fileDownloadUrl(item.id);
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
async function rename(item: FileItem | FolderItem) {
|
||||
const name = await promptDialog({
|
||||
title: t('common.rename', 'Rename'),
|
||||
defaultValue: item.name,
|
||||
confirmText: t('common.rename', 'Rename')
|
||||
});
|
||||
if (!name || name === item.name) return;
|
||||
try {
|
||||
if (isFile(item)) await renameFile(item.id, name);
|
||||
else await renameFolder(item.id, name);
|
||||
// Update the row in place so the user keeps their scroll
|
||||
// position instead of jumping back to page 1.
|
||||
const idx = raw.findIndex((it) => it.resource.id === item.id);
|
||||
if (idx !== -1) {
|
||||
raw[idx] = { ...raw[idx], resource: { ...raw[idx].resource, name } };
|
||||
}
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: FileItem | FolderItem) {
|
||||
const ok = await confirmDialog({
|
||||
title: t('common.delete', 'Delete'),
|
||||
message: t('files.confirm_delete', { name: item.name }, 'Delete "{{name}}"?'),
|
||||
confirmText: t('common.delete', 'Delete'),
|
||||
danger: true
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (isFile(item)) await deleteFile(item.id);
|
||||
else await deleteFolder(item.id);
|
||||
raw = raw.filter((it) => it.resource.id !== item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
|
||||
function openParent(item: FileItem | FolderItem) {
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) goto(resolve(`/files/${pid}`));
|
||||
}
|
||||
|
||||
// Canonical context-menu order — matches `/favorites` and `/files`
|
||||
// (Open parent, Download, Share, Move, Rename, Delete) so users
|
||||
// don't have to relearn the menu when switching sections.
|
||||
const contextActions: ContextAction[] = [
|
||||
{
|
||||
key: 'open_parent',
|
||||
label: t('files.open_parent', 'Open parent folder'),
|
||||
icon: 'folder-open',
|
||||
// Hidden only when there is literally no parent (drive-root
|
||||
// folders where `parent_id === null`). Otherwise stay visible
|
||||
// and disable when the caller lacks Read on the parent — a
|
||||
// grey entry reads as "you can't do this here" rather than
|
||||
// "the option is missing." `folderAccessCached` returns
|
||||
// true/false/undefined; disable only on explicit `false`.
|
||||
visible: (item) => parentFolderId(item) !== null,
|
||||
disabled: (item) => {
|
||||
const pid = parentFolderId(item);
|
||||
return pid === null || folderAccessCached(pid) === false;
|
||||
},
|
||||
run: openParent
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('common.download', 'Download'),
|
||||
icon: 'download',
|
||||
visible: isFile,
|
||||
run: downloadItem
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: t('files.share', 'Share'),
|
||||
icon: 'share-alt',
|
||||
run: openShareDialog
|
||||
},
|
||||
{
|
||||
key: 'move',
|
||||
label: t('files.move', 'Move'),
|
||||
icon: 'arrows-alt',
|
||||
run: openMoveDialog
|
||||
},
|
||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('common.delete', 'Delete'),
|
||||
icon: 'trash',
|
||||
danger: true,
|
||||
run: remove
|
||||
}
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
// re-run when query, sort, scope, or any filter changes
|
||||
void sortBy;
|
||||
// re-run when query, sort/direction, scope, or any filter changes
|
||||
void groupBy;
|
||||
void reversed;
|
||||
void scope;
|
||||
void typeFilter;
|
||||
void sizeFilter;
|
||||
@@ -211,13 +592,12 @@
|
||||
* browser's default handler navigates to the dropped file — the tab
|
||||
* REPLACES the app with the file itself, which is data-loss-esque
|
||||
* (user loses their in-progress search + any unsaved UI state).
|
||||
* The `ResourceList`-based views (`/photos`, `/shared`, `/trash`, …)
|
||||
* already fire a "wrong drop zone" toast via their `.rl-root`
|
||||
* wrapper when `enableSystemDrop` is false — this handler brings
|
||||
* `/search` to the same contract, and covers the whole viewport
|
||||
* (not just a list surface) so drops on the sticky header /
|
||||
* result-card margins are caught too. Same toast copy + "Go to
|
||||
* Files" action as `ResourceList` for a consistent recovery UX.
|
||||
* ResourceList already fires a "wrong drop zone" toast via its
|
||||
* `.rl-root` wrapper when `enableSystemDrop` is false, but that
|
||||
* covers only the list area — this handler covers the whole
|
||||
* viewport (sticky header, empty-state screen, gaps around the
|
||||
* list). Same toast copy + "Go to Files" action as ResourceList
|
||||
* for a consistent recovery UX.
|
||||
*/
|
||||
function onWindowDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer?.types?.includes('Files')) return;
|
||||
@@ -258,38 +638,80 @@
|
||||
|
||||
<svelte:window ondragover={onWindowDragOver} ondrop={onWindowDrop} />
|
||||
|
||||
<div class="page-sticky-header search-head">
|
||||
<h1 class="page-title">
|
||||
{#if query}{t('search.results_for', { q: query }, 'Results for “{{q}}”')}{:else}{t(
|
||||
'search.title',
|
||||
'Search'
|
||||
)}{/if}
|
||||
{#if results?.query_time_ms != null}
|
||||
<span class="search-time">({results.query_time_ms} ms)</span>
|
||||
{/if}
|
||||
</h1>
|
||||
{#if query}
|
||||
<div class="search-controls">
|
||||
{#if filesStore.currentFolder}
|
||||
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'all'}
|
||||
data-testid="search-scope-all-btn"
|
||||
onclick={() => (scope = 'all')}
|
||||
>
|
||||
{t('search.everywhere', 'Everywhere')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
data-testid="search-scope-folder-btn"
|
||||
onclick={() => (scope = 'folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !query}
|
||||
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
|
||||
{:else}
|
||||
<ResourceList
|
||||
title={resultsTitle}
|
||||
{items}
|
||||
{loading}
|
||||
{error}
|
||||
emptyIcon="search"
|
||||
emptyText={t('search.no_results', 'No results found for this search')}
|
||||
hasMore={!!cursor}
|
||||
onloadmore={loadMore}
|
||||
showPath
|
||||
showViewToggle
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
onshared={openShareDialog}
|
||||
{contextActions}
|
||||
{groupBys}
|
||||
bind:groupBy
|
||||
bind:reversed
|
||||
onreload={() => {
|
||||
// Group-by or asc/desc changed — reset pagination and let the
|
||||
// `$effect` above pick up the new state on its next tick (it's
|
||||
// already reactive on `groupBy` + `reversed`). Explicit
|
||||
// `cursor = undefined` here just guarantees the in-flight
|
||||
// `next_cursor` from the OLD sort can't feed a stale page 2.
|
||||
cursor = undefined;
|
||||
}}
|
||||
menuPrepare={async (item) => {
|
||||
// Lazy folder-access probe — fires only when the user opens
|
||||
// the context menu on a row, not proactively for every row on
|
||||
// load. Cached in the LRU (see `folderAccess.ts`) so
|
||||
// subsequent right-clicks on the same folder are instant.
|
||||
// Mirrors /favorites + /recent so the "Open parent folder"
|
||||
// entry lands enabled/disabled without a "flash of enabled"
|
||||
// on first right-click.
|
||||
const pid = parentFolderId(item);
|
||||
if (pid) await probeFolderAccess(pid);
|
||||
}}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<!--
|
||||
Scope segment is always visible so the affordance is
|
||||
discoverable even from a cold `/search?q=…` load; when
|
||||
there's no `filesStore.currentFolder` (user typed the URL
|
||||
directly, or search bar navigation didn't carry a folder),
|
||||
"This folder" disables — clicking it wouldn't have a
|
||||
folder to scope to. Pre-refactor the whole segment was
|
||||
hidden in that case, which read as "the option was
|
||||
removed" (2026-07-26 UX feedback).
|
||||
-->
|
||||
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'all'}
|
||||
data-testid="search-scope-all-btn"
|
||||
onclick={() => setScope('all')}
|
||||
>
|
||||
{t('search.everywhere', 'Everywhere')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
data-testid="search-scope-folder-btn"
|
||||
disabled={!effectiveFolder}
|
||||
title={effectiveFolder
|
||||
? undefined
|
||||
: t('search.this_folder_disabled', 'Open a folder in Files to scope search to it')}
|
||||
onclick={() => setScope('folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={typeFilter}
|
||||
@@ -317,114 +739,117 @@
|
||||
{#each DATES as o (o.v)}<option value={o.v} data-testid={`search-date-${o.v}`}>{o.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sortBy}
|
||||
aria-label={t('search.sort_by', 'Sort by')}
|
||||
data-testid="search-sort-select"
|
||||
>
|
||||
{#each SORTS as s (s.v)}<option value={s.v} data-testid={`search-sort-${s.v}`}>{s.l}</option
|
||||
>{/each}
|
||||
</select>
|
||||
<!--
|
||||
NOTE: sort dimension + asc/desc live in ResourceList's
|
||||
built-in DisplayModeControls now (fed by `groupBys` +
|
||||
`bind:groupBy` + `bind:reversed` below), matching
|
||||
/favorites / /recent / /trash. The old
|
||||
`<select bind:value={sortBy}>` was removed with the
|
||||
`SORTS` array.
|
||||
-->
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" data-testid="search-clear-filters-btn" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet breadcrumb()}
|
||||
<!--
|
||||
Only render when the search is folder-scoped AND the URL
|
||||
param is present — the sticky "Home > Photos" cue answers
|
||||
the "which directory was this search done in?" question
|
||||
Ed raised 2026-07-26. Hidden for scope='all' (searching
|
||||
everywhere → no folder to breadcrumb) and for a fresh
|
||||
`/search?q=…` with no `in=` param.
|
||||
|
||||
{#if loading}
|
||||
<div class="search-loading">
|
||||
<Icon name="spinner" class="search-loading__spinner" />
|
||||
<h2 class="search-loading__text">
|
||||
{t('search.searching_for', { q: query }, 'Searching for “{{q}}”…')}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if error}
|
||||
<EmptyState title={error} error />
|
||||
{:else if !query}
|
||||
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
|
||||
{:else if isEmpty}
|
||||
<EmptyState icon="search" title={t('search.no_results', 'No results found for this search')} />
|
||||
{:else if results}
|
||||
<div class="files-container">
|
||||
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
|
||||
<div class="list-header">
|
||||
<div>{t('files.col_name', 'Name')}</div>
|
||||
<div>{t('files.col_path', 'Path')}</div>
|
||||
<div>{t('files.col_size', 'Size')}</div>
|
||||
<div>{t('files.col_modified', 'Modified')}</div>
|
||||
</div>
|
||||
Single-segment for now (Home icon + scope folder as a
|
||||
link). Full parent-chain walk is a follow-up; it needs
|
||||
stepping through `parent_id` via `getFolder`, which
|
||||
would be a second pass here.
|
||||
-->
|
||||
{#if scope === 'folder' && scopeFolderId}
|
||||
<nav class="breadcrumb" aria-label={t('breadcrumb.aria', 'Breadcrumb')}>
|
||||
<a
|
||||
href={resolve('/files')}
|
||||
class="breadcrumb-item breadcrumb-home breadcrumb-link"
|
||||
title={t('breadcrumb.home', 'Home')}
|
||||
data-testid="search-breadcrumb-home-link"
|
||||
>
|
||||
<Icon name="home" />
|
||||
</a>
|
||||
<span class="breadcrumb-separator">></span>
|
||||
<a
|
||||
href={resolve(`/files/${scopeFolderId}`)}
|
||||
class="breadcrumb-item breadcrumb-current breadcrumb-link"
|
||||
data-testid="search-breadcrumb-folder-link"
|
||||
>
|
||||
{scopeFolderName ?? '…'}
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet itemActions(item)}
|
||||
<!--
|
||||
Per-row "Open parent folder" quick-action — search results
|
||||
are context-poor by nature (the path column shows WHERE the
|
||||
match is, but jumping there takes an extra right-click on
|
||||
every other section). Surface it as a direct button so a
|
||||
single click navigates. Same `.btn-action` treatment as
|
||||
trash's Restore / Delete and recent's broom, so the row's
|
||||
action-cell keeps the visual rhythm shared across sections.
|
||||
Hidden when there's no meaningful parent (drive-root
|
||||
folders where `parent_id === null`).
|
||||
-->
|
||||
{#if parentFolderId(item) !== null}
|
||||
<button
|
||||
class="btn-action btn-action--hover"
|
||||
data-testid={`search-open-parent-btn-${item.id}`}
|
||||
title={t('files.open_parent', 'Open parent folder')}
|
||||
aria-label={t('files.open_parent', 'Open parent folder')}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openParent(item);
|
||||
}}
|
||||
>
|
||||
<Icon name="folder-open" />
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
{/if}
|
||||
|
||||
<VirtualList
|
||||
items={entries}
|
||||
rowHeight={56}
|
||||
key={(e) => (e.kind === 'folder' ? e.folder.id : e.file.id)}
|
||||
>
|
||||
{#snippet row(e)}
|
||||
{#if e.kind === 'folder'}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.folder.name}
|
||||
data-testid={e.folder.name}
|
||||
onclick={() => openFolder(e.folder)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
||||
<span>{e.folder.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{e.folder.path}</div>
|
||||
<div class="size-cell">—</div>
|
||||
<div class="date-cell">{formatDate(e.folder.modified_at)}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={e.file.name}
|
||||
data-testid={e.file.name}
|
||||
onclick={() => openFile(e.file)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon {fileIconKindClass(iconNameFromClass(e.file.icon_class))}"
|
||||
><Icon name={iconNameFromClass(e.file.icon_class)} /></span
|
||||
>
|
||||
<span>{e.file.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{e.file.path}</div>
|
||||
<div class="size-cell">{e.file.size != null ? formatBytes(e.file.size) : ''}</div>
|
||||
<div class="date-cell">{formatDate(e.file.modified_at)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</div>
|
||||
</div>
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
<FileViewer bind:open={viewerOpen} file={viewerFile} />
|
||||
{/if}
|
||||
{#if moveDialog.component}
|
||||
{@const MoveDialog = moveDialog.component}
|
||||
<MoveDialog
|
||||
bind:open={moveOpen}
|
||||
item={moveTarget}
|
||||
onmoved={() => {
|
||||
// A move can shift the row out of the current scope (`?in=<uuid>`)
|
||||
// or into it, and the SQL name-match count may change. Reload
|
||||
// page 1 rather than trying to patch state in place — search
|
||||
// state is already reactive on query/scope so a fresh `run()`
|
||||
// is cheap and correct.
|
||||
void run(query);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
{@const ShareDialog = shareDialog.component}
|
||||
<ShareDialog bind:open={shareOpen} item={shareTarget} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.search-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
/* Filter cluster lives inside ResourceList's action-bar snippet now,
|
||||
but the actual DOM is scoped to THIS component's <style> block —
|
||||
Svelte's scoped selectors still apply because these are declared
|
||||
with the elements they style below.
|
||||
|
||||
Every color/border here uses tokens; no raw values (Stylelint gate). */
|
||||
.sort-select {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -453,10 +878,9 @@
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.search-time {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--color-text-muted);
|
||||
.seg__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clear-filters {
|
||||
@@ -474,25 +898,4 @@
|
||||
.clear-filters:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-loading :global(.search-loading__spinner) {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-accent);
|
||||
animation: spin var(--spin-duration) linear infinite;
|
||||
}
|
||||
|
||||
.search-loading__text {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,10 +7,10 @@ const { goto, pageState } = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ goto }));
|
||||
vi.mock('$app/state', () => ({ page: pageState }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchFiles: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
|
||||
vi.mock('$lib/api/endpoints/files', () => ({ fileInlineUrl: () => '/in' }));
|
||||
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { searchResources } from '$lib/api/endpoints/search';
|
||||
import SearchPage from './+page.svelte';
|
||||
|
||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
@@ -18,13 +18,13 @@ const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
pageState.url = new URL('http://localhost/search?q=report');
|
||||
m(searchFiles).mockResolvedValue({ files: [], folders: [], total: 0 });
|
||||
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 0, total: 0 });
|
||||
});
|
||||
|
||||
it('runs a search from the q query parameter on mount', async () => {
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
expect(m(searchFiles).mock.calls[0][0]).toBe('report');
|
||||
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||
expect(m(searchResources).mock.calls[0][0]).toBe('report');
|
||||
});
|
||||
|
||||
it('does not search when there is no query', async () => {
|
||||
@@ -32,12 +32,12 @@ it('does not search when there is no query', async () => {
|
||||
render(SearchPage);
|
||||
// Give the reactive effect a tick to (not) fire.
|
||||
await Promise.resolve();
|
||||
expect(searchFiles).not.toHaveBeenCalled();
|
||||
expect(searchResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a search error', async () => {
|
||||
m(searchFiles).mockRejectedValue(new Error('search boom'));
|
||||
m(searchResources).mockRejectedValue(new Error('search boom'));
|
||||
render(SearchPage);
|
||||
await waitFor(() => expect(searchFiles).toHaveBeenCalled());
|
||||
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText('search boom')).toBeTruthy());
|
||||
});
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "أنشئ بواسطة",
|
||||
"col_opened": "افتُح",
|
||||
"col_path": "الموقع",
|
||||
"new_elements": "عناصر جديدة"
|
||||
"new_elements": "عناصر جديدة",
|
||||
"open_parent": "فتح المجلد الأصلي",
|
||||
"move": "نقل",
|
||||
"favorite": "إضافة إلى المفضلة",
|
||||
"unfavorite": "إزالة من المفضلة",
|
||||
"open": "فتح",
|
||||
"download_zip": "تنزيل كملف مضغوط"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "إعادة تسمية المجلد",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "الحجم",
|
||||
"title": "بحث",
|
||||
"type": {
|
||||
"audio": "صوت"
|
||||
"audio": "صوت",
|
||||
"all": "جميع الأنواع",
|
||||
"image": "الصور",
|
||||
"video": "مقاطع الفيديو",
|
||||
"document": "المستندات",
|
||||
"archive": "الأرشيفات"
|
||||
},
|
||||
"type_label": "النوع"
|
||||
"type_label": "النوع",
|
||||
"sort": {
|
||||
"relevance": "الصلة"
|
||||
},
|
||||
"results_for": "نتائج البحث عن \"{{q}}\"",
|
||||
"results_summary": "نتائج البحث عن \"{{q}}\" · {{n}} نتيجة في {{ms}} مللي ثانية",
|
||||
"results_summary_no_total": "نتائج البحث عن \"{{q}}\" · {{ms}} مللي ثانية",
|
||||
"everywhere": "في كل مكان",
|
||||
"this_folder": "هذا المجلد",
|
||||
"this_folder_disabled": "افتح مجلدًا في الملفات لقصر البحث عليه",
|
||||
"scope": "النطاق",
|
||||
"date_label": "التاريخ",
|
||||
"sort_by": "الترتيب حسب",
|
||||
"clear_filters": "مسح الفلاتر",
|
||||
"searching_for": "البحث عن \"{{q}}\"…",
|
||||
"no_results": "لم يتم العثور على نتائج لهذا البحث",
|
||||
"prompt": "اكتب استعلامًا في شريط البحث أعلاه.",
|
||||
"size": {
|
||||
"all": "أي حجم",
|
||||
"small": "< 1 ميغابايت",
|
||||
"medium": "1–100 ميغابايت",
|
||||
"large": "> 100 ميغابايت"
|
||||
},
|
||||
"date": {
|
||||
"all": "في أي وقت",
|
||||
"day": "آخر 24 ساعة",
|
||||
"week": "الأسبوع الماضي",
|
||||
"month": "الشهر الماضي",
|
||||
"year": "العام الماضي"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "مجلدات"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Erstellt von",
|
||||
"col_opened": "Geöffnet",
|
||||
"col_path": "Speicherort",
|
||||
"new_elements": "Neue Elemente"
|
||||
"new_elements": "Neue Elemente",
|
||||
"open_parent": "Übergeordneten Ordner öffnen",
|
||||
"move": "Verschieben",
|
||||
"favorite": "Zu Favoriten hinzufügen",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"open": "Öffnen",
|
||||
"download_zip": "Als ZIP herunterladen"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Größe",
|
||||
"title": "Suchen",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Alle Typen",
|
||||
"image": "Bilder",
|
||||
"video": "Videos",
|
||||
"document": "Dokumente",
|
||||
"archive": "Archive"
|
||||
},
|
||||
"type_label": "Typ"
|
||||
"type_label": "Typ",
|
||||
"sort": {
|
||||
"relevance": "Relevanz"
|
||||
},
|
||||
"results_for": "Ergebnisse für „{{q}}\"",
|
||||
"results_summary": "Ergebnisse für „{{q}}\" · {{n}} Ergebnisse in {{ms}} ms",
|
||||
"results_summary_no_total": "Ergebnisse für „{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "Überall",
|
||||
"this_folder": "Dieser Ordner",
|
||||
"this_folder_disabled": "Öffnen Sie einen Ordner in Dateien, um die Suche darauf zu beschränken",
|
||||
"scope": "Bereich",
|
||||
"date_label": "Datum",
|
||||
"sort_by": "Sortieren nach",
|
||||
"clear_filters": "Filter zurücksetzen",
|
||||
"searching_for": "Suche nach „{{q}}\"…",
|
||||
"no_results": "Keine Ergebnisse für diese Suche gefunden",
|
||||
"prompt": "Geben Sie oben eine Suchanfrage ein.",
|
||||
"size": {
|
||||
"all": "Beliebige Größe",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "Beliebiger Zeitraum",
|
||||
"day": "Letzte 24 Stunden",
|
||||
"week": "Letzte Woche",
|
||||
"month": "Letzter Monat",
|
||||
"year": "Letztes Jahr"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Ordner"
|
||||
|
||||
@@ -1576,7 +1576,10 @@
|
||||
"image": "Images",
|
||||
"video": "Videos"
|
||||
},
|
||||
"type_label": "Type"
|
||||
"type_label": "Type",
|
||||
"results_summary": "Results for “{{q}}” · {{n}} results in {{ms}} ms",
|
||||
"results_summary_no_total": "Results for “{{q}}” · {{ms}} ms",
|
||||
"this_folder_disabled": "Open a folder in Files to scope search to it"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Language"
|
||||
|
||||
@@ -389,7 +389,13 @@
|
||||
"col_created_by": "Creado por",
|
||||
"col_opened": "Abierto",
|
||||
"col_path": "Ubicación",
|
||||
"new_elements": "Nuevos elementos"
|
||||
"new_elements": "Nuevos elementos",
|
||||
"open_parent": "Abrir carpeta principal",
|
||||
"move": "Mover",
|
||||
"favorite": "Añadir a favoritos",
|
||||
"unfavorite": "Quitar de favoritos",
|
||||
"open": "Abrir",
|
||||
"download_zip": "Descargar como ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
@@ -1212,9 +1218,43 @@
|
||||
"size_label": "Tamaño",
|
||||
"title": "Buscar",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Todos los tipos",
|
||||
"image": "Imágenes",
|
||||
"video": "Vídeos",
|
||||
"document": "Documentos",
|
||||
"archive": "Archivos"
|
||||
},
|
||||
"type_label": "Tipo"
|
||||
"type_label": "Tipo",
|
||||
"sort": {
|
||||
"relevance": "Relevancia"
|
||||
},
|
||||
"results_for": "Resultados para \"{{q}}\"",
|
||||
"results_summary": "Resultados para \"{{q}}\" · {{n}} resultados en {{ms}} ms",
|
||||
"results_summary_no_total": "Resultados para \"{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "En cualquier lugar",
|
||||
"this_folder": "Esta carpeta",
|
||||
"this_folder_disabled": "Abre una carpeta en Archivos para limitar la búsqueda a ella",
|
||||
"scope": "Ámbito",
|
||||
"date_label": "Fecha",
|
||||
"sort_by": "Ordenar por",
|
||||
"clear_filters": "Borrar filtros",
|
||||
"searching_for": "Buscando \"{{q}}\"…",
|
||||
"no_results": "No se encontraron resultados para esta búsqueda",
|
||||
"prompt": "Escribe una consulta en la barra de búsqueda superior.",
|
||||
"size": {
|
||||
"all": "Cualquier tamaño",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "Cualquier momento",
|
||||
"day": "Últimas 24 horas",
|
||||
"week": "Última semana",
|
||||
"month": "Último mes",
|
||||
"year": "Último año"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Carpetas"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "ایجاد شده توسط",
|
||||
"col_opened": "باز شده",
|
||||
"col_path": "مکان",
|
||||
"new_elements": "موارد جدید"
|
||||
"new_elements": "موارد جدید",
|
||||
"open_parent": "باز کردن پوشه والد",
|
||||
"move": "انتقال",
|
||||
"favorite": "افزودن به علاقهمندیها",
|
||||
"unfavorite": "حذف از علاقهمندیها",
|
||||
"open": "باز کردن",
|
||||
"download_zip": "دانلود به صورت ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "تغییر نام پوشه",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "اندازه",
|
||||
"title": "جستوجو",
|
||||
"type": {
|
||||
"audio": "صوتی"
|
||||
"audio": "صوتی",
|
||||
"all": "همه انواع",
|
||||
"image": "تصاویر",
|
||||
"video": "ویدیوها",
|
||||
"document": "اسناد",
|
||||
"archive": "بایگانیها"
|
||||
},
|
||||
"type_label": "نوع"
|
||||
"type_label": "نوع",
|
||||
"sort": {
|
||||
"relevance": "مرتبط بودن"
|
||||
},
|
||||
"results_for": "نتایج جستجو برای «{{q}}»",
|
||||
"results_summary": "نتایج جستجو برای «{{q}}» · {{n}} نتیجه در {{ms}} میلیثانیه",
|
||||
"results_summary_no_total": "نتایج جستجو برای «{{q}}» · {{ms}} میلیثانیه",
|
||||
"everywhere": "همه جا",
|
||||
"this_folder": "این پوشه",
|
||||
"this_folder_disabled": "پوشهای را در پروندهها باز کنید تا جستجو به آن محدود شود",
|
||||
"scope": "محدوده",
|
||||
"date_label": "تاریخ",
|
||||
"sort_by": "مرتبسازی بر اساس",
|
||||
"clear_filters": "پاک کردن فیلترها",
|
||||
"searching_for": "در حال جستجوی «{{q}}»…",
|
||||
"no_results": "نتیجهای برای این جستجو یافت نشد",
|
||||
"prompt": "در نوار جستجوی بالا، عبارتی را وارد کنید.",
|
||||
"size": {
|
||||
"all": "هر اندازه",
|
||||
"small": "< 1 مگابایت",
|
||||
"medium": "1–100 مگابایت",
|
||||
"large": "> 100 مگابایت"
|
||||
},
|
||||
"date": {
|
||||
"all": "هر زمان",
|
||||
"day": "24 ساعت گذشته",
|
||||
"week": "هفته گذشته",
|
||||
"month": "ماه گذشته",
|
||||
"year": "سال گذشته"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "پوشهها"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Créé par",
|
||||
"col_opened": "Ouvert",
|
||||
"col_path": "Emplacement",
|
||||
"new_elements": "Nouveaux éléments"
|
||||
"new_elements": "Nouveaux éléments",
|
||||
"open_parent": "Ouvrir le dossier parent",
|
||||
"move": "Déplacer",
|
||||
"favorite": "Ajouter aux favoris",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"open": "Ouvrir",
|
||||
"download_zip": "Télécharger en ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renommer le dossier",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Taille",
|
||||
"title": "Rechercher",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Tous les types",
|
||||
"image": "Images",
|
||||
"video": "Vidéos",
|
||||
"document": "Documents",
|
||||
"archive": "Archives"
|
||||
},
|
||||
"type_label": "Type"
|
||||
"type_label": "Type",
|
||||
"sort": {
|
||||
"relevance": "Pertinence"
|
||||
},
|
||||
"results_for": "Résultats pour « {{q}} »",
|
||||
"results_summary": "Résultats pour « {{q}} » · {{n}} résultats en {{ms}} ms",
|
||||
"results_summary_no_total": "Résultats pour « {{q}} » · {{ms}} ms",
|
||||
"everywhere": "Partout",
|
||||
"this_folder": "Ce dossier",
|
||||
"this_folder_disabled": "Ouvrez un dossier dans Fichiers pour y limiter la recherche",
|
||||
"scope": "Portée",
|
||||
"date_label": "Date",
|
||||
"sort_by": "Trier par",
|
||||
"clear_filters": "Effacer les filtres",
|
||||
"searching_for": "Recherche de « {{q}} »…",
|
||||
"no_results": "Aucun résultat pour cette recherche",
|
||||
"prompt": "Saisissez une requête dans la barre de recherche ci-dessus.",
|
||||
"size": {
|
||||
"all": "Toute taille",
|
||||
"small": "< 1 Mo",
|
||||
"medium": "1–100 Mo",
|
||||
"large": "> 100 Mo"
|
||||
},
|
||||
"date": {
|
||||
"all": "N'importe quand",
|
||||
"day": "Dernières 24 heures",
|
||||
"week": "Semaine dernière",
|
||||
"month": "Mois dernier",
|
||||
"year": "Année dernière"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Dossiers"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "द्वारा बनाया गया",
|
||||
"col_opened": "खोला गया",
|
||||
"col_path": "स्थान",
|
||||
"new_elements": "नए तत्व"
|
||||
"new_elements": "नए तत्व",
|
||||
"open_parent": "मूल फ़ोल्डर खोलें",
|
||||
"move": "स्थानांतरित करें",
|
||||
"favorite": "पसंदीदा में जोड़ें",
|
||||
"unfavorite": "पसंदीदा से हटाएं",
|
||||
"open": "खोलें",
|
||||
"download_zip": "ZIP के रूप में डाउनलोड करें"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "आकार",
|
||||
"title": "खोजें",
|
||||
"type": {
|
||||
"audio": "ऑडियो"
|
||||
"audio": "ऑडियो",
|
||||
"all": "सभी प्रकार",
|
||||
"image": "छवियाँ",
|
||||
"video": "वीडियो",
|
||||
"document": "दस्तावेज़",
|
||||
"archive": "संग्रह"
|
||||
},
|
||||
"type_label": "प्रकार"
|
||||
"type_label": "प्रकार",
|
||||
"sort": {
|
||||
"relevance": "प्रासंगिकता"
|
||||
},
|
||||
"results_for": "\"{{q}}\" के लिए परिणाम",
|
||||
"results_summary": "\"{{q}}\" के लिए परिणाम · {{n}} परिणाम {{ms}} ms में",
|
||||
"results_summary_no_total": "\"{{q}}\" के लिए परिणाम · {{ms}} ms",
|
||||
"everywhere": "कहीं भी",
|
||||
"this_folder": "यह फ़ोल्डर",
|
||||
"this_folder_disabled": "खोज को इसमें सीमित करने के लिए फ़ाइलें में एक फ़ोल्डर खोलें",
|
||||
"scope": "दायरा",
|
||||
"date_label": "दिनांक",
|
||||
"sort_by": "क्रमबद्ध करें",
|
||||
"clear_filters": "फ़िल्टर साफ़ करें",
|
||||
"searching_for": "\"{{q}}\" खोजा जा रहा है…",
|
||||
"no_results": "इस खोज के लिए कोई परिणाम नहीं मिला",
|
||||
"prompt": "ऊपर सर्च बार में एक क्वेरी टाइप करें।",
|
||||
"size": {
|
||||
"all": "कोई भी आकार",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "कोई भी समय",
|
||||
"day": "पिछले 24 घंटे",
|
||||
"week": "पिछला सप्ताह",
|
||||
"month": "पिछला महीना",
|
||||
"year": "पिछला वर्ष"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "फ़ोल्डर"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Creato da",
|
||||
"col_opened": "Aperto",
|
||||
"col_path": "Posizione",
|
||||
"new_elements": "Nuovi elementi"
|
||||
"new_elements": "Nuovi elementi",
|
||||
"open_parent": "Apri cartella principale",
|
||||
"move": "Sposta",
|
||||
"favorite": "Aggiungi ai preferiti",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"open": "Apri",
|
||||
"download_zip": "Scarica come ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Rinomina cartella",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Dimensione",
|
||||
"title": "Cerca",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Tutti i tipi",
|
||||
"image": "Immagini",
|
||||
"video": "Video",
|
||||
"document": "Documenti",
|
||||
"archive": "Archivi"
|
||||
},
|
||||
"type_label": "Tipo"
|
||||
"type_label": "Tipo",
|
||||
"sort": {
|
||||
"relevance": "Rilevanza"
|
||||
},
|
||||
"results_for": "Risultati per \"{{q}}\"",
|
||||
"results_summary": "Risultati per \"{{q}}\" · {{n}} risultati in {{ms}} ms",
|
||||
"results_summary_no_total": "Risultati per \"{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "Ovunque",
|
||||
"this_folder": "Questa cartella",
|
||||
"this_folder_disabled": "Apri una cartella in File per limitare la ricerca ad essa",
|
||||
"scope": "Ambito",
|
||||
"date_label": "Data",
|
||||
"sort_by": "Ordina per",
|
||||
"clear_filters": "Cancella filtri",
|
||||
"searching_for": "Ricerca di \"{{q}}\"…",
|
||||
"no_results": "Nessun risultato per questa ricerca",
|
||||
"prompt": "Digita una query nella barra di ricerca in alto.",
|
||||
"size": {
|
||||
"all": "Qualsiasi dimensione",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "In qualsiasi momento",
|
||||
"day": "Ultime 24 ore",
|
||||
"week": "Ultima settimana",
|
||||
"month": "Ultimo mese",
|
||||
"year": "Ultimo anno"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Cartelle"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "作成者",
|
||||
"col_opened": "アクセス日時",
|
||||
"col_path": "場所",
|
||||
"new_elements": "新しいアイテム"
|
||||
"new_elements": "新しいアイテム",
|
||||
"open_parent": "親フォルダーを開く",
|
||||
"move": "移動",
|
||||
"favorite": "お気に入りに追加",
|
||||
"unfavorite": "お気に入りから削除",
|
||||
"open": "開く",
|
||||
"download_zip": "ZIP形式でダウンロード"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "フォルダ名を変更",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "サイズ",
|
||||
"title": "検索",
|
||||
"type": {
|
||||
"audio": "音声"
|
||||
"audio": "音声",
|
||||
"all": "すべての種類",
|
||||
"image": "画像",
|
||||
"video": "動画",
|
||||
"document": "文書",
|
||||
"archive": "アーカイブ"
|
||||
},
|
||||
"type_label": "種類"
|
||||
"type_label": "種類",
|
||||
"sort": {
|
||||
"relevance": "関連度"
|
||||
},
|
||||
"results_for": "「{{q}}」の検索結果",
|
||||
"results_summary": "「{{q}}」の検索結果 · {{n}}件 · {{ms}} ms",
|
||||
"results_summary_no_total": "「{{q}}」の検索結果 · {{ms}} ms",
|
||||
"everywhere": "どこでも",
|
||||
"this_folder": "このフォルダー",
|
||||
"this_folder_disabled": "検索を限定するには、ファイル内でフォルダーを開いてください",
|
||||
"scope": "範囲",
|
||||
"date_label": "日付",
|
||||
"sort_by": "並べ替え",
|
||||
"clear_filters": "フィルターをクリア",
|
||||
"searching_for": "「{{q}}」を検索中…",
|
||||
"no_results": "この検索の結果が見つかりませんでした",
|
||||
"prompt": "上の検索バーにクエリを入力してください。",
|
||||
"size": {
|
||||
"all": "任意のサイズ",
|
||||
"small": "1 MB 未満",
|
||||
"medium": "1–100 MB",
|
||||
"large": "100 MB 超"
|
||||
},
|
||||
"date": {
|
||||
"all": "任意の期間",
|
||||
"day": "過去24時間",
|
||||
"week": "過去1週間",
|
||||
"month": "過去1ヶ月",
|
||||
"year": "過去1年"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "フォルダ"
|
||||
|
||||
@@ -1556,7 +1556,10 @@
|
||||
"smallest": "작은 순"
|
||||
},
|
||||
"sort_by": "정렬 기준",
|
||||
"this_folder": "이 폴더"
|
||||
"this_folder": "이 폴더",
|
||||
"results_summary": "\"{{q}}\"에 대한 검색 결과 · {{n}}개 결과, {{ms}} ms",
|
||||
"results_summary_no_total": "\"{{q}}\"에 대한 검색 결과 · {{ms}} ms",
|
||||
"this_folder_disabled": "검색을 제한하려면 파일에서 폴더를 여세요"
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "폴더",
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Gemaakt door",
|
||||
"col_opened": "Geopend",
|
||||
"col_path": "Locatie",
|
||||
"new_elements": "Nieuwe items"
|
||||
"new_elements": "Nieuwe items",
|
||||
"open_parent": "Bovenliggende map openen",
|
||||
"move": "Verplaatsen",
|
||||
"favorite": "Toevoegen aan favorieten",
|
||||
"unfavorite": "Verwijderen uit favorieten",
|
||||
"open": "Openen",
|
||||
"download_zip": "Downloaden als ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Map hernoemen",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Grootte",
|
||||
"title": "Zoeken",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Alle typen",
|
||||
"image": "Afbeeldingen",
|
||||
"video": "Video's",
|
||||
"document": "Documenten",
|
||||
"archive": "Archieven"
|
||||
},
|
||||
"type_label": "Type"
|
||||
"type_label": "Type",
|
||||
"sort": {
|
||||
"relevance": "Relevantie"
|
||||
},
|
||||
"results_for": "Resultaten voor \"{{q}}\"",
|
||||
"results_summary": "Resultaten voor \"{{q}}\" · {{n}} resultaten in {{ms}} ms",
|
||||
"results_summary_no_total": "Resultaten voor \"{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "Overal",
|
||||
"this_folder": "Deze map",
|
||||
"this_folder_disabled": "Open een map in Bestanden om de zoekopdracht ertoe te beperken",
|
||||
"scope": "Bereik",
|
||||
"date_label": "Datum",
|
||||
"sort_by": "Sorteren op",
|
||||
"clear_filters": "Filters wissen",
|
||||
"searching_for": "Zoeken naar \"{{q}}\"…",
|
||||
"no_results": "Geen resultaten gevonden voor deze zoekopdracht",
|
||||
"prompt": "Typ een zoekopdracht in de zoekbalk hierboven.",
|
||||
"size": {
|
||||
"all": "Elke grootte",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "Elk moment",
|
||||
"day": "Afgelopen 24 uur",
|
||||
"week": "Afgelopen week",
|
||||
"month": "Afgelopen maand",
|
||||
"year": "Afgelopen jaar"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Mappen"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Utworzone przez",
|
||||
"col_opened": "Otwarte",
|
||||
"col_path": "Lokalizacja",
|
||||
"new_elements": "Nowe elementy"
|
||||
"new_elements": "Nowe elementy",
|
||||
"open_parent": "Otwórz folder nadrzędny",
|
||||
"move": "Przenieś",
|
||||
"favorite": "Dodaj do ulubionych",
|
||||
"unfavorite": "Usuń z ulubionych",
|
||||
"open": "Otwórz",
|
||||
"download_zip": "Pobierz jako ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Zmień nazwę folderu",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Rozmiar",
|
||||
"title": "Szukaj",
|
||||
"type": {
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"all": "Wszystkie typy",
|
||||
"image": "Obrazy",
|
||||
"video": "Filmy",
|
||||
"document": "Dokumenty",
|
||||
"archive": "Archiwa"
|
||||
},
|
||||
"type_label": "Typ"
|
||||
"type_label": "Typ",
|
||||
"sort": {
|
||||
"relevance": "Trafność"
|
||||
},
|
||||
"results_for": "Wyniki dla \"{{q}}\"",
|
||||
"results_summary": "Wyniki dla \"{{q}}\" · {{n}} wyników w {{ms}} ms",
|
||||
"results_summary_no_total": "Wyniki dla \"{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "Wszędzie",
|
||||
"this_folder": "Ten folder",
|
||||
"this_folder_disabled": "Otwórz folder w Plikach, aby ograniczyć wyszukiwanie do niego",
|
||||
"scope": "Zakres",
|
||||
"date_label": "Data",
|
||||
"sort_by": "Sortuj według",
|
||||
"clear_filters": "Wyczyść filtry",
|
||||
"searching_for": "Wyszukiwanie „{{q}}\"…",
|
||||
"no_results": "Nie znaleziono wyników dla tego wyszukiwania",
|
||||
"prompt": "Wpisz zapytanie na pasku wyszukiwania powyżej.",
|
||||
"size": {
|
||||
"all": "Dowolny rozmiar",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "Kiedykolwiek",
|
||||
"day": "Ostatnie 24 godziny",
|
||||
"week": "Ostatni tydzień",
|
||||
"month": "Ostatni miesiąc",
|
||||
"year": "Ostatni rok"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Foldery"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Criado por",
|
||||
"col_opened": "Aberto",
|
||||
"col_path": "Localização",
|
||||
"new_elements": "Novos itens"
|
||||
"new_elements": "Novos itens",
|
||||
"open_parent": "Abrir pasta pai",
|
||||
"move": "Mover",
|
||||
"favorite": "Adicionar aos favoritos",
|
||||
"unfavorite": "Remover dos favoritos",
|
||||
"open": "Abrir",
|
||||
"download_zip": "Baixar como ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renomear pasta",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Tamanho",
|
||||
"title": "Pesquisar",
|
||||
"type": {
|
||||
"audio": "Áudio"
|
||||
"audio": "Áudio",
|
||||
"all": "Todos os tipos",
|
||||
"image": "Imagens",
|
||||
"video": "Vídeos",
|
||||
"document": "Documentos",
|
||||
"archive": "Arquivos"
|
||||
},
|
||||
"type_label": "Tipo"
|
||||
"type_label": "Tipo",
|
||||
"sort": {
|
||||
"relevance": "Relevância"
|
||||
},
|
||||
"results_for": "Resultados para \"{{q}}\"",
|
||||
"results_summary": "Resultados para \"{{q}}\" · {{n}} resultados em {{ms}} ms",
|
||||
"results_summary_no_total": "Resultados para \"{{q}}\" · {{ms}} ms",
|
||||
"everywhere": "Em qualquer lugar",
|
||||
"this_folder": "Esta pasta",
|
||||
"this_folder_disabled": "Abra uma pasta em Ficheiros para limitar a pesquisa a ela",
|
||||
"scope": "Âmbito",
|
||||
"date_label": "Data",
|
||||
"sort_by": "Ordenar por",
|
||||
"clear_filters": "Limpar filtros",
|
||||
"searching_for": "Pesquisando \"{{q}}\"…",
|
||||
"no_results": "Nenhum resultado encontrado para esta pesquisa",
|
||||
"prompt": "Digite uma consulta na barra de pesquisa acima.",
|
||||
"size": {
|
||||
"all": "Qualquer tamanho",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "A qualquer momento",
|
||||
"day": "Últimas 24 horas",
|
||||
"week": "Última semana",
|
||||
"month": "Último mês",
|
||||
"year": "Último ano"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Pastas"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "Создано",
|
||||
"col_opened": "Открыт",
|
||||
"col_path": "Расположение",
|
||||
"new_elements": "Новые элементы"
|
||||
"new_elements": "Новые элементы",
|
||||
"open_parent": "Открыть родительскую папку",
|
||||
"move": "Переместить",
|
||||
"favorite": "Добавить в избранное",
|
||||
"unfavorite": "Удалить из избранного",
|
||||
"open": "Открыть",
|
||||
"download_zip": "Скачать как ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Переименовать папку",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "Размер",
|
||||
"title": "Найти",
|
||||
"type": {
|
||||
"audio": "Аудио"
|
||||
"audio": "Аудио",
|
||||
"all": "Все типы",
|
||||
"image": "Изображения",
|
||||
"video": "Видео",
|
||||
"document": "Документы",
|
||||
"archive": "Архивы"
|
||||
},
|
||||
"type_label": "Тип"
|
||||
"type_label": "Тип",
|
||||
"sort": {
|
||||
"relevance": "Релевантность"
|
||||
},
|
||||
"results_for": "Результаты для «{{q}}»",
|
||||
"results_summary": "Результаты для «{{q}}» · {{n}} результатов за {{ms}} мс",
|
||||
"results_summary_no_total": "Результаты для «{{q}}» · {{ms}} мс",
|
||||
"everywhere": "Везде",
|
||||
"this_folder": "Эта папка",
|
||||
"this_folder_disabled": "Откройте папку в «Файлах», чтобы ограничить поиск ею",
|
||||
"scope": "Область",
|
||||
"date_label": "Дата",
|
||||
"sort_by": "Сортировать по",
|
||||
"clear_filters": "Очистить фильтры",
|
||||
"searching_for": "Поиск «{{q}}»…",
|
||||
"no_results": "По этому запросу ничего не найдено",
|
||||
"prompt": "Введите запрос в строке поиска выше.",
|
||||
"size": {
|
||||
"all": "Любой размер",
|
||||
"small": "< 1 МБ",
|
||||
"medium": "1–100 МБ",
|
||||
"large": "> 100 МБ"
|
||||
},
|
||||
"date": {
|
||||
"all": "Любое время",
|
||||
"day": "Последние 24 часа",
|
||||
"week": "Последняя неделя",
|
||||
"month": "Последний месяц",
|
||||
"year": "Последний год"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "Папки"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "建立者",
|
||||
"col_opened": "開啟日期",
|
||||
"col_path": "位置",
|
||||
"new_elements": "新項目"
|
||||
"new_elements": "新項目",
|
||||
"open_parent": "開啟上層資料夾",
|
||||
"move": "移動",
|
||||
"favorite": "加入我的最愛",
|
||||
"unfavorite": "從我的最愛移除",
|
||||
"open": "開啟",
|
||||
"download_zip": "下載為 ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重新命名資料夾",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "大小",
|
||||
"title": "搜尋",
|
||||
"type": {
|
||||
"audio": "音訊"
|
||||
"audio": "音訊",
|
||||
"all": "所有類型",
|
||||
"image": "圖片",
|
||||
"video": "影片",
|
||||
"document": "文件",
|
||||
"archive": "壓縮檔"
|
||||
},
|
||||
"type_label": "型別"
|
||||
"type_label": "型別",
|
||||
"sort": {
|
||||
"relevance": "相關性"
|
||||
},
|
||||
"results_for": "\"{{q}}\" 的搜尋結果",
|
||||
"results_summary": "\"{{q}}\" 的搜尋結果 · {{n}} 個結果 · {{ms}} 毫秒",
|
||||
"results_summary_no_total": "\"{{q}}\" 的搜尋結果 · {{ms}} 毫秒",
|
||||
"everywhere": "任何位置",
|
||||
"this_folder": "此資料夾",
|
||||
"this_folder_disabled": "開啟檔案中的資料夾以將搜尋限制在其中",
|
||||
"scope": "範圍",
|
||||
"date_label": "日期",
|
||||
"sort_by": "排序方式",
|
||||
"clear_filters": "清除篩選",
|
||||
"searching_for": "正在搜尋「{{q}}」…",
|
||||
"no_results": "此搜尋未找到結果",
|
||||
"prompt": "在上方搜尋列中輸入查詢內容。",
|
||||
"size": {
|
||||
"all": "任何大小",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "任何時間",
|
||||
"day": "過去 24 小時",
|
||||
"week": "過去一週",
|
||||
"month": "過去一個月",
|
||||
"year": "過去一年"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "資料夾"
|
||||
|
||||
@@ -384,7 +384,13 @@
|
||||
"col_created_by": "创建者",
|
||||
"col_opened": "打开日期",
|
||||
"col_path": "位置",
|
||||
"new_elements": "新元素"
|
||||
"new_elements": "新元素",
|
||||
"open_parent": "打开父文件夹",
|
||||
"move": "移动",
|
||||
"favorite": "添加到收藏",
|
||||
"unfavorite": "从收藏中移除",
|
||||
"open": "打开",
|
||||
"download_zip": "下载为 ZIP"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重命名文件夹",
|
||||
@@ -1197,9 +1203,43 @@
|
||||
"size_label": "大小",
|
||||
"title": "搜索",
|
||||
"type": {
|
||||
"audio": "音频"
|
||||
"audio": "音频",
|
||||
"all": "所有类型",
|
||||
"image": "图片",
|
||||
"video": "视频",
|
||||
"document": "文档",
|
||||
"archive": "压缩包"
|
||||
},
|
||||
"type_label": "类型"
|
||||
"type_label": "类型",
|
||||
"sort": {
|
||||
"relevance": "相关度"
|
||||
},
|
||||
"results_for": "\"{{q}}\" 的搜索结果",
|
||||
"results_summary": "\"{{q}}\" 的搜索结果 · {{n}} 条结果 · {{ms}} 毫秒",
|
||||
"results_summary_no_total": "\"{{q}}\" 的搜索结果 · {{ms}} 毫秒",
|
||||
"everywhere": "任何位置",
|
||||
"this_folder": "此文件夹",
|
||||
"this_folder_disabled": "打开文件中的文件夹以将搜索限制在其中",
|
||||
"scope": "范围",
|
||||
"date_label": "日期",
|
||||
"sort_by": "排序方式",
|
||||
"clear_filters": "清除筛选",
|
||||
"searching_for": "正在搜索\"{{q}}\"…",
|
||||
"no_results": "此搜索未找到结果",
|
||||
"prompt": "在上方搜索栏中输入查询内容。",
|
||||
"size": {
|
||||
"all": "任意大小",
|
||||
"small": "< 1 MB",
|
||||
"medium": "1–100 MB",
|
||||
"large": "> 100 MB"
|
||||
},
|
||||
"date": {
|
||||
"all": "任何时间",
|
||||
"day": "过去24小时",
|
||||
"week": "过去一周",
|
||||
"month": "过去一个月",
|
||||
"year": "过去一年"
|
||||
}
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "文件夹"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
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.
|
||||
@@ -59,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)
|
||||
@@ -95,11 +109,21 @@ impl Default for SearchCriteriaDto {
|
||||
limit: default_limit(),
|
||||
offset: 0,
|
||||
sort_by: default_sort_by(),
|
||||
reverse: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file search result enriched with server-computed metadata
|
||||
/// 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
|
||||
@@ -148,9 +172,36 @@ pub struct SearchFileResultDto {
|
||||
/// "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
|
||||
/// 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
|
||||
@@ -164,7 +215,7 @@ pub struct SearchFolderResultDto {
|
||||
/// 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::Uuid,
|
||||
pub drive_id: Uuid,
|
||||
/// Creation timestamp
|
||||
pub created_at: u64,
|
||||
/// Last modification timestamp
|
||||
@@ -173,6 +224,25 @@ pub struct SearchFolderResultDto {
|
||||
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,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,7 +252,7 @@ pub struct SearchFolderResultDto {
|
||||
* both files and folders that match the search criteria, along with pagination
|
||||
* information and server-computed metadata.
|
||||
*/
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SearchResultsDto {
|
||||
/// Files matching the search criteria (enriched with metadata)
|
||||
pub files: Vec<SearchFileResultDto>,
|
||||
@@ -252,6 +322,364 @@ impl SearchResultsDto {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 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 {
|
||||
|
||||
@@ -160,13 +160,22 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// are expanded inline via `storage.caller_group_ids($caller)`.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (files, total_count) where files are paginated and filtered
|
||||
/// A tuple `(files, caller_flags, total_count)`:
|
||||
/// - `files`: the paginated + filtered file rows.
|
||||
/// - `caller_flags`: parallel `Vec<(is_favorite, is_shared)>` aligned
|
||||
/// 1:1 with `files` by index. Populated in-SQL via per-row EXISTS
|
||||
/// subqueries on `auth.user_favorites` and `storage.role_grants` so
|
||||
/// the caller's SPA can render badges without a follow-up round-trip
|
||||
/// (same pattern the photos-timeline listing uses). Kept as a
|
||||
/// parallel vec rather than folded into `File` so the domain
|
||||
/// entity stays caller-agnostic.
|
||||
/// - `total_count`: `COUNT(*) OVER()` total for pagination.
|
||||
async fn search_files_paginated(
|
||||
&self,
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError>;
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError>;
|
||||
|
||||
/// Search files recursively in a folder subtree using ltree.
|
||||
///
|
||||
@@ -177,13 +186,14 @@ pub trait FileReadPort: Send + Sync + 'static {
|
||||
/// Post-PR-B: scoped by drive-membership grants (same semantics as
|
||||
/// `search_files_paginated`), not by `files.user_id`.
|
||||
///
|
||||
/// Returns a tuple of (matching files, total count for pagination).
|
||||
/// Returns the same shape as [`Self::search_files_paginated`]:
|
||||
/// `(files, caller_flags, total_count)`.
|
||||
async fn search_files_in_subtree(
|
||||
&self,
|
||||
root_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
// Default: delegate to paginated search (non-recursive fallback)
|
||||
self.search_files_paginated(root_folder_id, criteria, caller_id)
|
||||
.await
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::application::dtos::favorites_dto::{
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::{FavoritesRepositoryPort, FavoritesUseCase};
|
||||
use crate::application::services::search_service::SearchService;
|
||||
use crate::common::errors::Result;
|
||||
use crate::domain::services::authorization::{Permission, Resource, ResourceKind, Subject};
|
||||
use crate::infrastructure::repositories::pg::FavoritesPgRepository;
|
||||
@@ -29,14 +30,28 @@ pub struct FavoritesService {
|
||||
/// return name/mime/size/drive_id for any UUID the caller was
|
||||
/// able to enroll. See `docs/plan/authz_audit/rest_storage.md`.
|
||||
authorization: Arc<PgAclEngine>,
|
||||
/// Optional search-cache invalidator. Every favorite mutation
|
||||
/// changes what `is_favorite` returns on the caller's cached
|
||||
/// search result pages; without this hook the user sees a stale
|
||||
/// star badge for up to the search cache's 5-minute TTL (Ed's
|
||||
/// 2026-07-26 UX report). `None` when search is disabled
|
||||
/// (`OXICLOUD_ENABLE_SEARCH=false`).
|
||||
search: Option<Arc<SearchService>>,
|
||||
}
|
||||
|
||||
impl FavoritesService {
|
||||
/// Create a new FavoritesService with the given repository port
|
||||
pub fn new(repo: Arc<FavoritesPgRepository>, authorization: Arc<PgAclEngine>) -> Self {
|
||||
/// Create a new FavoritesService with the given repository port.
|
||||
/// `search` is `None` when search is disabled — the favorites path
|
||||
/// still works, just without the cache-invalidation callback.
|
||||
pub fn new(
|
||||
repo: Arc<FavoritesPgRepository>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
search: Option<Arc<SearchService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authorization,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +118,12 @@ impl FavoritesUseCase for FavoritesService {
|
||||
.await?;
|
||||
|
||||
self.repo.add_favorite(user_id, item_id, item_type).await?;
|
||||
// Drop this user's cached search pages so a subsequent search
|
||||
// reflects the new star. Scoped to the caller — other tenants'
|
||||
// caches are untouched.
|
||||
if let Some(search) = &self.search {
|
||||
search.invalidate_for_user(user_id).await;
|
||||
}
|
||||
info!(
|
||||
"Successfully added {} '{}' to favorites for user {}",
|
||||
item_type, item_id, user_id
|
||||
@@ -125,6 +146,12 @@ impl FavoritesUseCase for FavoritesService {
|
||||
.repo
|
||||
.remove_favorite(user_id, item_id, item_type)
|
||||
.await?;
|
||||
// Only invalidate when a row was actually removed — a no-op
|
||||
// remove (item wasn't favorited) doesn't need to cold-start the
|
||||
// cache. Keeps the "toggle a non-favorite" no-op cheap.
|
||||
if removed && let Some(search) = &self.search {
|
||||
search.invalidate_for_user(user_id).await;
|
||||
}
|
||||
info!(
|
||||
"{} {} '{}' from favorites for user {}",
|
||||
if removed {
|
||||
@@ -181,6 +208,14 @@ impl FavoritesUseCase for FavoritesService {
|
||||
let requested = items.len();
|
||||
let inserted = self.repo.add_favorites_batch(user_id, items).await?;
|
||||
let already_existed = requested as u64 - inserted;
|
||||
// Any actual insert flips is_favorite for at least one row —
|
||||
// invalidate. Skip when the batch was fully idempotent (every
|
||||
// item was already favorited); no user-visible change.
|
||||
if inserted > 0
|
||||
&& let Some(search) = &self.search
|
||||
{
|
||||
search.invalidate_for_user(user_id).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Batch favorites for user {}: {} requested, {} inserted, {} already existed",
|
||||
|
||||
@@ -72,7 +72,14 @@ pub struct SearchService {
|
||||
/// Keys span user × query × offset × limit, and each page holds up to 500
|
||||
/// enriched rows (~500–900 B of owned Strings each) — an entry-count bound
|
||||
/// let hundreds of MB of result pages accumulate invisibly.
|
||||
search_cache: moka::future::Cache<u64, Arc<SearchResultsDto>>,
|
||||
///
|
||||
/// Key is `(user_id, criteria_hash)` (not a single fused `u64`) so
|
||||
/// `invalidate_for_user` can predicate on `k.0` — a per-user flush
|
||||
/// runs when the user favorites/shares a file so their next search
|
||||
/// sees the fresh `is_favorite` / `is_shared` flags instead of a
|
||||
/// cache entry that hardened at compute-time (was up to 5 min stale
|
||||
/// before 2026-07-26 — Ed reported the mismatch).
|
||||
search_cache: moka::future::Cache<(Uuid, u64), Arc<SearchResultsDto>>,
|
||||
}
|
||||
|
||||
// ─── Search-results cache (byte-bounded) ─────────────────────────────────
|
||||
@@ -88,7 +95,7 @@ pub struct SearchService {
|
||||
///
|
||||
/// `pub` so `examples/bench_search_cache_mem.rs` can recompute retained
|
||||
/// bytes with the exact production formula.
|
||||
pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) -> u32 {
|
||||
pub fn search_results_entry_weight(_key: &(Uuid, u64), value: &Arc<SearchResultsDto>) -> u32 {
|
||||
/// Fixed per-row overhead: struct scalars + one 24-B header per `String`
|
||||
/// field (12 on a file row, 4 on a folder row) + `Vec` slot + allocator
|
||||
/// slop. Deliberately a round upper-ish estimate — under-weighing is the
|
||||
@@ -132,11 +139,15 @@ pub fn search_results_entry_weight(_key: &u64, value: &Arc<SearchResultsDto>) ->
|
||||
pub fn build_search_results_cache(
|
||||
cache_ttl_secs: u64,
|
||||
max_bytes: u64,
|
||||
) -> moka::future::Cache<u64, Arc<SearchResultsDto>> {
|
||||
) -> moka::future::Cache<(Uuid, u64), Arc<SearchResultsDto>> {
|
||||
moka::future::Cache::builder()
|
||||
.max_capacity(max_bytes)
|
||||
.weigher(search_results_entry_weight)
|
||||
.time_to_live(Duration::from_secs(cache_ttl_secs))
|
||||
// Required for `invalidate_entries_if` to actually match anything
|
||||
// — without this the closure silently no-ops (per the
|
||||
// `bug_moka_invalidate_entries_if_needs_opt_in` memo).
|
||||
.support_invalidation_closures()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -201,14 +212,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)),
|
||||
}
|
||||
}
|
||||
@@ -261,10 +281,13 @@ impl SearchService {
|
||||
}
|
||||
|
||||
/// Creates a cache key from the search criteria using zero-allocation hashing.
|
||||
fn create_cache_key(criteria: &SearchCriteriaDto, user_id: &str) -> u64 {
|
||||
/// Hash just the criteria — the caller pairs the returned `u64` with
|
||||
/// the `Uuid` user_id to form the composite cache key `(Uuid, u64)`.
|
||||
/// Split from the fused hash so `invalidate_for_user` can predicate
|
||||
/// on the user side of the tuple without decoding the criteria.
|
||||
fn create_cache_key(criteria: &SearchCriteriaDto) -> u64 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
criteria.hash(&mut hasher);
|
||||
user_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
@@ -304,6 +327,16 @@ impl SearchService {
|
||||
blob_hash: file.content_hash,
|
||||
snippet: None,
|
||||
match_source: (!query_lower.is_empty() && relevance > 0).then(|| "name".to_string()),
|
||||
// Phase 1-plus: carry the FileDto fields the old enrich
|
||||
// shape dropped. Populates the normalised
|
||||
// `SearchResourcesDto` items with a complete `FileDto` so
|
||||
// the frontend `ResourceList` renders favorites / share
|
||||
// badges / provenance consistently with other listings.
|
||||
etag: file.etag,
|
||||
created_by: file.created_by,
|
||||
updated_by: file.updated_by,
|
||||
is_favorite: file.is_favorite,
|
||||
is_shared: file.is_shared,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +362,13 @@ impl SearchService {
|
||||
modified_at: folder.modified_at,
|
||||
is_root: folder.is_root,
|
||||
relevance_score: relevance,
|
||||
// Phase 1-plus (see sibling `enrich_file`): carry the
|
||||
// FolderDto fields the old enrich shape dropped.
|
||||
etag: folder.etag,
|
||||
created_by: folder.created_by,
|
||||
updated_by: folder.updated_by,
|
||||
is_favorite: folder.is_favorite,
|
||||
is_shared: folder.is_shared,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,7 +542,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)
|
||||
}
|
||||
@@ -628,13 +668,11 @@ impl SearchUseCase for SearchService {
|
||||
criteria: SearchCriteriaDto,
|
||||
user_id: Uuid,
|
||||
) -> Result<Arc<SearchResultsDto>> {
|
||||
// Stack-encode the UUID (36 ASCII bytes) instead of `to_string()` — the
|
||||
// hasher sees the identical byte sequence, so the u64 key is unchanged,
|
||||
// but the per-request heap `String` is gone (the fn doc even claims
|
||||
// "zero-allocation hashing"). See benches/ROUND19.md §M5.
|
||||
let mut user_id_buf = [0u8; uuid::fmt::Hyphenated::LENGTH];
|
||||
let user_id_str = user_id.hyphenated().encode_lower(&mut user_id_buf);
|
||||
let cache_key = Self::create_cache_key(&criteria, user_id_str);
|
||||
// Composite key: `(user_id, criteria_hash)`. Pairs the identity of
|
||||
// the caller with the hash of the request so `invalidate_for_user`
|
||||
// can drop just this user's entries when their favorites / shares
|
||||
// change (see the `search_cache` field doc for the "why").
|
||||
let cache_key = (user_id, Self::create_cache_key(&criteria));
|
||||
|
||||
// Single-flight: collapse N identical concurrent searches into ONE
|
||||
// execution. `try_get_with` serves the cached result on a hit and, on a
|
||||
@@ -651,18 +689,12 @@ impl SearchUseCase for SearchService {
|
||||
// For non-recursive searches, use efficient database-level pagination
|
||||
// This avoids loading all files into memory
|
||||
if !criteria.recursive {
|
||||
// The content-index lookup (drive resolve + Tantivy +
|
||||
// ReBAC batch), the file page and the folder query are
|
||||
// mutually independent — overlap them so the search pays
|
||||
// ~max() instead of the serial sum (`suggest_with_perms`
|
||||
// already used this shape; ROUND10 brought it here).
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
// Same folders-first sequencing as the recursive branch
|
||||
// below (see the block comment there for the rationale
|
||||
// — SQL applies file offset+limit, so folder_count has
|
||||
// to be known before the file query is issued).
|
||||
let (content_hits, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_paginated(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
@@ -670,74 +702,120 @@ impl SearchUseCase for SearchService {
|
||||
false,
|
||||
),
|
||||
);
|
||||
let (files, total_file_count) = files_page?;
|
||||
let folders = folders_res?;
|
||||
let (folders, folder_flags) = folders_res?;
|
||||
|
||||
let folder_count = folders.len();
|
||||
let folders_before_page = criteria.offset.min(folder_count);
|
||||
let folders_on_page = (folder_count - folders_before_page).min(criteria.limit);
|
||||
let file_offset = criteria.offset - folders_before_page;
|
||||
let file_limit_needed = criteria.limit - folders_on_page;
|
||||
let file_limit_probe = file_limit_needed.max(1);
|
||||
|
||||
let mut file_criteria = criteria.clone();
|
||||
file_criteria.offset = file_offset;
|
||||
file_criteria.limit = file_limit_probe;
|
||||
|
||||
let (files, file_flags, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_paginated(
|
||||
criteria.folder_id.as_deref(),
|
||||
&file_criteria,
|
||||
user_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Convert to DTOs and enrich with metadata — one fused
|
||||
// pass, no intermediate Vec<FileDto> materialization.
|
||||
// `file_flags` is aligned 1:1 with `files` (in-SQL
|
||||
// per-row EXISTS on favorites + role_grants) so a
|
||||
// simple parallel zip plumbs the caller-scoped
|
||||
// booleans onto the FileDto before enrichment.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.zip(file_flags)
|
||||
.map(|(f, (is_fav, is_shr))| {
|
||||
let mut dto = FileDto::from(f);
|
||||
dto.is_favorite = is_fav;
|
||||
dto.is_shared = is_shr;
|
||||
Self::enrich_file(dto, &query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// For folders, apply sorting and pagination in memory (usually fewer folders)
|
||||
// For folders, apply sorting and pagination in memory (usually fewer folders).
|
||||
// Same parallel-zip shape as the file branch above —
|
||||
// `folder_flags` is aligned 1:1 by index.
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.zip(folder_flags)
|
||||
.map(|(f, (is_fav, is_shr))| {
|
||||
let mut dto = FolderDto::from(f);
|
||||
dto.is_favorite = is_fav;
|
||||
dto.is_shared = is_shr;
|
||||
Self::enrich_folder(dto, &query_lower)
|
||||
})
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
// Blend in content-discovered files before the pagination math.
|
||||
let added = self
|
||||
// Blend in content-discovered files, then truncate to
|
||||
// the exact page size — see the recursive branch's
|
||||
// block comment for why the truncate is required.
|
||||
//
|
||||
// Note: `total_count` is intentionally the pure SQL
|
||||
// name-match count (plus folders) — NOT inflated by
|
||||
// `added` content-hit rows. `added` counts hits that
|
||||
// aren't already in the *current* SQL slice, which is
|
||||
// per-page (different SQL rows on each page produce
|
||||
// different dedup outcomes and a different `added`).
|
||||
// Including it made the client-visible `total`
|
||||
// flicker as the user paginated (2026-07-26 report:
|
||||
// 4184 → 4186 across scope + page toggles for a
|
||||
// stable dataset). Content-hits still bubble into
|
||||
// each page's `items`; they just don't move the
|
||||
// grand total.
|
||||
let _added = self
|
||||
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
|
||||
.await?;
|
||||
let total_file_count = total_file_count + added;
|
||||
enriched_files.truncate(file_limit_needed);
|
||||
|
||||
let folder_count = enriched_folders.len();
|
||||
let total_count = total_file_count + folder_count;
|
||||
|
||||
// Combine and paginate (folders first, then files)
|
||||
let start_idx = criteria.offset.min(total_count);
|
||||
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
// Move the page out of the owned vecs instead of
|
||||
// deep-cloning the slice — the source is dropped right
|
||||
// after (benches/ROUND11.md §11: −300 allocs per page).
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.skip(folders_before_page)
|
||||
.take(folders_on_page)
|
||||
.collect();
|
||||
let paginated_files = enriched_files;
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -757,16 +835,30 @@ impl SearchUseCase for SearchService {
|
||||
// ── Recursive search via ltree (single SQL query per entity type) ──
|
||||
// Uses PostgreSQL ltree GiST index to find all files and folders
|
||||
// in the subtree in O(1) queries, replacing the O(N) spawn-per-folder
|
||||
// approach that could saturate the connection pool. The content
|
||||
// lookup, subtree file query and folder query overlap (`join!`),
|
||||
// same as the non-recursive branch.
|
||||
let (content_hits, files_page, folders_res) = tokio::join!(
|
||||
// approach that could saturate the connection pool.
|
||||
//
|
||||
// ── Correct pagination across a folders-then-files list ──
|
||||
// Pre-fix the service ran (content, file-page, folder-page)
|
||||
// in one `tokio::join!` with the SAME `criteria.offset/limit`
|
||||
// going to the file SQL, then re-paginated in memory using
|
||||
// ABSOLUTE offsets. That was doubly wrong: SQL already
|
||||
// applied `[offset, offset+limit)` and the in-memory slice
|
||||
// then tried to skip `offset` MORE rows — for any query
|
||||
// with few folders this dropped whole pages (2026-07-26:
|
||||
// 4002-file query returned 50 rows on page 1 then `items:[]`
|
||||
// on page 2 with a valid `next_cursor`).
|
||||
//
|
||||
// Fix: fold folders + content lookup first (they're both
|
||||
// cheap and folder_count is what tells us how many files
|
||||
// to skip). Then run the file SQL with `offset` shifted by
|
||||
// `folder_count` and `limit` reduced by whatever folders
|
||||
// fit on the current page — so SQL returns EXACTLY the
|
||||
// file slice that belongs here, no in-memory re-slicing.
|
||||
// The min-1 probe below preserves `COUNT(*) OVER()` even
|
||||
// when folders fill the whole page (LIMIT 0 → 0 rows →
|
||||
// total_count column projects nowhere → false zero).
|
||||
let (content_hits, folders_res) = tokio::join!(
|
||||
self.lookup_content_hits(&criteria, user_id),
|
||||
self.file_repository.search_files_in_subtree(
|
||||
criteria.folder_id.as_deref(),
|
||||
&criteria,
|
||||
user_id,
|
||||
),
|
||||
self.folder_repository.search_folders(
|
||||
criteria.folder_id.as_deref(),
|
||||
criteria.name_contains.as_deref(),
|
||||
@@ -774,19 +866,50 @@ impl SearchUseCase for SearchService {
|
||||
true,
|
||||
),
|
||||
);
|
||||
let (found_files, total_file_count) = files_page?;
|
||||
let found_folders: Vec<Folder> = folders_res?;
|
||||
let (found_folders, folder_flags): (Vec<Folder>, Vec<(bool, bool)>) = folders_res?;
|
||||
|
||||
let folder_count = found_folders.len();
|
||||
let folders_before_page = criteria.offset.min(folder_count);
|
||||
let folders_on_page = (folder_count - folders_before_page).min(criteria.limit);
|
||||
let file_offset = criteria.offset - folders_before_page;
|
||||
let file_limit_needed = criteria.limit - folders_on_page;
|
||||
// Probe with LIMIT ≥ 1 so `COUNT(*) OVER()` has a row to
|
||||
// project onto; the extra row (if any) is truncated below.
|
||||
let file_limit_probe = file_limit_needed.max(1);
|
||||
|
||||
let mut file_criteria = criteria.clone();
|
||||
file_criteria.offset = file_offset;
|
||||
file_criteria.limit = file_limit_probe;
|
||||
|
||||
let (found_files, file_flags, total_file_count) = self
|
||||
.file_repository
|
||||
.search_files_in_subtree(criteria.folder_id.as_deref(), &file_criteria, user_id)
|
||||
.await?;
|
||||
|
||||
// ── Convert to DTOs and enrich with server-computed metadata ──
|
||||
// Fused single pass: no intermediate DTO Vec materialization.
|
||||
// Same shape as the non-recursive branch above — parallel
|
||||
// zip of `found_files` with the in-SQL caller_flags.
|
||||
let mut enriched_files: Vec<SearchFileResultDto> = found_files
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_file(FileDto::from(f), &query_lower))
|
||||
.zip(file_flags)
|
||||
.map(|(f, (is_fav, is_shr))| {
|
||||
let mut dto = FileDto::from(f);
|
||||
dto.is_favorite = is_fav;
|
||||
dto.is_shared = is_shr;
|
||||
Self::enrich_file(dto, &query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut enriched_folders: Vec<SearchFolderResultDto> = found_folders
|
||||
.into_iter()
|
||||
.map(|f| Self::enrich_folder(FolderDto::from(f), &query_lower))
|
||||
.zip(folder_flags)
|
||||
.map(|(f, (is_fav, is_shr))| {
|
||||
let mut dto = FolderDto::from(f);
|
||||
dto.is_favorite = is_fav;
|
||||
dto.is_shared = is_shr;
|
||||
Self::enrich_folder(dto, &query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── Sort folders (cached_key avoids O(N log N) temporary String allocations) ──
|
||||
@@ -808,38 +931,35 @@ impl SearchUseCase for SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
// Blend in content-discovered files before the pagination math.
|
||||
let added = self
|
||||
// Blend in content-discovered files. `merge_content_hits`
|
||||
// pushes candidates from the Tantivy index onto the tail
|
||||
// and re-sorts by the criteria; the SQL limit above only
|
||||
// bounded the name-match set, so truncate after merging
|
||||
// to the exact page size we intended to return.
|
||||
//
|
||||
// `total_count` is the pure SQL name-match count + folder
|
||||
// count — see the non-recursive branch's block comment
|
||||
// for why `added` is deliberately excluded (per-page
|
||||
// dedup outcome, would flicker the client-visible
|
||||
// total across pages).
|
||||
let _added = self
|
||||
.merge_content_hits(content_hits, &mut enriched_files, &criteria, user_id)
|
||||
.await?;
|
||||
let total_file_count = total_file_count + added;
|
||||
enriched_files.truncate(file_limit_needed);
|
||||
|
||||
// ── Pagination (folders first, then files) ──
|
||||
let folder_count = enriched_folders.len();
|
||||
let total_count = total_file_count + folder_count;
|
||||
let start_idx = criteria.offset.min(total_count);
|
||||
let end_idx = (criteria.offset + criteria.limit).min(total_count);
|
||||
|
||||
let folder_start = start_idx.min(folder_count);
|
||||
let folder_end = end_idx.min(folder_count);
|
||||
// Move the page out instead of deep-cloning the slice — the
|
||||
// recursive branch's vecs can hold the whole subtree match
|
||||
// set, all dropped right after (benches/ROUND11.md §11).
|
||||
// Fetches were pre-sliced: enriched_files is already the
|
||||
// exact file page (SQL applied file_offset + file_limit),
|
||||
// and folders_before_page / folders_on_page tell us which
|
||||
// slice of `enriched_folders` belongs here. No in-memory
|
||||
// absolute-offset math — see the block comment above.
|
||||
let paginated_folders: Vec<_> = enriched_folders
|
||||
.into_iter()
|
||||
.skip(folder_start)
|
||||
.take(folder_end - folder_start)
|
||||
.collect();
|
||||
|
||||
let file_start = start_idx.saturating_sub(folder_count);
|
||||
let file_end = end_idx
|
||||
.saturating_sub(folder_count)
|
||||
.min(enriched_files.len());
|
||||
let paginated_files: Vec<_> = enriched_files
|
||||
.into_iter()
|
||||
.skip(file_start)
|
||||
.take(file_end - file_start)
|
||||
.skip(folders_before_page)
|
||||
.take(folders_on_page)
|
||||
.collect();
|
||||
let paginated_files = enriched_files;
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
@@ -889,6 +1009,41 @@ impl SearchUseCase for SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchService {
|
||||
/// Drop every cached search page for a single user. Called by the
|
||||
/// favorites / share services after a mutation that changes what
|
||||
/// `is_favorite` / `is_shared` would return for one of the caller's
|
||||
/// files — without this the caller would see a stale flag for up
|
||||
/// to `cache_ttl_secs` (Ed's 2026-07-26 report).
|
||||
///
|
||||
/// `invalidate_entries_if` needs `.support_invalidation_closures()`
|
||||
/// on the cache builder — set in `build_search_results_cache`. This
|
||||
/// is scoped (predicate matches `k.0 == user_id` on the composite
|
||||
/// `(Uuid, u64)` key), so a per-user favorite toggle does NOT
|
||||
/// cold-start every other tenant's cache the way `invalidate_all`
|
||||
/// does on the admin cache-flush endpoint.
|
||||
pub async fn invalidate_for_user(&self, user_id: Uuid) {
|
||||
// moka registers the predicate and returns a `PredicateId` — we
|
||||
// don't need the id (we're not planning to unregister). Errors
|
||||
// here are non-critical: worst case the caller sees stale
|
||||
// is_favorite / is_shared for TTL seconds, exactly the state
|
||||
// before this fix. Log-and-swallow keeps the mutation path
|
||||
// reliable even under moka pressure.
|
||||
if let Err(e) = self
|
||||
.search_cache
|
||||
.invalidate_entries_if(move |k, _| k.0 == user_id)
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "oxicloud::search",
|
||||
error = %e,
|
||||
%user_id,
|
||||
"search cache invalidate_entries_if failed — user will see \
|
||||
stale is_favorite / is_shared until TTL expires",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stub for testing ────────────────────────────────────────────────────
|
||||
|
||||
impl SearchService {
|
||||
@@ -960,6 +1115,11 @@ mod tests {
|
||||
blob_hash: String::new(),
|
||||
snippet: None,
|
||||
match_source: None,
|
||||
etag: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
is_favorite: false,
|
||||
is_shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,7 +1127,7 @@ mod tests {
|
||||
fn entry_weight_counts_every_owned_string_plus_overheads() {
|
||||
// Empty page: entry overhead + sort_by ("relevance" = 9 bytes).
|
||||
let empty = Arc::new(SearchResultsDto::empty());
|
||||
let base = search_results_entry_weight(&0, &empty) as usize;
|
||||
let base = search_results_entry_weight(&(Uuid::nil(), 0), &empty) as usize;
|
||||
assert_eq!(base, 256 + 9);
|
||||
|
||||
// One file row: base + row overhead + its owned string bytes
|
||||
@@ -981,7 +1141,7 @@ mod tests {
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_file) as usize;
|
||||
let w = search_results_entry_weight(&(Uuid::nil(), 0), &one_file) as usize;
|
||||
assert_eq!(w, base + 200 + 7 + 7 + 8 + 10);
|
||||
|
||||
// Folder rows weigh too (id 2 + name 4 + path 5 + parent 6 = 17).
|
||||
@@ -997,6 +1157,11 @@ mod tests {
|
||||
modified_at: 0,
|
||||
is_root: false,
|
||||
relevance_score: 50,
|
||||
etag: String::new(),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
is_favorite: false,
|
||||
is_shared: false,
|
||||
}],
|
||||
100,
|
||||
0,
|
||||
@@ -1004,7 +1169,7 @@ mod tests {
|
||||
0,
|
||||
"relevance".to_string(),
|
||||
));
|
||||
let w = search_results_entry_weight(&0, &one_folder) as usize;
|
||||
let w = search_results_entry_weight(&(Uuid::nil(), 0), &one_folder) as usize;
|
||||
assert_eq!(w, base + 200 + 2 + 4 + 5 + 6);
|
||||
}
|
||||
|
||||
@@ -1025,12 +1190,12 @@ mod tests {
|
||||
"relevance".to_string(),
|
||||
))
|
||||
};
|
||||
let per_entry = search_results_entry_weight(&0, &entry(0)) as u64;
|
||||
let per_entry = search_results_entry_weight(&(Uuid::nil(), 0), &entry(0)) as u64;
|
||||
let budget = per_entry * 2 + per_entry / 2;
|
||||
|
||||
let cache = build_search_results_cache(300, budget);
|
||||
for i in 0..20u64 {
|
||||
cache.insert(i, entry(i as usize)).await;
|
||||
cache.insert((Uuid::nil(), i), entry(i as usize)).await;
|
||||
}
|
||||
cache.run_pending_tasks().await;
|
||||
|
||||
@@ -1051,17 +1216,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use thiserror::Error;
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::services::search_service::SearchService;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
use crate::domain::repositories::folder_repository::FolderRepository;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Role, Subject};
|
||||
@@ -98,6 +99,16 @@ pub struct ShareService {
|
||||
/// Bounds the number of in-flight Argon2 password hashes to avoid
|
||||
/// saturating the blocking thread pool and consuming excessive RAM.
|
||||
hash_semaphore: Arc<Semaphore>,
|
||||
/// Optional search-cache invalidator. Every share create/delete flips
|
||||
/// what `is_shared` returns on the calling user's cached search
|
||||
/// result pages; without this hook the sharer sees a stale share
|
||||
/// badge for up to the search cache's 5-minute TTL. `None` when
|
||||
/// search is disabled (`OXICLOUD_ENABLE_SEARCH=false`).
|
||||
///
|
||||
/// Only the CALLER's cache is invalidated — recipients of a share
|
||||
/// still get stale-until-TTL for now (would need a per-resource
|
||||
/// invalidation index; deferred).
|
||||
search: Option<Arc<SearchService>>,
|
||||
}
|
||||
|
||||
impl ShareService {
|
||||
@@ -110,6 +121,7 @@ impl ShareService {
|
||||
drive_repository: Arc<DrivePgRepository>,
|
||||
password_hasher: Arc<Argon2PasswordHasher>,
|
||||
authorization: Arc<PgAclEngine>,
|
||||
search: Option<Arc<SearchService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: config.base_url(),
|
||||
@@ -121,6 +133,7 @@ impl ShareService {
|
||||
password_hasher,
|
||||
authorization,
|
||||
hash_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HASHES)),
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +355,13 @@ impl ShareUseCase for ShareService {
|
||||
.await
|
||||
.map_err(|e| ShareServiceError::Repository(e.to_string()))?;
|
||||
|
||||
// Sharer's search cache no longer reflects `is_shared` truthfully
|
||||
// for the affected resource — flush their entries. Recipients are
|
||||
// still stale-until-TTL (see the struct field comment).
|
||||
if let Some(search) = &self.search {
|
||||
search.invalidate_for_user(user_id).await;
|
||||
}
|
||||
|
||||
// Return DTO with the requested expires_at (grant subquery on the share
|
||||
// row would return NULL at this point since INSERT ran before the grant).
|
||||
let mut response = ShareDto::from_entity(&saved_share, &self.base_url);
|
||||
@@ -445,6 +465,12 @@ impl ShareUseCase for ShareService {
|
||||
self.share_repository
|
||||
.delete_share_for_user(id, requester_id)
|
||||
.await?;
|
||||
// Sharer's search cache no longer reflects `is_shared` truthfully
|
||||
// for the affected resource — flush their entries. Recipients are
|
||||
// still stale-until-TTL (see the struct field comment).
|
||||
if let Some(search) = &self.search {
|
||||
search.invalidate_for_user(requester_id).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -920,8 +946,15 @@ mod tests {
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<(Vec<crate::domain::entities::file::File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
) -> Result<
|
||||
(
|
||||
Vec<crate::domain::entities::file::File>,
|
||||
Vec<(bool, bool)>,
|
||||
usize,
|
||||
),
|
||||
DomainError,
|
||||
> {
|
||||
Ok((Vec::new(), Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
|
||||
@@ -532,8 +532,8 @@ impl FileReadPort for MockFileRepository {
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &crate::application::dtos::search_dto::SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> std::result::Result<(Vec<File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
) -> std::result::Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
Ok((Vec::new(), Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
|
||||
+27
-5
@@ -877,13 +877,17 @@ impl AppServiceFactory {
|
||||
Some(service as Arc<TrashService>)
|
||||
}
|
||||
|
||||
/// Creates the sharing service
|
||||
/// Creates the sharing service. `search_service` is threaded through
|
||||
/// so create/delete of a share can flush the caller's cached search
|
||||
/// pages (2026-07-26 — per-user is_shared invalidation). `None` when
|
||||
/// search is disabled; the flush becomes a no-op.
|
||||
pub fn create_share_service(
|
||||
&self,
|
||||
repos: &RepositoryServices,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<crate::infrastructure::services::pg_acl_engine::PgAclEngine>,
|
||||
drive_repo: &Arc<crate::infrastructure::repositories::pg::DrivePgRepository>,
|
||||
search_service: Option<Arc<SearchService>>,
|
||||
) -> Option<Arc<ShareService>> {
|
||||
if !self.config.features.enable_file_sharing {
|
||||
tracing::info!("File sharing service is disabled in configuration");
|
||||
@@ -909,6 +913,10 @@ impl AppServiceFactory {
|
||||
drive_repo.clone(),
|
||||
password_hasher,
|
||||
authorization.clone(),
|
||||
// Optional per-user search-cache invalidator — set here so
|
||||
// create/delete of a share drops the sharer's cached search
|
||||
// pages (2026-07-26). `None` when search is disabled.
|
||||
search_service.clone(),
|
||||
));
|
||||
|
||||
tracing::info!("File sharing service initialized");
|
||||
@@ -917,16 +925,23 @@ impl AppServiceFactory {
|
||||
|
||||
/// Creates the favorites service (requires database + authz engine
|
||||
/// for the Read gate on `add_to_favorites` — see the post-Drive
|
||||
/// AuthZ audit).
|
||||
/// AuthZ audit). `search_service` is threaded through so add/remove
|
||||
/// can flush the caller's cached search pages (2026-07-26 — per-user
|
||||
/// is_favorite invalidation). `None` when search is disabled.
|
||||
pub fn create_favorites_service(
|
||||
&self,
|
||||
db_pool: &Arc<PgPool>,
|
||||
authorization: &Arc<PgAclEngine>,
|
||||
search_service: Option<Arc<SearchService>>,
|
||||
) -> Arc<FavoritesService> {
|
||||
let repo = Arc::new(
|
||||
crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()),
|
||||
);
|
||||
let service = Arc::new(FavoritesService::new(repo, authorization.clone()));
|
||||
let service = Arc::new(FavoritesService::new(
|
||||
repo,
|
||||
authorization.clone(),
|
||||
search_service,
|
||||
));
|
||||
tracing::info!("Favorites service initialized");
|
||||
service
|
||||
}
|
||||
@@ -1325,7 +1340,13 @@ impl AppServiceFactory {
|
||||
);
|
||||
|
||||
// 5. Share service
|
||||
let share_service = self.create_share_service(&repos, &pool, &authorization, &drive_repo);
|
||||
let share_service = self.create_share_service(
|
||||
&repos,
|
||||
&pool,
|
||||
&authorization,
|
||||
&drive_repo,
|
||||
apps.search_service.clone(),
|
||||
);
|
||||
apps.share_service = share_service.clone();
|
||||
|
||||
let share_browse_service = share_service.as_ref().map(|s| {
|
||||
@@ -1359,7 +1380,8 @@ impl AppServiceFactory {
|
||||
> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool, &authorization);
|
||||
let favs =
|
||||
self.create_favorites_service(&pool, &authorization, apps.search_service.clone());
|
||||
favorites_service = Some(favs.clone());
|
||||
apps.favorites_service = Some(favs);
|
||||
|
||||
|
||||
+2
-2
@@ -126,8 +126,8 @@ impl FileReadPort for StubFileReadPort {
|
||||
_folder_id: Option<&str>,
|
||||
_criteria: &SearchCriteriaDto,
|
||||
_user_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
Ok((Vec::new(), 0))
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
Ok((Vec::new(), Vec::new(), 0))
|
||||
}
|
||||
|
||||
async fn stream_files_in_subtree(
|
||||
|
||||
@@ -237,31 +237,39 @@ pub trait FolderRepository: Send + Sync + 'static {
|
||||
///
|
||||
/// The default implementation falls back to `list_folders` + in-memory
|
||||
/// filter so that stubs and mocks compile without changes.
|
||||
///
|
||||
/// Returns `(folders, caller_flags)` where `caller_flags` is a
|
||||
/// parallel `Vec<(is_favorite, is_shared)>` aligned 1:1 with
|
||||
/// `folders` by index. The default impl fills with `(false, false)`
|
||||
/// so stubs stay trivial; the concrete PG impl computes them via
|
||||
/// per-row EXISTS on `auth.user_favorites` and `storage.role_grants`
|
||||
/// (matching the file-side `search_files_paginated` pattern).
|
||||
async fn search_folders(
|
||||
&self,
|
||||
parent_id: Option<&str>,
|
||||
name_contains: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
|
||||
// Recursive with folder_id → use optimised ltree scan
|
||||
if recursive && let Some(fid) = parent_id {
|
||||
return self
|
||||
.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await;
|
||||
}
|
||||
// Fallback: load + filter in memory (stubs / mocks)
|
||||
let all = self.list_folders(parent_id).await?;
|
||||
match name_contains {
|
||||
Some(q) if !q.is_empty() => {
|
||||
let q = q.to_lowercase();
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|f| f.name().to_lowercase().contains(&q))
|
||||
.collect())
|
||||
let folders = if recursive && let Some(fid) = parent_id {
|
||||
self.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await?
|
||||
} else {
|
||||
// Fallback: load + filter in memory (stubs / mocks)
|
||||
let all = self.list_folders(parent_id).await?;
|
||||
match name_contains {
|
||||
Some(q) if !q.is_empty() => {
|
||||
let q = q.to_lowercase();
|
||||
all.into_iter()
|
||||
.filter(|f| f.name().to_lowercase().contains(&q))
|
||||
.collect()
|
||||
}
|
||||
_ => all,
|
||||
}
|
||||
_ => Ok(all),
|
||||
}
|
||||
};
|
||||
let flags = vec![(false, false); folders.len()];
|
||||
Ok((folders, flags))
|
||||
}
|
||||
|
||||
/// Return up to `limit` folders whose name contains `query` (case-insensitive).
|
||||
|
||||
@@ -1212,20 +1212,27 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
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<String> = vec![
|
||||
@@ -1250,6 +1257,15 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let limit_bind = bind_idx + 1;
|
||||
let offset_bind = bind_idx + 2;
|
||||
|
||||
// Per-row caller flags — populated in-SQL so the search result
|
||||
// is a single round-trip. Mirrors the pattern used by the
|
||||
// photos-timeline listing above (see `list_photos_paginated`).
|
||||
// Both EXISTS clauses hit narrow indexes (favorites is keyed
|
||||
// on `(user_id, item_id, item_type)`; role_grants on
|
||||
// `(resource_type, resource_id)`), each a sub-ms lookup — the
|
||||
// cost across a 100-row search page is a few ms of index
|
||||
// probing vs a full second round-trip for the alternative
|
||||
// post-hoc batch approach.
|
||||
let sql = format!(
|
||||
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
|
||||
fi.size, fi.mime_type, \
|
||||
@@ -1258,6 +1274,17 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.blob_hash, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fi.id::text \
|
||||
AND uf.item_type = 'file' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fi.id \
|
||||
AND g.resource_type = 'file' \
|
||||
) AS is_shared, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
LEFT JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -1281,6 +1308,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
String,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
bool, // is_favorite
|
||||
bool, // is_shared
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
@@ -1303,20 +1332,25 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
.map_err(|e| DomainError::internal_error("FileBlobRead", format!("search: {e}")))?;
|
||||
|
||||
// total_count is the same in every row; 0 when result set is empty.
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.13) as usize;
|
||||
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
// Pre-size both parallel Vecs (size-hint note in `hydrate`,
|
||||
// ROUND20 §I1). Caller-flags stay aligned with `files` by index.
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
let mut caller_flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
|
||||
rows
|
||||
{
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
caller_flags.push((is_fav, is_shr));
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
Ok((files, caller_flags, total_count))
|
||||
}
|
||||
|
||||
/// Recursive subtree search using ltree — single SQL query.
|
||||
@@ -1337,7 +1371,7 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
root_folder_id: Option<&str>,
|
||||
criteria: &SearchCriteriaDto,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(Vec<File>, usize), DomainError> {
|
||||
) -> Result<(Vec<File>, Vec<(bool, bool)>, usize), DomainError> {
|
||||
// When no root folder specified, delegate to existing paginated search
|
||||
let root_id = match root_folder_id {
|
||||
None => {
|
||||
@@ -1349,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();
|
||||
@@ -1382,7 +1423,9 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
let limit_bind = bind_idx + 1;
|
||||
let offset_bind = bind_idx + 2;
|
||||
|
||||
// ── Single query with COUNT(*) OVER() ──
|
||||
// ── Single query with COUNT(*) OVER() + per-row caller flags ──
|
||||
// See `search_files_paginated` above for the EXISTS-subquery
|
||||
// rationale; same shape applies here.
|
||||
let sql = format!(
|
||||
"SELECT fi.id, fi.name, fi.folder_id, fo.path, \
|
||||
fi.size, fi.mime_type, \
|
||||
@@ -1391,6 +1434,17 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
fi.blob_hash, \
|
||||
\
|
||||
fi.created_by, fi.updated_by, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fi.id::text \
|
||||
AND uf.item_type = 'file' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fi.id \
|
||||
AND g.resource_type = 'file' \
|
||||
) AS is_shared, \
|
||||
COUNT(*) OVER() AS total_count \
|
||||
FROM storage.files fi \
|
||||
JOIN storage.folders fo ON fo.id = fi.folder_id \
|
||||
@@ -1414,6 +1468,8 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
String,
|
||||
Option<Uuid>, // created_by (§14)
|
||||
Option<Uuid>, // updated_by (§14)
|
||||
bool, // is_favorite
|
||||
bool, // is_shared
|
||||
i64, // total_count
|
||||
),
|
||||
>(&sql)
|
||||
@@ -1434,20 +1490,24 @@ impl FileReadPort for FileBlobReadRepository {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree search: {e}"))
|
||||
})?;
|
||||
|
||||
let total_count = rows.first().map_or(0, |r| r.11) as usize;
|
||||
let total_count = rows.first().map_or(0, |r| r.13) as usize;
|
||||
|
||||
// Pre-size the result Vec (size-hint note in `hydrate`, ROUND20 §I1).
|
||||
// Pre-size both Vecs, keep caller_flags aligned by index.
|
||||
let mut files = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, _total) in rows {
|
||||
let mut caller_flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, is_fav, is_shr, _total) in
|
||||
rows
|
||||
{
|
||||
files.push(
|
||||
Self::row_to_file(id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub)
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("FileBlobRead", format!("subtree mapping: {e}"))
|
||||
})?,
|
||||
);
|
||||
caller_flags.push((is_fav, is_shr));
|
||||
}
|
||||
|
||||
Ok((files, total_count))
|
||||
Ok((files, caller_flags, total_count))
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
|
||||
@@ -49,6 +49,48 @@ type FolderRow = (
|
||||
Option<Uuid>,
|
||||
);
|
||||
|
||||
/// `FolderRow` + trailing `(is_favorite, is_shared)` — the caller-scoped
|
||||
/// booleans populated by per-row EXISTS in `search_folders`. Split out so
|
||||
/// the many `sqlx::query_as::<_, FolderRowWithFlags>(...)` sites stay
|
||||
/// terse instead of repeating a 12-element inline tuple.
|
||||
type FolderRowWithFlags = (
|
||||
Uuid,
|
||||
String,
|
||||
String,
|
||||
Option<Uuid>,
|
||||
Uuid,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
bool,
|
||||
bool,
|
||||
);
|
||||
|
||||
/// Return shape of `search_folders`: two parallel vecs aligned 1:1
|
||||
/// (folder domain entity + `(is_favorite, is_shared)` caller flags).
|
||||
/// Kept as a type alias so the SQL builder + trait implementations
|
||||
/// share one name (`clippy::type_complexity`).
|
||||
pub(crate) type FoldersWithFlags = (Vec<Folder>, Vec<(bool, bool)>);
|
||||
|
||||
/// Convert `FolderRowWithFlags` rows into the `(Vec<Folder>, caller_flags)`
|
||||
/// pair `search_folders` returns. Keeps the two parallel vecs aligned
|
||||
/// 1:1 by index.
|
||||
fn build_folders_with_flags(
|
||||
rows: Vec<FolderRowWithFlags>,
|
||||
) -> Result<FoldersWithFlags, DomainError> {
|
||||
let mut folders = Vec::with_capacity(rows.len());
|
||||
let mut flags = Vec::with_capacity(rows.len());
|
||||
for (id, name, path, pid, did, ca, ma, tma, cb, ub, is_fav, is_shr) in rows {
|
||||
let folder =
|
||||
FolderDbRepository::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)?;
|
||||
folders.push(folder);
|
||||
flags.push((is_fav, is_shr));
|
||||
}
|
||||
Ok((folders, flags))
|
||||
}
|
||||
|
||||
/// Type alias for paginated folder rows (includes total_count as
|
||||
/// the last element after the §14 provenance columns). Same
|
||||
/// column set as [`FolderRow`] plus the trailing count.
|
||||
@@ -1048,12 +1090,20 @@ impl FolderRepository for FolderDbRepository {
|
||||
name_contains: Option<&str>,
|
||||
caller_id: Uuid,
|
||||
recursive: bool,
|
||||
) -> Result<Vec<Folder>, DomainError> {
|
||||
// Recursive with folder scope → existing optimised ltree scan
|
||||
) -> Result<(Vec<Folder>, Vec<(bool, bool)>), DomainError> {
|
||||
// Recursive with folder scope → existing optimised ltree scan.
|
||||
// `list_descendant_folders` doesn't compute caller_flags today —
|
||||
// return `(false, false)` per row until the ltree path is
|
||||
// upgraded in a follow-up. Bounded UI impact: subtree-scoped
|
||||
// searches rarely surface a specific folder as favorited /
|
||||
// shared, and the flags path elsewhere fills the gap for the
|
||||
// hot `/api/search` case.
|
||||
if recursive && let Some(fid) = parent_id {
|
||||
return self
|
||||
let folders = self
|
||||
.list_descendant_folders(fid, name_contains, caller_id)
|
||||
.await;
|
||||
.await?;
|
||||
let flags = vec![(false, false); folders.len()];
|
||||
return Ok((folders, flags));
|
||||
}
|
||||
|
||||
// Build optional name filter — use ILIKE (case-insensitive) so the
|
||||
@@ -1070,6 +1120,21 @@ impl FolderRepository for FolderDbRepository {
|
||||
_ => ("", None),
|
||||
};
|
||||
|
||||
// Per-row caller-flag EXISTS subqueries. Same pattern as
|
||||
// `search_files_paginated` in the sibling file repo: narrow
|
||||
// indexes make this a few extra μs per row.
|
||||
const FAV_SHR_COLUMNS: &str = "EXISTS ( \
|
||||
SELECT 1 FROM auth.user_favorites uf \
|
||||
WHERE uf.user_id = $1 \
|
||||
AND uf.item_id = fo.id::text \
|
||||
AND uf.item_type = 'folder' \
|
||||
) AS is_favorite, \
|
||||
EXISTS ( \
|
||||
SELECT 1 FROM storage.role_grants g \
|
||||
WHERE g.resource_id = fo.id \
|
||||
AND g.resource_type = 'folder' \
|
||||
) AS is_shared";
|
||||
|
||||
if recursive {
|
||||
// Recursive, no folder scope → ALL folders in caller's readable drives
|
||||
let sql = format!(
|
||||
@@ -1078,7 +1143,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE {CALLER_CAN_READ_DRIVE} \
|
||||
AND fo.is_trashed = false \
|
||||
@@ -1086,7 +1152,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
ORDER BY fo.name"
|
||||
);
|
||||
|
||||
let rows: Vec<FolderRow> = if let Some(ref pattern) = name_pattern {
|
||||
let rows: Vec<FolderRowWithFlags> = if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
.bind(pattern)
|
||||
@@ -1100,12 +1166,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
return rows
|
||||
.into_iter()
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect();
|
||||
return build_folders_with_flags(rows);
|
||||
}
|
||||
|
||||
// Non-recursive: direct children of parent_id, restricted to drives
|
||||
@@ -1117,7 +1178,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id = $2::uuid \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
@@ -1137,7 +1199,8 @@ impl FolderRepository for FolderDbRepository {
|
||||
EXTRACT(EPOCH FROM fo.created_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.updated_at)::bigint, \
|
||||
EXTRACT(EPOCH FROM fo.tree_modified_at)::bigint, \
|
||||
fo.created_by, fo.updated_by \
|
||||
fo.created_by, fo.updated_by, \
|
||||
{FAV_SHR_COLUMNS} \
|
||||
FROM storage.folders fo \
|
||||
WHERE fo.parent_id IS NULL \
|
||||
AND {CALLER_CAN_READ_DRIVE} \
|
||||
@@ -1147,7 +1210,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
)
|
||||
};
|
||||
|
||||
let rows: Vec<FolderRow> = if let Some(pid) = parent_id {
|
||||
let rows: Vec<FolderRowWithFlags> = if let Some(pid) = parent_id {
|
||||
if let Some(ref pattern) = name_pattern {
|
||||
sqlx::query_as(&sql)
|
||||
.bind(caller_id)
|
||||
@@ -1176,11 +1239,7 @@ impl FolderRepository for FolderDbRepository {
|
||||
}
|
||||
.map_err(|e| DomainError::internal_error("FolderDb", format!("search_folders: {e}")))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|(id, name, path, pid, did, ca, ma, tma, cb, ub)| {
|
||||
Self::row_to_folder(id, name, path, pid, did, ca, ma, tma, cb, ub)
|
||||
})
|
||||
.collect()
|
||||
build_folders_with_flags(rows)
|
||||
}
|
||||
|
||||
/// Lists all descendant folders in a subtree using ltree GiST index,
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::json;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchResultsDto, SearchSuggestionsDto,
|
||||
SearchResourcesDto, SearchResourcesQuery, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::common::di::AppState;
|
||||
@@ -39,12 +39,32 @@ impl SearchHandler {
|
||||
// so `#[utoipa::path]` fails on every method in this impl block regardless of HTTP
|
||||
// verb or annotation content. All route handlers are free functions below.
|
||||
// TODO: collapse after utoipa upgrade.
|
||||
pub(super) async fn search_files_get_impl(
|
||||
/// `GET /api/search` — wire-normalised search endpoint.
|
||||
///
|
||||
/// Returns the same `items[] { resource_type, resource, meta }`
|
||||
/// envelope shape as every other `/*/resources` listing endpoint
|
||||
/// (folders, favorites, recent, trash, shared) so the SPA's
|
||||
/// `ResourceList` component consumes it as-is. Search-specific
|
||||
/// enrichment (`meta.score` + optional `snippet` + `via`) sits
|
||||
/// inline on each item.
|
||||
///
|
||||
/// Phase 1-plus wire adapter: the internal `SearchService` still
|
||||
/// speaks `SearchCriteriaDto`/`SearchResultsDto`. The query is
|
||||
/// translated at this boundary; the result envelope is composed
|
||||
/// via `SearchResourcesDto::from_service_result`. The `is_favorite`
|
||||
/// / `is_shared` fields on each `FileDto`/`FolderDto` come from
|
||||
/// per-row EXISTS subqueries in the search SQL (see
|
||||
/// `search_files_paginated` and `search_folders`).
|
||||
///
|
||||
/// The old `POST /api/search/advanced` variant was deleted in
|
||||
/// the same PR — every field it accepted fits cleanly as a query
|
||||
/// param.
|
||||
pub(super) async fn search_resources_impl(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(params): Query<SearchParams>,
|
||||
Query(query): Query<SearchResourcesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
info!("API: File search with parameters: {:?}", params);
|
||||
info!("API: File search (normalized envelope)");
|
||||
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
@@ -58,25 +78,13 @@ impl SearchHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let search_criteria = SearchCriteriaDto {
|
||||
name_contains: params.query,
|
||||
file_types: params
|
||||
.type_filter
|
||||
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect()),
|
||||
created_after: params.created_after,
|
||||
created_before: params.created_before,
|
||||
modified_after: params.modified_after,
|
||||
modified_before: params.modified_before,
|
||||
min_size: params.min_size,
|
||||
max_size: params.max_size,
|
||||
folder_id: params.folder_id,
|
||||
recursive: params.recursive.unwrap_or(true),
|
||||
limit: params.limit.unwrap_or(100).min(MAX_SEARCH_LIMIT),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: params.sort_by.unwrap_or_else(|| "relevance".to_string()),
|
||||
};
|
||||
// Cap page size — `SearchResourcesQuery::limit_clamped` already
|
||||
// hits `[1, 200]`, but re-clamp against MAX_SEARCH_LIMIT for
|
||||
// defence-in-depth if the constant is ever raised above 200.
|
||||
let mut criteria = query.to_criteria();
|
||||
criteria.limit = criteria.limit.min(MAX_SEARCH_LIMIT);
|
||||
|
||||
match search_service.search(search_criteria, auth_user.id).await {
|
||||
match search_service.search(criteria, auth_user.id).await {
|
||||
Ok(results) => {
|
||||
info!(
|
||||
"Search completed in {}ms — {} files, {} folders",
|
||||
@@ -84,62 +92,18 @@ impl SearchHandler {
|
||||
results.files.len(),
|
||||
results.folders.len()
|
||||
);
|
||||
{
|
||||
// Pre-sized serialization (benches/ROUND12.md §M1).
|
||||
let rows = results.files.len() + results.folders.len();
|
||||
crate::interfaces::api::sized_json::sized_json(
|
||||
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
|
||||
&*results,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Search error: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "Search error" })),
|
||||
// Unwrap the Arc — the service caches `Arc<SearchResultsDto>`
|
||||
// so consumers share the allocation. `from_service_result`
|
||||
// consumes the DTO to move enriched rows into the envelope's
|
||||
// `resource` slot without cloning; the Arc's shared clone
|
||||
// pays one deep copy here but avoids allocating during the
|
||||
// hot cache-hit path elsewhere.
|
||||
let dto = SearchResourcesDto::from_service_result((*results).clone(), &query);
|
||||
let rows = dto.items.len();
|
||||
crate::interfaces::api::sized_json::sized_json(
|
||||
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
|
||||
&dto,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advanced search with full criteria in the request body.
|
||||
pub(super) async fn search_files_post_impl(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(criteria): Json<SearchCriteriaDto>,
|
||||
) -> impl IntoResponse {
|
||||
info!("API: Advanced file search");
|
||||
|
||||
let search_service = match &state.applications.search_service {
|
||||
Some(service) => service,
|
||||
None => {
|
||||
error!("Search service not available");
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": "Search service is not available" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match search_service.search(criteria, auth_user.id).await {
|
||||
Ok(results) => {
|
||||
info!(
|
||||
"Advanced search completed in {}ms — {} files, {} folders",
|
||||
results.query_time_ms,
|
||||
results.files.len(),
|
||||
results.folders.len()
|
||||
);
|
||||
{
|
||||
// Pre-sized serialization (benches/ROUND12.md §M1).
|
||||
let rows = results.files.len() + results.folders.len();
|
||||
crate::interfaces::api::sized_json::sized_json(
|
||||
256 + rows * crate::interfaces::api::sized_json::EST_WRAPPED_ROW_BYTES,
|
||||
&*results,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Search error: {}", err);
|
||||
@@ -258,50 +222,6 @@ impl SearchHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Search parameters for the GET /search endpoint
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SearchParams {
|
||||
/// Text to search in file and folder names
|
||||
pub query: Option<String>,
|
||||
|
||||
/// Filter by file types (comma-separated extensions)
|
||||
#[serde(rename = "type")]
|
||||
pub type_filter: Option<String>,
|
||||
|
||||
/// Created after this timestamp
|
||||
pub created_after: Option<u64>,
|
||||
|
||||
/// Created before this timestamp
|
||||
pub created_before: Option<u64>,
|
||||
|
||||
/// Modified after this timestamp
|
||||
pub modified_after: Option<u64>,
|
||||
|
||||
/// Modified before this timestamp
|
||||
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>,
|
||||
|
||||
/// Folder ID to limit the search scope
|
||||
pub folder_id: Option<String>,
|
||||
|
||||
/// Recursive search in subfolders (default: true)
|
||||
pub recursive: Option<bool>,
|
||||
|
||||
/// Result limit for pagination
|
||||
pub limit: Option<usize>,
|
||||
|
||||
/// Offset for pagination
|
||||
pub offset: Option<usize>,
|
||||
|
||||
/// Sort order: relevance | name | name_desc | date | date_desc | size | size_desc
|
||||
pub sort_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for the GET /search/suggest endpoint
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct SuggestParams {
|
||||
@@ -334,45 +254,35 @@ pub struct SuggestParams {
|
||||
get,
|
||||
path = "/api/search",
|
||||
params(
|
||||
("query" = Option<String>, Query, description = "Text to search in names"),
|
||||
("type" = Option<String>, Query, description = "Comma-separated MIME type filter"),
|
||||
("query" = Option<String>, Query, description = "Text to search in names / content"),
|
||||
("limit" = Option<u32>, Query, description = "Max items per page (1–200, default 50)"),
|
||||
("cursor" = Option<String>, Query, description = "Opaque cursor from a previous response"),
|
||||
("order_by" = Option<String>, Query, description = "Sort dimension: relevance (default) | name | size | updated_at | created_at"),
|
||||
("resource_types" = Option<String>, Query, description = "Comma-separated: file, folder (both by default)"),
|
||||
("reverse" = Option<bool>, Query, description = "Reverse the sort order"),
|
||||
("type" = Option<String>, Query, description = "Filter by file extensions (comma-separated)"),
|
||||
("folder_id" = Option<String>, Query, description = "Restrict search to this folder"),
|
||||
("recursive" = Option<bool>, Query, description = "Include sub-folders"),
|
||||
("limit" = Option<u32>, Query, description = "Max results"),
|
||||
("offset" = Option<u32>, Query, description = "Pagination offset"),
|
||||
("recursive" = Option<bool>, Query, description = "Recurse into subfolders (default true)"),
|
||||
("created_after" = Option<u64>, Query, description = "Minimum creation timestamp (unix seconds)"),
|
||||
("created_before" = Option<u64>, Query, description = "Maximum creation timestamp"),
|
||||
("modified_after" = Option<u64>, Query, description = "Minimum modification timestamp"),
|
||||
("modified_before" = Option<u64>, Query, description = "Maximum modification timestamp"),
|
||||
("min_size" = Option<u64>, Query, description = "Minimum file size (bytes)"),
|
||||
("max_size" = Option<u64>, Query, description = "Maximum file size (bytes)"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = SearchResultsDto),
|
||||
(status = 200, description = "Search results (cursor-paginated envelope shared with /*/resources)", body = SearchResourcesDto),
|
||||
(status = 503, description = "Search service unavailable"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "search"
|
||||
)]
|
||||
pub async fn search_files_get(
|
||||
pub async fn search_resources(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
query: Query<SearchParams>,
|
||||
query: Query<SearchResourcesQuery>,
|
||||
) -> impl IntoResponse {
|
||||
SearchHandler::search_files_get_impl(state, auth_user, query).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/search/advanced",
|
||||
request_body(content = SearchCriteriaDto, content_type = "application/json", description = "Search criteria"),
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = SearchResultsDto),
|
||||
(status = 503, description = "Search service unavailable"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "search"
|
||||
)]
|
||||
pub async fn search_files_post(
|
||||
state: State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
json: Json<SearchCriteriaDto>,
|
||||
) -> impl IntoResponse {
|
||||
SearchHandler::search_files_post_impl(state, auth_user, json).await
|
||||
SearchHandler::search_resources_impl(state, auth_user, query).await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -34,8 +34,8 @@ use crate::application::dtos::i18n_dto::{
|
||||
use crate::application::dtos::pagination::{PaginationDto, PaginationRequestDto};
|
||||
use crate::application::dtos::recent_dto::{RecentItemDto, RecentResourceItemDto};
|
||||
use crate::application::dtos::search_dto::{
|
||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchResultsDto,
|
||||
SearchSuggestionItem, SearchSuggestionsDto,
|
||||
SearchCriteriaDto, SearchFileResultDto, SearchFolderResultDto, SearchMeta, SearchResourceItem,
|
||||
SearchResourcesDto, SearchResultsDto, SearchSuggestionItem, SearchSuggestionsDto,
|
||||
};
|
||||
use crate::application::dtos::share_dto::{CreateShareDto, ShareDto, UpdateShareDto};
|
||||
use crate::application::dtos::trash_dto::{
|
||||
@@ -103,8 +103,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::folder_handler::delete_folder_with_trash,
|
||||
handlers::folder_handler::download_folder_zip,
|
||||
// Search handlers (free functions — see search_handler.rs for why)
|
||||
handlers::search_handler::search_files_get,
|
||||
handlers::search_handler::search_files_post,
|
||||
handlers::search_handler::search_resources,
|
||||
handlers::search_handler::suggest_files,
|
||||
handlers::search_handler::clear_search_cache,
|
||||
// i18n handlers (free functions — see i18n_handler.rs for why)
|
||||
@@ -302,7 +301,15 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
MoveToTrashRequest,
|
||||
RestoreFromTrashRequest,
|
||||
DeletePermanentlyRequest,
|
||||
// Search schemas
|
||||
// Search schemas — wire envelope shares the /*/resources
|
||||
// shape (SearchResourcesDto → items[] { resource_type,
|
||||
// resource, meta }). The internal SearchCriteriaDto /
|
||||
// SearchResultsDto types are still emitted so external
|
||||
// consumers browsing the OpenAPI doc can see the service-
|
||||
// layer shape referenced by other docs.
|
||||
SearchResourcesDto,
|
||||
SearchResourceItem,
|
||||
SearchMeta,
|
||||
SearchCriteriaDto,
|
||||
SearchResultsDto,
|
||||
SearchFileResultDto,
|
||||
|
||||
@@ -88,9 +88,7 @@ use crate::interfaces::api::handlers::folder_handler::{
|
||||
use crate::interfaces::api::handlers::i18n_handler::{
|
||||
get_locales, get_translations_by_locale, translate,
|
||||
};
|
||||
use crate::interfaces::api::handlers::search_handler::{
|
||||
search_files_get, search_files_post, suggest_files,
|
||||
};
|
||||
use crate::interfaces::api::handlers::search_handler::{search_resources, suggest_files};
|
||||
use crate::interfaces::api::handlers::trash_handler;
|
||||
|
||||
/// Creates root-level health check routes — mounted directly at `/`, not under `/api/`.
|
||||
@@ -298,11 +296,14 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
let search_router = if search_service.is_some() {
|
||||
Router::new()
|
||||
// Simple search with query parameters
|
||||
.route("/", get(search_files_get))
|
||||
// Cursor-paginated search with the `/*/resources` envelope
|
||||
// (items + next_cursor + meta). `POST /search/advanced` was
|
||||
// deleted alongside this normalization — every field it
|
||||
// accepted fits fine as a query param, and it shared 100%
|
||||
// of the service call with GET (no fast-vs-deep semantics).
|
||||
.route("/", get(search_resources))
|
||||
// Lightweight autocomplete suggestions
|
||||
.route("/suggest", get(suggest_files))
|
||||
// Advanced search with full criteria object
|
||||
.route("/advanced", post(search_files_post))
|
||||
// `DELETE /api/search/cache` used to live here as a per-user-
|
||||
// reachable endpoint. It's an operator-only debug lever
|
||||
// (moka `invalidate_all()` — nukes every tenant), so it
|
||||
|
||||
+17
-14
@@ -126,7 +126,12 @@ Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.files" count >= 1
|
||||
# `/api/search` was normalised to the `/*/resources` envelope in
|
||||
# PR search-normalize (2026-07): items[] carry `resource_type` +
|
||||
# a `resource` (File | Folder | Drive) + inline search-meta. This
|
||||
# assertion checks the same anti-regression property as before
|
||||
# (needle file surfaces to its owner) against the new wire shape.
|
||||
jsonpath "$.items" count >= 1
|
||||
body contains "{{needle_file_id}}"
|
||||
|
||||
|
||||
@@ -140,8 +145,7 @@ Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.files" count == 0
|
||||
jsonpath "$.folders" count == 0
|
||||
jsonpath "$.items" count == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -236,7 +240,7 @@ delay: 2500ms
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Admin sees the content match — proves indexing landed.
|
||||
jsonpath "$.files" count >= 1
|
||||
jsonpath "$.items" count >= 1
|
||||
body contains "{{canary_file_id}}"
|
||||
|
||||
|
||||
@@ -248,23 +252,22 @@ HTTP 200
|
||||
# Bob has no access to admin's drive → Tantivy's Must-clause
|
||||
# filters every doc that doesn't carry one of Bob's drive_ids,
|
||||
# so the file vanishes entirely.
|
||||
jsonpath "$.files" count == 0
|
||||
jsonpath "$.folders" count == 0
|
||||
jsonpath "$.items" count == 0
|
||||
body not contains "{{canary_file_id}}"
|
||||
body not contains "ContentIndexCanaryXyzzy2026Drive"
|
||||
# Anti-enum: every count the response surfaces must reflect the
|
||||
# FILTERED set — i.e. zero when the caller has no accessible
|
||||
# hits. The §11 rule is "no 'you have N hidden matches' field
|
||||
# anywhere". `total_count` is a legitimate pagination count and
|
||||
# is OK as long as it equals the filtered total (zero here). The
|
||||
# other field names below MUST stay absent: a future field
|
||||
# called `hidden_count`/`filtered`/etc. that reveals matches
|
||||
# Bob can't see would be the regression.
|
||||
jsonpath "$.total_count" == 0
|
||||
jsonpath "$.has_more" == false
|
||||
# anywhere". `total` is the visible-to-caller count (permission-
|
||||
# filtered SUM); it's OK when it equals the visible total (zero
|
||||
# here). Old `total_count` / `has_more` names are retired with
|
||||
# the `files/folders` split. Names below MUST stay absent — a
|
||||
# future field like `hidden_count`/`filtered`/etc. that reveals
|
||||
# matches Bob can't see would be the regression.
|
||||
jsonpath "$.total" == 0
|
||||
jsonpath "$.next_cursor" not exists
|
||||
jsonpath "$.hidden_count" not exists
|
||||
jsonpath "$.filtered" not exists
|
||||
jsonpath "$.total" not exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user