@verbatims/cli 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,11 @@ verbatims quotes attributions list 123
30
30
  verbatims quotes attributions create 123 --author-id 1 --reference-id 2 --status manually_verified --primary
31
31
  verbatims quotes sources list 123
32
32
  verbatims quotes sources create 123 --attribution-id 7 --source-type book --source-url https://example.com/edition --status externally_verified
33
+ verbatims quotes sources update 123 7 --translator-name "Jane Doe"
34
+
35
+ # Structured author dates (era can be bce or ce)
36
+ verbatims authors create --name "Hypatia" --birth-year 350 --birth-era ce --birth-approximate
37
+ verbatims authors update 12 --death-year 415 --death-era ce --death-approximate
33
38
 
34
39
  # Create a quote with a source
35
40
  verbatims quotes create --name "Alea jacta est" --author-id 1 --source-type book --source-url https://example.com/edition
@@ -42,6 +47,15 @@ verbatims quotes browse
42
47
  # Search
43
48
  verbatims search "love" --type quotes
44
49
 
50
+ # Manage collections
51
+ verbatims collections list --limit 20
52
+ verbatims collections get 12 --page 1 --limit 20
53
+ verbatims collections create --name "Favorite quotes" --public
54
+ verbatims collections update 12 --name "Reading notes" --private
55
+ verbatims collections add 12 123
56
+ verbatims collections remove 12 123
57
+ verbatims collections delete 12 --yes
58
+
45
59
  # Apply a reproducible theme manifest (safe to preview first)
46
60
  verbatims themes apply ./theme.json --dry-run
47
61
  verbatims themes apply ./theme.json
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@verbatims/cli",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "CLI for the Verbatims quotes API",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": "^22 || ^24"
8
+ },
6
9
  "bin": {
7
10
  "verbatims": "./bin/verbatims.js"
8
11
  },
@@ -16,7 +19,7 @@
16
19
  "typecheck": "tsc --noEmit"
17
20
  },
