Add POST /pools/{id}/views, mirroring the file-view endpoint: it
enforces view ACL and appends a row to activity.pool_views (viewed_at
defaults to statement_timestamp(), so each view is its own history row).
The table existed but nothing wrote to it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the bottom-sheet pool picker (load, search, add) into a
reusable PoolPicker component and use it both for the grid's bulk
selection and from a new button in the file viewer's top bar, which adds
the single open file to a chosen pool. While the picker is open the
viewer hands it the keyboard so Escape closes the sheet (even from its
search) instead of the viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the server's secondary sort in the client-side sortTags (used for
a file's assigned tags) so the assigned and available lists agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The category_name tag sort now breaks ties within a category by the
tag's own name (same direction), so tags group by category and read
alphabetically inside each group; uncategorized tags stay last.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Listing files with a tag filter now logs each referenced tag to
activity.tag_uses, flagging it included (positive) or excluded (negated
under an odd number of NOTs); the untagged pseudo-token is skipped. The
filter AST is reused to determine polarity, so grouped negations like
!(A|B) mark both tags excluded.
Recording happens only when a filter is first applied — not on cursor
pagination or an anchored return — so one browse counts once. The write
is best-effort and never fails the listing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the single-file viewer's tag filter, Escape now clears a non-empty
filter first; on an empty filter it blurs and scrolls the preview back
to the top. In the bulk (multi-file) editor it clears, then releases
focus, so only the next Escape reaches the page handler and closes the
popup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Describe /files/import's application/x-ndjson response and the start/file/
done/error event schema.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consume the import endpoint's NDJSON progress stream via a new postStream
client helper (reuses the bearer token and 401 refresh, but keeps the body
as a stream). The Settings import card now renders a live progress bar
(processed/total) and a scrolling per-file list where each entry shows its
status — imported, skipped or error — with the failure reason inline and the
newest row kept in view. A final summary replaces the old single-shot result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The import endpoint did all the work in one request and returned only an
aggregate summary, so the UI couldn't show progress or per-file status.
Refactor FileService.Import to take an optional progress callback and emit
a "start" event (with the total entry count), one "file" event per entry as
it finishes (index, filename, status, optional reason), and a final "done"
event with the tallies. The handler streams these as newline-delimited JSON
and flushes after each, deferring the response headers until the first event
so a validation error raised before any file is touched is still returned as
a normal JSON error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend now shells out to exiftool for metadata extraction, so it must
be present alongside ffmpeg in the Alpine runtime stage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous goexif reader only understood EXIF in JPEG/TIFF, so videos,
PNGs and any image without an EXIF block were stored with no metadata at
all. Shell out to exiftool instead (the same tool the prior version used),
which covers images, video and audio in one pass.
Run it with `-n` so every tag comes back as a raw numeric/machine value
(FileSize in bytes, Duration in seconds, AvgBitrate as a number) rather
than human-readable strings — the metadata is the basis for analytics, not
decoration. Temp-file artifacts (SourceFile/Directory/permissions/inode
dates) are stripped and FileName is set to the original.
content_datetime now resolves from the first real capture date in the
metadata (DateTimeOriginal, then the video CreateDate atoms), still falling
back to the import mtime. When exiftool isn't on PATH the pure-Go EXIF
reader remains as a graceful fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the app service an explicit container_name so it shows up as `tfm`
instead of the generated `tanabata-app-1`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous attempt wrote scrollContainer.scrollTop, but <main> isn't the
scroll element here (the window/document scrolls, as getScroller and the
infinite-scroll listeners assume) — so it was a no-op and the grid stopped
following the focus. Move back to scrollIntoView({block:'nearest'}), which
scrolls whatever element actually scrolls, and give the card
scroll-margin-top/-bottom so it clears the sticky header and the fixed
navbar instead of sliding under them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer's tag picker focuses its search input on `e`, but the input
swallowed every key and the viewer's own handler bails on input targets,
so there was no keyboard way out of the field. Escape now blurs the input
back to the page, restoring arrow/Escape navigation in the viewer (a
second Escape then closes it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GetContent streamed the whole file with a plain 200/io.Copy and no
Accept-Ranges, so the browser couldn't seek or scrub audio/video opened
from the viewer. It now serves seekable bodies (the disk store returns an
*os.File) via http.ServeContent, which advertises Accept-Ranges and
answers Range requests with 206 Partial Content; non-seekable bodies
still fall back to a plain stream. Adds an integration test asserting a
ranged request returns 206 with the right Content-Range and bytes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closing the viewer (or returning via a deep-link ?anchor=) already
scrolled the grid back to the file you were on, but the keyboard
roving-focus stayed unset, so the next arrow press jumped to the top.
Both return paths now place the focus on that file and show the ring, so
arrow navigation resumes exactly where you left off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaces the previously UI-less POST /files/import: an admin-only Settings
card with an optional subfolder field, an Import button, and a result
summary (imported / skipped / per-file errors). Notes that imported files
are drained from the folder and that mtime is kept as the date when EXIF
is absent. Also documents the endpoint's drain + mtime behaviour in the
OpenAPI spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The directory import now removes each source file after it is safely
ingested, so the import folder drains and re-running doesn't create
duplicates (a removal failure is reported per-file but doesn't undo the
import). It also captures the source file's mtime and passes it as a new
ContentDatetimeFallback on Upload, used for content_datetime only when
the file has no EXIF date — so non-photo files keep a meaningful date
instead of the zero value once the source is gone. Adds an integration
test covering ingest, directory skip, source removal and the mtime
fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Arrowing up/down moved the focus ring but the view didn't follow: the
card was scrolled with scrollIntoView({block:'nearest'}), which aligns to
the scroller's edges and is unaware of the fixed bottom navbar overlaying
the scroll area — so the newly focused row slid under the navbar. Replace
it with a manual scroll that keeps the focused card inside the scroller
with a top margin and a bottom margin sized for the navbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plain Space/x toggles the focused card and drops a range anchor there;
Shift+Space / Shift+x now selects everything from that anchor to the
focused card, sharing the same anchor (lastSelectedIdx) as Shift+click so
mouse and keyboard range-selection are interchangeable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Command keys were matched by character (e.key), so on a non-Latin layout
(e.g. Russian) the physical g/f/e/p/x/j/k keys emitted Cyrillic letters
and nothing fired. Letter and digit commands now match by physical
position (e.code: KeyG, Digit1, Slash, …) across the global nav, the file
grid, and the viewer, so the same physical keys work on any layout. Named
keys (arrows, Enter, Esc, Delete), the Mod combos, and the filter's
literal operators (& | ! ( )) stay on e.key, where character matching is
correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the global `/` shortcut to focus the always-on search input on
Tags/Categories/Pools, matching what the help overlay advertises. Files
keeps its own `/` handler since it has no persistent input and instead
opens the filter bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grid's `e` opens the bulk tag editor, which has its own UI rather than
the shared TagPicker, so it needed the same keyboard handling: from the
search input, ↓/↑ highlight a suggestion and Enter adds it to all
selected files (focus stays for chaining); with the input empty ←/→ walk
the assigned tags and Del removes the focused one. Mirrors the viewer's
tag picker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags
section and drops the cursor into its filter, forcing the load so focus
lands even before the section is reached.
Tag picker & filter bar: from the search input, ↓/↑ highlight a
suggestion and Enter adds it (focus stays for chaining); with the input
empty ←/→ walk the added tags/tokens and Del removes the focused one. The
filter bar also inserts an operator token on & | ! ( ), applies on
Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Arrow keys move a focus ring across the grid (column count derived from
the layout, scrolling the focused card into view and pulling the next
page near the end). Enter opens the focused file; Space/x select; e edits
tags (opening the sheet and focusing its tag filter); p adds to a pool;
Del moves to trash — each falling back to the focused card when nothing
is selected. / opens the filter and focuses its search. The ring only
appears once keyboard navigation starts and is dismissed on pointer use,
so it never distracts mouse users. Escape layering is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a single window-level key dispatcher in the root layout: `g` then
c/t/f/p/s (or the digits 1–5) jump between the five sections, honouring
each section's remembered URL so you land back on the same filter and
scroll. `?` toggles a shortcuts cheat-sheet overlay. The handler stays
out of the way while typing in inputs or when a browser/OS modifier is
held. This is the foundation for the per-context keymaps (grid, viewer,
tag/filter pickers) that follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The offset-paginated lists lost their loaded items, search text and
scroll position when you left for another section, since search is local
(not in the URL) and the page unmounts on navigation. Each now snapshots
that state on departure and rehydrates it on return when the
sort/order/search still match, restoring scroll after the list paints.
Because these lists are edited on their own detail/new pages, the API
client drops the matching section's snapshot on any successful mutation
so a stale list never restores. Shared scroll-restore helper and an
OffsetListSnapshot type keep the three pages in lockstep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The grid grew without bound as you scrolled (and the section cache then
snapshotted the whole thing). It now keeps at most ~4 viewports of rows:
once it grows past the cap on one end, loadMore/loadPrev trim the
off-screen rows on the other end. The trimmed boundary cursor is dropped
and the opposite has-more flag is raised, so scrolling back refills that
side from an anchored window (?anchor=<file>), reusing the existing
prepend scroll-compensation. This bounds both live memory and the cached
snapshot regardless of how deep you scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leaving the Files list for another section unmounted the page and lost
the loaded grid, cursors and scroll position; returning refetched page 1
from the top. A new in-memory section cache snapshots that state on
departure (beforeNavigate) and rehydrates it on the next mount when the
sort/filter still match, reapplying the scroll offset after the grid
paints. Combined with the navbar remembering the section URL, tapping
back into Files lands you exactly where you left off. The snapshot is
session-only, validated by resetKey, and skipped for in-page query
changes and the shallow-routed viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bottom-nav links pointed at the bare section roots, so leaving a list
and tapping its tab again dropped the active filter/sort in the query
string. The root layout (which never unmounts) now records the last list
root URL — including its query — per section on navigation, and the nav
links target that remembered URL. Only the list root is recorded, so
detail pages, the trash sub-view, and the shallow-routed file viewer
don't hijack the tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Escape now peels one layer at a time on the files page: an open tag
editor, pool picker, or delete confirm closes first, and only a second
Escape drops the multi-select. Selection-exit handling moves out of
SelectionBar into a single window handler on the page so precedence is
deterministic rather than dependent on window-listener fire order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing the filter (or sort/order) reset the grid to empty but never fetched
page 1 — the reset effect only loaded for the deep-link anchor case and
otherwise relied on InfiniteScroll, which doesn't re-trigger without a remount
or scroll. So filtering blanked the list until a hard refresh. Load page 1 from
the reset effect itself for the non-anchor case (guarded by `loading`, so it
doesn't double-fetch with InfiniteScroll's mount load).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New tag rules should retroactively apply to existing files by default, so flip
the tagRuleApplyToExisting default to true. (Settings already saved in
localStorage keep their stored value.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's preview is now a real link (target=_blank) to the original,
instead of fetching it into a blob. A navigation can't send the auth header, so
the access token rides in the query — the auth middleware accepts ?access_token=
as a fallback, but only for GET, so a crafted link can't drive a mutation.
GetContent gains an ?inline=1 toggle (Content-Disposition: inline) so the tab
views the original instead of downloading it; download stays the default.
Documented in openapi.yaml; TestMediaQueryTokenAuth covers GET-with-query-token
(200), missing token (401) and query-token rejected on a non-GET (401).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BulkTagEditor optimistically marked only the clicked tag as common after a bulk
add, so tags applied by auto-tag rules (resolved server-side) never appeared.
Refetch /files/bulk/common-tags after each change and rebuild the common/partial
sets from the response, so rule-applied tags and partial->common shifts show up.
Backend bulk path was already correct — covered now by TestBulkTagAutoRule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The activity.file_views table existed but nothing ever wrote to it. Add a
POST /files/{id}/views endpoint: FileRepo.RecordView inserts a history row,
FileService.RecordView enforces view ACL first. The file viewer fires it
(fire-and-forget) when a file is opened, including while paging prev/next.
Documented in openapi.yaml; covered by TestRecordFileView (204 on view,
repeatable, 404 for unknown file).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file viewer's assigned-tags list showed tags in API order, ignoring the
sort the user picked on the tags page. Add a client-side sortTags helper and
order the assigned list (in TagPicker) by tagSorting, reactively so it re-sorts
the moment the sort changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tag pickers, filter bar and batch editor loaded only /tags?limit=200 and
filtered client-side, so with more than 200 tags the rest were invisible and
unsearchable. Same for the category dropdowns on the tag forms.
Add fetchAllTags / fetchAllCategories helpers that page past the server's
per-request cap of 200, and order results by the sort the user chose on the
tags / categories page (tagSorting / categorySorting) instead of a hardcoded
name-asc. Wire them into FilterBar, TagPicker, TagRuleEditor, BulkTagEditor and
the tag new/edit category dropdowns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The old autotags worked child -> parent (adding child_id pulled in parent_id,
per tfm__add_file_to_tag_recursive). The new tag_rules model is "when_tag
applied -> then_tag follows", so child must map to when_tag and parent to
then_tag. The transform had it reversed, so migrated rules never fired —
adding a tag added only itself.
Map child_id -> when_tag_id, parent_id -> then_tag_id. Verified on a scratch DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both thumbnails and previews went through imaging.Thumbnail, which scales and
centre-crops to the exact dimensions — so portrait images lost their top and
bottom in the viewer (and the grid). Switch both to imaging.Fit, which scales to
fit within the bounds preserving aspect ratio, never cropping or upscaling. The
grid cell letterboxes the thumbnail via the existing object-fit: contain.
Note: cached *_thumb.jpg / *_preview.jpg are regenerated only when absent, so
clear THUMBS_CACHE_PATH after deploying to drop the old cropped renders.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One-time migration from the old Python/Flask Tanabata DB into the new
core/data/acl/activity schema.
- transform.sql: reads a `legacy` schema and writes the new one in a single,
idempotent transaction. Remaps user/mime ids (uuid -> smallint by name),
inverts is_private -> is_public, lifts EXIF out of files.metadata into the
exif column, preserves pool hierarchy/created under metadata, synthesises
file_pool ordering, derives acl object types, sanitises colors/notes.
- migrate.sh: links the new DB to the old one via postgres_fdw, imports the
old public schema as `legacy`, runs the transform, tears the link down.
- README.md: mapping table, decisions/lossy points, and the separate
physical-blob copy step.
- docs/reference/schema.sql: the old DB schema the migration is built from
(referenced by the README).
Verified end-to-end on PostgreSQL 16 (synthetic legacy data, all
transformations and idempotency checked).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy to the production host on push to master via a self-hosted act_runner
(host/shell executor): git fetch + reset --hard in /opt/tanabata, then
docker compose up -d --build. Shell-only steps, so the host needs just git and
docker — no node, no rsync.
docs/DEPLOY.md covers the one-time setup: what a runner is, the runner user,
cloning to /opt/tanabata with a read-only deploy key, registering act_runner
with the host label, and the host .env. Notes the security reason to scope the
runner to this repository.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bundle the app + Postgres into a compose stack on top of the existing image.
- app: builds the image, publishes ${APP_PORT:-42776}, reads .env, pins
STATIC_DIR so SPA serving can't be disabled by an empty value
- db: postgres:14-alpine under the "with-db" profile; toggle it off via
COMPOSE_PROFILES to point the app at a Postgres on the host instead
(host.docker.internal), with depends_on required:false so it stays optional
Storage and the DB data dir each default to a named volume but can be bind
mounted to a host folder via FILES_DIR / THUMBS_DIR / IMPORT_DIR / DB_DIR.
Add PUID/PGID (via user:) so bind-mounted folders are writable by the
non-root container.
Run the container as a dedicated non-root user "tanabata" with uid/gid 42776,
reusing the project's signature number (also the default port). Document every
variable in .env.example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make 42776 the project's default listen port everywhere :8080 was the default:
.env.example, the Go config fallback, the Dockerfile (ENV/EXPOSE/healthcheck),
and the docs example. 42776 is the sum of the Unicode code points of 七夕
(七 U+4E03 = 19971, 夕 U+5915 = 22805).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run gofmt -w across the backend, normalising the manually-aligned := blocks
to the gofmt standard. No code behaviour changes.
Add Prettier (+ prettier-plugin-svelte) to the frontend with the SvelteKit
default config (tabs, single quotes) so formatting is reproducible, then run
it over the whole tree. Add format / format:check npm scripts and a
.prettierignore (build output, generated schema.ts, static assets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static,
no Node runtime in the final image) and the Go server, then ships an Alpine
runtime that serves both the static frontend and the API on one port.
- Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw)
- Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go)
- Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume,
healthcheck on /health; secrets passed at runtime, not baked in
To serve the SPA on the API port, the Go server now optionally hosts static
files behind a new STATIC_DIR env var: a request maps to a real file when one
exists, otherwise falls back to index.html for client-side routes; unknown
/api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API
standalone while Vite serves the UI. Cache-Control is tuned to adapter-static
output (immutable hashed assets, no-cache service worker) and .webmanifest is
registered so nosniff doesn't reject the PWA manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The light theme page background was a glaring near-white (#f5f5f5) with pure
white sheets. Mirror the dark theme's approach (its background isn't pure
black) by dimming the surfaces and adding a faint lavender tint from the brand
palette, keeping the surface relationships intact: page on the dimmest surface,
sheets brighter to pop, chips slightly darker for definition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The service worker and web manifest were already in place; close the remaining
gaps against the PWA spec:
- pwa.ts: resetPwa() cleared caches and unregistered the service worker but
never reloaded, despite its docstring. Add the hard reload so the page
re-fetches everything from the network after a clear.
- service-worker.ts: pre-cache the SPA entry HTML ('/') alongside build/files
so the shell — and the offline navigation fallback — works from the very
first visit, not just after a navigation has been seen by the runtime cache.
- settings: resetPwa now reloads, so the post-clear success toast (which told
the user to reload manually) is unreachable and misleading. Drop the dead
pwaSuccess state and toast; keep the disabled "Clearing…" button state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document that the frontend deliberately runs as a pure client-side SPA
(adapter-static, ssr=false) and that we stay on SvelteKit rather than migrating
to a bare Svelte + router setup: the file-based routing, client router, and —
most importantly — shallow routing (pushState + page.state, on which the
overlay file viewer depends) carry their weight, while the server half (SSR,
endpoints, form actions, hooks) is intentionally unused. Also drop the stale
hooks.* entries from the directory layout, since no such files exist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>