Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 7 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,11 @@ rawback camera contents delete <locator> [--force] [--json]
```

A **locator** is the string the camera returns from `contents list`; pass it back
verbatim. Newer bodies insert an extra path segment, which the CLI handles for
you.
verbatim. Bodies on CCAPI 1.4 (the EOS R6 Mark III, R5 Mark II, R1, …) file every
directory under a folder — `DCIM` for stills, `XFVC`/`CRM` for movie reels — which
`contents dirs` prints as part of each path. `list` takes the directory as a bare
name (`100CANON`, looked up on the card and preferring `DCIM`), as
`DCIM/100CANON`, or as a locator printed by `dirs`.

`get` streams to disk rather than buffering, so a RAW file costs no memory. Point
`--output` at a directory to keep the camera's own filename. An existing file is
Expand Down Expand Up @@ -303,6 +306,8 @@ rawback camera events watch [--count <n>] [--duration <s>] [--json]
rawback camera events clear [--force] [--json]
```

`poll --wait` holds the request until something changes, in the style the
camera's CCAPI version takes (`timeout=long` from 1.1, `continue=on` on 1.0).
`watch` streams changes until Ctrl-C, `--count`, or `--duration`. Every event
carries `changedKeys`, which lists every key the camera reported, including ones
the client does not model.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"dependencies": {
"@inquirer/prompts": "^8.7.0",
"@rawback/ccapi-js": "1.1.0",
"@rawback/ccapi-js": "1.1.2",
"@rawback/sdk": "0.3.4",
"ink": "^7.1.1",
"react": "^19.2.8",
Expand Down
62 changes: 19 additions & 43 deletions src/camera-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { mkdir, stat } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import { Writable } from 'node:stream'

import type { ContentDataKind, ContentLocator, ContentType, ContentsOrder } from '@rawback/ccapi-js'
import type { ContentDataKind, ContentType, ContentsOrder } from '@rawback/ccapi-js'

import { CameraError } from './camera-errors.ts'
import { folderOption, parseContentLocator, resolveDirectory } from './camera-locators.ts'
import {
withCameraSession,
type CameraCommandDependencies,
type CameraSession,
type CameraTargetOptions,
} from './camera-session.ts'
import { cameraPrompts } from './camera.ts'
Expand All @@ -20,37 +20,10 @@ import {
type ContentsListRow,
} from './features/camera/view.ts'

/**
* Locators are the strings the camera itself returns, so the ver140 `folder`
* segment never has to reach the user. Splitting after `/contents/` gives
* `storage/folder/directory/file` on ver140 and `storage/directory/file` below
* it — the same rule the desktop app uses.
*/
export function parseContentLocator(locator: string): ContentLocator {
const trimmed = locator.trim().replace(/\/+$/, '')
const afterContents = trimmed.split('/contents/')[1] ?? trimmed.replace(/^\/+/, '')
const segments = afterContents.split('/').filter((segment) => segment.length > 0)
export { parseContentLocator } from './camera-locators.ts'

if (segments.length === 4) {
const [storage, folder, directory, file] = segments as [string, string, string, string]
return { storage, folder, directory, file }
}
if (segments.length === 3) {
const [storage, directory, file] = segments as [string, string, string]
return { storage, directory, file }
}
throw new CameraError(
`Not a content locator: ${JSON.stringify(locator)}. ` +
'Use one printed by rawback camera contents list.',
)
}

function listOptions(
session: CameraSession,
options: { type?: string; order?: string; page?: number },
) {
function listOptions(options: { type?: string; order?: string; page?: number }) {
return {
...(session.folderSegment !== undefined ? { folder: session.folderSegment } : {}),
...(options.type !== undefined && options.type !== 'all'
? { type: options.type as ContentType }
: {}),
Expand Down Expand Up @@ -110,13 +83,15 @@ export async function runCameraContentsList(
): Promise<void> {
const ui = commandOutput(dependencies)
await withCameraSession(options, dependencies, async (session) => {
const directory = await resolveDirectory(session, options.storage, options.directory)
const where = { ...listOptions(options), ...folderOption(directory) }
if (options.all === true) {
// The chunked form streams the whole listing rather than one page.
const locators: string[] = []
for await (const page of session.client.contents.streamContents(
options.storage,
options.directory,
listOptions(session, options),
directory.storage,
directory.directory,
where,
)) {
locators.push(...page)
}
Expand All @@ -129,15 +104,16 @@ export async function runCameraContentsList(
}

const page = options.page ?? 1
const [listing, counts] = await Promise.all([
session.client.contents.listContents(options.storage, options.directory, {
...listOptions(session, options),
page,
}),
session.client.contents
.getContentsNumber(options.storage, options.directory, listOptions(session, options))
.catch(() => undefined),
])
// One after the other: a body serves one contents request at a time and
// answers `503` to a second that overlaps it.
const listing = await session.client.contents.listContents(
directory.storage,
directory.directory,
{ ...where, page },
)
const counts = await session.client.contents
.getContentsNumber(directory.storage, directory.directory, where)
.catch(() => undefined)

if (options.json === true) {
ui.json({
Expand Down
4 changes: 3 additions & 1 deletion src/camera-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export async function runCameraEventsPoll(
await withCameraSession(options, dependencies, async (session) => {
const event = await session.client.event.getPolling({
signal: session.signal,
...(options.wait === true ? { continue: true } : {}),
// `hold` long-polls in the style the body's event/polling version takes:
// `continue=on` on ver100, `timeout=long` from ver110 on.
...(options.wait === true ? { hold: true } : {}),
...(options.timeoutKind !== undefined ? { timeout: options.timeoutKind } : {}),
})

Expand Down
101 changes: 101 additions & 0 deletions src/camera-locators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
parseContentLocator as parseCameraLocator,
parseDirectoryLocator,
type ContentLocator,
type DirectoryLocator,
} from '@rawback/ccapi-js'

import { CameraError } from './camera-errors.ts'
import type { CameraSession } from './camera-session.ts'

/**
* Locators are the strings the camera itself returns, so the ver140 folder
* (`DCIM` for stills) never has to be typed by hand. Accepts what `contents
* list` prints — a path or a full URL — and a bare `storage/[folder/]directory/file`.
*/
export function parseContentLocator(locator: string): ContentLocator {
const trimmed = locator.trim()
for (const candidate of [trimmed, `/contents/${trimmed.replace(/^\/+/, '')}`]) {
try {
return parseCameraLocator(candidate)
} catch {
// Try the bare form, then give up with a message that says what to pass.
}
}
throw new CameraError(
`Not a content locator: ${JSON.stringify(locator)}. ` +
'Use one printed by rawback camera contents list.',
)
}

/**
* Where a directory argument lives on the card, with its ver140 folder.
*
* Accepts a locator printed by `contents dirs`
* (`/ccapi/ver140/contents/card1/DCIM/100CANON`), a `folder/directory` pair
* (`DCIM/100CANON`), or a bare directory name. A ver140 body files every
* directory under a folder that each contents path must include, so a bare
* name is looked up on the storage — preferring `DCIM`, where stills live —
* and passed through unchanged when the listing has no folders (ver130 and
* earlier) or does not contain it.
*
* `storage` is always the one the user chose: a locator naming another card is
* refused rather than followed, since `deleteDirectory` acts on whatever this
* returns. A failed lookup is reported as itself — a busy or unreachable
* camera must not turn into a folderless request that 404s for another reason.
*/
export async function resolveDirectory(
session: CameraSession,
storage: string,
directory: string,
): Promise<DirectoryLocator> {
const trimmed = directory.trim().replace(/^\/+|\/+$/g, '')
if (trimmed.includes('contents/')) {
let located: DirectoryLocator
try {
located = parseDirectoryLocator(trimmed)
} catch {
throw new CameraError(
`Not a directory locator: ${JSON.stringify(directory)}. ` +
'Use one printed by rawback camera contents dirs.',
)
}
if (located.storage !== storage) {
throw new CameraError(
`${JSON.stringify(directory)} is on ${located.storage}, not ${storage}. ` +
'Pass the storage the locator names, or a directory name.',
)
}
return located
}

const parts = trimmed.split('/').filter((part) => part.length > 0)
if (parts.length === 2) {
const [folder, name] = parts as [string, string]
return { storage, folder, directory: name }
}
if (parts.length !== 1) {
throw new CameraError(
`Not a directory: ${JSON.stringify(directory)}. ` +
'Pass a name like 100CANON, or DCIM/100CANON.',
)
}

const name = parts[0] as string
const { paths } = await session.client.contents.listDirectories(storage)
const matches = paths.flatMap((path) => {
try {
const located = parseDirectoryLocator(path)
return located.directory === name ? [located] : []
} catch {
return []
}
})
const match = matches.find((located) => located.folder === 'DCIM') ?? matches[0]
return match ?? { storage, directory: name }
}

/** The `folder` option a contents call takes, when the directory has one. */
export function folderOption(directory: DirectoryLocator): { folder?: string } {
return directory.folder !== undefined ? { folder: directory.folder } : {}
}
66 changes: 34 additions & 32 deletions src/camera-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// diverge.
//
// Ported from the reference TUI in the @rawback/ccapi-js repo, with three
// changes: `run` takes the CameraSession (so contents entries can default the
// ver140 `folder` segment and entries can be gated on `supports()`), every
// changes: `run` takes the CameraSession (so contents entries can look up a
// directory's ver140 folder and entries can be gated on `supports()`), every
// unsafe cast is replaced by a validating accessor, and the namespace order
// lists all nine namespaces (the reference lists six, so the rest sorted first).
//
Expand All @@ -18,6 +18,7 @@ import { PICTURE_STYLES } from '@rawback/ccapi-js'
import type { ContentLocator, GPSInfo, PictureStyleName } from '@rawback/ccapi-js'

import { CameraError } from './camera-errors.ts'
import { folderOption, parseContentLocator, resolveDirectory } from './camera-locators.ts'
import type { CameraSession } from './camera-session.ts'

export type Args = Record<string, unknown>
Expand Down Expand Up @@ -230,22 +231,12 @@ const LOCATOR: Param = {
}

function locatorOf(args: Args): ContentLocator {
const raw = argString(args, 'locator')
const afterContents = raw.split('/contents/')[1] ?? raw.replace(/^\/+/, '')
const segments = afterContents.split('/').filter((segment) => segment.length > 0)
if (segments.length === 4) {
const [storage, folder, directory, file] = segments as [string, string, string, string]
return { storage, folder, directory, file }
}
if (segments.length === 3) {
const [storage, directory, file] = segments as [string, string, string]
return { storage, directory, file }
}
throw new CameraError(`Not a content locator: ${JSON.stringify(raw)}`)
return parseContentLocator(argString(args, 'locator'))
}

function folderOptions(session: CameraSession) {
return session.folderSegment !== undefined ? { folder: session.folderSegment } : {}
/** A directory argument with its ver140 folder resolved — see `resolveDirectory`. */
function directoryOf(session: CameraSession, args: Args) {
return resolveDirectory(session, argString(args, 'storage'), argString(args, 'directory'))
}

// ── catalogue ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -769,18 +760,27 @@ export const REGISTRY: readonly ApiEntry[] = [
'4.7.3',
'GET',
[STR('storage'), STR('directory'), NUM('page', false)],
(s, a) =>
s.client.contents.listContents(argString(a, 'storage'), argString(a, 'directory'), {
...folderOptions(s),
async (s, a) => {
const directory = await directoryOf(s, a)
return s.client.contents.listContents(directory.storage, directory.directory, {
...folderOption(directory),
...(a.page !== undefined ? { page: argNumber(a, 'page') } : {}),
}),
})
},
),
api('contents.getContentsNumber', '4.7.3', 'GET', [STR('storage'), STR('directory')], (s, a) =>
s.client.contents.getContentsNumber(
argString(a, 'storage'),
argString(a, 'directory'),
folderOptions(s),
),
api(
'contents.getContentsNumber',
'4.7.3',
'GET',
[STR('storage'), STR('directory')],
async (s, a) => {
const directory = await directoryOf(s, a)
return s.client.contents.getContentsNumber(
directory.storage,
directory.directory,
folderOption(directory),
)
},
),
api('contents.getContentInfo', '4.7.5', 'GET', [LOCATOR], (s, a) =>
s.client.contents.getContentInfo(locatorOf(a)),
Expand All @@ -790,12 +790,14 @@ export const REGISTRY: readonly ApiEntry[] = [
'4.7.4',
'DELETE',
[STR('storage'), STR('directory')],
(s, a) =>
s.client.contents.deleteDirectory(
argString(a, 'storage'),
argString(a, 'directory'),
s.folderSegment,
),
async (s, a) => {
const directory = await directoryOf(s, a)
return s.client.contents.deleteDirectory(
directory.storage,
directory.directory,
directory.folder,
)
},
{ mutates: true },
),
api(
Expand Down
8 changes: 0 additions & 8 deletions src/camera-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ import type { CommandOutput } from './ui/output.tsx'
/** A cached discovery map older than this is re-read rather than trusted. */
const DISCOVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000

/** ver140 nests contents under an extra `folder` segment; earlier versions do not. */
const FOLDER_SEGMENT_VERSION = 'ver140'

export interface CameraTarget {
host: string
port: number
Expand Down Expand Up @@ -196,11 +193,6 @@ export class CameraSession {
return this.#abort.signal
}

/** ver140 inserts a `folder` segment into contents paths; earlier versions do not. */
get folderSegment(): string | undefined {
return this.#apiVersion >= FOLDER_SEGMENT_VERSION ? 'folder' : undefined
}

get errorContext(): CameraErrorContext {
return {
host: this.target.host,
Expand Down
Loading
Loading