18
21
  "dependencies": {
19
- "@verbatims/sdk": "^2.0.2",
22
+ "@verbatims/sdk": "^2.0.4",
20
23
  "@clack/prompts": "^0.10.1",
21
24
  "chalk": "^5.4.1",
22
25
  "commander": "^13.1.0",
@@ -11,6 +11,36 @@ function getFormat(program: Command): Format {
11
11
  return (program.opts().format ?? 'table') as Format
12
12
  }
13
13
 
14
+ function getLifeDateFields(options: Record<string, unknown>): Record<string, unknown> {
15
+ const fields: Record<string, unknown> = {}
16
+ const dateParts = ['date', 'year', 'era', 'month', 'day', 'approximate'] as const
17
+
18
+ for (const period of ['birth', 'death'] as const) {
19
+ for (const part of dateParts) {
20
+ const optionName = part === 'approximate' ? `${period}Approximate` : `${period}${part.slice(0, 1).toUpperCase()}${part.slice(1)}`
21
+ const fieldName = part === 'approximate' ? `${period}_is_approximate` : `${period}_${part}`
22
+ const value = options[optionName]
23
+ if (value !== undefined) fields[fieldName] = part === 'year' || part === 'month' || part === 'day' ? Number(value) : value
24
+ }
25
+ }
26
+
27
+ return fields
28
+ }
29
+
30
+ function addLifeDateOptions(command: Command): void {
31
+ for (const period of ['birth', 'death'] as const) {
32
+ const label = period === 'birth' ? 'Birth' : 'Death'
33
+ command
34
+ .option(`--${period}-date <date>`, `${label} date`)
35
+ .option(`--${period}-year <year>`, `${label} year (supports BCE with --${period}-era bce)`)
36
+ .option(`--${period}-era <era>`, `${label} era: bce or ce`)
37
+ .option(`--${period}-month <month>`, `${label} month`)
38
+ .option(`--${period}-day <day>`, `${label} day`)
39
+ .option(`--${period}-approximate`, `Mark ${label.toLowerCase()} date as approximate`)
40
+ .option(`--no-${period}-approximate`, `Mark ${label.toLowerCase()} date as exact`)
41
+ }
42
+ }
43
+
14
44
  export function registerAuthorsCommand(program: Command) {
15
45
  const authors = program.command('authors').description('Manage authors')
16
46
 
@@ -42,8 +72,8 @@ export function registerAuthorsCommand(program: Command) {
42
72
  output(data, format)
43
73
  })
44
74
 
45
- authors
46
- .command('create')
75
+ const createAuthor = authors.command('create')
76
+ createAuthor
47
77
  .description('Create an author')
48
78
  .option('--name <name>', 'Author name')
49
79
  .option('--job <job>', 'Job title')
@@ -51,15 +81,15 @@ export function registerAuthorsCommand(program: Command) {
51
81
  .option('--image-url <url>', 'Image URL')
52
82
  .option('--image-path <path>', 'Local image file path')
53
83
  .option('--fictional', 'Mark as fictional')
54
- .action(async (options: Record<string, string>) => {
84
+ .action(async (options: Record<string, unknown>) => {
55
85
  const client = await getClient()
56
86
  const format = getFormat(program)
57
87
 
58
- const name = options.name ?? await text({ message: 'Author name', validate: (v) => !v ? 'Required' : undefined })
88
+ const name = (options.name as string | undefined) ?? await text({ message: 'Author name', validate: (v) => !v ? 'Required' : undefined })
59
89
  if (isCancel(name)) cancel('Cancelled')
60
90
 
61
- let image_url: string | null = options.imageUrl || null
62
- const imagePath = options.imagePath
91
+ let image_url: string | null = typeof options.imageUrl === 'string' ? options.imageUrl : null
92
+ const imagePath = typeof options.imagePath === 'string' ? options.imagePath : undefined
63
93
  if (imagePath) {
64
94
  image_url = await withSpinner('Uploading image', () => uploadImageFromPath(client, imagePath), format)
65
95
  }
@@ -67,10 +97,11 @@ export function registerAuthorsCommand(program: Command) {
67
97
  const { data } = await withSpinner('Creating author', () =>
68
98
  client.authors.create({
69
99
  name: name as string,
70
- job: options.job ?? null,
71
- description: options.description ?? null,
100
+ job: (options.job as string | undefined) ?? null,
101
+ description: (options.description as string | undefined) ?? null,
72
102
  image_url,
73
- fictional: options.fictional !== undefined ? true : undefined,
103
+ fictional: options.fictional === true ? true : undefined,
104
+ ...getLifeDateFields(options),
74
105
  }),
75
106
  format,
76
107
  )
@@ -78,8 +109,8 @@ export function registerAuthorsCommand(program: Command) {
78
109
  output(data, format)
79
110
  })
80
111
 
81
- authors
82
- .command('update <id>')
112
+ const updateAuthor = authors.command('update <id>')
113
+ updateAuthor
83
114
  .description('Update an author')
84
115
  .option('--name <name>', 'Author name')
85
116
  .option('--job <job>', 'Job title')
@@ -88,27 +119,30 @@ export function registerAuthorsCommand(program: Command) {
88
119
  .option('--image-path <path>', 'Local image file path')
89
120
  .option('--fictional', 'Mark as fictional')
90
121
  .option('--no-fictional', 'Mark as not fictional')
91
- .action(async (id: string, options: Record<string, string>) => {
122
+ .action(async (id: string, options: Record<string, unknown>) => {
92
123
  const client = await getClient()
93
124
  const format = getFormat(program)
94
125
 
95
126
  const data_: Record<string, unknown> = {}
96
- if (options.name) data_.name = options.name
127
+ if (typeof options.name === 'string') data_.name = options.name
97
128
  if (options.job !== undefined) data_.job = options.job || null
98
129
  if (options.description !== undefined) data_.description = options.description || null
99
130
  if (options.imageUrl !== undefined) data_.image_url = options.imageUrl || null
100
- const updateImagePath = options.imagePath
131
+ const updateImagePath = typeof options.imagePath === 'string' ? options.imagePath : undefined
101
132
  if (updateImagePath !== undefined) {
102
133
  data_.image_url = await withSpinner('Uploading image', () => uploadImageFromPath(client, updateImagePath), format)
103
134
  }
104
- if (options.fictional !== undefined) data_.is_fictional = true
105
- if (options.noFictional !== undefined) data_.is_fictional = false
135
+ if (options.fictional !== undefined) data_.is_fictional = options.fictional === true
136
+ Object.assign(data_, getLifeDateFields(options))
106
137
 
107
138
  const { data } = await withSpinner('Updating author', () => client.authors.update(Number(id), data_ as any), format)
108
139
  console.log(chalk.green(' Author updated'))
109
140
  output(data, format)
110
141
  })
111
142
 
143
+ addLifeDateOptions(createAuthor)
144
+ addLifeDateOptions(updateAuthor)
145
+
112
146
  authors
113
147
  .command('delete <id>')
114
148
  .description('Delete an author')
@@ -1,5 +1,5 @@
1
1
  import type { Command } from 'commander'
2
- import { text, isCancel, cancel } from '@clack/prompts'
2
+ import { text, isCancel, cancel, confirm } from '@clack/prompts'
3
3
  import chalk from 'chalk'
4
4
  import { getClient } from '../utils/client.js'
5
5
  import { output, type Format } from '../utils/format.js'
@@ -12,6 +12,39 @@ function getFormat(program: Command): Format {
12
12
  export function registerCollectionsCommand(program: Command) {
13
13
  const collections = program.command('collections').description('Manage collections')
14
14
 
15
+ collections
16
+ .command('list')
17
+ .description('List collections')
18
+ .option('--page <n>', 'Page number', '1')
19
+ .option('--limit <n>', 'Number of results', '20')
20
+ .action(async (options: Record<string, string>) => {
21
+ const client = await getClient()
22
+ const format = getFormat(program)
23
+ const { data, pagination } = await withSpinner('Fetching collections', () =>
24
+ client.collections.list({ page: Number(options.page), limit: Number(options.limit) }),
25
+ format,
26
+ )
27
+ output(data, format)
28
+ if (pagination && format !== 'json') {
29
+ console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
30
+ }
31
+ })
32
+
33
+ collections
34
+ .command('get <id>')
35
+ .description('Get a collection and its quotes')
36
+ .option('--page <n>', 'Quote page number', '1')
37
+ .option('--limit <n>', 'Quotes per page', '20')
38
+ .action(async (id: string, options: Record<string, string>) => {
39
+ const client = await getClient()
40
+ const format = getFormat(program)
41
+ const { data } = await withSpinner('Fetching collection', () =>
42
+ client.collections.get(Number(id), { page: Number(options.page), limit: Number(options.limit) }),
43
+ format,
44
+ )
45
+ output(data, format)
46
+ })
47
+
15
48
  collections
16
49
  .command('create')
17
50
  .description('Create a collection')
@@ -33,6 +66,51 @@ export function registerCollectionsCommand(program: Command) {
33
66
  output(data, format)
34
67
  })
35
68
 
69
+ collections
70
+ .command('update <id>')
71
+ .description('Update a collection')
72
+ .option('--name <name>', 'Collection name')
73
+ .option('--description <text>', 'Description (use an empty value to clear)')
74
+ .option('--public', 'Make collection public')
75
+ .option('--private', 'Make collection private')
76
+ .action(async (id: string, options: Record<string, string | boolean>) => {
77
+ if (options.public && options.private) {
78
+ throw new Error('Choose either --public or --private')
79
+ }
80
+ const data: { name?: string; description?: string | null; is_public?: boolean } = {}
81
+ if (options.name) data.name = options.name as string
82
+ if (options.description !== undefined) data.description = options.description ? options.description as string : null
83
+ if (options.public) data.is_public = true
84
+ if (options.private) data.is_public = false
85
+ if (Object.keys(data).length === 0) {
86
+ throw new Error('Provide at least one field to update')
87
+ }
88
+
89
+ const client = await getClient()
90
+ const format = getFormat(program)
91
+ const { data: updated } = await withSpinner('Updating collection', () =>
92
+ client.collections.update(Number(id), data),
93
+ format,
94
+ )
95
+ console.log(chalk.green(' Collection updated'))
96
+ output(updated, format)
97
+ })
98
+
99
+ collections
100
+ .command('delete <id>')
101
+ .description('Delete a collection')
102
+ .option('--yes', 'Skip confirmation')
103
+ .action(async (id: string, options: { yes?: boolean }) => {
104
+ if (!options.yes) {
105
+ const confirmed = await confirm({ message: `Delete collection #${id}?` })
106
+ if (isCancel(confirmed) || !confirmed) cancel('Cancelled')
107
+ }
108
+ const client = await getClient()
109
+ const format = getFormat(program)
110
+ await withSpinner('Deleting collection', () => client.collections.delete(Number(id)), format)
111
+ console.log(chalk.green(` Collection #${id} deleted`))
112
+ })
113
+
36
114
  collections
37
115
  .command('add <collection-id> <quote-id>')
38
116
  .description('Add a quote to a collection')
@@ -70,6 +70,7 @@ export function registerQuotesCommand(program: Command) {
70
70
  .option('--reference-id <id>', 'Primary attribution reference ID')
71
71
  .option('--source-type <type>', 'Source type for the primary attribution')
72
72
  .option('--source-url <url>', 'Source URL for the primary attribution')
73
+ .option('--translator-name <name>', 'Translator name for the primary source')
73
74
  .action(async (options: Record<string, string>) => {
74
75
  const client = await getClient()
75
76
  const format = getFormat(program)
@@ -103,6 +104,7 @@ export function registerQuotesCommand(program: Command) {
103
104
  attribution_id: primary.id,
104
105
  source_type: sourceType,
105
106
  source_url: options.sourceUrl,
107
+ translator_name: options.translatorName,
106
108
  is_primary: true,
107
109
  }), format)
108
110
  }
@@ -292,6 +294,7 @@ export function registerQuotesCommand(program: Command) {
292
294
  .option('--source-type <type>', 'Source type')
293
295
  .option('--source-url <url>', 'Source URL')
294
296
  .option('--label <label>', 'Source label')
297
+ .option('--translator-name <name>', 'Translator name')
295
298
  .option('--status <status>', 'Verification status')
296
299
  .option('--notes <text>', 'Internal notes')
297
300
  .option('--primary', 'Set as primary source')
@@ -314,6 +317,7 @@ export function registerQuotesCommand(program: Command) {
314
317
  source_type: options.sourceType,
315
318
  source_url: options.sourceUrl,
316
319
  label: options.label,
320
+ translator_name: options.translatorName,
317
321
  verification_status: options.status,
318
322
  notes: options.notes,
319
323
  is_primary: options.primary,
@@ -329,6 +333,7 @@ export function registerQuotesCommand(program: Command) {
329
333
  if (options.sourceType !== undefined) data_.source_type = options.sourceType
330
334
  if (options.sourceUrl !== undefined) data_.source_url = options.sourceUrl
331
335
  if (options.label !== undefined) data_.label = options.label
336
+ if (options.translatorName !== undefined) data_.translator_name = options.translatorName
332
337
  if (options.status !== undefined) data_.verification_status = options.status
333
338
  if (options.notes !== undefined) data_.notes = options.notes
334
339
  if (options.primary !== undefined) data_.is_primary = options.primary