From 625e16c7b6ccefa7a14f2bfd3e9f66243b051987 Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Tue, 7 Apr 2026 21:45:34 +0800 Subject: [PATCH] chore: remove all docs/superpowers from git tracking These are local planning artifacts that should not be in the repository. The directory is already gitignored. --- .../plans/2026-04-04-http-compatibility.md | 494 ---- .../plans/2026-04-04-lite-docker-image.md | 1195 --------- .../plans/2026-04-06-edit-metadata.md | 2176 ----------------- .../2026-04-04-http-compatibility-design.md | 100 - .../2026-04-04-lite-docker-image-design.md | 182 -- .../specs/2026-04-06-edit-metadata-design.md | 202 -- 6 files changed, 4349 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-04-http-compatibility.md delete mode 100644 docs/superpowers/plans/2026-04-04-lite-docker-image.md delete mode 100644 docs/superpowers/plans/2026-04-06-edit-metadata.md delete mode 100644 docs/superpowers/specs/2026-04-04-http-compatibility-design.md delete mode 100644 docs/superpowers/specs/2026-04-04-lite-docker-image-design.md delete mode 100644 docs/superpowers/specs/2026-04-06-edit-metadata-design.md diff --git a/docs/superpowers/plans/2026-04-04-http-compatibility.md b/docs/superpowers/plans/2026-04-04-http-compatibility.md deleted file mode 100644 index 9f0d324b..00000000 --- a/docs/superpowers/plans/2026-04-04-http-compatibility.md +++ /dev/null @@ -1,494 +0,0 @@ -# HTTP/Non-Secure Context Compatibility - 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:** Make all frontend features work over plain HTTP on non-localhost addresses (LAN/NAS deployments). - -**Architecture:** Add `generateId()` and `copyToClipboard()` utilities to `apps/web/src/lib/utils.ts`, then replace all 10 call sites across 7 files. Test the utilities with Vitest. - -**Tech Stack:** TypeScript, Vitest (jsdom), `crypto.getRandomValues()`, `document.execCommand("copy")` - -**Spec:** `docs/superpowers/specs/2026-04-04-http-compatibility-design.md` - ---- - -### Task 1: Add `generateId()` utility and test - -**Files:** -- Modify: `apps/web/src/lib/utils.ts` -- Create: `tests/unit/web/utils.test.ts` - -- [ ] **Step 1: Write the test file** - -Create `tests/unit/web/utils.test.ts`: - -```ts -// @vitest-environment jsdom -import { describe, expect, it } from "vitest"; -import { generateId } from "../../../apps/web/src/lib/utils"; - -describe("generateId", () => { - it("returns a valid UUID v4 string", () => { - const id = generateId(); - expect(id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); - }); - - it("returns unique values on successive calls", () => { - const ids = new Set(Array.from({ length: 100 }, () => generateId())); - expect(ids.size).toBe(100); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm test:unit -- tests/unit/web/utils.test.ts` -Expected: FAIL - `generateId` is not exported from utils. - -- [ ] **Step 3: Implement `generateId` in utils.ts** - -Add to `apps/web/src/lib/utils.ts` after the existing `cn` function: - -```ts -export function generateId(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm test:unit -- tests/unit/web/utils.test.ts` -Expected: PASS (2 tests) - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/utils.ts tests/unit/web/utils.test.ts -git commit -m "feat: add generateId() utility for non-secure context compatibility" -``` - ---- - -### Task 2: Add `copyToClipboard()` utility and test - -**Files:** -- Modify: `apps/web/src/lib/utils.ts` -- Modify: `tests/unit/web/utils.test.ts` - -- [ ] **Step 1: Write the tests** - -Append to `tests/unit/web/utils.test.ts`: - -```ts -import { afterEach, describe, expect, it, vi } from "vitest"; -import { copyToClipboard } from "../../../apps/web/src/lib/utils"; - -describe("copyToClipboard", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("returns true when clipboard API succeeds", async () => { - Object.assign(navigator, { - clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); - expect(await copyToClipboard("hello")).toBe(true); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello"); - }); - - it("falls back to execCommand when clipboard API fails", async () => { - Object.assign(navigator, { clipboard: undefined }); - const execCommand = vi.spyOn(document, "execCommand").mockReturnValue(true); - expect(await copyToClipboard("hello")).toBe(true); - expect(execCommand).toHaveBeenCalledWith("copy"); - }); - - it("returns false when both approaches fail", async () => { - Object.assign(navigator, { clipboard: undefined }); - vi.spyOn(document, "execCommand").mockImplementation(() => { - throw new Error("not supported"); - }); - expect(await copyToClipboard("hello")).toBe(false); - }); -}); -``` - -Note: update the `import` line at the top of the file to also import `afterEach` and `vi` alongside the existing `describe, expect, it`, and import `copyToClipboard` from the same utils path. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm test:unit -- tests/unit/web/utils.test.ts` -Expected: FAIL - `copyToClipboard` is not exported from utils. - -- [ ] **Step 3: Implement `copyToClipboard` in utils.ts** - -Add to `apps/web/src/lib/utils.ts` after `generateId`: - -```ts -export async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - try { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(textarea); - return ok; - } catch { - return false; - } - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm test:unit -- tests/unit/web/utils.test.ts` -Expected: PASS (5 tests) - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/src/lib/utils.ts tests/unit/web/utils.test.ts -git commit -m "feat: add copyToClipboard() utility with execCommand fallback" -``` - ---- - -### Task 3: Replace `crypto.randomUUID()` in `use-tool-processor.ts` - -**Files:** -- Modify: `apps/web/src/hooks/use-tool-processor.ts` - -- [ ] **Step 1: Add import** - -Add `generateId` to imports at the top of `apps/web/src/hooks/use-tool-processor.ts`: - -```ts -import { generateId } from "@/lib/utils"; -``` - -- [ ] **Step 2: Replace line 91** - -Change: -```ts - const clientJobId = crypto.randomUUID(); -``` -To: -```ts - const clientJobId = generateId(); -``` - -- [ ] **Step 3: Replace line 260** - -Change: -```ts - const clientJobId = crypto.randomUUID(); -``` -To: -```ts - const clientJobId = generateId(); -``` - -- [ ] **Step 4: Verify no remaining `crypto.randomUUID` references** - -Run: `grep -n "crypto.randomUUID" apps/web/src/hooks/use-tool-processor.ts` -Expected: No output. - -- [ ] **Step 5: Run typecheck** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add apps/web/src/hooks/use-tool-processor.ts -git commit -m "fix: replace crypto.randomUUID with generateId in use-tool-processor" -``` - ---- - -### Task 4: Replace `crypto.randomUUID()` in AI tool settings - -**Files:** -- Modify: `apps/web/src/components/tools/ocr-settings.tsx` -- Modify: `apps/web/src/components/tools/erase-object-settings.tsx` - -- [ ] **Step 1: Update `ocr-settings.tsx`** - -Add import at the top: -```ts -import { generateId } from "@/lib/utils"; -``` - -Replace line 52: -```ts - const clientJobId = crypto.randomUUID(); -``` -With: -```ts - const clientJobId = generateId(); -``` - -- [ ] **Step 2: Update `erase-object-settings.tsx`** - -Add import at the top: -```ts -import { generateId } from "@/lib/utils"; -``` - -Replace line 52: -```ts - const clientJobId = crypto.randomUUID(); -``` -With: -```ts - const clientJobId = generateId(); -``` - -- [ ] **Step 3: Run typecheck** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/tools/ocr-settings.tsx apps/web/src/components/tools/erase-object-settings.tsx -git commit -m "fix: replace crypto.randomUUID with generateId in AI tool settings" -``` - ---- - -### Task 5: Replace `crypto.randomUUID()` in pipeline/automation - -**Files:** -- Modify: `apps/web/src/components/tools/pipeline-builder.tsx` -- Modify: `apps/web/src/pages/automate-page.tsx` - -- [ ] **Step 1: Update `pipeline-builder.tsx`** - -Add import at the top: -```ts -import { generateId } from "@/lib/utils"; -``` - -Replace line 104: -```ts - id: crypto.randomUUID(), -``` -With: -```ts - id: generateId(), -``` - -- [ ] **Step 2: Update `automate-page.tsx`** - -Add import at the top: -```ts -import { generateId } from "@/lib/utils"; -``` - -Replace line 147: -```ts - id: crypto.randomUUID(), -``` -With: -```ts - id: generateId(), -``` - -- [ ] **Step 3: Run typecheck** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/tools/pipeline-builder.tsx apps/web/src/pages/automate-page.tsx -git commit -m "fix: replace crypto.randomUUID with generateId in pipeline/automation" -``` - ---- - -### Task 6: Replace `navigator.clipboard` in all 4 call sites - -**Files:** -- Modify: `apps/web/src/components/settings/settings-dialog.tsx` -- Modify: `apps/web/src/components/tools/color-palette-settings.tsx` -- Modify: `apps/web/src/components/tools/ocr-settings.tsx` -- Modify: `apps/web/src/components/tools/barcode-read-settings.tsx` - -- [ ] **Step 1: Update `settings-dialog.tsx`** - -Add `copyToClipboard` to imports (the file already imports from `@/lib/utils` if `cn` is used, otherwise add a new import): -```ts -import { copyToClipboard } from "@/lib/utils"; -``` - -Replace lines 1147-1152: -```ts - const copyKey = useCallback((key: string) => { - navigator.clipboard.writeText(key).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - }, []); -``` -With: -```ts - const copyKey = useCallback(async (key: string) => { - const ok = await copyToClipboard(key); - if (ok) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }, []); -``` - -- [ ] **Step 2: Update `color-palette-settings.tsx`** - -Add import: -```ts -import { copyToClipboard } from "@/lib/utils"; -``` - -Replace lines 45-53: -```ts - const copyColor = async (color: string, idx: number) => { - try { - await navigator.clipboard.writeText(color); - setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 1500); - } catch { - // Fallback: silent fail - } - }; -``` -With: -```ts - const copyColor = async (color: string, idx: number) => { - const ok = await copyToClipboard(color); - if (ok) { - setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 1500); - } - }; -``` - -- [ ] **Step 3: Update `ocr-settings.tsx`** - -This file already has a `generateId` import from Task 4. Add `copyToClipboard` to the same import: -```ts -import { copyToClipboard, generateId } from "@/lib/utils"; -``` - -Replace lines 118-124: -```ts - const handleCopy = async () => { - if (text) { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; -``` -With: -```ts - const handleCopy = async () => { - if (text) { - const ok = await copyToClipboard(text); - if (ok) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - } - }; -``` - -- [ ] **Step 4: Update `barcode-read-settings.tsx`** - -Add import: -```ts -import { copyToClipboard } from "@/lib/utils"; -``` - -Replace lines 45-54: -```ts - const copyText = async () => { - if (!result?.text) return; - try { - await navigator.clipboard.writeText(result.text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // Fallback: silent fail - } - }; -``` -With: -```ts - const copyText = async () => { - if (!result?.text) return; - const ok = await copyToClipboard(result.text); - if (ok) { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }; -``` - -- [ ] **Step 5: Run typecheck** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 6: Commit** - -```bash -git add apps/web/src/components/settings/settings-dialog.tsx apps/web/src/components/tools/color-palette-settings.tsx apps/web/src/components/tools/ocr-settings.tsx apps/web/src/components/tools/barcode-read-settings.tsx -git commit -m "fix: replace navigator.clipboard with copyToClipboard utility" -``` - ---- - -### Task 7: Final verification - -- [ ] **Step 1: Verify no remaining direct usages** - -Run: `grep -rn "crypto\.randomUUID\|navigator\.clipboard" apps/web/src/` -Expected: No output. - -- [ ] **Step 2: Run full lint** - -Run: `pnpm lint` -Expected: PASS - -- [ ] **Step 3: Run full test suite** - -Run: `pnpm test` -Expected: All existing tests pass, plus the 5 new tests in `utils.test.ts`. - -- [ ] **Step 4: Commit any lint fixes if needed** - -```bash -git add -A -git commit -m "fix: lint fixes for http compatibility changes" -``` - -(Skip if lint passed cleanly.) diff --git a/docs/superpowers/plans/2026-04-04-lite-docker-image.md b/docs/superpowers/plans/2026-04-04-lite-docker-image.md deleted file mode 100644 index 039212ab..00000000 --- a/docs/superpowers/plans/2026-04-04-lite-docker-image.md +++ /dev/null @@ -1,1195 +0,0 @@ -# Lite Docker Image 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:** Ship a `:lite` Docker tag (~1-2 GB) that includes all Sharp-based tools but drops the Python AI/ML sidecar. - -**Architecture:** Single Dockerfile with `ARG VARIANT=full` (default). In lite mode, Python/ML packages and model downloads are skipped. The API returns 501 for AI routes, and the frontend greys out AI tools with an upgrade toast. CI publishes both `:latest` and `:lite` tags. - -**Tech Stack:** Docker multi-stage builds, Fastify, React/Zustand, sonner (toast), VitePress, GitHub Actions - -**Spec:** `docs/superpowers/specs/2026-04-04-lite-docker-image-design.md` - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|---------------| -| Modify | `packages/shared/src/constants.ts` | Add `PYTHON_SIDECAR_TOOLS` constant | -| Modify | `apps/api/src/routes/settings.ts` | Add `variant` + `variantUnavailableTools` to GET response | -| Modify | `apps/api/src/routes/tools/index.ts` | Register 501 stubs for AI tools in lite mode | -| Create | `tests/integration/lite-variant.test.ts` | Integration tests for lite mode API behavior | -| Create | `apps/web/src/stores/settings-store.ts` | Zustand store for shared settings + variant info | -| Modify | `apps/web/src/App.tsx` | Add sonner `` | -| Modify | `apps/web/src/components/common/tool-card.tsx` | AI badge, grey-out, toast on click | -| Modify | `apps/web/src/components/layout/tool-panel.tsx` | Use settings store for variant filtering | -| Modify | `apps/web/src/pages/home-page.tsx` | Grey out variant-unavailable tools | -| Modify | `apps/web/src/hooks/use-tool-processor.ts` | Import `PYTHON_SIDECAR_TOOLS` from shared | -| Modify | `docker/Dockerfile` | Add `ARG VARIANT`, conditional Python install | -| Modify | `.github/workflows/ci.yml` | Matrix for both variants in Docker smoke test | -| Modify | `.github/workflows/release.yml` | Matrix for publishing both `:latest` and `:lite` | -| Create | `apps/docs/guide/docker-tags.md` | Docs page explaining lite vs full | -| Modify | `apps/docs/.vitepress/config.mts` | Add sidebar entry for docker-tags page | - ---- - -### Task 1: Add PYTHON_SIDECAR_TOOLS Constant - -**Files:** -- Modify: `packages/shared/src/constants.ts` (append after `TOOLS` array) - -- [ ] **Step 1: Add the constant** - -At the end of `packages/shared/src/constants.ts`, after the `TOOLS` array and any other exports, add: - -```typescript -/** - * Tool IDs that require the Python sidecar (AI/ML tools). - * Used by the API to register 501 stubs in lite mode, - * and by the frontend for progress/timeout behavior. - */ -export const PYTHON_SIDECAR_TOOLS = [ - "remove-background", - "upscale", - "blur-faces", - "erase-object", - "ocr", -] as const; -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS (no errors) - -- [ ] **Step 3: Commit** - -```bash -git add packages/shared/src/constants.ts -git commit -m "feat: add PYTHON_SIDECAR_TOOLS constant to shared package" -``` - ---- - -### Task 2: API - Settings Endpoint Variant Info - -**Files:** -- Create: `tests/integration/lite-variant.test.ts` -- Modify: `apps/api/src/routes/settings.ts` - -- [ ] **Step 1: Write the failing test** - -Create `tests/integration/lite-variant.test.ts`: - -```typescript -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js"; - -describe("Lite variant", () => { - let testApp: TestApp; - let app: TestApp["app"]; - let adminToken: string; - - beforeAll(async () => { - process.env.STIRLING_VARIANT = "lite"; - testApp = await buildTestApp(); - app = testApp.app; - adminToken = await loginAsAdmin(app); - }, 30_000); - - afterAll(async () => { - delete process.env.STIRLING_VARIANT; - await testApp.cleanup(); - }, 10_000); - - describe("GET /api/v1/settings", () => { - it("includes variant and variantUnavailableTools", async () => { - const res = await app.inject({ - method: "GET", - url: "/api/v1/settings", - headers: { authorization: `Bearer ${adminToken}` }, - }); - - expect(res.statusCode).toBe(200); - const body = JSON.parse(res.body); - expect(body.variant).toBe("lite"); - expect(body.variantUnavailableTools).toEqual([ - "remove-background", - "upscale", - "blur-faces", - "erase-object", - "ocr", - ]); - }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run tests/integration/lite-variant.test.ts` -Expected: FAIL - `body.variant` is undefined - -- [ ] **Step 3: Implement variant info in settings endpoint** - -In `apps/api/src/routes/settings.ts`, add the import at the top: - -```typescript -import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared"; -``` - -Then modify the GET handler (around line 18-30). Replace the `return reply.send({ settings });` line with: - -```typescript - const variant = process.env.STIRLING_VARIANT === "lite" ? "lite" : "full"; - const variantUnavailableTools = - variant === "lite" ? [...PYTHON_SIDECAR_TOOLS] : []; - - return reply.send({ settings, variant, variantUnavailableTools }); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run tests/integration/lite-variant.test.ts` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add tests/integration/lite-variant.test.ts apps/api/src/routes/settings.ts -git commit -m "feat: include variant and variantUnavailableTools in settings response" -``` - ---- - -### Task 3: API - 501 Stubs for AI Routes in Lite Mode - -**Files:** -- Modify: `tests/integration/lite-variant.test.ts` -- Modify: `apps/api/src/routes/tools/index.ts` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/integration/lite-variant.test.ts`, inside the outer `describe("Lite variant")` block, after the settings tests: - -```typescript - describe("AI tool routes return 501", () => { - const aiTools = [ - "remove-background", - "upscale", - "blur-faces", - "erase-object", - "ocr", - ]; - - for (const toolId of aiTools) { - it(`POST /api/v1/tools/${toolId} returns 501`, async () => { - const res = await app.inject({ - method: "POST", - url: `/api/v1/tools/${toolId}`, - headers: { authorization: `Bearer ${adminToken}` }, - payload: {}, - }); - - expect(res.statusCode).toBe(501); - const body = JSON.parse(res.body); - expect(body.error).toBe("Not Available"); - expect(body.message).toContain("full image"); - }); - } - }); - - describe("Sharp tools still work in lite mode", () => { - it("POST /api/v1/tools/info returns 200 with valid image", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { fileURLToPath } = await import("node:url"); - const __dirname = join(fileURLToPath(import.meta.url), ".."); - const png = readFileSync(join(__dirname, "..", "fixtures", "test-200x150.png")); - - const boundary = "----TestBoundary"; - const body = Buffer.concat([ - Buffer.from( - `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n`, - ), - png, - Buffer.from(`\r\n--${boundary}--\r\n`), - ]); - - const res = await app.inject({ - method: "POST", - url: "/api/v1/tools/info", - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": `multipart/form-data; boundary=${boundary}`, - }, - payload: body, - }); - - expect(res.statusCode).toBe(200); - }); - }); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `pnpm vitest run tests/integration/lite-variant.test.ts` -Expected: FAIL - AI tools return something other than 501 (likely 400 or 500) - -- [ ] **Step 3: Implement 501 stubs in lite mode** - -In `apps/api/src/routes/tools/index.ts`, add the import at the top: - -```typescript -import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared"; -``` - -Then, inside the `registerToolRoutes` function, after the `skipTools` set is built (after line 67) and before the `toolRegistrations` array, add: - -```typescript - // In lite mode, register 501 stubs for AI tools instead of real handlers - const isLite = process.env.STIRLING_VARIANT === "lite"; - const liteStubTools = new Set(PYTHON_SIDECAR_TOOLS); -``` - -Then modify the registration loop (currently lines 123-131). Replace it with: - -```typescript - let skipped = 0; - let stubbed = 0; - for (const { id, register } of toolRegistrations) { - if (skipTools.has(id)) { - app.log.info(`Skipping disabled/experimental tool: ${id}`); - skipped++; - continue; - } - - if (isLite && liteStubTools.has(id)) { - // Register a 501 stub instead of the real handler - app.post(`/api/v1/tools/${id}`, async (_request, reply) => { - return reply.status(501).send({ - statusCode: 501, - error: "Not Available", - message: `The "${id}" tool requires the full image. Pull stirlingimage/stirling-image:latest for all features.`, - }); - }); - stubbed++; - continue; - } - - register(app); - } -``` - -Update the log line at the end: - -```typescript - const registered = toolRegistrations.length - skipped - stubbed; - app.log.info( - `Tool routes: ${registered} active, ${stubbed} lite-stubbed, ${skipped} skipped (${toolRegistrations.length} total)`, - ); -``` - -Also remove the individual AI tool imports that are no longer needed in lite mode. Wrap them in a conditional dynamic import. Replace the static AI imports (lines 6, 17, 22, 25, 34) with lazy registration. Change the `toolRegistrations` array entries for AI tools to use `register: () => {}` as placeholders, and instead do the actual registration conditionally. - -Simpler approach: keep the static imports. They are just JavaScript module imports that don't trigger Python. The bridge only spawns Python on actual request. The 501 stub intercepts before the real handler runs, so the imports are harmless. The only cost is a few KB of JS loaded but never executed. - -**Keep the existing imports as-is. No changes needed to the import block.** - -- [ ] **Step 4: Run test to verify it passes** - -Run: `pnpm vitest run tests/integration/lite-variant.test.ts` -Expected: PASS - -- [ ] **Step 5: Run full integration suite to ensure no regressions** - -Run: `pnpm test:integration` -Expected: All tests PASS - -- [ ] **Step 6: Commit** - -```bash -git add tests/integration/lite-variant.test.ts apps/api/src/routes/tools/index.ts -git commit -m "feat: register 501 stubs for AI tools in lite mode" -``` - ---- - -### Task 4: Frontend - Install Sonner and Create Settings Store - -**Files:** -- Modify: `apps/web/package.json` (via pnpm add) -- Modify: `apps/web/src/App.tsx` -- Create: `apps/web/src/stores/settings-store.ts` - -- [ ] **Step 1: Install sonner** - -Run: `pnpm --filter @stirling-image/web add sonner` - -- [ ] **Step 2: Add Toaster to App.tsx** - -In `apps/web/src/App.tsx`, add the import at the top: - -```typescript -import { Toaster } from "sonner"; -``` - -Inside the `App` component's return, add `` after `` and before ``: - -```typescript -export function App() { - return ( - - - -``` - -- [ ] **Step 3: Create settings store** - -Create `apps/web/src/stores/settings-store.ts`: - -```typescript -import { create } from "zustand"; -import { apiGet } from "@/lib/api"; - -interface SettingsState { - variant: "full" | "lite"; - variantUnavailableTools: string[]; - disabledTools: string[]; - experimentalEnabled: boolean; - loaded: boolean; - fetch: () => Promise; -} - -export const useSettingsStore = create((set, get) => ({ - variant: "full", - variantUnavailableTools: [], - disabledTools: [], - experimentalEnabled: false, - loaded: false, - - fetch: async () => { - if (get().loaded) return; - try { - const data = await apiGet<{ - settings: Record; - variant: "full" | "lite"; - variantUnavailableTools: string[]; - }>("/v1/settings"); - - set({ - variant: data.variant ?? "full", - variantUnavailableTools: data.variantUnavailableTools ?? [], - disabledTools: data.settings.disabledTools - ? JSON.parse(data.settings.disabledTools) - : [], - experimentalEnabled: data.settings.enableExperimentalTools === "true", - loaded: true, - }); - } catch { - // Settings fetch failed - default to full with no disabled tools - set({ loaded: true }); - } - }, -})); -``` - -- [ ] **Step 4: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 5: Commit** - -```bash -git add apps/web/package.json apps/web/src/App.tsx apps/web/src/stores/settings-store.ts pnpm-lock.yaml -git commit -m "feat: add sonner toast and settings store for variant support" -``` - ---- - -### Task 5: Frontend - Update ToolCard for Variant-Unavailable Tools - -**Files:** -- Modify: `apps/web/src/components/common/tool-card.tsx` - -- [ ] **Step 1: Update ToolCard to accept variantUnavailable prop** - -Replace the entire content of `apps/web/src/components/common/tool-card.tsx`: - -```typescript -import type { Tool } from "@stirling-image/shared"; -import * as icons from "lucide-react"; -import { FileImage, Sparkles, Star } from "lucide-react"; -import { Link } from "react-router-dom"; -import { toast } from "sonner"; -import { cn } from "@/lib/utils"; - -interface ToolCardProps { - tool: Tool; - variantUnavailable?: boolean; -} - -export function ToolCard({ tool, variantUnavailable }: ToolCardProps) { - const iconsMap = icons as unknown as Record>; - const IconComponent = iconsMap[tool.icon] || FileImage; - - if (variantUnavailable) { - return ( -
- - -
- ); - } - - return ( -
- - - - {tool.name} - {tool.experimental && ( - - Experimental - - )} - -
- ); -} -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/common/tool-card.tsx -git commit -m "feat: ToolCard shows AI badge and upgrade toast for variant-unavailable tools" -``` - ---- - -### Task 6: Frontend - Update ToolPanel to Use Settings Store - -**Files:** -- Modify: `apps/web/src/components/layout/tool-panel.tsx` - -- [ ] **Step 1: Replace local state with settings store** - -Replace the entire content of `apps/web/src/components/layout/tool-panel.tsx`: - -```typescript -import { CATEGORIES, TOOLS } from "@stirling-image/shared"; -import { useEffect, useMemo, useState } from "react"; -import { SearchBar } from "../common/search-bar"; -import { ToolCard } from "../common/tool-card"; -import { useSettingsStore } from "@/stores/settings-store"; - -export function ToolPanel() { - const [search, setSearch] = useState(""); - const { disabledTools, experimentalEnabled, variantUnavailableTools, loaded, fetch } = - useSettingsStore(); - - useEffect(() => { - fetch(); - }, [fetch]); - - const unavailableSet = useMemo( - () => new Set(variantUnavailableTools), - [variantUnavailableTools], - ); - - const visibleTools = useMemo(() => { - if (!loaded) return []; - return TOOLS.filter((t) => { - if (disabledTools.includes(t.id)) return false; - if (t.experimental && !experimentalEnabled) return false; - return true; - }); - }, [disabledTools, experimentalEnabled, loaded]); - - const filteredTools = useMemo(() => { - if (!search) return visibleTools; - const q = search.toLowerCase(); - return visibleTools.filter( - (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q), - ); - }, [search, visibleTools]); - - const groupedTools = useMemo(() => { - const groups = new Map(); - for (const tool of filteredTools) { - const list = groups.get(tool.category) || []; - list.push(tool); - groups.set(tool.category, list); - } - return groups; - }, [filteredTools]); - - return ( -
-
- -
-
- {CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => ( -
-

- {category.name} -

-
- {groupedTools.get(category.id)?.map((tool) => ( - - ))} -
-
- ))} - {filteredTools.length === 0 && ( -

No tools found

- )} -
-
- ); -} -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/components/layout/tool-panel.tsx -git commit -m "feat: ToolPanel uses settings store for variant-aware tool filtering" -``` - ---- - -### Task 7: Frontend - Update HomePage for Variant-Unavailable Tools - -**Files:** -- Modify: `apps/web/src/pages/home-page.tsx` - -- [ ] **Step 1: Add variant awareness to HomePage** - -In `apps/web/src/pages/home-page.tsx`, add the import near the top: - -```typescript -import { toast } from "sonner"; -import { useSettingsStore } from "@/stores/settings-store"; -``` - -Inside the `HomePage` component, after the existing hooks (`useFileStore`, `useNavigate`), add: - -```typescript - const { variantUnavailableTools, fetch: fetchSettings } = useSettingsStore(); - - useEffect(() => { - fetchSettings(); - }, [fetchSettings]); - - const unavailableSet = useMemo( - () => new Set(variantUnavailableTools), - [variantUnavailableTools], - ); -``` - -Add `useEffect` and `useMemo` to the existing import from `react`: - -```typescript -import { useCallback, useEffect, useMemo } from "react"; -``` - -Modify `handleToolClick` to check for variant-unavailable tools: - -```typescript - const handleToolClick = (route: string, toolId: string) => { - if (unavailableSet.has(toolId)) { - toast("This tool requires the full image.", { - description: - "Pull stirlingimage/stirling-image:latest for all features including AI tools.", - action: { - label: "Learn more", - onClick: () => - window.open( - "https://stirling-image.github.io/stirling-image/guide/docker-tags", - "_blank", - ), - }, - }); - return; - } - navigate(route); - }; -``` - -Update the quick actions button `onClick` (around line 83): - -```typescript -onClick={() => handleToolClick(tool.route, tool.id)} -``` - -Add opacity styling to quick action buttons for unavailable tools (around line 84): - -```typescript -className={cn( - "flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left", - unavailableSet.has(id) && "opacity-50", -)} -``` - -Update the "All Tools" section button `onClick` (around line 125): - -```typescript -onClick={() => handleToolClick(tool.route, tool.id)} -``` - -Add opacity styling to the all-tools buttons for unavailable tools (around line 126-129): - -```typescript -className={cn( - "flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors", - unavailableSet.has(tool.id) - ? "opacity-50 hover:bg-muted/50" - : "hover:bg-muted text-foreground", -)} -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 3: Commit** - -```bash -git add apps/web/src/pages/home-page.tsx -git commit -m "feat: HomePage greys out variant-unavailable tools with upgrade toast" -``` - ---- - -### Task 8: Frontend - Update use-tool-processor to Use Shared Constant - -**Files:** -- Modify: `apps/web/src/hooks/use-tool-processor.ts` - -- [ ] **Step 1: Replace hardcoded set with shared constant** - -In `apps/web/src/hooks/use-tool-processor.ts`, add the import at the top: - -```typescript -import { PYTHON_SIDECAR_TOOLS } from "@stirling-image/shared"; -``` - -Replace lines 30-38 (the `AI_PYTHON_TOOLS` definition): - -```typescript -// AI tools that go through Python/bridge.ts and can emit SSE progress. -// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded. -const AI_PYTHON_TOOLS = new Set([ - "remove-background", - "upscale", - "blur-faces", - "erase-object", - "ocr", -]); -``` - -With: - -```typescript -// AI tools that go through Python/bridge.ts and can emit SSE progress. -// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded. -const AI_PYTHON_TOOLS = new Set(PYTHON_SIDECAR_TOOLS); -``` - -- [ ] **Step 2: Verify typecheck passes** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 3: Run linter** - -Run: `pnpm lint` -Expected: PASS (no unused imports, formatting OK) - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/hooks/use-tool-processor.ts -git commit -m "refactor: use shared PYTHON_SIDECAR_TOOLS constant in use-tool-processor" -``` - ---- - -### Task 9: Dockerfile - Add VARIANT Build Arg - -**Files:** -- Modify: `docker/Dockerfile` - -- [ ] **Step 1: Add build arg and conditional Python install** - -At the very top of `docker/Dockerfile`, after the comment header (line 5) and before Stage 1, add: - -```dockerfile -ARG VARIANT=full -``` - -In the production stage (after line 41 `FROM node:22-bookworm AS production`), re-declare the arg: - -```dockerfile -ARG VARIANT -``` - -Replace the system dependencies block (lines 46-57) with: - -```dockerfile -# System dependencies shared by all variants -RUN apt-get update && apt-get install -y --no-install-recommends \ - imagemagick \ - libraw-dev \ - potrace \ - curl \ - gosu \ - libheif-examples \ - && rm -rf /var/lib/apt/lists/* - -# Python/ML system dependencies (full variant only) -RUN if [ "$VARIANT" = "full" ]; then \ - apt-get update && apt-get install -y --no-install-recommends \ - python3 python3-pip python3-venv python3-dev \ - tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \ - build-essential \ - libgl1 libglib2.0-0 \ - && rm -rf /var/lib/apt/lists/* \ -; fi -``` - -Replace the Python venv and ML install block (lines 59-88) with: - -```dockerfile -# Python venv + ML packages + model weights (full variant only) -COPY packages/ai/python/requirements.txt /tmp/requirements.txt -RUN if [ "$VARIANT" = "full" ]; then \ - python3 -m venv /opt/venv && \ - /opt/venv/bin/pip install --upgrade pip && \ - /opt/venv/bin/pip install \ - Pillow numpy opencv-python-headless onnxruntime && \ - (/opt/venv/bin/pip install rembg[cpu] || echo "WARNING: rembg not installed") && \ - (/opt/venv/bin/pip install realesrgan || echo "WARNING: realesrgan not installed") && \ - (/opt/venv/bin/pip install paddlepaddle paddleocr || echo "WARNING: PaddleOCR not installed") && \ - (/opt/venv/bin/pip install mediapipe || echo "WARNING: mediapipe not installed") && \ - (/opt/venv/bin/pip install lama-cleaner || echo "WARNING: lama-cleaner not installed") \ -; fi && rm -f /tmp/requirements.txt - -COPY docker/download_models.py /tmp/download_models.py -RUN if [ "$VARIANT" = "full" ]; then \ - /opt/venv/bin/python3 /tmp/download_models.py && \ - /opt/venv/bin/python3 -c "\ -try: \ - from paddleocr import PaddleOCR; \ - print('Downloading PaddleOCR models...'); \ - ocr = PaddleOCR(use_angle_cls=True, lang='en', show_log=False); \ - print('PaddleOCR models ready'); \ -except: print('PaddleOCR model pre-download skipped') \ -" 2>/dev/null || echo "WARNING: Could not pre-download PaddleOCR models" \ -; fi && rm -f /tmp/download_models.py -``` - -Replace the build-essential cleanup block (lines 108-109) with: - -```dockerfile -RUN if [ "$VARIANT" = "full" ]; then \ - apt-get purge -y --auto-remove build-essential python3-dev && \ - rm -rf /var/lib/apt/lists/* \ -; fi -``` - -Add the variant env var to the ENV block (after `RATE_LIMIT_PER_MIN=100` on line 146): - -```dockerfile - STIRLING_VARIANT=${VARIANT} -``` - -Update the `chown` line to handle the case where `/opt/venv` doesn't exist in lite mode. Replace line 150: - -```dockerfile -RUN chown -R stirling:stirling /app /data /tmp/workspace && \ - ([ -d /opt/venv ] && chown -R stirling:stirling /opt/venv || true) -``` - -- [ ] **Step 2: Test lite build locally** - -Run: `docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-test .` -Expected: Build succeeds. No Python installation steps in the output. - -- [ ] **Step 3: Test full build still works** - -Run: `docker build -f docker/Dockerfile -t stirling-image:full-test .` -Expected: Build succeeds with Python/ML installation as before. - -- [ ] **Step 4: Verify lite image is smaller** - -Run: `docker images | grep stirling-image` -Expected: `lite-test` is ~1-2 GB, `full-test` is ~11 GB. - -- [ ] **Step 5: Commit** - -```bash -git add docker/Dockerfile -git commit -m "feat: add VARIANT build arg to Dockerfile for lite image support" -``` - ---- - -### Task 10: CI - Add Lite Variant Smoke Test - -**Files:** -- Modify: `.github/workflows/ci.yml` - -- [ ] **Step 1: Add matrix to docker job** - -Replace the docker job in `.github/workflows/ci.yml` (lines 82-98) with: - -```yaml - docker: - name: Docker Build Test (${{ matrix.variant }}) - runs-on: ubuntu-latest - strategy: - matrix: - variant: [full, lite] - steps: - - uses: actions/checkout@v4 - - - uses: docker/setup-buildx-action@v3 - - - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - push: false - build-args: VARIANT=${{ matrix.variant }} - tags: stirling-image:ci-${{ matrix.variant }} - cache-from: type=gha,scope=${{ matrix.variant }} - cache-to: type=gha,mode=max,scope=${{ matrix.variant }} -``` - -- [ ] **Step 2: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: add matrix to build both full and lite Docker variants" -``` - ---- - -### Task 11: Release - Matrix for Publishing Both Variants - -**Files:** -- Modify: `.github/workflows/release.yml` - -- [ ] **Step 1: Replace single docker job with matrix** - -Replace the entire `docker` job in `.github/workflows/release.yml` (lines 51-105) with: - -```yaml - docker: - name: Docker (${{ matrix.variant }}) - needs: release - if: needs.release.outputs.new_version != '' - runs-on: ubuntu-latest - strategy: - matrix: - variant: [full, lite] - include: - - variant: full - suffix: "" - - variant: lite - suffix: "-lite" - steps: - - name: Checkout release tag - uses: actions/checkout@v4 - with: - ref: v${{ needs.release.outputs.new_version }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - stirlingimage/stirling-image - ghcr.io/${{ github.repository }} - tags: | - type=semver,pattern={{version}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} - type=semver,pattern={{major}}.{{minor}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} - type=semver,pattern={{major}}${{ matrix.suffix }},value=v${{ needs.release.outputs.new_version }} - type=raw,value=${{ matrix.variant == 'full' && 'latest' || 'lite' }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - push: true - build-args: VARIANT=${{ matrix.variant }} - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.variant }} - cache-to: type=gha,mode=max,scope=${{ matrix.variant }} -``` - -This produces for a v1.6.0 release: - -| Variant | Tags | -|---------|------| -| full | `1.6.0`, `1.6`, `1`, `latest` | -| lite | `1.6.0-lite`, `1.6-lite`, `1-lite`, `lite` | - -- [ ] **Step 2: Commit** - -```bash -git add .github/workflows/release.yml -git commit -m "ci: publish both full and lite Docker images on release" -``` - ---- - -### Task 12: Documentation - Docker Tags Page - -**Files:** -- Create: `apps/docs/guide/docker-tags.md` -- Modify: `apps/docs/.vitepress/config.mts` - -- [ ] **Step 1: Create the docs page** - -Create `apps/docs/guide/docker-tags.md`: - -```markdown -# Docker Image Tags - -Stirling Image ships two Docker image variants to fit different use cases. - -## Full (default) - -```bash -docker pull stirlingimage/stirling-image:latest -``` - -Includes all tools: image processing, AI-powered background removal, upscaling, face blurring, object erasing, and OCR. Size is ~11 GB due to bundled ML models. - -## Lite - -```bash -docker pull stirlingimage/stirling-image:lite -``` - -Includes all image processing tools (resize, crop, rotate, convert, compress, watermark, collage, and 20+ more) but excludes AI/ML tools. Size is ~1-2 GB. - -Use this if you: -- Only need standard image processing (no AI features) -- Are running on constrained hardware (Raspberry Pi, small VPS) -- Want faster pulls and smaller disk footprint - -### Tools excluded from lite - -| Tool | What it does | -|------|-------------| -| Remove Background | AI-powered background removal | -| Upscale | AI super-resolution upscaling | -| Blur Faces | AI face detection and blurring | -| Erase Object | AI inpainting to remove objects | -| OCR | Optical character recognition | - -All other tools (27+) work identically in both variants. - -## Docker Compose - -### Full - -```yaml -services: - stirling-image: - image: stirlingimage/stirling-image:latest - ports: - - "1349:1349" - volumes: - - stirling-data:/data - - stirling-workspace:/tmp/workspace - -volumes: - stirling-data: - stirling-workspace: -``` - -### Lite - -```yaml -services: - stirling-image: - image: stirlingimage/stirling-image:lite - ports: - - "1349:1349" - volumes: - - stirling-data:/data - - stirling-workspace:/tmp/workspace - -volumes: - stirling-data: - stirling-workspace: -``` - -## Switching from lite to full - -To upgrade from lite to full and unlock AI tools: - -1. Stop your container -2. Pull the full image: `docker pull stirlingimage/stirling-image:latest` -3. Update your compose file or run command to use `:latest` instead of `:lite` -4. Start the container - -Your data and settings are preserved in the volumes. - -## Version pinning - -Both variants support semver tags for pinning: - -| Tag | Description | -|-----|------------| -| `latest` | Latest full release | -| `lite` | Latest lite release | -| `1.6.0` | Exact full version | -| `1.6.0-lite` | Exact lite version | -| `1.6` | Latest patch in 1.6.x (full) | -| `1.6-lite` | Latest patch in 1.6.x (lite) | -``` - -- [ ] **Step 2: Add sidebar entry** - -In `apps/docs/.vitepress/config.mts`, add an entry to the Guide sidebar items array (after the "Deployment" entry, around line 34): - -```typescript - { text: "Docker tags", link: "/guide/docker-tags" }, -``` - -- [ ] **Step 3: Commit** - -```bash -git add apps/docs/guide/docker-tags.md apps/docs/.vitepress/config.mts -git commit -m "docs: add Docker tags guide for full vs lite image" -``` - ---- - -### Task 13: Final Verification - -- [ ] **Step 1: Run full test suite** - -Run: `pnpm test` -Expected: All unit and integration tests PASS - -- [ ] **Step 2: Run typecheck** - -Run: `pnpm typecheck` -Expected: PASS - -- [ ] **Step 3: Run linter** - -Run: `pnpm lint` -Expected: PASS (run `pnpm lint:fix` if formatting issues) - -- [ ] **Step 4: Verify lite Docker build** - -Run: `docker build --build-arg VARIANT=lite -f docker/Dockerfile -t stirling-image:lite-verify .` -Expected: Build succeeds, no Python in image - -- [ ] **Step 5: Smoke test lite container** - -Run: `docker run --rm -d -p 1349:1349 --name si-lite stirling-image:lite-verify` - -Verify: -- Health check passes: `curl http://localhost:1349/api/v1/health` -- Settings show lite variant: `curl -H "Authorization: Bearer " http://localhost:1349/api/v1/settings | jq .variant` -- AI route returns 501: `curl -X POST http://localhost:1349/api/v1/tools/remove-background` - -Run: `docker stop si-lite` - -- [ ] **Step 6: Check image size** - -Run: `docker images stirling-image:lite-verify --format '{{.Size}}'` -Expected: ~1-2 GB diff --git a/docs/superpowers/plans/2026-04-06-edit-metadata.md b/docs/superpowers/plans/2026-04-06-edit-metadata.md deleted file mode 100644 index 9f606d22..00000000 --- a/docs/superpowers/plans/2026-04-06-edit-metadata.md +++ /dev/null @@ -1,2176 +0,0 @@ -# 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 -``` diff --git a/docs/superpowers/specs/2026-04-04-http-compatibility-design.md b/docs/superpowers/specs/2026-04-04-http-compatibility-design.md deleted file mode 100644 index 064be6ca..00000000 --- a/docs/superpowers/specs/2026-04-04-http-compatibility-design.md +++ /dev/null @@ -1,100 +0,0 @@ -# Fix HTTP/Non-Secure Context Compatibility - -**Date:** 2026-04-04 -**Issues:** [#4](https://github.com/stirling-image/stirling-image/issues/4), [#5](https://github.com/stirling-image/stirling-image/issues/5) - -## Problem - -`crypto.randomUUID()` and `navigator.clipboard.writeText()` are secure-context-only Web APIs. They throw or are undefined when the app is accessed over plain HTTP on non-localhost addresses (e.g., `http://192.168.1.x:1349`). This breaks all tool operations and copy-to-clipboard functionality. - -Stirling-Image is a self-hosted tool where many users deploy on NAS/homelab devices over plain HTTP. This must be a first-class supported deployment mode. - -## Solution - -### Part 1: `generateId()` utility - -Add to `apps/web/src/lib/utils.ts`: - -```ts -export function generateId(): string { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; -} -``` - -Produces a standard UUID v4 string using `crypto.getRandomValues()`, which is available in all modern browsers regardless of secure context. - -**6 call sites replaced** (`crypto.randomUUID()` -> `generateId()`): - -| File | Line | Purpose | -|------|------|---------| -| `apps/web/src/hooks/use-tool-processor.ts` | 91 | Single-file job correlation ID | -| `apps/web/src/hooks/use-tool-processor.ts` | 260 | Batch job correlation ID | -| `apps/web/src/components/tools/ocr-settings.tsx` | 52 | OCR job ID | -| `apps/web/src/components/tools/erase-object-settings.tsx` | 52 | Erase object job ID | -| `apps/web/src/components/tools/pipeline-builder.tsx` | 104 | Pipeline step ID | -| `apps/web/src/pages/automate-page.tsx` | 147 | Automation step ID | - -### Part 2: `copyToClipboard()` utility - -Add to `apps/web/src/lib/utils.ts`: - -```ts -export async function copyToClipboard(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // Fallback for non-secure contexts (HTTP on LAN) - // document.execCommand is deprecated but works in all current browsers - // and does not require a secure context - try { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(textarea); - return ok; - } catch { - return false; - } - } -} -``` - -Tries the Clipboard API first (works on HTTPS/localhost). Falls back to `document.execCommand("copy")` which is deprecated but works in all current browsers including non-secure contexts. This is the standard clipboard compatibility pattern used by GitHub, Stack Overflow, etc. Returns `true`/`false` so callers can decide whether to show a "Copied!" confirmation. Only returns `false` if both approaches fail. - -**4 call sites replaced** (`navigator.clipboard.writeText()` -> `copyToClipboard()`): - -| File | Line | Current error handling | -|------|------|-----------------------| -| `apps/web/src/components/settings/settings-dialog.tsx` | 1148 | None - needs wrapping | -| `apps/web/src/components/tools/color-palette-settings.tsx` | 47 | Has try-catch, simplify | -| `apps/web/src/components/tools/ocr-settings.tsx` | 120 | None - needs wrapping | -| `apps/web/src/components/tools/barcode-read-settings.tsx` | 48 | Has try-catch, simplify | - -## What does NOT change - -- Server-side code: Node's `crypto.randomUUID()` works fine outside browsers -- `vitest.config.ts`: Runs in Node, not a browser -- No new dependencies added -- No config file changes - -## Testing - -- `pnpm typecheck` - verify all imports resolve -- `pnpm lint` - verify Biome formatting -- `pnpm test` - catch regressions -- Manual: access over HTTP on a non-localhost address, confirm tools work - -## Alternatives considered - -1. **`uuid` npm package** - rejected, adds a dependency for 5 lines of code that does the same thing internally -2. **Try-catch fallback wrapper** (try native `randomUUID()`, fall back to `getRandomValues()`) - rejected, two code paths for zero meaningful performance benefit diff --git a/docs/superpowers/specs/2026-04-04-lite-docker-image-design.md b/docs/superpowers/specs/2026-04-04-lite-docker-image-design.md deleted file mode 100644 index 8e46d4ca..00000000 --- a/docs/superpowers/specs/2026-04-04-lite-docker-image-design.md +++ /dev/null @@ -1,182 +0,0 @@ -# Lightweight Docker Image Without AI/ML Tools - -**Date:** 2026-04-04 -**Issue:** stirling-image/stirling-image#1 -**Status:** Design approved - -## Problem - -The full Docker image is ~11 GB, mostly Python ML dependencies (rembg, RealESRGAN, PaddleOCR, MediaPipe, LaMa) and pre-downloaded model weights. Users on constrained hardware (Raspberry Pi, small VPS) or those who only need image processing tools are paying for size they don't use. First community feedback on r/selfhosted flagged this. - -## Solution - -Ship a `:lite` Docker tag that drops the Python sidecar and all ML dependencies. Keep every Sharp-based tool. Target size: 1-2 GB. - -## Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Build strategy | Single Dockerfile, `ARG VARIANT=full` | One file to maintain. Avoids drift between two Dockerfiles. | -| Detection mechanism | Build-time `ENV STIRLING_VARIANT` | Explicit, instant, testable. No startup probing. | -| API behavior (lite) | AI routes return 501 | Clear signal vs confusing 404. Tells consumers what to do. | -| Frontend-API bridge | Extend `/v1/settings` response | Reuses existing fetch. Avoids extra endpoint complexity. | -| Frontend UX | Grey out AI tools + "AI" badge + toast on click | Users see what they're missing. Toast links to docs for upgrade path. | -| Tag naming | `:lite` / `:latest` (full) | "lite" = fewer features (accurate). "slim" = smaller OS base (misleading in Docker convention). | -| Feature scope | All Sharp tools stay, 5 Python tools dropped | Sharp tools add zero meaningful size. All savings come from Python. | -| Shared constants | `PYTHON_SIDECAR_TOOLS` in `packages/shared/` | Single source of truth for AI tool IDs across API and frontend. | - -## Architecture - -### Dockerfile (`docker/Dockerfile`) - -A build arg controls the variant, defaulting to `full`: - -```dockerfile -ARG VARIANT=full -``` - -In the production stage, the Python installation block (system packages, venv, pip installs, model downloads) is wrapped in a shell conditional: - -```dockerfile -ARG VARIANT -RUN if [ "$VARIANT" = "full" ]; then \ - apt-get install -y python3 python3-pip python3-venv python3-dev \ - tesseract-ocr tesseract-ocr-deu tesseract-ocr-fra \ - tesseract-ocr-spa tesseract-ocr-chi-sim \ - build-essential libgl1 libglib2.0-0 && \ - python3 -m venv /opt/venv && \ - /opt/venv/bin/pip install ... && \ - python3 docker/download_models.py && \ - ... model downloads ... \ -; fi -``` - -A runtime env var is set from the build arg: - -```dockerfile -ENV STIRLING_VARIANT=${VARIANT} -``` - -Packages kept in both variants (used by Sharp-based tools): imagemagick, libraw-dev, potrace, libheif-examples, gosu. - -Packages dropped in lite: python3, python3-pip, python3-venv, python3-dev, tesseract-ocr (+ language packs), build-essential, libgl1, libglib2.0-0. - -Base image stays `node:22-bookworm` for both variants. Switching lite to `bookworm-slim` is a future optimization, not in scope for the first pass. - -Building the lite image: `docker build --build-arg VARIANT=lite -t stirling-image:lite .` - -### Shared Constants (`packages/shared/`) - -A new constant in `packages/shared/src/constants.ts`: - -```typescript -export const PYTHON_SIDECAR_TOOLS = [ - "remove-background", - "upscale", - "blur-faces", - "erase-object", - "ocr", -] as const; -``` - -This replaces the hardcoded `AI_PYTHON_TOOLS` set in `apps/web/src/hooks/use-tool-processor.ts` and is used by the API for route registration and settings response. - -### API Changes (`apps/api/`) - -**Route registration** (`apps/api/src/routes/tools/index.ts`): - -When `STIRLING_VARIANT === "lite"`, the 5 AI tool routes are registered as lightweight stub handlers returning 501. The `@stirling-image/ai` package is not imported at all in lite mode (conditional import), avoiding any accidental Python spawn attempt. - -```typescript -if (process.env.STIRLING_VARIANT !== "lite") { - // Register actual AI tool routes (import @stirling-image/ai) -} else { - // Register stub routes returning 501 for each PYTHON_SIDECAR_TOOLS entry -} -``` - -The 501 response: - -```json -{ - "statusCode": 501, - "error": "Not Available", - "message": "This tool requires the full image. See docs at " -} -``` - -**Settings endpoint** (`/v1/settings` response): - -Two new fields, derived at startup from `process.env.STIRLING_VARIANT` and the shared constant. Not stored in the database. - -```json -{ - "...existing settings...", - "variant": "lite", - "variantUnavailableTools": ["remove-background", "upscale", "blur-faces", "erase-object", "ocr"] -} -``` - -In `full` mode: `variant: "full"`, `variantUnavailableTools: []`. - -### Frontend Changes (`apps/web/`) - -**Settings state**: Variant info is fetched once via a shared Zustand store (or shared hook) so both `ToolPanel` and `HomePage` access it without duplicate requests. - -**Tool rendering**: Tools listed in `variantUnavailableTools` are rendered greyed out with an "AI" badge. Clicking shows a toast: "This tool requires the full image" with a link to the docs page. - -This is distinct from user-disabled tools (`disabledTools`), which are hidden entirely with no toast. - -**`use-tool-processor.ts`**: Replaces the hardcoded `AI_PYTHON_TOOLS` set with the shared `PYTHON_SIDECAR_TOOLS` constant import. - -### CI/CD Changes (`.github/workflows/release.yml`) - -The Docker build job uses a matrix strategy: - -```yaml -strategy: - matrix: - variant: [full, lite] -``` - -Tags published per variant: - -| Variant | Tags | -|---------|------| -| full | `latest`, `1.6.0`, `1.6`, `1` | -| lite | `lite`, `1.6.0-lite`, `1.6-lite`, `1-lite` | - -Both variants are multi-arch (`linux/amd64,linux/arm64`) and pushed to Docker Hub (`stirlingimage/stirling-image`) and GHCR (`ghcr.io/stirling-image/stirling-image`). - -The CI workflow (`ci.yml`) also builds both variants as a smoke test (build only, no push). - -### Documentation (`apps/docs/`) - -A new docs page covering: - -- What the lite image is and why it exists -- Which tools are included vs excluded (the 5 AI tools) -- Pull commands: `docker pull stirlingimage/stirling-image:lite` -- Docker Compose examples for both variants -- How to switch from lite to full when AI tools are needed - -This is the page linked from the frontend toast and the 501 API response. - -## Tools by Variant - -### Included in lite (all Sharp-based, ~27 tools) - -resize, crop, rotate, convert, compress, strip-metadata, color-adjustments, watermark-text, watermark-image, text-overlay, compose, info, compare, find-duplicates, color-palette, qr-generate, barcode-read, collage, split, border, svg-to-raster, vectorize, gif-tools, bulk-rename, favicon, image-to-pdf, replace-color, smart-crop - -### Excluded from lite (Python sidecar required, 5 tools) - -remove-background, upscale, blur-faces, erase-object, ocr - -## Testing - -- Build both variants in CI and verify they start successfully -- Verify lite image does not contain Python, pip, or model weights -- Verify AI routes return 501 in lite mode -- Verify frontend shows greyed-out AI tools with correct toast in lite mode -- Verify all Sharp-based tools work identically in both variants -- Verify lite image size is in the 1-2 GB target range diff --git a/docs/superpowers/specs/2026-04-06-edit-metadata-design.md b/docs/superpowers/specs/2026-04-06-edit-metadata-design.md deleted file mode 100644 index 8170d7fa..00000000 --- a/docs/superpowers/specs/2026-04-06-edit-metadata-design.md +++ /dev/null @@ -1,202 +0,0 @@ -# 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