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/batchpanic!/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.
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)
- 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>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #200. Needed by FileViewer select mode (FileViewer-React #92), which today sends one
POST /image/tagsper photo × tag.New:
POST /image/tags/bulkIMMEDIATEtransaction: all of it applies or none (tested with a trigger that aborts on the second photo; the new tag rolls back too).addis by name: case-insensitive, created when missing, already-tagged photos are a no-op.tagsreturns the resolved tags so clients learn new ids.removeis by id and follows the photo's content, like reads.file_namesis empty or over 5000, nothing to add or remove, a blank name, or a tag in both lists (nothing written).library: tags are library-agnostic.Fixes to the existing tag writes
POST /image/tagsfor a tag the photo already has → 500 (UNIQUE(rel_path, tag_id))INSERT OR IGNORE)tagged_photo, socreate_taghitidx_tags_name_nocase)DELETE /image/tagsremoved only the exact path, so a tag on another copy (archive library, duplicate) stayed and the response was still 200content_hashand rows keyed on that hash, the same set reads unionPOST /image/tags/batchpanic!/unwrapwith the TagDao lock held → lock poisoned, every later tag request panics until restart/batchsilently skipped tag ids no photo carriesThe 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
tags::sqlite_tests): each bug above, bulk add/remove, atomicity, conflict, validation. Full suite 1051 passing, clippy clean.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
- 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>