@jinshuju/cli 0.1.1

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.
@@ -0,0 +1,1672 @@
1
+ import { CONTAINER_LIST_OPTIONS, CONTAINER_OPTIONS, FILTER_OPTION, FILTERS_OPTION, JSON_OPTION, LIMIT_OPTION, LOCAL_OPTIONS, MINE_OPTION, PAGINATION_OPTIONS, SORT_OPTION, TIME_BUCKETS, UsageError, parseDimension, parseFilter, parseMetric, parseSort, resolveContainer, resolveContainers } from './options.js';
2
+ import { CONFIG_KEYS } from './config.js';
3
+ import { readFileSync } from 'node:fs';
4
+ import { basename } from 'node:path';
5
+ import { validateCreateFormPayload } from './payload.js';
6
+ import { progress } from './progress.js';
7
+ /** Resource order in the root help, and the one-liner each gets. */
8
+ export const RESOURCES = [
9
+ { name: 'auth', summary: 'Manage authentication' },
10
+ { name: 'account', summary: 'Account and members' },
11
+ { name: 'folder', summary: 'Manage folders' },
12
+ { name: 'form', summary: 'Manage forms' },
13
+ { name: 'table', summary: 'Manage tables' },
14
+ { name: 'field', summary: 'Manage fields' },
15
+ { name: 'view', summary: 'Manage views' },
16
+ { name: 'entry', summary: 'Manage entries' },
17
+ { name: 'comment', summary: 'Manage entry comments' },
18
+ { name: 'opensearch', summary: 'Manage public queries' },
19
+ { name: 'config', summary: 'Manage CLI configuration' }
20
+ ];
21
+ // --- shared request building ----------------------------------------------
22
+ const API = '/api/v1';
23
+ /** The path segment a container lives under. */
24
+ function containerPath(input) {
25
+ const { token, kind } = resolveContainer(input.options);
26
+ return `${API}/${kind === 'table' ? 'tables' : 'forms'}/${token}`;
27
+ }
28
+ /** The conditions themselves. A read sends them as a query string, a write as a body. */
29
+ function filterConditions(input) {
30
+ const compact = input.options.filter ?? [];
31
+ const raw = input.options.filters;
32
+ if (raw !== undefined && compact.length > 0) {
33
+ throw new UsageError('--filter and --filters are alternatives, not both');
34
+ }
35
+ if (raw !== undefined)
36
+ return raw;
37
+ if (compact.length === 0)
38
+ return undefined;
39
+ const conditions = compact.map(parseFilter);
40
+ return conditions;
41
+ }
42
+ function filters(input) {
43
+ const conditions = filterConditions(input);
44
+ return conditions === undefined ? undefined : JSON.stringify(conditions);
45
+ }
46
+ function sort(input, key) {
47
+ const rules = input.options.sort ?? [];
48
+ if (rules.length === 0)
49
+ return undefined;
50
+ // The API names a sort key `api_code` for data and `field` for listings; the
51
+ // CLI shows one `--sort <field>:<order>` and translates here.
52
+ return JSON.stringify(rules.map(parseSort).map((rule) => ({ [key]: rule.field, order: rule.order })));
53
+ }
54
+ function paging(input) {
55
+ return {
56
+ limit: input.options.limit === undefined ? undefined : String(input.options.limit),
57
+ next: input.options.next
58
+ };
59
+ }
60
+ /** The sort rules as the API's own objects, for a body. */
61
+ function sortRules(input) {
62
+ const rules = input.options.sort ?? [];
63
+ if (rules.length === 0)
64
+ return undefined;
65
+ return rules.map(parseSort).map((rule) => ({ api_code: rule.field, order: rule.order }));
66
+ }
67
+ /**
68
+ * A delete says so out loud. Nothing here prompts — stdin belongs to `--json -`
69
+ * — so the confirmation is a flag, and leaving it out is the safe outcome
70
+ * rather than a question nobody is there to answer.
71
+ */
72
+ function confirmed(input, what) {
73
+ if (!input.options.yes)
74
+ throw new UsageError(`${what} is permanent; pass --yes to go ahead`);
75
+ }
76
+ /**
77
+ * Drops the keys an optional flag left undefined. JSON.stringify would drop
78
+ * them on the wire anyway, but a body that carries them reads as though the
79
+ * caller asked for the field to be unset, and shows up that way in --output
80
+ * json and in anything asserting on the request.
81
+ */
82
+ function given(body) {
83
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
84
+ }
85
+ /**
86
+ * A payload with the flags that were actually given laid over it.
87
+ *
88
+ * The obvious spelling — spread the payload, then assign each flag — writes
89
+ * `undefined` for every flag the caller left out, and that erases whatever the
90
+ * payload said. Someone asking for an exam form through --json got a plain one
91
+ * and no error. Only the flags that were given may override.
92
+ */
93
+ function overriding(payload, flags) {
94
+ return { ...payload, ...given(flags) };
95
+ }
96
+ /** A write that names one thing still sends the API a list of one. */
97
+ function one(value) {
98
+ return Array.isArray(value) ? value : [value];
99
+ }
100
+ /**
101
+ * The shape behind `--json` on a form, shown rather than described: `fields` is
102
+ * the part nothing else hints at, and one choice field carries more of the
103
+ * grammar than a paragraph would. What `type` may be, and what each type takes
104
+ * beyond this, is `field types`.
105
+ */
106
+ const FORM_PAYLOAD = [
107
+ '{',
108
+ ' "name": "Event signup",',
109
+ ' "description": "optional",',
110
+ ' "fields": [',
111
+ ' { "type": "TextField", "label": "Name", "required": true },',
112
+ ' { "type": "RadioButton", "label": "Ticket", "choices": [',
113
+ ' { "name": "Standard" }, { "name": "VIP" } ] }',
114
+ ' ]',
115
+ '}',
116
+ '',
117
+ 'A choice carries "name". The "value" it reads back with is the code the',
118
+ 'backend assigns, not what you sent.',
119
+ '',
120
+ 'Field types: jinshuju field types | A real one: jinshuju form get <token> --output json'
121
+ ];
122
+ /** One field, or a list of them — the same objects `fields` holds above. */
123
+ const FIELD_PAYLOAD = [
124
+ '{ "type": "TextField", "label": "Notes", "required": false }',
125
+ '',
126
+ 'Field types: jinshuju field types'
127
+ ];
128
+ const YES_OPTION = { name: '--yes', type: 'boolean', description: 'Confirm the deletion' };
129
+ const FOLDER_OPTION = {
130
+ name: '--folder', type: 'string', placeholder: '<token>', description: 'Folder token; empty moves it out of any folder'
131
+ };
132
+ /**
133
+ * `--mine` is a different range, not a narrower one: it reads what the caller
134
+ * submitted to forms they need not own. The flags that describe the owner-side
135
+ * question have no counterpart there, so naming one alongside --mine is
136
+ * refused rather than quietly dropped.
137
+ */
138
+ function refuseWithMine(input, flags) {
139
+ for (const flag of flags) {
140
+ const value = input.options[flag];
141
+ const given = Array.isArray(value) ? value.length > 0 : value !== undefined;
142
+ if (given)
143
+ throw new UsageError(`--${flag.replace(/_/g, '-')} cannot be combined with --mine`);
144
+ }
145
+ }
146
+ function list(value) {
147
+ const values = value;
148
+ return values && values.length > 0 ? values.join(',') : undefined;
149
+ }
150
+ /**
151
+ * Keywords stay separate: the API matches a name containing any of them, and
152
+ * joining them would ask for one keyword with a comma in it.
153
+ */
154
+ function keywords(value) {
155
+ const values = value;
156
+ return values && values.length > 0 ? values : undefined;
157
+ }
158
+ function labels(input) {
159
+ return input.options.labels ? 'true' : undefined;
160
+ }
161
+ const LABELS_OPTION = {
162
+ name: '--labels',
163
+ type: 'boolean',
164
+ description: "Pair each value with its field's label, saving a second read of the form"
165
+ };
166
+ const LISTING = { items: 'data', cursor: 'next' };
167
+ /** What the batch count endpoint accepts, and what the design document states. */
168
+ const MAX_COUNTED_CONTAINERS = 10;
169
+ /**
170
+ * A form's fields arrive as one object per field, keyed by api_code. A list of
171
+ * fields is what a caller asked for, so it is a list here, with the api_code
172
+ * alongside the rest rather than hidden in the key.
173
+ */
174
+ function selectFields(body) {
175
+ const fields = body?.fields ?? [];
176
+ return fields.flatMap((entry) => Object.entries(entry).map(([api_code, attributes]) => ({ api_code, ...attributes })));
177
+ }
178
+ /**
179
+ * `entry aggregate` answers a table taken apart: `columns` says what each
180
+ * column is, `rows` holds one positional array per row and no names at all.
181
+ * Nothing generic can put the two back together — a renderer given the rows
182
+ * alone sees arrays of mixed things and prints them as JSON — so they are
183
+ * zipped here, into the rows-of-objects every other listing already is.
184
+ *
185
+ * Only for reading. `--output json` keeps the positional shape: it is the one a
186
+ * script can index by position without knowing what the labels say, and the
187
+ * headings below are Chinese as often as not.
188
+ */
189
+ function aggregateTable(body) {
190
+ const { columns, rows, ...rest } = (body ?? {});
191
+ if (!Array.isArray(columns) || !Array.isArray(rows))
192
+ return body;
193
+ const headings = uniqueHeadings(columns.map(headingOf));
194
+ return {
195
+ ...rest,
196
+ rows: rows.map((row) => {
197
+ const cells = Array.isArray(row) ? row : [row];
198
+ return Object.fromEntries(headings.map((heading, index) => [heading, cellOf(cells[index])]));
199
+ })
200
+ };
201
+ }
202
+ /** `count(姓名)`, because `姓名` alone does not say what was done to it. */
203
+ function headingOf(column, index) {
204
+ const { label, field, func } = (column ?? {});
205
+ const name = label || field || `column_${index + 1}`;
206
+ return func ? `${func}(${name})` : name;
207
+ }
208
+ /** A dimension cell is the choice it grouped by; a metric cell is the number. */
209
+ function cellOf(cell) {
210
+ if (cell === null || typeof cell !== 'object' || Array.isArray(cell))
211
+ return cell;
212
+ const { label } = cell;
213
+ return label ?? JSON.stringify(cell);
214
+ }
215
+ /**
216
+ * Two columns heading the same object would leave one of them silently
217
+ * overwriting the other, so a repeat is numbered rather than lost.
218
+ */
219
+ function uniqueHeadings(headings) {
220
+ const seen = new Map();
221
+ return headings.map((heading) => {
222
+ const count = (seen.get(heading) ?? 0) + 1;
223
+ seen.set(heading, count);
224
+ return count === 1 ? heading : `${heading} (${count})`;
225
+ });
226
+ }
227
+ function payload(input) {
228
+ const value = input.options.json;
229
+ if (value === undefined)
230
+ throw new UsageError('--json <json|@file|-> is required');
231
+ return value;
232
+ }
233
+ // --- commands --------------------------------------------------------------
234
+ const ACCOUNT = [
235
+ {
236
+ path: ['account', 'get'],
237
+ summary: 'Show the current account, plan and quota',
238
+ request: () => ({ method: 'GET', path: `${API}/billing_account` })
239
+ },
240
+ {
241
+ path: ['account', 'member', 'list'],
242
+ summary: 'List account members',
243
+ options: [LIMIT_OPTION],
244
+ request: (input) => ({ method: 'GET', path: `${API}/billing_account/users`, query: paging(input) })
245
+ }
246
+ ];
247
+ const FOLDER = [
248
+ {
249
+ path: ['folder', 'list'],
250
+ summary: 'List folders',
251
+ options: [LIMIT_OPTION],
252
+ request: (input) => ({ method: 'GET', path: `${API}/folders`, query: paging(input) })
253
+ },
254
+ {
255
+ path: ['folder', 'create'],
256
+ summary: 'Create a folder',
257
+ description: 'A folder holds one kind. A table refuses a form folder, so say which when it is not forms.',
258
+ args: [{ name: 'name', required: true, description: 'Folder name' }],
259
+ options: [
260
+ { name: '--kind', type: 'string', choices: ['form', 'table'], placeholder: '<kind>', description: 'What the folder holds (default form)' }
261
+ ],
262
+ request: (input) => ({
263
+ method: 'POST',
264
+ path: `${API}/folders`,
265
+ body: { name: input.args.name, kind: input.options.kind }
266
+ }),
267
+ examples: ['jinshuju folder create 2026年活动', 'jinshuju folder create 台账 --kind table']
268
+ }
269
+ ];
270
+ /**
271
+ * What `form get --include` accepts, and the parameter each one asks for. The
272
+ * names are the CLI's — short, and about the thing rather than about the flag
273
+ * that fetches it.
274
+ */
275
+ const FORM_INCLUDES = {
276
+ // `setting` is in the design and already part of the payload, so asking for
277
+ // it is honoured by there being nothing to fetch. It is listed so the help
278
+ // matches what the flag accepts.
279
+ setting: '',
280
+ theme: 'include_theme',
281
+ rules: 'include_field_rules',
282
+ extended: 'include_extended_attributes',
283
+ transactions: 'include_transactions',
284
+ analytics: 'include_analytics'
285
+ };
286
+ const INCLUDE_OPTION = {
287
+ name: '--include',
288
+ type: 'list',
289
+ placeholder: Object.keys(FORM_INCLUDES).join(','),
290
+ description: `Extra blocks to carry: ${Object.keys(FORM_INCLUDES).join(', ')}. The setting is always there`
291
+ };
292
+ function includes(input) {
293
+ const asked = input.options.include ?? [];
294
+ const query = {};
295
+ for (const name of asked) {
296
+ const parameter = FORM_INCLUDES[name];
297
+ if (parameter === '')
298
+ continue;
299
+ if (!parameter) {
300
+ throw new UsageError(`--include takes ${Object.keys(FORM_INCLUDES).join(', ')}, got ${JSON.stringify(name)}`);
301
+ }
302
+ query[parameter] = 'true';
303
+ }
304
+ return query;
305
+ }
306
+ function themeBody(input) {
307
+ const rest = input.options.json ?? {};
308
+ return overriding(rest, {
309
+ primary_color: input.options.primary_color,
310
+ secondary_color: input.options.secondary_color
311
+ });
312
+ }
313
+ async function uploadImage(client, file, imageType) {
314
+ const watching = progress();
315
+ watching.step(`uploading ${basename(file)}…`);
316
+ try {
317
+ const uploaded = await client.request(upload(`${API}/form_image_attachments`, file, { image_type: imageType }));
318
+ return uploaded.attachment_id;
319
+ }
320
+ finally {
321
+ watching.done();
322
+ }
323
+ }
324
+ /** The scenes a form can be created for, as the API names them. */
325
+ const FORM_SCENES = ['survey', 'registry', 'vote', 'exam', 'reservation',
326
+ 'customer_acquisition', 'evaluation', 'online_payment'];
327
+ /**
328
+ * The field types a table column may be, which is a third of what a form takes.
329
+ * Worth naming in the help rather than leaving to a rejected create: the absence
330
+ * of TextField is the one nobody guesses.
331
+ */
332
+ const TABLE_FIELD_TYPES = ['TextArea', 'RadioButton', 'CheckBox', 'BooleanField', 'MobileField',
333
+ 'NumberField', 'DateTimeField', 'EmailField', 'LinkField', 'AttachmentField', 'FormulaField'];
334
+ /**
335
+ * A form's type picks both the scene it is created in and the settings block
336
+ * that belongs to it. Those settings live behind their own endpoint, so a
337
+ * payload carrying one is two requests, not one — and a generic edit would
338
+ * drop the block on the floor, since the form update only reads name,
339
+ * description, setting, fields and field_rules.
340
+ */
341
+ const FORM_TYPES = {
342
+ normal: {},
343
+ exam: { scene: 'exam', settingKey: 'exam_setting', path: 'exam_setting' },
344
+ evaluation: { scene: 'evaluation', settingKey: 'evaluation_setting', path: 'evaluation_setting' }
345
+ };
346
+ /** Which settings block a payload carries, and where it has to be sent. */
347
+ function settingsBlock(body) {
348
+ for (const { settingKey, path } of Object.values(FORM_TYPES)) {
349
+ if (settingKey && path && body[settingKey] !== undefined) {
350
+ return { key: settingKey, path, value: body[settingKey] };
351
+ }
352
+ }
353
+ return undefined;
354
+ }
355
+ /** The scene a --type implies, refusing a --scene that contradicts it. */
356
+ function sceneFor(input) {
357
+ const type = input.options.type ?? 'normal';
358
+ const scene = input.options.scene;
359
+ const implied = FORM_TYPES[type]?.scene;
360
+ if (implied && scene && scene !== implied) {
361
+ throw new UsageError(`--type ${type} is the ${implied} scene, so --scene ${scene} contradicts it`);
362
+ }
363
+ return implied ?? scene;
364
+ }
365
+ function createFormBody(input) {
366
+ const payloadBody = { ...validateCreateFormPayload(payload(input)) };
367
+ const settings = settingsBlock(payloadBody);
368
+ if (settings)
369
+ delete payloadBody[settings.key];
370
+ return {
371
+ body: overriding(payloadBody, {
372
+ scene: sceneFor(input),
373
+ layout: input.options.layout,
374
+ folder_token: input.options.folder
375
+ }),
376
+ settings
377
+ };
378
+ }
379
+ const FORM = [
380
+ {
381
+ path: ['form', 'list'],
382
+ summary: 'List forms',
383
+ description: 'Filters act on the form itself: form_name, created_at, last_entry_created_at, entries_count.',
384
+ options: [
385
+ { name: '--name', type: 'string', repeatable: true, placeholder: '<kw>', description: 'Match forms whose name contains the keyword, repeatable' },
386
+ { name: '--with-transactions', type: 'boolean', description: "Carry each payment form's collected totals" },
387
+ MINE_OPTION,
388
+ FILTER_OPTION, FILTERS_OPTION, SORT_OPTION, ...PAGINATION_OPTIONS
389
+ ],
390
+ request: (input) => {
391
+ if (input.options.mine) {
392
+ refuseWithMine(input, ['name', 'with_transactions', 'filter', 'filters', 'sort']);
393
+ return { method: 'GET', path: `${API}/my/forms`, query: paging(input) };
394
+ }
395
+ return {
396
+ method: 'GET',
397
+ path: `${API}/forms`,
398
+ query: {
399
+ q: keywords(input.options.name),
400
+ include_transactions: input.options.with_transactions ? 'true' : undefined,
401
+ filters: filters(input),
402
+ sort: sort(input, 'field'),
403
+ ...paging(input)
404
+ }
405
+ };
406
+ },
407
+ paginate: LISTING,
408
+ examples: [
409
+ "jinshuju form list --name 报名",
410
+ 'jinshuju form list --sort entries_count:desc --limit 10',
411
+ 'jinshuju form list --mine'
412
+ ]
413
+ },
414
+ {
415
+ path: ['form', 'get'],
416
+ summary: 'Show a form: fields, types, choices',
417
+ description: 'The form carries its setting already. --include adds the blocks that are separate reads ' +
418
+ 'otherwise, so asking for a form and its rules is one round trip. analytics says which ' +
419
+ 'statistics each field takes, which is what the analysis reads validate against. `fields` ' +
420
+ 'comes back as a list of one-key objects keyed by api_code, not a flat list — `field list` ' +
421
+ 'answers the same fields flattened, with api_code on each.',
422
+ args: [{ name: 'form', required: true, description: 'Form token, six letters and digits, e.g. Kp7mQ2' }],
423
+ options: [INCLUDE_OPTION],
424
+ request: (input) => ({
425
+ method: 'GET',
426
+ path: `${API}/forms/${input.args.form}`,
427
+ query: includes(input)
428
+ }),
429
+ examples: ['jinshuju form get Kp7mQ2', 'jinshuju form get Kp7mQ2 --include theme,rules,analytics']
430
+ },
431
+ {
432
+ path: ['form', 'create'],
433
+ summary: 'Create a form',
434
+ description: 'Field types use the API v1 names. Run `jinshuju field types` for the full list and what each ' +
435
+ 'one accepts. Do not pass api_code: the backend generates it. The scene decides what kind of ' +
436
+ 'form it is — an exam scores its answers, a reservation holds slots — and the card layout ' +
437
+ 'refuses the field types it cannot show.',
438
+ payload: FORM_PAYLOAD,
439
+ options: [
440
+ JSON_OPTION,
441
+ { name: '--type', type: 'string', choices: Object.keys(FORM_TYPES), placeholder: '<type>', description: 'normal, exam or evaluation. An exam or evaluation also takes its own settings block in the payload' },
442
+ { name: '--scene', type: 'string', choices: FORM_SCENES, placeholder: '<scene>', description: `What the form is for: ${FORM_SCENES.join(', ')}` },
443
+ { name: '--layout', type: 'string', choices: ['classic', 'card'], placeholder: '<layout>', description: 'classic shows every field at once, card one page at a time' },
444
+ FOLDER_OPTION
445
+ ],
446
+ request: (input) => ({
447
+ method: 'POST',
448
+ path: `${API}/forms`,
449
+ body: createFormBody(input).body
450
+ }),
451
+ run: async (input, client) => {
452
+ const { body, settings } = createFormBody(input);
453
+ if (!settings)
454
+ return undefined;
455
+ const form = await client.request({ method: 'POST', path: `${API}/forms`, body });
456
+ try {
457
+ await client.request({ method: 'PATCH', path: `${API}/forms/${form.token}/${settings.path}`, body: settings.value });
458
+ }
459
+ catch (error) {
460
+ // The form exists; saying so beats an error that reads as though
461
+ // nothing happened and inviting a second one to be created.
462
+ throw new Error(`form ${form.token} was created, but its ${settings.key} was refused: ` +
463
+ `${error.message}. Fix it and apply with \`form edit\`.`);
464
+ }
465
+ return client.request({ method: 'GET', path: `${API}/forms/${form.token}` });
466
+ },
467
+ examples: [
468
+ 'jinshuju form create --json @form.json',
469
+ 'jinshuju form create --json @exam.json --type exam',
470
+ 'cat form.json | jinshuju form create --json -'
471
+ ]
472
+ },
473
+ {
474
+ path: ['form', 'edit'],
475
+ summary: 'Edit a form',
476
+ description: 'The payload carries the operations to apply: name, description, setting, and fields as ' +
477
+ '{add, update, update_choices, remove}. Only what is named changes.',
478
+ args: [{ name: 'form', required: true, description: 'Form token' }],
479
+ options: [JSON_OPTION],
480
+ request: (input) => ({ method: 'PATCH', path: `${API}/forms/${input.args.form}`, body: payload(input) }),
481
+ run: async (input, client) => {
482
+ const body = { ...payload(input) };
483
+ const settings = settingsBlock(body);
484
+ if (!settings)
485
+ return undefined;
486
+ delete body[settings.key];
487
+ // The settings go first: they are the half that can be refused for what
488
+ // the form is, so leading with them is what keeps a refusal from landing
489
+ // after the rest was already written.
490
+ await client.request({ method: 'PATCH', path: `${API}/forms/${input.args.form}/${settings.path}`, body: settings.value });
491
+ if (Object.keys(body).length === 0)
492
+ return client.request({ method: 'GET', path: `${API}/forms/${input.args.form}` });
493
+ try {
494
+ return await client.request({ method: 'PATCH', path: `${API}/forms/${input.args.form}`, body });
495
+ }
496
+ catch (error) {
497
+ // Ordering cannot make two requests atomic; it only chooses which half
498
+ // fails first. When the second one fails the first has landed, and an
499
+ // error that reads as though nothing happened would invite the whole
500
+ // edit to be sent again.
501
+ throw new Error(`${settings.key} was saved, but the rest of the edit (${Object.keys(body).join(', ')}) ` +
502
+ `was refused: ${error.message}. Re-send only what failed.`);
503
+ }
504
+ },
505
+ examples: [
506
+ 'jinshuju form edit Kp7mQ2 --json \'{"name":"2026 活动报名"}\'',
507
+ 'jinshuju form edit Kp7mQ2 --json \'{"exam_setting":{"total_score":100}}\''
508
+ ]
509
+ },
510
+ {
511
+ path: ['form', 'copy'],
512
+ summary: 'Copy a form',
513
+ args: [{ name: 'form', required: true, description: 'Form token to copy' }],
514
+ options: [
515
+ { name: '--name', type: 'string', placeholder: '<name>', description: 'Name for the copy' },
516
+ FOLDER_OPTION
517
+ ],
518
+ request: (input) => ({
519
+ method: 'POST',
520
+ path: `${API}/forms/${input.args.form}/copy`,
521
+ body: given({ name: input.options.name, folder_token: input.options.folder })
522
+ })
523
+ },
524
+ {
525
+ path: ['form', 'move'],
526
+ summary: 'Move a form into a folder, or out of one',
527
+ args: [{ name: 'form', required: true, description: 'Form token' }],
528
+ options: [FOLDER_OPTION],
529
+ request: (input) => ({
530
+ method: 'PATCH',
531
+ path: `${API}/forms/${input.args.form}/folder`,
532
+ body: { folder_token: input.options.folder ?? '' }
533
+ }),
534
+ examples: ['jinshuju form move Kp7mQ2 --folder Fd2xK8', 'jinshuju form move Kp7mQ2']
535
+ },
536
+ {
537
+ path: ['form', 'theme', 'set'],
538
+ summary: "Set a form's theme",
539
+ description: 'The colours have flags of their own, and --wallpaper and --header each upload an image and ' +
540
+ 'bind it to the theme in one command. Everything else the theme takes — typography, ' +
541
+ 'form_container, submit_button — goes through --json.',
542
+ args: [{ name: 'form', required: true, description: 'Form token' }],
543
+ options: [
544
+ { name: '--primary-color', type: 'string', placeholder: '<hex>', description: 'Primary colour, e.g. #1F6FEB' },
545
+ { name: '--secondary-color', type: 'string', placeholder: '<hex>', description: 'Secondary colour' },
546
+ { name: '--wallpaper', type: 'string', placeholder: '<file>', description: 'Image file to use as the background' },
547
+ { name: '--header', type: 'string', placeholder: '<file>', description: 'Image file to use as the header' },
548
+ JSON_OPTION
549
+ ],
550
+ request: (input) => ({ method: 'PATCH', path: `${API}/forms/${input.args.form}/theme`, body: themeBody(input) }),
551
+ run: async (input, client) => {
552
+ const wallpaper = input.options.wallpaper;
553
+ const header = input.options.header;
554
+ if (!wallpaper && !header)
555
+ return undefined;
556
+ const body = themeBody(input);
557
+ if (wallpaper) {
558
+ const image = await uploadImage(client, wallpaper, 'wallpaper');
559
+ body.wallpaper = { ...(body.wallpaper ?? {}), background_image_attachment_id: image };
560
+ }
561
+ if (header) {
562
+ const image = await uploadImage(client, header, 'header');
563
+ body.header = { ...(body.header ?? {}), header_image_attachment_id: image };
564
+ }
565
+ return client.request({ method: 'PATCH', path: `${API}/forms/${input.args.form}/theme`, body });
566
+ },
567
+ examples: [
568
+ 'jinshuju form theme set Kp7mQ2 --primary-color "#1F6FEB"',
569
+ 'jinshuju form theme set Kp7mQ2 --wallpaper ./bg.png'
570
+ ]
571
+ },
572
+ {
573
+ path: ['form', 'rule', 'get'],
574
+ summary: 'Show the field display rules of a form',
575
+ args: [{ name: 'form', required: true, description: 'Form token' }],
576
+ request: (input) => ({ method: 'GET', path: `${API}/forms/${input.args.form}/field_rules` })
577
+ },
578
+ {
579
+ path: ['form', 'rule', 'edit'],
580
+ summary: 'Edit the field display rules of a form',
581
+ description: 'The payload is {add, update, remove}; a rule is targeted by the index `form rule get` shows.',
582
+ args: [{ name: 'form', required: true, description: 'Form token' }],
583
+ options: [JSON_OPTION],
584
+ request: (input) => ({
585
+ method: 'PATCH',
586
+ path: `${API}/forms/${input.args.form}`,
587
+ body: { field_rules: payload(input) }
588
+ })
589
+ },
590
+ {
591
+ path: ['form', 'cooperator', 'list'],
592
+ summary: 'List the cooperators of a form',
593
+ args: [{ name: 'form', required: true, description: 'Form token' }],
594
+ request: (input) => ({ method: 'GET', path: `${API}/forms/${input.args.form}/cooperators` })
595
+ }
596
+ ];
597
+ const TABLE = [
598
+ {
599
+ path: ['table', 'list'],
600
+ summary: 'List tables',
601
+ options: [
602
+ { name: '--name', type: 'string', repeatable: true, placeholder: '<kw>', description: 'Match tables whose name contains the keyword' },
603
+ FILTER_OPTION, FILTERS_OPTION, SORT_OPTION, ...PAGINATION_OPTIONS
604
+ ],
605
+ request: (input) => ({
606
+ method: 'GET',
607
+ path: `${API}/tables`,
608
+ query: { q: keywords(input.options.name), filters: filters(input), sort: sort(input, 'field'), ...paging(input) }
609
+ }),
610
+ paginate: LISTING,
611
+ examples: ['jinshuju table list --name 台账', 'jinshuju entry list --table Vn4xR8']
612
+ },
613
+ {
614
+ path: ['table', 'get'],
615
+ summary: 'Show a table: columns, types, choices',
616
+ args: [{ name: 'table', required: true, description: 'Table token, six letters and digits, e.g. Vn4xR8' }],
617
+ request: (input) => ({ method: 'GET', path: `${API}/tables/${input.args.table}` }),
618
+ examples: ['jinshuju table get Vn4xR8']
619
+ },
620
+ {
621
+ path: ['table', 'create'],
622
+ summary: 'Create a table',
623
+ description: 'Column types use the API v1 names, but a table takes fewer of them than a form: ' +
624
+ `${TABLE_FIELD_TYPES.join(', ')}. There is no TextField — a single line of text is a ` +
625
+ 'TextArea here. Do not pass api_code: the backend generates it.',
626
+ options: [
627
+ JSON_OPTION, FOLDER_OPTION,
628
+ { name: '--with-default-entries', type: 'boolean', description: 'Seed a few blank rows, as the UI does. Leave it off when rows follow' }
629
+ ],
630
+ request: (input) => ({
631
+ method: 'POST',
632
+ path: `${API}/tables`,
633
+ body: overriding(payload(input), {
634
+ folder_token: input.options.folder,
635
+ with_default_entries: input.options.with_default_entries ? true : undefined
636
+ })
637
+ }),
638
+ examples: ['jinshuju table create --json @table.json']
639
+ },
640
+ {
641
+ path: ['table', 'move'],
642
+ summary: 'Move a table into a folder, or out of one',
643
+ description: 'The folder must be a table folder; a form folder cannot hold a table.',
644
+ args: [{ name: 'table', required: true, description: 'Table token' }],
645
+ options: [FOLDER_OPTION],
646
+ request: (input) => ({
647
+ method: 'PATCH',
648
+ path: `${API}/tables/${input.args.table}/folder`,
649
+ body: { folder_token: input.options.folder ?? '' }
650
+ }),
651
+ examples: ['jinshuju table move Vn4xR8 --folder Nf7mDC', 'jinshuju table move Vn4xR8']
652
+ },
653
+ {
654
+ path: ['table', 'edit'],
655
+ summary: 'Edit a table',
656
+ description: 'The payload carries the operations to apply: name, description, setting, and columns as ' +
657
+ 'fields: {add, update, update_choices, remove}.',
658
+ args: [{ name: 'table', required: true, description: 'Table token' }],
659
+ options: [JSON_OPTION],
660
+ request: (input) => ({ method: 'PATCH', path: `${API}/tables/${input.args.table}`, body: payload(input) })
661
+ }
662
+ ];
663
+ /** `field_7:choice_1` into the target the check endpoint reads. */
664
+ function parseCheckTarget(target) {
665
+ const [field_api_code, choice_value] = target.split(':');
666
+ if (!field_api_code)
667
+ throw new UsageError(`a check target must be '<api-code>[:<choice>]', got ${JSON.stringify(target)}`);
668
+ return choice_value === undefined ? { field_api_code } : { field_api_code, choice_value };
669
+ }
670
+ /**
671
+ * The targets a batch check asks about: the plain ones as arguments, and the
672
+ * shapes the argument grammar cannot reach through --json. Both at once is
673
+ * allowed — one edit's targets belong in one call, whatever shape each is.
674
+ */
675
+ function checks(input, parse) {
676
+ const fromArgs = input.rest.map(parse);
677
+ const fromJson = input.options.json === undefined ? [] : one(input.options.json);
678
+ const all = [...fromArgs, ...fromJson];
679
+ if (all.length === 0)
680
+ throw new UsageError('name at least one target, as an argument or through --json');
681
+ return all;
682
+ }
683
+ function requiredOption(input, key) {
684
+ const value = input.options[key];
685
+ if (!value)
686
+ throw new UsageError(`--${key.replace(/_/g, '-')} is required`);
687
+ return value;
688
+ }
689
+ const KIND_OPTION = {
690
+ name: '--kind',
691
+ type: 'string',
692
+ choices: ['form', 'table'],
693
+ placeholder: '<kind>',
694
+ description: 'Which container the types are for (default form)'
695
+ };
696
+ const FIELD = [
697
+ {
698
+ path: ['field', 'types'],
699
+ summary: 'List the field types a form or table can hold',
700
+ description: 'What to put in `type` when adding a field, and what each type accepts. `takes_choices` says whether the field carries choices; `flags` are the booleans the payload may set on it; `settings` are the keys that type understands beyond the common ones. A table holds far fewer types than a form.',
701
+ args: [{ name: 'type', required: false, description: 'One type name, e.g. RadioButton' }],
702
+ options: [KIND_OPTION],
703
+ request: (input) => ({
704
+ method: 'GET',
705
+ path: input.args.type ? `${API}/field_types/${input.args.type}` : `${API}/field_types`,
706
+ query: { kind: input.options.kind }
707
+ }),
708
+ select: (body) => (Array.isArray(body.data) ? body : { data: [body] }),
709
+ examples: [
710
+ 'jinshuju field types',
711
+ 'jinshuju field types --kind table',
712
+ 'jinshuju field types RadioButton'
713
+ ]
714
+ },
715
+ {
716
+ path: ['field', 'list'],
717
+ summary: 'List the fields of a form or table',
718
+ description: 'Read out of the object structure, the same fields `form get` and `table get` return.',
719
+ options: [...CONTAINER_OPTIONS],
720
+ request: (input) => ({ method: 'GET', path: containerPath(input) }),
721
+ select: (body) => ({ data: selectFields(body) })
722
+ },
723
+ {
724
+ path: ['field', 'add'],
725
+ summary: 'Add fields to a form or table',
726
+ description: 'One field object, or a list of them. Do not pass api_code: the backend generates it.',
727
+ options: [...CONTAINER_OPTIONS, JSON_OPTION],
728
+ payload: FIELD_PAYLOAD,
729
+ request: (input) => ({
730
+ method: 'PATCH',
731
+ path: containerPath(input),
732
+ body: { fields: { add: one(payload(input)) } }
733
+ }),
734
+ examples: ['jinshuju field add --form Kp7mQ2 --json \'{"type":"TextField","label":"备注"}\'']
735
+ },
736
+ {
737
+ path: ['field', 'update'],
738
+ summary: 'Update one field',
739
+ description: 'The patch is merged onto the field; the api_code comes from the argument, not the payload.',
740
+ args: [{ name: 'api-code', required: true, description: 'Field api_code, e.g. field_3' }],
741
+ options: [...CONTAINER_OPTIONS, JSON_OPTION],
742
+ request: (input) => ({
743
+ method: 'PATCH',
744
+ path: containerPath(input),
745
+ body: { fields: { update: [{ ...payload(input), api_code: input.args['api-code'] }] } }
746
+ }),
747
+ examples: ['jinshuju field update --form Kp7mQ2 field_3 --json \'{"required":true}\'']
748
+ },
749
+ {
750
+ path: ['field', 'update-choices'],
751
+ summary: "Change a field's choices",
752
+ description: 'The payload is the choice operations the field takes, e.g. {add, update, remove}.',
753
+ args: [{ name: 'api-code', required: true, description: 'Field api_code' }],
754
+ options: [...CONTAINER_OPTIONS, JSON_OPTION],
755
+ request: (input) => ({
756
+ method: 'PATCH',
757
+ path: containerPath(input),
758
+ body: {
759
+ fields: {
760
+ update_choices: [{ ...payload(input), field_api_code: input.args['api-code'] }]
761
+ }
762
+ }
763
+ })
764
+ },
765
+ {
766
+ path: ['field', 'check'],
767
+ summary: 'Ask whether fields or choices already hold data',
768
+ description: 'The question to ask before removing one: removing a field or choice that still holds ' +
769
+ 'entries deletes those entries with it. Name a choice with <api-code>:<choice>. A shape ' +
770
+ 'this cannot express — a matrix statement, a cascade level — goes through --json.',
771
+ args: [{ name: 'target', required: false, variadic: true, description: 'field api_code, or api_code:choice' }],
772
+ options: [...CONTAINER_OPTIONS, JSON_OPTION],
773
+ request: (input) => ({
774
+ method: 'GET',
775
+ path: `${containerPath(input)}/fields/check`,
776
+ query: { checks: JSON.stringify(checks(input, parseCheckTarget)) }
777
+ }),
778
+ examples: [
779
+ 'jinshuju field check --form Kp7mQ2 field_3 field_9',
780
+ 'jinshuju field check --form Kp7mQ2 field_7:choice_1'
781
+ ]
782
+ },
783
+ {
784
+ path: ['field', 'preview-convert'],
785
+ summary: 'Preview what changing a field\'s type would do to its data',
786
+ description: 'The conversion happens in place, so the only thing at stake is the data: this reports how ' +
787
+ 'many values are kept and how many are cleared. supported=false means the edit would refuse it.',
788
+ args: [{ name: 'api-code', required: true, description: 'Field api_code' }],
789
+ options: [
790
+ ...CONTAINER_OPTIONS,
791
+ { name: '--to', type: 'string', placeholder: '<type>', description: 'Target field type, e.g. RadioButton' },
792
+ { name: '--precision', type: 'string', placeholder: '<precision>', description: 'For a DateTimeField target, the precision the edit will use' },
793
+ JSON_OPTION
794
+ ],
795
+ request: (input) => {
796
+ const inline = input.options.json === undefined
797
+ ? [{
798
+ field_api_code: input.args['api-code'],
799
+ target_type: requiredOption(input, 'to'),
800
+ target_precision: input.options.precision
801
+ }]
802
+ : one(input.options.json);
803
+ return {
804
+ method: 'GET',
805
+ path: `${containerPath(input)}/fields/preview_convert`,
806
+ query: { checks: JSON.stringify(inline) }
807
+ };
808
+ },
809
+ examples: ['jinshuju field preview-convert --form Kp7mQ2 field_1 --to RadioButton']
810
+ },
811
+ {
812
+ path: ['field', 'remove'],
813
+ summary: 'Remove a field',
814
+ description: 'Removing a field that still holds answers deletes those answers with it, and cannot be undone.',
815
+ args: [{ name: 'api-code', required: true, description: 'Field api_code' }],
816
+ options: [...CONTAINER_OPTIONS, YES_OPTION],
817
+ request: (input) => {
818
+ confirmed(input, `Removing ${input.args['api-code']} and any answers it holds`);
819
+ return { method: 'PATCH', path: containerPath(input), body: { fields: { remove: [input.args['api-code']] } } };
820
+ },
821
+ examples: ['jinshuju field remove --form Kp7mQ2 field_9 --yes']
822
+ }
823
+ ];
824
+ /** What `view create` and `view edit` both take, beyond the name. */
825
+ const VIEW_OPTIONS = [
826
+ { name: '--type', type: 'string', choices: ['grid', 'kanban', 'stats'], placeholder: '<type>', description: 'View type' },
827
+ { name: '--columns', type: 'list', placeholder: '<api-code,...>', description: 'Columns to show, in this order' },
828
+ FILTER_OPTION, FILTERS_OPTION, SORT_OPTION, JSON_OPTION
829
+ ];
830
+ function viewBody(input) {
831
+ const rest = input.options.json ?? {};
832
+ return overriding(rest, {
833
+ view_type: input.options.type,
834
+ prefer_columns: input.options.columns,
835
+ sort: sortRules(input),
836
+ filter: filterConditions(input)
837
+ });
838
+ }
839
+ const VIEW = [
840
+ {
841
+ path: ['view', 'list'],
842
+ summary: 'List the views of a form or table',
843
+ options: [...CONTAINER_OPTIONS],
844
+ request: (input) => ({ method: 'GET', path: `${containerPath(input)}/views` })
845
+ },
846
+ {
847
+ path: ['view', 'get'],
848
+ summary: 'Show a view',
849
+ args: [{ name: 'view', required: true, description: 'View token, six letters and digits, e.g. aB3dE9' }],
850
+ options: [...CONTAINER_OPTIONS],
851
+ request: (input) => ({ method: 'GET', path: `${containerPath(input)}/views/${input.args.view}` })
852
+ },
853
+ {
854
+ path: ['view', 'create'],
855
+ summary: 'Create a view',
856
+ description: 'A view carries its own filter, sort and columns, so `entry list --view` needs none of them. ' +
857
+ 'Anything without a flag of its own — kanban grouping, visibility — goes through --json.',
858
+ args: [{ name: 'name', required: true, description: 'View name' }],
859
+ options: [...CONTAINER_OPTIONS, ...VIEW_OPTIONS],
860
+ request: (input) => ({
861
+ method: 'POST',
862
+ path: `${containerPath(input)}/views`,
863
+ body: { ...viewBody(input), name: input.args.name }
864
+ }),
865
+ examples: ["jinshuju view create --form Kp7mQ2 高分 --filter 'field_3 gte 80' --sort created_at:desc"]
866
+ },
867
+ {
868
+ path: ['view', 'edit'],
869
+ summary: 'Edit a view',
870
+ description: 'Only what is named changes; --name renames it.',
871
+ args: [{ name: 'view', required: true, description: 'View token' }],
872
+ options: [
873
+ ...CONTAINER_OPTIONS,
874
+ { name: '--name', type: 'string', placeholder: '<name>', description: 'Rename the view' },
875
+ ...VIEW_OPTIONS
876
+ ],
877
+ request: (input) => ({
878
+ method: 'PATCH',
879
+ path: `${containerPath(input)}/views/${input.args.view}`,
880
+ body: overriding(viewBody(input), { name: input.options.name })
881
+ })
882
+ },
883
+ {
884
+ path: ['view', 'delete'],
885
+ summary: 'Delete a view',
886
+ args: [{ name: 'view', required: true, description: 'View token' }],
887
+ options: [...CONTAINER_OPTIONS, YES_OPTION],
888
+ request: (input) => {
889
+ confirmed(input, `Deleting view ${input.args.view}`);
890
+ return { method: 'DELETE', path: `${containerPath(input)}/views/${input.args.view}` };
891
+ }
892
+ }
893
+ ];
894
+ // --- uploads ----------------------------------------------------------------
895
+ /**
896
+ * A file on disk, as multipart. The three endpoints that take one authenticate
897
+ * like every other request, so there is no ticket to fetch first: the file goes
898
+ * up in one call and comes back with an id to refer to it by.
899
+ */
900
+ function upload(path, file, extra = {}) {
901
+ const form = new FormData();
902
+ let bytes;
903
+ try {
904
+ bytes = readFileSync(file);
905
+ }
906
+ catch (error) {
907
+ throw new UsageError(`could not read ${file}: ${error.message}`);
908
+ }
909
+ form.append('file', new Blob([new Uint8Array(bytes)]), basename(file));
910
+ for (const [name, value] of Object.entries(extra))
911
+ form.append(name, value);
912
+ return { method: 'POST', path, form };
913
+ }
914
+ /** `field_3=名称` or `field_3=2`: a label if it is not a column number. */
915
+ function parseColumnMapping(input) {
916
+ const at = input.indexOf('=');
917
+ if (at <= 0)
918
+ throw new UsageError(`--map must be '<api-code>=<column>', got ${JSON.stringify(input)}`);
919
+ const field_api_code = input.slice(0, at);
920
+ const column = input.slice(at + 1);
921
+ if (!column)
922
+ throw new UsageError(`--map needs a column after '=', got ${JSON.stringify(input)}`);
923
+ return /^\d+$/.test(column)
924
+ ? { field_api_code, sheet_column_index: Number.parseInt(column, 10) }
925
+ : { field_api_code, column_label: column };
926
+ }
927
+ const ATTACH_SHAPE = "--attach must be '<api-code>=<file>', or '<api-code>.<row>.<sub>=<file>' for a subtable column";
928
+ /**
929
+ * `field_5=/path/a.png`, or `field_5.0.field_2=/path/a.png` for one row of a
930
+ * subtable column.
931
+ *
932
+ * A subtable answer is a list of rows, so the file belongs in one of them and
933
+ * the row has to be named. Without a row it is the first, which is what a
934
+ * subtable filled in one go almost always has.
935
+ *
936
+ * The row is a dot and not a bracket because zsh reads `field_5[0]` as a
937
+ * pattern and refuses the command before the CLI sees it — a syntax that needs
938
+ * quoting to be typed at all is the wrong one to hand somebody. `[0]` is still
939
+ * accepted, for anyone who quotes it or arrives from another tool.
940
+ */
941
+ function parseAttachment(input) {
942
+ const at = input.indexOf('=');
943
+ if (at <= 0)
944
+ throw new UsageError(`${ATTACH_SHAPE}, got ${JSON.stringify(input)}`);
945
+ const file = input.slice(at + 1);
946
+ const target = /^([A-Za-z0-9_]+)(?:\[(\d+)\]|\.(\d+))?(?:\.([A-Za-z0-9_]+))?$/.exec(input.slice(0, at));
947
+ if (!target || !file)
948
+ throw new UsageError(`${ATTACH_SHAPE}, got ${JSON.stringify(input)}`);
949
+ const [, field, bracketed, dotted, dimension] = target;
950
+ const row = bracketed ?? dotted;
951
+ if (row !== undefined && dimension === undefined) {
952
+ throw new UsageError(`a row needs the subtable column it is a row of: ${field}.${row}.<sub>=<file>`);
953
+ }
954
+ return { field, row: row === undefined ? 0 : Number(row), dimension, file };
955
+ }
956
+ /** The states an import stops in; the rest mean it is still going. */
957
+ const IMPORT_SETTLED = new Set(['success', 'failed', 'cancelled']);
958
+ const IMPORT_POLL_MS = 1000;
959
+ /**
960
+ * Waits for the rows to be written. A failed import exits non-zero, because
961
+ * the alternative — answering 0 for an import that wrote nothing — is how a
962
+ * caller comes to believe data is there when it is not.
963
+ */
964
+ async function awaitImport(client, token, jobId, watching) {
965
+ // Every way out of this loop but the good one carries the job id. The rows are
966
+ // already being written by the time the first poll happens, so an error that
967
+ // drops the id leaves the caller unable to ask how it went and tempted to
968
+ // import the file a second time.
969
+ const recoverable = (reason) => new Error(`${reason}. The import is job ${jobId} and may still be running: ` +
970
+ `jinshuju entry import-status --form ${token} ${jobId}`);
971
+ for (;;) {
972
+ let job;
973
+ try {
974
+ job = await client.request({
975
+ method: 'GET', path: `${API}/forms/${token}/entry_imports/${jobId}`
976
+ });
977
+ }
978
+ catch (error) {
979
+ throw recoverable(`the import started, but asking how it is going failed: ${error.message}`);
980
+ }
981
+ if (IMPORT_SETTLED.has(job.status)) {
982
+ if (job.status !== 'success') {
983
+ throw recoverable(`import ${job.status}${job.error_message ? `: ${job.error_message}` : ''}`);
984
+ }
985
+ return job;
986
+ }
987
+ const seen = job.processed_rows ?? 0;
988
+ const total = job.total_rows;
989
+ watching.step(`importing ${seen}${total ? `/${total}` : ''} rows…`);
990
+ await new Promise((resolve) => setTimeout(resolve, IMPORT_POLL_MS));
991
+ }
992
+ }
993
+ const BATCH_OPTION = {
994
+ name: '--batch', type: 'json', placeholder: '<json|@file|->', description: 'Several rows in one request'
995
+ };
996
+ function attachments(input) {
997
+ return (input.options.attach ?? []).map(parseAttachment);
998
+ }
999
+ function isRecord(value) {
1000
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1001
+ }
1002
+ /** An attachment slot holds a list, so a second file joins the first. */
1003
+ function append(existing, id) {
1004
+ return Array.isArray(existing) ? [...existing, id] : [id];
1005
+ }
1006
+ function batchRows(input) {
1007
+ const rows = input.options.batch;
1008
+ if (rows === undefined)
1009
+ return undefined;
1010
+ if (input.options.json !== undefined)
1011
+ throw new UsageError('--json and --batch are alternatives, not both');
1012
+ if (!Array.isArray(rows))
1013
+ throw new UsageError('--batch must be a list');
1014
+ return rows;
1015
+ }
1016
+ /**
1017
+ * Batch writes are served under the forms path alone, and it takes a table's
1018
+ * token just as well, so a table batches through the same URL.
1019
+ */
1020
+ function batchPath(input) {
1021
+ const { token } = resolveContainer(input.options);
1022
+ return `${API}/forms/${token}/entries/batch`;
1023
+ }
1024
+ const ENTRY = [
1025
+ {
1026
+ path: ['entry', 'list'],
1027
+ summary: 'List entries',
1028
+ description: 'With --view the view carries its own filter and sort, so --filter, --keyword and --sort cannot be added on top.',
1029
+ options: [
1030
+ ...CONTAINER_OPTIONS,
1031
+ { name: '--view', type: 'string', placeholder: '<view>', description: 'Read the entries of this view' },
1032
+ { name: '--keyword', type: 'string', placeholder: '<kw>', description: 'Search every searchable field at once' },
1033
+ { name: '--fields', type: 'list', placeholder: '<api-code,...>', description: 'Return only these fields' },
1034
+ LABELS_OPTION, MINE_OPTION, FILTER_OPTION, FILTERS_OPTION, SORT_OPTION, ...PAGINATION_OPTIONS
1035
+ ],
1036
+ request: (input) => {
1037
+ if (input.options.mine) {
1038
+ refuseWithMine(input, ['view', 'sort']);
1039
+ const { token } = resolveContainer(input.options);
1040
+ return {
1041
+ method: 'GET',
1042
+ path: `${API}/my/forms/${token}/entries`,
1043
+ query: {
1044
+ filters: filters(input),
1045
+ keyword: input.options.keyword,
1046
+ fields: list(input.options.fields),
1047
+ include_labels: labels(input),
1048
+ ...paging(input)
1049
+ }
1050
+ };
1051
+ }
1052
+ const view = input.options.view;
1053
+ if (view) {
1054
+ for (const flag of ['filter', 'filters', 'keyword', 'sort']) {
1055
+ const value = input.options[flag];
1056
+ if (Array.isArray(value) ? value.length > 0 : value !== undefined) {
1057
+ throw new UsageError(`--${flag} cannot be combined with --view: the view carries its own filter and sort`);
1058
+ }
1059
+ }
1060
+ // The view decides its own columns and the endpoint takes no field
1061
+ // list, so --fields here asked for something that was never going to
1062
+ // happen. Refusing beats accepting it and answering every field.
1063
+ if (input.options.fields !== undefined) {
1064
+ throw new UsageError('--fields cannot be combined with --view: the view decides its own columns. ' +
1065
+ 'Change them with `view edit --columns`, or read the form without --view.');
1066
+ }
1067
+ return {
1068
+ method: 'GET',
1069
+ path: `${containerPath(input)}/views/${view}/entries`,
1070
+ query: { include_labels: labels(input), ...paging(input) }
1071
+ };
1072
+ }
1073
+ return {
1074
+ method: 'GET',
1075
+ path: `${containerPath(input)}/entries`,
1076
+ query: {
1077
+ filters: filters(input),
1078
+ keyword: input.options.keyword,
1079
+ fields: list(input.options.fields),
1080
+ include_labels: labels(input),
1081
+ sort: sort(input, 'api_code'),
1082
+ ...paging(input)
1083
+ }
1084
+ };
1085
+ },
1086
+ paginate: LISTING,
1087
+ examples: [
1088
+ 'jinshuju entry list --form Kp7mQ2',
1089
+ "jinshuju entry list --form Kp7mQ2 --filter 'field_3 gte 80' --sort created_at:desc",
1090
+ 'jinshuju entry list --form Kp7mQ2 --all',
1091
+ 'jinshuju entry list --form Kp7mQ2 --mine'
1092
+ ]
1093
+ },
1094
+ {
1095
+ path: ['entry', 'count'],
1096
+ summary: 'Count the entries matching a filter',
1097
+ description: 'The container is repeatable, up to 10. Counting several at once answers one row each plus the ' +
1098
+ 'sum, and takes no --keyword; a filter then has to name created_at, updated_at or creator_id, ' +
1099
+ 'because an api_code is a different field on every form.',
1100
+ options: [
1101
+ ...CONTAINER_LIST_OPTIONS,
1102
+ { name: '--keyword', type: 'string', placeholder: '<kw>', description: 'Search every searchable field at once' },
1103
+ FILTER_OPTION, FILTERS_OPTION
1104
+ ],
1105
+ request: (input) => {
1106
+ const { tokens, kind } = resolveContainers(input.options, MAX_COUNTED_CONTAINERS);
1107
+ const query = { filters: filters(input), keyword: input.options.keyword };
1108
+ if (tokens.length === 1) {
1109
+ return { method: 'GET', path: `${API}/${kind === 'table' ? 'tables' : 'forms'}/${tokens[0]}/entries/count`, query };
1110
+ }
1111
+ return { method: 'GET', path: `${API}/entries/count`, query: { ...query, form_tokens: tokens.join(',') } };
1112
+ },
1113
+ examples: [
1114
+ "jinshuju entry count --form Kp7mQ2 --filter 'field_3 gte 80'",
1115
+ 'jinshuju entry count --form Kp7mQ2 --form Vn4xR8 --form aB3dE9'
1116
+ ]
1117
+ },
1118
+ {
1119
+ path: ['entry', 'search'],
1120
+ summary: 'Search several forms for one keyword at once',
1121
+ description: 'Answers, per form, how many entries matched and some of their serial numbers; values are ' +
1122
+ 'never returned. Read what you need afterwards with `entry get` or `entry list --keyword`. ' +
1123
+ 'A form that matched nothing is left out, so a token you named and cannot find was searched ' +
1124
+ 'and matched nothing. A form that could not be searched is listed with a reason instead — ' +
1125
+ '"not searched" is not "no match". Name up to 10 containers, or name none and let ' +
1126
+ '--scope-filter describe them; the call is refused rather than truncated when more than 10 match.',
1127
+ args: [{ name: 'keyword', required: true, description: 'The text to search for' }],
1128
+ options: [
1129
+ ...CONTAINER_LIST_OPTIONS,
1130
+ {
1131
+ name: '--scope-filter',
1132
+ type: 'string',
1133
+ repeatable: true,
1134
+ placeholder: "'<field> <op> [value]'",
1135
+ description: 'Which forms to search, not which entries match. Takes form_name, created_at, ' +
1136
+ 'last_entry_created_at, entries_count. Empty forms are skipped unless you say otherwise'
1137
+ },
1138
+ MINE_OPTION
1139
+ ],
1140
+ request: (input) => {
1141
+ const containers = input.options.form ?? [];
1142
+ const tables = input.options.table ?? [];
1143
+ if (containers.length > 0 && tables.length > 0) {
1144
+ throw new UsageError('--form and --table are mutually exclusive');
1145
+ }
1146
+ const tokens = containers.length > 0 ? containers : tables;
1147
+ if (input.options.mine) {
1148
+ refuseWithMine(input, ['table', 'scope_filter']);
1149
+ return {
1150
+ method: 'GET',
1151
+ path: `${API}/my/search`,
1152
+ query: {
1153
+ keyword: input.args.keyword,
1154
+ form_tokens: tokens.length > 0 ? tokens.join(',') : undefined
1155
+ }
1156
+ };
1157
+ }
1158
+ const scope = input.options.scope_filter ?? [];
1159
+ return {
1160
+ method: 'GET',
1161
+ path: `${API}/entries/search`,
1162
+ query: {
1163
+ keyword: input.args.keyword,
1164
+ form_tokens: tokens.length > 0 ? tokens.join(',') : undefined,
1165
+ filters: scope.length > 0 ? JSON.stringify(scope.map(parseFilter)) : undefined
1166
+ }
1167
+ };
1168
+ },
1169
+ examples: [
1170
+ 'jinshuju entry search 某某公司',
1171
+ 'jinshuju entry search 13800138000 --form Kp7mQ2 --form Vn4xR8',
1172
+ "jinshuju entry search 报修 --scope-filter 'entries_count gt 100'",
1173
+ 'jinshuju entry search 某某公司 --mine'
1174
+ ]
1175
+ },
1176
+ {
1177
+ path: ['entry', 'stats'],
1178
+ summary: 'Count submissions per form over a date range',
1179
+ description: 'Not the question `entry count` answers. These are submissions as they happened: an import ' +
1180
+ 'lands on the day it ran whatever dates its rows carry, and deletions are never subtracted, ' +
1181
+ 'so this is how much arrived rather than how much is still there. A whole day is the ' +
1182
+ 'smallest window; both ends are inclusive and days are cut in the reported time zone.',
1183
+ options: [
1184
+ { name: '--from', type: 'string', placeholder: '<YYYY-MM-DD>', description: 'First day to count, inclusive' },
1185
+ { name: '--to', type: 'string', placeholder: '<YYYY-MM-DD>', description: 'Last day to count, inclusive. Defaults to today' },
1186
+ { name: '--kind', type: 'string', choices: ['form', 'table'], placeholder: '<kind>', description: 'Count only forms, or only tables' },
1187
+ { name: '--limit', type: 'integer', placeholder: '<n>', description: 'How many forms to list, most submissions first (default 100, max 100)' }
1188
+ ],
1189
+ request: (input) => ({
1190
+ method: 'GET',
1191
+ path: `${API}/entries/stats`,
1192
+ query: {
1193
+ from: requiredOption(input, 'from'),
1194
+ to: input.options.to,
1195
+ kind: input.options.kind,
1196
+ limit: input.options.limit === undefined ? undefined : String(input.options.limit)
1197
+ }
1198
+ }),
1199
+ examples: [
1200
+ 'jinshuju entry stats --from 2026-09-01',
1201
+ 'jinshuju entry stats --from 2026-09-01 --to 2026-09-07 --kind form --limit 10'
1202
+ ]
1203
+ },
1204
+ {
1205
+ path: ['entry', 'aggregate'],
1206
+ summary: 'Compute statistics over the entries matching a filter',
1207
+ description: 'The response is as big as the metrics and groups asked for, never as big as the data. Which ' +
1208
+ 'functions a field takes is the field\'s own answer: read analytics.agg_funcs from `form get`.',
1209
+ options: [
1210
+ ...CONTAINER_OPTIONS,
1211
+ { name: '--metric', type: 'string', repeatable: true, placeholder: '<func>:<field>', description: 'Statistic to compute, repeatable, 1 to 20. e.g. avg:field_3' },
1212
+ { name: '--by', type: 'string', repeatable: true, placeholder: `<field>[:${TIME_BUCKETS.join('|')}]`, description: 'Group by this field, repeatable, at most 2. A date field needs a bucket' },
1213
+ { name: '--limit', type: 'integer', placeholder: '<n>', description: 'How many groups, ranked by the first metric (default 20, max 200)' },
1214
+ FILTER_OPTION, FILTERS_OPTION
1215
+ ],
1216
+ request: (input) => {
1217
+ const metrics = input.options.metric ?? [];
1218
+ if (metrics.length === 0)
1219
+ throw new UsageError('--metric <func>:<field> is required, up to 20');
1220
+ const dimensions = input.options.by ?? [];
1221
+ return {
1222
+ method: 'GET',
1223
+ path: `${containerPath(input)}/entries/aggregate`,
1224
+ query: {
1225
+ metrics: JSON.stringify(metrics.map(parseMetric)),
1226
+ dimensions: dimensions.length > 0 ? JSON.stringify(dimensions.map(parseDimension)) : undefined,
1227
+ limit: input.options.limit === undefined ? undefined : String(input.options.limit),
1228
+ filters: filters(input)
1229
+ }
1230
+ };
1231
+ },
1232
+ render: aggregateTable,
1233
+ examples: [
1234
+ 'jinshuju entry aggregate --form Kp7mQ2 --metric avg:field_3',
1235
+ 'jinshuju entry aggregate --form Kp7mQ2 --metric count:field_1 --by created_at:month',
1236
+ "jinshuju entry aggregate --form Kp7mQ2 --metric sum:field_5 --by field_7 --limit 5 --filter 'created_at within_last 30d'"
1237
+ ]
1238
+ },
1239
+ {
1240
+ path: ['entry', 'summary'],
1241
+ summary: 'Profile every analysable field at once',
1242
+ description: 'One pass over the data describing each field in its own terms: choices by share, numbers by ' +
1243
+ 'spread, dates by range. Submission metadata is left out — it describes the submitting, not the answer.',
1244
+ options: [
1245
+ ...CONTAINER_OPTIONS,
1246
+ { name: '--fields', type: 'list', placeholder: '<api-code,...>', description: 'Profile only these fields, at most 60' },
1247
+ { name: '--no-overview', type: 'boolean', description: 'Leave out the form-level totals' },
1248
+ FILTER_OPTION, FILTERS_OPTION
1249
+ ],
1250
+ request: (input) => ({
1251
+ method: 'GET',
1252
+ path: `${containerPath(input)}/entries/summary`,
1253
+ query: {
1254
+ fields: list(input.options.fields),
1255
+ include_overview: input.options.no_overview ? 'false' : undefined,
1256
+ filters: filters(input)
1257
+ }
1258
+ }),
1259
+ examples: [
1260
+ 'jinshuju entry summary --form Kp7mQ2',
1261
+ 'jinshuju entry summary --form Kp7mQ2 --fields field_3,field_7 --no-overview'
1262
+ ]
1263
+ },
1264
+ {
1265
+ path: ['entry', 'create'],
1266
+ summary: 'Create entries',
1267
+ description: 'The payload is keyed by field api_code, not by field label. --batch takes a list of them and ' +
1268
+ 'writes them in one request.',
1269
+ options: [
1270
+ ...CONTAINER_OPTIONS, JSON_OPTION, BATCH_OPTION,
1271
+ { name: '--attach', type: 'string', repeatable: true, placeholder: '<api-code>[.<row>.<sub>]=<file>', description: 'Upload a file into this attachment field, repeatable. A subtable column names the row it fills: field_5.0.field_2=<file>' }
1272
+ ],
1273
+ request: (input) => {
1274
+ const batch = batchRows(input);
1275
+ if (batch)
1276
+ return { method: 'POST', path: batchPath(input), body: { entries: batch } };
1277
+ return { method: 'POST', path: `${containerPath(input)}/entries`, body: payload(input) };
1278
+ },
1279
+ run: async (input, client) => {
1280
+ const attached = attachments(input);
1281
+ if (attached.length === 0)
1282
+ return undefined;
1283
+ if (input.options.batch !== undefined)
1284
+ throw new UsageError('--attach cannot be combined with --batch');
1285
+ const container = containerPath(input);
1286
+ const { token } = resolveContainer(input.options);
1287
+ const body = { ...(input.options.json ?? {}) };
1288
+ const watching = progress();
1289
+ try {
1290
+ for (const { field, row, dimension, file } of attached) {
1291
+ watching.step(`uploading ${basename(file)}…`);
1292
+ const uploaded = await client.request(upload(`${API}/forms/${token}/entry_attachments`, file, dimension === undefined ? { field_api_code: field } : { field_api_code: field, dimension_api_code: dimension }));
1293
+ // A field holds a list of attachments, so each upload appends rather
1294
+ // than replacing what an earlier --attach for the same field put there.
1295
+ if (dimension === undefined) {
1296
+ body[field] = append(body[field], uploaded.id);
1297
+ continue;
1298
+ }
1299
+ // A subtable column is a list of rows and the file lives inside one of
1300
+ // them: {"field_5": [{"field_2": ["<id>"]}]}. Writing "field_5.field_2"
1301
+ // at the top level named no field the form has, so the server dropped it
1302
+ // and answered with an entry created — without the file just uploaded.
1303
+ const rows = Array.isArray(body[field]) ? [...body[field]] : [];
1304
+ while (rows.length <= row)
1305
+ rows.push({});
1306
+ const cells = { ...(isRecord(rows[row]) ? rows[row] : {}) };
1307
+ cells[dimension] = append(cells[dimension], uploaded.id);
1308
+ rows[row] = cells;
1309
+ body[field] = rows;
1310
+ }
1311
+ return await client.request({ method: 'POST', path: `${container}/entries`, body });
1312
+ }
1313
+ finally {
1314
+ watching.done();
1315
+ }
1316
+ },
1317
+ examples: [
1318
+ 'jinshuju entry create --form Kp7mQ2 --json \'{"field_1":"张三"}\'',
1319
+ 'jinshuju entry create --form Kp7mQ2 --json @entry.json --attach field_5=./id-card.jpg',
1320
+ 'jinshuju entry create --form Kp7mQ2 --json \'{"field_2":[{"field_1":"高铁票"}]}\' --attach field_2.0.field_2=./invoice.pdf',
1321
+ 'jinshuju entry create --form Kp7mQ2 --batch @entries.json'
1322
+ ]
1323
+ },
1324
+ {
1325
+ path: ['entry', 'update'],
1326
+ summary: 'Update entries',
1327
+ description: 'The payload merges onto the entry, leaving the fields it does not name alone; --replace ' +
1328
+ 'writes the entry as given, clearing the rest. --batch takes [{serial_number, entry}] and ' +
1329
+ 'always merges.',
1330
+ args: [{ name: 'serial', required: false, description: 'Entry serial number; leave out with --batch' }],
1331
+ options: [
1332
+ ...CONTAINER_OPTIONS, JSON_OPTION, BATCH_OPTION,
1333
+ { name: '--replace', type: 'boolean', description: 'Write the entry as given, clearing fields the payload leaves out' }
1334
+ ],
1335
+ request: (input) => {
1336
+ const batch = batchRows(input);
1337
+ if (batch) {
1338
+ if (input.args.serial)
1339
+ throw new UsageError('--batch carries its own serial numbers, so <serial> is not taken');
1340
+ if (input.options.replace)
1341
+ throw new UsageError('--replace cannot be combined with --batch');
1342
+ return { method: 'PATCH', path: batchPath(input), body: { entries: batch } };
1343
+ }
1344
+ if (!input.args.serial)
1345
+ throw new UsageError('<serial> is required, or pass --batch');
1346
+ return {
1347
+ method: input.options.replace ? 'PUT' : 'PATCH',
1348
+ path: `${containerPath(input)}/entries/${input.args.serial}`,
1349
+ body: payload(input)
1350
+ };
1351
+ },
1352
+ examples: [
1353
+ 'jinshuju entry update --form Kp7mQ2 12 --json \'{"field_1":"李四"}\'',
1354
+ 'jinshuju entry update --form Kp7mQ2 --batch @rows.json'
1355
+ ]
1356
+ },
1357
+ {
1358
+ path: ['entry', 'import'],
1359
+ summary: 'Import a spreadsheet into a form or table',
1360
+ description: 'Two requests underneath: the file goes up, then the mapping says which column feeds which ' +
1361
+ 'field. Everything knowable up front — the file, the size your plan allows, the header row, ' +
1362
+ 'the mapping — is checked before any row is written, so a refused import has changed nothing ' +
1363
+ 'and the message names the sheet\'s real layout. Once accepted the rows are written in the ' +
1364
+ 'background: the answer means started, not finished.',
1365
+ args: [{ name: 'file', required: true, description: 'Path to an .xlsx, .xls or .csv file' }],
1366
+ options: [
1367
+ ...CONTAINER_OPTIONS,
1368
+ { name: '--map', type: 'string', repeatable: true, placeholder: '<api-code>=<column>', description: 'Which column feeds which field. A number is a column index, anything else a header label' },
1369
+ { name: '--header-row', type: 'integer', placeholder: '<n>', description: 'Which row holds the headers, when it is not the first' },
1370
+ { name: '--unique', type: 'string', placeholder: '<api-code>', description: 'Treat this field as the key: a row matching an existing one updates it' },
1371
+ { name: '--wait', type: 'boolean', description: 'Wait for the rows to be written and report what the import did, failing if it failed' }
1372
+ ],
1373
+ run: async (input, client) => {
1374
+ const { token } = resolveContainer(input.options);
1375
+ const mappings = input.options.map ?? [];
1376
+ if (mappings.length === 0)
1377
+ throw new UsageError('--map <api-code>=<column> is required, at least once');
1378
+ const watching = progress();
1379
+ try {
1380
+ watching.step(`uploading ${basename(input.args.file)}…`);
1381
+ const uploaded = await client.request(upload(`${API}/forms/${token}/import_files`, input.args.file));
1382
+ watching.step('starting the import…');
1383
+ const started = await client.request({
1384
+ method: 'POST',
1385
+ path: `${API}/forms/${token}/entry_imports`,
1386
+ body: given({
1387
+ attachment_id: uploaded.id,
1388
+ columns: mappings.map(parseColumnMapping),
1389
+ header_row_index: input.options.header_row,
1390
+ unique_field_code: input.options.unique
1391
+ })
1392
+ });
1393
+ if (!input.options.wait)
1394
+ return started;
1395
+ return await awaitImport(client, token, started.job_id, watching);
1396
+ }
1397
+ finally {
1398
+ watching.done();
1399
+ }
1400
+ },
1401
+ examples: [
1402
+ 'jinshuju entry import --form Kp7mQ2 ./报名.xlsx --map field_1=姓名 --map field_2=手机号',
1403
+ 'jinshuju entry import --form Kp7mQ2 ./报名.xlsx --map field_1=姓名 --wait',
1404
+ 'jinshuju entry import --table Vn4xR8 ./rows.csv --map field_1=1 --map field_2=2 --header-row 2'
1405
+ ]
1406
+ },
1407
+ {
1408
+ path: ['entry', 'import-status'],
1409
+ summary: 'Show what an import did, or how far it has got',
1410
+ description: 'The id `entry import` answered with. A finished import reports how many rows it wrote, ' +
1411
+ 'skipped and rejected; one still running reports how far it has got.',
1412
+ args: [{ name: 'job', required: true, description: 'Job id from `entry import`' }],
1413
+ options: [...CONTAINER_OPTIONS],
1414
+ request: (input) => {
1415
+ const { token } = resolveContainer(input.options);
1416
+ return { method: 'GET', path: `${API}/forms/${token}/entry_imports/${input.args.job}` };
1417
+ },
1418
+ examples: ['jinshuju entry import-status --form Kp7mQ2 6ab12edb3134316548d106a9']
1419
+ },
1420
+ {
1421
+ path: ['entry', 'delete'],
1422
+ summary: 'Delete one entry',
1423
+ args: [{ name: 'serial', required: true, description: 'Entry serial number' }],
1424
+ options: [...CONTAINER_OPTIONS, YES_OPTION],
1425
+ request: (input) => {
1426
+ confirmed(input, `Deleting entry ${input.args.serial}`);
1427
+ return { method: 'DELETE', path: `${containerPath(input)}/entries/${input.args.serial}` };
1428
+ },
1429
+ examples: ['jinshuju entry delete --form Kp7mQ2 12 --yes']
1430
+ },
1431
+ {
1432
+ path: ['entry', 'get'],
1433
+ summary: 'Show one entry',
1434
+ args: [{ name: 'serial', required: true, description: 'Entry serial number' }],
1435
+ options: [
1436
+ ...CONTAINER_OPTIONS,
1437
+ { name: '--fields', type: 'list', placeholder: '<api-code,...>', description: 'Return only these fields' },
1438
+ LABELS_OPTION
1439
+ ],
1440
+ request: (input) => ({
1441
+ method: 'GET',
1442
+ path: `${containerPath(input)}/entries/${input.args.serial}`,
1443
+ query: { fields: list(input.options.fields), include_labels: labels(input) }
1444
+ })
1445
+ }
1446
+ ];
1447
+ /** A comment lives under its entry, so every verb needs the entry as well. */
1448
+ const ENTRY_OPTION = {
1449
+ name: '--entry', type: 'string', placeholder: '<serial>', description: 'Entry serial number'
1450
+ };
1451
+ function commentsPath(input) {
1452
+ const serial = input.options.entry;
1453
+ if (!serial)
1454
+ throw new UsageError('--entry <serial> is required');
1455
+ return `${containerPath(input)}/entries/${serial}/comments`;
1456
+ }
1457
+ const COMMENT = [
1458
+ {
1459
+ path: ['comment', 'list'],
1460
+ summary: "List an entry's comments",
1461
+ options: [
1462
+ ...CONTAINER_OPTIONS,
1463
+ { name: '--entry', type: 'string', placeholder: '<serial>', description: 'Entry serial number' }
1464
+ ],
1465
+ request: (input) => {
1466
+ const serial = input.options.entry;
1467
+ if (!serial)
1468
+ throw new UsageError('--entry <serial> is required');
1469
+ return { method: 'GET', path: `${containerPath(input)}/entries/${serial}/comments` };
1470
+ }
1471
+ },
1472
+ {
1473
+ path: ['comment', 'create'],
1474
+ summary: 'Comment on an entry',
1475
+ args: [{ name: 'content', required: true, description: 'Comment text' }],
1476
+ options: [
1477
+ ...CONTAINER_OPTIONS, ENTRY_OPTION,
1478
+ { name: '--reply-to', type: 'string', placeholder: '<comment-id>', description: 'Reply under this comment' }
1479
+ ],
1480
+ request: (input) => ({
1481
+ method: 'POST',
1482
+ path: `${commentsPath(input)}`,
1483
+ body: given({ content: input.args.content, parent_id: input.options.reply_to })
1484
+ }),
1485
+ examples: ['jinshuju comment create --form Kp7mQ2 --entry 12 "已联系,等回复"']
1486
+ },
1487
+ {
1488
+ path: ['comment', 'update'],
1489
+ summary: 'Edit a comment',
1490
+ args: [
1491
+ { name: 'comment', required: true, description: 'Comment id' },
1492
+ { name: 'content', required: true, description: 'New comment text' }
1493
+ ],
1494
+ options: [...CONTAINER_OPTIONS, ENTRY_OPTION],
1495
+ request: (input) => ({
1496
+ method: 'PATCH',
1497
+ path: `${commentsPath(input)}/${input.args.comment}`,
1498
+ body: { content: input.args.content }
1499
+ })
1500
+ },
1501
+ {
1502
+ path: ['comment', 'delete'],
1503
+ summary: 'Delete a comment',
1504
+ args: [{ name: 'comment', required: true, description: 'Comment id' }],
1505
+ options: [...CONTAINER_OPTIONS, ENTRY_OPTION, YES_OPTION],
1506
+ request: (input) => {
1507
+ confirmed(input, `Deleting comment ${input.args.comment}`);
1508
+ return { method: 'DELETE', path: `${commentsPath(input)}/${input.args.comment}` };
1509
+ }
1510
+ }
1511
+ ];
1512
+ const OPENSEARCH = [
1513
+ {
1514
+ path: ['opensearch', 'list'],
1515
+ summary: 'List public queries',
1516
+ options: [{ name: '--form', type: 'string', placeholder: '<token>', description: 'Only the queries of this form' }],
1517
+ request: (input) => ({
1518
+ method: 'GET',
1519
+ path: `${API}/opensearch/queries`,
1520
+ query: { form_token: input.options.form }
1521
+ })
1522
+ },
1523
+ {
1524
+ path: ['opensearch', 'get'],
1525
+ summary: 'Show a public query',
1526
+ args: [{ name: 'query', required: true, description: 'Public query token' }],
1527
+ request: (input) => ({ method: 'GET', path: `${API}/opensearch/queries/${input.args.query}` })
1528
+ },
1529
+ {
1530
+ path: ['opensearch', 'fields'],
1531
+ summary: 'Suggest the fields a public query can use',
1532
+ options: [{ name: '--form', type: 'string', placeholder: '<token>', description: 'Form token' }],
1533
+ request: (input) => {
1534
+ const form = input.options.form;
1535
+ if (!form)
1536
+ throw new UsageError('--form <token> is required');
1537
+ return { method: 'GET', path: `${API}/opensearch/query_suggestions`, query: { form_token: form } };
1538
+ }
1539
+ },
1540
+ {
1541
+ path: ['opensearch', 'create'],
1542
+ summary: 'Create a public query',
1543
+ description: 'Ask `opensearch fields --form <token>` which fields a query may search on and return.',
1544
+ options: [
1545
+ { name: '--form', type: 'string', placeholder: '<token>', description: 'Form the query reads' },
1546
+ JSON_OPTION
1547
+ ],
1548
+ request: (input) => {
1549
+ const form = input.options.form;
1550
+ if (!form)
1551
+ throw new UsageError('--form <token> is required');
1552
+ return {
1553
+ method: 'POST',
1554
+ path: `${API}/opensearch/queries`,
1555
+ body: overriding(payload(input), { form_token: form })
1556
+ };
1557
+ }
1558
+ },
1559
+ {
1560
+ path: ['opensearch', 'edit'],
1561
+ summary: 'Edit a public query, or turn it on and off',
1562
+ args: [{ name: 'query', required: true, description: 'Public query token' }],
1563
+ options: [
1564
+ JSON_OPTION,
1565
+ { name: '--enable', type: 'boolean', description: 'Turn the query on' },
1566
+ { name: '--disable', type: 'boolean', description: 'Turn the query off' }
1567
+ ],
1568
+ request: (input) => {
1569
+ if (input.options.enable && input.options.disable) {
1570
+ throw new UsageError('--enable and --disable are opposites, pass one');
1571
+ }
1572
+ const rest = input.options.json ?? {};
1573
+ const enabled = input.options.enable ? true : input.options.disable ? false : undefined;
1574
+ return {
1575
+ method: 'PATCH',
1576
+ path: `${API}/opensearch/queries/${input.args.query}`,
1577
+ body: overriding(rest, { enabled })
1578
+ };
1579
+ },
1580
+ examples: ['jinshuju opensearch edit Qy7nR3 --disable']
1581
+ }
1582
+ ];
1583
+ /** The local options a command takes, by flag name. */
1584
+ function local(...names) {
1585
+ return names.map((name) => {
1586
+ const spec = LOCAL_OPTIONS.find((candidate) => candidate.name === name);
1587
+ if (!spec)
1588
+ throw new Error(`no local option ${name}`);
1589
+ return spec;
1590
+ });
1591
+ }
1592
+ /**
1593
+ * The commands that never reach the API: they read and write the config file,
1594
+ * or run a browser login. `cli.ts` dispatches them itself, so they carry no
1595
+ * request — but they belong in this table all the same, because help is
1596
+ * rendered from it. Left out, `auth login --help` answered with the root
1597
+ * listing and `--no-open`, `--verify` and `--show-secret` were documented
1598
+ * nowhere a caller could reach.
1599
+ */
1600
+ const LOCAL = [
1601
+ {
1602
+ path: ['auth', 'login'],
1603
+ summary: 'Log in through the browser and store the session',
1604
+ description: 'Opens the authorization page, waits for the redirect on a loopback port, and writes the ' +
1605
+ 'session to the config file. An access token or an API key pair, if configured, still ' +
1606
+ 'outranks what this stores.',
1607
+ options: local('--auth-host', '--client-id', '--scopes', '--no-open', '--host'),
1608
+ examples: ['jinshuju auth login', 'jinshuju auth login --no-open']
1609
+ },
1610
+ {
1611
+ path: ['auth', 'status'],
1612
+ summary: 'Show which credential is in use, and where it came from',
1613
+ description: 'The precedence is access token, then API key and secret, then a stored browser login. ' +
1614
+ '--verify spends one lightweight call to confirm the credential still works.',
1615
+ options: local('--verify', '--api-key', '--api-secret', '--host', '--auth-host', '--client-id'),
1616
+ examples: ['jinshuju auth status', 'jinshuju auth status --verify']
1617
+ },
1618
+ {
1619
+ path: ['auth', 'refresh'],
1620
+ summary: 'Renew the stored browser session',
1621
+ description: 'Only an OAuth session can be refreshed; a token that stopped working has to be replaced by whoever issued it.',
1622
+ options: local('--auth-host', '--client-id')
1623
+ },
1624
+ {
1625
+ path: ['auth', 'logout'],
1626
+ summary: 'Revoke the stored browser session and forget it',
1627
+ description: 'Leaves an access token or API key pair in the config alone: those are not this command\'s to drop.',
1628
+ options: local('--auth-host', '--client-id')
1629
+ },
1630
+ {
1631
+ path: ['config', 'get'],
1632
+ summary: 'Read one configuration value',
1633
+ description: `Keys: ${CONFIG_KEYS.join(', ')}. Secrets are masked unless --show-secret says otherwise.`,
1634
+ args: [{ name: 'key', required: true, description: `One of ${CONFIG_KEYS.join(', ')}` }],
1635
+ options: local('--show-secret'),
1636
+ examples: ['jinshuju config get api_key', 'jinshuju config get access_token --show-secret']
1637
+ },
1638
+ {
1639
+ path: ['config', 'set'],
1640
+ summary: 'Write one configuration value',
1641
+ description: `Keys: ${CONFIG_KEYS.join(', ')}. The file is written with mode 600. Environment variables ` +
1642
+ 'of the same name (JINSHUJU_ACCESS_TOKEN, JINSHUJU_API_KEY, …) outrank whatever is stored here.',
1643
+ args: [
1644
+ { name: 'key', required: true, description: `One of ${CONFIG_KEYS.join(', ')}` },
1645
+ { name: 'value', required: true, description: 'The value to store' }
1646
+ ],
1647
+ examples: ['jinshuju config set access_token xxx', 'jinshuju config set host https://jinshuju.net']
1648
+ },
1649
+ {
1650
+ path: ['config', 'unset'],
1651
+ summary: 'Remove one configuration value',
1652
+ args: [{ name: 'key', required: true, description: `One of ${CONFIG_KEYS.join(', ')}` }],
1653
+ examples: ['jinshuju config unset api_secret']
1654
+ }
1655
+ ];
1656
+ export const COMMANDS = [
1657
+ ...LOCAL, ...ACCOUNT, ...FOLDER, ...FORM, ...TABLE, ...FIELD, ...VIEW, ...ENTRY, ...COMMENT, ...OPENSEARCH
1658
+ ];
1659
+ /** The command whose path the words begin with, longest match first. */
1660
+ export function findCommand(words, commands = COMMANDS) {
1661
+ let best;
1662
+ for (const command of commands) {
1663
+ if (command.path.length > words.length)
1664
+ continue;
1665
+ if (command.path.some((part, index) => words[index] !== part))
1666
+ continue;
1667
+ if (!best || command.path.length > best.path.length)
1668
+ best = command;
1669
+ }
1670
+ return best;
1671
+ }
1672
+ export { JSON_OPTION };