Skip to content

feat: per-photo EXIF editor (date taken + GPS coordinates)#3493

Open
karlitschek wants to merge 1 commit into
frank/feat/photos-version-bumpfrom
frank/feat/photos-metadata-editor
Open

feat: per-photo EXIF editor (date taken + GPS coordinates)#3493
karlitschek wants to merge 1 commit into
frank/feat/photos-version-bumpfrom
frank/feat/photos-metadata-editor

Conversation

@karlitschek
Copy link
Copy Markdown
Member

Adds an "Edit metadata…" entry to the per-photo overflow menu. Lets the user correct the two EXIF fields users actually edit in practice: capture date (cameras with the wrong clock; scanned old photos) and GPS location (phones without GPS; corrections).

The edits don't touch the original file. They live in a new photos-app table and overlay at read time, so:

  • the original on disk stays byte-identical
  • each user's overrides are private (bob's edit on a shared photo doesn't change what alice sees)
  • a Reset button per field falls back to the EXIF / mtime value

Backend

  • New oc_photos_metadata_edits table: composite PK (user_id, file_id), NULLable taken_at / gps_lat / gps_lng. Lat/lng stored as DECIMAL(9,6) — six decimals = ~11 cm at the equator, plenty for hand-corrected locations, no float-precision drift. Index on file_id alone for the future delete listener.
  • PhotoMetadataEditMapper upsert + per-file lookup + bulk lookup for a page of fileIds. Direct DB access (composite PK is incompatible with QBMapper).
  • MetadataEditController exposes: GET /api/v1/metadata/{fileId} → current overrides PUT /api/v1/metadata/{fileId} → patch (omitted = unchanged, null = clear). Authorisation via userFolder->getById($fileId) so users can't probe other users' libraries.
  • IndexController.composeItem now layers the override on top of the EXIF view: edited taken_at becomes both the response's top-level takenAt (timeline sort key) AND the metadata bag's photos-original_date_time (display key), so the photos UI stays internally consistent. Edited GPS replaces the EXIF GPS array entirely.
  • On save the controller also pushes the resolved taken_at into oc_photos_index.taken_at for that user's row, so the timeline ORDER BY picks up the edit immediately without a reload.

Frontend

  • PhotoMetadataEditService.ts thin REST wrapper. Patch shape: omitted field = leave alone, null field = clear override.
  • MetadataEditDialog.vue form-based dialog with a <input type="datetime-local"> for date and two NcTextField inputs for lat/lng. Live validation (range checks fire as the user types; Save stays disabled while values are out of range). Per-field "Reset to original" buttons that clear the stored override and re-seed from the EXIF defaults the caller passed.
  • PhotoActionsMenu gets a new "Edit metadata…" entry next to "View metadata", with the same icon-on-the-left layout as the other actions. Mounted lazily so the API call only fires when the user actually opens it.

Out of scope (follow-ups)

  • Map picker for GPS (latitude/longitude inputs are functional but not a great UX for non-technical users). Drop-in component candidates: vue-leaflet (already a dep on the map view) wired to a draggable marker.
  • Title / description / orientation fields. Those are XMP, not EXIF, and need a different storage path because NC indexes them separately.
  • Bulk edit (apply the same date offset to a multi-selection, useful for scanned albums where every photo is wrong by the same amount).
  • Writing the overrides back to the file's actual EXIF (would require shelling out to exiftool / ffmpeg). Current overlay approach is non-destructive — that's a feature for now, but power users may eventually want both.

Adds an "Edit metadata…" entry to the per-photo overflow menu.
Lets the user correct the two EXIF fields users actually edit in
practice: capture date (cameras with the wrong clock; scanned old
photos) and GPS location (phones without GPS; corrections).

The edits don't touch the original file. They live in a new
photos-app table and overlay at read time, so:
- the original on disk stays byte-identical
- each user's overrides are private (bob's edit on a shared photo
  doesn't change what alice sees)
- a Reset button per field falls back to the EXIF / mtime value

Backend
- New `oc_photos_metadata_edits` table: composite PK
  `(user_id, file_id)`, NULLable `taken_at` / `gps_lat` / `gps_lng`.
  Lat/lng stored as DECIMAL(9,6) — six decimals = ~11 cm at the
  equator, plenty for hand-corrected locations, no float-precision
  drift. Index on `file_id` alone for the future delete listener.
- `PhotoMetadataEditMapper` upsert + per-file lookup + bulk lookup
  for a page of fileIds. Direct DB access (composite PK is
  incompatible with QBMapper).
- `MetadataEditController` exposes:
    GET  /api/v1/metadata/{fileId} → current overrides
    PUT  /api/v1/metadata/{fileId} → patch (omitted = unchanged,
                                     null = clear).
  Authorisation via `userFolder->getById($fileId)` so users can't
  probe other users' libraries.
- `IndexController.composeItem` now layers the override on top of
  the EXIF view: edited `taken_at` becomes both the response's
  top-level `takenAt` (timeline sort key) AND the metadata bag's
  `photos-original_date_time` (display key), so the photos UI
  stays internally consistent. Edited GPS replaces the EXIF GPS
  array entirely.
- On save the controller also pushes the resolved `taken_at` into
  `oc_photos_index.taken_at` for that user's row, so the timeline
  ORDER BY picks up the edit immediately without a reload.

Frontend
- `PhotoMetadataEditService.ts` thin REST wrapper. Patch shape:
  omitted field = leave alone, `null` field = clear override.
- `MetadataEditDialog.vue` form-based dialog with a
  `<input type="datetime-local">` for date and two NcTextField
  inputs for lat/lng. Live validation (range checks fire as the
  user types; Save stays disabled while values are out of range).
  Per-field "Reset to original" buttons that clear the stored
  override and re-seed from the EXIF defaults the caller passed.
- `PhotoActionsMenu` gets a new "Edit metadata…" entry next to
  "View metadata", with the same icon-on-the-left layout as the
  other actions. Mounted lazily so the API call only fires when
  the user actually opens it.

Out of scope (follow-ups)
- Map picker for GPS (latitude/longitude inputs are functional but
  not a great UX for non-technical users). Drop-in component
  candidates: `vue-leaflet` (already a dep on the map view) wired
  to a draggable marker.
- Title / description / orientation fields. Those are XMP, not
  EXIF, and need a different storage path because NC indexes them
  separately.
- Bulk edit (apply the same date offset to a multi-selection,
  useful for scanned albums where every photo is wrong by the
  same amount).
- Writing the overrides back to the file's actual EXIF (would
  require shelling out to exiftool / ffmpeg). Current overlay
  approach is non-destructive — that's a feature for now, but
  power users may eventually want both.

Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@karlitschek
Copy link
Copy Markdown
Member Author

@codecov
Copy link
Copy Markdown

codecov Bot commented May 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant