Commit Graph

1471 Commits

Author SHA1 Message Date
Dionisio Pozo ddfc481616 Merge pull request #479 from EdouardVanbelle/feat/drive-impl
feat/drive impl
2026-06-19 18:48:32 +02:00
Dionisio Pozo 51cd1a71ab Merge pull request #490 from fkocik/main
Allow the use of system certificates to trust OIDC provider
2026-06-19 18:48:00 +02:00
Dionisio Pozo 180fd6482e Merge pull request #492 from AtalayaLabs/claude/frontend-performance-analysis-52a9he
Optimize folder listing with caching, virtualization, and badge batching
2026-06-19 18:47:43 +02:00
Claude a211d9d4a6 perf(boot): instant HTML splash in the shell for a faster perceived first paint
The app is a pure client-rendered SPA (ssr=false, adapter-static), so until the
JS bundle downloads, parses and mounts, the page is blank — only then does the
layout's own loading state appear. True route prerendering would mean turning on
SSR, an architectural change at odds with the SPA design.

Instead, bake a tiny boot splash into the shell (`app.html`): a centered spinner
that paints the moment the HTML is parsed — before the app bundle or its CSS
load — covering the blank gap. The root layout removes `#app-splash` the instant
it mounts (before `session.load`), so public routes like /login (which render
without waiting for the session) appear immediately and protected routes hand
off to their own loading UI. `light-dark()` plus early `color-scheme` rules make
the splash match the resolved theme (incl. the saved override), so there's no
colour flash when the app CSS arrives; it respects `prefers-reduced-motion`.

Pure HTML/CSS in the shell — no new requests, no JS framework on the critical
path, ~0.6 KB in index.html. Verified on the real static-dist build in headless
Chromium: the shell ships `#app-splash`, the layout removes it after mount, the
app renders, and there are no runtime errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:36:54 +00:00
Claude a8709b447a perf(i18n): load the English fallback lazily, off the startup critical path
`initI18n` runs in the client `init()` hook and blocks the first render. For
every non-English user it awaited TWO locale dictionaries back to back — the
active locale AND `en` (the fallback) — so first paint waited on two sequential
round-trips + JSON parses.

Now it awaits only the active locale, then warms `en` in the background
(non-blocking). `t()` only consults `dicts.en` for keys the active locale is
missing, and most call sites already pass an inline English fallback, so the
deferred `en` doesn't change what users see; when it arrives `dicts.en` is
reactive, so any key that fell through re-renders. English users are unchanged
(no second fetch was ever needed).

Net: non-English startup drops from two blocking locale fetches to one, halving
the i18n payload on the critical path (the server already serves these JSONs
brotli/gzip-compressed via the global CompressionLayer, so the wire cost was
already small — this removes the extra round-trip + parse from first paint).

Validated: new unit test (initI18n resolves while the en fetch is still pending,
en is kicked off in the background, and a key missing from the active locale
falls back once en lands) → 47 frontend tests green; npm run check; prod build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:27:58 +00:00
Claude afbc0ba515 perf(files): resolve breadcrumbs from a name cache, not N getFolder calls
Every navigation rebuilt the breadcrumb with one `GET /api/folders/{id}` per path
segment (a depth-D folder = D requests, each no-store) purely to label the trail.

Add an id→name cache, populated wherever a name is already known:
- every listing names its children, so `cacheFolder` records them, and
- `getFolder` records the folder it fetched.

`buildCrumbs` now reads names from the cache and only fetches the ids it hasn't
seen. During normal step-by-step navigation each ancestor was named by its
parent's listing, so the breadcrumb resolves with ZERO extra requests; only a
cold deep-link fetches its unknown ancestors (still in parallel). Folder renames
update the cache immediately so the trail stays correct.

The cache is a small LRU (cap 1000 — names are tiny) and is independent of the
listing cache (names survive a listing invalidation).

