Phase 0 of the delta-sync plan. Re-uploading a file the user already has
(another device, a restore, a duplicate) used to transfer every byte just
for the server to discard them as a dedup hit. The frontend now computes
the file's BLAKE3 locally and, on a hit, registers the file with a single
~150-byte metadata call.
Server — POST /api/files/by-hash:
- All checks live in the application service per the AuthZ rule:
Create permission on the target folder via the authorization engine,
hash ownership via the existing user-scoped query (a non-owned hash
returns 404 — same shape as "no such blob" — and emits an
instant_upload.rejected audit event), quota on the logical size.
- On success: one ref_count bump + the existing save_file_with_blob row
registration (compensation included); is_new_blob=false so lifecycle
hooks skip thumbnail regeneration. ~10 ms warm.
- The storage-usage service is now built before the application services
and injected, instead of only living on AppState.
Client — WASM BLAKE3 + worker:
- wasm/oxicloud-hash: the exact same blake3 crate the server uses,
compiled with WASM SIMD128 (~660 MB/s measured) so browser hashes match
server content addresses bit for bit. Built by scripts/build-wasm.sh;
the artifacts (45 KB wasm + 8 KB glue) are vendored like pdf.js — no
npm dependencies, no wasm toolchain needed for regular builds.
- static/js/workers/hashWorker.js streams the File in 8 MiB slices off
the main thread (constant RAM at any file size).
- features/files/instantUpload.js orchestrates: threshold (8 MiB — below
it the round-trips cost more than the bytes), user-scoped
/api/dedup/check, by-hash registration, and silent fallback to the
normal byte upload on any miss, race or unsupported environment.
Wired into both uploadFiles and uploadFolderEntries.
- biome.json vendors exclusion fixed to cover nested directories
(previous vendors were .mjs and never matched the *.js include).
Verified end-to-end against PostgreSQL 16: node-driven WASM hash equals
the server's content_hash for a 20 MB file; by-hash returns 201 in ~10 ms
warm with a 151-byte request (vs 20,971,873 bytes for the byte upload);
the copy downloads byte-identical and the manifest ref_count goes 1→2;
a second user probing the same hash gets exists:false and 404 plus the
audit line; duplicate name → 409, malformed hash → 400; worker and wasm
are served with correct MIME (application/wasm).
https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS
Three targeted frontend fixes:
1. pdf.js smart preload (thumbnail.js, resourceIcon.js). The first PDF
thumbnail of a session stalled 1-2s on the lazy import of the ~1.3 MB
pdf.js stack. buildResourceIcon() now fires thumbnail.preloadPdf()
the moment a PDF row enters the DOM, warming both the module
(~300 KB, via the now promise-memoized getPdfjsLib, shared with real
users) and the worker script (~1 MB, via a cache-priming fetch —
pdf.js only requests it on first getDocument). Only folders that
actually contain PDFs pay the download; idempotent after first call,
resets on failure so transient offline retries.
2. Admin migration poll cleanup (admin.js). The 2s setInterval kept
hitting the API and updating hidden DOM after leaving the Storage
tab, and polled a failing endpoint forever after session expiry
(!resp.ok returned without clearing). New stopMigrationPolling()
helper, invoked on tab switch away from Storage, on non-running
status, and on failed polls; tab re-entry re-arms via loadStorage().
3. resourceList.js render hoisting. Per-row i18n.t() type-cell lookups
and the fully item-invariant _renderCustomActions() HTML were
recomputed for every row; they now resolve once per batch via
_buildItemLabels() (per-category labels memoized, rebuilt each
batch so locale switches keep working). _findLaneByKey() swaps the
container-wide attribute querySelector for an O(1) _lanes Map kept
in sync at the only lane create/wipe sites.
https://claude.ai/code/session_0193Hff42gaA962wThxMGSd1
Every folder navigation awaited rebuildBreadCrumb() — one sequential
fetch per ancestor level — before requesting the first page, so a
depth-8 folder paid ~8 RTTs of dead time before the content even
started loading. And nothing was cached: navigating between sibling
folders re-fetched the same ancestors every time.
- filesModel: session cache id → FolderItem for breadcrumb resolution
(warm navigations rebuild the trail with zero fetches), invalidated
on rename/move (single + batch) via invalidateFolderMeta. The trail
is now built locally and committed atomically, guarded by a
generation token so a rebuild superseded by a faster follow-up
navigation can no longer interleave writes into the newer trail.
- filesView.loadFiles: the rebuild runs concurrently with the first
page fetch; crumbs, history and title update when it resolves.
Navigation latency becomes max(listing, ancestor chain) instead of
their sum — and ~equal to the listing alone once the cache is warm.
The home-folder fallback for inaccessible targets is preserved by
reloading the listing when the rebuild had to reset app.currentPath.
https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
Four related fixes to stop buffering files in page memory and stop
hammering layout from the selection paths:
- Inline viewer media: video/audio fetched the ENTIRE file into a blob
before the first frame (a 2 GB video = 2 GB of tab heap, no
progressive playback, no seek). The <video>/<audio> src now points
straight at the same-origin API URL — cookies travel automatically
and the browser streams with native Range requests, exactly like the
music player already did.
- Downloads (file, folder ZIP, batch ZIP, viewer button): the
fetch → blob → objectURL pattern materialized the whole payload in
RAM before the save dialog appeared (a 10 GB batch ZIP risked
crashing the tab) with no download progress UI. New shared
utils/download.js hands the URL to the browser, which streams to
disk with its own progress UI. Batch download uses the existing GET
endpoint (same URL contract as the drag-out DownloadURL, now shared
via buildBatchDownloadUrl); selections whose id list cannot fit in a
URL keep the buffered POST fallback. Trade-off: failed downloads now
surface in the browser's download shelf instead of an in-app toast.
- Rubber-band lasso: every mousemove (>100/s) walked all cards
interleaving getBoundingClientRect() reads with class writes — up to
N forced reflows per event, freezing the frame rate on folders with
thousands of loaded rows. Card rects (+ item info) are now snapshot
once per drag (rebuilt on scroll), and a single rAF pass per frame
compares against the cached geometry, touching only cards whose
selection state changed.
- batchToolbar: getSelection() rebuilt the selection by scanning every
.file-item in the document (the TODO admitted it); it now reads the
_selected Map that every selection path already keeps in sync.
clear() scopes its DOM sweep to #files-list (the only container the
toolbar manages) instead of the whole document.
https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
displaySearchResults inserted up to 100 results one at a time via
addItem({ scroll: true, highlight: true }) — each insert ran two DOM
scans (duplicate guard + row lookup), a smooth scrollIntoView and a
highlight pulse, so every live-search keystroke triggered ~100 competing
smooth-scrolls and O(n²) container scans.
Add filesView.renderItems(): a single component.render() pass (one
DocumentFragment, zero per-item scans/scrolls). addItem keeps the
scroll/highlight affordances for its documented purpose — optimistic
single-item inserts after upload/create.
https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
`uploadFiles` sent files one at a time (await per file), so dropping N
files meant N sequential round-trips. Reuse the bounded-concurrency pool
already proven in `uploadFolderEntries` (CONCURRENCY = 10): independent
files now upload up to 10 at a time — ~10x faster for many small files.
Per-file progress (XHR → bell), the legacy progress bar, timeout
notifications and the quota short-circuit are preserved. On a quota error
the pool stops pulling new files while in-flight uploads finish (matching
`uploadFolderEntries`) instead of the old hard `break`.
https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
Deleting a multi-photo selection fired one `DELETE /api/files/{id}` per
photo in series — 300 selected photos meant 300 sequential round-trips
with the UI blocked on `confirm()`. Switch to `POST /api/batch/trash`
(already used by the file view), chunked to the backend's MAX_BATCH_SIZE
of 1000, so a selection of any size collapses to ceil(N/1000) requests.
Only items the server reports as successfully trashed are removed from
the grid; the selection bar refreshes to reflect any failures.
https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
When a new browser visits the login page, the language selector runs first.
After the user selects a language and clicks continue, the code checked
system status and correctly showed the login panel when `initialized=true`
— but did not hide the "Set up administrator" link.
That link was only hidden by `showInitialPanel()`, which returns early
(without reaching the hide logic) whenever `isFirstRun()` is true. So on
any browser that had not previously stored the locale key, the link stayed
visible and clickable, leading users back to the admin setup panel even
after an admin already existed.
Fix: hide the link in the language-continue handler's `else` branch,
mirroring the same guard already present in `showInitialPanel()`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ensure not loading twice the same variables, namespace to ensure it in the future
- end to end tests where successfull, need to check it is not reusing a previous release (in cache from build CI)
- fix: #377#378
Adds full Traditional Chinese translation (628 keys, 100% parity with
en.json) and the registration plumbing to make it pickable in the UI.
Registration spans three layers that all needed updating for the locale
to actually be selectable end-to-end:
- static/locales/zh-TW.json (new) — TW vocabulary (儲存/雲端/檔案/偵測),
uses 「」 corner brackets for in-string quoting
- core/i18n.js: add 'zh-TW' to supportedLocales — without this,
setLocale('zh-TW') was silently rejected by the whitelist and the
previous locale stayed active (visible as the "picked 繁中 but the
setup step still shows 簡中" bug)
- core/languageSelector.js: add 'zh-TW' to AVAILABLE_LOCALES + fallback
- features/auth/auth.js: add 'zh-TW' to ALL_LANGUAGES (🇹🇼 繁體中文)
and LANGUAGE_TEXTS bootstrap table (used before i18n loads)
Browser detection rewrite (i18n.js + auth.js detectBrowserLanguage):
The previous navigator.language?.substring(0, 2) truncated zh-TW → zh
and routed Traditional Chinese browsers to Simplified. Replaced with
three-tier matching: exact full-tag > Chinese script/region heuristic
(zh-Hant*, zh-{TW,HK,MO}) > primary subtag fallback.
Disambiguates the existing zh entry: "Chinese / 中文" became
"Simplified Chinese / 简体中文".
Drive-by cleanups discovered while wiring up the above:
- Remove dead t() in i18n.js (export uses safeT, no callers of bare t)
- Remove dead fetchUserData() and logout() in auth.js (userMenu.js has
its own local logout())
- Extract errMessage(unknown→string) and inputVal(id) helpers for the
catch sites and getElementById('x').value sites that needed TS
narrowing under checkJs
- Type-annotate module-scope let forms/errors/panels with
HTMLFormElement and HTMLElement so .addEventListener and .reset()
resolve under strict
- Drop navigator.userLanguage IE legacy fallback (DOM lib has no field)
- jsconfig.json: drop exactOptionalPropertyTypes (only valid with
strictNullChecks, which the project deliberately disables)
- .gitignore: ignore docker-compose.override.yml for local bind-mount
dev workflow
Verified clean before commit: biome ci, tsc --noEmit, i18n key parity
(628/628), HTTP smoke test against running container.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- protect file_management_service::rename_file with validate_storage_name
- remove specific rename modal and use the generic modal class (less duplicate)
- handle errors on modal action: do not close the modal on error and display this error
- hide "Go to parent folder" contextMenu if section is files and folder is the same as current one
this change replace original window.fetch by a wrapper that check any 401 response, is so it will request a refresh token
this solve current issue with Favorites & Recent sections that give blank page when token is expired
- exclusion of requests to other domain (401 will not be handled here)
- security with shares /api/s is not handled
- check with CSRF, no risk