Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list

feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
Dionisio Pozo
2026-07-22 06:51:28 +02:00
committed by GitHub
50 changed files with 1140 additions and 287 deletions
+4
View File
@@ -154,6 +154,8 @@ mod before {
etag, etag,
created_by: parts.created_by, created_by: parts.created_by,
updated_by: parts.updated_by, updated_by: parts.updated_by,
is_favorite: false,
is_shared: false,
} }
} }
@@ -179,6 +181,8 @@ mod before {
etag, etag,
created_by: folder.created_by(), created_by: folder.created_by(),
updated_by: folder.updated_by(), updated_by: folder.updated_by(),
is_favorite: false,
is_shared: false,
} }
} }
} }
+2
View File
@@ -111,6 +111,8 @@ fn fixture_folder() -> FolderDto {
category: Arc::from("Folder"), category: Arc::from("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
} }
} }
+20
View File
@@ -97,6 +97,8 @@ fn rows(n: usize) -> Vec<FolderResourceRow> {
}, },
created_by: Some(Uuid::new_v4()), created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()), updated_by: Some(Uuid::new_v4()),
is_favorite: false,
is_shared: false,
sort_str: format!("row {i}"), sort_str: format!("row {i}"),
type_order: 0, type_order: 0,
folder_first: if is_folder { 0 } else { 1 }, folder_first: if is_folder { 0 } else { 1 },
@@ -130,6 +132,8 @@ fn map_before(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
(dto.name, dto.icon_class, dto.category) (dto.name, dto.icon_class, dto.category)
} else { } else {
@@ -163,6 +167,8 @@ fn map_before(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
etag, etag,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
(dto.name, dto.icon_class, dto.category) (dto.name, dto.icon_class, dto.category)
} }
@@ -191,6 +197,8 @@ fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
(dto.name, dto.icon_class, dto.category) (dto.name, dto.icon_class, dto.category)
} else { } else {
@@ -227,6 +235,8 @@ fn map_after(rows: Vec<FolderResourceRow>) -> Vec<Probe> {
etag, etag,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
(dto.name, dto.icon_class, dto.category) (dto.name, dto.icon_class, dto.category)
} }
@@ -266,6 +276,8 @@ fn fav_rows(n: usize) -> Vec<FavoriteResourceRow> {
}, },
created_by: Some(Uuid::new_v4()), created_by: Some(Uuid::new_v4()),
updated_by: Some(Uuid::new_v4()), updated_by: Some(Uuid::new_v4()),
is_favorite: true,
is_shared: false,
is_owner: true, is_owner: true,
favorited_at: ts, favorited_at: ts,
path: Some(format!("Documents/Work/item-{i:05}")), path: Some(format!("Documents/Work/item-{i:05}")),
@@ -314,6 +326,8 @@ fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
( (
dto.name, dto.name,
@@ -353,6 +367,8 @@ fn fav_map_before(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
etag, etag,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
( (
dto.name, dto.name,
@@ -393,6 +409,8 @@ fn fav_map_after(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
( (
dto.name, dto.name,
@@ -435,6 +453,8 @@ fn fav_map_after(rows: Vec<FavoriteResourceRow>) -> Vec<FavProbe> {
etag, etag,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}; };
( (
dto.name, dto.name,
+4
View File
@@ -242,6 +242,8 @@ mod before {
etag, etag,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
} }
} }
} }
@@ -297,6 +299,8 @@ fn folder_dtos(n: usize) -> Vec<FolderDto> {
category: Arc::from("Folder"), category: Arc::from("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
}) })
.collect() .collect()
} }
@@ -29,9 +29,7 @@ function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
const emptyListing = (): FolderListing => ({ const emptyListing = (): FolderListing => ({
folders: [], folders: [],
files: [], files: []
favoriteIds: [],
sharedIds: []
}); });
beforeEach(() => { beforeEach(() => {
@@ -56,7 +54,6 @@ describe('fetchFolderListing (cursor-paginated /resources)', () => {
expect(r.status).toBe(200); expect(r.status).toBe(200);
expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']); expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']); expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
expect(r.listing?.favoriteIds).toEqual([]);
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources'); expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
}); });
@@ -124,9 +121,7 @@ describe('folder name cache (breadcrumbs)', () => {
it("learns its children's names from a cached listing", () => { it("learns its children's names from a cached listing", () => {
cacheFolder('nc-parent', { cacheFolder('nc-parent', {
folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')], folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')],
files: [], files: []
favoriteIds: [],
sharedIds: []
}); });
expect(getFolderName('nc-a')).toBe('Alpha'); expect(getFolderName('nc-a')).toBe('Alpha');
expect(getFolderName('nc-b')).toBe('Beta'); expect(getFolderName('nc-b')).toBe('Beta');
+5 -6
View File
@@ -13,10 +13,9 @@ const NO_CACHE: RequestInit = {
export interface FolderListing { export interface FolderListing {
folders: FolderItem[]; folders: FolderItem[];
files: FileItem[]; files: FileItem[];
/** Ids in this listing the caller has favorited (server-computed badge set). */ // Legacy `favoriteIds` / `sharedIds` fields are gone — the same
favoriteIds: string[]; // signals now live inline on every `FileItem` / `FolderItem`
/** Ids in this listing the caller has an outgoing share/grant on. */ // (`is_favorite`, `is_shared`). Consumers read those directly.
sharedIds: string[];
} }
/** Result of a (possibly conditional) listing fetch. */ /** Result of a (possibly conditional) listing fetch. */
@@ -213,13 +212,13 @@ export async function fetchFolderListing(
files.push(...page.files); files.push(...page.files);
cursor = page.nextCursor; cursor = page.nextCursor;
} while (cursor); } while (cursor);
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } }; return { status: 200, listing: { folders, files } };
} }
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */ /** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> { export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const res = await fetchFolderListing(folderId, { forceRefresh }); const res = await fetchFolderListing(folderId, { forceRefresh });
return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] }; return res.listing ?? { folders: [], files: [] };
} }
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> { export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
+15
View File
@@ -89,6 +89,21 @@ export function expiryToIso(date: string | null | undefined): string | null {
return date ? new Date(`${date}T00:00:00Z`).toISOString() : null; return date ? new Date(`${date}T00:00:00Z`).toISOString() : null;
} }
/**
* Today's date in YYYY-MM-DD form (local time zone). Used as the `min`
* attribute on grant / share expiry date inputs so the native picker
* refuses to select a past date. Callers should also validate the
* changed value in their `onchange` handler as a belt-and-braces guard
* (some browsers still let scripted / paste input bypass `min`).
*/
export function todayIso(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise<Grant[]> { export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise<Grant[]> {
const params = new URLSearchParams({ resource_type: type, resource_id: id }); const params = new URLSearchParams({ resource_type: type, resource_id: id });
return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' }); return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' });
+19
View File
@@ -48,6 +48,21 @@ export interface FolderItem {
* leading segment being a drive-root folder id. * leading segment being a drive-root folder id.
*/ */
drive_id: string; drive_id: string;
/**
* Caller-scoped: `true` when the requesting user has favorited
* this folder. Always present on the wire — never null, never
* absent — per the backend enrichment contract. `ResourceList`
* renders the fav-star chip natively from this field.
*/
is_favorite: boolean;
/**
* Resource-scoped: `true` when the folder has any
* `storage.role_grants` entry (link share via `subject_type =
* 'token'`, user grant, group grant, any role). "Someone was
* given access to this beyond drive membership." Always present
* on the wire.
*/
is_shared: boolean;
} }
export interface FileItem { export interface FileItem {
@@ -70,6 +85,10 @@ export interface FileItem {
sort_date: number; sort_date: number;
etag: string; etag: string;
content_hash: string; content_hash: string;
/** See `FolderItem.is_favorite` — same wire contract. */
is_favorite: boolean;
/** See `FolderItem.is_shared` — same wire contract. */
is_shared: boolean;
/** Search-only: plain-text fragment around a content match. */ /** Search-only: plain-text fragment around a content match. */
snippet?: string; snippet?: string;
/** Search-only: "name" or "content". */ /** Search-only: "name" or "content". */
@@ -69,9 +69,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
m(listFolder).mockResolvedValue({ m(listFolder).mockResolvedValue({
folders: [folder('sub1', 'Sub')], folders: [folder('sub1', 'Sub')],
files: [], files: []
favoriteIds: [],
sharedIds: []
}); });
}); });
+55 -27
View File
@@ -119,13 +119,6 @@
* fields (`modified_at`, `created_by`). * fields (`modified_at`, `created_by`).
*/ */
contextMap?: Map<string, ItemContext>; contextMap?: Map<string, ItemContext>;
/**
* Set of item ids the caller considers "favorite". When
* provided, the star widget renders next to each row and
* `onfavorite` is invoked on click. Kept as an external Set so
* the page owns the source of truth (e.g. the favorites store).
*/
favoriteIds?: Set<string>;
/** /**
* Resolve `userId → display name`. Optional; when absent * Resolve `userId → display name`. Optional; when absent
* `UserVignette` falls back to its own internal resolution. * `UserVignette` falls back to its own internal resolution.
@@ -213,6 +206,14 @@
onopen?: (item: FileItem | FolderItem) => void; onopen?: (item: FileItem | FolderItem) => void;
/** Per-item favorite star toggle. */ /** Per-item favorite star toggle. */
onfavorite?: (item: FileItem | FolderItem) => void; onfavorite?: (item: FileItem | FolderItem) => void;
/**
* Per-item share affordance — opens the page's ShareDialog for
* this row. Wired symmetrically to `onfavorite`: the button lives
* in `.action-cell`, its `.active` styling tracks `item.is_shared`,
* and rows that are shared keep the button visible in list view
* even when the row isn't hovered.
*/
onshared?: (item: FileItem | FolderItem) => void;
/** Selection changed (set of selected item ids). */ /** Selection changed (set of selected item ids). */
onselectionchange?: (ids: Set<string>) => void; onselectionchange?: (ids: Set<string>) => void;
/** /**
@@ -353,7 +354,6 @@
title, title,
items, items,
contextMap, contextMap,
favoriteIds,
resolveOwnerName, resolveOwnerName,
loading = false, loading = false,
error = null, error = null,
@@ -383,6 +383,7 @@
onreload, onreload,
onopen, onopen,
onfavorite, onfavorite,
onshared,
onselectionchange, onselectionchange,
oncontextmenu: onContextMenuOverride, oncontextmenu: onContextMenuOverride,
menuPrepare, menuPrepare,
@@ -463,7 +464,11 @@
// gate below. Feeds both the list-view column track and the header // gate below. Feeds both the list-view column track and the header
// row's trailing placeholder so the layout stays in sync. // row's trailing placeholder so the layout stays in sync.
const hasActionCell = $derived( const hasActionCell = $derived(
!!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length !!onfavorite ||
!!onshared ||
!!itemActions ||
!!onContextMenuOverride ||
!!contextActions?.length
); );
// Build the list-view column track from the enabled cells. // Build the list-view column track from the enabled cells.
@@ -978,7 +983,6 @@
{#snippet row(item: FileItem | FolderItem)} {#snippet row(item: FileItem | FolderItem)}
{@const kind = isFile(item) ? 'file' : 'folder'} {@const kind = isFile(item) ? 'file' : 'folder'}
{@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))} {@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))}
{@const isFav = favoriteIds?.has(item.id) ?? false}
{@const ctx = ctxOf(item.id)} {@const ctx = ctxOf(item.id)}
{@const ownerId = ownerIdOf(item)} {@const ownerId = ownerIdOf(item)}
{@const dateVal = dateOf(item)} {@const dateVal = dateOf(item)}
@@ -1060,11 +1064,11 @@
}} }}
/> />
{/if} {/if}
<!-- Row badge (e.g. /trash's expiration chip) sits absolutely <!-- `rowBadge` snippet — page-specific extension slot (e.g.
inside `.file-icon` — one DOM location, one render, works `/trash`'s expiration chip). `is_favorite` / `is_shared`
for both views. Because it's positioned absolutely it are NOT rendered here — both are surfaced as buttons in
never affects the row/card height; the surrounding `.action-cell` so the two flags share a single visual
layout can't stretch to accommodate it. --> grammar (button whose `.active` styling tracks the flag). -->
{#if rowBadge} {#if rowBadge}
<span class="file-icon__badge">{@render rowBadge(item, ctx)}</span> <span class="file-icon__badge">{@render rowBadge(item, ctx)}</span>
{/if} {/if}
@@ -1106,31 +1110,55 @@
</div> </div>
<!-- <!--
Every row that surfaces an action puts everything into a single Every row that surfaces an action puts everything into a single
`.action-cell` — the shared `ported/resourceList.css` styles both `.action-cell` — the shared `ported/resourceList.css` styles the
the favorite-star and the `.file-actions` kebab expecting them to favorite-star, the shared-button, and the `.file-actions` kebab
live inside `.action-cell` (grid view uses the corner-overlay CSS expecting them to live inside `.action-cell` (grid view uses the
to float `.file-actions` into the top-right; list view flexes them corner-overlay CSS to float them into the top-right; list view
inline). The cell renders when ANY of favorite / itemActions / flexes them inline). The cell renders when ANY of favorite /
context-menu is enabled; a row with none of those still lays out shared / itemActions / context-menu is enabled; a row with none
cleanly because the columns collapse via the grid track. of those still lays out cleanly because the columns collapse via
the grid track.
Fav-star and shared-button share the same interaction grammar:
each toggles/opens the corresponding affordance, each carries an
`.active` class that tracks its DTO flag, and (via CSS in
`ported/resourceList.css`) each stays visible in list view even
without a row hover when its `.active` flag is set — so a
favorited or shared row is discoverable at a glance without the
user having to mouse over it.
--> -->
{#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length} {#if hasActionCell}
<div class="action-cell"> <div class="action-cell">
{#if onshared}
<button
class="shared-button"
class:active={item.is_shared}
data-testid={`resource-list-shared-${item.id}-btn`}
title={item.is_shared ? t('files.shared', 'Shared') : t('files.share', 'Share')}
aria-pressed={item.is_shared}
onclick={(e) => {
e.stopPropagation();
onshared(item);
}}
>
<Icon name="oxiexport" />
</button>
{/if}
{#if onfavorite} {#if onfavorite}
<button <button
class="favorite-star" class="favorite-star"
class:active={isFav} class:active={item.is_favorite}
data-testid={`resource-list-favorite-${item.id}-btn`} data-testid={`resource-list-favorite-${item.id}-btn`}
title={isFav title={item.is_favorite
? t('files.unfavorite', 'Remove favorite') ? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')} : t('files.favorite', 'Add favorite')}
aria-pressed={isFav} aria-pressed={item.is_favorite}
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
onfavorite(item); onfavorite(item);
}} }}
> >
<Icon name={isFav ? 'star' : 'star-outline'} /> <Icon name={item.is_favorite ? 'star' : 'star-outline'} />
</button> </button>
{/if} {/if}
{#if itemActions}{@render itemActions(item)}{/if} {#if itemActions}{@render itemActions(item)}{/if}
+82 -15
View File
@@ -14,6 +14,7 @@
fetchGrantsForResource, fetchGrantsForResource,
notifyGrantRecipient, notifyGrantRecipient,
revokeGrant, revokeGrant,
todayIso,
updateGrantRole, updateGrantRole,
type Grant, type Grant,
type GrantSubject, type GrantSubject,
@@ -378,15 +379,33 @@
}); });
</script> </script>
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── --> <!-- ── Reusable expiry chip ───────────────────────────────────────────────
Both branches drive a `<input type="date">` and open the native
picker via `HTMLInputElement.showPicker()`. The previous invisible-
overlay trick (an `opacity: 0` input covering a chip label) was
unreliable — some browsers refuse to open the picker for a hidden
input, which is why "Set expiry" appeared inert. `showPicker()`
is the modern, explicit path and works from a button click.
`min={todayIso()}` (from `../../api/endpoints/grants`) hard-caps
the picker to today-or-later so a past date can't be selected.
The `onchange` handler mirrors the same check as a belt-and-braces
guard against the min attribute being ignored. -->
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)} {#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
{@const today = todayIso()}
<span class="chip-edit"> <span class="chip-edit">
{#if value} {#if value}
<input <input
class="chip-edit__date" class="chip-edit__date"
type="date" type="date"
value={value ?? ''} value={value ?? ''}
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)} min={today}
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
if (v && v < today) return;
onchange(v || null);
}}
aria-label={t('share.expiry', 'Expiry')} aria-label={t('share.expiry', 'Expiry')}
/> />
<button <button
@@ -396,16 +415,34 @@
aria-label={t('actions.clear', 'Clear')}>×</button aria-label={t('actions.clear', 'Clear')}>×</button
> >
{:else} {:else}
<label class="chip chip--ghost"> <span class="chip-edit__ghost">
<Icon name="infinity" /> <button
<span>{t('share.noExpiry', 'No expiry')}</span> type="button"
<input class="chip chip--ghost"
class="chip-edit__date chip-edit__date--hidden"
type="date"
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.set_expiry', 'Set expiry')} aria-label={t('share.set_expiry', 'Set expiry')}
onclick={(e) => {
const picker = (e.currentTarget as HTMLElement)
.nextElementSibling as HTMLInputElement | null;
picker?.showPicker?.();
picker?.focus();
}}
>
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
</button>
<input
class="chip-edit__date chip-edit__date--offscreen"
type="date"
min={today}
aria-hidden="true"
tabindex="-1"
onchange={(e) => {
const v = (e.currentTarget as HTMLInputElement).value;
if (v && v < today) return;
onchange(v || null);
}}
/> />
</label> </span>
{/if} {/if}
</span> </span>
{/snippet} {/snippet}
@@ -584,7 +621,12 @@
type="date" type="date"
data-testid="share-dialog-link-expires-input" data-testid="share-dialog-link-expires-input"
value={expiresAt ?? ''} value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)} min={todayIso()}
onchange={(e) => {
const v = e.currentTarget.value;
if (v && v < todayIso()) return;
expiresAt = v || null;
}}
/> />
</label> </label>
</div> </div>
@@ -892,7 +934,19 @@
.chip--ghost { .chip--ghost {
border-style: dashed; border-style: dashed;
color: var(--color-text-muted); /* WCAG-friendly foreground on both light and dark surfaces —
`--color-text-muted` was under the minimum AA contrast ratio,
making "No expiry" hard to read. Use the subtle-but-not-muted
text token instead, and give the ghost chip a low-tint
background so it visually separates from the modal body. */
color: var(--color-text-subtle);
background: var(--color-bg-input);
}
.chip--ghost:hover,
.chip--ghost:focus-visible {
color: var(--color-text);
background: var(--color-border-subtle);
} }
.chip-edit__date { .chip-edit__date {
@@ -904,11 +958,24 @@
font-size: var(--text-sm); font-size: var(--text-sm);
} }
.chip-edit__date--hidden { /* Positions the hidden `<input type="date">` off-screen (no `display:
none` — `showPicker()` refuses to open on a display:none input in
several browsers). The button next to it invokes `showPicker()`
programmatically. */
.chip-edit__ghost {
position: relative;
display: inline-flex;
align-items: center;
}
.chip-edit__date--offscreen {
position: absolute; position: absolute;
inset: 0; width: 1px;
height: 1px;
left: 0;
bottom: 0;
opacity: 0; opacity: 0;
cursor: pointer; pointer-events: none;
} }
.chip-edit__clear { .chip-edit__clear {
@@ -18,6 +18,7 @@ vi.mock('$lib/api/endpoints/grants', () => ({
fetchGrantsForResource: vi.fn(), fetchGrantsForResource: vi.fn(),
notifyGrantRecipient: vi.fn(), notifyGrantRecipient: vi.fn(),
revokeGrant: vi.fn(), revokeGrant: vi.fn(),
todayIso: () => '2026-07-22',
updateGrantRole: vi.fn() updateGrantRole: vi.fn()
})); }));
vi.mock('$lib/api/endpoints/recipients', () => ({ vi.mock('$lib/api/endpoints/recipients', () => ({
@@ -43,6 +43,8 @@ interface TestFile {
sort_date: number; sort_date: number;
etag: string; etag: string;
content_hash: string; content_hash: string;
is_favorite: boolean;
is_shared: boolean;
} }
function fileItem(i: number): TestFile { function fileItem(i: number): TestFile {
@@ -63,7 +65,9 @@ function fileItem(i: number): TestFile {
size_formatted: '4 B', size_formatted: '4 B',
sort_date: 0, sort_date: 0,
etag: 'e', etag: 'e',
content_hash: 'h' content_hash: 'h',
is_favorite: false,
is_shared: false
}; };
} }
+147 -53
View File
@@ -178,10 +178,6 @@
background-color: var(--color-warning-ring); background-color: var(--color-warning-ring);
} }
.file-badge-shared {
color: var(--color-badge-blue-text);
}
.file-item .file-icon > i, .file-item .file-icon > i,
.file-item .file-icon > svg { .file-item .file-icon > svg {
position: absolute; position: absolute;
@@ -460,7 +456,17 @@
} }
.files-list-view .file-item .action-cell { .files-list-view .file-item .action-cell {
/* Right-justified flex row: gives every list-view action button
(shared, fav, broom, kebab, trash's restore/delete, …) a
predictable horizontal cluster with a stable gap. The legacy
`display: inline` on individual buttons ignored `width`/`height`,
so once /recent grew a 4th button the mix of `inline` + `inline-
flex` (`.btn-action`) started wrapping onto pseudo-rows that
read as a broken grid. Flex flattens the mix into one line. */
display: flex;
justify-content: flex-end;
align-items: center; align-items: center;
gap: var(--space-1);
text-align: right; text-align: right;
} }
@@ -493,27 +499,44 @@
color: var(--color-text-dark); color: var(--color-text-dark);
} }
/* could be visible if we want */ /* Fav-star + shared-button share the same visibility rule: hidden on
.files-list-view .file-item .action-cell button.favorite-star { quiet rows, visible on row hover, and — crucially — always visible
display: none; 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.
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.favorite-star,
.files-list-view .file-item .action-cell button.shared-button {
visibility: hidden;
border: none; border: none;
} }
.files-list-view .file-item:hover .action-cell button.favorite-star { .files-list-view .file-item:hover .action-cell button.favorite-star,
display: inline; .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;
} }
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable /* Reveal the kebab on hover for cleaner rows — but only on hover-capable
devices, so touch users (no hover) keep it always tappable. Stays visible devices, so touch users (no hover) keep it always tappable. Stays visible
on keyboard focus within the row. */ on keyboard focus within the row. Applies to both list and grid views
because both keep the kebab inside `.action-cell`. */
@media (hover: hover) { @media (hover: hover) {
.files-list-view .file-item .action-cell button.file-actions { .files-list-view .file-item .action-cell button.file-actions,
.files-grid-view .file-item .action-cell button.file-actions {
opacity: 0; opacity: 0;
transition: opacity var(--motion-fast) var(--ease-standard); transition: opacity var(--motion-fast) var(--ease-standard);
} }
.files-list-view .file-item:hover .action-cell button.file-actions, .files-list-view .file-item:hover .action-cell button.file-actions,
.files-list-view .file-item:focus-within .action-cell button.file-actions { .files-list-view .file-item:focus-within .action-cell button.file-actions,
.files-grid-view .file-item:hover .action-cell button.file-actions,
.files-grid-view .file-item:focus-within .action-cell button.file-actions {
opacity: 1; opacity: 1;
} }
} }
@@ -737,26 +760,95 @@
z-index: 10; z-index: 10;
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
opacity: 0;
transition: opacity var(--motion-fast) var(--ease-standard);
} }
.files-grid-view .file-item:hover .action-cell, /* Full-width top action bar — only applies when the row surfaces at
.files-grid-view .file-item:focus-within .action-cell, least one state chip (shared or favorite). Trash (no shared / no
.files-grid-view .file-item .action-cell:has(.favorite-star.active) { favorite) falls through to the horizontal corner cluster above,
opacity: 1; preserving its Restore / Delete / kebab layout.
Layout target:
┌────────────────────────────────────────────┐
│[chk] [kebab] [btn-action…] [shared] [fav] │ ← single row
│ │
│ (thumbnail) │
│ │
└────────────────────────────────────────────┘
The checkbox stays where it is (its own `.checkbox-cell` absolute at
top-left); the action-cell spans horizontally next to it so the two
clusters read as one row. `justify-content: space-between` + `order`
splits the flex row into a left group (kebab + btn-actions) and a
right group (shared + favorite) without fighting DOM order — the
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));
right: calc(var(--space-3) + 8px);
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-1);
} }
/* The favorite state is already shown by the corner star button, so the inline .files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .file-actions {
name-cell favorite badge is redundant on grid cards. */ order: 1;
.files-grid-view .file-item .name-cell .item-badge { }
display: none;
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .btn-action {
order: 2;
}
/* The first "right group" item eats all remaining horizontal space via
`margin-left: auto`, which is the flexbox idiom for splitting a row
into left+right clusters without wrapping the two groups in extra
containers. When both buttons are wired, shared is first (`order: 3`)
and takes the push; fav follows with the normal gap. When only fav
is wired (`/shared-with-me` — recipient side, no share affordance),
fav is the sole right-group item and takes the push instead. */
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .shared-button {
order: 3;
margin-left: auto;
}
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .favorite-star {
order: 4;
}
.files-grid-view
.file-item
.action-cell:has(.favorite-star):not(:has(.shared-button))
.favorite-star {
margin-left: auto;
}
/* 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. */
.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);
}
.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;
} }
/* Chip visuals for anything inside the corner cluster — the kebab, the star, /* Chip visuals for anything inside the corner cluster — the kebab, the star,
any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */ the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they
line up in the flex row. */
.files-grid-view .file-item .action-cell .file-actions, .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 .favorite-star,
.files-grid-view .file-item .action-cell .shared-button,
.files-grid-view .file-item .action-cell .btn-action { .files-grid-view .file-item .action-cell .btn-action {
position: static; position: static;
width: 30px; width: 30px;
@@ -792,36 +884,15 @@
border: 2px dashed var(--color-warning-border); border: 2px dashed var(--color-warning-border);
} }
/* "Shared" indicator — top-left of the thumbnail. Sits below the checkbox /* Favorite star + shared button — visual overrides only. Position,
(which only appears on hover), so the two never both compete for the eye. */ hover-reveal, chip geometry all come from the shared corner-cluster
.files-grid-view .file-item .file-badge-shared { rule on `.files-grid-view .file-item .action-cell`. What's left
position: absolute; here is just the per-state colour: subtle at rest, saturated when
top: calc(var(--space-3) + 8px); the item's flag is set. `.active` on either button also bumps the
left: calc(var(--space-3) + 8px); parent cluster's opacity (via `:has()` above) so an unhovered card
width: 24px; still shows its favorited/shared state. */
height: 24px; .files-grid-view .file-item button.favorite-star,
border-radius: var(--radius-full); .files-grid-view .file-item button.shared-button {
border: none;
background: var(--color-scrim-control);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
box-shadow: 0 1px 3px var(--color-shadow-sm);
display: flex;
align-items: center;
justify-content: center;
z-index: 9;
font-size: var(--text-2xs);
padding: 0;
line-height: var(--leading-none);
}
/* Favorite star — 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 star's per-state colour: subtle at rest, active-gold when the
item is a favorite. `.active` still bumps the parent cluster's
opacity so an unhovered card can still show its star. */
.files-grid-view .file-item button.favorite-star {
color: var(--color-text-subtle); color: var(--color-text-subtle);
font-size: 15px; font-size: 15px;
line-height: var(--leading-none); line-height: var(--leading-none);
@@ -839,6 +910,11 @@
color: var(--color-star-active); 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);
}
.files-grid-view .file-item .name-cell { .files-grid-view .file-item .name-cell {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
@@ -1201,6 +1277,24 @@
color: var(--color-text-dark); color: var(--color-text-dark);
} }
/* 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. */
.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);
}
.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;
}
/* Legacy: a margin-top on `.btn-action` in grid view for the era when /* Legacy: a margin-top on `.btn-action` in grid view for the era when
these buttons flowed at the bottom of the card. Kept for any these buttons flowed at the bottom of the card. Kept for any
free-standing use outside the corner cluster; reset inside free-standing use outside the corner cluster; reset inside
+5 -1
View File
@@ -38,6 +38,10 @@ export function minimalPhotoItem(id: string): FileItem {
size_formatted: '', size_formatted: '',
sort_date: 0, sort_date: 0,
etag: '', etag: '',
content_hash: '' content_hash: '',
// Stub item — never wired to a live server response, so the
// two required wire flags default to the safe "not set" value.
is_favorite: false,
is_shared: false
}; };
} }
+10 -19
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { primeContextPage } from '$lib/utils/listContext'; import { primeContextPage } from '$lib/utils/listContext';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte'; import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
@@ -50,22 +50,15 @@
// action surfaces don't filter, algorithmic surfaces do. // action surfaces don't filter, algorithmic surfaces do.
// //
// ResourceList consumes raw `FileItem | FolderItem`; the favorites // ResourceList consumes raw `FileItem | FolderItem`; the favorites
// envelope contributes `favorited_at` via `date` in contextMap. All // envelope contributes `favorited_at` via `date` in contextMap.
// items on this page are favorites — pass every id in `favoriteIds` // Every item on this page has `is_favorite: true` by construction
// so the star widget lights up universally. // (the listing SQL hardcodes `TRUE AS is_favorite`), so the star
// widget lights up universally without any shadow set here.
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem)); const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2) // Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list // instead of rebuilding a fresh Map that re-hashes the whole accumulated list
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet. // on every infinite-scroll page.
const contextMap = new SvelteMap<string, ItemContext>(); const contextMap = new SvelteMap<string, ItemContext>();
// Persistent reactive set, updated in place per page (add the fresh page's
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
// whole accumulated list on every infinite-scroll page — that was O(N²)
// across a drain and, being a new instance each page, invalidated every
// mounted star reader. Every item on this page is a favorite, and removed
// items are no longer rendered, so the set only needs to be a superset of
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
const favoriteIds = new SvelteSet<string>();
const groupBys: GroupByDef[] = [ const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
@@ -114,10 +107,6 @@
resourceTypes: ['file', 'folder'] resourceTypes: ['file', 'folder']
}); });
raw = reset ? page.items : [...raw, ...page.items]; raw = reset ? page.items : [...raw, ...page.items];
// Keep the persistent favoriteIds set in sync incrementally: clear on
// reset, then add only this page's ids (benches/ROUND14.md §F2).
if (reset) favoriteIds.clear();
for (const it of page.items) favoriteIds.add(it.resource.id);
primeContextPage(contextMap, reset, page.items, (it) => [ primeContextPage(contextMap, reset, page.items, (it) => [
it.resource.id, it.resource.id,
{ date: it.favorited_at } { date: it.favorited_at }
@@ -164,7 +153,6 @@
try { try {
await removeFavorite(kind, item.id); await removeFavorite(kind, item.id);
raw = raw.filter((i) => i.resource.id !== item.id); raw = raw.filter((i) => i.resource.id !== item.id);
favoriteIds.delete(item.id);
} catch (e) { } catch (e) {
errorToast(e); errorToast(e);
} }
@@ -317,7 +305,6 @@
title={t('nav.favorites', 'Favorites')} title={t('nav.favorites', 'Favorites')}
{items} {items}
{contextMap} {contextMap}
{favoriteIds}
resolveOwnerName={(id) => owners.name(id)} resolveOwnerName={(id) => owners.name(id)}
{loading} {loading}
{error} {error}
@@ -328,6 +315,10 @@
onloadmore={() => load(false, orderByForGroup())} onloadmore={() => load(false, orderByForGroup())}
onopen={open} onopen={open}
onfavorite={unfavorite} onfavorite={unfavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner showOwner
showPath showPath
dateLabel={t('files.col_added', 'Added')} dateLabel={t('files.col_added', 'Added')}
@@ -108,7 +108,7 @@
return drivesStore.findByRootFolderId(pathSegments[0] ?? null); return drivesStore.findByRootFolderId(pathSegments[0] ?? null);
}); });
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); let listing = $state<FolderListing>({ folders: [], files: [] });
// Server-order accumulator — items in the exact sequence the backend // Server-order accumulator — items in the exact sequence the backend
// returned across pages, honouring `sortField`+`reversed` on the wire. // returned across pages, honouring `sortField`+`reversed` on the wire.
// Under order_by=name/type/size the server puts folders first then files; // Under order_by=name/type/size the server puts folders first then files;
@@ -176,14 +176,13 @@
let actionTarget = $state<ActionTarget | null>(null); let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null); let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badge sets for the current folder, seeded directly from // Favorite / shared state now lives inline on every `FileItem` /
// the listing response (server-computed, scoped to these items — no extra // `FolderItem` DTO (`is_favorite`, `is_shared` — see
// per-navigation fetch) and updated optimistically on mutation. // `frontend/src/lib/api/types.ts`). Populated by the backend
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying // listing SQL (per-row `EXISTS`) and single-item enrichment
// the whole set, and every other present-key `.has()` reader is spared // helper. The row-badge snippet and star gate read these
// (measured in selectionPatterns.bench.test.ts). // fields directly; the toggle path mutates the item in place
const favoriteIds = new SvelteSet<string>(); // inside `orderedItems`. No more `SvelteSet` shadowing.
const sharedIds = new SvelteSet<string>();
function openMove(kind: ItemType, id: string, name: string) { function openMove(kind: ItemType, id: string, name: string) {
actionTarget = { id, name, kind }; actionTarget = { id, name, kind };
@@ -203,17 +202,20 @@
} }
async function toggleFavorite(kind: ItemType, id: string) { async function toggleFavorite(kind: ItemType, id: string) {
const isFav = favoriteIds.has(id); // Server-authoritative `is_favorite` lives on every
// Optimistic toggle, reverted on failure. // `FileItem`/`FolderItem` DTO. Read → optimistic flip → server
if (isFav) favoriteIds.delete(id); // call → revert on failure. All state changes happen in-place
else favoriteIds.add(id); // on the item inside `orderedItems`; there is no shadow set.
const item = orderedItems.find((it) => it.id === id);
if (!item) return; // row scrolled off / navigated away mid-click
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try { try {
if (isFav) await removeFavorite(kind, id); if (wasFav) await removeFavorite(kind, id);
else await addFavorite(kind, id); else await addFavorite(kind, id);
} catch (e) { } catch (e) {
errorToast(e); errorToast(e);
if (isFav) favoriteIds.add(id); item.is_favorite = wasFav;
else favoriteIds.delete(id);
} }
} }
@@ -295,7 +297,7 @@
// Reset paging state: previous folder's cursor is meaningless here, // Reset paging state: previous folder's cursor is meaningless here,
// and mixing its rows with the new folder's would flash a wrong list. // and mixing its rows with the new folder's would flash a wrong list.
pageCursor = undefined; pageCursor = undefined;
listing = { folders: [], files: [], favoriteIds: [], sharedIds: [] }; listing = { folders: [], files: [] };
orderedItems = []; orderedItems = [];
loading = true; loading = true;
@@ -336,17 +338,13 @@
if (reset) { if (reset) {
listing = { listing = {
folders: page.folders, folders: page.folders,
files: page.files, files: page.files
favoriteIds: [],
sharedIds: []
}; };
orderedItems = page.items; orderedItems = page.items;
} else { } else {
listing = { listing = {
folders: [...listing.folders, ...page.folders], folders: [...listing.folders, ...page.folders],
files: [...listing.files, ...page.files], files: [...listing.files, ...page.files]
favoriteIds: listing.favoriteIds,
sharedIds: listing.sharedIds
}; };
orderedItems = [...orderedItems, ...page.items]; orderedItems = [...orderedItems, ...page.items];
} }
@@ -1009,7 +1007,13 @@
/** Batch add the selection to favorites — single /api/favorites/batch call. */ /** Batch add the selection to favorites — single /api/favorites/batch call. */
async function batchFavorites() { async function batchFavorites() {
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id)); // Build an id → item index so the "already favorite" filter is
// O(1) per selection member instead of an O(N·M) scan. Reused
// after success to flip `is_favorite` in place on each row.
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
const byId = new Map<string, FileItem | FolderItem>();
for (const it of orderedItems) byId.set(it.id, it);
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
if (items.length === 0) { if (items.length === 0) {
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info'); ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
clearSelection(); clearSelection();
@@ -1025,7 +1029,10 @@
}) })
}); });
if (!res.ok) throw new Error(`Server returned ${res.status}`); if (!res.ok) throw new Error(`Server returned ${res.status}`);
for (const it of items) favoriteIds.add(it.id); for (const it of items) {
const row = byId.get(it.id);
if (row) row.is_favorite = true;
}
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection(); clearSelection();
} catch (e) { } catch (e) {
@@ -1779,7 +1786,6 @@
<ResourceList <ResourceList
title={t('nav.files', 'Files')} title={t('nav.files', 'Files')}
items={rlItems} items={rlItems}
{favoriteIds}
emptyText={hiddenCount > 0 emptyText={hiddenCount > 0
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder') ? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
: t('files.empty_title', 'This folder is empty')} : t('files.empty_title', 'This folder is empty')}
@@ -1812,6 +1818,7 @@
}} }}
onopen={rlOnOpen} onopen={rlOnOpen}
onfavorite={rlOnFavorite} onfavorite={rlOnFavorite}
onshared={(item) => openShare(isFile(item) ? 'file' : 'folder', item.id, item.name)}
oncontextmenu={rlOnContextMenu} oncontextmenu={rlOnContextMenu}
onselectionchange={(ids) => replaceSet(selected, ids)} onselectionchange={(ids) => replaceSet(selected, ids)}
isDraggable={rlIsDraggable} isDraggable={rlIsDraggable}
@@ -1993,19 +2000,6 @@
<span>{t('common.delete', 'Delete')}</span> <span>{t('common.delete', 'Delete')}</span>
</button> </button>
{/snippet} {/snippet}
{#snippet rowBadge(item)}
{#if favoriteIds.has(item.id)}
<span class="item-badge item-badge--fav" title={t('files.favorited', 'Favorite')}>
<Icon name="star" />
</span>
{/if}
{#if sharedIds.has(item.id)}
<span class="file-badge file-badge-shared" title={t('files.shared', 'Shared')}>
<Icon name="oxiexport" />
</span>
{/if}
{/snippet}
</ResourceList> </ResourceList>
</div> </div>
@@ -2024,7 +2018,16 @@
{/if} {/if}
{#if shareDialog.component} {#if shareDialog.component}
{@const ShareDialog = shareDialog.component} {@const ShareDialog = shareDialog.component}
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} /> <ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => {
// Optimistic in-place flip so the shared chip appears on
// the row without waiting for the next listing refetch.
const row = orderedItems.find((it) => it.id === id);
if (row) row.is_shared = true;
}}
/>
{/if} {/if}
{#if fileViewer.component} {#if fileViewer.component}
{@const FileViewer = fileViewer.component} {@const FileViewer = fileViewer.component}
@@ -2182,7 +2185,7 @@
}} }}
> >
<Icon name="star" /> <Icon name="star" />
{favoriteIds.has(ctxTarget.id) {orderedItems.find((it) => it.id === ctxTarget!.id)?.is_favorite
? t('files.unfavorite', 'Remove favorite') ? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')} : t('files.favorite', 'Add favorite')}
</button> </button>
@@ -2214,27 +2217,21 @@
min-height: 100%; min-height: 100%;
} }
.item-badge { /* Scrim + menu must sit ABOVE `.page-sticky-header` (breadcrumb +
display: inline-flex; action bar, `z-index: var(--z-sticky)` = 100) so a click on the
align-items: center; breadcrumb or the action bar closes the menu. Otherwise the
margin-left: var(--space-1); sticky header covers the scrim and swallows the outside-click.
font-size: 0.75rem; Aligns with ResourceList's built-in menu (`.rl-ctx-scrim` uses
color: var(--color-text-muted); `1000` / `.rl-ctx-menu` uses `1001`). */
}
.item-badge--fav {
color: var(--color-warning-text, var(--color-accent));
}
.ctx-scrim { .ctx-scrim {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 90; z-index: 1000;
} }
.ctx-menu { .ctx-menu {
position: fixed; position: fixed;
z-index: 100; z-index: 1001;
min-width: 12rem; min-width: 12rem;
padding: var(--space-1); padding: var(--space-1);
background: var(--color-bg-surface); background: var(--color-bg-surface);
+30 -12
View File
@@ -16,6 +16,7 @@
import { import {
addFavorite, addFavorite,
dateBucket, dateBucket,
removeFavorite,
resolveOwnerName, resolveOwnerName,
sizeBucket, sizeBucket,
typeLabel typeLabel
@@ -314,24 +315,36 @@
} }
}, },
{ {
// "Add to favorites" — /recent doesn't track per-row favorite key: 'favorite_add',
// state (the star widget was replaced by the broom), so the
// entry always reads "Add" and the backend swallows duplicate
// adds idempotently. If the user wants to un-favorite, they
// navigate to /favorites and use the row menu there. Placed
// between Move and Rename to match the canonical context-menu
// order on `/files`.
key: 'favorite',
label: t('files.favorite', 'Add favorite'), label: t('files.favorite', 'Add favorite'),
icon: 'star', icon: 'star',
run: (item) => { visible: (item) => !item.is_favorite,
void addFavorite(kindOf(item), item.id).catch(errorToast); run: toggleFavorite
} },
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
}, },
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename }, { key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove } { key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
]; ];
async function toggleFavorite(item: FileItem | FolderItem) {
const kind = kindOf(item);
const wasFav = item.is_favorite;
item.is_favorite = !wasFav;
try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// ── Selection + batch ───────────────────────────────────────────────────── // ── Selection + batch ─────────────────────────────────────────────────────
// Selected items arrive via the batchActions snippet param — // Selected items arrive via the batchActions snippet param —
// ResourceList already derives them (O(selection), not O(N)); a // ResourceList already derives them (O(selection), not O(N)); a
@@ -372,6 +385,11 @@
hasMore={!!cursor} hasMore={!!cursor}
onloadmore={() => load(false, orderByForGroup())} onloadmore={() => load(false, orderByForGroup())}
onopen={open} onopen={open}
onfavorite={toggleFavorite}
onshared={(item) => {
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
shareOpen = true;
}}
showOwner showOwner
showPath showPath
dateLabel={t('files.col_opened', 'Opened')} dateLabel={t('files.col_opened', 'Opened')}
@@ -436,7 +454,7 @@
buttons at the row's action-cell. buttons at the row's action-cell.
--> -->
<button <button
class="btn-action" class="btn-action btn-action--hover"
data-testid={`recent-remove-btn-${item.id}`} data-testid={`recent-remove-btn-${item.id}`}
title={t('recent.remove_item', 'Remove from recent')} title={t('recent.remove_item', 'Remove from recent')}
aria-label={t('recent.remove_item', 'Remove from recent')} aria-label={t('recent.remove_item', 'Remove from recent')}
@@ -8,6 +8,7 @@
import { import {
addFavorite, addFavorite,
dateBucket, dateBucket,
removeFavorite,
resolveOwnerName, resolveOwnerName,
typeLabel typeLabel
} from '$lib/api/endpoints/favorites'; } from '$lib/api/endpoints/favorites';
@@ -161,11 +162,23 @@
// folders show "Download as ZIP" (server-side archive). Two entries // folders show "Download as ZIP" (server-side archive). Two entries
// with `visible?` predicates rather than one label that changes, // with `visible?` predicates rather than one label that changes,
// so the `.icon` reads correctly per kind too. // so the `.icon` reads correctly per kind too.
// async function toggleFavorite(item: FileItem | FolderItem) {
// The favorite entry stays "Add to favorites" only: un-favoriting const kind = isFile(item) ? 'file' : 'folder';
// from here would need per-row favorite-state tracking which this const wasFav = item.is_favorite;
// view doesn't carry — users toggle off from /favorites' own row item.is_favorite = !wasFav;
// menu. Backend swallows duplicate `addFavorite` calls idempotently. try {
if (wasFav) await removeFavorite(kind, item.id);
else await addFavorite(kind, item.id);
} catch (e) {
errorToast(e);
item.is_favorite = wasFav;
}
}
// Favorite entry mirrors the star toggle in the row action cell —
// same wording, same behavior. Context-menu label flips based on
// `item.is_favorite` so keyboard users get the same state read as
// the button-hovering ones.
const contextActions: ContextAction[] = [ const contextActions: ContextAction[] = [
{ {
key: 'download', key: 'download',
@@ -182,12 +195,18 @@
run: downloadItem run: downloadItem
}, },
{ {
key: 'favorite', key: 'favorite_add',
label: t('files.favorite', 'Add favorite'), label: t('files.favorite', 'Add favorite'),
icon: 'star', icon: 'star',
run: (item) => { visible: (item) => !item.is_favorite,
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast); run: toggleFavorite
} },
{
key: 'favorite_remove',
label: t('files.unfavorite', 'Remove favorite'),
icon: 'star',
visible: (item) => item.is_favorite,
run: toggleFavorite
} }
]; ];
@@ -235,6 +254,7 @@
bind:reversed bind:reversed
onloadmore={() => load(false, orderByForGroup())} onloadmore={() => load(false, orderByForGroup())}
onopen={open} onopen={open}
onfavorite={toggleFavorite}
onreload={(orderBy, rev) => { onreload={(orderBy, rev) => {
cursor = undefined; cursor = undefined;
load(true, orderBy, rev); load(true, orderBy, rev);
+8
View File
@@ -132,6 +132,14 @@ pub struct FavoriteResourceRow {
pub created_by: Option<Uuid>, pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row. /// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped favorite state — `TRUE` by construction on this
/// listing (every row IS a favorite). The listing SQL hardcodes
/// `TRUE AS is_favorite`; this field stays here so the DTO builder
/// signature stays symmetric with the other listings.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry. Computed by a per-row `EXISTS` in the listing SQL.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`. /// `true` when `owner_id == requesting user_id`.
pub is_owner: bool, pub is_owner: bool,
pub favorited_at: DateTime<Utc>, pub favorited_at: DateTime<Utc>,
+26
View File
@@ -83,6 +83,25 @@ pub struct FileDto {
/// stub/legacy files. /// stub/legacy files.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this file. **Wire contract: always present**, never null and
/// never absent — the SPA reads it as a required `boolean` with no
/// nullish branch. Every emission path (listing endpoints inline
/// via a per-row `EXISTS` in the listing SQL; single-item endpoints
/// via the shared `caller_flags` helper on the favorites port) is
/// responsible for populating this before the DTO reaches the
/// wire. WebDAV/CalDAV/CardDAV DTOs default to `false` — the XML
/// property serializer drops the field entirely, so a stale
/// default is never observable on those surfaces.
pub is_favorite: bool,
/// Resource-scoped: `true` when the file has ANY explicit
/// role-grant on it (link share via `subject_type = 'token'`,
/// user/group grant, any role). "Someone was given access to
/// this beyond drive membership." Same wire contract as
/// `is_favorite` — always present.
pub is_shared: bool,
} }
impl From<File> for FileDto { impl From<File> for FileDto {
@@ -132,6 +151,11 @@ impl From<File> for FileDto {
etag, etag,
created_by: parts.created_by, created_by: parts.created_by,
updated_by: parts.updated_by, updated_by: parts.updated_by,
// `From<File>` has no caller context. Callers that will
// emit the DTO to the SPA MUST override these before
// Json emission via the `caller_flags` helper.
is_favorite: false,
is_shared: false,
} }
} }
} }
@@ -189,6 +213,8 @@ impl FileDto {
sort_date: None, sort_date: None,
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
} }
} }
} }
+28
View File
@@ -96,6 +96,17 @@ pub struct FolderDto {
/// stub/legacy folders. /// stub/legacy folders.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this folder. See `FileDto::is_favorite` for the full wire
/// contract note (always present, never null; enrichment path
/// covers listing rows via inline `EXISTS` and single-item
/// endpoints via the `caller_flags` helper).
pub is_favorite: bool,
/// Resource-scoped: `true` when the folder has ANY explicit
/// role-grant on it. Same wire contract as `is_favorite`.
pub is_shared: bool,
} }
impl From<Folder> for FolderDto { impl From<Folder> for FolderDto {
@@ -127,6 +138,11 @@ impl From<Folder> for FolderDto {
etag, etag,
created_by: parts.created_by, created_by: parts.created_by,
updated_by: parts.updated_by, updated_by: parts.updated_by,
// `From<Folder>` has no caller context. Handlers that
// emit to the SPA MUST override via `caller_flags` before
// Json response.
is_favorite: false,
is_shared: false,
} }
} }
} }
@@ -179,6 +195,8 @@ impl FolderDto {
etag: String::new(), etag: String::new(),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
is_favorite: false,
is_shared: false,
} }
} }
} }
@@ -226,6 +244,16 @@ pub struct FolderResourceRow {
pub created_by: Option<Uuid>, pub created_by: Option<Uuid>,
/// §14 provenance — who last touched the row. /// §14 provenance — who last touched the row.
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` / `FolderDto::is_favorite`
/// on the listing without a follow-up query. Computed by the
/// per-row `EXISTS` in `list_resources_paged`.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any `storage.role_grants`
/// entry — link share (`subject_type = 'token'`), user grant, group
/// grant, or any role. Populates `FileDto::is_shared` /
/// `FolderDto::is_shared`.
pub is_shared: bool,
// Pre-computed sort fields — returned by the SQL for cursor construction. // Pre-computed sort fields — returned by the SQL for cursor construction.
/// `LOWER(name)` used by `name`/`type` sorts. /// `LOWER(name)` used by `name`/`type` sorts.
pub sort_str: String, pub sort_str: String,
+8
View File
@@ -122,6 +122,14 @@ pub struct RecentResourceRow {
/// consumed by the UI but surfaced for API parity with the other /// consumed by the UI but surfaced for API parity with the other
/// listing endpoints. /// listing endpoints.
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the requesting user has favorited
/// this row. Populates `FileDto::is_favorite` /
/// `FolderDto::is_favorite` on this listing via a per-row
/// `EXISTS` in the SQL.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry.
pub is_shared: bool,
/// `true` when `owner_id == requesting user_id`. /// `true` when `owner_id == requesting user_id`.
pub is_owner: bool, pub is_owner: bool,
pub accessed_at: DateTime<Utc>, pub accessed_at: DateTime<Utc>,
+7
View File
@@ -77,6 +77,13 @@ pub struct TrashResourceRow {
/// §14 provenance — who last touched the row (includes the trash /// §14 provenance — who last touched the row (includes the trash
/// action itself, which stamps `updated_by = caller_id`). /// action itself, which stamps `updated_by = caller_id`).
pub updated_by: Option<Uuid>, pub updated_by: Option<Uuid>,
/// Caller-scoped: `true` when the caller has favorited this
/// trashed item.
pub is_favorite: bool,
/// Resource-scoped: `true` when the row has any
/// `storage.role_grants` entry (surviving trash — grants are
/// GC'd by `purge_expired_grants` on the 15-day grace).
pub is_shared: bool,
pub trashed_at: DateTime<Utc>, pub trashed_at: DateTime<Utc>,
pub deletion_date: DateTime<Utc>, pub deletion_date: DateTime<Utc>,
/// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). /// Original location path (for folders: `path`; for files: `parent.path || '/' || name`).
+22
View File
@@ -90,4 +90,26 @@ pub trait FavoritesRepositoryPort: Send + Sync + 'static {
kinds: Option<&[ResourceKind]>, kinds: Option<&[ResourceKind]>,
reverse: bool, reverse: bool,
) -> Result<Vec<FavoriteResourceRow>>; ) -> Result<Vec<FavoriteResourceRow>>;
/// Caller-scoped inline state flags for a single resource — the
/// shared enrichment helper called by every single-item handler
/// that emits `FileDto` / `FolderDto` to the SPA (get / rename /
/// move / upload / delta upload / photos / bulk get by ids).
///
/// Runs one SQL round trip with two `EXISTS` in the SELECT:
/// * `is_favorite` — `EXISTS on auth.user_favorites` for the
/// `(caller_id, resource_id, resource_type)` triple.
/// * `is_shared` — `EXISTS on storage.role_grants` for the
/// `(resource_id, resource_type)` pair, regardless of role /
/// subject_type / granter. Link shares live in `role_grants`
/// as `subject_type = 'token'` under the unified model, so
/// one EXISTS covers link shares + user grants + group grants.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)>;
} }
@@ -50,6 +50,26 @@ impl FavoritesService {
) -> Result<HashSet<String>> { ) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, items).await self.repo.batch_check_favorites(user_id, items).await
} }
/// Shared enrichment helper — computes `is_favorite` + `is_shared`
/// for a single resource so single-item handlers (get / rename /
/// move / upload / delta upload / photos / bulk get by ids) can
/// populate the two wire-contract flags on FileDto / FolderDto
/// before Json emission. Delegates straight to the repository
/// port; kept on `FavoritesService` because the port already
/// lives on that service and callers already hold it in DI.
///
/// `resource_type` MUST be `"file"` or `"folder"`.
pub async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
self.repo
.caller_flags(caller_id, resource_type, resource_id)
.await
}
} }
impl FavoritesUseCase for FavoritesService { impl FavoritesUseCase for FavoritesService {
+9 -1
View File
@@ -828,7 +828,15 @@ impl FolderService {
// 2. Fetch limit+1 rows so we can detect has_next // 2. Fetch limit+1 rows so we can detect has_next
let mut rows = self let mut rows = self
.folder_storage .folder_storage
.list_resources_paged(pid, limit + 1, cursor.as_ref(), order_by, kinds, reverse) .list_resources_paged(
pid,
caller_id,
limit + 1,
cursor.as_ref(),
order_by,
kinds,
reverse,
)
.await?; .await?;
// 3. Detect has_next, build encoded next cursor // 3. Detect has_next, build encoded next cursor
@@ -824,6 +824,7 @@ impl TrashService {
.trash_repository .trash_repository
.list_resources_paged( .list_resources_paged(
&drive_ids, &drive_ids,
user_id,
limit + 1, limit + 1,
cursor.as_ref(), cursor.as_ref(),
order_by, order_by,
@@ -890,6 +891,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
TrashResourceItemDto { TrashResourceItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
@@ -933,6 +936,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto {
etag, etag,
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
TrashResourceItemDto { TrashResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
@@ -289,6 +289,53 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect()) Ok(rows.iter().map(|r| r.get::<String, _>("item_id")).collect())
} }
async fn caller_flags(
&self,
caller_id: Uuid,
resource_type: &str,
resource_id: Uuid,
) -> Result<(bool, bool)> {
// One round trip, two per-row EXISTS in the SELECT. Both hit
// covering indexes: `auth.user_favorites` UNIQUE on
// `(user_id, item_id, item_type)` and
// `idx_role_grants_resource` on `(resource_type, resource_id)`.
// Sub-millisecond on hot data.
//
// Positional tuple decode (Dio's pattern) — no per-column
// name lookup, no HashMap. Sqlx's 2-tuple `query_as` decodes
// by index, matching the perf shape used in the
// folder_db_repository listing hot path.
let (is_favorite, is_shared): (bool, bool) = sqlx::query_as(
"SELECT \
EXISTS ( \
SELECT 1 FROM auth.user_favorites \
WHERE user_id = $1 \
AND item_id = $2::text \
AND item_type = $3 \
), \
EXISTS ( \
SELECT 1 FROM storage.role_grants \
WHERE resource_id = $2 \
AND resource_type = $3 \
)",
)
.bind(caller_id)
.bind(resource_id)
.bind(resource_type)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error running caller_flags: {}", e);
DomainError::new(
ErrorKind::InternalError,
"CallerFlags",
format!("Failed to compute caller flags: {}", e),
)
})?;
Ok((is_favorite, is_shared))
}
async fn list_resources_paged( async fn list_resources_paged(
&self, &self,
user_id: Uuid, user_id: Uuid,
@@ -319,6 +366,14 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
NULL::text AS blob_hash, NULL::text AS blob_hash,
fld.created_by AS created_by, fld.created_by AS created_by,
fld.updated_by AS updated_by, fld.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction —
-- hardcode the flag to skip a per-row EXISTS.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS ( EXISTS (
SELECT 1 FROM storage.role_grants g SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive' WHERE g.resource_type = 'drive'
@@ -352,6 +407,13 @@ impl FavoritesRepositoryPort for FavoritesPgRepository {
f.blob_hash, f.blob_hash,
f.created_by AS created_by, f.created_by AS created_by,
f.updated_by AS updated_by, f.updated_by AS updated_by,
-- Every row on this feed IS a favorite by construction.
TRUE AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS ( EXISTS (
SELECT 1 FROM storage.role_grants g SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive' WHERE g.resource_type = 'drive'
@@ -532,6 +594,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id, r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at, r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by, r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.favorited_at, r.resource_path, r.is_owner, r.favorited_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col} r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r FROM resources r
@@ -607,6 +670,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(), blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(), created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(), updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(true),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
favorited_at: row.get("favorited_at"), favorited_at: row.get("favorited_at"),
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -22,6 +22,8 @@ type MediaFileRow = (
String, // blob_hash String, // blob_hash
Option<Uuid>, // created_by (§14 provenance) Option<Uuid>, // created_by (§14 provenance)
Option<Uuid>, // updated_by (§14 provenance) Option<Uuid>, // updated_by (§14 provenance)
bool, // is_favorite (caller-scoped EXISTS on user_favorites)
bool, // is_shared (resource-scoped EXISTS on role_grants)
i64, // sort_date i64, // sort_date
Option<i32>, // width Option<i32>, // width
Option<i32>, // height Option<i32>, // height
@@ -522,7 +524,19 @@ impl FileBlobReadRepository {
caller_id: Uuid, caller_id: Uuid,
before: Option<i64>, before: Option<i64>,
limit: i64, limit: i64,
) -> Result<(Vec<File>, Vec<i64>, Vec<(Option<i32>, Option<i32>)>), DomainError> { ) -> Result<
(
Vec<File>,
Vec<i64>,
Vec<(Option<i32>, Option<i32>)>,
// Per-row caller flags (is_favorite, is_shared). Aligned
// with `files` — zip 1:1. Kept parallel to `sort_dates` /
// `dims` instead of on the File entity so the domain
// stays caller-agnostic.
Vec<(bool, bool)>,
),
DomainError,
> {
// Sargable keyset cursor: compare the RAW `media_sort_date` column // Sargable keyset cursor: compare the RAW `media_sort_date` column
// against a timestamptz bind so the planner can use the cursor as // against a timestamptz bind so the planner can use the cursor as
// an index boundary condition on `idx_files_media_timeline_by_drive`. // an index boundary condition on `idx_files_media_timeline_by_drive`.
@@ -562,6 +576,17 @@ impl FileBlobReadRepository {
EXTRACT(EPOCH FROM top.updated_at)::bigint, EXTRACT(EPOCH FROM top.updated_at)::bigint,
top.blob_hash, top.blob_hash,
top.created_by, top.updated_by, top.created_by, top.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1
AND uf.item_id = top.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = top.id
AND g.resource_type = 'file'
) AS is_shared,
EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date, EXTRACT(EPOCH FROM top.media_sort_date)::bigint AS sort_date,
fm.width, fm.height fm.width, fm.height
FROM ( FROM (
@@ -596,16 +621,36 @@ impl FileBlobReadRepository {
let mut files = Vec::with_capacity(rows.len()); let mut files = Vec::with_capacity(rows.len());
let mut sort_dates = Vec::with_capacity(rows.len()); let mut sort_dates = Vec::with_capacity(rows.len());
let mut dims = Vec::with_capacity(rows.len()); let mut dims = Vec::with_capacity(rows.len());
let mut flags = Vec::with_capacity(rows.len());
for (id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, sd, w, h) in rows { for (
id,
name,
fid,
fpath,
size,
mime,
ca,
ma,
blob_hash,
cb,
ub,
is_fav,
is_shr,
sd,
w,
h,
) in rows
{
files.push(Self::row_to_file( files.push(Self::row_to_file(
id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub, id, name, fid, fpath, size, mime, ca, ma, blob_hash, cb, ub,
)?); )?);
sort_dates.push(sd); sort_dates.push(sd);
dims.push((w, h)); dims.push((w, h));
flags.push((is_fav, is_shr));
} }
Ok((files, sort_dates, dims)) Ok((files, sort_dates, dims, flags))
} }
/// Aggregate the caller's geotagged photos into grid cells of side `cell` /// Aggregate the caller's geotagged photos into grid cells of side `cell`
@@ -1417,9 +1417,11 @@ impl FolderDbRepository {
/// Fetches `limit` rows (caller should pass `desired_page_size + 1` to /// Fetches `limit` rows (caller should pass `desired_page_size + 1` to
/// detect the existence of a next page). Returns raw [`FolderResourceRow`] /// detect the existence of a next page). Returns raw [`FolderResourceRow`]
/// values; the handler / service layer converts them to DTOs. /// values; the handler / service layer converts them to DTOs.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged( pub async fn list_resources_paged(
&self, &self,
parent_id: Uuid, parent_id: Uuid,
caller_id: Uuid,
limit: usize, limit: usize,
cursor: Option<&FolderResourceCursor>, cursor: Option<&FolderResourceCursor>,
order_by: &str, order_by: &str,
@@ -1448,6 +1450,17 @@ impl FolderDbRepository {
NULL::text AS blob_hash, NULL::text AS blob_hash,
f.created_by, f.created_by,
f.updated_by, f.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'folder'
) AS is_shared,
LOWER(f.name) AS sort_str, LOWER(f.name) AS sort_str,
0::bigint AS type_order, 0::bigint AS type_order,
0::int AS folder_first 0::int AS folder_first
@@ -1469,6 +1482,17 @@ impl FolderDbRepository {
fm.blob_hash, fm.blob_hash,
fm.created_by, fm.created_by,
fm.updated_by, fm.updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $7::uuid
AND uf.item_id = fm.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fm.id
AND g.resource_type = 'file'
) AS is_shared,
LOWER(fm.name) AS sort_str, LOWER(fm.name) AS sort_str,
fm.category_order::bigint AS type_order, fm.category_order::bigint AS type_order,
1::int AS folder_first 1::int AS folder_first
@@ -1660,66 +1684,71 @@ impl FolderDbRepository {
"SELECT resource_type, id, name, folder_id, mime_type, size, \ "SELECT resource_type, id, name, folder_id, mime_type, size, \
created_at, modified_at, drive_id, blob_hash, \ created_at, modified_at, drive_id, blob_hash, \
created_by, updated_by, \ created_by, updated_by, \
is_favorite, is_shared, \
sort_str, type_order, folder_first \ sort_str, type_order, folder_first \
FROM ({inner}) r \ FROM ({inner}) r \
{outer_order} \ {outer_order} \
LIMIT $6" LIMIT $6"
); );
// Row: (resource_type, id, name, folder_id, mime_type, size, // 17 columns exceed sqlx's 16-element tuple `FromRow` limit,
// created_at, modified_at, drive_id, blob_hash, // so we can't use `query_as::<_, (T1..T17)>` directly. Instead
// created_by, updated_by, // we fetch raw `PgRow`s and decode each column positionally
// sort_str, type_order, folder_first) // into `FolderResourceRow` via `try_get_unchecked(idx)`. This
type Row = ( // matches Dio's positional shape (index decode, no per-row
String, // column-name HashMap) AND skips the intermediate tuple
Uuid, // struct + row-to-struct move that a manual `FromRow` impl
String, // would introduce — one construction, one destination.
Option<Uuid>, //
Option<String>, // Column indices below MUST match the outer `SELECT` list
i64, // above (resource_type, id, name, folder_id, mime_type, size,
chrono::DateTime<chrono::Utc>, // created_at, modified_at, drive_id, blob_hash, created_by,
chrono::DateTime<chrono::Utc>, // updated_by, is_favorite, is_shared, sort_str, type_order,
Uuid, // drive_id // folder_first).
Option<String>, let rows = sqlx::query(&sql)
Option<Uuid>, // created_by
Option<Uuid>, // updated_by
String,
i64,
i32,
);
let rows = sqlx::query_as::<_, Row>(&sql)
.bind(parent_id) .bind(parent_id)
.bind(cursor_str) .bind(cursor_str)
.bind(cursor_int) .bind(cursor_int)
.bind(cursor_ts) .bind(cursor_ts)
.bind(cursor_id) .bind(cursor_id)
.bind(limit as i64) .bind(limit as i64)
.bind(caller_id)
.fetch_all(self.pool()) .fetch_all(self.pool())
.await .await
.map_err(|e| { .map_err(|e| {
DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}")) DomainError::internal_error("FolderDb", format!("list_resources_paged: {e}"))
})?; })?;
Ok(rows use sqlx::Row as _;
.into_iter() let decode_err = |col: usize, e: sqlx::Error| -> DomainError {
.map(|r| FolderResourceRow { DomainError::internal_error(
resource_type: r.0, "FolderDb",
id: r.1, format!("list_resources_paged decode col {col}: {e}"),
name: r.2, )
parent_id: r.3, };
mime_type: r.4,
size: r.5, rows.into_iter()
created_at: r.6, .map(|r| {
modified_at: r.7, Ok(FolderResourceRow {
drive_id: r.8, resource_type: r.try_get_unchecked(0).map_err(|e| decode_err(0, e))?,
blob_hash: r.9, id: r.try_get_unchecked(1).map_err(|e| decode_err(1, e))?,
created_by: r.10, name: r.try_get_unchecked(2).map_err(|e| decode_err(2, e))?,
updated_by: r.11, parent_id: r.try_get_unchecked(3).map_err(|e| decode_err(3, e))?,
sort_str: r.12, mime_type: r.try_get_unchecked(4).map_err(|e| decode_err(4, e))?,
type_order: r.13, size: r.try_get_unchecked(5).map_err(|e| decode_err(5, e))?,
folder_first: r.14, created_at: r.try_get_unchecked(6).map_err(|e| decode_err(6, e))?,
modified_at: r.try_get_unchecked(7).map_err(|e| decode_err(7, e))?,
drive_id: r.try_get_unchecked(8).map_err(|e| decode_err(8, e))?,
blob_hash: r.try_get_unchecked(9).map_err(|e| decode_err(9, e))?,
created_by: r.try_get_unchecked(10).map_err(|e| decode_err(10, e))?,
updated_by: r.try_get_unchecked(11).map_err(|e| decode_err(11, e))?,
is_favorite: r.try_get_unchecked(12).map_err(|e| decode_err(12, e))?,
is_shared: r.try_get_unchecked(13).map_err(|e| decode_err(13, e))?,
sort_str: r.try_get_unchecked(14).map_err(|e| decode_err(14, e))?,
type_order: r.try_get_unchecked(15).map_err(|e| decode_err(15, e))?,
folder_first: r.try_get_unchecked(16).map_err(|e| decode_err(16, e))?,
})
}) })
.collect()) .collect()
} }
} }
@@ -245,6 +245,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
NULL::text AS blob_hash, NULL::text AS blob_hash,
fld.created_by AS created_by, fld.created_by AS created_by,
fld.updated_by AS updated_by, fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
EXISTS ( EXISTS (
SELECT 1 FROM storage.role_grants g SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive' WHERE g.resource_type = 'drive'
@@ -278,6 +289,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository {
f.blob_hash, f.blob_hash,
f.created_by AS created_by, f.created_by AS created_by,
f.updated_by AS updated_by, f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $1::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
EXISTS ( EXISTS (
SELECT 1 FROM storage.role_grants g SELECT 1 FROM storage.role_grants g
WHERE g.resource_type = 'drive' WHERE g.resource_type = 'drive'
@@ -457,6 +479,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id, r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at, r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by, r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.is_owner, r.accessed_at, r.resource_path, r.is_owner, r.accessed_at, r.resource_path,
r.sort_str, r.type_order, r.folder_first{username_col} r.sort_str, r.type_order, r.folder_first{username_col}
FROM resources r FROM resources r
@@ -536,6 +559,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(), blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(), created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(), updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
is_owner: row.try_get("is_owner").unwrap_or(false), is_owner: row.try_get("is_owner").unwrap_or(false),
accessed_at: row.get("accessed_at"), accessed_at: row.get("accessed_at"),
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -333,9 +333,11 @@ impl TrashDbRepository {
/// Returns rows in caller-requested sort order. The caller is expected to /// Returns rows in caller-requested sort order. The caller is expected to
/// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns /// fetch `limit + 1` to detect end-of-results. Empty `drive_ids` returns
/// an empty page without hitting PG. /// an empty page without hitting PG.
#[allow(clippy::too_many_arguments)]
pub async fn list_resources_paged( pub async fn list_resources_paged(
&self, &self,
drive_ids: &[Uuid], drive_ids: &[Uuid],
caller_id: Uuid,
limit: usize, limit: usize,
cursor: Option<&TrashCursor>, cursor: Option<&TrashCursor>,
order_by: &str, order_by: &str,
@@ -369,6 +371,17 @@ impl TrashDbRepository {
NULL::text AS blob_hash, NULL::text AS blob_hash,
fld.created_by AS created_by, fld.created_by AS created_by,
fld.updated_by AS updated_by, fld.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = fld.id::text
AND uf.item_type = 'folder'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = fld.id
AND g.resource_type = 'folder'
) AS is_shared,
fld.trashed_at AS trashed_at, fld.trashed_at AS trashed_at,
(fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
fld.path::text AS resource_path, fld.path::text AS resource_path,
@@ -397,6 +410,17 @@ impl TrashDbRepository {
f.blob_hash, f.blob_hash,
f.created_by AS created_by, f.created_by AS created_by,
f.updated_by AS updated_by, f.updated_by AS updated_by,
EXISTS (
SELECT 1 FROM auth.user_favorites uf
WHERE uf.user_id = $8::uuid
AND uf.item_id = f.id::text
AND uf.item_type = 'file'
) AS is_favorite,
EXISTS (
SELECT 1 FROM storage.role_grants g
WHERE g.resource_id = f.id
AND g.resource_type = 'file'
) AS is_shared,
f.trashed_at AS trashed_at, f.trashed_at AS trashed_at,
(f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date,
COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path,
@@ -527,6 +551,7 @@ SELECT
r.resource_type, r.resource_id, r.name, r.parent_id, r.resource_type, r.resource_id, r.name, r.parent_id,
r.mime_type, r.size, r.resource_created_at, r.modified_at, r.mime_type, r.size, r.resource_created_at, r.modified_at,
r.drive_id, r.blob_hash, r.created_by, r.updated_by, r.drive_id, r.blob_hash, r.created_by, r.updated_by,
r.is_favorite, r.is_shared,
r.trashed_at, r.deletion_date, r.resource_path, r.trashed_at, r.deletion_date, r.resource_path,
r.sort_str, r.type_order, r.folder_first r.sort_str, r.type_order, r.folder_first
FROM resources r FROM resources r
@@ -543,6 +568,7 @@ LIMIT $6"
.bind(cur_id) // $5 .bind(cur_id) // $5
.bind(limit as i64) // $6 .bind(limit as i64) // $6
.bind(self.retention_days as i32) // $7 .bind(self.retention_days as i32) // $7
.bind(caller_id) // $8 — favorites EXISTS on the branch SELECTs
.fetch_all(self.pool.as_ref()) .fetch_all(self.pool.as_ref())
.await .await
.map_err(|e| { .map_err(|e| {
@@ -588,6 +614,8 @@ LIMIT $6"
blob_hash: row.try_get("blob_hash").ok(), blob_hash: row.try_get("blob_hash").ok(),
created_by: row.try_get("created_by").ok(), created_by: row.try_get("created_by").ok(),
updated_by: row.try_get("updated_by").ok(), updated_by: row.try_get("updated_by").ok(),
is_favorite: row.try_get("is_favorite").unwrap_or(false),
is_shared: row.try_get("is_shared").unwrap_or(false),
trashed_at, trashed_at,
deletion_date, deletion_date,
path: row.try_get("resource_path").ok(), path: row.try_get("resource_path").ok(),
@@ -189,6 +189,14 @@ impl PathResolverService {
// reload through the repo. // reload through the repo.
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// Caller state flags not looked up here — the
// resolver is an internal utility that answers
// existence/type questions, not a wire emission
// path. Callers that emit to the SPA reload
// through the listing repo or the caller_flags
// helper.
is_favorite: false,
is_shared: false,
})) }))
} }
_ => { _ => {
@@ -217,6 +225,10 @@ impl PathResolverService {
// §14 provenance not selected by this resolver path // §14 provenance not selected by this resolver path
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// Caller state flags not looked up here — see
// the folder branch above for rationale.
is_favorite: false,
is_shared: false,
})) }))
} }
} }
@@ -0,0 +1,88 @@
//! Shared enrichment helpers that populate the `is_favorite` and
//! `is_shared` wire-contract flags on `FileDto` / `FolderDto` before
//! Json emission.
//!
//! The two functions live here so single-item handlers across
//! `folder_handler`, `file_handler`, `delta_upload_handler`,
//! `photos_handler`, etc. all go through the same path — one place
//! to change if the enrichment strategy ever moves (e.g. batch
//! lookups, background prefetch).
//!
//! Every handler that returns a `FileDto` or `FolderDto` to the SPA
//! MUST call one of these helpers. Handlers that emit only to
//! WebDAV / NextCloud DAV surfaces (which drop these fields via the
//! XML property serializer) can skip enrichment — the default `false`
//! is never observable on those wires.
use std::sync::Arc;
use uuid::Uuid;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::common::di::AppState as GlobalAppState;
/// Populate the `is_favorite` + `is_shared` flags on a `FolderDto`.
///
/// Silently leaves the flags at their default `false` when the
/// favorites service isn't wired (feature-off) or when the resource
/// id doesn't parse as a UUID — the DTO stays valid on the wire and
/// the misleading-`false` window closes as soon as the next listing
/// refetch runs.
pub async fn enrich_folder_flags(
state: &Arc<GlobalAppState>,
dto: &mut FolderDto,
caller_id: Uuid,
) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "folder", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// File counterpart of [`enrich_folder_flags`] — see that doc.
pub async fn enrich_file_flags(state: &Arc<GlobalAppState>, dto: &mut FileDto, caller_id: Uuid) {
let Some(favs) = state.favorites_service.as_ref() else {
return;
};
let Ok(resource_id) = Uuid::parse_str(&dto.id) else {
return;
};
if let Ok((fav, shr)) = favs.caller_flags(caller_id, "file", resource_id).await {
dto.is_favorite = fav;
dto.is_shared = shr;
}
}
/// Batch variant: enrich every `FileDto` in a slice with per-item
/// `caller_flags`. Runs the lookups sequentially — for the bulk
/// endpoints (`get_files_by_ids`, `photos_handler`) this is one
/// round trip per item; if that becomes hot on a large fetch, the
/// callsite can be replaced with a single SQL query returning the
/// pairs. Kept simple for now; the DTO is `&mut`, no clones.
pub async fn enrich_file_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FileDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_file_flags(state, dto, caller_id).await;
}
}
/// Batch variant for folders — mirror of [`enrich_file_flags_batch`].
pub async fn enrich_folder_flags_batch(
state: &Arc<GlobalAppState>,
dtos: &mut [FolderDto],
caller_id: Uuid,
) {
for dto in dtos.iter_mut() {
enrich_folder_flags(state, dto, caller_id).await;
}
}
@@ -235,12 +235,18 @@ pub async fn delta_commit(
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
Ok(match outcome { Ok(match outcome {
DeltaCommitOutcome::Done { file, created } => { DeltaCommitOutcome::Done { mut file, created } => {
let status = if created { let status = if created {
StatusCode::CREATED StatusCode::CREATED
} else { } else {
StatusCode::OK StatusCode::OK
}; };
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(status, Json(file)).into_response() (status, Json(file)).into_response()
} }
DeltaCommitOutcome::StillMissing(still_missing) => ( DeltaCommitOutcome::StillMissing(still_missing) => (
@@ -219,6 +219,11 @@ pub async fn list_favorites_resources(
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
// Every row on this listing is a favorite by
// construction; the listing repo returns
// `TRUE AS is_favorite` unconditionally.
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
FavoritesResourceItemDto { FavoritesResourceItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
@@ -267,6 +272,8 @@ pub async fn list_favorites_resources(
etag, etag,
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
FavoritesResourceItemDto { FavoritesResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+34 -5
View File
@@ -64,7 +64,15 @@ impl FileHandler {
multipart: Multipart, multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
match Self::upload_file_inner(&state, &auth_user, multipart).await { match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok((file, _blob_hash)) => Self::created_json_response(&file).into_response(), Ok((mut file, _blob_hash)) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(response) => response.into_response(), Err(response) => response.into_response(),
} }
} }
@@ -115,7 +123,15 @@ impl FileHandler {
) )
.await .await
{ {
Ok(file) => Self::created_json_response(&file).into_response(), Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response()
}
Err(err) => { Err(err) => {
// Anti-enumeration shape: every "caller cannot reach this // Anti-enumeration shape: every "caller cannot reach this
// hash" outcome collapses into the same 404 with an // hash" outcome collapses into the same 404 with an
@@ -890,11 +906,16 @@ impl FileHandler {
auth_user: AuthUser, auth_user: AuthUser,
multipart: Multipart, multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
let (file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await { let (mut file, _) = match Self::upload_file_inner(&state, &auth_user, multipart).await {
Ok(pair) => pair, Ok(pair) => pair,
Err(response) => return response.into_response(), Err(response) => return response.into_response(),
}; };
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
Self::created_json_response(&file).into_response() Self::created_json_response(&file).into_response()
} }
@@ -1026,7 +1047,15 @@ impl FileHandler {
.move_file_with_perms(&id, auth_user.id, payload.folder_id) .move_file_with_perms(&id, auth_user.id, payload.folder_id)
.await .await
{ {
Ok(file) => (StatusCode::OK, Json(file)).into_response(), Ok(mut file) => {
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state,
&mut file,
auth_user.id,
)
.await;
(StatusCode::OK, Json(file)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
+34 -12
View File
@@ -25,6 +25,8 @@ use crate::interfaces::middleware::auth::AuthUser;
type AppState = Arc<FolderService>; type AppState = Arc<FolderService>;
use crate::interfaces::api::handlers::caller_flags::enrich_folder_flags;
/// Handler for folder-related API endpoints /// Handler for folder-related API endpoints
pub struct FolderHandler; pub struct FolderHandler;
@@ -40,10 +42,11 @@ impl FolderHandler {
/// When parent_id is not provided, the folder is created inside the /// When parent_id is not provided, the folder is created inside the
/// authenticated user's home folder rather than at the storage root. /// authenticated user's home folder rather than at the storage root.
pub(super) async fn create_folder_impl( pub(super) async fn create_folder_impl(
State(service): State<AppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Json(mut dto): Json<CreateFolderDto>, Json(mut dto): Json<CreateFolderDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
// If no parent_id was supplied, resolve the user's home folder as // If no parent_id was supplied, resolve the user's home folder as
// the default parent so the new folder is nested correctly. // the default parent so the new folder is nested correctly.
if dto.parent_id.is_none() { if dto.parent_id.is_none() {
@@ -77,7 +80,10 @@ impl FolderHandler {
} }
match service.create_folder_with_perms(dto, auth_user.id).await { match service.create_folder_with_perms(dto, auth_user.id).await {
Ok(folder) => (StatusCode::CREATED, Json(folder)).into_response(), Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::CREATED, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
@@ -85,12 +91,16 @@ impl FolderHandler {
/// Gets a folder by ID. /// Gets a folder by ID.
/// Validates that the authenticated user owns the folder. /// Validates that the authenticated user owns the folder.
pub(super) async fn get_folder_impl( pub(super) async fn get_folder_impl(
State(service): State<AppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.get_folder_with_perms(&id, auth_user.id).await { match service.get_folder_with_perms(&id, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
@@ -126,29 +136,37 @@ impl FolderHandler {
/// Renames a folder (ownership enforced). /// Renames a folder (ownership enforced).
pub(super) async fn rename_folder_impl( pub(super) async fn rename_folder_impl(
State(service): State<AppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<RenameFolderDto>, Json(dto): Json<RenameFolderDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service match service
.rename_folder_with_perms(&id, dto, auth_user.id) .rename_folder_with_perms(&id, dto, auth_user.id)
.await .await
{ {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
/// Moves a folder to a new parent (ownership enforced). /// Moves a folder to a new parent (ownership enforced).
pub(super) async fn move_folder_impl( pub(super) async fn move_folder_impl(
State(service): State<AppState>, State(state): State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<String>, Path(id): Path<String>,
Json(dto): Json<MoveFolderDto>, Json(dto): Json<MoveFolderDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let service = &state.applications.folder_service_concrete;
match service.move_folder_with_perms(&id, dto, auth_user.id).await { match service.move_folder_with_perms(&id, dto, auth_user.id).await {
Ok(folder) => (StatusCode::OK, Json(folder)).into_response(), Ok(mut folder) => {
enrich_folder_flags(&state, &mut folder, auth_user.id).await;
(StatusCode::OK, Json(folder)).into_response()
}
Err(err) => AppError::from(err).into_response(), Err(err) => AppError::from(err).into_response(),
} }
} }
@@ -300,7 +318,7 @@ impl FolderHandler {
tag = "folders" tag = "folders"
)] )]
pub async fn create_folder( pub async fn create_folder(
state: State<AppState>, state: State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
json: Json<CreateFolderDto>, json: Json<CreateFolderDto>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -319,7 +337,7 @@ pub async fn create_folder(
tag = "folders" tag = "folders"
)] )]
pub async fn get_folder( pub async fn get_folder(
state: State<AppState>, state: State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
path: Path<String>, path: Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
@@ -355,7 +373,7 @@ pub async fn list_root_folders(
tag = "folders" tag = "folders"
)] )]
pub async fn rename_folder( pub async fn rename_folder(
state: State<AppState>, state: State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
path: Path<String>, path: Path<String>,
json: Json<RenameFolderDto>, json: Json<RenameFolderDto>,
@@ -376,7 +394,7 @@ pub async fn rename_folder(
tag = "folders" tag = "folders"
)] )]
pub async fn move_folder( pub async fn move_folder(
state: State<AppState>, state: State<Arc<GlobalAppState>>,
auth_user: AuthUser, auth_user: AuthUser,
path: Path<String>, path: Path<String>,
json: Json<MoveFolderDto>, json: Json<MoveFolderDto>,
@@ -490,6 +508,8 @@ pub async fn list_folder_resources(
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
FolderResourceItemDto { FolderResourceItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
@@ -541,6 +561,8 @@ pub async fn list_folder_resources(
etag, etag,
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
FolderResourceItemDto { FolderResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
+34 -8
View File
@@ -933,19 +933,28 @@ pub async fn list_shared_with_me(
// looking each resolved resource up by id. // looking each resolved resource up by id.
let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len()); let mut items: Vec<SharedWithMeItemDto> = Vec::with_capacity(summaries.len());
// Enrich caller flags on every returned resource DTO. Incoming
// grants pages are typically small (10-50 items), so N sequential
// helper calls is acceptable; the folded-into-SQL treatment
// photos got is overkill here. Follow-up path if this grows
// hot: same LATERAL EXISTS shape in `get_files_by_ids` /
// `get_folders_by_ids`.
for summary in &summaries { for summary in &summaries {
let rid = summary.resource_id.to_string(); let rid = summary.resource_id.to_string();
match summary.resource_type { match summary.resource_type {
ResourceKind::File => match file_map.get(&rid) { ResourceKind::File => match file_map.get(&rid) {
Some(file_dto) => { Some(file_dto) => {
let mut dto = file_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto { items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at, granted_at: summary.granted_at,
granted_by: summary.granted_by, granted_by: summary.granted_by,
resource: ResourceContentDto::File( resource: ResourceContentDto::File(dto),
file_dto.clone().without_hierarchy_info(),
),
}); });
} }
None => warn!( None => warn!(
@@ -955,14 +964,17 @@ pub async fn list_shared_with_me(
}, },
ResourceKind::Folder => match folder_map.get(&rid) { ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => { Some(folder_dto) => {
let mut dto = folder_dto.clone().without_hierarchy_info();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
items.push(SharedWithMeItemDto { items.push(SharedWithMeItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
permissions: summary.permissions.iter().map(|p| (*p).into()).collect(), permissions: summary.permissions.iter().map(|p| (*p).into()).collect(),
granted_at: summary.granted_at, granted_at: summary.granted_at,
granted_by: summary.granted_by, granted_by: summary.granted_by,
resource: ResourceContentDto::Folder( resource: ResourceContentDto::Folder(dto),
folder_dto.clone().without_hierarchy_info(),
),
}); });
} }
None => warn!( None => warn!(
@@ -1209,10 +1221,18 @@ pub async fn list_my_shares(
// Caller is the granter — they had share-access to the // Caller is the granter — they had share-access to the
// resource, so the containing hierarchy is already known // resource, so the containing hierarchy is already known
// to them. Keep `path` (unlike list_shared_with_me). // to them. Keep `path` (unlike list_shared_with_me).
let mut dto = file_dto.clone();
// is_shared: TRUE by construction on this feed.
// is_favorite: real EXISTS via the shared helper.
crate::interfaces::api::handlers::caller_flags::enrich_file_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto { items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
first_shared_at: summary.first_shared_at, first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::File(file_dto.clone()), resource: ResourceContentDto::File(dto),
grants, grants,
}); });
} }
@@ -1223,10 +1243,16 @@ pub async fn list_my_shares(
}, },
ResourceKind::Folder => match folder_map.get(&rid) { ResourceKind::Folder => match folder_map.get(&rid) {
Some(folder_dto) => { Some(folder_dto) => {
let mut dto = folder_dto.clone();
crate::interfaces::api::handlers::caller_flags::enrich_folder_flags(
&state, &mut dto, caller_id,
)
.await;
dto.is_shared = true;
items.push(OutgoingResourceItemDto { items.push(OutgoingResourceItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
first_shared_at: summary.first_shared_at, first_shared_at: summary.first_shared_at,
resource: ResourceContentDto::Folder(folder_dto.clone()), resource: ResourceContentDto::Folder(dto),
grants, grants,
}); });
} }
+1
View File
@@ -3,6 +3,7 @@ pub mod app_password_handler;
pub mod auth_handler; pub mod auth_handler;
pub mod batch_handler; pub mod batch_handler;
pub mod caldav_handler; pub mod caldav_handler;
pub mod caller_flags;
pub mod carddav_handler; pub mod carddav_handler;
pub mod chunked_upload_handler; pub mod chunked_upload_handler;
pub mod contacts_handler; pub mod contacts_handler;
+11 -3
View File
@@ -75,7 +75,7 @@ pub async fn list_photos(
.list_media_files(caller_id, params.before, limit) .list_media_files(caller_id, params.before, limit)
.await .await
{ {
Ok((files, sort_dates, dims)) => { Ok((files, sort_dates, dims, flags)) => {
// Lightweight revalidation ETag: page identity (cursor + limit) plus a // Lightweight revalidation ETag: page identity (cursor + limit) plus a
// freshness signal (max modified_at + row count over the page), // freshness signal (max modified_at + row count over the page),
// mirroring the file-list endpoint. With `Cache-Control: no-cache` the // mirroring the file-list endpoint. With `Cache-Control: no-cache` the
@@ -106,14 +106,22 @@ pub async fn list_photos(
info!("Photos: returned {} media files for user", count); info!("Photos: returned {} media files for user", count);
// Convert to DTOs with sort_date + pixel dimensions populated. // Convert to DTOs with sort_date + pixel dimensions + inline
// caller flags populated. `list_media_files` computes
// `is_favorite` / `is_shared` via two per-row `EXISTS`
// columns in its SELECT — the same pattern the four
// `list_resources_paged` repos use — so this stays a
// single round trip regardless of page size.
let dtos: Vec<PhotoDto> = files let dtos: Vec<PhotoDto> = files
.into_iter() .into_iter()
.zip(sort_dates.iter()) .zip(sort_dates.iter())
.zip(dims.iter()) .zip(dims.iter())
.map(|((file, &sd), &(w, h))| { .zip(flags.iter())
.map(|(((file, &sd), &(w, h)), &(is_fav, is_shr))| {
let mut dto = FileDto::from(file); let mut dto = FileDto::from(file);
dto.sort_date = Some(sd as u64); dto.sort_date = Some(sd as u64);
dto.is_favorite = is_fav;
dto.is_shared = is_shr;
PhotoDto { PhotoDto {
file: dto, file: dto,
width: w.map(|v| v.max(0) as u32), width: w.map(|v| v.max(0) as u32),
@@ -239,6 +239,8 @@ pub async fn list_recent_resources(
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
RecentResourceItemDto { RecentResourceItemDto {
resource_type: ResourceTypeDto::Folder, resource_type: ResourceTypeDto::Folder,
@@ -285,6 +287,8 @@ pub async fn list_recent_resources(
etag, etag,
created_by: row.created_by, created_by: row.created_by,
updated_by: row.updated_by, updated_by: row.updated_by,
is_favorite: row.is_favorite,
is_shared: row.is_shared,
}; };
RecentResourceItemDto { RecentResourceItemDto {
resource_type: ResourceTypeDto::File, resource_type: ResourceTypeDto::File,
@@ -565,6 +565,9 @@ async fn handle_propfind(
category: intern_display("Folder"), category: intern_display("Folder"),
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// Synthetic root, not on the SPA path — safe default.
is_favorite: false,
is_shared: false,
}; };
// Skip the 2-query quota resolution when the request's prop list // Skip the 2-query quota resolution when the request's prop list
// never mentions quota (benches/QUOTA-PATH.md). // never mentions quota (benches/QUOTA-PATH.md).
+13 -4
View File
@@ -206,15 +206,23 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
batch_service: batch_service.clone(), batch_service: batch_service.clone(),
}; };
// Create the basic folders router with service operations // Basic folder listing (no caller-flag enrichment needed — flags
// come from the per-row SQL EXISTS in the listing repo).
let folders_basic_router = Router::new() let folders_basic_router = Router::new()
.route("/", post(create_folder))
.route("/", get(list_root_folders)) .route("/", get(list_root_folders))
.route("/{id}", get(get_folder))
.route("/{id}/resources", get(list_folder_resources)) .route("/{id}/resources", get(list_folder_resources))
.with_state(folder_service.clone());
// Single-item CRUD emits a FolderDto to the SPA and MUST carry
// authoritative `is_favorite` / `is_shared` flags. The impls call
// the `caller_flags` enrichment helper via
// `state.favorites_service`, so the full `AppState` is required.
let folders_crud_router = Router::new()
.route("/", post(create_folder))
.route("/{id}", get(get_folder))
.route("/{id}/rename", put(rename_folder)) .route("/{id}/rename", put(rename_folder))
.route("/{id}/move", put(move_folder)) .route("/{id}/move", put(move_folder))
.with_state(folder_service.clone()); .with_state(app_state.clone());
// Special route for ZIP download that requires AppState instead of just FolderService // Special route for ZIP download that requires AppState instead of just FolderService
let folder_zip_router = Router::new() let folder_zip_router = Router::new()
@@ -226,6 +234,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Merge the routers // Merge the routers
let folders_router = folders_basic_router let folders_router = folders_basic_router
.merge(folders_crud_router)
.merge(folders_ops_router) .merge(folders_ops_router)
.merge(folder_zip_router); .merge(folder_zip_router);
@@ -462,6 +462,10 @@ fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileRes
// §14 provenance not selected by the search result DTO. // §14 provenance not selected by the search result DTO.
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// NextCloud search doesn't render the SPA badges; safe
// defaults, dropped by the WebDAV/NC XML property serializer.
is_favorite: false,
is_shared: false,
} }
} }
@@ -495,6 +499,10 @@ fn folder_dto_from_search(
// §14 provenance not selected by search results. // §14 provenance not selected by search results.
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// See file_dto_from_search: NC/DAV property serializer drops
// these; safe default.
is_favorite: false,
is_shared: false,
} }
} }
@@ -2401,6 +2401,10 @@ mod tests {
// §14 provenance not relevant to path-mapper tests. // §14 provenance not relevant to path-mapper tests.
created_by: None, created_by: None,
updated_by: None, updated_by: None,
// Caller state flags not read by the WebDAV path mapper;
// Nextcloud DAV surfaces don't render the SPA badges.
is_favorite: false,
is_shared: false,
} }
} }
+5
View File
@@ -94,6 +94,11 @@ jsonpath "$.items" count == 1
jsonpath "$.items[0].resource.id" == {{file_id}} jsonpath "$.items[0].resource.id" == {{file_id}}
jsonpath "$.items[0].resource_type" == "file" jsonpath "$.items[0].resource_type" == "file"
jsonpath "$.items[0].resource.name" == "hello-renamed.txt" jsonpath "$.items[0].resource.name" == "hello-renamed.txt"
# Caller-flag contract — every row on /api/favorites/resources IS
# favorited by construction (the listing SQL hardcodes
# `TRUE AS is_favorite`). `is_shared` is a real per-row EXISTS.
jsonpath "$.items[0].resource.is_favorite" == true
jsonpath "$.items[0].resource.is_shared" == false
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
+7
View File
@@ -107,6 +107,10 @@ jsonpath "$.parent_id" == {{home_folder_id}}
# D0 §14 provenance — self-creation: both fields stamp the caller. # D0 §14 provenance — self-creation: both fields stamp the caller.
jsonpath "$.created_by" == "{{admin_user_id}}" jsonpath "$.created_by" == "{{admin_user_id}}"
jsonpath "$.updated_by" == "{{admin_user_id}}" jsonpath "$.updated_by" == "{{admin_user_id}}"
# Caller-flag contract — fresh folder: not favorited, no grants on it.
# Wire contract is "always present bool" — never null, never absent.
jsonpath "$.is_favorite" == false
jsonpath "$.is_shared" == false
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -176,6 +180,9 @@ jsonpath "$.size" == 32
jsonpath "$.mime_type" == "text/plain" jsonpath "$.mime_type" == "text/plain"
# D0 §14 provenance — uploader's id stamps both fields on a fresh upload. # D0 §14 provenance — uploader's id stamps both fields on a fresh upload.
jsonpath "$.created_by" == "{{admin_user_id}}" jsonpath "$.created_by" == "{{admin_user_id}}"
# Caller-flag contract — fresh upload: not favorited, no grants on it.
jsonpath "$.is_favorite" == false
jsonpath "$.is_shared" == false
jsonpath "$.updated_by" == "{{admin_user_id}}" jsonpath "$.updated_by" == "{{admin_user_id}}"
+10
View File
@@ -738,6 +738,16 @@ jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}"
jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg" jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg"
jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}" jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}"
jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}" jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}"
# Caller-flag contract on the listing endpoint. Neither row
# favorited by Alice → is_favorite = false. The `perm_folder_id`
# tree carries a role_grant on the FOLDER (Adam as Editor), not
# on `perm-test-child` or `adam-renamed-logo.jpg` — so both
# child rows should carry is_shared = false; the grant on the
# parent doesn't cascade to per-child EXISTS.
jsonpath "$.items[0].resource.is_favorite" == false
jsonpath "$.items[0].resource.is_shared" == false
jsonpath "$.items[1].resource.is_favorite" == false
jsonpath "$.items[1].resource.is_shared" == false
# ── Thumbnail push (Update) succeeds ──────────────────────── # ── Thumbnail push (Update) succeeds ────────────────────────
PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview