Photo gallery: free-form tags — editor, gallery filter/search, EXIF keyword seeding #893

Closed
opened 2026-07-29 05:48:20 +00:00 by spikerj · 1 comment
Owner

Photos should be taggable and searchable by tag.

State (verified): Photograph.Tags exists end-to-end (entity/DTO/Angular model) but is completely dead — no writer, no filter, no UI — and the EF converter at SpikerDbContext.cs:1662 persists it as a JSON STRING, which blocks server-side array queries and multikey indexes.

Scope (user-approved: free-form tags + autocomplete; seed from EXIF/IPTC):

  • Backend: remove the converter (BSON array like BlogPost.Tags) + mongodb-scripts migration; shared PhotographTagRules (normalize/merge/validate, 50×64 caps, case-preserving store + case-insensitive dedupe); PUT photos/{id}/tags (owner-only, 404-not-403 #618); GET photos?tags=a,b OR-filter (blog precedent); GET photography/tags tag-universe with counts; ExifToolOutputParser.ParseKeywords (IPTC:Keywords/XMP-dc:Subject/XPKeywords — needs array-aware StrList) merged into Tags in the metadata consumer; tools/BackfillPhotographTags re-parses stored RawExifJson; {Owner,Tags} index.
  • Angular: gallery tag-filter chips + URL sync (art-asset-library pattern, rxResource params refetch); lightbox mat-chip-grid tag editor + autocomplete from the owner's tag universe; i18n.

Ordering: converter removal + migration script must land/run in the same deploy window, before the filter endpoints ship. AI auto-tagging is tracked separately.

Photos should be taggable and searchable by tag. State (verified): Photograph.Tags exists end-to-end (entity/DTO/Angular model) but is completely dead — no writer, no filter, no UI — and the EF converter at SpikerDbContext.cs:1662 persists it as a JSON STRING, which blocks server-side array queries and multikey indexes. Scope (user-approved: free-form tags + autocomplete; seed from EXIF/IPTC): - Backend: remove the converter (BSON array like BlogPost.Tags) + mongodb-scripts migration; shared PhotographTagRules (normalize/merge/validate, 50×64 caps, case-preserving store + case-insensitive dedupe); PUT photos/{id}/tags (owner-only, 404-not-403 #618); GET photos?tags=a,b OR-filter (blog precedent); GET photography/tags tag-universe with counts; ExifToolOutputParser.ParseKeywords (IPTC:Keywords/XMP-dc:Subject/XPKeywords — needs array-aware StrList) merged into Tags in the metadata consumer; tools/BackfillPhotographTags re-parses stored RawExifJson; {Owner,Tags} index. - Angular: gallery tag-filter chips + URL sync (art-asset-library pattern, rxResource params refetch); lightbox mat-chip-grid tag editor + autocomplete from the owner's tag universe; i18n. Ordering: converter removal + migration script must land/run in the same deploy window, before the filter endpoints ship. AI auto-tagging is tracked separately.
Author
Owner

Resolved in spikersoft-backend PR #507 (b061923c) + spikersoft-angular PR #600 (db191289), both merged to master. Verified against origin/master, walking the ticket's bullet list:

Storage / schema

  • JSON converter removed — SpikerDbContext.cs:1665-1673 now sets only a ValueComparer on Tags, no HasConversion (comment cites the BlogPost precedent).
  • Migration mongodb-scripts/2026-07-migrate-photograph-tags-array.js — string→parsed array, seeds missing→[], leaves existing arrays alone, DRY_RUN=true by default, with a residual $type: "string" assertion at the end.
  • {Owner, Tags} multikey index — SpikerDbContext.cs:1637.
  • PhotographTagRulesMaxTags = 50, MaxTagLength = 64 (:13-14), case-preserving store with StringComparer.OrdinalIgnoreCase dedupe (:22).

API

  • PUT photos/{id}/tags, owner-only — PhotographyController.cs:328; handler throws UnauthorizedAccessException for non-owner (SetPhotographTagsCommandHandler.cs:23-24) and the controller maps it to NotFound() (:362-366), so there's no ownership oracle. Uses NotFound() rather than Forbid(), which sidesteps the #788 Forbid(msg)=scheme gotcha.
  • GET photos?tags=a,b OR-filter, server-side over the BSON array — PhotographyController.cs:136,144-146; GetMyPhotographsQueryHandler.cs:31-33.
  • GET photography/tags with counts — GetMyPhotographTagsQueryHandler.cs.

EXIF seeding

  • ExifToolOutputParser.ParseKeywords (:86-99) covers all three sources: IPTC:Keywords (array-aware StrList), XMP-dc:Subject/XMP:Subject/Subject, and semicolon-split XPKeywords. Merged as a union so user tags survive — PhotographConsumers.cs:165-169. Backfill re-parses stored RawExifJson in tools/BackfillPhotographTags/Program.cs.

Angular

  • Filter chips with URL sync — photo-gallery.component.ts:120-127 reads ?tags=, :235-242 writes it back, and :156-157 includes tags in the rxResource params so it refetches.
  • Lightbox mat-chip-grid editor with autocomplete off the owner's tag universe — photo-lightbox.component.ts:51,134-140,315-360.
  • i18n verified by key-set diff (not line count) of devTools/en.json vs es.json: full parity; all five new photoGallery.*tags* keys present in both. Only asymmetry is pre-existing _review metadata keys.

Closing.

Resolved in spikersoft-backend PR #507 (`b061923c`) + spikersoft-angular PR #600 (`db191289`), both merged to `master`. Verified against `origin/master`, walking the ticket's bullet list: **Storage / schema** - JSON converter removed — `SpikerDbContext.cs:1665-1673` now sets only a `ValueComparer` on `Tags`, no `HasConversion` (comment cites the BlogPost precedent). - Migration `mongodb-scripts/2026-07-migrate-photograph-tags-array.js` — string→parsed array, seeds missing→`[]`, leaves existing arrays alone, `DRY_RUN=true` by default, with a residual `$type: "string"` assertion at the end. - `{Owner, Tags}` multikey index — `SpikerDbContext.cs:1637`. - `PhotographTagRules` — `MaxTags = 50`, `MaxTagLength = 64` (`:13-14`), case-preserving store with `StringComparer.OrdinalIgnoreCase` dedupe (`:22`). **API** - `PUT photos/{id}/tags`, owner-only — `PhotographyController.cs:328`; handler throws `UnauthorizedAccessException` for non-owner (`SetPhotographTagsCommandHandler.cs:23-24`) and the controller maps it to `NotFound()` (`:362-366`), so there's no ownership oracle. Uses `NotFound()` rather than `Forbid()`, which sidesteps the #788 `Forbid(msg)=scheme` gotcha. - `GET photos?tags=a,b` OR-filter, server-side over the BSON array — `PhotographyController.cs:136,144-146`; `GetMyPhotographsQueryHandler.cs:31-33`. - `GET photography/tags` with counts — `GetMyPhotographTagsQueryHandler.cs`. **EXIF seeding** - `ExifToolOutputParser.ParseKeywords` (`:86-99`) covers all three sources: `IPTC:Keywords` (array-aware `StrList`), `XMP-dc:Subject`/`XMP:Subject`/`Subject`, and semicolon-split `XPKeywords`. Merged as a union so user tags survive — `PhotographConsumers.cs:165-169`. Backfill re-parses stored `RawExifJson` in `tools/BackfillPhotographTags/Program.cs`. **Angular** - Filter chips with URL sync — `photo-gallery.component.ts:120-127` reads `?tags=`, `:235-242` writes it back, and `:156-157` includes tags in the rxResource params so it refetches. - Lightbox `mat-chip-grid` editor with autocomplete off the owner's tag universe — `photo-lightbox.component.ts:51,134-140,315-360`. - i18n verified by key-set diff (not line count) of `devTools/en.json` vs `es.json`: full parity; all five new `photoGallery.*tags*` keys present in both. Only asymmetry is pre-existing `_review` metadata keys. Closing.
Sign in to join this conversation.