@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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only module that replaces a workbook's bytes (docs/issue/0022 P1).
|
|
3
|
+
*
|
|
4
|
+
* Every commit is a write-ahead journal plus temp-file renames, so an
|
|
5
|
+
* interrupted save is finished by the next read rather than leaving half a
|
|
6
|
+
* workbook on disk. It takes the journal lock from `store/read.ts` so a commit
|
|
7
|
+
* can never interleave with the recovery of another.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import { DEFAULT_WORKBOOK, type WorkbookMeta, type WorkbookWorktree } from '../types.ts'
|
|
12
|
+
import { normalizeWorkbookId, normalizeWorktreeId } from '../contract/ids.ts'
|
|
13
|
+
import { PROPOSAL_LOG_LIMIT, operationSummary } from '../contract/operations.ts'
|
|
14
|
+
import { validateSnapshotBounds } from '../contract/mutations.ts'
|
|
15
|
+
import { cloneSnapshot, normalizeSnapshotName } from '../contract/snapshots.ts'
|
|
16
|
+
import {
|
|
17
|
+
workbookCheckpointsPath,
|
|
18
|
+
workbookIndexPath,
|
|
19
|
+
workbookOperationsPath,
|
|
20
|
+
workbookPath,
|
|
21
|
+
workbookProposalsPath,
|
|
22
|
+
workbookRoot,
|
|
23
|
+
workbookSnapshotPath,
|
|
24
|
+
workbookTransactionPath,
|
|
25
|
+
workbookWorktreeDirectory,
|
|
26
|
+
workbookWorktreePath,
|
|
27
|
+
} from './paths.ts'
|
|
28
|
+
import {
|
|
29
|
+
fileExists,
|
|
30
|
+
readWorkbook,
|
|
31
|
+
recoverWorkbookTransactionNow,
|
|
32
|
+
summarize,
|
|
33
|
+
withWorkbookTransactionQueue,
|
|
34
|
+
workbookCache,
|
|
35
|
+
type StoredWorkbookTransaction,
|
|
36
|
+
type WorkbookIndex,
|
|
37
|
+
type WorkbookTransactionFile,
|
|
38
|
+
} from './read.ts'
|
|
39
|
+
|
|
40
|
+
/** Write one workbook meta atomically (creates the repo root on demand). */
|
|
41
|
+
export async function writeWorkbook(meta: WorkbookMeta, options: { persistSnapshot?: boolean; persistCheckpoints?: boolean } = {}): Promise<void> {
|
|
42
|
+
const normalizedId = normalizeWorkbookId(meta.id)
|
|
43
|
+
await withWorkbookTransactionQueue(normalizedId, async () => {
|
|
44
|
+
await mkdir(workbookRoot(), { recursive: true })
|
|
45
|
+
await recoverWorkbookTransactionNow(normalizedId)
|
|
46
|
+
// The DSH metadata name is the user-facing workbook name. Keep the Univer
|
|
47
|
+
// snapshot name aligned so exports and other consumers use the same name.
|
|
48
|
+
const normalizedMeta = { ...meta, id: normalizedId, snapshot: normalizeSnapshotName(meta.snapshot, meta.name) }
|
|
49
|
+
validateSnapshotBounds(normalizedMeta.snapshot)
|
|
50
|
+
const operationLog = normalizedMeta.operations ?? []
|
|
51
|
+
const proposalLog = normalizedMeta.proposals ?? []
|
|
52
|
+
// Versions are the durable audit trail. Keep the append-only operation log
|
|
53
|
+
// instead of truncating it to a recent tail so old versions can be replayed.
|
|
54
|
+
normalizedMeta.operations = operationLog
|
|
55
|
+
// The single place this bound is applied, since every write comes through here.
|
|
56
|
+
normalizedMeta.proposals = proposalLog.slice(-PROPOSAL_LOG_LIMIT)
|
|
57
|
+
const snapshotExists = await fileExists(workbookSnapshotPath(normalizedId))
|
|
58
|
+
const persistSnapshot = options.persistSnapshot === true || !snapshotExists
|
|
59
|
+
const checkpointsExist = await fileExists(workbookCheckpointsPath(normalizedId))
|
|
60
|
+
const snapshotRevision = persistSnapshot
|
|
61
|
+
? normalizedMeta.revision ?? 0
|
|
62
|
+
: (typeof normalizedMeta.snapshotRevision === 'number' ? normalizedMeta.snapshotRevision : 0)
|
|
63
|
+
const {
|
|
64
|
+
snapshot: _snapshot,
|
|
65
|
+
checkpoints: _checkpoints,
|
|
66
|
+
operations: _operations,
|
|
67
|
+
proposals: _proposals,
|
|
68
|
+
...metadata
|
|
69
|
+
} = normalizedMeta
|
|
70
|
+
const metadataWithSnapshotRevision = { ...metadata, snapshotRevision }
|
|
71
|
+
const files: WorkbookTransactionFile[] = [
|
|
72
|
+
{ target: workbookOperationsPath(normalizedId), value: normalizedMeta.operations ?? [] },
|
|
73
|
+
{ target: workbookProposalsPath(normalizedId), value: normalizedMeta.proposals ?? [] },
|
|
74
|
+
{ target: workbookPath(normalizedId), value: metadataWithSnapshotRevision },
|
|
75
|
+
{ target: workbookIndexPath(normalizedId), value: buildWorkbookIndex(normalizedMeta) },
|
|
76
|
+
]
|
|
77
|
+
if (persistSnapshot) files.unshift({ target: workbookSnapshotPath(normalizedId), value: { revision: normalizedMeta.revision ?? 0, snapshot: normalizedMeta.snapshot } })
|
|
78
|
+
if (options.persistCheckpoints === true || !checkpointsExist) files.splice(persistSnapshot ? 1 : 0, 0, { target: workbookCheckpointsPath(normalizedId), value: { checkpoints: normalizedMeta.checkpoints ?? [] } })
|
|
79
|
+
await commitWorkbookTransaction(normalizedId, files, normalizedMeta.revision ?? 0)
|
|
80
|
+
workbookCache.set(normalizedId, {
|
|
81
|
+
meta: { ...normalizedMeta, snapshotRevision, checkpoints: normalizedMeta.checkpoints ?? [] },
|
|
82
|
+
checkpointsLoaded: options.persistCheckpoints === true || !checkpointsExist,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function writeJsonAtomically(path: string, value: unknown): Promise<void> {
|
|
88
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(tempPath, JSON.stringify(value, null, 2), 'utf8')
|
|
91
|
+
await rename(tempPath, path)
|
|
92
|
+
} finally {
|
|
93
|
+
try { await unlink(tempPath) } catch { /* rename already removed it */ }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function commitWorkbookTransaction(id: string, files: WorkbookTransactionFile[], revision: number): Promise<void> {
|
|
98
|
+
const transactionId = randomUUID()
|
|
99
|
+
const journalPath = workbookTransactionPath(id)
|
|
100
|
+
const prepared = files.map(file => ({ target: file.target, temp: `${file.target}.${transactionId}.tmp` }))
|
|
101
|
+
let journalWritten = false
|
|
102
|
+
try {
|
|
103
|
+
for (const [index, file] of files.entries()) {
|
|
104
|
+
await writeFile(prepared[index]!.temp, JSON.stringify(file.value, null, 2), 'utf8')
|
|
105
|
+
}
|
|
106
|
+
await writeJsonAtomically(journalPath, { revision, files: prepared } satisfies StoredWorkbookTransaction)
|
|
107
|
+
journalWritten = true
|
|
108
|
+
for (const file of prepared) await rename(file.temp, file.target)
|
|
109
|
+
await unlink(journalPath)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Once the journal exists, its prepared files are intentionally retained
|
|
112
|
+
// so the next read can complete the commit. Before that point cleanup is
|
|
113
|
+
// safe because no target has been replaced.
|
|
114
|
+
if (!journalWritten) {
|
|
115
|
+
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
|
116
|
+
}
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeWorktree(worktree: WorkbookWorktree): Promise<void> {
|
|
122
|
+
await mkdir(workbookWorktreeDirectory(worktree.workbookId), { recursive: true })
|
|
123
|
+
await writeJsonAtomically(workbookWorktreePath(worktree.workbookId, worktree.id), worktree)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function buildWorkbookIndex(meta: WorkbookMeta): WorkbookIndex {
|
|
127
|
+
const summary = summarize(meta)
|
|
128
|
+
return {
|
|
129
|
+
id: summary.id,
|
|
130
|
+
name: summary.name,
|
|
131
|
+
sheetOrder: summary.sheets.map(sheet => sheet.id),
|
|
132
|
+
sheets: summary.sheets,
|
|
133
|
+
updatedAt: summary.updatedAt,
|
|
134
|
+
revision: meta.revision ?? 0,
|
|
135
|
+
operations: (meta.operations ?? []).map(operation => ({
|
|
136
|
+
opId: operation.opId,
|
|
137
|
+
workbookId: operation.workbookId,
|
|
138
|
+
revision: operation.revision,
|
|
139
|
+
actor: operation.actor,
|
|
140
|
+
type: operation.type,
|
|
141
|
+
timestamp: operation.timestamp,
|
|
142
|
+
summary: operationSummary(operation),
|
|
143
|
+
})),
|
|
144
|
+
proposals: (meta.proposals ?? []).map(({ snapshot: _snapshot, ...proposal }) => proposal),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a workbook and all of its Datasheet sidecars. `main` is the stable
|
|
150
|
+
* default workbook and is intentionally protected from accidental deletion.
|
|
151
|
+
* The queue makes deletion serialize with an in-flight save for the same id.
|
|
152
|
+
*/
|
|
153
|
+
export async function deleteWorkbook(id: string): Promise<void> {
|
|
154
|
+
const normalizedId = normalizeWorkbookId(id)
|
|
155
|
+
if (normalizedId === DEFAULT_WORKBOOK) throw new Error('默认工作簿 main 不能删除')
|
|
156
|
+
await withWorkbookQueue(normalizedId, async () => {
|
|
157
|
+
workbookCache.delete(normalizedId)
|
|
158
|
+
const paths = [
|
|
159
|
+
workbookPath(normalizedId),
|
|
160
|
+
workbookIndexPath(normalizedId),
|
|
161
|
+
workbookSnapshotPath(normalizedId),
|
|
162
|
+
workbookCheckpointsPath(normalizedId),
|
|
163
|
+
workbookOperationsPath(normalizedId),
|
|
164
|
+
workbookProposalsPath(normalizedId),
|
|
165
|
+
workbookTransactionPath(normalizedId),
|
|
166
|
+
]
|
|
167
|
+
for (const path of paths) {
|
|
168
|
+
try { await unlink(path) } catch (error) {
|
|
169
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await rm(workbookWorktreeDirectory(normalizedId), { recursive: true, force: true })
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Rename the user-facing workbook label without changing its stable id or
|
|
177
|
+
* history. The snapshot name is kept in sync by `writeWorkbook`. */
|
|
178
|
+
export async function renameWorkbook(id: string, name: string): Promise<WorkbookMeta> {
|
|
179
|
+
const normalizedId = normalizeWorkbookId(id)
|
|
180
|
+
const normalizedName = name.trim()
|
|
181
|
+
if (normalizedName.length === 0) throw new Error('工作簿名称不能为空')
|
|
182
|
+
return withWorkbookQueue(normalizedId, async () => {
|
|
183
|
+
const existing = await readWorkbook(normalizedId)
|
|
184
|
+
if (existing === null) throw new Error(`工作簿不存在:${id}`)
|
|
185
|
+
const meta = cloneSnapshot(existing) as WorkbookMeta
|
|
186
|
+
meta.name = normalizedName
|
|
187
|
+
meta.updatedAt = new Date().toISOString()
|
|
188
|
+
await writeWorkbook(meta)
|
|
189
|
+
return meta
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Serialize mutations (and worktree writes) per id.
|
|
194
|
+
* `deleteWorkbook` and `renameWorkbook` take the same lock as `mutateWorkbook`,
|
|
195
|
+
* so a delete can never interleave with an in-flight save. The manager imports
|
|
196
|
+
* them from here rather than owning them, because the store must not depend on
|
|
197
|
+
* its callers. */
|
|
198
|
+
|
|
199
|
+
const mutationQueues = new Map<string, Promise<void>>()
|
|
200
|
+
|
|
201
|
+
const worktreeQueues = new Map<string, Promise<void>>()
|
|
202
|
+
|
|
203
|
+
export async function withWorkbookQueue<T>(id: string, task: () => Promise<T>): Promise<T> {
|
|
204
|
+
const previous = mutationQueues.get(id) ?? Promise.resolve()
|
|
205
|
+
let release!: () => void
|
|
206
|
+
const current = new Promise<void>(resolveRelease => { release = resolveRelease })
|
|
207
|
+
mutationQueues.set(id, current)
|
|
208
|
+
await previous
|
|
209
|
+
try {
|
|
210
|
+
return await task()
|
|
211
|
+
} finally {
|
|
212
|
+
release()
|
|
213
|
+
if (mutationQueues.get(id) === current) mutationQueues.delete(id)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function withWorktreeQueue<T>(workbookId: string, worktreeId: string, task: () => Promise<T>): Promise<T> {
|
|
218
|
+
const key = `${normalizeWorkbookId(workbookId)}:${normalizeWorktreeId(worktreeId)}`
|
|
219
|
+
const previous = worktreeQueues.get(key) ?? Promise.resolve()
|
|
220
|
+
let release!: () => void
|
|
221
|
+
const current = new Promise<void>(resolveRelease => { release = resolveRelease })
|
|
222
|
+
worktreeQueues.set(key, current)
|
|
223
|
+
await previous
|
|
224
|
+
try {
|
|
225
|
+
return await task()
|
|
226
|
+
} finally {
|
|
227
|
+
release()
|
|
228
|
+
if (worktreeQueues.get(key) === current) worktreeQueues.delete(key)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading tool arguments (docs/issue/0022 P3b).
|
|
3
|
+
*
|
|
4
|
+
* The Agent hands a tool an untyped record; every field has to be validated
|
|
5
|
+
* before it can become part of an operation. These are the conversions several
|
|
6
|
+
* tools share — the ones only one tool needs live in that tool.
|
|
7
|
+
*/
|
|
8
|
+
import { SHEET_MAX_ROWS, type WorkbookCellDeleteShift } from '../types.ts'
|
|
9
|
+
import { normalizeWorkbookId } from '../contract/ids.ts'
|
|
10
|
+
import { parseIndex } from '../request-params.ts'
|
|
11
|
+
|
|
12
|
+
export type ToolArguments = Record<string, unknown>
|
|
13
|
+
|
|
14
|
+
export function asToolArguments(value: unknown, label = '参数'): ToolArguments {
|
|
15
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(label + '必须是对象')
|
|
16
|
+
return value as ToolArguments
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate and normalize a workbook id argument. There is no implicit
|
|
20
|
+
* `main`: every Agent call must identify the workbook it intends to change. */
|
|
21
|
+
export function workbookIdOf(raw: unknown): string {
|
|
22
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) {
|
|
23
|
+
throw new Error('必须明确指定 workbookId;请先调用 workbook_catalog(action=list) 获取工作簿 id')
|
|
24
|
+
}
|
|
25
|
+
return normalizeWorkbookId(raw)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** ISO timestamp helper. */
|
|
29
|
+
export function now(): string {
|
|
30
|
+
return new Date().toISOString()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function optionalIntent(value: unknown, name = 'intent'): string | undefined {
|
|
34
|
+
if (value === undefined || value === null) return undefined
|
|
35
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.trim().length > 280) throw new Error(`${name} 必须是 1 到 280 字符的字符串`)
|
|
36
|
+
return value.trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function optionalAuditText(value: unknown, name: string, maxLength: number): string | undefined {
|
|
40
|
+
if (value === undefined || value === null) return undefined
|
|
41
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.trim().length > maxLength) throw new Error(`${name} 必须是 1 到 ${maxLength} 字符的字符串`)
|
|
42
|
+
return value.trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function optionalString(value: unknown): string | undefined {
|
|
46
|
+
if (value === undefined || value === null) return undefined
|
|
47
|
+
if (typeof value !== 'string' || value.trim().length === 0) throw new Error('opId 必须是非空字符串')
|
|
48
|
+
return value.trim()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseCount(value: unknown): number {
|
|
52
|
+
const count = parseIndex(value, 'count')
|
|
53
|
+
if (count < 1 || count > SHEET_MAX_ROWS) throw new Error(`count 必须是 1 到 ${SHEET_MAX_ROWS} 之间的整数`)
|
|
54
|
+
return count
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `verifyHeader` defaults to true because comparing header labels is the only
|
|
59
|
+
* guard that catches a column order or rename mistake before rows are written.
|
|
60
|
+
* Only an explicit `false` relaxes it.
|
|
61
|
+
*/
|
|
62
|
+
export function parseHeaderVerification(value: unknown, label: string): boolean {
|
|
63
|
+
if (value === undefined || value === null) return true
|
|
64
|
+
if (typeof value !== 'boolean') throw new Error(`${label} 必须是布尔值`)
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function parseCellDeleteShift(value: unknown): WorkbookCellDeleteShift {
|
|
69
|
+
if (value === undefined || value === null || value === '') return 'clear'
|
|
70
|
+
if (value === 'clear' || value === 'left' || value === 'up') return value
|
|
71
|
+
throw new Error('shift 必须是 clear、left 或 up')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function commandPayload(raw: ToolArguments): ToolArguments {
|
|
75
|
+
const nested = raw.payload
|
|
76
|
+
if (nested === undefined || nested === null) return raw
|
|
77
|
+
if (typeof nested !== 'object' || Array.isArray(nested)) throw new Error('command.payload 必须是对象')
|
|
78
|
+
// Permit callers to send the durable { type, payload } shape while keeping
|
|
79
|
+
// the documented flat command shape. Explicit top-level fields win.
|
|
80
|
+
return { ...(nested as ToolArguments), ...raw }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Accept the vocabulary used by both the public Agent tool and the Univer
|
|
85
|
+
* facade. Older callers use `sheet`, while generated calls often use the
|
|
86
|
+
* persisted field name `sheetId` or the human-facing `sheetName`.
|
|
87
|
+
*/
|
|
88
|
+
export function sheetRefFromArguments(raw: ToolArguments, label = 'sheet'): string | undefined {
|
|
89
|
+
const candidates = ['sheet', 'sheetId', 'sheetName', 'sheet_id', 'sheet_name']
|
|
90
|
+
.filter(key => raw[key] !== undefined && raw[key] !== null && raw[key] !== '')
|
|
91
|
+
if (candidates.length === 0) return undefined
|
|
92
|
+
const refs = candidates.map(key => parseNonEmptyString(raw[key], `${label}.${key}`))
|
|
93
|
+
const first = refs[0] as string
|
|
94
|
+
if (refs.some(ref => ref !== first)) throw new Error(`${label}、sheetId、sheetName 只能指定同一个 sheet`)
|
|
95
|
+
return first
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve an optional Agent sheet reference. A missing reference is safe only
|
|
99
|
+
* when the workbook has one sheet; otherwise returning the first sheet would
|
|
100
|
+
* make a write silently target the wrong tab. */
|
|
101
|
+
export function sheetRefForSnapshot(snapshot: unknown, raw: ToolArguments, label: string): string {
|
|
102
|
+
const explicit = sheetRefFromArguments(raw, label)
|
|
103
|
+
if (explicit !== undefined) return explicit
|
|
104
|
+
const root = asToolArguments(snapshot, 'workbook snapshot')
|
|
105
|
+
const order = Array.isArray(root.sheetOrder) ? root.sheetOrder.filter(item => typeof item === 'string') as string[] : []
|
|
106
|
+
if (order.length === 1 && order[0] !== undefined) return order[0]
|
|
107
|
+
const sheets = typeof root.sheets === 'object' && root.sheets !== null && !Array.isArray(root.sheets)
|
|
108
|
+
? Object.entries(root.sheets as Record<string, unknown>).map(([id, value]) => {
|
|
109
|
+
const sheet = value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
110
|
+
return `${id}${typeof sheet.name === 'string' ? `(${sheet.name})` : ''}`
|
|
111
|
+
})
|
|
112
|
+
: []
|
|
113
|
+
throw new Error(`${label} 缺少 sheet;当前工作簿有多个 sheet,请使用 sheetId 或 sheetName:${sheets.join('、')}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function commandSheet(raw: ToolArguments, index: number, _optional = false): string | undefined {
|
|
117
|
+
return sheetRefFromArguments(raw, 'commands[' + index + '].sheet')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function commandIndex(raw: ToolArguments, index: number, field: 'index' | 'startRow' | 'startColumn'): number {
|
|
121
|
+
const value = raw[field] ?? (field === 'startRow' ? raw.index : field === 'startColumn' ? raw.index : undefined)
|
|
122
|
+
return parseIndex(value, 'commands[' + index + '].' + field)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseNonEmptyString(value: unknown, label: string): string {
|
|
126
|
+
if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`${label} 必须是非空字符串`)
|
|
127
|
+
return value.trim()
|
|
128
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload vocabulary shared by the two tools that write to a workbook
|
|
3
|
+
* (docs/issue/0022 P3b).
|
|
4
|
+
*
|
|
5
|
+
* `workbook_edit` and `univer_api` both accept a value matrix plus a range, both
|
|
6
|
+
* accept cells whose value comes from a file, both declare a table's columns, and
|
|
7
|
+
* both size a row or column. That translation lives here rather than in either
|
|
8
|
+
* tool: a tool importing the other tool is exactly the coupling this phase
|
|
9
|
+
* exists to prevent, and every helper here is about building a payload, not about
|
|
10
|
+
* which endpoint received it.
|
|
11
|
+
*/
|
|
12
|
+
import { statSync } from 'node:fs'
|
|
13
|
+
import { readFile } from 'node:fs/promises'
|
|
14
|
+
import { resolve } from 'node:path'
|
|
15
|
+
import {
|
|
16
|
+
SHEET_MAX_COLUMNS,
|
|
17
|
+
SHEET_MAX_NAME,
|
|
18
|
+
SHEET_MAX_ROWS,
|
|
19
|
+
type SheetColumn,
|
|
20
|
+
type SheetColumnType,
|
|
21
|
+
type WorkbookCellUpdate,
|
|
22
|
+
type WorkbookOperationPayload,
|
|
23
|
+
type WorkbookRange,
|
|
24
|
+
} from '../types.ts'
|
|
25
|
+
import { parseA1Range, parseIndex, parseRange } from '../request-params.ts'
|
|
26
|
+
import { asToolArguments, parseNonEmptyString, type ToolArguments } from './arguments.ts'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/** Sheet column types a modelled table may declare. */
|
|
30
|
+
const COLUMN_TYPES: readonly SheetColumnType[] = ['string', 'number', 'boolean', 'date', 'currency']
|
|
31
|
+
|
|
32
|
+
export function parseCells(value: unknown): WorkbookCellUpdate[] {
|
|
33
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error('cells 必须是非空数组')
|
|
34
|
+
if (value.length > SHEET_MAX_ROWS * SHEET_MAX_COLUMNS) throw new Error('一次修改的单元格数量超过上限')
|
|
35
|
+
return value.map((item, index) => {
|
|
36
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) throw new Error(`第 ${index + 1} 个 cell 不是对象`)
|
|
37
|
+
const raw = item as Record<string, unknown>
|
|
38
|
+
const cellValue = raw.value
|
|
39
|
+
if (cellValue !== null && typeof cellValue !== 'string' && typeof cellValue !== 'number' && typeof cellValue !== 'boolean') {
|
|
40
|
+
throw new Error(`第 ${index + 1} 个 cell 的 value 必须是 string/number/boolean/null`)
|
|
41
|
+
}
|
|
42
|
+
if (typeof cellValue === 'number' && !Number.isFinite(cellValue)) throw new Error(`第 ${index + 1} 个 cell 的 value 不是有限数字`)
|
|
43
|
+
if (raw.formula !== undefined && typeof raw.formula !== 'string') throw new Error(`第 ${index + 1} 个 cell 的 formula 必须是字符串`)
|
|
44
|
+
return {
|
|
45
|
+
row: parseIndex(raw.row, `cells[${index}].row`),
|
|
46
|
+
column: parseIndex(raw.column, `cells[${index}].column`),
|
|
47
|
+
value: cellValue as WorkbookCellUpdate['value'],
|
|
48
|
+
...(raw.formula === undefined ? {} : { formula: raw.formula }),
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve `cells[].valueFromFile` into a literal value (docs/issue/0011 I4 / T7).
|
|
55
|
+
*
|
|
56
|
+
* Long text had to be inlined into the tool call, and the same SQL was retyped
|
|
57
|
+
* into every workbook that needed it. A cell may now name a local text file
|
|
58
|
+
* instead. The content is read here and handed to the ordinary `parseCells`
|
|
59
|
+
* validation, so nothing bypasses the existing cell limits.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveCellFileValues(value: unknown[], label: string): Promise<unknown[]> {
|
|
62
|
+
return Promise.all(value.map(async (item, index) => {
|
|
63
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) return item
|
|
64
|
+
const raw = item as Record<string, unknown>
|
|
65
|
+
if (raw.valueFromFile === undefined || raw.valueFromFile === null) return item
|
|
66
|
+
const path = resolve(parseNonEmptyString(raw.valueFromFile, `${label}.cells[${index}].valueFromFile`))
|
|
67
|
+
let info: { size: number; isFile(): boolean }
|
|
68
|
+
try { info = statSync(path) } catch { throw new Error(`${label}.cells[${index}].valueFromFile 文件不存在:${path}`) }
|
|
69
|
+
if (!info.isFile()) throw new Error(`${label}.cells[${index}].valueFromFile 不是文件:${path}`)
|
|
70
|
+
if (info.size > TEXT_CELL_MAX_BYTES) {
|
|
71
|
+
throw new Error(`${label}.cells[${index}].valueFromFile 文件过大(${info.size} 字节,上限 ${TEXT_CELL_MAX_BYTES})`)
|
|
72
|
+
}
|
|
73
|
+
const content = await readFile(path, 'utf8')
|
|
74
|
+
const { valueFromFile: _consumed, ...rest } = raw
|
|
75
|
+
// An explicit `value` still wins, so the field is a fallback rather than an
|
|
76
|
+
// override that could silently disagree with the caller.
|
|
77
|
+
return raw.value === undefined ? { ...rest, value: content } : rest
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the target range of a value matrix.
|
|
83
|
+
*
|
|
84
|
+
* The matrix size is unambiguous, so it is the authority: `range` only needs
|
|
85
|
+
* to anchor the write, and a `range` whose declared end disagrees with
|
|
86
|
+
* `values` is corrected and reported as a warning instead of failing a large
|
|
87
|
+
* batch. End coordinates are inclusive, which the tool description now states.
|
|
88
|
+
*/
|
|
89
|
+
export function parseValueRange(value: unknown, matrix: unknown[][], label: string, warnings: string[] = []): WorkbookRange {
|
|
90
|
+
const rowCount = matrix.length
|
|
91
|
+
const columnCount = matrix[0]?.length ?? 0
|
|
92
|
+
const derived = (startRow: number, startColumn: number): WorkbookRange =>
|
|
93
|
+
parseRange({ startRow, startColumn, endRow: startRow + rowCount - 1, endColumn: startColumn + columnCount - 1 })
|
|
94
|
+
if (value === undefined || value === null) return derived(1, 0)
|
|
95
|
+
|
|
96
|
+
let startRow: number
|
|
97
|
+
let startColumn: number
|
|
98
|
+
let declaredEndRow: number | undefined
|
|
99
|
+
let declaredEndColumn: number | undefined
|
|
100
|
+
if (typeof value === 'string') {
|
|
101
|
+
const parsed = parseA1Range(value)
|
|
102
|
+
startRow = parsed.startRow
|
|
103
|
+
startColumn = parsed.startColumn
|
|
104
|
+
declaredEndRow = parsed.endRow
|
|
105
|
+
declaredEndColumn = parsed.endColumn
|
|
106
|
+
} else {
|
|
107
|
+
const raw = asToolArguments(value, label + '.range')
|
|
108
|
+
startRow = raw.startRow === undefined ? 1 : parseIndex(raw.startRow, label + '.range.startRow')
|
|
109
|
+
startColumn = raw.startColumn === undefined ? 0 : parseIndex(raw.startColumn, label + '.range.startColumn')
|
|
110
|
+
declaredEndRow = raw.endRow === undefined ? undefined : parseIndex(raw.endRow, label + '.range.endRow')
|
|
111
|
+
declaredEndColumn = raw.endColumn === undefined ? undefined : parseIndex(raw.endColumn, label + '.range.endColumn')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const target = derived(startRow, startColumn)
|
|
115
|
+
if (declaredEndRow === undefined && declaredEndColumn === undefined) return target
|
|
116
|
+
if (declaredEndRow === target.endRow && declaredEndColumn === target.endColumn) return target
|
|
117
|
+
// Supplying only the range origin is enough. A declared end that disagrees
|
|
118
|
+
// is downgraded to a warning: the values matrix already fixes the extent.
|
|
119
|
+
const declared = { endRow: declaredEndRow ?? target.endRow, endColumn: declaredEndColumn ?? target.endColumn }
|
|
120
|
+
if (declared.endRow < target.startRow || declared.endColumn < target.startColumn) {
|
|
121
|
+
throw new Error(`${label}.range 结束位置不能小于开始位置:range 为 rows ${startRow}-${declared.endRow} × cols ${startColumn}-${declared.endColumn}`)
|
|
122
|
+
}
|
|
123
|
+
const declaredRows = declared.endRow - target.startRow + 1
|
|
124
|
+
const declaredColumns = declared.endColumn - target.startColumn + 1
|
|
125
|
+
warnings.push(
|
|
126
|
+
`${label}.range 与 values 尺寸不一致:range 为 ${declaredRows}×${declaredColumns}(rows ${startRow}-${declared.endRow} × cols ${startColumn}-${declared.endColumn}),values 为 ${rowCount}×${columnCount};已按 values 落位为 rows ${target.startRow}-${target.endRow} × cols ${target.startColumn}-${target.endColumn}(end 坐标 inclusive)`,
|
|
127
|
+
)
|
|
128
|
+
return target
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseApiValues(matrix: unknown[][], range: WorkbookRange): WorkbookCellUpdate[] {
|
|
132
|
+
const rowCount = range.endRow - range.startRow + 1
|
|
133
|
+
const columnCount = range.endColumn - range.startColumn + 1
|
|
134
|
+
if (matrix.length !== rowCount) throw new Error(`values 行数必须为 ${rowCount}`)
|
|
135
|
+
const cells: WorkbookCellUpdate[] = []
|
|
136
|
+
for (const [rowOffset, line] of matrix.entries()) {
|
|
137
|
+
if (line.length !== columnCount) throw new Error(`values[${rowOffset}] 列数必须为 ${columnCount}`)
|
|
138
|
+
for (const [columnOffset, cell] of line.entries()) {
|
|
139
|
+
if (cell !== null && typeof cell !== 'string' && typeof cell !== 'number' && typeof cell !== 'boolean') {
|
|
140
|
+
throw new Error(`values[${rowOffset}][${columnOffset}] 必须是 string/number/boolean/null`)
|
|
141
|
+
}
|
|
142
|
+
if (typeof cell === 'number' && !Number.isFinite(cell)) throw new Error('values 不能包含非有限数字')
|
|
143
|
+
cells.push({ row: range.startRow + rowOffset, column: range.startColumn + columnOffset, value: cell as WorkbookCellUpdate['value'] })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return cells
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A single cell of long text (typically a SQL body) read from a local file. */
|
|
150
|
+
export const TEXT_CELL_MAX_BYTES = 1024 * 1024
|
|
151
|
+
|
|
152
|
+
export function parseFreeze(value: unknown): NonNullable<WorkbookOperationPayload['freeze']> {
|
|
153
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('freeze 必须是对象')
|
|
154
|
+
const raw = value as Record<string, unknown>
|
|
155
|
+
const rows = raw.rows === undefined ? 0 : parseIndex(raw.rows, 'freeze.rows')
|
|
156
|
+
const columns = raw.columns === undefined ? 0 : parseIndex(raw.columns, 'freeze.columns')
|
|
157
|
+
return { rows, columns }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseSort(value: unknown): NonNullable<WorkbookOperationPayload['sort']> {
|
|
161
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('sort 必须是对象')
|
|
162
|
+
const raw = value as Record<string, unknown>
|
|
163
|
+
const direction = raw.direction === undefined ? 'asc' : raw.direction
|
|
164
|
+
if (direction !== 'asc' && direction !== 'desc') throw new Error('sort.direction 必须是 asc 或 desc')
|
|
165
|
+
return { column: parseIndex(raw.column, 'sort.column'), direction }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ValidatedSheet {
|
|
169
|
+
name?: string
|
|
170
|
+
columns: SheetColumn[]
|
|
171
|
+
rows: Array<Record<string, unknown>>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function cellValue(type: SheetColumnType, value: unknown): number | string | boolean {
|
|
175
|
+
switch (type) {
|
|
176
|
+
case 'number':
|
|
177
|
+
case 'currency': {
|
|
178
|
+
const numeric = typeof value === 'number' ? value : Number(value)
|
|
179
|
+
return Number.isFinite(numeric) ? numeric : String(value)
|
|
180
|
+
}
|
|
181
|
+
case 'boolean':
|
|
182
|
+
return typeof value === 'boolean' ? value : String(value) === 'true'
|
|
183
|
+
case 'date':
|
|
184
|
+
return value instanceof Date ? value.toISOString().slice(0, 10) : String(value)
|
|
185
|
+
default:
|
|
186
|
+
return String(value)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Validate one sheet argument; throws a model-facing message on bad input. */
|
|
191
|
+
export function validateSheet(args: { name: unknown; columns?: unknown; rows: unknown }): ValidatedSheet {
|
|
192
|
+
const name = args.name === undefined || args.name === null ? undefined : parseNonEmptyString(args.name, 'name')
|
|
193
|
+
if (name !== undefined && name.length > SHEET_MAX_NAME) throw new Error(`表格名称过长(最多 ${SHEET_MAX_NAME} 字符)`)
|
|
194
|
+
if (args.columns !== undefined && !Array.isArray(args.columns)) throw new Error('columns 必须是数组;已有 sheet 可省略,Host 会从表头推断')
|
|
195
|
+
const rawColumns = Array.isArray(args.columns) ? args.columns : []
|
|
196
|
+
if (rawColumns.length === 0 && !Array.isArray(args.rows)) throw new Error('至少需要一列')
|
|
197
|
+
if (rawColumns.length > SHEET_MAX_COLUMNS) throw new Error(`列数超过上限(最多 ${SHEET_MAX_COLUMNS} 列)`)
|
|
198
|
+
const keys = new Set<string>()
|
|
199
|
+
const columns: SheetColumn[] = rawColumns.map((column, index) => {
|
|
200
|
+
// Column-definition errors have to state the accepted structure and echo
|
|
201
|
+
// what arrived: "列定义必须是对象" alone cost a debugging round in the
|
|
202
|
+
// original session (docs/issue/0011 I7 / T6).
|
|
203
|
+
if (typeof column !== 'object' || column === null || Array.isArray(column)) {
|
|
204
|
+
throw new Error(`列定义必须是对象:columns[${index}] 期望 { key, label, type },实际收到 ${JSON.stringify(column) ?? String(column)}`)
|
|
205
|
+
}
|
|
206
|
+
const raw = column as Record<string, unknown>
|
|
207
|
+
if (typeof raw.key !== 'string' || !/^[A-Za-z0-9_]+$/.test(raw.key)) {
|
|
208
|
+
throw new Error(`列 key 必须是仅含字母、数字、下划线的非空字符串:columns[${index}] 期望 { key, label, type },实际收到 ${JSON.stringify(raw)}`)
|
|
209
|
+
}
|
|
210
|
+
if (keys.has(raw.key)) throw new Error(`列 key 重复:${raw.key}(columns[${index}])`)
|
|
211
|
+
keys.add(raw.key)
|
|
212
|
+
if (typeof raw.label !== 'string' || raw.label.length === 0) {
|
|
213
|
+
throw new Error(`列 ${raw.key} 缺少 label:期望 { key: ${JSON.stringify(raw.key)}, label: string, type: string },实际收到 ${JSON.stringify(raw)}`)
|
|
214
|
+
}
|
|
215
|
+
if (!COLUMN_TYPES.includes(raw.type as SheetColumnType)) {
|
|
216
|
+
throw new Error(`列 ${raw.key} 类型未知:${String(raw.type)};type 只能是 ${COLUMN_TYPES.join('、')}`)
|
|
217
|
+
}
|
|
218
|
+
return { key: raw.key, label: raw.label, type: raw.type as SheetColumnType }
|
|
219
|
+
})
|
|
220
|
+
if (!Array.isArray(args.rows)) throw new Error('rows 必须是数组')
|
|
221
|
+
if (args.rows.length > SHEET_MAX_ROWS) {
|
|
222
|
+
throw new Error(`行数超过上限(最多 ${SHEET_MAX_ROWS} 行);请使用分页查询或拆成多个 workbook`)
|
|
223
|
+
}
|
|
224
|
+
const rows = args.rows.every(row => Array.isArray(row))
|
|
225
|
+
? args.rows.map((row, index) => {
|
|
226
|
+
if (columns.length === 0) throw new Error('rows 使用二维数组时必须同时提供 columns')
|
|
227
|
+
const values = row as unknown[]
|
|
228
|
+
if (values.length > columns.length) throw new Error(`第 ${index + 1} 行列数超过 columns 定义`)
|
|
229
|
+
return Object.fromEntries(columns.map((column, columnIndex) => [column.key, values[columnIndex] ?? null]))
|
|
230
|
+
})
|
|
231
|
+
: args.rows.map((row, index) => {
|
|
232
|
+
if (typeof row !== 'object' || row === null || Array.isArray(row)) {
|
|
233
|
+
throw new Error(`第 ${index + 1} 行不是对象`)
|
|
234
|
+
}
|
|
235
|
+
return row as Record<string, unknown>
|
|
236
|
+
})
|
|
237
|
+
return { name, columns, rows }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function chunkRows(rows: Array<Record<string, unknown>>, size: number): Array<Array<Record<string, unknown>>> {
|
|
241
|
+
const chunks: Array<Array<Record<string, unknown>>> = []
|
|
242
|
+
for (let start = 0; start < rows.length; start += size) chunks.push(rows.slice(start, start + size))
|
|
243
|
+
return chunks
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function dimensionRange(index: number, dimension: 'row' | 'column'): WorkbookRange {
|
|
247
|
+
return dimension === 'column'
|
|
248
|
+
? { startRow: 0, startColumn: index, endRow: 0, endColumn: index }
|
|
249
|
+
: { startRow: index, startColumn: 0, endRow: index, endColumn: 0 }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Row/column structural commands accept either an explicit `range` (matching
|
|
254
|
+
* the documented command vocabulary) or the older `index`/`startRow` +
|
|
255
|
+
* `count` pair. Missing position arguments report the full accepted contract
|
|
256
|
+
* instead of the single `startRow` field the old implementation demanded.
|
|
257
|
+
*/
|
|
258
|
+
export function dimensionCommandRange(raw: ToolArguments, index: number, dimension: 'row' | 'column'): WorkbookRange {
|
|
259
|
+
const label = `commands[${index}]`
|
|
260
|
+
const positionField = dimension === 'row' ? 'startRow' : 'startColumn'
|
|
261
|
+
const endField = dimension === 'row' ? 'endRow' : 'endColumn'
|
|
262
|
+
const explicit = raw.range
|
|
263
|
+
if (typeof explicit === 'string') {
|
|
264
|
+
const parsed = parseA1Range(explicit)
|
|
265
|
+
return dimension === 'row'
|
|
266
|
+
? { startRow: parsed.startRow, startColumn: 0, endRow: parsed.endRow, endColumn: 0 }
|
|
267
|
+
: { startRow: 0, startColumn: parsed.startColumn, endRow: 0, endColumn: parsed.endColumn }
|
|
268
|
+
}
|
|
269
|
+
const range = explicit === undefined || explicit === null ? undefined : asToolArguments(explicit, `${label}.range`)
|
|
270
|
+
const startValue = range?.[positionField] ?? raw[positionField] ?? raw.index
|
|
271
|
+
if (startValue === undefined || startValue === null) {
|
|
272
|
+
throw new Error(`${label} 缺少${dimension === 'row' ? '行' : '列'}位置:请传 ${label}.range:{${positionField},${endField}}(0-based,结束坐标 inclusive),或传 ${label}.index / ${label}.${positionField} 搭配 count`)
|
|
273
|
+
}
|
|
274
|
+
const start = parseIndex(startValue, `${label}.${positionField}`)
|
|
275
|
+
const rawEnd = range?.[endField]
|
|
276
|
+
const end = rawEnd === undefined || rawEnd === null ? start : parseIndex(rawEnd, `${label}.${endField}`)
|
|
277
|
+
if (end < start) throw new Error(`${label}.range 结束位置不能小于开始位置(${start} > ${end})`)
|
|
278
|
+
return dimension === 'row'
|
|
279
|
+
? { startRow: start, startColumn: 0, endRow: end, endColumn: 0 }
|
|
280
|
+
: { startRow: 0, startColumn: start, endRow: 0, endColumn: end }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function parseFiniteNumber(value: unknown, label: string): number {
|
|
284
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${label} 必须是有限数字`)
|
|
285
|
+
return value
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function parseHyperlink(value: unknown): string {
|
|
289
|
+
if (typeof value !== 'string' || value.trim().length === 0) throw new Error('url 必须是非空字符串')
|
|
290
|
+
const url = value.trim()
|
|
291
|
+
if (!/^(?:https?:|mailto:|file:|#)/i.test(url)) throw new Error('url 只支持 http(s)、mailto、file 或 sheet 内部链接')
|
|
292
|
+
return url
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function parseHyperlinkLabel(value: unknown): string {
|
|
296
|
+
if (typeof value !== 'string') throw new Error('label 必须是字符串')
|
|
297
|
+
return value
|
|
298
|
+
}
|