@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.
Files changed (267) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -2
  3. package/cordis.patch.yml +7 -0
  4. package/lib/client.js +16766 -0
  5. package/lib/contract-envelope.d.ts +91 -0
  6. package/lib/contract-envelope.js +106 -0
  7. package/lib/contract-layout.d.ts +130 -0
  8. package/lib/contract-layout.js +138 -0
  9. package/lib/contract-refs.d.ts +39 -0
  10. package/lib/contract-refs.js +9 -0
  11. package/lib/contract-version.d.ts +311 -0
  12. package/lib/contract-version.js +270 -0
  13. package/lib/contract-wire.d.ts +147 -0
  14. package/lib/contract-wire.js +99 -0
  15. package/lib/contracts.d.ts +12 -0
  16. package/lib/contracts.js +12 -0
  17. package/lib/types/audit.d.ts +42 -0
  18. package/lib/types/audit.js +39 -0
  19. package/lib/types/client/ProjectImportMenu.d.ts +30 -0
  20. package/lib/types/client/ProjectImportMenu.js +70 -0
  21. package/lib/types/client/SheetPanel.d.ts +33 -0
  22. package/lib/types/client/SheetPanel.js +1766 -0
  23. package/lib/types/client/SheetToggle.d.ts +18 -0
  24. package/lib/types/client/SheetToggle.js +26 -0
  25. package/lib/types/client/index.d.ts +15 -0
  26. package/lib/types/client/index.js +110 -0
  27. package/lib/types/client/persistence/changes.d.ts +15 -0
  28. package/lib/types/client/persistence/changes.js +51 -0
  29. package/lib/types/client/persistence/ids.d.ts +34 -0
  30. package/lib/types/client/persistence/ids.js +48 -0
  31. package/lib/types/client/persistence/merge.d.ts +35 -0
  32. package/lib/types/client/persistence/merge.js +362 -0
  33. package/lib/types/client/store.d.ts +61 -0
  34. package/lib/types/client/store.js +152 -0
  35. package/lib/types/client/surface-context.d.ts +27 -0
  36. package/lib/types/client/surface-context.js +104 -0
  37. package/lib/types/client/surface-control.d.ts +2 -0
  38. package/lib/types/client/surface-control.js +12 -0
  39. package/lib/types/client/transport/workbookApi.d.ts +159 -0
  40. package/lib/types/client/transport/workbookApi.js +246 -0
  41. package/lib/types/client/univer/StandaloneWorkbookPage.d.ts +5 -0
  42. package/lib/types/client/univer/StandaloneWorkbookPage.js +7 -0
  43. package/lib/types/client/univer/WorkbookCanvas.d.ts +40 -0
  44. package/lib/types/client/univer/WorkbookCanvas.js +87 -0
  45. package/lib/types/client/univer/WorkbookView.d.ts +3 -0
  46. package/lib/types/client/univer/WorkbookView.js +2 -0
  47. package/lib/types/client/univer/loader.d.ts +18 -0
  48. package/lib/types/client/univer/loader.js +81 -0
  49. package/lib/types/client/univer/standalone-entry.d.ts +1 -0
  50. package/lib/types/client/univer/standalone-entry.js +19 -0
  51. package/lib/types/client/versions/VersionChrome.d.ts +33 -0
  52. package/lib/types/client/versions/VersionChrome.js +21 -0
  53. package/lib/types/client/versions/inspect.d.ts +70 -0
  54. package/lib/types/client/versions/inspect.js +88 -0
  55. package/lib/types/client/versions/preview-decoration.d.ts +11 -0
  56. package/lib/types/client/versions/preview-decoration.js +54 -0
  57. package/lib/types/client/versions/records.d.ts +19 -0
  58. package/lib/types/client/versions/records.js +70 -0
  59. package/lib/types/client/workspace-host.d.ts +61 -0
  60. package/lib/types/client/workspace-host.js +124 -0
  61. package/lib/types/collaboration-hub.d.ts +31 -0
  62. package/lib/types/collaboration-hub.js +69 -0
  63. package/lib/types/contract/ids.d.ts +24 -0
  64. package/lib/types/contract/ids.js +46 -0
  65. package/lib/types/contract/mutations.d.ts +25 -0
  66. package/lib/types/contract/mutations.js +782 -0
  67. package/lib/types/contract/operations.d.ts +54 -0
  68. package/lib/types/contract/operations.js +203 -0
  69. package/lib/types/contract/snapshots.d.ts +176 -0
  70. package/lib/types/contract/snapshots.js +351 -0
  71. package/lib/types/contract/univer-api-catalog.d.ts +29 -0
  72. package/lib/types/contract/univer-api-catalog.js +264 -0
  73. package/lib/types/errors.d.ts +8 -0
  74. package/lib/types/errors.js +12 -0
  75. package/lib/types/http/assets-routes.d.ts +3 -0
  76. package/lib/types/http/assets-routes.js +169 -0
  77. package/lib/types/http/collaboration.d.ts +10 -0
  78. package/lib/types/http/collaboration.js +154 -0
  79. package/lib/types/http/history.d.ts +15 -0
  80. package/lib/types/http/history.js +134 -0
  81. package/lib/types/http/project-files.d.ts +79 -0
  82. package/lib/types/http/project-files.js +206 -0
  83. package/lib/types/http/project-scope.d.ts +50 -0
  84. package/lib/types/http/project-scope.js +52 -0
  85. package/lib/types/http/routes.d.ts +98 -0
  86. package/lib/types/http/routes.js +323 -0
  87. package/lib/types/http/standalone.d.ts +9 -0
  88. package/lib/types/http/standalone.js +31 -0
  89. package/lib/types/http/trunk.d.ts +10 -0
  90. package/lib/types/http/trunk.js +322 -0
  91. package/lib/types/http/worktree.d.ts +9 -0
  92. package/lib/types/http/worktree.js +145 -0
  93. package/lib/types/index.d.ts +18 -0
  94. package/lib/types/index.js +156 -0
  95. package/lib/types/invariant.d.ts +15 -0
  96. package/lib/types/invariant.js +15 -0
  97. package/lib/types/request-params.d.ts +21 -0
  98. package/lib/types/request-params.js +76 -0
  99. package/lib/types/store/paths.d.ts +68 -0
  100. package/lib/types/store/paths.js +148 -0
  101. package/lib/types/store/project.d.ts +88 -0
  102. package/lib/types/store/project.js +120 -0
  103. package/lib/types/store/read.d.ts +110 -0
  104. package/lib/types/store/read.js +404 -0
  105. package/lib/types/store/retention.d.ts +27 -0
  106. package/lib/types/store/retention.js +111 -0
  107. package/lib/types/store/write.d.ts +22 -0
  108. package/lib/types/store/write.js +210 -0
  109. package/lib/types/tools/arguments.d.ts +40 -0
  110. package/lib/types/tools/arguments.js +130 -0
  111. package/lib/types/tools/cells.d.ts +50 -0
  112. package/lib/types/tools/cells.js +298 -0
  113. package/lib/types/tools/index.d.ts +2 -0
  114. package/lib/types/tools/index.js +250 -0
  115. package/lib/types/tools/render.d.ts +47 -0
  116. package/lib/types/tools/render.js +290 -0
  117. package/lib/types/tools/univer-api.d.ts +18 -0
  118. package/lib/types/tools/univer-api.js +198 -0
  119. package/lib/types/tools/workbook-catalog.d.ts +3 -0
  120. package/lib/types/tools/workbook-catalog.js +83 -0
  121. package/lib/types/tools/workbook-edit.d.ts +192 -0
  122. package/lib/types/tools/workbook-edit.js +912 -0
  123. package/lib/types/tools/workbook-find.d.ts +11 -0
  124. package/lib/types/tools/workbook-find.js +89 -0
  125. package/lib/types/tools/workbook-history.d.ts +2 -0
  126. package/lib/types/tools/workbook-history.js +59 -0
  127. package/lib/types/tools/workbook-read.d.ts +21 -0
  128. package/lib/types/tools/workbook-read.js +204 -0
  129. package/lib/types/types.d.ts +377 -0
  130. package/lib/types/types.js +34 -0
  131. package/lib/types/univer/apply-operation.d.ts +14 -0
  132. package/lib/types/univer/apply-operation.js +398 -0
  133. package/lib/types/univer/chunk.d.ts +68 -0
  134. package/lib/types/univer/chunk.js +16 -0
  135. package/lib/types/univer/decoration.d.ts +34 -0
  136. package/lib/types/univer/decoration.js +88 -0
  137. package/lib/types/univer/diagnostics.d.ts +31 -0
  138. package/lib/types/univer/diagnostics.js +141 -0
  139. package/lib/types/univer/engine.d.ts +33 -0
  140. package/lib/types/univer/engine.js +338 -0
  141. package/lib/types/univer/entry.d.ts +8 -0
  142. package/lib/types/univer/entry.js +11 -0
  143. package/lib/types/univer/facade.d.ts +107 -0
  144. package/lib/types/univer/facade.js +1 -0
  145. package/lib/types/univer/fonts.d.ts +202 -0
  146. package/lib/types/univer/fonts.js +140 -0
  147. package/lib/types/univer/plugins/collaboration.d.ts +23 -0
  148. package/lib/types/univer/plugins/collaboration.js +47 -0
  149. package/lib/types/univer/plugins/export.d.ts +39 -0
  150. package/lib/types/univer/plugins/export.js +246 -0
  151. package/lib/types/univer/plugins/history.d.ts +24 -0
  152. package/lib/types/univer/plugins/history.js +50 -0
  153. package/lib/types/univer/plugins/version.d.ts +13 -0
  154. package/lib/types/univer/plugins/version.js +13 -0
  155. package/lib/types/version/diff.d.ts +36 -0
  156. package/lib/types/version/diff.js +156 -0
  157. package/lib/types/version/grouping.d.ts +7 -0
  158. package/lib/types/version/grouping.js +94 -0
  159. package/lib/types/version/records.d.ts +17 -0
  160. package/lib/types/version/records.js +102 -0
  161. package/lib/types/version/session.d.ts +36 -0
  162. package/lib/types/version/session.js +89 -0
  163. package/lib/types/version/summary.d.ts +24 -0
  164. package/lib/types/version/summary.js +111 -0
  165. package/lib/types/workbook-manager.d.ts +191 -0
  166. package/lib/types/workbook-manager.js +935 -0
  167. package/lib/types/xlsx-export-worker.d.ts +5 -0
  168. package/lib/types/xlsx-export-worker.js +24 -0
  169. package/lib/types/xlsx-export.d.ts +21 -0
  170. package/lib/types/xlsx-export.js +371 -0
  171. package/lib/types/xlsx-import-worker.d.ts +1 -0
  172. package/lib/types/xlsx-import-worker.js +19 -0
  173. package/lib/univer-client.js +690 -0
  174. package/lib/workbook-page.js +23826 -0
  175. package/lib/xlsx-export-worker.js +104 -0
  176. package/lib/xlsx-import-worker.js +46 -0
  177. package/package.json +106 -3
  178. package/src/audit.ts +58 -0
  179. package/src/client/ProjectImportMenu.tsx +100 -0
  180. package/src/client/SheetPanel.module.css +1578 -0
  181. package/src/client/SheetPanel.tsx +1934 -0
  182. package/src/client/SheetToggle.module.css +73 -0
  183. package/src/client/SheetToggle.tsx +65 -0
  184. package/src/client/contract.d.ts +19 -0
  185. package/src/client/index.ts +131 -0
  186. package/src/client/persistence/changes.ts +58 -0
  187. package/src/client/persistence/ids.ts +60 -0
  188. package/src/client/persistence/merge.ts +392 -0
  189. package/src/client/store.ts +188 -0
  190. package/src/client/surface-context.ts +123 -0
  191. package/src/client/surface-control.ts +13 -0
  192. package/src/client/transport/workbookApi.ts +355 -0
  193. package/src/client/univer/SheetView.module.css +110 -0
  194. package/src/client/univer/StandaloneWorkbookPage.module.css +8 -0
  195. package/src/client/univer/StandaloneWorkbookPage.tsx +15 -0
  196. package/src/client/univer/WorkbookCanvas.tsx +128 -0
  197. package/src/client/univer/WorkbookView.module.css +30 -0
  198. package/src/client/univer/WorkbookView.tsx +3 -0
  199. package/src/client/univer/loader.ts +90 -0
  200. package/src/client/univer/react-dom-client.d.ts +10 -0
  201. package/src/client/univer/standalone-entry.tsx +19 -0
  202. package/src/client/univer/standalone.css +12 -0
  203. package/src/client/univer/univer-locales.d.ts +89 -0
  204. package/src/client/univer/univer-modules.d.ts +26 -0
  205. package/src/client/versions/VersionChrome.tsx +114 -0
  206. package/src/client/versions/inspect.ts +138 -0
  207. package/src/client/versions/preview-decoration.ts +54 -0
  208. package/src/client/versions/records.ts +78 -0
  209. package/src/client/workspace-host.ts +147 -0
  210. package/src/collaboration-hub.ts +83 -0
  211. package/src/contract/ids.ts +50 -0
  212. package/src/contract/mutations.ts +791 -0
  213. package/src/contract/operations.ts +221 -0
  214. package/src/contract/snapshots.ts +383 -0
  215. package/src/contract/univer-api-catalog.ts +289 -0
  216. package/src/css-modules.d.ts +6 -0
  217. package/src/errors.ts +25 -0
  218. package/src/http/assets-routes.ts +177 -0
  219. package/src/http/collaboration.ts +144 -0
  220. package/src/http/history.ts +151 -0
  221. package/src/http/project-files.ts +227 -0
  222. package/src/http/project-scope.ts +77 -0
  223. package/src/http/routes.ts +364 -0
  224. package/src/http/standalone.ts +32 -0
  225. package/src/http/trunk.ts +322 -0
  226. package/src/http/worktree.ts +119 -0
  227. package/src/index.ts +165 -0
  228. package/src/invariant.ts +17 -0
  229. package/src/request-params.ts +92 -0
  230. package/src/store/paths.ts +165 -0
  231. package/src/store/project.ts +137 -0
  232. package/src/store/read.ts +454 -0
  233. package/src/store/retention.ts +106 -0
  234. package/src/store/write.ts +230 -0
  235. package/src/tools/arguments.ts +128 -0
  236. package/src/tools/cells.ts +298 -0
  237. package/src/tools/index.ts +274 -0
  238. package/src/tools/render.ts +301 -0
  239. package/src/tools/univer-api.ts +243 -0
  240. package/src/tools/workbook-catalog.ts +94 -0
  241. package/src/tools/workbook-edit.ts +987 -0
  242. package/src/tools/workbook-find.ts +80 -0
  243. package/src/tools/workbook-history.ts +63 -0
  244. package/src/tools/workbook-read.ts +210 -0
  245. package/src/types.ts +432 -0
  246. package/src/univer/apply-operation.ts +369 -0
  247. package/src/univer/chunk.ts +77 -0
  248. package/src/univer/decoration.ts +111 -0
  249. package/src/univer/diagnostics.ts +136 -0
  250. package/src/univer/engine.ts +370 -0
  251. package/src/univer/entry.ts +14 -0
  252. package/src/univer/facade.ts +91 -0
  253. package/src/univer/fonts.ts +148 -0
  254. package/src/univer/overrides.css +33 -0
  255. package/src/univer/plugins/collaboration.ts +47 -0
  256. package/src/univer/plugins/export.ts +236 -0
  257. package/src/univer/plugins/history.ts +49 -0
  258. package/src/univer/plugins/version.ts +13 -0
  259. package/src/version/diff.ts +169 -0
  260. package/src/version/grouping.ts +96 -0
  261. package/src/version/records.ts +113 -0
  262. package/src/version/session.ts +90 -0
  263. package/src/version/summary.ts +117 -0
  264. package/src/workbook-manager.ts +1111 -0
  265. package/src/xlsx-export-worker.ts +34 -0
  266. package/src/xlsx-export.ts +373 -0
  267. 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
+ }