From b133a713a77bed966cdc2790f160852cd8e74e83 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 18:55:25 +0800 Subject: [PATCH 01/13] docs: add design spec for edit metadata tool Design spec for new edit-metadata tool (issue #15). Covers common EXIF field editing, GPS clearing, granular per-field stripping, and shared metadata infrastructure extracted from strip-metadata. --- .../specs/2026-04-06-edit-metadata-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-06-edit-metadata-design.md diff --git a/docs/superpowers/specs/2026-04-06-edit-metadata-design.md b/docs/superpowers/specs/2026-04-06-edit-metadata-design.md new file mode 100644 index 00000000..8170d7fa --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-edit-metadata-design.md @@ -0,0 +1,202 @@ +# Edit Metadata Tool - Design Spec + +**Date:** 2026-04-06 +**Issue:** [stirling-image/stirling-image#15](https://github.com/stirling-image/stirling-image/issues/15) +**Approach:** Shared metadata infrastructure (Approach 2) + +## Overview + +A new tool for editing and selectively removing EXIF metadata from images. Covers common editable fields (description, artist, copyright, software, dates), GPS clearing, and granular per-field stripping. Builds on shared infrastructure extracted from the existing strip-metadata tool. + +## Scope + +**In scope:** +- Edit common EXIF fields: description, artist, copyright, software, date modified, date taken +- GPS clear via checkbox +- Granular strip: per-field removal of any displayed EXIF tag +- Read-only display of current metadata (EXIF, GPS, XMP) +- Pre-population of edit form from current values +- Dirty tracking to distinguish untouched/edited/cleared fields +- Shared metadata parsing and UI components extracted from strip-metadata + +**Out of scope (potential future work):** +- Arbitrary advanced EXIF field editing (camera make/model, lens, exposure, etc.) +- XMP/ICC profile editing +- Batch-specific metadata (different values per file) + +## Architecture + +### File changes + +``` +packages/image-engine/ + src/utils/metadata.ts EXTEND add parseExif(), parseGps(), parseXmp(), sanitizeValue() + src/operations/edit-metadata.ts NEW editMetadata() function + src/types.ts EXTEND add EditMetadataOptions + src/index.ts EXTEND export new operation + +apps/api/ + src/routes/tools/edit-metadata.ts NEW /inspect + /edit endpoints + src/routes/tools/strip-metadata.ts REFACTOR swap local parsing helpers for shared imports + src/routes/tools/index.ts EXTEND register new tool + +apps/web/ + src/components/common/collapsible-section.tsx NEW extract from strip-metadata + src/components/common/metadata-grid.tsx NEW extract from strip-metadata + src/lib/metadata-utils.ts NEW EXIF_LABELS, SKIP_KEYS, formatExifValue, exifStr + src/components/tools/edit-metadata-settings.tsx NEW main component + src/components/tools/strip-metadata-settings.tsx REFACTOR use shared imports + src/lib/tool-registry.tsx EXTEND register new tool + +packages/shared/ + src/constants.ts EXTEND add tool entry + src/i18n/en.ts EXTEND add i18n strings +``` + +### Image-engine layer + +**Extended `utils/metadata.ts`** adds four parsing functions alongside the existing `getImageInfo()`: + +- `sanitizeValue(v)` - makes EXIF values JSON-safe (Dates to ISO strings, Buffers to arrays or ``, recursion for nested objects) +- `parseExif(exifBuffer)` - calls `exif-reader`, returns `{ image, photo, iop }` sections with sanitized values +- `parseGps(gpsInfo)` - extracts DMS coordinates to decimal `{ latitude, longitude, altitude }` +- `parseXmp(xmpBuffer)` - regex extraction of key/value pairs from XMP XML + +**New `operations/edit-metadata.ts`** - `editMetadata(image, options)`: + +- Maps common option fields (artist, copyright, imageDescription, software, dateTime, dateTimeOriginal) to their IFD0/IFD2 EXIF tag names +- Accepts `fieldsToRemove: string[]` for granular strip +- Logic: + - If `clearGps` or `fieldsToRemove` has entries: read existing EXIF, rebuild the EXIF object minus the removed fields/GPS, merge in edits, then `withExif()` (full replace) + - If only edits (no removals): `withExifMerge()` (non-destructive merge) + - If nothing to do: `keepMetadata()` (passthrough) + +**New type:** +```ts +interface EditMetadataOptions { + artist?: string; + copyright?: string; + imageDescription?: string; + software?: string; + dateTime?: string; + dateTimeOriginal?: string; + clearGps?: boolean; + fieldsToRemove?: string[]; +} +``` + +### API route design + +**`POST /api/v1/tools/edit-metadata/inspect`** - custom endpoint: +- Accepts multipart file upload +- Calls shared parsing functions from image-engine +- Returns: + ```json + { + "filename": "photo.jpg", + "fileSize": 2048000, + "exif": { "Artist": "John", "Software": "Lightroom", ... }, + "gps": { "GPSLatitude": [...], "_latitude": 51.5074, "_longitude": -0.1278, ... }, + "xmp": { "dc:creator": "John", ... } + } + ``` + +**`POST /api/v1/tools/edit-metadata`** - via `createToolRoute` factory: +- Settings schema: + ```ts + z.object({ + artist: z.string().optional(), + copyright: z.string().optional(), + imageDescription: z.string().optional(), + software: z.string().optional(), + dateTime: z.string().optional(), + dateTimeOriginal: z.string().optional(), + clearGps: z.boolean().default(false), + fieldsToRemove: z.array(z.string()).default([]), + }) + ``` +- Process function: reads format, calls `editMetadata(image, settings)`, re-encodes in original format, returns `{ buffer, filename, contentType }` + +### UI component design + +**Shared extractions (from strip-metadata):** +- `CollapsibleSection` to `components/common/collapsible-section.tsx` - unchanged from strip-metadata +- `MetadataGrid` to `components/common/metadata-grid.tsx` - extended with optional `onRemove?: (key: string) => void` and `removedKeys?: Set` props. When `onRemove` is provided, each row shows a trash icon. When a key is in `removedKeys`, the row renders with strikethrough + muted styling. Strip-metadata passes neither prop (read-only behavior preserved). +- `EXIF_LABELS`, `SKIP_KEYS`, `formatExifValue()`, `exifStr()` to `lib/metadata-utils.ts` + +**`EditMetadataSettings` - three sections:** + +**1. Current Metadata (read-only + granular strip)** +- Auto-fetched via `/inspect` on file selection (per-file cache, AbortController cleanup) +- EXIF: `CollapsibleSection` with `MetadataGrid`. String-typed and safely-serializable fields get a trash icon for granular removal. Binary blobs (MakerNote, PrintImageMatching) and complex array fields are displayed read-only without a remove option - this avoids data corruption from lossy EXIF round-trips through `withExif()`. Clicking a trash icon toggles the tag into `fieldsToRemove` set (strikethrough + muted styling). +- GPS: `CollapsibleSection` with warning styling if GPS detected, coordinates displayed + +**2. Edit Fields** +- Common fields: Description, Artist, Copyright, Software, Date Modified, Date Taken as `LabeledInput` components, pre-populated from inspect data +- Dirty tracking: store initial values from inspect. On submit, compare current to initial. Changed + has value = include in settings. Changed + empty = add to `fieldsToRemove`. Untouched = skip. +- GPS: "Remove GPS location data" checkbox with coordinate display if present + +**3. Submit / Download** +- Submit via `useToolProcessor("edit-metadata")` +- `ProgressCard` during processing, download link after + +**Display mode:** `"no-comparison"` in tool registry. + +**Edit + remove conflict resolution:** If a user marks a field for removal in the metadata view AND edits the same field in the edit form, the edit wins. Submit logic checks edit fields first, only adds to `fieldsToRemove` tags that aren't being written. + +## Data Flow + +1. User drops image into dropzone +2. Component auto-calls `/inspect`, parses response, pre-populates form, stores initial values +3. User edits fields and/or marks tags for removal in metadata view +4. On submit: dirty-diff builds settings object (e.g. `{ artist: "New Name", fieldsToRemove: ["Software", "MeteringMode"], clearGps: true }`) +5. Tool factory receives file + settings, calls `editMetadata()`, re-encodes, returns download URL +6. User downloads modified image + +## Error Handling + +- **Inspect fails** (corrupt file, unsupported format): inline warning "Could not read metadata", form fields start empty, user can still write new metadata +- **No EXIF in image**: "No metadata found" in current metadata section, form fields start empty, editing still works (writes fresh EXIF) +- **Format with limited EXIF support** (PNG): no special handling. Sharp writes what the format supports, silently drops what it doesn't. Matches strip-metadata behavior. +- **Processing fails**: tool factory returns 422, component displays error from response +- **No changes submitted**: `keepMetadata()` passthrough, image re-encoded with metadata preserved + +## Testing + +### Unit tests (image-engine) +- `editMetadata` writes common fields, readable back via `exif-reader` +- `editMetadata` with `clearGps: true` removes GPS, preserves other EXIF +- `editMetadata` with `fieldsToRemove` drops specific tags, preserves others +- `editMetadata` with no options preserves metadata +- Edit + remove conflict: edit wins +- Works through `processImage` pipeline + +### Unit tests (web utilities) +- Dirty tracking: detects changed fields, cleared fields, ignores untouched +- Settings builder: correctly splits edits vs removals +- `formatExifValue` and `exifStr` tests (moved from fork's tests to shared location) + +### Integration tests (API) +- `/inspect` returns parsed EXIF/GPS/XMP for test JPEG with known metadata +- `/inspect` returns nulls for metadata-free PNG +- `/inspect` rejects no-file and invalid-file requests +- Edit endpoint writes metadata, returns downloadable file +- Edit endpoint with `fieldsToRemove` strips specific tags +- Edit endpoint with `clearGps` removes GPS +- Edit endpoint with empty settings preserves original metadata + +### Strip-metadata regression +- Re-run all existing strip-metadata tests after the shared extraction refactor to confirm no behavioral changes + +### E2e tests (Playwright) +- Tool appears in tool list and is navigable +- Upload image, verify metadata displays +- Edit a field, submit, download, re-upload and verify +- Mark a field for removal, submit, verify removal +- Add to `tools-all.spec.ts` + +### Docker + Playwright GUI verification +- Docker rebuild with cache +- Spin up container +- Playwright headed/GUI mode against running container +- Manual verification: navigate to tool, upload test image with known EXIF/GPS, confirm metadata displays, edit fields, mark tags for removal, submit, download, re-upload to confirm changes persisted From 9afea7b985c1aa7138f0e3c961785834b0e7922e Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:05:13 +0800 Subject: [PATCH 02/13] docs: add implementation plan for edit metadata tool 12-task plan covering: test fixture, image-engine types + parsing + operation, strip-metadata refactor, API route, shared constants/i18n, shared UI extraction, edit-metadata component, e2e tests, and Docker verification. --- .../plans/2026-04-06-edit-metadata.md | 2176 +++++++++++++++++ 1 file changed, 2176 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-06-edit-metadata.md diff --git a/docs/superpowers/plans/2026-04-06-edit-metadata.md b/docs/superpowers/plans/2026-04-06-edit-metadata.md new file mode 100644 index 00000000..9f606d22 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-edit-metadata.md @@ -0,0 +1,2176 @@ +# Edit Metadata Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new "Edit Metadata" tool that lets users view, edit, and selectively remove EXIF metadata from images. + +**Architecture:** Shared metadata infrastructure extracted from strip-metadata (parsing utilities in image-engine, display components in common/). New edit-metadata tool with its own API route, image-engine operation, and UI component. Strip-metadata refactored to use shared imports (behavioral no-op). + +**Tech Stack:** Sharp 0.33.5 (`withExifMerge`, `withExif`, `keepMetadata`), exif-reader, Fastify, React, Zustand, Zod, Vitest, Playwright + +--- + +### Task 1: Create test fixture with EXIF data + +The existing test JPEGs have no EXIF metadata. We need a fixture with known EXIF/GPS data for testing. + +**Files:** +- Create: `tests/fixtures/test-with-exif.jpg` + +- [ ] **Step 1: Generate a JPEG with known EXIF data** + +```bash +cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image +node -e " +const path = require('path'); +const sharp = require(path.join(process.cwd(), 'packages', 'image-engine', 'node_modules', 'sharp')); + +// Create a 100x100 red JPEG with EXIF metadata +sharp({ + create: { width: 100, height: 100, channels: 3, background: '#ff0000' } +}) + .withExif({ + IFD0: { + Artist: 'Test Artist', + Copyright: '2026 Test Copyright', + ImageDescription: 'Test Description', + Software: 'Stirling-Image Test', + DateTime: '2026:01:15 10:30:00', + Make: 'TestCamera', + Model: 'TestModel', + }, + IFD2: { + DateTimeOriginal: '2026:01:15 10:30:00', + }, + }) + .jpeg({ quality: 95 }) + .toFile(path.join('tests', 'fixtures', 'test-with-exif.jpg')) + .then(() => console.log('Created test-with-exif.jpg')) + .catch(e => console.error(e)); +" +``` + +- [ ] **Step 2: Verify the fixture has EXIF data** + +```bash +node -e " +const path = require('path'); +const sharp = require(path.join(process.cwd(), 'packages', 'image-engine', 'node_modules', 'sharp')); +const exifReader = require(path.join(process.cwd(), 'apps', 'api', 'node_modules', 'exif-reader')); +sharp(path.join('tests', 'fixtures', 'test-with-exif.jpg')).metadata().then(m => { + console.log('has exif:', !!m.exif); + if (m.exif) { + const parsed = exifReader(m.exif); + console.log('Artist:', parsed.Image?.Artist); + console.log('Copyright:', parsed.Image?.Copyright); + console.log('Software:', parsed.Image?.Software); + console.log('Description:', parsed.Image?.ImageDescription); + } +}); +" +``` + +Expected: `has exif: true`, Artist = "Test Artist", Copyright = "2026 Test Copyright", etc. + +- [ ] **Step 3: Commit** + +```bash +git add tests/fixtures/test-with-exif.jpg +git commit -m "test: add JPEG fixture with known EXIF data for edit-metadata tests" +``` + +--- + +### Task 2: Add `EditMetadataOptions` type and `exif-reader` dependency to image-engine + +**Files:** +- Modify: `packages/image-engine/src/types.ts:64` (after `StripMetadataOptions`) +- Modify: `packages/image-engine/package.json:14` (add exif-reader dependency) + +- [ ] **Step 1: Add the `EditMetadataOptions` type** + +Add after line 64 in `packages/image-engine/src/types.ts` (after `StripMetadataOptions` closing brace): + +```typescript +export interface EditMetadataOptions { + artist?: string; + copyright?: string; + imageDescription?: string; + software?: string; + dateTime?: string; + dateTimeOriginal?: string; + clearGps?: boolean; + fieldsToRemove?: string[]; +} +``` + +- [ ] **Step 2: Add exif-reader to image-engine dependencies** + +The parsing utilities we're about to extract use `exif-reader`. It's currently only in `apps/api/package.json`. Add it to `packages/image-engine/package.json` under `dependencies`: + +```json +"dependencies": { + "@stirling-image/shared": "workspace:*", + "exif-reader": "^2.0.3", + "sharp": "^0.33.0" +} +``` + +- [ ] **Step 3: Install the new dependency** + +```bash +pnpm install +``` + +- [ ] **Step 4: Verify typecheck passes** + +```bash +pnpm typecheck +``` + +Expected: no errors + +- [ ] **Step 5: Commit** + +```bash +git add packages/image-engine/src/types.ts packages/image-engine/package.json pnpm-lock.yaml +git commit -m "feat: add EditMetadataOptions type and exif-reader dep to image-engine" +``` + +--- + +### Task 3: Extract shared metadata parsing into image-engine + +Move `sanitizeValue`, `parseGpsCoordinates`, `parseXmp` from `apps/api/src/routes/tools/strip-metadata.ts` into `packages/image-engine/src/utils/metadata.ts`. Add `parseExif`. + +**Files:** +- Modify: `packages/image-engine/src/utils/metadata.ts` + +- [ ] **Step 1: Write failing tests for the parsing utilities** + +Create test cases in `tests/unit/image-engine/operations.test.ts`. Add at the top of the import block: + +```typescript +import { + // existing imports... + getImageInfo, + sanitizeValue, + parseExif, + parseGps, + parseXmp, +} from "@stirling-image/image-engine"; +``` + +Add a new fixture at the top with the others: + +```typescript +let jpgWithExif: Buffer; +``` + +In the `beforeAll`: + +```typescript +jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg")); +``` + +Add test blocks at the end of the file: + +```typescript +// --------------------------------------------------------------------------- +// Shared metadata parsing utilities +// --------------------------------------------------------------------------- +describe("sanitizeValue", () => { + it("converts Date to ISO string", () => { + const d = new Date("2026-01-15T10:30:00Z"); + expect(sanitizeValue(d)).toBe("2026-01-15T10:30:00.000Z"); + }); + + it("converts small Buffer to number array", () => { + const buf = Buffer.from([1, 2, 3]); + expect(sanitizeValue(buf)).toEqual([1, 2, 3]); + }); + + it("converts large Buffer to placeholder string", () => { + const buf = Buffer.alloc(300, 0); + expect(sanitizeValue(buf)).toBe(""); + }); + + it("recursively sanitizes objects", () => { + const d = new Date("2026-01-01T00:00:00Z"); + const result = sanitizeValue({ nested: { date: d } }); + expect(result).toEqual({ nested: { date: "2026-01-01T00:00:00.000Z" } }); + }); + + it("passes through primitives unchanged", () => { + expect(sanitizeValue("hello")).toBe("hello"); + expect(sanitizeValue(42)).toBe(42); + expect(sanitizeValue(null)).toBe(null); + expect(sanitizeValue(true)).toBe(true); + }); +}); + +describe("parseExif", () => { + it("parses EXIF buffer from test fixture", async () => { + const metadata = await sharp(jpgWithExif).metadata(); + expect(metadata.exif).toBeTruthy(); + const result = parseExif(metadata.exif!); + expect(result.image.Artist).toBe("Test Artist"); + expect(result.image.Copyright).toBe("2026 Test Copyright"); + expect(result.image.Software).toBe("Stirling-Image Test"); + expect(result.image.ImageDescription).toBe("Test Description"); + }); + + it("returns empty sections for buffer with no data", async () => { + const metadata = await sharp(png1x1).metadata(); + // PNG has no EXIF - pass a minimal valid EXIF buffer + // This tests the error/empty path + const result = parseExif(Buffer.from([])); + expect(result.image).toEqual({}); + expect(result.gps).toEqual({}); + }); +}); + +describe("parseGps", () => { + it("parses DMS coordinates to decimal degrees", () => { + const result = parseGps({ + GPSLatitude: [51, 30, 26.4], + GPSLatitudeRef: "N", + GPSLongitude: [0, 7, 39.6], + GPSLongitudeRef: "W", + GPSAltitude: 10, + GPSAltitudeRef: 0, + }); + expect(result.latitude).toBeCloseTo(51.5073, 3); + expect(result.longitude).toBeCloseTo(-0.1277, 3); + expect(result.altitude).toBe(10); + }); + + it("returns nulls for empty GPS data", () => { + const result = parseGps({}); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + expect(result.altitude).toBeNull(); + }); + + it("handles southern hemisphere", () => { + const result = parseGps({ + GPSLatitude: [33, 51, 54], + GPSLatitudeRef: "S", + GPSLongitude: [151, 12, 36], + GPSLongitudeRef: "E", + }); + expect(result.latitude).toBeCloseTo(-33.865, 2); + expect(result.longitude).toBeCloseTo(151.21, 2); + }); +}); + +describe("parseXmp", () => { + it("extracts key-value pairs from XMP XML", () => { + const xml = Buffer.from( + '' + + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["dc:creator"]).toBe("Alice"); + expect(result["dc:title"]).toBe("My Photo"); + }); + + it("skips xmlns and rdf namespace prefixes", () => { + const xml = Buffer.from( + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["xmlns:x"]).toBeUndefined(); + expect(result["xmlns:dc"]).toBeUndefined(); + expect(result["rdf:about"]).toBeUndefined(); + expect(result["dc:format"]).toBe("image/jpeg"); + }); + + it("returns empty object for empty buffer", () => { + const result = parseXmp(Buffer.from("")); + expect(result).toEqual({}); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm test:unit -- --grep "sanitizeValue|parseExif|parseGps|parseXmp" +``` + +Expected: FAIL - functions not exported from `@stirling-image/image-engine` + +- [ ] **Step 3: Implement the shared parsing utilities** + +Replace the contents of `packages/image-engine/src/utils/metadata.ts` with: + +```typescript +import exifReader from "exif-reader"; +import sharp from "sharp"; +import type { ImageInfo } from "../types.js"; + +/** + * Extract comprehensive image metadata from a buffer. + */ +export async function getImageInfo(buffer: Buffer): Promise { + const metadata = await sharp(buffer).metadata(); + + return { + width: metadata.width ?? 0, + height: metadata.height ?? 0, + format: metadata.format ?? "unknown", + channels: metadata.channels ?? 0, + size: buffer.length, + hasAlpha: metadata.hasAlpha ?? false, + metadata: { + space: metadata.space, + density: metadata.density, + isProgressive: metadata.isProgressive, + hasProfile: metadata.hasProfile, + orientation: metadata.orientation, + exif: !!metadata.exif, + icc: !!metadata.icc, + xmp: !!metadata.xmp, + }, + }; +} + +/** + * Serialize a value for JSON - convert Buffers/Dates and drop overly large blobs. + */ +export function sanitizeValue(v: unknown): unknown { + if (v instanceof Date) return v.toISOString(); + if (Buffer.isBuffer(v)) { + if (v.length > 256) return ``; + return Array.from(v); + } + if (Array.isArray(v)) return v.map(sanitizeValue); + if (v !== null && typeof v === "object") { + const out: Record = {}; + for (const [k, val] of Object.entries(v)) { + out[k] = sanitizeValue(val); + } + return out; + } + return v; +} + +/** + * Parse an EXIF buffer into sanitized sections. + * Returns { image, photo, iop, gps } with JSON-safe values. + */ +export function parseExif(exifBuffer: Buffer): { + image: Record; + photo: Record; + iop: Record; + gps: Record; +} { + const result = { image: {} as Record, photo: {} as Record, iop: {} as Record, gps: {} as Record }; + + if (!exifBuffer || exifBuffer.length === 0) return result; + + try { + const parsed = exifReader(exifBuffer); + + if (parsed.Image) { + for (const [k, v] of Object.entries(parsed.Image)) { + result.image[k] = sanitizeValue(v); + } + } + if (parsed.Photo) { + for (const [k, v] of Object.entries(parsed.Photo)) { + result.photo[k] = sanitizeValue(v); + } + } + if (parsed.Iop) { + for (const [k, v] of Object.entries(parsed.Iop)) { + result.iop[k] = sanitizeValue(v); + } + } + if (parsed.GPSInfo) { + for (const [k, v] of Object.entries(parsed.GPSInfo)) { + result.gps[k] = sanitizeValue(v); + } + } + } catch { + // Return empty sections on parse failure + } + + return result; +} + +/** + * Parse GPS coordinates from EXIF GPSInfo into decimal degrees. + */ +export function parseGps(gps: Record): { + latitude: number | null; + longitude: number | null; + altitude: number | null; +} { + let latitude: number | null = null; + let longitude: number | null = null; + let altitude: number | null = null; + + const lat = gps.GPSLatitude as number[] | undefined; + const latRef = gps.GPSLatitudeRef as string | undefined; + if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) { + latitude = lat[0] + lat[1] / 60 + lat[2] / 3600; + if (latRef === "S") latitude = -latitude; + } + + const lon = gps.GPSLongitude as number[] | undefined; + const lonRef = gps.GPSLongitudeRef as string | undefined; + if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) { + longitude = lon[0] + lon[1] / 60 + lon[2] / 3600; + if (lonRef === "W") longitude = -longitude; + } + + if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) { + altitude = gps.GPSAltitude; + if (gps.GPSAltitudeRef === 1) altitude = -altitude; + } + + return { latitude, longitude, altitude }; +} + +/** + * Parse XMP XML buffer into key-value pairs. + */ +export function parseXmp(xmpBuffer: Buffer): Record { + const xml = xmpBuffer.toString("utf-8"); + const result: Record = {}; + + for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) { + const key = match[1]; + if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue; + result[key] = match[2]; + } + + return result; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +pnpm test:unit -- --grep "sanitizeValue|parseExif|parseGps|parseXmp" +``` + +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/image-engine/src/utils/metadata.ts tests/unit/image-engine/operations.test.ts +git commit -m "feat: extract shared metadata parsing utilities into image-engine" +``` + +--- + +### Task 4: Implement `editMetadata` operation in image-engine + +**Files:** +- Create: `packages/image-engine/src/operations/edit-metadata.ts` +- Modify: `packages/image-engine/src/engine.ts:15,31,49` (add to operation map) +- Modify: `packages/image-engine/src/index.ts:16` (add export) + +- [ ] **Step 1: Write failing tests for `editMetadata`** + +Add `editMetadata` to the imports in `tests/unit/image-engine/operations.test.ts`: + +```typescript +import { + // existing imports... + editMetadata, + getImageInfo, + sanitizeValue, + parseExif, + parseGps, + parseXmp, +} from "@stirling-image/image-engine"; +``` + +Add test block: + +```typescript +// --------------------------------------------------------------------------- +// editMetadata +// --------------------------------------------------------------------------- +describe("editMetadata", () => { + it("writes common fields readable via exif-reader", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + artist: "New Artist", + copyright: "New Copyright", + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + expect(meta.exif).toBeTruthy(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("New Artist"); + expect(parsed.Image?.Copyright).toBe("New Copyright"); + // Original fields should be preserved via withExifMerge + expect(parsed.Image?.Software).toBe("Stirling-Image Test"); + }); + + it("clears GPS while preserving other EXIF", async () => { + // First write GPS to the image + const withGps = sharp(jpgWithExif).withExif({ + IFD0: { Artist: "GPS Test" }, + IFD3: { GPSLatitudeRef: "N" }, + }); + const gpsBuf = await withGps.jpeg().toBuffer(); + + const image = sharp(gpsBuf); + const result = await editMetadata(image, { clearGps: true }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + // GPS should be gone + expect(parsed.GPSInfo).toBeUndefined(); + // Other EXIF should still be present + expect(parsed.Image?.Artist).toBe("GPS Test"); + }); + + it("removes specific fields via fieldsToRemove", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + fieldsToRemove: ["Software"], + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Software).toBeUndefined(); + // Other fields preserved + expect(parsed.Image?.Artist).toBe("Test Artist"); + }); + + it("preserves metadata with no options", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, {}); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + expect(meta.exif).toBeTruthy(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("Test Artist"); + }); + + it("edit wins over remove for same field", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + artist: "Override Artist", + fieldsToRemove: ["Artist"], + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("Override Artist"); + }); + + it("writes fresh EXIF to image without existing metadata", async () => { + const image = sharp(png1x1); + const result = await editMetadata(image, { + artist: "Fresh Artist", + copyright: "Fresh Copyright", + }); + const buf = await result.png().toBuffer(); + const meta = await sharp(buf).metadata(); + // PNG may or may not preserve EXIF depending on Sharp version + // At minimum, the operation should not throw + expect(buf.length).toBeGreaterThan(0); + }); +}); +``` + +Also add `exifReader` import at the top of the test file: + +```typescript +const exifReader = require( + path.resolve(__dirname, "../../../apps/api/node_modules/exif-reader"), +) as typeof import("exif-reader").default; +``` + +Wait - `exif-reader` is now in image-engine. Update the require: + +```typescript +const exifReader = require( + path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"), +) as typeof import("exif-reader").default; +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm test:unit -- --grep "editMetadata" +``` + +Expected: FAIL - `editMetadata` not exported + +- [ ] **Step 3: Implement `editMetadata`** + +Create `packages/image-engine/src/operations/edit-metadata.ts`: + +```typescript +import exifReader from "exif-reader"; +import type { EditMetadataOptions, Sharp } from "../types.js"; +import { sanitizeValue } from "../utils/metadata.js"; + +/** + * Map of common option field names to their EXIF IFD and tag name. + */ +const COMMON_FIELD_MAP: Array<{ + option: keyof EditMetadataOptions; + ifd: "IFD0" | "IFD2"; + tag: string; +}> = [ + { option: "artist", ifd: "IFD0", tag: "Artist" }, + { option: "copyright", ifd: "IFD0", tag: "Copyright" }, + { option: "imageDescription", ifd: "IFD0", tag: "ImageDescription" }, + { option: "software", ifd: "IFD0", tag: "Software" }, + { option: "dateTime", ifd: "IFD0", tag: "DateTime" }, + { option: "dateTimeOriginal", ifd: "IFD2", tag: "DateTimeOriginal" }, +]; + +export async function editMetadata( + image: Sharp, + options: EditMetadataOptions = {}, +): Promise { + const edits: { IFD0: Record; IFD2: Record } = { + IFD0: {}, + IFD2: {}, + }; + + // Build edit map from common fields + for (const { option, ifd, tag } of COMMON_FIELD_MAP) { + const value = options[option]; + if (typeof value === "string" && value.length > 0) { + edits[ifd][tag] = value; + } + } + + // Collect tags being written (for edit-wins-over-remove) + const writtenTags = new Set([ + ...Object.keys(edits.IFD0), + ...Object.keys(edits.IFD2), + ]); + + // Filter fieldsToRemove: remove any that are also being written (edit wins) + const fieldsToRemove = (options.fieldsToRemove ?? []).filter( + (f) => !writtenTags.has(f), + ); + + const hasEdits = Object.keys(edits.IFD0).length > 0 || Object.keys(edits.IFD2).length > 0; + const hasRemovals = fieldsToRemove.length > 0 || options.clearGps === true; + + // Nothing to do - preserve everything + if (!hasEdits && !hasRemovals) { + return image.keepMetadata(); + } + + // Removals require full EXIF replacement via withExif() + if (hasRemovals) { + // Read existing EXIF to rebuild minus removed fields + const buf = await image.clone().toBuffer(); + const metadata = await (await import("sharp")).default(buf).metadata(); + + const existingIFD0: Record = {}; + const existingIFD2: Record = {}; + + if (metadata.exif) { + try { + const parsed = exifReader(metadata.exif); + // Rebuild IFD0 from Image section + if (parsed.Image) { + for (const [k, v] of Object.entries(parsed.Image)) { + if (fieldsToRemove.includes(k)) continue; + const sv = sanitizeValue(v); + if (typeof sv === "string" || typeof sv === "number") { + existingIFD0[k] = String(sv); + } + } + } + // Rebuild IFD2 from Photo section + if (parsed.Photo) { + for (const [k, v] of Object.entries(parsed.Photo)) { + if (fieldsToRemove.includes(k)) continue; + const sv = sanitizeValue(v); + if (typeof sv === "string" || typeof sv === "number") { + existingIFD2[k] = String(sv); + } + } + } + } catch { + // If parsing fails, proceed with just the edits + } + } + + // Merge edits on top of existing (edits override) + const finalIFD0 = { ...existingIFD0, ...edits.IFD0 }; + const finalIFD2 = { ...existingIFD2, ...edits.IFD2 }; + + const exif: Record> = {}; + if (Object.keys(finalIFD0).length > 0) exif.IFD0 = finalIFD0; + if (Object.keys(finalIFD2).length > 0) exif.IFD2 = finalIFD2; + // Omit IFD3 (GPS) when clearGps is true; otherwise rebuild would need GPS parsing too + // withExif replaces all EXIF, so omitting IFD3 drops GPS + + return image.withExif(exif); + } + + // Edits only - non-destructive merge + const exif: Record> = {}; + if (Object.keys(edits.IFD0).length > 0) exif.IFD0 = edits.IFD0; + if (Object.keys(edits.IFD2).length > 0) exif.IFD2 = edits.IFD2; + + return image.withExifMerge(exif); +} +``` + +- [ ] **Step 4: Export from index and add to engine pipeline** + +In `packages/image-engine/src/index.ts`, add after the stripMetadata export (line 16): + +```typescript +export { editMetadata } from "./operations/edit-metadata.js"; +``` + +In `packages/image-engine/src/engine.ts`, add the import (after line 15): + +```typescript +import { editMetadata } from "./operations/edit-metadata.js"; +``` + +Add to the import of types (line 30): + +```typescript +import type { + // existing types... + EditMetadataOptions, +} from "./types.js"; +``` + +Add to `OPERATION_MAP` (after line 49, the strip-metadata entry): + +```typescript + "edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions), +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +pnpm test:unit -- --grep "editMetadata" +``` + +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/image-engine/src/operations/edit-metadata.ts packages/image-engine/src/engine.ts packages/image-engine/src/index.ts tests/unit/image-engine/operations.test.ts +git commit -m "feat: implement editMetadata operation in image-engine" +``` + +--- + +### Task 5: Refactor strip-metadata route to use shared parsing + +Replace local `sanitizeValue`, `parseGpsCoordinates`, `parseXmp` in strip-metadata with imports from image-engine. Keep `parseIccProfile` local (it's only used by strip-metadata). Behavioral no-op. + +**Files:** +- Modify: `apps/api/src/routes/tools/strip-metadata.ts:1-85` + +- [ ] **Step 1: Replace local parsing helpers with shared imports** + +In `apps/api/src/routes/tools/strip-metadata.ts`, replace lines 1-85 (imports through `parseXmp`) with: + +```typescript +import { basename } from "node:path"; +import { parseExif, parseGps, parseXmp, sanitizeValue, stripMetadata } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + stripExif: z.boolean().default(false), + stripGps: z.boolean().default(false), + stripIcc: z.boolean().default(false), + stripXmp: z.boolean().default(false), + stripAll: z.boolean().default(true), +}); +``` + +Then replace the inspect endpoint's EXIF/GPS parsing (lines 207-239 in the original) to use the shared functions. Replace the inline parsing with: + +```typescript + // Parse EXIF + if (metadata.exif) { + try { + const parsed = parseExif(metadata.exif); + const exifData: Record = { + ...parsed.image, + ...parsed.photo, + ...parsed.iop, + }; + const gpsData: Record = { ...parsed.gps }; + + if (Object.keys(parsed.gps).length > 0) { + const coords = parseGps(parsed.gps); + if (coords.latitude !== null) gpsData._latitude = coords.latitude; + if (coords.longitude !== null) gpsData._longitude = coords.longitude; + if (coords.altitude !== null) gpsData._altitude = coords.altitude; + } + + if (Object.keys(exifData).length > 0) result.exif = exifData; + if (Object.keys(gpsData).length > 0) result.gps = gpsData; + } catch { + result.exif = null; + result.exifError = "Failed to parse EXIF data"; + } + } +``` + +The XMP parsing (lines 259-265) becomes: + +```typescript + // Parse XMP + if (metadata.xmp) { + try { + result.xmp = parseXmp(metadata.xmp); + } catch { + result.xmp = null; + } + } +``` + +Keep the `parseIccProfile` function local (lines 90-165) - it's only used by strip-metadata. + +Remove the old `exif-reader` import (it was only used for the inline EXIF parsing that's now in image-engine). + +- [ ] **Step 2: Run existing strip-metadata tests to verify no regression** + +```bash +pnpm test:unit -- --grep "stripMetadata" +pnpm test:integration -- --grep "strip-metadata" +``` + +Expected: all existing tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/routes/tools/strip-metadata.ts +git commit -m "refactor: use shared metadata parsing in strip-metadata route" +``` + +--- + +### Task 6: Create edit-metadata API route + +**Files:** +- Create: `apps/api/src/routes/tools/edit-metadata.ts` +- Modify: `apps/api/src/routes/tools/index.ts:5,83` + +- [ ] **Step 1: Write failing integration tests** + +Add to the end of `tests/integration/api.test.ts`, before the closing of the last `describe` block: + +```typescript +// ═══════════════════════════════════════════════════════════════════════════ +// EDIT METADATA TOOL +// ══════════════════════════════════════════════════════��════════════════════ +describe("Edit metadata", () => { + const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg")); + + describe("POST /api/v1/tools/edit-metadata/inspect", () => { + it("returns parsed EXIF for JPEG with metadata", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "exif.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.filename).toBe("exif.jpg"); + expect(body.exif).toBeTruthy(); + expect(body.exif.Artist).toBe("Test Artist"); + expect(body.exif.Copyright).toBe("2026 Test Copyright"); + }); + + it("returns nulls for metadata-free PNG", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "plain.png", contentType: "image/png", content: PNG_1x1 }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.exif).toBeUndefined(); + }); + + it("rejects request with no file", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "multipart/form-data; boundary=---", + }, + payload: "-----\r\n", + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe("POST /api/v1/tools/edit-metadata", () => { + it("writes metadata and returns downloadable file", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "edit.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ artist: "New Author" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBeDefined(); + expect(body.jobId).toBeDefined(); + }); + + it("strips specific fields via fieldsToRemove", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "strip.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ fieldsToRemove: ["Software"] }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + + it("preserves metadata with empty settings", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "noop.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm test:integration -- --grep "Edit metadata" +``` + +Expected: FAIL - route not found (404) + +- [ ] **Step 3: Create the edit-metadata route** + +Create `apps/api/src/routes/tools/edit-metadata.ts`: + +```typescript +import { basename } from "node:path"; +import { editMetadata, parseExif, parseGps, parseXmp } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + artist: z.string().optional(), + copyright: z.string().optional(), + imageDescription: z.string().optional(), + software: z.string().optional(), + dateTime: z.string().optional(), + dateTimeOriginal: z.string().optional(), + clearGps: z.boolean().default(false), + fieldsToRemove: z.array(z.string()).default([]), +}); + +export function registerEditMetadata(app: FastifyInstance) { + // Inspect endpoint - returns parsed metadata as JSON for pre-populating the form + app.post( + "/api/v1/tools/edit-metadata/inspect", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const metadata = await sharp(fileBuffer).metadata(); + const result: Record = { + filename, + fileSize: fileBuffer.length, + }; + + if (metadata.exif) { + try { + const parsed = parseExif(metadata.exif); + const exifData: Record = { + ...parsed.image, + ...parsed.photo, + ...parsed.iop, + }; + const gpsData: Record = { ...parsed.gps }; + + if (Object.keys(parsed.gps).length > 0) { + const coords = parseGps(parsed.gps); + if (coords.latitude !== null) gpsData._latitude = coords.latitude; + if (coords.longitude !== null) gpsData._longitude = coords.longitude; + if (coords.altitude !== null) gpsData._altitude = coords.altitude; + } + + if (Object.keys(exifData).length > 0) result.exif = exifData; + if (Object.keys(gpsData).length > 0) result.gps = gpsData; + } catch { + result.exif = null; + result.exifError = "Failed to parse EXIF data"; + } + } + + if (metadata.xmp) { + try { + result.xmp = parseXmp(metadata.xmp); + } catch { + result.xmp = null; + } + } + + return reply.send(result); + } catch (err) { + return reply.status(422).send({ + error: "Failed to read image metadata", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); + + // Edit endpoint - writes metadata and returns the updated image + createToolRoute(app, { + toolId: "edit-metadata", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const metadata = await sharp(inputBuffer).metadata(); + const format = metadata.format ?? "jpeg"; + const image = sharp(inputBuffer); + const result = await editMetadata(image, settings); + + switch (format) { + case "jpeg": + result.jpeg({ quality: 95, mozjpeg: true }); + break; + case "png": + result.png({ compressionLevel: 6 }); + break; + case "webp": + result.webp({ quality: 90 }); + break; + case "avif": + result.avif({ quality: 60 }); + break; + case "tiff": + result.tiff({ quality: 90 }); + break; + default: + result.jpeg({ quality: 95 }); + break; + } + + const buffer = await result.toBuffer(); + const ext = format === "jpeg" ? "jpg" : format; + const outFilename = filename.replace(/\.[^.]+$/, `.${ext}`); + const mimeMap: Record = { + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + avif: "image/avif", + tiff: "image/tiff", + gif: "image/gif", + }; + + return { + buffer, + filename: outFilename, + contentType: mimeMap[format] ?? "image/jpeg", + }; + }, + }); +} +``` + +- [ ] **Step 4: Register the route** + +In `apps/api/src/routes/tools/index.ts`, add import (after line 31): + +```typescript +import { registerEditMetadata } from "./edit-metadata.js"; +``` + +Add to `toolRegistrations` array (after line 83, the strip-metadata entry): + +```typescript + { id: "edit-metadata", register: registerEditMetadata }, +``` + +- [ ] **Step 5: Run integration tests** + +```bash +pnpm test:integration -- --grep "Edit metadata" +``` + +Expected: all PASS + +- [ ] **Step 6: Run strip-metadata regression** + +```bash +pnpm test:integration -- --grep "strip-metadata" +``` + +Expected: all PASS (no regression) + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/routes/tools/edit-metadata.ts apps/api/src/routes/tools/index.ts tests/integration/api.test.ts +git commit -m "feat: add edit-metadata API route with inspect and edit endpoints" +``` + +--- + +### Task 7: Register tool in shared constants and i18n + +**Files:** +- Modify: `packages/shared/src/constants.ts:65` (after strip-metadata entry) +- Modify: `packages/shared/src/i18n/en.ts:38` (after strip-metadata entry) + +- [ ] **Step 1: Add tool to TOOLS array** + +In `packages/shared/src/constants.ts`, add after the strip-metadata entry (after line 65): + +```typescript + { + id: "edit-metadata", + name: "Edit Metadata", + description: "Edit EXIF, GPS, and camera info", + category: "optimization", + icon: "PenLine", + route: "/edit-metadata", + }, +``` + +- [ ] **Step 2: Add i18n strings** + +In `packages/shared/src/i18n/en.ts`, add after the strip-metadata entry (after line 38): + +```typescript + "edit-metadata": { name: "Edit Metadata", description: "Edit EXIF, GPS, and camera info" }, +``` + +- [ ] **Step 3: Verify typecheck** + +```bash +pnpm typecheck +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add packages/shared/src/constants.ts packages/shared/src/i18n/en.ts +git commit -m "feat: register edit-metadata in shared constants and i18n" +``` + +--- + +### Task 8: Extract shared UI components from strip-metadata + +**Files:** +- Create: `apps/web/src/components/common/collapsible-section.tsx` +- Create: `apps/web/src/components/common/metadata-grid.tsx` +- Create: `apps/web/src/lib/metadata-utils.ts` +- Modify: `apps/web/src/components/tools/strip-metadata-settings.tsx` + +- [ ] **Step 1: Create `metadata-utils.ts`** + +Create `apps/web/src/lib/metadata-utils.ts`: + +```typescript +/** Human-friendly labels for common EXIF keys */ +export const EXIF_LABELS: Record = { + Make: "Camera Make", + Model: "Camera Model", + Software: "Software", + DateTime: "Date/Time", + DateTimeOriginal: "Date Taken", + DateTimeDigitized: "Date Digitized", + ExposureTime: "Exposure Time", + FNumber: "F-Number", + ISOSpeedRatings: "ISO", + FocalLength: "Focal Length", + FocalLengthIn35mmFilm: "Focal Length (35mm)", + ExposureBiasValue: "Exposure Bias", + MeteringMode: "Metering Mode", + Flash: "Flash", + WhiteBalance: "White Balance", + ExposureMode: "Exposure Mode", + SceneCaptureType: "Scene Type", + Contrast: "Contrast", + Saturation: "Saturation", + Sharpness: "Sharpness", + DigitalZoomRatio: "Digital Zoom", + ImageWidth: "Width", + ImageLength: "Height", + Orientation: "Orientation", + XResolution: "X Resolution", + YResolution: "Y Resolution", + ResolutionUnit: "Resolution Unit", + ColorSpace: "Color Space", + PixelXDimension: "Pixel Width", + PixelYDimension: "Pixel Height", + Artist: "Artist", + Copyright: "Copyright", + ImageDescription: "Description", + LensMake: "Lens Make", + LensModel: "Lens Model", + BodySerialNumber: "Body Serial", + CameraOwnerName: "Camera Owner", +}; + +/** Keys to skip in display (internal/binary/redundant) */ +export const SKIP_KEYS = new Set([ + "ExifTag", + "GPSTag", + "InteroperabilityTag", + "MakerNote", + "PrintImageMatching", + "ComponentsConfiguration", + "FlashpixVersion", + "ExifVersion", + "FileSource", + "SceneType", + "UserComment", + "InteroperabilityIndex", + "InteroperabilityVersion", +]); + +/** + * Keys that are binary blobs or complex arrays - NOT safe for EXIF round-trip. + * These should not get a remove button in the edit-metadata UI. + */ +export const UNSAFE_ROUND_TRIP_KEYS = new Set([ + "MakerNote", + "PrintImageMatching", + "ComponentsConfiguration", + "FlashpixVersion", + "ExifVersion", + "FileSource", + "SceneType", + "UserComment", + "InteroperabilityIndex", + "InteroperabilityVersion", +]); + +export function formatExifValue(key: string, value: unknown): string { + if (value === null || value === undefined) return "N/A"; + if (typeof value === "string") return value; + if (typeof value === "number") { + if (key === "ExposureTime" && value > 0 && value < 1) { + return `1/${Math.round(1 / value)}s`; + } + if (key === "FNumber") return `f/${value}`; + if (key === "FocalLength") return `${value}mm`; + if (key === "FocalLengthIn35mmFilm") return `${value}mm`; + return String(value); + } + if (Array.isArray(value)) { + if (typeof value[0] === "number" && value.length <= 4) { + return value.join(", "); + } + return `[${value.length} values]`; + } + return String(value); +} + +export function exifStr(exif: Record | null | undefined, key: string): string { + const v = exif?.[key]; + if (typeof v === "string") return v; + if (typeof v === "number") return String(v); + return ""; +} +``` + +- [ ] **Step 2: Create `collapsible-section.tsx`** + +Create `apps/web/src/components/common/collapsible-section.tsx`: + +```tsx +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +export function CollapsibleSection({ + title, + badge, + warning, + defaultOpen, + children, +}: { + title: string; + badge?: string; + warning?: boolean; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen ?? false); + + return ( +
+ + {open &&
{children}
} +
+ ); +} +``` + +- [ ] **Step 3: Create `metadata-grid.tsx`** + +Create `apps/web/src/components/common/metadata-grid.tsx`: + +```tsx +import { Trash2 } from "lucide-react"; +import { SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS, formatExifValue } from "@/lib/metadata-utils"; + +export function MetadataGrid({ + data, + labelMap, + onRemove, + removedKeys, +}: { + data: Record; + labelMap?: Record; + onRemove?: (key: string) => void; + removedKeys?: Set; +}) { + const entries = Object.entries(data).filter( + ([k, v]) => + !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "", + ); + + if (entries.length === 0) { + return

No data

; + } + + return ( +
+ {entries.map(([k, v]) => { + const isRemoved = removedKeys?.has(k); + const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k); + return ( +
+
+ {labelMap?.[k] ?? k} +
+
+ {formatExifValue(k, v)} +
+
+ {canRemove ? ( + + ) : ( +
+ )} +
+
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 4: Update strip-metadata-settings to use shared imports** + +In `apps/web/src/components/tools/strip-metadata-settings.tsx`: + +Replace lines 1-6 imports with: + +```typescript +import { AlertTriangle, Download, Loader2, MapPin } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { MetadataGrid } from "@/components/common/metadata-grid"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { SKIP_KEYS } from "@/lib/metadata-utils"; +import { formatHeaders } from "@/lib/api"; +import { useFileStore } from "@/stores/file-store"; +``` + +Remove the local definitions of: +- `EXIF_LABELS` (lines 19-57) +- `SKIP_KEYS` (lines 60-74) +- `formatExifValue` (lines 76-95) +- `CollapsibleSection` (lines 97-135) +- `MetadataGrid` (lines 137-170) + +The `MetadataGrid` usage in strip-metadata does NOT pass `onRemove` or `removedKeys`, so it gets the read-only 2-column layout. The 3-column grid with the auto-width third column still looks correct when the third column has no content (auto = 0 width). + +Wait - the existing MetadataGrid was 2-column. The new one is 3-column. For strip-metadata (no onRemove), the third column is always the empty spacer `
`. This adds a tiny amount of dead space. To avoid visual regression, only render the third column when `onRemove` is provided: + +Update `metadata-grid.tsx` - wrap the grid class conditionally: + +```tsx + const hasRemoveColumn = !!onRemove; + + return ( +
+ {entries.map(([k, v]) => { + const isRemoved = removedKeys?.has(k); + const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k); + return ( +
+
+ {labelMap?.[k] ?? k} +
+
+ {formatExifValue(k, v)} +
+ {hasRemoveColumn && ( +
+ {canRemove ? ( + + ) : ( +
+ )} +
+ )} +
+ ); + })} +
+ ); +``` + +- [ ] **Step 5: Verify lint and typecheck pass** + +```bash +pnpm lint && pnpm typecheck +``` + +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/common/collapsible-section.tsx apps/web/src/components/common/metadata-grid.tsx apps/web/src/lib/metadata-utils.ts apps/web/src/components/tools/strip-metadata-settings.tsx +git commit -m "refactor: extract shared metadata UI components from strip-metadata" +``` + +--- + +### Task 9: Create edit-metadata UI component + +**Files:** +- Create: `apps/web/src/components/tools/edit-metadata-settings.tsx` +- Modify: `apps/web/src/lib/tool-registry.tsx` + +- [ ] **Step 1: Create the edit-metadata settings component** + +Create `apps/web/src/components/tools/edit-metadata-settings.tsx`: + +```tsx +import { + AlertTriangle, + Download, + Loader2, + MapPin, + PenLine, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { MetadataGrid } from "@/components/common/metadata-grid"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { EXIF_LABELS, SKIP_KEYS, exifStr } from "@/lib/metadata-utils"; +import { formatHeaders } from "@/lib/api"; +import { useFileStore } from "@/stores/file-store"; + +interface InspectResult { + filename: string; + fileSize: number; + exif?: Record | null; + exifError?: string; + gps?: Record | null; + xmp?: Record | null; +} + +interface FormFields { + artist: string; + copyright: string; + imageDescription: string; + software: string; + dateTime: string; + dateTimeOriginal: string; + clearGps: boolean; +} + +const EMPTY_FORM: FormFields = { + artist: "", + copyright: "", + imageDescription: "", + software: "", + dateTime: "", + dateTimeOriginal: "", + clearGps: false, +}; + +function LabeledInput({ + label, + id, + value, + onChange, + placeholder, + hint, +}: { + label: string; + id: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + hint?: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {hint &&

{hint}

} +
+ ); +} + +export function EditMetadataSettings() { + const { entries, selectedIndex, files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("edit-metadata"); + + const [form, setForm] = useState(EMPTY_FORM); + const [initialForm, setInitialForm] = useState(EMPTY_FORM); + const [fieldsToRemove, setFieldsToRemove] = useState>(new Set()); + const [inspectData, setInspectData] = useState(null); + const [inspecting, setInspecting] = useState(false); + const [inspectError, setInspectError] = useState(null); + const [inspectCache, setInspectCache] = useState>(new Map()); + + const currentFile = entries[selectedIndex]?.file ?? null; + const fileKey = currentFile + ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` + : null; + + const populateForm = useCallback((data: InspectResult) => { + const exif = data.exif ?? {}; + setInspectData(data); + const populated: FormFields = { + artist: exifStr(exif, "Artist"), + copyright: exifStr(exif, "Copyright"), + imageDescription: exifStr(exif, "ImageDescription"), + software: exifStr(exif, "Software"), + dateTime: exifStr(exif, "DateTime"), + dateTimeOriginal: exifStr(exif, "DateTimeOriginal"), + clearGps: false, + }; + setForm(populated); + setInitialForm(populated); + setFieldsToRemove(new Set()); + }, []); + + useEffect(() => { + if (!currentFile || !fileKey) { + setForm(EMPTY_FORM); + setInitialForm(EMPTY_FORM); + setInspectData(null); + setInspectError(null); + setFieldsToRemove(new Set()); + return; + } + + const cached = inspectCache.get(fileKey); + if (cached) { + populateForm(cached); + return; + } + + const controller = new AbortController(); + (async () => { + setInspecting(true); + setInspectError(null); + setInspectData(null); + try { + const formData = new FormData(); + formData.append("file", currentFile); + const res = await fetch("/api/v1/tools/edit-metadata/inspect", { + method: "POST", + headers: formatHeaders(), + body: formData, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + const data: InspectResult = await res.json(); + setInspectCache((prev) => new Map(prev).set(fileKey, data)); + populateForm(data); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setInspectError(err instanceof Error ? err.message : "Failed to inspect file"); + setForm(EMPTY_FORM); + setInitialForm(EMPTY_FORM); + } finally { + setInspecting(false); + } + })(); + + return () => controller.abort(); + }, [currentFile, fileKey, inspectCache, populateForm]); + + const setField = (key: K, value: FormFields[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const toggleRemoveField = (key: string) => { + setFieldsToRemove((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const hasFile = files.length > 0; + const gpsLat = inspectData?.gps?._latitude as number | undefined; + const gpsLon = inspectData?.gps?._longitude as number | undefined; + const gpsCoords = gpsLat != null && gpsLon != null ? { lat: gpsLat, lon: gpsLon } : null; + const exifEntryCount = inspectData?.exif + ? Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length + : 0; + const hasGps = + !!inspectData?.gps && Object.keys(inspectData.gps).filter((k) => !k.startsWith("_")).length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!hasFile || processing) return; + + // Build settings from dirty tracking + const settings: Record = { clearGps: form.clearGps }; + + // Common fields: only send if changed from initial + const fieldMap: Array<{ formKey: keyof FormFields; settingsKey: string; exifTag: string }> = [ + { formKey: "artist", settingsKey: "artist", exifTag: "Artist" }, + { formKey: "copyright", settingsKey: "copyright", exifTag: "Copyright" }, + { formKey: "imageDescription", settingsKey: "imageDescription", exifTag: "ImageDescription" }, + { formKey: "software", settingsKey: "software", exifTag: "Software" }, + { formKey: "dateTime", settingsKey: "dateTime", exifTag: "DateTime" }, + { formKey: "dateTimeOriginal", settingsKey: "dateTimeOriginal", exifTag: "DateTimeOriginal" }, + ]; + + const removeSet = new Set(fieldsToRemove); + + for (const { formKey, settingsKey, exifTag } of fieldMap) { + const current = form[formKey] as string; + const initial = initialForm[formKey] as string; + if (current !== initial) { + if (current.trim()) { + settings[settingsKey] = current.trim(); + removeSet.delete(exifTag); // edit wins over remove + } else { + removeSet.add(exifTag); // cleared field = remove + } + } + } + + if (removeSet.size > 0) { + settings.fieldsToRemove = Array.from(removeSet); + } + + processFiles(files, settings); + }; + + return ( +
+ {/* Current Metadata */} + {hasFile && ( +
+

Current Metadata

+ + {inspecting && ( +
+ + Reading metadata... +
+ )} + + {inspectError && !inspecting && ( +
+ + Could not read metadata - fields will start empty. +
+ )} + + {inspectData && ( +
+ {exifEntryCount > 0 && inspectData.exif ? ( + + + + ) : ( +

No EXIF data found.

+ )} + {hasGps && inspectData.gps && ( + + !k.startsWith("_")), + )} + /> + + )} +
+ )} +
+ )} + + {/* Edit Fields */} + {hasFile && ( +
+
+

Edit Fields

+ + setField("imageDescription", v)} + placeholder="Image description" + /> + setField("artist", v)} + placeholder="Photographer / creator name" + /> + setField("copyright", v)} + placeholder="2026 Example" + /> + setField("software", v)} + placeholder="e.g. Lightroom, Photoshop" + /> + setField("dateTime", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + hint="EXIF date format: 2026:04:06 12:00:00" + /> + setField("dateTimeOriginal", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + /> + + {/* GPS */} +
+
+ {gpsCoords ? ( +
+ +
+

+ Location data found +

+

+ {gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)} +

+
+
+ ) : ( +

No GPS data in this image.

+ )} + +
+
+ )} + + {!hasFile && ( +
+ +

Upload an image to edit its metadata.

+
+ )} + + {error &&

{error}

} + + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {processing ? ( + + ) : ( + + )} + + {downloadUrl && ( + + + Download + + )} + + ); +} +``` + +- [ ] **Step 2: Register in tool-registry** + +In `apps/web/src/lib/tool-registry.tsx`, add the lazy import after `StripMetadataSettings` (after line 83): + +```typescript +const EditMetadataSettings = lazy(() => + import("@/components/tools/edit-metadata-settings").then((m) => ({ + default: m.EditMetadataSettings, + })), +); +``` + +Add to the registry map, after the strip-metadata entry (after line 240): + +```typescript + ["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }], +``` + +- [ ] **Step 3: Verify lint and typecheck** + +```bash +pnpm lint && pnpm typecheck +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/tools/edit-metadata-settings.tsx apps/web/src/lib/tool-registry.tsx +git commit -m "feat: add edit-metadata UI component with granular strip support" +``` + +--- + +### Task 10: Add E2E tests + +**Files:** +- Modify: `tests/e2e/tools-all.spec.ts` +- Modify: `tests/e2e/tools-process.spec.ts` + +- [ ] **Step 1: Add edit-metadata to tools-all list** + +In `tests/e2e/tools-all.spec.ts`, add to the tools array (after the strip-metadata entry): + +```typescript + { id: "edit-metadata", name: "Edit Metadata" }, +``` + +Also add `"edit-metadata"` to the `TOOL_IDS` array used for fullscreen grid tests (if one exists - grep for the array that includes `"strip-metadata"` and add after it). + +- [ ] **Step 2: Add edit-metadata process test** + +In `tests/e2e/tools-process.spec.ts`, add after the strip-metadata test: + +```typescript + test("edit-metadata processes image", async ({ loggedInPage: page }) => { + await page.goto("/edit-metadata"); + await uploadTestImage(page); + + // Wait for inspect to complete and form to populate + await page.waitForSelector('[id="em-artist"]', { timeout: 10_000 }); + + // Edit the artist field + await page.fill('[id="em-artist"]', "E2E Test Artist"); + + await page.getByRole("button", { name: /apply metadata/i }).click(); + await waitForProcessing(page); + await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({ + timeout: 15_000, + }); + }); +``` + +- [ ] **Step 3: Run e2e tests locally** + +```bash +pnpm test:e2e -- --grep "edit-metadata" +``` + +Expected: PASS (may need dev servers running) + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/tools-all.spec.ts tests/e2e/tools-process.spec.ts +git commit -m "test: add e2e tests for edit-metadata tool" +``` + +--- + +### Task 11: Full test suite and lint check + +- [ ] **Step 1: Run all unit tests** + +```bash +pnpm test:unit +``` + +Expected: all PASS + +- [ ] **Step 2: Run all integration tests** + +```bash +pnpm test:integration +``` + +Expected: all PASS (including strip-metadata regression) + +- [ ] **Step 3: Run lint and typecheck** + +```bash +pnpm lint && pnpm typecheck +``` + +Expected: no errors + +- [ ] **Step 4: Fix any issues found** + +If any tests or lint checks fail, fix them before proceeding. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "fix: resolve test and lint issues from edit-metadata implementation" +``` + +(Skip if nothing to fix) + +--- + +### Task 12: Docker build and Playwright GUI verification + +- [ ] **Step 1: Build Docker image with cache** + +```bash +docker compose build +``` + +Expected: successful build + +- [ ] **Step 2: Start the container** + +```bash +docker compose up -d +``` + +Expected: container starts and API becomes available + +- [ ] **Step 3: Run Playwright in headed mode** + +```bash +pnpm test:e2e -- --headed --grep "edit-metadata" +``` + +Expected: browser opens, test runs visually, PASS + +- [ ] **Step 4: Manual verification** + +Open the app in browser. Navigate to Edit Metadata tool. Upload a test image with known EXIF data. Verify: +1. Current metadata displays correctly in collapsible sections +2. Form fields are pre-populated with existing values +3. Trash icons appear on string-typed EXIF fields (not on binary blobs) +4. Clicking a trash icon shows strikethrough styling +5. Editing a field and submitting produces a downloadable image +6. Re-uploading the downloaded image shows the edited metadata +7. Marking fields for removal and submitting removes them +8. GPS clear checkbox works when GPS data is present + +- [ ] **Step 5: Stop containers** + +```bash +docker compose down +``` From ec2010fa7ba48c1bc65dd99e59341504a17190a1 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:08:18 +0800 Subject: [PATCH 03/13] test: add JPEG fixture with known EXIF data for edit-metadata tests --- tests/fixtures/test-with-exif.jpg | Bin 0 -> 762 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/fixtures/test-with-exif.jpg diff --git a/tests/fixtures/test-with-exif.jpg b/tests/fixtures/test-with-exif.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f458377807a9fc020b40d97f5df9980af6c9dfa7 GIT binary patch literal 762 zcmbVJJxc>Y5Pf@lU%AAO(`ca}!Ab(cUPO^th(wDBR{95mIpcvzNJAcBQW>?~7T zT3V&Z53uy35-j`)7Q#AvcM=UYIxzd*&b>D~!^sEv!a5tr!5&a9=V1dF!Z6d|5__i{ zA)Y4eQH~OK2=6Gz7zZO%ImL_!Z-`GP{FeA!qS1(Z&lZb*R zcsri-oTg(}ah=g7=p-6X2-b-=lM>K6@itZR^KV>j_S?+z86{{;g*r7AI#;Km>s&Vt z(~Qze*`}4UOw&%=DMtmdOxnq)i)s>EHG^}*u}mwH{OOQgxHhV&ayARiW!z=5hY7Ic xzo-qOu&JCK9jt Date: Mon, 6 Apr 2026 19:09:18 +0800 Subject: [PATCH 04/13] feat: add EditMetadataOptions type and exif-reader dep to image-engine --- packages/image-engine/package.json | 1 + packages/image-engine/src/types.ts | 11 +++++++++++ pnpm-lock.yaml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/packages/image-engine/package.json b/packages/image-engine/package.json index 2917bdf8..658960f8 100644 --- a/packages/image-engine/package.json +++ b/packages/image-engine/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@stirling-image/shared": "workspace:*", + "exif-reader": "^2.0.3", "sharp": "^0.33.0" }, "devDependencies": { diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 5b2985ce..ca02315c 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -63,6 +63,17 @@ export interface StripMetadataOptions { stripAll?: boolean; } +export interface EditMetadataOptions { + artist?: string; + copyright?: string; + imageDescription?: string; + software?: string; + dateTime?: string; + dateTimeOriginal?: string; + clearGps?: boolean; + fieldsToRemove?: string[]; +} + export interface BrightnessOptions { value: number; // -100 to +100 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47aca688..8bf961b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: '@stirling-image/shared': specifier: workspace:* version: link:../shared + exif-reader: + specifier: ^2.0.3 + version: 2.0.3 sharp: specifier: ^0.33.0 version: 0.33.5 From 7fba05e1dccb4fd370848bf6b20276a9edffbd86 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:12:56 +0800 Subject: [PATCH 05/13] feat: extract shared metadata parsing utilities into image-engine Move sanitizeValue, parseExif, parseGps, parseXmp into the shared image-engine package so both strip-metadata and edit-metadata can reuse them. Includes 13 unit tests covering all four functions. --- packages/image-engine/src/utils/metadata.ts | 119 ++++++++++++++++++ tests/unit/image-engine/operations.test.ts | 127 ++++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/packages/image-engine/src/utils/metadata.ts b/packages/image-engine/src/utils/metadata.ts index 499d613e..8a71ffa1 100644 --- a/packages/image-engine/src/utils/metadata.ts +++ b/packages/image-engine/src/utils/metadata.ts @@ -1,3 +1,4 @@ +import exifReader from "exif-reader"; import sharp from "sharp"; import type { ImageInfo } from "../types.js"; @@ -26,3 +27,121 @@ export async function getImageInfo(buffer: Buffer): Promise { }, }; } + +/** + * Serialize a value for JSON - convert Buffers/Dates and drop overly large blobs. + */ +export function sanitizeValue(v: unknown): unknown { + if (v instanceof Date) return v.toISOString(); + if (Buffer.isBuffer(v)) { + if (v.length > 256) return ``; + return Array.from(v); + } + if (Array.isArray(v)) return v.map(sanitizeValue); + if (v !== null && typeof v === "object") { + const out: Record = {}; + for (const [k, val] of Object.entries(v)) { + out[k] = sanitizeValue(val); + } + return out; + } + return v; +} + +/** + * Parse an EXIF buffer into sanitized sections. + */ +export function parseExif(exifBuffer: Buffer): { + image: Record; + photo: Record; + iop: Record; + gps: Record; +} { + const result = { + image: {} as Record, + photo: {} as Record, + iop: {} as Record, + gps: {} as Record, + }; + + if (!exifBuffer || exifBuffer.length === 0) return result; + + try { + const parsed = exifReader(exifBuffer); + + if (parsed.Image) { + for (const [k, v] of Object.entries(parsed.Image)) { + result.image[k] = sanitizeValue(v); + } + } + if (parsed.Photo) { + for (const [k, v] of Object.entries(parsed.Photo)) { + result.photo[k] = sanitizeValue(v); + } + } + if (parsed.Iop) { + for (const [k, v] of Object.entries(parsed.Iop)) { + result.iop[k] = sanitizeValue(v); + } + } + if (parsed.GPSInfo) { + for (const [k, v] of Object.entries(parsed.GPSInfo)) { + result.gps[k] = sanitizeValue(v); + } + } + } catch { + // Return empty sections on parse failure + } + + return result; +} + +/** + * Parse GPS coordinates from EXIF GPSInfo into decimal degrees. + */ +export function parseGps(gps: Record): { + latitude: number | null; + longitude: number | null; + altitude: number | null; +} { + let latitude: number | null = null; + let longitude: number | null = null; + let altitude: number | null = null; + + const lat = gps.GPSLatitude as number[] | undefined; + const latRef = gps.GPSLatitudeRef as string | undefined; + if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) { + latitude = lat[0] + lat[1] / 60 + lat[2] / 3600; + if (latRef === "S") latitude = -latitude; + } + + const lon = gps.GPSLongitude as number[] | undefined; + const lonRef = gps.GPSLongitudeRef as string | undefined; + if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) { + longitude = lon[0] + lon[1] / 60 + lon[2] / 3600; + if (lonRef === "W") longitude = -longitude; + } + + if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) { + altitude = gps.GPSAltitude; + if (gps.GPSAltitudeRef === 1) altitude = -altitude; + } + + return { latitude, longitude, altitude }; +} + +/** + * Parse XMP XML buffer into key-value pairs. + */ +export function parseXmp(xmpBuffer: Buffer): Record { + const xml = xmpBuffer.toString("utf-8"); + const result: Record = {}; + + for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) { + const key = match[1]; + if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue; + result[key] = match[2]; + } + + return result; +} diff --git a/tests/unit/image-engine/operations.test.ts b/tests/unit/image-engine/operations.test.ts index 56d40bed..4743ea69 100644 --- a/tests/unit/image-engine/operations.test.ts +++ b/tests/unit/image-engine/operations.test.ts @@ -8,6 +8,9 @@ const require = createRequire( path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"), ); const sharp = require("sharp") as typeof import("sharp").default; +const exifReader = require( + path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"), +) as typeof import("exif-reader").default; import { brightness, @@ -17,11 +20,16 @@ import { convert, crop, flip, + getImageInfo, grayscale, invert, + parseExif, + parseGps, + parseXmp, processImage, resize, rotate, + sanitizeValue, saturation, sepia, stripMetadata, @@ -44,12 +52,14 @@ let png200x150: Buffer; let png1x1: Buffer; let jpg100x100: Buffer; let webp50x50: Buffer; +let jpgWithExif: Buffer; beforeAll(() => { png200x150 = readFileSync(path.join(FIXTURES_DIR, "test-200x150.png")); png1x1 = readFileSync(path.join(FIXTURES_DIR, "test-1x1.png")); jpg100x100 = readFileSync(path.join(FIXTURES_DIR, "test-100x100.jpg")); webp50x50 = readFileSync(path.join(FIXTURES_DIR, "test-50x50.webp")); + jpgWithExif = readFileSync(path.join(FIXTURES_DIR, "test-with-exif.jpg")); }); // --------------------------------------------------------------------------- @@ -1343,3 +1353,120 @@ describe("processImage", () => { expect(typeof result.info.hasAlpha).toBe("boolean"); }); }); + +// --------------------------------------------------------------------------- +// Shared metadata parsing utilities +// --------------------------------------------------------------------------- +describe("sanitizeValue", () => { + it("converts Date to ISO string", () => { + const d = new Date("2026-01-15T10:30:00Z"); + expect(sanitizeValue(d)).toBe("2026-01-15T10:30:00.000Z"); + }); + + it("converts small Buffer to number array", () => { + const buf = Buffer.from([1, 2, 3]); + expect(sanitizeValue(buf)).toEqual([1, 2, 3]); + }); + + it("converts large Buffer to placeholder string", () => { + const buf = Buffer.alloc(300, 0); + expect(sanitizeValue(buf)).toBe(""); + }); + + it("recursively sanitizes objects", () => { + const d = new Date("2026-01-01T00:00:00Z"); + const result = sanitizeValue({ nested: { date: d } }); + expect(result).toEqual({ nested: { date: "2026-01-01T00:00:00.000Z" } }); + }); + + it("passes through primitives unchanged", () => { + expect(sanitizeValue("hello")).toBe("hello"); + expect(sanitizeValue(42)).toBe(42); + expect(sanitizeValue(null)).toBe(null); + expect(sanitizeValue(true)).toBe(true); + }); +}); + +describe("parseExif", () => { + it("parses EXIF buffer from test fixture", async () => { + const metadata = await sharp(jpgWithExif).metadata(); + expect(metadata.exif).toBeTruthy(); + const result = parseExif(metadata.exif!); + expect(result.image.Artist).toBe("Test Artist"); + expect(result.image.Copyright).toBe("2026 Test Copyright"); + expect(result.image.Software).toBe("Stirling-Image Test"); + expect(result.image.ImageDescription).toBe("Test Description"); + }); + + it("returns empty sections for empty buffer", () => { + const result = parseExif(Buffer.from([])); + expect(result.image).toEqual({}); + expect(result.gps).toEqual({}); + }); +}); + +describe("parseGps", () => { + it("parses DMS coordinates to decimal degrees", () => { + const result = parseGps({ + GPSLatitude: [51, 30, 26.4], + GPSLatitudeRef: "N", + GPSLongitude: [0, 7, 39.6], + GPSLongitudeRef: "W", + GPSAltitude: 10, + GPSAltitudeRef: 0, + }); + expect(result.latitude).toBeCloseTo(51.5073, 3); + expect(result.longitude).toBeCloseTo(-0.1277, 3); + expect(result.altitude).toBe(10); + }); + + it("returns nulls for empty GPS data", () => { + const result = parseGps({}); + expect(result.latitude).toBeNull(); + expect(result.longitude).toBeNull(); + expect(result.altitude).toBeNull(); + }); + + it("handles southern hemisphere", () => { + const result = parseGps({ + GPSLatitude: [33, 51, 54], + GPSLatitudeRef: "S", + GPSLongitude: [151, 12, 36], + GPSLongitudeRef: "E", + }); + expect(result.latitude).toBeCloseTo(-33.865, 2); + expect(result.longitude).toBeCloseTo(151.21, 2); + }); +}); + +describe("parseXmp", () => { + it("extracts key-value pairs from XMP XML", () => { + const xml = Buffer.from( + '' + + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["dc:creator"]).toBe("Alice"); + expect(result["dc:title"]).toBe("My Photo"); + }); + + it("skips xmlns and rdf namespace prefixes", () => { + const xml = Buffer.from( + '' + + '' + + "", + ); + const result = parseXmp(xml); + expect(result["xmlns:x"]).toBeUndefined(); + expect(result["xmlns:dc"]).toBeUndefined(); + expect(result["rdf:about"]).toBeUndefined(); + expect(result["dc:format"]).toBe("image/jpeg"); + }); + + it("returns empty object for empty buffer", () => { + const result = parseXmp(Buffer.from("")); + expect(result).toEqual({}); + }); +}); From 939bb049413202a4df37d1affa3765cd9a41e9e2 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:21:15 +0800 Subject: [PATCH 06/13] feat: implement editMetadata operation in image-engine --- packages/image-engine/src/engine.ts | 3 + packages/image-engine/src/index.ts | 1 + .../src/operations/edit-metadata.ts | 91 +++++++++++++++++++ tests/unit/image-engine/operations.test.ts | 87 ++++++++++++++++++ 4 files changed, 182 insertions(+) create mode 100644 packages/image-engine/src/operations/edit-metadata.ts diff --git a/packages/image-engine/src/engine.ts b/packages/image-engine/src/engine.ts index 25d1090e..a2d6e578 100644 --- a/packages/image-engine/src/engine.ts +++ b/packages/image-engine/src/engine.ts @@ -5,6 +5,7 @@ import { compress } from "./operations/compress.js"; import { contrast } from "./operations/contrast.js"; import { convert } from "./operations/convert.js"; import { crop } from "./operations/crop.js"; +import { editMetadata } from "./operations/edit-metadata.js"; import { flip } from "./operations/flip.js"; import { grayscale } from "./operations/grayscale.js"; import { invert } from "./operations/invert.js"; @@ -20,6 +21,7 @@ import type { ContrastOptions, ConvertOptions, CropOptions, + EditMetadataOptions, FlipOptions, OperationResult, OutputFormat, @@ -54,6 +56,7 @@ const OPERATION_MAP: Record< grayscale: (img) => grayscale(img), sepia: (img) => sepia(img), invert: (img) => invert(img), + "edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions), }; const FORMAT_MAP: Record = { diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 135cba4f..c9d173be 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -6,6 +6,7 @@ export { compress } from "./operations/compress.js"; export { contrast } from "./operations/contrast.js"; export { convert } from "./operations/convert.js"; export { crop } from "./operations/crop.js"; +export { editMetadata } from "./operations/edit-metadata.js"; export { flip } from "./operations/flip.js"; export { grayscale } from "./operations/grayscale.js"; export { invert } from "./operations/invert.js"; diff --git a/packages/image-engine/src/operations/edit-metadata.ts b/packages/image-engine/src/operations/edit-metadata.ts new file mode 100644 index 00000000..c2e9f525 --- /dev/null +++ b/packages/image-engine/src/operations/edit-metadata.ts @@ -0,0 +1,91 @@ +import exifReader from "exif-reader"; +import type { EditMetadataOptions, Sharp } from "../types.js"; +import { sanitizeValue } from "../utils/metadata.js"; + +const COMMON_FIELD_MAP: Array<{ + option: keyof EditMetadataOptions; + ifd: "IFD0" | "IFD2"; + tag: string; +}> = [ + { option: "artist", ifd: "IFD0", tag: "Artist" }, + { option: "copyright", ifd: "IFD0", tag: "Copyright" }, + { option: "imageDescription", ifd: "IFD0", tag: "ImageDescription" }, + { option: "software", ifd: "IFD0", tag: "Software" }, + { option: "dateTime", ifd: "IFD0", tag: "DateTime" }, + { option: "dateTimeOriginal", ifd: "IFD2", tag: "DateTimeOriginal" }, +]; + +export async function editMetadata( + image: Sharp, + options: EditMetadataOptions = {}, +): Promise { + const edits: { IFD0: Record; IFD2: Record } = { + IFD0: {}, + IFD2: {}, + }; + + for (const { option, ifd, tag } of COMMON_FIELD_MAP) { + const value = options[option]; + if (typeof value === "string" && value.length > 0) { + edits[ifd][tag] = value; + } + } + + const writtenTags = new Set([...Object.keys(edits.IFD0), ...Object.keys(edits.IFD2)]); + const fieldsToRemove = (options.fieldsToRemove ?? []).filter((f) => !writtenTags.has(f)); + + const hasEdits = Object.keys(edits.IFD0).length > 0 || Object.keys(edits.IFD2).length > 0; + const hasRemovals = fieldsToRemove.length > 0 || options.clearGps === true; + + if (!hasEdits && !hasRemovals) { + return image.keepMetadata(); + } + + if (hasRemovals) { + const metadata = await image.metadata(); + + const existingIFD0: Record = {}; + const existingIFD2: Record = {}; + + if (metadata.exif) { + try { + const parsed = exifReader(metadata.exif); + if (parsed.Image) { + for (const [k, v] of Object.entries(parsed.Image)) { + if (fieldsToRemove.includes(k)) continue; + const sv = sanitizeValue(v); + if (typeof sv === "string" || typeof sv === "number") { + existingIFD0[k] = String(sv); + } + } + } + if (parsed.Photo) { + for (const [k, v] of Object.entries(parsed.Photo)) { + if (fieldsToRemove.includes(k)) continue; + const sv = sanitizeValue(v); + if (typeof sv === "string" || typeof sv === "number") { + existingIFD2[k] = String(sv); + } + } + } + } catch { + // If parsing fails, proceed with just the edits + } + } + + const finalIFD0 = { ...existingIFD0, ...edits.IFD0 }; + const finalIFD2 = { ...existingIFD2, ...edits.IFD2 }; + + const exif: Record> = {}; + if (Object.keys(finalIFD0).length > 0) exif.IFD0 = finalIFD0; + if (Object.keys(finalIFD2).length > 0) exif.IFD2 = finalIFD2; + + return image.withExif(exif); + } + + const exif: Record> = {}; + if (Object.keys(edits.IFD0).length > 0) exif.IFD0 = edits.IFD0; + if (Object.keys(edits.IFD2).length > 0) exif.IFD2 = edits.IFD2; + + return image.withExifMerge(exif); +} diff --git a/tests/unit/image-engine/operations.test.ts b/tests/unit/image-engine/operations.test.ts index 4743ea69..c3909fb8 100644 --- a/tests/unit/image-engine/operations.test.ts +++ b/tests/unit/image-engine/operations.test.ts @@ -19,6 +19,7 @@ import { contrast, convert, crop, + editMetadata, flip, getImageInfo, grayscale, @@ -1470,3 +1471,89 @@ describe("parseXmp", () => { expect(result).toEqual({}); }); }); + +// --------------------------------------------------------------------------- +// editMetadata +// --------------------------------------------------------------------------- +describe("editMetadata", () => { + it("writes common fields readable via exif-reader", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + artist: "New Artist", + copyright: "New Copyright", + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + expect(meta.exif).toBeTruthy(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("New Artist"); + expect(parsed.Image?.Copyright).toBe("New Copyright"); + // Original fields should be preserved via withExifMerge + expect(parsed.Image?.Software).toBe("Stirling-Image Test"); + }); + + it("clears GPS while preserving other EXIF", async () => { + // First write GPS to the image + const withGps = sharp(jpgWithExif).withExif({ + IFD0: { Artist: "GPS Test" }, + IFD3: { GPSLatitudeRef: "N" }, + }); + const gpsBuf = await withGps.jpeg().toBuffer(); + + const image = sharp(gpsBuf); + const result = await editMetadata(image, { clearGps: true }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + // GPS should be gone + expect(parsed.GPSInfo).toBeUndefined(); + // Other EXIF should still be present + expect(parsed.Image?.Artist).toBe("GPS Test"); + }); + + it("removes specific fields via fieldsToRemove", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + fieldsToRemove: ["Software"], + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Software).toBeUndefined(); + // Other fields preserved + expect(parsed.Image?.Artist).toBe("Test Artist"); + }); + + it("preserves metadata with no options", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, {}); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + expect(meta.exif).toBeTruthy(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("Test Artist"); + }); + + it("edit wins over remove for same field", async () => { + const image = sharp(jpgWithExif); + const result = await editMetadata(image, { + artist: "Override Artist", + fieldsToRemove: ["Artist"], + }); + const buf = await result.jpeg().toBuffer(); + const meta = await sharp(buf).metadata(); + const parsed = exifReader(meta.exif!); + expect(parsed.Image?.Artist).toBe("Override Artist"); + }); + + it("writes fresh EXIF to image without existing metadata", async () => { + const image = sharp(png1x1); + const result = await editMetadata(image, { + artist: "Fresh Artist", + copyright: "Fresh Copyright", + }); + const buf = await result.png().toBuffer(); + // The operation should not throw + expect(buf.length).toBeGreaterThan(0); + }); +}); From 6ac366b1556b285c6764927267fcb3b885b90fe9 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:23:18 +0800 Subject: [PATCH 07/13] feat: register edit-metadata in shared constants and i18n --- packages/shared/src/constants.ts | 8 ++++++++ packages/shared/src/i18n/en.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9ad5897b..2705b733 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -63,6 +63,14 @@ export const TOOLS: Tool[] = [ icon: "ShieldOff", route: "/strip-metadata", }, + { + id: "edit-metadata", + name: "Edit Metadata", + description: "Edit EXIF, GPS, and camera info", + category: "optimization", + icon: "PenLine", + route: "/edit-metadata", + }, { id: "bulk-rename", name: "Bulk Rename", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 5cb53b47..c79133de 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -36,6 +36,7 @@ export const en = { convert: { name: "Convert", description: "Convert between image formats" }, compress: { name: "Compress", description: "Reduce file size by quality or target size" }, "strip-metadata": { name: "Strip Metadata", description: "Remove EXIF, GPS, and camera info" }, + "edit-metadata": { name: "Edit Metadata", description: "Edit EXIF, GPS, and camera info" }, "bulk-rename": { name: "Bulk Rename", description: "Rename multiple files with patterns" }, "image-to-pdf": { name: "Image to PDF", description: "Combine images into a PDF document" }, favicon: { name: "Favicon Generator", description: "Generate all favicon and app icon sizes" }, From 1312858cf1a1deafbd205a68ebd4f5ee4cc93f1e Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:24:05 +0800 Subject: [PATCH 08/13] refactor: use shared metadata parsing in strip-metadata route --- apps/api/src/routes/tools/strip-metadata.ts | 108 ++------------------ 1 file changed, 10 insertions(+), 98 deletions(-) diff --git a/apps/api/src/routes/tools/strip-metadata.ts b/apps/api/src/routes/tools/strip-metadata.ts index 9ce26b6c..1c09b3fe 100644 --- a/apps/api/src/routes/tools/strip-metadata.ts +++ b/apps/api/src/routes/tools/strip-metadata.ts @@ -1,6 +1,5 @@ import { basename } from "node:path"; -import { stripMetadata } from "@stirling-image/image-engine"; -import exifReader from "exif-reader"; +import { parseExif, parseGps, parseXmp, stripMetadata } from "@stirling-image/image-engine"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import sharp from "sharp"; import { z } from "zod"; @@ -14,76 +13,6 @@ const settingsSchema = z.object({ stripAll: z.boolean().default(true), }); -/** - * Serialize a value for JSON — convert Buffers/Dates and drop overly large blobs. - */ -function sanitizeValue(v: unknown): unknown { - if (v instanceof Date) return v.toISOString(); - if (Buffer.isBuffer(v)) { - if (v.length > 256) return ``; - return Array.from(v); - } - if (Array.isArray(v)) return v.map(sanitizeValue); - if (v !== null && typeof v === "object") { - const out: Record = {}; - for (const [k, val] of Object.entries(v)) { - out[k] = sanitizeValue(val); - } - return out; - } - return v; -} - -/** - * Parse GPS coordinates from EXIF GPSInfo into decimal degrees. - */ -function parseGpsCoordinates(gps: Record): { - latitude: number | null; - longitude: number | null; - altitude: number | null; -} { - let latitude: number | null = null; - let longitude: number | null = null; - let altitude: number | null = null; - - const lat = gps.GPSLatitude as number[] | undefined; - const latRef = gps.GPSLatitudeRef as string | undefined; - if (lat && lat.length === 3 && lat.every((v) => typeof v === "number" && !Number.isNaN(v))) { - latitude = lat[0] + lat[1] / 60 + lat[2] / 3600; - if (latRef === "S") latitude = -latitude; - } - - const lon = gps.GPSLongitude as number[] | undefined; - const lonRef = gps.GPSLongitudeRef as string | undefined; - if (lon && lon.length === 3 && lon.every((v) => typeof v === "number" && !Number.isNaN(v))) { - longitude = lon[0] + lon[1] / 60 + lon[2] / 3600; - if (lonRef === "W") longitude = -longitude; - } - - if (typeof gps.GPSAltitude === "number" && !Number.isNaN(gps.GPSAltitude)) { - altitude = gps.GPSAltitude; - if (gps.GPSAltitudeRef === 1) altitude = -altitude; - } - - return { latitude, longitude, altitude }; -} - -/** - * Parse XMP XML buffer into key-value pairs. - */ -function parseXmp(xmpBuffer: Buffer): Record { - const xml = xmpBuffer.toString("utf-8"); - const result: Record = {}; - - for (const match of xml.matchAll(/(\w+:\w+)="([^"]+)"/g)) { - const key = match[1]; - if (key.startsWith("xmlns:") || key.startsWith("rdf:")) continue; - result[key] = match[2]; - } - - return result; -} - /** * Parse ICC profile buffer into basic info. */ @@ -206,33 +135,16 @@ export function registerStripMetadata(app: FastifyInstance) { // Parse EXIF if (metadata.exif) { try { - const parsed = exifReader(metadata.exif); - const exifData: Record = {}; - const gpsData: Record = {}; + const parsed = parseExif(metadata.exif); + const exifData: Record = { + ...parsed.image, + ...parsed.photo, + ...parsed.iop, + }; + const gpsData: Record = { ...parsed.gps }; - if (parsed.Image) { - for (const [k, v] of Object.entries(parsed.Image)) { - exifData[k] = sanitizeValue(v); - } - } - - if (parsed.Photo) { - for (const [k, v] of Object.entries(parsed.Photo)) { - exifData[k] = sanitizeValue(v); - } - } - - if (parsed.Iop) { - for (const [k, v] of Object.entries(parsed.Iop)) { - exifData[k] = sanitizeValue(v); - } - } - - if (parsed.GPSInfo) { - for (const [k, v] of Object.entries(parsed.GPSInfo)) { - gpsData[k] = sanitizeValue(v); - } - const coords = parseGpsCoordinates(parsed.GPSInfo as Record); + if (Object.keys(parsed.gps).length > 0) { + const coords = parseGps(parsed.gps); if (coords.latitude !== null) gpsData._latitude = coords.latitude; if (coords.longitude !== null) gpsData._longitude = coords.longitude; if (coords.altitude !== null) gpsData._altitude = coords.altitude; From 5284d5ece5dd393bf346f29d4d95b506f0c87c94 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:27:33 +0800 Subject: [PATCH 09/13] feat: add edit-metadata API route with inspect and edit endpoints --- apps/api/src/routes/tools/edit-metadata.ts | 150 +++++++++++++++++++++ apps/api/src/routes/tools/index.ts | 2 + tests/integration/api.test.ts | 101 ++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 apps/api/src/routes/tools/edit-metadata.ts diff --git a/apps/api/src/routes/tools/edit-metadata.ts b/apps/api/src/routes/tools/edit-metadata.ts new file mode 100644 index 00000000..1ec5bb1a --- /dev/null +++ b/apps/api/src/routes/tools/edit-metadata.ts @@ -0,0 +1,150 @@ +import { basename } from "node:path"; +import { editMetadata, parseExif, parseGps, parseXmp } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + artist: z.string().optional(), + copyright: z.string().optional(), + imageDescription: z.string().optional(), + software: z.string().optional(), + dateTime: z.string().optional(), + dateTimeOriginal: z.string().optional(), + clearGps: z.boolean().default(false), + fieldsToRemove: z.array(z.string()).default([]), +}); + +export function registerEditMetadata(app: FastifyInstance) { + // Inspect endpoint - returns parsed metadata as JSON + app.post( + "/api/v1/tools/edit-metadata/inspect", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + let filename = "image"; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + filename = basename(part.filename ?? "image"); + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse multipart request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + try { + const metadata = await sharp(fileBuffer).metadata(); + const result: Record = { + filename, + fileSize: fileBuffer.length, + }; + + if (metadata.exif) { + try { + const parsed = parseExif(metadata.exif); + const exifData: Record = { + ...parsed.image, + ...parsed.photo, + ...parsed.iop, + }; + const gpsData: Record = { ...parsed.gps }; + + if (Object.keys(parsed.gps).length > 0) { + const coords = parseGps(parsed.gps); + if (coords.latitude !== null) gpsData._latitude = coords.latitude; + if (coords.longitude !== null) gpsData._longitude = coords.longitude; + if (coords.altitude !== null) gpsData._altitude = coords.altitude; + } + + if (Object.keys(exifData).length > 0) result.exif = exifData; + if (Object.keys(gpsData).length > 0) result.gps = gpsData; + } catch { + result.exif = null; + result.exifError = "Failed to parse EXIF data"; + } + } + + if (metadata.xmp) { + try { + result.xmp = parseXmp(metadata.xmp); + } catch { + result.xmp = null; + } + } + + return reply.send(result); + } catch (err) { + return reply.status(422).send({ + error: "Failed to read image metadata", + details: err instanceof Error ? err.message : "Unknown error", + }); + } + }, + ); + + // Edit endpoint - writes metadata and returns updated image + createToolRoute(app, { + toolId: "edit-metadata", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const metadata = await sharp(inputBuffer).metadata(); + const format = metadata.format ?? "jpeg"; + const image = sharp(inputBuffer); + const result = await editMetadata(image, settings); + + switch (format) { + case "jpeg": + result.jpeg({ quality: 95, mozjpeg: true }); + break; + case "png": + result.png({ compressionLevel: 6 }); + break; + case "webp": + result.webp({ quality: 90 }); + break; + case "avif": + result.avif({ quality: 60 }); + break; + case "tiff": + result.tiff({ quality: 90 }); + break; + default: + result.jpeg({ quality: 95 }); + break; + } + + const buffer = await result.toBuffer(); + const ext = format === "jpeg" ? "jpg" : format; + const outFilename = filename.replace(/\.[^.]+$/, `.${ext}`); + const mimeMap: Record = { + jpeg: "image/jpeg", + png: "image/png", + webp: "image/webp", + avif: "image/avif", + tiff: "image/tiff", + gif: "image/gif", + }; + + return { + buffer, + filename: outFilename, + contentType: mimeMap[format] ?? "image/jpeg", + }; + }, + }); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index a3d62f92..6f1849df 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -14,6 +14,7 @@ import { registerCompose } from "./compose.js"; import { registerCompress } from "./compress.js"; import { registerConvert } from "./convert.js"; import { registerCrop } from "./crop.js"; +import { registerEditMetadata } from "./edit-metadata.js"; import { registerEraseObject } from "./erase-object.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; @@ -81,6 +82,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "convert", register: registerConvert }, { id: "compress", register: registerCompress }, { id: "strip-metadata", register: registerStripMetadata }, + { id: "edit-metadata", register: registerEditMetadata }, { id: "color-adjustments", register: registerColorAdjustments }, // Watermark & Overlay diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index d0faa88c..6f9ab2d6 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -20,6 +20,7 @@ const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg")); const WEBP_50x50 = readFileSync(join(FIXTURES, "test-50x50.webp")); const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png")); +const EXIF_JPG = readFileSync(join(FIXTURES, "test-with-exif.jpg")); // --------------------------------------------------------------------------- // Shared state @@ -3382,3 +3383,103 @@ describe("Workspace integrity", () => { expect(jobId1).not.toBe(jobId2); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// EDIT METADATA +// ═══════════════════════════════════════════════════════════════════════════ +describe("Edit metadata", () => { + describe("POST /api/v1/tools/edit-metadata/inspect", () => { + it("returns parsed EXIF for JPEG with metadata", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "exif.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.filename).toBe("exif.jpg"); + expect(body.exif).toBeTruthy(); + expect(body.exif.Artist).toBe("Test Artist"); + expect(body.exif.Copyright).toBe("2026 Test Copyright"); + }); + + it("returns no exif for metadata-free PNG", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "plain.png", contentType: "image/png", content: PNG_1x1 }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.exif).toBeUndefined(); + }); + + it("rejects request with no file", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata/inspect", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": "multipart/form-data; boundary=---test", + }, + payload: Buffer.from("-----test--\r\n"), + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe("POST /api/v1/tools/edit-metadata", () => { + it("writes metadata and returns downloadable file", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "edit.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ artist: "New Author" }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBeDefined(); + expect(body.jobId).toBeDefined(); + }); + + it("strips specific fields via fieldsToRemove", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "strip.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({ fieldsToRemove: ["Software"] }) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + + it("preserves metadata with empty settings", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "noop.jpg", contentType: "image/jpeg", content: EXIF_JPG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/edit-metadata", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + payload, + }); + expect(res.statusCode).toBe(200); + }); + }); +}); From d8bb2f1d61825f23910684ce1e08ed7bfb1c420a Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:30:47 +0800 Subject: [PATCH 10/13] refactor: extract shared metadata UI components from strip-metadata --- .../components/common/collapsible-section.tsx | 42 +++++ .../src/components/common/metadata-grid.tsx | 70 ++++++++ .../tools/strip-metadata-settings.tsx | 159 +----------------- apps/web/src/lib/metadata-utils.ts | 99 +++++++++++ 4 files changed, 215 insertions(+), 155 deletions(-) create mode 100644 apps/web/src/components/common/collapsible-section.tsx create mode 100644 apps/web/src/components/common/metadata-grid.tsx create mode 100644 apps/web/src/lib/metadata-utils.ts diff --git a/apps/web/src/components/common/collapsible-section.tsx b/apps/web/src/components/common/collapsible-section.tsx new file mode 100644 index 00000000..20e26d23 --- /dev/null +++ b/apps/web/src/components/common/collapsible-section.tsx @@ -0,0 +1,42 @@ +import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +export function CollapsibleSection({ + title, + badge, + warning, + defaultOpen, + children, +}: { + title: string; + badge?: string; + warning?: boolean; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen ?? false); + + return ( +
+ + {open &&
{children}
} +
+ ); +} diff --git a/apps/web/src/components/common/metadata-grid.tsx b/apps/web/src/components/common/metadata-grid.tsx new file mode 100644 index 00000000..31db3cea --- /dev/null +++ b/apps/web/src/components/common/metadata-grid.tsx @@ -0,0 +1,70 @@ +import { Trash2 } from "lucide-react"; +import { formatExifValue, SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS } from "@/lib/metadata-utils"; + +export function MetadataGrid({ + data, + labelMap, + onRemove, + removedKeys, +}: { + data: Record; + labelMap?: Record; + onRemove?: (key: string) => void; + removedKeys?: Set; +}) { + const entries = Object.entries(data).filter( + ([k, v]) => + !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "", + ); + + if (entries.length === 0) { + return

No data

; + } + + const hasRemoveColumn = !!onRemove; + + return ( +
+ {entries.map(([k, v]) => { + const isRemoved = removedKeys?.has(k); + const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k); + return ( +
+
+ {labelMap?.[k] ?? k} +
+
+ {formatExifValue(k, v)} +
+ {hasRemoveColumn && ( +
+ {canRemove ? ( + + ) : ( +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index aac6f3a0..ebe65b2f 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -1,8 +1,11 @@ -import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } from "lucide-react"; +import { Download, Loader2, MapPin } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { MetadataGrid } from "@/components/common/metadata-grid"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { formatHeaders } from "@/lib/api"; +import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils"; import { useFileStore } from "@/stores/file-store"; interface MetadataResult { @@ -15,160 +18,6 @@ interface MetadataResult { xmp?: Record | null; } -/** Human-friendly labels for common EXIF keys */ -const EXIF_LABELS: Record = { - Make: "Camera Make", - Model: "Camera Model", - Software: "Software", - DateTime: "Date/Time", - DateTimeOriginal: "Date Taken", - DateTimeDigitized: "Date Digitized", - ExposureTime: "Exposure Time", - FNumber: "F-Number", - ISOSpeedRatings: "ISO", - FocalLength: "Focal Length", - FocalLengthIn35mmFilm: "Focal Length (35mm)", - ExposureBiasValue: "Exposure Bias", - MeteringMode: "Metering Mode", - Flash: "Flash", - WhiteBalance: "White Balance", - ExposureMode: "Exposure Mode", - SceneCaptureType: "Scene Type", - Contrast: "Contrast", - Saturation: "Saturation", - Sharpness: "Sharpness", - DigitalZoomRatio: "Digital Zoom", - ImageWidth: "Width", - ImageLength: "Height", - Orientation: "Orientation", - XResolution: "X Resolution", - YResolution: "Y Resolution", - ResolutionUnit: "Resolution Unit", - ColorSpace: "Color Space", - PixelXDimension: "Pixel Width", - PixelYDimension: "Pixel Height", - Artist: "Artist", - Copyright: "Copyright", - ImageDescription: "Description", - LensMake: "Lens Make", - LensModel: "Lens Model", - BodySerialNumber: "Body Serial", - CameraOwnerName: "Camera Owner", -}; - -/** Keys to skip in display (internal/binary/redundant) */ -const SKIP_KEYS = new Set([ - "ExifTag", - "GPSTag", - "InteroperabilityTag", - "MakerNote", - "PrintImageMatching", - "ComponentsConfiguration", - "FlashpixVersion", - "ExifVersion", - "FileSource", - "SceneType", - "UserComment", - "InteroperabilityIndex", - "InteroperabilityVersion", -]); - -function formatExifValue(key: string, value: unknown): string { - if (value === null || value === undefined) return "N/A"; - if (typeof value === "string") return value; - if (typeof value === "number") { - if (key === "ExposureTime" && value > 0 && value < 1) { - return `1/${Math.round(1 / value)}s`; - } - if (key === "FNumber") return `f/${value}`; - if (key === "FocalLength") return `${value}mm`; - if (key === "FocalLengthIn35mmFilm") return `${value}mm`; - return String(value); - } - if (Array.isArray(value)) { - if (typeof value[0] === "number" && value.length <= 4) { - return value.join(", "); - } - return `[${value.length} values]`; - } - return String(value); -} - -function CollapsibleSection({ - title, - badge, - warning, - defaultOpen, - children, -}: { - title: string; - badge?: string; - warning?: boolean; - defaultOpen?: boolean; - children: React.ReactNode; -}) { - const [open, setOpen] = useState(defaultOpen ?? false); - - return ( -
- - {open &&
{children}
} -
- ); -} - -function MetadataGrid({ - data, - labelMap, -}: { - data: Record; - labelMap?: Record; -}) { - const entries = Object.entries(data).filter( - ([k, v]) => - !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "", - ); - - if (entries.length === 0) { - return

No data

; - } - - return ( -
- {entries.map(([k, v]) => ( -
-
- {labelMap?.[k] ?? k} -
-
- {formatExifValue(k, v)} -
-
- ))} -
- ); -} - interface StripMetadataControlsProps { onChange?: (settings: Record) => void; /** Passed from parent to preserve field-count badges in checkbox labels */ diff --git a/apps/web/src/lib/metadata-utils.ts b/apps/web/src/lib/metadata-utils.ts new file mode 100644 index 00000000..93db39c9 --- /dev/null +++ b/apps/web/src/lib/metadata-utils.ts @@ -0,0 +1,99 @@ +/** Human-friendly labels for common EXIF keys */ +export const EXIF_LABELS: Record = { + Make: "Camera Make", + Model: "Camera Model", + Software: "Software", + DateTime: "Date/Time", + DateTimeOriginal: "Date Taken", + DateTimeDigitized: "Date Digitized", + ExposureTime: "Exposure Time", + FNumber: "F-Number", + ISOSpeedRatings: "ISO", + FocalLength: "Focal Length", + FocalLengthIn35mmFilm: "Focal Length (35mm)", + ExposureBiasValue: "Exposure Bias", + MeteringMode: "Metering Mode", + Flash: "Flash", + WhiteBalance: "White Balance", + ExposureMode: "Exposure Mode", + SceneCaptureType: "Scene Type", + Contrast: "Contrast", + Saturation: "Saturation", + Sharpness: "Sharpness", + DigitalZoomRatio: "Digital Zoom", + ImageWidth: "Width", + ImageLength: "Height", + Orientation: "Orientation", + XResolution: "X Resolution", + YResolution: "Y Resolution", + ResolutionUnit: "Resolution Unit", + ColorSpace: "Color Space", + PixelXDimension: "Pixel Width", + PixelYDimension: "Pixel Height", + Artist: "Artist", + Copyright: "Copyright", + ImageDescription: "Description", + LensMake: "Lens Make", + LensModel: "Lens Model", + BodySerialNumber: "Body Serial", + CameraOwnerName: "Camera Owner", +}; + +/** Keys to skip in display (internal/binary/redundant) */ +export const SKIP_KEYS = new Set([ + "ExifTag", + "GPSTag", + "InteroperabilityTag", + "MakerNote", + "PrintImageMatching", + "ComponentsConfiguration", + "FlashpixVersion", + "ExifVersion", + "FileSource", + "SceneType", + "UserComment", + "InteroperabilityIndex", + "InteroperabilityVersion", +]); + +/** Keys that are binary/complex and NOT safe for EXIF round-trip via withExif() */ +export const UNSAFE_ROUND_TRIP_KEYS = new Set([ + "MakerNote", + "PrintImageMatching", + "ComponentsConfiguration", + "FlashpixVersion", + "ExifVersion", + "FileSource", + "SceneType", + "UserComment", + "InteroperabilityIndex", + "InteroperabilityVersion", +]); + +export function formatExifValue(key: string, value: unknown): string { + if (value === null || value === undefined) return "N/A"; + if (typeof value === "string") return value; + if (typeof value === "number") { + if (key === "ExposureTime" && value > 0 && value < 1) { + return `1/${Math.round(1 / value)}s`; + } + if (key === "FNumber") return `f/${value}`; + if (key === "FocalLength") return `${value}mm`; + if (key === "FocalLengthIn35mmFilm") return `${value}mm`; + return String(value); + } + if (Array.isArray(value)) { + if (typeof value[0] === "number" && value.length <= 4) { + return value.join(", "); + } + return `[${value.length} values]`; + } + return String(value); +} + +export function exifStr(exif: Record | null | undefined, key: string): string { + const v = exif?.[key]; + if (typeof v === "string") return v; + if (typeof v === "number") return String(v); + return ""; +} From 492eeb0f962b50db598b5ac50cc9c2ceea9ce605 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:34:00 +0800 Subject: [PATCH 11/13] feat: add edit-metadata UI component with granular strip support --- .../tools/edit-metadata-settings.tsx | 408 ++++++++++++++++++ apps/web/src/lib/tool-registry.tsx | 6 + 2 files changed, 414 insertions(+) create mode 100644 apps/web/src/components/tools/edit-metadata-settings.tsx diff --git a/apps/web/src/components/tools/edit-metadata-settings.tsx b/apps/web/src/components/tools/edit-metadata-settings.tsx new file mode 100644 index 00000000..cd8743c7 --- /dev/null +++ b/apps/web/src/components/tools/edit-metadata-settings.tsx @@ -0,0 +1,408 @@ +import { AlertTriangle, Download, Loader2, MapPin, PenLine } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { CollapsibleSection } from "@/components/common/collapsible-section"; +import { MetadataGrid } from "@/components/common/metadata-grid"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { formatHeaders } from "@/lib/api"; +import { EXIF_LABELS, exifStr, SKIP_KEYS } from "@/lib/metadata-utils"; +import { useFileStore } from "@/stores/file-store"; + +interface InspectResult { + filename: string; + fileSize: number; + exif?: Record | null; + exifError?: string; + gps?: Record | null; + xmp?: Record | null; +} + +interface FormFields { + artist: string; + copyright: string; + imageDescription: string; + software: string; + dateTime: string; + dateTimeOriginal: string; + clearGps: boolean; +} + +const EMPTY_FORM: FormFields = { + artist: "", + copyright: "", + imageDescription: "", + software: "", + dateTime: "", + dateTimeOriginal: "", + clearGps: false, +}; + +function LabeledInput({ + label, + id, + value, + onChange, + placeholder, + hint, +}: { + label: string; + id: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + hint?: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className="w-full px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" + /> + {hint &&

{hint}

} +
+ ); +} + +export function EditMetadataSettings() { + const { entries, selectedIndex, files } = useFileStore(); + const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = + useToolProcessor("edit-metadata"); + + const [form, setForm] = useState(EMPTY_FORM); + const [initialForm, setInitialForm] = useState(EMPTY_FORM); + const [fieldsToRemove, setFieldsToRemove] = useState>(new Set()); + const [inspectData, setInspectData] = useState(null); + const [inspecting, setInspecting] = useState(false); + const [inspectError, setInspectError] = useState(null); + const [inspectCache, setInspectCache] = useState>(new Map()); + + const currentFile = entries[selectedIndex]?.file ?? null; + const fileKey = currentFile + ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` + : null; + + const populateForm = useCallback((data: InspectResult) => { + const exif = data.exif ?? {}; + setInspectData(data); + const populated: FormFields = { + artist: exifStr(exif, "Artist"), + copyright: exifStr(exif, "Copyright"), + imageDescription: exifStr(exif, "ImageDescription"), + software: exifStr(exif, "Software"), + dateTime: exifStr(exif, "DateTime"), + dateTimeOriginal: exifStr(exif, "DateTimeOriginal"), + clearGps: false, + }; + setForm(populated); + setInitialForm(populated); + setFieldsToRemove(new Set()); + }, []); + + useEffect(() => { + if (!currentFile || !fileKey) { + setForm(EMPTY_FORM); + setInitialForm(EMPTY_FORM); + setInspectData(null); + setInspectError(null); + setFieldsToRemove(new Set()); + return; + } + + const cached = inspectCache.get(fileKey); + if (cached) { + populateForm(cached); + return; + } + + const controller = new AbortController(); + (async () => { + setInspecting(true); + setInspectError(null); + setInspectData(null); + try { + const formData = new FormData(); + formData.append("file", currentFile); + const res = await fetch("/api/v1/tools/edit-metadata/inspect", { + method: "POST", + headers: formatHeaders(), + body: formData, + signal: controller.signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Failed: ${res.status}`); + } + const data: InspectResult = await res.json(); + setInspectCache((prev) => new Map(prev).set(fileKey, data)); + populateForm(data); + } catch (err) { + if ((err as Error).name === "AbortError") return; + setInspectError(err instanceof Error ? err.message : "Failed to inspect file"); + setForm(EMPTY_FORM); + setInitialForm(EMPTY_FORM); + } finally { + setInspecting(false); + } + })(); + + return () => controller.abort(); + }, [currentFile, fileKey, inspectCache, populateForm]); + + const setField = (key: K, value: FormFields[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const toggleRemoveField = (key: string) => { + setFieldsToRemove((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const hasFile = files.length > 0; + const gpsLat = inspectData?.gps?._latitude as number | undefined; + const gpsLon = inspectData?.gps?._longitude as number | undefined; + const gpsCoords = gpsLat != null && gpsLon != null ? { lat: gpsLat, lon: gpsLon } : null; + const exifEntryCount = inspectData?.exif + ? Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length + : 0; + const hasGps = + !!inspectData?.gps && Object.keys(inspectData.gps).filter((k) => !k.startsWith("_")).length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!hasFile || processing) return; + + const settings: Record = { clearGps: form.clearGps }; + + const fieldMap: Array<{ + formKey: keyof FormFields; + settingsKey: string; + exifTag: string; + }> = [ + { formKey: "artist", settingsKey: "artist", exifTag: "Artist" }, + { formKey: "copyright", settingsKey: "copyright", exifTag: "Copyright" }, + { + formKey: "imageDescription", + settingsKey: "imageDescription", + exifTag: "ImageDescription", + }, + { formKey: "software", settingsKey: "software", exifTag: "Software" }, + { formKey: "dateTime", settingsKey: "dateTime", exifTag: "DateTime" }, + { + formKey: "dateTimeOriginal", + settingsKey: "dateTimeOriginal", + exifTag: "DateTimeOriginal", + }, + ]; + + const removeSet = new Set(fieldsToRemove); + + for (const { formKey, settingsKey, exifTag } of fieldMap) { + const current = form[formKey] as string; + const initial = initialForm[formKey] as string; + if (current !== initial) { + if (current.trim()) { + settings[settingsKey] = current.trim(); + removeSet.delete(exifTag); + } else { + removeSet.add(exifTag); + } + } + } + + if (removeSet.size > 0) { + settings.fieldsToRemove = Array.from(removeSet); + } + + processFiles(files, settings); + }; + + return ( +
+ {/* Current Metadata */} + {hasFile && ( +
+

Current Metadata

+ + {inspecting && ( +
+ + Reading metadata... +
+ )} + + {inspectError && !inspecting && ( +
+ + Could not read metadata - fields will start empty. +
+ )} + + {inspectData && ( +
+ {exifEntryCount > 0 && inspectData.exif ? ( + + + + ) : ( +

No EXIF data found.

+ )} + {hasGps && inspectData.gps && ( + + !k.startsWith("_")), + )} + /> + + )} +
+ )} +
+ )} + + {/* Edit Fields */} + {hasFile && ( +
+
+

Edit Fields

+ + setField("imageDescription", v)} + placeholder="Image description" + /> + setField("artist", v)} + placeholder="Photographer / creator name" + /> + setField("copyright", v)} + placeholder="2026 Example" + /> + setField("software", v)} + placeholder="e.g. Lightroom, Photoshop" + /> + setField("dateTime", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + hint="EXIF date format: 2026:04:06 12:00:00" + /> + setField("dateTimeOriginal", v)} + placeholder="YYYY:MM:DD HH:MM:SS" + /> + + {/* GPS */} +
+
+ {gpsCoords ? ( +
+ +
+

+ Location data found +

+

+ {gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)} +

+
+
+ ) : ( +

No GPS data in this image.

+ )} + +
+
+ )} + + {!hasFile && ( +
+ +

Upload an image to edit its metadata.

+
+ )} + + {error &&

{error}

} + + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Processed: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {processing ? ( + + ) : ( + + )} + + {downloadUrl && ( + + + Download + + )} + + ); +} diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index a77b51d6..bd64b5b1 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -81,6 +81,11 @@ const StripMetadataSettings = lazy(() => default: m.StripMetadataSettings, })), ); +const EditMetadataSettings = lazy(() => + import("@/components/tools/edit-metadata-settings").then((m) => ({ + default: m.EditMetadataSettings, + })), +); const ColorSettings = lazy(() => import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })), ); @@ -238,6 +243,7 @@ export const toolRegistry = new Map([ ["convert", { displayMode: "no-comparison", Settings: ConvertSettings }], ["compress", { displayMode: "before-after", Settings: CompressSettings }], ["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }], + ["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }], // Color adjustments (all share ColorSettings with different toolId) ...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map( From 79628873879728082ad6ed49949c705dcc51d5d5 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:35:25 +0800 Subject: [PATCH 12/13] test: add e2e tests for edit-metadata tool --- tests/e2e/tools-all.spec.ts | 1 + tests/e2e/tools-process.spec.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/tests/e2e/tools-all.spec.ts b/tests/e2e/tools-all.spec.ts index e7af3a26..5f616f34 100644 --- a/tests/e2e/tools-all.spec.ts +++ b/tests/e2e/tools-all.spec.ts @@ -12,6 +12,7 @@ const TOOLS_WITH_DROPZONE = [ { id: "convert", name: "Convert" }, { id: "compress", name: "Compress" }, { id: "strip-metadata", name: "Strip Metadata" }, + { id: "edit-metadata", name: "Edit Metadata" }, { id: "bulk-rename", name: "Bulk Rename" }, { id: "image-to-pdf", name: "Image to PDF" }, { id: "favicon", name: "Favicon" }, diff --git a/tests/e2e/tools-process.spec.ts b/tests/e2e/tools-process.spec.ts index 64ed0bcc..6dac1a98 100644 --- a/tests/e2e/tools-process.spec.ts +++ b/tests/e2e/tools-process.spec.ts @@ -89,6 +89,23 @@ test.describe("Tool processing (core tools)", () => { }); }); + test("edit-metadata processes image", async ({ loggedInPage: page }) => { + await page.goto("/edit-metadata"); + await uploadTestImage(page); + + // Wait for inspect to complete and form to populate + await page.waitForSelector('[id="em-artist"]', { timeout: 10_000 }); + + // Edit the artist field + await page.fill('[id="em-artist"]', "E2E Test Artist"); + + await page.getByRole("button", { name: /apply metadata/i }).click(); + await waitForProcessing(page); + await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({ + timeout: 15_000, + }); + }); + test("brightness-contrast processes image", async ({ loggedInPage: page }) => { await page.goto("/brightness-contrast"); await uploadTestImage(page); From a430e913245f6c83194cc1e47c34a327d80840d5 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Mon, 6 Apr 2026 19:40:15 +0800 Subject: [PATCH 13/13] fix: filter unsafe round-trip keys server-side in editMetadata Prevent silent data corruption when the API is called directly (bypassing UI guards). Binary/complex EXIF fields like MakerNote are now filtered from fieldsToRemove in the image-engine operation. --- .../src/operations/edit-metadata.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/image-engine/src/operations/edit-metadata.ts b/packages/image-engine/src/operations/edit-metadata.ts index c2e9f525..39b52992 100644 --- a/packages/image-engine/src/operations/edit-metadata.ts +++ b/packages/image-engine/src/operations/edit-metadata.ts @@ -2,6 +2,27 @@ import exifReader from "exif-reader"; import type { EditMetadataOptions, Sharp } from "../types.js"; import { sanitizeValue } from "../utils/metadata.js"; +/** + * Keys that are binary blobs or complex arrays - NOT safe for EXIF round-trip + * through withExif(). Silently filtered from fieldsToRemove to prevent + * data corruption when the API is called directly (bypassing UI guards). + */ +const UNSAFE_ROUND_TRIP_KEYS = new Set([ + "MakerNote", + "PrintImageMatching", + "ComponentsConfiguration", + "FlashpixVersion", + "ExifVersion", + "FileSource", + "SceneType", + "UserComment", + "InteroperabilityIndex", + "InteroperabilityVersion", + "ExifTag", + "GPSTag", + "InteroperabilityTag", +]); + const COMMON_FIELD_MAP: Array<{ option: keyof EditMetadataOptions; ifd: "IFD0" | "IFD2"; @@ -32,7 +53,9 @@ export async function editMetadata( } const writtenTags = new Set([...Object.keys(edits.IFD0), ...Object.keys(edits.IFD2)]); - const fieldsToRemove = (options.fieldsToRemove ?? []).filter((f) => !writtenTags.has(f)); + const fieldsToRemove = (options.fieldsToRemove ?? []).filter( + (f) => !writtenTags.has(f) && !UNSAFE_ROUND_TRIP_KEYS.has(f), + ); const hasEdits = Object.keys(edits.IFD0).length > 0 || Object.keys(edits.IFD2).length > 0; const hasRemovals = fieldsToRemove.length > 0 || options.clearGps === true;