@tita-agent/dsh-data-table 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/LICENSE +21 -0
- package/README.md +172 -2
- package/cordis.patch.yml +7 -0
- package/lib/client.js +16766 -0
- package/lib/contract-envelope.d.ts +91 -0
- package/lib/contract-envelope.js +106 -0
- package/lib/contract-layout.d.ts +130 -0
- package/lib/contract-layout.js +138 -0
- package/lib/contract-refs.d.ts +39 -0
- package/lib/contract-refs.js +9 -0
- package/lib/contract-version.d.ts +311 -0
- package/lib/contract-version.js +270 -0
- package/lib/contract-wire.d.ts +147 -0
- package/lib/contract-wire.js +99 -0
- package/lib/contracts.d.ts +12 -0
- package/lib/contracts.js +12 -0
- package/lib/types/audit.d.ts +42 -0
- package/lib/types/audit.js +39 -0
- package/lib/types/client/ProjectImportMenu.d.ts +30 -0
- package/lib/types/client/ProjectImportMenu.js +70 -0
- package/lib/types/client/SheetPanel.d.ts +33 -0
- package/lib/types/client/SheetPanel.js +1766 -0
- package/lib/types/client/SheetToggle.d.ts +18 -0
- package/lib/types/client/SheetToggle.js +26 -0
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +110 -0
- package/lib/types/client/persistence/changes.d.ts +15 -0
- package/lib/types/client/persistence/changes.js +51 -0
- package/lib/types/client/persistence/ids.d.ts +34 -0
- package/lib/types/client/persistence/ids.js +48 -0
- package/lib/types/client/persistence/merge.d.ts +35 -0
- package/lib/types/client/persistence/merge.js +362 -0
- package/lib/types/client/store.d.ts +61 -0
- package/lib/types/client/store.js +152 -0
- package/lib/types/client/surface-context.d.ts +27 -0
- package/lib/types/client/surface-context.js +104 -0
- package/lib/types/client/surface-control.d.ts +2 -0
- package/lib/types/client/surface-control.js +12 -0
- package/lib/types/client/transport/workbookApi.d.ts +159 -0
- package/lib/types/client/transport/workbookApi.js +246 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
- package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
- package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
- package/lib/types/client/univer/WorkbookCanvas.js +87 -0
- package/lib/types/client/univer/WorkbookView.d.ts +3 -0
- package/lib/types/client/univer/WorkbookView.js +2 -0
- package/lib/types/client/univer/loader.d.ts +18 -0
- package/lib/types/client/univer/loader.js +81 -0
- package/lib/types/client/univer/standalone-entry.d.ts +1 -0
- package/lib/types/client/univer/standalone-entry.js +19 -0
- package/lib/types/client/versions/VersionChrome.d.ts +33 -0
- package/lib/types/client/versions/VersionChrome.js +21 -0
- package/lib/types/client/versions/inspect.d.ts +70 -0
- package/lib/types/client/versions/inspect.js +88 -0
- package/lib/types/client/versions/preview-decoration.d.ts +11 -0
- package/lib/types/client/versions/preview-decoration.js +54 -0
- package/lib/types/client/versions/records.d.ts +19 -0
- package/lib/types/client/versions/records.js +70 -0
- package/lib/types/client/workspace-host.d.ts +61 -0
- package/lib/types/client/workspace-host.js +124 -0
- package/lib/types/collaboration-hub.d.ts +31 -0
- package/lib/types/collaboration-hub.js +69 -0
- package/lib/types/contract/ids.d.ts +24 -0
- package/lib/types/contract/ids.js +46 -0
- package/lib/types/contract/mutations.d.ts +25 -0
- package/lib/types/contract/mutations.js +782 -0
- package/lib/types/contract/operations.d.ts +54 -0
- package/lib/types/contract/operations.js +203 -0
- package/lib/types/contract/snapshots.d.ts +176 -0
- package/lib/types/contract/snapshots.js +351 -0
- package/lib/types/contract/univer-api-catalog.d.ts +29 -0
- package/lib/types/contract/univer-api-catalog.js +264 -0
- package/lib/types/errors.d.ts +8 -0
- package/lib/types/errors.js +12 -0
- package/lib/types/http/assets-routes.d.ts +3 -0
- package/lib/types/http/assets-routes.js +169 -0
- package/lib/types/http/collaboration.d.ts +10 -0
- package/lib/types/http/collaboration.js +154 -0
- package/lib/types/http/history.d.ts +15 -0
- package/lib/types/http/history.js +134 -0
- package/lib/types/http/project-files.d.ts +79 -0
- package/lib/types/http/project-files.js +206 -0
- package/lib/types/http/project-scope.d.ts +50 -0
- package/lib/types/http/project-scope.js +52 -0
- package/lib/types/http/routes.d.ts +98 -0
- package/lib/types/http/routes.js +323 -0
- package/lib/types/http/standalone.d.ts +9 -0
- package/lib/types/http/standalone.js +31 -0
- package/lib/types/http/trunk.d.ts +10 -0
- package/lib/types/http/trunk.js +322 -0
- package/lib/types/http/worktree.d.ts +9 -0
- package/lib/types/http/worktree.js +145 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.js +156 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/invariant.js +15 -0
- package/lib/types/request-params.d.ts +21 -0
- package/lib/types/request-params.js +76 -0
- package/lib/types/store/paths.d.ts +68 -0
- package/lib/types/store/paths.js +148 -0
- package/lib/types/store/project.d.ts +88 -0
- package/lib/types/store/project.js +120 -0
- package/lib/types/store/read.d.ts +110 -0
- package/lib/types/store/read.js +404 -0
- package/lib/types/store/retention.d.ts +27 -0
- package/lib/types/store/retention.js +111 -0
- package/lib/types/store/write.d.ts +22 -0
- package/lib/types/store/write.js +210 -0
- package/lib/types/tools/arguments.d.ts +40 -0
- package/lib/types/tools/arguments.js +130 -0
- package/lib/types/tools/cells.d.ts +50 -0
- package/lib/types/tools/cells.js +298 -0
- package/lib/types/tools/index.d.ts +2 -0
- package/lib/types/tools/index.js +250 -0
- package/lib/types/tools/render.d.ts +47 -0
- package/lib/types/tools/render.js +290 -0
- package/lib/types/tools/univer-api.d.ts +18 -0
- package/lib/types/tools/univer-api.js +198 -0
- package/lib/types/tools/workbook-catalog.d.ts +3 -0
- package/lib/types/tools/workbook-catalog.js +83 -0
- package/lib/types/tools/workbook-edit.d.ts +192 -0
- package/lib/types/tools/workbook-edit.js +912 -0
- package/lib/types/tools/workbook-find.d.ts +11 -0
- package/lib/types/tools/workbook-find.js +89 -0
- package/lib/types/tools/workbook-history.d.ts +2 -0
- package/lib/types/tools/workbook-history.js +59 -0
- package/lib/types/tools/workbook-read.d.ts +21 -0
- package/lib/types/tools/workbook-read.js +204 -0
- package/lib/types/types.d.ts +377 -0
- package/lib/types/types.js +34 -0
- package/lib/types/univer/apply-operation.d.ts +14 -0
- package/lib/types/univer/apply-operation.js +398 -0
- package/lib/types/univer/chunk.d.ts +68 -0
- package/lib/types/univer/chunk.js +16 -0
- package/lib/types/univer/decoration.d.ts +34 -0
- package/lib/types/univer/decoration.js +88 -0
- package/lib/types/univer/diagnostics.d.ts +31 -0
- package/lib/types/univer/diagnostics.js +141 -0
- package/lib/types/univer/engine.d.ts +33 -0
- package/lib/types/univer/engine.js +338 -0
- package/lib/types/univer/entry.d.ts +8 -0
- package/lib/types/univer/entry.js +11 -0
- package/lib/types/univer/facade.d.ts +107 -0
- package/lib/types/univer/facade.js +1 -0
- package/lib/types/univer/fonts.d.ts +202 -0
- package/lib/types/univer/fonts.js +140 -0
- package/lib/types/univer/plugins/collaboration.d.ts +23 -0
- package/lib/types/univer/plugins/collaboration.js +47 -0
- package/lib/types/univer/plugins/export.d.ts +39 -0
- package/lib/types/univer/plugins/export.js +246 -0
- package/lib/types/univer/plugins/history.d.ts +24 -0
- package/lib/types/univer/plugins/history.js +50 -0
- package/lib/types/univer/plugins/version.d.ts +13 -0
- package/lib/types/univer/plugins/version.js +13 -0
- package/lib/types/version/diff.d.ts +36 -0
- package/lib/types/version/diff.js +156 -0
- package/lib/types/version/grouping.d.ts +7 -0
- package/lib/types/version/grouping.js +94 -0
- package/lib/types/version/records.d.ts +17 -0
- package/lib/types/version/records.js +102 -0
- package/lib/types/version/session.d.ts +36 -0
- package/lib/types/version/session.js +89 -0
- package/lib/types/version/summary.d.ts +24 -0
- package/lib/types/version/summary.js +111 -0
- package/lib/types/workbook-manager.d.ts +191 -0
- package/lib/types/workbook-manager.js +935 -0
- package/lib/types/xlsx-export-worker.d.ts +5 -0
- package/lib/types/xlsx-export-worker.js +24 -0
- package/lib/types/xlsx-export.d.ts +21 -0
- package/lib/types/xlsx-export.js +371 -0
- package/lib/types/xlsx-import-worker.d.ts +1 -0
- package/lib/types/xlsx-import-worker.js +19 -0
- package/lib/univer-client.js +690 -0
- package/lib/workbook-page.js +23826 -0
- package/lib/xlsx-export-worker.js +104 -0
- package/lib/xlsx-import-worker.js +46 -0
- package/package.json +106 -3
- package/src/audit.ts +58 -0
- package/src/client/ProjectImportMenu.tsx +100 -0
- package/src/client/SheetPanel.module.css +1578 -0
- package/src/client/SheetPanel.tsx +1934 -0
- package/src/client/SheetToggle.module.css +73 -0
- package/src/client/SheetToggle.tsx +65 -0
- package/src/client/contract.d.ts +19 -0
- package/src/client/index.ts +131 -0
- package/src/client/persistence/changes.ts +58 -0
- package/src/client/persistence/ids.ts +60 -0
- package/src/client/persistence/merge.ts +392 -0
- package/src/client/store.ts +188 -0
- package/src/client/surface-context.ts +123 -0
- package/src/client/surface-control.ts +13 -0
- package/src/client/transport/workbookApi.ts +355 -0
- package/src/client/univer/SheetView.module.css +110 -0
- package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
- package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
- package/src/client/univer/WorkbookCanvas.tsx +128 -0
- package/src/client/univer/WorkbookView.module.css +30 -0
- package/src/client/univer/WorkbookView.tsx +3 -0
- package/src/client/univer/loader.ts +90 -0
- package/src/client/univer/react-dom-client.d.ts +10 -0
- package/src/client/univer/standalone-entry.tsx +19 -0
- package/src/client/univer/standalone.css +12 -0
- package/src/client/univer/univer-locales.d.ts +89 -0
- package/src/client/univer/univer-modules.d.ts +26 -0
- package/src/client/versions/VersionChrome.tsx +114 -0
- package/src/client/versions/inspect.ts +138 -0
- package/src/client/versions/preview-decoration.ts +54 -0
- package/src/client/versions/records.ts +78 -0
- package/src/client/workspace-host.ts +147 -0
- package/src/collaboration-hub.ts +83 -0
- package/src/contract/ids.ts +50 -0
- package/src/contract/mutations.ts +791 -0
- package/src/contract/operations.ts +221 -0
- package/src/contract/snapshots.ts +383 -0
- package/src/contract/univer-api-catalog.ts +289 -0
- package/src/css-modules.d.ts +6 -0
- package/src/errors.ts +25 -0
- package/src/http/assets-routes.ts +177 -0
- package/src/http/collaboration.ts +144 -0
- package/src/http/history.ts +151 -0
- package/src/http/project-files.ts +227 -0
- package/src/http/project-scope.ts +77 -0
- package/src/http/routes.ts +364 -0
- package/src/http/standalone.ts +32 -0
- package/src/http/trunk.ts +322 -0
- package/src/http/worktree.ts +119 -0
- package/src/index.ts +165 -0
- package/src/invariant.ts +17 -0
- package/src/request-params.ts +92 -0
- package/src/store/paths.ts +165 -0
- package/src/store/project.ts +137 -0
- package/src/store/read.ts +454 -0
- package/src/store/retention.ts +106 -0
- package/src/store/write.ts +230 -0
- package/src/tools/arguments.ts +128 -0
- package/src/tools/cells.ts +298 -0
- package/src/tools/index.ts +274 -0
- package/src/tools/render.ts +301 -0
- package/src/tools/univer-api.ts +243 -0
- package/src/tools/workbook-catalog.ts +94 -0
- package/src/tools/workbook-edit.ts +987 -0
- package/src/tools/workbook-find.ts +80 -0
- package/src/tools/workbook-history.ts +63 -0
- package/src/tools/workbook-read.ts +210 -0
- package/src/types.ts +432 -0
- package/src/univer/apply-operation.ts +369 -0
- package/src/univer/chunk.ts +77 -0
- package/src/univer/decoration.ts +111 -0
- package/src/univer/diagnostics.ts +136 -0
- package/src/univer/engine.ts +370 -0
- package/src/univer/entry.ts +14 -0
- package/src/univer/facade.ts +91 -0
- package/src/univer/fonts.ts +148 -0
- package/src/univer/overrides.css +33 -0
- package/src/univer/plugins/collaboration.ts +47 -0
- package/src/univer/plugins/export.ts +236 -0
- package/src/univer/plugins/history.ts +49 -0
- package/src/univer/plugins/version.ts +13 -0
- package/src/version/diff.ts +169 -0
- package/src/version/grouping.ts +96 -0
- package/src/version/records.ts +113 -0
- package/src/version/session.ts +90 -0
- package/src/version/summary.ts +117 -0
- package/src/workbook-manager.ts +1111 -0
- package/src/xlsx-export-worker.ts +34 -0
- package/src/xlsx-export.ts +373 -0
- package/src/xlsx-import-worker.ts +30 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `workbook_edit`: one plan, many semantic Versions (docs/issue/0022 P3b).
|
|
3
|
+
*
|
|
4
|
+
* The largest tool surface by far: it parses command lists and version groups,
|
|
5
|
+
* expands `duplicate_sheet` and `valueFromFile`, resolves a table's columns from
|
|
6
|
+
* the sheet it will land on, and reports a receipt per imported file. It is one
|
|
7
|
+
* file because those steps are one transaction — a plan either commits whole or
|
|
8
|
+
* is rejected whole (PRD 0002 §5).
|
|
9
|
+
*/
|
|
10
|
+
import { cloneSnapshot } from '../contract/snapshots.ts'
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
12
|
+
import { statSync } from 'node:fs'
|
|
13
|
+
import { readFile } from 'node:fs/promises'
|
|
14
|
+
import { extname, join, resolve } from 'node:path'
|
|
15
|
+
import * as XLSX from 'xlsx'
|
|
16
|
+
import { cellAddress } from '#contracts'
|
|
17
|
+
import {
|
|
18
|
+
SHEET_MAX_APPEND_ROWS,
|
|
19
|
+
SHEET_MAX_COLUMNS,
|
|
20
|
+
SHEET_MAX_PHYSICAL_ROWS,
|
|
21
|
+
SHEET_MAX_ROWS,
|
|
22
|
+
type SheetColumn,
|
|
23
|
+
type SheetColumnType,
|
|
24
|
+
type WorkbookCellUpdate,
|
|
25
|
+
type WorkbookOperationPayload,
|
|
26
|
+
type WorkbookOperationType,
|
|
27
|
+
type WorkbookRange,
|
|
28
|
+
} from '../types.ts'
|
|
29
|
+
import { DataSheetError } from '../errors.ts'
|
|
30
|
+
|
|
31
|
+
import { applyWorkbookOperation } from '../contract/mutations.ts'
|
|
32
|
+
import { workbookOperationSteps } from '../contract/operations.ts'
|
|
33
|
+
import type { LineageContext } from '#contracts'
|
|
34
|
+
import { mutateWorkbookPlan, readWorkbook } from '../workbook-manager.ts'
|
|
35
|
+
import { isOperationType, normalizeValueMatrix, parseIndex, parseRange } from '../request-params.ts'
|
|
36
|
+
import {
|
|
37
|
+
asToolArguments,
|
|
38
|
+
commandIndex,
|
|
39
|
+
commandPayload,
|
|
40
|
+
commandSheet,
|
|
41
|
+
now,
|
|
42
|
+
optionalAuditText,
|
|
43
|
+
optionalIntent,
|
|
44
|
+
parseCellDeleteShift,
|
|
45
|
+
parseCount,
|
|
46
|
+
parseHeaderVerification,
|
|
47
|
+
parseNonEmptyString,
|
|
48
|
+
sheetRefForSnapshot,
|
|
49
|
+
type ToolArguments,
|
|
50
|
+
} from './arguments.ts'
|
|
51
|
+
import {
|
|
52
|
+
chunkRows,
|
|
53
|
+
dimensionCommandRange,
|
|
54
|
+
dimensionRange,
|
|
55
|
+
parseApiValues,
|
|
56
|
+
parseCells,
|
|
57
|
+
parseFiniteNumber,
|
|
58
|
+
parseFreeze,
|
|
59
|
+
parseHyperlink,
|
|
60
|
+
parseHyperlinkLabel,
|
|
61
|
+
parseSort,
|
|
62
|
+
parseValueRange,
|
|
63
|
+
resolveCellFileValues,
|
|
64
|
+
validateSheet,
|
|
65
|
+
} from './cells.ts'
|
|
66
|
+
import { operationReceipt, sheetOverviews, sheetReferenceList } from './render.ts'
|
|
67
|
+
import { snapshotSheet } from './workbook-read.ts'
|
|
68
|
+
|
|
69
|
+
/** Agent-facing receipt seed for one `import_file` step. It is stripped before
|
|
70
|
+
* the command is persisted, so the Version/Step payload keeps its existing
|
|
71
|
+
* shape and replay model. */
|
|
72
|
+
export interface ImportFileMeta {
|
|
73
|
+
sourcePath: string
|
|
74
|
+
range: WorkbookRange
|
|
75
|
+
rowsWritten: number
|
|
76
|
+
cellsWritten: number
|
|
77
|
+
sourceRowCount: number
|
|
78
|
+
truncated: boolean
|
|
79
|
+
verifyCapacity: boolean
|
|
80
|
+
firstRow: unknown[]
|
|
81
|
+
lastRow: unknown[]
|
|
82
|
+
valuesHash: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One parsed command plus non-persisted Agent-facing metadata. */
|
|
86
|
+
export interface ParsedCommand {
|
|
87
|
+
type: WorkbookOperationType
|
|
88
|
+
payload: WorkbookOperationPayload
|
|
89
|
+
importFile?: ImportFileMeta
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Copy a sheet's cell content and column widths into a new sheet of the target
|
|
94
|
+
* workbook (docs/issue/0011 I2 / T7).
|
|
95
|
+
*
|
|
96
|
+
* Expanded into the existing `create_sheet` / `update_cells` /
|
|
97
|
+
* `set_column_width` commands rather than becoming a new persisted operation:
|
|
98
|
+
* the Version/Step model is untouched and the browser projection needs no new
|
|
99
|
+
* branch. The source is read from storage, so it has to exist before the batch
|
|
100
|
+
* starts — a sheet created earlier in the same batch is not visible here.
|
|
101
|
+
*
|
|
102
|
+
* What is copied is the content that was tedious to retype (title block, header
|
|
103
|
+
* row, long SQL text) plus column widths. Styles, merges, freeze panes and data
|
|
104
|
+
* validation are deliberately not copied; they stay with the source sheet.
|
|
105
|
+
*/
|
|
106
|
+
export async function expandDuplicateSheet(raw: ToolArguments, index: number, targetWorkbookId: string, warnings: string[] = []): Promise<ParsedCommand[]> {
|
|
107
|
+
const label = `commands[${index}]`
|
|
108
|
+
const sourceWorkbookId = raw.fromWorkbookId === undefined || raw.fromWorkbookId === null
|
|
109
|
+
? targetWorkbookId
|
|
110
|
+
: parseNonEmptyString(raw.fromWorkbookId, `${label}.fromWorkbookId`)
|
|
111
|
+
const name = parseNonEmptyString(raw.name, `${label}.name`)
|
|
112
|
+
const explicitId = raw.sheetId === undefined || raw.sheetId === null
|
|
113
|
+
? undefined
|
|
114
|
+
: parseNonEmptyString(raw.sheetId, `${label}.sheetId`)
|
|
115
|
+
const source = await readWorkbook(sourceWorkbookId)
|
|
116
|
+
if (source === null) throw new Error(`${label}.fromWorkbookId 工作簿不存在:${sourceWorkbookId}`)
|
|
117
|
+
const root = asToolArguments(source.snapshot, 'workbook snapshot')
|
|
118
|
+
const order = Array.isArray(root.sheetOrder) ? root.sheetOrder.map(String) : Object.keys(asToolArguments(root.sheets, 'workbook snapshot.sheets'))
|
|
119
|
+
const requested = raw.sheet === undefined || raw.sheet === null
|
|
120
|
+
? order[0]
|
|
121
|
+
: parseNonEmptyString(raw.sheet, `${label}.sheet`)
|
|
122
|
+
if (requested === undefined) throw new Error(`${label} 源工作簿没有可复制的 sheet`)
|
|
123
|
+
const sheet = snapshotSheet(source.snapshot, requested)
|
|
124
|
+
const newSheetId = explicitId ?? stableCreateSheetId(targetWorkbookId, name, 0, index)
|
|
125
|
+
|
|
126
|
+
const commands: ParsedCommand[] = [{ type: 'create_sheet', payload: { sheetId: newSheetId, name } }]
|
|
127
|
+
const cells: WorkbookCellUpdate[] = []
|
|
128
|
+
const cellData = sheet.cellData
|
|
129
|
+
if (typeof cellData === 'object' && cellData !== null) {
|
|
130
|
+
for (const [rowKey, line] of Object.entries(cellData as Record<string, Record<string, { v?: unknown; f?: unknown }>>)) {
|
|
131
|
+
const row = Number(rowKey)
|
|
132
|
+
if (!Number.isInteger(row) || row < 0) continue
|
|
133
|
+
for (const [columnKey, cell] of Object.entries(line ?? {})) {
|
|
134
|
+
const column = Number(columnKey)
|
|
135
|
+
if (!Number.isInteger(column) || column < 0) continue
|
|
136
|
+
if (cell === null || typeof cell !== 'object') continue
|
|
137
|
+
const value = cell.v === undefined ? null : cell.v
|
|
138
|
+
const formula = typeof cell.f === 'string' ? cell.f : undefined
|
|
139
|
+
// An empty cell carries nothing to reproduce.
|
|
140
|
+
if (value === null && formula === undefined) continue
|
|
141
|
+
cells.push({ row, column, value: value as WorkbookCellUpdate['value'], ...(formula === undefined ? {} : { formula }) })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (cells.length === 0) {
|
|
146
|
+
warnings.push(`${label} 源 sheet「${requested}」没有可复制的内容,只创建了空 sheet`)
|
|
147
|
+
} else {
|
|
148
|
+
commands.push({ type: 'update_cells', payload: { sheetId: newSheetId, cells } })
|
|
149
|
+
}
|
|
150
|
+
const columnData = sheet.columnData
|
|
151
|
+
if (typeof columnData === 'object' && columnData !== null) {
|
|
152
|
+
for (const [key, entry] of Object.entries(columnData as Record<string, { width?: unknown }>)) {
|
|
153
|
+
const column = Number(key)
|
|
154
|
+
const width = entry?.width
|
|
155
|
+
if (!Number.isInteger(column) || typeof width !== 'number' || !(width > 0) || width > 1000) continue
|
|
156
|
+
commands.push({
|
|
157
|
+
type: 'set_column_width',
|
|
158
|
+
payload: { sheetId: newSheetId, range: { startRow: 0, endRow: 0, startColumn: column, endColumn: column }, size: width },
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return commands
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function parseEditCommands(value: unknown, warnings: string[] = [], targetWorkbookId = 'workbook'): Promise<ParsedCommand[]> {
|
|
166
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) throw new Error('commands 必须是 1 到 100 项的数组')
|
|
167
|
+
const aliases: Record<string, WorkbookOperationType> = {
|
|
168
|
+
set_values: 'update_cells',
|
|
169
|
+
clear: 'clear_range',
|
|
170
|
+
format: 'format_cells',
|
|
171
|
+
merge: 'merge_cells',
|
|
172
|
+
unmerge: 'unmerge_cells',
|
|
173
|
+
freeze: 'freeze_panes',
|
|
174
|
+
sort: 'sort_range',
|
|
175
|
+
}
|
|
176
|
+
const importedTables = await Promise.all(value.map(async (candidate, index) => {
|
|
177
|
+
const raw = commandPayload(asToolArguments(candidate, 'commands[' + index + ']'))
|
|
178
|
+
const rawType = raw.type
|
|
179
|
+
const type = typeof rawType === 'string' && aliases[rawType] !== undefined ? aliases[rawType] : rawType
|
|
180
|
+
if (raw.sourcePath === undefined) return undefined
|
|
181
|
+
// `update_cells` prefers a literal matrix or `cells` payload; reading a file
|
|
182
|
+
// the command will ignore would fail the whole plan when that path is
|
|
183
|
+
// missing and would waste I/O on a file that is never used.
|
|
184
|
+
const usesImport = type === 'import_file' || type === 'append_rows'
|
|
185
|
+
|| (type === 'update_cells' && raw.cells === undefined && literalValueMatrix(raw) === undefined)
|
|
186
|
+
return usesImport ? readTabularSource(raw.sourcePath, raw.sourceFormat) : undefined
|
|
187
|
+
}))
|
|
188
|
+
// `duplicate_sheet` has to read its source workbook from storage, so it is
|
|
189
|
+
// the one command whose expansion cannot happen inside the synchronous loop
|
|
190
|
+
// below (which uses `return` to end each case). Resolve those expansions up
|
|
191
|
+
// front and inject them in place, so command order — and therefore the
|
|
192
|
+
// name-to-id binding in `bindEditVersionGroups` — is unchanged.
|
|
193
|
+
const duplicateExpansions = new Map<number, ParsedCommand[]>()
|
|
194
|
+
const fileCellExpansions = new Map<number, WorkbookCellUpdate[]>()
|
|
195
|
+
await Promise.all(value.map(async (candidate, index) => {
|
|
196
|
+
const raw = commandPayload(asToolArguments(candidate, 'commands[' + index + ']'))
|
|
197
|
+
if (raw.type === 'duplicate_sheet') {
|
|
198
|
+
duplicateExpansions.set(index, await expandDuplicateSheet(raw, index, targetWorkbookId, warnings))
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
// `cells[].valueFromFile` needs storage too, and `parseCells` is synchronous.
|
|
202
|
+
if (raw.type === 'update_cells' && Array.isArray(raw.cells)) {
|
|
203
|
+
const needsFile = raw.cells.some(cell => (
|
|
204
|
+
typeof cell === 'object' && cell !== null && !Array.isArray(cell)
|
|
205
|
+
&& (cell as Record<string, unknown>).valueFromFile !== undefined
|
|
206
|
+
&& (cell as Record<string, unknown>).valueFromFile !== null
|
|
207
|
+
))
|
|
208
|
+
if (needsFile) {
|
|
209
|
+
fileCellExpansions.set(index, parseCells(await resolveCellFileValues(raw.cells, `commands[${index}]`)))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}))
|
|
213
|
+
const result: ParsedCommand[] = []
|
|
214
|
+
value.forEach((candidate, index) => {
|
|
215
|
+
const raw = commandPayload(asToolArguments(candidate, 'commands[' + index + ']'))
|
|
216
|
+
if (raw.scope !== undefined && raw.scope !== 'workbook' && raw.scope !== 'sheet' && raw.scope !== 'cell') {
|
|
217
|
+
throw new Error('commands[' + index + '].scope 必须是 workbook、sheet 或 cell')
|
|
218
|
+
}
|
|
219
|
+
const rawType = raw.type
|
|
220
|
+
const aliasedType = typeof rawType === 'string' && aliases[rawType] !== undefined ? aliases[rawType] : rawType
|
|
221
|
+
// `import_file` and `duplicate_sheet` are Agent-facing conveniences that are
|
|
222
|
+
// expanded below into the durable command set, so neither ever reaches the
|
|
223
|
+
// Version/Step model as a type of its own.
|
|
224
|
+
const type: WorkbookOperationType | 'import_file' | 'duplicate_sheet' =
|
|
225
|
+
aliasedType === 'import_file' || aliasedType === 'duplicate_sheet'
|
|
226
|
+
? aliasedType
|
|
227
|
+
: aliasedType as WorkbookOperationType
|
|
228
|
+
if (type !== 'import_file' && type !== 'duplicate_sheet'
|
|
229
|
+
&& (!isOperationType(type) || type === 'batch' || type === 'snapshot_replace' || type === 'remove_duplicates')) {
|
|
230
|
+
throw new Error('commands[' + index + '].type 不支持')
|
|
231
|
+
}
|
|
232
|
+
const sheet = commandSheet(raw, index, type === 'create_sheet' || type === 'append_rows')
|
|
233
|
+
switch (type) {
|
|
234
|
+
case 'update_cells': {
|
|
235
|
+
// A literal matrix wins. `sourcePath` is the bulk alternative, so a
|
|
236
|
+
// large result never has to be spelled out inside the tool call.
|
|
237
|
+
const imported = importedTables[index]
|
|
238
|
+
const matrix = literalValueMatrix(raw)
|
|
239
|
+
?? (imported === undefined ? undefined : importedValueMatrix(imported, `commands[${index}]`))
|
|
240
|
+
const cells = fileCellExpansions.get(index) ?? (raw.cells === undefined
|
|
241
|
+
? (() => {
|
|
242
|
+
const valueMatrix = normalizeValueMatrix(matrix)
|
|
243
|
+
return parseApiValues(valueMatrix, parseValueRange(valueCommandRange(raw), valueMatrix, `commands[${index}]`, warnings))
|
|
244
|
+
})()
|
|
245
|
+
: parseCells(raw.cells))
|
|
246
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), cells } })
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
case 'clear_range':
|
|
250
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range) } })
|
|
251
|
+
return
|
|
252
|
+
case 'delete_cells':
|
|
253
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range), shift: parseCellDeleteShift(raw.shift) } })
|
|
254
|
+
return
|
|
255
|
+
case 'insert_rows':
|
|
256
|
+
case 'delete_rows': {
|
|
257
|
+
const range = dimensionCommandRange(raw, index, 'row')
|
|
258
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range, count: raw.count === undefined ? range.endRow - range.startRow + 1 : parseCount(raw.count) } })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
case 'insert_columns':
|
|
262
|
+
case 'delete_columns': {
|
|
263
|
+
const range = dimensionCommandRange(raw, index, 'column')
|
|
264
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range, count: raw.count === undefined ? range.endColumn - range.startColumn + 1 : parseCount(raw.count) } })
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
case 'create_sheet':
|
|
268
|
+
result.push({ type, payload: { sheetId: sheet, name: parseNonEmptyString(raw.name, 'commands[' + index + '].name') } })
|
|
269
|
+
return
|
|
270
|
+
case 'delete_sheet':
|
|
271
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index) } })
|
|
272
|
+
return
|
|
273
|
+
case 'rename_sheet':
|
|
274
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), name: parseNonEmptyString(raw.name, 'commands[' + index + '].name') } })
|
|
275
|
+
return
|
|
276
|
+
case 'append_rows': {
|
|
277
|
+
const label = `commands[${index}]`
|
|
278
|
+
const imported = importedTables[index]
|
|
279
|
+
const table = validateSheet({
|
|
280
|
+
name: raw.name ?? sheet ?? imported?.name ?? 'Sheet',
|
|
281
|
+
columns: raw.columns ?? imported?.columns,
|
|
282
|
+
rows: raw.rows ?? raw.data ?? raw.records ?? imported?.rows,
|
|
283
|
+
})
|
|
284
|
+
// Report sheets keep a title block above the real header row, so the
|
|
285
|
+
// header is addressable instead of being pinned to row 0.
|
|
286
|
+
const headerRowIndex = parseIndex(raw.headerRowIndex ?? 0, `${label}.headerRowIndex`)
|
|
287
|
+
if (headerRowIndex >= SHEET_MAX_PHYSICAL_ROWS) {
|
|
288
|
+
throw new Error(`${label}.headerRowIndex=${headerRowIndex} 超出上限(最后一个物理行是 ${SHEET_MAX_ROWS})`)
|
|
289
|
+
}
|
|
290
|
+
const verifyHeader = parseHeaderVerification(raw.verifyHeader, `${label}.verifyHeader`)
|
|
291
|
+
const chunks = table.rows.length === 0 ? [[]] : chunkRows(table.rows, SHEET_MAX_APPEND_ROWS)
|
|
292
|
+
for (const rows of chunks) {
|
|
293
|
+
result.push({ type, payload: { sheetId: sheet, name: table.name, columns: table.columns, rows, meta: parseSheetMeta(raw.meta), headerRowIndex, verifyHeader } })
|
|
294
|
+
}
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
case 'import_file': {
|
|
298
|
+
// A first-class "one command + one file path" bulk import for report
|
|
299
|
+
// sheets: the origin is explicit, row 0 never has to be a header, and
|
|
300
|
+
// the result carries a self-verifying receipt. It is normalized into
|
|
301
|
+
// the existing update_cells write path before persistence.
|
|
302
|
+
const label = `commands[${index}]`
|
|
303
|
+
const imported = importedTables[index]
|
|
304
|
+
if (imported === undefined) throw new Error(`${label}.sourcePath 是 import_file 的必填参数(支持 CSV/JSON/JSONL/XLSX/XLS)`)
|
|
305
|
+
const origin = raw.range === undefined || raw.range === null ? undefined : asToolArguments(raw.range, `${label}.range`)
|
|
306
|
+
const startValue = raw.startRow ?? origin?.startRow
|
|
307
|
+
if (startValue === undefined) throw new Error(`${label}.startRow 是 import_file 的必填参数(0-based 写入起点);也可用 range.startRow 给出`)
|
|
308
|
+
const startRow = parseIndex(startValue, `${label}.startRow`)
|
|
309
|
+
const startColumn = parseIndex(raw.startColumn ?? origin?.startColumn ?? 0, `${label}.startColumn`)
|
|
310
|
+
const source = importedValueMatrix(imported, label)
|
|
311
|
+
const available = SHEET_MAX_PHYSICAL_ROWS - startRow
|
|
312
|
+
if (available < 1) {
|
|
313
|
+
throw new Error(`${label}.startRow=${startRow} 超出上限(最多 ${SHEET_MAX_ROWS} 条数据行 + 表头,最后一个物理行是 ${SHEET_MAX_ROWS})`)
|
|
314
|
+
}
|
|
315
|
+
// A partial bulk write is a silent data-loss trap, so it must be asked
|
|
316
|
+
// for explicitly instead of being reported as a successful import.
|
|
317
|
+
if (source.length > available && raw.allowTruncate !== true) {
|
|
318
|
+
throw new Error(`${label} 源文件 ${source.length} 行超出可写区间:从第 ${startRow + 1} 行起最多还能写 ${available} 行(上限 ${SHEET_MAX_ROWS} 条数据行 + 表头)。请减少源文件行数、下移起点,或显式传 allowTruncate: true 只导入前 ${available} 行`)
|
|
319
|
+
}
|
|
320
|
+
const rows = source.length > available ? source.slice(0, available) : source
|
|
321
|
+
if (rows.length === 0) throw new Error(`${label}.startRow=${startRow} 没有可写入的行`)
|
|
322
|
+
const truncated = rows.length < source.length
|
|
323
|
+
const range = parseRange({ startRow, startColumn, endRow: startRow + rows.length - 1, endColumn: startColumn + (rows[0]?.length ?? 1) - 1 })
|
|
324
|
+
const cells = parseApiValues(rows, range)
|
|
325
|
+
result.push({
|
|
326
|
+
type: 'update_cells',
|
|
327
|
+
payload: { sheetId: commandSheet(raw, index), cells },
|
|
328
|
+
importFile: {
|
|
329
|
+
sourcePath: imported.path,
|
|
330
|
+
range,
|
|
331
|
+
rowsWritten: rows.length,
|
|
332
|
+
cellsWritten: cells.length,
|
|
333
|
+
sourceRowCount: source.length,
|
|
334
|
+
truncated,
|
|
335
|
+
verifyCapacity: raw.verifyCapacity !== false,
|
|
336
|
+
firstRow: rows[0] ?? [],
|
|
337
|
+
lastRow: rows[rows.length - 1] ?? [],
|
|
338
|
+
valuesHash: hashValueMatrix(rows),
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
case 'format_cells':
|
|
344
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range), style: parseStyle(raw.style) } })
|
|
345
|
+
return
|
|
346
|
+
case 'merge_cells':
|
|
347
|
+
case 'unmerge_cells':
|
|
348
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range) } })
|
|
349
|
+
return
|
|
350
|
+
case 'freeze_panes':
|
|
351
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), freeze: parseFreeze(raw.freeze) } })
|
|
352
|
+
return
|
|
353
|
+
case 'set_column_width':
|
|
354
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: dimensionRange(commandIndex(raw, index, 'index'), 'column'), size: parseFiniteNumber(raw.size, 'commands[' + index + '].size') } })
|
|
355
|
+
return
|
|
356
|
+
case 'set_row_height':
|
|
357
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: dimensionRange(commandIndex(raw, index, 'index'), 'row'), size: parseFiniteNumber(raw.size, 'commands[' + index + '].size') } })
|
|
358
|
+
return
|
|
359
|
+
case 'sort_range':
|
|
360
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range), sort: parseSort(raw.sort) } })
|
|
361
|
+
return
|
|
362
|
+
case 'set_hyperlink':
|
|
363
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range), url: parseHyperlink(raw.url), label: raw.label === undefined ? undefined : parseHyperlinkLabel(raw.label) } })
|
|
364
|
+
return
|
|
365
|
+
case 'remove_hyperlink':
|
|
366
|
+
result.push({ type, payload: { sheetId: commandSheet(raw, index), range: parseRange(raw.range) } })
|
|
367
|
+
return
|
|
368
|
+
case 'duplicate_sheet':
|
|
369
|
+
result.push(...(duplicateExpansions.get(index) ?? []))
|
|
370
|
+
return
|
|
371
|
+
default:
|
|
372
|
+
assertNeverEditCommand(type)
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
return result
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface ImportedTable {
|
|
379
|
+
/** Resolved absolute path of the source file. */
|
|
380
|
+
path: string
|
|
381
|
+
name?: string
|
|
382
|
+
columns?: unknown
|
|
383
|
+
rows: unknown[]
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Expand a tabular import (`{ columns?, rows }`) into the rectangular value
|
|
388
|
+
* matrix the `update_cells` write path consumes. CSV/XLSX/JSONL produce row
|
|
389
|
+
* objects, so the column order is inferred from the row keys — the same rule
|
|
390
|
+
* `append_rows` uses — while `validateSheet` keeps the caps and error wording
|
|
391
|
+
* for both shapes. Ragged array rows are padded by `normalizeValueMatrix`.
|
|
392
|
+
*/
|
|
393
|
+
export function importedValueMatrix(imported: ImportedTable, label: string): unknown[][] {
|
|
394
|
+
const rows = imported.rows
|
|
395
|
+
if (rows.length === 0) throw new Error(`${label}.sourcePath 没有可导入的数据行`)
|
|
396
|
+
if (rows.every(row => Array.isArray(row))) return normalizeValueMatrix(rows as unknown[][])
|
|
397
|
+
if (imported.columns !== undefined && !Array.isArray(imported.columns)) throw new Error(`${label}.sourcePath columns 必须是数组`)
|
|
398
|
+
const declared = Array.isArray(imported.columns) ? imported.columns : undefined
|
|
399
|
+
if (declared !== undefined && declared.length > 0) {
|
|
400
|
+
const table = validateSheet({ name: undefined, columns: declared, rows })
|
|
401
|
+
return (table.rows as Array<Record<string, unknown>>).map(row => table.columns.map(column => row[column.key] ?? null))
|
|
402
|
+
}
|
|
403
|
+
const keys: string[] = []
|
|
404
|
+
rows.forEach((row, index) => {
|
|
405
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) throw new Error(`${label}.sourcePath 第 ${index + 1} 行不是对象`)
|
|
406
|
+
for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key)
|
|
407
|
+
})
|
|
408
|
+
if (keys.length === 0) throw new Error(`${label}.sourcePath 无法从数据行推断列,请改用 CSV/XLSX 或显式提供 values`)
|
|
409
|
+
if (keys.length > SHEET_MAX_COLUMNS) throw new Error(`${label}.sourcePath 列数超过上限(最多 ${SHEET_MAX_COLUMNS} 列)`)
|
|
410
|
+
// The validator's sanitized keys are only metadata here: values are projected
|
|
411
|
+
// positionally by the original key order, exactly like `inferAppendTable`.
|
|
412
|
+
const table = validateSheet({
|
|
413
|
+
name: undefined,
|
|
414
|
+
columns: keys.map((key, index) => ({ key: /^[A-Za-z0-9_]+$/.test(key) ? key : `column_${index + 1}`, label: key, type: 'string' as SheetColumnType })),
|
|
415
|
+
rows,
|
|
416
|
+
})
|
|
417
|
+
if (table.columns.length !== keys.length) throw new Error(`${label}.sourcePath 列推断失败`)
|
|
418
|
+
return rows.map(row => keys.map(key => (row as Record<string, unknown>)[key] ?? null))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Stable content digest so an import receipt can prove what was written. */
|
|
422
|
+
export function hashValueMatrix(matrix: readonly unknown[][]): string {
|
|
423
|
+
return createHash('sha256').update(JSON.stringify(matrix)).digest('hex')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The literal value matrix of an `update_cells` command, if it carries one.
|
|
428
|
+
* Shared by the import pre-read and the command branch so both agree on when a
|
|
429
|
+
* `sourcePath` is actually consulted.
|
|
430
|
+
*/
|
|
431
|
+
export function literalValueMatrix(raw: ToolArguments): unknown {
|
|
432
|
+
return raw.values ?? raw.data ?? raw.matrix
|
|
433
|
+
?? (Array.isArray(raw.rows) && raw.rows.some(item => Array.isArray(item)) ? raw.rows : undefined)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* `update_cells` anchors at `range`, or at the top-level `startRow` /
|
|
438
|
+
* `startColumn` pair. A missing anchor keeps the historical default (row 1,
|
|
439
|
+
* column 0) — the P1-2 origin change is intentionally out of scope here.
|
|
440
|
+
*/
|
|
441
|
+
export function valueCommandRange(raw: ToolArguments): unknown {
|
|
442
|
+
if (raw.range !== undefined && raw.range !== null) return raw.range
|
|
443
|
+
if (raw.startRow === undefined && raw.startColumn === undefined) return undefined
|
|
444
|
+
return { startRow: raw.startRow, startColumn: raw.startColumn }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Read a local tabular file for bulk Agent imports. The file is converted to
|
|
448
|
+
* the same append_rows/update_cells contract before it reaches the durable
|
|
449
|
+
* mutator, so a failed parse never creates a partial workbook revision. */
|
|
450
|
+
export async function readTabularSource(rawPath: unknown, rawFormat: unknown): Promise<ImportedTable> {
|
|
451
|
+
const sourcePath = resolve(parseNonEmptyString(rawPath, 'sourcePath'))
|
|
452
|
+
let info: { size: number }
|
|
453
|
+
try { info = statSync(sourcePath) } catch { throw new Error(`sourcePath 文件不存在:${sourcePath}`) }
|
|
454
|
+
if (!Number.isInteger(info.size) || info.size < 0 || info.size > 128 * 1024 * 1024) throw new Error('sourcePath 文件必须不超过 128 MiB')
|
|
455
|
+
const extension = typeof rawFormat === 'string' && rawFormat.trim() !== '' ? `.${rawFormat.trim().replace(/^\./, '').toLowerCase()}` : extname(sourcePath).toLowerCase()
|
|
456
|
+
if (!['.csv', '.json', '.jsonl', '.xlsx', '.xls'].includes(extension)) throw new Error('sourcePath 仅支持 CSV、JSON、JSONL、XLSX、XLS')
|
|
457
|
+
const bytes = await readFile(sourcePath)
|
|
458
|
+
if (extension === '.json' || extension === '.jsonl') {
|
|
459
|
+
const text = bytes.toString('utf8').replace(/^\uFEFF/, '')
|
|
460
|
+
const value = extension === '.jsonl'
|
|
461
|
+
? text.split(/\r?\n/).map(line => line.trim()).filter(Boolean).map((line, index) => {
|
|
462
|
+
try { return JSON.parse(line) as unknown } catch { throw new Error(`sourcePath JSONL 第 ${index + 1} 行不是有效 JSON`) }
|
|
463
|
+
})
|
|
464
|
+
: (() => { try { return JSON.parse(text) as unknown } catch { throw new Error('sourcePath JSON 不是有效 JSON') } })()
|
|
465
|
+
if (Array.isArray(value)) return { path: sourcePath, rows: value }
|
|
466
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
467
|
+
const record = value as Record<string, unknown>
|
|
468
|
+
const rows = record.rows ?? record.data ?? record.records
|
|
469
|
+
if (Array.isArray(rows)) return { path: sourcePath, name: typeof record.name === 'string' ? record.name : undefined, columns: record.columns, rows }
|
|
470
|
+
}
|
|
471
|
+
throw new Error('sourcePath JSON 须是数组,或包含 rows/data/records 数组的对象')
|
|
472
|
+
}
|
|
473
|
+
// CSV is text: decode UTF-8 explicitly so quoted commas/newlines parse
|
|
474
|
+
// correctly and CJK values do not come back as latin1 mojibake.
|
|
475
|
+
const workbook = XLSX.read(extension === '.csv' ? bytes.toString('utf8').replace(/^\uFEFF/, '') : bytes, extension === '.csv' ? { type: 'string' } : { type: 'buffer' })
|
|
476
|
+
const firstName = workbook.SheetNames[0]
|
|
477
|
+
if (firstName === undefined) throw new Error('sourcePath 工作簿没有 sheet')
|
|
478
|
+
const first = workbook.Sheets[firstName]
|
|
479
|
+
if (first === undefined) throw new Error('sourcePath 第一个 sheet 不存在')
|
|
480
|
+
return { path: sourcePath, name: firstName, rows: XLSX.utils.sheet_to_json(first, { defval: null }) as unknown[] }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export interface ParsedEditVersion {
|
|
484
|
+
summary: string
|
|
485
|
+
intent?: string
|
|
486
|
+
commands: ParsedCommand[]
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Convert the tool protocol into the aggregate's two durable levels:
|
|
491
|
+
* semantic Version groups, each containing replayable command Steps. The
|
|
492
|
+
* shorthand keeps older callers working while making their whole request one
|
|
493
|
+
* coherent Version instead of one Version per command.
|
|
494
|
+
*/
|
|
495
|
+
export async function parseEditVersionGroups(rawVersions: unknown, rawCommands: unknown, defaultIntent?: string, warnings: string[] = [], targetWorkbookId = 'workbook'): Promise<ParsedEditVersion[]> {
|
|
496
|
+
if (rawVersions !== undefined && rawVersions !== null) {
|
|
497
|
+
if (rawCommands !== undefined && rawCommands !== null) throw new Error('versions 与 commands 只能提供其中之一')
|
|
498
|
+
if (!Array.isArray(rawVersions) || rawVersions.length === 0 || rawVersions.length > 20) throw new Error('versions 必须是 1 到 20 项的数组')
|
|
499
|
+
const groups = await Promise.all(rawVersions.map(async (candidate, index) => {
|
|
500
|
+
const raw = asToolArguments(candidate, `versions[${index}]`)
|
|
501
|
+
const summary = optionalIntent(raw.summary, `versions[${index}].summary`)
|
|
502
|
+
if (summary === undefined) throw new Error(`versions[${index}].summary 必须是 1 到 280 字符的字符串`)
|
|
503
|
+
return {
|
|
504
|
+
summary,
|
|
505
|
+
intent: optionalAuditText(raw.intent, `versions[${index}].intent`, 1000) ?? defaultIntent,
|
|
506
|
+
commands: await parseEditCommands(raw.commands, warnings, targetWorkbookId),
|
|
507
|
+
}
|
|
508
|
+
}))
|
|
509
|
+
const commandCount = groups.reduce((total, group) => total + group.commands.length, 0)
|
|
510
|
+
if (commandCount > 100) throw new Error('versions 中 commands 总数最多 100 条')
|
|
511
|
+
return groups
|
|
512
|
+
}
|
|
513
|
+
const commands = await parseEditCommands(rawCommands, warnings, targetWorkbookId)
|
|
514
|
+
return [{
|
|
515
|
+
summary: defaultIntent ?? `Agent 编辑(${commands.length} 个 step)`,
|
|
516
|
+
intent: defaultIntent,
|
|
517
|
+
commands,
|
|
518
|
+
}]
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function assertNeverEditCommand(value: never): never {
|
|
522
|
+
throw new Error('不支持的编辑命令:' + String(value))
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Bind the forgiving Agent wire format to the durable workbook format. This
|
|
527
|
+
* runs after the workbook has been loaded, so a single-sheet workbook can
|
|
528
|
+
* safely omit `sheet`, sheet names can be converted to ids, and commands that
|
|
529
|
+
* create a sheet can be followed by commands targeting that new sheet.
|
|
530
|
+
*/
|
|
531
|
+
export function bindEditVersionGroups(snapshot: unknown, groups: ParsedEditVersion[], workbookId = 'workbook'): ParsedEditVersion[] {
|
|
532
|
+
let working = cloneSnapshot(snapshot)
|
|
533
|
+
return groups.map((group, groupIndex) => {
|
|
534
|
+
const commands = group.commands.map((command, commandIndex) => {
|
|
535
|
+
const label = `versions[${groupIndex}].commands[${commandIndex}]`
|
|
536
|
+
const payload = { ...command.payload }
|
|
537
|
+
if (command.type === 'create_sheet') {
|
|
538
|
+
// A missing id used to fall back to `sheet-${opId.slice(0,12)}`, which
|
|
539
|
+
// differs between the bind probe and the real commit and stranded
|
|
540
|
+
// later commands in the same batch on a stale id.
|
|
541
|
+
const explicit = payload.sheetId ?? payload.sheetName
|
|
542
|
+
payload.sheetId = explicit === undefined || String(explicit).trim() === ''
|
|
543
|
+
? stableCreateSheetId(workbookId, payload.name, groupIndex, commandIndex)
|
|
544
|
+
: parseNonEmptyString(explicit, `${label}.sheet`)
|
|
545
|
+
delete payload.sheetName
|
|
546
|
+
} else {
|
|
547
|
+
const explicitRef = payload.sheetId ?? payload.sheetName
|
|
548
|
+
const ref = resolveCommandSheetRef(working, explicitRef, `${label}.sheet`, command.type === 'append_rows')
|
|
549
|
+
if (ref !== undefined) {
|
|
550
|
+
payload.sheetId = ref
|
|
551
|
+
delete payload.sheetName
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (command.type === 'append_rows' && (payload.columns === undefined || payload.columns.length === 0)) {
|
|
555
|
+
const inferred = inferAppendTable(working, payload.sheetId ?? payload.sheetName, payload.rows ?? [], label)
|
|
556
|
+
payload.columns = inferred.columns
|
|
557
|
+
payload.rows = inferred.rows
|
|
558
|
+
}
|
|
559
|
+
const bound: ParsedCommand = {
|
|
560
|
+
type: command.type,
|
|
561
|
+
payload,
|
|
562
|
+
...(command.importFile === undefined ? {} : { importFile: command.importFile }),
|
|
563
|
+
}
|
|
564
|
+
working = applyWorkbookOperation(working, {
|
|
565
|
+
opId: `bind:${groupIndex}:${commandIndex}`,
|
|
566
|
+
workbookId: 'bind',
|
|
567
|
+
revision: 0,
|
|
568
|
+
actor: 'agent',
|
|
569
|
+
timestamp: new Date(0).toISOString(),
|
|
570
|
+
type: bound.type,
|
|
571
|
+
payload: bound.payload,
|
|
572
|
+
})
|
|
573
|
+
return bound
|
|
574
|
+
})
|
|
575
|
+
return { ...group, commands }
|
|
576
|
+
})
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Deterministic create_sheet id so preview and commit address the same sheet. */
|
|
580
|
+
export function stableCreateSheetId(workbookId: string, name: unknown, groupIndex: number, commandIndex: number): string {
|
|
581
|
+
const seed = `${workbookId}#${groupIndex}#${commandIndex}#${String(name ?? 'sheet')}`
|
|
582
|
+
return `sheet-${createHash('sha256').update(seed).digest('hex').slice(0, 12)}`
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Warn when one edit plan writes the same cell more than once. The later
|
|
587
|
+
* command wins, which is easy to do by accident in a 15-command batch.
|
|
588
|
+
*/
|
|
589
|
+
export function collectDuplicateCellWrites(versions: readonly ParsedEditVersion[], warnings: string[]): void {
|
|
590
|
+
const firstWrite = new Map<string, string>()
|
|
591
|
+
const reported = new Set<string>()
|
|
592
|
+
let suppressed = 0
|
|
593
|
+
versions.forEach((version, groupIndex) => {
|
|
594
|
+
version.commands.forEach((command, commandIndex) => {
|
|
595
|
+
if (command.type !== 'update_cells') return
|
|
596
|
+
const sheetId = String(command.payload.sheetId ?? command.payload.sheetName ?? '')
|
|
597
|
+
const label = `versions[${groupIndex}].commands[${commandIndex}]`
|
|
598
|
+
for (const cell of command.payload.cells ?? []) {
|
|
599
|
+
const key = `${sheetId}:${cell.row}:${cell.column}`
|
|
600
|
+
const previous = firstWrite.get(key)
|
|
601
|
+
if (previous === undefined) {
|
|
602
|
+
firstWrite.set(key, label)
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
if (reported.has(key)) continue
|
|
606
|
+
reported.add(key)
|
|
607
|
+
if (reported.size > 20) { suppressed += 1; continue }
|
|
608
|
+
warnings.push(`cells ${cellAddress(cell.row, cell.column)} 被 ${previous} 与 ${label} 依次覆盖;后写入的命令生效`)
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
if (suppressed > 0) warnings.push(`另有 ${suppressed} 个 cell 被重复写入,已省略`)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export function resolveCommandSheetRef(snapshot: unknown, explicit: unknown, label: string, allowMissing: boolean): string | undefined {
|
|
616
|
+
if (explicit === undefined || explicit === null || explicit === '') return sheetRefForSnapshot(snapshot, {}, label)
|
|
617
|
+
const ref = parseNonEmptyString(explicit, label)
|
|
618
|
+
try {
|
|
619
|
+
const sheet = snapshotSheet(snapshot, ref)
|
|
620
|
+
return typeof sheet.id === 'string' && sheet.id.length > 0 ? sheet.id : ref
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (allowMissing) return ref
|
|
623
|
+
throw new Error(`${label} 指向不存在的 sheet:${ref}。当前工作簿 sheet:${sheetReferenceList(snapshot)}。若该 sheet 由本批次 create_sheet 新建,请把 create_sheet 放在引用它的命令之前(同一个 version 内按顺序执行),或先单独提交 create_sheet`)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function inferAppendTable(snapshot: unknown, sheetRef: unknown, rawRows: unknown, label: string): { columns: SheetColumn[]; rows: Array<Record<string, unknown>> } {
|
|
628
|
+
if (!Array.isArray(rawRows)) throw new Error(`${label}.rows 必须是对象数组`)
|
|
629
|
+
const rows = rawRows as Array<Record<string, unknown>>
|
|
630
|
+
let existing: Record<string, unknown> | undefined
|
|
631
|
+
if (sheetRef !== undefined) {
|
|
632
|
+
try { existing = snapshotSheet(snapshot, String(sheetRef)) } catch { /* append_rows may create a new sheet */ }
|
|
633
|
+
}
|
|
634
|
+
const schema = existing?.dataSheetSchema
|
|
635
|
+
const schemaRecord = typeof schema === 'object' && schema !== null && !Array.isArray(schema)
|
|
636
|
+
? schema as Record<string, unknown>
|
|
637
|
+
: undefined
|
|
638
|
+
if (Array.isArray(schemaRecord?.columns) && schemaRecord.columns.length > 0) {
|
|
639
|
+
const columns = schemaRecord.columns as SheetColumn[]
|
|
640
|
+
return { columns, rows: projectRowsToColumns(rows, columns) }
|
|
641
|
+
}
|
|
642
|
+
const keys: string[] = []
|
|
643
|
+
for (const row of rows) {
|
|
644
|
+
for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key)
|
|
645
|
+
}
|
|
646
|
+
if (keys.length === 0) throw new Error(`${label} 缺少 columns,且无法从 rows 推断列`)
|
|
647
|
+
if (keys.length > SHEET_MAX_COLUMNS) throw new Error(`推断列数超过上限(最多 ${SHEET_MAX_COLUMNS} 列)`)
|
|
648
|
+
const header = existing?.cellData
|
|
649
|
+
const headerRow = header !== undefined && typeof header === 'object' && header !== null && !Array.isArray(header)
|
|
650
|
+
? (header as Record<string, unknown>)['0']
|
|
651
|
+
: undefined
|
|
652
|
+
const headerValues = headerRow !== null && typeof headerRow === 'object' && headerRow !== undefined && !Array.isArray(headerRow)
|
|
653
|
+
? headerRow as Record<string, unknown>
|
|
654
|
+
: {}
|
|
655
|
+
const columns = keys.map((sourceKey, index) => {
|
|
656
|
+
const key = /^[A-Za-z0-9_]+$/.test(sourceKey) ? sourceKey : `column_${index + 1}`
|
|
657
|
+
const labelValue = headerValues[String(index)] && typeof headerValues[String(index)] === 'object'
|
|
658
|
+
? (headerValues[String(index)] as Record<string, unknown>).v
|
|
659
|
+
: undefined
|
|
660
|
+
return { key, label: typeof labelValue === 'string' && labelValue.length > 0 ? labelValue : sourceKey, type: inferredColumnType(rows, sourceKey) }
|
|
661
|
+
})
|
|
662
|
+
const normalizedRows = rows.map(row => Object.fromEntries(columns.map((column, index) => [column.key, row[keys[index]]])))
|
|
663
|
+
return { columns, rows: normalizedRows }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function projectRowsToColumns(rows: Array<Record<string, unknown>>, columns: SheetColumn[]): Array<Record<string, unknown>> {
|
|
667
|
+
return rows.map(row => Object.fromEntries(columns.map(column => {
|
|
668
|
+
if (Object.hasOwn(row, column.key)) return [column.key, row[column.key]]
|
|
669
|
+
if (Object.hasOwn(row, column.label)) return [column.key, row[column.label]]
|
|
670
|
+
return [column.key, undefined]
|
|
671
|
+
})))
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function inferredColumnType(rows: Array<Record<string, unknown>>, key: string): SheetColumnType {
|
|
675
|
+
const values = rows.map(row => row[key]).filter(value => value !== undefined && value !== null)
|
|
676
|
+
if (values.length > 0 && values.every(value => typeof value === 'boolean')) return 'boolean'
|
|
677
|
+
if (values.length > 0 && values.every(value => typeof value === 'number' && Number.isFinite(value))) return 'number'
|
|
678
|
+
return 'string'
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* A rejected edit plan used to come back as a bare message, so the caller had
|
|
683
|
+
* to replay the whole payload just to discover what it had sent (docs/issue/0011
|
|
684
|
+
* I8 / T7). Every validation failure now names the shape that was received and
|
|
685
|
+
* says that only the offending command has to change; `details` carries the
|
|
686
|
+
* same facts as data for callers that can read them.
|
|
687
|
+
*/
|
|
688
|
+
export function withEditRetryContext(error: unknown, args: { workbookId: string; versions?: unknown; commands?: unknown }): DataSheetError {
|
|
689
|
+
if (error instanceof DataSheetError) return error
|
|
690
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
691
|
+
const commandList = Array.isArray(args.commands) ? args.commands : []
|
|
692
|
+
const versionList = Array.isArray(args.versions) ? args.versions : []
|
|
693
|
+
const types = commandList.map(entry => (
|
|
694
|
+
typeof entry === 'object' && entry !== null ? String((entry as Record<string, unknown>).type ?? '?') : '?'
|
|
695
|
+
))
|
|
696
|
+
const shape = versionList.length > 0
|
|
697
|
+
? `${versionList.length} 个 version 分组`
|
|
698
|
+
: `${commandList.length} 条命令${types.length === 0 ? '' : `(${[...new Set(types)].join('、')})`}`
|
|
699
|
+
return new DataSheetError(
|
|
700
|
+
'VALIDATION_ERROR',
|
|
701
|
+
`${message};已收参数:workbookId=${args.workbookId},${shape}。修正出错的那条命令后重试即可,不必重发整份 payload。`,
|
|
702
|
+
400,
|
|
703
|
+
{
|
|
704
|
+
workbookId: args.workbookId,
|
|
705
|
+
versionCount: versionList.length,
|
|
706
|
+
commandCount: commandList.length,
|
|
707
|
+
commandTypes: [...new Set(types)],
|
|
708
|
+
},
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export async function executeWorkbookEdit(args: {
|
|
713
|
+
workbookId: string
|
|
714
|
+
versions: unknown
|
|
715
|
+
commands: unknown
|
|
716
|
+
baseRevision?: number
|
|
717
|
+
opId?: string
|
|
718
|
+
intent?: string
|
|
719
|
+
agentRunId?: string
|
|
720
|
+
lineage?: LineageContext
|
|
721
|
+
dryRun: boolean
|
|
722
|
+
}): Promise<unknown> {
|
|
723
|
+
const warnings: string[] = []
|
|
724
|
+
let parsedVersions: ParsedEditVersion[]
|
|
725
|
+
try {
|
|
726
|
+
parsedVersions = await parseEditVersionGroups(args.versions, args.commands, args.intent, warnings, args.workbookId)
|
|
727
|
+
} catch (error) {
|
|
728
|
+
throw withEditRetryContext(error, args)
|
|
729
|
+
}
|
|
730
|
+
const meta = await readWorkbook(args.workbookId)
|
|
731
|
+
if (meta === null) throw new Error('工作簿不存在:' + args.workbookId)
|
|
732
|
+
const versions = bindEditVersionGroups(meta.snapshot, parsedVersions, args.workbookId)
|
|
733
|
+
collectDuplicateCellWrites(versions, warnings)
|
|
734
|
+
const commands = versions.flatMap(version => version.commands)
|
|
735
|
+
const importSteps = collectImportFileSteps(versions)
|
|
736
|
+
const currentRevision = meta.revision ?? 0
|
|
737
|
+
const steps = versions.map((version, index) => {
|
|
738
|
+
const operationId = args.opId === undefined ? undefined : versions.length === 1 ? args.opId : args.opId + ':version:' + (index + 1)
|
|
739
|
+
return {
|
|
740
|
+
type: 'batch' as const,
|
|
741
|
+
// Only the durable { type, payload } shape is persisted: the Agent-facing
|
|
742
|
+
// import receipt metadata must not change the Version/Step model or the
|
|
743
|
+
// request hash (and therefore opId idempotency).
|
|
744
|
+
payload: { operations: version.commands.map(command => ({ type: command.type, payload: command.payload })) },
|
|
745
|
+
opId: operationId,
|
|
746
|
+
versionId: newAgentVersionId(args.workbookId, operationId),
|
|
747
|
+
versionSummary: version.summary,
|
|
748
|
+
// One Agent tool call is a deliberate Version, not session churn (§4.2).
|
|
749
|
+
versionKind: 'named' as const,
|
|
750
|
+
versionIntent: version.intent,
|
|
751
|
+
agentRunId: args.agentRunId,
|
|
752
|
+
lineage: args.lineage,
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
// dryRun shares this exact call: the same opId checks, payload resolution,
|
|
756
|
+
// `applyWorkbookOperation` validation and revision accounting run before the
|
|
757
|
+
// manager decides whether to persist.
|
|
758
|
+
const plan = await mutateWorkbookPlan({
|
|
759
|
+
workbookId: args.workbookId,
|
|
760
|
+
actor: 'agent',
|
|
761
|
+
steps,
|
|
762
|
+
expectedRevision: args.baseRevision,
|
|
763
|
+
...(args.dryRun ? { dryRun: true } : {}),
|
|
764
|
+
})
|
|
765
|
+
const committedVersions = plan.operations
|
|
766
|
+
const revisionAfter = plan.meta.revision ?? currentRevision
|
|
767
|
+
const last = committedVersions.at(-1)
|
|
768
|
+
const affected = summarizeAffectedSheets(versions, plan.meta.snapshot)
|
|
769
|
+
// A replayed plan wrote nothing this turn, so no receipt may be fabricated:
|
|
770
|
+
// the file on disk may have changed since the original write and the sheet
|
|
771
|
+
// still holds the earlier content. `replayed: true` tells the caller to read
|
|
772
|
+
// the workbook instead of trusting a proof from this turn.
|
|
773
|
+
const imports = plan.replayed === true ? [] : importSteps.map(step => importFileReceipt(step, {
|
|
774
|
+
workbookId: args.workbookId,
|
|
775
|
+
revisionBefore: currentRevision,
|
|
776
|
+
revisionAfter,
|
|
777
|
+
snapshotBefore: meta.snapshot,
|
|
778
|
+
snapshotAfter: plan.meta.snapshot,
|
|
779
|
+
}))
|
|
780
|
+
for (const receipt of imports) for (const warning of receipt.warnings) warnings.push(warning)
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
...(args.dryRun ? { dryRun: true } : {}),
|
|
784
|
+
workbookId: args.workbookId,
|
|
785
|
+
revision: args.dryRun ? currentRevision : revisionAfter,
|
|
786
|
+
revisionBefore: currentRevision,
|
|
787
|
+
revisionAfter,
|
|
788
|
+
versionCount: committedVersions.length,
|
|
789
|
+
commandCount: commands.length,
|
|
790
|
+
affectedSheets: affected.sheets,
|
|
791
|
+
sheetRefs: affected.refs,
|
|
792
|
+
affectedCellCount: affected.cellCount,
|
|
793
|
+
...(imports.length === 0 ? {} : { imports }),
|
|
794
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
795
|
+
...(plan.replayed === true ? { replayed: true } : {}),
|
|
796
|
+
...(plan.transformed === true
|
|
797
|
+
? { transformed: true, rebasedOperations: plan.rebasedOperations ?? [] }
|
|
798
|
+
: {}),
|
|
799
|
+
...(args.intent === undefined ? {} : { intent: args.intent }),
|
|
800
|
+
...(args.agentRunId === undefined ? {} : { agentRunId: args.agentRunId }),
|
|
801
|
+
...(args.lineage === undefined ? {} : { lineage: args.lineage }),
|
|
802
|
+
versions: committedVersions.map((operation, index) => ({
|
|
803
|
+
...operationReceipt(operation),
|
|
804
|
+
summary: versions[index]?.summary ?? operation.versionSummary ?? '',
|
|
805
|
+
...(versions[index]?.intent === undefined ? {} : { intent: versions[index]?.intent }),
|
|
806
|
+
stepCount: versions[index]?.commands.length ?? workbookOperationSteps(operation).length,
|
|
807
|
+
commands: (versions[index]?.commands ?? []).map(command => ({ type: command.type, sheet: command.payload.sheetId ?? command.payload.sheetName ?? null })),
|
|
808
|
+
})),
|
|
809
|
+
...(last === undefined ? {} : { operation: operationReceipt(last) }),
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* One `import_file` step after binding (its sheet reference is resolved). The
|
|
815
|
+
* command itself is already normalized to a durable `update_cells` payload.
|
|
816
|
+
*/
|
|
817
|
+
export interface ImportFileStep {
|
|
818
|
+
type: WorkbookOperationType
|
|
819
|
+
payload: WorkbookOperationPayload
|
|
820
|
+
importFile: ImportFileMeta
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function collectImportFileSteps(versions: readonly ParsedEditVersion[]): ImportFileStep[] {
|
|
824
|
+
const steps: ImportFileStep[] = []
|
|
825
|
+
for (const version of versions) {
|
|
826
|
+
for (const command of version.commands) {
|
|
827
|
+
if (command.importFile === undefined) continue
|
|
828
|
+
steps.push({ type: command.type, payload: command.payload, importFile: command.importFile })
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return steps
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** One Agent-facing import receipt for a single `import_file` command. */
|
|
835
|
+
export interface ImportFileReceipt {
|
|
836
|
+
workbookId: string
|
|
837
|
+
revisionBefore: number
|
|
838
|
+
revisionAfter: number
|
|
839
|
+
sheetId: string | null
|
|
840
|
+
range: WorkbookRange
|
|
841
|
+
rowsWritten: number
|
|
842
|
+
cellsWritten: number
|
|
843
|
+
sourceRowCount: number
|
|
844
|
+
truncated: boolean
|
|
845
|
+
sourcePath: string
|
|
846
|
+
rowCountBefore: number | null
|
|
847
|
+
rowCountAfter: number | null
|
|
848
|
+
capacityExpanded: boolean
|
|
849
|
+
firstRow: unknown[]
|
|
850
|
+
lastRow: unknown[]
|
|
851
|
+
valuesHash: string
|
|
852
|
+
warnings: string[]
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Self-verifying import receipt: range/row/cell counts, source size, a content
|
|
857
|
+
* digest plus first/last row samples, and the auto-expansion result so the
|
|
858
|
+
* Agent never has to reread the whole range to prove the write landed.
|
|
859
|
+
*/
|
|
860
|
+
export function importFileReceipt(step: ImportFileStep, context: {
|
|
861
|
+
workbookId: string
|
|
862
|
+
revisionBefore: number
|
|
863
|
+
revisionAfter: number
|
|
864
|
+
snapshotBefore: unknown
|
|
865
|
+
snapshotAfter: unknown
|
|
866
|
+
}): ImportFileReceipt {
|
|
867
|
+
const meta = step.importFile
|
|
868
|
+
const sheetId = step.payload.sheetId ?? null
|
|
869
|
+
const rowCountBefore = snapshotRowCapacity(context.snapshotBefore, sheetId)
|
|
870
|
+
const rowCountAfter = snapshotRowCapacity(context.snapshotAfter, sheetId)
|
|
871
|
+
const capacityExpanded = rowCountBefore !== null && rowCountAfter !== null && rowCountAfter > rowCountBefore
|
|
872
|
+
const receiptWarnings: string[] = []
|
|
873
|
+
if (capacityExpanded && meta.verifyCapacity) {
|
|
874
|
+
receiptWarnings.push(`import_file 自动扩容:${sheetId ?? '(Host 推断的 sheet)'} rowCount ${rowCountBefore} → ${rowCountAfter}(写入到第 ${meta.range.endRow + 1} 行),无需 insert_rows`)
|
|
875
|
+
}
|
|
876
|
+
if (meta.truncated) {
|
|
877
|
+
receiptWarnings.push(`import_file 按 allowTruncate 只导入了源文件 ${meta.sourceRowCount} 行中的前 ${meta.rowsWritten} 行,其余未写入`)
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
workbookId: context.workbookId,
|
|
881
|
+
revisionBefore: context.revisionBefore,
|
|
882
|
+
revisionAfter: context.revisionAfter,
|
|
883
|
+
sheetId,
|
|
884
|
+
range: meta.range,
|
|
885
|
+
rowsWritten: meta.rowsWritten,
|
|
886
|
+
cellsWritten: meta.cellsWritten,
|
|
887
|
+
sourceRowCount: meta.sourceRowCount,
|
|
888
|
+
truncated: meta.truncated,
|
|
889
|
+
sourcePath: meta.sourcePath,
|
|
890
|
+
rowCountBefore,
|
|
891
|
+
rowCountAfter,
|
|
892
|
+
capacityExpanded,
|
|
893
|
+
firstRow: meta.firstRow,
|
|
894
|
+
lastRow: meta.lastRow,
|
|
895
|
+
valuesHash: meta.valuesHash,
|
|
896
|
+
warnings: receiptWarnings,
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Physical row capacity (`rowCount`) of one sheet in a snapshot. */
|
|
901
|
+
export function snapshotRowCapacity(snapshot: unknown, sheetRef: string | null): number | null {
|
|
902
|
+
if (sheetRef === null) return null
|
|
903
|
+
try {
|
|
904
|
+
const sheet = snapshotSheet(snapshot, sheetRef)
|
|
905
|
+
return typeof sheet.rowCount === 'number' ? sheet.rowCount : null
|
|
906
|
+
} catch {
|
|
907
|
+
return null
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Receipt data that lets an Agent prove what a write touched without rereading storage. */
|
|
912
|
+
export function summarizeAffectedSheets(versions: readonly ParsedEditVersion[], snapshot: unknown): { sheets: string[]; refs: Array<{ name: string; sheetId: string }>; cellCount: number } {
|
|
913
|
+
const names = new Map(sheetOverviews(snapshot).map(sheet => [sheet.id, sheet.name]))
|
|
914
|
+
const labels = new Set<string>()
|
|
915
|
+
const refs = new Map<string, { name: string; sheetId: string }>()
|
|
916
|
+
let cellCount = 0
|
|
917
|
+
for (const version of versions) {
|
|
918
|
+
for (const command of version.commands) {
|
|
919
|
+
const sheetId = command.payload.sheetId ?? command.payload.sheetName
|
|
920
|
+
const label = typeof sheetId === 'string' && sheetId.length > 0
|
|
921
|
+
? `${names.get(sheetId) ?? sheetId}(${sheetId})`
|
|
922
|
+
: '(由 Host 推断)'
|
|
923
|
+
labels.add(label)
|
|
924
|
+
// The readable mapping is also published as data: sheet ids are hashes,
|
|
925
|
+
// and a caller that just created a sheet needs the id without parsing the
|
|
926
|
+
// "name(id)" label back out (docs/issue/0011 I6 / T7).
|
|
927
|
+
if (typeof sheetId === 'string' && sheetId.length > 0) {
|
|
928
|
+
refs.set(sheetId, { name: names.get(sheetId) ?? sheetId, sheetId })
|
|
929
|
+
}
|
|
930
|
+
const payload = command.payload
|
|
931
|
+
if (Array.isArray(payload.cells)) cellCount += payload.cells.length
|
|
932
|
+
else if (Array.isArray(payload.rows)) cellCount += payload.rows.length * (Array.isArray(payload.columns) ? payload.columns.length : 0)
|
|
933
|
+
else if (payload.range !== undefined) {
|
|
934
|
+
cellCount += (payload.range.endRow - payload.range.startRow + 1) * (payload.range.endColumn - payload.range.startColumn + 1)
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return { sheets: [...labels], refs: [...refs.values()], cellCount }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export function newAgentVersionId(workbookId: string, opId?: string): string {
|
|
942
|
+
const suffix = opId === undefined
|
|
943
|
+
? randomUUID().slice(0, 12)
|
|
944
|
+
: createHash('sha256').update(opId).digest('hex').slice(0, 16)
|
|
945
|
+
return `agent-version:${workbookId}:${suffix}`
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export function parseStyle(value: unknown): NonNullable<WorkbookOperationPayload['style']> {
|
|
949
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('style 必须是对象')
|
|
950
|
+
const raw = value as Record<string, unknown>
|
|
951
|
+
const style: NonNullable<WorkbookOperationPayload['style']> = {}
|
|
952
|
+
for (const key of ['ff', 'bg', 'cl', 'n'] as const) {
|
|
953
|
+
if (raw[key] !== undefined) {
|
|
954
|
+
if (typeof raw[key] !== 'string') throw new Error(`style.${key} 必须是字符串`)
|
|
955
|
+
style[key] = raw[key]
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
for (const key of ['fs', 'ht', 'vt', 'tr'] as const) {
|
|
959
|
+
if (raw[key] !== undefined) {
|
|
960
|
+
if (typeof raw[key] !== 'number' || !Number.isFinite(raw[key])) throw new Error(`style.${key} 必须是有限数字`)
|
|
961
|
+
style[key] = raw[key]
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
for (const key of ['bl', 'it', 'ul', 'st'] as const) {
|
|
965
|
+
if (raw[key] !== undefined) {
|
|
966
|
+
if (typeof raw[key] !== 'boolean') throw new Error(`style.${key} 必须是布尔值`)
|
|
967
|
+
style[key] = raw[key]
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return style
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
export function parseSheetMeta(value: unknown): { source?: string; query?: string; generatedAt?: string; notes?: string } | undefined {
|
|
976
|
+
if (value === undefined || value === null) return undefined
|
|
977
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error('meta 必须是对象')
|
|
978
|
+
const raw = value as Record<string, unknown>
|
|
979
|
+
const meta: { source?: string; query?: string; generatedAt?: string; notes?: string } = {}
|
|
980
|
+
for (const key of ['source', 'query', 'generatedAt', 'notes'] as const) {
|
|
981
|
+
if (raw[key] !== undefined) {
|
|
982
|
+
if (typeof raw[key] !== 'string') throw new Error(`meta.${key} 必须是字符串`)
|
|
983
|
+
meta[key] = raw[key]
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return meta
|
|
987
|
+
}
|