@xinhe-oa/cli 0.0.0-stage → 0.2.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 +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,3040 @@
|
|
|
1
|
+
// 多维表格:`bitable base|table|field|record|view|form`(契约 §2.1、§5)。
|
|
2
|
+
//
|
|
3
|
+
// 参数 → 服务端路径的对应全部是**常量**(没有一条由用户输入拼出来),
|
|
4
|
+
// 所以"没有裸调面"这件事是可以逐行核对的。
|
|
5
|
+
//
|
|
6
|
+
// 本文件是 `cli/crates/xinhe-oa-cli/src/commands/bitable.rs`(含 `bitable/import_cmd.rs`、
|
|
7
|
+
// `bitable/visibility.rs`)的 Node 移植。注释里凡是"为什么"的部分都照抄了 Rust 侧的现场结论——
|
|
8
|
+
// 这个域几乎每条口径都是踩出来的,删掉理由就等于把坑重新埋回去。
|
|
9
|
+
|
|
10
|
+
import { CliError, Kind, exactlyOne } from '../core/error.js';
|
|
11
|
+
import { flagList, flagOn, flagValue } from '../core/args.js';
|
|
12
|
+
import { filtersToAst, orderParam, parsePairs, parseSetPairs, valuesObject } from '../core/values.js';
|
|
13
|
+
import { dataOf, idempotencyKey, readFileBytes } from '../core/runtime.js';
|
|
14
|
+
import { multipartBody, requestSpec } from '../core/http.js';
|
|
15
|
+
import { newIdempotencyKey } from '../core/util.js';
|
|
16
|
+
import { progress, renderTable, verbose, warn } from '../core/output.js';
|
|
17
|
+
import * as checkpoint from '../core/checkpoint.js';
|
|
18
|
+
import * as imp from '../core/import.js';
|
|
19
|
+
import { fileNameOf } from '../core/values.js';
|
|
20
|
+
|
|
21
|
+
/** 单页读记录的条数上限(与服务端 `rows_limit_max`、`/me` 的公布值同源)。 */
|
|
22
|
+
const MAX_PAGE_LIMIT = 1000;
|
|
23
|
+
/** `--all` 并发取页的硬上限。 */
|
|
24
|
+
const MAX_PAGE_WORKERS = 16;
|
|
25
|
+
/** 一次 `--all` 最多翻到第几行(护栏,防止把服务端拖住)。 */
|
|
26
|
+
const MAX_PAGING_OFFSET = 100_000;
|
|
27
|
+
/** 视图取数(游标分页)一次 `--all` 最多翻几页(游标没有"第几行"这个概念)。 */
|
|
28
|
+
const MAX_VIEW_PAGES = 200;
|
|
29
|
+
/** 单批请求体的字节上限:服务端 `max_body_kb` 默认 1024KB,留 ~10% 余量。 */
|
|
30
|
+
const MAX_BODY_BYTES = 900 * 1024;
|
|
31
|
+
/** 并发发批的硬上限(超过这个数,客户端先于服务端成为瓶颈)。 */
|
|
32
|
+
const MAX_WORKERS = 16;
|
|
33
|
+
/** 同一批最多重投几次(429/5xx 之后重新排队)。 */
|
|
34
|
+
const MAX_BATCH_ATTEMPTS = 5;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `bitable` 六个子域的分发。
|
|
38
|
+
*
|
|
39
|
+
* **动作由 `args` 决定,不由规格条目名决定** —— 这一条与 Rust 逐字同源:
|
|
40
|
+
* Rust 那边 `BaseArgs` / `TableArgs` 这些结构体承载一整组动作,clap 只负责把旗标收进来,
|
|
41
|
+
* "这次到底是 --list 还是 --archive" 是 `BaseArgs::action()` 在**代码里**判的。
|
|
42
|
+
* Node 版的解析层同样会把 `bitable base --archive bs_1` 归到某一条规格条目上
|
|
43
|
+
* (条目名只说明"这是 base 这一组"),所以按条目名分发会在 `--archive` / `--visibility`
|
|
44
|
+
* 这些动作上悄悄走错分支。按 `args` 判既稳,也与 Rust 的组合约束一一对应。
|
|
45
|
+
*/
|
|
46
|
+
export async function handleBitable({ args, runtime, command }) {
|
|
47
|
+
const tokens = String(command?.name ?? '').split(' ');
|
|
48
|
+
const resource = tokens[1];
|
|
49
|
+
switch (resource) {
|
|
50
|
+
case 'base':
|
|
51
|
+
return runBase(runtime, args);
|
|
52
|
+
case 'table':
|
|
53
|
+
return runTable(runtime, args);
|
|
54
|
+
case 'field':
|
|
55
|
+
return runField(runtime, args);
|
|
56
|
+
case 'record':
|
|
57
|
+
return runRecord(runtime, args);
|
|
58
|
+
case 'view':
|
|
59
|
+
return runView(runtime, args);
|
|
60
|
+
case 'form':
|
|
61
|
+
return runForm(runtime, args);
|
|
62
|
+
default:
|
|
63
|
+
throw CliError.usage(`\`${command?.name ?? 'bitable'}\` 还没有实现`).withHint(
|
|
64
|
+
'跑 `xinhe-oa-cli schema bitable` 看这个域的命令面',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `bitable base` 的动作收口(逐条对应 Rust 的 `BaseArgs::action()`)。 */
|
|
70
|
+
async function runBase(runtime, args) {
|
|
71
|
+
const vis = visibilityFlags(args);
|
|
72
|
+
// 组合约束在本地判定:参数拼错 = 退出码 2、不发请求。
|
|
73
|
+
if (flagOn(args, '--include-archived') && !flagOn(args, '--list')) {
|
|
74
|
+
throw CliError.usage('`--include-archived` 只对 `--list` 有意义').withHint(
|
|
75
|
+
'例:xinhe-oa-cli bitable base --list --include-archived --json',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const showState = visibilityShowState(args, vis);
|
|
79
|
+
if (showState.bareShow && vis.visibility === null) {
|
|
80
|
+
// 裸 `--show` 只用于 `--visibility <ID> --show`;进了 action 组就会与 `--visibility`
|
|
81
|
+
// 被判互斥(文件域 `file --show` 同一取舍),所以这三条在代码里判。
|
|
82
|
+
throw CliError.usage(
|
|
83
|
+
'裸 `--show` 只用于 `--visibility <BASE_ID> --show`;看详情请给 `--show <BASE_ID>`',
|
|
84
|
+
).withHint('例:xinhe-oa-cli bitable base --show <ID> --json');
|
|
85
|
+
}
|
|
86
|
+
if (vis.visibility !== null && args['--show'] !== undefined && !showState.bareShow) {
|
|
87
|
+
throw CliError.usage(
|
|
88
|
+
'`--show <BASE_ID>` 与 `--visibility <BASE_ID>` 不能同时给:它们各自已经在指名对象',
|
|
89
|
+
).withHint('例:xinhe-oa-cli bitable base --visibility <ID> --show --json');
|
|
90
|
+
}
|
|
91
|
+
if (vis.visibility !== null) {
|
|
92
|
+
validateVisibilityAction('bitable base', vis, showState.visShow);
|
|
93
|
+
}
|
|
94
|
+
if (vis.investorVisibility !== null && vis.investorSet === null) {
|
|
95
|
+
throw CliError.usage(
|
|
96
|
+
'`--investor-visibility <BASE_ID>` 还要给 `--investor-set open|closed`',
|
|
97
|
+
).withHint('例:xinhe-oa-cli bitable base --investor-visibility <ID> --investor-set open --yes');
|
|
98
|
+
}
|
|
99
|
+
const picked = exactlyOne(
|
|
100
|
+
[
|
|
101
|
+
['--list', flagOn(args, '--list')],
|
|
102
|
+
['--create', flagOn(args, '--create')],
|
|
103
|
+
['--show', args['--show'] !== undefined && !showState.visShow],
|
|
104
|
+
['--archive', vis.archive],
|
|
105
|
+
['--visibility', vis.visibility !== null],
|
|
106
|
+
['--share', vis.share !== null],
|
|
107
|
+
['--acl-list', vis.aclList !== null],
|
|
108
|
+
['--acl-revoke', vis.aclRevoke !== null],
|
|
109
|
+
['--move', vis.move !== null],
|
|
110
|
+
['--investor-visibility', vis.investorVisibility !== null],
|
|
111
|
+
],
|
|
112
|
+
'bitable base',
|
|
113
|
+
'例:xinhe-oa-cli bitable base --list --json',
|
|
114
|
+
);
|
|
115
|
+
switch (picked) {
|
|
116
|
+
case '--list':
|
|
117
|
+
return baseList(runtime, args);
|
|
118
|
+
case '--create':
|
|
119
|
+
return baseCreate(runtime, args);
|
|
120
|
+
case '--show':
|
|
121
|
+
return baseShow(runtime, args);
|
|
122
|
+
case '--archive':
|
|
123
|
+
return baseArchive(runtime, args);
|
|
124
|
+
case '--visibility':
|
|
125
|
+
return showState.visShow
|
|
126
|
+
? showVisibility(runtime, args, 'base', vis.visibility)
|
|
127
|
+
: setVisibility(runtime, args, 'base', vis.visibility, vis.set);
|
|
128
|
+
case '--share':
|
|
129
|
+
return share(runtime, args, 'base');
|
|
130
|
+
case '--acl-list':
|
|
131
|
+
return aclList(runtime, args, 'base');
|
|
132
|
+
case '--acl-revoke':
|
|
133
|
+
return aclRevoke(runtime, args, 'base');
|
|
134
|
+
case '--move':
|
|
135
|
+
return moveObject(runtime, args, 'base');
|
|
136
|
+
default:
|
|
137
|
+
return investorVisibility(runtime, args, 'base');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `bitable table` 的动作收口(逐条对应 Rust 的 `TableArgs::action()`)。 */
|
|
142
|
+
async function runTable(runtime, args) {
|
|
143
|
+
const vis = visibilityFlags(args);
|
|
144
|
+
const appended = flagOn(args, '--append');
|
|
145
|
+
if (appended && !flagOn(args, '--import')) {
|
|
146
|
+
throw CliError.usage(
|
|
147
|
+
'`--append` 只对 `--import` 有意义(把数据追加到已存在的同 key/同表名表)',
|
|
148
|
+
).withHint(
|
|
149
|
+
'例:xinhe-oa-cli bitable table --import --file 9月.csv --base <BASE_ID> --name 结算明细 --append',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (flagOn(args, '--include-archived') && !flagOn(args, '--list')) {
|
|
153
|
+
throw CliError.usage('`--include-archived` 只对 `--list` 有意义').withHint(
|
|
154
|
+
'例:xinhe-oa-cli bitable table --list --base <BASE_ID> --include-archived --json',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const showState = visibilityShowState(args, vis);
|
|
158
|
+
if (showState.bareShow && vis.visibility === null) {
|
|
159
|
+
throw CliError.usage(
|
|
160
|
+
'裸 `--show` 只用于 `--visibility <TABLE_ID> --show`;看详情请给 `--show <TABLE_ID>`',
|
|
161
|
+
).withHint('例:xinhe-oa-cli bitable table --show <ID> --json');
|
|
162
|
+
}
|
|
163
|
+
if (vis.visibility !== null && args['--show'] !== undefined && !showState.bareShow) {
|
|
164
|
+
throw CliError.usage(
|
|
165
|
+
'`--show <TABLE_ID>` 与 `--visibility <TABLE_ID>` 不能同时给:它们各自已经在指名对象',
|
|
166
|
+
).withHint('例:xinhe-oa-cli bitable table --visibility <ID> --show --json');
|
|
167
|
+
}
|
|
168
|
+
if (vis.visibility !== null) {
|
|
169
|
+
validateVisibilityAction('bitable table', vis, showState.visShow);
|
|
170
|
+
}
|
|
171
|
+
if (vis.investorVisibility !== null && vis.investorSet === null) {
|
|
172
|
+
throw CliError.usage(
|
|
173
|
+
'`--investor-visibility <TABLE_ID>` 还要给 `--investor-set open|closed`',
|
|
174
|
+
).withHint('例:xinhe-oa-cli bitable table --investor-visibility <ID> --investor-set open --yes');
|
|
175
|
+
}
|
|
176
|
+
const picked = exactlyOne(
|
|
177
|
+
[
|
|
178
|
+
['--list', flagOn(args, '--list')],
|
|
179
|
+
['--create', flagOn(args, '--create')],
|
|
180
|
+
['--show', args['--show'] !== undefined && !showState.visShow],
|
|
181
|
+
['--archive', vis.archive],
|
|
182
|
+
['--import', flagOn(args, '--import')],
|
|
183
|
+
['--visibility', vis.visibility !== null],
|
|
184
|
+
['--share', vis.share !== null],
|
|
185
|
+
['--acl-list', vis.aclList !== null],
|
|
186
|
+
['--acl-revoke', vis.aclRevoke !== null],
|
|
187
|
+
['--move', vis.move !== null],
|
|
188
|
+
['--investor-visibility', vis.investorVisibility !== null],
|
|
189
|
+
],
|
|
190
|
+
'bitable table',
|
|
191
|
+
'例:xinhe-oa-cli bitable table --import --file 单据.csv --base-name 开叔企业 --name 公账流水',
|
|
192
|
+
);
|
|
193
|
+
switch (picked) {
|
|
194
|
+
case '--list':
|
|
195
|
+
return tableList(runtime, args);
|
|
196
|
+
case '--create':
|
|
197
|
+
return tableCreate(runtime, args);
|
|
198
|
+
case '--show':
|
|
199
|
+
return tableShow(runtime, args);
|
|
200
|
+
case '--archive':
|
|
201
|
+
return tableArchive(runtime, args);
|
|
202
|
+
case '--import':
|
|
203
|
+
return importTable(runtime, args);
|
|
204
|
+
case '--visibility':
|
|
205
|
+
return showState.visShow
|
|
206
|
+
? showVisibility(runtime, args, 'table', vis.visibility)
|
|
207
|
+
: setVisibility(runtime, args, 'table', vis.visibility, vis.set);
|
|
208
|
+
case '--share':
|
|
209
|
+
return share(runtime, args, 'table');
|
|
210
|
+
case '--acl-list':
|
|
211
|
+
return aclList(runtime, args, 'table');
|
|
212
|
+
case '--acl-revoke':
|
|
213
|
+
return aclRevoke(runtime, args, 'table');
|
|
214
|
+
case '--move':
|
|
215
|
+
return moveObject(runtime, args, 'table');
|
|
216
|
+
default:
|
|
217
|
+
return investorVisibility(runtime, args, 'table');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `bitable field` 的动作收口(逐条对应 Rust 的 `FieldArgs::action()`)。 */
|
|
222
|
+
async function runField(runtime, args) {
|
|
223
|
+
const picked = exactlyOne(
|
|
224
|
+
[
|
|
225
|
+
['--list', flagOn(args, '--list')],
|
|
226
|
+
['--create', flagOn(args, '--create')],
|
|
227
|
+
['--update', args['--update'] !== undefined],
|
|
228
|
+
['--show', args['--show'] !== undefined],
|
|
229
|
+
],
|
|
230
|
+
'bitable field',
|
|
231
|
+
'例:xinhe-oa-cli bitable field --create --table <table_id> --name 金额 --type currency',
|
|
232
|
+
);
|
|
233
|
+
if (picked === '--list') return fieldList(runtime, args);
|
|
234
|
+
if (picked === '--create') return fieldCreate(runtime, args);
|
|
235
|
+
if (picked === '--update') return fieldUpdate(runtime, args);
|
|
236
|
+
return fieldShow(runtime, args);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `bitable record` 的动作收口(逐条对应 Rust 的 `RecordArgs::action()`)。 */
|
|
240
|
+
async function runRecord(runtime, args) {
|
|
241
|
+
const picked = exactlyOne(
|
|
242
|
+
[
|
|
243
|
+
['--list', flagOn(args, '--list')],
|
|
244
|
+
['--create', flagOn(args, '--create')],
|
|
245
|
+
['--update', args['--update'] !== undefined],
|
|
246
|
+
['--delete', args['--delete'] !== undefined],
|
|
247
|
+
['--show', args['--show'] !== undefined],
|
|
248
|
+
],
|
|
249
|
+
'bitable record',
|
|
250
|
+
'例:xinhe-oa-cli bitable record --list --table <table_id> --json',
|
|
251
|
+
);
|
|
252
|
+
// ORG-003:`--dept` 只属于取数动作(表取数的 GET 查询参数是 `dept`)。
|
|
253
|
+
if (args['--dept'] !== undefined && picked !== '--list') {
|
|
254
|
+
throw CliError.usage(`\`bitable record ${picked}\` 不接受 --dept`).withHint(
|
|
255
|
+
'--dept 只用于:xinhe-oa-cli bitable record --list --table <TABLE_ID> --dept <DEPT_ID>',
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (picked === '--list') return recordList(runtime, args);
|
|
259
|
+
if (picked === '--create') return recordCreate(runtime, args);
|
|
260
|
+
if (picked === '--update') return recordUpdate(runtime, args);
|
|
261
|
+
if (picked === '--delete') return recordDelete(runtime, args);
|
|
262
|
+
return recordShow(runtime, args);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `bitable view` 的动作收口(逐条对应 Rust 的 `ViewArgs::action()`)。 */
|
|
266
|
+
async function runView(runtime, args) {
|
|
267
|
+
const picked = exactlyOne(
|
|
268
|
+
[
|
|
269
|
+
['--list', flagOn(args, '--list')],
|
|
270
|
+
['--create', flagOn(args, '--create')],
|
|
271
|
+
['--query', flagOn(args, '--query')],
|
|
272
|
+
['--aggregate', flagOn(args, '--aggregate')],
|
|
273
|
+
['--show', args['--show'] !== undefined],
|
|
274
|
+
],
|
|
275
|
+
'bitable view',
|
|
276
|
+
'例:xinhe-oa-cli bitable view --query --view <view_id> --json',
|
|
277
|
+
);
|
|
278
|
+
if (picked === '--list') return viewList(runtime, args);
|
|
279
|
+
if (picked === '--create') return viewCreate(runtime, args);
|
|
280
|
+
if (picked === '--query') return viewQuery(runtime, args);
|
|
281
|
+
if (picked === '--aggregate') return viewAggregate(runtime, args);
|
|
282
|
+
return viewShow(runtime, args);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `bitable form` 的动作收口(逐条对应 Rust 的 `FormArgs::action()`)。 */
|
|
286
|
+
async function runForm(runtime, args) {
|
|
287
|
+
const picked = exactlyOne(
|
|
288
|
+
[
|
|
289
|
+
['--list', flagOn(args, '--list')],
|
|
290
|
+
['--create', flagOn(args, '--create')],
|
|
291
|
+
['--submit', flagOn(args, '--submit')],
|
|
292
|
+
['--show', args['--show'] !== undefined],
|
|
293
|
+
],
|
|
294
|
+
'bitable form',
|
|
295
|
+
'例:xinhe-oa-cli bitable form --submit --form <form_id> --set 金额=1200',
|
|
296
|
+
);
|
|
297
|
+
if (picked === '--list') return formList(runtime, args);
|
|
298
|
+
if (picked === '--create') return formCreate(runtime, args);
|
|
299
|
+
if (picked === '--submit') return formSubmit(runtime, args);
|
|
300
|
+
return formShow(runtime, args);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** `ObjectVisibilityArgs` 那组旗标(库与表共用一份)。 */
|
|
304
|
+
function visibilityFlags(args) {
|
|
305
|
+
return {
|
|
306
|
+
visibility: optionalId(args, '--visibility'),
|
|
307
|
+
set: flagValue(args, '--set'),
|
|
308
|
+
share: optionalId(args, '--share'),
|
|
309
|
+
aclList: optionalId(args, '--acl-list'),
|
|
310
|
+
aclRevoke: optionalId(args, '--acl-revoke'),
|
|
311
|
+
move: optionalId(args, '--move'),
|
|
312
|
+
investorVisibility: optionalId(args, '--investor-visibility'),
|
|
313
|
+
investorSet: flagValue(args, '--investor-set'),
|
|
314
|
+
archive: args['--archive'] !== undefined,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 裸 `--show`(无值)与"带 ID 的 --show"是两件事:前者是 `--visibility` 的读形态。 */
|
|
319
|
+
function visibilityShowState(args, vis) {
|
|
320
|
+
const showText = flagValue(args, '--show');
|
|
321
|
+
const bareShow = args['--show'] !== undefined && (showText === null || showText.trim().length === 0);
|
|
322
|
+
return { bareShow, visShow: vis.visibility !== null && bareShow };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `--visibility <ID>` 必须再给一个动作:`--show`(读)或 `--set <档位>`(写),互斥。 */
|
|
326
|
+
function validateVisibilityAction(domain, vis, showGiven) {
|
|
327
|
+
if (showGiven === (vis.set !== null)) {
|
|
328
|
+
throw CliError.usage(
|
|
329
|
+
`\`${domain} --visibility <ID>\` 还要给 \`--show\`(读)或 \`--set private|dept|internal\`(写)`,
|
|
330
|
+
).withHint(`例:xinhe-oa-cli ${domain} --visibility <ID> --show --json`);
|
|
331
|
+
}
|
|
332
|
+
if (vis.set !== null) {
|
|
333
|
+
const mode = String(vis.set).trim().toLowerCase();
|
|
334
|
+
if (!['private', 'dept', 'internal'].includes(mode)) {
|
|
335
|
+
throw CliError.usage(
|
|
336
|
+
`\`--set\` 只能是 private | dept | internal(收到 ${JSON.stringify(mode)}); \`public\` 本期没有对外入口`,
|
|
337
|
+
).withHint(`例:xinhe-oa-cli ${domain} --visibility <ID> --set internal --yes`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// 参数小工具
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
/** 取一个"必须给的 ID 位置"旗标(`--archive` / `--show` / `--visibility` …)。 */
|
|
346
|
+
function requireId(args, flag, what) {
|
|
347
|
+
const value = flagValue(args, flag);
|
|
348
|
+
if (value === null || value.trim().length === 0) {
|
|
349
|
+
throw CliError.usage(`\`${what} ${flag}\` 需要 <ID>`).withHint(
|
|
350
|
+
`例:xinhe-oa-cli ${what} ${flag} <ID> --json`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return value.trim();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 取一个必填的文本旗标。 */
|
|
357
|
+
function requireText(args, flag, what, hint) {
|
|
358
|
+
const value = flagValue(args, flag);
|
|
359
|
+
if (value === null) {
|
|
360
|
+
const error = CliError.usage(`\`${what}\` 需要 ${flag}`);
|
|
361
|
+
throw hint ? error.withHint(hint) : error;
|
|
362
|
+
}
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 整数旗标。
|
|
368
|
+
*
|
|
369
|
+
* `min` / `max` 是**本地**约束(契约 §2「参数即约束」):越界在发请求之前判死,
|
|
370
|
+
* 而不是把服务端的 422 原样丢给调用方。
|
|
371
|
+
*/
|
|
372
|
+
function intFlag(args, flag, { min = null, max = null, fallback = null } = {}) {
|
|
373
|
+
const raw = flagValue(args, flag);
|
|
374
|
+
if (raw === null) return fallback;
|
|
375
|
+
const text = String(raw).trim();
|
|
376
|
+
if (text.length === 0) {
|
|
377
|
+
throw CliError.usage(`\`${flag}\` 需要一个取值`).withHint(`例:${flag} 50`);
|
|
378
|
+
}
|
|
379
|
+
if (!/^[+-]?\d+$/.test(text)) {
|
|
380
|
+
throw CliError.usage(`\`${flag}\` 要一个整数,收到 ${JSON.stringify(raw)}`);
|
|
381
|
+
}
|
|
382
|
+
const value = Number.parseInt(text, 10);
|
|
383
|
+
if (min !== null && value < min) throw CliError.usage(`\`${flag}\` 不能小于 ${min}(收到 ${value})`);
|
|
384
|
+
if (max !== null && value > max) throw CliError.usage(`\`${flag}\` 不能大于 ${max}(收到 ${value})`);
|
|
385
|
+
return value;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 取 `<ID>` 这类旗标;没给就是 null(不报错)。 */
|
|
389
|
+
function optionalId(args, flag) {
|
|
390
|
+
const value = flagValue(args, flag);
|
|
391
|
+
if (value === null) return null;
|
|
392
|
+
const trimmed = value.trim();
|
|
393
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 服务端把 refresh 绑在设备上;`GET /me` 是"我属于哪家企业"的唯一事实源。 */
|
|
397
|
+
async function resolveTenant(runtime, requested) {
|
|
398
|
+
const value = requested === null || requested === undefined ? '' : String(requested).trim();
|
|
399
|
+
if (value.length > 0) return value;
|
|
400
|
+
const response = await runtime.sendAuthed('GET', '/me', requestSpec());
|
|
401
|
+
const data = dataOf(response);
|
|
402
|
+
const tenants = Array.isArray(data?.scope?.tenants) ? data.scope.tenants : [];
|
|
403
|
+
if (tenants.length === 1) {
|
|
404
|
+
const id = tenants[0]?.id;
|
|
405
|
+
return typeof id === 'string' ? id : null;
|
|
406
|
+
}
|
|
407
|
+
if (tenants.length === 0) return null;
|
|
408
|
+
throw CliError.usage('你有多个可见企业,请用 --tenant <TENANT_ID> 指明').withDetails({
|
|
409
|
+
visible_tenants: tenants,
|
|
410
|
+
hint: 'xinhe-oa-cli tenant --list',
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Base
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
async function baseList(runtime, args) {
|
|
418
|
+
// 列表**不需要**"唯一可见企业":不点名就列全部可见企业的库(服务端按权限裁剪)。
|
|
419
|
+
// 只有**建**东西时才必须落在某一家企业里(那时才走 resolveTenant)。
|
|
420
|
+
const query = [];
|
|
421
|
+
const tenant = flagValue(args, '--tenant');
|
|
422
|
+
if (tenant !== null && tenant.trim().length > 0) query.push(['tenant', tenant.trim()]);
|
|
423
|
+
query.push(['limit', String(intFlag(args, '--limit', { min: 0, fallback: 100 }))]);
|
|
424
|
+
const includeArchived = flagOn(args, '--include-archived');
|
|
425
|
+
// 归档的库默认不出现(服务端 include_archived 默认 false);要看就显式要。
|
|
426
|
+
if (includeArchived) query.push(['include_archived', 'true']);
|
|
427
|
+
const response = await runtime.sendAuthed('GET', '/bases', requestSpec({ query }));
|
|
428
|
+
const data = dataOf(response);
|
|
429
|
+
runtime.finishFrom(response, {
|
|
430
|
+
bases: Array.isArray(data?.bases) ? data.bases : [],
|
|
431
|
+
total: data?.total ?? null,
|
|
432
|
+
include_archived: includeArchived,
|
|
433
|
+
next_step: '看某个库里的表:xinhe-oa-cli bitable table --list --base <BASE_ID> --json',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function baseCreate(runtime, args) {
|
|
438
|
+
const name = requireText(args, '--name', 'bitable base --create', '例:xinhe-oa-cli bitable base --create --name 开叔企业 --tenant kashu');
|
|
439
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
|
|
440
|
+
if (tenant === null) {
|
|
441
|
+
throw CliError.usage('建库必须指明企业').withHint(
|
|
442
|
+
'加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// 机器名:不给就按显示名推导(中文 → 报错让人显式给,绝不静默造一个 t_1727…)。
|
|
446
|
+
const key = flagValue(args, '--key') ?? deriveKey(name);
|
|
447
|
+
const body = {
|
|
448
|
+
tenant,
|
|
449
|
+
key,
|
|
450
|
+
name,
|
|
451
|
+
description: flagValue(args, '--description'),
|
|
452
|
+
};
|
|
453
|
+
const spec = requestSpec({ body });
|
|
454
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', '/bases', spec);
|
|
455
|
+
const response = await runtime.sendAuthed('POST', '/bases', spec);
|
|
456
|
+
const data = dataOf(response);
|
|
457
|
+
const baseId = typeof data?.id === 'string' ? data.id : '';
|
|
458
|
+
runtime.finishFrom(response, {
|
|
459
|
+
base: data,
|
|
460
|
+
next_step: `建表:xinhe-oa-cli bitable table --create --base ${baseId} --name <表名>`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function baseShow(runtime, args) {
|
|
465
|
+
const baseId = requireId(args, '--show', 'bitable base');
|
|
466
|
+
const response = await runtime.sendAuthed('GET', `/bases/${baseId}`, requestSpec());
|
|
467
|
+
runtime.finishFrom(response, dataOf(response));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* `bitable base --archive`:走服务端 `PATCH /bases/{id}` 的 `status="archived"`。
|
|
472
|
+
*
|
|
473
|
+
* 服务端**没有**硬删端点(只有归档):归档后其下所有表不再可写、历史仍可读、
|
|
474
|
+
* 默认列表里不再出现 —— 这就是"把探路建的垃圾库清掉"在 CLI 时代的正解。
|
|
475
|
+
*/
|
|
476
|
+
async function baseArchive(runtime, args) {
|
|
477
|
+
const baseId = requireId(args, '--archive', 'bitable base');
|
|
478
|
+
runtime.requireYes(
|
|
479
|
+
'bitable base --archive(归档整个库:其下所有表不再可写)',
|
|
480
|
+
`xinhe-oa-cli bitable base --archive ${baseId}`,
|
|
481
|
+
);
|
|
482
|
+
const spec = archiveSpec();
|
|
483
|
+
if (runtime.dryRun) return runtime.dryRunReport('PATCH', `/bases/${baseId}`, spec);
|
|
484
|
+
const response = await runtime.sendAuthed('PATCH', `/bases/${baseId}`, spec);
|
|
485
|
+
const data = dataOf(response);
|
|
486
|
+
runtime.finishFrom(
|
|
487
|
+
response,
|
|
488
|
+
archiveOutput('base', data, {
|
|
489
|
+
next_step:
|
|
490
|
+
'确认它不在默认列表里了:xinhe-oa-cli bitable base --list --json(要看归档的加 --include-archived)',
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 从显示名推一个合法的数据 key(`^[a-z][a-z0-9_]{0,63}$`)。
|
|
497
|
+
*
|
|
498
|
+
* 中文名/数字开头**直接报错**:机器名创建后不可变,静默造一个 `t_1727…`
|
|
499
|
+
* 客户第二天就认不出来是什么了。
|
|
500
|
+
*/
|
|
501
|
+
export function deriveKey(name) {
|
|
502
|
+
let key = '';
|
|
503
|
+
for (const ch of String(name ?? '')) {
|
|
504
|
+
if (/[A-Za-z0-9]/.test(ch)) key += ch.toLowerCase();
|
|
505
|
+
else if (ch.charCodeAt(0) < 128) key += '_';
|
|
506
|
+
}
|
|
507
|
+
while (key.includes('__')) key = key.split('__').join('_');
|
|
508
|
+
key = key.replace(/^_+/, '').replace(/_+$/, '');
|
|
509
|
+
if (key.length > 48) key = key.slice(0, 48);
|
|
510
|
+
if (key.length === 0 || !/^[a-z]/.test(key)) {
|
|
511
|
+
throw CliError.usage(
|
|
512
|
+
`显示名 ${JSON.stringify(name)} 推不出合法的机器名(key 只能是小写字母开头 + 字母/数字/下划线)`,
|
|
513
|
+
).withHint('显式给一个:--key kashu(机器名创建后不可变,显示名随时可改)');
|
|
514
|
+
}
|
|
515
|
+
return key;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** 推不出 key 时的兜底(服务端只要求形状合法,形状之外没有语义)。 */
|
|
519
|
+
function fallbackKey(prefix) {
|
|
520
|
+
return `${prefix}${newIdempotencyKey('x').split(':')[1].slice(0, 8)}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// Table
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
async function tableList(runtime, args) {
|
|
527
|
+
const baseId = requireText(args, '--base', 'bitable table --list', '先看有哪些库:xinhe-oa-cli bitable base --list --json');
|
|
528
|
+
const includeArchived = flagOn(args, '--include-archived');
|
|
529
|
+
const query = [];
|
|
530
|
+
// 归档的表默认不出现(服务端 include_archived 默认 false)。
|
|
531
|
+
if (includeArchived) query.push(['include_archived', 'true']);
|
|
532
|
+
const response = await runtime.sendAuthed(
|
|
533
|
+
'GET',
|
|
534
|
+
`/bases/${baseId}/tables`,
|
|
535
|
+
requestSpec({ query }),
|
|
536
|
+
);
|
|
537
|
+
const data = dataOf(response);
|
|
538
|
+
runtime.finishFrom(response, {
|
|
539
|
+
tables: Array.isArray(data?.tables) ? data.tables : [],
|
|
540
|
+
total: data?.total ?? null,
|
|
541
|
+
include_archived: includeArchived,
|
|
542
|
+
next_step: '看表里的字段:xinhe-oa-cli bitable field --list --table <TABLE_ID> --json',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function tableCreate(runtime, args) {
|
|
547
|
+
const baseId = requireText(args, '--base', 'bitable table --create', '先看有哪些库:xinhe-oa-cli bitable base --list --json');
|
|
548
|
+
const name = requireText(args, '--name', 'bitable table --create');
|
|
549
|
+
const key = optionalId(args, '--key') ?? safeDeriveKey(name, 't');
|
|
550
|
+
const body = {
|
|
551
|
+
key,
|
|
552
|
+
name,
|
|
553
|
+
description: flagValue(args, '--description'),
|
|
554
|
+
semantic_kind: flagValue(args, '--semantic-kind'),
|
|
555
|
+
};
|
|
556
|
+
const spec = requestSpec({ body });
|
|
557
|
+
const path = `/bases/${baseId}/tables`;
|
|
558
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
559
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
560
|
+
const data = dataOf(response);
|
|
561
|
+
const tableId = typeof data?.id === 'string' ? data.id : '';
|
|
562
|
+
runtime.finishFrom(response, {
|
|
563
|
+
table: data,
|
|
564
|
+
next_step: `建字段:xinhe-oa-cli bitable field --create --table ${tableId} --name 金额 --type currency`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function safeDeriveKey(name, prefix) {
|
|
569
|
+
try {
|
|
570
|
+
return deriveKey(name);
|
|
571
|
+
} catch {
|
|
572
|
+
return fallbackKey(prefix);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function tableShow(runtime, args) {
|
|
577
|
+
const tableId = requireId(args, '--show', 'bitable table');
|
|
578
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}`, requestSpec());
|
|
579
|
+
runtime.finishFrom(response, dataOf(response));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function tableArchive(runtime, args) {
|
|
583
|
+
const tableId = requireId(args, '--archive', 'bitable table');
|
|
584
|
+
runtime.requireYes(
|
|
585
|
+
'bitable table --archive(归档这张表:不再可写、默认列表不再出现)',
|
|
586
|
+
`xinhe-oa-cli bitable table --archive ${tableId}`,
|
|
587
|
+
);
|
|
588
|
+
// 归档前先看一眼它属于哪个 base:归档完的下一步就是"回那个库里确认它不在了"。
|
|
589
|
+
let baseId = optionalId(args, '--base');
|
|
590
|
+
if (baseId === null) {
|
|
591
|
+
try {
|
|
592
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}`, requestSpec());
|
|
593
|
+
const data = dataOf(response);
|
|
594
|
+
baseId = typeof data?.base_id === 'string' ? data.base_id : null;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
// 读不到详情不该挡住归档本身(服务端仍是权威)。
|
|
597
|
+
warn(`归档前读表详情失败(${err.code ?? 'cli.error'}:${err.message}),继续归档`);
|
|
598
|
+
baseId = null;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const spec = archiveSpec();
|
|
602
|
+
if (runtime.dryRun) return runtime.dryRunReport('PATCH', `/tables/${tableId}`, spec);
|
|
603
|
+
const response = await runtime.sendAuthed('PATCH', `/tables/${tableId}`, spec);
|
|
604
|
+
const data = dataOf(response);
|
|
605
|
+
const nextStep =
|
|
606
|
+
baseId !== null
|
|
607
|
+
? `确认它不在默认列表里了:xinhe-oa-cli bitable table --list --base ${baseId} --json(要看归档的加 --include-archived)`
|
|
608
|
+
: '确认它不在默认列表里了:xinhe-oa-cli bitable table --list --base <BASE_ID> --json(要看归档的加 --include-archived)';
|
|
609
|
+
runtime.finishFrom(response, archiveOutput('table', data, { next_step: nextStep }));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 归档请求体:服务端只认 `status`(`active` / `archived`)。 */
|
|
613
|
+
function archiveSpec() {
|
|
614
|
+
return requestSpec({ body: { status: 'archived' } });
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 归档结果:人类可读面要能一眼看到"哪一张、现在什么状态"。 */
|
|
618
|
+
function archiveOutput(kind, data, extra) {
|
|
619
|
+
const out = {
|
|
620
|
+
archived: true,
|
|
621
|
+
[kind]: data,
|
|
622
|
+
id: data?.id ?? null,
|
|
623
|
+
name: data?.name ?? null,
|
|
624
|
+
status: data?.status ?? 'archived',
|
|
625
|
+
note: '归档 = 不再接受写入、历史仍可读、默认列表里不再出现;服务端没有硬删端点,归档就是 CLI 的删表动作',
|
|
626
|
+
};
|
|
627
|
+
for (const [key, value] of Object.entries(extra ?? {})) out[key] = value;
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Field
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
async function fieldList(runtime, args) {
|
|
635
|
+
const tableId = requireText(args, '--table', 'bitable field --list');
|
|
636
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}/fields`, requestSpec());
|
|
637
|
+
const data = dataOf(response);
|
|
638
|
+
runtime.finishFrom(response, {
|
|
639
|
+
fields: Array.isArray(data?.fields) ? data.fields : [],
|
|
640
|
+
total: data?.total ?? null,
|
|
641
|
+
note: '写记录时 `--set` 的键可以是 semantic_key,也可以是 field_id',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function fieldCreate(runtime, args) {
|
|
646
|
+
const tableId = requireText(args, '--table', 'bitable field --create');
|
|
647
|
+
const name = requireText(args, '--name', 'bitable field --create');
|
|
648
|
+
const fieldType = requireText(
|
|
649
|
+
args,
|
|
650
|
+
'--type',
|
|
651
|
+
'bitable field --create',
|
|
652
|
+
`类型只能是:${imp.FIELD_TYPES.join(' / ')}`,
|
|
653
|
+
);
|
|
654
|
+
if (!imp.FIELD_TYPES.includes(fieldType)) {
|
|
655
|
+
throw CliError.usage(`不支持的字段类型 ${JSON.stringify(fieldType)}`).withHint(
|
|
656
|
+
`类型只能是:${imp.FIELD_TYPES.join(' / ')}`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const semanticKey = optionalId(args, '--semantic-key') ?? safeDeriveKey(name, 'f');
|
|
660
|
+
const body = {
|
|
661
|
+
semantic_key: semanticKey,
|
|
662
|
+
name,
|
|
663
|
+
type: fieldType,
|
|
664
|
+
};
|
|
665
|
+
const description = flagValue(args, '--description');
|
|
666
|
+
if (description !== null) body.description = description;
|
|
667
|
+
if (flagOn(args, '--required')) body.is_required = true;
|
|
668
|
+
if (flagOn(args, '--sensitive')) body.is_sensitive = true;
|
|
669
|
+
const options = flagValue(args, '--options');
|
|
670
|
+
if (options !== null) {
|
|
671
|
+
const items = options
|
|
672
|
+
.split(',')
|
|
673
|
+
.map((item) => item.trim())
|
|
674
|
+
.filter((item) => item.length > 0);
|
|
675
|
+
if (items.length === 0) {
|
|
676
|
+
throw CliError.usage('--options 里没有有效选项').withHint('例:--options 待审,已审,已驳回');
|
|
677
|
+
}
|
|
678
|
+
if (fieldType !== 'single_select' && fieldType !== 'multi_select') {
|
|
679
|
+
throw CliError.usage(
|
|
680
|
+
`--options 只对 single_select / multi_select 有意义(当前 --type ${fieldType})`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
body.options = items;
|
|
684
|
+
}
|
|
685
|
+
const spec = requestSpec({ body });
|
|
686
|
+
const path = `/tables/${tableId}/fields`;
|
|
687
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
688
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
689
|
+
const data = dataOf(response);
|
|
690
|
+
runtime.finishFrom(response, {
|
|
691
|
+
field: data,
|
|
692
|
+
note: 'semantic_key 与 type 创建后不可变;要改类型 = 新建字段 + 回填',
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function fieldUpdate(runtime, args) {
|
|
697
|
+
const fieldId = requireId(args, '--update', 'bitable field');
|
|
698
|
+
const body = {};
|
|
699
|
+
const name = flagValue(args, '--name');
|
|
700
|
+
if (name !== null) body.name = name;
|
|
701
|
+
const description = flagValue(args, '--description');
|
|
702
|
+
if (description !== null) body.description = description;
|
|
703
|
+
if (flagOn(args, '--required')) body.is_required = true;
|
|
704
|
+
if (flagOn(args, '--archive')) body.status = 'archived';
|
|
705
|
+
const options = flagValue(args, '--options');
|
|
706
|
+
if (options !== null) {
|
|
707
|
+
body.options = options
|
|
708
|
+
.split(',')
|
|
709
|
+
.map((item) => item.trim())
|
|
710
|
+
.filter((item) => item.length > 0);
|
|
711
|
+
}
|
|
712
|
+
if (Object.keys(body).length === 0) {
|
|
713
|
+
throw CliError.usage('`bitable field --update` 至少要给一个要改的东西').withHint(
|
|
714
|
+
'例:--name 金额(含税)| --options a,b | --archive',
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (flagValue(args, '--type') !== null || flagValue(args, '--semantic-key') !== null) {
|
|
718
|
+
throw CliError.usage('semantic_key 与 type 创建后不可变,不能改').withHint(
|
|
719
|
+
'要改类型就新建一个字段再回填数据(服务端也是这个口径)',
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const spec = requestSpec({ body });
|
|
723
|
+
const path = `/fields/${fieldId}`;
|
|
724
|
+
if (runtime.dryRun) return runtime.dryRunReport('PATCH', path, spec);
|
|
725
|
+
const response = await runtime.sendAuthed('PATCH', path, spec);
|
|
726
|
+
runtime.finishFrom(response, { field: dataOf(response) });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function fieldShow(_runtime, args) {
|
|
730
|
+
// 服务端没有 `GET /fields/{id}`:字段详情从所在表的字段列表里挑。
|
|
731
|
+
const fieldId = requireId(args, '--show', 'bitable field');
|
|
732
|
+
throw CliError.usage('`bitable field --show` 需要知道字段属于哪张表').withHint(
|
|
733
|
+
`用 xinhe-oa-cli bitable field --list --table <TABLE_ID> --json,再按 id 找 ${fieldId}`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// Record —— 取数(表取数 = limit/offset;视图 = 游标)
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
async function recordList(runtime, args) {
|
|
741
|
+
const tableId = requireText(args, '--table', 'bitable record --list');
|
|
742
|
+
const viewId = optionalId(args, '--view');
|
|
743
|
+
const dept = optionalId(args, '--dept');
|
|
744
|
+
const offsetRaw = flagValue(args, '--offset');
|
|
745
|
+
const offset = offsetRaw === null ? null : intFlag(args, '--offset', { min: 0 });
|
|
746
|
+
const limit = intFlag(args, '--limit', { fallback: 50 });
|
|
747
|
+
const all = flagOn(args, '--all');
|
|
748
|
+
|
|
749
|
+
// 带 `--view` 就走视图取数(服务端会套用视图的过滤/排序/列)。
|
|
750
|
+
if (viewId !== null) {
|
|
751
|
+
if (dept !== null) {
|
|
752
|
+
// `--dept` 是 `GET /tables/{id}/records` 上的查询参数;视图取数是另一个入口
|
|
753
|
+
// (`POST /views/{id}/query`,按视图自己的过滤走)。静默忽略会让调用方
|
|
754
|
+
// 以为"收窄了",实际没窄 —— 那正是 ORG-003 要防的事,所以这里判死。
|
|
755
|
+
throw CliError.usage(
|
|
756
|
+
'`--dept` 只用于表取数(`bitable record --list --table <TABLE_ID>`)',
|
|
757
|
+
).withHint(
|
|
758
|
+
`视图取数请把部门条件写进视图过滤:xinhe-oa-cli bitable view --query --view ${viewId} --filter <字段>:eq:<值>`,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
if (offset !== null) {
|
|
762
|
+
// 视图取数是**游标**分页(回 `next_cursor`),服务端根本没有 offset 这个参数。
|
|
763
|
+
// 与其让 `--offset` 被静默忽略(调用方以为翻页了,实际每次都是第一页),
|
|
764
|
+
// 不如在这里说清楚续页该用哪个参数。
|
|
765
|
+
throw CliError.usage(
|
|
766
|
+
'`--offset` 只用于 `bitable record --list --table`(表取数是 limit/offset 分页)',
|
|
767
|
+
).withHint(
|
|
768
|
+
'视图取数是游标分页:续页用 `xinhe-oa-cli bitable view --query --view <VIEW_ID> --cursor <上一页的 next_cursor>`',
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
return recordListViaView(runtime, viewId, { limit, all, filters: flagList(args, '--filter') });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// 服务端 `GET /tables/{id}/records` 的 limit 上界是 1000。超了服务端回
|
|
775
|
+
// `request.invalid` —— 那个 422 指不到参数名;契约 §2「参数即约束」要求在这里本地
|
|
776
|
+
// 判成用法错误(退出码 2、不发请求)。
|
|
777
|
+
if (limit < 1 || limit > MAX_PAGE_LIMIT) {
|
|
778
|
+
throw CliError.usage(
|
|
779
|
+
`--limit 必须在 1..=${MAX_PAGE_LIMIT}(服务端上限 ${MAX_PAGE_LIMIT}),收到 ${limit}`,
|
|
780
|
+
).withHint(
|
|
781
|
+
`翻全表用 --all(每页 ${MAX_PAGE_LIMIT} 自动翻,可用 --workers 并发取页);只取一页用 --limit N --offset N`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const ast = filtersToAst(flagList(args, '--filter'));
|
|
786
|
+
if (ast !== null && !all) {
|
|
787
|
+
// 服务端的 `GET /tables/{id}/records` 不接受任意过滤条件(只有 order/limit);
|
|
788
|
+
// 有过滤就翻页在**客户端**筛 —— 这正是"业务计算在客户端"的口径。
|
|
789
|
+
verbose(runtime.verbose, '服务端记录列表不支持过滤条件:改在客户端筛选(--all 会翻完各页)');
|
|
790
|
+
}
|
|
791
|
+
if (ast !== null && offset !== null) {
|
|
792
|
+
// 过滤在客户端做,而 offset 是**服务端结果里**的位置:两者叠加时下一页要看
|
|
793
|
+
// `next_offset`(服务端位置),不能拿"这一页返回了几条"去推。
|
|
794
|
+
verbose(
|
|
795
|
+
runtime.verbose,
|
|
796
|
+
'过滤在客户端做:--offset 指服务端(过滤前)的位置,这一页过滤后可能不足 --limit 条;续页看 next_offset',
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const startOffset = offset ?? 0;
|
|
801
|
+
const workers = clamp(intFlag(args, '--workers', { fallback: 1 }), 1, MAX_PAGE_WORKERS);
|
|
802
|
+
const store = optionalId(args, '--store');
|
|
803
|
+
// 表取数用 `limit/offset` 翻页 —— 那就**必须**用一个全序排序,否则页边界会飘。
|
|
804
|
+
const order = recordOrderParam(flagValue(args, '--order'));
|
|
805
|
+
const collected = [];
|
|
806
|
+
let currentOffset = startOffset;
|
|
807
|
+
let pageLen = 0;
|
|
808
|
+
let fetched = 0;
|
|
809
|
+
let total = null;
|
|
810
|
+
let reachedEnd = false;
|
|
811
|
+
const requestIds = [];
|
|
812
|
+
let lastResponse = null;
|
|
813
|
+
|
|
814
|
+
const pageQuery = (at) => recordPageQuery({ limit, order, store, dept }, at);
|
|
815
|
+
|
|
816
|
+
// 第一页永远串行:既拿 `total`(后面并发翻页要用它算页数),也顺带确认
|
|
817
|
+
// "这条路走得通"——第一页就 4xx 时不该先并发开一堆注定失败的请求。
|
|
818
|
+
{
|
|
819
|
+
const response = await runtime.sendAuthed(
|
|
820
|
+
'GET',
|
|
821
|
+
`/tables/${tableId}/records`,
|
|
822
|
+
requestSpec({ query: pageQuery(currentOffset) }),
|
|
823
|
+
);
|
|
824
|
+
if (response.request_id) requestIds.push(response.request_id);
|
|
825
|
+
const data = dataOf(response);
|
|
826
|
+
const page = pageRecords(data);
|
|
827
|
+
total = data?.total ?? total;
|
|
828
|
+
pageLen = page.length;
|
|
829
|
+
fetched += pageLen;
|
|
830
|
+
collected.push(...page);
|
|
831
|
+
lastResponse = response;
|
|
832
|
+
// 不带 `--all`(只取一页)或第一页就没取满(已经到底了)。
|
|
833
|
+
reachedEnd = all && pageLen < limit;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (all && !reachedEnd) {
|
|
837
|
+
const knownTotal = typeof total === 'number' && Number.isInteger(total) ? total : null;
|
|
838
|
+
// 并发翻页的**前提**是知道总行数(不然不知道要取几页,只能一页页问到空为止)。
|
|
839
|
+
// 有 total 时页与页彼此独立,一次发出去比串行快一个量级。
|
|
840
|
+
if (workers > 1 && knownTotal !== null && knownTotal > startOffset + pageLen) {
|
|
841
|
+
const offsets = [];
|
|
842
|
+
let next = startOffset + limit;
|
|
843
|
+
while (next < knownTotal) {
|
|
844
|
+
if (next > MAX_PAGING_OFFSET) break;
|
|
845
|
+
offsets.push(next);
|
|
846
|
+
next += limit;
|
|
847
|
+
}
|
|
848
|
+
if (offsets.length > 0) {
|
|
849
|
+
verbose(runtime.verbose, `--all 并发取页:${offsets.length} 页 / 并发 ${workers}`);
|
|
850
|
+
// 只有"这批页确实盖到了 total"才算翻到底;被 10 万条护栏截断时不能
|
|
851
|
+
// 对外声称"整表",完整性自检也就不该下结论。
|
|
852
|
+
const last = offsets[offsets.length - 1];
|
|
853
|
+
const coveredToEnd = last + limit >= knownTotal;
|
|
854
|
+
const pages = await fetchRecordPages(runtime, tableId, pageQuery, offsets, workers);
|
|
855
|
+
// 已按 offset 排好序:拼接结果与串行翻页逐行一致。
|
|
856
|
+
for (const response of pages) {
|
|
857
|
+
if (response.request_id) requestIds.push(response.request_id);
|
|
858
|
+
const data = dataOf(response);
|
|
859
|
+
const page = pageRecords(data);
|
|
860
|
+
pageLen = page.length;
|
|
861
|
+
fetched += pageLen;
|
|
862
|
+
collected.push(...page);
|
|
863
|
+
total = data?.total ?? total;
|
|
864
|
+
lastResponse = response;
|
|
865
|
+
}
|
|
866
|
+
reachedEnd = coveredToEnd;
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
for (;;) {
|
|
870
|
+
currentOffset += limit;
|
|
871
|
+
if (currentOffset > MAX_PAGING_OFFSET) {
|
|
872
|
+
// 护栏只挡"一次翻完",不挡续取:这里的 offset 就是下一页的入口
|
|
873
|
+
// (输出里的 next_offset 与它一致,has_more 仍是 true)。
|
|
874
|
+
warn(`翻页超过 10 万条,停下(避免把服务端拖住);要接着取:--offset ${currentOffset}`);
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
const response = await runtime.sendAuthed(
|
|
878
|
+
'GET',
|
|
879
|
+
`/tables/${tableId}/records`,
|
|
880
|
+
requestSpec({ query: pageQuery(currentOffset) }),
|
|
881
|
+
);
|
|
882
|
+
if (response.request_id) requestIds.push(response.request_id);
|
|
883
|
+
const data = dataOf(response);
|
|
884
|
+
const page = pageRecords(data);
|
|
885
|
+
total = data?.total ?? total;
|
|
886
|
+
pageLen = page.length;
|
|
887
|
+
fetched += pageLen;
|
|
888
|
+
collected.push(...page);
|
|
889
|
+
lastResponse = response;
|
|
890
|
+
if (pageLen < limit) {
|
|
891
|
+
reachedEnd = true;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// 完整性自检:`--all` 号称"整表",那就得真的是整表。
|
|
899
|
+
//
|
|
900
|
+
// 这一条是现场复测逼出来的:`--all --limit 50` 翻 910 行,返回 910 条里只有
|
|
901
|
+
// 683 个唯一值(227 行重复、227 行没取到),**还不报错** —— 调用方拿到一个
|
|
902
|
+
// 看着完整的残缺结果。服务端已经补了稳定排序,但"服务端排序不稳定"
|
|
903
|
+
// (老部署)与"翻页期间表被写过"(页边界错位)都可能再犯,
|
|
904
|
+
// 所以这里在客户端兜一道:**宁可报错,也不静默交半份数据**。
|
|
905
|
+
const integrity = pagingIntegrity(collected, total, startOffset, reachedEnd);
|
|
906
|
+
const failure = integrityError(integrity, tableId, order, limit);
|
|
907
|
+
if (failure !== null) throw failure;
|
|
908
|
+
|
|
909
|
+
// 客户端过滤
|
|
910
|
+
let filtered = collected;
|
|
911
|
+
if (ast !== null) {
|
|
912
|
+
const before = collected.length;
|
|
913
|
+
filtered = collected.filter((record) => recordMatches(record, ast));
|
|
914
|
+
verbose(runtime.verbose, `客户端过滤:${before} → ${filtered.length} 条`);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const count = filtered.length;
|
|
918
|
+
const [nextOffset, hasMore] = pagingState(startOffset, fetched, pageLen, limit, total);
|
|
919
|
+
const out = {
|
|
920
|
+
records: filtered,
|
|
921
|
+
total,
|
|
922
|
+
count,
|
|
923
|
+
limit,
|
|
924
|
+
// 本页第一条在服务端结果里的位置(= 调用方给的 `--offset`,不传是 0)。
|
|
925
|
+
offset: startOffset,
|
|
926
|
+
// **实际发出去的**排序(CLI 总会补一个唯一的 `id` 打破并列)。
|
|
927
|
+
order,
|
|
928
|
+
// 服务端实际取回的行数(**客户端过滤之前**;`--all` 时是所有页之和)。
|
|
929
|
+
fetched,
|
|
930
|
+
unique: integrity.unique,
|
|
931
|
+
consistent: integrity.consistent,
|
|
932
|
+
has_more: hasMore,
|
|
933
|
+
next_offset: hasMore ? nextOffset : null,
|
|
934
|
+
filtered_by_client: ast !== null,
|
|
935
|
+
request_ids: requestIds,
|
|
936
|
+
next_step: recordNextStep(tableId, limit, hasMore ? nextOffset : null),
|
|
937
|
+
};
|
|
938
|
+
if (lastResponse !== null) runtime.finishFrom(lastResponse, out);
|
|
939
|
+
else runtime.finish(out);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** 表取数(`limit/offset` 分页)**实际发出去**的 `order`。 */
|
|
943
|
+
function recordOrderParam(raw) {
|
|
944
|
+
const trimmed = raw === null || raw === undefined ? '' : String(raw).trim();
|
|
945
|
+
// 不给 `--order`(或给了空串/只有空白)= 服务端的默认口径:最新在前。
|
|
946
|
+
const normalized = trimmed.length > 0 ? orderParam(trimmed) : 'created_at:desc';
|
|
947
|
+
const pieces = normalized.split(',');
|
|
948
|
+
if (pieces.some((piece) => piece.split(':')[0].trim() === 'id')) return normalized;
|
|
949
|
+
// 打破键的方向跟随最后一个键:`created_at:desc` → 并列段里新的在前;
|
|
950
|
+
// `created_at:asc` → 并列段里旧的在前。方向本身不影响正确性(只要是全序),
|
|
951
|
+
// 跟着走只是让"同一批导入的 200 行"在两种排序下都保持插入顺序。
|
|
952
|
+
const last = pieces[pieces.length - 1];
|
|
953
|
+
const colon = last.lastIndexOf(':');
|
|
954
|
+
const direction = colon > 0 ? last.slice(colon + 1).trim().toLowerCase() : '';
|
|
955
|
+
return `${normalized},id:${direction === 'asc' ? 'asc' : 'desc'}`;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** 记录列表的查询参数(串行与并发共用同一段,避免两条路径漂移)。 */
|
|
959
|
+
function recordPageQuery({ limit, order, store, dept }, offset) {
|
|
960
|
+
const query = [
|
|
961
|
+
['limit', String(limit)],
|
|
962
|
+
['offset', String(offset)],
|
|
963
|
+
// 永远显式带上 order(以唯一的 `id` 收尾的全序):老服务端的默认排序只有
|
|
964
|
+
// `created_at DESC`、并列段顺序不保证 —— 靠客户端把 `id` 补上才不重不漏。
|
|
965
|
+
['order', order],
|
|
966
|
+
];
|
|
967
|
+
if (store !== null && store !== undefined) query.push(['store_id', store]);
|
|
968
|
+
// ORG-003:部门过滤(只收窄)。不传时这一段什么都不发 ——
|
|
969
|
+
// 授权自带的部门档位由服务端夹着,CLI 不参与放宽。
|
|
970
|
+
if (dept !== null && dept !== undefined) query.push(['dept', dept]);
|
|
971
|
+
return query;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* 并发取多页(`--all --workers N`):页与页彼此独立,一次发一批。
|
|
976
|
+
*
|
|
977
|
+
* 并发前先取一次凭据,**续期不在并发里做**(refresh 是一次性令牌,
|
|
978
|
+
* 并发刷新会互相吊销)。同一波里按 offset 排序后再拼接:并发返回顺序不定,
|
|
979
|
+
* 而 `--all` 的结果必须与串行翻页**逐行一致**。
|
|
980
|
+
*/
|
|
981
|
+
async function fetchRecordPages(runtime, tableId, pageQuery, offsets, workers) {
|
|
982
|
+
await runtime.ensureFreshToken();
|
|
983
|
+
const out = [];
|
|
984
|
+
const queue = [...offsets];
|
|
985
|
+
while (queue.length > 0) {
|
|
986
|
+
const take = Math.min(workers, queue.length);
|
|
987
|
+
const wave = queue.splice(0, take);
|
|
988
|
+
const results = await Promise.all(
|
|
989
|
+
wave.map(async (offset) => {
|
|
990
|
+
const response = await runtime.sendAuthed(
|
|
991
|
+
'GET',
|
|
992
|
+
`/tables/${tableId}/records`,
|
|
993
|
+
requestSpec({ query: pageQuery(offset) }),
|
|
994
|
+
);
|
|
995
|
+
return { offset, response };
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
results.sort((left, right) => left.offset - right.offset);
|
|
999
|
+
for (const item of results) out.push(item.response);
|
|
1000
|
+
}
|
|
1001
|
+
return out;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** 响应里的行数组:**缺键或类型不对 = 契约错误**,绝不静默当空数组。 */
|
|
1005
|
+
function contractError(data, expected, what) {
|
|
1006
|
+
const keys = data && typeof data === 'object' && !Array.isArray(data) ? Object.keys(data).sort() : [];
|
|
1007
|
+
const expectedText = expected.map((key) => `\`${key}\``).join(' 或 ');
|
|
1008
|
+
return new CliError(
|
|
1009
|
+
Kind.Server,
|
|
1010
|
+
'cli.contract',
|
|
1011
|
+
`${what} 的响应里没有 ${expectedText} 数组 —— 客户端与服务端的响应形状对不上`,
|
|
1012
|
+
)
|
|
1013
|
+
.withDetails({ expected_keys: expected, response_keys: keys })
|
|
1014
|
+
.withHint('先升级客户端:xinhe-oa-cli doctor --upgrade;仍复现就把 response_keys 报上来');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function requiredRows(data, key, what) {
|
|
1018
|
+
const rows = data?.[key];
|
|
1019
|
+
if (Array.isArray(rows)) return rows;
|
|
1020
|
+
throw contractError(data, [key], what);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** 表取数(`GET /tables/{id}/records`)的一页 `records`。 */
|
|
1024
|
+
function pageRecords(data) {
|
|
1025
|
+
return requiredRows(data, 'records', '表取数');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* 视图取数 / 视图聚合的行数组。
|
|
1030
|
+
*
|
|
1031
|
+
* 服务端的键是 **`items`**(`query_engine.list_records` / `aggregate_records`);`records`
|
|
1032
|
+
* 只作为对更早服务端形状的回落。0.1.4 的缺陷(docs/issues/CLI-089)是只读 `records`
|
|
1033
|
+
* —— 于是**有数据的视图读成空**,`ok:true`、不报错、不告警。契约错位以后还可能再犯,
|
|
1034
|
+
* 但"读不到就说读不到"必须钉死:宁可退出码 6,也不把空结果当答案交出去。
|
|
1035
|
+
*/
|
|
1036
|
+
function viewItems(data, what) {
|
|
1037
|
+
for (const key of ['items', 'records']) {
|
|
1038
|
+
if (Array.isArray(data?.[key])) return data[key];
|
|
1039
|
+
}
|
|
1040
|
+
throw contractError(data, ['items', 'records'], what);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** 翻页结果的完整性:唯一行数、是否有重复、是否与 `total` 对得上。 */
|
|
1044
|
+
function pagingIntegrity(records, total, startOffset, reachedEnd) {
|
|
1045
|
+
const fetched = records.length;
|
|
1046
|
+
// 只按 `id` 去重:`id` 是主键,"两条记录同一个 id"就是服务端把同一行发了两遍。
|
|
1047
|
+
// 没有 `id` 的项(投影坏了才会有)不参与去重,免得把"缺字段"误报成"重复"。
|
|
1048
|
+
const ids = records
|
|
1049
|
+
.map((item) => (item && typeof item.id === 'string' ? item.id : null))
|
|
1050
|
+
.filter((id) => id !== null);
|
|
1051
|
+
const withoutId = fetched - ids.length;
|
|
1052
|
+
const unique = new Set(ids).size + withoutId;
|
|
1053
|
+
const duplicates = Math.max(fetched - unique, 0);
|
|
1054
|
+
const expected = typeof total === 'number' && Number.isInteger(total)
|
|
1055
|
+
? Math.max(total - startOffset, 0)
|
|
1056
|
+
: null;
|
|
1057
|
+
const missing = expected !== null && reachedEnd ? Math.max(expected - unique, 0) : 0;
|
|
1058
|
+
return {
|
|
1059
|
+
fetched,
|
|
1060
|
+
unique,
|
|
1061
|
+
duplicates,
|
|
1062
|
+
expected,
|
|
1063
|
+
missing,
|
|
1064
|
+
reached_end: reachedEnd,
|
|
1065
|
+
consistent: duplicates === 0 && missing === 0,
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* 完整性不过关时把"残缺结果"变成一条**错误**。
|
|
1071
|
+
*
|
|
1072
|
+
* 纪律:宁可让调用方重跑一次,也不要把"看着像全量"的半份数据交出去
|
|
1073
|
+
* —— 现场那次 910 行只回 683 个唯一值,就是因为没人报错。
|
|
1074
|
+
*/
|
|
1075
|
+
function integrityError(integrity, tableId, order, limit) {
|
|
1076
|
+
if (integrity.consistent) return null;
|
|
1077
|
+
const why =
|
|
1078
|
+
integrity.duplicates > 0
|
|
1079
|
+
? `翻页读到的 ${integrity.fetched} 行里只有 ${integrity.unique} 个唯一记录(${integrity.duplicates} 行重复)`
|
|
1080
|
+
: `翻完了却只读到 ${integrity.unique} 行,服务端说有 ${integrity.expected ?? 0} 行(少 ${integrity.missing} 行)`;
|
|
1081
|
+
return new CliError(
|
|
1082
|
+
Kind.Conflict,
|
|
1083
|
+
'cli.paging_unstable',
|
|
1084
|
+
`${why} —— 这份结果不是一致的快照,已拒绝按全量返回`,
|
|
1085
|
+
)
|
|
1086
|
+
.withDetails({
|
|
1087
|
+
table_id: tableId,
|
|
1088
|
+
order,
|
|
1089
|
+
limit,
|
|
1090
|
+
fetched: integrity.fetched,
|
|
1091
|
+
unique: integrity.unique,
|
|
1092
|
+
duplicates: integrity.duplicates,
|
|
1093
|
+
expected: integrity.expected,
|
|
1094
|
+
missing: integrity.missing,
|
|
1095
|
+
reached_end: integrity.reached_end,
|
|
1096
|
+
})
|
|
1097
|
+
.withHint(
|
|
1098
|
+
`重跑一次(多半是翻页期间有人在写这张表);要一致的快照用视图的游标分页:` +
|
|
1099
|
+
`xinhe-oa-cli bitable view --query --view <VIEW_ID> --all --json(排序键已含 id:${order})`,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** 分页进度:`next_offset`(下一页从哪开始)与 `has_more`。 */
|
|
1104
|
+
function pagingState(startOffset, fetched, pageLen, limit, total) {
|
|
1105
|
+
const nextOffset = startOffset + fetched;
|
|
1106
|
+
const hasMore =
|
|
1107
|
+
typeof total === 'number' && Number.isInteger(total) ? nextOffset < total : pageLen >= limit;
|
|
1108
|
+
return [nextOffset, hasMore];
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** 下一页 / 下一步该敲哪条命令(还有下一页时优先回答"怎么翻页")。 */
|
|
1112
|
+
function recordNextStep(tableId, limit, nextOffset) {
|
|
1113
|
+
if (nextOffset !== null) {
|
|
1114
|
+
return `还有下一页:xinhe-oa-cli bitable record --list --table ${tableId} --limit ${limit} --offset ${nextOffset} --json`;
|
|
1115
|
+
}
|
|
1116
|
+
return '改一条记录:xinhe-oa-cli bitable record --update <RECORD_ID> --version <N> --set 键=值';
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* `record --list --view <VIEW_ID>`:视图取数(游标分页),输出键仍是 `records`。
|
|
1121
|
+
*
|
|
1122
|
+
* 注意 `--cursor`:Rust 0.1.7 的 `view --query` 收下了 `--cursor` 却**从不使用**
|
|
1123
|
+
* (`cursor` 从 `None` 起步,只由响应里的 `next_cursor` 推进)。这里**照抄该行为**:
|
|
1124
|
+
* 行为等价优先于"顺手修一个上游缺陷"——两条实现同参数必须发同样的请求体,
|
|
1125
|
+
* 否则对拍永远红。真要用它,得 Rust 与 Node 一起改(记在交付说明里)。
|
|
1126
|
+
*/
|
|
1127
|
+
async function recordListViaView(runtime, viewId, { limit, all, filters }) {
|
|
1128
|
+
const collected = [];
|
|
1129
|
+
let cursor = null;
|
|
1130
|
+
const pageSize = clamp(limit, 1, 1000);
|
|
1131
|
+
let lastResponse = null;
|
|
1132
|
+
let pages = 0;
|
|
1133
|
+
let truncated = null;
|
|
1134
|
+
for (;;) {
|
|
1135
|
+
pages += 1;
|
|
1136
|
+
const body = { limit: pageSize };
|
|
1137
|
+
if (cursor !== null) body.cursor = cursor;
|
|
1138
|
+
const ast = filtersToAst(filters);
|
|
1139
|
+
if (ast !== null) body.filters = ast;
|
|
1140
|
+
const response = await runtime.sendAuthed(
|
|
1141
|
+
'POST',
|
|
1142
|
+
`/views/${viewId}/query`,
|
|
1143
|
+
requestSpec({ body }),
|
|
1144
|
+
);
|
|
1145
|
+
const data = dataOf(response);
|
|
1146
|
+
collected.push(...viewItems(data, '视图取数(record --list --view)'));
|
|
1147
|
+
cursor = typeof data?.next_cursor === 'string' ? data.next_cursor : null;
|
|
1148
|
+
const hasMore = data?.has_more === true;
|
|
1149
|
+
lastResponse = response;
|
|
1150
|
+
if (!all || !hasMore || cursor === null) break;
|
|
1151
|
+
if (pages >= MAX_VIEW_PAGES) {
|
|
1152
|
+
truncated = cursor;
|
|
1153
|
+
warn(
|
|
1154
|
+
`视图取数到 ${MAX_VIEW_PAGES} 页停下(每页 ${pageSize} 条):这**不是**全量;` +
|
|
1155
|
+
`续取:xinhe-oa-cli bitable view --query --view ${viewId} --cursor ${truncated ?? ''}`,
|
|
1156
|
+
);
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const out = {
|
|
1161
|
+
records: collected,
|
|
1162
|
+
total: collected.length,
|
|
1163
|
+
pages,
|
|
1164
|
+
view_id: viewId,
|
|
1165
|
+
truncated: truncated !== null,
|
|
1166
|
+
next_step: '聚合:xinhe-oa-cli bitable view --aggregate --view <VIEW_ID> --measure 金额:sum',
|
|
1167
|
+
};
|
|
1168
|
+
if (truncated !== null) out.next_cursor = truncated;
|
|
1169
|
+
if (lastResponse !== null) runtime.finishFrom(lastResponse, out);
|
|
1170
|
+
else runtime.finish(out);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** 客户端过滤:`values` 里的字段与字面量比较(与服务端算子同名的最小子集)。 */
|
|
1174
|
+
function recordMatches(record, ast) {
|
|
1175
|
+
const children = Array.isArray(ast?.children) ? ast.children : null;
|
|
1176
|
+
if (children === null) return true;
|
|
1177
|
+
return children.every((leaf) => {
|
|
1178
|
+
const field = typeof leaf?.field === 'string' ? leaf.field : '';
|
|
1179
|
+
const operator =
|
|
1180
|
+
typeof leaf?.operator === 'string' ? leaf.operator.toLowerCase() : 'eq';
|
|
1181
|
+
const wanted = leaf?.value ?? null;
|
|
1182
|
+
const actual = record?.values?.[field] ?? null;
|
|
1183
|
+
return compare(actual, operator, wanted);
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** 与 Rust 的 `compare` 逐条同义:数值能解析就按数值比,否则按**字典序**。 */
|
|
1188
|
+
function compare(actual, operator, wanted) {
|
|
1189
|
+
const actualText = asText(actual);
|
|
1190
|
+
const wantedText = asText(wanted);
|
|
1191
|
+
switch (operator) {
|
|
1192
|
+
case 'eq':
|
|
1193
|
+
case '=':
|
|
1194
|
+
return actualText === wantedText;
|
|
1195
|
+
case 'ne':
|
|
1196
|
+
case '!=':
|
|
1197
|
+
return actualText !== wantedText;
|
|
1198
|
+
case 'contains':
|
|
1199
|
+
return actualText.includes(wantedText);
|
|
1200
|
+
case 'not_contains':
|
|
1201
|
+
return !actualText.includes(wantedText);
|
|
1202
|
+
case 'starts_with':
|
|
1203
|
+
return actualText.startsWith(wantedText);
|
|
1204
|
+
case 'ends_with':
|
|
1205
|
+
return actualText.endsWith(wantedText);
|
|
1206
|
+
case 'gt':
|
|
1207
|
+
case 'gte':
|
|
1208
|
+
case 'lt':
|
|
1209
|
+
case 'lte': {
|
|
1210
|
+
const left = parseNumberStrict(actualText);
|
|
1211
|
+
const right = parseNumberStrict(wantedText);
|
|
1212
|
+
if (left !== null && right !== null) {
|
|
1213
|
+
if (operator === 'gt') return left > right;
|
|
1214
|
+
if (operator === 'gte') return left >= right;
|
|
1215
|
+
if (operator === 'lt') return left < right;
|
|
1216
|
+
return left <= right;
|
|
1217
|
+
}
|
|
1218
|
+
if (operator === 'gt') return actualText > wantedText;
|
|
1219
|
+
if (operator === 'gte') return actualText >= wantedText;
|
|
1220
|
+
if (operator === 'lt') return actualText < wantedText;
|
|
1221
|
+
return actualText <= wantedText;
|
|
1222
|
+
}
|
|
1223
|
+
case 'is_empty':
|
|
1224
|
+
return actualText.length === 0;
|
|
1225
|
+
case 'is_not_empty':
|
|
1226
|
+
return actualText.length > 0;
|
|
1227
|
+
default:
|
|
1228
|
+
return false;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Rust 的 `str::parse::<f64>()`:整串都必须是数字(`''`、`12abc` 都不算)。 */
|
|
1233
|
+
function parseNumberStrict(text) {
|
|
1234
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(text)) return null;
|
|
1235
|
+
const value = Number(text);
|
|
1236
|
+
return Number.isFinite(value) ? value : null;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** 值 → 比较用的文本(与 Rust 的 `as_text` 同形;日期因此是**字典序**比较)。 */
|
|
1240
|
+
function asText(value) {
|
|
1241
|
+
if (value === null || value === undefined) return '';
|
|
1242
|
+
if (typeof value === 'string') return value;
|
|
1243
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
1244
|
+
if (typeof value === 'number') return String(value);
|
|
1245
|
+
if (Array.isArray(value)) return value.map(asText).join(',');
|
|
1246
|
+
return JSON.stringify(value);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// ---------------------------------------------------------------------------
|
|
1250
|
+
// Record —— 写
|
|
1251
|
+
// ---------------------------------------------------------------------------
|
|
1252
|
+
async function recordCreate(runtime, args) {
|
|
1253
|
+
const tableId = requireText(args, '--table', 'bitable record --create');
|
|
1254
|
+
const pairs = parseSetPairs(flagList(args, '--set'), '--set');
|
|
1255
|
+
const attach = flagList(args, '--attach');
|
|
1256
|
+
if (pairs.length === 0 && attach.length === 0) {
|
|
1257
|
+
throw CliError.usage('`bitable record --create` 至少要给 --set 或 --attach').withHint(
|
|
1258
|
+
'例:--set 金额=1200 --set 日期=2026-03-01',
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
const body = {
|
|
1262
|
+
values: valuesObject(pairs),
|
|
1263
|
+
source: flagValue(args, '--source') ?? 'cli',
|
|
1264
|
+
};
|
|
1265
|
+
const reason = flagValue(args, '--reason');
|
|
1266
|
+
if (reason !== null) body.reason = reason;
|
|
1267
|
+
const store = optionalId(args, '--store');
|
|
1268
|
+
if (store !== null) body.store_id = store;
|
|
1269
|
+
const path = `/tables/${tableId}/records`;
|
|
1270
|
+
const spec = requestSpec({
|
|
1271
|
+
body,
|
|
1272
|
+
idempotencyKey: idempotencyKey('base-record-create', flagValue(args, '--idempotency-key')),
|
|
1273
|
+
});
|
|
1274
|
+
if (runtime.dryRun) {
|
|
1275
|
+
runtime.dryRunReport('POST', path, spec);
|
|
1276
|
+
for (const attachment of attach) {
|
|
1277
|
+
warn(`(还会上传附件 ${attachment} 并挂到对应字段)`);
|
|
1278
|
+
}
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1282
|
+
const data = dataOf(response);
|
|
1283
|
+
const recordId = typeof data?.id === 'string' ? data.id : '';
|
|
1284
|
+
|
|
1285
|
+
// `--attach 字段=文件`:上传 + 关联(关联后再把附件 id 写进该字段)。
|
|
1286
|
+
const attachments = [];
|
|
1287
|
+
if (attach.length > 0) {
|
|
1288
|
+
for (const [field, file] of parsePairs(attach, '--attach')) {
|
|
1289
|
+
const uploaded = await uploadFile(runtime, file, null, null);
|
|
1290
|
+
const attachmentId = typeof uploaded?.id === 'string' ? uploaded.id : '';
|
|
1291
|
+
if (attachmentId.length === 0) {
|
|
1292
|
+
warn(`附件 ${file} 上传后没有拿到 id,跳过关联`);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
// 关联到记录(字段可选:先按字段名/键找 field_id,找不到就只关联到记录)
|
|
1296
|
+
const fieldId = await resolveFieldId(runtime, tableId, field);
|
|
1297
|
+
const linkSpec = requestSpec({
|
|
1298
|
+
body: { resource_type: 'record', resource_id: recordId, field_id: fieldId },
|
|
1299
|
+
});
|
|
1300
|
+
const linkResponse = await runtime.sendAuthed(
|
|
1301
|
+
'POST',
|
|
1302
|
+
`/attachments/${attachmentId}/links`,
|
|
1303
|
+
linkSpec,
|
|
1304
|
+
);
|
|
1305
|
+
attachments.push({
|
|
1306
|
+
field,
|
|
1307
|
+
field_id: fieldId,
|
|
1308
|
+
attachment_id: attachmentId,
|
|
1309
|
+
link: dataOf(linkResponse),
|
|
1310
|
+
});
|
|
1311
|
+
// 把附件 id 写进该字段(`attachment_ref` 字段要求非空字符串)
|
|
1312
|
+
const patchSpec = requestSpec({
|
|
1313
|
+
body: { values: { [field]: attachmentId }, version: data?.version ?? 1 },
|
|
1314
|
+
idempotencyKey: newIdempotencyKey('base-record-attach'),
|
|
1315
|
+
});
|
|
1316
|
+
try {
|
|
1317
|
+
await runtime.sendAuthed('PATCH', `/records/${recordId}`, patchSpec);
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
// 附件已经传上去、也关联到记录了;字段没写进去只是"少一个便捷键"。
|
|
1320
|
+
warn(`附件已上传并关联到记录,但写进字段 ${field} 失败(${err.code}):${err.message}`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
runtime.finishFrom(response, {
|
|
1326
|
+
record: data,
|
|
1327
|
+
attachments,
|
|
1328
|
+
note: '写操作带幂等键:同一个键重放会拿回首次结果(不会写两条)',
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** 把字段名(显示名或 semantic_key 或 field_id)解析成 field_id。 */
|
|
1333
|
+
async function resolveFieldId(runtime, tableId, field) {
|
|
1334
|
+
if (String(field).startsWith('fld_')) return field;
|
|
1335
|
+
try {
|
|
1336
|
+
const response = await runtime.sendAuthed(
|
|
1337
|
+
'GET',
|
|
1338
|
+
`/tables/${tableId}/fields`,
|
|
1339
|
+
requestSpec(),
|
|
1340
|
+
);
|
|
1341
|
+
const data = dataOf(response);
|
|
1342
|
+
const fields = Array.isArray(data?.fields) ? data.fields : [];
|
|
1343
|
+
for (const item of fields) {
|
|
1344
|
+
if (item?.name === field || item?.semantic_key === field) {
|
|
1345
|
+
return typeof item.id === 'string' ? item.id : null;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
} catch {
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function recordUpdate(runtime, args) {
|
|
1355
|
+
const recordId = requireId(args, '--update', 'bitable record');
|
|
1356
|
+
const pairs = parseSetPairs(flagList(args, '--set'), '--set');
|
|
1357
|
+
if (pairs.length === 0) {
|
|
1358
|
+
throw CliError.usage('`bitable record --update` 需要 --set 键=值').withHint('例:--set 状态=已审');
|
|
1359
|
+
}
|
|
1360
|
+
// version 可以省:先 GET 一次拿最新的(乐观锁的语义仍是"基于我读到的版本")。
|
|
1361
|
+
const version = await resolveVersion(runtime, recordId, flagValue(args, '--version'), true);
|
|
1362
|
+
const reason = flagValue(args, '--reason');
|
|
1363
|
+
const body = { values: valuesObject(pairs), version, reason };
|
|
1364
|
+
const spec = requestSpec({
|
|
1365
|
+
body,
|
|
1366
|
+
idempotencyKey: idempotencyKey('base-record-update', flagValue(args, '--idempotency-key')),
|
|
1367
|
+
});
|
|
1368
|
+
const path = `/records/${recordId}`;
|
|
1369
|
+
if (runtime.dryRun) return runtime.dryRunReport('PATCH', path, spec);
|
|
1370
|
+
const response = await runtime.sendAuthed('PATCH', path, spec);
|
|
1371
|
+
runtime.finishFrom(response, { record: dataOf(response) });
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** 乐观锁版本:给了就用,没给就先读一次(读不到 = 服务端形状不对,退出码 6)。 */
|
|
1375
|
+
async function resolveVersion(runtime, recordId, raw, verboseFlag) {
|
|
1376
|
+
if (raw !== null) {
|
|
1377
|
+
const text = String(raw).trim();
|
|
1378
|
+
if (!/^[+-]?\d+$/.test(text)) {
|
|
1379
|
+
throw CliError.usage(`\`--version\` 要一个整数,收到 ${JSON.stringify(raw)}`);
|
|
1380
|
+
}
|
|
1381
|
+
return Number.parseInt(text, 10);
|
|
1382
|
+
}
|
|
1383
|
+
const response = await runtime.sendAuthed('GET', `/records/${recordId}`, requestSpec());
|
|
1384
|
+
const data = dataOf(response);
|
|
1385
|
+
const version = data?.version;
|
|
1386
|
+
if (typeof version !== 'number' || !Number.isInteger(version)) {
|
|
1387
|
+
throw new CliError(
|
|
1388
|
+
Kind.Server,
|
|
1389
|
+
'cli.bad_response',
|
|
1390
|
+
'读不到记录的 version(乐观锁必须用它)',
|
|
1391
|
+
).withRequestId(response.request_id ?? null);
|
|
1392
|
+
}
|
|
1393
|
+
if (verboseFlag) verbose(runtime.verbose, `自动读到 version=${version}`);
|
|
1394
|
+
return version;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function recordDelete(runtime, args) {
|
|
1398
|
+
const recordId = requireId(args, '--delete', 'bitable record');
|
|
1399
|
+
runtime.requireYes(
|
|
1400
|
+
'bitable record --delete(软删一条记录)',
|
|
1401
|
+
`xinhe-oa-cli bitable record --delete ${recordId}`,
|
|
1402
|
+
);
|
|
1403
|
+
const version = await resolveVersion(runtime, recordId, flagValue(args, '--version'), false);
|
|
1404
|
+
const query = [['version', String(version)]];
|
|
1405
|
+
const reason = flagValue(args, '--reason');
|
|
1406
|
+
if (reason !== null) query.push(['reason', reason]);
|
|
1407
|
+
const spec = requestSpec({
|
|
1408
|
+
query,
|
|
1409
|
+
idempotencyKey: idempotencyKey('base-record-delete', flagValue(args, '--idempotency-key')),
|
|
1410
|
+
});
|
|
1411
|
+
const path = `/records/${recordId}`;
|
|
1412
|
+
if (runtime.dryRun) return runtime.dryRunReport('DELETE', path, spec);
|
|
1413
|
+
const response = await runtime.sendAuthed('DELETE', path, spec);
|
|
1414
|
+
runtime.finishFrom(response, {
|
|
1415
|
+
record: dataOf(response),
|
|
1416
|
+
note: '软删:数据还在,可复盘',
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
async function recordShow(runtime, args) {
|
|
1421
|
+
const recordId = requireId(args, '--show', 'bitable record');
|
|
1422
|
+
const response = await runtime.sendAuthed('GET', `/records/${recordId}`, requestSpec());
|
|
1423
|
+
runtime.finishFrom(response, { record: dataOf(response) });
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ---------------------------------------------------------------------------
|
|
1427
|
+
// View
|
|
1428
|
+
// ---------------------------------------------------------------------------
|
|
1429
|
+
async function viewList(runtime, args) {
|
|
1430
|
+
const tableId = requireText(args, '--table', 'bitable view --list');
|
|
1431
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}/views`, requestSpec());
|
|
1432
|
+
const data = dataOf(response);
|
|
1433
|
+
runtime.finishFrom(response, {
|
|
1434
|
+
views: Array.isArray(data?.views) ? data.views : [],
|
|
1435
|
+
total: data?.total ?? null,
|
|
1436
|
+
next_step: '按视图取数:xinhe-oa-cli bitable view --query --view <VIEW_ID> --all --json',
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
async function viewCreate(runtime, args) {
|
|
1441
|
+
const tableId = requireText(args, '--table', 'bitable view --create');
|
|
1442
|
+
const name = requireText(args, '--name', 'bitable view --create');
|
|
1443
|
+
const viewType = flagValue(args, '--view-type') ?? 'grid';
|
|
1444
|
+
if (!['grid', 'kanban', 'form'].includes(viewType)) {
|
|
1445
|
+
throw CliError.usage(`--view-type 只能是 grid / kanban / form(收到 ${JSON.stringify(viewType)})`);
|
|
1446
|
+
}
|
|
1447
|
+
const visibility = flagValue(args, '--visibility') ?? 'public';
|
|
1448
|
+
if (!['public', 'personal'].includes(visibility)) {
|
|
1449
|
+
throw CliError.usage(`--visibility 只能是 public / personal(收到 ${JSON.stringify(visibility)})`);
|
|
1450
|
+
}
|
|
1451
|
+
const key = optionalId(args, '--key') ?? safeDeriveKey(name, 'v');
|
|
1452
|
+
const body = { key, name, view_type: viewType, visibility };
|
|
1453
|
+
const ast = filtersToAst(flagList(args, '--filter'));
|
|
1454
|
+
if (ast !== null) body.filters = ast;
|
|
1455
|
+
const spec = requestSpec({ body });
|
|
1456
|
+
const path = `/tables/${tableId}/views`;
|
|
1457
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1458
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1459
|
+
runtime.finishFrom(response, { view: dataOf(response) });
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
async function viewQuery(runtime, args) {
|
|
1463
|
+
const viewId = requireId(args, '--view', 'bitable view');
|
|
1464
|
+
const pageSize = intFlag(args, '--page-size', { fallback: 50 });
|
|
1465
|
+
const all = flagOn(args, '--all');
|
|
1466
|
+
const filters = flagList(args, '--filter');
|
|
1467
|
+
const collected = [];
|
|
1468
|
+
let cursor = null;
|
|
1469
|
+
let pages = 0;
|
|
1470
|
+
let truncated = null;
|
|
1471
|
+
let lastResponse = null;
|
|
1472
|
+
for (;;) {
|
|
1473
|
+
pages += 1;
|
|
1474
|
+
const body = { limit: pageSize };
|
|
1475
|
+
if (cursor !== null) body.cursor = cursor;
|
|
1476
|
+
const ast = filtersToAst(filters);
|
|
1477
|
+
if (ast !== null) body.filters = ast;
|
|
1478
|
+
const response = await runtime.sendAuthed(
|
|
1479
|
+
'POST',
|
|
1480
|
+
`/views/${viewId}/query`,
|
|
1481
|
+
requestSpec({ body }),
|
|
1482
|
+
);
|
|
1483
|
+
const data = dataOf(response);
|
|
1484
|
+
collected.push(...viewItems(data, '视图取数(view --query)'));
|
|
1485
|
+
cursor = typeof data?.next_cursor === 'string' ? data.next_cursor : null;
|
|
1486
|
+
const hasMore = data?.has_more === true;
|
|
1487
|
+
lastResponse = response;
|
|
1488
|
+
if (!all || !hasMore || cursor === null) break;
|
|
1489
|
+
if (pages >= MAX_VIEW_PAGES) {
|
|
1490
|
+
truncated = cursor;
|
|
1491
|
+
warn(
|
|
1492
|
+
`视图取数到 ${MAX_VIEW_PAGES} 页停下(每页 ${pageSize} 条):这**不是**全量;` +
|
|
1493
|
+
`续取:xinhe-oa-cli bitable view --query --view ${viewId} --cursor ${truncated ?? ''}`,
|
|
1494
|
+
);
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const out = {
|
|
1499
|
+
records: collected,
|
|
1500
|
+
total: collected.length,
|
|
1501
|
+
pages,
|
|
1502
|
+
view_id: viewId,
|
|
1503
|
+
truncated: truncated !== null,
|
|
1504
|
+
};
|
|
1505
|
+
if (truncated !== null) out.next_cursor = truncated;
|
|
1506
|
+
if (lastResponse !== null) runtime.finishFrom(lastResponse, out);
|
|
1507
|
+
else runtime.finish(out);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
async function viewAggregate(runtime, args) {
|
|
1511
|
+
const viewId = requireId(args, '--view', 'bitable view');
|
|
1512
|
+
const measures = flagList(args, '--measure');
|
|
1513
|
+
if (measures.length === 0) {
|
|
1514
|
+
throw CliError.usage('`bitable view --aggregate` 需要 --measure 字段:sum').withHint(
|
|
1515
|
+
'聚合算子:sum | avg | max | min | count;例:--measure 金额:sum',
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
const metrics = [];
|
|
1519
|
+
for (const measure of measures) {
|
|
1520
|
+
const colon = String(measure).lastIndexOf(':');
|
|
1521
|
+
if (colon <= 0) {
|
|
1522
|
+
throw CliError.usage(
|
|
1523
|
+
`--measure 的写法是 \`字段:算子\`,收到 ${JSON.stringify(measure)}`,
|
|
1524
|
+
).withHint('例:--measure 金额:sum');
|
|
1525
|
+
}
|
|
1526
|
+
const field = String(measure).slice(0, colon).trim();
|
|
1527
|
+
const agg = String(measure).slice(colon + 1).trim().toLowerCase();
|
|
1528
|
+
if (!['sum', 'avg', 'max', 'min', 'count'].includes(agg)) {
|
|
1529
|
+
throw CliError.usage(`不支持的聚合算子 ${JSON.stringify(agg)}`).withHint(
|
|
1530
|
+
'算子:sum | avg | max | min | count',
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
metrics.push({ field, agg });
|
|
1534
|
+
}
|
|
1535
|
+
// 分组来自视图配置(服务端口径);`--group-by` 用于在结果里挑出分组列名(提示用)。
|
|
1536
|
+
const spec = requestSpec({ body: { metrics } });
|
|
1537
|
+
const path = `/views/${viewId}/aggregate`;
|
|
1538
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1539
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1540
|
+
const data = dataOf(response);
|
|
1541
|
+
// 行/分组数组的键与视图取数同源:服务端给的是 `items`(`aggregate_records`)。
|
|
1542
|
+
// 旧实现取 `groups` 并把整个 `data` 当兜底,于是结果落在 `data.groups.items` 这个
|
|
1543
|
+
// 错位的形状里(docs/issues/CLI-089):不丢数据,但按文档取 `data.groups` 取到的是整个响应体。
|
|
1544
|
+
let groups;
|
|
1545
|
+
if (data?.items !== undefined) groups = data.items;
|
|
1546
|
+
else if (data?.groups !== undefined) groups = data.groups;
|
|
1547
|
+
else groups = [];
|
|
1548
|
+
runtime.finishFrom(response, {
|
|
1549
|
+
groups,
|
|
1550
|
+
totals: data?.totals ?? null,
|
|
1551
|
+
count: data?.count ?? null,
|
|
1552
|
+
group_by: data?.group_by ?? null,
|
|
1553
|
+
view_id: viewId,
|
|
1554
|
+
// `--group-by` 在 Rust 那边是 `Vec<String>`:不给就是 `[]`(不是 null)。
|
|
1555
|
+
group_by_hint: flagList(args, '--group-by'),
|
|
1556
|
+
note: '分组来自视图自身的 groups 配置;要换分组先 PATCH 视图(服务端口径)',
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function viewShow(runtime, args) {
|
|
1561
|
+
const viewId = requireId(args, '--show', 'bitable view');
|
|
1562
|
+
const response = await runtime.sendAuthed('GET', `/views/${viewId}`, requestSpec());
|
|
1563
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ---------------------------------------------------------------------------
|
|
1567
|
+
// Form
|
|
1568
|
+
// ---------------------------------------------------------------------------
|
|
1569
|
+
async function formList(runtime, args) {
|
|
1570
|
+
const tableId = requireText(args, '--table', 'bitable form --list');
|
|
1571
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}/forms`, requestSpec());
|
|
1572
|
+
const data = dataOf(response);
|
|
1573
|
+
runtime.finishFrom(response, {
|
|
1574
|
+
forms: Array.isArray(data?.forms) ? data.forms : [],
|
|
1575
|
+
total: data?.total ?? null,
|
|
1576
|
+
next_step: '提交:xinhe-oa-cli bitable form --submit --form <FORM_ID> --set 键=值',
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async function formCreate(runtime, args) {
|
|
1581
|
+
const tableId = requireText(args, '--table', 'bitable form --create');
|
|
1582
|
+
const name = requireText(args, '--name', 'bitable form --create');
|
|
1583
|
+
const submitMode = flagValue(args, '--submit-mode') ?? 'create';
|
|
1584
|
+
if (!['create', 'update'].includes(submitMode)) {
|
|
1585
|
+
throw CliError.usage(`--submit-mode 只能是 create / update(收到 ${JSON.stringify(submitMode)})`);
|
|
1586
|
+
}
|
|
1587
|
+
const key = optionalId(args, '--key') ?? safeDeriveKey(name, 'f');
|
|
1588
|
+
const spec = requestSpec({ body: { key, name, submit_mode: submitMode } });
|
|
1589
|
+
const path = `/tables/${tableId}/forms`;
|
|
1590
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1591
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1592
|
+
runtime.finishFrom(response, { form: dataOf(response) });
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async function formSubmit(runtime, args) {
|
|
1596
|
+
const formId = requireText(args, '--form', 'bitable form --submit');
|
|
1597
|
+
const pairs = parseSetPairs(flagList(args, '--set'), '--set');
|
|
1598
|
+
if (pairs.length === 0) {
|
|
1599
|
+
throw CliError.usage('`bitable form --submit` 需要 --set 键=值');
|
|
1600
|
+
}
|
|
1601
|
+
const submitMode = flagValue(args, '--submit-mode') ?? 'create';
|
|
1602
|
+
const record = optionalId(args, '--record');
|
|
1603
|
+
if (submitMode === 'update' && record === null) {
|
|
1604
|
+
throw CliError.usage(
|
|
1605
|
+
'提交方式为 update 时必须给 --record <RECORD_ID>(乐观锁还要 --version)',
|
|
1606
|
+
).withHint('先看记录:xinhe-oa-cli bitable record --list --table <TABLE_ID> --json');
|
|
1607
|
+
}
|
|
1608
|
+
const versionRaw = flagValue(args, '--version');
|
|
1609
|
+
let version = null;
|
|
1610
|
+
if (versionRaw !== null) {
|
|
1611
|
+
if (!/^[+-]?\d+$/.test(String(versionRaw).trim())) {
|
|
1612
|
+
throw CliError.usage(`\`--version\` 要一个整数,收到 ${JSON.stringify(versionRaw)}`);
|
|
1613
|
+
}
|
|
1614
|
+
version = Number.parseInt(String(versionRaw).trim(), 10);
|
|
1615
|
+
}
|
|
1616
|
+
const body = { values: valuesObject(pairs), record_id: record, version };
|
|
1617
|
+
const spec = requestSpec({
|
|
1618
|
+
body,
|
|
1619
|
+
idempotencyKey: idempotencyKey('base-form-submit', flagValue(args, '--idempotency-key')),
|
|
1620
|
+
});
|
|
1621
|
+
const path = `/forms/${formId}/submit`;
|
|
1622
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1623
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1624
|
+
runtime.finishFrom(response, { record: dataOf(response) });
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
async function formShow(runtime, args) {
|
|
1628
|
+
const formId = requireId(args, '--show', 'bitable form');
|
|
1629
|
+
const response = await runtime.sendAuthed('GET', `/forms/${formId}`, requestSpec());
|
|
1630
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// ---------------------------------------------------------------------------
|
|
1634
|
+
// 可见性与分享(TABLE-001 #96 / TABLE-002 #97)—— 库与表共用一份实现
|
|
1635
|
+
// ---------------------------------------------------------------------------
|
|
1636
|
+
/** URL 段(复数)与 CLI 词(单数):提示里给的是"照着抄就能跑"的命令。 */
|
|
1637
|
+
function kindOf(kind) {
|
|
1638
|
+
return kind === 'base'
|
|
1639
|
+
? { segment: 'bases', label: '库', cli: 'base' }
|
|
1640
|
+
: { segment: 'tables', label: '表', cli: 'table' };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* 这次请求要用哪家企业。
|
|
1645
|
+
*
|
|
1646
|
+
* **`--dry-run` 不联网**:它只该把"将要发出的请求"打出来,所以在 dry-run 下
|
|
1647
|
+
* 用 `<TENANT_ID>` 占位、绝不为了问企业去发 `GET /me`。
|
|
1648
|
+
*/
|
|
1649
|
+
async function tenantOf(runtime, args, action) {
|
|
1650
|
+
const tenant = flagValue(args, '--tenant');
|
|
1651
|
+
if (tenant !== null && tenant.trim().length > 0) return tenant.trim();
|
|
1652
|
+
if (runtime.dryRun) return '<TENANT_ID>';
|
|
1653
|
+
const resolved = await resolveTenant(runtime, null);
|
|
1654
|
+
if (resolved === null) {
|
|
1655
|
+
throw CliError.usage(`${action}必须指明企业`).withHint(
|
|
1656
|
+
'加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
return resolved;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const tenantQuery = (tenant) => [['tenant', tenant]];
|
|
1663
|
+
|
|
1664
|
+
/** `--expected-epoch`:先 `--show` 拿 `inherit_epoch` 原样传回(乐观锁)。 */
|
|
1665
|
+
function epochValue(raw) {
|
|
1666
|
+
if (raw === null || raw === undefined) return null;
|
|
1667
|
+
const text = String(raw).trim();
|
|
1668
|
+
if (!/^[+-]?\d+$/.test(text)) {
|
|
1669
|
+
throw CliError.usage(`\`--expected-epoch\` 必须是整数(收到 ${JSON.stringify(text)})`).withHint(
|
|
1670
|
+
'先 `--visibility <ID> --show` 拿 inherit_epoch 原样传回',
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
return Number.parseInt(text, 10);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/** `--visibility <ID> --show`:读档位 + **夹紧后的有效档位**。 */
|
|
1677
|
+
async function showVisibility(runtime, args, kind, id) {
|
|
1678
|
+
const tenant = await tenantOf(runtime, args, '读可见性');
|
|
1679
|
+
const path = `/${kindOf(kind).segment}/${id}/visibility`;
|
|
1680
|
+
const spec = requestSpec({ query: tenantQuery(tenant) });
|
|
1681
|
+
if (runtime.dryRun) return runtime.dryRunReport('GET', path, spec);
|
|
1682
|
+
const response = await runtime.sendAuthed('GET', path, spec);
|
|
1683
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/** `--visibility <ID> --set private|dept|internal`:改档位(**高风险**)。 */
|
|
1687
|
+
async function setVisibility(runtime, args, kind, id, rawTier) {
|
|
1688
|
+
const tier = String(rawTier).trim().toLowerCase();
|
|
1689
|
+
if (!['private', 'dept', 'internal'].includes(tier)) {
|
|
1690
|
+
// `public` 本期没有对外入口:本地拒(退出码 2、不发请求),与文件域同一条红线。
|
|
1691
|
+
const hint = `例:xinhe-oa-cli bitable ${kindOf(kind).cli} --visibility <ID> --set internal --yes`;
|
|
1692
|
+
throw CliError.usage(
|
|
1693
|
+
`\`--set\` 只能是 private | dept | internal(收到 ${JSON.stringify(tier)}); \`public\` 本期没有对外入口`,
|
|
1694
|
+
).withHint(hint);
|
|
1695
|
+
}
|
|
1696
|
+
runtime.requireYes(
|
|
1697
|
+
`${kindOf(kind).cli} --visibility --set(一次可能放开一批人或收掉一批人)`,
|
|
1698
|
+
`xinhe-oa-cli bitable ${kindOf(kind).cli} --visibility ${id} --set ${tier} --yes`,
|
|
1699
|
+
);
|
|
1700
|
+
const tenant = await tenantOf(runtime, args, '改可见性');
|
|
1701
|
+
const body = { visibility: tier };
|
|
1702
|
+
const dept = optionalId(args, '--dept');
|
|
1703
|
+
if (dept !== null) body.dept_id = dept;
|
|
1704
|
+
const epoch = epochValue(flagValue(args, '--expected-epoch'));
|
|
1705
|
+
if (epoch !== null) body.expected_epoch = epoch;
|
|
1706
|
+
const path = `/${kindOf(kind).segment}/${id}/visibility`;
|
|
1707
|
+
const spec = requestSpec({
|
|
1708
|
+
body,
|
|
1709
|
+
query: tenantQuery(tenant),
|
|
1710
|
+
idempotencyKey: newIdempotencyKey('object-visibility'),
|
|
1711
|
+
});
|
|
1712
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1713
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1714
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/** `--move <ID> --to-dept <OU_ID>`:换归属 = **重算继承**(**高风险**)。 */
|
|
1718
|
+
async function moveObject(runtime, args, kind) {
|
|
1719
|
+
const id = requireId(args, '--move', `bitable ${kindOf(kind).cli}`);
|
|
1720
|
+
const toDept = optionalId(args, '--to-dept');
|
|
1721
|
+
if (toDept === null) {
|
|
1722
|
+
throw CliError.usage('`--move` 需要 `--to-dept <OU_ID>`').withHint(
|
|
1723
|
+
`例:xinhe-oa-cli bitable ${kindOf(kind).cli} --move <${kind === 'base' ? 'BASE' : 'TABLE'}_ID> --to-dept ou_xxx --yes`,
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
runtime.requireYes(
|
|
1727
|
+
`${kindOf(kind).label} --move(换归属会夹紧档位并作废越出容器的分享)`,
|
|
1728
|
+
`xinhe-oa-cli bitable ${kindOf(kind).cli} --move ${id} --to-dept ${toDept} --yes`,
|
|
1729
|
+
);
|
|
1730
|
+
const tenant = await tenantOf(runtime, args, '移动归属');
|
|
1731
|
+
const body = { to_dept: toDept };
|
|
1732
|
+
const epoch = epochValue(flagValue(args, '--expected-epoch'));
|
|
1733
|
+
if (epoch !== null) body.expected_epoch = epoch;
|
|
1734
|
+
const path = `/${kindOf(kind).segment}/${id}/move`;
|
|
1735
|
+
const spec = requestSpec({
|
|
1736
|
+
body,
|
|
1737
|
+
query: tenantQuery(tenant),
|
|
1738
|
+
idempotencyKey: newIdempotencyKey('object-move'),
|
|
1739
|
+
});
|
|
1740
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1741
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1742
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** `--share <ID> --to-user|--to-dept [--acl-action read|write]`(**默认给读**)。 */
|
|
1746
|
+
async function share(runtime, args, kind) {
|
|
1747
|
+
const id = requireId(args, '--share', `bitable ${kindOf(kind).cli}`);
|
|
1748
|
+
const toUser = optionalId(args, '--to-user');
|
|
1749
|
+
const toDept = optionalId(args, '--to-dept');
|
|
1750
|
+
if ((toUser === null) === (toDept === null)) {
|
|
1751
|
+
throw CliError.usage(
|
|
1752
|
+
'`--share` 需要 `--to-user <USER_ID>` 与 `--to-dept <OU_ID>` **恰好一个**',
|
|
1753
|
+
).withHint(
|
|
1754
|
+
`例:xinhe-oa-cli bitable ${kindOf(kind).cli} --share <ID> --to-user usr_xxx --json`,
|
|
1755
|
+
);
|
|
1756
|
+
}
|
|
1757
|
+
const action = (flagValue(args, '--acl-action') ?? 'read').trim().toLowerCase();
|
|
1758
|
+
if (!['read', 'write'].includes(action)) {
|
|
1759
|
+
throw CliError.usage(
|
|
1760
|
+
`\`--acl-action\` 只能是 read | write(收到 ${JSON.stringify(action)});不传就是 read`,
|
|
1761
|
+
).withHint('例:--acl-action write(写分享默认 90 天到期)');
|
|
1762
|
+
}
|
|
1763
|
+
const tenant = await tenantOf(runtime, args, '分享');
|
|
1764
|
+
const body = {};
|
|
1765
|
+
if (toUser !== null) body.to_user = toUser;
|
|
1766
|
+
if (toDept !== null) body.to_dept = toDept;
|
|
1767
|
+
body.action = action;
|
|
1768
|
+
const expires = optionalId(args, '--expires-at');
|
|
1769
|
+
if (expires !== null) body.expires_at = expires;
|
|
1770
|
+
const path = `/${kindOf(kind).segment}/${id}/share`;
|
|
1771
|
+
const spec = requestSpec({
|
|
1772
|
+
body,
|
|
1773
|
+
query: tenantQuery(tenant),
|
|
1774
|
+
idempotencyKey: newIdempotencyKey('object-share'),
|
|
1775
|
+
});
|
|
1776
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1777
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1778
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** `--acl-list <ID>`:这张表/库被分享给了谁(读)。 */
|
|
1782
|
+
async function aclList(runtime, args, kind) {
|
|
1783
|
+
const id = requireId(args, '--acl-list', `bitable ${kindOf(kind).cli}`);
|
|
1784
|
+
const tenant = await tenantOf(runtime, args, '读分享清单');
|
|
1785
|
+
const path = `/${kindOf(kind).segment}/${id}/acl`;
|
|
1786
|
+
const spec = requestSpec({ query: tenantQuery(tenant) });
|
|
1787
|
+
if (runtime.dryRun) return runtime.dryRunReport('GET', path, spec);
|
|
1788
|
+
const response = await runtime.sendAuthed('GET', path, spec);
|
|
1789
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/** `--acl-revoke <ID> --from-user|--from-dept --acl-action`:收回一条分享(**降险,不卡 `--yes`**)。 */
|
|
1793
|
+
async function aclRevoke(runtime, args, kind) {
|
|
1794
|
+
const id = requireId(args, '--acl-revoke', `bitable ${kindOf(kind).cli}`);
|
|
1795
|
+
const fromUser = optionalId(args, '--from-user');
|
|
1796
|
+
const fromDept = optionalId(args, '--from-dept');
|
|
1797
|
+
if ((fromUser === null) === (fromDept === null)) {
|
|
1798
|
+
throw CliError.usage(
|
|
1799
|
+
'`--acl-revoke` 需要 `--from-user <USER_ID>` 与 `--from-dept <OU_ID>` **恰好一个**',
|
|
1800
|
+
).withHint(
|
|
1801
|
+
`例:xinhe-oa-cli bitable ${kindOf(kind).cli} --acl-revoke <ID> --from-user usr_xxx --acl-action read`,
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
const action = (flagValue(args, '--acl-action') ?? 'read').trim().toLowerCase();
|
|
1805
|
+
if (!['read', 'write'].includes(action)) {
|
|
1806
|
+
throw CliError.usage(
|
|
1807
|
+
`\`--acl-action\` 只能是 read | write(收到 ${JSON.stringify(action)})`,
|
|
1808
|
+
).withHint('收回哪一条分享就写哪个动作;不传 = read');
|
|
1809
|
+
}
|
|
1810
|
+
const tenant = await tenantOf(runtime, args, '收回分享');
|
|
1811
|
+
const body = {};
|
|
1812
|
+
if (fromUser !== null) body.from_user = fromUser;
|
|
1813
|
+
if (fromDept !== null) body.from_dept = fromDept;
|
|
1814
|
+
body.action = action;
|
|
1815
|
+
const path = `/${kindOf(kind).segment}/${id}/acl/revoke`;
|
|
1816
|
+
const spec = requestSpec({
|
|
1817
|
+
body,
|
|
1818
|
+
query: tenantQuery(tenant),
|
|
1819
|
+
idempotencyKey: newIdempotencyKey('object-acl-revoke'),
|
|
1820
|
+
});
|
|
1821
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1822
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1823
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/** `--investor-visibility <ID> --investor-set open|closed`(TABLE-002,**高风险**)。 */
|
|
1827
|
+
async function investorVisibility(runtime, args, kind) {
|
|
1828
|
+
const id = requireId(args, '--investor-visibility', `bitable ${kindOf(kind).cli}`);
|
|
1829
|
+
const raw = (flagValue(args, '--investor-set') ?? '').trim().toLowerCase();
|
|
1830
|
+
let wanted;
|
|
1831
|
+
if (raw === 'open') wanted = true;
|
|
1832
|
+
else if (raw === 'closed') wanted = false;
|
|
1833
|
+
else {
|
|
1834
|
+
throw CliError.usage(
|
|
1835
|
+
`\`--investor-set\` 只能是 open | closed(收到 ${JSON.stringify(raw)})`,
|
|
1836
|
+
).withHint(
|
|
1837
|
+
`例:xinhe-oa-cli bitable ${kindOf(kind).cli} --investor-visibility <ID> --investor-set open --yes`,
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
runtime.requireYes(
|
|
1841
|
+
`${kindOf(kind).label} --investor-visibility(一次可能让外部投资人看到一批数据)`,
|
|
1842
|
+
`xinhe-oa-cli bitable ${kindOf(kind).cli} --investor-visibility ${id} --investor-set ${raw} --yes`,
|
|
1843
|
+
);
|
|
1844
|
+
const tenant = await tenantOf(runtime, args, '开关投资人可见性');
|
|
1845
|
+
const path = `/${kindOf(kind).segment}/${id}/investor-visibility`;
|
|
1846
|
+
const spec = requestSpec({
|
|
1847
|
+
body: { investor_visible: wanted },
|
|
1848
|
+
query: tenantQuery(tenant),
|
|
1849
|
+
idempotencyKey: newIdempotencyKey('object-investor-visibility'),
|
|
1850
|
+
});
|
|
1851
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
1852
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
1853
|
+
runtime.finishFrom(response, dataOf(response));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ---------------------------------------------------------------------------
|
|
1857
|
+
// 附件(`--keep-source` / `record --create --attach` 共用)
|
|
1858
|
+
// ---------------------------------------------------------------------------
|
|
1859
|
+
/** 上传一个文件到 `/attachments`(multipart 的字段与服务端口径逐字一致)。 */
|
|
1860
|
+
async function uploadFile(runtime, file, tenant, name) {
|
|
1861
|
+
const bytes = readFileBytes(file);
|
|
1862
|
+
const fileName = fileNameOf(file);
|
|
1863
|
+
let resolvedTenant = tenant === null || tenant === undefined ? '' : String(tenant).trim();
|
|
1864
|
+
if (resolvedTenant.length === 0) {
|
|
1865
|
+
const found = await resolveTenant(runtime, null);
|
|
1866
|
+
if (found === null) {
|
|
1867
|
+
throw CliError.usage('上传附件必须指明企业').withHint(
|
|
1868
|
+
'加 --tenant <TENANT_ID>;用 `xinhe-oa-cli tenant --list` 看有哪些企业',
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
resolvedTenant = found;
|
|
1872
|
+
}
|
|
1873
|
+
const form = multipartBody(
|
|
1874
|
+
[
|
|
1875
|
+
['tenant', resolvedTenant],
|
|
1876
|
+
['name', name ?? fileName],
|
|
1877
|
+
],
|
|
1878
|
+
['file', fileName, bytes],
|
|
1879
|
+
);
|
|
1880
|
+
if (runtime.dryRun) {
|
|
1881
|
+
runtime.dryRunReport('POST', '/attachments', requestSpec({ multipart: form }));
|
|
1882
|
+
return { dry_run: true };
|
|
1883
|
+
}
|
|
1884
|
+
const response = await runtime.sendUpload('/attachments', form, newIdempotencyKey('attachment-upload'));
|
|
1885
|
+
return dataOf(response);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// ---------------------------------------------------------------------------
|
|
1889
|
+
// 导入:`bitable table --import`
|
|
1890
|
+
// ---------------------------------------------------------------------------
|
|
1891
|
+
/**
|
|
1892
|
+
* 解析文件 → 找或建库 → 建表 → 建字段 → 分批写记录(契约 §2.1、§5、§6.2)。
|
|
1893
|
+
*
|
|
1894
|
+
* `--append` 是"每月例行更新"的正解。它的三条硬语义:
|
|
1895
|
+
*
|
|
1896
|
+
* 1. **绝不静默建新表**:同 `--key` / 同 `--name` 的表找不到 → 报可读的错(列出候选);
|
|
1897
|
+
* 2. **字段沿用已有的**:按显示名匹配,其次机器名;类型以**表里那个字段**为准;
|
|
1898
|
+
* 3. **幂等键由文件内容决定**:同一个文件重跑 = 重放,不会写两遍。
|
|
1899
|
+
*/
|
|
1900
|
+
async function importTable(runtime, args) {
|
|
1901
|
+
const file = flagValue(args, '--file');
|
|
1902
|
+
if (file === null) {
|
|
1903
|
+
throw CliError.usage('`bitable table --import` 需要 --file <x.csv|x.xlsx>').withHint(
|
|
1904
|
+
'例:xinhe-oa-cli bitable table --import --file 公账流水.csv --base-name 开叔企业 --name 公账流水',
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
const tableName = flagValue(args, '--name');
|
|
1908
|
+
if (tableName === null) {
|
|
1909
|
+
throw CliError.usage('`bitable table --import` 需要 --name <表名>').withHint('例:--name 公账流水');
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// ① 解析(类型覆盖在解析里生效)
|
|
1913
|
+
const overrides = imp.parseTypeOverrides(flagList(args, '--type'));
|
|
1914
|
+
const parsed = imp.parseFile(
|
|
1915
|
+
file,
|
|
1916
|
+
flagValue(args, '--sheet'),
|
|
1917
|
+
overrides,
|
|
1918
|
+
flagOn(args, '--no-infer'),
|
|
1919
|
+
);
|
|
1920
|
+
parsed.file = file;
|
|
1921
|
+
verbose(
|
|
1922
|
+
runtime.verbose,
|
|
1923
|
+
`解析 ${file}:${parsed.columns.length} 列 × ${parsed.rows.length} 行(${parsed.sourceFormat})`,
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
// ② `--store-column` 先校验:这是"参数拼错",必须在**建表之前**报出来,
|
|
1927
|
+
// 否则表建好了才发现列名写错,白白留下半张表。
|
|
1928
|
+
const storeColumn = flagValue(args, '--store-column');
|
|
1929
|
+
let storeColumnIndex = null;
|
|
1930
|
+
if (storeColumn !== null) {
|
|
1931
|
+
storeColumnIndex = parsed.columns.findIndex((column) => column.name === storeColumn);
|
|
1932
|
+
if (storeColumnIndex < 0) {
|
|
1933
|
+
throw CliError.usage(
|
|
1934
|
+
`--store-column 里的列 ${JSON.stringify(storeColumn)} 不在文件的表头里`,
|
|
1935
|
+
).withDetails({ available_columns: parsed.columns.map((column) => column.name) });
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// ③ 目标库 / 目标表(`--append` 时表必须已存在,绝不静默新建)
|
|
1940
|
+
const target = await resolveTarget(runtime, args, tableName);
|
|
1941
|
+
// ④ 列的落点:新建全部字段;追加则按 显示名 → 机器名 沿用已有字段
|
|
1942
|
+
const resolved = resolveColumns(parsed, target.existingFields);
|
|
1943
|
+
|
|
1944
|
+
// ⑤ `--dry-run`:打印完整计划,一个请求都不发
|
|
1945
|
+
if (runtime.dryRun) {
|
|
1946
|
+
const plan = buildPlan(runtime, {
|
|
1947
|
+
file,
|
|
1948
|
+
parsed,
|
|
1949
|
+
target,
|
|
1950
|
+
columns: resolved,
|
|
1951
|
+
batchSize: importBatchSize(args),
|
|
1952
|
+
keepSource: flagOn(args, '--keep-source'),
|
|
1953
|
+
storeColumn,
|
|
1954
|
+
storeColumnIndex,
|
|
1955
|
+
});
|
|
1956
|
+
const data = {
|
|
1957
|
+
dry_run: true,
|
|
1958
|
+
plan,
|
|
1959
|
+
note: '--dry-run:一个请求都没发,也没写任何东西;库/表是否已存在未联网确认(计划里标了"未确认")',
|
|
1960
|
+
};
|
|
1961
|
+
return runtime.finishWithHuman(data, (markdown) => planHuman(plan, markdown));
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// ⑥ 建表(`--append` 时跳过:目标表已经在 ③ 里找到了)
|
|
1965
|
+
let tableId = target.tableId;
|
|
1966
|
+
if (tableId === null) {
|
|
1967
|
+
const baseId = target.baseId ?? '';
|
|
1968
|
+
const tableBody = {
|
|
1969
|
+
key: target.tableKey,
|
|
1970
|
+
name: tableName,
|
|
1971
|
+
description: flagValue(args, '--description') ?? `由 ${file} 导入`,
|
|
1972
|
+
semantic_kind: flagValue(args, '--semantic-kind') ?? 'ledger',
|
|
1973
|
+
};
|
|
1974
|
+
const response = await runtime.sendAuthed(
|
|
1975
|
+
'POST',
|
|
1976
|
+
`/bases/${baseId}/tables`,
|
|
1977
|
+
requestSpec({ body: tableBody }),
|
|
1978
|
+
);
|
|
1979
|
+
const table = dataOf(response);
|
|
1980
|
+
if (typeof table?.id !== 'string') {
|
|
1981
|
+
throw new CliError(Kind.Server, 'cli.bad_response', '建表后没有拿到 table_id').withRequestId(
|
|
1982
|
+
response.request_id ?? null,
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
tableId = table.id;
|
|
1986
|
+
}
|
|
1987
|
+
const tableCreated = !flagOn(args, '--append');
|
|
1988
|
+
|
|
1989
|
+
// ⑦ 建字段:只建"这次文件里有、表里还没有"的列(追加时已有的字段原样沿用)
|
|
1990
|
+
const fieldsCreated = [];
|
|
1991
|
+
const fieldErrors = [];
|
|
1992
|
+
for (const [index, item] of resolved.entries()) {
|
|
1993
|
+
if (item.action !== 'create') continue;
|
|
1994
|
+
const payload = imp.fieldPayload(item.column, index);
|
|
1995
|
+
try {
|
|
1996
|
+
const response = await runtime.sendAuthed(
|
|
1997
|
+
'POST',
|
|
1998
|
+
`/tables/${tableId}/fields`,
|
|
1999
|
+
requestSpec({ body: payload }),
|
|
2000
|
+
);
|
|
2001
|
+
fieldsCreated.push(dataOf(response));
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
// 一个字段失败不该让整张表白建:记下来,最后如实报告。
|
|
2004
|
+
warn(`字段 ${item.column.name} 建失败(${err.code}:${err.message}),继续`);
|
|
2005
|
+
fieldErrors.push({
|
|
2006
|
+
column: item.column.name,
|
|
2007
|
+
semantic_key: item.column.semanticKey,
|
|
2008
|
+
error: { code: err.code, message: err.message, details: err.details ?? {} },
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// ⑧ 分批写记录:并发发批 + 断点续传 + 每批进度
|
|
2014
|
+
const fileHash = imp.fileFingerprint(parsed);
|
|
2015
|
+
const [recordsCreated, batchReports] = await writeBatches(runtime, {
|
|
2016
|
+
args,
|
|
2017
|
+
parsed,
|
|
2018
|
+
columns: resolved,
|
|
2019
|
+
file,
|
|
2020
|
+
tableId,
|
|
2021
|
+
fileHash,
|
|
2022
|
+
storeColumnIndex,
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
// ⑨ 可选:把原文件作为附件挂上
|
|
2026
|
+
const attachment = await keepSourceAttachment(runtime, args, file, batchReports);
|
|
2027
|
+
|
|
2028
|
+
const columnsReport = resolved.map((item) => ({
|
|
2029
|
+
column: item.column.name,
|
|
2030
|
+
semantic_key: item.writeKey,
|
|
2031
|
+
field_id: item.fieldId,
|
|
2032
|
+
type: item.existingType ?? item.column.columnType,
|
|
2033
|
+
type_source:
|
|
2034
|
+
item.existingType !== null
|
|
2035
|
+
? 'existing_field'
|
|
2036
|
+
: item.column.overridden
|
|
2037
|
+
? 'override'
|
|
2038
|
+
: 'inferred',
|
|
2039
|
+
action: item.action,
|
|
2040
|
+
matched_by: item.matchedBy,
|
|
2041
|
+
reason: item.column.reason,
|
|
2042
|
+
}));
|
|
2043
|
+
const fieldsReused = resolved.filter((item) => item.action === 'reuse').length;
|
|
2044
|
+
const appended = flagOn(args, '--append');
|
|
2045
|
+
const out = {
|
|
2046
|
+
base_id: target.baseId,
|
|
2047
|
+
base_created: target.baseAction === 'create',
|
|
2048
|
+
table_id: tableId,
|
|
2049
|
+
table_name: tableName,
|
|
2050
|
+
table_created: tableCreated,
|
|
2051
|
+
appended,
|
|
2052
|
+
source_file: file,
|
|
2053
|
+
source_format: parsed.sourceFormat,
|
|
2054
|
+
sheet: parsed.sheetName,
|
|
2055
|
+
fields_created: fieldsCreated.length,
|
|
2056
|
+
fields_reused: fieldsReused,
|
|
2057
|
+
records_created: recordsCreated,
|
|
2058
|
+
rows_in_file: parsed.rows.length,
|
|
2059
|
+
skipped_empty_rows: parsed.skippedEmptyRows,
|
|
2060
|
+
batches: batchReports.length,
|
|
2061
|
+
batch_size: Math.min(importBatchSize(args), imp.MAX_BATCH),
|
|
2062
|
+
columns: columnsReport,
|
|
2063
|
+
field_errors: fieldErrors,
|
|
2064
|
+
batch_reports: batchReports,
|
|
2065
|
+
attachment,
|
|
2066
|
+
next_step: `看数据:xinhe-oa-cli bitable record --list --table ${tableId} --limit 5 --json`,
|
|
2067
|
+
note: appended
|
|
2068
|
+
? '解析、类型推断、分批全在客户端完成;追加:字段沿用目标表已有的(按显示名→机器名匹配),记录按内容幂等键写入'
|
|
2069
|
+
: '解析、类型推断、分批全在客户端完成;平台只存结果(契约 §6.2)。中途失败重跑时加 --append 会补进这张表,不会再建一张',
|
|
2070
|
+
};
|
|
2071
|
+
// 人类可读面:**列 → 字段的映射**才是导完之后要照着用的东西
|
|
2072
|
+
//(通用渲染会挑到 batch_reports 那张表,把映射藏起来)。
|
|
2073
|
+
runtime.finishWithHuman(out, (markdown) => importHuman(out, markdown));
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/** 一次导入的目标(库 + 表):`--append` 时表必须已存在。 */
|
|
2077
|
+
async function resolveTarget(runtime, args, tableName) {
|
|
2078
|
+
const appended = flagOn(args, '--append');
|
|
2079
|
+
const tableAction = appended ? 'append' : 'create';
|
|
2080
|
+
const tableKey = optionalId(args, '--key') ?? safeDeriveKey(tableName, 't');
|
|
2081
|
+
|
|
2082
|
+
// ---- 库 ----
|
|
2083
|
+
let baseId = optionalId(args, '--base');
|
|
2084
|
+
let baseName = null;
|
|
2085
|
+
let baseAction;
|
|
2086
|
+
if (baseId !== null) {
|
|
2087
|
+
baseAction = 'given';
|
|
2088
|
+
} else {
|
|
2089
|
+
const wanted = flagValue(args, '--base-name');
|
|
2090
|
+
if (wanted === null) {
|
|
2091
|
+
throw CliError.usage(
|
|
2092
|
+
'`bitable table --import` 需要 --base <BASE_ID> 或 --base-name <库名>',
|
|
2093
|
+
).withHint('例:--base-name 开叔企业(新表时找不到就建一个)');
|
|
2094
|
+
}
|
|
2095
|
+
baseName = wanted;
|
|
2096
|
+
if (runtime.dryRun) {
|
|
2097
|
+
// dry-run 不联网:库是否存在留到计划里标"未确认"。
|
|
2098
|
+
baseAction = 'find-or-create';
|
|
2099
|
+
} else {
|
|
2100
|
+
const found = await findBaseByName(runtime, wanted);
|
|
2101
|
+
if (found !== null) {
|
|
2102
|
+
baseId = found;
|
|
2103
|
+
baseAction = 'reuse';
|
|
2104
|
+
} else if (appended) {
|
|
2105
|
+
// 追加语义:目标必须已存在 —— 连库都没有,就不该悄悄建一个。
|
|
2106
|
+
const names = await visibleBaseNames(runtime);
|
|
2107
|
+
throw CliError.usage(
|
|
2108
|
+
`--append 要追加到已存在的库,但没找到名为 ${JSON.stringify(wanted)} 的库(追加不会新建库)`,
|
|
2109
|
+
)
|
|
2110
|
+
.withDetails({ wanted_base_name: wanted, visible_bases: names })
|
|
2111
|
+
.withHint('确认库名(或改用 --base <BASE_ID>);要新建库就去掉 --append');
|
|
2112
|
+
} else {
|
|
2113
|
+
baseId = await createBase(runtime, args, wanted, tableName);
|
|
2114
|
+
baseAction = 'create';
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const target = {
|
|
2120
|
+
baseId,
|
|
2121
|
+
baseName,
|
|
2122
|
+
baseAction,
|
|
2123
|
+
tableId: null,
|
|
2124
|
+
tableKey,
|
|
2125
|
+
tableName,
|
|
2126
|
+
tableAction,
|
|
2127
|
+
existingFields: [],
|
|
2128
|
+
};
|
|
2129
|
+
|
|
2130
|
+
// ---- 表 ----
|
|
2131
|
+
if (runtime.dryRun) return target;
|
|
2132
|
+
const baseRef = baseId ?? '';
|
|
2133
|
+
// 追加时要能看见归档的表(好给出"这张表已归档,写不进去"这种可读的错)。
|
|
2134
|
+
const tables = await listTables(runtime, baseRef, appended);
|
|
2135
|
+
|
|
2136
|
+
if (appended) {
|
|
2137
|
+
const found = pickAppendTarget(tables, args, tableName);
|
|
2138
|
+
const foundId = typeof found?.id === 'string' ? found.id : '';
|
|
2139
|
+
target.tableId = foundId;
|
|
2140
|
+
target.existingFields = await fetchFields(runtime, foundId);
|
|
2141
|
+
verbose(
|
|
2142
|
+
runtime.verbose,
|
|
2143
|
+
`追加到已有表 ${found?.name ?? ''}(${foundId},key=${found?.key ?? ''})`,
|
|
2144
|
+
);
|
|
2145
|
+
return target;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// 建新表:同名 / 同 key 的表已存在就报错 —— **不静默再建一张同名的**
|
|
2149
|
+
// (现场问题:探路导一次、正式再导一次,库里就多了一张同名表,而 key 还被占着)。
|
|
2150
|
+
const existing = findExistingTable(tables, args, tableName);
|
|
2151
|
+
if (existing !== null) {
|
|
2152
|
+
const existingId = typeof existing.id === 'string' ? existing.id : '';
|
|
2153
|
+
const existingName = typeof existing.name === 'string' ? existing.name : '';
|
|
2154
|
+
const existingKey = typeof existing.key === 'string' ? existing.key : '';
|
|
2155
|
+
const matched = optionalId(args, '--key') === existingKey && existingKey.length > 0 ? '--key' : '--name';
|
|
2156
|
+
throw CliError.usage(
|
|
2157
|
+
`这个库里已经有一张表叫 ${JSON.stringify(existingName)}(${existingId},key=${existingKey}),撞的是 ${matched}`,
|
|
2158
|
+
)
|
|
2159
|
+
.withDetails({
|
|
2160
|
+
existing_table: { id: existingId, name: existingName, key: existingKey },
|
|
2161
|
+
base_id: baseRef,
|
|
2162
|
+
})
|
|
2163
|
+
.withHint(
|
|
2164
|
+
`要往它里面追加:加 --append(xinhe-oa-cli bitable table --import --file <文件> --base ${baseRef} --name ${existingName} --append);要另建一张:换个 --name 或 --key`,
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
return target;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/** 列一遍可见的库,按显示名找(名字相同取第一个;机器名才是身份)。 */
|
|
2171
|
+
async function findBaseByName(runtime, wanted) {
|
|
2172
|
+
const response = await runtime.sendAuthed('GET', '/bases', requestSpec());
|
|
2173
|
+
const data = dataOf(response);
|
|
2174
|
+
const bases = Array.isArray(data?.bases) ? data.bases : [];
|
|
2175
|
+
const hit = bases.find((item) => item?.name === wanted);
|
|
2176
|
+
return typeof hit?.id === 'string' ? hit.id : null;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
/** 报错时列出可见库名(让人一眼看出是不是名字记错了)。 */
|
|
2180
|
+
async function visibleBaseNames(runtime) {
|
|
2181
|
+
const response = await runtime.sendAuthed('GET', '/bases', requestSpec());
|
|
2182
|
+
const data = dataOf(response);
|
|
2183
|
+
const bases = Array.isArray(data?.bases) ? data.bases : [];
|
|
2184
|
+
return bases
|
|
2185
|
+
.map((item) => (typeof item?.name === 'string' ? item.name : null))
|
|
2186
|
+
.filter((name) => name !== null);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** 建库(只有**不追加**时才会走到这里)。 */
|
|
2190
|
+
async function createBase(runtime, args, wanted, tableName) {
|
|
2191
|
+
// key 由库名推导,推不出来就用表名 + 随机后缀(合法且稳定到"这次导入")。
|
|
2192
|
+
let key;
|
|
2193
|
+
try {
|
|
2194
|
+
key = deriveKey(wanted);
|
|
2195
|
+
} catch {
|
|
2196
|
+
let tableKey = 't';
|
|
2197
|
+
try {
|
|
2198
|
+
tableKey = deriveKey(tableName);
|
|
2199
|
+
} catch {
|
|
2200
|
+
/* 表名也推不出来就用 't' */
|
|
2201
|
+
}
|
|
2202
|
+
key = `b${newIdempotencyKey('x').split(':')[1].slice(0, 6)}_${tableKey}`;
|
|
2203
|
+
}
|
|
2204
|
+
const tenant = await resolveTenant(runtime, flagValue(args, '--tenant'));
|
|
2205
|
+
if (tenant === null) {
|
|
2206
|
+
throw CliError.usage('导入必须落在某家企业里,但看不出你属于哪一家').withHint(
|
|
2207
|
+
'用 `xinhe-oa-cli me --json` 看 scope.tenants,或 `tenant --list`;也可显式 --tenant <TENANT_ID>',
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
const body = {
|
|
2211
|
+
tenant,
|
|
2212
|
+
key,
|
|
2213
|
+
name: wanted,
|
|
2214
|
+
description: '由 bitable table --import 自动创建',
|
|
2215
|
+
};
|
|
2216
|
+
const response = await runtime.sendAuthed('POST', '/bases', requestSpec({ body }));
|
|
2217
|
+
const data = dataOf(response);
|
|
2218
|
+
if (typeof data?.id !== 'string') {
|
|
2219
|
+
throw new CliError(Kind.Server, 'cli.bad_response', '建库后没有拿到 base_id').withRequestId(
|
|
2220
|
+
response.request_id ?? null,
|
|
2221
|
+
);
|
|
2222
|
+
}
|
|
2223
|
+
verbose(runtime.verbose, `新建库 ${wanted}(${data.id})`);
|
|
2224
|
+
return data.id;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/** 列 base 下的表(`--append` 时带上已归档的:好把"表已归档"说清楚)。 */
|
|
2228
|
+
async function listTables(runtime, baseId, includeArchived) {
|
|
2229
|
+
const query = [];
|
|
2230
|
+
if (includeArchived) query.push(['include_archived', 'true']);
|
|
2231
|
+
const response = await runtime.sendAuthed(
|
|
2232
|
+
'GET',
|
|
2233
|
+
`/bases/${baseId}/tables`,
|
|
2234
|
+
requestSpec({ query }),
|
|
2235
|
+
);
|
|
2236
|
+
const data = dataOf(response);
|
|
2237
|
+
return Array.isArray(data?.tables) ? data.tables : [];
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
async function fetchFields(runtime, tableId) {
|
|
2241
|
+
const response = await runtime.sendAuthed('GET', `/tables/${tableId}/fields`, requestSpec());
|
|
2242
|
+
const data = dataOf(response);
|
|
2243
|
+
return Array.isArray(data?.fields) ? data.fields : [];
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/** 表的一行摘要(报错时列候选、计划里说明目标用)。 */
|
|
2247
|
+
function tableBrief(table) {
|
|
2248
|
+
return {
|
|
2249
|
+
id: table?.id ?? null,
|
|
2250
|
+
key: table?.key ?? null,
|
|
2251
|
+
name: table?.name ?? null,
|
|
2252
|
+
status: table?.status ?? null,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* 追加目标:`--key` 优先(key 是身份,显示名可以改),否则按显示名。
|
|
2258
|
+
*
|
|
2259
|
+
* 找不到 / 撞多张 / 已归档 → 三种可读的错,**绝不静默建新表**。
|
|
2260
|
+
*/
|
|
2261
|
+
function pickAppendTarget(tables, args, tableName) {
|
|
2262
|
+
const key = optionalId(args, '--key');
|
|
2263
|
+
const matches =
|
|
2264
|
+
key !== null
|
|
2265
|
+
? tables.filter((table) => table?.key === key)
|
|
2266
|
+
: tables.filter((table) => table?.name === tableName);
|
|
2267
|
+
const wanted = key !== null ? `key=${key}` : `表名 ${JSON.stringify(tableName)}`;
|
|
2268
|
+
const catalogue = tables.map(tableBrief);
|
|
2269
|
+
if (matches.length === 0) {
|
|
2270
|
+
throw CliError.usage(
|
|
2271
|
+
`--append 要追加到已存在的表,但这个库里没有 ${wanted} 的表(追加不会新建表)`,
|
|
2272
|
+
)
|
|
2273
|
+
.withDetails({ tables_in_base: catalogue })
|
|
2274
|
+
.withHint(
|
|
2275
|
+
'确认表名/--key,或先不带 --append 导入建表;`bitable table --list --base <BASE_ID> --include-archived` 看全部表',
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
if (matches.length === 1) {
|
|
2279
|
+
const only = matches[0];
|
|
2280
|
+
if (only?.status === 'archived') {
|
|
2281
|
+
throw CliError.usage(
|
|
2282
|
+
`${wanted} 指向的表已归档(${only?.id ?? ''}),归档的表不再接受写入`,
|
|
2283
|
+
)
|
|
2284
|
+
.withDetails({ table: tableBrief(only) })
|
|
2285
|
+
.withHint('去掉 --append 用新名字建一张表,或换一张没归档的表(`table --list --base <BASE_ID>`)');
|
|
2286
|
+
}
|
|
2287
|
+
return only;
|
|
2288
|
+
}
|
|
2289
|
+
throw CliError.usage(
|
|
2290
|
+
`--append 找到 ${matches.length} 张 ${wanted} 的表,CLI 不替你选`,
|
|
2291
|
+
)
|
|
2292
|
+
.withDetails({ candidates: matches.map(tableBrief) })
|
|
2293
|
+
.withHint('用 --key 指明是哪一张(表名可以重复,机器名不行)');
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/** 建新表前的"防重":同名或同 key 的表已经存在。 */
|
|
2297
|
+
function findExistingTable(tables, args, tableName) {
|
|
2298
|
+
const key = optionalId(args, '--key');
|
|
2299
|
+
const hit = tables.find(
|
|
2300
|
+
(table) =>
|
|
2301
|
+
(key !== null && table?.key === key) || table?.name === tableName,
|
|
2302
|
+
);
|
|
2303
|
+
return hit ?? null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* 把解析出来的列落到目标表的字段上。
|
|
2308
|
+
*
|
|
2309
|
+
* - 目标表已有这张表(`--append`):按**显示名**匹配,其次**机器名**;
|
|
2310
|
+
* 命中的列沿用那个字段(键用它的 semantic_key,类型用它的 type),只有新列才建字段;
|
|
2311
|
+
* - 建新表:全部新建。
|
|
2312
|
+
*/
|
|
2313
|
+
function resolveColumns(parsed, existingFields) {
|
|
2314
|
+
return parsed.columns.map((column) => {
|
|
2315
|
+
let hit = existingFields.find((field) => field?.name === column.name);
|
|
2316
|
+
let matchedBy = hit === undefined ? null : 'name';
|
|
2317
|
+
if (hit === undefined) {
|
|
2318
|
+
hit = existingFields.find((field) => field?.semantic_key === column.semanticKey);
|
|
2319
|
+
matchedBy = hit === undefined ? null : 'semantic_key';
|
|
2320
|
+
}
|
|
2321
|
+
if (hit === undefined) {
|
|
2322
|
+
return {
|
|
2323
|
+
writeKey: column.semanticKey,
|
|
2324
|
+
column,
|
|
2325
|
+
action: 'create',
|
|
2326
|
+
fieldId: null,
|
|
2327
|
+
existingType: null,
|
|
2328
|
+
matchedBy: null,
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
const writeKey =
|
|
2332
|
+
typeof hit.semantic_key === 'string' && hit.semantic_key.length > 0
|
|
2333
|
+
? hit.semantic_key
|
|
2334
|
+
: column.semanticKey;
|
|
2335
|
+
const existingType = typeof hit.type === 'string' ? hit.type : 'text';
|
|
2336
|
+
// 值按**表里那个字段的类型**转;推断类型只在新建字段时才有意义。
|
|
2337
|
+
const columnType = imp.columnTypeFromServer(existingType) ?? imp.ColumnType.Text;
|
|
2338
|
+
return {
|
|
2339
|
+
writeKey,
|
|
2340
|
+
column: {
|
|
2341
|
+
name: column.name,
|
|
2342
|
+
semanticKey: writeKey,
|
|
2343
|
+
columnType,
|
|
2344
|
+
reason: `沿用表里已有的字段(服务端类型 ${existingType})`,
|
|
2345
|
+
maxScale: column.maxScale,
|
|
2346
|
+
overridden: column.overridden,
|
|
2347
|
+
},
|
|
2348
|
+
action: 'reuse',
|
|
2349
|
+
fieldId: typeof hit.id === 'string' ? hit.id : null,
|
|
2350
|
+
existingType,
|
|
2351
|
+
matchedBy,
|
|
2352
|
+
};
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// ---------------------------------------------------------------------------
|
|
2357
|
+
// 批次计划与写批
|
|
2358
|
+
// ---------------------------------------------------------------------------
|
|
2359
|
+
/** 一个批次在计划里的位置。**边界一旦落进 checkpoint 就不再变**。 */
|
|
2360
|
+
function planBatches(parsed, columns, file, tableId, fileHash, batchSize, storeColumnIndex) {
|
|
2361
|
+
const out = [];
|
|
2362
|
+
const queue = imp.batchIndices(parsed.rows.length, batchSize);
|
|
2363
|
+
while (queue.length > 0) {
|
|
2364
|
+
const [start, end] = queue.shift();
|
|
2365
|
+
if (end > start + 1) {
|
|
2366
|
+
const storeId = batchStoreId(parsed, storeColumnIndex, start, end);
|
|
2367
|
+
const body = batchBody(parsed, columns, file, out.length, start, end, storeId);
|
|
2368
|
+
if (bodyBytes(body) > MAX_BODY_BYTES) {
|
|
2369
|
+
// 宽表先撞 1MB 的字节上限:按体积二分(服务端只会回一句"请求体过大")。
|
|
2370
|
+
const mid = start + Math.floor((end - start) / 2);
|
|
2371
|
+
queue.unshift([mid, end]);
|
|
2372
|
+
queue.unshift([start, mid]);
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
const index = out.length;
|
|
2377
|
+
out.push({
|
|
2378
|
+
index,
|
|
2379
|
+
start,
|
|
2380
|
+
end,
|
|
2381
|
+
// 幂等键由**文件内容 + 批序号**决定:同一个文件重跑 = 重放,不会写第二遍。
|
|
2382
|
+
idempotencyKey: `import:${fileHash}:${tableId}:${index}`,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
return out;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function bodyBytes(body) {
|
|
2389
|
+
return Buffer.byteLength(JSON.stringify(body), 'utf8');
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* 一批记录的请求体(真实写入与 dry-run 计划**共用同一段构造**,免得两处漂移)。
|
|
2394
|
+
*/
|
|
2395
|
+
function batchBody(parsed, columns, file, batchIndex, start, end, storeId) {
|
|
2396
|
+
const records = parsed.rows.slice(start, end).map((row) => ({
|
|
2397
|
+
values: imp.rowValues(columns, row),
|
|
2398
|
+
}));
|
|
2399
|
+
const body = {
|
|
2400
|
+
records,
|
|
2401
|
+
source: `import:${fileNameOf(file)}`,
|
|
2402
|
+
reason: `bitable table --import:${fileNameOf(file)} 第 ${batchIndex + 1} 批(${start}..${end})`,
|
|
2403
|
+
};
|
|
2404
|
+
if (storeId !== null && storeId !== undefined) body.store_id = storeId;
|
|
2405
|
+
return body;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
/**
|
|
2409
|
+
* 门店归属:指定了门店列、且**这一批**的门店一致时,整批落到那个门店。
|
|
2410
|
+
*
|
|
2411
|
+
* 重新算而不是存进 checkpoint:它只由文件内容与这一批的范围决定,
|
|
2412
|
+
* 存下来反而多一处可能与源数据不一致的副本。
|
|
2413
|
+
*/
|
|
2414
|
+
function batchStoreId(parsed, storeColumnIndex, start, end) {
|
|
2415
|
+
if (storeColumnIndex === null || storeColumnIndex === undefined) return null;
|
|
2416
|
+
const seen = [];
|
|
2417
|
+
for (const row of parsed.rows.slice(start, end)) {
|
|
2418
|
+
const raw = String(row[storeColumnIndex] ?? '').trim();
|
|
2419
|
+
if (raw.length === 0) return null;
|
|
2420
|
+
if (!seen.includes(raw)) seen.push(raw);
|
|
2421
|
+
}
|
|
2422
|
+
return seen.length === 1 ? seen[0] : null;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* ⑧ 的实体:并发发批 + 断点续传 + 进度。返回 `[写入行数, 逐批报告]`。
|
|
2427
|
+
*
|
|
2428
|
+
* 为什么并发是安全的:批与批之间**没有顺序依赖** —— 每批自带幂等键、
|
|
2429
|
+
* 自带门店归属、写入是单事务;服务端对同一张表的并发写只是各自加行。
|
|
2430
|
+
* 真正需要串行的只有凭据续期(见循环里的注释)。
|
|
2431
|
+
*/
|
|
2432
|
+
async function writeBatches(runtime, { args, parsed, columns, file, tableId, fileHash, storeColumnIndex }) {
|
|
2433
|
+
const quiet = runtime.quiet;
|
|
2434
|
+
const batchSize = clamp(importBatchSize(args), 1, imp.MAX_BATCH);
|
|
2435
|
+
const workers = clamp(intFlag(args, '--workers', { fallback: 3 }), 1, MAX_WORKERS);
|
|
2436
|
+
const path = `/tables/${tableId}/records`;
|
|
2437
|
+
const checkpointPath = checkpoint.pathFor(runtime.paths, tableId, fileHash);
|
|
2438
|
+
const planned = planBatches(parsed, columns, file, tableId, fileHash, batchSize, storeColumnIndex);
|
|
2439
|
+
|
|
2440
|
+
// ---- 断点续传:只有"同一个文件 + 同一张表 + 同一套批划分"才敢续 ----
|
|
2441
|
+
let state = checkpoint.newCheckpoint({
|
|
2442
|
+
tableId,
|
|
2443
|
+
fileFingerprint: fileHash,
|
|
2444
|
+
rows: parsed.rows.length,
|
|
2445
|
+
batchSize,
|
|
2446
|
+
batches: planned,
|
|
2447
|
+
updatedAt: nowRfc3339(),
|
|
2448
|
+
});
|
|
2449
|
+
let resumed = false;
|
|
2450
|
+
if (!flagOn(args, '--fresh')) {
|
|
2451
|
+
const existing = checkpoint.load(checkpointPath);
|
|
2452
|
+
if (existing !== null && checkpoint.samePlan(existing, tableId, planned) && checkpoint.doneCount(existing) > 0) {
|
|
2453
|
+
resumed = true;
|
|
2454
|
+
state = existing;
|
|
2455
|
+
} else if (existing !== null && !checkpoint.samePlan(existing, tableId, planned)) {
|
|
2456
|
+
warn(
|
|
2457
|
+
`发现同文件的旧进度(${existing.rows} 行 / ${existing.batches.length} 批),但这次的批划分与它不同` +
|
|
2458
|
+
`(--batch-size ${existing.batch_size} → ${batchSize})——幂等键跟着批走,续传会重复写入;` +
|
|
2459
|
+
`本次从头开始(旧进度保留在 ${checkpointPath})`,
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
if (flagOn(args, '--fresh') && checkpoint.exists(checkpointPath)) {
|
|
2464
|
+
checkpoint.remove(checkpointPath);
|
|
2465
|
+
}
|
|
2466
|
+
if (resumed) {
|
|
2467
|
+
if (checkpoint.isComplete(state)) {
|
|
2468
|
+
progress(
|
|
2469
|
+
quiet,
|
|
2470
|
+
`· 这个文件上次已经导完(${state.batches.length} 批 / ${checkpoint.doneRecords(state)} 行):` +
|
|
2471
|
+
`本次全部走**幂等重放**,不会重复写入。要重新导一遍(比如表被归档后重建)加 --fresh`,
|
|
2472
|
+
);
|
|
2473
|
+
} else {
|
|
2474
|
+
progress(
|
|
2475
|
+
quiet,
|
|
2476
|
+
`· 续传:已完成 ${checkpoint.doneCount(state)}/${state.batches.length} 批` +
|
|
2477
|
+
`(${checkpoint.doneRecords(state)} 行),只补剩余 ${state.batches.length - checkpoint.doneCount(state)} 批`,
|
|
2478
|
+
);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
const totalRows = parsed.rows.length;
|
|
2483
|
+
const started = Date.now();
|
|
2484
|
+
let recordsCreated = checkpoint.doneRecords(state);
|
|
2485
|
+
const batchReports = [];
|
|
2486
|
+
const pending = state.batches.filter((batch) => !batch.done).map((batch) => batch.index);
|
|
2487
|
+
const attempts = new Array(state.batches.length).fill(0);
|
|
2488
|
+
let wave = workers;
|
|
2489
|
+
|
|
2490
|
+
while (pending.length > 0) {
|
|
2491
|
+
// 每一波都取一次凭据:**续期只发生在主循环里**(并发刷新会互相吊销,
|
|
2492
|
+
// 服务端把 refresh 当一次性令牌用)。波与波之间共享同一枚 access。
|
|
2493
|
+
await runtime.ensureFreshToken();
|
|
2494
|
+
|
|
2495
|
+
const take = Math.min(wave, pending.length);
|
|
2496
|
+
const indexes = pending.splice(0, take);
|
|
2497
|
+
const settled = await Promise.all(
|
|
2498
|
+
indexes.map(async (index) => {
|
|
2499
|
+
const batch = state.batches[index];
|
|
2500
|
+
const storeId = batchStoreId(parsed, storeColumnIndex, batch.start, batch.end);
|
|
2501
|
+
const spec = requestSpec({
|
|
2502
|
+
body: batchBody(parsed, columns, file, batch.index, batch.start, batch.end, storeId),
|
|
2503
|
+
idempotencyKey: batch.idempotency_key,
|
|
2504
|
+
});
|
|
2505
|
+
try {
|
|
2506
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
2507
|
+
return { index, response };
|
|
2508
|
+
} catch (error) {
|
|
2509
|
+
return { index, error };
|
|
2510
|
+
}
|
|
2511
|
+
}),
|
|
2512
|
+
);
|
|
2513
|
+
|
|
2514
|
+
let softFailure = false;
|
|
2515
|
+
let tokenExpired = false;
|
|
2516
|
+
for (const item of settled) {
|
|
2517
|
+
const { index } = item;
|
|
2518
|
+
if (item.response !== undefined) {
|
|
2519
|
+
const outcome = batchOutcome(state.batches[index], item.response);
|
|
2520
|
+
state.batches[index].done = true;
|
|
2521
|
+
recordsCreated += outcome.created;
|
|
2522
|
+
const batch = state.batches[index];
|
|
2523
|
+
const storeId = batchStoreId(parsed, storeColumnIndex, batch.start, batch.end);
|
|
2524
|
+
// **每批成功就落盘**:checkpoint 的价值在于"进程被 kill 也不丢",
|
|
2525
|
+
// 攒到最后一起写就等于没有。
|
|
2526
|
+
state.updated_at = nowRfc3339();
|
|
2527
|
+
checkpoint.save(checkpointPath, state);
|
|
2528
|
+
const done = checkpoint.doneCount(state);
|
|
2529
|
+
const doneRows = checkpoint.doneRecords(state);
|
|
2530
|
+
const elapsed = (Date.now() - started) / 1000;
|
|
2531
|
+
const rate = elapsed > 0 ? doneRows / elapsed : 0;
|
|
2532
|
+
progress(
|
|
2533
|
+
quiet,
|
|
2534
|
+
`batch ${done}/${state.batches.length} · ${doneRows}/${totalRows} 行 · elapsed ${elapsed.toFixed(0)}s · ${rate.toFixed(1)} 行/秒`,
|
|
2535
|
+
);
|
|
2536
|
+
batchReports.push({
|
|
2537
|
+
batch: batch.index + 1,
|
|
2538
|
+
range: [batch.start, batch.end],
|
|
2539
|
+
records: outcome.created,
|
|
2540
|
+
store_id: storeId,
|
|
2541
|
+
record_ids: dataOf(item.response)?.record_ids ?? null,
|
|
2542
|
+
request_id: item.response.request_id ?? null,
|
|
2543
|
+
replayed: outcome.replayed,
|
|
2544
|
+
});
|
|
2545
|
+
continue;
|
|
2546
|
+
}
|
|
2547
|
+
const err = item.error;
|
|
2548
|
+
if (err?.code === 'auth.token_expired') {
|
|
2549
|
+
// 下一波开头会重新取凭据;这一批原样重投(幂等键不变)。
|
|
2550
|
+
tokenExpired = true;
|
|
2551
|
+
pending.unshift(index);
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
if (isPressure(err)) {
|
|
2555
|
+
attempts[index] += 1;
|
|
2556
|
+
if (attempts[index] >= MAX_BATCH_ATTEMPTS) {
|
|
2557
|
+
throw resumeError(err, state, checkpointPath, index, totalRows);
|
|
2558
|
+
}
|
|
2559
|
+
// 服务端在喊"慢一点":**降并发**,并把这一批放回队头。
|
|
2560
|
+
// 只重试不降并发,等于把限流当网络抖动,会把服务端按得更死。
|
|
2561
|
+
if (wave > 1) {
|
|
2562
|
+
wave -= 1;
|
|
2563
|
+
warn(
|
|
2564
|
+
`服务端压力信号(${err.httpStatus ?? 0} ${err.code}):并发降到 ${wave},` +
|
|
2565
|
+
`这一批稍后重投(第 ${attempts[index]}/${MAX_BATCH_ATTEMPTS} 次)`,
|
|
2566
|
+
);
|
|
2567
|
+
}
|
|
2568
|
+
softFailure = true;
|
|
2569
|
+
pending.unshift(index);
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
throw resumeError(err, state, checkpointPath, index, totalRows);
|
|
2573
|
+
}
|
|
2574
|
+
// 压力过去了就慢慢把并发加回去(一步加一,避免抖动)
|
|
2575
|
+
if (!softFailure && !tokenExpired && wave < workers) wave += 1;
|
|
2576
|
+
if (softFailure) await sleep(500);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// 全部完成:删掉 checkpoint(下次重跑是"新的一次导入",而不是"已完成的续传")
|
|
2580
|
+
checkpoint.remove(checkpointPath);
|
|
2581
|
+
const elapsed = (Date.now() - started) / 1000;
|
|
2582
|
+
const rate = elapsed > 0 ? recordsCreated / elapsed : 0;
|
|
2583
|
+
progress(
|
|
2584
|
+
quiet,
|
|
2585
|
+
`· 导入完成:${recordsCreated} 行 / ${state.batches.length} 批 · ${elapsed.toFixed(0)}s · ${rate.toFixed(1)} 行/秒(并发 ${workers})`,
|
|
2586
|
+
);
|
|
2587
|
+
return [recordsCreated, batchReports];
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
/** 服务端"压力信号":限流 / 暂不可用。这类失败要降并发重投,不是直接失败。 */
|
|
2591
|
+
function isPressure(err) {
|
|
2592
|
+
const status = err?.httpStatus;
|
|
2593
|
+
if ([429, 500, 502, 503, 504].includes(status)) return true;
|
|
2594
|
+
const code = String(err?.code ?? '');
|
|
2595
|
+
return code.startsWith('rate.') || code === 'rate.limited';
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
/**
|
|
2599
|
+
* 失败收尾:把"已经写到哪了、怎么续"说清楚,而不是只抛一个网络错误。
|
|
2600
|
+
*
|
|
2601
|
+
* 现场教训(2026-09-29):三次中断各丢掉几千行的工作量,而且**没有任何续传能力**,
|
|
2602
|
+
* 只能靠人工做多重集比对才敢接着导。所以这里必须打印 checkpoint 的绝对路径。
|
|
2603
|
+
*/
|
|
2604
|
+
function resumeError(err, state, checkpointPath, index, totalRows) {
|
|
2605
|
+
const done = checkpoint.doneCount(state);
|
|
2606
|
+
const doneRows = checkpoint.doneRecords(state);
|
|
2607
|
+
const batch = state.batches[index];
|
|
2608
|
+
const remaining = Math.max(totalRows - doneRows, 0);
|
|
2609
|
+
const error =
|
|
2610
|
+
err instanceof CliError ? err : new CliError(Kind.Server, 'cli.internal', String(err?.message ?? err));
|
|
2611
|
+
return error.withHint(
|
|
2612
|
+
`已写入 ${doneRows} 行(${done}/${state.batches.length} 批)。**直接重跑同一条命令会自动续传**` +
|
|
2613
|
+
`(只补剩余批次,不会写重复);进度存在 ${checkpointPath}。要重新来过加 --fresh;` +
|
|
2614
|
+
`剩余 ${remaining} 行也可以另存一份 CSV 用 --append --file 续。` +
|
|
2615
|
+
`失败的批是第 ${batch.index + 1} 批(第 ${batch.start}..${batch.end} 行)`,
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
function batchOutcome(batch, response) {
|
|
2620
|
+
const data = dataOf(response);
|
|
2621
|
+
const created =
|
|
2622
|
+
typeof data?.created === 'number' && Number.isInteger(data.created) ? data.created : batch.records;
|
|
2623
|
+
const rules = Array.isArray(response?.body?.applied_rules) ? response.body.applied_rules : [];
|
|
2624
|
+
const replayed = rules.some((rule) => rule === 'idempotent_replay');
|
|
2625
|
+
return { created, replayed };
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** `--keep-source`:把原文件传上去并挂到第一条记录上(挂不上也如实说明)。 */
|
|
2629
|
+
async function keepSourceAttachment(runtime, args, file, batchReports) {
|
|
2630
|
+
if (!flagOn(args, '--keep-source')) return null;
|
|
2631
|
+
try {
|
|
2632
|
+
const uploaded = await uploadFile(runtime, file, null, null);
|
|
2633
|
+
const attachmentId = typeof uploaded?.id === 'string' ? uploaded.id : '';
|
|
2634
|
+
// 关联到"表"这个资源:服务端本期支持 `record`;
|
|
2635
|
+
// 没有记录可挂时如实说明,不假装挂上了。
|
|
2636
|
+
const first = batchReports[0]?.record_ids;
|
|
2637
|
+
const anchorRecord =
|
|
2638
|
+
Array.isArray(first) && typeof first[0] === 'string' ? first[0] : null;
|
|
2639
|
+
let linkResult = null;
|
|
2640
|
+
let linkNote = null;
|
|
2641
|
+
if (anchorRecord !== null) {
|
|
2642
|
+
try {
|
|
2643
|
+
const response = await runtime.sendAuthed(
|
|
2644
|
+
'POST',
|
|
2645
|
+
`/attachments/${attachmentId}/links`,
|
|
2646
|
+
requestSpec({ body: { resource_type: 'record', resource_id: anchorRecord } }),
|
|
2647
|
+
);
|
|
2648
|
+
linkResult = dataOf(response);
|
|
2649
|
+
} catch (err) {
|
|
2650
|
+
linkNote = `关联失败(${err.code}:${err.message})—— 附件已上传,可用 attachment --link 重试`;
|
|
2651
|
+
}
|
|
2652
|
+
} else {
|
|
2653
|
+
linkNote = '表里没有记录可挂:附件已上传,可用 attachment --link 挂到别处';
|
|
2654
|
+
}
|
|
2655
|
+
return {
|
|
2656
|
+
attachment: uploaded,
|
|
2657
|
+
linked_to_record: anchorRecord,
|
|
2658
|
+
link: linkResult,
|
|
2659
|
+
note: linkNote,
|
|
2660
|
+
};
|
|
2661
|
+
} catch (err) {
|
|
2662
|
+
warn(`--keep-source 上传失败(${err.code}:${err.message}):数据已经导进去了,只有原文件没挂上`);
|
|
2663
|
+
return {
|
|
2664
|
+
error: { code: err.code, message: err.message },
|
|
2665
|
+
note: '表与记录已经建好;重跑 attachment --upload 即可补上原文件',
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// ---------------------------------------------------------------------------
|
|
2671
|
+
// `--dry-run` 的完整计划
|
|
2672
|
+
// ---------------------------------------------------------------------------
|
|
2673
|
+
/**
|
|
2674
|
+
* 拼一份**完整**的导入计划(现场问题:以前只打印"建表"那一个请求,
|
|
2675
|
+
* 分批写记录、字段与推断类型都看不见,等于没法预检)。
|
|
2676
|
+
*
|
|
2677
|
+
* 一条纪律:**不联网**。所以"库/表是否已存在"只能标成"未确认",
|
|
2678
|
+
* 请求里的 `<base_id>` / `<table_id>` 是占位符。
|
|
2679
|
+
*/
|
|
2680
|
+
function buildPlan(runtime, ctx) {
|
|
2681
|
+
const baseRef = ctx.target.baseId ?? '<base_id>';
|
|
2682
|
+
const tableRef = ctx.target.tableId ?? '<table_id>';
|
|
2683
|
+
const batches = imp.batchIndices(ctx.parsed.rows.length, ctx.batchSize);
|
|
2684
|
+
const fileHash = imp.fileFingerprint(ctx.parsed);
|
|
2685
|
+
// 追加模式 + dry-run(不联网):字段的"沿用还是新建"没法确认,
|
|
2686
|
+
// 按最坏情况(都要新建)列出来,并把这句话说清楚。
|
|
2687
|
+
const appendUnconfirmed =
|
|
2688
|
+
ctx.target.tableAction === 'append' && ctx.target.existingFields.length === 0;
|
|
2689
|
+
|
|
2690
|
+
const fields = ctx.columns.map((item) => ({
|
|
2691
|
+
column: item.column.name,
|
|
2692
|
+
semantic_key: item.writeKey,
|
|
2693
|
+
field_id: item.fieldId,
|
|
2694
|
+
type: item.existingType ?? item.column.columnType,
|
|
2695
|
+
action: item.action,
|
|
2696
|
+
reason: item.column.reason,
|
|
2697
|
+
max_scale: item.column.maxScale,
|
|
2698
|
+
}));
|
|
2699
|
+
|
|
2700
|
+
const batchRows = batches.map(([start, end], index) => ({
|
|
2701
|
+
batch: index + 1,
|
|
2702
|
+
range: [start, end],
|
|
2703
|
+
records: end - start,
|
|
2704
|
+
}));
|
|
2705
|
+
|
|
2706
|
+
const requests = [];
|
|
2707
|
+
const push = (kind, entry) => {
|
|
2708
|
+
entry.step = requests.length + 1;
|
|
2709
|
+
entry.kind = kind;
|
|
2710
|
+
requests.push(entry);
|
|
2711
|
+
};
|
|
2712
|
+
|
|
2713
|
+
// 库:只有"可能要建"才列出来(--base 直用时不会建)
|
|
2714
|
+
if (ctx.target.baseAction === 'create' || ctx.target.baseAction === 'find-or-create') {
|
|
2715
|
+
const body = {
|
|
2716
|
+
tenant: '<你的可见企业;--tenant 可指定>',
|
|
2717
|
+
key: '<由库名推导>',
|
|
2718
|
+
name: ctx.target.baseName,
|
|
2719
|
+
description: '由 bitable table --import 自动创建',
|
|
2720
|
+
};
|
|
2721
|
+
const request = runtime.dryRunRequest('POST', '/bases', requestSpec({ body }));
|
|
2722
|
+
push('create_base', {
|
|
2723
|
+
path: '/bases',
|
|
2724
|
+
conditional: ctx.target.baseAction === 'find-or-create',
|
|
2725
|
+
note:
|
|
2726
|
+
ctx.target.baseAction === 'find-or-create'
|
|
2727
|
+
? '先按名字找;只有找不到才建(dry-run 未确认,实际可能一个请求都不发)'
|
|
2728
|
+
: '库不存在,会新建',
|
|
2729
|
+
request,
|
|
2730
|
+
});
|
|
2731
|
+
}
|
|
2732
|
+
// 表:建 or 追加
|
|
2733
|
+
if (ctx.target.tableAction === 'create') {
|
|
2734
|
+
const body = {
|
|
2735
|
+
key: ctx.target.tableKey,
|
|
2736
|
+
name: ctx.target.tableName,
|
|
2737
|
+
description: `由 ${ctx.file} 导入`,
|
|
2738
|
+
semantic_kind: 'ledger',
|
|
2739
|
+
};
|
|
2740
|
+
const request = runtime.dryRunRequest(
|
|
2741
|
+
'POST',
|
|
2742
|
+
`/bases/${baseRef}/tables`,
|
|
2743
|
+
requestSpec({ body }),
|
|
2744
|
+
);
|
|
2745
|
+
push('create_table', {
|
|
2746
|
+
path: `/bases/${baseRef}/tables`,
|
|
2747
|
+
note: '建一张新表(同名/同 key 的表已存在时,真实运行会报错并提示加 --append)',
|
|
2748
|
+
request,
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2751
|
+
// 字段
|
|
2752
|
+
ctx.columns.forEach((item, index) => {
|
|
2753
|
+
if (item.action !== 'create') return;
|
|
2754
|
+
const payload = imp.fieldPayload(item.column, index);
|
|
2755
|
+
const request = runtime.dryRunRequest(
|
|
2756
|
+
'POST',
|
|
2757
|
+
`/tables/${tableRef}/fields`,
|
|
2758
|
+
requestSpec({ body: payload }),
|
|
2759
|
+
);
|
|
2760
|
+
push('create_field', {
|
|
2761
|
+
path: `/tables/${tableRef}/fields`,
|
|
2762
|
+
conditional: appendUnconfirmed,
|
|
2763
|
+
column: item.column.name,
|
|
2764
|
+
semantic_key: item.writeKey,
|
|
2765
|
+
type: item.column.columnType,
|
|
2766
|
+
reason: item.column.reason,
|
|
2767
|
+
request,
|
|
2768
|
+
});
|
|
2769
|
+
});
|
|
2770
|
+
// 记录批次
|
|
2771
|
+
batches.forEach(([start, end], index) => {
|
|
2772
|
+
const storeId = batchStoreId(ctx.parsed, ctx.storeColumnIndex, start, end);
|
|
2773
|
+
const body = batchBody(ctx.parsed, ctx.columns, ctx.file, index, start, end, storeId);
|
|
2774
|
+
const spec = requestSpec({
|
|
2775
|
+
body,
|
|
2776
|
+
idempotencyKey: `import:${fileHash}:${tableRef}:${index}`,
|
|
2777
|
+
});
|
|
2778
|
+
const request = runtime.dryRunRequest('POST', `/tables/${tableRef}/records`, spec);
|
|
2779
|
+
let sample = null;
|
|
2780
|
+
if (Array.isArray(request.body?.records)) {
|
|
2781
|
+
const total = request.body.records.length;
|
|
2782
|
+
sample = request.body.records[0] ?? null;
|
|
2783
|
+
// 200 条 × N 批全量打印没有意义(人只看首条对不对),但**批次一条不少**。
|
|
2784
|
+
request.body.records = request.body.records.slice(0, 1);
|
|
2785
|
+
request.body.records_total = total;
|
|
2786
|
+
request.body.records_omitted = Math.max(total - 1, 0);
|
|
2787
|
+
}
|
|
2788
|
+
push('write_records', {
|
|
2789
|
+
path: `/tables/${tableRef}/records`,
|
|
2790
|
+
batch: index + 1,
|
|
2791
|
+
range: [start, end],
|
|
2792
|
+
records: end - start,
|
|
2793
|
+
store_id: storeId,
|
|
2794
|
+
sample_record: sample,
|
|
2795
|
+
note: 'records 数组只留首条(其余同构);幂等键 = 文件内容 + 表 id + 批次下标',
|
|
2796
|
+
request,
|
|
2797
|
+
});
|
|
2798
|
+
});
|
|
2799
|
+
// 原文件附件
|
|
2800
|
+
if (ctx.keepSource) {
|
|
2801
|
+
const request = runtime.dryRunRequest(
|
|
2802
|
+
'POST',
|
|
2803
|
+
'/attachments',
|
|
2804
|
+
requestSpec({ multipart: multipartBody([['kind', 'source']], null) }),
|
|
2805
|
+
);
|
|
2806
|
+
push('upload_source', {
|
|
2807
|
+
path: '/attachments',
|
|
2808
|
+
file: ctx.file,
|
|
2809
|
+
note: '--keep-source:上传原文件并挂到第一条记录上',
|
|
2810
|
+
request,
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
return {
|
|
2815
|
+
source: {
|
|
2816
|
+
file: ctx.file,
|
|
2817
|
+
format: ctx.parsed.sourceFormat,
|
|
2818
|
+
sheet: ctx.parsed.sheetName,
|
|
2819
|
+
rows: ctx.parsed.rows.length,
|
|
2820
|
+
columns: ctx.parsed.columns.length,
|
|
2821
|
+
skipped_empty_rows: ctx.parsed.skippedEmptyRows,
|
|
2822
|
+
},
|
|
2823
|
+
base: {
|
|
2824
|
+
action: ctx.target.baseAction,
|
|
2825
|
+
base_id: ctx.target.baseId,
|
|
2826
|
+
name: ctx.target.baseName,
|
|
2827
|
+
confirmed: ctx.target.baseId !== null,
|
|
2828
|
+
},
|
|
2829
|
+
table: {
|
|
2830
|
+
action: ctx.target.tableAction,
|
|
2831
|
+
table_id: ctx.target.tableId,
|
|
2832
|
+
key: ctx.target.tableKey,
|
|
2833
|
+
name: ctx.target.tableName,
|
|
2834
|
+
confirmed: ctx.target.tableId !== null && ctx.target.tableAction === 'append',
|
|
2835
|
+
note:
|
|
2836
|
+
ctx.target.tableAction === 'append'
|
|
2837
|
+
? '追加模式:不会建表;真实运行会先按 --key/--name 找已存在的表(找不到就报错,不会新建)'
|
|
2838
|
+
: '新建一张表',
|
|
2839
|
+
},
|
|
2840
|
+
fields,
|
|
2841
|
+
fields_note: appendUnconfirmed
|
|
2842
|
+
? '追加模式:真实运行会先按显示名 → 机器名匹配目标表已有的字段(命中的沿用、不重建);dry-run 不联网,这里按"都要新建"列出'
|
|
2843
|
+
: null,
|
|
2844
|
+
fields_to_create: ctx.columns.filter((item) => item.action === 'create').length,
|
|
2845
|
+
fields_to_reuse: ctx.columns.filter((item) => item.action === 'reuse').length,
|
|
2846
|
+
batches: batchRows,
|
|
2847
|
+
records_total: ctx.parsed.rows.length,
|
|
2848
|
+
batch_size: Math.min(ctx.batchSize, imp.MAX_BATCH),
|
|
2849
|
+
store_column: ctx.storeColumn,
|
|
2850
|
+
keep_source: ctx.keepSource,
|
|
2851
|
+
requests_total: requests.length,
|
|
2852
|
+
requests,
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
// ---------------------------------------------------------------------------
|
|
2857
|
+
// 人类可读渲染(`table` / `md` 面;`--json` 的形状一个字都不变)
|
|
2858
|
+
// ---------------------------------------------------------------------------
|
|
2859
|
+
function stringOf(value) {
|
|
2860
|
+
if (value === null || value === undefined) return '';
|
|
2861
|
+
if (typeof value === 'string') return value;
|
|
2862
|
+
return JSON.stringify(value);
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
/** 列 → 字段的表格(计划与导入结果共用同一段渲染)。 */
|
|
2866
|
+
function fieldsTable(entries, markdown) {
|
|
2867
|
+
const rows = entries.map((field) => [
|
|
2868
|
+
stringOf(field.column),
|
|
2869
|
+
stringOf(field.semantic_key),
|
|
2870
|
+
stringOf(field.type),
|
|
2871
|
+
field.action === 'reuse' ? '沿用' : '新建',
|
|
2872
|
+
stringOf(field.reason),
|
|
2873
|
+
]);
|
|
2874
|
+
return renderTable(['列名', '机器名', '类型', '动作', '依据'], rows, markdown);
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
/** 导入结果的人类可读视图:落到哪张表 / 列与类型的映射 / 写了多少条。 */
|
|
2878
|
+
function importHuman(out, markdown) {
|
|
2879
|
+
const action = out.appended === true ? '追加到已有表' : '新建表';
|
|
2880
|
+
let text = `${stringOf(out.table_name)}(${action}):${stringOf(out.records_created)} 条记录\n`;
|
|
2881
|
+
text += renderTable(
|
|
2882
|
+
['项', '值'],
|
|
2883
|
+
[
|
|
2884
|
+
['表 id', stringOf(out.table_id)],
|
|
2885
|
+
['库 id', stringOf(out.base_id)],
|
|
2886
|
+
['源文件', stringOf(out.source_file)],
|
|
2887
|
+
[
|
|
2888
|
+
'行/批次',
|
|
2889
|
+
`${stringOf(out.rows_in_file)} 行 → ${stringOf(out.records_created)} 条,${stringOf(out.batches)} 批(每批 ≤${stringOf(out.batch_size)})`,
|
|
2890
|
+
],
|
|
2891
|
+
['字段', `新建 ${stringOf(out.fields_created)} / 沿用已有 ${stringOf(out.fields_reused)}`],
|
|
2892
|
+
['下一步', stringOf(out.next_step)],
|
|
2893
|
+
],
|
|
2894
|
+
markdown,
|
|
2895
|
+
);
|
|
2896
|
+
const columns = Array.isArray(out.columns) ? out.columns : [];
|
|
2897
|
+
text += `\n\n字段映射(${columns.length} 列)\n`;
|
|
2898
|
+
text += fieldsTable(columns, markdown);
|
|
2899
|
+
const errors = Array.isArray(out.field_errors) ? out.field_errors : [];
|
|
2900
|
+
if (errors.length > 0) {
|
|
2901
|
+
text += `\n注意:有 ${errors.length} 个字段没建成功(见 --json 的 field_errors)\n`;
|
|
2902
|
+
}
|
|
2903
|
+
text += `\n${stringOf(out.note)}\n`;
|
|
2904
|
+
return text.replace(/\s+$/, '');
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
/** 给人看的计划:文件 / 目标 / 字段与推断类型 / 分批 / 将发的请求。 */
|
|
2908
|
+
function planHuman(plan, markdown) {
|
|
2909
|
+
let out = '导入计划(--dry-run:一个请求都不会发,也不会写任何东西)\n';
|
|
2910
|
+
const source = plan.source;
|
|
2911
|
+
const skipped =
|
|
2912
|
+
typeof source.skipped_empty_rows === 'number' && source.skipped_empty_rows > 0
|
|
2913
|
+
? `;跳过全空行 ${source.skipped_empty_rows}`
|
|
2914
|
+
: '';
|
|
2915
|
+
out += renderTable(
|
|
2916
|
+
['项', '值'],
|
|
2917
|
+
[
|
|
2918
|
+
[
|
|
2919
|
+
'文件',
|
|
2920
|
+
`${stringOf(source.file)}(${stringOf(source.format)};${source.rows} 行 × ${source.columns} 列${skipped})`,
|
|
2921
|
+
],
|
|
2922
|
+
['目标库', baseLine(plan.base)],
|
|
2923
|
+
['目标表', tableLine(plan.table)],
|
|
2924
|
+
],
|
|
2925
|
+
markdown,
|
|
2926
|
+
);
|
|
2927
|
+
out += '\n';
|
|
2928
|
+
|
|
2929
|
+
const fields = Array.isArray(plan.fields) ? plan.fields : [];
|
|
2930
|
+
out += `字段(${fields.length} 个:新建 ${plan.fields_to_create} / 沿用已有 ${plan.fields_to_reuse})\n`;
|
|
2931
|
+
out += fieldsTable(fields, markdown);
|
|
2932
|
+
if (typeof plan.fields_note === 'string' && plan.fields_note.length > 0) {
|
|
2933
|
+
out += `\n注意:${plan.fields_note}`;
|
|
2934
|
+
}
|
|
2935
|
+
out += '\n';
|
|
2936
|
+
|
|
2937
|
+
const batches = Array.isArray(plan.batches) ? plan.batches : [];
|
|
2938
|
+
out += `记录批次(${batches.length} 批,共 ${plan.records_total} 条)\n`;
|
|
2939
|
+
out += renderTable(
|
|
2940
|
+
['批次', '区间', '条数'],
|
|
2941
|
+
batches.map((batch) => [
|
|
2942
|
+
stringOf(batch.batch),
|
|
2943
|
+
`${stringOf(batch.range?.[0])}..${stringOf(batch.range?.[1])}`,
|
|
2944
|
+
stringOf(batch.records),
|
|
2945
|
+
]),
|
|
2946
|
+
markdown,
|
|
2947
|
+
);
|
|
2948
|
+
out += '\n';
|
|
2949
|
+
|
|
2950
|
+
const requests = Array.isArray(plan.requests) ? plan.requests : [];
|
|
2951
|
+
out += `将发送的请求(${requests.length} 个)\n`;
|
|
2952
|
+
const requestRows = requests.map((entry) => {
|
|
2953
|
+
const kind = stringOf(entry.kind);
|
|
2954
|
+
let detail = '';
|
|
2955
|
+
if (kind === 'write_records') {
|
|
2956
|
+
detail = `(${entry.records} 条,第 ${entry.batch} 批,幂等键 ${stringOf(entry.request?.headers?.['Idempotency-Key'])})`;
|
|
2957
|
+
} else if (kind === 'create_field') {
|
|
2958
|
+
detail = `(${stringOf(entry.column)} → ${stringOf(entry.type)}${entry.conditional === true ? ',追加时缺了才建' : ''})`;
|
|
2959
|
+
} else if (kind === 'create_base' && entry.conditional === true) {
|
|
2960
|
+
detail = '(按名字找不到才建)';
|
|
2961
|
+
} else if (kind === 'create_table') {
|
|
2962
|
+
detail = `(key=${stringOf(entry.request?.body?.key)})`;
|
|
2963
|
+
} else if (kind === 'upload_source') {
|
|
2964
|
+
detail = '(--keep-source)';
|
|
2965
|
+
}
|
|
2966
|
+
return [stringOf(entry.step), kindLabel(kind), stringOf(entry.request?.method), `${stringOf(entry.path)}${detail}`];
|
|
2967
|
+
});
|
|
2968
|
+
out += renderTable(['#', '动作', '方法', '路径'], requestRows, markdown);
|
|
2969
|
+
out +=
|
|
2970
|
+
'\n注意:`--dry-run` 不联网 —— 库/表是否已存在未确认;`<base_id>` / `<table_id>` 是占位符,\n' +
|
|
2971
|
+
'真实运行时才由服务端返回。计划里字段的"类型"就是建字段时会用的类型(推断或 --type 覆盖)。';
|
|
2972
|
+
return out;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function baseLine(base) {
|
|
2976
|
+
const name = typeof base.name === 'string' ? base.name : '<未指定>';
|
|
2977
|
+
switch (base.action) {
|
|
2978
|
+
case 'given':
|
|
2979
|
+
return `${stringOf(base.base_id)}(--base 直接指定)`;
|
|
2980
|
+
case 'reuse':
|
|
2981
|
+
return `${name}(已存在,复用 ${stringOf(base.base_id)})`;
|
|
2982
|
+
case 'create':
|
|
2983
|
+
return `${name}(不存在,会新建)`;
|
|
2984
|
+
default:
|
|
2985
|
+
return `${name}(--base-name:先按名字找,找不到才新建;dry-run 未确认)`;
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function tableLine(table) {
|
|
2990
|
+
const name = stringOf(table.name);
|
|
2991
|
+
if (table.action === 'append') {
|
|
2992
|
+
const suffix =
|
|
2993
|
+
typeof table.table_id === 'string' && table.table_id.length > 0
|
|
2994
|
+
? `;目标 ${table.table_id}`
|
|
2995
|
+
: ';dry-run 未联网,目标未确认';
|
|
2996
|
+
return `${name}(追加到已存在的表;找不到就报错,不会新建${suffix})`;
|
|
2997
|
+
}
|
|
2998
|
+
return `${name}(新建;key=${stringOf(table.key)})`;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
function kindLabel(kind) {
|
|
3002
|
+
switch (kind) {
|
|
3003
|
+
case 'create_base':
|
|
3004
|
+
return '建库';
|
|
3005
|
+
case 'create_table':
|
|
3006
|
+
return '建表';
|
|
3007
|
+
case 'create_field':
|
|
3008
|
+
return '建字段';
|
|
3009
|
+
case 'write_records':
|
|
3010
|
+
return '写记录';
|
|
3011
|
+
case 'upload_source':
|
|
3012
|
+
return '传原文件';
|
|
3013
|
+
default:
|
|
3014
|
+
return '追加目标';
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// ---------------------------------------------------------------------------
|
|
3019
|
+
// 小工具
|
|
3020
|
+
// ---------------------------------------------------------------------------
|
|
3021
|
+
/** `--batch-size`:默认 200,落到 1..=500(= 服务端 `row_ops_batch_max`)。 */
|
|
3022
|
+
function importBatchSize(args) {
|
|
3023
|
+
return clamp(intFlag(args, '--batch-size', { fallback: 200 }), 1, imp.MAX_BATCH);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
function clamp(value, min, max) {
|
|
3027
|
+
const number = Number.isFinite(value) ? value : min;
|
|
3028
|
+
return Math.min(Math.max(number, min), max);
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
function nowRfc3339() {
|
|
3032
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
function sleep(ms) {
|
|
3036
|
+
return new Promise((resolve) => {
|
|
3037
|
+
const handle = setTimeout(resolve, ms);
|
|
3038
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
3039
|
+
});
|
|
3040
|
+
}
|