@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,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version history over HTTP (docs/issue/0022 P3).
|
|
3
|
+
*
|
|
4
|
+
* The list, one historical snapshot or Step, and the diff between two revisions.
|
|
5
|
+
* The list is also where an abandoned editing session becomes observable:
|
|
6
|
+
* reading it is what settles it into a saved Version (PRD v3.3 D21).
|
|
7
|
+
*
|
|
8
|
+
* The Version record's wire shape comes from `version/records.ts`, which this
|
|
9
|
+
* endpoint answers with but does not define. It said the opposite — that the
|
|
10
|
+
* shape lived here and the model knew nothing about HTTP — while importing
|
|
11
|
+
* `toVersionRecords` from there on the next line.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
14
|
+
|
|
15
|
+
import type { VersionRecord } from '#contracts'
|
|
16
|
+
import { DataSheetError } from '../errors.ts'
|
|
17
|
+
import { diffReachedLimit, diffWorkbookSnapshots } from '../version/diff.ts'
|
|
18
|
+
import { parseRevision } from './routes.ts'
|
|
19
|
+
import { historyVersionsFromOperations } from '../version/grouping.ts'
|
|
20
|
+
import { activeHistoryRevisions, canReplayHistoryRevision, checkpointHasher, toVersionRecords } from '../version/records.ts'
|
|
21
|
+
import { settleIdleUserVersions } from '../version/session.ts'
|
|
22
|
+
import { readWorkbook, readWorkbookAtRevision, readWorkbookAtStep, readStepChanges } from '../workbook-manager.ts'
|
|
23
|
+
import { writeOperationError, type WorkbookRoute } from './routes.ts'
|
|
24
|
+
import type { WorkbookCheckpoint, WorkbookHistoryVersion, WorkbookOperation } from '../types.ts'
|
|
25
|
+
import { operationSummary } from '../contract/operations.ts'
|
|
26
|
+
|
|
27
|
+
export async function handleHistoryRoute(route: WorkbookRoute, req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean> {
|
|
28
|
+
const id = route.id
|
|
29
|
+
|
|
30
|
+
if (route.kind === 'history-diff' && req.method === 'GET') {
|
|
31
|
+
const fromRaw = url.searchParams.get('from')
|
|
32
|
+
const toRaw = url.searchParams.get('to')
|
|
33
|
+
// `parseRevision` rather than a bare `Number()`: the sibling endpoints use it
|
|
34
|
+
// for the same kind of cursor, and `Number()` also accepts `0x10`, `1e3` and
|
|
35
|
+
// `5.0` — a revision is a decimal integer. The defaults differ (`to` falls
|
|
36
|
+
// back to the current revision), so only the parsing is shared.
|
|
37
|
+
const from = parseRevision(fromRaw)
|
|
38
|
+
const currentMeta = await readWorkbook(id)
|
|
39
|
+
if (currentMeta === null) { writeOperationError(res, new DataSheetError('WORKBOOK_NOT_FOUND', '工作簿不存在', 404)); return true }
|
|
40
|
+
const to = toRaw === null ? currentMeta.revision ?? 0 : parseRevision(toRaw)
|
|
41
|
+
if (!Number.isInteger(from) || from < 0 || !Number.isInteger(to) || to < 0 || from > to) {
|
|
42
|
+
writeOperationError(res, new DataSheetError('VALIDATION_ERROR', 'from/to 必须是非负整数,且 from 不得大于 to'))
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
const left = await readWorkbookAtRevision(id, from)
|
|
46
|
+
const right = await readWorkbookAtRevision(id, to)
|
|
47
|
+
if (left === null || right === null) {
|
|
48
|
+
// User-facing copy: this is the panel's notice text, so it says what
|
|
49
|
+
// happened and what still works, not which revision number failed.
|
|
50
|
+
writeOperationError(res, new DataSheetError('REVISION_UNAVAILABLE', '这两版之间的历史记录已被压缩,无法重建内容快照,因此不能比较。更早的版本仍然可以恢复。', 409, { from, to }))
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
54
|
+
// `truncated` rather than nothing: the client renders these counts, and a
|
|
55
|
+
// capped list would otherwise read as the whole comparison.
|
|
56
|
+
const changes = diffWorkbookSnapshots(left.snapshot, right.snapshot)
|
|
57
|
+
res.end(JSON.stringify({ ok: true, workbookId: id, from, to, changes, truncated: diffReachedLimit(changes) }))
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (route.kind === 'history' && req.method === 'GET') {
|
|
62
|
+
// Reading the list is the moment "stopped typing ten minutes ago"
|
|
63
|
+
// becomes observable, so that is where an abandoned session is settled
|
|
64
|
+
// into a saved Version (PRD v3.3 D21). Idempotent and cheap.
|
|
65
|
+
await settleIdleUserVersions(id).catch(() => 0)
|
|
66
|
+
const meta = await readWorkbook(id, { includeCheckpoints: true })
|
|
67
|
+
if (meta === null) {
|
|
68
|
+
res.writeHead(404)
|
|
69
|
+
res.end()
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
const currentRevision = meta.revision ?? 0
|
|
73
|
+
const requested = url.searchParams.get('revision')
|
|
74
|
+
if (requested !== null) {
|
|
75
|
+
const targetRevision = Number(requested)
|
|
76
|
+
if (!Number.isInteger(targetRevision) || targetRevision < 0) {
|
|
77
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
78
|
+
res.end(JSON.stringify({ ok: false, error: 'revision 必须是大于等于 0 的整数' }))
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
const stepRaw = url.searchParams.get('step')
|
|
82
|
+
if (stepRaw !== null) {
|
|
83
|
+
const step = Number(stepRaw)
|
|
84
|
+
if (!Number.isInteger(step) || step < 0) {
|
|
85
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
86
|
+
res.end(JSON.stringify({ ok: false, error: 'step 必须是大于等于 0 的整数' }))
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const historical = stepRaw === null
|
|
91
|
+
? await readWorkbookAtRevision(id, targetRevision)
|
|
92
|
+
: await readWorkbookAtStep(id, targetRevision, Number(stepRaw))
|
|
93
|
+
if (historical === null) {
|
|
94
|
+
res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
|
|
95
|
+
res.end(JSON.stringify({ ok: false, error: '该版本的历史记录已被压缩,无法重建内容快照,因此不能预览。它仍然可以被恢复。', revision: currentRevision }))
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
99
|
+
// A Step preview also returns what that Step changed, so the canvas
|
|
100
|
+
// can scroll to and tint the cells (PRD v3.3 §7.2).
|
|
101
|
+
const stepChanges = stepRaw === null ? null : await readStepChanges(id, targetRevision, Number(stepRaw))
|
|
102
|
+
const stepResult = stepRaw === null ? {} : {
|
|
103
|
+
step: (historical as Awaited<ReturnType<typeof readWorkbookAtStep>>)?.step,
|
|
104
|
+
stepCount: (historical as Awaited<ReturnType<typeof readWorkbookAtStep>>)?.stepCount,
|
|
105
|
+
changes: stepChanges ?? [],
|
|
106
|
+
// A Step can be a batch, so this list is bounded the same way the diff
|
|
107
|
+
// list is and has to say so when it stops.
|
|
108
|
+
truncated: stepChanges === null ? false : diffReachedLimit(stepChanges),
|
|
109
|
+
}
|
|
110
|
+
res.end(JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
workbookId: id,
|
|
113
|
+
revision: historical.revision,
|
|
114
|
+
snapshot: historical.snapshot,
|
|
115
|
+
...stepResult,
|
|
116
|
+
}))
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
const operations = (meta.operations ?? []).slice().sort((left, right) => left.revision - right.revision)
|
|
120
|
+
const rawCheckpoints = meta?.checkpoints ?? []
|
|
121
|
+
const checkpoints = rawCheckpoints.map(checkpoint => checkpoint.revision).sort((left, right) => left - right)
|
|
122
|
+
const operationByRevision = new Map(operations.map(operation => [operation.revision, operation]))
|
|
123
|
+
const activeRevisions = activeHistoryRevisions(currentRevision, operationByRevision)
|
|
124
|
+
const canReplay = (targetRevision: number): boolean => canReplayHistoryRevision(targetRevision, currentRevision, checkpoints, operationByRevision)
|
|
125
|
+
const entries = operations.map(operation => ({
|
|
126
|
+
opId: operation.opId,
|
|
127
|
+
revision: operation.revision,
|
|
128
|
+
parentRevision: operation.parentRevision ?? Math.max(0, operation.revision - 1),
|
|
129
|
+
actor: operation.actor,
|
|
130
|
+
type: operation.type,
|
|
131
|
+
timestamp: operation.timestamp,
|
|
132
|
+
summary: operationSummary(operation),
|
|
133
|
+
canPreview: canReplay(operation.revision),
|
|
134
|
+
}))
|
|
135
|
+
const versions = historyVersionsFromOperations(operations, canReplay, activeRevisions)
|
|
136
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
137
|
+
res.end(JSON.stringify({
|
|
138
|
+
ok: true,
|
|
139
|
+
workbookId: id,
|
|
140
|
+
revision: currentRevision,
|
|
141
|
+
entries,
|
|
142
|
+
versions,
|
|
143
|
+
// Unified cross-plugin model (PRD §7.1); `versions` stays for compatibility.
|
|
144
|
+
versionRecords: toVersionRecords(versions, id, checkpointHasher(rawCheckpoints)),
|
|
145
|
+
checkpoints,
|
|
146
|
+
}))
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project file chooser's data source.
|
|
3
|
+
*
|
|
4
|
+
* The browser's own file dialog cannot do the two things this plugin needs: it
|
|
5
|
+
* shows no creation date, and it can browse the whole disk, which is the wrong
|
|
6
|
+
* default for a project that keeps its data in `<project>/storages`. This route
|
|
7
|
+
* lists one directory of that container instead, with each entry's size and its
|
|
8
|
+
* creation and modification times.
|
|
9
|
+
*
|
|
10
|
+
* Two properties are deliberate:
|
|
11
|
+
*
|
|
12
|
+
* - **Contained.** `dir` is a relative path inside `<project>/storages`, and
|
|
13
|
+
* anything that resolves outside it is refused. The chooser is not a general
|
|
14
|
+
* filesystem browser.
|
|
15
|
+
* - **Not the only way in.** Importing from outside the project stays possible
|
|
16
|
+
* (the OS dialog the client already had): containment is about where a
|
|
17
|
+
* project's files *live*, not about what a user may read.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-data-table/http/project-files
|
|
20
|
+
*/
|
|
21
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
22
|
+
import { join, resolve, sep } from 'node:path'
|
|
23
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
24
|
+
import { projectFor, sessionIdOf } from './project-scope.ts'
|
|
25
|
+
import { guardDataSheetRequest, type LegacyRoute } from './routes.ts'
|
|
26
|
+
import { projectStoragesDirectory, runInProject } from '../store/project.ts'
|
|
27
|
+
|
|
28
|
+
/** One entry of the chooser's listing. */
|
|
29
|
+
export interface ProjectFileEntry {
|
|
30
|
+
/** Basename inside the listed directory. */
|
|
31
|
+
readonly name: string
|
|
32
|
+
/** Path relative to `<project>/storages`, `/`-separated; empty at the root. */
|
|
33
|
+
readonly path: string
|
|
34
|
+
readonly type: 'file' | 'directory'
|
|
35
|
+
/** Bytes, for a regular file. */
|
|
36
|
+
readonly size: number
|
|
37
|
+
/** Filesystem creation time, ISO; empty when the platform does not report one. */
|
|
38
|
+
readonly createdAt: string
|
|
39
|
+
/** Last modification time, ISO. */
|
|
40
|
+
readonly modifiedAt: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One directory of the project's storage, as the chooser renders it. */
|
|
44
|
+
export interface ProjectFileListing {
|
|
45
|
+
/** The listed directory, relative to `<project>/storages`; empty at the root. */
|
|
46
|
+
readonly directory: string
|
|
47
|
+
/** How many entries were dropped by the cap. */
|
|
48
|
+
readonly truncated: number
|
|
49
|
+
readonly entries: readonly ProjectFileEntry[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Cap on one page; a chooser that renders ten thousand rows helps nobody. */
|
|
53
|
+
const MAX_ENTRIES = 500
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Normalize the requested directory to a relative path inside the storage root.
|
|
57
|
+
*
|
|
58
|
+
* @param raw - What the client sent: `''`, `data-table/`.
|
|
59
|
+
* @returns a `/`-separated relative path with no traversal.
|
|
60
|
+
* @throws when the path escapes the storage container.
|
|
61
|
+
*/
|
|
62
|
+
export function safeRelativeDirectory(raw: string): string {
|
|
63
|
+
// A leading separator or a `..` segment is refused rather than normalized
|
|
64
|
+
// away: `/etc` silently becoming `etc` would list a *different* directory than
|
|
65
|
+
// the caller asked for, and a caller that sends one is wrong about something.
|
|
66
|
+
const cleaned = raw.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
67
|
+
if (cleaned.length === 0) return ''
|
|
68
|
+
if (cleaned.startsWith('/')) throw new Error('目录必须是 storages 内的相对路径')
|
|
69
|
+
const segments = cleaned.split('/')
|
|
70
|
+
if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
|
|
71
|
+
throw new Error('目录必须是 storages 内的相对路径')
|
|
72
|
+
}
|
|
73
|
+
return segments.join('/')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** ISO creation and modification times of one entry, best effort. */
|
|
77
|
+
function times(entry: { birthtimeMs: number; mtimeMs: number }): { createdAt: string; modifiedAt: string } {
|
|
78
|
+
// `birthtimeMs` is 0 on filesystems that do not record a creation time; an
|
|
79
|
+
// empty string is honest there, while `0` would render as 1970.
|
|
80
|
+
const created = Number.isFinite(entry.birthtimeMs) && entry.birthtimeMs > 0 ? new Date(entry.birthtimeMs) : null
|
|
81
|
+
return {
|
|
82
|
+
createdAt: created === null ? '' : created.toISOString(),
|
|
83
|
+
modifiedAt: new Date(entry.mtimeMs).toISOString(),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List one page of a storage container rooted at `root`.
|
|
89
|
+
*
|
|
90
|
+
* `root` is the project directory, and the container is `<root>/storages`; the
|
|
91
|
+
* parameter exists so tests can point at a temporary tree without a Session.
|
|
92
|
+
*/
|
|
93
|
+
export async function listProjectFiles(root: string, relativeDirectory: string): Promise<ProjectFileListing> {
|
|
94
|
+
const directory = safeRelativeDirectory(relativeDirectory)
|
|
95
|
+
const target = join(projectStoragesDirectory(root), ...(directory === '' ? [] : directory.split('/')))
|
|
96
|
+
const containment = resolve(projectStoragesDirectory(root))
|
|
97
|
+
const resolved = resolve(target)
|
|
98
|
+
if (resolved !== containment && !resolved.startsWith(containment + sep)) {
|
|
99
|
+
throw new Error('目录必须是 storages 内的相对路径')
|
|
100
|
+
}
|
|
101
|
+
const dirents = await readdir(resolved, { withFileTypes: true }).catch(() => [])
|
|
102
|
+
const entries: ProjectFileEntry[] = []
|
|
103
|
+
for (const dirent of dirents) {
|
|
104
|
+
const absolute = join(resolved, dirent.name)
|
|
105
|
+
const info = await stat(absolute).catch(() => null)
|
|
106
|
+
if (info === null) continue
|
|
107
|
+
const isDirectory = info.isDirectory()
|
|
108
|
+
if (!isDirectory && !info.isFile()) continue
|
|
109
|
+
entries.push({
|
|
110
|
+
name: dirent.name,
|
|
111
|
+
path: directory === '' ? dirent.name : `${directory}/${dirent.name}`,
|
|
112
|
+
type: isDirectory ? 'directory' : 'file',
|
|
113
|
+
size: isDirectory ? 0 : info.size,
|
|
114
|
+
...times(info),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
entries.sort((left, right) => {
|
|
118
|
+
if (left.type !== right.type) return left.type === 'directory' ? -1 : 1
|
|
119
|
+
return left.name.localeCompare(right.name)
|
|
120
|
+
})
|
|
121
|
+
return { directory, truncated: Math.max(0, entries.length - MAX_ENTRIES), entries: entries.slice(0, MAX_ENTRIES) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Largest file the chooser will hand back; matches the client's own import cap. */
|
|
125
|
+
export const MAX_PICKED_BYTES = 25 * 1024 * 1024
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read one file from the storage container.
|
|
129
|
+
*
|
|
130
|
+
* The browser cannot open a path itself, so a choice made in the chooser comes
|
|
131
|
+
* back through here. Contained exactly like the listing — the same root, the
|
|
132
|
+
* same refusal — and capped, because the import pipeline caps at the same size
|
|
133
|
+
* and a truncated workbook is worse than a clear refusal.
|
|
134
|
+
*
|
|
135
|
+
* @param root - Project directory.
|
|
136
|
+
* @param relativePath - File path relative to `<project>/storages`.
|
|
137
|
+
* @param maxBytes - Cap, defaulting to {@link MAX_PICKED_BYTES}.
|
|
138
|
+
* @returns the file's bytes and its basename.
|
|
139
|
+
* @throws when the path escapes the container, is a directory, or is too large.
|
|
140
|
+
*/
|
|
141
|
+
export async function readProjectFile(root: string, relativePath: string, maxBytes = MAX_PICKED_BYTES): Promise<{ name: string; bytes: Buffer }> {
|
|
142
|
+
const container = resolve(projectStoragesDirectory(root))
|
|
143
|
+
const cleaned = safeRelativeDirectory(relativePath)
|
|
144
|
+
if (cleaned === '') throw new Error('请选择一个文件')
|
|
145
|
+
const resolved = resolve(join(container, ...cleaned.split('/')))
|
|
146
|
+
if (!resolved.startsWith(container + sep)) throw new Error('文件必须在 storages 内')
|
|
147
|
+
const info = await stat(resolved).catch(() => null)
|
|
148
|
+
if (info === null || !info.isFile()) throw new Error('文件不存在')
|
|
149
|
+
if (info.size > maxBytes) throw new Error(`文件过大(最大 ${Math.floor(maxBytes / (1024 * 1024))} MB)`)
|
|
150
|
+
return { name: cleaned.split('/').pop() ?? cleaned, bytes: await readFile(resolved) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The chooser's listing for one request.
|
|
155
|
+
*
|
|
156
|
+
* @param project - Absolute project directory, or `undefined` when none was named.
|
|
157
|
+
* @param relativeDirectory - Directory relative to the storage container.
|
|
158
|
+
* @returns the listing; empty when there is no project or no storage yet.
|
|
159
|
+
* @throws when the path escapes the container.
|
|
160
|
+
*/
|
|
161
|
+
export async function projectFiles(project: string | undefined, relativeDirectory: string): Promise<ProjectFileListing> {
|
|
162
|
+
if (project === undefined) return { directory: safeRelativeDirectory(relativeDirectory), truncated: 0, entries: [] }
|
|
163
|
+
return runInProject(project, async () => listProjectFiles(project, relativeDirectory))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The chooser's two routes: the listing, and one file's bytes.
|
|
168
|
+
*
|
|
169
|
+
* They live here rather than in the plugin body because the body has a 200-line
|
|
170
|
+
* budget — it assembles routes, it does not implement them (the same reason
|
|
171
|
+
* `assetRoutes()` is a module).
|
|
172
|
+
*
|
|
173
|
+
* @param ctx - Host context, read for the Session registry.
|
|
174
|
+
* @returns the routes to register.
|
|
175
|
+
*/
|
|
176
|
+
export function projectFileRoutes(ctx: Context): LegacyRoute[] {
|
|
177
|
+
/** The project this request belongs to, resolved once per request. */
|
|
178
|
+
const projectOf = (req: Parameters<NonNullable<LegacyRoute['handler']>>[0], url: URL): string | undefined =>
|
|
179
|
+
projectFor(ctx, sessionIdOf(req.headers as Record<string, unknown>, url))
|
|
180
|
+
|
|
181
|
+
return [{
|
|
182
|
+
kind: 'exact',
|
|
183
|
+
path: '/data-sheet/project-files.json',
|
|
184
|
+
handler: async (req, res) => {
|
|
185
|
+
if (!guardDataSheetRequest(req, res)) return
|
|
186
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
187
|
+
try {
|
|
188
|
+
const listing = await projectFiles(projectOf(req, url), url.searchParams.get('dir') ?? '')
|
|
189
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
190
|
+
res.end(JSON.stringify({ ok: true, ...listing }))
|
|
191
|
+
} catch (error) {
|
|
192
|
+
// A path outside the container is the caller's mistake, not a crash.
|
|
193
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
194
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }))
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}, {
|
|
198
|
+
// One file's bytes, so a choice made in the chooser can reach the import
|
|
199
|
+
// pipeline: the browser cannot open a path by itself.
|
|
200
|
+
kind: 'exact',
|
|
201
|
+
path: '/data-sheet/project-file.json',
|
|
202
|
+
handler: async (req, res) => {
|
|
203
|
+
if (!guardDataSheetRequest(req, res)) return
|
|
204
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
205
|
+
const project = projectOf(req, url)
|
|
206
|
+
if (project === undefined) {
|
|
207
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
208
|
+
res.end(JSON.stringify({ ok: false, error: '无法确定项目目录' }))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const file = await readProjectFile(project, url.searchParams.get('path') ?? '')
|
|
213
|
+
res.writeHead(200, {
|
|
214
|
+
'content-type': 'application/octet-stream',
|
|
215
|
+
'content-length': String(file.bytes.byteLength),
|
|
216
|
+
// The chooser showed this name; the client keeps using it.
|
|
217
|
+
'x-file-name': encodeURIComponent(file.name),
|
|
218
|
+
'cache-control': 'no-cache',
|
|
219
|
+
})
|
|
220
|
+
res.end(file.bytes)
|
|
221
|
+
} catch (error) {
|
|
222
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
223
|
+
res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }))
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}]
|
|
227
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which project a browser request writes into.
|
|
3
|
+
*
|
|
4
|
+
* The workbook routes are declared in `index.ts` and dispatched to the domain
|
|
5
|
+
* handlers in `http/`, so the decision "this request belongs to that project"
|
|
6
|
+
* has to be made once, before the dispatch — but the plugin body has a 200-line
|
|
7
|
+
* budget precisely so that decisions with a home live in it. This is that home.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-data-table/http/project-scope
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
12
|
+
import { projectScopeOf, runInProject, type SessionRegistry } from '../store/project.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Host fields the project scope is derived from.
|
|
16
|
+
*
|
|
17
|
+
* Read structurally rather than injected: both are Host services a plugin may
|
|
18
|
+
* run without, and refusing to load over a missing optional service would be a
|
|
19
|
+
* worse failure than the fallbacks below.
|
|
20
|
+
*/
|
|
21
|
+
interface HostScope {
|
|
22
|
+
/** Session registry, read for the header's working directory. */
|
|
23
|
+
sessions?: SessionRegistry
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The Session a browser request belongs to.
|
|
28
|
+
*
|
|
29
|
+
* The header first, because that is one line in the client's single transport
|
|
30
|
+
* (`requestJson` sets it on every call); the query parameter second, because an
|
|
31
|
+
* `EventSource` cannot set headers and the live collaboration stream is the one
|
|
32
|
+
* route that needs the fallback.
|
|
33
|
+
*
|
|
34
|
+
* @param headers - Node request headers.
|
|
35
|
+
* @param url - Parsed request URL.
|
|
36
|
+
* @returns the Session id, or `undefined` for a caller that named none.
|
|
37
|
+
*/
|
|
38
|
+
export function sessionIdOf(headers: Record<string, unknown>, url: URL): string | undefined {
|
|
39
|
+
const header = headers['x-dsh-session']
|
|
40
|
+
const value = Array.isArray(header) ? header[0] : header
|
|
41
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
42
|
+
const query = url.searchParams.get('sessionId')
|
|
43
|
+
return query !== null && query.length > 0 ? query : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The project directory one browser request writes into.
|
|
48
|
+
*
|
|
49
|
+
* A Session's header carries its working directory, and the registry is where
|
|
50
|
+
* that header is read — the same lookup the Agent tools make for the same
|
|
51
|
+
* Session id, so a panel and a tool call cannot mean two directories. `undefined`
|
|
52
|
+
* means **no project**: nobody named one, so the store keeps its pre-project
|
|
53
|
+
* location rather than inventing a project from the process directory — one Host
|
|
54
|
+
* serves several projects at once, and a shared fallback would mix them.
|
|
55
|
+
*
|
|
56
|
+
* @param ctx - Host context.
|
|
57
|
+
* @param sessionId - Session named by the request, when it named one.
|
|
58
|
+
* @returns an absolute project directory, or `undefined`.
|
|
59
|
+
*/
|
|
60
|
+
export function projectFor(ctx: Context, sessionId: string | undefined): string | undefined {
|
|
61
|
+
const scope = ctx as unknown as HostScope
|
|
62
|
+
return projectScopeOf(scope.sessions, sessionId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run one request's store work inside `project`, when there is one.
|
|
67
|
+
*
|
|
68
|
+
* A missing project is not a failure: the body runs unscoped and the store
|
|
69
|
+
* answers with the location it used before projects existed.
|
|
70
|
+
*
|
|
71
|
+
* @param project - Project directory, or `undefined` for "nobody named one".
|
|
72
|
+
* @param body - The request's store work.
|
|
73
|
+
* @returns whatever `body` returns.
|
|
74
|
+
*/
|
|
75
|
+
export function runForProject<T>(project: string | undefined, body: () => Promise<T>): Promise<T> {
|
|
76
|
+
return project === undefined ? body() : runInProject(project, body)
|
|
77
|
+
}
|