@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,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed between two snapshots (docs/issue/0022 P2).
|
|
3
|
+
*
|
|
4
|
+
* Pure, and the only place that decides what counts as a change: a cell's
|
|
5
|
+
* value, a sheet's identity, or the structure around it (merges, freeze,
|
|
6
|
+
* row/column sizes). The Version list and the comparison bar both read this
|
|
7
|
+
* shape, so the answer to "what changed" is defined once.
|
|
8
|
+
*
|
|
9
|
+
* `scopes` narrows the walk to the sheets and ranges an operation actually
|
|
10
|
+
* touched; without it the whole workbook is compared.
|
|
11
|
+
*/
|
|
12
|
+
import type { WorkbookChange, WorkbookOperationPayload, WorkbookOperationType, WorkbookRange } from '../types.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How many differences one diff reports.
|
|
16
|
+
*
|
|
17
|
+
* A bound is necessary — the list travels in an HTTP response and an Agent
|
|
18
|
+
* result — but an *unsignalled* bound is not: a caller receiving exactly this
|
|
19
|
+
* many cannot tell a comparison that found this many from one that stopped
|
|
20
|
+
* looking, so `describeSnapshotReplace` stated `修改 500 个单元格` for a save that
|
|
21
|
+
* touched thousands, and the comparison bar showed the same number as though it
|
|
22
|
+
* were the whole answer. Callers ask {@link diffReachedLimit}.
|
|
23
|
+
*/
|
|
24
|
+
export const DIFF_CHANGE_LIMIT = 500
|
|
25
|
+
|
|
26
|
+
/** Whether a diff stopped at the limit, so its count is a lower bound. */
|
|
27
|
+
export function diffReachedLimit(changes: readonly WorkbookChange[]): boolean {
|
|
28
|
+
return changes.length >= DIFF_CHANGE_LIMIT
|
|
29
|
+
}
|
|
30
|
+
import { asWorkbookSnapshot, toCellValue } from '../contract/snapshots.ts'
|
|
31
|
+
|
|
32
|
+
export interface WorkbookDiffScope {
|
|
33
|
+
ref: string
|
|
34
|
+
wholeSheet: boolean
|
|
35
|
+
ranges: WorkbookRange[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Collect the smallest safe regions touched by a proposal. */
|
|
39
|
+
export function collectDiffScopes(operations: Array<{ type: WorkbookOperationType; payload: WorkbookOperationPayload }>): Map<string, WorkbookDiffScope> {
|
|
40
|
+
const scopes = new Map<string, WorkbookDiffScope>()
|
|
41
|
+
const add = (ref: string | undefined, range?: WorkbookRange, wholeSheet = false): void => {
|
|
42
|
+
if (ref === undefined || ref.trim().length === 0) return
|
|
43
|
+
const key = ref.trim()
|
|
44
|
+
const scope = scopes.get(key) ?? { ref: key, wholeSheet: false, ranges: [] }
|
|
45
|
+
scope.wholeSheet ||= wholeSheet
|
|
46
|
+
if (range !== undefined) scope.ranges.push(range)
|
|
47
|
+
scopes.set(key, scope)
|
|
48
|
+
}
|
|
49
|
+
for (const operation of operations) {
|
|
50
|
+
const payload = operation.payload
|
|
51
|
+
const ref = payload.sheetId ?? payload.sheetName ?? (operation.type === 'create_sheet' ? payload.name : undefined)
|
|
52
|
+
if (operation.type === 'batch') {
|
|
53
|
+
for (const child of payload.operations ?? []) {
|
|
54
|
+
const nested = collectDiffScopes([child])
|
|
55
|
+
for (const scope of nested.values()) {
|
|
56
|
+
const target = scopes.get(scope.ref) ?? { ref: scope.ref, wholeSheet: false, ranges: [] }
|
|
57
|
+
target.wholeSheet ||= scope.wholeSheet
|
|
58
|
+
target.ranges.push(...scope.ranges)
|
|
59
|
+
scopes.set(scope.ref, target)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (operation.type === 'update_cells') {
|
|
65
|
+
const cells = payload.cells ?? []
|
|
66
|
+
if (cells.length > 0) {
|
|
67
|
+
add(ref, {
|
|
68
|
+
startRow: Math.min(...cells.map(cell => cell.row)),
|
|
69
|
+
startColumn: Math.min(...cells.map(cell => cell.column)),
|
|
70
|
+
endRow: Math.max(...cells.map(cell => cell.row)),
|
|
71
|
+
endColumn: Math.max(...cells.map(cell => cell.column)),
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (operation.type === 'clear_range' || operation.type === 'delete_cells' || operation.type === 'format_cells'
|
|
77
|
+
|| operation.type === 'merge_cells' || operation.type === 'unmerge_cells' || operation.type === 'sort_range'
|
|
78
|
+
|| operation.type === 'set_hyperlink' || operation.type === 'remove_hyperlink' || operation.type === 'remove_duplicates') {
|
|
79
|
+
add(ref, payload.range ?? payload.merge)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (operation.type === 'set_column_width' || operation.type === 'set_row_height' || operation.type === 'freeze_panes'
|
|
83
|
+
|| operation.type === 'append_rows' || operation.type === 'insert_rows' || operation.type === 'delete_rows'
|
|
84
|
+
|| operation.type === 'insert_columns' || operation.type === 'delete_columns') {
|
|
85
|
+
add(ref, undefined, true)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (operation.type === 'create_sheet' || operation.type === 'delete_sheet' || operation.type === 'rename_sheet') add(ref, undefined, true)
|
|
89
|
+
}
|
|
90
|
+
return scopes
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** No value at either end: `toCellValue` answers `null` for a valueless cell. */
|
|
94
|
+
function isBlankValue(value: unknown): boolean {
|
|
95
|
+
return value === null || value === undefined || String(value) === ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function diffWorkbookSnapshots(before: unknown, after: unknown, scopes?: Map<string, WorkbookDiffScope>): WorkbookChange[] {
|
|
99
|
+
const left = asWorkbookSnapshot(before)
|
|
100
|
+
const right = asWorkbookSnapshot(after)
|
|
101
|
+
const changes: WorkbookChange[] = []
|
|
102
|
+
const sheetIds = new Set([...left.sheetOrder, ...right.sheetOrder])
|
|
103
|
+
for (const sheetId of sheetIds) {
|
|
104
|
+
const beforeSheet = left.sheets[sheetId]
|
|
105
|
+
const afterSheet = right.sheets[sheetId]
|
|
106
|
+
if (beforeSheet === undefined || afterSheet === undefined) {
|
|
107
|
+
changes.push({ kind: 'sheet', sheetId, sheetName: afterSheet?.name ?? beforeSheet?.name, description: afterSheet === undefined ? '删除 sheet' : '新增 sheet' })
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
const scope = scopes === undefined || scopes.size === 0
|
|
111
|
+
? undefined
|
|
112
|
+
: [...scopes.values()].find(candidate => candidate.ref === sheetId || candidate.ref === beforeSheet.name || candidate.ref === afterSheet.name)
|
|
113
|
+
if (scopes !== undefined && scopes.size > 0 && scope === undefined) continue
|
|
114
|
+
if (beforeSheet.name !== afterSheet.name) changes.push({ kind: 'sheet', sheetId, sheetName: afterSheet.name, before: beforeSheet.name, after: afterSheet.name, description: '重命名 sheet' })
|
|
115
|
+
for (const key of ['mergeData', 'freeze', 'columnData', 'rowData'] as const) {
|
|
116
|
+
if (JSON.stringify(beforeSheet[key]) !== JSON.stringify(afterSheet[key])) {
|
|
117
|
+
changes.push({ kind: 'structure', sheetId, sheetName: afterSheet.name, before: beforeSheet[key], after: afterSheet[key], description: key === 'mergeData' ? '调整合并单元格' : key === 'freeze' ? '调整冻结窗格' : '调整行列尺寸' })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const rows = scope?.wholeSheet || scope === undefined
|
|
121
|
+
? new Set([...Object.keys(beforeSheet.cellData ?? {}), ...Object.keys(afterSheet.cellData ?? {})])
|
|
122
|
+
: new Set(scope.ranges.flatMap(range => {
|
|
123
|
+
const result: string[] = []
|
|
124
|
+
for (let row = range.startRow; row <= range.endRow; row += 1) result.push(String(row))
|
|
125
|
+
return result
|
|
126
|
+
}))
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const columns = scope?.wholeSheet || scope === undefined
|
|
129
|
+
? new Set([...Object.keys(beforeSheet.cellData?.[row] ?? {}), ...Object.keys(afterSheet.cellData?.[row] ?? {})])
|
|
130
|
+
: new Set(scope.ranges.flatMap(range => {
|
|
131
|
+
const result: string[] = []
|
|
132
|
+
for (let column = range.startColumn; column <= range.endColumn; column += 1) result.push(String(column))
|
|
133
|
+
return result
|
|
134
|
+
}))
|
|
135
|
+
for (const column of columns) {
|
|
136
|
+
const beforeCell = beforeSheet.cellData?.[row]?.[column]
|
|
137
|
+
const afterCell = afterSheet.cellData?.[row]?.[column]
|
|
138
|
+
if (JSON.stringify(beforeCell) === JSON.stringify(afterCell)) continue
|
|
139
|
+
const beforeValue = toCellValue(beforeCell)
|
|
140
|
+
const afterValue = toCellValue(afterCell)
|
|
141
|
+
// A cell with no value on either side that only gained a style is not a
|
|
142
|
+
// difference worth reporting. Formatting a range writes a style id into
|
|
143
|
+
// every cell it covers, empty ones included, and those entries used to
|
|
144
|
+
// dominate the count: 473 of them against 11 real edits, which made the
|
|
145
|
+
// comparison bar unreadable (GUI feedback 2026-09-19).
|
|
146
|
+
//
|
|
147
|
+
// `toCellValue` answers `null` for a valueless cell, so "blank" here has
|
|
148
|
+
// to mean null/empty and not just `undefined` — testing for `undefined`
|
|
149
|
+
// compiles, passes the unit tests, and silently changes nothing.
|
|
150
|
+
if (isBlankValue(beforeValue) && isBlankValue(afterValue)) continue
|
|
151
|
+
// Same value, different cell object: the formatting moved, the content
|
|
152
|
+
// did not. Reported as 样式 rather than as a modification.
|
|
153
|
+
const styleOnly = beforeValue === afterValue
|
|
154
|
+
changes.push({
|
|
155
|
+
kind: 'cell',
|
|
156
|
+
sheetId,
|
|
157
|
+
sheetName: afterSheet.name,
|
|
158
|
+
row: Number(row),
|
|
159
|
+
column: Number(column),
|
|
160
|
+
before: beforeValue,
|
|
161
|
+
after: afterValue,
|
|
162
|
+
...(styleOnly ? { styleOnly: true } : {}),
|
|
163
|
+
})
|
|
164
|
+
if (changes.length >= DIFF_CHANGE_LIMIT) return changes
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return changes
|
|
169
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Physical revisions → the logical Versions a person sees (docs/issue/0022 P2).
|
|
3
|
+
*
|
|
4
|
+
* One user save can fill a single open Version across several requests, and one
|
|
5
|
+
* Agent semantic batch is a Version with N replayable Steps. This is the only
|
|
6
|
+
* place that decides where one Version ends and the next begins, so the list,
|
|
7
|
+
* the comparison bar and the Agent's history tool cannot disagree about it.
|
|
8
|
+
*/
|
|
9
|
+
import { operationSummary, versionKindOfOperations, workbookOperationSteps } from '../contract/operations.ts'
|
|
10
|
+
import { describeSessionSteps } from './summary.ts'
|
|
11
|
+
import type { WorkbookHistoryVersion, WorkbookOperation } from '../types.ts'
|
|
12
|
+
|
|
13
|
+
export function operationVersionId(operation: WorkbookOperation): string {
|
|
14
|
+
return operation.versionId ?? `revision:${operation.revision}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function historyVersionStatus(status: WorkbookOperation['versionStatus']): WorkbookHistoryVersion['status'] {
|
|
18
|
+
return status === 'temporary' || status === undefined ? (status === 'temporary' ? 'open' : 'final') : status
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Group physical revisions into the logical Version shown to users. A user
|
|
22
|
+
* save can fill one open Version over several requests; an Agent semantic
|
|
23
|
+
* batch is one Version with N replayable Steps. */
|
|
24
|
+
export function historyVersionsFromOperations(
|
|
25
|
+
source: WorkbookOperation[],
|
|
26
|
+
canReplay: (revision: number) => boolean,
|
|
27
|
+
activeRevisions: Set<number>,
|
|
28
|
+
): WorkbookHistoryVersion[] {
|
|
29
|
+
const groups = new Map<string, WorkbookOperation[]>()
|
|
30
|
+
for (const operation of source.slice().sort((left, right) => left.revision - right.revision)) {
|
|
31
|
+
const id = operationVersionId(operation)
|
|
32
|
+
const group = groups.get(id)
|
|
33
|
+
if (group === undefined) groups.set(id, [operation])
|
|
34
|
+
else group.push(operation)
|
|
35
|
+
}
|
|
36
|
+
const entries = [...groups.entries()]
|
|
37
|
+
const numberByVersionId = new Map(entries.map(([versionId], index) => [versionId, index + 1]))
|
|
38
|
+
/** Display number of the Version whose revision range covers `revision`. */
|
|
39
|
+
const numberAtRevision = (revision: number | undefined): number | undefined => {
|
|
40
|
+
if (revision === undefined) return undefined
|
|
41
|
+
for (const [versionId, operations] of entries) {
|
|
42
|
+
const first = operations[0]
|
|
43
|
+
const last = operations.at(-1)
|
|
44
|
+
if (first === undefined || last === undefined) continue
|
|
45
|
+
if (revision >= first.revision && revision <= last.revision) return numberByVersionId.get(versionId)
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
return entries.map(([versionId, operations], index) => {
|
|
50
|
+
const first = operations[0]
|
|
51
|
+
const last = operations.at(-1)
|
|
52
|
+
if (first === undefined || last === undefined) throw new Error('历史版本缺少操作')
|
|
53
|
+
const steps = operations.flatMap(operation => workbookOperationSteps(operation))
|
|
54
|
+
const storedSummary = last.versionSummary ?? first.versionSummary ?? (operations.length === 1
|
|
55
|
+
? operationSummary(first)
|
|
56
|
+
: describeSessionSteps(steps))
|
|
57
|
+
const branch = operations.every(operation => activeRevisions.has(operation.revision)) ? 'main' : 'superseded'
|
|
58
|
+
const storedStatus = historyVersionStatus(last.versionStatus)
|
|
59
|
+
const intent = last.versionIntent ?? first.versionIntent
|
|
60
|
+
const agentRunId = last.agentRunId ?? first.agentRunId
|
|
61
|
+
const lineage = last.lineage ?? first.lineage
|
|
62
|
+
const restoredFromRevision = last.restoredFromRevision ?? first.restoredFromRevision
|
|
63
|
+
const restoredFromNumber = numberAtRevision(restoredFromRevision)
|
|
64
|
+
// A stored `恢复到 revision 48` predates the v3 wording rule; show the
|
|
65
|
+
// Version number instead of the internal revision (PRD v3 §7.3).
|
|
66
|
+
const summary = restoredFromNumber !== undefined && /^恢复到 revision \d+$/.test(storedSummary)
|
|
67
|
+
? `恢复到 V${restoredFromNumber}`
|
|
68
|
+
: storedSummary
|
|
69
|
+
// v3 tier. Explicit when written; pre-v3 logs are derived from the facts
|
|
70
|
+
// that already separated a deliberate Version from editing-session churn.
|
|
71
|
+
const kind = versionKindOfOperations(operations)
|
|
72
|
+
return {
|
|
73
|
+
versionId,
|
|
74
|
+
// Creation order: a restore lands after every existing Version, so its
|
|
75
|
+
// number is always fresh and old numbers are never reused (PRD §3.2).
|
|
76
|
+
number: index + 1,
|
|
77
|
+
startRevision: first.revision,
|
|
78
|
+
revision: last.revision,
|
|
79
|
+
parentRevision: first.parentRevision ?? Math.max(0, first.revision - 1),
|
|
80
|
+
actor: first.actor,
|
|
81
|
+
timestamp: first.timestamp,
|
|
82
|
+
summary,
|
|
83
|
+
kind,
|
|
84
|
+
...(intent === undefined ? {} : { intent }),
|
|
85
|
+
...(agentRunId === undefined ? {} : { agentRunId }),
|
|
86
|
+
...(lineage === undefined ? {} : { lineage }),
|
|
87
|
+
...(restoredFromRevision === undefined ? {} : { restoredFromRevision }),
|
|
88
|
+
...(restoredFromNumber === undefined ? {} : { restoredFromNumber }),
|
|
89
|
+
stepCount: steps.length,
|
|
90
|
+
steps: steps.map((step, index) => ({ index, opId: step.opId, type: step.type, summary: operationSummary(step), ...(step.revision === undefined ? {} : { revision: step.revision }) })),
|
|
91
|
+
canPreview: canReplay(last.revision),
|
|
92
|
+
status: branch === 'superseded' ? 'superseded' : storedStatus,
|
|
93
|
+
branch,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Version as the wire presents it (docs/issue/0022 P2/P3).
|
|
3
|
+
*
|
|
4
|
+
* The history endpoint and the Agent's history tool answer the same question,
|
|
5
|
+
* so they must produce the same record shape — including the content hash that
|
|
6
|
+
* lets a client prove it has the right Version without materializing a snapshot.
|
|
7
|
+
* That is why this is not part of either transport.
|
|
8
|
+
*
|
|
9
|
+
* `node:crypto` is deliberate and is pure computation: the hash is of the
|
|
10
|
+
* Version's *inputs* (its checkpoint plus its ordered Step ids), not of a file.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import type { VersionRecord } from '#contracts'
|
|
14
|
+
import type { WorkbookCheckpoint, WorkbookHistoryVersion, WorkbookOperation } from '../types.ts'
|
|
15
|
+
|
|
16
|
+
/** Follow logical parents from the current head. Versions skipped by a
|
|
17
|
+
* rollback remain in history but are no longer part of the active main line. */
|
|
18
|
+
export function activeHistoryRevisions(currentRevision: number, operations: Map<number, WorkbookOperation>): Set<number> {
|
|
19
|
+
const active = new Set<number>()
|
|
20
|
+
let cursor = currentRevision
|
|
21
|
+
const visited = new Set<number>()
|
|
22
|
+
while (cursor > 0 && !visited.has(cursor)) {
|
|
23
|
+
visited.add(cursor)
|
|
24
|
+
const operation = operations.get(cursor)
|
|
25
|
+
if (operation === undefined) break
|
|
26
|
+
active.add(cursor)
|
|
27
|
+
cursor = operation.parentRevision ?? Math.max(0, cursor - 1)
|
|
28
|
+
}
|
|
29
|
+
return active
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function canReplayHistoryRevision(
|
|
33
|
+
targetRevision: number,
|
|
34
|
+
currentRevision: number,
|
|
35
|
+
checkpoints: number[],
|
|
36
|
+
operations: Map<number, WorkbookOperation>,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (targetRevision === currentRevision) return true
|
|
39
|
+
const checkpoint = checkpoints.filter(revision => revision <= targetRevision).at(-1)
|
|
40
|
+
if (checkpoint === undefined) return false
|
|
41
|
+
for (let revision = checkpoint + 1; revision <= targetRevision; revision += 1) {
|
|
42
|
+
const operation = operations.get(revision)
|
|
43
|
+
if (operation === undefined || (operation.type === 'snapshot_replace' && operation.payload.snapshot === undefined)) return false
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stable content hash for a Version (PRD §3.2): the checkpoint the Version
|
|
50
|
+
* starts from, plus the ordered opIds applied on top of it. It is a pure
|
|
51
|
+
* function of the Version's inputs, so the history list never has to
|
|
52
|
+
* materialise or copy a workbook snapshot into the browser (PRD §11).
|
|
53
|
+
*/
|
|
54
|
+
export function versionContentHash(version: WorkbookHistoryVersion, checkpointHash: (revision: number) => string): string {
|
|
55
|
+
const chain = version.steps.map(step => step.opId).join(',')
|
|
56
|
+
return createHash('sha256').update(`${checkpointHash(version.revision)}#${version.startRevision}#${chain}`).digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Map the Data Sheet history shape onto the shared Version record (PRD §7.1). */
|
|
60
|
+
export function toVersionRecords(
|
|
61
|
+
versions: WorkbookHistoryVersion[],
|
|
62
|
+
workbookId: string,
|
|
63
|
+
checkpointHash: (revision: number) => string,
|
|
64
|
+
): VersionRecord[] {
|
|
65
|
+
return versions.map(version => {
|
|
66
|
+
const status: VersionRecord['status'] = version.status === 'final'
|
|
67
|
+
? 'committed'
|
|
68
|
+
: version.status === 'temporary'
|
|
69
|
+
? 'open'
|
|
70
|
+
: version.status
|
|
71
|
+
return {
|
|
72
|
+
versionId: version.versionId,
|
|
73
|
+
artifactKind: 'workbook',
|
|
74
|
+
artifactId: workbookId,
|
|
75
|
+
number: version.number,
|
|
76
|
+
startRevision: version.startRevision,
|
|
77
|
+
endRevision: version.revision,
|
|
78
|
+
// The unified record points at the reinstated Version, like other versioned artifacts
|
|
79
|
+
// record does; `r48` only survives on the legacy history shape.
|
|
80
|
+
...(version.restoredFromRevision === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { restoredFromVersionId: versions.find(candidate => candidate.revision === version.restoredFromRevision)?.versionId ?? `r${version.restoredFromRevision}` }),
|
|
83
|
+
status,
|
|
84
|
+
kind: version.kind,
|
|
85
|
+
actor: version.actor === 'agent' ? 'agent' : 'user',
|
|
86
|
+
summary: version.summary,
|
|
87
|
+
...(version.intent === undefined ? {} : { intent: version.intent }),
|
|
88
|
+
stepCount: version.stepCount,
|
|
89
|
+
contentHash: versionContentHash(version, checkpointHash),
|
|
90
|
+
createdAt: version.timestamp,
|
|
91
|
+
...(status === 'committed' || status === 'restored' ? { committedAt: version.timestamp } : {}),
|
|
92
|
+
...(version.lineage === undefined ? {} : { lineage: version.lineage }),
|
|
93
|
+
replayable: version.canPreview,
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Memoized checkpoint hash lookup: hashes each snapshot at most once. */
|
|
99
|
+
export function checkpointHasher(checkpoints: WorkbookCheckpoint[]): (revision: number) => string {
|
|
100
|
+
const ascending = checkpoints.slice().sort((left, right) => left.revision - right.revision)
|
|
101
|
+
const cache = new Map<number, string>()
|
|
102
|
+
const genesis = createHash('sha256').update('genesis').digest('hex')
|
|
103
|
+
return (revision: number): string => {
|
|
104
|
+
const cached = cache.get(revision)
|
|
105
|
+
if (cached !== undefined) return cached
|
|
106
|
+
const base = ascending.filter(checkpoint => checkpoint.revision <= revision).at(-1)
|
|
107
|
+
const hash = base === undefined
|
|
108
|
+
? genesis
|
|
109
|
+
: createHash('sha256').update(JSON.stringify(base.snapshot)).digest('hex')
|
|
110
|
+
cache.set(revision, hash)
|
|
111
|
+
return hash
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The editing session: when a Version opens, what closes it, and the shape of
|
|
3
|
+
* the version fields on the wire (docs/issue/0022 P2).
|
|
4
|
+
*
|
|
5
|
+
* Two boundaries close a session (PRD v3.5 D21) — the idle window and the Step
|
|
6
|
+
* cap — and nothing else does. `settleIdleUserVersions` exists because the write
|
|
7
|
+
* path can only fork a session when the *next* write arrives: an abandoned tab
|
|
8
|
+
* left a Version `open` forever, so settling is evaluated lazily at the moment
|
|
9
|
+
* the user can observe the outcome (reading the version list).
|
|
10
|
+
*/
|
|
11
|
+
import { VERSION_MAX_STEPS, type VersionKind } from '#contracts'
|
|
12
|
+
import { cloneSnapshot } from '../contract/snapshots.ts'
|
|
13
|
+
import { isIdlePast } from '../store/retention.ts'
|
|
14
|
+
import { readWorkbook } from '../store/read.ts'
|
|
15
|
+
import { writeWorkbook } from '../store/write.ts'
|
|
16
|
+
import type { WorkbookMeta, WorkbookVersionStatus } from '../types.ts'
|
|
17
|
+
|
|
18
|
+
// Safety cap only: idle and semantic boundaries are the primary Version
|
|
19
|
+
// boundary now (PRD §4.1). Shared through data-contracts.
|
|
20
|
+
export const VERSION_STEP_LIMIT = VERSION_MAX_STEPS
|
|
21
|
+
|
|
22
|
+
/** Old logs use `temporary`; expose it as the clearer `open` lifecycle state. */
|
|
23
|
+
export function normalizeVersionStatus(status: WorkbookVersionStatus | undefined): WorkbookVersionStatus | undefined {
|
|
24
|
+
return status === 'temporary' ? 'open' : status
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** v3 tier on the wire; unknown values are dropped rather than guessed. */
|
|
28
|
+
export function normalizeVersionKind(kind: VersionKind | undefined): VersionKind | undefined {
|
|
29
|
+
return kind === 'named' || kind === 'auto' ? kind : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isOpenVersionStatus(status: WorkbookVersionStatus | undefined): boolean {
|
|
33
|
+
return status === 'open' || status === 'temporary'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function optionalVersionText(value: string | undefined, name: string, maxLength: number): string | undefined {
|
|
37
|
+
if (value === undefined) return undefined
|
|
38
|
+
const normalized = value.trim()
|
|
39
|
+
if (normalized.length === 0 || normalized.length > maxLength) throw new Error(`${name} 必须是 1 到 ${maxLength} 字符的字符串`)
|
|
40
|
+
return normalized
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function finalizeOpenUserVersions(meta: WorkbookMeta): void {
|
|
44
|
+
const openVersionIds = new Set((meta.operations ?? [])
|
|
45
|
+
.filter(operation => operation.actor === 'user' && isOpenVersionStatus(operation.versionStatus) && operation.versionId !== undefined)
|
|
46
|
+
.map(operation => operation.versionId as string))
|
|
47
|
+
if (openVersionIds.size === 0) return
|
|
48
|
+
meta.operations = (meta.operations ?? []).map(operation => openVersionIds.has(operation.versionId ?? '')
|
|
49
|
+
? { ...operation, versionStatus: 'final' as const }
|
|
50
|
+
: operation)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Close every user Version whose last Step is older than the idle window
|
|
55
|
+
* (PRD v3.3 D21).
|
|
56
|
+
*
|
|
57
|
+
* The write path already forks a session when the *next* write arrives after a
|
|
58
|
+
* pause, but nothing settled a session that was simply abandoned — leaving the
|
|
59
|
+
* tab open, or closing it, left the Version `open` forever, so "停手 10 分钟"
|
|
60
|
+
* never actually produced a saved Version. Settling is evaluated lazily when
|
|
61
|
+
* the version list is read, which is the moment a user can observe the outcome.
|
|
62
|
+
*
|
|
63
|
+
* Idempotent: a second call finds nothing open past the window and writes
|
|
64
|
+
* nothing.
|
|
65
|
+
*/
|
|
66
|
+
export async function settleIdleUserVersions(workbookId: string, nowMs = Date.now()): Promise<number> {
|
|
67
|
+
const meta = await readWorkbook(workbookId)
|
|
68
|
+
if (meta === null) return 0
|
|
69
|
+
const open = (meta.operations ?? []).filter(operation => operation.actor === 'user' && isOpenVersionStatus(operation.versionStatus))
|
|
70
|
+
if (open.length === 0) return 0
|
|
71
|
+
const lastAt = new Map<string, string>()
|
|
72
|
+
for (const operation of open) {
|
|
73
|
+
const id = operation.versionId ?? `revision:${operation.revision}`
|
|
74
|
+
const previous = lastAt.get(id)
|
|
75
|
+
if (previous === undefined || operation.timestamp > previous) lastAt.set(id, operation.timestamp)
|
|
76
|
+
}
|
|
77
|
+
const idle = [...lastAt.entries()].filter(([, timestamp]) => isIdlePast(timestamp, nowMs)).map(([id]) => id)
|
|
78
|
+
if (idle.length === 0) return 0
|
|
79
|
+
const next = cloneSnapshot(meta) as WorkbookMeta
|
|
80
|
+
for (const id of idle) finalizeVersion(next, id)
|
|
81
|
+
await writeWorkbook(next, { persistSnapshot: false, persistCheckpoints: false })
|
|
82
|
+
return idle.length
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Finalize exactly one open Version, leaving other Versions untouched. */
|
|
86
|
+
export function finalizeVersion(meta: WorkbookMeta, versionId: string): void {
|
|
87
|
+
meta.operations = (meta.operations ?? []).map(operation => operation.versionId === versionId && isOpenVersionStatus(operation.versionStatus)
|
|
88
|
+
? { ...operation, versionStatus: 'final' as const }
|
|
89
|
+
: operation)
|
|
90
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Version and its Steps are described in words (docs/issue/0022 P2).
|
|
3
|
+
*
|
|
4
|
+
* This is the copy the user reads in the version list, so it is deliberately a
|
|
5
|
+
* module of its own: PRD 0002 changed it four times, and each of those changes
|
|
6
|
+
* is a change to *one* file rather than to the repository layer.
|
|
7
|
+
*
|
|
8
|
+
* A one-line summary of an *operation* (before it is diffed) lives in
|
|
9
|
+
* `contract/operations.ts` instead — `store/write.ts` embeds it in the sidecar
|
|
10
|
+
* index, and a store module may not import upward into `version/`.
|
|
11
|
+
*/
|
|
12
|
+
import { cellAddress, formatCellReference } from '#contracts'
|
|
13
|
+
import { DIFF_CHANGE_LIMIT, diffReachedLimit, diffWorkbookSnapshots } from './diff.ts'
|
|
14
|
+
import type { WorkbookOperation } from '../types.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Describe a whole-snapshot save by what it actually changed (PRD v3.1 §7).
|
|
18
|
+
* The browser persists most edits this way, and 「替换 workbook 快照」 told the
|
|
19
|
+
* user nothing; the diff is already computed for Version comparison, so a Step
|
|
20
|
+
* can say `修改 1 个单元格:Sheet1!C3` — an address the user can paste straight
|
|
21
|
+
* into the formula bar, unlike the former `Sheet1!R3C2`.
|
|
22
|
+
*/
|
|
23
|
+
export function describeSnapshotReplace(before: unknown, after: unknown): string {
|
|
24
|
+
const changes = diffWorkbookSnapshots(before, after)
|
|
25
|
+
if (changes.length === 0) return '保存内容(无变化)'
|
|
26
|
+
// Content edits are 修改; everything else is 样式. Splitting them is what
|
|
27
|
+
// makes the sentence true: a cell that only had its formatting touched is not
|
|
28
|
+
// a modification of anything the reader can see in the grid.
|
|
29
|
+
const cells = changes.filter(change => change.kind === 'cell' && change.styleOnly !== true)
|
|
30
|
+
const styled = changes.filter(change => change.kind !== 'cell' || change.styleOnly === true)
|
|
31
|
+
const sheetOf = (change: { sheetName?: string; sheetId?: string }): string => change.sheetName ?? change.sheetId ?? 'sheet'
|
|
32
|
+
// One sheet is named once: `发版日志!B27、B28、B29` reads like a spreadsheet
|
|
33
|
+
// selection, while `发版日志!B27、发版日志!B28` repeats the same word per cell.
|
|
34
|
+
const head = changes.slice(0, 3)
|
|
35
|
+
const sheets = new Set(head.map(change => (change.kind === 'cell' ? sheetOf(change) : '')))
|
|
36
|
+
const labels = head.map(change => change.kind === 'cell'
|
|
37
|
+
? (sheets.size === 1
|
|
38
|
+
? cellAddress(change.row ?? 0, change.column ?? 0)
|
|
39
|
+
: formatCellReference({ sheet: sheetOf(change), row: change.row ?? 0, column: change.column ?? 0 }))
|
|
40
|
+
: (change.description === undefined ? sheetOf(change) : `${sheetOf(change)}·${change.description}`))
|
|
41
|
+
const scope = sheets.size === 1 && [...sheets][0] !== '' ? `${[...sheets][0]}!` : ''
|
|
42
|
+
// Three structural changes in one sheet used to print `Sheet1、Sheet1、Sheet1`.
|
|
43
|
+
const distinct = [...new Set(labels)]
|
|
44
|
+
// A sheet being merged or frozen — or a cell that only had its formatting
|
|
45
|
+
// touched — is not a 修改 of anything: nothing about the *content* changed.
|
|
46
|
+
// The old headline said `修改 4 处结构变化`, which read as though the count
|
|
47
|
+
// were the verb's object, and it once reported `修改 496 个单元格` when 473 of
|
|
48
|
+
// those were styled-but-empty cells. Each bucket now carries its own verb.
|
|
49
|
+
// 结构 / 改变 → 样式 is the user's wording (GUI feedback 2026-09-19): it is
|
|
50
|
+
// the word they read cell-formatting adjustments as, and it covers the
|
|
51
|
+
// sheet-level ones too.
|
|
52
|
+
// `capped` is not the same as `truncated` below: that one is about the *sample*
|
|
53
|
+
// list being cut short, this one about the counts themselves being a floor.
|
|
54
|
+
const capped = diffReachedLimit(changes)
|
|
55
|
+
const headline = [
|
|
56
|
+
cells.length > 0 ? `修改 ${cells.length}${capped ? '+' : ''} 个单元格` : '',
|
|
57
|
+
styled.length > 0 ? `样式 ${styled.length}${capped ? '+' : ''} 处` : '',
|
|
58
|
+
].filter(part => part !== '').join('、')
|
|
59
|
+
const truncated = changes.length > 3 || distinct.length < labels.length
|
|
60
|
+
const limitNote = capped ? `(差异已达 ${DIFF_CHANGE_LIMIT} 条上限,计数为下限)` : ''
|
|
61
|
+
return `${headline}:${scope}${distinct.join('、')}${truncated ? ' 等' : ''}${limitNote}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One Step's kind → the bucket a session summary counts it in. */
|
|
65
|
+
const SESSION_STEP_BUCKET: Readonly<Record<string, string>> = {
|
|
66
|
+
update_cells: '修改单元格',
|
|
67
|
+
append_rows: '追加行',
|
|
68
|
+
set_hyperlink: '设置超链接',
|
|
69
|
+
remove_hyperlink: '移除超链接',
|
|
70
|
+
snapshot_replace: '保存内容',
|
|
71
|
+
create_sheet: '新增 sheet',
|
|
72
|
+
delete_sheet: '删除 sheet',
|
|
73
|
+
rename_sheet: '重命名 sheet',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fixed bucket order: what was edited first, then what the workbook is made of,
|
|
78
|
+
* then the presentation of a sheet. Map insertion order would have made the
|
|
79
|
+
* same session read differently depending on which Step happened to come first.
|
|
80
|
+
*/
|
|
81
|
+
const SESSION_BUCKET_ORDER = [
|
|
82
|
+
'修改单元格', '追加行', '设置超链接', '移除超链接', '保存内容',
|
|
83
|
+
'新增 sheet', '删除 sheet', '重命名 sheet',
|
|
84
|
+
] as const
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Describe an editing session that was never given a name (PRD v3.3 D21, D22).
|
|
88
|
+
*
|
|
89
|
+
* It used to read `编辑会话 · 5 个 Step`, which stated the same count twice: the
|
|
90
|
+
* row's meta line immediately above it already says `用户 · 5 个 Step`, and the
|
|
91
|
+
* two lines are read together. A session has no single operation to quote, and
|
|
92
|
+
* diffing its two ends would mean two snapshot reads plus a diff per Version on
|
|
93
|
+
* every list request — but the *kinds* of its Steps are already in hand, and
|
|
94
|
+
* 「改了什么」 is the question the summary line is there to answer; the size of
|
|
95
|
+
* the session is what the meta line is for.
|
|
96
|
+
*
|
|
97
|
+
* An unknown Step type is counted rather than dropped: a summary that silently
|
|
98
|
+
* omits a kind of change is worse than one that admits it does not know it.
|
|
99
|
+
*/
|
|
100
|
+
export function describeSessionSteps(steps: readonly WorkbookOperation[]): string {
|
|
101
|
+
const counts = new Map<string, number>()
|
|
102
|
+
for (const step of steps) {
|
|
103
|
+
const bucket = SESSION_STEP_BUCKET[step.type] ?? '其他改动'
|
|
104
|
+
counts.set(bucket, (counts.get(bucket) ?? 0) + 1)
|
|
105
|
+
}
|
|
106
|
+
const buckets = [...counts.keys()].sort(
|
|
107
|
+
(left, right) => bucketRank(left) - bucketRank(right),
|
|
108
|
+
)
|
|
109
|
+
if (buckets.length === 0) return '编辑会话'
|
|
110
|
+
return `编辑会话:${buckets.map(bucket => `${bucket} ${counts.get(bucket) ?? 0} 次`).join('、')}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Position in {@link SESSION_BUCKET_ORDER}; anything unlisted sorts last. */
|
|
114
|
+
function bucketRank(bucket: string): number {
|
|
115
|
+
const at = SESSION_BUCKET_ORDER.indexOf(bucket as typeof SESSION_BUCKET_ORDER[number])
|
|
116
|
+
return at === -1 ? SESSION_BUCKET_ORDER.length : at
|
|
117
|
+
}
|