From 9e1e664ab6e27f7888cbada7a1293b297facf4f0 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:41:16 +0800 Subject: [PATCH 01/14] docs: add image location reader refactor design --- ...2026-07-12-image-location-reader-design.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-image-location-reader-design.md diff --git a/docs/superpowers/specs/2026-07-12-image-location-reader-design.md b/docs/superpowers/specs/2026-07-12-image-location-reader-design.md new file mode 100644 index 0000000..e46a919 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-image-location-reader-design.md @@ -0,0 +1,284 @@ +# Image Location Reader Refactor Design + +Date: 2026-07-12 +Branch: refactor/image-location-reader +Status: ready for written-spec review + +## Context + +The current selection flow mixes three concerns in `useEagleSelection`: + +- Eagle selection access. +- Binary file fetching from `item.fileURL`. +- EXIF GPS parsing and UI load state updates. + +The current EXIF parser in `src/lib/location.ts` also uses a shallow chain: + +- `ArrayBuffer` to binary string. +- `piexif-ts` load/dump. +- `Buffer` conversion. +- `exif-reader` parsing. +- local GPS conversion helpers. + +That makes the reader harder to test, keeps Node-oriented details in browser code, and spreads state and race handling into the React hook. + +## Goals + +- Make "read location from a file URL" an independent module. +- Keep the UI text, interactions, loading behavior, and map behavior unchanged. +- Replace the current parser chain with `exifr`, which has been verified in this repo with Vite dev, Chrome headless, and a Vite production build. +- Give React a single discriminated state value instead of parallel `state`, `coordinates`, and `errorMessage` state. +- Make stale async results harmless when selection changes, React StrictMode re-runs effects, or the hook unmounts. +- Add focused tests at the module interfaces. + +## Non-Goals + +- No visual redesign. +- No new coordinate formatting or validation policy. +- No Eagle API feature expansion beyond current selected-item loading. +- No map rendering changes. +- No persistence, caching, or background indexing. + +## Module Design + +### `src/lib/image-location-reader.ts` + +This is the deep module for reading GPS coordinates from an image resource URL. + +Primary interface: + +```ts +export interface ReadImageLocationOptions { + signal?: AbortSignal; +} + +export async function readImageLocation( + sourceUrl: string, + options?: ReadImageLocationOptions, +): Promise; +``` + +Behavior: + +- Fetches the resource with `cache: "no-store"`. +- Throws on non-OK HTTP responses and fetch/read failures. +- Parses GPS metadata from the fetched `ArrayBuffer`. +- Returns `Coordinates` when latitude and longitude are present. +- Returns `null` for a valid image that has no complete GPS location. +- Preserves altitude when present, including negative altitude if the metadata marks it below sea level. +- Honors `AbortSignal` during the fetch/read stage. + +`exifr` usage: + +Use `exifr.parse`, not `exifr.gps`, because this app needs altitude. The tested option shape is: + +```ts +pick: [ + "GPSLatitude", + "GPSLatitudeRef", + "GPSLongitude", + "GPSLongitudeRef", + "GPSAltitude", + "GPSAltitudeRef", +] +``` + +Do not pick `latitude` and `longitude` directly. They are derived fields in `exifr`; selecting only those fields did not trigger the source GPS fields in the browser verification. + +Internal test interface: + +The production path exposes `readImageLocation(sourceUrl, options)`. Tests that simulate read failure or abort behavior without global fetch patching use a small module-local factory: + +```ts +type BinaryReader = ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; + +export function createImageLocationReader( + readBinary: BinaryReader, +): typeof readImageLocation; +``` + +The app imports only `readImageLocation`. + +### `src/lib/selection-location-loader.ts` + +This module translates "current Eagle selection" into a domain result. It should know about the minimal selected item shape, not the full Eagle `Item`. + +Primary app interface: + +```ts +export type SelectedImage = Pick; + +export type SelectionLocationResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: Coordinates }; + +export async function loadSelectionLocation( + selection: readonly SelectedImage[], + options?: { signal?: AbortSignal }, +): Promise; +``` + +Internal test interface: + +```ts +type ReadImageLocation = ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; + +export function createSelectionLocationLoader( + readLocation: ReadImageLocation, +): typeof loadSelectionLocation; +``` + +The app imports only `loadSelectionLocation`. Tests use `createSelectionLocationLoader` to avoid coupling loader behavior to fetch or EXIF parsing. + +Behavior: + +- Uses only the first selected item, matching current plugin behavior and `multiSelect: false`. +- Returns `no-selection` when the selection is empty. +- Calls `readImageLocation(item.fileURL, { signal })` for the selected image. +- Converts `null` from the reader into `no-gps`. +- Lets read/parse exceptions propagate to the hook, where they become UI error state. + +### `src/hooks/use-eagle-selection.ts` + +The hook remains the bridge between Eagle events and React state. + +Internal state: + +```ts +export type SelectionLocationState = + | { status: "loading" } + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: Coordinates } + | { status: "error"; message: string }; +``` + +React behavior: + +- Set `loading` before each new selection load, preserving current visible behavior. +- Create an `AbortController` per request. +- Abort the previous request before starting the next one. +- Keep a request generation counter so a completed older request cannot overwrite a newer state. +- On unmount, abort the current request and block future state updates. +- Treat `AbortError` from an obsolete request as silent. +- Log the original unexpected error and expose the same safe message style through UI state. + +The returned hook shape can temporarily preserve the existing app contract if that keeps the UI diff small: + +```ts +return { + state: current.status, + coordinates: current.status === "ready" ? current.coordinates : null, + errorMessage: current.status === "error" ? current.message : "", +}; +``` + +If the app consumer is updated to consume the discriminated union directly, the rendered text and behavior must stay unchanged. + +## Data Flow + +```text +Eagle item + fileURL + | + v +selection-location-loader + selected image URL + | + v +image-location-reader + fetch resource -> ArrayBuffer -> exifr GPS metadata + | + v +Coordinates | null + | + v +use-eagle-selection + SelectionLocationState + | + v +React UI +``` + +## Web and Eagle Constraints + +- `exifr@7.1.3` provides an ESM browser entry and was verified with this repo's Vite setup. +- Browser verification used `fetch` to load the existing JPG fixture, converted it to `ArrayBuffer`, and parsed it with `exifr.parse`. +- The verified browser result contained latitude, longitude, and altitude. +- Generic browser support for `file:` URLs remains inconsistent. This refactor does not broaden that surface; it preserves the current Eagle behavior of fetching `item.fileURL`. +- The Vite mock path continues to use an HTTP-served fixture URL, so local web development remains supported. + +## Error Handling + +- Empty selection: `no-selection`. +- Valid image without complete GPS: `no-gps`. +- Fetch non-OK, fetch rejection, malformed metadata parser failure: `error`. +- Obsolete aborted request: no UI transition. +- Latest request abort caused by unmount: no UI transition. +- Unexpected non-Error throw: convert to `"Unexpected error"` for UI. + +## Dependency Changes + +After migration, remove dependencies that are no longer used: + +- `buffer` +- `exif-reader` +- `piexif-ts` + +Keep `exifr`. + +Remove obsolete exported helpers from `src/lib/location.ts` if no call sites remain: + +- `convertDecimalToGps` +- `formatGpsTime` +- `formatGpsCoordinate` + +## Testing Strategy + +Use TDD for implementation. + +Reader tests: + +- Existing JPG fixture returns the same latitude, longitude, and altitude. +- GPS-less thumbnail PNG returns `null`. +- Fetch/read failure rejects. +- Abort during fetch rejects with abort behavior and does not parse. + +Loader tests: + +- Empty selection returns `no-selection`. +- Selected image with GPS returns `ready`. +- Selected image without GPS returns `no-gps`. +- Reader exception propagates. +- The loader needs only `{ fileURL }`, not full Eagle item data. + +Hook tests: + +- Initial load moves through `loading` into the resolved state. +- Newer request wins over stale older request. +- Abort errors from obsolete requests do not render `error`. +- Unmount prevents state updates after an in-flight request resolves. +- StrictMode-style double effect does not leave duplicate state writes visible to the UI. + +Verification commands: + +- `pnpm test` +- `pnpm run type-check` +- `pnpm run lint:check` +- `pnpm run build` + +## Acceptance Criteria + +- The app renders the same user-facing states as before. +- The location reader can be understood and tested without reading the React hook. +- The React hook no longer performs binary file reading or EXIF parsing directly. +- Selection changes cannot be overwritten by stale async results. +- Production build does not require Node `Buffer` polyfills for location parsing. +- The old parser dependencies are removed when unused. From 7e631f71bc97abeacb10f94ce5daa9c9a548a22d Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:06:50 +0800 Subject: [PATCH 02/14] docs: add image location reader implementation plan --- ...26-07-12-image-location-reader-refactor.md | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md diff --git a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md new file mode 100644 index 0000000..f24d58f --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md @@ -0,0 +1,468 @@ +# Image Location Reader Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move image location reading into independent modules, replace the old EXIF parser chain with `exifr`, and make React selection state race-safe without changing user-facing UI behavior. + +**Architecture:** `image-location-reader` owns resource reading and GPS parsing behind `readImageLocation(sourceUrl, options)`. `selection-location-loader` converts selected `{ fileURL }` values into domain results. `use-eagle-selection` stays as the Eagle/React bridge and exposes the current app contract while storing a discriminated internal state. + +**Tech Stack:** React 19, TypeScript strict mode, Vite 8, Vitest 4, exifr 7.1.3, jsdom + React Testing Library for hook tests. + +## Global Constraints + +- Keep UI text, interactions, loading behavior, and map behavior unchanged. +- New file and directory names use kebab-case. +- Do not use `any`; prefer precise types or `unknown` narrowing. +- Use `exifr.parse`, not `exifr.gps`, because altitude is required. +- Use raw GPS tags in `exifr.parse`: `GPSLatitude`, `GPSLatitudeRef`, `GPSLongitude`, `GPSLongitudeRef`, `GPSAltitude`, `GPSAltitudeRef`. +- Keep generic browser `file:` behavior unchanged: production still reads `item.fileURL` through fetch. +- The app imports production functions only; tests may use factory functions for local dependency injection. +- Follow TDD: write the failing test, verify red, implement, verify green. + +--- + +## File Structure + +- Create `src/lib/image-location-reader.ts`: deep module for URL/binary reading and EXIF GPS parsing. +- Create `src/lib/image-location-reader.test.ts`: reader tests using fixtures and injected binary readers. +- Create `src/lib/selection-location-loader.ts`: selected-image-to-location-result module. +- Create `src/lib/selection-location-loader.test.ts`: loader tests with injected reader. +- Modify `src/hooks/use-eagle-selection.ts`: remove binary/EXIF work, add abort and generation guards. +- Create `src/hooks/use-eagle-selection.test.tsx`: jsdom hook tests for loading, stale results, aborts, and unmount. +- Modify `src/types.ts`: add discriminated `SelectionLocationState` while preserving `LoadState` and `Coordinates`. +- Delete `src/lib/location.ts`, `src/lib/location.test.ts`, and `src/lib/__snapshots__/location.test.ts.snap` after migration. +- Modify `package.json` and `pnpm-lock.yaml`: add hook test dependencies, remove old parser dependencies when unused. + +--- + +### Task 1: Image Location Reader + +**Files:** +- Create: `src/lib/image-location-reader.ts` +- Create: `src/lib/image-location-reader.test.ts` +- Keep for now: `src/lib/location.ts` + +**Interfaces:** +- Produces: + +```ts +export interface ReadImageLocationOptions { + signal?: AbortSignal; +} + +export type BinaryReader = ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; + +export function createImageLocationReader( + readBinary: BinaryReader, +): (sourceUrl: string, options?: ReadImageLocationOptions) => Promise; + +export async function readImageLocation( + sourceUrl: string, + options?: ReadImageLocationOptions, +): Promise; +``` + +- Consumes: `Coordinates` from `src/types.ts`. + +- [ ] **Step 1: Write the failing reader tests** + +Create `src/lib/image-location-reader.test.ts` with tests for GPS extraction, no GPS, read failure, abort propagation, and non-OK fetch: + +```ts +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + createImageLocationReader, + readImageLocation, +} from "./image-location-reader"; + +const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); +const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); +const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); +const thumbnailFixture = path.join(infoDirectory, "DSC02497_thumbnail.png"); + +async function readArrayBuffer(filePath: string): Promise { + const data = await readFile(filePath); + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +describe("readImageLocation", () => { + it("reads decimal coordinates and altitude from a JPEG", async () => { + const imageData = await readArrayBuffer(imageFixture); + const reader = createImageLocationReader(async () => imageData); + + await expect(reader("fixture.jpg")).resolves.toEqual({ + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, + }); + }); + + it("returns null when an image has no readable GPS metadata", async () => { + const imageData = await readArrayBuffer(thumbnailFixture); + const reader = createImageLocationReader(async () => imageData); + + await expect(reader("thumbnail.png")).resolves.toBeNull(); + }); + + it("rejects read failures", async () => { + const error = new Error("cannot read source"); + const reader = createImageLocationReader(async () => { + throw error; + }); + + await expect(reader("broken.jpg")).rejects.toBe(error); + }); + + it("passes the abort signal to the binary reader", async () => { + const controller = new AbortController(); + const abortError = new DOMException("Aborted", "AbortError"); + const reader = createImageLocationReader(async (_sourceUrl, options) => { + expect(options?.signal).toBe(controller.signal); + throw abortError; + }); + + controller.abort(); + + await expect( + reader("slow.jpg", { signal: controller.signal }), + ).rejects.toBe(abortError); + }); + + it("rejects non-OK fetch responses in the production reader", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => new Response(null, { status: 404 })); + + try { + await expect(readImageLocation("/missing.jpg")).rejects.toThrow( + "Failed to fetch source (404)", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); +``` + +- [ ] **Step 2: Verify reader tests fail** + +Run: `pnpm exec vitest run src/lib/image-location-reader.test.ts` + +Expected: FAIL because `./image-location-reader` does not exist. + +- [ ] **Step 3: Implement reader** + +Create `src/lib/image-location-reader.ts` with `exifr.parse`, raw GPS tag picking, `unknown` narrowing, fetch `cache: "no-store"`, abort signal forwarding, and `"Unknown file format"` mapped to `null` for unsupported/no-metadata image inputs. + +- [ ] **Step 4: Verify reader tests pass** + +Run: `pnpm exec vitest run src/lib/image-location-reader.test.ts` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit task** + +Run: + +```bash +git add src/lib/image-location-reader.ts src/lib/image-location-reader.test.ts +git commit -m "feat: add image location reader" +``` + +--- + +### Task 2: Selection Location Loader + +**Files:** +- Create: `src/lib/selection-location-loader.ts` +- Create: `src/lib/selection-location-loader.test.ts` + +**Interfaces:** +- Consumes: + +```ts +readImageLocation( + sourceUrl: string, + options?: ReadImageLocationOptions, +): Promise; +``` + +- Produces: + +```ts +export interface SelectedImage { + fileURL: string; +} + +export type SelectionLocationResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: Coordinates }; + +export function createSelectionLocationLoader( + readLocation: ReadImageLocation, +): ( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, +) => Promise; + +export async function loadSelectionLocation( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, +): Promise; +``` + +- [ ] **Step 1: Write the failing loader tests** + +Create `src/lib/selection-location-loader.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import type { Coordinates } from "../types"; +import { createSelectionLocationLoader } from "./selection-location-loader"; + +const coordinates: Coordinates = { + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, +}; + +describe("loadSelectionLocation", () => { + it("returns no-selection for an empty selection", async () => { + const loader = createSelectionLocationLoader(async () => { + throw new Error("reader should not be called"); + }); + + await expect(loader([])).resolves.toEqual({ status: "no-selection" }); + }); + + it("loads the first selected file URL", async () => { + const controller = new AbortController(); + const calls: Array<{ sourceUrl: string; signal?: AbortSignal }> = []; + const loader = createSelectionLocationLoader(async (sourceUrl, options) => { + calls.push({ sourceUrl, signal: options?.signal }); + return coordinates; + }); + + await expect( + loader([{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], { + signal: controller.signal, + }), + ).resolves.toEqual({ status: "ready", coordinates }); + expect(calls).toEqual([ + { sourceUrl: "first.jpg", signal: controller.signal }, + ]); + }); + + it("returns no-gps when the reader returns null", async () => { + const loader = createSelectionLocationLoader(async () => null); + + await expect(loader([{ fileURL: "image.jpg" }])).resolves.toEqual({ + status: "no-gps", + }); + }); + + it("propagates reader errors", async () => { + const error = new Error("reader failed"); + const loader = createSelectionLocationLoader(async () => { + throw error; + }); + + await expect(loader([{ fileURL: "image.jpg" }])).rejects.toBe(error); + }); +}); +``` + +- [ ] **Step 2: Verify loader tests fail** + +Run: `pnpm exec vitest run src/lib/selection-location-loader.test.ts` + +Expected: FAIL because `./selection-location-loader` does not exist. + +- [ ] **Step 3: Implement loader** + +Create `src/lib/selection-location-loader.ts` with the interfaces above. Use only `selection[0]?.fileURL`, pass through `options?.signal`, convert `null` to `no-gps`, and let exceptions propagate. + +- [ ] **Step 4: Verify loader tests pass** + +Run: `pnpm exec vitest run src/lib/selection-location-loader.test.ts` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: Commit task** + +Run: + +```bash +git add src/lib/selection-location-loader.ts src/lib/selection-location-loader.test.ts +git commit -m "feat: add selection location loader" +``` + +--- + +### Task 3: React Selection Hook + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/hooks/use-eagle-selection.ts` +- Create: `src/hooks/use-eagle-selection.test.tsx` +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +**Interfaces:** +- Consumes: + +```ts +loadSelectionLocation( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, +): Promise; +``` + +- Produces: existing hook return shape remains: + +```ts +{ + state: LoadState; + coordinates: Coordinates | null; + errorMessage: string; +} +``` + +- [ ] **Step 1: Install hook test dependencies** + +Run: + +```bash +pnpm add -D @testing-library/react jsdom +``` + +Expected: `package.json` and `pnpm-lock.yaml` update. + +- [ ] **Step 2: Write the failing hook tests** + +Create `src/hooks/use-eagle-selection.test.tsx` using `// @vitest-environment jsdom`. Mock `../eagle`, `../eagle/env`, and `../lib/selection-location-loader`. Cover initial ready state, stale result ignoring, abort-error silence, and unmount abort. + +- [ ] **Step 3: Verify hook tests fail** + +Run: `pnpm exec vitest run src/hooks/use-eagle-selection.test.tsx` + +Expected: FAIL because the current hook still imports `resolveImageLocation` and does not use `loadSelectionLocation`. + +- [ ] **Step 4: Add discriminated state type** + +Modify `src/types.ts`: + +```ts +export type LoadState = + | "loading" + | "ready" + | "no-selection" + | "no-gps" + | "error"; + +export interface Coordinates { + latitude: number; + longitude: number; + altitude?: number; +} + +export type SelectionLocationState = + | { status: "loading" } + | { status: "ready"; coordinates: Coordinates } + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "error"; message: string }; +``` + +- [ ] **Step 5: Implement hook** + +Modify `src/hooks/use-eagle-selection.ts` to remove `fetchBinary`, `resolveItemLocation`, and `resolveImageLocation`. Use `loadSelectionLocation`, one `SelectionLocationState`, `AbortController`, request generation guards, unmount cleanup, silent obsolete `AbortError`, and the existing returned shape. + +- [ ] **Step 6: Verify hook tests pass** + +Run: `pnpm exec vitest run src/hooks/use-eagle-selection.test.tsx` + +Expected: PASS. + +- [ ] **Step 7: Commit task** + +Run: + +```bash +git add package.json pnpm-lock.yaml src/types.ts src/hooks/use-eagle-selection.ts src/hooks/use-eagle-selection.test.tsx +git commit -m "feat: make Eagle selection loading race-safe" +``` + +--- + +### Task 4: Remove Old Parser Path and Verify + +**Files:** +- Delete: `src/lib/location.ts` +- Delete: `src/lib/location.test.ts` +- Delete: `src/lib/__snapshots__/location.test.ts.snap` +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +**Interfaces:** +- No consumers may import `src/lib/location.ts`. +- `image-location-reader` is the only EXIF parsing module. + +- [ ] **Step 1: Confirm old parser has no call sites** + +Run: + +```bash +rg -n "resolveImageLocation|convertDecimalToGps|formatGpsTime|formatGpsCoordinate|src/lib/location|\\.\\/location|\\.\\.\\/lib/location" src +``` + +Expected: no production call sites after Task 3. + +- [ ] **Step 2: Delete old parser files** + +Remove `src/lib/location.ts`, `src/lib/location.test.ts`, and `src/lib/__snapshots__/location.test.ts.snap`. Remove `src/lib/__snapshots__` if it becomes empty. + +- [ ] **Step 3: Remove unused parser dependencies** + +Run: + +```bash +pnpm remove buffer exif-reader piexif-ts +``` + +Expected: `package.json` keeps `exifr` and removes `buffer`, `exif-reader`, and `piexif-ts`. + +- [ ] **Step 4: Run full verification** + +Run: + +```bash +pnpm test +pnpm run type-check +pnpm run lint:check +pnpm run build +``` + +Expected: all commands exit 0. + +- [ ] **Step 5: Commit task** + +Run: + +```bash +git add package.json pnpm-lock.yaml src/lib src/hooks src/types.ts +git commit -m "refactor: remove legacy EXIF parser" +``` + +--- + +## Plan Self-Review + +- Spec coverage: reader module, loader module, hook race handling, dependency removal, and verification commands are covered by Tasks 1-4. +- Placeholder scan: no `TBD`, `TODO`, or "implement later" markers are present. +- Type consistency: `ReadImageLocationOptions`, `SelectionLocationResult`, and `SelectionLocationState` names match across tasks. From 3208866d20667c5eb9345da32ad3e51e3078207e Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:07:50 +0800 Subject: [PATCH 03/14] feat: add image location reader --- src/lib/image-location-reader.test.ts | 74 +++++++++++++++++ src/lib/image-location-reader.ts | 109 ++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/lib/image-location-reader.test.ts create mode 100644 src/lib/image-location-reader.ts diff --git a/src/lib/image-location-reader.test.ts b/src/lib/image-location-reader.test.ts new file mode 100644 index 0000000..a699469 --- /dev/null +++ b/src/lib/image-location-reader.test.ts @@ -0,0 +1,74 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + createImageLocationReader, + readImageLocation, +} from "./image-location-reader"; + +const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); +const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); +const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); +const thumbnailFixture = path.join(infoDirectory, "DSC02497_thumbnail.png"); + +async function readArrayBuffer(filePath: string): Promise { + const data = await readFile(filePath); + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +describe("readImageLocation", () => { + it("reads decimal coordinates and altitude from a JPEG", async () => { + const imageData = await readArrayBuffer(imageFixture); + const reader = createImageLocationReader(async () => imageData); + + await expect(reader("fixture.jpg")).resolves.toEqual({ + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, + }); + }); + + it("returns null when an image has no readable GPS metadata", async () => { + const imageData = await readArrayBuffer(thumbnailFixture); + const reader = createImageLocationReader(async () => imageData); + + await expect(reader("thumbnail.png")).resolves.toBeNull(); + }); + + it("rejects read failures", async () => { + const error = new Error("cannot read source"); + const reader = createImageLocationReader(async () => { + throw error; + }); + + await expect(reader("broken.jpg")).rejects.toBe(error); + }); + + it("passes the abort signal to the binary reader", async () => { + const controller = new AbortController(); + const abortError = new DOMException("Aborted", "AbortError"); + const reader = createImageLocationReader(async (_sourceUrl, options) => { + expect(options?.signal).toBe(controller.signal); + throw abortError; + }); + + controller.abort(); + + await expect( + reader("slow.jpg", { signal: controller.signal }), + ).rejects.toBe(abortError); + }); + + it("rejects non-OK fetch responses in the production reader", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async () => new Response(null, { status: 404 })); + + try { + await expect(readImageLocation("/missing.jpg")).rejects.toThrow( + "Failed to fetch source (404)", + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/lib/image-location-reader.ts b/src/lib/image-location-reader.ts new file mode 100644 index 0000000..5b4f7f9 --- /dev/null +++ b/src/lib/image-location-reader.ts @@ -0,0 +1,109 @@ +import exifr from "exifr"; +import type { Coordinates } from "../types"; + +export interface ReadImageLocationOptions { + signal?: AbortSignal; +} + +export type BinaryReader = ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; + +const GPS_TAGS = [ + "GPSLatitude", + "GPSLatitudeRef", + "GPSLongitude", + "GPSLongitudeRef", + "GPSAltitude", + "GPSAltitudeRef", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isUnknownFileFormat(error: unknown): boolean { + return error instanceof Error && error.message === "Unknown file format"; +} + +function isBelowSeaLevel(ref: unknown): boolean { + if (typeof ref === "number") { + return ref === 1; + } + + if (ref instanceof Uint8Array || Array.isArray(ref)) { + return ref[0] === 1; + } + + return isRecord(ref) && ref["0"] === 1; +} + +function toCoordinates(metadata: unknown): Coordinates | null { + if (!isRecord(metadata)) { + return null; + } + + const { latitude, longitude, GPSAltitude, GPSAltitudeRef } = metadata; + + if (typeof latitude !== "number" || typeof longitude !== "number") { + return null; + } + + if (typeof GPSAltitude !== "number") { + return { latitude, longitude }; + } + + return { + latitude, + longitude, + altitude: isBelowSeaLevel(GPSAltitudeRef) ? -GPSAltitude : GPSAltitude, + }; +} + +async function parseImageLocation( + imageArrayBuffer: ArrayBuffer, +): Promise { + try { + const metadata = (await exifr.parse(imageArrayBuffer, { + pick: [...GPS_TAGS], + })) as unknown; + + return toCoordinates(metadata); + } catch (error) { + if (isUnknownFileFormat(error)) { + return null; + } + + throw error; + } +} + +async function readBinaryFromUrl( + sourceUrl: string, + options?: ReadImageLocationOptions, +): Promise { + const response = await fetch(sourceUrl, { + cache: "no-store", + signal: options?.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch source (${response.status})`); + } + + return response.arrayBuffer(); +} + +export function createImageLocationReader(readBinary: BinaryReader) { + return async ( + sourceUrl: string, + options?: ReadImageLocationOptions, + ): Promise => { + const imageArrayBuffer = await readBinary(sourceUrl, options); + return parseImageLocation(imageArrayBuffer); + }; +} + +export const readImageLocation = + createImageLocationReader(readBinaryFromUrl); From 3bc06d55a82c9ac5f36ad29cc16a3b08f427ef4a Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:08:34 +0800 Subject: [PATCH 04/14] feat: add selection location loader --- src/lib/selection-location-loader.test.ts | 54 +++++++++++++++++++++++ src/lib/selection-location-loader.ts | 51 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/lib/selection-location-loader.test.ts create mode 100644 src/lib/selection-location-loader.ts diff --git a/src/lib/selection-location-loader.test.ts b/src/lib/selection-location-loader.test.ts new file mode 100644 index 0000000..1c86734 --- /dev/null +++ b/src/lib/selection-location-loader.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { Coordinates } from "../types"; +import { createSelectionLocationLoader } from "./selection-location-loader"; + +const coordinates: Coordinates = { + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, +}; + +describe("loadSelectionLocation", () => { + it("returns no-selection for an empty selection", async () => { + const loader = createSelectionLocationLoader(async () => { + throw new Error("reader should not be called"); + }); + + await expect(loader([])).resolves.toEqual({ status: "no-selection" }); + }); + + it("loads the first selected file URL", async () => { + const controller = new AbortController(); + const calls: Array<{ sourceUrl: string; signal?: AbortSignal }> = []; + const loader = createSelectionLocationLoader(async (sourceUrl, options) => { + calls.push({ sourceUrl, signal: options?.signal }); + return coordinates; + }); + + await expect( + loader([{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], { + signal: controller.signal, + }), + ).resolves.toEqual({ status: "ready", coordinates }); + expect(calls).toEqual([ + { sourceUrl: "first.jpg", signal: controller.signal }, + ]); + }); + + it("returns no-gps when the reader returns null", async () => { + const loader = createSelectionLocationLoader(async () => null); + + await expect(loader([{ fileURL: "image.jpg" }])).resolves.toEqual({ + status: "no-gps", + }); + }); + + it("propagates reader errors", async () => { + const error = new Error("reader failed"); + const loader = createSelectionLocationLoader(async () => { + throw error; + }); + + await expect(loader([{ fileURL: "image.jpg" }])).rejects.toBe(error); + }); +}); diff --git a/src/lib/selection-location-loader.ts b/src/lib/selection-location-loader.ts new file mode 100644 index 0000000..0743436 --- /dev/null +++ b/src/lib/selection-location-loader.ts @@ -0,0 +1,51 @@ +import type { Coordinates } from "../types"; +import { + readImageLocation, + type ReadImageLocationOptions, +} from "./image-location-reader"; + +export interface SelectedImage { + fileURL: string; +} + +export interface LoadSelectionLocationOptions { + signal?: AbortSignal; +} + +export type SelectionLocationResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: Coordinates }; + +type ReadImageLocation = ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; + +export function createSelectionLocationLoader( + readLocation: ReadImageLocation, +) { + return async ( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, + ): Promise => { + const selectedImage = selection[0]; + + if (!selectedImage) { + return { status: "no-selection" }; + } + + const coordinates = await readLocation(selectedImage.fileURL, { + signal: options?.signal, + }); + + if (!coordinates) { + return { status: "no-gps" }; + } + + return { status: "ready", coordinates }; + }; +} + +export const loadSelectionLocation = + createSelectionLocationLoader(readImageLocation); From 9b6516d6fd89042d456c1a9cbe8c7594acceeedf Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:10:49 +0800 Subject: [PATCH 05/14] feat: make Eagle selection loading race-safe --- package.json | 2 + pnpm-lock.yaml | 430 ++++++++++++++++++++++++- src/hooks/use-eagle-selection.test.tsx | 231 +++++++++++++ src/hooks/use-eagle-selection.ts | 88 +++-- src/types.ts | 7 + 5 files changed, 721 insertions(+), 37 deletions(-) create mode 100644 src/hooks/use-eagle-selection.test.tsx diff --git a/package.json b/package.json index 46f1e67..3074dbe 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -41,6 +42,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "prettier": "^3.8.4", "prettier-plugin-tailwindcss": "^0.8.0", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a83685b..8d91d10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': specifier: ^25.9.3 version: 25.9.3 @@ -66,6 +69,9 @@ importers: globals: specifier: ^17.6.0 version: 17.6.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 prettier: specifier: ^3.8.4 version: 3.8.4 @@ -83,10 +89,25 @@ importers: version: 8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) + version: 4.1.8(@types/node@25.9.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) packages: + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -142,6 +163,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -154,6 +179,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -358,6 +423,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -628,9 +702,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -794,6 +890,17 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arr-union@3.1.0: resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==} engines: {node: '>=0.10.0'} @@ -818,6 +925,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -850,9 +960,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -862,13 +980,23 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -879,6 +1007,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -1035,6 +1167,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1070,6 +1206,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1084,6 +1223,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1193,9 +1341,17 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1203,6 +1359,9 @@ packages: resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1244,6 +1403,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1344,6 +1506,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + protocol-buffers-schema@3.6.1: resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} @@ -1359,6 +1525,9 @@ packages: peerDependencies: react: ^19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-map-gl@8.1.1: resolution: {integrity: sha512-aSqFAFoxvY7wxbGI93Dz0E41171mkAb3GcNbnkFIotmu88OFw495os6mIDZSi7irYNT/PZEIOEHUxhun4ToGuQ==} peerDependencies: @@ -1376,6 +1545,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -1387,6 +1560,10 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1440,6 +1617,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} @@ -1465,6 +1645,21 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1499,6 +1694,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + union-value@1.0.1: resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} engines: {node: '>=0.10.0'} @@ -1596,6 +1795,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1610,6 +1825,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1628,6 +1850,26 @@ packages: snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1705,6 +1947,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1728,6 +1972,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1856,6 +2128,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2071,11 +2345,34 @@ snapshots: tailwindcss: 4.3.1 vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2266,6 +2563,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + arr-union@3.1.0: {} assertion-error@2.0.1: {} @@ -2278,6 +2583,10 @@ snapshots: baseline-browser-mapping@2.10.37: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2316,16 +2625,34 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + earcut@3.0.2: {} electron-to-chromium@1.5.372: {} @@ -2335,6 +2662,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-module-lexer@2.1.0: {} esbuild@0.27.2: @@ -2520,6 +2849,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -2544,6 +2879,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} isobject@3.0.1: {} @@ -2552,6 +2889,32 @@ snapshots: js-tokens@4.0.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2630,10 +2993,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2660,6 +3027,8 @@ snapshots: quickselect: 3.0.0 tinyqueue: 3.0.0 + mdn-data@2.27.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2695,6 +3064,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2731,6 +3104,12 @@ snapshots: prettier@3.8.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + protocol-buffers-schema@3.6.1: {} punycode@2.3.1: {} @@ -2742,6 +3121,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react-map-gl@8.1.1(maplibre-gl@5.24.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@vis.gl/react-mapbox': 8.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2753,6 +3134,8 @@ snapshots: react@19.2.7: {} + require-from-string@2.0.2: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.1 @@ -2780,6 +3163,10 @@ snapshots: rw@1.3.3: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2824,6 +3211,8 @@ snapshots: std-env@4.1.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -2841,6 +3230,20 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -2873,6 +3276,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.28.0: {} + union-value@1.0.1: dependencies: arr-union: 3.1.0 @@ -2903,7 +3308,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)): + vitest@4.1.8(@types/node@25.9.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) @@ -2927,9 +3332,26 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 + jsdom: 29.1.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2941,6 +3363,10 @@ snapshots: word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx new file mode 100644 index 0000000..913e521 --- /dev/null +++ b/src/hooks/use-eagle-selection.test.tsx @@ -0,0 +1,231 @@ +// @vitest-environment jsdom + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SelectionLocationResult } from "../lib/selection-location-loader"; +import type { Coordinates } from "../types"; +import { useEagleSelection } from "./use-eagle-selection"; + +const mocks = vi.hoisted(() => { + type MinimalItem = { fileURL: string }; + type MinimalCoordinates = { + latitude: number; + longitude: number; + altitude?: number; + }; + type MinimalResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: MinimalCoordinates }; + + return { + callbacks: { + create: undefined as (() => void) | undefined, + run: undefined as (() => void) | undefined, + }, + getSelected: vi.fn<() => Promise>(), + loadSelectionLocation: + vi.fn< + ( + selection: readonly MinimalItem[], + options?: { signal?: AbortSignal }, + ) => Promise + >(), + onPluginCreate: vi.fn<(callback: () => void) => void>(), + onPluginRun: vi.fn<(callback: () => void) => void>(), + }; +}); + +vi.mock("../eagle/env", () => ({ IN_EAGLE: true })); + +vi.mock("../eagle", () => ({ + eagle: { + item: { + getSelected: mocks.getSelected, + }, + onPluginCreate: mocks.onPluginCreate, + onPluginRun: mocks.onPluginRun, + }, +})); + +vi.mock("../lib/selection-location-loader", () => ({ + loadSelectionLocation: mocks.loadSelectionLocation, +})); + +const coordinates: Coordinates = { + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, +}; + +const newerCoordinates: Coordinates = { + latitude: 35.7, + longitude: 139.77, + altitude: 42, +}; + +function selected(fileURL: string) { + return { fileURL }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +} + +async function triggerPluginCreate(): Promise { + expect(mocks.callbacks.create).toBeTypeOf("function"); + await act(async () => { + mocks.callbacks.create?.(); + }); +} + +async function triggerPluginRun(): Promise { + expect(mocks.callbacks.run).toBeTypeOf("function"); + await act(async () => { + mocks.callbacks.run?.(); + }); +} + +beforeEach(() => { + mocks.callbacks.create = undefined; + mocks.callbacks.run = undefined; + mocks.getSelected.mockReset(); + mocks.loadSelectionLocation.mockReset(); + mocks.onPluginCreate.mockReset(); + mocks.onPluginRun.mockReset(); + mocks.onPluginCreate.mockImplementation((callback) => { + mocks.callbacks.create = callback; + }); + mocks.onPluginRun.mockImplementation((callback) => { + mocks.callbacks.run = callback; + }); +}); + +describe("useEagleSelection", () => { + it("loads the selected image into the ready state", async () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const { result } = renderHook(() => useEagleSelection()); + + expect(result.current).toEqual({ + state: "loading", + coordinates: null, + errorMessage: "", + }); + + await triggerPluginCreate(); + + await waitFor(() => expect(result.current.state).toBe("ready")); + expect(result.current.coordinates).toEqual(coordinates); + expect(result.current.errorMessage).toBe(""); + + const firstCall = mocks.loadSelectionLocation.mock.calls[0]; + expect(firstCall?.[0]).toEqual([selected("image.jpg")]); + expect(firstCall?.[1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it("ignores an older result after a newer selection request finishes", async () => { + const firstResult = deferred(); + const secondResult = deferred(); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + + const { result } = renderHook(() => useEagleSelection()); + + await triggerPluginCreate(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + + await triggerPluginRun(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), + ); + + await act(async () => { + secondResult.resolve({ status: "ready", coordinates: newerCoordinates }); + }); + + await waitFor(() => + expect(result.current.coordinates).toEqual(newerCoordinates), + ); + + await act(async () => { + firstResult.resolve({ status: "ready", coordinates }); + }); + + expect(result.current.coordinates).toEqual(newerCoordinates); + }); + + it("does not show an error for an obsolete aborted request", async () => { + const firstResult = deferred(); + const secondResult = deferred(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + + const { result } = renderHook(() => useEagleSelection()); + + await triggerPluginCreate(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + + await triggerPluginRun(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), + ); + + await act(async () => { + firstResult.reject(new DOMException("Aborted", "AbortError")); + secondResult.resolve({ status: "no-gps" }); + }); + + await waitFor(() => expect(result.current.state).toBe("no-gps")); + expect(result.current.errorMessage).toBe(""); + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + it("aborts an in-flight request on unmount", async () => { + const pendingResult = deferred(); + let requestSignal: AbortSignal | undefined; + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockImplementation((_selection, options) => { + requestSignal = options?.signal; + return pendingResult.promise; + }); + + const { unmount } = renderHook(() => useEagleSelection()); + + await triggerPluginCreate(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + expect(requestSignal?.aborted).toBe(false); + + unmount(); + + expect(requestSignal?.aborted).toBe(true); + + await act(async () => { + pendingResult.resolve({ status: "ready", coordinates }); + }); + }); +}); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index ae69d42..30f3adf 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -1,64 +1,70 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { eagle } from "../eagle"; import { IN_EAGLE } from "../eagle/env"; -import type { Item } from "../eagle/types"; -import { resolveImageLocation } from "../lib/location"; -import type { Coordinates, LoadState } from "../types"; +import { loadSelectionLocation } from "../lib/selection-location-loader"; +import type { Coordinates, LoadState, SelectionLocationState } from "../types"; -async function fetchBinary(url: string): Promise { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`Failed to fetch source (${response.status})`); - } - - return response.arrayBuffer(); +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; } -const resolveItemLocation = async (item: Item): Promise => { - const filePath = item.fileURL; - const buffer = await fetchBinary(filePath); - return resolveImageLocation(buffer); -}; +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unexpected error"; +} export function useEagleSelection() { - const [state, setState] = useState("loading"); - const [coordinates, setCoordinates] = useState(null); - const [errorMessage, setErrorMessage] = useState(""); + const [selectionState, setSelectionState] = + useState({ status: "loading" }); + const currentRequest = useRef<{ + id: number; + controller: AbortController; + } | null>(null); + const isMounted = useRef(false); const loadSelection = useCallback(async (): Promise => { - setState("loading"); - setErrorMessage(""); + currentRequest.current?.controller.abort(); + + const requestId = (currentRequest.current?.id ?? 0) + 1; + const controller = new AbortController(); + currentRequest.current = { id: requestId, controller }; + + const isCurrentRequest = () => + isMounted.current && currentRequest.current?.id === requestId; + + setSelectionState({ status: "loading" }); try { const selection = await eagle.item.getSelected(); - const item = selection.length > 0 ? selection[0] : undefined; - if (!item) { - setCoordinates(null); - setState("no-selection"); + if (!isCurrentRequest() || controller.signal.aborted) { return; } - const location = await resolveItemLocation(item); + const result = await loadSelectionLocation(selection, { + signal: controller.signal, + }); - if (!location) { - setCoordinates(null); - setState("no-gps"); + if (!isCurrentRequest() || controller.signal.aborted) { return; } - setCoordinates(location); - setState("ready"); + setSelectionState(result); } catch (error) { + if (!isCurrentRequest() || isAbortError(error)) { + return; + } + console.error("Failed to load Eagle selection", error); - setErrorMessage( - error instanceof Error ? error.message : "Unexpected error", - ); - setState("error"); + setSelectionState({ + status: "error", + message: toErrorMessage(error), + }); } }, []); useEffect(() => { + isMounted.current = true; + const initialize = () => { void loadSelection(); }; @@ -71,7 +77,19 @@ export function useEagleSelection() { } else { initialize(); } + + return () => { + isMounted.current = false; + currentRequest.current?.controller.abort(); + currentRequest.current = null; + }; }, [loadSelection]); + const state: LoadState = selectionState.status; + const coordinates: Coordinates | null = + selectionState.status === "ready" ? selectionState.coordinates : null; + const errorMessage = + selectionState.status === "error" ? selectionState.message : ""; + return { state, coordinates, errorMessage }; } diff --git a/src/types.ts b/src/types.ts index 0077b1f..8adff97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,3 +10,10 @@ export interface Coordinates { longitude: number; altitude?: number; } + +export type SelectionLocationState = + | { status: "loading" } + | { status: "ready"; coordinates: Coordinates } + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "error"; message: string }; From 398d28c3f55de5de0058fc26c7ecba3aed26dc0b Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:13:23 +0800 Subject: [PATCH 06/14] refactor: remove legacy EXIF parser --- package.json | 3 - pnpm-lock.yaml | 37 ------ src/hooks/use-eagle-selection.test.tsx | 32 ++--- src/hooks/use-eagle-selection.ts | 8 +- src/lib/__snapshots__/location.test.ts.snap | 9 -- src/lib/image-location-reader.test.ts | 16 +-- src/lib/location.test.ts | 16 --- src/lib/location.ts | 129 -------------------- src/lib/selection-location-loader.test.ts | 16 ++- src/lib/selection-location-loader.ts | 5 +- 10 files changed, 39 insertions(+), 232 deletions(-) delete mode 100644 src/lib/__snapshots__/location.test.ts.snap delete mode 100644 src/lib/location.test.ts delete mode 100644 src/lib/location.ts diff --git a/package.json b/package.json index 3074dbe..a95d7c7 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,8 @@ "preview": "vite preview" }, "dependencies": { - "buffer": "^6.0.3", - "exif-reader": "^2.0.3", "exifr": "^7.1.3", "maplibre-gl": "^5.24.0", - "piexif-ts": "^2.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d91d10..a972aaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,21 +8,12 @@ importers: .: dependencies: - buffer: - specifier: ^6.0.3 - version: 6.0.3 - exif-reader: - specifier: ^2.0.3 - version: 2.0.3 exifr: specifier: ^7.1.3 version: 7.1.3 maplibre-gl: specifier: ^5.24.0 version: 5.24.0 - piexif-ts: - specifier: ^2.1.0 - version: 2.1.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -917,9 +908,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.37: resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} engines: {node: '>=6.0.0'} @@ -937,9 +925,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bytewise-core@1.2.3: resolution: {integrity: sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==} @@ -1083,9 +1068,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - exif-reader@2.0.3: - resolution: {integrity: sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==} - exifr@7.1.3: resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} @@ -1171,9 +1153,6 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1432,9 +1411,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - piexif-ts@2.1.0: - resolution: {integrity: sha512-Fk1T7NbZnmiI4IPHrxz8y3dpzMAywDk1+9g0iGwHf+UPYPZJ1/CKALzhPPRPqAxwalwNz2t0NB4krY/PkAI93A==} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -2579,8 +2555,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.37: {} bidi-js@1.0.3: @@ -2599,11 +2573,6 @@ snapshots: node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bytewise-core@1.2.3: dependencies: typewise-core: 1.2.0 @@ -2785,8 +2754,6 @@ snapshots: esutils@2.0.3: {} - exif-reader@2.0.3: {} - exifr@7.1.3: {} expect-type@1.3.0: {} @@ -2855,8 +2822,6 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -3086,8 +3051,6 @@ snapshots: picomatch@4.0.4: {} - piexif-ts@2.1.0: {} - postcss@8.5.15: dependencies: nanoid: 3.3.12 diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index 913e521..3c0f346 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -79,16 +79,16 @@ function deferred() { return { promise, resolve, reject }; } -async function triggerPluginCreate(): Promise { +function triggerPluginCreate(): void { expect(mocks.callbacks.create).toBeTypeOf("function"); - await act(async () => { + act(() => { mocks.callbacks.create?.(); }); } -async function triggerPluginRun(): Promise { +function triggerPluginRun(): void { expect(mocks.callbacks.run).toBeTypeOf("function"); - await act(async () => { + act(() => { mocks.callbacks.run?.(); }); } @@ -124,15 +124,15 @@ describe("useEagleSelection", () => { errorMessage: "", }); - await triggerPluginCreate(); + triggerPluginCreate(); await waitFor(() => expect(result.current.state).toBe("ready")); expect(result.current.coordinates).toEqual(coordinates); expect(result.current.errorMessage).toBe(""); const firstCall = mocks.loadSelectionLocation.mock.calls[0]; - expect(firstCall?.[0]).toEqual([selected("image.jpg")]); - expect(firstCall?.[1]?.signal).toBeInstanceOf(AbortSignal); + expect(firstCall[0]).toEqual([selected("image.jpg")]); + expect(firstCall[1]?.signal).toBeInstanceOf(AbortSignal); }); it("ignores an older result after a newer selection request finishes", async () => { @@ -145,17 +145,17 @@ describe("useEagleSelection", () => { const { result } = renderHook(() => useEagleSelection()); - await triggerPluginCreate(); + triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); - await triggerPluginRun(); + triggerPluginRun(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), ); - await act(async () => { + act(() => { secondResult.resolve({ status: "ready", coordinates: newerCoordinates }); }); @@ -163,7 +163,7 @@ describe("useEagleSelection", () => { expect(result.current.coordinates).toEqual(newerCoordinates), ); - await act(async () => { + act(() => { firstResult.resolve({ status: "ready", coordinates }); }); @@ -181,17 +181,17 @@ describe("useEagleSelection", () => { const { result } = renderHook(() => useEagleSelection()); - await triggerPluginCreate(); + triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); - await triggerPluginRun(); + triggerPluginRun(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), ); - await act(async () => { + act(() => { firstResult.reject(new DOMException("Aborted", "AbortError")); secondResult.resolve({ status: "no-gps" }); }); @@ -214,7 +214,7 @@ describe("useEagleSelection", () => { const { unmount } = renderHook(() => useEagleSelection()); - await triggerPluginCreate(); + triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); @@ -224,7 +224,7 @@ describe("useEagleSelection", () => { expect(requestSignal?.aborted).toBe(true); - await act(async () => { + act(() => { pendingResult.resolve({ status: "ready", coordinates }); }); }); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 30f3adf..dc7037f 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -12,6 +12,10 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "Unexpected error"; } +function isSignalAborted(signal: AbortSignal): boolean { + return signal.aborted; +} + export function useEagleSelection() { const [selectionState, setSelectionState] = useState({ status: "loading" }); @@ -36,7 +40,7 @@ export function useEagleSelection() { try { const selection = await eagle.item.getSelected(); - if (!isCurrentRequest() || controller.signal.aborted) { + if (!isCurrentRequest() || isSignalAborted(controller.signal)) { return; } @@ -44,7 +48,7 @@ export function useEagleSelection() { signal: controller.signal, }); - if (!isCurrentRequest() || controller.signal.aborted) { + if (!isCurrentRequest() || isSignalAborted(controller.signal)) { return; } diff --git a/src/lib/__snapshots__/location.test.ts.snap b/src/lib/__snapshots__/location.test.ts.snap deleted file mode 100644 index a5c0642..0000000 --- a/src/lib/__snapshots__/location.test.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`resolveImageLocation > converts EXIF GPS metadata to decimal coordinates 1`] = ` -{ - "altitude": 57.75, - "latitude": 35.702755186666664, - "longitude": 139.77182481666668, -} -`; diff --git a/src/lib/image-location-reader.test.ts b/src/lib/image-location-reader.test.ts index a699469..d241863 100644 --- a/src/lib/image-location-reader.test.ts +++ b/src/lib/image-location-reader.test.ts @@ -19,7 +19,7 @@ async function readArrayBuffer(filePath: string): Promise { describe("readImageLocation", () => { it("reads decimal coordinates and altitude from a JPEG", async () => { const imageData = await readArrayBuffer(imageFixture); - const reader = createImageLocationReader(async () => imageData); + const reader = createImageLocationReader(() => Promise.resolve(imageData)); await expect(reader("fixture.jpg")).resolves.toEqual({ latitude: 35.702755186666664, @@ -30,16 +30,14 @@ describe("readImageLocation", () => { it("returns null when an image has no readable GPS metadata", async () => { const imageData = await readArrayBuffer(thumbnailFixture); - const reader = createImageLocationReader(async () => imageData); + const reader = createImageLocationReader(() => Promise.resolve(imageData)); await expect(reader("thumbnail.png")).resolves.toBeNull(); }); it("rejects read failures", async () => { const error = new Error("cannot read source"); - const reader = createImageLocationReader(async () => { - throw error; - }); + const reader = createImageLocationReader(() => Promise.reject(error)); await expect(reader("broken.jpg")).rejects.toBe(error); }); @@ -47,9 +45,9 @@ describe("readImageLocation", () => { it("passes the abort signal to the binary reader", async () => { const controller = new AbortController(); const abortError = new DOMException("Aborted", "AbortError"); - const reader = createImageLocationReader(async (_sourceUrl, options) => { + const reader = createImageLocationReader((_sourceUrl, options) => { expect(options?.signal).toBe(controller.signal); - throw abortError; + return Promise.reject(abortError); }); controller.abort(); @@ -61,7 +59,9 @@ describe("readImageLocation", () => { it("rejects non-OK fetch responses in the production reader", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async () => new Response(null, { status: 404 })); + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response(null, { status: 404 })), + ); try { await expect(readImageLocation("/missing.jpg")).rejects.toThrow( diff --git a/src/lib/location.test.ts b/src/lib/location.test.ts deleted file mode 100644 index f491e6f..0000000 --- a/src/lib/location.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveImageLocation } from "./location"; - -const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); -const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); -const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); - -describe("resolveImageLocation", () => { - it("converts EXIF GPS metadata to decimal coordinates", async () => { - const imageData = await readFile(imageFixture); - const location = resolveImageLocation(imageData.buffer); - expect(location).matchSnapshot(); - }); -}); diff --git a/src/lib/location.ts b/src/lib/location.ts deleted file mode 100644 index 92e67f9..0000000 --- a/src/lib/location.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Buffer } from "buffer"; -import type { Coordinates } from "../types"; -// https://github.com/devongovett/exif-reader -import exifReader from "exif-reader"; -// https://github.com/holwech/piexif-ts -import type * as Piexif from "piexif-ts"; -// @ts-expect-error - piexif-ts has incorrect package.json exports, importing dist directly -import * as piexifJs from "piexif-ts/dist/piexif.js"; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const piexif: typeof Piexif = piexifJs; - -// Convert GPS coordinates from EXIF format to decimal degrees -const convertGpsCoordinate = ( - coordinate: number[], - ref: string, -): number | null => { - if (coordinate.length < 3) return null; - - const degrees = coordinate[0]; - const minutes = coordinate[1]; - const seconds = coordinate[2]; - - let decimal = degrees + minutes / 60 + seconds / 3600; - - // Apply reference direction (S and W are negative) - if (ref === "S" || ref === "W") { - decimal = -decimal; - } - - return decimal; -}; - -// Convert decimal degrees to EXIF GPS format -export const convertDecimalToGps = ( - decimal: number, - type: "lat" | "lng", -): { coordinate: number[]; ref: string } => { - const ref = - type === "lat" ? (decimal >= 0 ? "N" : "S") : decimal >= 0 ? "E" : "W"; - const absDecimal = Math.abs(decimal); - - const degrees = Math.floor(absDecimal); - const minutes = Math.floor((absDecimal - degrees) * 60); - const seconds = ((absDecimal - degrees) * 60 - minutes) * 3600; - - return { coordinate: [degrees, minutes, seconds], ref }; -}; - -// Format GPS time from array to readable string -export const formatGpsTime = (timeArray: number[]): string => { - if (timeArray.length < 3) return ""; - - const hours = Math.floor(timeArray[0]).toString().padStart(2, "0"); - const minutes = Math.floor(timeArray[1]).toString().padStart(2, "0"); - const seconds = Math.floor(timeArray[2]).toString().padStart(2, "0"); - - return `${hours}:${minutes}:${seconds}`; -}; - -// Format GPS coordinate for display -export const formatGpsCoordinate = ( - coordinate: number[], - ref: string, - type: "lat" | "lng", -): string => { - if (coordinate.length < 3) return "N/A"; - - const degrees = Math.floor(coordinate[0]); - const minutes = Math.floor(coordinate[1]); - const seconds = coordinate[2].toFixed(2); - - const direction = - type === "lat" ? (ref === "N" ? "N" : "S") : ref === "E" ? "E" : "W"; - - return `${degrees}°${minutes}'${seconds}"${direction}`; -}; - -const extractExif = (arrayBuffer: ArrayBuffer) => { - // Convert ArrayBuffer to binary string for piexif.load() - const uint8Array = new Uint8Array(arrayBuffer); - let binaryString = ""; - for (const byte of uint8Array) { - binaryString += String.fromCodePoint(byte); - } - - const exifObj = piexif.load(binaryString); - const exifSegmentStr = piexif.dump(exifObj); - return Buffer.from(exifSegmentStr, "binary"); -}; - -export const resolveImageLocation = ( - imageArrayBuffer: ArrayBuffer, -): Coordinates | null => { - const exifSegmentBuffer = extractExif(imageArrayBuffer); - - const metadata = exifReader(exifSegmentBuffer); - const gpsData = metadata.GPSInfo; - - if (!gpsData) { - return null; - } - - // Convert coordinates to decimal degrees - const latitude = - gpsData.GPSLatitude && gpsData.GPSLatitudeRef - ? convertGpsCoordinate(gpsData.GPSLatitude, gpsData.GPSLatitudeRef) - : null; - - const longitude = - gpsData.GPSLongitude && gpsData.GPSLongitudeRef - ? convertGpsCoordinate(gpsData.GPSLongitude, gpsData.GPSLongitudeRef) - : null; - if (latitude == null || longitude == null) { - return null; - } - - const altitude = gpsData.GPSAltitude - ? gpsData.GPSAltitudeRef === 1 - ? -gpsData.GPSAltitude - : gpsData.GPSAltitude - : undefined; - - return { - latitude, - longitude, - altitude, - }; -}; diff --git a/src/lib/selection-location-loader.test.ts b/src/lib/selection-location-loader.test.ts index 1c86734..6615a60 100644 --- a/src/lib/selection-location-loader.test.ts +++ b/src/lib/selection-location-loader.test.ts @@ -10,9 +10,9 @@ const coordinates: Coordinates = { describe("loadSelectionLocation", () => { it("returns no-selection for an empty selection", async () => { - const loader = createSelectionLocationLoader(async () => { - throw new Error("reader should not be called"); - }); + const loader = createSelectionLocationLoader(() => + Promise.reject(new Error("reader should not be called")), + ); await expect(loader([])).resolves.toEqual({ status: "no-selection" }); }); @@ -20,9 +20,9 @@ describe("loadSelectionLocation", () => { it("loads the first selected file URL", async () => { const controller = new AbortController(); const calls: Array<{ sourceUrl: string; signal?: AbortSignal }> = []; - const loader = createSelectionLocationLoader(async (sourceUrl, options) => { + const loader = createSelectionLocationLoader((sourceUrl, options) => { calls.push({ sourceUrl, signal: options?.signal }); - return coordinates; + return Promise.resolve(coordinates); }); await expect( @@ -36,7 +36,7 @@ describe("loadSelectionLocation", () => { }); it("returns no-gps when the reader returns null", async () => { - const loader = createSelectionLocationLoader(async () => null); + const loader = createSelectionLocationLoader(() => Promise.resolve(null)); await expect(loader([{ fileURL: "image.jpg" }])).resolves.toEqual({ status: "no-gps", @@ -45,9 +45,7 @@ describe("loadSelectionLocation", () => { it("propagates reader errors", async () => { const error = new Error("reader failed"); - const loader = createSelectionLocationLoader(async () => { - throw error; - }); + const loader = createSelectionLocationLoader(() => Promise.reject(error)); await expect(loader([{ fileURL: "image.jpg" }])).rejects.toBe(error); }); diff --git a/src/lib/selection-location-loader.ts b/src/lib/selection-location-loader.ts index 0743436..6b7ed87 100644 --- a/src/lib/selection-location-loader.ts +++ b/src/lib/selection-location-loader.ts @@ -29,12 +29,11 @@ export function createSelectionLocationLoader( selection: readonly SelectedImage[], options?: LoadSelectionLocationOptions, ): Promise => { - const selectedImage = selection[0]; - - if (!selectedImage) { + if (selection.length === 0) { return { status: "no-selection" }; } + const selectedImage = selection[0]; const coordinates = await readLocation(selectedImage.fileURL, { signal: options?.signal, }); From 05c0c8067e763e889c7b68000ebbf294557cef2f Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:22:03 +0800 Subject: [PATCH 07/14] fix: address image location review findings --- ...26-07-12-image-location-reader-refactor.md | 2 +- src/hooks/use-eagle-selection.test.tsx | 16 +++++++ src/hooks/use-eagle-selection.ts | 4 ++ src/lib/image-location-reader.exifr.test.ts | 45 +++++++++++++++++++ src/lib/image-location-reader.ts | 40 +++++++++++++++-- 5 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 src/lib/image-location-reader.exifr.test.ts diff --git a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md index f24d58f..a08b7ae 100644 --- a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md +++ b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md @@ -6,7 +6,7 @@ **Architecture:** `image-location-reader` owns resource reading and GPS parsing behind `readImageLocation(sourceUrl, options)`. `selection-location-loader` converts selected `{ fileURL }` values into domain results. `use-eagle-selection` stays as the Eagle/React bridge and exposes the current app contract while storing a discriminated internal state. -**Tech Stack:** React 19, TypeScript strict mode, Vite 8, Vitest 4, exifr 7.1.3, jsdom + React Testing Library for hook tests. +**Tech Stack:** React 19, TypeScript strict mode, the repo's current Vite configuration, Vitest 4, exifr 7.1.3, jsdom + React Testing Library for hook tests. ## Global Constraints diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index 3c0f346..201e701 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -228,4 +228,20 @@ describe("useEagleSelection", () => { pendingResult.resolve({ status: "ready", coordinates }); }); }); + + it("ignores retained Eagle callbacks after unmount", () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const { unmount } = renderHook(() => useEagleSelection()); + + unmount(); + triggerPluginRun(); + + expect(mocks.getSelected).not.toHaveBeenCalled(); + expect(mocks.loadSelectionLocation).not.toHaveBeenCalled(); + }); }); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index dc7037f..5e750d0 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -26,6 +26,10 @@ export function useEagleSelection() { const isMounted = useRef(false); const loadSelection = useCallback(async (): Promise => { + if (!isMounted.current) { + return; + } + currentRequest.current?.controller.abort(); const requestId = (currentRequest.current?.id ?? 0) + 1; diff --git a/src/lib/image-location-reader.exifr.test.ts b/src/lib/image-location-reader.exifr.test.ts new file mode 100644 index 0000000..f74606a --- /dev/null +++ b/src/lib/image-location-reader.exifr.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createImageLocationReader } from "./image-location-reader"; + +const mocks = vi.hoisted(() => { + type ExifrParse = ( + input: ArrayBuffer, + options: { pick: string[] }, + ) => Promise; + + return { + parse: vi.fn(), + }; +}); + +vi.mock("exifr", () => ({ + default: { + parse: mocks.parse, + }, +})); + +beforeEach(() => { + mocks.parse.mockReset(); +}); + +describe("readImageLocation EXIF metadata mapping", () => { + it("converts raw GPS tags when derived coordinates are absent", async () => { + mocks.parse.mockResolvedValue({ + GPSLatitude: [35, 30, 0], + GPSLatitudeRef: "S", + GPSLongitude: [139, 15, 0], + GPSLongitudeRef: "W", + GPSAltitude: 12, + GPSAltitudeRef: Uint8Array.from([1]), + }); + const reader = createImageLocationReader(() => + Promise.resolve(new ArrayBuffer(0)), + ); + + await expect(reader("raw-gps.jpg")).resolves.toEqual({ + latitude: -35.5, + longitude: -139.25, + altitude: -12, + }); + }); +}); diff --git a/src/lib/image-location-reader.ts b/src/lib/image-location-reader.ts index 5b4f7f9..b8d09e8 100644 --- a/src/lib/image-location-reader.ts +++ b/src/lib/image-location-reader.ts @@ -39,14 +39,48 @@ function isBelowSeaLevel(ref: unknown): boolean { return isRecord(ref) && ref["0"] === 1; } +function gpsCoordinateToDecimal( + coordinate: unknown, + ref: unknown, +): number | null { + if (!Array.isArray(coordinate) || coordinate.length < 3) { + return null; + } + + const coordinateParts = coordinate as readonly unknown[]; + const degrees = coordinateParts[0]; + const minutes = coordinateParts[1]; + const seconds = coordinateParts[2]; + + if ( + typeof degrees !== "number" || + typeof minutes !== "number" || + typeof seconds !== "number" || + typeof ref !== "string" + ) { + return null; + } + + const decimal = degrees + minutes / 60 + seconds / 3600; + return ref === "S" || ref === "W" ? -decimal : decimal; +} + function toCoordinates(metadata: unknown): Coordinates | null { if (!isRecord(metadata)) { return null; } - const { latitude, longitude, GPSAltitude, GPSAltitudeRef } = metadata; - - if (typeof latitude !== "number" || typeof longitude !== "number") { + const { GPSAltitude, GPSAltitudeRef } = metadata; + const latitude = + typeof metadata.latitude === "number" + ? metadata.latitude + : gpsCoordinateToDecimal(metadata.GPSLatitude, metadata.GPSLatitudeRef); + const longitude = + typeof metadata.longitude === "number" + ? metadata.longitude + : gpsCoordinateToDecimal(metadata.GPSLongitude, metadata.GPSLongitudeRef); + + if (latitude === null || longitude === null) { return null; } From 3e04491695b89f461b5af9b067c7984180f93b4f Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:44:15 +0800 Subject: [PATCH 08/14] style: format image location refactor files --- .../2026-07-12-image-location-reader-refactor.md | 13 ++++++++++++- .../2026-07-12-image-location-reader-design.md | 2 +- src/hooks/use-eagle-selection.test.tsx | 4 +++- src/hooks/use-eagle-selection.ts | 5 +++-- src/lib/image-location-reader.ts | 3 +-- src/lib/selection-location-loader.ts | 4 +--- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md index a08b7ae..daa55e0 100644 --- a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md +++ b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md @@ -38,11 +38,13 @@ ### Task 1: Image Location Reader **Files:** + - Create: `src/lib/image-location-reader.ts` - Create: `src/lib/image-location-reader.test.ts` - Keep for now: `src/lib/location.ts` **Interfaces:** + - Produces: ```ts @@ -57,7 +59,10 @@ export type BinaryReader = ( export function createImageLocationReader( readBinary: BinaryReader, -): (sourceUrl: string, options?: ReadImageLocationOptions) => Promise; +): ( + sourceUrl: string, + options?: ReadImageLocationOptions, +) => Promise; export async function readImageLocation( sourceUrl: string, @@ -178,10 +183,12 @@ git commit -m "feat: add image location reader" ### Task 2: Selection Location Loader **Files:** + - Create: `src/lib/selection-location-loader.ts` - Create: `src/lib/selection-location-loader.test.ts` **Interfaces:** + - Consumes: ```ts @@ -307,6 +314,7 @@ git commit -m "feat: add selection location loader" ### Task 3: React Selection Hook **Files:** + - Modify: `src/types.ts` - Modify: `src/hooks/use-eagle-selection.ts` - Create: `src/hooks/use-eagle-selection.test.tsx` @@ -314,6 +322,7 @@ git commit -m "feat: add selection location loader" - Modify: `pnpm-lock.yaml` **Interfaces:** + - Consumes: ```ts @@ -403,6 +412,7 @@ git commit -m "feat: make Eagle selection loading race-safe" ### Task 4: Remove Old Parser Path and Verify **Files:** + - Delete: `src/lib/location.ts` - Delete: `src/lib/location.test.ts` - Delete: `src/lib/__snapshots__/location.test.ts.snap` @@ -410,6 +420,7 @@ git commit -m "feat: make Eagle selection loading race-safe" - Modify: `pnpm-lock.yaml` **Interfaces:** + - No consumers may import `src/lib/location.ts`. - `image-location-reader` is the only EXIF parsing module. diff --git a/docs/superpowers/specs/2026-07-12-image-location-reader-design.md b/docs/superpowers/specs/2026-07-12-image-location-reader-design.md index e46a919..ab4168a 100644 --- a/docs/superpowers/specs/2026-07-12-image-location-reader-design.md +++ b/docs/superpowers/specs/2026-07-12-image-location-reader-design.md @@ -80,7 +80,7 @@ pick: [ "GPSLongitudeRef", "GPSAltitude", "GPSAltitudeRef", -] +]; ``` Do not pick `latitude` and `longitude` directly. They are derived fields in `exifr`; selecting only those fields did not trigger the source GPS fields in the browser verification. diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index 201e701..c97ecbe 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -173,7 +173,9 @@ describe("useEagleSelection", () => { it("does not show an error for an obsolete aborted request", async () => { const firstResult = deferred(); const secondResult = deferred(); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); mocks.getSelected.mockResolvedValue([selected("image.jpg")]); mocks.loadSelectionLocation .mockReturnValueOnce(firstResult.promise) diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 5e750d0..592ebb5 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -17,8 +17,9 @@ function isSignalAborted(signal: AbortSignal): boolean { } export function useEagleSelection() { - const [selectionState, setSelectionState] = - useState({ status: "loading" }); + const [selectionState, setSelectionState] = useState({ + status: "loading", + }); const currentRequest = useRef<{ id: number; controller: AbortController; diff --git a/src/lib/image-location-reader.ts b/src/lib/image-location-reader.ts index b8d09e8..1b605c1 100644 --- a/src/lib/image-location-reader.ts +++ b/src/lib/image-location-reader.ts @@ -139,5 +139,4 @@ export function createImageLocationReader(readBinary: BinaryReader) { }; } -export const readImageLocation = - createImageLocationReader(readBinaryFromUrl); +export const readImageLocation = createImageLocationReader(readBinaryFromUrl); diff --git a/src/lib/selection-location-loader.ts b/src/lib/selection-location-loader.ts index 6b7ed87..a297849 100644 --- a/src/lib/selection-location-loader.ts +++ b/src/lib/selection-location-loader.ts @@ -22,9 +22,7 @@ type ReadImageLocation = ( options?: ReadImageLocationOptions, ) => Promise; -export function createSelectionLocationLoader( - readLocation: ReadImageLocation, -) { +export function createSelectionLocationLoader(readLocation: ReadImageLocation) { return async ( selection: readonly SelectedImage[], options?: LoadSelectionLocationOptions, From 1bd947f92d550248f7610b6a640ae8aa8fbb72e5 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:21:18 +0800 Subject: [PATCH 09/14] docs: remove superpowers planning files --- ...26-07-12-image-location-reader-refactor.md | 479 ------------------ ...2026-07-12-image-location-reader-design.md | 284 ----------- 2 files changed, 763 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md delete mode 100644 docs/superpowers/specs/2026-07-12-image-location-reader-design.md diff --git a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md b/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md deleted file mode 100644 index daa55e0..0000000 --- a/docs/superpowers/plans/2026-07-12-image-location-reader-refactor.md +++ /dev/null @@ -1,479 +0,0 @@ -# Image Location Reader Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move image location reading into independent modules, replace the old EXIF parser chain with `exifr`, and make React selection state race-safe without changing user-facing UI behavior. - -**Architecture:** `image-location-reader` owns resource reading and GPS parsing behind `readImageLocation(sourceUrl, options)`. `selection-location-loader` converts selected `{ fileURL }` values into domain results. `use-eagle-selection` stays as the Eagle/React bridge and exposes the current app contract while storing a discriminated internal state. - -**Tech Stack:** React 19, TypeScript strict mode, the repo's current Vite configuration, Vitest 4, exifr 7.1.3, jsdom + React Testing Library for hook tests. - -## Global Constraints - -- Keep UI text, interactions, loading behavior, and map behavior unchanged. -- New file and directory names use kebab-case. -- Do not use `any`; prefer precise types or `unknown` narrowing. -- Use `exifr.parse`, not `exifr.gps`, because altitude is required. -- Use raw GPS tags in `exifr.parse`: `GPSLatitude`, `GPSLatitudeRef`, `GPSLongitude`, `GPSLongitudeRef`, `GPSAltitude`, `GPSAltitudeRef`. -- Keep generic browser `file:` behavior unchanged: production still reads `item.fileURL` through fetch. -- The app imports production functions only; tests may use factory functions for local dependency injection. -- Follow TDD: write the failing test, verify red, implement, verify green. - ---- - -## File Structure - -- Create `src/lib/image-location-reader.ts`: deep module for URL/binary reading and EXIF GPS parsing. -- Create `src/lib/image-location-reader.test.ts`: reader tests using fixtures and injected binary readers. -- Create `src/lib/selection-location-loader.ts`: selected-image-to-location-result module. -- Create `src/lib/selection-location-loader.test.ts`: loader tests with injected reader. -- Modify `src/hooks/use-eagle-selection.ts`: remove binary/EXIF work, add abort and generation guards. -- Create `src/hooks/use-eagle-selection.test.tsx`: jsdom hook tests for loading, stale results, aborts, and unmount. -- Modify `src/types.ts`: add discriminated `SelectionLocationState` while preserving `LoadState` and `Coordinates`. -- Delete `src/lib/location.ts`, `src/lib/location.test.ts`, and `src/lib/__snapshots__/location.test.ts.snap` after migration. -- Modify `package.json` and `pnpm-lock.yaml`: add hook test dependencies, remove old parser dependencies when unused. - ---- - -### Task 1: Image Location Reader - -**Files:** - -- Create: `src/lib/image-location-reader.ts` -- Create: `src/lib/image-location-reader.test.ts` -- Keep for now: `src/lib/location.ts` - -**Interfaces:** - -- Produces: - -```ts -export interface ReadImageLocationOptions { - signal?: AbortSignal; -} - -export type BinaryReader = ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; - -export function createImageLocationReader( - readBinary: BinaryReader, -): ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; - -export async function readImageLocation( - sourceUrl: string, - options?: ReadImageLocationOptions, -): Promise; -``` - -- Consumes: `Coordinates` from `src/types.ts`. - -- [ ] **Step 1: Write the failing reader tests** - -Create `src/lib/image-location-reader.test.ts` with tests for GPS extraction, no GPS, read failure, abort propagation, and non-OK fetch: - -```ts -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - createImageLocationReader, - readImageLocation, -} from "./image-location-reader"; - -const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); -const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); -const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); -const thumbnailFixture = path.join(infoDirectory, "DSC02497_thumbnail.png"); - -async function readArrayBuffer(filePath: string): Promise { - const data = await readFile(filePath); - return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); -} - -describe("readImageLocation", () => { - it("reads decimal coordinates and altitude from a JPEG", async () => { - const imageData = await readArrayBuffer(imageFixture); - const reader = createImageLocationReader(async () => imageData); - - await expect(reader("fixture.jpg")).resolves.toEqual({ - latitude: 35.702755186666664, - longitude: 139.77182481666668, - altitude: 57.75, - }); - }); - - it("returns null when an image has no readable GPS metadata", async () => { - const imageData = await readArrayBuffer(thumbnailFixture); - const reader = createImageLocationReader(async () => imageData); - - await expect(reader("thumbnail.png")).resolves.toBeNull(); - }); - - it("rejects read failures", async () => { - const error = new Error("cannot read source"); - const reader = createImageLocationReader(async () => { - throw error; - }); - - await expect(reader("broken.jpg")).rejects.toBe(error); - }); - - it("passes the abort signal to the binary reader", async () => { - const controller = new AbortController(); - const abortError = new DOMException("Aborted", "AbortError"); - const reader = createImageLocationReader(async (_sourceUrl, options) => { - expect(options?.signal).toBe(controller.signal); - throw abortError; - }); - - controller.abort(); - - await expect( - reader("slow.jpg", { signal: controller.signal }), - ).rejects.toBe(abortError); - }); - - it("rejects non-OK fetch responses in the production reader", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(async () => new Response(null, { status: 404 })); - - try { - await expect(readImageLocation("/missing.jpg")).rejects.toThrow( - "Failed to fetch source (404)", - ); - } finally { - globalThis.fetch = originalFetch; - } - }); -}); -``` - -- [ ] **Step 2: Verify reader tests fail** - -Run: `pnpm exec vitest run src/lib/image-location-reader.test.ts` - -Expected: FAIL because `./image-location-reader` does not exist. - -- [ ] **Step 3: Implement reader** - -Create `src/lib/image-location-reader.ts` with `exifr.parse`, raw GPS tag picking, `unknown` narrowing, fetch `cache: "no-store"`, abort signal forwarding, and `"Unknown file format"` mapped to `null` for unsupported/no-metadata image inputs. - -- [ ] **Step 4: Verify reader tests pass** - -Run: `pnpm exec vitest run src/lib/image-location-reader.test.ts` - -Expected: PASS, 5 tests. - -- [ ] **Step 5: Commit task** - -Run: - -```bash -git add src/lib/image-location-reader.ts src/lib/image-location-reader.test.ts -git commit -m "feat: add image location reader" -``` - ---- - -### Task 2: Selection Location Loader - -**Files:** - -- Create: `src/lib/selection-location-loader.ts` -- Create: `src/lib/selection-location-loader.test.ts` - -**Interfaces:** - -- Consumes: - -```ts -readImageLocation( - sourceUrl: string, - options?: ReadImageLocationOptions, -): Promise; -``` - -- Produces: - -```ts -export interface SelectedImage { - fileURL: string; -} - -export type SelectionLocationResult = - | { status: "no-selection" } - | { status: "no-gps" } - | { status: "ready"; coordinates: Coordinates }; - -export function createSelectionLocationLoader( - readLocation: ReadImageLocation, -): ( - selection: readonly SelectedImage[], - options?: LoadSelectionLocationOptions, -) => Promise; - -export async function loadSelectionLocation( - selection: readonly SelectedImage[], - options?: LoadSelectionLocationOptions, -): Promise; -``` - -- [ ] **Step 1: Write the failing loader tests** - -Create `src/lib/selection-location-loader.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import type { Coordinates } from "../types"; -import { createSelectionLocationLoader } from "./selection-location-loader"; - -const coordinates: Coordinates = { - latitude: 35.702755186666664, - longitude: 139.77182481666668, - altitude: 57.75, -}; - -describe("loadSelectionLocation", () => { - it("returns no-selection for an empty selection", async () => { - const loader = createSelectionLocationLoader(async () => { - throw new Error("reader should not be called"); - }); - - await expect(loader([])).resolves.toEqual({ status: "no-selection" }); - }); - - it("loads the first selected file URL", async () => { - const controller = new AbortController(); - const calls: Array<{ sourceUrl: string; signal?: AbortSignal }> = []; - const loader = createSelectionLocationLoader(async (sourceUrl, options) => { - calls.push({ sourceUrl, signal: options?.signal }); - return coordinates; - }); - - await expect( - loader([{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], { - signal: controller.signal, - }), - ).resolves.toEqual({ status: "ready", coordinates }); - expect(calls).toEqual([ - { sourceUrl: "first.jpg", signal: controller.signal }, - ]); - }); - - it("returns no-gps when the reader returns null", async () => { - const loader = createSelectionLocationLoader(async () => null); - - await expect(loader([{ fileURL: "image.jpg" }])).resolves.toEqual({ - status: "no-gps", - }); - }); - - it("propagates reader errors", async () => { - const error = new Error("reader failed"); - const loader = createSelectionLocationLoader(async () => { - throw error; - }); - - await expect(loader([{ fileURL: "image.jpg" }])).rejects.toBe(error); - }); -}); -``` - -- [ ] **Step 2: Verify loader tests fail** - -Run: `pnpm exec vitest run src/lib/selection-location-loader.test.ts` - -Expected: FAIL because `./selection-location-loader` does not exist. - -- [ ] **Step 3: Implement loader** - -Create `src/lib/selection-location-loader.ts` with the interfaces above. Use only `selection[0]?.fileURL`, pass through `options?.signal`, convert `null` to `no-gps`, and let exceptions propagate. - -- [ ] **Step 4: Verify loader tests pass** - -Run: `pnpm exec vitest run src/lib/selection-location-loader.test.ts` - -Expected: PASS, 4 tests. - -- [ ] **Step 5: Commit task** - -Run: - -```bash -git add src/lib/selection-location-loader.ts src/lib/selection-location-loader.test.ts -git commit -m "feat: add selection location loader" -``` - ---- - -### Task 3: React Selection Hook - -**Files:** - -- Modify: `src/types.ts` -- Modify: `src/hooks/use-eagle-selection.ts` -- Create: `src/hooks/use-eagle-selection.test.tsx` -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` - -**Interfaces:** - -- Consumes: - -```ts -loadSelectionLocation( - selection: readonly SelectedImage[], - options?: LoadSelectionLocationOptions, -): Promise; -``` - -- Produces: existing hook return shape remains: - -```ts -{ - state: LoadState; - coordinates: Coordinates | null; - errorMessage: string; -} -``` - -- [ ] **Step 1: Install hook test dependencies** - -Run: - -```bash -pnpm add -D @testing-library/react jsdom -``` - -Expected: `package.json` and `pnpm-lock.yaml` update. - -- [ ] **Step 2: Write the failing hook tests** - -Create `src/hooks/use-eagle-selection.test.tsx` using `// @vitest-environment jsdom`. Mock `../eagle`, `../eagle/env`, and `../lib/selection-location-loader`. Cover initial ready state, stale result ignoring, abort-error silence, and unmount abort. - -- [ ] **Step 3: Verify hook tests fail** - -Run: `pnpm exec vitest run src/hooks/use-eagle-selection.test.tsx` - -Expected: FAIL because the current hook still imports `resolveImageLocation` and does not use `loadSelectionLocation`. - -- [ ] **Step 4: Add discriminated state type** - -Modify `src/types.ts`: - -```ts -export type LoadState = - | "loading" - | "ready" - | "no-selection" - | "no-gps" - | "error"; - -export interface Coordinates { - latitude: number; - longitude: number; - altitude?: number; -} - -export type SelectionLocationState = - | { status: "loading" } - | { status: "ready"; coordinates: Coordinates } - | { status: "no-selection" } - | { status: "no-gps" } - | { status: "error"; message: string }; -``` - -- [ ] **Step 5: Implement hook** - -Modify `src/hooks/use-eagle-selection.ts` to remove `fetchBinary`, `resolveItemLocation`, and `resolveImageLocation`. Use `loadSelectionLocation`, one `SelectionLocationState`, `AbortController`, request generation guards, unmount cleanup, silent obsolete `AbortError`, and the existing returned shape. - -- [ ] **Step 6: Verify hook tests pass** - -Run: `pnpm exec vitest run src/hooks/use-eagle-selection.test.tsx` - -Expected: PASS. - -- [ ] **Step 7: Commit task** - -Run: - -```bash -git add package.json pnpm-lock.yaml src/types.ts src/hooks/use-eagle-selection.ts src/hooks/use-eagle-selection.test.tsx -git commit -m "feat: make Eagle selection loading race-safe" -``` - ---- - -### Task 4: Remove Old Parser Path and Verify - -**Files:** - -- Delete: `src/lib/location.ts` -- Delete: `src/lib/location.test.ts` -- Delete: `src/lib/__snapshots__/location.test.ts.snap` -- Modify: `package.json` -- Modify: `pnpm-lock.yaml` - -**Interfaces:** - -- No consumers may import `src/lib/location.ts`. -- `image-location-reader` is the only EXIF parsing module. - -- [ ] **Step 1: Confirm old parser has no call sites** - -Run: - -```bash -rg -n "resolveImageLocation|convertDecimalToGps|formatGpsTime|formatGpsCoordinate|src/lib/location|\\.\\/location|\\.\\.\\/lib/location" src -``` - -Expected: no production call sites after Task 3. - -- [ ] **Step 2: Delete old parser files** - -Remove `src/lib/location.ts`, `src/lib/location.test.ts`, and `src/lib/__snapshots__/location.test.ts.snap`. Remove `src/lib/__snapshots__` if it becomes empty. - -- [ ] **Step 3: Remove unused parser dependencies** - -Run: - -```bash -pnpm remove buffer exif-reader piexif-ts -``` - -Expected: `package.json` keeps `exifr` and removes `buffer`, `exif-reader`, and `piexif-ts`. - -- [ ] **Step 4: Run full verification** - -Run: - -```bash -pnpm test -pnpm run type-check -pnpm run lint:check -pnpm run build -``` - -Expected: all commands exit 0. - -- [ ] **Step 5: Commit task** - -Run: - -```bash -git add package.json pnpm-lock.yaml src/lib src/hooks src/types.ts -git commit -m "refactor: remove legacy EXIF parser" -``` - ---- - -## Plan Self-Review - -- Spec coverage: reader module, loader module, hook race handling, dependency removal, and verification commands are covered by Tasks 1-4. -- Placeholder scan: no `TBD`, `TODO`, or "implement later" markers are present. -- Type consistency: `ReadImageLocationOptions`, `SelectionLocationResult`, and `SelectionLocationState` names match across tasks. diff --git a/docs/superpowers/specs/2026-07-12-image-location-reader-design.md b/docs/superpowers/specs/2026-07-12-image-location-reader-design.md deleted file mode 100644 index ab4168a..0000000 --- a/docs/superpowers/specs/2026-07-12-image-location-reader-design.md +++ /dev/null @@ -1,284 +0,0 @@ -# Image Location Reader Refactor Design - -Date: 2026-07-12 -Branch: refactor/image-location-reader -Status: ready for written-spec review - -## Context - -The current selection flow mixes three concerns in `useEagleSelection`: - -- Eagle selection access. -- Binary file fetching from `item.fileURL`. -- EXIF GPS parsing and UI load state updates. - -The current EXIF parser in `src/lib/location.ts` also uses a shallow chain: - -- `ArrayBuffer` to binary string. -- `piexif-ts` load/dump. -- `Buffer` conversion. -- `exif-reader` parsing. -- local GPS conversion helpers. - -That makes the reader harder to test, keeps Node-oriented details in browser code, and spreads state and race handling into the React hook. - -## Goals - -- Make "read location from a file URL" an independent module. -- Keep the UI text, interactions, loading behavior, and map behavior unchanged. -- Replace the current parser chain with `exifr`, which has been verified in this repo with Vite dev, Chrome headless, and a Vite production build. -- Give React a single discriminated state value instead of parallel `state`, `coordinates`, and `errorMessage` state. -- Make stale async results harmless when selection changes, React StrictMode re-runs effects, or the hook unmounts. -- Add focused tests at the module interfaces. - -## Non-Goals - -- No visual redesign. -- No new coordinate formatting or validation policy. -- No Eagle API feature expansion beyond current selected-item loading. -- No map rendering changes. -- No persistence, caching, or background indexing. - -## Module Design - -### `src/lib/image-location-reader.ts` - -This is the deep module for reading GPS coordinates from an image resource URL. - -Primary interface: - -```ts -export interface ReadImageLocationOptions { - signal?: AbortSignal; -} - -export async function readImageLocation( - sourceUrl: string, - options?: ReadImageLocationOptions, -): Promise; -``` - -Behavior: - -- Fetches the resource with `cache: "no-store"`. -- Throws on non-OK HTTP responses and fetch/read failures. -- Parses GPS metadata from the fetched `ArrayBuffer`. -- Returns `Coordinates` when latitude and longitude are present. -- Returns `null` for a valid image that has no complete GPS location. -- Preserves altitude when present, including negative altitude if the metadata marks it below sea level. -- Honors `AbortSignal` during the fetch/read stage. - -`exifr` usage: - -Use `exifr.parse`, not `exifr.gps`, because this app needs altitude. The tested option shape is: - -```ts -pick: [ - "GPSLatitude", - "GPSLatitudeRef", - "GPSLongitude", - "GPSLongitudeRef", - "GPSAltitude", - "GPSAltitudeRef", -]; -``` - -Do not pick `latitude` and `longitude` directly. They are derived fields in `exifr`; selecting only those fields did not trigger the source GPS fields in the browser verification. - -Internal test interface: - -The production path exposes `readImageLocation(sourceUrl, options)`. Tests that simulate read failure or abort behavior without global fetch patching use a small module-local factory: - -```ts -type BinaryReader = ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; - -export function createImageLocationReader( - readBinary: BinaryReader, -): typeof readImageLocation; -``` - -The app imports only `readImageLocation`. - -### `src/lib/selection-location-loader.ts` - -This module translates "current Eagle selection" into a domain result. It should know about the minimal selected item shape, not the full Eagle `Item`. - -Primary app interface: - -```ts -export type SelectedImage = Pick; - -export type SelectionLocationResult = - | { status: "no-selection" } - | { status: "no-gps" } - | { status: "ready"; coordinates: Coordinates }; - -export async function loadSelectionLocation( - selection: readonly SelectedImage[], - options?: { signal?: AbortSignal }, -): Promise; -``` - -Internal test interface: - -```ts -type ReadImageLocation = ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; - -export function createSelectionLocationLoader( - readLocation: ReadImageLocation, -): typeof loadSelectionLocation; -``` - -The app imports only `loadSelectionLocation`. Tests use `createSelectionLocationLoader` to avoid coupling loader behavior to fetch or EXIF parsing. - -Behavior: - -- Uses only the first selected item, matching current plugin behavior and `multiSelect: false`. -- Returns `no-selection` when the selection is empty. -- Calls `readImageLocation(item.fileURL, { signal })` for the selected image. -- Converts `null` from the reader into `no-gps`. -- Lets read/parse exceptions propagate to the hook, where they become UI error state. - -### `src/hooks/use-eagle-selection.ts` - -The hook remains the bridge between Eagle events and React state. - -Internal state: - -```ts -export type SelectionLocationState = - | { status: "loading" } - | { status: "no-selection" } - | { status: "no-gps" } - | { status: "ready"; coordinates: Coordinates } - | { status: "error"; message: string }; -``` - -React behavior: - -- Set `loading` before each new selection load, preserving current visible behavior. -- Create an `AbortController` per request. -- Abort the previous request before starting the next one. -- Keep a request generation counter so a completed older request cannot overwrite a newer state. -- On unmount, abort the current request and block future state updates. -- Treat `AbortError` from an obsolete request as silent. -- Log the original unexpected error and expose the same safe message style through UI state. - -The returned hook shape can temporarily preserve the existing app contract if that keeps the UI diff small: - -```ts -return { - state: current.status, - coordinates: current.status === "ready" ? current.coordinates : null, - errorMessage: current.status === "error" ? current.message : "", -}; -``` - -If the app consumer is updated to consume the discriminated union directly, the rendered text and behavior must stay unchanged. - -## Data Flow - -```text -Eagle item - fileURL - | - v -selection-location-loader - selected image URL - | - v -image-location-reader - fetch resource -> ArrayBuffer -> exifr GPS metadata - | - v -Coordinates | null - | - v -use-eagle-selection - SelectionLocationState - | - v -React UI -``` - -## Web and Eagle Constraints - -- `exifr@7.1.3` provides an ESM browser entry and was verified with this repo's Vite setup. -- Browser verification used `fetch` to load the existing JPG fixture, converted it to `ArrayBuffer`, and parsed it with `exifr.parse`. -- The verified browser result contained latitude, longitude, and altitude. -- Generic browser support for `file:` URLs remains inconsistent. This refactor does not broaden that surface; it preserves the current Eagle behavior of fetching `item.fileURL`. -- The Vite mock path continues to use an HTTP-served fixture URL, so local web development remains supported. - -## Error Handling - -- Empty selection: `no-selection`. -- Valid image without complete GPS: `no-gps`. -- Fetch non-OK, fetch rejection, malformed metadata parser failure: `error`. -- Obsolete aborted request: no UI transition. -- Latest request abort caused by unmount: no UI transition. -- Unexpected non-Error throw: convert to `"Unexpected error"` for UI. - -## Dependency Changes - -After migration, remove dependencies that are no longer used: - -- `buffer` -- `exif-reader` -- `piexif-ts` - -Keep `exifr`. - -Remove obsolete exported helpers from `src/lib/location.ts` if no call sites remain: - -- `convertDecimalToGps` -- `formatGpsTime` -- `formatGpsCoordinate` - -## Testing Strategy - -Use TDD for implementation. - -Reader tests: - -- Existing JPG fixture returns the same latitude, longitude, and altitude. -- GPS-less thumbnail PNG returns `null`. -- Fetch/read failure rejects. -- Abort during fetch rejects with abort behavior and does not parse. - -Loader tests: - -- Empty selection returns `no-selection`. -- Selected image with GPS returns `ready`. -- Selected image without GPS returns `no-gps`. -- Reader exception propagates. -- The loader needs only `{ fileURL }`, not full Eagle item data. - -Hook tests: - -- Initial load moves through `loading` into the resolved state. -- Newer request wins over stale older request. -- Abort errors from obsolete requests do not render `error`. -- Unmount prevents state updates after an in-flight request resolves. -- StrictMode-style double effect does not leave duplicate state writes visible to the UI. - -Verification commands: - -- `pnpm test` -- `pnpm run type-check` -- `pnpm run lint:check` -- `pnpm run build` - -## Acceptance Criteria - -- The app renders the same user-facing states as before. -- The location reader can be understood and tested without reading the React hook. -- The React hook no longer performs binary file reading or EXIF parsing directly. -- Selection changes cannot be overwritten by stale async results. -- Production build does not require Node `Buffer` polyfills for location parsing. -- The old parser dependencies are removed when unused. From c9d7980f59aa28d9c51ff3d0d7408206248e7bf1 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:21:21 +0800 Subject: [PATCH 10/14] refactor: simplify image location pipeline --- src/lib/binary-reader.test.ts | 36 ++++++ src/lib/binary-reader.ts | 19 +++ src/lib/image-location-reader.exifr.test.ts | 17 +-- src/lib/image-location-reader.test.ts | 52 +------- src/lib/image-location-reader.ts | 126 ++++---------------- src/lib/selection-location-loader.test.ts | 92 +++++++++----- src/lib/selection-location-loader.ts | 46 +++---- 7 files changed, 168 insertions(+), 220 deletions(-) create mode 100644 src/lib/binary-reader.test.ts create mode 100644 src/lib/binary-reader.ts diff --git a/src/lib/binary-reader.test.ts b/src/lib/binary-reader.test.ts new file mode 100644 index 0000000..8626c3e --- /dev/null +++ b/src/lib/binary-reader.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readBinaryFromUrl } from "./binary-reader"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("readBinaryFromUrl", () => { + it("returns the response body and forwards the abort signal", async () => { + const binary = new ArrayBuffer(4); + const controller = new AbortController(); + const fetchMock = vi.fn(() => + Promise.resolve(new Response(binary, { status: 200 })), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + readBinaryFromUrl("/image.jpg", { signal: controller.signal }), + ).resolves.toEqual(binary); + expect(fetchMock).toHaveBeenCalledWith("/image.jpg", { + cache: "no-store", + signal: controller.signal, + }); + }); + + it("rejects non-OK responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + + await expect(readBinaryFromUrl("/missing.jpg")).rejects.toThrow( + "Failed to fetch source (404)", + ); + }); +}); diff --git a/src/lib/binary-reader.ts b/src/lib/binary-reader.ts new file mode 100644 index 0000000..bf8328d --- /dev/null +++ b/src/lib/binary-reader.ts @@ -0,0 +1,19 @@ +export interface ReadBinaryOptions { + signal?: AbortSignal; +} + +export async function readBinaryFromUrl( + sourceUrl: string, + options?: ReadBinaryOptions, +): Promise { + const response = await fetch(sourceUrl, { + cache: "no-store", + signal: options?.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch source (${response.status})`); + } + + return response.arrayBuffer(); +} diff --git a/src/lib/image-location-reader.exifr.test.ts b/src/lib/image-location-reader.exifr.test.ts index f74606a..cd0f40d 100644 --- a/src/lib/image-location-reader.exifr.test.ts +++ b/src/lib/image-location-reader.exifr.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createImageLocationReader } from "./image-location-reader"; +import { parseImageLocation } from "./image-location-reader"; const mocks = vi.hoisted(() => { type ExifrParse = ( @@ -22,21 +22,16 @@ beforeEach(() => { mocks.parse.mockReset(); }); -describe("readImageLocation EXIF metadata mapping", () => { - it("converts raw GPS tags when derived coordinates are absent", async () => { +describe("parseImageLocation EXIF metadata mapping", () => { + it("maps exifr coordinates and below-sea-level altitude", async () => { mocks.parse.mockResolvedValue({ - GPSLatitude: [35, 30, 0], - GPSLatitudeRef: "S", - GPSLongitude: [139, 15, 0], - GPSLongitudeRef: "W", + latitude: -35.5, + longitude: -139.25, GPSAltitude: 12, GPSAltitudeRef: Uint8Array.from([1]), }); - const reader = createImageLocationReader(() => - Promise.resolve(new ArrayBuffer(0)), - ); - await expect(reader("raw-gps.jpg")).resolves.toEqual({ + await expect(parseImageLocation(new ArrayBuffer(0))).resolves.toEqual({ latitude: -35.5, longitude: -139.25, altitude: -12, diff --git a/src/lib/image-location-reader.test.ts b/src/lib/image-location-reader.test.ts index d241863..7165753 100644 --- a/src/lib/image-location-reader.test.ts +++ b/src/lib/image-location-reader.test.ts @@ -1,10 +1,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import { - createImageLocationReader, - readImageLocation, -} from "./image-location-reader"; +import { describe, expect, it } from "vitest"; +import { parseImageLocation } from "./image-location-reader"; const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); @@ -16,12 +13,11 @@ async function readArrayBuffer(filePath: string): Promise { return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); } -describe("readImageLocation", () => { +describe("parseImageLocation", () => { it("reads decimal coordinates and altitude from a JPEG", async () => { const imageData = await readArrayBuffer(imageFixture); - const reader = createImageLocationReader(() => Promise.resolve(imageData)); - await expect(reader("fixture.jpg")).resolves.toEqual({ + await expect(parseImageLocation(imageData)).resolves.toEqual({ latitude: 35.702755186666664, longitude: 139.77182481666668, altitude: 57.75, @@ -30,45 +26,7 @@ describe("readImageLocation", () => { it("returns null when an image has no readable GPS metadata", async () => { const imageData = await readArrayBuffer(thumbnailFixture); - const reader = createImageLocationReader(() => Promise.resolve(imageData)); - await expect(reader("thumbnail.png")).resolves.toBeNull(); - }); - - it("rejects read failures", async () => { - const error = new Error("cannot read source"); - const reader = createImageLocationReader(() => Promise.reject(error)); - - await expect(reader("broken.jpg")).rejects.toBe(error); - }); - - it("passes the abort signal to the binary reader", async () => { - const controller = new AbortController(); - const abortError = new DOMException("Aborted", "AbortError"); - const reader = createImageLocationReader((_sourceUrl, options) => { - expect(options?.signal).toBe(controller.signal); - return Promise.reject(abortError); - }); - - controller.abort(); - - await expect( - reader("slow.jpg", { signal: controller.signal }), - ).rejects.toBe(abortError); - }); - - it("rejects non-OK fetch responses in the production reader", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn(() => - Promise.resolve(new Response(null, { status: 404 })), - ); - - try { - await expect(readImageLocation("/missing.jpg")).rejects.toThrow( - "Failed to fetch source (404)", - ); - } finally { - globalThis.fetch = originalFetch; - } + await expect(parseImageLocation(imageData)).resolves.toBeNull(); }); }); diff --git a/src/lib/image-location-reader.ts b/src/lib/image-location-reader.ts index 1b605c1..473f392 100644 --- a/src/lib/image-location-reader.ts +++ b/src/lib/image-location-reader.ts @@ -1,15 +1,13 @@ import exifr from "exifr"; import type { Coordinates } from "../types"; -export interface ReadImageLocationOptions { - signal?: AbortSignal; +interface ExifLocationMetadata { + latitude?: number; + longitude?: number; + GPSAltitude?: number; + GPSAltitudeRef?: Uint8Array; } -export type BinaryReader = ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; - const GPS_TAGS = [ "GPSLatitude", "GPSLatitudeRef", @@ -19,124 +17,42 @@ const GPS_TAGS = [ "GPSAltitudeRef", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isUnknownFileFormat(error: unknown): boolean { - return error instanceof Error && error.message === "Unknown file format"; -} - -function isBelowSeaLevel(ref: unknown): boolean { - if (typeof ref === "number") { - return ref === 1; - } - - if (ref instanceof Uint8Array || Array.isArray(ref)) { - return ref[0] === 1; - } - - return isRecord(ref) && ref["0"] === 1; -} - -function gpsCoordinateToDecimal( - coordinate: unknown, - ref: unknown, -): number | null { - if (!Array.isArray(coordinate) || coordinate.length < 3) { +function toCoordinates(metadata?: ExifLocationMetadata): Coordinates | null { + if (metadata?.latitude === undefined || metadata.longitude === undefined) { return null; } - const coordinateParts = coordinate as readonly unknown[]; - const degrees = coordinateParts[0]; - const minutes = coordinateParts[1]; - const seconds = coordinateParts[2]; - - if ( - typeof degrees !== "number" || - typeof minutes !== "number" || - typeof seconds !== "number" || - typeof ref !== "string" - ) { - return null; - } - - const decimal = degrees + minutes / 60 + seconds / 3600; - return ref === "S" || ref === "W" ? -decimal : decimal; -} - -function toCoordinates(metadata: unknown): Coordinates | null { - if (!isRecord(metadata)) { - return null; - } - - const { GPSAltitude, GPSAltitudeRef } = metadata; - const latitude = - typeof metadata.latitude === "number" - ? metadata.latitude - : gpsCoordinateToDecimal(metadata.GPSLatitude, metadata.GPSLatitudeRef); - const longitude = - typeof metadata.longitude === "number" - ? metadata.longitude - : gpsCoordinateToDecimal(metadata.GPSLongitude, metadata.GPSLongitudeRef); - - if (latitude === null || longitude === null) { - return null; - } - - if (typeof GPSAltitude !== "number") { - return { latitude, longitude }; + if (metadata.GPSAltitude === undefined) { + return { + latitude: metadata.latitude, + longitude: metadata.longitude, + }; } return { - latitude, - longitude, - altitude: isBelowSeaLevel(GPSAltitudeRef) ? -GPSAltitude : GPSAltitude, + latitude: metadata.latitude, + longitude: metadata.longitude, + altitude: + metadata.GPSAltitudeRef?.[0] === 1 + ? -metadata.GPSAltitude + : metadata.GPSAltitude, }; } -async function parseImageLocation( +export async function parseImageLocation( imageArrayBuffer: ArrayBuffer, ): Promise { try { const metadata = (await exifr.parse(imageArrayBuffer, { pick: [...GPS_TAGS], - })) as unknown; + })) as ExifLocationMetadata | undefined; return toCoordinates(metadata); } catch (error) { - if (isUnknownFileFormat(error)) { + if (error instanceof Error && error.message === "Unknown file format") { return null; } throw error; } } - -async function readBinaryFromUrl( - sourceUrl: string, - options?: ReadImageLocationOptions, -): Promise { - const response = await fetch(sourceUrl, { - cache: "no-store", - signal: options?.signal, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch source (${response.status})`); - } - - return response.arrayBuffer(); -} - -export function createImageLocationReader(readBinary: BinaryReader) { - return async ( - sourceUrl: string, - options?: ReadImageLocationOptions, - ): Promise => { - const imageArrayBuffer = await readBinary(sourceUrl, options); - return parseImageLocation(imageArrayBuffer); - }; -} - -export const readImageLocation = createImageLocationReader(readBinaryFromUrl); diff --git a/src/lib/selection-location-loader.test.ts b/src/lib/selection-location-loader.test.ts index 6615a60..19aac12 100644 --- a/src/lib/selection-location-loader.test.ts +++ b/src/lib/selection-location-loader.test.ts @@ -1,6 +1,25 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Coordinates } from "../types"; -import { createSelectionLocationLoader } from "./selection-location-loader"; +import { loadSelectionLocation } from "./selection-location-loader"; + +const mocks = vi.hoisted(() => { + type ParseImageLocation = + typeof import("./image-location-reader").parseImageLocation; + type ReadBinaryFromUrl = typeof import("./binary-reader").readBinaryFromUrl; + + return { + parseImageLocation: vi.fn(), + readBinaryFromUrl: vi.fn(), + }; +}); + +vi.mock("./binary-reader", () => ({ + readBinaryFromUrl: mocks.readBinaryFromUrl, +})); + +vi.mock("./image-location-reader", () => ({ + parseImageLocation: mocks.parseImageLocation, +})); const coordinates: Coordinates = { latitude: 35.702755186666664, @@ -8,45 +27,62 @@ const coordinates: Coordinates = { altitude: 57.75, }; -describe("loadSelectionLocation", () => { - it("returns no-selection for an empty selection", async () => { - const loader = createSelectionLocationLoader(() => - Promise.reject(new Error("reader should not be called")), - ); +beforeEach(() => { + mocks.parseImageLocation.mockReset(); + mocks.readBinaryFromUrl.mockReset(); +}); - await expect(loader([])).resolves.toEqual({ status: "no-selection" }); +describe("loadSelectionLocation", () => { + it("returns no-selection without reading a file", async () => { + await expect(loadSelectionLocation([])).resolves.toEqual({ + status: "no-selection", + }); + expect(mocks.readBinaryFromUrl).not.toHaveBeenCalled(); }); - it("loads the first selected file URL", async () => { + it("reads and parses the first selected file", async () => { + const binary = new ArrayBuffer(4); const controller = new AbortController(); - const calls: Array<{ sourceUrl: string; signal?: AbortSignal }> = []; - const loader = createSelectionLocationLoader((sourceUrl, options) => { - calls.push({ sourceUrl, signal: options?.signal }); - return Promise.resolve(coordinates); - }); + mocks.readBinaryFromUrl.mockResolvedValue(binary); + mocks.parseImageLocation.mockResolvedValue(coordinates); await expect( - loader([{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], { - signal: controller.signal, - }), + loadSelectionLocation( + [{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], + { signal: controller.signal }, + ), ).resolves.toEqual({ status: "ready", coordinates }); - expect(calls).toEqual([ - { sourceUrl: "first.jpg", signal: controller.signal }, - ]); + expect(mocks.readBinaryFromUrl).toHaveBeenCalledWith("first.jpg", { + signal: controller.signal, + }); + expect(mocks.parseImageLocation).toHaveBeenCalledWith(binary); }); - it("returns no-gps when the reader returns null", async () => { - const loader = createSelectionLocationLoader(() => Promise.resolve(null)); + it("returns no-gps when the parser returns null", async () => { + mocks.readBinaryFromUrl.mockResolvedValue(new ArrayBuffer(0)); + mocks.parseImageLocation.mockResolvedValue(null); - await expect(loader([{ fileURL: "image.jpg" }])).resolves.toEqual({ - status: "no-gps", - }); + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).resolves.toEqual({ status: "no-gps" }); }); - it("propagates reader errors", async () => { + it("propagates binary read errors", async () => { const error = new Error("reader failed"); - const loader = createSelectionLocationLoader(() => Promise.reject(error)); + mocks.readBinaryFromUrl.mockRejectedValue(error); - await expect(loader([{ fileURL: "image.jpg" }])).rejects.toBe(error); + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).rejects.toBe(error); + }); + + it("propagates location parse errors", async () => { + const error = new Error("parser failed"); + mocks.readBinaryFromUrl.mockResolvedValue(new ArrayBuffer(0)); + mocks.parseImageLocation.mockRejectedValue(error); + + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).rejects.toBe(error); }); }); diff --git a/src/lib/selection-location-loader.ts b/src/lib/selection-location-loader.ts index a297849..3c65496 100644 --- a/src/lib/selection-location-loader.ts +++ b/src/lib/selection-location-loader.ts @@ -1,8 +1,6 @@ import type { Coordinates } from "../types"; -import { - readImageLocation, - type ReadImageLocationOptions, -} from "./image-location-reader"; +import { readBinaryFromUrl } from "./binary-reader"; +import { parseImageLocation } from "./image-location-reader"; export interface SelectedImage { fileURL: string; @@ -17,32 +15,22 @@ export type SelectionLocationResult = | { status: "no-gps" } | { status: "ready"; coordinates: Coordinates }; -type ReadImageLocation = ( - sourceUrl: string, - options?: ReadImageLocationOptions, -) => Promise; +export async function loadSelectionLocation( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, +): Promise { + if (selection.length === 0) { + return { status: "no-selection" }; + } -export function createSelectionLocationLoader(readLocation: ReadImageLocation) { - return async ( - selection: readonly SelectedImage[], - options?: LoadSelectionLocationOptions, - ): Promise => { - if (selection.length === 0) { - return { status: "no-selection" }; - } + const binary = await readBinaryFromUrl(selection[0].fileURL, { + signal: options?.signal, + }); + const coordinates = await parseImageLocation(binary); - const selectedImage = selection[0]; - const coordinates = await readLocation(selectedImage.fileURL, { - signal: options?.signal, - }); + if (!coordinates) { + return { status: "no-gps" }; + } - if (!coordinates) { - return { status: "no-gps" }; - } - - return { status: "ready", coordinates }; - }; + return { status: "ready", coordinates }; } - -export const loadSelectionLocation = - createSelectionLocationLoader(readImageLocation); From d0853821a13b0aee4bd1026c1523832d54ba1d5a Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:53:24 +0800 Subject: [PATCH 11/14] refactor: simplify selection request guards --- src/hooks/use-eagle-selection.test.tsx | 23 +++++++++++++++++++++++ src/hooks/use-eagle-selection.ts | 26 ++++++++------------------ 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index c97ecbe..d052421 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -205,6 +205,29 @@ describe("useEagleSelection", () => { consoleError.mockRestore(); }); + it("reports an AbortError from the active request", async () => { + const error = new Error("Parser aborted unexpectedly"); + error.name = "AbortError"; + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockRejectedValue(error); + + const { result } = renderHook(() => useEagleSelection()); + + triggerPluginCreate(); + + await waitFor(() => expect(result.current.state).toBe("error")); + expect(result.current.errorMessage).toBe("Parser aborted unexpectedly"); + expect(consoleError).toHaveBeenCalledWith( + "Failed to load Eagle selection", + error, + ); + + consoleError.mockRestore(); + }); + it("aborts an in-flight request on unmount", async () => { const pendingResult = deferred(); let requestSignal: AbortSignal | undefined; diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 592ebb5..71adaea 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -4,18 +4,6 @@ import { IN_EAGLE } from "../eagle/env"; import { loadSelectionLocation } from "../lib/selection-location-loader"; import type { Coordinates, LoadState, SelectionLocationState } from "../types"; -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Unexpected error"; -} - -function isSignalAborted(signal: AbortSignal): boolean { - return signal.aborted; -} - export function useEagleSelection() { const [selectionState, setSelectionState] = useState({ status: "loading", @@ -37,15 +25,17 @@ export function useEagleSelection() { const controller = new AbortController(); currentRequest.current = { id: requestId, controller }; - const isCurrentRequest = () => - isMounted.current && currentRequest.current?.id === requestId; + const isActiveRequest = () => + isMounted.current && + currentRequest.current?.id === requestId && + !controller.signal.aborted; setSelectionState({ status: "loading" }); try { const selection = await eagle.item.getSelected(); - if (!isCurrentRequest() || isSignalAborted(controller.signal)) { + if (!isActiveRequest()) { return; } @@ -53,20 +43,20 @@ export function useEagleSelection() { signal: controller.signal, }); - if (!isCurrentRequest() || isSignalAborted(controller.signal)) { + if (!isActiveRequest()) { return; } setSelectionState(result); } catch (error) { - if (!isCurrentRequest() || isAbortError(error)) { + if (!isActiveRequest()) { return; } console.error("Failed to load Eagle selection", error); setSelectionState({ status: "error", - message: toErrorMessage(error), + message: error instanceof Error ? error.message : "Unexpected error", }); } }, []); From 662df660b36359075742fbfd2e15bc4852c35b59 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:54:47 +0800 Subject: [PATCH 12/14] refactor: model Eagle selection events as store --- src/hooks/use-eagle-selection-refresh.ts | 51 ++++++++++++++ src/hooks/use-eagle-selection.test.tsx | 51 +++++++++++--- src/hooks/use-eagle-selection.ts | 88 ++++++++---------------- 3 files changed, 120 insertions(+), 70 deletions(-) create mode 100644 src/hooks/use-eagle-selection-refresh.ts diff --git a/src/hooks/use-eagle-selection-refresh.ts b/src/hooks/use-eagle-selection-refresh.ts new file mode 100644 index 0000000..00464d9 --- /dev/null +++ b/src/hooks/use-eagle-selection-refresh.ts @@ -0,0 +1,51 @@ +import { useSyncExternalStore } from "react"; +import { eagle } from "../eagle"; +import { IN_EAGLE } from "../eagle/env"; + +type RefreshVersion = number | null; + +const initialRefreshVersion: RefreshVersion = IN_EAGLE ? null : 0; + +let refreshVersion = initialRefreshVersion; +// Eagle has no matching unsubscribe API, so lifecycle events are registered once. +let isRegistered = false; + +const listeners = new Set<() => void>(); + +function emitRefresh(): void { + refreshVersion = (refreshVersion ?? 0) + 1; + listeners.forEach((listener) => listener()); +} + +function registerEagleEvents(): void { + if (!IN_EAGLE || isRegistered) { + return; + } + + isRegistered = true; + eagle.onPluginCreate(emitRefresh); + eagle.onPluginRun(emitRefresh); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + registerEagleEvents(); + + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): RefreshVersion { + return refreshVersion; +} + +export function useEagleSelectionRefresh(): RefreshVersion { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function resetEagleSelectionRefreshForTest(): void { + refreshVersion = initialRefreshVersion; + isRegistered = false; + listeners.clear(); +} diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index d052421..bb7d724 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom import { act, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode, type PropsWithChildren } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SelectionLocationResult } from "../lib/selection-location-loader"; import type { Coordinates } from "../types"; import { useEagleSelection } from "./use-eagle-selection"; +import { resetEagleSelectionRefreshForTest } from "./use-eagle-selection-refresh"; const mocks = vi.hoisted(() => { type MinimalItem = { fileURL: string }; @@ -20,8 +22,8 @@ const mocks = vi.hoisted(() => { return { callbacks: { - create: undefined as (() => void) | undefined, - run: undefined as (() => void) | undefined, + create: [] as Array<() => void>, + run: [] as Array<() => void>, }, getSelected: vi.fn<() => Promise>(), loadSelectionLocation: @@ -79,32 +81,37 @@ function deferred() { return { promise, resolve, reject }; } +function StrictModeWrapper({ children }: PropsWithChildren) { + return {children}; +} + function triggerPluginCreate(): void { - expect(mocks.callbacks.create).toBeTypeOf("function"); + expect(mocks.callbacks.create.length).toBeGreaterThan(0); act(() => { - mocks.callbacks.create?.(); + mocks.callbacks.create.forEach((callback) => callback()); }); } function triggerPluginRun(): void { - expect(mocks.callbacks.run).toBeTypeOf("function"); + expect(mocks.callbacks.run.length).toBeGreaterThan(0); act(() => { - mocks.callbacks.run?.(); + mocks.callbacks.run.forEach((callback) => callback()); }); } beforeEach(() => { - mocks.callbacks.create = undefined; - mocks.callbacks.run = undefined; + resetEagleSelectionRefreshForTest(); + mocks.callbacks.create = []; + mocks.callbacks.run = []; mocks.getSelected.mockReset(); mocks.loadSelectionLocation.mockReset(); mocks.onPluginCreate.mockReset(); mocks.onPluginRun.mockReset(); mocks.onPluginCreate.mockImplementation((callback) => { - mocks.callbacks.create = callback; + mocks.callbacks.create.push(callback); }); mocks.onPluginRun.mockImplementation((callback) => { - mocks.callbacks.run = callback; + mocks.callbacks.run.push(callback); }); }); @@ -135,6 +142,30 @@ describe("useEagleSelection", () => { expect(firstCall[1]?.signal).toBeInstanceOf(AbortSignal); }); + it("registers Eagle selection callbacks once across remounts", async () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const firstRender = renderHook(() => useEagleSelection(), { + wrapper: StrictModeWrapper, + }); + firstRender.unmount(); + + renderHook(() => useEagleSelection(), { wrapper: StrictModeWrapper }); + + expect(mocks.onPluginCreate).toHaveBeenCalledTimes(1); + expect(mocks.onPluginRun).toHaveBeenCalledTimes(1); + + triggerPluginRun(); + + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + }); + it("ignores an older result after a newer selection request finishes", async () => { const firstResult = deferred(); const secondResult = deferred(); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 71adaea..07ab3be 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -1,88 +1,56 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { eagle } from "../eagle"; -import { IN_EAGLE } from "../eagle/env"; import { loadSelectionLocation } from "../lib/selection-location-loader"; import type { Coordinates, LoadState, SelectionLocationState } from "../types"; +import { useEagleSelectionRefresh } from "./use-eagle-selection-refresh"; export function useEagleSelection() { + const refreshVersion = useEagleSelectionRefresh(); const [selectionState, setSelectionState] = useState({ status: "loading", }); - const currentRequest = useRef<{ - id: number; - controller: AbortController; - } | null>(null); - const isMounted = useRef(false); - const loadSelection = useCallback(async (): Promise => { - if (!isMounted.current) { + useEffect(() => { + if (refreshVersion === null) { return; } - currentRequest.current?.controller.abort(); - - const requestId = (currentRequest.current?.id ?? 0) + 1; const controller = new AbortController(); - currentRequest.current = { id: requestId, controller }; - - const isActiveRequest = () => - isMounted.current && - currentRequest.current?.id === requestId && - !controller.signal.aborted; - setSelectionState({ status: "loading" }); + async function loadSelection(): Promise { + setSelectionState({ status: "loading" }); - try { - const selection = await eagle.item.getSelected(); + try { + const selection = await eagle.item.getSelected(); - if (!isActiveRequest()) { - return; - } + const result = await loadSelectionLocation(selection, { + signal: controller.signal, + }); - const result = await loadSelectionLocation(selection, { - signal: controller.signal, - }); + if (controller.signal.aborted) { + return; + } - if (!isActiveRequest()) { - return; - } + setSelectionState(result); + } catch (error) { + if (controller.signal.aborted) { + return; + } - setSelectionState(result); - } catch (error) { - if (!isActiveRequest()) { - return; + console.error("Failed to load Eagle selection", error); + setSelectionState({ + status: "error", + message: error instanceof Error ? error.message : "Unexpected error", + }); } - - console.error("Failed to load Eagle selection", error); - setSelectionState({ - status: "error", - message: error instanceof Error ? error.message : "Unexpected error", - }); } - }, []); - useEffect(() => { - isMounted.current = true; - - const initialize = () => { - void loadSelection(); - }; - - if (IN_EAGLE) { - eagle.onPluginCreate(initialize); - eagle.onPluginRun(() => { - void loadSelection(); - }); - } else { - initialize(); - } + void loadSelection(); return () => { - isMounted.current = false; - currentRequest.current?.controller.abort(); - currentRequest.current = null; + controller.abort(); }; - }, [loadSelection]); + }, [refreshVersion]); const state: LoadState = selectionState.status; const coordinates: Coordinates | null = From 268ec4282efd6ba65da8d0baa8cdcbf0ae2e1a58 Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:11:57 +0800 Subject: [PATCH 13/14] refactor: model Eagle refresh as cancellable event --- src/hooks/create-event-hook.test.tsx | 70 +++++++++++++++++++ src/hooks/create-event-hook.ts | 41 +++++++++++ src/hooks/use-eagle-selection-refresh.ts | 62 +++++------------ src/hooks/use-eagle-selection.test.tsx | 87 ++++++++++++++---------- src/hooks/use-eagle-selection.ts | 57 ++++++---------- 5 files changed, 199 insertions(+), 118 deletions(-) create mode 100644 src/hooks/create-event-hook.test.tsx create mode 100644 src/hooks/create-event-hook.ts diff --git a/src/hooks/create-event-hook.test.tsx b/src/hooks/create-event-hook.test.tsx new file mode 100644 index 0000000..028aa41 --- /dev/null +++ b/src/hooks/create-event-hook.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEventHook } from "./create-event-hook"; + +afterEach(() => { + cleanup(); +}); + +describe("createEventHook", () => { + it("replays an earlier event and aborts it on unmount", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + event.emit(); + + const { unmount } = renderHook(() => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }), + ); + + expect(signals).toHaveLength(1); + expect(signals[0].aborted).toBe(false); + + unmount(); + + expect(signals[0].aborted).toBe(true); + }); + + it("aborts the previous handler before running the next event", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + + renderHook(() => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }), + ); + + act(() => event.emit()); + act(() => event.emit()); + + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it("cleans up an earlier mount before replaying the event", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + event.emit(); + + const useEvent = () => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }); + + const firstRender = renderHook(useEvent); + firstRender.unmount(); + renderHook(useEvent); + + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); +}); diff --git a/src/hooks/create-event-hook.ts b/src/hooks/create-event-hook.ts new file mode 100644 index 0000000..8016365 --- /dev/null +++ b/src/hooks/create-event-hook.ts @@ -0,0 +1,41 @@ +import { useEffect, useEffectEvent } from "react"; + +export type CancellableEventHandler = (signal: AbortSignal) => Promise; + +/** Replays the latest event to new subscribers and aborts superseded handlers. */ +export function createEventHook() { + let hasEmitted = false; + const listeners = new Set<() => void>(); + + function emit(): void { + hasEmitted = true; + listeners.forEach((listener) => listener()); + } + + function useEvent(handler: CancellableEventHandler): void { + const handleEvent = useEffectEvent(handler); + + useEffect(() => { + let controller: AbortController | undefined; + + const listener = () => { + controller?.abort(); + controller = new AbortController(); + void handleEvent(controller.signal); + }; + + listeners.add(listener); + + if (hasEmitted) { + listener(); + } + + return () => { + listeners.delete(listener); + controller?.abort(); + }; + }, []); + } + + return { emit, useEvent }; +} diff --git a/src/hooks/use-eagle-selection-refresh.ts b/src/hooks/use-eagle-selection-refresh.ts index 00464d9..470c5bc 100644 --- a/src/hooks/use-eagle-selection-refresh.ts +++ b/src/hooks/use-eagle-selection-refresh.ts @@ -1,51 +1,21 @@ -import { useSyncExternalStore } from "react"; import { eagle } from "../eagle"; import { IN_EAGLE } from "../eagle/env"; - -type RefreshVersion = number | null; - -const initialRefreshVersion: RefreshVersion = IN_EAGLE ? null : 0; - -let refreshVersion = initialRefreshVersion; -// Eagle has no matching unsubscribe API, so lifecycle events are registered once. -let isRegistered = false; - -const listeners = new Set<() => void>(); - -function emitRefresh(): void { - refreshVersion = (refreshVersion ?? 0) + 1; - listeners.forEach((listener) => listener()); -} - -function registerEagleEvents(): void { - if (!IN_EAGLE || isRegistered) { - return; - } - - isRegistered = true; - eagle.onPluginCreate(emitRefresh); - eagle.onPluginRun(emitRefresh); -} - -function subscribe(listener: () => void): () => void { - listeners.add(listener); - registerEagleEvents(); - - return () => { - listeners.delete(listener); - }; -} - -function getSnapshot(): RefreshVersion { - return refreshVersion; -} - -export function useEagleSelectionRefresh(): RefreshVersion { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +import { + createEventHook, + type CancellableEventHandler, +} from "./create-event-hook"; + +const selectionRefresh = createEventHook(); + +if (IN_EAGLE) { + eagle.onPluginCreate(selectionRefresh.emit); + eagle.onPluginRun(selectionRefresh.emit); +} else { + selectionRefresh.emit(); } -export function resetEagleSelectionRefreshForTest(): void { - refreshVersion = initialRefreshVersion; - isRegistered = false; - listeners.clear(); +export function useEagleSelectionRefresh( + handler: CancellableEventHandler, +): void { + selectionRefresh.useEvent(handler); } diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index bb7d724..3a68455 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -1,12 +1,20 @@ // @vitest-environment jsdom -import { act, renderHook, waitFor } from "@testing-library/react"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { StrictMode, type PropsWithChildren } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; import type { SelectionLocationResult } from "../lib/selection-location-loader"; import type { Coordinates } from "../types"; import { useEagleSelection } from "./use-eagle-selection"; -import { resetEagleSelectionRefreshForTest } from "./use-eagle-selection-refresh"; +import { useEagleSelectionRefresh } from "./use-eagle-selection-refresh"; const mocks = vi.hoisted(() => { type MinimalItem = { fileURL: string }; @@ -20,11 +28,13 @@ const mocks = vi.hoisted(() => { | { status: "no-gps" } | { status: "ready"; coordinates: MinimalCoordinates }; + const callbacks = { + create: [] as Array<() => void>, + run: [] as Array<() => void>, + }; + return { - callbacks: { - create: [] as Array<() => void>, - run: [] as Array<() => void>, - }, + callbacks, getSelected: vi.fn<() => Promise>(), loadSelectionLocation: vi.fn< @@ -33,8 +43,12 @@ const mocks = vi.hoisted(() => { options?: { signal?: AbortSignal }, ) => Promise >(), - onPluginCreate: vi.fn<(callback: () => void) => void>(), - onPluginRun: vi.fn<(callback: () => void) => void>(), + onPluginCreate: vi.fn<(callback: () => void) => void>((callback) => { + callbacks.create.push(callback); + }), + onPluginRun: vi.fn<(callback: () => void) => void>((callback) => { + callbacks.run.push(callback); + }), }; }); @@ -100,22 +114,30 @@ function triggerPluginRun(): void { } beforeEach(() => { - resetEagleSelectionRefreshForTest(); - mocks.callbacks.create = []; - mocks.callbacks.run = []; mocks.getSelected.mockReset(); mocks.loadSelectionLocation.mockReset(); - mocks.onPluginCreate.mockReset(); - mocks.onPluginRun.mockReset(); - mocks.onPluginCreate.mockImplementation((callback) => { - mocks.callbacks.create.push(callback); - }); - mocks.onPluginRun.mockImplementation((callback) => { - mocks.callbacks.run.push(callback); - }); +}); + +afterEach(() => { + cleanup(); +}); + +beforeAll(() => { + triggerPluginCreate(); }); describe("useEagleSelection", () => { + it("runs a refresh handler with an AbortSignal", async () => { + const handler = vi + .fn<(signal: AbortSignal) => Promise>() + .mockResolvedValue(undefined); + + renderHook(() => useEagleSelectionRefresh(handler)); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler.mock.calls[0][0]).toBeInstanceOf(AbortSignal); + }); + it("loads the selected image into the ready state", async () => { mocks.getSelected.mockResolvedValue([selected("image.jpg")]); mocks.loadSelectionLocation.mockResolvedValue({ @@ -131,8 +153,6 @@ describe("useEagleSelection", () => { errorMessage: "", }); - triggerPluginCreate(); - await waitFor(() => expect(result.current.state).toBe("ready")); expect(result.current.coordinates).toEqual(coordinates); expect(result.current.errorMessage).toBe(""); @@ -142,7 +162,7 @@ describe("useEagleSelection", () => { expect(firstCall[1]?.signal).toBeInstanceOf(AbortSignal); }); - it("registers Eagle selection callbacks once across remounts", async () => { + it("registers Eagle selection callbacks once across remounts", () => { mocks.getSelected.mockResolvedValue([selected("image.jpg")]); mocks.loadSelectionLocation.mockResolvedValue({ status: "ready", @@ -154,16 +174,14 @@ describe("useEagleSelection", () => { }); firstRender.unmount(); - renderHook(() => useEagleSelection(), { wrapper: StrictModeWrapper }); + const secondRender = renderHook(() => useEagleSelection(), { + wrapper: StrictModeWrapper, + }); expect(mocks.onPluginCreate).toHaveBeenCalledTimes(1); expect(mocks.onPluginRun).toHaveBeenCalledTimes(1); - triggerPluginRun(); - - await waitFor(() => - expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), - ); + secondRender.unmount(); }); it("ignores an older result after a newer selection request finishes", async () => { @@ -176,7 +194,6 @@ describe("useEagleSelection", () => { const { result } = renderHook(() => useEagleSelection()); - triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); @@ -214,7 +231,6 @@ describe("useEagleSelection", () => { const { result } = renderHook(() => useEagleSelection()); - triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); @@ -247,8 +263,6 @@ describe("useEagleSelection", () => { const { result } = renderHook(() => useEagleSelection()); - triggerPluginCreate(); - await waitFor(() => expect(result.current.state).toBe("error")); expect(result.current.errorMessage).toBe("Parser aborted unexpectedly"); expect(consoleError).toHaveBeenCalledWith( @@ -270,7 +284,6 @@ describe("useEagleSelection", () => { const { unmount } = renderHook(() => useEagleSelection()); - triggerPluginCreate(); await waitFor(() => expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), ); @@ -285,7 +298,7 @@ describe("useEagleSelection", () => { }); }); - it("ignores retained Eagle callbacks after unmount", () => { + it("ignores retained Eagle callbacks after unmount", async () => { mocks.getSelected.mockResolvedValue([selected("image.jpg")]); mocks.loadSelectionLocation.mockResolvedValue({ status: "ready", @@ -294,7 +307,11 @@ describe("useEagleSelection", () => { const { unmount } = renderHook(() => useEagleSelection()); + await waitFor(() => expect(mocks.getSelected).toHaveBeenCalledTimes(1)); + unmount(); + mocks.getSelected.mockClear(); + mocks.loadSelectionLocation.mockClear(); triggerPluginRun(); expect(mocks.getSelected).not.toHaveBeenCalled(); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 07ab3be..74cf081 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -1,56 +1,39 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { eagle } from "../eagle"; import { loadSelectionLocation } from "../lib/selection-location-loader"; import type { Coordinates, LoadState, SelectionLocationState } from "../types"; import { useEagleSelectionRefresh } from "./use-eagle-selection-refresh"; export function useEagleSelection() { - const refreshVersion = useEagleSelectionRefresh(); const [selectionState, setSelectionState] = useState({ status: "loading", }); - useEffect(() => { - if (refreshVersion === null) { - return; - } - - const controller = new AbortController(); - - async function loadSelection(): Promise { - setSelectionState({ status: "loading" }); - - try { - const selection = await eagle.item.getSelected(); - - const result = await loadSelectionLocation(selection, { - signal: controller.signal, - }); + useEagleSelectionRefresh(async (signal) => { + setSelectionState({ status: "loading" }); - if (controller.signal.aborted) { - return; - } + try { + const selection = await eagle.item.getSelected(); - setSelectionState(result); - } catch (error) { - if (controller.signal.aborted) { - return; - } + const result = await loadSelectionLocation(selection, { signal }); - console.error("Failed to load Eagle selection", error); - setSelectionState({ - status: "error", - message: error instanceof Error ? error.message : "Unexpected error", - }); + if (signal.aborted) { + return; } - } - void loadSelection(); + setSelectionState(result); + } catch (error) { + if (signal.aborted) { + return; + } - return () => { - controller.abort(); - }; - }, [refreshVersion]); + console.error("Failed to load Eagle selection", error); + setSelectionState({ + status: "error", + message: error instanceof Error ? error.message : "Unexpected error", + }); + } + }); const state: LoadState = selectionState.status; const coordinates: Coordinates | null = From 93cd87f39109deea63d9c7fd07be801132138d7d Mon Sep 17 00:00:00 2001 From: lawvs <18554747+lawvs@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:55:18 +0800 Subject: [PATCH 14/14] refactor: colocate Eagle selection refresh --- src/hooks/use-eagle-selection-refresh.ts | 21 --------------------- src/hooks/use-eagle-selection.test.tsx | 12 ------------ src/hooks/use-eagle-selection.ts | 19 ++++++++++++++++++- 3 files changed, 18 insertions(+), 34 deletions(-) delete mode 100644 src/hooks/use-eagle-selection-refresh.ts diff --git a/src/hooks/use-eagle-selection-refresh.ts b/src/hooks/use-eagle-selection-refresh.ts deleted file mode 100644 index 470c5bc..0000000 --- a/src/hooks/use-eagle-selection-refresh.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { eagle } from "../eagle"; -import { IN_EAGLE } from "../eagle/env"; -import { - createEventHook, - type CancellableEventHandler, -} from "./create-event-hook"; - -const selectionRefresh = createEventHook(); - -if (IN_EAGLE) { - eagle.onPluginCreate(selectionRefresh.emit); - eagle.onPluginRun(selectionRefresh.emit); -} else { - selectionRefresh.emit(); -} - -export function useEagleSelectionRefresh( - handler: CancellableEventHandler, -): void { - selectionRefresh.useEvent(handler); -} diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx index 3a68455..6603107 100644 --- a/src/hooks/use-eagle-selection.test.tsx +++ b/src/hooks/use-eagle-selection.test.tsx @@ -14,7 +14,6 @@ import { import type { SelectionLocationResult } from "../lib/selection-location-loader"; import type { Coordinates } from "../types"; import { useEagleSelection } from "./use-eagle-selection"; -import { useEagleSelectionRefresh } from "./use-eagle-selection-refresh"; const mocks = vi.hoisted(() => { type MinimalItem = { fileURL: string }; @@ -127,17 +126,6 @@ beforeAll(() => { }); describe("useEagleSelection", () => { - it("runs a refresh handler with an AbortSignal", async () => { - const handler = vi - .fn<(signal: AbortSignal) => Promise>() - .mockResolvedValue(undefined); - - renderHook(() => useEagleSelectionRefresh(handler)); - - await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); - expect(handler.mock.calls[0][0]).toBeInstanceOf(AbortSignal); - }); - it("loads the selected image into the ready state", async () => { mocks.getSelected.mockResolvedValue([selected("image.jpg")]); mocks.loadSelectionLocation.mockResolvedValue({ diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index 74cf081..163a9f5 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -1,8 +1,25 @@ import { useState } from "react"; import { eagle } from "../eagle"; +import { IN_EAGLE } from "../eagle/env"; import { loadSelectionLocation } from "../lib/selection-location-loader"; import type { Coordinates, LoadState, SelectionLocationState } from "../types"; -import { useEagleSelectionRefresh } from "./use-eagle-selection-refresh"; +import { + createEventHook, + type CancellableEventHandler, +} from "./create-event-hook"; + +const selectionRefresh = createEventHook(); + +if (IN_EAGLE) { + eagle.onPluginCreate(selectionRefresh.emit); + eagle.onPluginRun(selectionRefresh.emit); +} else { + selectionRefresh.emit(); +} + +function useEagleSelectionRefresh(handler: CancellableEventHandler): void { + selectionRefresh.useEvent(handler); +} export function useEagleSelection() { const [selectionState, setSelectionState] = useState({