@verbatims/cli 0.1.0
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 +30 -0
- package/bin/verbatims.js +3 -0
- package/package.json +37 -0
- package/src/commands/authors.ts +95 -0
- package/src/commands/collections.ts +53 -0
- package/src/commands/config.ts +62 -0
- package/src/commands/index.ts +18 -0
- package/src/commands/quotes.ts +149 -0
- package/src/commands/references.ts +101 -0
- package/src/commands/search.ts +43 -0
- package/src/commands/tags.ts +18 -0
- package/src/index.ts +27 -0
- package/src/utils/browse.ts +88 -0
- package/src/utils/client.ts +18 -0
- package/src/utils/config.ts +32 -0
- package/src/utils/format.ts +173 -0
- package/src/utils/spinner.ts +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @verbatims/cli
|
|
2
|
+
|
|
3
|
+
CLI for the [Verbatims](https://verbatims.cc) quotes API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @verbatims/cli
|
|
9
|
+
# or use directly
|
|
10
|
+
npx @verbatims/cli --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Configure API key
|
|
17
|
+
verbatims config
|
|
18
|
+
|
|
19
|
+
# List quotes
|
|
20
|
+
verbatims quotes list --language fr --limit 10
|
|
21
|
+
|
|
22
|
+
# Browse interactively
|
|
23
|
+
verbatims quotes browse
|
|
24
|
+
|
|
25
|
+
# Search
|
|
26
|
+
verbatims search "love" --type quotes
|
|
27
|
+
|
|
28
|
+
# See all commands
|
|
29
|
+
verbatims --help
|
|
30
|
+
```
|
package/bin/verbatims.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@verbatims/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for the Verbatims quotes API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"verbatims": "./bin/verbatims.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"prepublishOnly": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@verbatims/sdk": "^1.0.0",
|
|
21
|
+
"@clack/prompts": "^0.10.1",
|
|
22
|
+
"chalk": "^5.4.1",
|
|
23
|
+
"commander": "^13.1.0",
|
|
24
|
+
"tsx": "^4.19.4"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.9.0"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/rootasjey/verbatims-sdk.git"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { text, isCancel, cancel } from '@clack/prompts'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { getClient } from '../utils/client.js'
|
|
5
|
+
import { output, type Format } from '../utils/format.js'
|
|
6
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
7
|
+
import { browse } from '../utils/browse.js'
|
|
8
|
+
|
|
9
|
+
function getFormat(program: Command): Format {
|
|
10
|
+
return (program.opts().format ?? 'table') as Format
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerAuthorsCommand(program: Command) {
|
|
14
|
+
const authors = program.command('authors').description('Manage authors')
|
|
15
|
+
|
|
16
|
+
authors
|
|
17
|
+
.command('list')
|
|
18
|
+
.description('List authors')
|
|
19
|
+
.option('--search <q>', 'Search authors')
|
|
20
|
+
.option('--limit <n>', 'Number of results', '20')
|
|
21
|
+
.action(async (options: Record<string, string>) => {
|
|
22
|
+
const client = await getClient()
|
|
23
|
+
const format = getFormat(program)
|
|
24
|
+
const { data, pagination } = await withSpinner('Fetching authors', () =>
|
|
25
|
+
client.authors.list({ search: options.search, limit: Number(options.limit) }),
|
|
26
|
+
format,
|
|
27
|
+
)
|
|
28
|
+
output(data, format)
|
|
29
|
+
if (pagination && format !== 'json') {
|
|
30
|
+
console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
authors
|
|
35
|
+
.command('get <id>')
|
|
36
|
+
.description('Get an author by ID')
|
|
37
|
+
.action(async (id: string) => {
|
|
38
|
+
const client = await getClient()
|
|
39
|
+
const format = getFormat(program)
|
|
40
|
+
const { data } = await withSpinner('Fetching author', () => client.authors.get(Number(id)), format)
|
|
41
|
+
output(data, format)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
authors
|
|
45
|
+
.command('create')
|
|
46
|
+
.description('Create an author')
|
|
47
|
+
.option('--name <name>', 'Author name')
|
|
48
|
+
.option('--job <job>', 'Job title')
|
|
49
|
+
.option('--description <text>', 'Description')
|
|
50
|
+
.action(async (options: Record<string, string>) => {
|
|
51
|
+
const client = await getClient()
|
|
52
|
+
const format = getFormat(program)
|
|
53
|
+
|
|
54
|
+
const name = options.name ?? await text({ message: 'Author name', validate: (v) => !v ? 'Required' : undefined })
|
|
55
|
+
if (isCancel(name)) cancel('Cancelled')
|
|
56
|
+
|
|
57
|
+
const { data } = await withSpinner('Creating author', () =>
|
|
58
|
+
client.authors.create({ name: name as string, job: options.job ?? null, description: options.description ?? null }),
|
|
59
|
+
format,
|
|
60
|
+
)
|
|
61
|
+
console.log(chalk.green(' Author created'))
|
|
62
|
+
output(data, format)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
authors
|
|
66
|
+
.command('update <id>')
|
|
67
|
+
.description('Update an author')
|
|
68
|
+
.option('--name <name>', 'Author name')
|
|
69
|
+
.option('--description <text>', 'Description')
|
|
70
|
+
.action(async (id: string, options: Record<string, string>) => {
|
|
71
|
+
const client = await getClient()
|
|
72
|
+
const format = getFormat(program)
|
|
73
|
+
|
|
74
|
+
const data_: Record<string, unknown> = {}
|
|
75
|
+
if (options.name) data_.name = options.name
|
|
76
|
+
if (options.description !== undefined) data_.description = options.description
|
|
77
|
+
|
|
78
|
+
const { data } = await withSpinner('Updating author', () => client.authors.update(Number(id), data_ as any), format)
|
|
79
|
+
console.log(chalk.green(' Author updated'))
|
|
80
|
+
output(data, format)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
authors
|
|
84
|
+
.command('browse')
|
|
85
|
+
.description('Browse authors interactively')
|
|
86
|
+
.option('--search <q>', 'Search authors')
|
|
87
|
+
.action(async (options: Record<string, string>) => {
|
|
88
|
+
const client = await getClient()
|
|
89
|
+
const format = getFormat(program)
|
|
90
|
+
await browse(
|
|
91
|
+
(page) => client.authors.list({ page, search: options.search }),
|
|
92
|
+
format,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { text, isCancel, cancel } from '@clack/prompts'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { getClient } from '../utils/client.js'
|
|
5
|
+
import { output, type Format } from '../utils/format.js'
|
|
6
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
7
|
+
|
|
8
|
+
function getFormat(program: Command): Format {
|
|
9
|
+
return (program.opts().format ?? 'plain') as Format
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerCollectionsCommand(program: Command) {
|
|
13
|
+
const collections = program.command('collections').description('Manage collections')
|
|
14
|
+
|
|
15
|
+
collections
|
|
16
|
+
.command('create')
|
|
17
|
+
.description('Create a collection')
|
|
18
|
+
.option('--name <name>', 'Collection name')
|
|
19
|
+
.option('--description <text>', 'Description')
|
|
20
|
+
.option('--public', 'Make collection public')
|
|
21
|
+
.action(async (options: Record<string, string>) => {
|
|
22
|
+
const client = await getClient()
|
|
23
|
+
const format = getFormat(program)
|
|
24
|
+
|
|
25
|
+
const name = options.name ?? await text({ message: 'Collection name', validate: (v) => !v ? 'Required' : undefined })
|
|
26
|
+
if (isCancel(name)) cancel('Cancelled')
|
|
27
|
+
|
|
28
|
+
const { data } = await withSpinner('Creating collection', () =>
|
|
29
|
+
client.collections.create({ name: name as string, description: options.description ?? null, is_public: Boolean(options.public) }),
|
|
30
|
+
format,
|
|
31
|
+
)
|
|
32
|
+
console.log(chalk.green(' Collection created'))
|
|
33
|
+
output(data, format)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
collections
|
|
37
|
+
.command('add <collection-id> <quote-id>')
|
|
38
|
+
.description('Add a quote to a collection')
|
|
39
|
+
.action(async (collectionId: string, quoteId: string) => {
|
|
40
|
+
const client = await getClient()
|
|
41
|
+
await withSpinner('Adding quote to collection', () => client.collections.addQuote(Number(collectionId), Number(quoteId)))
|
|
42
|
+
console.log(chalk.green(` Quote #${quoteId} added to collection #${collectionId}`))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
collections
|
|
46
|
+
.command('remove <collection-id> <quote-id>')
|
|
47
|
+
.description('Remove a quote from a collection')
|
|
48
|
+
.action(async (collectionId: string, quoteId: string) => {
|
|
49
|
+
const client = await getClient()
|
|
50
|
+
await withSpinner('Removing quote from collection', () => client.collections.removeQuote(Number(collectionId), Number(quoteId)))
|
|
51
|
+
console.log(chalk.green(` Quote #${quoteId} removed from collection #${collectionId}`))
|
|
52
|
+
})
|
|
53
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { intro, outro, text, isCancel, cancel } from '@clack/prompts'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { loadConfig, saveConfig, getConfigPath } from '../utils/config.js'
|
|
5
|
+
|
|
6
|
+
export function registerConfigCommand(program: Command) {
|
|
7
|
+
program
|
|
8
|
+
.command('config')
|
|
9
|
+
.description('Manage configuration')
|
|
10
|
+
.alias('cfg')
|
|
11
|
+
.option('--api-key <key>', 'Set API key')
|
|
12
|
+
.option('--base-url <url>', 'Set base URL')
|
|
13
|
+
.option('--show', 'Show current config')
|
|
14
|
+
.action(async (options: Record<string, unknown>) => {
|
|
15
|
+
if (options.show) {
|
|
16
|
+
const config = await loadConfig()
|
|
17
|
+
const key = config.apiKey
|
|
18
|
+
? `${config.apiKey.slice(0, 8)}...${config.apiKey.slice(-4)}`
|
|
19
|
+
: chalk.dim('not set')
|
|
20
|
+
console.log(`API key : ${key}`)
|
|
21
|
+
console.log(`Base URL: ${config.baseUrl ?? chalk.dim('default')}`)
|
|
22
|
+
console.log(`Config : ${getConfigPath()}`)
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const flags: Record<string, string> = {}
|
|
27
|
+
if (options.apiKey) flags.apiKey = options.apiKey as string
|
|
28
|
+
if (options.baseUrl) flags.baseUrl = options.baseUrl as string
|
|
29
|
+
|
|
30
|
+
if (Object.keys(flags).length > 0) {
|
|
31
|
+
const existing = await loadConfig()
|
|
32
|
+
await saveConfig({ ...existing, ...flags })
|
|
33
|
+
console.log(chalk.green('✓ Config updated'))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
intro(chalk.bold('Verbatims Config'))
|
|
38
|
+
|
|
39
|
+
const existing = await loadConfig()
|
|
40
|
+
|
|
41
|
+
const apiKey = await text({
|
|
42
|
+
message: 'API key',
|
|
43
|
+
placeholder: 'vbt_xxx...',
|
|
44
|
+
defaultValue: existing.apiKey ?? '',
|
|
45
|
+
})
|
|
46
|
+
if (isCancel(apiKey)) cancel('Cancelled')
|
|
47
|
+
|
|
48
|
+
const baseUrl = await text({
|
|
49
|
+
message: 'Base URL (optional)',
|
|
50
|
+
placeholder: 'https://api.verbatims.com/v1',
|
|
51
|
+
defaultValue: existing.baseUrl ?? '',
|
|
52
|
+
})
|
|
53
|
+
if (isCancel(baseUrl)) cancel('Cancelled')
|
|
54
|
+
|
|
55
|
+
await saveConfig({
|
|
56
|
+
apiKey: apiKey as string,
|
|
57
|
+
baseUrl: (baseUrl as string) || undefined,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
outro(chalk.green('Config saved'))
|
|
61
|
+
})
|
|
62
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { registerQuotesCommand } from './quotes.js'
|
|
3
|
+
import { registerAuthorsCommand } from './authors.js'
|
|
4
|
+
import { registerReferencesCommand } from './references.js'
|
|
5
|
+
import { registerTagsCommand } from './tags.js'
|
|
6
|
+
import { registerCollectionsCommand } from './collections.js'
|
|
7
|
+
import { registerSearchCommand } from './search.js'
|
|
8
|
+
import { registerConfigCommand } from './config.js'
|
|
9
|
+
|
|
10
|
+
export function registerCommands(program: Command) {
|
|
11
|
+
registerQuotesCommand(program)
|
|
12
|
+
registerAuthorsCommand(program)
|
|
13
|
+
registerReferencesCommand(program)
|
|
14
|
+
registerTagsCommand(program)
|
|
15
|
+
registerCollectionsCommand(program)
|
|
16
|
+
registerSearchCommand(program)
|
|
17
|
+
registerConfigCommand(program)
|
|
18
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { text, isCancel, cancel, confirm } from '@clack/prompts'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { getClient } from '../utils/client.js'
|
|
5
|
+
import { output, type Format } from '../utils/format.js'
|
|
6
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
7
|
+
import { browse } from '../utils/browse.js'
|
|
8
|
+
|
|
9
|
+
function getFormat(program: Command): Format {
|
|
10
|
+
return (program.opts().format ?? 'table') as Format
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerQuotesCommand(program: Command) {
|
|
14
|
+
const quotes = program.command('quotes').description('Manage quotes')
|
|
15
|
+
|
|
16
|
+
quotes
|
|
17
|
+
.command('list')
|
|
18
|
+
.description('List quotes')
|
|
19
|
+
.option('--language <lang>', 'Filter by language')
|
|
20
|
+
.option('--limit <n>', 'Number of results', '20')
|
|
21
|
+
.option('--author <id>', 'Filter by author ID')
|
|
22
|
+
.option('--tag <name>', 'Filter by tag')
|
|
23
|
+
.option('--search <q>', 'Search in quote text')
|
|
24
|
+
.option('--sort-by <field>', 'Sort field')
|
|
25
|
+
.option('--sort-order <order>', 'Sort order (asc|desc)')
|
|
26
|
+
.action(async (options: Record<string, string>) => {
|
|
27
|
+
const client = await getClient()
|
|
28
|
+
const format = getFormat(program)
|
|
29
|
+
|
|
30
|
+
const { data, pagination } = await withSpinner('Fetching quotes', () =>
|
|
31
|
+
client.quotes.list({
|
|
32
|
+
language: options.language,
|
|
33
|
+
limit: Number(options.limit),
|
|
34
|
+
author_id: options.author ? Number(options.author) : undefined,
|
|
35
|
+
tag: options.tag,
|
|
36
|
+
search: options.search,
|
|
37
|
+
sort_by: options.sortBy,
|
|
38
|
+
sort_order: options.sortOrder as 'asc' | 'desc',
|
|
39
|
+
}),
|
|
40
|
+
format,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (data) output(data, format)
|
|
44
|
+
if (pagination && format !== 'json') {
|
|
45
|
+
console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
quotes
|
|
50
|
+
.command('get <id>')
|
|
51
|
+
.description('Get a quote by ID')
|
|
52
|
+
.action(async (id: string) => {
|
|
53
|
+
const client = await getClient()
|
|
54
|
+
const format = getFormat(program)
|
|
55
|
+
const { data } = await withSpinner('Fetching quote', () => client.quotes.get(Number(id)), format)
|
|
56
|
+
output(data, format)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
quotes
|
|
60
|
+
.command('create')
|
|
61
|
+
.description('Create a quote')
|
|
62
|
+
.option('--name <text>', 'Quote text')
|
|
63
|
+
.option('--language <lang>', 'Language')
|
|
64
|
+
.option('--author-id <id>', 'Author ID')
|
|
65
|
+
.option('--reference-id <id>', 'Reference ID')
|
|
66
|
+
.action(async (options: Record<string, string>) => {
|
|
67
|
+
const client = await getClient()
|
|
68
|
+
const format = getFormat(program)
|
|
69
|
+
|
|
70
|
+
const name = options.name ?? await text({ message: 'Quote text', validate: (v) => !v ? 'Required' : undefined })
|
|
71
|
+
if (isCancel(name)) cancel('Cancelled')
|
|
72
|
+
|
|
73
|
+
const language = options.language ?? await text({ message: 'Language', initialValue: 'fr' })
|
|
74
|
+
if (isCancel(language)) cancel('Cancelled')
|
|
75
|
+
|
|
76
|
+
const authorId = options.authorId ? Number(options.authorId) : undefined
|
|
77
|
+
const referenceId = options.referenceId ? Number(options.referenceId) : undefined
|
|
78
|
+
|
|
79
|
+
const { data } = await withSpinner('Creating quote', () =>
|
|
80
|
+
client.quotes.create({ content: name as string, name: name as string, language: language as string, author_id: authorId, reference_id: referenceId }),
|
|
81
|
+
format,
|
|
82
|
+
)
|
|
83
|
+
console.log(chalk.green(' Quote created'))
|
|
84
|
+
output(data, format)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
quotes
|
|
88
|
+
.command('update <id>')
|
|
89
|
+
.description('Update a quote')
|
|
90
|
+
.option('--name <text>', 'Quote text')
|
|
91
|
+
.option('--language <lang>', 'Language')
|
|
92
|
+
.option('--author-id <id>', 'Author ID')
|
|
93
|
+
.option('--reference-id <id>', 'Reference ID')
|
|
94
|
+
.action(async (id: string, options: Record<string, string>) => {
|
|
95
|
+
const client = await getClient()
|
|
96
|
+
const format = getFormat(program)
|
|
97
|
+
|
|
98
|
+
const data_: Record<string, unknown> = {}
|
|
99
|
+
if (options.name) { data_.name = options.name; data_.content = options.name }
|
|
100
|
+
if (options.language) data_.language = options.language
|
|
101
|
+
if (options.authorId !== undefined) data_.author_id = options.authorId === 'null' ? null : Number(options.authorId)
|
|
102
|
+
if (options.referenceId !== undefined) data_.reference_id = options.referenceId === 'null' ? null : Number(options.referenceId)
|
|
103
|
+
|
|
104
|
+
const { data } = await withSpinner('Updating quote', () => client.quotes.update(Number(id), data_ as any), format)
|
|
105
|
+
console.log(chalk.green(' Quote updated'))
|
|
106
|
+
output(data, format)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
quotes
|
|
110
|
+
.command('delete <id>')
|
|
111
|
+
.description('Delete a quote')
|
|
112
|
+
.option('--yes', 'Skip confirmation')
|
|
113
|
+
.action(async (id: string, options: Record<string, unknown>) => {
|
|
114
|
+
if (!options.yes) {
|
|
115
|
+
const confirmed = await confirm({ message: `Delete quote #${id}?` })
|
|
116
|
+
if (isCancel(confirmed) || !confirmed) cancel('Cancelled')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const client = await getClient()
|
|
120
|
+
await withSpinner('Deleting quote', () => client.quotes.delete(Number(id)))
|
|
121
|
+
console.log(chalk.green(` Quote #${id} deleted`))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
quotes
|
|
125
|
+
.command('browse')
|
|
126
|
+
.description('Browse quotes interactively')
|
|
127
|
+
.option('--language <lang>', 'Filter by language')
|
|
128
|
+
.option('--author <id>', 'Filter by author ID')
|
|
129
|
+
.option('--tag <name>', 'Filter by tag')
|
|
130
|
+
.option('--search <q>', 'Search in quote text')
|
|
131
|
+
.option('--sort-by <field>', 'Sort field')
|
|
132
|
+
.option('--sort-order <order>', 'Sort order (asc|desc)')
|
|
133
|
+
.action(async (options: Record<string, string>) => {
|
|
134
|
+
const client = await getClient()
|
|
135
|
+
const format = getFormat(program)
|
|
136
|
+
await browse(
|
|
137
|
+
(page) => client.quotes.list({
|
|
138
|
+
page,
|
|
139
|
+
language: options.language,
|
|
140
|
+
author_id: options.author ? Number(options.author) : undefined,
|
|
141
|
+
tag: options.tag,
|
|
142
|
+
search: options.search,
|
|
143
|
+
sort_by: options.sortBy,
|
|
144
|
+
sort_order: options.sortOrder as 'asc' | 'desc',
|
|
145
|
+
}),
|
|
146
|
+
format,
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { text, isCancel, cancel } from '@clack/prompts'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import { getClient } from '../utils/client.js'
|
|
5
|
+
import { output, type Format } from '../utils/format.js'
|
|
6
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
7
|
+
import { browse } from '../utils/browse.js'
|
|
8
|
+
|
|
9
|
+
function getFormat(program: Command): Format {
|
|
10
|
+
return (program.opts().format ?? 'table') as Format
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function registerReferencesCommand(program: Command) {
|
|
14
|
+
const refs = program.command('references').description('Manage references')
|
|
15
|
+
|
|
16
|
+
refs
|
|
17
|
+
.command('list')
|
|
18
|
+
.description('List references')
|
|
19
|
+
.option('--type <type>', 'Filter by type')
|
|
20
|
+
.option('--search <q>', 'Search references')
|
|
21
|
+
.option('--limit <n>', 'Number of results', '20')
|
|
22
|
+
.action(async (options: Record<string, string>) => {
|
|
23
|
+
const client = await getClient()
|
|
24
|
+
const format = getFormat(program)
|
|
25
|
+
const { data, pagination } = await withSpinner('Fetching references', () =>
|
|
26
|
+
client.references.list({ type: options.type, search: options.search, limit: Number(options.limit) }),
|
|
27
|
+
format,
|
|
28
|
+
)
|
|
29
|
+
output(data, format)
|
|
30
|
+
if (pagination && format !== 'json') {
|
|
31
|
+
console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
refs
|
|
36
|
+
.command('get <id>')
|
|
37
|
+
.description('Get a reference by ID')
|
|
38
|
+
.action(async (id: string) => {
|
|
39
|
+
const client = await getClient()
|
|
40
|
+
const format = getFormat(program)
|
|
41
|
+
const { data } = await withSpinner('Fetching reference', () => client.references.get(Number(id)), format)
|
|
42
|
+
output(data, format)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
refs
|
|
46
|
+
.command('create')
|
|
47
|
+
.description('Create a reference')
|
|
48
|
+
.option('--name <name>', 'Reference name')
|
|
49
|
+
.option('--type <type>', 'Primary type')
|
|
50
|
+
.option('--description <text>', 'Description')
|
|
51
|
+
.option('--language <lang>', 'Original language')
|
|
52
|
+
.action(async (options: Record<string, string>) => {
|
|
53
|
+
const client = await getClient()
|
|
54
|
+
const format = getFormat(program)
|
|
55
|
+
|
|
56
|
+
const name = options.name ?? await text({ message: 'Reference name', validate: (v) => !v ? 'Required' : undefined })
|
|
57
|
+
if (isCancel(name)) cancel('Cancelled')
|
|
58
|
+
|
|
59
|
+
const primaryType = options.type ?? await text({ message: 'Primary type' })
|
|
60
|
+
if (isCancel(primaryType)) cancel('Cancelled')
|
|
61
|
+
|
|
62
|
+
const { data } = await withSpinner('Creating reference', () =>
|
|
63
|
+
client.references.create({ name: name as string, type: primaryType as string, primary_type: primaryType as string, description: options.description ?? null, language: options.language, original_language: options.language }),
|
|
64
|
+
format,
|
|
65
|
+
)
|
|
66
|
+
console.log(chalk.green(' Reference created'))
|
|
67
|
+
output(data, format)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
refs
|
|
71
|
+
.command('update <id>')
|
|
72
|
+
.description('Update a reference')
|
|
73
|
+
.option('--name <name>', 'Reference name')
|
|
74
|
+
.option('--description <text>', 'Description')
|
|
75
|
+
.action(async (id: string, options: Record<string, string>) => {
|
|
76
|
+
const client = await getClient()
|
|
77
|
+
const format = getFormat(program)
|
|
78
|
+
|
|
79
|
+
const data_: Record<string, unknown> = {}
|
|
80
|
+
if (options.name) { data_.name = options.name }
|
|
81
|
+
if (options.description !== undefined) data_.description = options.description
|
|
82
|
+
|
|
83
|
+
const { data } = await withSpinner('Updating reference', () => client.references.update(Number(id), data_ as any), format)
|
|
84
|
+
console.log(chalk.green(' Reference updated'))
|
|
85
|
+
output(data, format)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
refs
|
|
89
|
+
.command('browse')
|
|
90
|
+
.description('Browse references interactively')
|
|
91
|
+
.option('--type <type>', 'Filter by type')
|
|
92
|
+
.option('--search <q>', 'Search references')
|
|
93
|
+
.action(async (options: Record<string, string>) => {
|
|
94
|
+
const client = await getClient()
|
|
95
|
+
const format = getFormat(program)
|
|
96
|
+
await browse(
|
|
97
|
+
(page) => client.references.list({ page, type: options.type, search: options.search }),
|
|
98
|
+
format,
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { getClient } from '../utils/client.js'
|
|
4
|
+
import { output, type Format } from '../utils/format.js'
|
|
5
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
6
|
+
import { browse } from '../utils/browse.js'
|
|
7
|
+
|
|
8
|
+
function getFormat(program: Command): Format {
|
|
9
|
+
return (program.opts().format ?? 'table') as Format
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerSearchCommand(program: Command) {
|
|
13
|
+
program
|
|
14
|
+
.command('search <query>')
|
|
15
|
+
.description('Search quotes, authors, or references')
|
|
16
|
+
.option('--type <type>', 'Type to search: quotes|authors|references', 'quotes')
|
|
17
|
+
.option('--limit <n>', 'Number of results', '20')
|
|
18
|
+
.action(async (query: string, options: Record<string, string>) => {
|
|
19
|
+
const client = await getClient()
|
|
20
|
+
const format = getFormat(program)
|
|
21
|
+
const { data, pagination } = await withSpinner('Searching', () =>
|
|
22
|
+
client.search.query({ q: query, type: options.type as 'quotes' | 'authors' | 'references', limit: Number(options.limit) }),
|
|
23
|
+
format,
|
|
24
|
+
)
|
|
25
|
+
output(data, format)
|
|
26
|
+
if (pagination && format !== 'json') {
|
|
27
|
+
console.log(chalk.dim(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`))
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command('search-browse <query>')
|
|
33
|
+
.description('Browse search results interactively')
|
|
34
|
+
.option('--type <type>', 'Type to search: quotes|authors|references', 'quotes')
|
|
35
|
+
.action(async (query: string, options: Record<string, string>) => {
|
|
36
|
+
const client = await getClient()
|
|
37
|
+
const format = getFormat(program)
|
|
38
|
+
await browse(
|
|
39
|
+
(page) => client.search.query({ q: query, type: options.type as 'quotes' | 'authors' | 'references', page }),
|
|
40
|
+
format,
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import { getClient } from '../utils/client.js'
|
|
3
|
+
import { output } from '../utils/format.js'
|
|
4
|
+
import { withSpinner } from '../utils/spinner.js'
|
|
5
|
+
|
|
6
|
+
export function registerTagsCommand(program: Command) {
|
|
7
|
+
program
|
|
8
|
+
.command('tags')
|
|
9
|
+
.description('Manage tags')
|
|
10
|
+
.command('list')
|
|
11
|
+
.description('List tags')
|
|
12
|
+
.action(async () => {
|
|
13
|
+
const client = await getClient()
|
|
14
|
+
const format = (program.opts().format ?? 'table') as 'table' | 'json' | 'plain'
|
|
15
|
+
const { data } = await withSpinner('Fetching tags', () => client.tags.list(), format)
|
|
16
|
+
output(data, format)
|
|
17
|
+
})
|
|
18
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import { registerCommands } from './commands/index.js'
|
|
5
|
+
import { setSilent } from './utils/spinner.js'
|
|
6
|
+
|
|
7
|
+
process.on('unhandledRejection', (err) => {
|
|
8
|
+
console.error(err instanceof Error ? err.message : String(err))
|
|
9
|
+
process.exit(1)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const program = new Command()
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('verbatims')
|
|
16
|
+
.description('CLI for the Verbatims quotes API')
|
|
17
|
+
.option('--api-key <key>', 'API key (overrides config and env)')
|
|
18
|
+
.option('--format <type>', 'Output format: table|json|plain', 'table')
|
|
19
|
+
.showHelpAfterError()
|
|
20
|
+
.hook('preAction', (thisCommand) => {
|
|
21
|
+
const opts = thisCommand.optsWithGlobals()
|
|
22
|
+
if (opts.format === 'json') setSilent(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
registerCommands(program)
|
|
26
|
+
|
|
27
|
+
program.parse()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { formatTable, type Format } from './format.js'
|
|
2
|
+
import type { PaginationMeta } from '@verbatims/sdk'
|
|
3
|
+
|
|
4
|
+
type PageFetcher = (page: number) => Promise<{
|
|
5
|
+
data?: unknown[]
|
|
6
|
+
pagination?: PaginationMeta
|
|
7
|
+
}>
|
|
8
|
+
|
|
9
|
+
export async function browse(fetchPage: PageFetcher, format: Format): Promise<void> {
|
|
10
|
+
if (!process.stdin.isTTY) {
|
|
11
|
+
console.error('Browse mode requires an interactive terminal.')
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let page = 1
|
|
16
|
+
let hasMore = false
|
|
17
|
+
|
|
18
|
+
process.stdin.setRawMode(true)
|
|
19
|
+
process.stdin.resume()
|
|
20
|
+
|
|
21
|
+
const render = async () => {
|
|
22
|
+
const result = await fetchPage(page)
|
|
23
|
+
const items = result.data ?? []
|
|
24
|
+
const pagination = result.pagination
|
|
25
|
+
hasMore = pagination?.hasMore ?? false
|
|
26
|
+
|
|
27
|
+
console.clear()
|
|
28
|
+
|
|
29
|
+
if (items.length === 0) {
|
|
30
|
+
console.log('No results')
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const allKeys = Object.keys(items[0] as Record<string, unknown>)
|
|
35
|
+
const keyOrder = ['id', 'content', 'name', 'language', 'type', 'author', 'reference']
|
|
36
|
+
const columns = keyOrder
|
|
37
|
+
.filter((k) => allKeys.includes(k))
|
|
38
|
+
.map((k) => ({ key: k, label: k }))
|
|
39
|
+
|
|
40
|
+
const table = formatTable(items as Record<string, unknown>[], columns)
|
|
41
|
+
console.log(table)
|
|
42
|
+
|
|
43
|
+
if (pagination) {
|
|
44
|
+
console.log(`\nPage ${pagination.page}/${pagination.totalPages} — ${pagination.total} total`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (hasMore || page > 1) {
|
|
48
|
+
const nav = []
|
|
49
|
+
if (page > 1) nav.push('◀ p')
|
|
50
|
+
if (hasMore) nav.push('n ▶')
|
|
51
|
+
nav.push('q quit')
|
|
52
|
+
console.log(`\n${nav.join(' │ ')}`)
|
|
53
|
+
} else {
|
|
54
|
+
console.log('\nq quit')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await render()
|
|
61
|
+
|
|
62
|
+
const onKey = async (key: string) => {
|
|
63
|
+
if (key === 'q' || key === '\u001b') {
|
|
64
|
+
process.stdin.setRawMode(false)
|
|
65
|
+
process.stdin.pause()
|
|
66
|
+
process.stdin.removeListener('data', onData)
|
|
67
|
+
console.log()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if ((key === 'n' || key === ' ' || key === '\u001b[C') && hasMore) {
|
|
72
|
+
page++
|
|
73
|
+
await render()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ((key === 'p' || key === '\u001b[D') && page > 1) {
|
|
77
|
+
page--
|
|
78
|
+
await render()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const onData = (buf: Buffer) => {
|
|
83
|
+
const key = buf.toString()
|
|
84
|
+
onKey(key)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
process.stdin.on('data', onData)
|
|
88
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { VerbatimsClient } from '@verbatims/sdk'
|
|
2
|
+
import { loadConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = 'https://verbatims.cc/api/v1'
|
|
5
|
+
|
|
6
|
+
export async function getClient(): Promise<VerbatimsClient> {
|
|
7
|
+
const config = await loadConfig()
|
|
8
|
+
const apiKey = process.env.VERBATIMS_API_KEY ?? config.apiKey
|
|
9
|
+
|
|
10
|
+
if (!apiKey) {
|
|
11
|
+
console.error('Missing API key. Set VERBATIMS_API_KEY env var or run `verbatims config init`.')
|
|
12
|
+
process.exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return new VerbatimsClient(apiKey, {
|
|
16
|
+
baseUrl: process.env.VERBATIMS_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.config', 'verbatims')
|
|
7
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
|
|
8
|
+
|
|
9
|
+
export interface Config {
|
|
10
|
+
apiKey?: string
|
|
11
|
+
baseUrl?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getConfigPath(): string {
|
|
15
|
+
return CONFIG_PATH
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function loadConfig(): Promise<Config> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await readFile(CONFIG_PATH, 'utf-8')
|
|
21
|
+
return JSON.parse(raw) as Config
|
|
22
|
+
} catch {
|
|
23
|
+
return {}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function saveConfig(config: Config): Promise<void> {
|
|
28
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
29
|
+
await mkdir(CONFIG_DIR, { recursive: true })
|
|
30
|
+
}
|
|
31
|
+
await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8')
|
|
32
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
|
|
3
|
+
export type Format = 'table' | 'json' | 'plain'
|
|
4
|
+
|
|
5
|
+
const stripAnsi = (s: string) => s.replace(/\x1B\[[0-?9;]*[mK]/g, '')
|
|
6
|
+
|
|
7
|
+
function padVisual(s: string, max: number): string {
|
|
8
|
+
const padLen = Math.max(0, max - stripAnsi(s).length)
|
|
9
|
+
return s + ' '.repeat(padLen)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function wordWrap(s: string, max: number): string[] {
|
|
13
|
+
if (stripAnsi(s).length <= max) return [s]
|
|
14
|
+
const lines: string[] = []
|
|
15
|
+
let current = ''
|
|
16
|
+
for (const word of s.split(' ')) {
|
|
17
|
+
const cleanWord = stripAnsi(word)
|
|
18
|
+
const cleanCurrent = stripAnsi(current)
|
|
19
|
+
if (cleanCurrent.length + cleanWord.length + (cleanCurrent.length > 0 ? 1 : 0) > max) {
|
|
20
|
+
if (cleanCurrent.length > 0) {
|
|
21
|
+
lines.push(current)
|
|
22
|
+
current = word
|
|
23
|
+
} else {
|
|
24
|
+
lines.push(word)
|
|
25
|
+
current = ''
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
current += (current ? ' ' : '') + word
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (current) lines.push(current)
|
|
32
|
+
return lines
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function terminalWidth(): number {
|
|
36
|
+
return process.stdout.columns ?? 100
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatCell(value: unknown): string {
|
|
40
|
+
if (value === null || value === undefined) return chalk.dim('—')
|
|
41
|
+
if (typeof value === 'object') {
|
|
42
|
+
const obj = value as Record<string, unknown>
|
|
43
|
+
if ('name' in obj && typeof obj.name === 'string') return obj.name
|
|
44
|
+
if ('views' in obj && 'likes' in obj) {
|
|
45
|
+
const v = obj.views ?? 0
|
|
46
|
+
const l = obj.likes ?? 0
|
|
47
|
+
return `${chalk.dim('👁')} ${v} ${chalk.dim('❤')} ${l}`
|
|
48
|
+
}
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value.map((item) => {
|
|
51
|
+
if (typeof item === 'object' && item && 'name' in item) return (item as { name: string }).name
|
|
52
|
+
return String(item)
|
|
53
|
+
}).join(', ') || chalk.dim('—')
|
|
54
|
+
}
|
|
55
|
+
return chalk.dim('…')
|
|
56
|
+
}
|
|
57
|
+
if (typeof value === 'boolean') return value ? chalk.green('✓') : chalk.dim('✗')
|
|
58
|
+
return String(value)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const FIXED = ['id', 'language', 'featured', 'stats']
|
|
62
|
+
|
|
63
|
+
export function formatTable<T extends Record<string, unknown>>(
|
|
64
|
+
items: T[],
|
|
65
|
+
columns: { key: keyof T; label: string }[],
|
|
66
|
+
): string {
|
|
67
|
+
if (items.length === 0) return chalk.dim('No results')
|
|
68
|
+
|
|
69
|
+
const colSizes = columns.map((col) => {
|
|
70
|
+
return Math.max(
|
|
71
|
+
col.label.length,
|
|
72
|
+
...items.map((item) => stripAnsi(formatCell(item[col.key])).length),
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const total = colSizes.reduce((a, b) => a + b + 3, 0) + 1
|
|
77
|
+
const terminal = terminalWidth()
|
|
78
|
+
|
|
79
|
+
if (total > terminal) {
|
|
80
|
+
const overflow = total - terminal
|
|
81
|
+
const contentIdx = columns.findIndex((c) => c.key === 'content' || c.key === 'name')
|
|
82
|
+
if (contentIdx !== -1) {
|
|
83
|
+
colSizes[contentIdx] = Math.max(24, colSizes[contentIdx]! - overflow)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const secondTotal = colSizes.reduce((a, b) => a + b + 3, 0) + 1
|
|
88
|
+
if (secondTotal > terminal) {
|
|
89
|
+
const overflow2 = secondTotal - terminal
|
|
90
|
+
const flexIdxs = columns
|
|
91
|
+
.map((c, i) => ({ key: c.key as string, i }))
|
|
92
|
+
.filter((c) => !FIXED.includes(c.key) && c.key !== 'content' && c.key !== 'name')
|
|
93
|
+
const flexTotal = flexIdxs.reduce((s, { i }) => s + colSizes[i]!, 0)
|
|
94
|
+
if (flexTotal > 0) {
|
|
95
|
+
for (const { i } of flexIdxs) {
|
|
96
|
+
colSizes[i] = Math.max(3, colSizes[i]! - Math.round(overflow2 * colSizes[i]! / flexTotal))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sep = (left: string, mid: string, right: string) =>
|
|
102
|
+
left + columns.map((col, i) => '─'.repeat(colSizes[i]! + 2) + (i < columns.length - 1 ? mid : '')).join('') + right
|
|
103
|
+
|
|
104
|
+
const cell = (vals: string[]) =>
|
|
105
|
+
'│' + vals.map((v, i) => ` ${padVisual(v, colSizes[i]!)} │`).join('')
|
|
106
|
+
|
|
107
|
+
const headerLine = cell(columns.map((col, i) => chalk.bold(padVisual(col.label, colSizes[i]!))))
|
|
108
|
+
|
|
109
|
+
const rowParts = items.map((item) => {
|
|
110
|
+
const cellLines = columns.map((col, i) => wordWrap(formatCell(item[col.key]), colSizes[i]!))
|
|
111
|
+
const maxLines = Math.max(...cellLines.map((l) => l.length), 1)
|
|
112
|
+
const lines: string[] = []
|
|
113
|
+
for (let line = 0; line < maxLines; line++) {
|
|
114
|
+
const vals = columns.map((_col, i) => cellLines[i]![line] ?? '')
|
|
115
|
+
lines.push(cell(vals))
|
|
116
|
+
}
|
|
117
|
+
return lines.join('\n')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const top = sep('┌', '┬', '┐')
|
|
121
|
+
const mid = sep('├', '┼', '┤')
|
|
122
|
+
const bot = sep('└', '┴', '┘')
|
|
123
|
+
|
|
124
|
+
return [top, headerLine, mid, ...rowParts, bot].join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function output(data: unknown, format: Format): void {
|
|
128
|
+
if (format === 'json') {
|
|
129
|
+
console.log(JSON.stringify(data, null, 2))
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (Array.isArray(data)) {
|
|
134
|
+
if (data.length === 0) {
|
|
135
|
+
console.log(chalk.dim('No results'))
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const allKeys = Object.keys(data[0] as Record<string, unknown>)
|
|
140
|
+
const keyOrder = ['id', 'content', 'name', 'language', 'type', 'author', 'reference']
|
|
141
|
+
const columns = keyOrder
|
|
142
|
+
.filter((k) => allKeys.includes(k))
|
|
143
|
+
.map((k) => ({ key: k, label: k }))
|
|
144
|
+
|
|
145
|
+
if (format === 'table') {
|
|
146
|
+
console.log(formatTable(data as Record<string, unknown>[], columns))
|
|
147
|
+
} else {
|
|
148
|
+
for (const item of data) {
|
|
149
|
+
console.log(formatItem(item as Record<string, unknown>))
|
|
150
|
+
console.log()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (typeof data === 'object' && data !== null) {
|
|
157
|
+
if (format === 'table') {
|
|
158
|
+
console.log(JSON.stringify(data, null, 2))
|
|
159
|
+
} else {
|
|
160
|
+
console.log(formatItem(data as Record<string, unknown>))
|
|
161
|
+
}
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(String(data))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatItem(item: Record<string, unknown>): string {
|
|
169
|
+
return Object.entries(item)
|
|
170
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
171
|
+
.map(([k, v]) => `${chalk.bold(k)}: ${formatCell(v)}`)
|
|
172
|
+
.join('\n')
|
|
173
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { spinner as clackSpinner } from '@clack/prompts'
|
|
2
|
+
import type { Format } from './format.js'
|
|
3
|
+
|
|
4
|
+
let silentMode = false
|
|
5
|
+
|
|
6
|
+
export function setSilent(silent: boolean) {
|
|
7
|
+
silentMode = silent
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isSilent(): boolean {
|
|
11
|
+
return silentMode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function withSpinner<T>(label: string, fn: () => Promise<T>, format?: Format): Promise<T> {
|
|
15
|
+
if (format === 'json' || silentMode) {
|
|
16
|
+
return fn()
|
|
17
|
+
}
|
|
18
|
+
const s = clackSpinner()
|
|
19
|
+
s.start(label)
|
|
20
|
+
try {
|
|
21
|
+
const result = await fn()
|
|
22
|
+
s.stop('Done')
|
|
23
|
+
return result
|
|
24
|
+
} catch (err) {
|
|
25
|
+
s.stop('Error')
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
}
|