- TagBadge: derive color/style so they track a changing tag prop.
- FilterBar: seed tokens from the value prop via untrack (the effect keeps
it in sync), silencing state_referenced_locally.
- FileCard: give the interactive card role="button".
- admin user toggles: add aria-labels to the switch buttons.
svelte-check is now clean (0 errors, 0 warnings).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the GNU AGPL-3.0 license text, point the openapi.yaml license metadata
at AGPL-3.0-or-later (was the "Proprietary" placeholder), and note the
license in the README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the in-pool file selection the same action set as the files grid.
SelectionBar is now driven by props (count + callbacks) instead of reading
the selection store directly, with an optional "Remove from pool" action.
The pool page uses it to offer Edit tags, Add to pool, Mark reviewed,
Remove from pool and Delete (to trash), replacing the previous
add/remove-only bar.
Also rename the content_datetime label from the photo-centric "Date taken"
to "Content date" across the files sort, the file viewer field and the pool
sort options (and align the pool's "created" option with the files grid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pools now store a sort setting (sort_key + sort_order). "manual" keeps the
user-arranged order in file_pool.position and allows drag-to-reorder; any
other key (content_datetime, created, original_name) sorts the pool's files
automatically server-side, in which case reordering is rejected. Manual
order is always ascending by position — direction does not apply.
Backend: add the columns (sort_key defaults to 'manual'), generalise the
pool-files keyset cursor to page by the active sort, persist the setting on
create/update, and guard reorder against non-manual pools.
Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes
the pool and reloads; drag-to-reorder is disabled unless the pool is manual.
Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prepare the 3.0.0 release:
- Bump the version in openapi.yaml and frontend/package.json to 3.0.0.
- Document the existing GET /health endpoint in openapi.yaml (served at the
root, outside /api/v1) and refine the auth note.
- Add docs/REQUIREMENTS.md (product requirements, in English) and
docs/ARCHITECTURE.md (system overview); remove the old Russian
docs/Описание.md.
- Rewrite GO_PROJECT_STRUCTURE.md and FRONTEND_STRUCTURE.md to match the
current code (dedup CLI, imagehash, real components/stores, Tailwind v4).
- Fix stale counts and references in CLAUDE.md and link the new docs from
README.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
minimalJPEG() returned a hand-written byte literal that passed MIME
sniffing but failed image.Decode ("missing SOS marker"), so the upload
path left phash NULL and TestDuplicateDetection found no duplicate
clusters. Build the fixture with image/jpeg.Encode instead, so it decodes
through the same path the server uses to hash uploads. Encoding is
deterministic, so two uploads still hash identically.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run the HTTP server in a goroutine and, on SIGINT/SIGTERM, call
srv.Shutdown so it stops accepting connections and lets in-flight
requests finish before exiting (ErrServerClosed is a clean exit). This
stops uploads/streams being cut when the container is stopped or
recreated on deploy.
The drain deadline is configurable via SHUTDOWN_TIMEOUT (default 15s).
docker-compose.yml feeds the same variable into the app's
stop_grace_period, so Docker won't SIGKILL mid-drain and the two values
can't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat key/value metadata editor with a recursive tree editor.
Each entry is a leaf (scalar edited as text) or a nested object with its own
children; a per-row toggle switches between the two, expanding pasted JSON
into rows and collapsing groups back to JSON text without losing data. Leaf
values still round-trip as JSON where possible, so numbers and booleans keep
their type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a key/value metadata editor to the file viewer (display, add, edit and
remove fields; values round-trip as JSON where possible, otherwise as plain
strings) and a compact side-by-side metadata preview to the duplicate merge
dialog so each side's keys and values are visible while choosing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FileRepo.Update rewrites every editable column, so a PATCH that omitted a
field cleared it. Seed original_name and metadata — which have no always-
present input in the file editor — from the current file so a partial
update leaves them untouched instead of nulling them. The merge path builds
its own complete patch and calls the repo directly, so it is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each action row now shows the perceptual distance (Δn) between the kept file
and that file, read from the new per-cluster distances; recomputed live when the
survivor pick changes. A transitively-linked pair with no stored distance shows
a muted Δ—.
ListVisible already loads each pair's Hamming distance, but clusterPairs threw
it away. Thread it through: Clusters now returns a Cluster carrying the stored
pairwise distances (indexed once per page), and the list endpoint emits them as
{a, b, distance}. Pairs linked only transitively have no stored distance and are
omitted. Lets the UI show how close each file is to the kept one without a
client-side hash compare (phash exceeds JS's safe-integer range).
The lightbox only showed the preview image, but dedup decisions need the
metadata — date, tags, EXIF — to choose a survivor. Replace it with the same
FileViewer the files page uses, mounted as a full-screen overlay with prev/
next wired across the cluster's files. A review toggle made in the viewer is
mirrored back into the cluster list. Drops the now-superseded PreviewLightbox.
Every action button (Not a dup, Delete, Merge) called reload(), which wiped
the whole cluster list and refetched from offset 0 — re-running the costly
server-side clustering and jumping the scroll to the top on each tap. Give
each cluster a stable local key and edit it in place: drop the resolved file
from its cluster, removing the cluster when fewer than two files remain. The
server view is live, so subsequent pages reconcile. A separate monotonic page
cursor keeps "Load more" from repeating clusters after local removals.
The duplicate cluster view only showed 96px thumbnails, too small to tell
near-duplicates apart. Add a zoom button per tile that opens a lightbox
showing the full preview — the same image the single-file viewer uses — and
pages across the cluster with arrows/Esc. The zoom button stops propagation
so it doesn't also reassign the cluster's "keep" selection.
Thumb and FileCard fetched their auth-gated thumbnail on mount, so a view
that rendered many tiles at once — notably a duplicate cluster, which can
hold hundreds of files — fired thousands of requests immediately and hung
the tab. Gate each fetch behind an IntersectionObserver that loads only when
the tile nears the viewport (200px rootMargin), then stops observing.
The distance histogram over a real ~65k-pair table is flat-to-rising from
distance 1 and nearly doubles at 10 — the 8-10 band is dominated by
coincidental matches, not duplicates. Default the example to 4 (the valley
minimum) to keep the genuine-duplicate signal and cut the pairs to cluster.
The backend has had PUT /files/:id/content for a while, but nothing in the
UI exposed it. Add a "Replace file content" action to the file viewer's top
bar: it opens a file picker, confirms before overwriting (the original bytes
are replaced irreversibly under the same id; tags/pools/metadata are kept),
then uploads via a new api.uploadPut helper.
The file id is unchanged, so the preview URL stays the same while the bytes
behind it don't — refetch the preview past the browser cache (the server
cache is already invalidated by the replace) and re-mint the content token so
"open original" serves the new content. A spinner overlay covers the preview
during the upload, and the viewer's own shortcuts yield while the confirm is
open or a replace is in flight.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous Python/Flask app under docs/reference/ was kept as a visual
design reference while bootstrapping the new frontend. That's well past
done, and 85 files of dead code just add noise to search and exploration.
Remove it (recoverable from history if needed) and update CLAUDE.md: keep
the design tokens, drop the now-dead pointer at the folder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pool view's selection bar could only remove files from the current
pool. Add an "Add to pool" action beside it that opens the existing file
picker with the selected files (in selection order), so a multi-select can
be copied into another pool in one step. On success the picker closes and
the selection clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer could only open the pool picker via its top-right button —
there was no `p` shortcut there (only the grid had one), so pressing `p`
on the view page did nothing. Add `p` to open the picker from the viewer,
and give the picker itself full keyboard control: `/` focuses the search
box, arrows move a highlight through the pool list, Enter adds to the
highlighted pool, and Escape clears the search first, then closes.
Both the viewer and the grid now yield the keyboard entirely to the open
picker (the picker owns Escape via its own window handler) so the
clear-then-close behaviour isn't pre-empted by the host's own Escape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Video thumbnails and previews were extracted ~1s in, which lands on
shared intros, title cards or black lead-in frames. Take the frame from
the middle (duration/2) instead — the same frame used for the perceptual
hash — so the thumbnail/preview reflects what dedup compared. Fold the
midpoint logic into a shared extractVideoFrameMiddle helper reused by
both the thumbnail/preview path and VideoFrameMiddle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shift range-select normalized the range with Math.min/Math.max and
always iterated ascending, so the selection's insertion order (which the
Set preserves and which carries through to e.g. pool add order) ignored
the gesture direction. Iterate anchor → target instead via a shared
selectRange helper, so selecting first→last and last→first yield
correspondingly ordered selections.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorder was the only pool-file operation that didn't record an audit
entry, unlike AddFiles (file_pool_add) and RemoveFiles
(file_pool_remove). Log file_pool_reorder on success and seed the new
action type.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both phases now share a small, dependency-free progress indicator: an in-place
bar on a TTY (e.g. `docker compose run`), and a line every 10% when stdout is
piped (cron/CI) so logs don't fill with carriage returns. Also fixes the pairs
phase, which mislabelled its progress as "hashed" — it now reads "matching".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dedup pairs rebuild reads this tunable (default 10/64); it was added to the
backend config but never documented for operators. No other new env vars were
introduced by duplicate detection — the dedup compose service reuses the
existing PUID/PGID/FILES_DIR/THUMBS_DIR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a `dedup` task service under the "tools" profile so it's kept out of
`docker compose up` and run on demand:
docker compose run --rm dedup # hashes, then rebuild pairs
docker compose run --rm dedup -pairs # only rebuild pairs
docker compose run --rm dedup -hashes # only backfill hashes
It reuses the app image, .env, volumes and networks, overriding only the
entrypoint to /app/dedup. Unlike `docker exec` on the live server, this runs in
its own container and is self-documented for cron/CI use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend build stage compiled only ./cmd/server, so the dedup maintenance
tool was never available on deploy. Build it alongside the server and copy
/out/dedup to /app/dedup in the runtime image (which already has ffmpeg/ffprobe
for video frames and the /data volume). Run it with `docker exec`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the duplicate-detection UI:
- api/duplicates.ts: getDuplicates / dismissDuplicate / resolveDuplicate, plus
the cluster and merge-field types.
- /files/duplicates: an offset-paginated list of clusters. Each cluster shows its
files (auth-loaded thumbnails via a reusable Thumb component); the user clicks a
file to mark it the survivor, then per other file: Merge, Delete, or "Not a dup"
(dismiss). The list reloads after each action so it stays consistent with the
rescan-gated server state.
- DuplicateMergeDialog: a bottom sheet to merge two files field-by-field — each
scalar from the kept or other file, metadata keep/other/merge, tags & pools
keep-or-union, with a swap-survivor toggle and an optional trash-the-other box.
- Entry point: a Duplicates action in the files Header next to Trash.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GET /files/duplicates, POST /files/duplicates/dismiss and POST
/files/duplicates/resolve to the OpenAPI spec, plus the DuplicateCluster,
DuplicateClusterPage and DuplicateResolve (with MergeScalarChoice /
MergeRelationChoice) schemas describing the field-by-field merge contract.
Also fills a pre-existing gap in the File schema: it now documents the `tags`
array (always returned by the API) and marks the always-present fields required,
so generated clients type these as non-optional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd/dedup is the offline maintenance tool for duplicate detection. It reuses the
server's config and runs two phases (both by default; -hashes / -pairs to pick):
- hashes: compute the perceptual hash of every live image/video missing one —
images from their bytes, videos from a middle frame via DiskStorage.
VideoFrameMiddle. Per-file failures are reported and counted, not fatal.
- pairs: rebuild data.duplicate_pairs from all current hashes (DuplicateService.
Rescan).
Idempotent and safe to re-run: hashing only touches NULL phashes, the pairs
rebuild is a full replace. This is how video phashes and any backlog get
computed, and how newly uploaded duplicates become visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the duplicate-detection backend on top of perceptual hashing:
- Two tables (edited into the original migrations): data.duplicate_pairs holds
precomputed near-duplicate candidates (rebuilt wholesale by the rescan), and
data.duplicate_dismissals is a global "not a duplicate" overlay that survives
rescans. New audit actions file_merge / duplicate_dismiss.
- DuplicateService:
- Rescan builds every pair within DUPLICATE_HASH_THRESHOLD via a BK-tree over
the perceptual hashes and replaces the pairs table. This is the only thing
that populates pairs, so GET never compares all-vs-all (scales to 110k+).
- Clusters reads the precomputed pairs (ACL-filtered, non-trashed, non-
dismissed), groups them into connected components via union-find, and
paginates whole clusters.
- Resolve merges a pair field-by-field: each scalar from keep or discard,
metadata keep/discard/shallow-merge, tags/pools keep or union; then trashes
the discarded file. Enforces edit ACL on both.
- Dismiss records a canonical pair (view ACL on both).
- Endpoints under /files: GET /files/duplicates, POST /files/duplicates/dismiss,
POST /files/duplicates/resolve (registered before /:id to avoid collision).
Plain delete reuses /files/bulk/delete.
- Repo support: ListMissingPHash, ListAllPHashes, CopyPoolMemberships, plus the
DuplicatePairRepo (ReplaceAll via COPY, ListVisible) and DismissalRepo.
Unit tests cover the BK-tree pairing, union-find clustering, metadata merge and
field validation; an integration test covers rescan -> list -> merge -> dismiss
(including that a dismissal survives a re-rescan).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a 64-bit dHash perceptual hash (internal/imagehash, built on the existing
disintegration/imaging — no new dependency) and starts populating the long-unused
data.files.phash column:
- Upload sets phash inline for images (cheap, from the in-memory bytes).
- Replace recomputes it from new content for images and clears it for anything
else, so a stale hash never survives a content swap.
- FileRepo.SetPHash sets/clears the hash (used by Replace and, later, the dedup
backfill).
- DiskStorage.VideoFrameMiddle extracts a frame from the middle of a clip
(ffprobe duration -> ffmpeg -ss duration/2), avoiding the shared-intro collision
a fixed early offset causes. It is a concrete method, not part of the storage
port: only the dedup CLI needs it, keeping ffmpeg off the upload path. Video
phashes are therefore computed by that CLI, not at upload time.
- DUPLICATE_HASH_THRESHOLD config (default 10/64) for the later pair rescan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `# syntax=docker/dockerfile:1` line made BuildKit fetch its frontend
image from Docker Hub on every build, even when all base images and layers
were already cached. On a host that briefly can't resolve registry-1.docker.io
this is the first and only mandatory network round-trip, so the build fails at
"resolve image config for docker-image://docker.io/docker/dockerfile:1" before
any stage runs.
This Dockerfile uses no frontend-specific syntax (no heredocs, no RUN --mount,
no COPY --link) — only multi-stage, COPY --from/--chown, RUN, ENV, etc., all
handled by the engine's built-in frontend. Dropping the directive removes the
Docker Hub dependency and lets a fully cached build complete offline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tag pooled connections with application_name="tanabata-backend" via the
parsed pgxpool config, so the backend's sessions are identifiable in
pg_stat_activity and server logs. An application_name supplied in the
DSN (or PGAPPNAME) still takes precedence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The API client only skipped JSON parsing for 204, so a success with an
empty body (e.g. 201 from POST /pools/:id/files) hit res.json() on an
empty stream, threw, and surfaced as "Failed to add to pool" even though
the add had committed. Read the body as text and parse only when present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The manifest's largest, maskable icon (ms-icon-310x310) was transparent,
so installing the PWA produced a transparent app icon. Generate opaque
192/512 "any" + maskable icons from favicon-bg.png (solid #524B6B
background, maskable variants padded into the inner 80% safe zone) and
point the manifest at them instead of the transparent ms-icon entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the Files grid's roving keyboard focus on the tag and category
lists (and the tags shown on a category page): arrows move a focus ring,
Enter opens the focused item, "/" jumps to search, Escape drops the ring.
Extracts the model into a reusable createRovingGrid controller; vertical
movement is geometric since the pills wrap at variable widths. The
tag/category edit pages gain Escape-to-leave parity with the file viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grid windowed to ~4 viewports, dropping off-screen rows as it grew.
That broke large multi-selects: range/drag selection could not span past
trimmed cards, which silently vanished mid-scroll. Accumulate all loaded
rows for the visit instead; the grid is still cleared on sort/filter
change and on leaving the page (reset effect + section cache). Removes
the now-dead trim/anchor-refill fallbacks in loadMore/loadPrev.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DSL already supported m~/m= tokens but the filter UI had no way to
add them. Add Images/Video quick buttons and a free-text MIME input that
append m~<pattern> tokens (LIKE on the type name), plus friendly token
labels in dsl.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FilterBar gains an Any/Needs review/Reviewed segment (r=1/r=0 token);
FileCard shows a "needs review" dot; FileViewer gets a header toggle that
propagates back to the grid; SelectionBar gains a bulk "Mark reviewed"
action. Adds a --color-success theme token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add File.needs_review, the POST /files/bulk/review path, and the r=1/r=0
filter tokens to the DSL description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the old "untagged" sentinel tag with a proper per-file workflow
status: needs_review starts true on upload/import and is cleared by an
explicit action (no auto-clear on tagging). Surfaced as a filter token
(r=1 needs review, r=0 done) so it combines with tag/MIME conditions, and
toggled via POST /files/bulk/review (single id or many, edit-ACL enforced,
audit-logged as file_review).
needs_review lives on data.files (column added to the original 003 migration,
partial index in 006, action type seeded in 007).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
os.ReadDir returns entries in name order; sort them by ascending mtime
before importing so each Upload's created_at reflects the files'
chronological order. mtimes are cached once for the sort and reused as the
content_datetime fallback, dropping the redundant per-file Info() call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CreateRule accepted apply_to_existing but ignored it, so enabling the
checkbox while creating a rule never retroactively tagged files already
carrying the when-tag — only activating an existing rule did. Extract the
retroactive expansion into TagRuleRepo.ApplyToExisting (reused by SetActive)
and call it from CreateRule when the rule is active, inside one transaction
so a file is never left half-tagged. Mirrors SetRuleActive semantics,
including following only active downstream rules.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every duration in the config is a token TTL (access, refresh, content). A zero
or negative value mints already-expired tokens — no login, no media playback —
and previously loaded silently. parseDuration now rejects <= 0 with a clear
error, so misconfiguration fails fast at startup instead of mysteriously at
runtime. The AuthService itself stays permissive (it's constructed directly in
tests with arbitrary TTLs); config load is the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add POST /files/{file_id}/content-token to the spec, note that the content
GET's access_token parameter also accepts a content token, and document the
CONTENT_TOKEN_TTL knob (default 6h) and its leak/revocation trade-off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mint a content token on file load (POST /files/:id/content-token) and put it in
the original-content URL instead of the access token, so opening an original —
especially a long video — in a new tab keeps working past the 15-minute access
token expiry. Falls back to the access token until the content token arrives,
and re-mints when paging to another file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.
Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root README covering the stack, quick start, and dev commands, plus a Reverse
proxy (nginx) section: client_max_body_size for large uploads, forwarded headers
feeding the rate limiter, and buffering-off for streaming large media.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>