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

Closed
opened 2026-11-01 17:41:54 +00:00 by cameron · 0 comments
Owner

FileViewer's select mode (FileViewer-React #72 / PR #92) tags many photos at once. Today that is one POST /image/tags per photo × tag, and reading the tag write path turned up several bugs.

Bugs (confirmed against SQLite)

  1. Adding a tag a photo already has → 500. tag_file is a plain INSERT and tagged_photo has UNIQUE(rel_path, tag_id). A retry, or a bulk tag over a selection where some photos already carry it, fails.
  2. Re-adding a tag that has no photos → 500. add_tag looks the name up via get_all_tags, which inner-joins tagged_photo, so an unused tag isn't found; create_tag then hits idx_tags_name_nocase. Same lookup in update_tags silently skips unused tag ids.
  3. Removing a tag can answer 200 and remove nothing. Reads union tags over every path sharing the photo's content_hash; remove_tag deletes only the exact (canonical) path.
  4. Removal matches the name case-sensitively, while add matches case-insensitively.
  5. POST /image/tags/batch panics while holding the TagDao lock (panic! / unwrap), poisoning it so every later tag request panics until restart.

New endpoint

POST /image/tags/bulk
{ "file_names": [...], "add": ["travel", "Road trip"], "remove": [12, 40] }
-> { "added": n, "removed": n, "tags": [Tag...] }   // tags = the resolved `add` tags
  • One transaction: all of it applies or none.
  • add by name, case-insensitive, created when missing; already-tagged = no-op.
  • remove by id, across every path sharing the photo's content (and a version's original), matching reads.
  • 400 when both lists are empty, file_names is empty or too long, or a tag is in both.

No schema change.

FileViewer's select mode (FileViewer-React #72 / PR #92) tags many photos at once. Today that is one `POST /image/tags` per photo × tag, and reading the tag write path turned up several bugs. ## Bugs (confirmed against SQLite) 1. **Adding a tag a photo already has → 500.** `tag_file` is a plain INSERT and `tagged_photo` has `UNIQUE(rel_path, tag_id)`. A retry, or a bulk tag over a selection where some photos already carry it, fails. 2. **Re-adding a tag that has no photos → 500.** `add_tag` looks the name up via `get_all_tags`, which inner-joins `tagged_photo`, so an unused tag isn't found; `create_tag` then hits `idx_tags_name_nocase`. Same lookup in `update_tags` silently skips unused tag ids. 3. **Removing a tag can answer 200 and remove nothing.** Reads union tags over every path sharing the photo's `content_hash`; `remove_tag` deletes only the exact (canonical) path. 4. **Removal matches the name case-sensitively**, while add matches case-insensitively. 5. **`POST /image/tags/batch` panics while holding the TagDao lock** (`panic!` / `unwrap`), poisoning it so every later tag request panics until restart. ## New endpoint ``` POST /image/tags/bulk { "file_names": [...], "add": ["travel", "Road trip"], "remove": [12, 40] } -> { "added": n, "removed": n, "tags": [Tag...] } // tags = the resolved `add` tags ``` - One transaction: all of it applies or none. - `add` by name, case-insensitive, created when missing; already-tagged = no-op. - `remove` by id, across every path sharing the photo's content (and a version's original), matching reads. - 400 when both lists are empty, file_names is empty or too long, or a tag is in both. No schema change.
Sign in to join this conversation.