@modesto_ma/dsh-channels 0.5.0-beta.2 → 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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Failure values and helpers shared by the wiki tool families (docx, sheet,
3
+ * base): stable failure codes with readable reasons, argument parsing,
4
+ * Feishu error mapping, ownership, links, and consent-card previews.
5
+ *
6
+ * @module dsh-channels/feishu/wiki/shared
7
+ */
8
+
9
+ import type { z } from 'zod'
10
+ import { FeishuApiError } from '../../feishu-channel.ts'
11
+ import type { WikiConsentAction } from '../../cards/wiki.ts'
12
+ import type { InteractionRoute } from '../../interactions/pending.ts'
13
+ import type { WikiApi, WikiNode } from './api.ts'
14
+ import type { BaseApi } from './base-api.ts'
15
+ import type { WikiConsentOutcome } from './consent.ts'
16
+ import type { WikiRef, WikiRoot } from './links.ts'
17
+ import type { SheetApi } from './sheet-api.ts'
18
+
19
+ /** What every gated call works with. */
20
+ export interface WikiCallContext {
21
+ /** Topic and owner the consent cards go to. */
22
+ readonly route: InteractionRoute
23
+ readonly api: WikiApi
24
+ /** Absent on channel stubs without spreadsheet support. */
25
+ readonly sheets?: SheetApi
26
+ /** Absent on channel stubs without base support. */
27
+ readonly bases?: BaseApi
28
+ readonly roots: readonly WikiRoot[]
29
+ readonly botOpenId: string | undefined
30
+ }
31
+
32
+ /** Outcome of the per-call gates. */
33
+ export type WikiGate = { ok: true; context: WikiCallContext } | WikiFailure
34
+
35
+ /** Consent card summary fields. */
36
+ export interface WikiConsentFields {
37
+ readonly title: string
38
+ readonly url?: string
39
+ readonly location: string
40
+ readonly details: readonly string[]
41
+ }
42
+
43
+ /** The gates and consent steps every wiki tool family runs through (implemented by the wiki tool runtime). */
44
+ export interface WikiToolKit {
45
+ /**
46
+ * Switch, route, whitelist, and channel gates of one call.
47
+ * @param sessionId The calling Session.
48
+ * @param tool Tool name (its switch decides).
49
+ * @returns The call context, or the failure.
50
+ */
51
+ gate(sessionId: string, tool: string): WikiGate
52
+ /**
53
+ * Resolve a node reference and check `wikiRoots` containment.
54
+ * @param context The call context.
55
+ * @param ref Parsed reference (a space is rejected).
56
+ * @returns The node, or the failure.
57
+ */
58
+ containedNode(context: WikiCallContext, ref: WikiRef): Promise<WikiNode | WikiFailure>
59
+ /**
60
+ * Ask the topic owner on a consent card and wait.
61
+ * @param sessionId The calling Session.
62
+ * @param context The call context.
63
+ * @param action What is asked for.
64
+ * @param fields Card summary.
65
+ * @param signal The tool call's abort signal.
66
+ * @returns Undefined when allowed, else the failure.
67
+ */
68
+ consent(
69
+ sessionId: string,
70
+ context: WikiCallContext,
71
+ action: WikiConsentAction,
72
+ fields: WikiConsentFields,
73
+ signal: AbortSignal,
74
+ ): Promise<WikiFailure | undefined>
75
+ /**
76
+ * Read consent for one node: reuses the Session's standing grant, otherwise asks and records it.
77
+ * @param sessionId The calling Session.
78
+ * @param context The call context.
79
+ * @param node The node to open.
80
+ * @param signal The tool call's abort signal.
81
+ * @returns Undefined when reading may proceed, else the failure.
82
+ */
83
+ readConsent(sessionId: string, context: WikiCallContext, node: WikiNode, signal: AbortSignal): Promise<WikiFailure | undefined>
84
+ /**
85
+ * Record a standing read grant (for a document the owner just approved creating).
86
+ * @param sessionId The calling Session.
87
+ * @param documentId The node's `objToken`.
88
+ */
89
+ grantRead(sessionId: string, documentId: string): void
90
+ /**
91
+ * `空间 / 父节点` label of a node.
92
+ * @param api Wiki endpoints.
93
+ * @param node The node.
94
+ * @returns The label.
95
+ */
96
+ locationOf(api: WikiApi, node: WikiNode): Promise<string>
97
+ /** Whether writes are switched on (`feishuTools.wikiWrite`). */
98
+ writeEnabled(): boolean
99
+ /**
100
+ * Structured warning sink.
101
+ * @param message Format string.
102
+ * @param args Format arguments.
103
+ */
104
+ warn(message: string, ...args: unknown[]): void
105
+ }
106
+
107
+ /** One model-facing tool of a wiki tool family (the runtime adds output rendering and mounting). */
108
+ export interface WikiToolSpec {
109
+ readonly name: string
110
+ /** Switch that mounts it: `wikiRead` or `wikiWrite`. */
111
+ readonly access: 'read' | 'write'
112
+ readonly description: string
113
+ readonly parameters: Record<string, unknown>
114
+ /**
115
+ * Run one call; never rejects for Feishu failures (they become failure values).
116
+ * @param kit Gates and consent.
117
+ * @param sessionId The calling Session.
118
+ * @param rawArgs Model arguments (validated here).
119
+ * @param signal The tool call's abort signal.
120
+ * @returns The tool value.
121
+ */
122
+ execute(kit: WikiToolKit, sessionId: string, rawArgs: Record<string, unknown>, signal: AbortSignal): Promise<object>
123
+ /**
124
+ * One-line call presentation.
125
+ * @param args Model arguments.
126
+ * @returns Display text.
127
+ */
128
+ presentCall(args: Record<string, unknown>): string
129
+ }
130
+
131
+ /** Characters of each content shown on a consent card. */
132
+ export const PREVIEW_CHARS = 300
133
+
134
+ // ---- Results ------------------------------------------------------------
135
+
136
+ /** Stable failure codes; the readable `reason` carries the details. */
137
+ export type WikiFailureCode =
138
+ | 'TOOL_DISABLED'
139
+ | 'ROUTE_NOT_FOUND'
140
+ | 'NOT_AUTHORIZED'
141
+ | 'CHANNEL_UNAVAILABLE'
142
+ | 'INVALID_REFERENCE'
143
+ | 'INVALID_OPERATION'
144
+ | 'OUTSIDE_ROOTS'
145
+ | 'NOT_FOUND'
146
+ | 'NO_ACCESS'
147
+ | 'UNSUPPORTED_TYPE'
148
+ | 'NOT_OWNED'
149
+ | 'REVISION_CONFLICT'
150
+ | 'CONSENT_DENIED'
151
+ | 'CONSENT_EXPIRED'
152
+ | 'CONSENT_UNAVAILABLE'
153
+ | 'PERMISSION_DENIED'
154
+ | 'CONTENT_FAILED'
155
+ | 'EDIT_FAILED'
156
+ | 'EDIT_PARTIAL'
157
+ | 'API_FAILED'
158
+ | 'ABORTED'
159
+
160
+ export const FAILURE_REASONS: Readonly<Record<WikiFailureCode, string>> = {
161
+ TOOL_DISABLED: '知识库工具已在插件配置中关闭',
162
+ ROUTE_NOT_FOUND: '当前会话没有绑定飞书话题,无法确定授权人',
163
+ NOT_AUTHORIZED: '当前话题的发起人不在知识库白名单中;请管理员发送 /auth wiki add @该用户 后重试',
164
+ CHANNEL_UNAVAILABLE: '飞书连接当前不可用,请稍后重试',
165
+ INVALID_REFERENCE: '无法识别的知识库引用:请传知识库节点链接 https://<租户>.feishu.cn/wiki/<node_token>、node_token,或知识空间链接 https://<租户>.feishu.cn/wiki/space/<space_id>',
166
+ INVALID_OPERATION: '操作参数不正确',
167
+ OUTSIDE_ROOTS: '该位置不在插件配置的知识库范围(wikiRoots)内,不能访问',
168
+ NOT_FOUND: '知识库节点不存在或已被删除',
169
+ NO_ACCESS: '机器人对该知识库节点没有访问权限;请知识库管理员把应用(或包含机器人的群)加为该空间成员',
170
+ UNSUPPORTED_TYPE: '这个工具不支持该类型的节点:飞书文档(docx)用 feishu_wiki_read / feishu_wiki_edit,电子表格(sheet)用 feishu_wiki_sheet_read / feishu_wiki_sheet_edit,多维表格(bitable)用 feishu_wiki_base_read / feishu_wiki_base_edit;其他类型暂不支持',
171
+ NOT_OWNED: '只能修改由本应用创建(所有者是本应用)的文档',
172
+ REVISION_CONFLICT: '文档在你读取之后被修改过;请重新用 feishu_wiki_read 读取最新内容,基于新的 revision_id 再修改',
173
+ CONSENT_DENIED: '话题发起人拒绝了这次知识库操作',
174
+ CONSENT_EXPIRED: '话题发起人没有在授权卡有效期内确认,已自动拒绝',
175
+ CONSENT_UNAVAILABLE: '授权卡发送失败,无法征得话题发起人同意,已取消操作',
176
+ PERMISSION_DENIED: '飞书拒绝调用:应用缺少知识库或文档权限(wiki:wiki、docx:document),请管理员在开发者后台开通并发布版本',
177
+ CONTENT_FAILED: '文档已创建,但写入内容失败',
178
+ EDIT_FAILED: '修改失败',
179
+ EDIT_PARTIAL: '飞书报告操作只部分生效;已停止后续操作,请用 feishu_wiki_read 读取最新内容核验后再继续',
180
+ API_FAILED: '飞书接口调用失败',
181
+ ABORTED: '已取消',
182
+ }
183
+
184
+ /** One failed call. */
185
+ export interface WikiFailure {
186
+ readonly ok: false
187
+ readonly code: WikiFailureCode
188
+ readonly reason: string
189
+ /** Set by `CONTENT_FAILED`: the document that was created anyway. */
190
+ readonly node_token?: string
191
+ readonly url?: string
192
+ /** Set by `EDIT_FAILED`/`EDIT_PARTIAL`: operations fully applied before the stop. */
193
+ readonly applied?: number
194
+ /** Set by `REVISION_CONFLICT`: the current revision. */
195
+ readonly revision_id?: number
196
+ }
197
+
198
+ export function failure(code: WikiFailureCode, reason?: string): WikiFailure {
199
+ return { ok: false, code, reason: reason ?? FAILURE_REASONS[code] }
200
+ }
201
+
202
+ export function parseArgs<T>(tool: string, schema: z.ZodType<T>, rawArgs: Record<string, unknown>): T {
203
+ const parsed = schema.safeParse(rawArgs)
204
+ if (parsed.success) return parsed.data
205
+ const issue = parsed.error.issues[0]
206
+ const where = issue === undefined || issue.path.length === 0 ? 'arguments' : issue.path.join('.')
207
+ throw new Error(`${tool}: invalid ${where}: ${issue?.message ?? 'invalid arguments'}`)
208
+ }
209
+
210
+ /** Run one gated call body, mapping thrown Feishu errors to failures. */
211
+ export async function guarded<T>(
212
+ deps: { warn(message: string, ...args: unknown[]): void },
213
+ tool: string,
214
+ body: () => Promise<T | WikiFailure>,
215
+ ): Promise<T | WikiFailure> {
216
+ try {
217
+ return await body()
218
+ } catch (error) {
219
+ const mapped = failureOf(error)
220
+ if (mapped.code === 'API_FAILED') deps.warn('dsh-channels: %s failed: %s', tool, mapped.reason)
221
+ return mapped
222
+ }
223
+ }
224
+
225
+ /** Feishu codes: node missing/deleted, no resource access, missing app scope. */
226
+ const NOT_FOUND_CODES: ReadonlySet<number> = new Set([131005, 131012, 131013, 131014, 131016])
227
+ const NO_ACCESS_CODES: ReadonlySet<number> = new Set([131006, 1770032, 1770002])
228
+ const PERMISSION_CODES: ReadonlySet<number> = new Set([99991672, 99991679])
229
+
230
+ export function failureOf(error: unknown): WikiFailure {
231
+ const detail = describeError(error)
232
+ if (error instanceof FeishuApiError && error.code !== undefined) {
233
+ if (NOT_FOUND_CODES.has(error.code)) return failure('NOT_FOUND', `${FAILURE_REASONS.NOT_FOUND}(${detail})`)
234
+ if (NO_ACCESS_CODES.has(error.code)) return failure('NO_ACCESS', `${FAILURE_REASONS.NO_ACCESS}(${detail})`)
235
+ if (PERMISSION_CODES.has(error.code)) return failure('PERMISSION_DENIED', `${FAILURE_REASONS.PERMISSION_DENIED}(${detail})`)
236
+ }
237
+ return failure('API_FAILED', `${FAILURE_REASONS.API_FAILED}:${detail}`)
238
+ }
239
+
240
+ export function consentFailure(outcome: WikiConsentOutcome): WikiFailure | undefined {
241
+ switch (outcome) {
242
+ case 'allowed': return undefined
243
+ case 'denied': return failure('CONSENT_DENIED')
244
+ case 'expired': return failure('CONSENT_EXPIRED')
245
+ case 'cancelled': return failure('ABORTED')
246
+ case 'unavailable': return failure('CONSENT_UNAVAILABLE')
247
+ }
248
+ }
249
+
250
+ /** Whether the app owns a node: Feishu reports the bot open_id as the owner. */
251
+ export function ownedByApp(node: WikiNode, botOpenId: string | undefined): boolean {
252
+ return botOpenId !== undefined && botOpenId !== '' && node.owner === botOpenId
253
+ }
254
+
255
+ /** A node's web link; falls back to the first root's origin. */
256
+ export function nodeUrl(roots: readonly WikiRoot[], node: WikiNode): string {
257
+ if (node.url !== undefined) return node.url
258
+ const first = roots[0]
259
+ const origin = first === undefined ? 'https://feishu.cn' : new URL(first.link).origin
260
+ return `${origin}/wiki/${node.nodeToken}`
261
+ }
262
+
263
+ /** One-line clipped preview with Markdown control characters neutralized. */
264
+ export function preview(value: string, max = 80): string {
265
+ const flat = value.replace(/\s+/gu, ' ').trim().replace(/([[\]()*_`~<>])/gu, '\\$1')
266
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
267
+ }
268
+
269
+ /** A fenced preview of the first characters of a content. */
270
+ export function previewBlock(content: string): string {
271
+ const head = content.length <= PREVIEW_CHARS ? content : `${content.slice(0, PREVIEW_CHARS)}\n…`
272
+ return `\`\`\`\n${head.replace(/```/gu, '`​``')}\n\`\`\``
273
+ }
274
+
275
+ export function display(value: unknown): string {
276
+ return typeof value === 'string' ? preview(value, 120) : '?'
277
+ }
278
+
279
+ /** Readable, bounded text for a thrown value. */
280
+ export function describeError(error: unknown): string {
281
+ const text = error instanceof FeishuApiError ? error.detail : error instanceof Error ? error.message : String(error)
282
+ const cleaned = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim()
283
+ return cleaned.length <= 300 ? cleaned : `${cleaned.slice(0, 300)}…`
284
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Feishu spreadsheet (电子表格) calls used by the wiki sheet tools, made with
3
+ * the app identity (tenant access token).
4
+ *
5
+ * Everything goes through the `sheet_ai/v2` tool endpoints: `POST
6
+ * spreadsheets/:token/tools/invoke_read` (or `invoke_write`) with body
7
+ * `{tool_name, input}`, where `input` is a JSON string that also carries
8
+ * `excel_id`, and the result is the JSON string `data.output`. Read tools sent
9
+ * to `invoke_write` (or the reverse) are rejected by the gateway. Writes carry
10
+ * no base revision and no idempotency key; callers check the workbook
11
+ * revision before writing.
12
+ *
13
+ * @module dsh-channels/feishu/wiki/sheet-api
14
+ */
15
+
16
+ import { FeishuApiError } from '../../feishu-channel.ts'
17
+ import { call, segment } from './api.ts'
18
+ import type { WikiRawRequest } from './api.ts'
19
+
20
+ /** One sub-sheet of a workbook. */
21
+ export interface SheetInfo {
22
+ readonly sheetId: string
23
+ readonly title: string
24
+ readonly index: number
25
+ /** `sheet` for a cell grid; other kinds (an embedded `bitable`) cannot be read as cells. */
26
+ readonly resourceType: string
27
+ readonly hidden: boolean
28
+ /** Physical grid size (not the data extent); absent for non-grid sheets. */
29
+ readonly rowCount?: number
30
+ readonly columnCount?: number
31
+ }
32
+
33
+ /** Workbook structure and its current revision. */
34
+ export interface SheetWorkbook {
35
+ /** Monotonic workbook revision; absent when Feishu omits it. */
36
+ readonly revision?: number
37
+ readonly sheets: readonly SheetInfo[]
38
+ }
39
+
40
+ /** One `get_range_as_csv` request. */
41
+ export interface SheetCsvRequest {
42
+ readonly sheetId: string
43
+ /** Bare A1 range (`A1:F30`, `A:C`), no sheet prefix. */
44
+ readonly range: string
45
+ readonly maxChars: number
46
+ }
47
+
48
+ /** One range read as CSV. */
49
+ export interface SheetCsv {
50
+ /** RFC 4180 CSV; every record starts with `[row=N] ` (N = sheet row). */
51
+ readonly csv: string
52
+ /** What was actually read (clipped to the grid or by `maxChars`). */
53
+ readonly actualRange?: string
54
+ /** Contiguous non-empty block around the request; stops at a fully empty row or column. */
55
+ readonly currentRegion?: string
56
+ /** True when `maxChars` cut the range; continue after `actualRange`. */
57
+ readonly hasMore: boolean
58
+ readonly warning?: string
59
+ }
60
+
61
+ /**
62
+ * One cell value to write: a string starting with `=` is a formula, `null`
63
+ * leaves the cell unchanged.
64
+ */
65
+ export type SheetCellValue = string | number | boolean | null
66
+
67
+ /** One `set_cell_range` request. */
68
+ export interface SheetWriteRequest {
69
+ readonly sheetId: string
70
+ /** Rectangle whose size equals `rows` exactly. */
71
+ readonly range: string
72
+ readonly rows: readonly (readonly SheetCellValue[])[]
73
+ /** False makes the write fail when any target cell is non-empty; the server default is true. */
74
+ readonly allowOverwrite?: boolean
75
+ }
76
+
77
+ /** One formula `set_cell_range` stored as text because it did not compile. */
78
+ export interface SheetFormulaError {
79
+ readonly cell: string
80
+ readonly formula?: string
81
+ /** `compile_failed` or `non_formula`. */
82
+ readonly errorType?: string
83
+ readonly detail?: string
84
+ }
85
+
86
+ /** Outcome of one `set_cell_range`; the cells are written even when formulas fail to compile. */
87
+ export interface SheetWriteResult {
88
+ readonly formulaErrors: readonly SheetFormulaError[]
89
+ }
90
+
91
+ /** The `sheet_ai` spreadsheet tools the wiki sheet tools consume. */
92
+ export interface SheetApi {
93
+ /**
94
+ * Sub-sheets and revision of one workbook.
95
+ * @param token Spreadsheet token (the wiki node's `objToken`).
96
+ * @returns The workbook structure.
97
+ */
98
+ workbook(token: string): Promise<SheetWorkbook>
99
+ /**
100
+ * Read one range as CSV (formatted display values).
101
+ * @param token Spreadsheet token.
102
+ * @param request Sheet, range, and size cap.
103
+ * @returns The CSV and range metadata.
104
+ */
105
+ readCsv(token: string, request: SheetCsvRequest): Promise<SheetCsv>
106
+ /**
107
+ * Write one rectangle of values and formulas.
108
+ * @param token Spreadsheet token.
109
+ * @param request Sheet, rectangle, and values.
110
+ * @returns Formulas that failed to compile.
111
+ */
112
+ writeCells(token: string, request: SheetWriteRequest): Promise<SheetWriteResult>
113
+ }
114
+
115
+ interface RawSheet {
116
+ sheet_id?: unknown
117
+ title?: unknown
118
+ sheet_name?: unknown
119
+ index?: unknown
120
+ resource_type?: unknown
121
+ hidden?: unknown
122
+ is_hidden?: unknown
123
+ row_count?: unknown
124
+ column_count?: unknown
125
+ }
126
+
127
+ function invokePath(token: string, kind: 'read' | 'write'): string {
128
+ return `/open-apis/sheet_ai/v2/spreadsheets/${segment(token)}/tools/invoke_${kind}`
129
+ }
130
+
131
+ async function invoke(
132
+ request: WikiRawRequest,
133
+ token: string,
134
+ kind: 'read' | 'write',
135
+ toolName: string,
136
+ input: Record<string, unknown>,
137
+ ): Promise<Record<string, unknown>> {
138
+ const url = invokePath(token, kind)
139
+ const data = await call<{ output?: unknown }>(request, {
140
+ method: 'POST',
141
+ url,
142
+ data: { tool_name: toolName, input: JSON.stringify({ excel_id: token, ...input }) },
143
+ })
144
+ if (typeof data.output !== 'string' || data.output === '') return {}
145
+ let output: unknown
146
+ try {
147
+ output = JSON.parse(data.output)
148
+ } catch (error) {
149
+ throw new FeishuApiError(`${toolName}: output is not JSON: ${error instanceof Error ? error.message : String(error)}`)
150
+ }
151
+ if (Array.isArray(output)) return { sheets: output }
152
+ if (typeof output !== 'object' || output === null) throw new FeishuApiError(`${toolName}: output is not an object`)
153
+ return output as Record<string, unknown>
154
+ }
155
+
156
+ function optionalString(value: unknown): string | undefined {
157
+ return typeof value === 'string' && value !== '' ? value : undefined
158
+ }
159
+
160
+ function optionalNumber(value: unknown): number | undefined {
161
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
162
+ }
163
+
164
+ function sheetInfo(raw: RawSheet, position: number): SheetInfo | undefined {
165
+ const sheetId = optionalString(raw.sheet_id)
166
+ if (sheetId === undefined) return undefined
167
+ const rowCount = optionalNumber(raw.row_count)
168
+ const columnCount = optionalNumber(raw.column_count)
169
+ return {
170
+ sheetId,
171
+ title: optionalString(raw.title) ?? optionalString(raw.sheet_name) ?? '',
172
+ index: optionalNumber(raw.index) ?? position,
173
+ resourceType: optionalString(raw.resource_type) ?? 'sheet',
174
+ hidden: raw.hidden === true || raw.is_hidden === true,
175
+ ...(rowCount === undefined ? {} : { rowCount }),
176
+ ...(columnCount === undefined ? {} : { columnCount }),
177
+ }
178
+ }
179
+
180
+ function cellOf(value: SheetCellValue): object {
181
+ if (value === null) return {}
182
+ if (typeof value === 'string' && value.startsWith('=')) return { formula: value }
183
+ return { value }
184
+ }
185
+
186
+ /**
187
+ * {@link SheetApi} over the SDK's generic authenticated request.
188
+ *
189
+ * @param request `Client.request` of the connected SDK client.
190
+ * @returns The API.
191
+ */
192
+ function formulaError(raw: unknown): SheetFormulaError[] {
193
+ if (typeof raw !== 'object' || raw === null) return []
194
+ const entry = raw as Record<string, unknown>
195
+ const cell = optionalString(entry.cell)
196
+ if (cell === undefined) return []
197
+ const formula = optionalString(entry.formula)
198
+ const errorType = optionalString(entry.error_type)
199
+ const detail = optionalString(entry.detail)
200
+ return [{
201
+ cell,
202
+ ...(formula === undefined ? {} : { formula }),
203
+ ...(errorType === undefined ? {} : { errorType }),
204
+ ...(detail === undefined ? {} : { detail }),
205
+ }]
206
+ }
207
+
208
+ export function createSheetApi(request: WikiRawRequest): SheetApi {
209
+ return {
210
+ async workbook(token) {
211
+ const output = await invoke(request, token, 'read', 'get_workbook_structure', {})
212
+ const rawSheets = Array.isArray(output.sheets) ? output.sheets as RawSheet[] : []
213
+ const sheets = rawSheets.flatMap((raw, position) => {
214
+ const info = typeof raw === 'object' && raw !== null ? sheetInfo(raw, position) : undefined
215
+ return info === undefined ? [] : [info]
216
+ })
217
+ const revision = optionalNumber(output.revision)
218
+ return { ...(revision === undefined ? {} : { revision }), sheets }
219
+ },
220
+ async readCsv(token, read) {
221
+ const output = await invoke(request, token, 'read', 'get_range_as_csv', {
222
+ sheet_id: read.sheetId,
223
+ range: read.range,
224
+ max_chars: read.maxChars,
225
+ max_rows: 1_000_000_000,
226
+ skip_hidden: false,
227
+ })
228
+ const actualRange = optionalString(output.actual_range)
229
+ const currentRegion = optionalString(output.current_region)
230
+ const warning = optionalString(output.warning_message)
231
+ return {
232
+ csv: typeof output.annotated_csv === 'string' ? output.annotated_csv : '',
233
+ ...(actualRange === undefined ? {} : { actualRange }),
234
+ ...(currentRegion === undefined ? {} : { currentRegion }),
235
+ hasMore: output.has_more === true,
236
+ ...(warning === undefined ? {} : { warning }),
237
+ }
238
+ },
239
+ async writeCells(token, write) {
240
+ const output = await invoke(request, token, 'write', 'set_cell_range', {
241
+ sheet_id: write.sheetId,
242
+ range: write.range,
243
+ cells: write.rows.map(row => row.map(cellOf)),
244
+ ...(write.allowOverwrite === false ? { allow_overwrite: false } : {}),
245
+ })
246
+ const raw: readonly unknown[] = Array.isArray(output.formula_errors) ? output.formula_errors : []
247
+ return { formulaErrors: raw.flatMap(formulaError) }
248
+ },
249
+ }
250
+ }