Tag writes: bulk endpoint, and fix remove/re-add/duplicate bugs #201

Merged
cameron merged 1 commits from feat/bulk-tags into master 2026-11-01 18:05:46 +00:00
Owner

Closes #200. Needed by FileViewer select mode (FileViewer-React #92), which today sends one POST /image/tags per photo × tag.

New: POST /image/tags/bulk

{ "file_names": [...], "add": ["travel", "Road trip"], "remove": [12, 40] }
-> { "added": n, "removed": n, "tags": [Tag...] }
  • One IMMEDIATE transaction: all of it applies or none (tested with a trigger that aborts on the second photo; the new tag rolls back too).
  • add is by name: case-insensitive, created when missing, already-tagged photos are a no-op. tags returns the resolved tags so clients learn new ids.
  • remove is by id and follows the photo's content, like reads.
  • 400 when file_names is empty or over 5000, nothing to add or remove, a blank name, or a tag in both lists (nothing written).
  • No library: tags are library-agnostic.

Fixes to the existing tag writes

Before After
POST /image/tags for a tag the photo already has → 500 (UNIQUE(rel_path, tag_id)) no-op, 200 (INSERT OR IGNORE)
Re-adding a tag no photo carries → 500 (lookup inner-joined tagged_photo, so create_tag hit idx_tags_name_nocase) finds the existing tag
DELETE /image/tags removed only the exact path, so a tag on another copy (archive library, duplicate) stayed and the response was still 200 deletes from every path sharing the photo's content_hash and rows keyed on that hash, the same set reads union
Removal matched the name case-sensitively case-insensitive, like add
POST /image/tags/batch panic!/unwrap with the TagDao lock held → lock poisoned, every later tag request panics until restart errors return 500; lock survives
/batch silently skipped tag ids no photo carries adds them

The add/remove/find logic is now three helpers shared by the single and bulk paths (find_or_create_tag_in, insert_tag_row, remove_tag_rows). A version path still binds to its original.

Testing

  • 12 new tests against real in-memory SQLite (tags::sqlite_tests): each bug above, bulk add/remove, atomicity, conflict, validation. Full suite 1051 passing, clippy clean.
  • Over HTTP on the e2e server: bulk add (2 rows, a repeat adds 0), bulk remove, conflict / empty / nothing-to-do 400s, duplicate single add 200.

Deploy needed before FileViewer can use /bulk; the single-tag fixes apply to the current app as soon as it's deployed.

🤖 Generated with Claude Code

Closes #200. Needed by FileViewer select mode (FileViewer-React #92), which today sends one `POST /image/tags` per photo × tag. ## New: `POST /image/tags/bulk` ``` { "file_names": [...], "add": ["travel", "Road trip"], "remove": [12, 40] } -> { "added": n, "removed": n, "tags": [Tag...] } ``` - One `IMMEDIATE` transaction: all of it applies or none (tested with a trigger that aborts on the second photo; the new tag rolls back too). - `add` is by name: case-insensitive, created when missing, already-tagged photos are a no-op. `tags` returns the resolved tags so clients learn new ids. - `remove` is by id and follows the photo's content, like reads. - 400 when `file_names` is empty or over 5000, nothing to add or remove, a blank name, or a tag in both lists (nothing written). - No `library`: tags are library-agnostic. ## Fixes to the existing tag writes | Before | After | |---|---| | `POST /image/tags` for a tag the photo already has → **500** (`UNIQUE(rel_path, tag_id)`) | no-op, 200 (`INSERT OR IGNORE`) | | Re-adding a tag no photo carries → **500** (lookup inner-joined `tagged_photo`, so `create_tag` hit `idx_tags_name_nocase`) | finds the existing tag | | `DELETE /image/tags` removed only the exact path, so a tag on another copy (archive library, duplicate) stayed and the response was still 200 | deletes from every path sharing the photo's `content_hash` and rows keyed on that hash, the same set reads union | | Removal matched the name case-sensitively | case-insensitive, like add | | `POST /image/tags/batch` `panic!`/`unwrap` with the TagDao lock held → lock poisoned, every later tag request panics until restart | errors return 500; lock survives | | `/batch` silently skipped tag ids no photo carries | adds them | The add/remove/find logic is now three helpers shared by the single and bulk paths (`find_or_create_tag_in`, `insert_tag_row`, `remove_tag_rows`). A version path still binds to its original. ## Testing - 12 new tests against real in-memory SQLite (`tags::sqlite_tests`): each bug above, bulk add/remove, atomicity, conflict, validation. Full suite 1051 passing, clippy clean. - Over HTTP on the e2e server: bulk add (2 rows, a repeat adds 0), bulk remove, conflict / empty / nothing-to-do 400s, duplicate single add 200. Deploy needed before FileViewer can use `/bulk`; the single-tag fixes apply to the current app as soon as it's deployed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
cameron added 1 commit 2026-11-01 17:49:50 +00:00
- POST /image/tags/bulk { file_names, add: [names], remove: [ids] }: one
  transaction over many photos; names match case-insensitively and are
  created when missing; 400 if a tag is both added and removed.
- Adding a tag a photo already has is a no-op (was a 500 on
  UNIQUE(rel_path, tag_id)).
- Adding a tag no photo carries reuses it (the lookup only saw tags in
  use, so create_tag hit idx_tags_name_nocase: 500). update_tags could
  likewise skip such ids silently.
- Removing a tag matches the name case-insensitively and deletes it from
  every path sharing the photo's content, as reads union them; it used to
  answer 200 and leave the tag on the other copy.
- update_tags returns errors instead of panicking with the TagDao lock
  held, which poisoned it for every later tag request.

Closes #200

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cameron merged commit 5ea6d88d0c into master 2026-11-01 18:05:46 +00:00
cameron deleted branch feat/bulk-tags 2026-11-01 18:05:46 +00:00
Sign in to join this conversation.