diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..10df100 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_/ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..72c4429 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm test diff --git a/README.md b/README.md index d6c26bd..79dcf3b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ $ ppls config get hostname * [`ppls document-types update ID`](#ppls-document-types-update-id) * [`ppls documents add [PATH]`](#ppls-documents-add-path) * [`ppls documents delete ID`](#ppls-documents-delete-id) -* [`ppls documents download ID`](#ppls-documents-download-id) +* [`ppls documents download [ID]`](#ppls-documents-download-id) * [`ppls documents list`](#ppls-documents-list) * [`ppls documents show ID`](#ppls-documents-show-id) * [`ppls documents update ID`](#ppls-documents-update-id) @@ -280,11 +280,11 @@ List correspondents ``` USAGE $ ppls correspondents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -457,11 +457,11 @@ List custom fields ``` USAGE $ ppls custom-fields list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -632,11 +632,11 @@ List document types ``` USAGE $ ppls document-types list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -806,17 +806,17 @@ EXAMPLES _See code: [src/commands/documents/delete.ts](https://github.com/nickchristensen/ppls/blob/v1.1.0/src/commands/documents/delete.ts)_ -## `ppls documents download ID` +## `ppls documents download [ID]` Download one or more documents ``` USAGE - $ ppls documents download ID [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--original] [-o | --output-dir ] + $ ppls documents download [ID...] [--date-format ] [--header ...] [--hostname ] [--plain | --json + | --table] [--token ] [--original] [-o | --output-dir ] ARGUMENTS - ID Document id or comma-separated list of ids + [ID...] Document id (repeatable or comma-separated) FLAGS -o, --output= Output file path (single document) @@ -838,9 +838,11 @@ DESCRIPTION Download one or more documents EXAMPLES - $ ppls documents download 123 --output document.pdf + $ ppls documents download --output document.pdf 123 - $ ppls documents download 123,124 --output-dir ./downloads + $ ppls documents download --output-dir ./downloads 123 456 + + $ ppls documents download --output-dir ./downloads 123,456 ``` _See code: [src/commands/documents/download.ts](https://github.com/nickchristensen/ppls/blob/v1.1.0/src/commands/documents/download.ts)_ @@ -852,11 +854,11 @@ List documents ``` USAGE $ ppls documents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page @@ -1088,11 +1090,11 @@ List tags ``` USAGE $ ppls tags list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in | --name-contains ] [--page --page-size ] [--sort - ] + --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] + [--sort ] FLAGS - --id-in= Filter by id list (comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) --name-contains= Filter by name substring --page= Page number to fetch --page-size= [default: disable pagination, all results] Number of results per page diff --git a/package-lock.json b/package-lock.json index 10cf943..127b30c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ppls", + "name": "@nickchristensen/ppls", "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ppls", + "name": "@nickchristensen/ppls", "version": "1.1.0", "license": "MIT", "dependencies": { @@ -29,6 +29,7 @@ "eslint": "^9", "eslint-config-oclif": "^6", "eslint-config-prettier": "^10", + "husky": "^9.1.7", "mocha": "^10", "oclif": "^4", "shx": "^0.3.3", @@ -7280,6 +7281,22 @@ "node": ">=10.19.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index 13a6355..cd60165 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "eslint": "^9", "eslint-config-oclif": "^6", "eslint-config-prettier": "^10", + "husky": "^9.1.7", "mocha": "^10", "oclif": "^4", "shx": "^0.3.3", @@ -80,6 +81,7 @@ "lint": "eslint", "postpack": "shx rm -f oclif.manifest.json", "posttest": "npm run lint", + "prepare": "husky install", "prepack": "npm run build", "postbuild": "oclif manifest && oclif readme --no-aliases", "test": "mocha --forbid-only \"test/**/*.test.ts\"", diff --git a/src/base-command.ts b/src/base-command.ts index 53d47e5..f338e7f 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -10,6 +10,8 @@ export type ApiFlags = { token: string } +type QueryParams = Record + type ResolvedGlobalFlags = ApiFlags & { dateFormat: string } @@ -64,7 +66,7 @@ export abstract class BaseCommand extends Command { protected buildApiUrl( hostnameValue: string, path: string, - params: Record = {}, + params: QueryParams = {}, ): URL { let url: URL @@ -86,7 +88,7 @@ export abstract class BaseCommand extends Command { protected buildApiUrlFromFlags( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): URL { return this.buildApiUrl(flags.hostname, path, params) } @@ -124,7 +126,7 @@ export abstract class BaseCommand extends Command { protected async fetchApiBinary( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): Promise<{data: Uint8Array; headers: Headers}> { const url = this.buildApiUrlFromFlags(flags, path, params) const requestHeaders: Record = { @@ -149,7 +151,7 @@ export abstract class BaseCommand extends Command { protected async fetchApiJson( flags: ApiFlags, path: string, - params: Record = {}, + params: QueryParams = {}, ): Promise { const url = this.buildApiUrlFromFlags(flags, path, params) return this.fetchJson(url, flags.token, flags.headers) diff --git a/src/commands/documents/download.ts b/src/commands/documents/download.ts index 663674d..229ad57 100644 --- a/src/commands/documents/download.ts +++ b/src/commands/documents/download.ts @@ -4,10 +4,6 @@ import path from 'node:path' import {BaseCommand} from '../../base-command.js' -type DocumentsDownloadArgs = { - id: string -} - type DocumentsDownloadFlags = { original?: boolean output?: string @@ -84,14 +80,30 @@ const resolveOutputPath = async (output: string | undefined, filename: string): return resolved } +const splitDelimitedValues = (input: string, delimiter: string): string[] => { + const splitRegex = new RegExp(`(? value.trim()) + .map((value) => + value + .replaceAll(new RegExp(`\\\\${delimiter}`, 'g'), delimiter) + .replace(/^"(.*)"$/, '$1') + .replace(/^'(.*)'$/, '$1'), + ) + .filter((value) => value.length > 0) +} + export default class DocumentsDownload extends BaseCommand { static override args = { - id: Args.string({description: 'Document id or comma-separated list of ids', required: true}), + id: Args.string({description: 'Document id (repeatable or comma-separated)'}), } static override description = 'Download one or more documents' static override examples = [ - '<%= config.bin %> <%= command.id %> 123 --output document.pdf', - '<%= config.bin %> <%= command.id %> 123,124 --output-dir ./downloads', + '<%= config.bin %> <%= command.id %> --output document.pdf 123', + '<%= config.bin %> <%= command.id %> --output-dir ./downloads 123 456', + '<%= config.bin %> <%= command.id %> --output-dir ./downloads 123,456', ] static override flags = { original: Flags.boolean({description: 'Download original file'}), @@ -106,6 +118,7 @@ export default class DocumentsDownload extends BaseCommand { exists: true, }), } + static override strict = false protected async downloadDocument(options: DownloadDocumentOptions): Promise { const {apiFlags, id, original, output, outputDir} = options @@ -155,32 +168,25 @@ export default class DocumentsDownload extends BaseCommand { return results } - protected parseIds(raw: string): number[] { - const values = raw - .split(',') - .map((value) => value.trim()) - .filter((value) => value.length > 0) + public async run(): Promise { + const {flags, metadata, raw} = await this.parse() + const {headers: apiHeaders, hostname, token} = await this.resolveGlobalFlags(flags, metadata) + const apiFlags = {headers: apiHeaders, hostname, token} + const typedFlags = flags as DocumentsDownloadFlags + const rawArgs = raw.filter((token) => token.type === 'arg').map((token) => token.input) + const values = rawArgs.flatMap((value) => splitDelimitedValues(value, ',')) if (values.length === 0) { this.error('Provide at least one document id.') } - return values.map((value) => { + const ids = values.map((value) => { if (!/^-?\d+$/.test(value)) { this.error(`Invalid document id: ${value}`) } return Number.parseInt(value, 10) }) - } - - public async run(): Promise { - const {args, flags, metadata} = await this.parse() - const {headers: apiHeaders, hostname, token} = await this.resolveGlobalFlags(flags, metadata) - const apiFlags = {headers: apiHeaders, hostname, token} - const typedArgs = args as DocumentsDownloadArgs - const typedFlags = flags as DocumentsDownloadFlags - const ids = this.parseIds(typedArgs.id) let outputDir = typedFlags['output-dir'] if (ids.length > 1) { diff --git a/src/commands/documents/list.ts b/src/commands/documents/list.ts index 2966a77..020c32b 100644 --- a/src/commands/documents/list.ts +++ b/src/commands/documents/list.ts @@ -10,7 +10,7 @@ export default class DocumentsList extends ListCommand { protected override listParams( flags: Parameters[0], - ): Record { + ): Record { const params = super.listParams(flags) delete params.name__icontains diff --git a/src/list-command.ts b/src/list-command.ts index 1cc1267..4e3ef8d 100644 --- a/src/list-command.ts +++ b/src/list-command.ts @@ -6,7 +6,7 @@ import {BaseCommand} from './base-command.js' import {createValueFormatter, type TableColumn, type TableRow} from './helpers/table.js' type ListCommandFlags = ApiFlags & { - 'id-in'?: string + 'id-in'?: string[] 'name-contains'?: string page?: number 'page-size': number @@ -32,8 +32,10 @@ export abstract class ListCommand< static baseFlags = { ...BaseCommand.baseFlags, 'id-in': Flags.string({ - description: 'Filter by id list (comma-separated)', + delimiter: ',', + description: 'Filter by id list (repeatable or comma-separated)', exclusive: ['name-contains'], + multiple: true, }), 'name-contains': Flags.string({ description: 'Filter by name substring', @@ -57,7 +59,7 @@ export abstract class ListCommand< protected async fetchListResults(options: { flags: ListCommandFlags - params?: Record + params?: Record path: string }): Promise { const {flags, params = {}, path} = options @@ -79,7 +81,7 @@ export abstract class ListCommand< } } - protected listParams(flags: ListCommandFlags): Record { + protected listParams(flags: ListCommandFlags): Record { return { 'id__in': flags['id-in'], 'name__icontains': flags['name-contains'], @@ -161,7 +163,7 @@ export abstract class ListCommand< private buildListUrl(options: { flags: ListCommandFlags - params?: Record + params?: Record path: string }): URL { const {flags, params = {}, path} = options diff --git a/test/commands/documents/download.test.ts b/test/commands/documents/download.test.ts index 0f27bfd..07bcfbb 100644 --- a/test/commands/documents/download.test.ts +++ b/test/commands/documents/download.test.ts @@ -86,7 +86,7 @@ describe('documents:download', () => { expect(contents).to.equal('data') }) - it('downloads multiple documents to an output directory', async () => { + it('downloads multiple documents to an output directory with repeated args', async () => { const outputDir = path.join(tempDir, 'downloads') await mkdir(outputDir) @@ -100,7 +100,7 @@ describe('documents:download', () => { }) } - const {stdout} = await runCommand(`documents:download 12,34 --output-dir ${outputDir} --json`) + const {stdout} = await runCommand(`documents:download 12 34 --output-dir ${outputDir} --json`) const payload = JSON.parse(stdout) as Array<{filename: string; output: string}> expect(payload).to.have.length(2) @@ -113,4 +113,48 @@ describe('documents:download', () => { expect(contents12).to.equal('data-12') expect(contents34).to.equal('data-34') }) + + it('supports comma-separated ids', async () => { + const outputDir = path.join(tempDir, 'downloads-comma') + await mkdir(outputDir) + + globalThis.fetch = async (input) => { + requests.push(String(input)) + const idMatch = /\/documents\/(\d+)\/download/.exec(String(input)) + const id = idMatch ? idMatch[1] : 'unknown' + return new Response(new TextEncoder().encode(`data-${id}`), { + headers: {'Content-Disposition': `attachment; filename="doc-${id}.pdf"`}, + status: 200, + }) + } + + const {stdout} = await runCommand(`documents:download 12,34 --output-dir ${outputDir} --json`) + const payload = JSON.parse(stdout) as Array<{filename: string; output: string}> + + expect(payload).to.have.length(2) + expect(payload[0]?.output).to.equal(path.join(outputDir, 'doc-12.pdf')) + expect(payload[1]?.output).to.equal(path.join(outputDir, 'doc-34.pdf')) + }) + + it('rejects escaped delimiters that yield non-numeric ids', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand(String.raw`documents:download 12\,34`) + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('Invalid document id: 12,34') + }) + + it('rejects empty comma-separated input', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:download ","') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('Provide at least one document id.') + }) })