diff --git a/.codex/skills/paperless-ngx-api/SKILL.md b/.codex/skills/paperless-ngx-api/SKILL.md index e2af88e..dbce31f 100644 --- a/.codex/skills/paperless-ngx-api/SKILL.md +++ b/.codex/skills/paperless-ngx-api/SKILL.md @@ -1,6 +1,6 @@ --- name: paperless-ngx-api -description: Use when working with the Paperless-ngx REST API: looking up endpoints, request/response shapes, auth requirements, filtering/pagination, or generating example requests/responses from the OpenAPI spec. Trigger for tasks involving Paperless-ngx API integrations, client code, curl examples, or schema details. +description: Use when working with the Paperless-ngx REST API; looking up endpoints, request/response shapes, auth requirements, filtering/pagination, or generating example requests/responses from the OpenAPI spec. Trigger for tasks involving Paperless-ngx API integrations, client code, curl examples, or schema details. --- # Paperless Ngx Api @@ -44,4 +44,5 @@ Common `yq` snippets (adjust selectors based on the spec structure): ## Resources ### references/ + - `paperless-ngx-rest-api.yaml` (OpenAPI spec) diff --git a/README.md b/README.md index 79dcf3b..e96c074 100644 --- a/README.md +++ b/README.md @@ -284,11 +284,9 @@ USAGE [--sort ] FLAGS - --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 - --sort= Sort results by the provided field + --page= Page number to fetch + --page-size= [default: disable pagination, all results] Number of results per page + --sort= Sort results by the provided field GLOBAL FLAGS --date-format= [default: yyyy-MM-dd, env: PPLS_DATE_FORMAT] Format output dates using a template. @@ -301,6 +299,10 @@ ENVIRONMENT FLAGS --hostname= [env: PPLS_HOSTNAME] Paperless-ngx base URL --token= [env: PPLS_TOKEN] Paperless-ngx API token +FILTER FLAGS + --id-in=... Filter by id list (repeatable or comma-separated) + --name-contains= Filter by name substring + DESCRIPTION List correspondents @@ -461,11 +463,9 @@ USAGE [--sort ] FLAGS - --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 - --sort= Sort results by the provided field + --page= Page number to fetch + --page-size= [default: disable pagination, all results] Number of results per page + --sort= Sort results by the provided field GLOBAL FLAGS --date-format= [default: yyyy-MM-dd, env: PPLS_DATE_FORMAT] Format output dates using a template. @@ -478,6 +478,10 @@ ENVIRONMENT FLAGS --hostname= [env: PPLS_HOSTNAME] Paperless-ngx base URL --token= [env: PPLS_TOKEN] Paperless-ngx API token +FILTER FLAGS + --id-in=... Filter by id list (repeatable or comma-separated) + --name-contains= Filter by name substring + DESCRIPTION List custom fields @@ -636,11 +640,9 @@ USAGE [--sort ] FLAGS - --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 - --sort= Sort results by the provided field + --page= Page number to fetch + --page-size= [default: disable pagination, all results] Number of results per page + --sort= Sort results by the provided field GLOBAL FLAGS --date-format= [default: yyyy-MM-dd, env: PPLS_DATE_FORMAT] Format output dates using a template. @@ -653,6 +655,10 @@ ENVIRONMENT FLAGS --hostname= [env: PPLS_HOSTNAME] Paperless-ngx base URL --token= [env: PPLS_TOKEN] Paperless-ngx API token +FILTER FLAGS + --id-in=... Filter by id list (repeatable or comma-separated) + --name-contains= Filter by name substring + DESCRIPTION List document types @@ -854,15 +860,37 @@ List documents ``` USAGE $ ppls documents list [--date-format ] [--header ...] [--hostname ] [--plain | --json | - --table] [--token ] [--id-in ... | --name-contains ] [--page --page-size ] - [--sort ] + --table] [--token ] [--page --page-size ] [--sort ] [--added-after YYYY-MM-DD|ISO-8601 + | [--id-in ... | --name-contains ]] [--added-before YYYY-MM-DD|ISO-8601 | ] [--created-after + YYYY-MM-DD|ISO-8601 | ] [--created-before YYYY-MM-DD|ISO-8601 | ] [--modified-after YYYY-MM-DD|ISO-8601 | ] + [--modified-before YYYY-MM-DD|ISO-8601 | ] [--no-correspondent | [--correspondent ... | ] | + [--correspondent-not ... | ] | ] [--no-document-type | [--document-type ... | ] | [--document-type-not + ... | ] | ] [--no-tag | --tag ... | --tag-all ... | --tag-not ... | ] FLAGS - --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 - --sort= Sort results by the provided field + --page= Page number to fetch + --page-size= [default: disable pagination, all results] Number of results per page + --sort= Sort results by the provided field + +FILTER FLAGS + --added-after=YYYY-MM-DD|ISO-8601 Filter by added date (YYYY-MM-DD) or datetime (ISO 8601) >= value + --added-before=YYYY-MM-DD|ISO-8601 Filter by added date (YYYY-MM-DD) or datetime (ISO 8601) <= value + --correspondent=... Filter by correspondent ids (repeatable or comma-separated) + --correspondent-not=... Exclude correspondent ids (repeatable or comma-separated) + --created-after=YYYY-MM-DD|ISO-8601 Filter by created date (YYYY-MM-DD) or datetime (ISO 8601) >= value + --created-before=YYYY-MM-DD|ISO-8601 Filter by created date (YYYY-MM-DD) or datetime (ISO 8601) <= value + --document-type=... Filter by document type ids (repeatable or comma-separated) + --document-type-not=... Exclude document type ids (repeatable or comma-separated) + --id-in=... Filter by id list (repeatable or comma-separated) + --modified-after=YYYY-MM-DD|ISO-8601 Filter by modified date (YYYY-MM-DD) or datetime (ISO 8601) >= value + --modified-before=YYYY-MM-DD|ISO-8601 Filter by modified date (YYYY-MM-DD) or datetime (ISO 8601) <= value + --name-contains= Filter by name substring + --no-correspondent Filter documents with no correspondent + --no-document-type Filter documents with no document type + --no-tag Filter documents with no tags + --tag=... Filter by tag ids (repeatable or comma-separated, OR) + --tag-all=... Filter by tag ids (repeatable or comma-separated, AND) + --tag-not=... Exclude tag ids (repeatable or comma-separated) GLOBAL FLAGS --date-format= [default: yyyy-MM-dd, env: PPLS_DATE_FORMAT] Format output dates using a template. @@ -1094,11 +1122,9 @@ USAGE [--sort ] FLAGS - --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 - --sort= Sort results by the provided field + --page= Page number to fetch + --page-size= [default: disable pagination, all results] Number of results per page + --sort= Sort results by the provided field GLOBAL FLAGS --date-format= [default: yyyy-MM-dd, env: PPLS_DATE_FORMAT] Format output dates using a template. @@ -1111,6 +1137,10 @@ ENVIRONMENT FLAGS --hostname= [env: PPLS_HOSTNAME] Paperless-ngx base URL --token= [env: PPLS_TOKEN] Paperless-ngx API token +FILTER FLAGS + --id-in=... Filter by id list (repeatable or comma-separated) + --name-contains= Filter by name substring + DESCRIPTION List tags diff --git a/src/base-command.ts b/src/base-command.ts index f338e7f..75ff064 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -10,7 +10,7 @@ export type ApiFlags = { token: string } -type QueryParams = Record +type QueryParams = Record type ResolvedGlobalFlags = ApiFlags & { dateFormat: string diff --git a/src/commands/documents/list.ts b/src/commands/documents/list.ts index 020c32b..2693d48 100644 --- a/src/commands/documents/list.ts +++ b/src/commands/documents/list.ts @@ -1,21 +1,228 @@ +import {Flags} from '@oclif/core' + import type {Document} from '../../types/documents.js' +import {dateLike} from '../../flags/date-like.js' +import {isDateOnly} from '../../helpers/date-utils.js' import {ListCommand} from '../../list-command.js' +type DocumentsListFlags = { + 'added-after'?: string + 'added-before'?: string + correspondent?: number[] + 'correspondent-not'?: number[] + 'created-after'?: string + 'created-before'?: string + 'document-type'?: number[] + 'document-type-not'?: number[] + 'modified-after'?: string + 'modified-before'?: string + 'no-correspondent'?: boolean + 'no-document-type'?: boolean + 'no-tag'?: boolean + tag?: number[] + 'tag-all'?: number[] + 'tag-not'?: number[] +} + +type DateRangeFields = { + after: string + before: string +} + +type DateRangeFlagGroups = { + added: DateRangeFields + created: DateRangeFields + modified: DateRangeFields +} + +const DATE_RANGE_FLAGS = { + added: { + after: 'added-after', + before: 'added-before', + }, + created: { + after: 'created-after', + before: 'created-before', + }, + modified: { + after: 'modified-after', + before: 'modified-before', + }, +} as const satisfies DateRangeFlagGroups + +type DateRangeFlagName = (typeof DATE_RANGE_FLAGS)[keyof typeof DATE_RANGE_FLAGS]['after' | 'before'] + +const addDateRangeParams = ( + params: Record, + field: keyof DateRangeFlagGroups, + flags: DocumentsListFlags, +): void => { + const {after, before} = DATE_RANGE_FLAGS[field] + const typedFlags = flags as Record + const afterValue = typedFlags[after] + const beforeValue = typedFlags[before] + + if (afterValue) { + const key = isDateOnly(afterValue) ? `${field}__date__gte` : `${field}__gte` + params[key] = afterValue + } + + if (beforeValue) { + const key = isDateOnly(beforeValue) ? `${field}__date__lte` : `${field}__lte` + params[key] = beforeValue + } +} + export default class DocumentsList extends ListCommand { static override description = 'List documents' static override examples = ['<%= config.bin %> <%= command.id %>'] + static override flags = { + ...ListCommand.baseFlags, + 'added-after': dateLike({ + description: 'Filter by added date (YYYY-MM-DD) or datetime (ISO 8601) >= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + 'added-before': dateLike({ + description: 'Filter by added date (YYYY-MM-DD) or datetime (ISO 8601) <= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + correspondent: Flags.integer({ + delimiter: ',', + description: 'Filter by correspondent ids (repeatable or comma-separated)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'correspondent-not': Flags.integer({ + delimiter: ',', + description: 'Exclude correspondent ids (repeatable or comma-separated)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'created-after': dateLike({ + description: 'Filter by created date (YYYY-MM-DD) or datetime (ISO 8601) >= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + 'created-before': dateLike({ + description: 'Filter by created date (YYYY-MM-DD) or datetime (ISO 8601) <= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + 'document-type': Flags.integer({ + delimiter: ',', + description: 'Filter by document type ids (repeatable or comma-separated)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'document-type-not': Flags.integer({ + delimiter: ',', + description: 'Exclude document type ids (repeatable or comma-separated)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'modified-after': dateLike({ + description: 'Filter by modified date (YYYY-MM-DD) or datetime (ISO 8601) >= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + 'modified-before': dateLike({ + description: 'Filter by modified date (YYYY-MM-DD) or datetime (ISO 8601) <= value', + exclusive: ['id-in'], + helpGroup: 'FILTER', + }), + 'no-correspondent': Flags.boolean({ + description: 'Filter documents with no correspondent', + exclusive: ['correspondent', 'correspondent-not', 'id-in'], + helpGroup: 'FILTER', + }), + 'no-document-type': Flags.boolean({ + description: 'Filter documents with no document type', + exclusive: ['document-type', 'document-type-not', 'id-in'], + helpGroup: 'FILTER', + }), + 'no-tag': Flags.boolean({ + description: 'Filter documents with no tags', + exclusive: ['tag', 'tag-all', 'tag-not', 'id-in'], + helpGroup: 'FILTER', + }), + tag: Flags.integer({ + delimiter: ',', + description: 'Filter by tag ids (repeatable or comma-separated, OR)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'tag-all': Flags.integer({ + delimiter: ',', + description: 'Filter by tag ids (repeatable or comma-separated, AND)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + 'tag-not': Flags.integer({ + delimiter: ',', + description: 'Exclude tag ids (repeatable or comma-separated)', + exclusive: ['id-in'], + helpGroup: 'FILTER', + multiple: true, + }), + } protected listPath = '/api/documents/' protected tableAttrs = ['id', 'title', 'created', 'added', 'correspondent', 'document_type', 'tags'] + protected override extraListFlags(flags: Record): Record { + const typedFlags = flags as DocumentsListFlags + + return { + 'added-after': typedFlags['added-after'], + 'added-before': typedFlags['added-before'], + correspondent: typedFlags.correspondent, + 'correspondent-not': typedFlags['correspondent-not'], + 'created-after': typedFlags['created-after'], + 'created-before': typedFlags['created-before'], + 'document-type': typedFlags['document-type'], + 'document-type-not': typedFlags['document-type-not'], + 'modified-after': typedFlags['modified-after'], + 'modified-before': typedFlags['modified-before'], + 'no-correspondent': typedFlags['no-correspondent'], + 'no-document-type': typedFlags['no-document-type'], + 'no-tag': typedFlags['no-tag'], + tag: typedFlags.tag, + 'tag-all': typedFlags['tag-all'], + 'tag-not': typedFlags['tag-not'], + } + } + protected override listParams( flags: Parameters[0], - ): Record { + ): Record { + const typedFlags = flags as DocumentsListFlags & Parameters[0] const params = super.listParams(flags) delete params.name__icontains - // eslint-disable-next-line camelcase -- API uses double-underscore field names. + /* eslint-disable camelcase -- API uses double-underscore field names. */ params.title__icontains = flags['name-contains'] + params.tags__id__in = typedFlags.tag + params.tags__id__all = typedFlags['tag-all'] + params.tags__id__none = typedFlags['tag-not'] + params.is_tagged = typedFlags['no-tag'] ? 'false' : undefined + params.correspondent__id__in = typedFlags.correspondent + params.correspondent__id__none = typedFlags['correspondent-not'] + params.correspondent__isnull = typedFlags['no-correspondent'] ? 'true' : undefined + params.document_type__id__in = typedFlags['document-type'] + params.document_type__id__none = typedFlags['document-type-not'] + params.document_type__isnull = typedFlags['no-document-type'] ? 'true' : undefined + /* eslint-enable camelcase */ + addDateRangeParams(params, 'added', typedFlags) + addDateRangeParams(params, 'created', typedFlags) + addDateRangeParams(params, 'modified', typedFlags) return params } diff --git a/src/flags/date-like.ts b/src/flags/date-like.ts new file mode 100644 index 0000000..0356d18 --- /dev/null +++ b/src/flags/date-like.ts @@ -0,0 +1,14 @@ +import {Flags} from '@oclif/core' + +import {isDateOnly, isDateTime} from '../helpers/date-utils.js' + +export const dateLike = Flags.custom({ + helpValue: 'YYYY-MM-DD|ISO-8601', + async parse(input: string): Promise { + if (isDateOnly(input) || isDateTime(input)) { + return input + } + + throw new Error('Use YYYY-MM-DD or an ISO 8601 datetime.') + }, +}) diff --git a/src/list-command.ts b/src/list-command.ts index 4e3ef8d..8a5e668 100644 --- a/src/list-command.ts +++ b/src/list-command.ts @@ -35,11 +35,13 @@ export abstract class ListCommand< delimiter: ',', description: 'Filter by id list (repeatable or comma-separated)', exclusive: ['name-contains'], + helpGroup: 'FILTER', multiple: true, }), 'name-contains': Flags.string({ description: 'Filter by name substring', exclusive: ['id-in'], + helpGroup: 'FILTER', }), page: Flags.integer({ dependsOn: ['page-size'], @@ -57,9 +59,13 @@ export abstract class ListCommand< protected abstract listPath: string protected abstract tableAttrs: TableColumnInput[] + protected extraListFlags(_flags: Record): Record { + return {} + } + protected async fetchListResults(options: { flags: ListCommandFlags - params?: Record + params?: Record path: string }): Promise { const {flags, params = {}, path} = options @@ -81,7 +87,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'], @@ -122,7 +128,7 @@ export abstract class ListCommand< const {flags, metadata} = await this.parse() const {dateFormat, ...apiFlags} = await this.resolveGlobalFlags(flags, metadata) - const listFlags: ListCommandFlags = { + const listFlags: ListCommandFlags & Record = { headers: apiFlags.headers, hostname: apiFlags.hostname, 'id-in': flags['id-in'], @@ -131,6 +137,7 @@ export abstract class ListCommand< 'page-size': flags['page-size'], sort: flags.sort, token: apiFlags.token, + ...this.extraListFlags(flags), } const outputFlags: ListOutputFlags = { ...listFlags, @@ -163,7 +170,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/list.test.ts b/test/commands/documents/list.test.ts index 593a2b7..9570054 100644 --- a/test/commands/documents/list.test.ts +++ b/test/commands/documents/list.test.ts @@ -129,6 +129,192 @@ describe('documents:list', () => { expect(requestUrl.searchParams.get('title__icontains')).to.equal('BFPR100') }) + it('supports added/created/modified date filters', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ + next: null, + results: [], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, + }, + ) + } + + await runCommand([ + 'documents:list', + '--added-after', + '2024-01-01', + '--added-before', + '2024-01-31', + '--created-after', + '2024-02-01T02:03:04Z', + '--created-before', + '2024-02-28T00:00:00Z', + '--modified-after', + '2024-03-01', + '--modified-before', + '2024-03-31T23:59:59Z', + ]) + + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('added__date__gte')).to.equal('2024-01-01') + expect(requestUrl.searchParams.get('added__date__lte')).to.equal('2024-01-31') + expect(requestUrl.searchParams.get('created__gte')).to.equal('2024-02-01T02:03:04Z') + expect(requestUrl.searchParams.get('created__lte')).to.equal('2024-02-28T00:00:00Z') + expect(requestUrl.searchParams.get('modified__date__gte')).to.equal('2024-03-01') + expect(requestUrl.searchParams.get('modified__lte')).to.equal('2024-03-31T23:59:59Z') + }) + + it('supports tag filters', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ + next: null, + results: [], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, + }, + ) + } + + await runCommand('documents:list --tag 1,2 --tag-all 3,4 --tag-not 5') + const requestUrl = new URL(requests[0]) + + expect(requestUrl.searchParams.get('tags__id__in')).to.equal('1,2') + expect(requestUrl.searchParams.get('tags__id__all')).to.equal('3,4') + expect(requestUrl.searchParams.get('tags__id__none')).to.equal('5') + }) + + it('supports no-tag filtering', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ + next: null, + results: [], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, + }, + ) + } + + await runCommand('documents:list --no-tag') + const requestUrl = new URL(requests[0]) + expect(requestUrl.searchParams.get('is_tagged')).to.equal('false') + }) + + it('supports correspondent and document type filters', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ + next: null, + results: [], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, + }, + ) + } + + await runCommand( + 'documents:list --correspondent 12,13 --correspondent-not 8 --document-type 4,5 --document-type-not 6', + ) + const requestUrl = new URL(requests[0]) + + expect(requestUrl.searchParams.get('correspondent__id__in')).to.equal('12,13') + expect(requestUrl.searchParams.get('correspondent__id__none')).to.equal('8') + expect(requestUrl.searchParams.get('document_type__id__in')).to.equal('4,5') + expect(requestUrl.searchParams.get('document_type__id__none')).to.equal('6') + }) + + it('supports no-correspondent and no-document-type filters', async () => { + globalThis.fetch = async (input) => { + requests.push(String(input)) + return new Response( + JSON.stringify({ + next: null, + results: [], + }), + { + headers: {'Content-Type': 'application/json'}, + status: 200, + }, + ) + } + + await runCommand('documents:list --no-correspondent --no-document-type') + const requestUrl = new URL(requests[0]) + + expect(requestUrl.searchParams.get('correspondent__isnull')).to.equal('true') + expect(requestUrl.searchParams.get('document_type__isnull')).to.equal('true') + }) + + it('rejects invalid date filters', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:list --added-after not-a-date') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.match(/YYYY-MM-DD|ISO 8601/i) + }) + + it('rejects id-in with tag filters', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:list --id-in 1,2 --tag 3') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('cannot also be provided') + }) + + it('rejects no-tag with tag filters', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:list --no-tag --tag 1') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('cannot also be provided') + }) + + it('rejects no-correspondent with correspondent filters', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:list --no-correspondent --correspondent 1') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('cannot also be provided') + }) + + it('rejects no-document-type with document type filters', async () => { + globalThis.fetch = async () => { + throw new Error('Unexpected fetch call') + } + + const {error} = await runCommand('documents:list --no-document-type --document-type 1') + + expect(error).to.be.instanceOf(Error) + expect(error?.message).to.contain('cannot also be provided') + }) + it('respects page size overrides', async () => { globalThis.fetch = async (input) => { requests.push(String(input))