Validated: 3 new unit tests (listing populates child names, getFolder records,
rename overwrites) → 46 frontend tests green; npm run check; headless render of
the real files route (list + grid) — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 16:12:20 +00:00
Edouard Vanbelle ad2187c21b chore(ci): force OXICLOUD_RUST_ASSETS=1 2026-06-19 17:55:59 +02:00
Claude 3125c866c7 perf(files): stale-while-revalidate folder listings with conditional ETag
Every folder navigation re-downloaded the full listing: `listFolder` cache-busted
with `?t=<ts>` + `Cache-Control: no-store`, so back/forward and re-entering a
folder always paid a full round-trip + payload.

Now the files browser caches listings in memory and serves SWR:
- On navigation it paints a previously-visited folder instantly from cache, then
  revalidates with `If-None-Match` (the backend ETag covers folders + files +
  favorite/share badges, so it's a faithful validator). Unchanged → 304 with an
  empty body; changed → 200 refreshes cache + UI.
- A generation token guards against a slow in-flight response clobbering a newer
  navigation; breadcrumbs now resolve independently so they never block the grid
  paint.
- Mutations (create/upload/rename/move/copy/delete, incl. the move dialog) go
  through `reload()`, which drops the cache and refetches fresh — no stale view
  after an action.

API layer (`folders.ts`):
- `fetchFolderListing(id, { etag?, forceRefresh? })` does the conditional fetch
  (200 → parsed listing + ETag, 304 → empty); `listFolder` stays as a
  non-conditional wrapper for the move-dialog tree.
- A small LRU (cap 40) cache with `getCachedFolder` / `cacheFolder` /
  `invalidateFolderCache`. `cache: 'no-store'` keeps the browser HTTP cache out
  of the way; revalidation is driven entirely by our own ETag.

Net: instant back/forward navigation, and an unchanged folder revalidates with a
0-byte 304 instead of re-downloading the whole listing. Validated: 7 new unit
tests (conditional If-None-Match + 304, LRU eviction/recency, invalidation),
npm run check, and a headless render of the real files route (list + grid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:39:08 +00:00
Claude 9ccaeef0ab perf(listing): return per-item is_favorite/is_shared, drop client badge fetches
The folder listing now carries the favorite/share badge state for exactly the
items it returns, so the files browser stops fetching favorites and outgoing
shares separately. This removes the last per-navigation badge round-trips AND
fixes the correctness hole of the previous approaches: badges were derived from
only the first 200 global favorites / shares, so a favorited or shared item
outside that window showed no badge. Now every listed item is correct, and the
work is scoped to the items on screen.

Backend (`GET /api/folders/{id}/listing`):
- `FolderListingDto` gains `favorite_ids` and `shared_ids` (sorted) — listing-
  level metadata, so no churn to the many FileDto/FolderDto constructors.
- The handler computes both with two batched, index-backed queries run
  concurrently: `FavoritesService::favorited_ids` (auth.user_favorites, ANY) and
  `PgAclEngine::shared_resource_ids` (storage.role_grants by granted_by + ANY,
  which already covers public links as 'token' grants — same membership the
  /grants/outgoing/resources endpoint exposes). Both fold into the ETag.
- Public-share browsing passes empty sets (anonymous, read-only context).

Frontend:
- `listFolder` reads `favorite_ids` / `shared_ids`; the files view seeds local
  badge sets straight from the listing and updates them optimistically on
  favorite toggle / batch / share creation (via ShareDialog's `onshared`).
- Removes the session `badges` store + its fetches entirely — the listing is now
  the single, authoritative, fetch-free source.

Net: favorite/share badges cost zero extra client requests per navigation and
are correct regardless of how many favorites/shares the user has. Validated:
cargo check + clippy -D warnings (backend; integration tests need Postgres,
unavailable here), frontend npm run check + unit tests, and a headless render of
the real files route (list + grid) with the new flags present — no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:21:01 +00:00
Fabien KOCIK 59d00c76d2 fix: Allow the use of system certificates to trust OIDC provider 2026-06-19 17:01:02 +02:00
Claude 546dcef305 perf(frontend): load favorite/share badges once per session, not per navigation
The files browser re-fetched the first 200 favorites AND the first 200 outgoing
shares on every folder navigation (two round-trips each time) just to render the
star / shared badges — work that grew with how much the user browsed, for data
that barely changes.

Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches
once (concurrent callers share one in-flight request) and every later navigation
reads from cache, so browsing costs zero extra requests. Mutations keep the cache
in sync optimistically:
- favorite toggle / batch-favorite → `setFavorite` (revert on failure),
- share creation → `markShared`, wired through a new optional `onshared` callback
  on ShareDialog (fired when a grant or public link is created). This also makes
  the shared badge appear immediately instead of only after re-navigating.

Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total.
The 200-item ceiling is unchanged from before; the fully-correct fix is per-item
flags on the listing endpoint (a backend change, noted in the store).

Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic
favorite/share, reset) and a headless render of the real files route in list and
grid (virtualization intact, no runtime errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 15:00:14 +00:00
Edouard Vanbelle 7c1bdedc9d fix(test): fix test from main branch + drive 2026-06-19 16:59:35 +02:00
Claude 8794324c3d perf(frontend): virtualize grid views + the files browser (list & grid)
Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of
ResourceList (recent / favorites / shared / shared-with-me / trash / search) and
the main file browser (`files/[...path]`), in both list and grid layouts.

- VirtualList gains a real grid mode: its inner window carries the caller's grid
  class (`files-grid-view`) and lays out `columns` cards per windowed row. The
  row pitch is auto-measured (grid card height tracks column width via the 4/3
  aspect-ratio thumbnail) and re-measured on resize.
- `useVirtualWindow` now distinguishes scroll from resize and exposes a
  `resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when
  it can actually change.
- `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min`
  / gap so the windowed row count matches the browser's real wrapping exactly;
  shared by both grid callers.
- The files browser flattens folders-then-files into one discriminated `entries`
  list rendered through VirtualList (list: columns=1; grid: columns from width).
  Grouped (swimlane) views stay fully rendered, as before — they're bounded.

ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after:

  rows  | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
  ------+-------------+-----------+---------+--------------+------------
   1000 | 979→75 ms   | 19k→879   | 16→3 MB | 16→17 ms     | 0→0
   5000 | 4005→67 ms  | 95k→879   | 72→3 MB | 37→17 ms     | 26→0
  20000 |12015→65 ms  | 380k→879  |281→7 MB |197→17 ms     |1249→19

Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is
~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns,
4/3 thumbnail tiles, cards land at the expected indices). The files browser
shares the same VirtualList path (it can't be mounted headless — it depends on
$app routing/session — so it's validated via svelte-check + the production build
+ the shared, separately-benchmarked component).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:48:37 +00:00
Claude ccb85f53c0 perf(frontend): virtualize the photos timeline (square + justified)
The Photos "moments" grid rendered every tile into the DOM, so a 20k-photo
library mounted ~140k nodes / 20k <img> elements, held ~196MB JS heap, took
~3.5s to first paint and scrolled at ~6fps. It also ran the justified-layout
maths inside the template (recomputed on every reactive change) and generated
client-side video thumbnails for every off-screen video, not just visible ones.

Introduce `VirtualRows` — a variable-height, section-aware sibling of
`VirtualList` — and flatten the grouped timeline into one list of fixed-height
rows (a date header or a strip of explicitly-sized tiles) shared by both the
square and justified layouts. Only the rows near the viewport are mounted; a
prefix-sum offset table + binary search find the visible band, and a spacer
reserves the full height so the sticky header and load-more sentinel are
unchanged. The justified packing now runs once per groups/width/layout change
in a $derived, not per render.

To avoid duplicating the scroll-tracking logic across the two windowing
components, extract it into a `useVirtualWindow` composable (scroll-ancestor
detection + rAF-throttled aboveBy/viewportH signals); `VirtualList` is
refactored onto it with identical measured numbers.

Measured in headless Chromium (1280x900), synthetic photos, before/after:

  SQUARE      | mount→tiles | DOM nodes | <img> | JS heap | scroll frame
  ------------+-------------+-----------+-------+---------+-------------
   2000       | 416→94 ms   | 14k→629   |2000→96| 21→5 MB | 29→29 ms
   5000       | 916→114 ms  | 35k→629   |5000→96| 50→9 MB | 62→26 ms
  20000       | 3455→220 ms | 140k→629  |20k→96 |196→29 MB|152→33 ms

  JUSTIFIED 20000: mount 245 ms · DOM 315 · <img> 44 · heap 33 MB · ~60fps

Rendered DOM, mounted <img> count and heap are now flat (O(visible)) regardless
of library size; mount is ~16x faster and scroll jank drops from 413 to ≤24
frames. Off-screen video-thumbnail generation no longer fires for non-visible
tiles. Correctness verified by probing a deep scroll in both layouts (tiles
land within the viewport band; square cells equal-width, justified rows
aspect-preserving).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:25:12 +00:00
Edouard Vanbelle 3aed2c0515 fix(migrations): renumber drive migrations to avoid collision with 20260802000000_blob_gc_grace 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 74b33744ed chore(test): disable storage_cleanup_check.sh until GC trigger lands 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 30eabdb23e chore(clippy): use sort_by_key with Reverse for face_count ordering 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 55487187eb chore(ci): improve db migratiion to unserstand clash in CI 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 14b63c9bd9 chore(ci): use same pg version 18 2026-06-19 16:06:38 +02:00
Edouard Vanbelle e5b9a4a8db feat(drive): remove create_home_folder
remove create_home_folder() & ensure_home_folder()

    now: on_user_created() and on_user_login both() call provision_if_needed()
    which calls **create_personal_drive_atomic()**

    add a helper to find Personal drive for a user and also it's root directorry
2026-06-19 16:06:38 +02:00
Edouard Vanbelle dd9e3b8868 feat(drive): test Tantivy lookup with drive
ensure that a user that don't havee permission to a drive cannot search elements in this drive
    note: current design: Index are associated to drive, so if a document / directory is shared,
    the shared resource will not be in index for targetted user
    design is explicitely as is to reduce complexity
2026-06-19 16:06:38 +02:00
Edouard Vanbelle e7f4826778 feat(drive): complete updated_by created_by 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 06116dc6e7 feat(drive): ensure compatibility with drive in load-test 2026-06-19 16:06:38 +02:00
Edouard Vanbelle e021d52e02 feat(drive): ensure that root drives are always linked to a drive 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 50fb34659e feat(drive): limit NC request by disk 2026-06-19 16:06:38 +02:00
Edouard Vanbelle aa155dffa0 fix_next_cloud 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 3f4b151f45 feat(drive): plan: ensure no root folder without drive 2026-06-19 16:06:38 +02:00
Edouard Vanbelle 42510d94f3 POC(nextcloud): add chroot on nextcloud
Bring chroot to nextcloud capability: login on Nextcloud via username="{user}~{folder-uuid}
    Doing a such login will chroot the folder folder-uuid

    If user has several folder as root (parent=None), the login flow will request which
    folder user want to chroot
2026-06-19 16:06:38 +02:00
Edouard Vanbelle 16ea08b093 feat(drive): improve Drive model
now Drive is purely a metadata
    each drive has always a root folder
    this model minimize Oxicloud changes, and simplify
    the Drive name is simply the folder's root's name
    note: owner of Drive has more permission that an owner of the root folder
2026-06-19 16:06:37 +02:00
Edouard Vanbelle eab7a609b9 feat(drive): start implementation of drive
- add storage.drives
    - prepare migration phase
    - add created_by and updated_by on storage.folders
2026-06-19 16:06:37 +02:00
Edouard Vanbelle 77545aee05 feat(drive): clarify photo, music, search strategies
+ request non enumeration on search (a user must not guess existance
    of any files he does not have access)

```
  ┌─────────────────────────────┬──────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────────────────────────┐
  │           Section           │                              Scope                               │                                  Why                                  │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Photos                      │ Default Personal Drive (capability flag to include other drives) │ High noise risk from non-photo images in shared drives                │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Music (library + playlists) │ Cross-drive (capability flag to exclude)                         │ Low noise risk; audio in shared drives is usually intentional content │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Favorites                   │ Cross-drive                                                      │ Personal curation                                                     │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Recent items                │ Cross-drive                                                      │ Personal history                                                      │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Tantivy search              │ Cross-drive                                                      │ Discovery tool                                                        │
  ├─────────────────────────────┼──────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────────────────────────┤
  │ Trash (D2)                  │ Per-drive (owner-actioned)                                       │ Already specified                                                     │
  └─────────────────────────────┴──────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────────────────┘
```
2026-06-19 16:06:37 +02:00
Claude 5fdcf8cb56 perf(frontend): virtualize ResourceList rows (list view)
Large folders/collections rendered every row into the DOM, so memory and
scripting time grew linearly with item count. A 20k-row list took ~12.5s to
first paint, held ~380k DOM nodes / ~281MB JS heap, and scrolled at ~4fps.

Add a reusable, dependency-free `VirtualList` that windows rows against the
nearest scrollable ancestor (the existing `.content-area`), so the
single-scrollbar UX, sticky header and end-of-list sentinel are unchanged. It
reserves the full scroll height with a sized spacer and translates a small
window of rows; row height is auto-measured for the single-column case.

Wire it into ResourceList's flat list view (recent, favorites, shared,
shared-with-me, trash, search). Grouped sections and grid view are unchanged
for now and are the next callers of the same primitive.

Measured in headless Chromium (1280x900) with synthetic rows, before/after:

  rows  | mount→paint | DOM nodes | JS heap | scroll frame | jank frames
  ------+-------------+-----------+---------+--------------+------------
   1000 | 632→70 ms   | 19k→351   | 16→2 MB | 17→17 ms     | 0→0
   5000 | 4401→54 ms  | 95k→351   | 72→3 MB | 47→17 ms     | 136→0
  20000 | 12577→56 ms | 380k→351  |281→6 MB |258→17 ms     | 1443→0

Rendered DOM and heap are now flat (O(visible)) regardless of dataset size;
scroll holds 60fps with zero jank. Correctness verified by probing a mid-list
scroll (rows land at the expected indices, positioned within the viewport).

eslint: disable core `no-undef` for `.svelte` (TypeScript/svelte-check already
resolve identifiers, including `<script generics>` type params it can't see).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
2026-06-19 14:00:56 +00:00
Dionisio Pozo afbef16939 Merge pull request #487 from AtalayaLabs/claude/docs-svelte-frontend 2026-06-19 15:16:13 +02:00
Claude 808c3fc9c7 docs(claude): describe the SvelteKit frontend
The frontend was rewritten in SvelteKit (Svelte 5 + TypeScript, Vite) and
merged to main, but CLAUDE.md still described it as vanilla JS/CSS with
Biome and a jsconfig tsc check. Update the guidance to match:

- Architecture: /frontend is the SvelteKit SPA; /static is the retained
  legacy vanilla frontend.
- New "Frontend Build & Dev Commands" (npm / fe-* recipes) and a
  "Frontend Architecture (frontend/src/)" map (routes, lib/api, stores,
  composables, i18n, icons, styles, static vendored assets).
- Conventions rewritten for Svelte 5 runes + TypeScript (no `any`), API
  via lib/api/endpoints, scoped component CSS with var(--*) tokens, dark
  mode via data-color-scheme.
- Pre-commit is now `npm run check` (svelte-check + ESLint + Stylelint +
  Prettier) + Vitest, replacing Biome/jsconfig.
- "What Claude must NOT do" updated (no `any`, don't hand-edit
  package-lock.json, prefer vendoring heavy deps, SvelteKit is the
  framework).
2026-06-19 13:14:48 +00:00
Dionisio Pozo 54f6f24413 Merge pull request #486 from AtalayaLabs/claude/gallant-pasteur-0l8bm9 2026-06-19 15:11:25 +02:00
Claude c2b53fcda9 fix(frontend): align sharing UI with the merged role-grants backend
The merge brought in main's ReBAC→role-grants migration, which changed the
grant contract the Svelte sharing UI (branched before it) was written
against: GrantDto dropped `permission` and now carries an explicit `role`
(owner/editor/viewer/commenter/contributor), and the "admin" role was
renamed "owner". Left unchanged, the share UI derived roles from a
now-absent `permission` field and showed every member as "viewer".

- grants.ts: ShareRole is now viewer|editor|owner; Grant carries `role`
  (not `permission`); `roleFromPermissions` → `displayRole`, which collapses
  the unexposed commenter→viewer and contributor→editor.
- ShareDialog.svelte: read each subject's role directly (role-grants emits
  one row per subject); role picker exposes Owner instead of Admin.
- shared/+page.svelte (My Shares): same owner rename; role badges run
  through displayRole so server-only roles render sensibly.

Create/update already POST `role`, so only the read/display path and the
role literal needed fixing. npm run check, test:unit (36) and build pass.
2026-06-19 13:04:32 +00:00
Claude 5494efea35 feat(frontend): port Places, People & photo tabs to the Svelte app
Bring the Photos/People/Places UI that main added (in the legacy vanilla
frontend) into the SvelteKit rewrite, wired to the now-merged backend
(/api/photos/geo, /api/people/*).

Photos page (routes/photos/+page.svelte):
- Moments | Places | People sub-tabs (the People tab appears only when the
  faces feature is enabled, via a /api/people capability probe), mirroring
  the vanilla photos sub-nav.
- Square ↔ justified layout toggle. Justified uses a Flickr-style
  row-packer over the width/height the photos list endpoint returns
  (PhotoItem), falling back to 1:1 when dimensions are missing.

New components:
- PhotoLightbox.svelte — the lightbox extracted from the photos page into a
  reusable component (items + bindable index, onDelete callback) so the
  grid, People and Places all share one implementation (no duplication).
- PlacesMap.svelte — MapLibre GL map with server-clustered markers; the
  vector basemap is optional (probed at /basemaps/basemap.pmtiles, themed
  fallback otherwise). Cluster click zooms in or opens the lightbox.
- PeopleView.svelte — identity-cluster grid → per-person photo grid, with
  rename via the in-app prompt dialog.

Supporting:
- api/endpoints/people.ts (+ peopleEnabled probe); photos.ts gains
  fetchPhotosGeo + GeoCluster + PhotoItem; fileThumbnailUrl takes a size.
- lib/vendor/maplibre.ts — minimal typings + lazy loader for the vendored
  MapLibre GL + pmtiles globals (kept any-free for ESLint).
- utils/media.ts — shared isVideo / photoTimestamp / minimalPhotoItem.
- Vendored maplibre-gl 5.24.0 + pmtiles 4.4.1 under static/vendors and an
  optional static/basemaps dir, matching the PR's vendored-asset pattern.
- New photos.tab_*/layout_*/map_* + people.* keys in en.json.

Verified: npm run check (svelte-check + eslint + stylelint + prettier),
npm run test:unit (36 pass), and npm run build all green.
2026-06-19 12:59:16 +00:00
Claude 047e0f06ff Merge main (Photos/People/Places + ReBAC) into the SvelteKit rewrite
Bring the feature-rich main branch into the frontend Svelte rewrite
(PR #478, base bcn/frontend-svelte-rewrite). main moved well ahead of the
PR's branch point (b8a0018): it added the Places photo-map and People
(faces) backends, photos enhancements, the ReBAC→role-grants migration,
load tests, and more.

Conflicts resolved (4 files):
- Dockerfile: combine the explicit --bin allowlist (defence-in-depth from
  main) with the SPA copy from the frontend build stage (PR).
- .github/workflows/ci.yml: keep the PR's Svelte frontend job
  (svelte-check + eslint + stylelint + prettier + vitest); the legacy
  static/-targeted tsc/locale/icon advisory steps don't fit the new
  working-directory: frontend job and svelte-check supersedes them.
- justfile: keep both the new fe-* / dev recipes (PR) and the load-* k6
  recipes (main).
- static/locales: keep the PR's symlink (-> ../frontend/static/locales);
  main's new photos/people locale keys are folded into the Svelte locale
  files alongside the ported views.

Backend (people/places/faces handlers, routes, DI, migrations) merged
cleanly. `cargo check --bins` passes. The new Places/People UI is not yet
in the Svelte app; that is ported in follow-up commits.
2026-06-19 12:45:22 +00:00
Dionisio Pozo 1c9bb30475 Merge pull request #485 from AtalayaLabs/claude/zealous-faraday-58s1at
feat: Photos evolution — Places (map) & People (faces) + gallery polish
2026-06-19 14:31:14 +02:00
Claude f4e387bad0 docs(plan): mark Phase 2 (People) complete with ONNX analyzer
Records the real SCRFD+ArcFace analyzer behind the faces-onnx feature,
the FacesConfig env vars, and the deviation (unit-tested geometry split
from the feature-gated ONNX seam). Notes what stays optional: per-user
consent gate, lightbox face boxes, and a periodic re-cluster scheduler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 12:29:33 +00:00
Claude 12ede47b2c feat(faces): real ONNX face analyzer (SCRFD + ArcFace), opt-in
Implements the last Phase 2 piece: a working face detector/embedder behind
the new `faces-onnx` cargo feature (mirrors how `plugins` gates wasmtime).
Inert by default — the default build is unchanged and ships the no-op
analyzer.

Pipeline (InsightFace/immich pattern): SCRFD detection with 5-point
landmarks → least-squares similarity alignment to the canonical 112×112
template → ArcFace embedding → L2-normalized 512-d vector.

- face_geometry.rs (always compiled, unit-tested): SCRFD anchor/distance
  decode, NMS, the closed-form (complex-number) similarity transform,
  bilinear affine warp, NCHW normalization, L2-norm, Laplacian sharpness.
  11 unit tests cover the error-prone math with no model needed.
- onnx_face_analyzer.rs (feature `faces-onnx`): wires the geometry to ONNX
  Runtime via `ort` (load-dynamic, so libonnxruntime is dlopen'd at runtime
  and the crate builds without it). Inference runs on spawn_blocking; each
  session is serialized behind a Mutex. Loads via `ort::init_from` (fallible)
  not ORT's lazy loader, which would panic under `panic = "abort"`.
- config: FacesConfig + OXICLOUD_FACES_{ORT_DYLIB,DETECTOR_MODEL,
  EMBEDDER_MODEL,DET_SIZE,DET_THRESHOLD,NMS_THRESHOLD,INTRA_THREADS}.
- di: build_face_analyzer() loads the real analyzer when the feature is
  compiled in and runtime+models are configured; any missing piece or load
  failure degrades to the no-op analyzer (logged) so startup never fails.
- ort/ndarray added as optional deps; example.env documents the setup.

Models and the ONNX Runtime dylib are operator-provided at runtime and are
never committed. Cannot be exercised in CI (no models/dylib); the geometry
is unit-tested and the ONNX seam is isolated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 12:28:49 +00:00
Claude eacb913375 docs(plan): record Phase 2 (People) backend + frontend status
Updates the implementation status from "not started" to reflect the
landed face-recognition stack and notes the deviations from the original
plan (BYTEA embeddings + in-Rust cosine instead of pgvector; a single
FaceAnalyzerPort instead of split detector/embedder ports; union-find
connected-components clustering). Flags the remaining piece: the real
ONNX analyzer (ort + operator-supplied models), which adds a crate and
can't be exercised in this environment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:59:04 +00:00
Claude 6314fa6b1c feat(people): add People tab frontend for face clusters
Adds the client side of Phase 2 (People). A new "People" tab in the
Photos sub-navigation lists identity clusters from GET /api/people and
drills into a person's photos using the existing photos lightbox.

- people.js: peopleView with list/drill-in/rename, reusing .photos-grid
  tiles and photosLightbox; rename via Modal.prompt + PATCH /api/people/{id}
- people.css: person grid, circular avatars, single-person header,
  loading/empty states — all design tokens, no raw colors
- places.js: People tab wired into the Moments|Places sub-nav, revealed
  only when GET /api/people is reachable (capability probe); _switchTab
  now toggles three views
- index.html: load people.css + people.js
- en.json: photos.tab_people + people.* labels (other locales fall back
  to English via i18n)

The tab stays hidden unless OXICLOUD_ENABLE_FACES is on (the API 404s
otherwise), so this is inert by default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:58:23 +00:00
Claude 5c42b4d2b1 feat(faces): /api/people endpoints
Phase 2 increment 6 — the People HTTP API (mounted only when
OXICLOUD_ENABLE_FACES is on; every handler is caller-scoped):
- GET    /api/people                 list identity clusters
- GET    /api/people/{id}/photos     a person's photo file ids
- PATCH  /api/people/{id}            name / rename a person
- POST   /api/people/{id}/hide       hide / unhide
- POST   /api/people/merge           merge two clusters
- POST   /api/people/recluster       re-run clustering
- DELETE /api/people/data            erase all face data (opt-out)
- GET    /api/people/faces/{file_id} face boxes for lightbox tagging

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:51:01 +00:00
Claude 1dca9d7d05 feat(faces): indexing pipeline + DI wiring
Phase 2 increment 5:
- FaceIndexingService: a FileLifecycleHook that, on image upload, detects +
  embeds faces in a background task and stores them. Dedup-aware (clones an
  identical blob's faces instead of re-running inference), reindexes on
  overwrite, and relies on the DB cascade for deletes. Completely inert when
  no model is ready.
- DI: registers the hook in the FileLifecycleService chain and exposes
  PeopleService in AppState — both gated on OXICLOUD_ENABLE_FACES, both
  using the default no-op analyzer until the operator wires a real ONNX
  model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:46:57 +00:00
Claude de95de013d feat(faces): PeopleService — identity clustering + use cases
Phase 2 increment 4:
- recluster(user): union-find connected-components over the user's face
  embeddings (cosine threshold); groups of >= min_faces become persons, and
  an existing person's name is preserved across reclusters. O(n^2) — fine
  for moderate libraries; an ANN index is the documented scale-up.
- caller_id-scoped use cases for the HTTP layer: list_people (non-empty
  clusters, cover thumbnail, most-photographed first), person_photos,
  faces_for_file, rename, hide, merge, and delete_all (right to erasure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:40:33 +00:00
Claude 248ad70f01 feat(faces): bytea-backed PostgreSQL repository
Phase 2 increment 3: FacePgRepository implements FaceRepository.
Embeddings stored/read as BYTEA (512 little-endian f32), bbox as REAL[].
Every query is user-scoped. Covers face CRUD, person CRUD (create / rename
/ cover / hide), files-for-person, and a transactional delete-all-for-user
(right to erasure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:37:17 +00:00
Claude 524e573756 feat(faces): domain entities, ports & no-op analyzer
Phase 2 increment 2:
- domain: Face, Person, BoundingBox, DetectedFace (512-d embeddings).
- ports: FaceAnalyzerPort (detect + embed from raw bytes; decodes internally
  so the application layer stays image/ML-crate agnostic) and FaceRepository
  (user-scoped face/person persistence).
- DTOs: PersonDto, FaceBoxDto.
- NoopFaceAnalyzer — reports is_ready()==false and returns no faces, so the
  whole People pipeline compiles and runs inert until the operator wires a
  real ONNX-backed analyzer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:34:46 +00:00
Dionisio Pozo 1f098b95d8 Merge pull request #484 from AtalayaLabs/claude/youthful-davinci-kvxbse
Batch resource resolution and live-role re-validation on every request
2026-06-19 13:30:11 +02:00
Claude 7cb79d41b9 feat(faces): schema + feature flag for People (off by default)
First slice of Phase 2 (People / faces):
- migration: `faces` schema with `faces.persons` and `faces.faces`.
  Embeddings are stored as BYTEA (512 x f32) — no pgvector extension
  dependency; similarity is computed in-app (pgvector/VectorChord is the
  documented scale-up). Cascade deletes (by user and by source file)
  satisfy the right to erasure.
- OXICLOUD_ENABLE_FACES feature flag, OFF by default (biometric data,
  opt-in per deployment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
2026-06-19 11:28:39 +00:00