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.
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 #788Forbid(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.
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.
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):
Ordering: converter removal + migration script must land/run in the same deploy window, before the filter endpoints ship. AI auto-tagging is tracked separately.
Resolved in spikersoft-backend PR #507 (
b061923c) + spikersoft-angular PR #600 (db191289), both merged tomaster. Verified againstorigin/master, walking the ticket's bullet list:Storage / schema
SpikerDbContext.cs:1665-1673now sets only aValueCompareronTags, noHasConversion(comment cites the BlogPost precedent).mongodb-scripts/2026-07-migrate-photograph-tags-array.js— string→parsed array, seeds missing→[], leaves existing arrays alone,DRY_RUN=trueby 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 withStringComparer.OrdinalIgnoreCasededupe (:22).API
PUT photos/{id}/tags, owner-only —PhotographyController.cs:328; handler throwsUnauthorizedAccessExceptionfor non-owner (SetPhotographTagsCommandHandler.cs:23-24) and the controller maps it toNotFound()(:362-366), so there's no ownership oracle. UsesNotFound()rather thanForbid(), which sidesteps the #788Forbid(msg)=schemegotcha.GET photos?tags=a,bOR-filter, server-side over the BSON array —PhotographyController.cs:136,144-146;GetMyPhotographsQueryHandler.cs:31-33.GET photography/tagswith counts —GetMyPhotographTagsQueryHandler.cs.EXIF seeding
ExifToolOutputParser.ParseKeywords(:86-99) covers all three sources:IPTC:Keywords(array-awareStrList),XMP-dc:Subject/XMP:Subject/Subject, and semicolon-splitXPKeywords. Merged as a union so user tags survive —PhotographConsumers.cs:165-169. Backfill re-parses storedRawExifJsonintools/BackfillPhotographTags/Program.cs.Angular
photo-gallery.component.ts:120-127reads?tags=,:235-242writes it back, and:156-157includes tags in the rxResource params so it refetches.mat-chip-grideditor with autocomplete off the owner's tag universe —photo-lightbox.component.ts:51,134-140,315-360.devTools/en.jsonvses.json: full parity; all five newphotoGallery.*tags*keys present in both. Only asymmetry is pre-existing_reviewmetadata keys.Closing.