From dad80b745a59ebf86681cc0193f9fcd221b8df1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:57:04 +0000 Subject: [PATCH] feat: location search, photo permission and spots commands - photos list/search: --near lat,lng, --radius (default 1000 m), --sort newest|rating|relevance|distance; photos list also takes --permission. Distance/relevance sorts are refused without the input they rank by. JSON and the table now carry each photo's access level and location. - photos permission sets private, protected or public, a few photos at a time, reporting each result and exiting 1 if any failed. - New spots group: list (optionally --near, --featured), view and sun --date for sunrise, sunset and golden hour in the spot's time zone. Works signed out. - Pin @rawback/sdk 0.3.4, the release carrying these operations. bun.lock is regenerated once that version is published. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KzhEsyVbVE2xHqLoqPpERK --- AGENTS.md | 24 ++-- README.md | 17 ++- docs/commands.md | 79 +++++++++++ package.json | 2 +- src/cli.ts | 206 ++++++++++++++++++++++++++- src/features/photos/view.ts | 36 ++++- src/features/spots/view.ts | 150 ++++++++++++++++++++ src/geo.ts | 40 ++++++ src/photos.ts | 154 ++++++++++++++++++++- src/spots.ts | 206 +++++++++++++++++++++++++++ test/commands-cli.test.ts | 61 ++++++++ test/photos.test.ts | 222 ++++++++++++++++++++++++++++- test/spots.test.ts | 268 ++++++++++++++++++++++++++++++++++++ 13 files changed, 1445 insertions(+), 20 deletions(-) create mode 100644 src/features/spots/view.ts create mode 100644 src/geo.ts create mode 100644 src/spots.ts create mode 100644 test/spots.test.ts diff --git a/AGENTS.md b/AGENTS.md index b18733c..e994553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,18 +31,18 @@ cannot run, report exactly which command was skipped and why. ## Layout -| Path | What | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/index.ts` | `#!/usr/bin/env bun` shim; delegates to `features/cli/runtime.ts` | -| `src/cli.ts` | The entire yargs command tree — 77 commands, options, validation, dispatch | -| `src/features/` | Per-domain presenters and UI controllers (`albums/ articles/ auth/ camera/ cli/ config/ credentials/ dreams/ memory/ photos/ pricing/ shares/ social/ upload/ uploads/ usage/ videos/`) | -| `src/ui/` | Ink rendering, formatting, and the `CommandOutput` stdout/stderr boundary | -| `src/camera*.ts` | CCAPI registry, session, store, liveview, events, settings | -| `src/upload*.ts`, `photo-check.ts`, `video-repair.ts` | Transfer pipeline | -| `src/api.ts`, `client.ts`, `http.ts`, `session.ts`, `credentials.ts`, `sftp-*.ts` | SDK adapters | -| `src/gql/` | Generated GraphQL client — never hand-edit | -| `test/` | 42 Bun test files, flat | -| `docs/` | `commands.md`, `configuration.md`, `development.md` | +| Path | What | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.ts` | `#!/usr/bin/env bun` shim; delegates to `features/cli/runtime.ts` | +| `src/cli.ts` | The entire yargs command tree — 81 commands, options, validation, dispatch | +| `src/features/` | Per-domain presenters and UI controllers (`albums/ articles/ auth/ camera/ cli/ config/ credentials/ dreams/ memory/ photos/ pricing/ shares/ social/ spots/ upload/ uploads/ usage/ videos/`) | +| `src/ui/` | Ink rendering, formatting, and the `CommandOutput` stdout/stderr boundary | +| `src/camera*.ts` | CCAPI registry, session, store, liveview, events, settings | +| `src/upload*.ts`, `photo-check.ts`, `video-repair.ts` | Transfer pipeline | +| `src/api.ts`, `client.ts`, `http.ts`, `session.ts`, `credentials.ts`, `sftp-*.ts` | SDK adapters | +| `src/gql/` | Generated GraphQL client — never hand-edit | +| `test/` | 43 Bun test files, flat | +| `docs/` | `commands.md`, `configuration.md`, `development.md` | ## Project snapshot diff --git a/README.md b/README.md index 47c07d2..f2b4949 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ in a browser. - Safely resume an interrupted upload and skip exact files already uploaded. - Check which local photo and RAW files are already in your Rawback library. - Find photos by describing them — "from 2012, all images in NYC" — or by - metadata, capture date, rating, location, and GPS data. + metadata, capture date, rating, access level, place, and distance from a point. +- Choose who can see each photo: private, any signed-in user, or public. +- Discover public photography Spots near you and plan sunrise, sunset and golden hour there. - Create and curate albums, smart filters, cover images, tags, and Markdown stories. - List and inspect daily AI-generated dream recaps, including their contributing photos. - Browse content shared with you and manage your outgoing share links. @@ -343,6 +345,17 @@ rawback photos list \ --captured-after 2026-01-01 \ --captured-before 2026-02-01 +# Photos taken within 2 km of a point, nearest first +rawback photos list --near 37.7749,-122.4194 --radius 2000 --sort distance + +# Publish photos (public also lists them in Spots), or list what you have published +rawback photos permission public 108 109 +rawback photos list --permission public + +# Find public photo spots nearby and plan the light at one +rawback spots list --near 37.7749,-122.4194 --radius 5000 +rawback spots sun 18-41928-101324 --date 2026-06-21 + # Upload up to eight files in parallel rawback photos upload --path ./photos --concurrency 8 @@ -388,7 +401,9 @@ and automation note. You can also ask the binary for context-specific help: rawback --help rawback photos search --help rawback photos list --help +rawback photos permission --help rawback photos upload --help +rawback spots --help rawback dream --help rawback config --help rawback album --help diff --git a/docs/commands.md b/docs/commands.md index e6a0188..552ca7f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -372,6 +372,9 @@ results — a summary line plus one field per filter it applied. | ---------------------- | ---------------------------------------------------------------- | ------- | | `` | What to look for, in plain language (required) | — | | `--ai-search-id ` | Reuse an earlier interpretation instead of spending an AI credit | — | +| `--near ` | Only photos taken near this point, in degrees | — | +| `--radius ` | Meters around `--near`, up to 500000 | `1000` | +| `--sort ` | `newest`, `rating`, or `distance` (needs `--near`) | newest | | `--page ` | Positive result page | `1` | | `--page-size ` | Results per page, from 1 through 100 | `24` | | `--json` | Print machine-readable JSON | `false` | @@ -424,6 +427,10 @@ rawback photos list [options] | `--city ` | Filter by city | — | | `--country ` | Filter by country | — | | `--has-gps` | Include only photos with GPS coordinates | `false` | +| `--near ` | Only photos taken near this point, in degrees | — | +| `--radius ` | Meters around `--near`, up to 500000 | `1000` | +| `--sort ` | `newest`, `rating`, `relevance`, or `distance` | newest | +| `--permission ` | Filter by access: `private`, `protected`, `public` | — | | `--page ` | Positive result page | `1` | | `--page-size ` | Results per page, from 1 through 100 | `24` | | `--json` | Print machine-readable JSON | `false` | @@ -445,6 +452,43 @@ wins over the AI's reading of the prompt. Supplying `--prompt` also drops the `3,4,5` rating default, for the reason given under [`rawback photos search`](#rawback-photos-search). +`--near` keeps photos whose GPS position lies within `--radius` meters of the +point; photos without GPS never match. `--sort distance` lists the nearest +first and needs `--near`; `--sort relevance` ranks by `--search` and needs it. +Both are refused rather than silently falling back to newest-first. + +```bash +rawback photos list --near 37.7749,-122.4194 --radius 2000 --sort distance +rawback photos list --permission public # everything you have published +``` + +`--permission` matches each photo's own access setting. A private photo in a +shared album is still `private` here, because album access is contextual. + +## `rawback photos permission` + +Sets who can see one or more photos: + +```bash +rawback photos permission +rawback photos permission public 12 13 14 +rawback photos permission private 12,13 --json +``` + +| Level | Who can see the photo | +| ----------- | -------------------------------------------------------------------------- | +| `private` | Only you. The default for every upload | +| `protected` | Anyone signed in to Rawback | +| `public` | Anyone, and the photo is listed in [Spots](#rawback-spots) when it has GPS | + +Albums and share links you created keep working for private photos: an album +you shared still shows its photos to the people you shared it with, whatever +each photo's own level. + +Each photo is updated separately. One failure does not stop the rest, but the +command exits `1` if any photo could not be updated. With `--json` the output is +`{ permission, results: [{ id, ok, permission, error }], succeeded, failed }`. + ## `rawback photos check` Checks whether supported local photo and RAW files are already in the @@ -503,6 +547,41 @@ EXIF capture time; duplicate-check failures do not block the SFTP upload. Local EXIF workers are controlled separately by `metadata.concurrency` in the shared configuration file. +## `rawback spots` + +Browses public photography Spots: places where Rawback photographers published +photos. Spots are public, so these commands also work signed out. + +```bash +rawback spots list [--near [--radius ]] [--featured] +rawback spots view +rawback spots sun --date +``` + +`spots list` options: + +| Option | Description | Default | +| ---------------------- | ----------------------------------------- | ------- | +| `--near ` | Only spots near this point, nearest first | — | +| `--radius ` | Meters around `--near`, up to 500000 | `1000` | +| `--featured` | Rank by community reactions instead | `false` | +| `--page ` | Positive result page | `1` | +| `--page-size ` | Spots per page, from 1 through 100 | `24` | +| `--json` | Print `spots` and `pageInfo` as JSON | `false` | + +A spot ID looks like `18-240801-157013`. `spots view ` lists the photos +published there with photographer, camera and exposure settings, and takes +`--page`, `--page-size` and `--json`. + +`spots sun --date 2026-06-21` prints sunrise, sunset, and the morning and +evening golden hours for that local date, in the spot's own time zone, with the +sun's azimuth at sunrise and sunset. `--photo-id ` uses one published +photo's exact position inside the spot. `--json` prints the times as RFC 3339 +timestamps next to the IANA `timezone`. + +To publish your own photos to Spots, set them `public` with +[`rawback photos permission`](#rawback-photos-permission). + ## `rawback videos` Lists, uploads, and manages videos. Videos upload directly to storage rather diff --git a/package.json b/package.json index 110003f..ef77858 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@inquirer/prompts": "^8.7.0", "@rawback/ccapi-js": "1.1.0", - "@rawback/sdk": "0.3.3", + "@rawback/sdk": "0.3.4", "ink": "^7.1.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", diff --git a/src/cli.ts b/src/cli.ts index 8be0cd4..910db13 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -326,7 +326,7 @@ export function createProgram(version: string, output = new CommandOutput()): Ar ) .command( 'photos', - 'search, list and upload photos', + 'search, list, share and upload photos', (command) => command .command( @@ -343,6 +343,19 @@ export function createProgram(version: string, output = new CommandOutput()): Ar "reuse a previous search's interpretation (from aiSearch.id) instead of spending another AI credit", type: 'string', }) + .option('near', { + describe: 'only photos taken near "latitude,longitude", e.g. 37.7749,-122.4194', + type: 'string', + }) + .option('radius', { + describe: 'meters around --near (default 1000, max 500000)', + type: 'number', + }) + .option('sort', { + choices: ['newest', 'rating', 'distance'] as const, + describe: 'order: distance needs --near', + type: 'string', + }) .option('page', { default: 1, describe: 'page number', @@ -368,6 +381,9 @@ export function createProgram(version: string, output = new CommandOutput()): Ar json: args.json, ...(args.prompt !== undefined ? { prompt: args.prompt } : {}), ...(args.aiSearchId !== undefined ? { aiSearchId: args.aiSearchId } : {}), + ...(args.near !== undefined ? { near: args.near } : {}), + ...(args.radius !== undefined ? { radius: args.radius } : {}), + ...(args.sort !== undefined ? { sort: args.sort } : {}), }), ) }, @@ -455,6 +471,25 @@ export function createProgram(version: string, output = new CommandOutput()): Ar describe: 'only include photos with GPS coordinates', type: 'boolean', }) + .option('near', { + describe: 'only photos taken near "latitude,longitude", e.g. 37.7749,-122.4194', + type: 'string', + }) + .option('radius', { + describe: 'meters around --near (default 1000, max 500000)', + type: 'number', + }) + .option('sort', { + choices: ['newest', 'rating', 'relevance', 'distance'] as const, + describe: 'order: distance needs --near, relevance needs --search', + type: 'string', + }) + .option('permission', { + array: true, + describe: + 'filter by access: private, protected, public (repeat or comma-separate)', + type: 'string', + }) .option('page', { default: 1, describe: 'result page', @@ -503,6 +538,43 @@ export function createProgram(version: string, output = new CommandOutput()): Ar ...(args.rate !== undefined ? { rate: args.rate } : {}), ...(args.city !== undefined ? { city: args.city } : {}), ...(args.country !== undefined ? { country: args.country } : {}), + ...(args.near !== undefined ? { near: args.near } : {}), + ...(args.radius !== undefined ? { radius: args.radius } : {}), + ...(args.sort !== undefined ? { sort: args.sort } : {}), + ...(args.permission !== undefined ? { permission: args.permission } : {}), + }), + ) + }, + ) + .command( + 'permission ', + 'set who can see photos: private, protected, or public', + (permission) => + permission + .positional('level', { + choices: ['private', 'protected', 'public'] as const, + describe: + 'private: only you; protected: any signed-in Rawback user; public: anyone, and listed in Spots when the photo has GPS', + type: 'string', + }) + .positional('image-ids', { + array: true, + describe: 'photo IDs (space- or comma-separated)', + type: 'string', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runPhotoPermission } = await import('./photos.ts') + await runCommand(() => + runPhotoPermission({ + imageIds: args.imageIds ?? [], + json: args.json, + level: args.level ?? '', }), ) }, @@ -577,7 +649,137 @@ export function createProgram(version: string, output = new CommandOutput()): Ar ) }, ) - .demandCommand(1, 'Choose a photos command: search, list, check, or upload') + .demandCommand(1, 'Choose a photos command: search, list, permission, check, or upload') + .strict(), + () => {}, + ) + .command( + 'spots', + 'discover public photography spots and plan the light', + (command) => + command + .command( + 'list', + 'list public spots, optionally near a point', + (list) => + list + .option('near', { + describe: 'only spots near "latitude,longitude", nearest first', + type: 'string', + }) + .option('radius', { + describe: 'meters around --near (default 1000, max 500000)', + type: 'number', + }) + .option('featured', { + default: false, + describe: 'rank by community reactions', + type: 'boolean', + }) + .option('page', { + default: 1, + describe: 'page number', + type: 'number', + }) + .option('page-size', { + default: 24, + describe: 'spots per page (1-100)', + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runSpotList } = await import('./spots.ts') + await runCommand(() => + runSpotList({ + featured: args.featured, + json: args.json, + page: args.page, + pageSize: args.pageSize, + ...(args.near !== undefined ? { near: args.near } : {}), + ...(args.radius !== undefined ? { radius: args.radius } : {}), + }), + ) + }, + ) + .command( + 'view ', + 'show a spot and the photos published there', + (view) => + view + .positional('id', { + describe: 'spot ID from rawback spots list, e.g. 18-206451-130118', + type: 'string', + }) + .option('page', { + default: 1, + describe: 'page of photos', + type: 'number', + }) + .option('page-size', { + default: 24, + describe: 'photos per page (1-100)', + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runSpotGet } = await import('./spots.ts') + await runCommand(() => + runSpotGet({ + id: args.id ?? '', + json: args.json, + page: args.page, + pageSize: args.pageSize, + }), + ) + }, + ) + .command( + 'sun ', + "sunrise, sunset and golden hour at a spot, in the spot's time zone", + (sun) => + sun + .positional('id', { + describe: 'spot ID from rawback spots list', + type: 'string', + }) + .option('date', { + demandOption: true, + describe: 'local date at the spot, YYYY-MM-DD', + type: 'string', + }) + .option('photo-id', { + describe: "use this photo's exact coordinates within the spot", + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runSpotSun } = await import('./spots.ts') + await runCommand(() => + runSpotSun({ + date: args.date, + id: args.id ?? '', + json: args.json, + ...(args.photoId !== undefined ? { photoId: args.photoId } : {}), + }), + ) + }, + ) + .demandCommand(1, 'Choose a spots command: list, view, or sun') .strict(), () => {}, ) diff --git a/src/features/photos/view.ts b/src/features/photos/view.ts index 5acbdff..dbaf078 100644 --- a/src/features/photos/view.ts +++ b/src/features/photos/view.ts @@ -1,4 +1,4 @@ -import { type PhotosQuery } from '@rawback/sdk' +import { type ImagePermission, type PhotosQuery } from '@rawback/sdk' import { formatTimestamp } from '../../ui/format.ts' import { type UiBlock, cell, type UiDocument } from '../../ui/model.ts' @@ -60,6 +60,7 @@ export function photoListDocument( { key: 'filename', label: 'Filename', required: true, priority: 1, minWidth: 12 }, { key: 'status', label: 'Status', priority: 2 }, { key: 'rating', label: 'Rating', priority: 5 }, + { key: 'access', label: 'Access', priority: 4 }, { key: 'captured', label: 'Captured', priority: 3, minWidth: 10 }, { key: 'camera', label: 'Camera', priority: 6, minWidth: 8 }, { key: 'dimensions', label: 'Dimensions', priority: 4 }, @@ -72,6 +73,9 @@ export function photoListDocument( }), rating: photo.rate === null || photo.rate === undefined ? cell('—', { dim: true }) : photo.rate, + access: cell(photo.permission, { + tone: photo.permission === 'public' ? 'info' : 'neutral', + }), captured: formatTimestamp(photo.capturedAt).slice(0, 10), camera: [photo.cameraMake, photo.cameraModel].filter(Boolean).join(' ') || @@ -93,3 +97,33 @@ export function photoListDocument( ], } } + +export function photoPermissionDocument( + permission: ImagePermission, + results: ReadonlyArray<{ id: number; ok: boolean; error: string | null }>, +): UiDocument { + const blocks: UiBlock[] = [ + { + type: 'table', + columns: [ + { key: 'id', label: 'ID', required: true, priority: 1 }, + { key: 'result', label: 'Result', required: true, priority: 1, minWidth: 12 }, + ], + rows: results.map((result) => ({ + id: result.id, + result: result.ok + ? cell(permission, { tone: 'success' }) + : cell(result.error ?? 'failed', { tone: 'error' }), + })), + }, + ] + if (permission === 'public' && results.some((result) => result.ok)) { + blocks.push({ + type: 'notice', + tone: 'info', + message: + 'Public photos are visible to anyone and appear in Spots when they carry GPS coordinates.', + }) + } + return { title: 'Photo access', blocks } +} diff --git a/src/features/spots/view.ts b/src/features/spots/view.ts new file mode 100644 index 0000000..928b049 --- /dev/null +++ b/src/features/spots/view.ts @@ -0,0 +1,150 @@ +import { type SpotSunPlanQuery, type SpotsQuery } from '@rawback/sdk' + +import type { Spot, SpotPhoto } from '../../spots.ts' +import { sanitizeCell } from '../../ui/format.ts' +import { type UiBlock, cell, type UiDocument, type UiField } from '../../ui/model.ts' + +type PageInfo = SpotsQuery['spots']['pageInfo'] +type SunPlan = SpotSunPlanQuery['spotSunPlan'] + +const dash = () => cell('—', { dim: true }) + +function coordinates(latitude: number, longitude: number): string { + return latitude.toFixed(5) + ', ' + longitude.toFixed(5) +} + +function pagination(count: number, pageInfo: PageInfo): UiBlock { + return { + type: 'pagination', + page: pageInfo.page, + pageSize: pageInfo.pageSize, + count, + totalCount: pageInfo.totalCount, + totalPages: pageInfo.totalPages, + } +} + +function exposure(photo: SpotPhoto): string { + return [ + photo.focalLength ? String(photo.focalLength) + 'mm' : '', + photo.aperture ? 'f/' + String(photo.aperture) : '', + photo.exposureTime ? sanitizeCell(photo.exposureTime) + 's' : '', + photo.iso ? 'ISO ' + String(photo.iso) : '', + ] + .filter(Boolean) + .join(' ') +} + +export function spotListDocument(spots: Spot[], pageInfo: PageInfo): UiDocument { + return { + title: 'Spots', + blocks: [ + { + type: 'table', + emptyMessage: 'No public spots found.', + columns: [ + // Never truncated: the ID is what `spots view` and `spots sun` take. + { key: 'id', label: 'ID', required: true, priority: 1, minWidth: 16 }, + { key: 'name', label: 'Place', required: true, priority: 1, minWidth: 12 }, + { key: 'photos', label: 'Photos', priority: 2 }, + { key: 'location', label: 'Location', priority: 3 }, + { key: 'photographer', label: 'Top photo by', priority: 4 }, + ], + rows: spots.map((spot) => ({ + id: spot.id, + name: sanitizeCell(spot.name), + photos: spot.photoCount, + location: coordinates(spot.latitude, spot.longitude), + photographer: sanitizeCell(spot.representative.photographerName) || dash(), + })), + }, + pagination(spots.length, pageInfo), + ], + } +} + +export function spotDocument(spot: Spot, photos: SpotPhoto[], pageInfo: PageInfo): UiDocument { + return { + title: sanitizeCell(spot.name), + blocks: [ + { + type: 'fields', + fields: [ + { label: 'ID', value: spot.id }, + { label: 'Location', value: coordinates(spot.latitude, spot.longitude) }, + { label: 'Photos', value: spot.photoCount }, + ], + }, + { + type: 'table', + emptyMessage: 'No photos on this page.', + columns: [ + { key: 'id', label: 'ID', required: true, priority: 1 }, + { key: 'title', label: 'Title', priority: 2, minWidth: 10 }, + { key: 'photographer', label: 'Photographer', priority: 2 }, + { key: 'camera', label: 'Camera', priority: 4 }, + { key: 'settings', label: 'Settings', priority: 3 }, + { key: 'reactions', label: 'Reactions', priority: 5 }, + ], + rows: photos.map((photo) => ({ + id: photo.id, + title: sanitizeCell(photo.title) || dash(), + photographer: sanitizeCell(photo.photographerName) || dash(), + camera: photo.camera ? sanitizeCell(photo.camera) : dash(), + settings: exposure(photo) || dash(), + reactions: photo.reactionCount, + })), + }, + pagination(photos.length, pageInfo), + ], + } +} + +/** A time rendered in the spot's own zone, which is what a shoot is planned in. */ +function localTime(value: string | null, timeZone: string): string | undefined { + if (!value) return undefined + const date = new Date(value) + if (Number.isNaN(date.getTime())) return undefined + try { + return date.toLocaleTimeString('en-GB', { timeZone, hour: '2-digit', minute: '2-digit' }) + } catch { + return date.toISOString().slice(11, 16) + ' UTC' + } +} + +function azimuth(value: number | null): string { + return value === null ? '' : ' (' + String(Math.round(value)) + '°)' +} + +export function sunPlanDocument(spotId: string, plan: SunPlan): UiDocument { + const tz = plan.timezone + const range = (start: string | null, end: string | null) => { + const from = localTime(start, tz) + const to = localTime(end, tz) + return from && to ? from + '–' + to : dash() + } + const fields: UiField[] = [ + { label: 'Spot', value: spotId }, + { label: 'Date', value: plan.date + ' (' + tz + ')' }, + { label: 'Location', value: coordinates(plan.latitude, plan.longitude) }, + ] + if (plan.state === 'polarDay' || plan.state === 'polarNight') { + fields.push({ + label: 'Sun', + value: plan.state === 'polarDay' ? 'up all day (polar day)' : 'down all day (polar night)', + }) + } else { + const sunrise = localTime(plan.sunrise, tz) + const sunset = localTime(plan.sunset, tz) + fields.push( + { label: 'Sunrise', value: sunrise ? sunrise + azimuth(plan.sunriseAzimuth) : dash() }, + { + label: 'Morning golden hour', + value: range(plan.morningGoldenHourStart, plan.morningGoldenHourEnd), + }, + { label: 'Evening golden hour', value: range(plan.goldenHourStart, plan.goldenHourEnd) }, + { label: 'Sunset', value: sunset ? sunset + azimuth(plan.sunsetAzimuth) : dash() }, + ) + } + return { title: 'Sun plan', blocks: [{ type: 'fields', fields }] } +} diff --git a/src/geo.ts b/src/geo.ts new file mode 100644 index 0000000..c1e845c --- /dev/null +++ b/src/geo.ts @@ -0,0 +1,40 @@ +import type { GeoNearInput } from '@rawback/sdk' + +/** The server's limit for `near.radiusMeters`, shared by photos and spots. */ +export const MAX_RADIUS_METERS = 500_000 +export const DEFAULT_RADIUS_METERS = 1_000 + +/** + * Builds a `GeoNearInput` from `--near "lat,lng"` and `--radius `. + * Returns undefined when neither is given, and throws before any request when + * they are malformed or `--radius` arrives without a point. + */ +export function parseNear( + near: string | undefined, + radius: number | undefined, +): GeoNearInput | undefined { + if (near === undefined) { + if (radius !== undefined) throw new Error('--radius needs --near') + return undefined + } + const parts = near.split(',').map((part) => part.trim()) + const [latitude, longitude] = parts.map((part) => (part === '' ? Number.NaN : Number(part))) + if ( + parts.length !== 2 || + latitude === undefined || + longitude === undefined || + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + Math.abs(latitude) > 90 || + Math.abs(longitude) > 180 + ) { + throw new Error('--near must be "latitude,longitude" in degrees, e.g. --near 37.7749,-122.4194') + } + const radiusMeters = radius ?? DEFAULT_RADIUS_METERS + if (!Number.isFinite(radiusMeters) || radiusMeters <= 0 || radiusMeters > MAX_RADIUS_METERS) { + throw new Error( + `--radius must be greater than 0 and at most ${String(MAX_RADIUS_METERS)} meters`, + ) + } + return { latitude, longitude, radiusMeters } +} diff --git a/src/photos.ts b/src/photos.ts index 0aa02f9..157336f 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -1,14 +1,27 @@ -import { type ImageFilter, ImageStatus, PhotosDocument, type PhotosQuery } from '@rawback/sdk' +import { + CliUpdatePhotoDocument, + type ImageFilter, + ImageOrderBy, + ImagePermission, + ImageStatus, + PhotosDocument, + type PhotosQuery, +} from '@rawback/sdk' +import { parsePositiveIds } from './albums.ts' import { createCommandClient, commandOutput, type ReadCommandDependencies, validatePagination, } from './command.ts' -import { photoListDocument } from './features/photos/view.ts' +import { photoListDocument, photoPermissionDocument } from './features/photos/view.ts' +import { parseNear } from './geo.ts' const IMAGE_STATUSES = new Set(Object.values(ImageStatus)) +const IMAGE_PERMISSIONS = new Set(Object.values(ImagePermission)) +export const PHOTO_SORTS = ['newest', 'rating', 'relevance', 'distance'] as const +export type PhotoSort = (typeof PHOTO_SORTS)[number] export interface PhotoListOptions { /** @@ -29,12 +42,19 @@ export interface PhotoListOptions { hasGps?: boolean json?: boolean lensModel?: string[] + /** `"latitude,longitude"`: only photos taken within `radius` meters of it. */ + near?: string page: number pageSize: number + /** Standalone access levels to keep: private, protected, public. */ + permission?: string[] /** A plain-language request the server translates into filters. */ prompt?: string + /** Meters around `near`; defaults to 1000. */ + radius?: number rate?: string[] search?: string + sort?: string status?: string[] } @@ -119,6 +139,12 @@ export function createPhotoFilter(options: PhotoListOptions): ImageFilter { throw new Error('--captured-after must not be later than --captured-before') } + const permissions = listValues(options.permission) + if (permissions?.some((permission) => !IMAGE_PERMISSIONS.has(permission))) { + throw new Error(`--permission must contain only: ${[...IMAGE_PERMISSIONS].join(', ')}`) + } + const near = parseNear(options.near, options.radius) + const search = options.search?.trim() return { ...(rates ? { rate: [...new Set(rates)] } : {}), @@ -141,6 +167,31 @@ export function createPhotoFilter(options: PhotoListOptions): ImageFilter { ...(listValues(options.city) ? { city: listValues(options.city) } : {}), ...(listValues(options.country) ? { country: listValues(options.country) } : {}), ...(options.hasGps ? { hasGps: true } : {}), + ...(near ? { near } : {}), + ...(permissions ? { permission: permissions as ImagePermission[] } : {}), + } +} + +/** + * Maps `--sort` onto `ImageOrderBy`. `distance` and `relevance` would quietly + * fall back to newest-first on the server without the input they rank by, so + * that is refused here instead. + */ +export function photoOrderBy(options: PhotoListOptions): ImageOrderBy | undefined { + switch (options.sort) { + case undefined: + case 'newest': + return undefined + case 'rating': + return ImageOrderBy.RATEDESC + case 'relevance': + if (!options.search?.trim()) throw new Error('--sort relevance needs --search') + return ImageOrderBy.RELEVANCE + case 'distance': + if (options.near === undefined) throw new Error('--sort distance needs --near') + return ImageOrderBy.DISTANCE + default: + throw new Error(`--sort must be one of: ${PHOTO_SORTS.join(', ')}`) } } @@ -160,6 +211,11 @@ function serializePhoto(photo: Photo) { cameraModel: photo.cameraModel ?? null, rotation: photo.rotation, rate: photo.rate ?? null, + permission: photo.permission, + latitude: photo.latitude ?? null, + longitude: photo.longitude ?? null, + city: photo.city ?? null, + country: photo.country ?? null, editedImages: photo.editedImages.map((image) => ({ url: image.url, thumbnailUrl: image.thumbnailUrl ?? null, @@ -176,6 +232,7 @@ export async function runPhotoList( dependencies: PhotoListDependencies = {}, ): Promise { const filter = createPhotoFilter(options) + const orderBy = photoOrderBy(options) const ui = commandOutput(dependencies) const result = await ui.withActivity( 'Loading photos…', @@ -186,6 +243,7 @@ export async function runPhotoList( variables: { filter, pagination: { page: options.page, pageSize: options.pageSize }, + ...(orderBy ? { orderBy } : {}), }, }) }, @@ -233,3 +291,95 @@ export function runPhotoSearch( } return runPhotoList(options, dependencies) } + +export interface PhotoPermissionOptions { + imageIds: ReadonlyArray + json?: boolean + /** private, protected or public. */ + level: string +} + +export interface PhotoPermissionResult { + id: number + ok: boolean + permission: ImagePermission | null + error: string | null +} + +/** Parallel `updateImage` calls; small enough to stay polite to the API. */ +const PERMISSION_CONCURRENCY = 4 + +export function photoPermission(level: string): ImagePermission { + if (!IMAGE_PERMISSIONS.has(level)) { + throw new Error(`Permission must be one of: ${[...IMAGE_PERMISSIONS].join(', ')}`) + } + return level as ImagePermission +} + +/** + * `rawback photos permission ` — sets each photo's + * standalone access. The server takes one photo per call, so the IDs are sent + * a few at a time; one photo failing does not stop the rest, but any failure + * makes the command exit nonzero. + */ +export async function runPhotoPermission( + options: PhotoPermissionOptions, + dependencies: PhotoListDependencies = {}, +): Promise { + const permission = photoPermission(options.level) + const ids = parsePositiveIds(options.imageIds, 'Image ID') + if (ids.length === 0) throw new Error('Provide at least one image ID') + const ui = commandOutput(dependencies) + const results = await ui.withActivity( + `Setting ${String(ids.length)} photo${ids.length === 1 ? '' : 's'} to ${permission}…`, + async () => { + const client = await createCommandClient(dependencies) + const out: PhotoPermissionResult[] = Array.from({ length: ids.length }) + let next = 0 + const worker = async (): Promise => { + while (next < ids.length) { + const index = next++ + const id = ids[index] as number + try { + const result = await client.graphql.mutate({ + mutation: CliUpdatePhotoDocument, + variables: { input: { id, permission } }, + }) + if (result.error) throw result.error + if (!result.data) throw new Error('The response did not include the updated photo') + out[index] = { + id, + ok: true, + permission: result.data.updateImage.permission, + error: null, + } + } catch (error) { + out[index] = { + id, + ok: false, + permission: null, + error: error instanceof Error ? error.message : String(error), + } + } + } + } + await Promise.all( + Array.from({ length: Math.min(PERMISSION_CONCURRENCY, ids.length) }, () => worker()), + ) + return out + }, + !options.json, + ) + + const failed = results.filter((result) => !result.ok).length + if (options.json) { + ui.json({ permission, results, succeeded: results.length - failed, failed }) + } else { + ui.document(photoPermissionDocument(permission, results)) + } + if (failed > 0) { + throw new Error( + `${String(failed)} of ${String(results.length)} photo${results.length === 1 ? '' : 's'} could not be updated`, + ) + } +} diff --git a/src/spots.ts b/src/spots.ts new file mode 100644 index 0000000..3d494ae --- /dev/null +++ b/src/spots.ts @@ -0,0 +1,206 @@ +import { + type FragmentType, + SpotDocument, + type SpotFieldsFragment, + SpotFieldsFragmentDoc, + type SpotPhotoFieldsFragment, + SpotPhotoFieldsFragmentDoc, + SpotsDocument, + SpotSunPlanDocument, + useFragment, +} from '@rawback/sdk' + +import { validatePositiveId } from './albums.ts' +import { + createCommandClient, + commandOutput, + type ReadCommandDependencies, + validatePagination, +} from './command.ts' +import { spotDocument, spotListDocument, sunPlanDocument } from './features/spots/view.ts' +import { parseNear } from './geo.ts' + +export type SpotCommandDependencies = ReadCommandDependencies + +export interface SpotListOptions { + featured?: boolean + json?: boolean + /** `"latitude,longitude"`: only spots within `radius` meters, nearest first. */ + near?: string + page: number + pageSize: number + radius?: number +} + +export interface SpotGetOptions { + id: string + json?: boolean + page: number + pageSize: number +} + +export interface SpotSunOptions { + date: string + id: string + json?: boolean + photoId?: number +} + +/** Spot IDs are level-18 map cells, `18--`. */ +const SPOT_ID = /^18-\d+-\d+$/ +const LOCAL_DATE = /^\d{4}-\d{2}-\d{2}$/ + +export function validateSpotId(id: string): string { + const trimmed = id.trim() + if (!SPOT_ID.test(trimmed)) { + throw new Error('Spot ID must look like 18-206451-130118 (from rawback spots list)') + } + return trimmed +} + +export type SpotPhoto = SpotPhotoFieldsFragment +export type Spot = Omit & { representative: SpotPhoto } + +function unmaskSpot(value: FragmentType): Spot { + const spot = useFragment(SpotFieldsFragmentDoc, value) + return { ...spot, representative: unmaskPhoto(spot.representative) } +} + +function unmaskPhoto(value: FragmentType): SpotPhoto { + return useFragment(SpotPhotoFieldsFragmentDoc, value) +} + +function serializePhoto(photo: SpotPhoto) { + return { + id: photo.id, + title: photo.title, + url: photo.url, + thumbnailUrl: photo.thumbnailUrl, + photographerName: photo.photographerName, + photographerSlug: photo.photographerSlug, + latitude: photo.latitude, + longitude: photo.longitude, + capturedAt: photo.capturedAt ?? null, + camera: photo.camera ?? null, + lens: photo.lens ?? null, + iso: photo.iso ?? null, + aperture: photo.aperture ?? null, + exposureTime: photo.exposureTime ?? null, + focalLength: photo.focalLength ?? null, + reactionCount: photo.reactionCount, + } +} + +function serializeSpot(spot: Spot) { + return { + id: spot.id, + name: spot.name, + latitude: spot.latitude, + longitude: spot.longitude, + photoCount: spot.photoCount, + representative: serializePhoto(spot.representative), + } +} + +/** `rawback spots list` — public photography spots, optionally near a point. */ +export async function runSpotList( + options: SpotListOptions, + dependencies: SpotCommandDependencies = {}, +): Promise { + validatePagination(options.page, options.pageSize) + const near = parseNear(options.near, options.radius) + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Loading spots…', + async () => { + // Spots are public; a signed-out CLI can browse them too. + const client = await createCommandClient(dependencies, false) + return client.graphql.query({ + query: SpotsDocument, + variables: { + page: options.page, + pageSize: options.pageSize, + ...(options.featured ? { featured: true } : {}), + ...(near ? { near } : {}), + }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + if (!result.data) throw new Error('The spots response did not include spot data') + + const spots = result.data.spots.spots.map(unmaskSpot) + const pageInfo = result.data.spots.pageInfo + if (options.json) { + ui.json({ spots: spots.map(serializeSpot), pageInfo }) + return + } + ui.document(spotListDocument(spots, pageInfo)) +} + +/** `rawback spots view ` — one spot and a page of its published photos. */ +export async function runSpotGet( + options: SpotGetOptions, + dependencies: SpotCommandDependencies = {}, +): Promise { + const id = validateSpotId(options.id) + validatePagination(options.page, options.pageSize) + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Loading spot…', + async () => { + const client = await createCommandClient(dependencies, false) + return client.graphql.query({ + query: SpotDocument, + variables: { id, page: options.page, pageSize: options.pageSize }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + if (!result.data?.spot) throw new Error(`Spot ${id} was not found or has no public photos`) + + const spot = unmaskSpot(result.data.spot) + const photos = result.data.spot.photos.photos.map(unmaskPhoto) + const pageInfo = result.data.spot.photos.pageInfo + if (options.json) { + ui.json({ spot: serializeSpot(spot), photos: photos.map(serializePhoto), pageInfo }) + return + } + ui.document(spotDocument(spot, photos, pageInfo)) +} + +/** `rawback spots sun --date YYYY-MM-DD` — sunrise, sunset and golden hour. */ +export async function runSpotSun( + options: SpotSunOptions, + dependencies: SpotCommandDependencies = {}, +): Promise { + const id = validateSpotId(options.id) + const date = options.date.trim() + if (!LOCAL_DATE.test(date) || Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) { + throw new Error('--date must be a calendar date, YYYY-MM-DD') + } + const photoId = + options.photoId === undefined ? undefined : validatePositiveId(options.photoId, '--photo-id') + const ui = commandOutput(dependencies) + const result = await ui.withActivity( + 'Planning the light…', + async () => { + const client = await createCommandClient(dependencies, false) + return client.graphql.query({ + query: SpotSunPlanDocument, + variables: { id, date, ...(photoId !== undefined ? { photoId } : {}) }, + }) + }, + !options.json, + ) + if (result.error) throw result.error + if (!result.data) throw new Error('The response did not include a sun plan') + + if (options.json) { + ui.json({ spotId: id, ...result.data.spotSunPlan }) + return + } + ui.document(sunPlanDocument(id, result.data.spotSunPlan)) +} diff --git a/test/commands-cli.test.ts b/test/commands-cli.test.ts index 1ff61ea..8453c12 100644 --- a/test/commands-cli.test.ts +++ b/test/commands-cli.test.ts @@ -74,6 +74,10 @@ describe('new command hierarchy', () => { '--city', '--country', '--has-gps', + '--near', + '--radius', + '--sort', + '--permission', '--page-size', '--json', ]) { @@ -81,6 +85,63 @@ describe('new command hierarchy', () => { } }) + test('documents photo permission and warns what public means', () => { + const result = runCli('photos', 'permission', '--help') + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('rawback photos permission ') + expect(result.stdout).toContain('listed in Spots') + }) + + test('rejects an unknown permission level and missing IDs before authentication', () => { + const level = runCli('photos', 'permission', 'secret', '1') + expect(level.exitCode).toBe(1) + expect(level.stderr).toContain('Invalid values') + expect(level.stderr).not.toContain('Authentication credentials') + + const ids = runCli('photos', 'permission', 'public') + expect(ids.exitCode).toBe(1) + expect(ids.stderr).not.toContain('Authentication credentials') + }) + + test('validates photo location options before authentication', () => { + const radius = runCli('photos', 'list', '--radius', '100') + expect(radius.exitCode).toBe(1) + expect(radius.stderr).toContain('--radius needs --near') + expect(radius.stderr).not.toContain('Authentication credentials') + + const sort = runCli('photos', 'list', '--sort', 'distance') + expect(sort.exitCode).toBe(1) + expect(sort.stderr).toContain('--sort distance needs --near') + + const bad = runCli('photos', 'list', '--sort', 'oldest') + expect(bad.exitCode).toBe(1) + expect(bad.stderr).toContain('Invalid values') + }) + + test('documents the spots commands', () => { + const group = runCli('spots', '--help') + expect(group.exitCode).toBe(0) + for (const command of ['list', 'view', 'sun']) { + expect(group.stdout).toContain(`rawback spots ${command}`) + } + const list = runCli('spots', 'list', '--help').stdout + for (const flag of ['--near', '--radius', '--featured', '--page-size', '--json']) { + expect(list).toContain(flag) + } + expect(runCli('spots', 'sun', '--help').stdout).toContain('--date') + expect(runCli('spots').stderr).toContain('Choose a spots command') + }) + + test('validates spots arguments before any request', () => { + const id = runCli('spots', 'view', 'not-a-spot') + expect(id.exitCode).toBe(1) + expect(id.stderr).toContain('Spot ID must look like') + + const date = runCli('spots', 'sun', '18-1-1') + expect(date.exitCode).toBe(1) + expect(date.stderr).toContain('Missing required argument: date') + }) + test('documents upload sessions, usage, pricing, and web', () => { expect(runCli('uploads', '--help').stdout).toContain('--status') const usageHelp = runCli('usage', '--help').stdout diff --git a/test/photos.test.ts b/test/photos.test.ts index f209dba..6f1ec37 100644 --- a/test/photos.test.ts +++ b/test/photos.test.ts @@ -6,7 +6,9 @@ import { join } from 'node:path' import { writeCredentials } from '../src/credentials.ts' import { createPhotoFilter, + photoOrderBy, runPhotoList, + runPhotoPermission, runPhotoSearch, type PhotoListDependencies, } from '../src/photos.ts' @@ -76,6 +78,7 @@ describe('photos list', () => { cameraMake: 'Fujifilm', cameraModel: 'X-T5', rotation: 0, + permission: 'private', rate: 2, editedImages: [], }, @@ -116,7 +119,16 @@ describe('photos list', () => { ) expect(JSON.parse(lines.join('\n'))).toMatchObject({ - photos: [{ id: 7, thumbnailUrl: null, editedImages: [] }], + photos: [ + { + id: 7, + thumbnailUrl: null, + editedImages: [], + permission: 'private', + latitude: null, + city: null, + }, + ], pageInfo: { page: 2, totalCount: 17 }, }) }) @@ -144,6 +156,7 @@ describe('photos list', () => { cameraMake: 'Sony', cameraModel: 'A7', rotation: 0, + permission: 'private', rate: 5, editedImages: [], }, @@ -209,6 +222,7 @@ describe('photos search', () => { cameraMake: 'Canon', cameraModel: 'EOS R5', rotation: 0, + permission: 'private', rate: 4, editedImages: [], }, @@ -311,3 +325,209 @@ describe('photos search', () => { expect(output).toContain('--page 2') }) }) + +describe('photos location and access filters', () => { + test('maps --near, --radius, --sort and --permission onto the query', async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + expect(body.variables).toEqual({ + filter: { + rate: [3, 4, 5], + near: { latitude: 37.7749, longitude: -122.4194, radiusMeters: 2500 }, + permission: ['public', 'protected'], + }, + pagination: { page: 1, pageSize: 24 }, + orderBy: 'DISTANCE', + }) + return Response.json({ + data: { + images: { + edges: [ + { + id: 9, + filename: 'bridge.jpg', + url: 'https://cdn/bridge', + thumbnailUrl: null, + status: 'completed', + width: null, + height: null, + capturedAt: null, + cameraMake: null, + cameraModel: null, + rotation: 0, + rate: 4, + permission: 'public', + latitude: 37.77, + longitude: -122.42, + city: 'San Francisco', + country: 'United States', + editedImages: [], + }, + ], + pageInfo: { + page: 1, + pageSize: 24, + totalCount: 1, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, + }, + aiSearch: null, + }, + }, + }) + }, lines) + + await runPhotoList( + { + json: true, + near: ' 37.7749 , -122.4194 ', + page: 1, + pageSize: 24, + permission: ['public,protected'], + radius: 2500, + sort: 'distance', + }, + deps, + ) + expect(JSON.parse(lines.join('\n')).photos[0]).toMatchObject({ + id: 9, + permission: 'public', + latitude: 37.77, + longitude: -122.42, + city: 'San Francisco', + }) + }) + + test('defaults the radius to 1000 meters', () => { + expect(createPhotoFilter({ near: '51.5,-0.12', page: 1, pageSize: 24 }).near).toEqual({ + latitude: 51.5, + longitude: -0.12, + radiusMeters: 1000, + }) + }) + + test('rejects malformed location, sort and permission input before any request', () => { + const base = { page: 1, pageSize: 24 } + expect(() => createPhotoFilter({ ...base, radius: 500 })).toThrow(/--radius needs --near/) + expect(() => createPhotoFilter({ ...base, near: '91,0' })).toThrow(/--near must be/) + expect(() => createPhotoFilter({ ...base, near: 'paris' })).toThrow(/--near must be/) + expect(() => createPhotoFilter({ ...base, near: '1,2,3' })).toThrow(/--near must be/) + expect(() => createPhotoFilter({ ...base, near: '1,2', radius: 0 })).toThrow(/--radius must/) + expect(() => createPhotoFilter({ ...base, near: '1,2', radius: 600_000 })).toThrow( + /--radius must/, + ) + expect(() => createPhotoFilter({ ...base, permission: ['secret'] })).toThrow( + /--permission must contain only/, + ) + expect(() => photoOrderBy({ ...base, sort: 'distance' })).toThrow(/needs --near/) + expect(() => photoOrderBy({ ...base, sort: 'relevance' })).toThrow(/needs --search/) + expect(() => photoOrderBy({ ...base, sort: 'oldest' })).toThrow(/--sort must be one of/) + expect(photoOrderBy({ ...base, sort: 'relevance', search: 'bridge' })).toBe('RELEVANCE') + expect(photoOrderBy({ ...base, sort: 'rating' })).toBe('RATE_DESC') + expect(photoOrderBy({ ...base, sort: 'newest' })).toBeUndefined() + }) +}) + +describe('photos permission', () => { + test('updates every photo and reports each result in JSON', async () => { + const lines: string[] = [] + const seen: number[] = [] + const deps = await dependencies((body) => { + expect(body.operationName).toBe('CliUpdatePhoto') + expect(body.variables.input.permission).toBe('protected') + seen.push(body.variables.input.id) + return Response.json({ + data: { + updateImage: { + id: body.variables.input.id, + filename: 'a.jpg', + displayName: '', + rate: null, + permission: 'protected', + }, + }, + }) + }, lines) + + await runPhotoPermission({ imageIds: ['3,4', 5, '3'], json: true, level: 'protected' }, deps) + + expect(seen.toSorted()).toEqual([3, 4, 5]) + expect(JSON.parse(lines.join('\n'))).toEqual({ + permission: 'protected', + results: [ + { id: 3, ok: true, permission: 'protected', error: null }, + { id: 4, ok: true, permission: 'protected', error: null }, + { id: 5, ok: true, permission: 'protected', error: null }, + ], + succeeded: 3, + failed: 0, + }) + }) + + test('keeps going past a failed photo and then fails the command', async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + if (body.variables.input.id === 8) { + return Response.json({ errors: [{ message: 'image not found' }], data: null }) + } + return Response.json({ + data: { + updateImage: { + id: body.variables.input.id, + filename: 'a.jpg', + displayName: '', + rate: null, + permission: 'public', + }, + }, + }) + }, lines) + + await expect( + runPhotoPermission({ imageIds: [7, 8], json: true, level: 'public' }, deps), + ).rejects.toThrow('1 of 2 photos could not be updated') + const output = JSON.parse(lines.join('\n')) + expect(output).toMatchObject({ succeeded: 1, failed: 1 }) + expect(output.results[0]).toMatchObject({ id: 7, ok: true }) + expect(output.results[1]).toMatchObject({ id: 8, ok: false, permission: null }) + expect(output.results[1].error).toContain('image not found') + }) + + test('prints a table and the publication notice for public photos', async () => { + const lines: string[] = [] + const deps = await dependencies( + (body) => + Response.json({ + data: { + updateImage: { + id: body.variables.input.id, + filename: 'a.jpg', + displayName: '', + rate: null, + permission: 'public', + }, + }, + }), + lines, + ) + await runPhotoPermission({ imageIds: [12], level: 'public' }, deps) + const output = lines.join('\n') + expect(output).toContain('12') + expect(output).toContain('public') + expect(output).toContain('appear in Spots') + }) + + test('rejects an unknown level or bad IDs before any request', async () => { + const lines: string[] = [] + const deps = await dependencies(() => { + throw new Error('should not have made a request') + }, lines) + await expect(runPhotoPermission({ imageIds: [1], level: 'secret' }, deps)).rejects.toThrow( + /Permission must be one of/, + ) + await expect(runPhotoPermission({ imageIds: ['x'], level: 'public' }, deps)).rejects.toThrow( + /Image ID/, + ) + }) +}) diff --git a/test/spots.test.ts b/test/spots.test.ts new file mode 100644 index 0000000..4419484 --- /dev/null +++ b/test/spots.test.ts @@ -0,0 +1,268 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + runSpotGet, + runSpotList, + runSpotSun, + type SpotCommandDependencies, + validateSpotId, +} from '../src/spots.ts' + +const temporaryDirectories: string[] = [] + +// No credentials file: Spots are public, so every command works signed out. +async function dependencies( + handler: (body: Record) => Response, + output: string[], +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'rawback-spots-')) + temporaryDirectories.push(directory) + return { + configPath: join(directory, 'config.yml'), + credentialsPath: join(directory, 'credentials.json'), + fetch: (async (_input, init) => handler(JSON.parse(String(init?.body)))) as typeof fetch, + stdout: (message) => output.push(message), + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +const photo = { + id: 41, + title: 'Opera house at dusk', + url: 'https://cdn/full', + thumbnailUrl: 'https://cdn/thumb', + photographerName: 'Ada', + photographerSlug: 'ada', + latitude: -33.8568, + longitude: 151.2153, + capturedAt: '2026-06-21T08:12:00Z', + timezone: 600, + camera: 'Fujifilm X-T5', + lens: 'XF 23mm', + iso: 400, + aperture: 8, + exposureTime: '1/250', + focalLength: 23, + reactionCount: 3, +} + +const spot = { + id: '18-240801-157013', + name: 'Sydney, New South Wales, Australia', + latitude: -33.857, + longitude: 151.215, + photoCount: 2, + representative: photo, +} + +const pageInfo = { + page: 1, + pageSize: 24, + totalCount: 1, + totalPages: 1, + hasNextPage: false, + hasPreviousPage: false, +} + +describe('spots list', () => { + test('sends near and featured and emits a stable JSON envelope', async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + expect(body.operationName).toBe('Spots') + expect(body.variables).toEqual({ + page: 1, + pageSize: 24, + featured: true, + near: { latitude: -33.86, longitude: 151.21, radiusMeters: 3000 }, + }) + return Response.json({ data: { spots: { spots: [spot], pageInfo } } }) + }, lines) + + await runSpotList( + { featured: true, json: true, near: '-33.86,151.21', page: 1, pageSize: 24, radius: 3000 }, + deps, + ) + expect(JSON.parse(lines.join('\n'))).toEqual({ + spots: [ + { + id: spot.id, + name: spot.name, + latitude: spot.latitude, + longitude: spot.longitude, + photoCount: 2, + representative: { + id: 41, + title: 'Opera house at dusk', + url: 'https://cdn/full', + thumbnailUrl: 'https://cdn/thumb', + photographerName: 'Ada', + photographerSlug: 'ada', + latitude: -33.8568, + longitude: 151.2153, + capturedAt: '2026-06-21T08:12:00Z', + camera: 'Fujifilm X-T5', + lens: 'XF 23mm', + iso: 400, + aperture: 8, + exposureTime: '1/250', + focalLength: 23, + reactionCount: 3, + }, + }, + ], + pageInfo, + }) + }) + + test('renders a table without sending near when none is given', async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + expect(body.variables).toEqual({ page: 1, pageSize: 24 }) + return Response.json({ data: { spots: { spots: [spot], pageInfo } } }) + }, lines) + await runSpotList({ page: 1, pageSize: 24 }, deps) + const output = lines.join('\n') + expect(output).toContain(spot.id) + expect(output).toContain('Sydney') + expect(output).toContain('Ada') + }) + + test('validates paging and location before any request', async () => { + const lines: string[] = [] + const deps = await dependencies(() => { + throw new Error('should not have made a request') + }, lines) + await expect(runSpotList({ page: 0, pageSize: 24 }, deps)).rejects.toThrow(/--page/) + await expect(runSpotList({ page: 1, pageSize: 24, radius: 10 }, deps)).rejects.toThrow( + /--radius needs --near/, + ) + }) +}) + +describe('spots view', () => { + test('shows the spot and a page of its photos', async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + expect(body.operationName).toBe('Spot') + expect(body.variables).toEqual({ id: spot.id, page: 2, pageSize: 10 }) + return Response.json({ + data: { spot: { ...spot, photos: { photos: [photo], pageInfo } } }, + }) + }, lines) + await runSpotGet({ id: ` ${spot.id} `, page: 2, pageSize: 10 }, deps) + const output = lines.join('\n') + expect(output).toContain('Opera house') + expect(output).toContain('23mm f/8') + }) + + test('JSON carries the spot, its photos and paging', async () => { + const lines: string[] = [] + const deps = await dependencies( + () => Response.json({ data: { spot: { ...spot, photos: { photos: [photo], pageInfo } } } }), + lines, + ) + await runSpotGet({ id: spot.id, json: true, page: 1, pageSize: 24 }, deps) + const output = JSON.parse(lines.join('\n')) + expect(output.spot.id).toBe(spot.id) + expect(output.photos).toHaveLength(1) + expect(output.pageInfo.totalCount).toBe(1) + }) + + test('reports a spot with no public photos as not found', async () => { + const lines: string[] = [] + const deps = await dependencies(() => Response.json({ data: { spot: null } }), lines) + await expect(runSpotGet({ id: spot.id, page: 1, pageSize: 24 }, deps)).rejects.toThrow( + /was not found/, + ) + }) + + test('rejects a malformed spot ID before any request', () => { + expect(() => validateSpotId('17-1-1')).toThrow(/Spot ID must look like/) + expect(() => validateSpotId('18-a-1')).toThrow(/Spot ID must look like/) + expect(validateSpotId(' 18-1-2 ')).toBe('18-1-2') + }) +}) + +describe('spots sun', () => { + const plan = { + date: '2026-06-21', + timezone: 'Australia/Sydney', + state: 'normal', + latitude: -33.857, + longitude: 151.215, + sunrise: '2026-06-20T21:00:00Z', + sunset: '2026-06-21T06:53:00Z', + sunriseAzimuth: 62.4, + sunsetAzimuth: 297.6, + morningGoldenHourStart: '2026-06-20T21:00:00Z', + morningGoldenHourEnd: '2026-06-20T21:40:00Z', + goldenHourStart: '2026-06-21T06:10:00Z', + goldenHourEnd: '2026-06-21T06:53:00Z', + } + + test("prints times in the spot's time zone", async () => { + const lines: string[] = [] + const deps = await dependencies((body) => { + expect(body.operationName).toBe('SpotSunPlan') + expect(body.variables).toEqual({ id: spot.id, date: '2026-06-21', photoId: 41 }) + return Response.json({ data: { spotSunPlan: plan } }) + }, lines) + await runSpotSun({ date: '2026-06-21', id: spot.id, photoId: 41 }, deps) + const output = lines.join('\n') + expect(output).toContain('07:00 (62°)') + expect(output).toContain('16:10–16:53') + expect(output).toContain('Australia/Sydney') + }) + + test('JSON passes the plan through with the spot ID', async () => { + const lines: string[] = [] + const deps = await dependencies(() => Response.json({ data: { spotSunPlan: plan } }), lines) + await runSpotSun({ date: '2026-06-21', id: spot.id, json: true }, deps) + expect(JSON.parse(lines.join('\n'))).toEqual({ spotId: spot.id, ...plan }) + }) + + test('describes polar days instead of listing times', async () => { + const lines: string[] = [] + const deps = await dependencies( + () => + Response.json({ + data: { + spotSunPlan: { + ...plan, + state: 'polarDay', + sunrise: null, + sunset: null, + sunriseAzimuth: null, + sunsetAzimuth: null, + }, + }, + }), + lines, + ) + await runSpotSun({ date: '2026-06-21', id: spot.id }, deps) + expect(lines.join('\n')).toContain('up all day') + }) + + test('validates the date and photo ID before any request', async () => { + const lines: string[] = [] + const deps = await dependencies(() => { + throw new Error('should not have made a request') + }, lines) + await expect(runSpotSun({ date: '21/06/2026', id: spot.id }, deps)).rejects.toThrow( + /--date must be/, + ) + await expect(runSpotSun({ date: '2026-06-21', id: spot.id, photoId: 0 }, deps)).rejects.toThrow( + /--photo-id/, + ) + }) +})