@modesto_ma/dsh-channels 0.5.0-beta.1 → 0.5.0-beta.3
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 +15 -7
- package/docs/access-control-design.md +1 -0
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +6 -2
- package/lib/client.js +271 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +3374 -167
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/store.ts +31 -1
- package/src/cards/picker.ts +9 -16
- package/src/cards/wiki.ts +124 -0
- package/src/client/panel/shared.tsx +96 -1
- package/src/client/string-list.ts +139 -0
- package/src/client/styles.ts +3 -0
- package/src/commands/registry.ts +6 -3
- package/src/config.ts +50 -6
- package/src/domain.ts +10 -0
- package/src/feishu/delivery.ts +34 -1
- package/src/feishu/message-read.ts +78 -2
- package/src/feishu/scopes.ts +35 -0
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +361 -0
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/consent.ts +183 -0
- package/src/feishu/wiki/links.ts +122 -0
- package/src/feishu/wiki/scope.ts +66 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +897 -0
- package/src/feishu-channel.ts +23 -0
- package/src/service.ts +87 -7
- package/src/settings/contract.ts +42 -4
- package/src/settings/surface.ts +111 -10
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu wiki (知识库) and docx OpenAPI calls used by the wiki tools, made
|
|
3
|
+
* with the app identity (tenant access token).
|
|
4
|
+
*
|
|
5
|
+
* Structure calls use `wiki/v2` (`spaces/get_node`, `spaces/:id`,
|
|
6
|
+
* `spaces/:id/nodes`); document content uses the `docs_ai/v1` document
|
|
7
|
+
* endpoints, which read and write Markdown or Feishu document XML directly
|
|
8
|
+
* (block ids are exported only in XML); the revision check uses
|
|
9
|
+
* `docx/v1/documents/:id`. Every call maps a thrown transport error or a
|
|
10
|
+
* non-zero Feishu `code` to {@link FeishuApiError}.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/api
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
16
|
+
|
|
17
|
+
/** Object kinds a wiki node can hold. */
|
|
18
|
+
export type WikiObjType = 'doc' | 'docx' | 'sheet' | 'mindnote' | 'bitable' | 'file' | 'slides'
|
|
19
|
+
|
|
20
|
+
/** One wiki node as the tools use it. */
|
|
21
|
+
export interface WikiNode {
|
|
22
|
+
readonly spaceId: string
|
|
23
|
+
readonly nodeToken: string
|
|
24
|
+
/** Token of the document the node holds (the docx `document_id` for docx). */
|
|
25
|
+
readonly objToken: string
|
|
26
|
+
readonly objType: WikiObjType
|
|
27
|
+
/** Parent node; absent for a top-level node of its space. */
|
|
28
|
+
readonly parentNodeToken?: string
|
|
29
|
+
readonly nodeType: 'origin' | 'shortcut'
|
|
30
|
+
readonly title: string
|
|
31
|
+
readonly hasChild: boolean
|
|
32
|
+
/** Owner id as Feishu reports it (an open_id; the app's bot open_id when the app owns it). */
|
|
33
|
+
readonly owner?: string
|
|
34
|
+
/** Web link, when Feishu returned one. */
|
|
35
|
+
readonly url?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One page of child nodes. */
|
|
39
|
+
export interface WikiNodePage {
|
|
40
|
+
readonly items: readonly WikiNode[]
|
|
41
|
+
/** Cursor of the next page; absent on the last page. */
|
|
42
|
+
readonly pageToken?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Read scopes of `docs_ai` fetch. */
|
|
46
|
+
export type WikiReadScope = 'full' | 'outline' | 'section' | 'range' | 'keyword'
|
|
47
|
+
|
|
48
|
+
/** Content format of `docs_ai` reads and writes. */
|
|
49
|
+
export type WikiDocFormat = 'markdown' | 'xml'
|
|
50
|
+
|
|
51
|
+
/** One `docs_ai` fetch request. */
|
|
52
|
+
export interface WikiFetchRequest {
|
|
53
|
+
readonly format: WikiDocFormat
|
|
54
|
+
/** Export block ids (XML only; Feishu ignores it for Markdown). */
|
|
55
|
+
readonly withBlockIds: boolean
|
|
56
|
+
readonly scope: WikiReadScope
|
|
57
|
+
readonly startBlockId?: string
|
|
58
|
+
readonly endBlockId?: string
|
|
59
|
+
readonly keyword?: string
|
|
60
|
+
/** `outline`: heading level cap; other scopes: subtree depth. */
|
|
61
|
+
readonly maxDepth?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One fetched document. */
|
|
65
|
+
export interface WikiDocument {
|
|
66
|
+
readonly revisionId: number
|
|
67
|
+
readonly content: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `docs_ai` update commands. */
|
|
71
|
+
export type WikiUpdateCommand =
|
|
72
|
+
| 'str_replace'
|
|
73
|
+
| 'block_insert_after'
|
|
74
|
+
| 'block_replace'
|
|
75
|
+
| 'block_delete'
|
|
76
|
+
| 'overwrite'
|
|
77
|
+
|
|
78
|
+
/** One `docs_ai` update request. */
|
|
79
|
+
export interface WikiUpdateRequest {
|
|
80
|
+
readonly command: WikiUpdateCommand
|
|
81
|
+
readonly format: WikiDocFormat
|
|
82
|
+
readonly content?: string
|
|
83
|
+
readonly pattern?: string
|
|
84
|
+
/** Anchor or target block; `-1` = document end for `block_insert_after`. */
|
|
85
|
+
readonly blockId?: string
|
|
86
|
+
readonly startBlockId?: string
|
|
87
|
+
readonly endBlockId?: string
|
|
88
|
+
/** Base revision; Feishu rejects the update when the document moved past it. */
|
|
89
|
+
readonly revisionId?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Outcome of one update. */
|
|
93
|
+
export interface WikiUpdateResult {
|
|
94
|
+
/** `success`, `partial_success`, or `failed` as Feishu reports it. */
|
|
95
|
+
readonly result: string
|
|
96
|
+
readonly revisionId?: number
|
|
97
|
+
readonly updatedBlocks?: number
|
|
98
|
+
readonly warnings: readonly string[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The wiki/docx endpoints the wiki tools consume. */
|
|
102
|
+
export interface WikiApi {
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a node token (or, with `objType: 'docx'`, a docx token mounted in
|
|
105
|
+
* the wiki) to its node.
|
|
106
|
+
* @param token Node token or docx token.
|
|
107
|
+
* @param objType `wiki` for a node token, `docx` for a document token.
|
|
108
|
+
* @returns The node.
|
|
109
|
+
*/
|
|
110
|
+
getNode(token: string, objType: 'wiki' | 'docx'): Promise<WikiNode>
|
|
111
|
+
/**
|
|
112
|
+
* Name of one space.
|
|
113
|
+
* @param spaceId Wiki space id.
|
|
114
|
+
* @returns The space name.
|
|
115
|
+
*/
|
|
116
|
+
spaceName(spaceId: string): Promise<string>
|
|
117
|
+
/**
|
|
118
|
+
* One page of children; the space's top-level nodes without a parent.
|
|
119
|
+
* @param spaceId Wiki space id.
|
|
120
|
+
* @param parentNodeToken Parent node, or undefined for the space top level.
|
|
121
|
+
* @param pageToken Cursor from the previous page.
|
|
122
|
+
* @returns The page.
|
|
123
|
+
*/
|
|
124
|
+
listChildren(spaceId: string, parentNodeToken: string | undefined, pageToken?: string): Promise<WikiNodePage>
|
|
125
|
+
/**
|
|
126
|
+
* Create one empty docx, spreadsheet, or Base node.
|
|
127
|
+
* @param spaceId Wiki space id.
|
|
128
|
+
* @param parentNodeToken Parent node, or undefined for the space top level.
|
|
129
|
+
* @param title Node title.
|
|
130
|
+
* @param objType Object kind of the new node.
|
|
131
|
+
* @returns The new node.
|
|
132
|
+
*/
|
|
133
|
+
createNode(spaceId: string, parentNodeToken: string | undefined, title: string, objType: 'docx' | 'sheet' | 'bitable'): Promise<WikiNode>
|
|
134
|
+
/**
|
|
135
|
+
* Current revision of one docx document.
|
|
136
|
+
* @param documentId Docx document id (the node's `objToken`).
|
|
137
|
+
* @returns The revision id.
|
|
138
|
+
*/
|
|
139
|
+
documentRevision(documentId: string): Promise<number>
|
|
140
|
+
/**
|
|
141
|
+
* Read one docx document.
|
|
142
|
+
* @param documentId Docx document id.
|
|
143
|
+
* @param request Format and scope.
|
|
144
|
+
* @returns Content and revision.
|
|
145
|
+
*/
|
|
146
|
+
fetchDocument(documentId: string, request: WikiFetchRequest): Promise<WikiDocument>
|
|
147
|
+
/**
|
|
148
|
+
* Apply one update command to a docx document.
|
|
149
|
+
* @param documentId Docx document id.
|
|
150
|
+
* @param request The command.
|
|
151
|
+
* @returns Feishu's outcome.
|
|
152
|
+
*/
|
|
153
|
+
updateDocument(documentId: string, request: WikiUpdateRequest): Promise<WikiUpdateResult>
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The SDK's generic authenticated request (`Client.request`). */
|
|
157
|
+
export type WikiRawRequest = <T>(input: {
|
|
158
|
+
method: 'GET' | 'POST' | 'PUT'
|
|
159
|
+
url: string
|
|
160
|
+
params?: Record<string, string | number>
|
|
161
|
+
data?: object
|
|
162
|
+
}) => Promise<T>
|
|
163
|
+
|
|
164
|
+
interface FeishuEnvelope<T> {
|
|
165
|
+
code?: number
|
|
166
|
+
msg?: string
|
|
167
|
+
data?: T
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface RawNode {
|
|
171
|
+
space_id?: string
|
|
172
|
+
node_token?: string
|
|
173
|
+
obj_token?: string
|
|
174
|
+
obj_type?: string
|
|
175
|
+
parent_node_token?: string
|
|
176
|
+
node_type?: string
|
|
177
|
+
title?: string
|
|
178
|
+
has_child?: boolean
|
|
179
|
+
owner?: string
|
|
180
|
+
url?: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const OBJ_TYPES: ReadonlySet<string> = new Set(['doc', 'docx', 'sheet', 'mindnote', 'bitable', 'file', 'slides'])
|
|
184
|
+
|
|
185
|
+
/** Feishu's per-page maximum for `spaces/:id/nodes`. */
|
|
186
|
+
const LIST_PAGE_SIZE = 50
|
|
187
|
+
|
|
188
|
+
function thrownError(error: unknown): FeishuApiError {
|
|
189
|
+
if (error instanceof FeishuApiError) return error
|
|
190
|
+
const data = (error as { response?: { data?: { code?: unknown; msg?: unknown } } } | undefined)?.response?.data
|
|
191
|
+
const code = typeof data?.code === 'number' ? data.code : undefined
|
|
192
|
+
const msg = typeof data?.msg === 'string' ? data.msg : undefined
|
|
193
|
+
if (code === undefined && msg === undefined) return new FeishuApiError(error instanceof Error ? error.message : String(error))
|
|
194
|
+
return new FeishuApiError([code === undefined ? undefined : `code=${code}`, msg].filter(Boolean).join(' '), code)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One Feishu OpenAPI call: unwraps `data` and maps a transport error or a
|
|
199
|
+
* non-zero `code` to {@link FeishuApiError}.
|
|
200
|
+
*
|
|
201
|
+
* @param request The SDK's generic request.
|
|
202
|
+
* @param input Method, URL, query, and body.
|
|
203
|
+
* @returns The response `data`.
|
|
204
|
+
*/
|
|
205
|
+
export async function call<T>(request: WikiRawRequest, input: Parameters<WikiRawRequest>[0]): Promise<T> {
|
|
206
|
+
let response: FeishuEnvelope<T>
|
|
207
|
+
try {
|
|
208
|
+
response = await request<FeishuEnvelope<T>>(input)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw thrownError(error)
|
|
211
|
+
}
|
|
212
|
+
if (response.code !== undefined && response.code !== 0) {
|
|
213
|
+
throw new FeishuApiError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
|
|
214
|
+
}
|
|
215
|
+
if (response.data === undefined) throw new FeishuApiError(`${input.url}: response has no data`)
|
|
216
|
+
return response.data
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function wikiNode(raw: RawNode | undefined, where: string): WikiNode {
|
|
220
|
+
if (raw?.space_id === undefined || raw.node_token === undefined || raw.obj_token === undefined
|
|
221
|
+
|| raw.obj_type === undefined || !OBJ_TYPES.has(raw.obj_type)) {
|
|
222
|
+
throw new FeishuApiError(`${where}: response node is incomplete`)
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
spaceId: raw.space_id,
|
|
226
|
+
nodeToken: raw.node_token,
|
|
227
|
+
objToken: raw.obj_token,
|
|
228
|
+
objType: raw.obj_type as WikiObjType,
|
|
229
|
+
...(raw.parent_node_token === undefined || raw.parent_node_token === '' ? {} : { parentNodeToken: raw.parent_node_token }),
|
|
230
|
+
nodeType: raw.node_type === 'shortcut' ? 'shortcut' : 'origin',
|
|
231
|
+
title: raw.title ?? '',
|
|
232
|
+
hasChild: raw.has_child === true,
|
|
233
|
+
...(raw.owner === undefined || raw.owner === '' ? {} : { owner: raw.owner }),
|
|
234
|
+
...(raw.url === undefined || raw.url === '' ? {} : { url: raw.url }),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Encode one URL path segment.
|
|
240
|
+
*
|
|
241
|
+
* @param value Raw segment.
|
|
242
|
+
* @returns The encoded segment.
|
|
243
|
+
*/
|
|
244
|
+
export function segment(value: string): string {
|
|
245
|
+
return encodeURIComponent(value)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* {@link WikiApi} over the SDK's generic authenticated request.
|
|
250
|
+
*
|
|
251
|
+
* @param request `Client.request` of the connected SDK client.
|
|
252
|
+
* @returns The API.
|
|
253
|
+
*/
|
|
254
|
+
export function createWikiApi(request: WikiRawRequest): WikiApi {
|
|
255
|
+
return {
|
|
256
|
+
async getNode(token, objType) {
|
|
257
|
+
const url = '/open-apis/wiki/v2/spaces/get_node'
|
|
258
|
+
const data = await call<{ node?: RawNode }>(request, { method: 'GET', url, params: { token, obj_type: objType } })
|
|
259
|
+
return wikiNode(data.node, url)
|
|
260
|
+
},
|
|
261
|
+
async spaceName(spaceId) {
|
|
262
|
+
const data = await call<{ space?: { name?: string } }>(request, {
|
|
263
|
+
method: 'GET', url: `/open-apis/wiki/v2/spaces/${segment(spaceId)}`,
|
|
264
|
+
})
|
|
265
|
+
return data.space?.name ?? ''
|
|
266
|
+
},
|
|
267
|
+
async listChildren(spaceId, parentNodeToken, pageToken) {
|
|
268
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`
|
|
269
|
+
const data = await call<{ items?: RawNode[]; page_token?: string; has_more?: boolean }>(request, {
|
|
270
|
+
method: 'GET',
|
|
271
|
+
url,
|
|
272
|
+
params: {
|
|
273
|
+
page_size: LIST_PAGE_SIZE,
|
|
274
|
+
...(parentNodeToken === undefined ? {} : { parent_node_token: parentNodeToken }),
|
|
275
|
+
...(pageToken === undefined ? {} : { page_token: pageToken }),
|
|
276
|
+
},
|
|
277
|
+
})
|
|
278
|
+
return {
|
|
279
|
+
items: (data.items ?? []).map(item => wikiNode(item, url)),
|
|
280
|
+
...(data.has_more === true && data.page_token !== undefined && data.page_token !== '' ? { pageToken: data.page_token } : {}),
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async createNode(spaceId, parentNodeToken, title, objType) {
|
|
284
|
+
const url = `/open-apis/wiki/v2/spaces/${segment(spaceId)}/nodes`
|
|
285
|
+
const data = await call<{ node?: RawNode }>(request, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
url,
|
|
288
|
+
data: {
|
|
289
|
+
obj_type: objType,
|
|
290
|
+
node_type: 'origin',
|
|
291
|
+
title,
|
|
292
|
+
...(parentNodeToken === undefined ? {} : { parent_node_token: parentNodeToken }),
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
return wikiNode(data.node, url)
|
|
296
|
+
},
|
|
297
|
+
async documentRevision(documentId) {
|
|
298
|
+
const url = `/open-apis/docx/v1/documents/${segment(documentId)}`
|
|
299
|
+
const data = await call<{ document?: { revision_id?: number } }>(request, { method: 'GET', url })
|
|
300
|
+
const revision = data.document?.revision_id
|
|
301
|
+
if (typeof revision !== 'number') throw new FeishuApiError(`${url}: response has no revision_id`)
|
|
302
|
+
return revision
|
|
303
|
+
},
|
|
304
|
+
async fetchDocument(documentId, fetch) {
|
|
305
|
+
const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}/fetch`
|
|
306
|
+
const ids = fetch.format === 'xml' && fetch.withBlockIds
|
|
307
|
+
const readOption = fetch.scope === 'full' ? undefined : {
|
|
308
|
+
read_mode: fetch.scope,
|
|
309
|
+
...(fetch.startBlockId === undefined ? {} : { start_block_id: fetch.startBlockId }),
|
|
310
|
+
...(fetch.endBlockId === undefined ? {} : { end_block_id: fetch.endBlockId }),
|
|
311
|
+
...(fetch.keyword === undefined ? {} : { keyword: fetch.keyword }),
|
|
312
|
+
...(fetch.maxDepth === undefined ? {} : { max_depth: String(fetch.maxDepth) }),
|
|
313
|
+
}
|
|
314
|
+
const data = await call<{ document?: { revision_id?: number; content?: string } }>(request, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
url,
|
|
317
|
+
data: {
|
|
318
|
+
format: fetch.format,
|
|
319
|
+
export_option: { export_block_id: ids, export_style_attrs: false, export_cite_extra_data: false },
|
|
320
|
+
...(readOption === undefined ? {} : { read_option: readOption }),
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
const document = data.document
|
|
324
|
+
if (document === undefined || typeof document.content !== 'string') {
|
|
325
|
+
throw new FeishuApiError(`${url}: response has no document content`)
|
|
326
|
+
}
|
|
327
|
+
return { revisionId: document.revision_id ?? 0, content: document.content }
|
|
328
|
+
},
|
|
329
|
+
async updateDocument(documentId, update) {
|
|
330
|
+
const url = `/open-apis/docs_ai/v1/documents/${segment(documentId)}`
|
|
331
|
+
const data = await call<{
|
|
332
|
+
result?: string
|
|
333
|
+
updated_blocks_count?: number
|
|
334
|
+
warnings?: unknown
|
|
335
|
+
document?: { revision_id?: number }
|
|
336
|
+
}>(request, {
|
|
337
|
+
method: 'PUT',
|
|
338
|
+
url,
|
|
339
|
+
data: {
|
|
340
|
+
format: update.format,
|
|
341
|
+
command: update.command,
|
|
342
|
+
...(update.content === undefined ? {} : { content: update.content }),
|
|
343
|
+
...(update.pattern === undefined ? {} : { pattern: update.pattern }),
|
|
344
|
+
...(update.blockId === undefined ? {} : { block_id: update.blockId }),
|
|
345
|
+
...(update.startBlockId === undefined ? {} : { start_block_id: update.startBlockId }),
|
|
346
|
+
...(update.endBlockId === undefined ? {} : { end_block_id: update.endBlockId }),
|
|
347
|
+
...(update.revisionId === undefined ? {} : { revision_id: update.revisionId }),
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
const warnings = Array.isArray(data.warnings)
|
|
351
|
+
? data.warnings.filter((item): item is string => typeof item === 'string' && item !== '')
|
|
352
|
+
: []
|
|
353
|
+
return {
|
|
354
|
+
result: data.result ?? 'success',
|
|
355
|
+
...(data.document?.revision_id === undefined ? {} : { revisionId: data.document.revision_id }),
|
|
356
|
+
...(data.updated_blocks_count === undefined ? {} : { updatedBlocks: data.updated_blocks_count }),
|
|
357
|
+
warnings,
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu Base (多维表格) calls used by the wiki base tools, made with the app
|
|
3
|
+
* identity (tenant access token).
|
|
4
|
+
*
|
|
5
|
+
* Everything goes through `base/v3/bases/:base_token/...` (the legacy
|
|
6
|
+
* `bitable/v1` API is not used). Record reads return a column matrix that
|
|
7
|
+
* this module turns into one field map per record; record writes take flat
|
|
8
|
+
* maps keyed by field name or field id. Writes carry no precondition, are
|
|
9
|
+
* applied asynchronously (a read straight after a write may not show it), and
|
|
10
|
+
* must not run in parallel on one table.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-channels/feishu/wiki/base-api
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { FeishuApiError } from '../../feishu-channel.ts'
|
|
16
|
+
import { call, segment } from './api.ts'
|
|
17
|
+
import type { WikiRawRequest } from './api.ts'
|
|
18
|
+
|
|
19
|
+
/** Most records one batch write may carry (Feishu limit). */
|
|
20
|
+
export const BASE_BATCH_MAX_RECORDS = 200
|
|
21
|
+
|
|
22
|
+
/** One table of a base. */
|
|
23
|
+
export interface BaseTable {
|
|
24
|
+
readonly tableId: string
|
|
25
|
+
readonly name: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One field (column) of a table, as Feishu describes it. */
|
|
29
|
+
export interface BaseField {
|
|
30
|
+
readonly fieldId: string
|
|
31
|
+
readonly name: string
|
|
32
|
+
/** `text`, `number`, `select`, `datetime`, `user`, `link`, `checkbox`, … */
|
|
33
|
+
readonly type: string
|
|
34
|
+
/** Remaining Feishu field properties (style, options, multiple, …), passed through. */
|
|
35
|
+
readonly properties: Readonly<Record<string, unknown>>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One cell value as Feishu reads or writes it (JSON). */
|
|
39
|
+
export type BaseCellValue = unknown
|
|
40
|
+
|
|
41
|
+
/** One record: its id and a map from field name to value. */
|
|
42
|
+
export interface BaseRecord {
|
|
43
|
+
readonly recordId: string
|
|
44
|
+
readonly fields: Readonly<Record<string, BaseCellValue>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Filter, sort, projection, and paging of a record query. */
|
|
48
|
+
export interface BaseRecordQuery {
|
|
49
|
+
/** `{logic, conditions: [[field, operator, value?], …]}`. */
|
|
50
|
+
readonly filter?: object
|
|
51
|
+
/** `[{field, desc}]`. */
|
|
52
|
+
readonly sort?: readonly object[]
|
|
53
|
+
/** Field names or ids to return; all view-visible fields when absent. */
|
|
54
|
+
readonly fields?: readonly string[]
|
|
55
|
+
readonly viewId?: string
|
|
56
|
+
readonly offset: number
|
|
57
|
+
readonly limit: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A keyword search over some fields. */
|
|
61
|
+
export interface BaseRecordSearch extends BaseRecordQuery {
|
|
62
|
+
readonly keyword: string
|
|
63
|
+
readonly searchFields: readonly string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One page of records. */
|
|
67
|
+
export interface BaseRecordPage {
|
|
68
|
+
readonly records: readonly BaseRecord[]
|
|
69
|
+
readonly hasMore: boolean
|
|
70
|
+
/** Table data snapshot version; changes when the data changes. */
|
|
71
|
+
readonly rev?: number
|
|
72
|
+
readonly timezone?: string
|
|
73
|
+
/** Fields Feishu skipped, with its reason. */
|
|
74
|
+
readonly ignoredFields: readonly string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Field values of one record write, keyed by field name or id. */
|
|
78
|
+
export type BaseRecordFields = Readonly<Record<string, BaseCellValue>>
|
|
79
|
+
|
|
80
|
+
/** The `base/v3` endpoints the wiki base tools consume. */
|
|
81
|
+
export interface BaseApi {
|
|
82
|
+
/**
|
|
83
|
+
* Tables of one base.
|
|
84
|
+
* @param baseToken Base token (the wiki node's `objToken`).
|
|
85
|
+
* @returns The tables.
|
|
86
|
+
*/
|
|
87
|
+
listTables(baseToken: string): Promise<readonly BaseTable[]>
|
|
88
|
+
/**
|
|
89
|
+
* Fields of one table.
|
|
90
|
+
* @param baseToken Base token.
|
|
91
|
+
* @param tableId Table id.
|
|
92
|
+
* @returns The fields.
|
|
93
|
+
*/
|
|
94
|
+
listFields(baseToken: string, tableId: string): Promise<readonly BaseField[]>
|
|
95
|
+
/**
|
|
96
|
+
* One page of records, filtered and sorted.
|
|
97
|
+
* @param baseToken Base token.
|
|
98
|
+
* @param tableId Table id.
|
|
99
|
+
* @param query Filter, sort, projection, paging.
|
|
100
|
+
* @returns The page.
|
|
101
|
+
*/
|
|
102
|
+
listRecords(baseToken: string, tableId: string, query: BaseRecordQuery): Promise<BaseRecordPage>
|
|
103
|
+
/**
|
|
104
|
+
* One page of records matching a keyword.
|
|
105
|
+
* @param baseToken Base token.
|
|
106
|
+
* @param tableId Table id.
|
|
107
|
+
* @param search Keyword, searched fields, and query options.
|
|
108
|
+
* @returns The page.
|
|
109
|
+
*/
|
|
110
|
+
searchRecords(baseToken: string, tableId: string, search: BaseRecordSearch): Promise<BaseRecordPage>
|
|
111
|
+
/**
|
|
112
|
+
* Create records (at most {@link BASE_BATCH_MAX_RECORDS}).
|
|
113
|
+
* @param baseToken Base token.
|
|
114
|
+
* @param tableId Table id.
|
|
115
|
+
* @param records Field maps.
|
|
116
|
+
* @returns The new record ids, in input order.
|
|
117
|
+
*/
|
|
118
|
+
createRecords(baseToken: string, tableId: string, records: readonly BaseRecordFields[]): Promise<readonly string[]>
|
|
119
|
+
/**
|
|
120
|
+
* Update records (at most {@link BASE_BATCH_MAX_RECORDS}); only the given fields change.
|
|
121
|
+
* @param baseToken Base token.
|
|
122
|
+
* @param tableId Table id.
|
|
123
|
+
* @param updates Record id → changed fields.
|
|
124
|
+
* @returns Fields Feishu ignored (read-only fields), with its reason.
|
|
125
|
+
*/
|
|
126
|
+
updateRecords(baseToken: string, tableId: string, updates: Readonly<Record<string, BaseRecordFields>>): Promise<readonly string[]>
|
|
127
|
+
/**
|
|
128
|
+
* Delete records (at most {@link BASE_BATCH_MAX_RECORDS}).
|
|
129
|
+
* @param baseToken Base token.
|
|
130
|
+
* @param tableId Table id.
|
|
131
|
+
* @param recordIds Records to delete.
|
|
132
|
+
*/
|
|
133
|
+
deleteRecords(baseToken: string, tableId: string, recordIds: readonly string[]): Promise<void>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface RawMatrix {
|
|
137
|
+
fields?: unknown
|
|
138
|
+
record_id_list?: unknown
|
|
139
|
+
data?: unknown
|
|
140
|
+
has_more?: unknown
|
|
141
|
+
rev?: unknown
|
|
142
|
+
timezone?: unknown
|
|
143
|
+
ignored_fields?: unknown
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stringArray(value: unknown): string[] | undefined {
|
|
147
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string') ? value as string[] : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ignoredFieldsOf(value: unknown): string[] {
|
|
151
|
+
if (!Array.isArray(value)) return []
|
|
152
|
+
return value.flatMap((item) => {
|
|
153
|
+
if (typeof item !== 'object' || item === null) return []
|
|
154
|
+
const { name, id, reason } = item as { name?: unknown; id?: unknown; reason?: unknown }
|
|
155
|
+
const label = typeof name === 'string' && name !== '' ? name : typeof id === 'string' ? id : '?'
|
|
156
|
+
return [typeof reason === 'string' && reason !== '' ? `${label}: ${reason}` : label]
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function recordPage(raw: RawMatrix, where: string): BaseRecordPage {
|
|
161
|
+
const names = stringArray(raw.fields) ?? []
|
|
162
|
+
const ids = stringArray(raw.record_id_list) ?? []
|
|
163
|
+
const rows = Array.isArray(raw.data) ? raw.data : []
|
|
164
|
+
if (rows.length !== ids.length) throw new FeishuApiError(`${where}: record ids and rows differ in length`)
|
|
165
|
+
const records = ids.map((recordId, index) => {
|
|
166
|
+
const row: unknown = rows[index]
|
|
167
|
+
if (!Array.isArray(row) || row.length !== names.length) throw new FeishuApiError(`${where}: row ${index} does not match the field list`)
|
|
168
|
+
return { recordId, fields: Object.fromEntries(names.map((name, column) => [name, row[column] ?? null])) }
|
|
169
|
+
})
|
|
170
|
+
return {
|
|
171
|
+
records,
|
|
172
|
+
hasMore: raw.has_more === true,
|
|
173
|
+
...(typeof raw.rev === 'number' ? { rev: raw.rev } : {}),
|
|
174
|
+
...(typeof raw.timezone === 'string' && raw.timezone !== '' ? { timezone: raw.timezone } : {}),
|
|
175
|
+
ignoredFields: ignoredFieldsOf(raw.ignored_fields),
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* {@link BaseApi} over the SDK's generic authenticated request.
|
|
181
|
+
*
|
|
182
|
+
* @param request `Client.request` of the connected SDK client.
|
|
183
|
+
* @returns The API.
|
|
184
|
+
*/
|
|
185
|
+
export function createBaseApi(request: WikiRawRequest): BaseApi {
|
|
186
|
+
const tablePath = (baseToken: string, tableId: string): string =>
|
|
187
|
+
`/open-apis/base/v3/bases/${segment(baseToken)}/tables/${segment(tableId)}`
|
|
188
|
+
return {
|
|
189
|
+
async listTables(baseToken) {
|
|
190
|
+
const data = await call<{ tables?: unknown; items?: unknown }>(request, {
|
|
191
|
+
method: 'GET',
|
|
192
|
+
url: `/open-apis/base/v3/bases/${segment(baseToken)}/tables`,
|
|
193
|
+
params: { offset: 0, limit: 300 },
|
|
194
|
+
})
|
|
195
|
+
const raw = Array.isArray(data.tables) ? data.tables : Array.isArray(data.items) ? data.items : []
|
|
196
|
+
return raw.flatMap((item: unknown) => {
|
|
197
|
+
if (typeof item !== 'object' || item === null) return []
|
|
198
|
+
const { id, table_id, name, table_name } = item as Record<string, unknown>
|
|
199
|
+
const tableId = typeof id === 'string' ? id : typeof table_id === 'string' ? table_id : undefined
|
|
200
|
+
if (tableId === undefined) return []
|
|
201
|
+
return [{ tableId, name: typeof name === 'string' ? name : typeof table_name === 'string' ? table_name : '' }]
|
|
202
|
+
})
|
|
203
|
+
},
|
|
204
|
+
async listFields(baseToken, tableId) {
|
|
205
|
+
const data = await call<{ fields?: unknown; items?: unknown }>(request, {
|
|
206
|
+
method: 'GET',
|
|
207
|
+
url: `${tablePath(baseToken, tableId)}/fields`,
|
|
208
|
+
params: { offset: 0, limit: 300 },
|
|
209
|
+
})
|
|
210
|
+
const raw = Array.isArray(data.fields) ? data.fields : Array.isArray(data.items) ? data.items : []
|
|
211
|
+
return raw.flatMap((item: unknown) => {
|
|
212
|
+
if (typeof item !== 'object' || item === null) return []
|
|
213
|
+
const { id, field_id, name, field_name, type, ...properties } = item as Record<string, unknown>
|
|
214
|
+
const fieldId = typeof id === 'string' ? id : typeof field_id === 'string' ? field_id : undefined
|
|
215
|
+
if (fieldId === undefined) return []
|
|
216
|
+
return [{
|
|
217
|
+
fieldId,
|
|
218
|
+
name: typeof name === 'string' ? name : typeof field_name === 'string' ? field_name : '',
|
|
219
|
+
type: typeof type === 'string' ? type : 'not_support',
|
|
220
|
+
properties,
|
|
221
|
+
}]
|
|
222
|
+
})
|
|
223
|
+
},
|
|
224
|
+
async listRecords(baseToken, tableId, query) {
|
|
225
|
+
const url = `${tablePath(baseToken, tableId)}/records`
|
|
226
|
+
const params = new URLSearchParams({ offset: String(query.offset), limit: String(query.limit) })
|
|
227
|
+
for (const field of query.fields ?? []) params.append('field_id', field)
|
|
228
|
+
if (query.viewId !== undefined) params.set('view_id', query.viewId)
|
|
229
|
+
if (query.filter !== undefined) params.set('filter', JSON.stringify(query.filter))
|
|
230
|
+
if (query.sort !== undefined) params.set('sort', JSON.stringify(query.sort))
|
|
231
|
+
const data = await call<RawMatrix>(request, { method: 'GET', url: `${url}?${params.toString()}` })
|
|
232
|
+
return recordPage(data, url)
|
|
233
|
+
},
|
|
234
|
+
async searchRecords(baseToken, tableId, search) {
|
|
235
|
+
const url = `${tablePath(baseToken, tableId)}/records/search`
|
|
236
|
+
const data = await call<RawMatrix>(request, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
url,
|
|
239
|
+
data: {
|
|
240
|
+
keyword: search.keyword,
|
|
241
|
+
search_fields: search.searchFields,
|
|
242
|
+
...(search.fields === undefined ? {} : { select_fields: search.fields }),
|
|
243
|
+
...(search.viewId === undefined ? {} : { view_id: search.viewId }),
|
|
244
|
+
...(search.filter === undefined ? {} : { filter: search.filter }),
|
|
245
|
+
...(search.sort === undefined ? {} : { sort: search.sort }),
|
|
246
|
+
offset: search.offset,
|
|
247
|
+
limit: search.limit,
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
return recordPage(data, url)
|
|
251
|
+
},
|
|
252
|
+
async createRecords(baseToken, tableId, records) {
|
|
253
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_create`
|
|
254
|
+
const data = await call<{ record_id_list?: unknown }>(request, { method: 'POST', url, data: { create_records: records } })
|
|
255
|
+
return stringArray(data.record_id_list) ?? []
|
|
256
|
+
},
|
|
257
|
+
async updateRecords(baseToken, tableId, updates) {
|
|
258
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_update`
|
|
259
|
+
const data = await call<{ ignored_fields?: unknown }>(request, { method: 'POST', url, data: { update_records: updates } })
|
|
260
|
+
return ignoredFieldsOf(data.ignored_fields)
|
|
261
|
+
},
|
|
262
|
+
async deleteRecords(baseToken, tableId, recordIds) {
|
|
263
|
+
const url = `${tablePath(baseToken, tableId)}/records/batch_delete`
|
|
264
|
+
await call<unknown>(request, { method: 'POST', url, data: { record_id_list: recordIds } })
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
}
|