diff --git a/README.md b/README.md index 0670bf1..a25cc68 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ in a browser. - List and inspect daily AI-generated dream recaps, including their contributing photos. - Browse content shared with you and manage your outgoing share links. - Control a Canon camera over CCAPI: shoot, change settings, browse and download the card, and stream live view. +- Import photos you pick in Google Photos, and export library photos, albums, or a date range to Google Photos. - Manage the SFTP credentials associated with your account. - Inspect the shared local configuration without exposing its SFTP password. - Inspect upload sessions, storage usage, AI credits, and pricing. @@ -291,6 +292,35 @@ task worker. The web video page shows its status, text, and an SRT download. See [video setup and recovery](docs/configuration.md#video-uploads-and-transcription) for local-server configuration and recovery limits. +## Google Photos + +Import what you pick in Google Photos, or send library photos the other way. +Both connect your Google account in the browser the first time: + +```bash +rawback import google # pick in Google's picker, confirm, import +rawback export google --dry-run # how many photos, how large, already sent +rawback export google --from 2024-06-01 --to 2024-06-30 +rawback export google --album 12 --album-title "Iceland" +rawback export google --images-file ids.txt --file fullsize --yes --no-wait +rawback google status # linked account and running transfers +rawback google jobs # history; follow one with google job --watch +rawback google cancel 40 +rawback google disconnect +``` + +Imports run on the server through the same pipeline as an SFTP upload, so they +appear in `rawback uploads`. Videos and photos already in your library are +skipped, and imported photos arrive without GPS because Google withholds +location from downloads. Exports go into a Google Photos album named +"Rawback" unless you pass `--album-title` or `--no-album`, and photos an +earlier export already sent are skipped unless you pass `--no-skip-exported`. +Under `--json`, links, prompts and progress go to standard error; pass `--yes`, +because the command cannot ask for confirmation. Ctrl-C while a transfer is +being watched stops watching — the job keeps running on the server. See +[the command reference](docs/commands.md#rawback-import-google) for every +option. + ## Camera control Rawback can drive a Canon camera directly over CCAPI, where the camera itself is diff --git a/bun.lock b/bun.lock index 4d4ff7e..a235db5 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@inquirer/prompts": "^8.7.0", "@rawback/ccapi-js": "1.1.3", - "@rawback/sdk": "0.3.3", + "@rawback/sdk": "0.3.5", "ink": "^7.1.1", "react": "^19.2.8", "react-devtools-core": "^7.0.1", @@ -149,7 +149,7 @@ "@rawback/ccapi-js": ["@rawback/ccapi-js@1.1.3", "", {}, "sha512-+ixw0xQw+YDZvHwUeYnum89/ZxxVPanJKxeSEkV9jXMkXhXtOarUz3a0ZrTRz7ia3H9FWJJpsu8rW4Hdx+I6VQ=="], - "@rawback/sdk": ["@rawback/sdk@0.3.3", "", { "dependencies": { "@graphql-typed-document-node/core": "3.2.0", "blurhash": "2.0.5", "exiftool-vendored": "37.0.0", "graphql": "17.0.2", "pino": "^10.3.1", "pino-roll": "^4.0.0", "proper-lockfile": "4.1.2", "ssh2": "1.17.0", "yaml": "2.9.0", "zod": "4.4.3" } }, "sha512-HWEsEDIQMQ+zMb/OgMHb1eNkpMEown9PbbVeuzCccEferWemnI35lkr1rIIvIxCXiLd581OlktQfPVds7srEMQ=="], + "@rawback/sdk": ["@rawback/sdk@0.3.5", "", { "dependencies": { "@graphql-typed-document-node/core": "3.2.0", "blurhash": "2.0.5", "exiftool-vendored": "37.0.0", "graphql": "17.0.2", "pino": "^10.3.1", "pino-roll": "^4.0.0", "proper-lockfile": "4.1.2", "ssh2": "1.17.0", "yaml": "2.9.0", "zod": "4.4.3" } }, "sha512-y5llQyWT6keIA1Dm9BCziYa0oZVyibYC8k2/v6aSm36c45vT1VjZJP/tUSmHNSwV011IWkENKGBfIwCwX/tVaw=="], "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], diff --git a/docs/commands.md b/docs/commands.md index bfe1144..af8c3f8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1191,7 +1191,8 @@ non-interactive use. JSON reports `{ "deleted": , "id": }`. ## `rawback uploads` -Lists FTP and SFTP upload sessions: +Lists upload sessions: FTP and SFTP uploads, app uploads, and Google Photos +imports (source `GOOGLE PHOTOS`): ```bash rawback uploads [options] @@ -1204,6 +1205,167 @@ rawback uploads [options] | `--page-size ` | Results per page, from 1 through 100 | `20` | | `--json` | Print an `uploads` and `pageInfo` JSON object | `false` | +## `rawback import google` + +Imports photos you pick in Google Photos: + +```bash +rawback import google [--yes] [--no-wait] [--json] +``` + +1. Checks the Google Photos link. Without a linked account, with one Google no + longer accepts, or with one that never granted the import permission, it + prints an authorization link on standard error, opens it in the browser, and + waits until Google's consent screen is done (at most ten minutes). Nothing is + stored locally: the grant lives on the server. +2. Opens Google Photos' own picker the same way and waits until you select + Done. One pick holds up to 2,000 items. +3. Shows how many photos were picked and how many videos will be skipped, then + asks to confirm. +4. Starts the import and shows its progress until it finishes. + +The import runs on the server through the same pipeline as an SFTP upload: it +appears in [`rawback uploads`](#rawback-uploads), and photos already in your +library (same filename and capture time) are skipped. Videos are not imported. +Google withholds location metadata from downloads, so imported photos arrive +without GPS. Only one import runs at a time; a running one is reported with its +job ID before the picker opens. + +| Option | Description | Default | +| ----------- | --------------------------------------------------------- | ------- | +| `--yes` | Import what you pick without asking to confirm | `false` | +| `--no-wait` | Print the started job and exit instead of watching it | waits | +| `--json` | Print `{ "picked", "job", "finished" }`; requires `--yes` | `false` | + +Without `--yes` the command needs an interactive terminal, and says so before +anything opens. Declining, or Ctrl-C while the picker is open, ends the picker +session and imports nothing. Ctrl-C while the import is being watched stops +watching and exits `130`; the import keeps running on the server and can be +followed with `rawback google job --watch`. The command exits `1` when +the job fails, is cancelled, or finishes with failed items. + +`picked` is `{ "total", "photos", "videos" }`. `job` is the job as +[`rawback google jobs`](#rawback-google-jobs) reports it, and `finished` is +`false` under `--no-wait` or when watching was interrupted. + +## `rawback export google` + +Exports library photos to Google Photos: + +```bash +rawback export google [selection] [options] +``` + +Choose at most one selection; without one, every photo is exported. + +| Selection | Exports | +| ------------------------------ | --------------------------------------------------------------------- | +| `--all` | Every photo (the default) | +| `--from `, `--to ` | Photos captured — or, without a capture time, uploaded — in the range | +| `--album ` | Photos in these albums; repeat or comma-separate, up to 100 | +| `--image ` | These photos; repeat or comma-separate | +| `--images-file ` | Photo IDs from a file, one per line (`-` reads standard input) | + +Dates are `YYYY-MM-DD`, which covers that whole UTC day, or an RFC3339 +timestamp. Either bound may be omitted. `--image` and `--images-file` combine. + +| Option | Description | Default | +| ----------------------- | ---------------------------------------------------------------------------------------------- | ---------- | +| `--include-secret` | Also export photos a secret album hides from the library | `false` | +| `--include-archived` | Also export archived photos | `false` | +| `--no-skip-exported` | Send photos an earlier export already sent to this Google account | skips them | +| `--album-title ` | Google Photos album to add the photos to, created on first use and reused after | `Rawback` | +| `--no-album` | Add the photos to the Google Photos library only | — | +| `--file <file>` | `original` (the stored file, RAW included) or `fullsize` (a full-resolution AVIF for RAW/HEIC) | `original` | +| `--dry-run` | Show the count, size, and already-exported photos, and stop | `false` | +| `--yes` | Export without asking to confirm | `false` | +| `--no-wait` | Print the started job and exit instead of watching it | waits | +| `--json` | Print `{ "dryRun", "input", "preview", "job", "finished" }`; requires `--yes` or `--dry-run` | `false` | + +The command validates the selection before any request, connects the Google +account the same way `rawback import google` does when it cannot export yet, +then previews the selection and asks to confirm. `--dry-run` needs no linked +account. One export sends at most 50,000 photos, and only one export runs at a +time. `fullsize` sends the AVIF rendition kept for RAW and HEIC originals, which +Google Photos always opens; other photos still send their original. + +`input` is the selection exactly as sent to the server, `preview` is +`{ "count", "totalBytes", "alreadyExported" }`, and `job` is `null` for a dry +run or when nothing matched. Interruption, `--no-wait` and exit codes behave as +for `rawback import google`. + +## `rawback google` + +Manages the Google Photos link and its transfers. `import google` and +`export google` connect on their own; these commands are for everything around +them. + +```bash +rawback google status [--json] +rawback google connect [--json] +rawback google disconnect [--yes] [--json] +rawback google jobs [--kind import|export] [--limit <n>] [--offset <n>] [--json] +rawback google job <id> [--items <status>] [--limit <n>] [--offset <n>] [--watch] [--json] +rawback google cancel <id> [--json] +``` + +### `google status` + +Shows whether the server offers Google Photos, the linked account and what it +may do (import, export), the running import and export, and the limits. A server +without the feature is reported, not treated as an error. JSON: +`{ "available", "account", "activeImport", "activeExport", "maxPickerItems", "maxExportItems" }`. + +### `google connect` + +Links a Google account, or repairs one Google no longer accepts or that is +missing a permission, by printing an authorization link on standard error and +opening it. An account that already works is left alone; to switch Google +accounts, disconnect first. JSON: `{ "account", "connected" }`. + +### `google disconnect` + +Revokes Rawback's access at Google and cancels running imports and exports. +Asks to confirm unless `--yes` is given; `--json` requires `--yes`. JSON: +`{ "disconnected" }`. + +### `google jobs` + +Lists imports and exports, newest first. + +| Option | Description | Default | +| -------------- | -------------------------------- | ------- | +| `--kind` | `import` or `export` | both | +| `--limit <n>` | Jobs to show, from 1 through 100 | `20` | +| `--offset <n>` | Jobs to skip | `0` | +| `--json` | Print `{ "jobs": [...] }` | `false` | + +Each job carries `id`, `kind`, `status` (`pending`, `running`, `completed`, +`failed`, `cancelled`), `totalItems`, `doneItems`, `skippedItems`, +`failedItems`, `transferredBytes`, `albumTitle`, `albumUrl`, `exportFile`, +`error`, `pausedUntil` (set while the job waits out Google's request quota; it +stays `running`), `createdAt`, `startedAt`, `completedAt`, and `upload` (the +import's upload session). + +### `google job <id>` + +Shows one job and a page of its items, each with its status, file, size, the +library photo, the Google Photos link for an export, and the reason for a skip +or failure (`duplicate`, `unsupported_type`, `already_exported`, …). + +| Option | Description | Default | +| ------------------ | ------------------------------------------------------------------ | ------- | +| `--items <status>` | Only `pending`, `done`, `skipped`, or `failed` items | all | +| `--limit <n>` | Items to show, from 1 through 200 | `50` | +| `--offset <n>` | Items to skip | `0` | +| `--watch` | Follow the job until it finishes; exits `1` if it does not succeed | `false` | +| `--json` | Print `{ "job" }` with an `items` array | `false` | + +### `google cancel <id>` + +Stops a running import or export and prints the job. A job that already finished +is left as it is. JSON: `{ "job" }`. + ## `rawback usage` Shows a compact overview of the account: tier, and a quota meter for storage, AI @@ -1336,7 +1498,8 @@ standard error, so raising the verbosity with `-v` cannot disturb a script parsing `--json`. The CLI exits with status `0` on success, `1` for validation, API, filesystem, or -upload failures, and `130` when an interactive prompt is cancelled. Scripts +upload failures, and `130` when an interactive prompt is cancelled or Ctrl-C +stops a command that is waiting on a browser or a server-side transfer. Scripts should check the exit status before consuming output. ## Article languages diff --git a/package.json b/package.json index 358be0b..cec6b87 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@inquirer/prompts": "^8.7.0", "@rawback/ccapi-js": "1.1.3", - "@rawback/sdk": "0.3.4", + "@rawback/sdk": "0.3.5", "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 ebab1aa..cd22b03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,6 +34,14 @@ async function runCommand( process.exitCode = 130 return } + // Ctrl-C while a command waits on something outside it (a browser, a + // server-side job): the message says what was left behind. Matched by + // name so the module that throws it stays out of the startup path. + if (error instanceof Error && error.name === 'CommandInterruptedError') { + output.warning(error.message) + process.exitCode = 130 + return + } output.error(describeError(error)) process.exitCode = 1 } @@ -130,6 +138,44 @@ function checkMutatingIsNonInteractive( return true } +/** + * A command that asks before it acts needs a terminal to ask on, or `--yes`. + * Checked before any network call, so a script finds out before a browser + * window opens rather than after. + */ +function checkConfirmable( + args: { dryRun?: boolean | undefined; json?: boolean | undefined; yes?: boolean | undefined }, + command: string, + dryRunnable = false, +): true { + if (args.yes === true || args.dryRun === true) return true + const alternative = dryRunnable ? ' (or --dry-run)' : '' + if (args.json === true) { + throw new Error(`${command} --json also needs --yes${alternative}, because it cannot prompt`) + } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error(`${command} needs an interactive terminal unless --yes is provided`) + } + return true +} + +/** + * `--no-album` reaches yargs as a `false` among the `--album` IDs, because it + * negates the option of that name. Split it back out. + */ +function albumSelection(values: ReadonlyArray<string | number | boolean> | undefined): { + albums: string[] + libraryOnly: boolean +} { + const albums: string[] = [] + let libraryOnly = false + for (const value of values ?? []) { + if (value === false) libraryOnly = true + else if (value !== true) albums.push(String(value)) + } + return { albums, libraryOnly } +} + function albumMetadataOptions<T>(command: Argv<T>) { return command .option('description', { @@ -2145,6 +2191,350 @@ export function createProgram(version: string, output = new CommandOutput()): Ar .strict(), () => {}, ) + .command( + 'import', + 'import photos from another service', + (command) => + command + .command( + 'google', + 'import photos you pick in Google Photos', + (google) => + google + .usage( + "$0 import google [options]\n\nConnects your Google account if needed, opens Google Photos' own picker, and imports what you pick through the same pipeline as an SFTP upload. Videos and photos already in your library are skipped, and photos arrive without GPS because Google withholds location from downloads.", + ) + .option('yes', { + default: false, + describe: 'import what you pick without asking to confirm', + type: 'boolean', + }) + .option('wait', { + default: true, + describe: + 'watch the import until it finishes; --no-wait prints the started job and exits', + type: 'boolean', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON; needs --yes', + type: 'boolean', + }) + .check((args) => checkConfirmable(args, 'rawback import google')), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleImport } = await import('./google-photos.ts') + await runCommand( + () => runGoogleImport({ json: args.json, wait: args.wait, yes: args.yes }), + 'Google Photos import cancelled.', + ) + }, + ) + .demandCommand(1, 'Choose where to import from: google') + .strict(), + () => {}, + ) + .command( + 'export', + 'export photos to another service', + (command) => + command + .command( + 'google', + 'export library photos to Google Photos', + (google) => + google + .usage( + '$0 export google [selection] [options]\n\nExports photos to your Google Photos library, by default into an album named "Rawback". Choose at most one selection, --all (the default), --from/--to, --album, or --image/--images-file. Connects your Google account first if needed.', + ) + .option('all', { + default: false, + describe: 'export every photo (the default without another selection)', + type: 'boolean', + }) + .option('from', { + describe: + 'photos captured (or, without a capture time, uploaded) on or after this date: YYYY-MM-DD (a UTC day) or RFC3339', + type: 'string', + }) + .option('to', { + describe: + 'photos captured on or before this date: YYYY-MM-DD (a UTC day) or RFC3339', + type: 'string', + }) + .option('album', { + array: true, + describe: 'photos in this album ID (repeat or comma-separate, up to 100)', + type: 'string', + }) + .option('image', { + array: true, + describe: 'this photo ID (repeat or comma-separate)', + type: 'string', + }) + .option('images-file', { + coerce: expandHomePath, + describe: 'file of photo IDs, one per line; - reads standard input', + type: 'string', + }) + .option('include-secret', { + default: false, + describe: 'also export photos a secret album hides from the library', + type: 'boolean', + }) + .option('include-archived', { + default: false, + describe: 'also export archived photos', + type: 'boolean', + }) + .option('skip-exported', { + default: true, + describe: + 'leave out photos already exported to this Google account; --no-skip-exported sends them again', + type: 'boolean', + }) + .option('album-title', { + describe: + 'Google Photos album to add the photos to, created once and reused (default "Rawback"); --no-album adds them to the library only', + type: 'string', + }) + .option('file', { + choices: ['original', 'fullsize'] as const, + default: 'original' as const, + describe: + 'original: the stored file, RAW included; fullsize: the full-resolution AVIF kept for RAW and HEIC, which Google Photos always opens', + type: 'string', + }) + .option('dry-run', { + default: false, + describe: 'show what would be exported, and stop', + type: 'boolean', + }) + .option('yes', { + default: false, + describe: 'export without asking to confirm', + type: 'boolean', + }) + .option('wait', { + default: true, + describe: + 'watch the export until it finishes; --no-wait prints the started job and exits', + type: 'boolean', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON; needs --yes or --dry-run', + type: 'boolean', + }) + .check((args) => checkConfirmable(args, 'rawback export google', true)), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleExport } = await import('./google-photos.ts') + const { albums, libraryOnly } = albumSelection( + args.album as ReadonlyArray<string | boolean> | undefined, + ) + await runCommand( + () => + runGoogleExport({ + all: args.all, + albums, + dryRun: args.dryRun, + file: args.file, + includeArchived: args.includeArchived, + includeSecret: args.includeSecret, + json: args.json, + libraryOnly, + skipExported: args.skipExported, + wait: args.wait, + yes: args.yes, + ...(args.from !== undefined ? { from: args.from } : {}), + ...(args.to !== undefined ? { to: args.to } : {}), + ...(args.image !== undefined ? { images: args.image } : {}), + ...(args.imagesFile !== undefined ? { imagesFile: args.imagesFile } : {}), + ...(args.albumTitle !== undefined ? { albumTitle: args.albumTitle } : {}), + }), + 'Google Photos export cancelled.', + ) + }, + ) + .demandCommand(1, 'Choose where to export to: google') + .strict(), + () => {}, + ) + .command( + 'google', + 'manage the Google Photos connection and transfers', + (command) => + command + .command( + 'status', + 'show the connected Google account and running transfers', + (status) => + status.option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleStatus } = await import('./google-photos.ts') + await runCommand(() => runGoogleStatus({ json: args.json })) + }, + ) + .command( + 'connect', + 'link a Google account, or reconnect one Google stopped accepting', + (connect) => + connect.option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleConnect } = await import('./google-photos.ts') + await runCommand(() => runGoogleConnect({ json: args.json })) + }, + ) + .command( + 'disconnect', + "revoke Rawback's Google Photos access and cancel running transfers", + (disconnect) => + disconnect + .option('yes', { + default: false, + describe: 'disconnect without asking to confirm', + type: 'boolean', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON; needs --yes', + type: 'boolean', + }) + .check((args) => checkConfirmable(args, 'rawback google disconnect')), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleDisconnect } = await import('./google-photos.ts') + await runCommand( + () => runGoogleDisconnect({ json: args.json, yes: args.yes }), + 'Google Photos stays connected.', + ) + }, + ) + .command( + 'jobs', + 'list Google Photos imports and exports, newest first', + (jobs) => + jobs + .option('kind', { + choices: ['import', 'export'] as const, + describe: 'only imports or only exports', + type: 'string', + }) + .option('limit', { + default: 20, + describe: 'jobs to show (1-100)', + type: 'number', + }) + .option('offset', { + default: 0, + describe: 'jobs to skip', + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleJobs } = await import('./google-photos.ts') + await runCommand(() => + runGoogleJobs({ + json: args.json, + limit: args.limit, + offset: args.offset, + ...(args.kind !== undefined ? { kind: args.kind } : {}), + }), + ) + }, + ) + .command( + 'job <id>', + 'show one import or export and its items', + (job) => + job + .positional('id', { + describe: 'job ID from rawback google jobs', + type: 'number', + }) + .option('items', { + choices: ['pending', 'done', 'skipped', 'failed'] as const, + describe: 'only items in this state', + type: 'string', + }) + .option('limit', { + default: 50, + describe: 'items to show (1-200)', + type: 'number', + }) + .option('offset', { + default: 0, + describe: 'items to skip', + type: 'number', + }) + .option('watch', { + default: false, + describe: 'follow the job until it finishes; exits nonzero if it fails', + type: 'boolean', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleJob } = await import('./google-photos.ts') + await runCommand(() => + runGoogleJob({ + id: args.id as number, + json: args.json, + limit: args.limit, + offset: args.offset, + watch: args.watch, + ...(args.items !== undefined ? { items: args.items } : {}), + }), + ) + }, + ) + .command( + 'cancel <id>', + 'stop a running import or export', + (cancel) => + cancel + .positional('id', { + describe: 'job ID from rawback google jobs', + type: 'number', + }) + .option('json', { + default: false, + describe: 'output machine-readable JSON', + type: 'boolean', + }), + async (args) => { + if (process.exitCode !== undefined && process.exitCode !== 0) return + const { runGoogleCancel } = await import('./google-photos.ts') + await runCommand(() => runGoogleCancel({ id: args.id as number, json: args.json })) + }, + ) + .demandCommand( + 1, + 'Choose a google command: status, connect, disconnect, jobs, job, or cancel', + ) + .strict(), + () => {}, + ) .command( 'uploads', 'list FTP and SFTP upload sessions', diff --git a/src/features/google-photos/view.ts b/src/features/google-photos/view.ts new file mode 100644 index 0000000..ba1faca --- /dev/null +++ b/src/features/google-photos/view.ts @@ -0,0 +1,349 @@ +import type { + GooglePhotosAccountSummary, + GooglePhotosExportPreviewSummary, + GooglePhotosJobDetail, + GooglePhotosJobSummary, + GooglePhotosStatusSummary, +} from '@rawback/sdk' + +import { formatBytes, formatCount } from '../../ui/format.ts' +import { cell, type UiCell, type UiDocument, type UiTone } from '../../ui/model.ts' + +/** + * Copy for the server's job and item `error` labels. The server adds labels + * but never renames one; an unknown label is shown as it is. + */ +const ERROR_LABELS: Record<string, string> = { + needs_reauth: 'Google needs to be reconnected', + missing_scope: 'Google Photos permission was not granted', + rate_limited: "waiting out Google's request quota", + session_expired: 'the Google Photos picker session expired', + quota_exceeded: 'storage is full', + unsupported_type: 'not a supported photo (videos are not imported)', + too_large: 'file is too large', + duplicate: 'already in your library', + already_exported: 'already exported to this Google account', + not_found: 'no longer exists', + download_failed: 'download failed', + upload_failed: 'upload failed', + rejected_by_google: 'rejected by Google Photos', + album_failed: 'could not be added to the Google Photos album', + internal_error: 'internal error', + cancelled: 'cancelled', +} + +export function describeGoogleErrorLabel(label: string | null | undefined): string | undefined { + if (!label) return undefined + return ERROR_LABELS[label] ?? label +} + +/** `2026-10-07T10:00:00Z` → `2026-10-07 10:00`, the same instant in UTC. */ +export function formatInstant(value: string | null | undefined): string { + if (!value) return '—' + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) return '—' + return new Date(parsed).toISOString().slice(0, 16).replace('T', ' ') +} + +function statusTone(status: GooglePhotosJobSummary['status']): UiTone { + if (status === 'completed') return 'success' + if (status === 'failed') return 'error' + if (status === 'cancelled') return 'warning' + return 'info' +} + +function statusCell(job: GooglePhotosJobSummary): UiCell { + return cell(job.pausedUntil ? 'paused' : job.status, { + tone: job.pausedUntil ? 'warning' : statusTone(job.status), + }) +} + +function countCell(value: number, tone: UiTone): UiCell { + return value === 0 ? cell('0', { dim: true }) : cell(formatCount(value), { tone }) +} + +function verb(job: Pick<GooglePhotosJobSummary, 'kind'>): string { + return job.kind === 'import' ? 'Importing' : 'Exporting' +} + +/** One line of live progress, used both by the spinner and by plain stderr lines. */ +export function jobProgressLabel(job: GooglePhotosJobSummary): string { + if (job.status === 'pending') { + return `${verb(job)} — waiting to start (job ${String(job.id)})…` + } + const parts = [ + `${verb(job)} ${formatCount(job.doneItems + job.skippedItems + job.failedItems)}/${formatCount(job.totalItems)}`, + `${formatCount(job.skippedItems)} skipped`, + `${formatCount(job.failedItems)} failed`, + formatBytes(job.transferredBytes), + ] + if (job.pausedUntil) parts.push(`paused by Google until ${formatInstant(job.pausedUntil)} UTC`) + return parts.join(' · ') +} + +export function googleStatusDocument(status: GooglePhotosStatusSummary): UiDocument { + if (!status.available) { + return { + title: 'Google Photos', + blocks: [ + { + type: 'notice', + message: 'Google Photos is not enabled on this server.', + tone: 'warning', + }, + ], + } + } + const account = status.account + return { + title: 'Google Photos', + blocks: [ + account + ? account.needsReauth + ? { + type: 'notice', + message: "Google needs to be reconnected. Run 'rawback google connect'.", + tone: 'warning', + } + : { type: 'notice', message: 'Connected', tone: 'success' } + : { + type: 'notice', + message: "Not connected. Run 'rawback google connect' to link a Google account.", + tone: 'info', + }, + { + type: 'fields', + fields: [ + ...(account ? accountFields(account) : []), + { label: 'Active import', value: activeJobCell(status.activeImport) }, + { label: 'Active export', value: activeJobCell(status.activeExport) }, + { label: 'Pick limit', value: `${formatCount(status.maxPickerItems)} items` }, + { label: 'Export limit', value: `${formatCount(status.maxExportItems)} photos` }, + ], + }, + ], + } +} + +function accountFields(account: GooglePhotosAccountSummary) { + return [ + { + label: 'Account', + value: account.name ? `${account.name} <${account.email}>` : account.email, + }, + { + label: 'Import', + value: account.canImport + ? cell('allowed', { tone: 'success' }) + : cell('not granted', { tone: 'warning' }), + }, + { + label: 'Export', + value: account.canExport + ? cell('allowed', { tone: 'success' }) + : cell('not granted', { tone: 'warning' }), + }, + { label: 'Connected', value: formatInstant(account.connectedAt) }, + ] +} + +function activeJobCell(job: GooglePhotosJobSummary | null): UiCell { + if (!job) return cell('none', { dim: true }) + return cell(`job ${String(job.id)} — ${jobProgressLabel(job)}`, { tone: 'info' }) +} + +export interface PickedItemsSummary { + total: number + photos: number + videos: number +} + +export function pickedItemsDocument(picked: PickedItemsSummary): UiDocument { + return { + title: 'Picked in Google Photos', + blocks: [ + { + type: 'fields', + fields: [ + { label: 'Photos to import', value: formatCount(picked.photos) }, + { + label: 'Videos skipped', + value: + picked.videos === 0 + ? cell('0', { dim: true }) + : cell(`${formatCount(picked.videos)} (videos are not imported)`, { + tone: 'warning', + }), + }, + ], + }, + { + type: 'text', + text: 'Google withholds location metadata from downloads, so imported photos arrive without GPS. Photos already in your library are skipped.', + dim: true, + }, + ], + } +} + +export interface ExportPlanView { + selection: string + albumTitle: string | null + file: string + preview: GooglePhotosExportPreviewSummary + skipExported: boolean +} + +export function exportPreviewDocument(plan: ExportPlanView): UiDocument { + return { + title: 'Export to Google Photos', + blocks: [ + { + type: 'fields', + fields: [ + { label: 'Selection', value: plan.selection }, + { label: 'Photos to export', value: formatCount(plan.preview.count) }, + { label: 'Size', value: formatBytes(plan.preview.totalBytes) }, + { + label: 'Already exported', + value: + plan.preview.alreadyExported === 0 + ? cell('0', { dim: true }) + : cell( + `${formatCount(plan.preview.alreadyExported)} ${plan.skipExported ? '(skipped)' : '(sent again)'}`, + { tone: 'info' }, + ), + }, + { + label: 'Google album', + value: plan.albumTitle ?? cell('none — library only', { dim: true }), + }, + { label: 'File', value: plan.file }, + ], + }, + ], + } +} + +function jobFields(job: GooglePhotosJobSummary) { + const done = job.kind === 'import' ? 'Imported' : 'Exported' + return [ + { label: 'Job', value: job.id }, + { label: 'Kind', value: job.kind }, + { label: 'Status', value: statusCell(job) }, + { label: done, value: `${formatCount(job.doneItems)} of ${formatCount(job.totalItems)}` }, + { label: 'Skipped', value: countCell(job.skippedItems, 'info') }, + { label: 'Failed', value: countCell(job.failedItems, 'error') }, + { label: 'Transferred', value: formatBytes(job.transferredBytes) }, + ...(job.kind === 'export' + ? [ + { label: 'Google album', value: job.albumTitle ?? cell('library only', { dim: true }) }, + ...(job.albumUrl ? [{ label: 'Album link', value: job.albumUrl }] : []), + { label: 'File', value: job.exportFile }, + ] + : []), + ...(job.upload ? [{ label: 'Upload session', value: job.upload.id }] : []), + ...(job.error + ? [ + { + label: 'Error', + value: cell(describeGoogleErrorLabel(job.error) ?? job.error, { + tone: job.status === 'failed' ? 'error' : 'warning', + }), + }, + ] + : []), + ...(job.pausedUntil + ? [{ label: 'Paused until', value: `${formatInstant(job.pausedUntil)} UTC` }] + : []), + { label: 'Created', value: formatInstant(job.createdAt) }, + ...(job.completedAt ? [{ label: 'Finished', value: formatInstant(job.completedAt) }] : []), + ] +} + +export function jobDocument(job: GooglePhotosJobSummary, hints: string[] = []): UiDocument { + return { + title: job.kind === 'import' ? 'Google Photos import' : 'Google Photos export', + blocks: [ + { type: 'fields', fields: jobFields(job) }, + ...hints.map((text) => ({ type: 'text' as const, text, dim: true })), + ], + } +} + +export function jobDetailDocument(job: GooglePhotosJobDetail): UiDocument { + return { + title: job.kind === 'import' ? 'Google Photos import' : 'Google Photos export', + blocks: [ + { type: 'fields', fields: jobFields(job) }, + { + type: 'table', + emptyMessage: 'No items on this page.', + columns: [ + { key: 'id', label: 'Item', required: true, priority: 1 }, + { key: 'status', label: 'Status', required: true, priority: 1 }, + { key: 'filename', label: 'File', required: true, priority: 1, minWidth: 12 }, + { key: 'size', label: 'Size', priority: 3 }, + { key: 'photo', label: 'Photo', priority: 4 }, + { key: 'detail', label: 'Detail', priority: 2, minWidth: 12 }, + ], + rows: job.items.map((item) => ({ + id: item.id, + status: cell(item.status, { + tone: + item.status === 'done' + ? 'success' + : item.status === 'failed' + ? 'error' + : item.status === 'skipped' + ? 'info' + : 'neutral', + }), + filename: item.filename, + size: item.sizeBytes > 0 ? formatBytes(item.sizeBytes) : cell('—', { dim: true }), + photo: item.image ? item.image.id : cell('—', { dim: true }), + detail: + describeGoogleErrorLabel(item.error) ?? item.googleUrl ?? cell('—', { dim: true }), + })), + }, + ], + } +} + +export function jobListDocument(jobs: GooglePhotosJobSummary[]): UiDocument { + return { + title: 'Google Photos jobs', + blocks: [ + { + type: 'table', + emptyMessage: 'No Google Photos imports or exports yet.', + columns: [ + { key: 'id', label: 'ID', required: true, priority: 1 }, + { key: 'kind', label: 'Kind', required: true, priority: 1 }, + { key: 'status', label: 'Status', required: true, priority: 1 }, + { key: 'done', label: 'Done', priority: 2 }, + { key: 'skipped', label: 'Skipped', priority: 3 }, + { key: 'failed', label: 'Failed', priority: 3 }, + { key: 'size', label: 'Size', priority: 4 }, + { key: 'created', label: 'Created', priority: 5, minWidth: 16 }, + { key: 'detail', label: 'Detail', priority: 6, minWidth: 12 }, + ], + rows: jobs.map((job) => ({ + id: job.id, + kind: job.kind, + status: statusCell(job), + done: `${formatCount(job.doneItems)}/${formatCount(job.totalItems)}`, + skipped: countCell(job.skippedItems, 'info'), + failed: countCell(job.failedItems, 'error'), + size: formatBytes(job.transferredBytes), + created: formatInstant(job.createdAt), + detail: + describeGoogleErrorLabel(job.error) ?? + job.albumUrl ?? + job.albumTitle ?? + cell('—', { dim: true }), + })), + }, + ], + } +} diff --git a/src/features/uploads/view.ts b/src/features/uploads/view.ts index adf18da..0903146 100644 --- a/src/features/uploads/view.ts +++ b/src/features/uploads/view.ts @@ -6,6 +6,11 @@ import { cell, type UiDocument } from '../../ui/model.ts' type UploadSession = UploadSessionsQuery['uploads']['edges'][number] type PageInfo = UploadSessionsQuery['uploads']['pageInfo'] +/** Source kinds are short codes shown in capitals; the multi-word ones read better spelled out. */ +export function uploadSourceLabel(kind: UploadSession['sourceKind']): string { + return kind === 'google_photos' ? 'GOOGLE PHOTOS' : kind.toUpperCase() +} + export function uploadSessionListDocument( uploads: UploadSession[], pageInfo: PageInfo, @@ -37,7 +42,7 @@ export function uploadSessionListDocument( ? 'success' : 'neutral', }), - source: upload.sourceKind.toUpperCase(), + source: uploadSourceLabel(upload.sourceKind), credential: upload.credential?.name ?? cell('—', { dim: true }), files: String(upload.processedFiles) + '/' + String(upload.totalFiles), failed: diff --git a/src/google-photos.ts b/src/google-photos.ts new file mode 100644 index 0000000..9d450cc --- /dev/null +++ b/src/google-photos.ts @@ -0,0 +1,1101 @@ +import { readFile } from 'node:fs/promises' + +import { + type GooglePhotosAccountSummary, + GooglePhotosErrorReason, + type GooglePhotosExportInput, + type GooglePhotosExportPreviewSummary, + type GooglePhotosJobDetail, + GooglePhotosJobNotFoundError, + type GooglePhotosJobSummary, + type GooglePhotosPickedItemSummary, + GooglePhotosService, + type GooglePhotosServiceOptions, + type GooglePhotosStatusSummary, + GooglePhotosTimeoutError, + googlePhotosErrorReason, + googlePhotosUnavailableError, + isGooglePhotosJobFinished, + RawbackGraphqlError, +} from '@rawback/sdk' + +import { parsePositiveIds, validatePositiveId } from './albums.ts' +import { commandOutput, createCommandClient, type ReadCommandDependencies } from './command.ts' +import { + describeGoogleErrorLabel, + exportPreviewDocument, + googleStatusDocument, + jobDetailDocument, + jobDocument, + jobListDocument, + jobProgressLabel, + pickedItemsDocument, + type PickedItemsSummary, +} from './features/google-photos/view.ts' +import { formatBytes, formatCount } from './ui/format.ts' +import type { UiTone } from './ui/model.ts' +import { type CommandOutput, noticeDocument } from './ui/output.tsx' +import { browserCommand, defaultOpen } from './web.ts' + +export interface GooglePhotosPrompts { + confirm(message: string, defaultAnswer: boolean): Promise<boolean> +} + +export interface GooglePhotosCommandDependencies extends ReadCommandDependencies { + prompts?: GooglePhotosPrompts + open?: (command: string, args: string[]) => Promise<number> + platform?: NodeJS.Platform + /** The SDK's polling clock and timer; tests make waiting instant. */ + polling?: GooglePhotosServiceOptions + /** Subscribes to Ctrl-C and returns the unsubscribe. */ + onInterrupt?: (handler: () => void) => () => void + /** Reads `--images-file -`. */ + readStdin?: () => Promise<string> + /** Wall clock that paces plain progress lines. */ + now?: () => number +} + +/** + * Ctrl-C while waiting: the command stops, explains what is left behind, and + * exits 130. `runCommand` recognises it by name, which keeps the SDK out of + * `cli.ts`'s startup path. + */ +export class CommandInterruptedError extends Error { + override readonly name = 'CommandInterruptedError' +} + +export const GOOGLE_EXPORT_FILES = ['original', 'fullsize'] as const +export const GOOGLE_JOB_KINDS = ['import', 'export'] as const +export const GOOGLE_JOB_ITEM_STATUSES = ['pending', 'done', 'skipped', 'failed'] as const +export const DEFAULT_GOOGLE_ALBUM_TITLE = 'Rawback' + +/** The server's cap on albums in one export selection. */ +const MAX_EXPORT_ALBUMS = 100 +/** Without a terminal, one progress line at most this often, plus one per state change. */ +const PLAIN_PROGRESS_INTERVAL_MS = 30_000 + +const REASON_MESSAGES: Record<GooglePhotosErrorReason, string> = { + [GooglePhotosErrorReason.Unavailable]: 'Google Photos is not enabled on this server.', + [GooglePhotosErrorReason.NotConnected]: + "Google Photos is not connected. Run 'rawback google connect' to link your Google account.", + [GooglePhotosErrorReason.NeedsReauth]: + "Google no longer accepts Rawback's saved access. Run 'rawback google connect' to reconnect.", + [GooglePhotosErrorReason.MissingScope]: + "Google Photos permission was not granted. Run 'rawback google connect' and allow access on Google's consent screen.", + [GooglePhotosErrorReason.JobActive]: + "Another Google Photos transfer of this kind is already running. See 'rawback google jobs'.", + [GooglePhotosErrorReason.NothingPicked]: + "Nothing was picked in Google Photos. Run 'rawback import google' and pick at least one photo.", +} + +/** + * Turns the server's Google Photos reasons, and the SDK's polling errors, into + * a message that says what to run next. Anything else passes through as is. + */ +export function describeGooglePhotosError(error: unknown): unknown { + if (error instanceof GooglePhotosTimeoutError) { + return new Error( + error.waitingFor === 'connection' + ? "Timed out waiting for Google authorization. Run 'rawback google connect' to try again." + : "The Google Photos picker closed before anything was picked. Run 'rawback import google' to start again.", + { cause: error }, + ) + } + if (error instanceof GooglePhotosJobNotFoundError) { + return new Error(`Google Photos job ${String(error.jobId)} was not found.`, { cause: error }) + } + const reason = googlePhotosErrorReason(error) + if (reason === undefined) return error + // The trace ID lives on the original error's headers, which the wrapper + // does not carry, so it is put into the message instead. + const traceId = error instanceof RawbackGraphqlError ? error.traceId : undefined + const message = REASON_MESSAGES[reason] + (traceId ? ` Trace ID: ${traceId}.` : '') + return new Error(message, { cause: error }) +} + +async function translated<T>(action: () => Promise<T>): Promise<T> { + try { + return await action() + } catch (error) { + throw describeGooglePhotosError(error) + } +} + +/** Runs one waiting step, turning an abort into the message for that step. */ +async function stage<T>(signal: AbortSignal, message: string, run: () => Promise<T>): Promise<T> { + try { + return await run() + } catch (error) { + if (signal.aborted) throw new CommandInterruptedError(message, { cause: error }) + throw error + } +} + +/** Ctrl-C while the server was starting a job: it may have started anyway. */ +function startInterrupted(kind: 'import' | 'export'): string { + return `Stopped while starting the ${kind}; it may have started anyway. See 'rawback google jobs'.` +} + +function defaultOnInterrupt(handler: () => void): () => void { + let count = 0 + const listener = () => { + count += 1 + if (count === 1) { + handler() + return + } + // A second Ctrl-C means stop now, whatever is still being cleaned up. + process.off('SIGINT', listener) + process.exitCode = 130 + process.kill(process.pid, 'SIGINT') + } + process.on('SIGINT', listener) + return () => process.off('SIGINT', listener) +} + +function defaultPrompts(nonInteractiveMessage: string): GooglePhotosPrompts { + return { + async confirm(message, defaultAnswer) { + if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(nonInteractiveMessage) + const { confirm } = await import('@inquirer/prompts') + return confirm({ default: defaultAnswer, message }) + }, + } +} + +async function googlePhotosClient( + dependencies: GooglePhotosCommandDependencies, +): Promise<GooglePhotosService> { + const client = await createCommandClient(dependencies) + return new GooglePhotosService(client.graphql, dependencies.polling ?? {}) +} + +/** Prose goes to stdout for people and to stderr under `--json`, which owns stdout. */ +function say(ui: CommandOutput, json: boolean, message: string, tone: UiTone = 'info'): void { + const document = noticeDocument(message, tone) + if (json) ui.documentError(document) + else ui.document(document) +} + +/** Shows a link on stderr — raw, so it stays copyable — and tries to open it. */ +async function openInBrowser( + ui: CommandOutput, + dependencies: GooglePhotosCommandDependencies, + intro: string, + url: string, +): Promise<void> { + ui.documentError(noticeDocument(intro, 'info')) + ui.rawError(url) + const [command, args] = browserCommand(dependencies.platform ?? process.platform, url) + try { + const exitCode = await (dependencies.open ?? defaultOpen)(command, args) + if (exitCode !== 0) { + ui.warning('Could not open the browser automatically. Use the link shown above.') + } + } catch { + ui.warning('Could not open the browser automatically. Use the link shown above.') + } +} + +/** A spinner on a terminal; one stderr line otherwise. */ +async function waiting<T>( + ui: CommandOutput, + json: boolean, + label: string, + run: () => Promise<T>, +): Promise<T> { + const live = !json && ui.interactive + if (!live) ui.documentError(noticeDocument(label, 'info')) + return ui.withActivity(label, run, live) +} + +export function serializeGoogleAccount(account: GooglePhotosAccountSummary) { + return { + email: account.email, + name: account.name, + avatar: account.avatar, + canImport: account.canImport, + canExport: account.canExport, + needsReauth: account.needsReauth, + connectedAt: account.connectedAt, + } +} + +export function serializeGoogleJob(job: GooglePhotosJobSummary) { + return { + id: job.id, + kind: job.kind, + status: job.status, + totalItems: job.totalItems, + doneItems: job.doneItems, + skippedItems: job.skippedItems, + failedItems: job.failedItems, + transferredBytes: job.transferredBytes, + albumTitle: job.albumTitle ?? null, + albumUrl: job.albumUrl ?? null, + exportFile: job.exportFile, + error: job.error ?? null, + pausedUntil: job.pausedUntil ?? null, + createdAt: job.createdAt, + startedAt: job.startedAt ?? null, + completedAt: job.completedAt ?? null, + upload: job.upload + ? { id: job.upload.id, sessionId: job.upload.sessionId, status: job.upload.status } + : null, + } +} + +function serializeJobDetail(job: GooglePhotosJobDetail) { + return { + ...serializeGoogleJob(job), + items: job.items.map((item) => ({ + id: item.id, + status: item.status, + filename: item.filename, + mimeType: item.mimeType, + sizeBytes: item.sizeBytes, + imageId: item.image?.id ?? null, + googleUrl: item.googleUrl ?? null, + error: item.error ?? null, + })), + } +} + +function serializeStatus(status: GooglePhotosStatusSummary) { + return { + available: status.available, + account: status.account ? serializeGoogleAccount(status.account) : null, + activeImport: status.activeImport ? serializeGoogleJob(status.activeImport) : null, + activeExport: status.activeExport ? serializeGoogleJob(status.activeExport) : null, + maxPickerItems: status.maxPickerItems, + maxExportItems: status.maxExportItems, + } +} + +function serializePreview(preview: GooglePhotosExportPreviewSummary) { + return { + count: preview.count, + totalBytes: preview.totalBytes, + alreadyExported: preview.alreadyExported, + } +} + +type Direction = 'import' | 'export' + +function allows(account: GooglePhotosAccountSummary, direction: Direction): boolean { + return direction === 'import' ? account.canImport : account.canExport +} + +function isUsable(account: GooglePhotosAccountSummary, direction: Direction): boolean { + return !account.needsReauth && allows(account, direction) +} + +function permissionName(direction: Direction): string { + return direction === 'import' ? 'read photos you pick' : 'add photos to your library' +} + +interface FlowContext { + dependencies: GooglePhotosCommandDependencies + google: GooglePhotosService + json: boolean + signal: AbortSignal + ui: CommandOutput +} + +/** Sends the user through Google's consent screen and waits until it is done. */ +async function connectAccount( + context: FlowContext, + direction: Direction | undefined, + why: string, +): Promise<GooglePhotosAccountSummary> { + const { google, json, signal, ui } = context + ui.documentError(noticeDocument(why, 'info')) + const start = await google.connect({ client: 'cli', signal }) + await openInBrowser( + ui, + context.dependencies, + 'Allow Rawback to use Google Photos in your browser:', + start.authorizationUrl, + ) + const account = await stage(signal, 'Stopped waiting for Google authorization.', () => + waiting(ui, json, 'Waiting for Google authorization…', () => + google.waitForConnection({ + expiresAt: start.expiresAt, + signal, + ...(direction ? { require: direction } : {}), + }), + ), + ) + say(ui, json, `Connected Google Photos as ${account.email}.`, 'success') + return account +} + +async function ensureConnected( + context: FlowContext, + status: GooglePhotosStatusSummary, + direction: Direction, +): Promise<GooglePhotosAccountSummary> { + const account = status.account + if (account && isUsable(account, direction)) return account + const why = !account + ? 'Connect a Google account to continue.' + : account.needsReauth + ? 'Google needs to be reconnected.' + : `Rawback is not allowed to ${permissionName(direction)}; allow it on Google's consent screen.` + return connectAccount(context, direction, why) +} + +function activeJobError(job: GooglePhotosJobSummary): Error { + const id = String(job.id) + return new Error( + `A Google Photos ${job.kind} is already running (job ${id}). Follow it with 'rawback google job ${id} --watch' or stop it with 'rawback google cancel ${id}'.`, + ) +} + +interface JobProgress { + report(job: GooglePhotosJobSummary): void + latest(): GooglePhotosJobSummary + stop(): Promise<void> +} + +function startJobProgress(context: FlowContext, initial: GooglePhotosJobSummary): JobProgress { + const { json, ui } = context + const now = context.dependencies.now ?? Date.now + const live = !json && ui.interactive + const activity = ui.startActivity(jobProgressLabel(initial), live) + let latest = initial + let lastState: string | undefined + let lastAt = 0 + const report = (job: GooglePhotosJobSummary) => { + latest = job + if (live) { + activity.update(jobProgressLabel(job)) + return + } + // The summary printed at the end covers the final state. + if (isGooglePhotosJobFinished(job)) return + const state = `${job.status}|${job.pausedUntil ?? ''}` + if (state === lastState && now() - lastAt < PLAIN_PROGRESS_INTERVAL_MS) return + lastState = state + lastAt = now() + ui.documentError(noticeDocument(jobProgressLabel(job), 'info')) + } + report(initial) + return { report, latest: () => latest, stop: () => activity.stop() } +} + +function followHint(job: GooglePhotosJobSummary): string { + return `Follow it with 'rawback google job ${String(job.id)} --watch'.` +} + +/** + * Watches a job to the end, or — on Ctrl-C — reports where it got to and + * leaves it running on the server. + */ +async function watchToEnd( + context: FlowContext, + job: GooglePhotosJobSummary, + report: (job: GooglePhotosJobSummary, finished: boolean) => void, +): Promise<GooglePhotosJobSummary> { + const progress = startJobProgress(context, job) + let final: GooglePhotosJobSummary + try { + final = await context.google.watchJob(job.id, { + signal: context.signal, + onProgress: progress.report, + }) + } catch (error) { + await progress.stop() + if (!context.signal.aborted) throw error + report(progress.latest(), false) + throw new CommandInterruptedError( + `Stopped watching. The ${job.kind} continues on the server as job ${String(job.id)}. ${followHint(job)}`, + { cause: error }, + ) + } + await progress.stop() + return final +} + +/** A finished job that did not do everything it was asked exits nonzero. */ +function assertJobSucceeded(job: GooglePhotosJobSummary): void { + const id = String(job.id) + if (job.status === 'failed') { + const label = describeGoogleErrorLabel(job.error) + throw new Error(`Google Photos ${job.kind} job ${id} failed${label ? `: ${label}` : ''}.`) + } + if (job.status === 'cancelled') { + throw new Error(`Google Photos ${job.kind} job ${id} was cancelled.`) + } + if (job.failedItems > 0) { + throw new Error( + `${formatCount(job.failedItems)} item${job.failedItems === 1 ? '' : 's'} failed. List them with 'rawback google job ${id} --items failed'.`, + ) + } +} + +function jobHints(job: GooglePhotosJobSummary): string[] { + if (!isGooglePhotosJobFinished(job)) return [followHint(job)] + if (job.kind === 'import' && job.upload && job.doneItems > 0) { + return [ + `Imported photos arrive as upload session ${String(job.upload.id)}; follow processing with 'rawback uploads'.`, + ] + } + return [] +} + +function plural(count: number, noun: string): string { + return `${formatCount(count)} ${noun}${count === 1 ? '' : 's'}` +} + +function withInterrupts<T>( + dependencies: GooglePhotosCommandDependencies, + run: (signal: AbortSignal) => Promise<T>, +): Promise<T> { + const controller = new AbortController() + const unsubscribe = (dependencies.onInterrupt ?? defaultOnInterrupt)(() => controller.abort()) + return run(controller.signal).finally(unsubscribe) +} + +export function summarizePicks(items: GooglePhotosPickedItemSummary[]): PickedItemsSummary { + const videos = items.filter((item) => item.type.toUpperCase() === 'VIDEO').length + return { total: items.length, photos: items.length - videos, videos } +} + +export interface GoogleImportOptions { + json?: boolean + /** `false` (`--no-wait`) prints the started job instead of watching it. */ + wait?: boolean + yes?: boolean +} + +/** + * `rawback import google`: connect if needed, let the user pick in Google's + * own picker, confirm, then import through the same pipeline as an SFTP upload. + */ +export async function runGoogleImport( + options: GoogleImportOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const json = options.json ?? false + const wait = options.wait ?? true + if (json && !options.yes) { + throw new Error('rawback import google --json also needs --yes, because it cannot prompt') + } + const ui = commandOutput(dependencies) + const prompts = + dependencies.prompts ?? + defaultPrompts('rawback import google needs an interactive terminal unless --yes is provided') + + await withInterrupts(dependencies, (signal) => + translated(async () => { + const google = await googlePhotosClient(dependencies) + const context: FlowContext = { dependencies, google, json, signal, ui } + const status = await ui.withActivity( + 'Checking Google Photos…', + () => google.status({ signal }), + !json, + ) + if (!status.available) throw googlePhotosUnavailableError() + if (status.activeImport) throw activeJobError(status.activeImport) + await stage(signal, 'Stopped before connecting; nothing was imported.', () => + ensureConnected(context, status, 'import'), + ) + + const session = await google.createPickerSession({ signal }) + // Ours to clean up until the server starts importing from it. + let owned = true + try { + await openInBrowser( + ui, + dependencies, + `Pick up to ${formatCount(status.maxPickerItems)} photos in Google Photos, then select Done:`, + session.pickerUri, + ) + await stage(signal, 'Import cancelled; nothing was imported.', () => + waiting(ui, json, 'Waiting for you to pick photos in Google Photos…', () => + google.waitForPicks(session, { signal }), + ), + ) + const items = await stage(signal, 'Import cancelled; nothing was imported.', () => + ui.withActivity( + 'Reading your picks…', + () => google.listPickedItems(session.id, { signal }), + !json, + ), + ) + const picked = summarizePicks(items) + if (picked.photos === 0) { + throw new Error( + picked.total === 0 + ? REASON_MESSAGES[GooglePhotosErrorReason.NothingPicked] + : `Nothing to import: the ${plural(picked.total, 'picked item')} ${picked.total === 1 ? 'is a video' : 'are all videos'}, and videos are not imported.`, + ) + } + if (json) { + ui.documentError( + noticeDocument( + `Picked ${plural(picked.photos, 'photo')}` + + (picked.videos > 0 ? `; ${plural(picked.videos, 'video')} will be skipped.` : '.'), + 'info', + ), + ) + } else { + ui.document(pickedItemsDocument(picked)) + } + if (!options.yes) { + const confirmed = await prompts.confirm( + `Import ${plural(picked.photos, 'photo')} from Google Photos?`, + true, + ) + if (!confirmed) { + say(ui, json, 'Import cancelled; nothing was imported.') + return + } + } + + let job: GooglePhotosJobSummary + try { + job = await stage(signal, startInterrupted('import'), () => + google.startImport(session.id, { signal }), + ) + } catch (error) { + // Only a refusal leaves the session unused; after a dropped + // connection the import may have started, and needs it. + owned = error instanceof RawbackGraphqlError + throw error + } + owned = false + + const report = (current: GooglePhotosJobSummary, finished: boolean) => { + if (json) ui.json({ picked, job: serializeGoogleJob(current), finished }) + else ui.document(jobDocument(current, jobHints(current))) + } + if (!wait) { + report(job, isGooglePhotosJobFinished(job)) + return + } + const final = await watchToEnd(context, job, report) + report(final, true) + assertJobSucceeded(final) + } finally { + if (owned) await google.deletePickerSession(session.id).catch(() => false) + } + }), + ) +} + +export interface GoogleExportOptions { + all?: boolean + /** Album IDs whose photos to export. */ + albums?: ReadonlyArray<string | number> + /** Google Photos album to add the photos to; defaults to "Rawback". */ + albumTitle?: string + dryRun?: boolean + file?: string + from?: string + images?: ReadonlyArray<string | number> + /** A file of photo IDs, one per line; `-` reads standard input. */ + imagesFile?: string + includeArchived?: boolean + includeSecret?: boolean + json?: boolean + /** `--no-album`: add the photos to the library only. */ + libraryOnly?: boolean + /** `false` (`--no-skip-exported`) sends photos an earlier export already sent. */ + skipExported?: boolean + to?: string + wait?: boolean + yes?: boolean +} + +export interface ResolvedGoogleExport { + input: GooglePhotosExportInput + /** The selection in words, for the preview. */ + selection: string +} + +/** A date-only bound covers its whole UTC day; a timestamp is taken as given. */ +function parseExportDate(value: string, option: string, bound: 'start' | 'end'): string { + const trimmed = value.trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + const start = Date.parse(`${trimmed}T00:00:00Z`) + if (Number.isFinite(start) && new Date(start).toISOString().slice(0, 10) === trimmed) { + return bound === 'start' ? `${trimmed}T00:00:00Z` : `${trimmed}T23:59:59Z` + } + } else if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) { + const parsed = Date.parse(trimmed) + if (Number.isFinite(parsed)) return new Date(parsed).toISOString() + } + throw new Error(`${option} must be a valid YYYY-MM-DD date or RFC3339 timestamp`) +} + +async function readImagesFile( + path: string, + dependencies: GooglePhotosCommandDependencies, +): Promise<number[]> { + let text: string + try { + text = + path === '-' + ? await (dependencies.readStdin ?? (() => Bun.stdin.text()))() + : await readFile(path, 'utf8') + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Unable to read --images-file ${path}: ${detail}`, { cause: error }) + } + return parsePositiveIds(text.split(/[\s,]+/), '--images-file') +} + +/** + * Validates the export flags into the server's input, before any network call: + * one selection, valid dates and IDs, and an album title or `--no-album`. + */ +export async function resolveGoogleExport( + options: GoogleExportOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<ResolvedGoogleExport> { + const file = options.file ?? 'original' + if (!(GOOGLE_EXPORT_FILES as readonly string[]).includes(file)) { + throw new Error(`--file must be one of: ${GOOGLE_EXPORT_FILES.join(', ')}`) + } + const albumIds = parsePositiveIds(options.albums, '--album') + const flagImageIds = parsePositiveIds(options.images, '--image') + const dated = options.from !== undefined || options.to !== undefined + const byImage = flagImageIds.length > 0 || options.imagesFile !== undefined + const chosen = [ + options.all ? '--all' : undefined, + dated ? '--from/--to' : undefined, + albumIds.length > 0 ? '--album' : undefined, + byImage ? '--image/--images-file' : undefined, + ].filter((flag): flag is string => flag !== undefined) + if (chosen.length > 1) { + throw new Error( + `Choose one selection, not ${chosen.join(' and ')}: --all, --from/--to, --album, or --image/--images-file`, + ) + } + if (albumIds.length > MAX_EXPORT_ALBUMS) { + throw new Error(`--album accepts at most ${String(MAX_EXPORT_ALBUMS)} albums`) + } + if (options.libraryOnly && options.albumTitle !== undefined) { + throw new Error('--album-title and --no-album cannot be combined') + } + const albumTitle = options.libraryOnly + ? undefined + : (options.albumTitle ?? DEFAULT_GOOGLE_ALBUM_TITLE).trim() + if (albumTitle === '') { + throw new Error( + '--album-title must not be empty; pass --no-album to add the photos to the library only', + ) + } + const from = + options.from === undefined ? undefined : parseExportDate(options.from, '--from', 'start') + const to = options.to === undefined ? undefined : parseExportDate(options.to, '--to', 'end') + if (from !== undefined && to !== undefined && Date.parse(from) > Date.parse(to)) { + throw new Error('--from must not be later than --to') + } + + let imageIds = flagImageIds + if (options.imagesFile !== undefined) { + const fileIds = await readImagesFile(options.imagesFile, dependencies) + imageIds = [...new Set([...flagImageIds, ...fileIds])] + if (imageIds.length === 0) throw new Error('--images-file lists no photo IDs') + } + + const scope = dated ? 'date_range' : albumIds.length > 0 ? 'albums' : byImage ? 'images' : 'all' + const selection = + scope === 'date_range' + ? `photos from ${from?.slice(0, 10) ?? 'the beginning'} to ${to?.slice(0, 10) ?? 'now'}` + : scope === 'albums' + ? `${plural(albumIds.length, 'album')} (${albumIds.join(', ')})` + : scope === 'images' + ? plural(imageIds.length, 'chosen photo') + : 'all photos' + const extras = [ + options.includeSecret ? 'secret albums included' : undefined, + options.includeArchived ? 'archived included' : undefined, + ].filter(Boolean) + + return { + input: { + scope, + file: file as GooglePhotosExportInput['file'], + includeSecret: options.includeSecret ?? false, + includeArchived: options.includeArchived ?? false, + skipExported: options.skipExported ?? true, + ...(albumTitle !== undefined ? { albumTitle } : {}), + ...(from !== undefined ? { from } : {}), + ...(to !== undefined ? { to } : {}), + ...(scope === 'albums' ? { albumIds } : {}), + ...(scope === 'images' ? { imageIds } : {}), + }, + selection: extras.length > 0 ? `${selection} (${extras.join(', ')})` : selection, + } +} + +/** `rawback export google`: preview a selection, confirm, export, and watch it. */ +export async function runGoogleExport( + options: GoogleExportOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const json = options.json ?? false + const wait = options.wait ?? true + if (json && !options.yes && !options.dryRun) { + throw new Error( + 'rawback export google --json also needs --yes (or --dry-run), because it cannot prompt', + ) + } + const plan = await resolveGoogleExport(options, dependencies) + const ui = commandOutput(dependencies) + const prompts = + dependencies.prompts ?? + defaultPrompts('rawback export google needs an interactive terminal unless --yes is provided') + const albumTitle = plan.input.albumTitle ?? null + + await withInterrupts(dependencies, (signal) => + translated(async () => { + const google = await googlePhotosClient(dependencies) + const context: FlowContext = { dependencies, google, json, signal, ui } + const status = await ui.withActivity( + 'Checking Google Photos…', + () => google.status({ signal }), + !json, + ) + if (!status.available) throw googlePhotosUnavailableError() + if (!options.dryRun) { + if (status.activeExport) throw activeJobError(status.activeExport) + await stage(signal, 'Stopped before connecting; nothing was exported.', () => + ensureConnected(context, status, 'export'), + ) + } + + const preview = await ui.withActivity( + 'Counting photos…', + () => google.previewExport(plan.input, { signal }), + !json, + ) + const output = (job: GooglePhotosJobSummary | null, finished: boolean) => + ui.json({ + dryRun: options.dryRun ?? false, + input: plan.input, + preview: serializePreview(preview), + job: job ? serializeGoogleJob(job) : null, + finished, + }) + const view = { + selection: plan.selection, + albumTitle, + file: plan.input.file ?? 'original', + preview, + skipExported: plan.input.skipExported !== false, + } + if (!json) ui.document(exportPreviewDocument(view)) + + // Checked before --dry-run returns, since a dry run is how a selection + // is checked: it must fail exactly as the real run would. + if (preview.count > status.maxExportItems) { + throw new Error( + `This selection has ${plural(preview.count, 'photo')}; one export sends at most ${formatCount(status.maxExportItems)}. Narrow it with --from/--to, --album or --image.`, + ) + } + if (options.dryRun) { + if (json) output(null, false) + return + } + if (preview.count === 0) { + say( + ui, + json, + preview.alreadyExported > 0 + ? `Nothing to export: ${plural(preview.alreadyExported, 'matching photo')} ${preview.alreadyExported === 1 ? 'was' : 'were'} already exported to this Google account. Pass --no-skip-exported to send them again.` + : 'Nothing to export: no photos match this selection.', + ) + if (json) output(null, false) + return + } + if (!options.yes) { + const destination = albumTitle ? ` album "${albumTitle}"` : '' + const confirmed = await prompts.confirm( + `Export ${plural(preview.count, 'photo')} (${formatBytes(preview.totalBytes)}) to Google Photos${destination}?`, + false, + ) + if (!confirmed) { + say(ui, json, 'Export cancelled; nothing was sent.') + return + } + } + + const job = await stage(signal, startInterrupted('export'), () => + google.startExport(plan.input, { signal }), + ) + const report = (current: GooglePhotosJobSummary, finished: boolean) => { + if (json) output(current, finished) + else ui.document(jobDocument(current, jobHints(current))) + } + if (!wait) { + report(job, isGooglePhotosJobFinished(job)) + return + } + const final = await watchToEnd(context, job, report) + report(final, true) + assertJobSucceeded(final) + }), + ) +} + +export interface GoogleJsonOptions { + json?: boolean +} + +/** `rawback google status`. A server without the feature is reported, not an error. */ +export async function runGoogleStatus( + options: GoogleJsonOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const ui = commandOutput(dependencies) + const status = await translated(async () => { + const google = await googlePhotosClient(dependencies) + return ui.withActivity('Checking Google Photos…', () => google.status(), !options.json) + }) + if (options.json) ui.json(serializeStatus(status)) + else ui.document(googleStatusDocument(status)) +} + +/** `rawback google connect`: link a Google account, or repair one that cannot do both directions. */ +export async function runGoogleConnect( + options: GoogleJsonOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const json = options.json ?? false + const ui = commandOutput(dependencies) + await withInterrupts(dependencies, (signal) => + translated(async () => { + const google = await googlePhotosClient(dependencies) + const context: FlowContext = { dependencies, google, json, signal, ui } + const status = await ui.withActivity( + 'Checking Google Photos…', + () => google.status({ signal }), + !json, + ) + if (!status.available) throw googlePhotosUnavailableError() + const current = status.account + if (current && isUsable(current, 'import') && isUsable(current, 'export')) { + say( + ui, + json, + `Already connected as ${current.email}. To use another Google account, run 'rawback google disconnect' first.`, + ) + if (json) ui.json({ account: serializeGoogleAccount(current), connected: false }) + return + } + // A linked account missing one permission waits for that one; anything + // else just waits for a working link. + const missing = + current && !current.needsReauth + ? (['import', 'export'] as const).find((direction) => !allows(current, direction)) + : undefined + const why = !current + ? 'Link a Google account to Rawback.' + : current.needsReauth + ? 'Google needs to be reconnected.' + : `Rawback is not allowed to ${permissionName(missing ?? 'import')}; allow it on Google's consent screen.` + const account = await connectAccount(context, missing, why) + for (const direction of ['import', 'export'] as const) { + if (!allows(account, direction)) { + ui.warning( + `Google did not grant permission to ${permissionName(direction)}. Run 'rawback google connect' again and allow it.`, + ) + } + } + if (json) ui.json({ account: serializeGoogleAccount(account), connected: true }) + }), + ) +} + +export interface GoogleDisconnectOptions extends GoogleJsonOptions { + yes?: boolean +} + +/** `rawback google disconnect`: revoke Rawback's access and cancel running jobs. */ +export async function runGoogleDisconnect( + options: GoogleDisconnectOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const json = options.json ?? false + if (json && !options.yes) { + throw new Error('rawback google disconnect --json also needs --yes, because it cannot prompt') + } + const ui = commandOutput(dependencies) + const prompts = + dependencies.prompts ?? + defaultPrompts( + 'rawback google disconnect needs an interactive terminal unless --yes is provided', + ) + await translated(async () => { + const google = await googlePhotosClient(dependencies) + const status = await ui.withActivity('Checking Google Photos…', () => google.status(), !json) + if (!status.available) throw googlePhotosUnavailableError() + const account = status.account + if (!account) { + if (json) ui.json({ disconnected: false }) + else ui.info('No Google account is connected.') + return + } + if (!options.yes) { + const confirmed = await prompts.confirm( + `Disconnect Google account ${account.email}? Rawback's access is revoked and running imports and exports are cancelled.`, + false, + ) + if (!confirmed) { + ui.info('Google Photos stays connected.') + return + } + } + const disconnected = await google.disconnect() + if (json) ui.json({ disconnected }) + else if (disconnected) ui.success(`Disconnected Google account ${account.email}.`) + else ui.info('No Google account was connected.') + }) +} + +export interface GoogleJobsOptions extends GoogleJsonOptions { + kind?: string + limit?: number + offset?: number +} + +function validateCount(value: number, option: string, min: number, max: number): number { + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new Error(`${option} must be an integer between ${String(min)} and ${String(max)}`) + } + return value +} + +function validateOffset(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('--offset must be a non-negative integer') + } + return value +} + +/** `rawback google jobs`: imports and exports, newest first. */ +export async function runGoogleJobs( + options: GoogleJobsOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + if ( + options.kind !== undefined && + !(GOOGLE_JOB_KINDS as readonly string[]).includes(options.kind) + ) { + throw new Error(`--kind must be one of: ${GOOGLE_JOB_KINDS.join(', ')}`) + } + const limit = validateCount(options.limit ?? 20, '--limit', 1, 100) + const offset = validateOffset(options.offset ?? 0) + const ui = commandOutput(dependencies) + const jobs = await translated(async () => { + const google = await googlePhotosClient(dependencies) + return ui.withActivity( + 'Loading Google Photos jobs…', + () => + google.listJobs({ + limit, + offset, + ...(options.kind !== undefined + ? { kind: options.kind as (typeof GOOGLE_JOB_KINDS)[number] } + : {}), + }), + !options.json, + ) + }) + if (options.json) ui.json({ jobs: jobs.map(serializeGoogleJob) }) + else ui.document(jobListDocument(jobs)) +} + +export interface GoogleJobOptions extends GoogleJsonOptions { + id: number + /** Only items in this state. */ + items?: string + limit?: number + offset?: number + /** Follow the job until it finishes first. */ + watch?: boolean +} + +/** `rawback google job <id>`: one job and a page of its items, optionally watched to the end. */ +export async function runGoogleJob( + options: GoogleJobOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const id = validatePositiveId(options.id, 'Job ID') + if ( + options.items !== undefined && + !(GOOGLE_JOB_ITEM_STATUSES as readonly string[]).includes(options.items) + ) { + throw new Error(`--items must be one of: ${GOOGLE_JOB_ITEM_STATUSES.join(', ')}`) + } + const limit = validateCount(options.limit ?? 50, '--limit', 1, 200) + const offset = validateOffset(options.offset ?? 0) + const json = options.json ?? false + const ui = commandOutput(dependencies) + + await withInterrupts(dependencies, (signal) => + translated(async () => { + const google = await googlePhotosClient(dependencies) + const read = () => + google.getJob(id, { + signal, + itemLimit: limit, + itemOffset: offset, + ...(options.items !== undefined + ? { itemStatus: options.items as (typeof GOOGLE_JOB_ITEM_STATUSES)[number] } + : {}), + }) + let detail = await ui.withActivity('Loading Google Photos job…', read, !json) + if (!detail) throw new GooglePhotosJobNotFoundError(id) + const report = (current: GooglePhotosJobSummary | GooglePhotosJobDetail) => { + if (json) + ui.json({ + job: 'items' in current ? serializeJobDetail(current) : serializeGoogleJob(current), + }) + else ui.document('items' in current ? jobDetailDocument(current) : jobDocument(current)) + } + if (options.watch && !isGooglePhotosJobFinished(detail)) { + const context: FlowContext = { dependencies, google, json, signal, ui } + await watchToEnd(context, detail, (current) => report(current)) + detail = await read() + if (!detail) throw new GooglePhotosJobNotFoundError(id) + } + report(detail) + if (options.watch) assertJobSucceeded(detail) + }), + ) +} + +export interface GoogleCancelOptions extends GoogleJsonOptions { + id: number +} + +/** `rawback google cancel <id>`: stop a running import or export. */ +export async function runGoogleCancel( + options: GoogleCancelOptions, + dependencies: GooglePhotosCommandDependencies = {}, +): Promise<void> { + const id = validatePositiveId(options.id, 'Job ID') + const ui = commandOutput(dependencies) + const job = await translated(async () => { + const google = await googlePhotosClient(dependencies) + return ui.withActivity( + 'Cancelling Google Photos job…', + () => google.cancelJob(id), + !options.json, + ) + }) + if (options.json) { + ui.json({ job: serializeGoogleJob(job) }) + return + } + ui.document(jobDocument(job)) + if (job.status === 'cancelled') + ui.success(`Cancelled Google Photos ${job.kind} job ${String(id)}.`) + else ui.info(`Job ${String(id)} is ${job.status}; nothing was cancelled.`) +} diff --git a/src/ui/output.tsx b/src/ui/output.tsx index f1a906d..b81e514 100644 --- a/src/ui/output.tsx +++ b/src/ui/output.tsx @@ -79,6 +79,11 @@ export class CommandOutput { this.#stdout(value) } + /** Undecorated text on stderr, e.g. a link that must stay copyable under `--json`. */ + rawError(value: string): void { + this.#stderr(value) + } + success(message: string): void { this.document(noticeDocument(message, 'success')) } diff --git a/test/commands-cli.test.ts b/test/commands-cli.test.ts index 8453c12..393dccd 100644 --- a/test/commands-cli.test.ts +++ b/test/commands-cli.test.ts @@ -557,3 +557,187 @@ describe('logs commands', () => { expect(() => JSON.parse(result.stdout.toString())).not.toThrow() }) }) + +describe('Google Photos commands', () => { + // Each case spawns the CLI, which takes most of a second. + const SPAWN_TIMEOUT_MS = 30_000 + + test( + 'are listed in the top-level help', + () => { + const result = runCli('--help') + expect(result.exitCode).toBe(0) + for (const command of ['rawback import', 'rawback export', 'rawback google']) { + expect(result.stdout).toContain(command) + } + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'document import google', + () => { + const result = runCli('import', 'google', '--help') + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('rawback import google [options]') + expect(result.stdout.replace(/\s+/g, ' ')).toContain('photos arrive without GPS') + for (const flag of ['--yes', '--wait', '--no-wait', '--json']) { + expect(result.stdout).toContain(flag) + } + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'document export google', + () => { + const result = runCli('export', 'google', '--help') + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('rawback export google [selection] [options]') + for (const flag of [ + '--all', + '--from', + '--to', + '--album', + '--image', + '--images-file', + '--include-secret', + '--include-archived', + '--no-skip-exported', + '--album-title', + '--no-album', + '--file', + '--dry-run', + '--yes', + '--no-wait', + '--json', + ]) { + expect(result.stdout).toContain(flag) + } + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'group the connection and job commands under google', + () => { + const result = runCli('google', '--help') + expect(result.exitCode).toBe(0) + for (const command of [ + 'rawback google status', + 'rawback google connect', + 'rawback google disconnect', + 'rawback google jobs', + 'rawback google job <id>', + 'rawback google cancel <id>', + ]) { + expect(result.stdout).toContain(command) + } + + const bare = runCli('google') + expect(bare.exitCode).toBe(1) + expect(bare.stderr).toContain('Choose a google command') + expect(runCli('import').stderr).toContain('Choose where to import from: google') + expect(runCli('export').stderr).toContain('Choose where to export to: google') + }, + SPAWN_TIMEOUT_MS, + ) + + // Every one of these must fail before credentials are read, and before a + // browser window would open. + test( + 'need --yes when they cannot prompt', + () => { + const notTty = runCli('import', 'google') + expect(notTty.exitCode).toBe(1) + expect(notTty.stderr).toContain('needs an interactive terminal unless --yes is provided') + expect(notTty.stderr).not.toContain('Authentication credentials') + + const importJson = runCli('import', 'google', '--json') + expect(importJson.exitCode).toBe(1) + expect(importJson.stdout).toBe('') + expect(importJson.stderr).toContain('rawback import google --json also needs --yes') + + const exportJson = runCli('export', 'google', '--json') + expect(exportJson.exitCode).toBe(1) + expect(exportJson.stderr).toContain('--json also needs --yes (or --dry-run)') + + const disconnect = runCli('google', 'disconnect', '--json') + expect(disconnect.exitCode).toBe(1) + expect(disconnect.stderr).toContain('rawback google disconnect --json also needs --yes') + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'reject conflicting or malformed export selections before authentication', + () => { + const cases: Array<[string[], string]> = [ + [['--all', '--album', '3'], 'Choose one selection, not --all and --album'], + [['--from', '2024-01-01', '--image', '5'], 'not --from/--to and --image/--images-file'], + [['--from', '2024-13-01'], '--from must be a valid YYYY-MM-DD date or RFC3339 timestamp'], + [['--from', '2024-05-01', '--to', '2024-04-01'], '--from must not be later than --to'], + [['--album', 'abc'], '--album must contain only positive integers'], + [ + ['--no-album', '--album-title', 'Trips'], + '--album-title and --no-album cannot be combined', + ], + [['--file', 'raw'], 'Invalid values'], + ] + for (const [flags, message] of cases) { + const result = runCli('export', 'google', '--yes', ...flags) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain(message) + expect(result.stderr).not.toContain('Authentication credentials') + } + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'accept a valid selection and only then need credentials', + () => { + for (const flags of [ + ['--dry-run', '--json'], + ['--dry-run', '--no-album', '--album', '3,4'], + ['--yes', '--from', '2024-01-01', '--no-skip-exported', '--file', 'fullsize', '--no-wait'], + ]) { + const result = runCli('export', 'google', ...flags) + expect(result.exitCode).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).toContain('Authentication credentials are missing') + } + const importYes = runCli('import', 'google', '--yes', '--no-wait') + expect(importYes.exitCode).toBe(1) + expect(importYes.stderr).toContain('Authentication credentials are missing') + }, + SPAWN_TIMEOUT_MS, + ) + + test( + 'validate job options before authentication', + () => { + const limit = runCli('google', 'jobs', '--limit', '0') + expect(limit.exitCode).toBe(1) + expect(limit.stderr).toContain('--limit must be an integer between 1 and 100') + expect(limit.stderr).not.toContain('Authentication credentials') + + const kind = runCli('google', 'jobs', '--kind', 'sync') + expect(kind.exitCode).toBe(1) + expect(kind.stderr).toContain('Invalid values') + + const id = runCli('google', 'job', 'abc') + expect(id.exitCode).toBe(1) + expect(id.stderr).toContain('Job ID must be a positive integer') + + const items = runCli('google', 'job', '3', '--items', 'lost') + expect(items.exitCode).toBe(1) + expect(items.stderr).toContain('Invalid values') + + const cancel = runCli('google', 'cancel', '0') + expect(cancel.exitCode).toBe(1) + expect(cancel.stderr).toContain('Job ID must be a positive integer') + }, + SPAWN_TIMEOUT_MS, + ) +}) diff --git a/test/google-photos.test.ts b/test/google-photos.test.ts new file mode 100644 index 0000000..8b213ca --- /dev/null +++ b/test/google-photos.test.ts @@ -0,0 +1,1335 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + GooglePhotosJobNotFoundError, + GooglePhotosTimeoutError, + NOOP_LOGGER, + RawbackGraphqlError, +} from '@rawback/sdk' + +import { writeCredentials } from '../src/credentials.ts' +import { + describeGoogleErrorLabel, + formatInstant, + jobProgressLabel, +} from '../src/features/google-photos/view.ts' +import { uploadSourceLabel } from '../src/features/uploads/view.ts' +import { + CommandInterruptedError, + describeGooglePhotosError, + type GooglePhotosCommandDependencies, + resolveGoogleExport, + runGoogleCancel, + runGoogleConnect, + runGoogleDisconnect, + runGoogleExport, + runGoogleImport, + runGoogleJob, + runGoogleJobs, + runGoogleStatus, + summarizePicks, +} from '../src/google-photos.ts' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ) +}) + +async function scratch(): Promise<string> { + const directory = await mkdtemp(join(tmpdir(), 'rawback-google-')) + temporaryDirectories.push(directory) + return directory +} + +type Reply = Record<string, unknown> | ((variables: Record<string, unknown>) => unknown) + +const account = { + email: 'ada@example.com', + name: 'Ada', + avatar: '', + canImport: true, + canExport: true, + needsReauth: false, + connectedAt: '2026-10-01T09:00:00Z', +} + +function status(overrides: Record<string, unknown> = {}) { + return { + data: { + googlePhotos: { + available: true, + maxPickerItems: 2000, + maxExportItems: 50000, + account, + activeImport: null, + activeExport: null, + ...overrides, + }, + }, + } +} + +function job(overrides: Record<string, unknown> = {}) { + return { + id: 31, + kind: 'import', + status: 'running', + totalItems: 2, + doneItems: 0, + skippedItems: 0, + failedItems: 0, + transferredBytes: 0, + albumTitle: null, + albumUrl: null, + exportFile: 'original', + error: null, + pausedUntil: null, + createdAt: '2026-10-07T10:00:00Z', + startedAt: null, + completedAt: null, + upload: { id: 88, sessionId: 'upload-88', status: 'in_progress' }, + ...overrides, + } +} + +function session(overrides: Record<string, unknown> = {}) { + return { + id: 'picker-1', + pickerUri: 'https://photos.google.com/picker/picker-1', + mediaItemsSet: false, + pollIntervalSeconds: 5, + timeoutSeconds: 1800, + expiresAt: null, + ...overrides, + } +} + +function item(id: string, type = 'PHOTO') { + return { + id, + filename: `${id}.jpg`, + mimeType: type === 'VIDEO' ? 'video/mp4' : 'image/jpeg', + type, + createdAt: null, + width: null, + height: null, + cameraMake: null, + cameraModel: null, + thumbnailUrl: null, + } +} + +const progress = (value: unknown) => ({ data: { googlePhotosJob: value } }) +const picked = (items: unknown[]) => ({ + data: { googlePhotosPickedItems: { items, nextPageToken: null } }, +}) +const refusal = (code: number, reason?: string, message = 'refused') => ({ + errors: [{ message, extensions: { code, ...(reason ? { reason } : {}) } }], +}) + +interface Harness { + deps: GooglePhotosCommandDependencies + stdout: string[] + stderr: string[] + opened: string[] + prompts: Array<{ message: string; defaultAnswer: boolean }> + calls: Array<{ operation: string; variables: Record<string, unknown> }> + operations: () => string[] + interrupt: () => void +} + +/** + * A fake API answering GraphQL by operation name, a queue per name whose last + * reply repeats, plus instant polling and captured prompts and browser opens. + */ +async function harness( + replies: Record<string, Reply[]>, + overrides: Partial<GooglePhotosCommandDependencies> & { answer?: boolean } = {}, +): Promise<Harness> { + const directory = await scratch() + const credentialsPath = join(directory, 'credentials.json') + await writeCredentials({ token: 'token', refreshToken: 'refresh' }, credentialsPath) + const calls: Harness['calls'] = [] + const stdout: string[] = [] + const stderr: string[] = [] + const opened: string[] = [] + const prompts: Harness['prompts'] = [] + let onInterrupt: (() => void) | undefined + let clock = Date.parse('2026-10-07T10:00:00Z') + const { answer = true, ...dependencyOverrides } = overrides + + const fetch = (async (_input: unknown, init: RequestInit | undefined) => { + const body = JSON.parse(String(init?.body)) as { + operationName: string + variables: Record<string, unknown> + } + calls.push({ operation: body.operationName, variables: body.variables }) + const queue = replies[body.operationName] + if (!queue?.length) { + return Response.json({ errors: [{ message: `unexpected ${body.operationName}` }] }) + } + const next = (queue.length > 1 ? queue.shift() : queue[0]) as Reply + return Response.json(typeof next === 'function' ? next(body.variables) : next) + }) as unknown as typeof globalThis.fetch + + const deps: GooglePhotosCommandDependencies = { + configPath: join(directory, 'config.yml'), + credentialsPath, + fetch, + logger: NOOP_LOGGER, + // Wide enough that no hint wraps mid-command. + columns: 200, + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + open: async (_command, args) => { + opened.push(args.at(-1) ?? '') + return 0 + }, + platform: 'linux', + prompts: { + confirm: async (message, defaultAnswer) => { + prompts.push({ message, defaultAnswer }) + return answer + }, + }, + polling: { + now: () => clock, + sleep: async (milliseconds) => { + clock += milliseconds + }, + }, + now: () => clock, + onInterrupt: (handler) => { + onInterrupt = handler + return () => { + onInterrupt = undefined + } + }, + ...dependencyOverrides, + } + return { + deps, + stdout, + stderr, + opened, + prompts, + calls, + operations: () => calls.map((call) => call.operation), + interrupt: () => onInterrupt?.(), + } +} + +function importReplies(overrides: Record<string, Reply[]> = {}): Record<string, Reply[]> { + return { + GooglePhotosStatus: [status()], + CreateGooglePhotosPickerSession: [{ data: { createGooglePhotosPickerSession: session() } }], + GooglePhotosPickerSession: [ + { data: { googlePhotosPickerSession: session() } }, + { data: { googlePhotosPickerSession: session({ mediaItemsSet: true }) } }, + ], + GooglePhotosPickedItems: [picked([item('a'), item('b'), item('c', 'VIDEO')])], + ImportGooglePhotos: [{ data: { importGooglePhotos: job({ status: 'pending' }) } }], + GooglePhotosJobProgress: [ + progress(job({ doneItems: 1 })), + progress( + job({ + status: 'completed', + doneItems: 2, + transferredBytes: 4096, + completedAt: '2026-10-07T10:02:00Z', + }), + ), + ], + DeleteGooglePhotosPickerSession: [{ data: { deleteGooglePhotosPickerSession: true } }], + ...overrides, + } +} + +describe('rawback import google', () => { + test('opens the picker, confirms, imports, and watches to the end', async () => { + const h = await harness(importReplies()) + await runGoogleImport({}, h.deps) + + expect(h.operations()).toEqual([ + 'GooglePhotosStatus', + 'CreateGooglePhotosPickerSession', + 'GooglePhotosPickerSession', + 'GooglePhotosPickerSession', + 'GooglePhotosPickedItems', + 'ImportGooglePhotos', + 'GooglePhotosJobProgress', + 'GooglePhotosJobProgress', + ]) + expect(h.opened).toEqual(['https://photos.google.com/picker/picker-1']) + // The link is printed undecorated on stderr, so it can be copied. + expect(h.stderr).toContain('https://photos.google.com/picker/picker-1') + expect(h.prompts).toEqual([ + { message: 'Import 2 photos from Google Photos?', defaultAnswer: true }, + ]) + const human = h.stdout.join('\n') + expect(human).toContain('Videos skipped') + expect(human).toContain('without GPS') + expect(human).toContain('2 of 2') + expect(human).toContain('rawback uploads') + expect(h.stderr.join('\n')).toContain('Importing 1/2') + }) + + test('keeps stdout to one JSON document under --json --yes', async () => { + const h = await harness(importReplies()) + await runGoogleImport({ json: true, yes: true }, h.deps) + + expect(h.prompts).toEqual([]) + const output = JSON.parse(h.stdout.join('\n')) as Record<string, unknown> + expect(output).toEqual({ + picked: { total: 3, photos: 2, videos: 1 }, + job: expect.objectContaining({ id: 31, status: 'completed', doneItems: 2 }), + finished: true, + }) + expect(h.stderr.join('\n')).toContain('1 video will be skipped') + }) + + test('connects the Google account first when none is linked', async () => { + const h = await harness( + importReplies({ + GooglePhotosStatus: [status({ account: null }), status({ account: null }), status()], + ConnectGooglePhotos: [ + { + data: { + connectGooglePhotos: { + authorizationUrl: 'https://api.rawback.test/google-photos/start?state=s', + expiresAt: '2026-10-07T10:10:00Z', + }, + }, + }, + ], + }), + ) + await runGoogleImport({ yes: true }, h.deps) + + expect(h.calls.find((call) => call.operation === 'ConnectGooglePhotos')?.variables).toEqual({ + client: 'cli', + }) + expect(h.opened[0]).toBe('https://api.rawback.test/google-photos/start?state=s') + expect(h.stderr.join('\n')).toContain('Connect a Google account to continue.') + expect(h.stdout.join('\n')).toContain('Connected Google Photos as ada@example.com.') + }) + + test('reconnects an account Google stopped accepting, and one missing the import permission', async () => { + for (const stale of [ + { ...account, needsReauth: true }, + { ...account, canImport: false }, + ]) { + const h = await harness( + importReplies({ + GooglePhotosStatus: [status({ account: stale }), status()], + ConnectGooglePhotos: [ + { + data: { + connectGooglePhotos: { + authorizationUrl: 'https://api.rawback.test/start', + expiresAt: '2026-10-07T10:10:00Z', + }, + }, + }, + ], + }), + ) + await runGoogleImport({ yes: true }, h.deps) + expect(h.operations()).toContain('ConnectGooglePhotos') + } + }) + + test('warns when the browser cannot be opened, and carries on', async () => { + const h = await harness(importReplies(), { open: async () => 1 }) + await runGoogleImport({ yes: true }, h.deps) + expect(h.stderr.join('\n')).toContain('Could not open the browser automatically') + + const thrown = await harness(importReplies(), { + open: async () => { + throw new Error('no xdg-open') + }, + }) + await runGoogleImport({ yes: true }, thrown.deps) + expect(thrown.stderr.join('\n')).toContain('Could not open the browser automatically') + }) + + test('says so when the server does not offer Google Photos', async () => { + const h = await harness({ GooglePhotosStatus: [status({ available: false, account: null })] }) + await expect(runGoogleImport({ yes: true }, h.deps)).rejects.toThrow( + 'Google Photos is not enabled on this server.', + ) + }) + + test('refuses to start a second import before the picker opens', async () => { + const h = await harness({ GooglePhotosStatus: [status({ activeImport: job({ id: 5 }) })] }) + await expect(runGoogleImport({ yes: true }, h.deps)).rejects.toThrow( + "A Google Photos import is already running (job 5). Follow it with 'rawback google job 5 --watch'", + ) + expect(h.operations()).toEqual(['GooglePhotosStatus']) + }) + + test('ends the picker session when the import is declined', async () => { + const h = await harness(importReplies(), { answer: false }) + await runGoogleImport({}, h.deps) + + expect(h.operations()).not.toContain('ImportGooglePhotos') + expect(h.calls.at(-1)).toEqual({ + operation: 'DeleteGooglePhotosPickerSession', + variables: { id: 'picker-1' }, + }) + expect(h.stdout.join('\n')).toContain('Import cancelled; nothing was imported.') + }) + + test('has nothing to import when every pick is a video', async () => { + const h = await harness( + importReplies({ + GooglePhotosPickedItems: [picked([item('v', 'VIDEO'), item('w', 'VIDEO')])], + }), + ) + await expect(runGoogleImport({ yes: true }, h.deps)).rejects.toThrow( + 'Nothing to import: the 2 picked items are all videos, and videos are not imported.', + ) + expect(h.operations().at(-1)).toBe('DeleteGooglePhotosPickerSession') + + const single = await harness( + importReplies({ GooglePhotosPickedItems: [picked([item('v', 'VIDEO')])] }), + ) + await expect(runGoogleImport({ yes: true }, single.deps)).rejects.toThrow('is a video') + + const empty = await harness(importReplies({ GooglePhotosPickedItems: [picked([])] })) + await expect(runGoogleImport({ yes: true }, empty.deps)).rejects.toThrow('Nothing was picked') + }) + + test('prints the started job and leaves it running with --no-wait', async () => { + const h = await harness(importReplies()) + await runGoogleImport({ json: true, yes: true, wait: false }, h.deps) + + expect(h.operations()).not.toContain('GooglePhotosJobProgress') + expect(JSON.parse(h.stdout.join('\n'))).toMatchObject({ + job: { id: 31, status: 'pending' }, + finished: false, + }) + + const human = await harness(importReplies()) + await runGoogleImport({ yes: true, wait: false }, human.deps) + expect(human.stdout.join('\n')).toContain('rawback google job 31 --watch') + }) + + test('exits nonzero when the job fails, is cancelled, or loses items', async () => { + const failed = await harness( + importReplies({ + GooglePhotosJobProgress: [progress(job({ status: 'failed', error: 'needs_reauth' }))], + }), + ) + await expect(runGoogleImport({ yes: true }, failed.deps)).rejects.toThrow( + 'Google Photos import job 31 failed: Google needs to be reconnected.', + ) + expect(failed.stdout.join('\n')).toContain('Google needs to be reconnected') + + const cancelled = await harness( + importReplies({ GooglePhotosJobProgress: [progress(job({ status: 'cancelled' }))] }), + ) + await expect(runGoogleImport({ yes: true }, cancelled.deps)).rejects.toThrow('was cancelled') + + const partial = await harness( + importReplies({ + GooglePhotosJobProgress: [ + progress(job({ status: 'completed', doneItems: 1, failedItems: 1 })), + ], + }), + ) + await expect(runGoogleImport({ yes: true }, partial.deps)).rejects.toThrow( + "1 item failed. List them with 'rawback google job 31 --items failed'.", + ) + }) + + test('maps a server refusal to its next step and ends the unused session', async () => { + const h = await harness( + importReplies({ + ImportGooglePhotos: [refusal(409, 'google_photos_job_active')], + }), + ) + await expect(runGoogleImport({ yes: true }, h.deps)).rejects.toThrow( + 'Another Google Photos transfer of this kind is already running.', + ) + expect(h.operations().at(-1)).toBe('DeleteGooglePhotosPickerSession') + }) + + test('stops waiting for picks on Ctrl-C and ends the session', async () => { + let h: Harness | undefined + h = await harness( + importReplies({ + GooglePhotosPickerSession: [ + () => { + h?.interrupt() + return { data: { googlePhotosPickerSession: session() } } + }, + ], + }), + ) + const error = await runGoogleImport({ yes: true }, h.deps).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(CommandInterruptedError) + expect((error as Error).message).toBe('Import cancelled; nothing was imported.') + expect(h.operations().at(-1)).toBe('DeleteGooglePhotosPickerSession') + }) + + test('keeps the picker session when Ctrl-C lands while the import starts', async () => { + let h: Harness | undefined + h = await harness( + importReplies({ + ImportGooglePhotos: [ + () => { + h?.interrupt() + throw new DOMException('This operation was aborted', 'AbortError') + }, + ], + }), + ) + const error = await runGoogleImport({ yes: true }, h.deps).catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(CommandInterruptedError) + expect((error as Error).message).toBe( + "Stopped while starting the import; it may have started anyway. See 'rawback google jobs'.", + ) + // The server may already be importing from it. + expect(h.operations()).not.toContain('DeleteGooglePhotosPickerSession') + }) + + test('stops watching on Ctrl-C, reporting the job it leaves running', async () => { + let h: Harness | undefined + h = await harness( + importReplies({ + GooglePhotosJobProgress: [ + () => { + h?.interrupt() + return progress(job({ doneItems: 1 })) + }, + ], + }), + ) + const error = await runGoogleImport({ json: true, yes: true }, h.deps).catch( + (thrown: unknown) => thrown, + ) + + expect(error).toBeInstanceOf(CommandInterruptedError) + expect((error as Error).message).toContain('continues on the server as job 31') + expect(JSON.parse(h.stdout.join('\n'))).toMatchObject({ + job: { id: 31, doneItems: 1 }, + finished: false, + }) + expect(h.operations()).not.toContain('DeleteGooglePhotosPickerSession') + }) + + test('explains a picker session that ran out of time', async () => { + const h = await harness( + importReplies({ + CreateGooglePhotosPickerSession: [ + { data: { createGooglePhotosPickerSession: session({ timeoutSeconds: 4 }) } }, + ], + GooglePhotosPickerSession: [ + { data: { googlePhotosPickerSession: session({ timeoutSeconds: 4 }) } }, + ], + }), + ) + await expect(runGoogleImport({ yes: true }, h.deps)).rejects.toThrow( + "The Google Photos picker closed before anything was picked. Run 'rawback import google' to start again.", + ) + }) + + test('cannot prompt under --json', async () => { + const h = await harness({}) + await expect(runGoogleImport({ json: true }, h.deps)).rejects.toThrow( + 'rawback import google --json also needs --yes', + ) + expect(h.calls).toEqual([]) + }) +}) + +describe('resolveGoogleExport', () => { + test('exports everything to the Rawback album by default', async () => { + expect(await resolveGoogleExport({})).toEqual({ + input: { + scope: 'all', + file: 'original', + includeSecret: false, + includeArchived: false, + skipExported: true, + albumTitle: 'Rawback', + }, + selection: 'all photos', + }) + expect((await resolveGoogleExport({ all: true })).input.scope).toBe('all') + }) + + test('turns date-only bounds into whole UTC days', async () => { + const { input, selection } = await resolveGoogleExport({ + from: '2024-01-01', + to: '2024-12-31', + }) + expect(input).toMatchObject({ + scope: 'date_range', + from: '2024-01-01T00:00:00Z', + to: '2024-12-31T23:59:59Z', + }) + expect(selection).toBe('photos from 2024-01-01 to 2024-12-31') + + const open = await resolveGoogleExport({ from: '2024-06-01T08:00:00+02:00' }) + expect(open.input).toMatchObject({ from: '2024-06-01T06:00:00.000Z' }) + expect(open.input).not.toHaveProperty('to') + expect(open.selection).toBe('photos from 2024-06-01 to now') + + expect((await resolveGoogleExport({ to: '2020-02-29' })).selection).toBe( + 'photos from the beginning to 2020-02-29', + ) + }) + + test('rejects malformed and reversed dates', async () => { + await expect(resolveGoogleExport({ from: '2024-02-30' })).rejects.toThrow( + '--from must be a valid YYYY-MM-DD date or RFC3339 timestamp', + ) + await expect(resolveGoogleExport({ to: 'yesterday' })).rejects.toThrow('--to must be') + await expect(resolveGoogleExport({ to: '2024-01-01Tnope' })).rejects.toThrow('--to must be') + await expect(resolveGoogleExport({ from: '2024-02-01', to: '2024-01-01' })).rejects.toThrow( + '--from must not be later than --to', + ) + }) + + test('selects albums or photos, and every option reaches the input', async () => { + const albums = await resolveGoogleExport({ + albums: ['3,4', 4], + includeSecret: true, + includeArchived: true, + skipExported: false, + file: 'fullsize', + albumTitle: ' Trips ', + }) + expect(albums.input).toEqual({ + scope: 'albums', + albumIds: [3, 4], + includeSecret: true, + includeArchived: true, + skipExported: false, + file: 'fullsize', + albumTitle: 'Trips', + }) + expect(albums.selection).toBe('2 albums (3, 4) (secret albums included, archived included)') + + const directory = await scratch() + const file = join(directory, 'ids.txt') + await writeFile(file, '12\n13\n\n12\n') + const images = await resolveGoogleExport({ + images: ['11'], + imagesFile: file, + libraryOnly: true, + }) + expect(images.input).toMatchObject({ scope: 'images', imageIds: [11, 12, 13] }) + expect(images.input).not.toHaveProperty('albumTitle') + expect(images.selection).toBe('3 chosen photos') + + const piped = await resolveGoogleExport( + { imagesFile: '-' }, + { readStdin: async () => '7, 8\n9' }, + ) + expect(piped.input.imageIds).toEqual([7, 8, 9]) + }) + + test('refuses more than one selection and other bad flags', async () => { + await expect(resolveGoogleExport({ all: true, albums: ['3'] })).rejects.toThrow( + 'Choose one selection, not --all and --album', + ) + await expect(resolveGoogleExport({ from: '2024-01-01', images: ['1'] })).rejects.toThrow( + 'not --from/--to and --image/--images-file', + ) + await expect(resolveGoogleExport({ file: 'raw' })).rejects.toThrow( + '--file must be one of: original, fullsize', + ) + await expect(resolveGoogleExport({ albums: ['x'] })).rejects.toThrow( + '--album must contain only positive integers', + ) + await expect( + resolveGoogleExport({ albums: Array.from({ length: 101 }, (_, index) => index + 1) }), + ).rejects.toThrow('--album accepts at most 100 albums') + await expect(resolveGoogleExport({ libraryOnly: true, albumTitle: 'X' })).rejects.toThrow( + '--album-title and --no-album cannot be combined', + ) + await expect(resolveGoogleExport({ albumTitle: ' ' })).rejects.toThrow( + '--album-title must not be empty; pass --no-album', + ) + await expect( + resolveGoogleExport({ imagesFile: '-' }, { readStdin: async () => '\n' }), + ).rejects.toThrow('--images-file lists no photo IDs') + await expect( + resolveGoogleExport({ imagesFile: '-' }, { readStdin: async () => 'abc' }), + ).rejects.toThrow('--images-file must contain only positive integers') + await expect( + resolveGoogleExport({ imagesFile: join(await scratch(), 'missing.txt') }), + ).rejects.toThrow('Unable to read --images-file') + }) +}) + +function exportReplies(overrides: Record<string, Reply[]> = {}): Record<string, Reply[]> { + return { + GooglePhotosStatus: [status()], + GooglePhotosExportPreview: [ + { + data: { + googlePhotosExportPreview: { count: 3, totalBytes: 3 * 1024 ** 2, alreadyExported: 1 }, + }, + }, + ], + ExportToGooglePhotos: [ + { + data: { + exportToGooglePhotos: job({ + id: 40, + kind: 'export', + status: 'pending', + totalItems: 3, + albumTitle: 'Rawback', + upload: null, + }), + }, + }, + ], + GooglePhotosJobProgress: [ + progress( + job({ + id: 40, + kind: 'export', + status: 'completed', + totalItems: 3, + doneItems: 3, + albumTitle: 'Rawback', + albumUrl: 'https://photos.google.com/album/abc', + upload: null, + }), + ), + ], + ...overrides, + } +} + +describe('rawback export google', () => { + test('previews without connecting or exporting on --dry-run', async () => { + const h = await harness(exportReplies({ GooglePhotosStatus: [status({ account: null })] })) + await runGoogleExport({ albums: ['3'], dryRun: true, json: true }, h.deps) + + expect(h.operations()).toEqual(['GooglePhotosStatus', 'GooglePhotosExportPreview']) + expect(JSON.parse(h.stdout.join('\n'))).toEqual({ + dryRun: true, + input: expect.objectContaining({ scope: 'albums', albumIds: [3] }), + preview: { count: 3, totalBytes: 3 * 1024 ** 2, alreadyExported: 1 }, + job: null, + finished: false, + }) + + const human = await harness(exportReplies()) + await runGoogleExport({ dryRun: true }, human.deps) + const output = human.stdout.join('\n') + expect(output).toContain('Photos to export') + expect(output).toContain('3 MB') + expect(output).toContain('(skipped)') + }) + + test('confirms, exports, and reports the album', async () => { + const h = await harness(exportReplies()) + await runGoogleExport({}, h.deps) + + expect(h.prompts).toEqual([ + { + message: 'Export 3 photos (3 MB) to Google Photos album "Rawback"?', + defaultAnswer: false, + }, + ]) + expect(h.calls.find((call) => call.operation === 'ExportToGooglePhotos')?.variables).toEqual({ + input: { + scope: 'all', + file: 'original', + includeSecret: false, + includeArchived: false, + skipExported: true, + albumTitle: 'Rawback', + }, + }) + const output = h.stdout.join('\n') + expect(output).toContain('https://photos.google.com/album/abc') + expect(output).toContain('3 of 3') + }) + + test('writes one JSON document with --json --yes', async () => { + const h = await harness(exportReplies()) + await runGoogleExport({ json: true, yes: true, libraryOnly: true }, h.deps) + const output = JSON.parse(h.stdout.join('\n')) as Record<string, unknown> + expect(output).toMatchObject({ + dryRun: false, + job: { id: 40, status: 'completed' }, + finished: true, + }) + expect(output.input).not.toHaveProperty('albumTitle') + }) + + test('stops when the export is declined', async () => { + const h = await harness(exportReplies(), { answer: false }) + await runGoogleExport({ libraryOnly: true }, h.deps) + expect(h.prompts[0]?.message).toBe('Export 3 photos (3 MB) to Google Photos?') + expect(h.operations()).not.toContain('ExportToGooglePhotos') + expect(h.stdout.join('\n')).toContain('Export cancelled; nothing was sent.') + }) + + test('has nothing to send when everything was exported before', async () => { + const h = await harness( + exportReplies({ + GooglePhotosExportPreview: [ + { data: { googlePhotosExportPreview: { count: 0, totalBytes: 0, alreadyExported: 4 } } }, + ], + }), + ) + await runGoogleExport({ json: true, yes: true }, h.deps) + expect(h.stderr.join('\n')).toContain('--no-skip-exported') + expect(JSON.parse(h.stdout.join('\n'))).toMatchObject({ job: null, finished: false }) + + const none = await harness( + exportReplies({ + GooglePhotosExportPreview: [ + { data: { googlePhotosExportPreview: { count: 0, totalBytes: 0, alreadyExported: 0 } } }, + ], + }), + ) + await runGoogleExport({ yes: true }, none.deps) + expect(none.stdout.join('\n')).toContain('no photos match this selection') + }) + + test('refuses a selection over the export limit before asking', async () => { + const h = await harness(exportReplies({ GooglePhotosStatus: [status({ maxExportItems: 2 })] })) + await expect(runGoogleExport({}, h.deps)).rejects.toThrow( + 'This selection has 3 photos; one export sends at most 2.', + ) + expect(h.prompts).toEqual([]) + + // A dry run fails the same way, rather than passing a selection the real run refuses. + const dry = await harness( + exportReplies({ GooglePhotosStatus: [status({ maxExportItems: 2 })] }), + ) + await expect(runGoogleExport({ dryRun: true }, dry.deps)).rejects.toThrow( + 'This selection has 3 photos; one export sends at most 2.', + ) + }) + + test('says an export interrupted while starting may have started', async () => { + let h: Harness | undefined + h = await harness( + exportReplies({ + ExportToGooglePhotos: [ + () => { + h?.interrupt() + throw new DOMException('This operation was aborted', 'AbortError') + }, + ], + }), + ) + const error = await runGoogleExport({ yes: true, libraryOnly: true }, h.deps).catch( + (thrown: unknown) => thrown, + ) + expect(error).toBeInstanceOf(CommandInterruptedError) + expect((error as Error).message).toBe( + "Stopped while starting the export; it may have started anyway. See 'rawback google jobs'.", + ) + }) + + test('reconnects for the export permission first', async () => { + const h = await harness( + exportReplies({ + GooglePhotosStatus: [ + status({ account: { ...account, canExport: false } }), + status({ account: { ...account, canExport: false } }), + status(), + ], + ConnectGooglePhotos: [ + { + data: { + connectGooglePhotos: { + authorizationUrl: 'https://api.rawback.test/start', + expiresAt: '2026-10-07T10:10:00Z', + }, + }, + }, + ], + }), + ) + await runGoogleExport({ yes: true }, h.deps) + expect(h.stderr.join('\n')).toContain('Rawback is not allowed to add photos to your library') + expect(h.operations().filter((operation) => operation === 'GooglePhotosStatus')).toHaveLength(3) + }) + + test('refuses a second export, and an unavailable server', async () => { + const busy = await harness({ + GooglePhotosStatus: [status({ activeExport: job({ id: 9, kind: 'export' }) })], + }) + await expect(runGoogleExport({ yes: true }, busy.deps)).rejects.toThrow( + 'A Google Photos export is already running (job 9).', + ) + + const off = await harness({ GooglePhotosStatus: [status({ available: false })] }) + await expect(runGoogleExport({ dryRun: true }, off.deps)).rejects.toThrow( + 'Google Photos is not enabled on this server.', + ) + }) + + test('validates flags before any request', async () => { + const h = await harness({}) + await expect(runGoogleExport({ json: true }, h.deps)).rejects.toThrow( + '--json also needs --yes (or --dry-run)', + ) + await expect(runGoogleExport({ all: true, from: '2024-01-01' }, h.deps)).rejects.toThrow( + 'Choose one selection', + ) + expect(h.calls).toEqual([]) + }) + + test('prints the started export with --no-wait', async () => { + const h = await harness(exportReplies()) + await runGoogleExport({ yes: true, wait: false }, h.deps) + expect(h.operations()).not.toContain('GooglePhotosJobProgress') + expect(h.stdout.join('\n')).toContain('rawback google job 40 --watch') + }) +}) + +describe('rawback google status', () => { + test('reports the account, the running jobs and the limits', async () => { + const h = await harness({ + GooglePhotosStatus: [ + status({ + activeExport: job({ id: 9, kind: 'export', pausedUntil: '2026-10-07T10:30:00Z' }), + }), + ], + }) + await runGoogleStatus({ json: true }, h.deps) + expect(JSON.parse(h.stdout.join('\n'))).toEqual({ + available: true, + account, + activeImport: null, + activeExport: expect.objectContaining({ id: 9, pausedUntil: '2026-10-07T10:30:00Z' }), + maxPickerItems: 2000, + maxExportItems: 50000, + }) + + const human = await harness({ GooglePhotosStatus: [status({ activeImport: job() })] }) + await runGoogleStatus({}, human.deps) + const output = human.stdout.join('\n') + expect(output).toContain('Ada <ada@example.com>') + expect(output).toContain('job 31') + }) + + test('describes every state of the link', async () => { + for (const [overrides, expected] of [ + [{ available: false, account: null }, 'not enabled on this server'], + [{ account: null }, 'Not connected'], + [{ account: { ...account, needsReauth: true, canExport: false } }, 'reconnected'], + ] as const) { + const h = await harness({ GooglePhotosStatus: [status(overrides)] }) + await runGoogleStatus({}, h.deps) + expect(h.stdout.join('\n')).toContain(expected) + } + }) +}) + +describe('rawback google connect', () => { + const connectReply = { + data: { + connectGooglePhotos: { + authorizationUrl: 'https://api.rawback.test/start', + expiresAt: '2026-10-07T10:10:00Z', + }, + }, + } + + test('leaves a working link alone', async () => { + const h = await harness({ GooglePhotosStatus: [status()] }) + await runGoogleConnect({ json: true }, h.deps) + expect(JSON.parse(h.stdout.join('\n'))).toEqual({ account, connected: false }) + expect(h.stderr.join('\n')).toContain("run 'rawback google disconnect' first") + }) + + test('links an account and reports it', async () => { + const h = await harness({ + GooglePhotosStatus: [status({ account: null }), status()], + ConnectGooglePhotos: [connectReply], + }) + await runGoogleConnect({ json: true }, h.deps) + expect(JSON.parse(h.stdout.join('\n'))).toEqual({ account, connected: true }) + expect(h.stderr).toContain('https://api.rawback.test/start') + }) + + test('warns about a permission Google still did not grant', async () => { + const h = await harness({ + GooglePhotosStatus: [ + status({ account: { ...account, needsReauth: true } }), + status({ account: { ...account, canExport: false } }), + ], + ConnectGooglePhotos: [connectReply], + }) + await runGoogleConnect({}, h.deps) + expect(h.stderr.join('\n')).toContain( + 'Google did not grant permission to add photos to your library', + ) + }) + + test('waits for the missing permission', async () => { + const h = await harness({ + GooglePhotosStatus: [ + status({ account: { ...account, canImport: false } }), + status({ account: { ...account, canImport: false } }), + status(), + ], + ConnectGooglePhotos: [connectReply], + }) + await runGoogleConnect({}, h.deps) + expect(h.stderr.join('\n')).toContain('Rawback is not allowed to read photos you pick') + expect(h.operations().filter((operation) => operation === 'GooglePhotosStatus')).toHaveLength(3) + }) + + test('gives up when authorization does not finish in time', async () => { + const h = await harness({ + GooglePhotosStatus: [status({ account: null })], + ConnectGooglePhotos: [connectReply], + }) + await expect(runGoogleConnect({}, h.deps)).rejects.toThrow( + 'Timed out waiting for Google authorization.', + ) + }) + + test('stops waiting on Ctrl-C', async () => { + let h: Harness | undefined + h = await harness({ + GooglePhotosStatus: [ + status({ account: null }), + () => { + h?.interrupt() + return status({ account: null }) + }, + ], + ConnectGooglePhotos: [connectReply], + }) + await expect(runGoogleConnect({}, h.deps)).rejects.toThrow( + 'Stopped waiting for Google authorization.', + ) + }) + + test('needs the feature on the server', async () => { + const h = await harness({ GooglePhotosStatus: [status({ available: false })] }) + await expect(runGoogleConnect({}, h.deps)).rejects.toThrow('not enabled on this server') + }) +}) + +describe('rawback google disconnect', () => { + test('confirms, then revokes the link', async () => { + const h = await harness({ + GooglePhotosStatus: [status()], + DisconnectGooglePhotos: [{ data: { disconnectGooglePhotos: true } }], + }) + await runGoogleDisconnect({}, h.deps) + expect(h.prompts[0]?.message).toContain('Disconnect Google account ada@example.com?') + expect(h.stdout.join('\n')).toContain('Disconnected Google account ada@example.com.') + }) + + test('keeps the link when declined', async () => { + const h = await harness({ GooglePhotosStatus: [status()] }, { answer: false }) + await runGoogleDisconnect({}, h.deps) + expect(h.operations()).not.toContain('DisconnectGooglePhotos') + expect(h.stdout.join('\n')).toContain('Google Photos stays connected.') + }) + + test('reports JSON, including when nothing was linked', async () => { + const h = await harness({ + GooglePhotosStatus: [status()], + DisconnectGooglePhotos: [{ data: { disconnectGooglePhotos: true } }], + }) + await runGoogleDisconnect({ json: true, yes: true }, h.deps) + expect(JSON.parse(h.stdout.join('\n'))).toEqual({ disconnected: true }) + + const none = await harness({ GooglePhotosStatus: [status({ account: null })] }) + await runGoogleDisconnect({ json: true, yes: true }, none.deps) + expect(JSON.parse(none.stdout.join('\n'))).toEqual({ disconnected: false }) + + const human = await harness({ GooglePhotosStatus: [status({ account: null })] }) + await runGoogleDisconnect({ yes: true }, human.deps) + expect(human.stdout.join('\n')).toContain('No Google account is connected.') + + const raced = await harness({ + GooglePhotosStatus: [status()], + DisconnectGooglePhotos: [{ data: { disconnectGooglePhotos: false } }], + }) + await runGoogleDisconnect({ yes: true }, raced.deps) + expect(raced.stdout.join('\n')).toContain('No Google account was connected.') + }) + + test('cannot prompt under --json, and needs the feature', async () => { + const h = await harness({ GooglePhotosStatus: [status({ available: false })] }) + await expect(runGoogleDisconnect({ json: true }, h.deps)).rejects.toThrow('needs --yes') + await expect(runGoogleDisconnect({ yes: true }, h.deps)).rejects.toThrow('not enabled') + }) +}) + +describe('rawback google jobs, job and cancel', () => { + test('lists jobs with the requested page', async () => { + const h = await harness({ + GooglePhotosJobs: [ + { + data: { + googlePhotosJobs: [ + job({ status: 'completed', doneItems: 2 }), + job({ + id: 32, + kind: 'export', + status: 'failed', + error: 'album_failed', + upload: null, + }), + ], + }, + }, + ], + }) + await runGoogleJobs({ json: true, kind: 'export', limit: 5, offset: 10 }, h.deps) + expect(h.calls[0]?.variables).toEqual({ kind: 'export', limit: 5, offset: 10 }) + expect((JSON.parse(h.stdout.join('\n')) as { jobs: unknown[] }).jobs).toHaveLength(2) + + const human = await harness({ + GooglePhotosJobs: [ + { + data: { + googlePhotosJobs: [job({ error: 'rate_limited', pausedUntil: '2026-10-07T11:00:00Z' })], + }, + }, + ], + }) + await runGoogleJobs({}, human.deps) + expect(human.calls[0]?.variables).toEqual({ limit: 20, offset: 0 }) + expect(human.stdout.join('\n')).toContain('paused') + }) + + test('validates the job list options before any request', async () => { + const h = await harness({}) + await expect(runGoogleJobs({ kind: 'sync' }, h.deps)).rejects.toThrow('--kind must be one of') + await expect(runGoogleJobs({ limit: 0 }, h.deps)).rejects.toThrow( + '--limit must be an integer between 1 and 100', + ) + await expect(runGoogleJobs({ offset: -1 }, h.deps)).rejects.toThrow('--offset must be') + expect(h.calls).toEqual([]) + }) + + const detail = (overrides: Record<string, unknown> = {}) => + job({ + status: 'completed', + doneItems: 1, + skippedItems: 1, + items: [ + { + id: 1, + status: 'done', + filename: 'a.jpg', + mimeType: 'image/jpeg', + sizeBytes: 2048, + googleUrl: null, + error: null, + image: { id: 501 }, + }, + { + id: 2, + status: 'skipped', + filename: 'b.jpg', + mimeType: 'image/jpeg', + sizeBytes: 0, + googleUrl: null, + error: 'duplicate', + image: null, + }, + ], + ...overrides, + }) + + test('shows one job and a page of its items', async () => { + const h = await harness({ GooglePhotosJob: [progress(detail())] }) + await runGoogleJob({ id: 31, items: 'skipped', limit: 10, offset: 5, json: true }, h.deps) + expect(h.calls[0]?.variables).toEqual({ + id: 31, + itemStatus: 'skipped', + itemLimit: 10, + itemOffset: 5, + }) + expect(JSON.parse(h.stdout.join('\n'))).toMatchObject({ + job: { + id: 31, + items: [ + { id: 1, imageId: 501, error: null }, + { id: 2, imageId: null, error: 'duplicate' }, + ], + }, + }) + + const human = await harness({ GooglePhotosJob: [progress(detail())] }) + await runGoogleJob({ id: 31 }, human.deps) + expect(human.stdout.join('\n')).toContain('already in your library') + }) + + test('watches a running job to the end before showing it', async () => { + const h = await harness({ + GooglePhotosJob: [ + progress(detail({ status: 'running', doneItems: 0, items: [] })), + progress(detail()), + ], + GooglePhotosJobProgress: [progress(job({ status: 'completed', doneItems: 2 }))], + }) + await runGoogleJob({ id: 31, watch: true }, h.deps) + expect(h.operations()).toEqual([ + 'GooglePhotosJob', + 'GooglePhotosJobProgress', + 'GooglePhotosJob', + ]) + + const failed = await harness({ + GooglePhotosJob: [progress(detail({ status: 'failed', error: 'internal_error' }))], + }) + await expect(runGoogleJob({ id: 31, watch: true }, failed.deps)).rejects.toThrow( + 'failed: internal error', + ) + }) + + test('reports a job that is not the caller’s', async () => { + const h = await harness({ GooglePhotosJob: [progress(null)] }) + await expect(runGoogleJob({ id: 404 }, h.deps)).rejects.toThrow( + 'Google Photos job 404 was not found.', + ) + const vanished = await harness({ + GooglePhotosJob: [progress(detail({ status: 'running' })), progress(null)], + GooglePhotosJobProgress: [progress(job({ status: 'completed' }))], + }) + await expect(runGoogleJob({ id: 31, watch: true }, vanished.deps)).rejects.toThrow( + 'was not found', + ) + }) + + test('validates job options before any request', async () => { + const h = await harness({}) + await expect(runGoogleJob({ id: 0 }, h.deps)).rejects.toThrow( + 'Job ID must be a positive integer', + ) + await expect(runGoogleJob({ id: 1, items: 'lost' }, h.deps)).rejects.toThrow( + '--items must be one of', + ) + await expect(runGoogleJob({ id: 1, limit: 201 }, h.deps)).rejects.toThrow( + '--limit must be an integer between 1 and 200', + ) + expect(h.calls).toEqual([]) + }) + + test('cancels a running job and says when there was nothing to cancel', async () => { + const h = await harness({ + CancelGooglePhotosJob: [ + { data: { cancelGooglePhotosJob: job({ status: 'cancelled', error: 'cancelled' }) } }, + ], + }) + await runGoogleCancel({ id: 31 }, h.deps) + expect(h.calls[0]?.variables).toEqual({ id: 31 }) + expect(h.stdout.join('\n')).toContain('Cancelled Google Photos import job 31.') + + const finished = await harness({ + CancelGooglePhotosJob: [{ data: { cancelGooglePhotosJob: job({ status: 'completed' }) } }], + }) + await runGoogleCancel({ id: 31 }, finished.deps) + expect(finished.stdout.join('\n')).toContain('Job 31 is completed; nothing was cancelled.') + + const json = await harness({ + CancelGooglePhotosJob: [{ data: { cancelGooglePhotosJob: job({ status: 'cancelled' }) } }], + }) + await runGoogleCancel({ id: 31, json: true }, json.deps) + expect(JSON.parse(json.stdout.join('\n'))).toMatchObject({ job: { status: 'cancelled' } }) + + await expect(runGoogleCancel({ id: -1 }, json.deps)).rejects.toThrow('Job ID must be') + }) +}) + +describe('Google Photos errors and labels', () => { + const failure = (reason: string) => + new RawbackGraphqlError( + [{ message: 'refused', extensions: { code: 412, reason } }], + undefined, + { + headers: new Headers({ 'x-trace-id': 'trace-123' }), + }, + ) + + test('names the next step for each server reason', () => { + const cases: Array<[string, string]> = [ + ['google_photos_unavailable', 'not enabled on this server'], + ['google_photos_not_connected', "Run 'rawback google connect' to link"], + ['google_photos_needs_reauth', "Run 'rawback google connect' to reconnect"], + ['google_photos_missing_scope', "allow access on Google's consent screen"], + ['google_photos_job_active', 'already running'], + ['google_photos_nothing_picked', 'pick at least one photo'], + ] + for (const [reason, expected] of cases) { + const described = describeGooglePhotosError(failure(reason)) as Error + expect(described.message).toContain(expected) + expect(described.message).toContain('Trace ID: trace-123.') + } + }) + + test('explains the SDK’s own waiting errors and passes everything else through', () => { + expect( + (describeGooglePhotosError(new GooglePhotosTimeoutError('connection', 'late')) as Error) + .message, + ).toContain('Timed out waiting for Google authorization') + expect((describeGooglePhotosError(new GooglePhotosJobNotFoundError(3)) as Error).message).toBe( + 'Google Photos job 3 was not found.', + ) + const other = new Error('boom') + expect(describeGooglePhotosError(other)).toBe(other) + const noReason = new RawbackGraphqlError( + [{ message: 'busy', extensions: { code: 429 } }], + undefined, + ) + expect(describeGooglePhotosError(noReason)).toBe(noReason) + }) + + test('labels job errors, progress and upload sources', () => { + expect(describeGoogleErrorLabel('duplicate')).toBe('already in your library') + expect(describeGoogleErrorLabel('brand_new_label')).toBe('brand_new_label') + expect(describeGoogleErrorLabel(null)).toBeUndefined() + expect(jobProgressLabel(job({ status: 'pending' }) as never)).toBe( + 'Importing — waiting to start (job 31)…', + ) + expect( + jobProgressLabel( + job({ + kind: 'export', + doneItems: 3, + skippedItems: 1, + failedItems: 1, + totalItems: 9, + pausedUntil: '2026-10-07T11:00:00Z', + }) as never, + ), + ).toBe( + 'Exporting 5/9 · 1 skipped · 1 failed · 0 Bytes · paused by Google until 2026-10-07 11:00 UTC', + ) + expect(formatInstant('nope')).toBe('—') + expect(formatInstant(null)).toBe('—') + expect(uploadSourceLabel('google_photos')).toBe('GOOGLE PHOTOS') + expect(uploadSourceLabel('sftp')).toBe('SFTP') + expect(summarizePicks([item('a'), item('b', 'video') as never])).toEqual({ + total: 2, + photos: 1, + videos: 1, + }) + }) +})