The content library had no batch operations — every item was managed one at a
time. Add multi-select with batch delete and batch move.
Backend (content.js):
- POST /content/batch/delete — array of ids, atomic: validates + authorizes
EVERY id first (malformed/missing/forbidden rejects the whole batch), then
deletes in one transaction. Reuses the single-delete teardown.
- POST /content/batch/move — array of ids + target folder_id, same atomic
validate-all-first; target folder must share each item's workspace. Folder is
organizational (not in the snapshot), so no device push.
- Refactor: extract purgeContentRow() (file removal + snapshot scrub + row
delete + affected-device collection) and pushContentUpdates(); DELETE /:id now
uses them, so single + batch share one scrub path (no duplication). Add a
boolean contentWritable() mirroring checkContentWrite's authorization.
- 500-item cap per batch; UUID validation guards the snapshot-scrub LIKE.
Frontend (content-library):
- Per-card selection checkbox, select-all/none (visible), shift-click range.
- Selection persists across folders/pages (issue-aligned cross-page selection);
cleared after a successful batch op.
- Batch toolbar (shown when >0 selected): count, move-to-folder picker, delete
with click-again confirm. Selected cards get an outline.
- api.batchDeleteContent / batchMoveContent; en/es i18n.
Not included: batch "set expiry" (listed in the issue's toolbar sketch but only
delete/move had endpoint specs) — deferred; PUT already does per-item expiry.
Test: content-batch-ops.test.js — batch delete removes rows+files+scrubs
snapshots; atomic rejection leaves valid rows intact; malformed id -> 400;
batch move reassigns folder; cross-workspace folder refused; empty batch -> 400.
Suite 553/553.
Closes#213
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>