# 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 ```