@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.1

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 (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -2
  3. package/package.json +41 -3
  4. package/src/domain/commit-message.ts +80 -0
  5. package/src/domain/locks.ts +5 -0
  6. package/src/domain/paths.ts +98 -0
  7. package/src/index.ts +11 -0
  8. package/src/node/authoring-clients.ts +206 -0
  9. package/src/node/build.ts +42 -0
  10. package/src/node/compile-session.ts +187 -0
  11. package/src/node/config.ts +141 -0
  12. package/src/node/document-assets.ts +30 -0
  13. package/src/node/document-audio.ts +473 -0
  14. package/src/node/document-authoring.ts +436 -0
  15. package/src/node/document-editor.ts +157 -0
  16. package/src/node/document-history.ts +190 -0
  17. package/src/node/document-images.ts +241 -0
  18. package/src/node/document-operations.ts +191 -0
  19. package/src/node/document-transcription-adoption.ts +96 -0
  20. package/src/node/document-transcription.ts +471 -0
  21. package/src/node/document-voice.ts +115 -0
  22. package/src/node/errors.ts +23 -0
  23. package/src/node/git.ts +68 -0
  24. package/src/node/jobs.ts +361 -0
  25. package/src/node/locks.ts +46 -0
  26. package/src/node/page-delete.ts +106 -0
  27. package/src/node/project-files.ts +163 -0
  28. package/src/node/projects.ts +192 -0
  29. package/src/node/review/agent-session-reviews.ts +374 -0
  30. package/src/node/review/git-guard.ts +104 -0
  31. package/src/node/review/git-review.ts +428 -0
  32. package/src/node/review/record.ts +77 -0
  33. package/src/node/review/routes.ts +73 -0
  34. package/src/node/review/task-reviews.ts +50 -0
  35. package/src/node/revisions.ts +599 -0
  36. package/src/node/routes.ts +329 -0
  37. package/src/node/serve.ts +503 -0
  38. package/src/node/site.ts +132 -0
  39. package/src/node/source-editor.ts +332 -0
  40. package/src/node/speech-settings.ts +21 -0
  41. package/src/node/templates.ts +27 -0
  42. package/src/node/workbench-api.ts +176 -0
  43. package/src/node/writer.ts +389 -0
  44. package/templates/document/default/lecture.json +75 -0
@@ -0,0 +1,190 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { DOCUMENT_ENTRY, DOCUMENT_HISTORY_MAX_FILE_BYTES, DOCUMENT_HISTORY_MAX_REQUEST_CHARS, DOCUMENT_HISTORY_MAX_RESPONSE_CHARS, DOCUMENT_HISTORY_PAGE_SIZE, fnv1a64, hasRole, jsonResponse, problemResponse } from '@lincyaw/kernel'
3
+ import type { DocumentHistory, DocumentHistoryAnswer, DocumentHistoryBinding, DocumentHistoryChange, DocumentHistoryChanged, DocumentHistoryFile, DocumentHistorySource, HistoryRevision, Member, Route } from '@lincyaw/kernel'
4
+ import { validateDocumentHistoryChange, validateDocumentHistoryQuery, validateDocumentHistoryRead } from '@lincyaw/kernel/node'
5
+ import { listDocuments } from './config.ts'
6
+ import type { WorkspaceConfig } from './config.ts'
7
+ import { catchRequestErrors, RequestError } from './errors.ts'
8
+ import { lockedError } from './locks.ts'
9
+ import type { DocumentLocks } from './locks.ts'
10
+ import type { Revisions } from './revisions.ts'
11
+ import type { SourceEditor } from './source-editor.ts'
12
+ import { documentDirOf } from './writer.ts'
13
+
14
+ const MAX_RECEIPTS = 64
15
+ const entryOf = (document: string) => document ? `${document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY
16
+ const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
17
+ const digest = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex')
18
+ const sameBinding = (a: DocumentHistoryBinding, b: DocumentHistoryBinding) => a.snapshot === b.snapshot && a.source.version === b.source.version && a.source.fingerprint === b.source.fingerprint
19
+ const size = (value: unknown) => JSON.stringify(value, null, 2).length
20
+ const requestSize = (value: unknown) => { if (JSON.stringify(value).length > DOCUMENT_HISTORY_MAX_REQUEST_CHARS) throw new RequestError(413, 'history-request-too-large') }
21
+
22
+ export interface DocumentHistoryOptions {
23
+ config: WorkspaceConfig
24
+ editor: SourceEditor
25
+ locks: DocumentLocks
26
+ revisions?: Revisions
27
+ reviewRequired?: boolean
28
+ changed(before: string[], after: string[]): Promise<void> | void
29
+ reverted?(target: string, revision: string): Promise<void> | void
30
+ }
31
+
32
+ /** The one scoped history application boundary, borrowing the existing git transaction owner. */
33
+ export function createDocumentHistory(options: DocumentHistoryOptions): { service: DocumentHistory, routes: Record<string, Route>, close(): Promise<void> } {
34
+ const { config, editor, locks, revisions } = options
35
+ let closed = false
36
+ const open = () => { if (closed) throw new RequestError(503, 'history-owner-closed') }
37
+ const receipts = new Map<string, { signature: string, answer: DocumentHistoryChanged }>()
38
+ const pending = new Map<string, { signature: string, work: Promise<DocumentHistoryChanged> }>()
39
+ const scope = (document: string, member: Member) => {
40
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'history-permission-denied')
41
+ if (!listDocuments(config).some(item => item.dir === document)) throw new RequestError(404, 'history-document-not-found')
42
+ return { document, home: false }
43
+ }
44
+ const source = (document: string, member: Member): DocumentHistorySource => {
45
+ const raw = editor.read(scope(document, member), entryOf(document), member)
46
+ return { version: raw.version, fingerprint: fnv1a64(raw.content) }
47
+ }
48
+ const binding = async (document: string, member: Member): Promise<DocumentHistoryBinding> => { const snapshot = await history().head(); if (!snapshot) throw new RequestError(409, 'history-empty-repository'); return { source: source(document, member), snapshot } }
49
+ const history = () => { if (!revisions) throw new RequestError(409, 'history-git-unavailable'); return revisions }
50
+ const pathAllowed = (document: string, path: string, member: Member) => {
51
+ const owned = scope(document, member)
52
+ editor.assertEditable(owned, path, member)
53
+ if ((/[\x00-\x1f\x7f]/.test(path) || path.split('/').some(part => part.startsWith('.'))) || documentDirOf(config, path) !== document) throw new RequestError(403, 'history-file-outside-document')
54
+ }
55
+ const compact = (document: string, entry: HistoryRevision, member: Member): HistoryRevision => {
56
+ const files = entry.files.filter(path => { try { pathAllowed(document, path, member); return path.length <= 500 } catch { return false } })
57
+ const message = (entry.message.split('\n')[0] ?? '').slice(0, 240)
58
+ return { id: entry.id, message, author: entry.author.slice(0, 120), date: entry.date, origin: { kind: entry.origin.kind }, files: files.slice(0, 6),
59
+ ...entry.revertedBy ? { revertedBy: entry.revertedBy } : {}, ...entry.reverts ? { reverts: entry.reverts } : {}, ...entry.redo ? { redo: true } : {},
60
+ ...entry.operation ? { operation: entry.operation } : {}, ...entry.revertible === false ? { revertible: false } : {}, ...entry.undone !== undefined ? { undone: entry.undone, revertTarget: entry.revertTarget } : {} }
61
+ }
62
+ const intent = (document: string, which: 'undo' | 'redo' | 'revert', request: DocumentHistoryChange, member: Member, origin: 'editor' | 'agent') => {
63
+ open()
64
+ requestSize(request)
65
+ if (!validateDocumentHistoryChange(request)) throw new RequestError(400, 'history-change-invalid')
66
+ scope(document, member)
67
+ if (origin === 'agent' && options.reviewRequired) throw new RequestError(409, 'authoring-review-required')
68
+ return { key: JSON.stringify([document, member.id, request.id]), signature: digest(JSON.stringify([which, request.id, request.snapshot, request.source.version, request.source.fingerprint, request.expect])) }
69
+ }
70
+ const targetPaths = async (document: string, request: DocumentHistoryChange, member: Member) => {
71
+ const paths = await history().filesOf(request.expect)
72
+ if (!paths.length) throw new RequestError(404, 'history-revision-not-found')
73
+ for (const path of paths) {
74
+ pathAllowed(document, path, member)
75
+ const owners = await Promise.all([history().ownerAt(request.expect, path), history().ownerAt(`${request.expect}^1`, path)])
76
+ if (!owners.includes(document) || owners.some(owner => owner !== undefined && owner !== document)) throw new RequestError(403, 'history-file-outside-document')
77
+ }
78
+ return paths
79
+ }
80
+ const guard = (document: string, request: DocumentHistoryChange, member: Member) => async (changed: string[]) => {
81
+ for (const path of changed) pathAllowed(document, path, member)
82
+ if (!sameBinding(await binding(document, member), { source: request.source, snapshot: request.snapshot })) throw new RequestError(409, 'history-source-changed')
83
+ }
84
+ const service: DocumentHistory = {
85
+ async list(document, query, member) {
86
+ open()
87
+ requestSize(query)
88
+ if (!validateDocumentHistoryQuery(query)) throw new RequestError(400, 'history-query-invalid')
89
+ scope(document, member)
90
+ if (!revisions) return { document, available: false, revisions: [] }
91
+ if (!await revisions.head()) { if (query.snapshot !== undefined || (query.offset ?? 0) > 0) throw new RequestError(409, 'history-snapshot-changed'); return { document, available: true, unborn: true, revisions: [] } }
92
+ const before = await binding(document, member)
93
+ if ((query.offset ?? 0) > 0 && !query.snapshot) throw new RequestError(400, 'history-snapshot-required')
94
+ if (query.snapshot !== undefined && query.snapshot !== before.snapshot) throw new RequestError(409, 'history-snapshot-changed')
95
+ const full = await revisions.historyWithUndo({ document }), changes = full.revisions.filter(entry => !entry.reverts), offset = query.offset ?? 0
96
+ if (offset > changes.length) throw new RequestError(400, 'history-offset-invalid')
97
+ const answer: DocumentHistoryAnswer = { document, available: true, historyBinding: before, revisions: [], ...full.undo ? { undo: full.undo } : {}, ...full.redo ? { redo: full.redo } : {} }
98
+ let index = offset
99
+ for (const entry of changes.slice(offset, offset + DOCUMENT_HISTORY_PAGE_SIZE)) {
100
+ const row = compact(document, entry, member)
101
+ const candidate = { ...answer, revisions: [...answer.revisions, row], nextOffset: index + 1, truncated: true }
102
+ if (size(candidate) > DOCUMENT_HISTORY_MAX_RESPONSE_CHARS) break
103
+ answer.revisions.push(row); index++
104
+ if (!same(row, entry)) answer.truncated = true
105
+ }
106
+ if (index < changes.length) { if (index === offset) throw new RequestError(413, 'history-entry-too-large'); answer.nextOffset = index }
107
+ if (!sameBinding(before, await binding(document, member))) throw new RequestError(409, 'history-snapshot-changed')
108
+ return answer
109
+ },
110
+ async read(document, query, member) {
111
+ open()
112
+ requestSize(query)
113
+ if (!validateDocumentHistoryRead(query)) throw new RequestError(400, 'history-read-invalid')
114
+ const path = query.path ?? entryOf(document)
115
+ pathAllowed(document, path, member)
116
+ const bytes = await history().readFile(query.revision, path, DOCUMENT_HISTORY_MAX_FILE_BYTES, document)
117
+ let content: string
118
+ try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes) } catch { throw new RequestError(422, 'history-file-not-utf8') }
119
+ if (content.includes('\0')) throw new RequestError(422, 'history-file-not-utf8-text')
120
+ const offset = query.offset ?? 0
121
+ if (offset > content.length || offset > 0 && /[\uD800-\uDBFF]/.test(content[offset - 1]!) && /[\uDC00-\uDFFF]/.test(content[offset] ?? '')) throw new RequestError(400, 'history-offset-invalid')
122
+ const reply = (end: number): DocumentHistoryFile => ({ document, revision: query.revision, path, encoding: 'utf8-text-fragment', text: content.slice(offset, end), offset, ...end < content.length ? { nextOffset: end } : {}, totalChars: content.length, complete: end === content.length, sha256: digest(bytes), verification: 'historical-file-only' })
123
+ let low = offset, high = Math.min(content.length, offset + DOCUMENT_HISTORY_MAX_RESPONSE_CHARS)
124
+ while (low < high) { const middle = Math.ceil((low + high) / 2); if (size(reply(middle)) <= DOCUMENT_HISTORY_MAX_RESPONSE_CHARS) low = middle; else high = middle - 1 }
125
+ if (low < content.length && low > offset && /[\uD800-\uDBFF]/.test(content[low - 1]!) && /[\uDC00-\uDFFF]/.test(content[low]!)) low--
126
+ if (low === offset && offset < content.length) throw new RequestError(413, 'history-file-reply-too-large')
127
+ return reply(low)
128
+ },
129
+ async checkChange(document, which, request, member, origin = 'editor') {
130
+ const { key, signature } = intent(document, which, request, member, origin)
131
+ const known = receipts.get(key), flight = pending.get(key)
132
+ if (known || flight) {
133
+ if ((known ?? flight)!.signature !== signature) throw new RequestError(409, 'history-intent-reused')
134
+ if (known && !flight && !sameBinding(await binding(document, member), { source: known.answer.source, snapshot: known.answer.snapshot })) throw new RequestError(409, 'history-result-no-longer-current')
135
+ return
136
+ }
137
+ const paths = await targetPaths(document, request, member)
138
+ if (!sameBinding(await binding(document, member), { source: request.source, snapshot: request.snapshot })) throw new RequestError(409, 'history-source-changed')
139
+ const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path)!), 'revision:revert')
140
+ if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
141
+ try {
142
+ const result = await history().checkRevert(request.expect, member, which === 'revert' ? undefined : { which, document }, guard(document, request, member))
143
+ if (!result.ok) throw new RequestError(409, 'history-conflict', { conflicts: result.conflicts.slice(0, 6).map(path => path.slice(0, 500)), conflictCount: result.conflicts.length })
144
+ } finally { claimed.release() }
145
+ },
146
+ async change(document, which, request, member, origin = 'editor') {
147
+ const { key, signature } = intent(document, which, request, member, origin)
148
+ const known = receipts.get(key), flight = pending.get(key)
149
+ if (known || flight) {
150
+ if ((known ?? flight)!.signature !== signature) throw new RequestError(409, 'history-intent-reused')
151
+ if (flight) return flight.work
152
+ if (!sameBinding(await binding(document, member), { source: known!.answer.source, snapshot: known!.answer.snapshot })) throw new RequestError(409, 'history-result-no-longer-current')
153
+ return structuredClone(known!.answer)
154
+ }
155
+ if (pending.size >= MAX_RECEIPTS) throw new RequestError(429, 'history-busy')
156
+ const work = Promise.resolve().then(async () => {
157
+ const paths = await targetPaths(document, request, member)
158
+ const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path)!), 'revision:revert')
159
+ if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
160
+ try {
161
+ const beforeDocuments = listDocuments(config).map(item => item.dir)
162
+ const result = await history().revert(request.expect, member, which === 'revert' ? undefined : { which, document }, guard(document, request, member), origin)
163
+ if (!result.ok) throw new RequestError(409, 'history-conflict', { conflicts: result.conflicts.slice(0, 6).map(path => path.slice(0, 500)), conflictCount: result.conflicts.length })
164
+ const answer: DocumentHistoryChanged = { document, ok: true, revision: { id: result.revision.id, origin: { kind: result.revision.origin.kind } }, ...await binding(document, member), verification: { saved: true, compiled: 'not-requested', runtime: 'not-requested' } }
165
+ receipts.set(key, { signature, answer }); while (receipts.size > MAX_RECEIPTS) receipts.delete(receipts.keys().next().value!)
166
+ await options.changed(beforeDocuments, listDocuments(config).map(item => item.dir))
167
+ void Promise.resolve(options.reverted?.(request.expect, result.revision.id)).catch(() => undefined)
168
+ return structuredClone(answer)
169
+ } finally { claimed.release() }
170
+ })
171
+ pending.set(key, { signature, work })
172
+ try { return await work } finally { if (pending.get(key)?.work === work) pending.delete(key) }
173
+ },
174
+ }
175
+ const readRoute = (read: 'list' | 'read'): Route => ({ role: 'editor', handle: (request, ctx) => catchRequestErrors(async () => {
176
+ if (request.method !== 'GET') return problemResponse(405, 'GET')
177
+ let query: unknown
178
+ try { query = JSON.parse(new URL(request.url).searchParams.get('query') ?? '{}') } catch { throw new RequestError(400, 'history-query-invalid') }
179
+ if (read === 'list') { if (!validateDocumentHistoryQuery(query)) throw new RequestError(400, 'history-query-invalid'); return jsonResponse(await service.list(ctx.document, query, ctx.member)) }
180
+ if (!validateDocumentHistoryRead(query)) throw new RequestError(400, 'history-read-invalid')
181
+ return jsonResponse(await service.read(ctx.document, query, ctx.member))
182
+ }) })
183
+ const changeRoute = (which: 'undo' | 'redo' | 'revert'): Route => ({ role: 'editor', maxBody: 8192, handle: (request, ctx) => catchRequestErrors(async () => {
184
+ if (request.method !== 'POST') return problemResponse(405, 'POST')
185
+ const body: unknown = await request.json().catch(() => undefined)
186
+ if (!validateDocumentHistoryChange(body)) throw new RequestError(400, 'history-change-invalid')
187
+ return jsonResponse(await service.change(ctx.document, which, body, ctx.member))
188
+ }) })
189
+ return { service, async close() { closed = true; await Promise.allSettled([...pending.values()].map(item => item.work)); receipts.clear() }, routes: { 'revisions': readRoute('list'), 'history/list': readRoute('list'), 'history/file': readRoute('read'), 'history/undo': changeRoute('undo'), 'history/redo': changeRoute('redo'), 'revisions/revert': changeRoute('revert') } }
190
+ }
@@ -0,0 +1,241 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { constants } from 'node:fs'
3
+ import { lstat, open, stat } from 'node:fs/promises'
4
+ import { basename, extname, join, relative } from 'node:path'
5
+ import { imageSize } from 'image-size'
6
+ import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_IMAGE_MAX_BYTES, fnv1a64, hasRole, isRecord, jsonResponse } from '@lincyaw/kernel'
7
+ import type { Asset, CanonicalSource, DocumentImageAuthoring, DocumentImageImport, DocumentImageImportAnswer, DocumentWorkspaceImageImport, Member, RequestContext, Route, TeachingDocument, VerifiedDocumentImageImport } from '@lincyaw/kernel'
8
+ import { productionAssetFile, validateDocumentWorkspaceImageImport, validateDocumentWorkspaceImageInspect } from '@lincyaw/kernel/node'
9
+ import { readAuthoringDocument } from './document-editor.ts'
10
+ import { catchRequestErrors, RequestError } from './errors.ts'
11
+ import type { Revisions } from './revisions.ts'
12
+ import type { SourceEditor, SourceTransactionHistory } from './source-editor.ts'
13
+
14
+ interface Options { root: string, editor: SourceEditor, revisions?: Revisions, reviewRequired?: boolean }
15
+ interface ImageFormat { extension: string, mediaType: string }
16
+ interface Receipt { signature: string, assetId: string, version: string, files?: DocumentImageImportAnswer['files'], commit?: DocumentImageImportAnswer['commit'] }
17
+ const FORMATS: Record<string, ImageFormat> = {
18
+ png: { extension: 'png', mediaType: 'image/png' }, jpg: { extension: 'jpg', mediaType: 'image/jpeg' },
19
+ gif: { extension: 'gif', mediaType: 'image/gif' }, webp: { extension: 'webp', mediaType: 'image/webp' },
20
+ svg: { extension: 'svg', mediaType: 'image/svg+xml' }, bmp: { extension: 'bmp', mediaType: 'image/bmp' },
21
+ }
22
+ const MAX_DIMENSION = 16384, MAX_PIXELS = 40_000_000
23
+ const header = (value: unknown): value is DocumentImageImport => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && typeof value.baseDocumentVersion === 'string' && /^[\da-f]{20}$/.test(value.baseDocumentVersion) && Object.keys(value).every(key => key === 'id' || key === 'baseDocumentVersion')
24
+
25
+ /** Only an allowed header and bounded dimensions are inspected; this does not fully decode an image. */
26
+ function inspect(bytes: Uint8Array) {
27
+ let found: ReturnType<typeof imageSize>
28
+ try { found = imageSize(bytes) } catch { throw new RequestError(422, 'document-image-format') }
29
+ const format = found.type && Object.hasOwn(FORMATS, found.type) ? FORMATS[found.type] : undefined
30
+ if (!format) throw new RequestError(422, 'document-image-format')
31
+ const { width, height } = found
32
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || width * height > MAX_PIXELS) throw new RequestError(422, 'document-image-dimensions')
33
+ return { ...format, dimensions: { width, height } }
34
+ }
35
+
36
+ /** Original bytes and the canonical asset bank are committed or rolled back together. */
37
+ export function createDocumentImageAuthoring({ root, editor, revisions, reviewRequired = false }: Options): { service: DocumentImageAuthoring, routes: Record<string, Route> } {
38
+ const receipts = new Map<string, Receipt>(), pending = new Map<string, { signature: string, done: Promise<DocumentImageImportAnswer> }>()
39
+ const binding = (current: ReturnType<typeof readAuthoringDocument>): CanonicalSource => ({ documentId: current.content.id, version: current.source.version, fingerprint: fnv1a64(current.source.content) })
40
+ const opened = (ctx: RequestContext, version?: string, source?: CanonicalSource) => {
41
+ const current = readAuthoringDocument(editor, ctx)
42
+ if (version !== undefined && current.source.version !== version || source && (current.content.id !== source.documentId || fnv1a64(current.source.content) !== source.fingerprint)) throw new RequestError(409, 'document-version-changed', { version: current.source.version })
43
+ return current
44
+ }
45
+ const matches = async (ctx: RequestContext, asset: Asset, bytes: Uint8Array, format: ImageFormat, signal: AbortSignal): Promise<boolean> => {
46
+ if (asset.kind !== 'image' || asset.mediaType !== undefined && asset.mediaType !== format.mediaType) return false
47
+ const file = productionAssetFile(join(root, ctx.document), asset.location)
48
+ if (!file || ![`.${format.extension}`, ...(format.extension === 'jpg' ? ['.jpeg'] : [])].includes(extname(file).toLowerCase())) return false
49
+ const path = relative(root, file).split('\\').join('/')
50
+ let handle: Awaited<ReturnType<typeof open>> | undefined
51
+ try {
52
+ editor.readableFile({ document: ctx.document, home: false }, path, ctx.member)
53
+ signal.throwIfAborted()
54
+ handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
55
+ const before = await handle.stat()
56
+ if (!before.isFile() || before.size !== bytes.byteLength) return false
57
+ const actual = Buffer.alloc(bytes.byteLength)
58
+ let offset = 0
59
+ while (offset < actual.length) {
60
+ signal.throwIfAborted()
61
+ const next = await handle.read(actual, offset, actual.length - offset, offset)
62
+ if (!next.bytesRead) return false
63
+ offset += next.bytesRead
64
+ }
65
+ const after = await handle.stat(), pathNow = await stat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
66
+ return before.size === after.size && before.mtimeMs === after.mtimeMs && before.ino === pathNow.ino && before.dev === pathNow.dev && actual.equals(bytes)
67
+ } catch (error) {
68
+ signal.throwIfAborted()
69
+ if (error instanceof RequestError || error && typeof error === 'object' && 'code' in error && ['ENOENT', 'ELOOP', 'EACCES'].includes(String(error.code))) return false
70
+ throw error
71
+ } finally { await handle?.close() }
72
+ }
73
+ const answer = (ctx: RequestContext, change: DocumentImageImport, current: ReturnType<typeof readAuthoringDocument>, assetId: string, dimensions: { width: number, height: number }): DocumentImageImportAnswer => ({
74
+ changeId: change.id, document: ctx.document, version: current.source.version, fingerprint: fnv1a64(current.source.content), content: current.content, issues: current.issues, files: [], assetId, dimensions,
75
+ })
76
+ const remember = (key: string, receipt: Receipt) => {
77
+ receipts.set(key, receipt)
78
+ while (receipts.size > 64) receipts.delete(receipts.keys().next().value!)
79
+ }
80
+ const history = (ctx: RequestContext, document: TeachingDocument, signal: AbortSignal, origin: 'editor' | 'agent'): SourceTransactionHistory => ({
81
+ run: work => {
82
+ const guarded = () => { signal.throwIfAborted(); return work() }
83
+ return revisions ? revisions.transaction(guarded) : guarded()
84
+ },
85
+ commit: async paths => {
86
+ if (!revisions) return undefined
87
+ const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => Boolean(path))
88
+ if (ignored.length) throw new RequestError(409, 'document-files-ignored', { ignored })
89
+ return revisions.commitWhileLocked({ paths, summary: document.metadata.lang.startsWith('en') ? 'Import image' : '导入图片', body: '', origin: { kind: origin, member: ctx.member } })
90
+ },
91
+ })
92
+ const signatureOf = (change: DocumentImageImport, title: string, hash: string, origin: 'editor' | 'agent', source?: CanonicalSource, inputPath?: string) => createHash('sha256').update(JSON.stringify([change.id, change.baseDocumentVersion, source?.documentId ?? null, source?.fingerprint ?? null, origin, inputPath ?? null, title, hash])).digest('hex')
93
+ const keyOf = (ctx: RequestContext, change: DocumentImageImport) => JSON.stringify([ctx.document, ctx.member.id, change.id])
94
+ const apply = async (ctx: RequestContext, change: DocumentImageImport, title: string, bytes: Uint8Array, image: ReturnType<typeof inspect>, hash: string, signal: AbortSignal, origin: 'editor' | 'agent', source?: CanonicalSource, inputPath?: string): Promise<DocumentImageImportAnswer> => {
95
+ const signature = signatureOf(change, title, hash, origin, source, inputPath), key = keyOf(ctx, change)
96
+ const replay = async () => {
97
+ const previous = receipts.get(key)
98
+ if (!previous) return undefined
99
+ if (previous.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
100
+ const current = opened(ctx, previous.version), asset = current.content.assets[previous.assetId]
101
+ if (!asset || !await matches(ctx, asset, bytes, image, signal)) throw new RequestError(409, 'document-image-asset-changed')
102
+ return { ...answer(ctx, change, opened(ctx, previous.version), previous.assetId, image.dimensions), ...origin === 'agent' && previous.files ? { files: previous.files } : {}, ...origin === 'agent' && previous.commit ? { commit: previous.commit } : {} }
103
+ }
104
+ const previous = await replay()
105
+ if (previous) return previous
106
+ const active = pending.get(key)
107
+ if (active) {
108
+ if (active.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
109
+ await active.done
110
+ const repeated = await replay()
111
+ if (!repeated) throw new RequestError(409, 'document-image-receipt-expired')
112
+ return repeated
113
+ }
114
+ const work = async (): Promise<DocumentImageImportAnswer> => {
115
+ const current = opened(ctx, change.baseDocumentVersion, source)
116
+ const candidates = Object.values(current.content.assets).filter(asset => asset.kind === 'image' && (asset.fingerprint === undefined || asset.fingerprint === `sha256:${hash}`)).sort((a, b) => Number(b.fingerprint !== undefined) - Number(a.fingerprint !== undefined))
117
+ for (const asset of candidates) if (await matches(ctx, asset, bytes, image, signal)) {
118
+ const fresh = opened(ctx, change.baseDocumentVersion, source)
119
+ remember(key, { signature, assetId: asset.id, version: fresh.source.version })
120
+ return answer(ctx, change, fresh, asset.id, image.dimensions)
121
+ }
122
+ const assetId = `image_${randomUUID().replaceAll('-', '')}`, location = `assets/images/${assetId}.${image.extension}`, path = ctx.document ? `${ctx.document}/${location}` : location
123
+ const candidate = structuredClone(current.content)
124
+ candidate.assets[assetId] = { id: assetId, kind: 'image', location, ...title ? { title } : {}, fingerprint: `sha256:${hash}`, mediaType: image.mediaType }
125
+ const content = `${JSON.stringify(candidate, null, 2)}\n`
126
+ signal.throwIfAborted()
127
+ const saved = await editor.transactionWithBinary({ document: ctx.document, home: false }, { path: current.source.path, content, base: current.source.version }, { path, content: bytes }, ctx.member, history(ctx, candidate, signal, origin))
128
+ const version = saved.versions.find(file => file.path === current.source.path)!.version
129
+ remember(key, { signature, assetId, version, files: saved.versions, ...saved.revision ? { commit: saved.revision } : {} })
130
+ return { changeId: change.id, document: ctx.document, version, fingerprint: fnv1a64(content), content: candidate, issues: current.issues, files: saved.versions, assetId, dimensions: image.dimensions, ...saved.revision ? { commit: saved.revision } : {} }
131
+ }
132
+ const done = Promise.resolve().then(work)
133
+ pending.set(key, { signature, done })
134
+ try { return await done }
135
+ finally { if (pending.get(key)?.done === done) pending.delete(key) }
136
+ }
137
+ const ctxOf = (document: string, member: Member): RequestContext => ({ document, member, rest: '' })
138
+ const authorize = (member: Member) => { if (!hasRole(member, 'editor')) throw new RequestError(403, 'image-authoring-permission-denied') }
139
+ const workspacePath = (ctx: RequestContext, path: string) => {
140
+ editor.assertEditable({ document: ctx.document, home: false }, path, ctx.member)
141
+ if (path.split('/').some(part => part.startsWith('.'))) throw new RequestError(403, 'image-file-path')
142
+ }
143
+ const reader = async (ctx: RequestContext, path: string, signal?: AbortSignal) => {
144
+ workspacePath(ctx, path); signal?.throwIfAborted()
145
+ const file = editor.readableFile({ document: ctx.document, home: false }, path, ctx.member)
146
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
147
+ if (error.code === 'ELOOP') throw new RequestError(403, 'image-file-path')
148
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') throw new RequestError(404, 'image-file-missing')
149
+ throw error
150
+ })
151
+ try {
152
+ const before = await handle.stat(), named = await lstat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
153
+ if (!before.isFile() || before.dev !== named.dev || before.ino !== named.ino) throw new RequestError(403, 'image-file-path')
154
+ if (!before.size || before.size > DOCUMENT_IMAGE_MAX_BYTES) throw new RequestError(413, 'document-image-size')
155
+ const bytes = new Uint8Array(before.size + 1); let length = 0
156
+ while (length < bytes.length) { signal?.throwIfAborted(); const read = await handle.read(bytes, length, bytes.length - length, length); if (!read.bytesRead) break; length += read.bytesRead }
157
+ const after = await handle.stat(), current = await lstat(editor.readableFile({ document: ctx.document, home: false }, path, ctx.member))
158
+ const stamp = (value: typeof before) => [value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs].join(':')
159
+ if (length !== before.size || stamp(before) !== stamp(after) || after.dev !== current.dev || after.ino !== current.ino) throw new RequestError(409, 'image-file-changed')
160
+ signal?.throwIfAborted(); return bytes.slice(0, length)
161
+ } finally { await handle.close() }
162
+ }
163
+ const workspaceImport = async (ctx: RequestContext, request: DocumentWorkspaceImageImport, signal?: AbortSignal) => {
164
+ const bytes = await reader(ctx, request.file.path, signal), sha256 = createHash('sha256').update(bytes).digest('hex')
165
+ if (bytes.length !== request.file.byteLength || sha256 !== request.file.sha256) throw new RequestError(409, 'image-file-changed')
166
+ const image = inspect(bytes), title = [...basename(request.file.path).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
167
+ return { bytes, image, sha256, title }
168
+ }
169
+ let active = 0
170
+ const limited = async <T>(work: () => Promise<T>): Promise<T> => {
171
+ if (active >= 4) throw new RequestError(503, 'image-authoring-busy')
172
+ active++
173
+ try { return await work() } finally { active-- }
174
+ }
175
+ const service: DocumentImageAuthoring = {
176
+ async capabilities(document, member) {
177
+ authorize(member)
178
+ const source = binding(opened(ctxOf(document, member)))
179
+ return { source, available: true, verification: 'configuration-only', actions: { inspectWorkspace: true, importWorkspace: !reviewRequired }, ...reviewRequired ? { importUnavailableReason: 'review-required' as const } : {}, limits: { imageBytes: DOCUMENT_IMAGE_MAX_BYTES, receipts: 64, concurrent: 4 }, workspaceFileScope: 'same-document', idempotency: 'process-current-source', review: reviewRequired ? 'required-unsupported' : revisions ? 'locks-and-history' : 'locks-only' }
180
+ },
181
+ async inspectWorkspace(document, request, member, signal) {
182
+ authorize(member)
183
+ if (!validateDocumentWorkspaceImageInspect(request)) throw new RequestError(400, 'document-image-query-invalid')
184
+ return limited(async () => {
185
+ const ctx = ctxOf(document, member); opened(ctx, request.source.version, request.source)
186
+ const bytes = await reader(ctx, request.path, signal), image = inspect(bytes), sha256 = createHash('sha256').update(bytes).digest('hex')
187
+ signal?.throwIfAborted(); opened(ctx, request.source.version, request.source)
188
+ return { document, source: request.source, file: { path: request.path, sha256, byteLength: bytes.length }, image: { mediaType: image.mediaType, dimensions: image.dimensions } }
189
+ })
190
+ },
191
+ async checkWorkspaceImport(document, request, member, signal) {
192
+ authorize(member)
193
+ if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
194
+ if (!validateDocumentWorkspaceImageImport(request)) throw new RequestError(400, 'document-image-import-invalid')
195
+ return limited(async () => {
196
+ const ctx = ctxOf(document, member), change = { id: request.id, baseDocumentVersion: request.source.version }, key = keyOf(ctx, change)
197
+ if (!receipts.has(key) && !pending.has(key)) opened(ctx, request.source.version, request.source)
198
+ const { sha256, title } = await workspaceImport(ctx, request, signal), signature = signatureOf(change, title, sha256, 'agent', request.source, request.file.path)
199
+ const receipt = receipts.get(key), known = receipt ?? pending.get(key)
200
+ if (known && known.signature !== signature) throw new RequestError(409, 'document-change-id-reused')
201
+ // A receipt of this exact import replays while the source is still the one it saved; an import in flight decides itself.
202
+ if (receipt) opened(ctx, receipt.version)
203
+ else if (!known) opened(ctx, request.source.version, request.source)
204
+ })
205
+ },
206
+ async importWorkspace(document, request, member, signal) {
207
+ authorize(member)
208
+ if (reviewRequired) throw new RequestError(409, 'authoring-review-required')
209
+ if (!validateDocumentWorkspaceImageImport(request)) throw new RequestError(400, 'document-image-import-invalid')
210
+ return limited(async () => {
211
+ const ctx = ctxOf(document, member), { bytes, image, sha256, title } = await workspaceImport(ctx, request, signal)
212
+ const result = await apply(ctx, { id: request.id, baseDocumentVersion: request.source.version }, title, bytes, image, sha256, signal ?? new AbortController().signal, 'agent', request.source, request.file.path)
213
+ try {
214
+ const current = opened(ctx, result.version), asset = current.content.assets[result.assetId], file = asset && productionAssetFile(join(root, document), asset.location)
215
+ if (!asset || !file || !await matches(ctx, asset, bytes, image, new AbortController().signal)) throw new Error('asset-changed')
216
+ const verified = opened(ctx, result.version)
217
+ return { ...result, content: verified.content, fingerprint: fnv1a64(verified.source.content), verification: { saved: true, source: binding(verified), asset: { assetId: result.assetId, path: relative(root, file).split('\\').join('/'), sha256, byteLength: bytes.length }, compiled: 'not-requested', runtime: 'not-requested' } } satisfies VerifiedDocumentImageImport
218
+ } catch (error) {
219
+ throw new RequestError(409, 'image-readback-changed', { committed: result.files.length > 0, changeId: request.id, version: result.version, assetId: result.assetId, reason: error instanceof Error ? error.message : 'readback-failed' })
220
+ }
221
+ })
222
+ },
223
+ }
224
+ const routes: Record<string, Route> = {
225
+ 'document/image': { role: 'editor', maxBody: DOCUMENT_IMAGE_MAX_BYTES + (64 << 10), handle: (request, ctx) => catchRequestErrors(async () => {
226
+ if (request.method !== 'POST') throw new RequestError(405, 'POST')
227
+ const form = await request.formData().catch(() => undefined)
228
+ if (!form || [...form.keys()].some(key => key !== 'change' && key !== 'file') || form.getAll('change').length !== 1 || form.getAll('file').length !== 1) throw new RequestError(400, 'document-image-form-invalid')
229
+ const text = form.get('change'), file = form.get('file')
230
+ if (typeof text !== 'string' || text.length > 4096 || !(file instanceof File)) throw new RequestError(400, 'document-image-form-invalid')
231
+ let change: unknown
232
+ try { change = JSON.parse(text) } catch { throw new RequestError(400, 'document-image-change-invalid') }
233
+ if (!header(change)) throw new RequestError(400, 'document-image-change-invalid')
234
+ if (!file.size || file.size > DOCUMENT_IMAGE_MAX_BYTES) throw new RequestError(413, 'document-image-size')
235
+ const bytes = new Uint8Array(await file.arrayBuffer()), image = inspect(bytes), hash = createHash('sha256').update(bytes).digest('hex')
236
+ const title = [...(file.name.replaceAll('\\', '/').split('/').at(-1) ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()].slice(0, ASSET_TITLE_MAX_LENGTH).join('')
237
+ return jsonResponse(await apply(ctx, change, title, bytes, image, hash, request.signal, 'editor'))
238
+ }) },
239
+ }
240
+ return { service, routes }
241
+ }
@@ -0,0 +1,191 @@
1
+ import { existsSync, mkdirSync, renameSync, rmdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { DocumentOperation, DocumentFileChange, Member, OperationAnswer, RevisionOperation } from '@lincyaw/kernel'
4
+ import { hasRole } from '@lincyaw/kernel'
5
+ import type { CommitOrigin } from '../domain/commit-message.ts'
6
+ import { invalidDocumentDir, isWithin } from '../domain/paths.ts'
7
+ import { isDocumentDir, listDocuments } from './config.ts'
8
+ import type { WorkspaceConfig } from './config.ts'
9
+ import { RequestError } from './errors.ts'
10
+ import type { Git } from './git.ts'
11
+ import { lockedError } from './locks.ts'
12
+ import type { DocumentLocks } from './locks.ts'
13
+ import type { Revisions } from './revisions.ts'
14
+ import { DEFAULT_TEMPLATE, listTemplates } from './templates.ts'
15
+ import { DocumentCheckpointRefusal, makeDocumentDir } from './writer.ts'
16
+ import type { DocumentWriter } from './writer.ts'
17
+
18
+ export interface DocumentsChange {
19
+ removed: string[]
20
+ added: string[]
21
+ }
22
+
23
+ export function documentsDiff(before: string[], after: string[]): DocumentsChange {
24
+ return { removed: before.filter(dir => !after.includes(dir)), added: after.filter(dir => !before.includes(dir)) }
25
+ }
26
+
27
+ export interface DocumentOperationsOptions {
28
+ config: WorkspaceConfig
29
+ git: Git
30
+ revisions: Revisions
31
+ writer: DocumentWriter
32
+ changed?: (change: DocumentsChange) => void | Promise<void>
33
+ locks?: DocumentLocks
34
+ }
35
+
36
+ function touched(operation: DocumentOperation): string[] {
37
+ return operation.op === 'create' || operation.op === 'delete' ? [operation.dir] : [operation.dir, operation.to]
38
+ }
39
+
40
+ /** Creating, renaming, copying and deleting a document, each under the document locks and as one commit. */
41
+ export class DocumentOperations {
42
+ readonly #config: WorkspaceConfig
43
+ readonly #revisions: Revisions
44
+ readonly #writer: DocumentWriter
45
+ readonly #changed: (change: DocumentsChange) => void | Promise<void>
46
+ readonly #locks?: DocumentLocks
47
+
48
+ constructor(options: DocumentOperationsOptions) {
49
+ this.#config = options.config
50
+ this.#revisions = options.revisions
51
+ this.#writer = options.writer
52
+ this.#changed = options.changed ?? (() => {})
53
+ this.#locks = options.locks
54
+ }
55
+
56
+ async apply(operation: DocumentOperation, member: Member, prepared?: { documentId?: string, origin: 'editor' | 'agent', changes?: DocumentFileChange[], verify(): Promise<void> }): Promise<OperationAnswer> {
57
+ if (!hasRole(member, 'editor')) throw new RequestError(403, 'document operations need the role editor')
58
+ if (this.#config.singleDeck) throw new RequestError(409, 'this workspace is a single deck: it has no document operations')
59
+ if (operation.op !== 'create' && operation.dir === '') throw new RequestError(409, 'project-root-operation-unavailable')
60
+ let release: (() => void) | undefined, committedError: RequestError | undefined, cleanupError: unknown
61
+ try {
62
+ const answer = await this.#revisions.transaction(async () => {
63
+ if (prepared) {
64
+ const claimed = this.#locks?.claimAll([''], `document:${operation.op}`)
65
+ if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
66
+ release ??= claimed?.release
67
+ }
68
+ await prepared?.verify()
69
+ const head = await this.#revisions.head()
70
+ const before = await this.#writer.checkpoint(touched(operation), operation.op === 'rename' ? { from: operation.dir, to: operation.to } : undefined, prepared?.changes?.map(change => change.path)).catch(error => {
71
+ if (error instanceof DocumentCheckpointRefusal) throw new RequestError(error.status, error.message, error.detail)
72
+ throw error
73
+ })
74
+ let discardCheckpoint = true
75
+ try {
76
+ const claimed = prepared ? undefined : this.#locks?.claimAll(before.documents, `document:${operation.op}`)
77
+ if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
78
+ release ??= claimed?.release
79
+ let mutationStarted = false
80
+ try {
81
+ const result = await this.#apply(operation, member, () => { mutationStarted = true; discardCheckpoint = false }, prepared)
82
+ discardCheckpoint = true
83
+ return result
84
+ }
85
+ catch (error) {
86
+ if (!mutationStarted) throw error
87
+ let after: string | undefined
88
+ try { after = await this.#revisions.head() }
89
+ catch { throw new RequestError(500, 'project-commit-state-unavailable', { committed: 'unknown', recovery: before.recovery }) }
90
+ if (after === undefined) throw new RequestError(500, 'project-commit-state-unavailable', { committed: 'unknown', recovery: before.recovery })
91
+ if (after !== head) { discardCheckpoint = true; committedError = new RequestError(409, 'project-readback-changed', { committed: true, snapshot: after }); return undefined }
92
+ try { await before.restore() }
93
+ catch { throw new RequestError(500, 'project-rollback-failed', { committed: false, recovery: before.recovery }) }
94
+ discardCheckpoint = true
95
+ throw error
96
+ }
97
+ } finally { if (discardCheckpoint) try { before.dispose() } catch (error) { cleanupError = error } }
98
+ })
99
+ if (committedError) throw committedError
100
+ if (!answer) throw new RequestError(409, 'project-result-unavailable')
101
+ if (cleanupError) throw new RequestError(500, 'project-checkpoint-cleanup-failed', { committed: Boolean(answer.revision), snapshot: await this.#revisions.head() })
102
+ const added = operation.op === 'delete' ? [] : [answer.dir!]
103
+ const removed = operation.op === 'rename' || operation.op === 'delete' ? [operation.dir] : []
104
+ await this.#changed({ removed, added })
105
+ return answer
106
+ } finally {
107
+ release?.()
108
+ }
109
+ }
110
+
111
+ async #fill(dir: string, fill: () => Promise<void>): Promise<void> {
112
+ makeDocumentDir(this.#config.root, dir)
113
+ try {
114
+ await fill()
115
+ } catch (error) {
116
+ await this.#remove(dir).catch(() => undefined)
117
+ throw error
118
+ }
119
+ }
120
+
121
+ async #remove(dir: string): Promise<void> {
122
+ for (let attempt = 0; ; attempt++) {
123
+ await this.#writer.empty(dir)
124
+ try {
125
+ rmdirSync(join(this.#config.root, dir))
126
+ return
127
+ } catch (error) {
128
+ if (attempt > 0 || (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY') throw new RequestError(409, `${dir} could not be removed: ${error instanceof Error ? error.message : String(error)}`)
129
+ }
130
+ }
131
+ }
132
+
133
+ async #apply(operation: DocumentOperation, member: Member, beforeWrite: () => void, prepared?: { documentId?: string, origin: 'editor' | 'agent', changes?: DocumentFileChange[] }): Promise<OperationAnswer> {
134
+ const config = this.#config
135
+ const origin = (done: RevisionOperation): CommitOrigin => ({ kind: prepared?.origin === 'agent' ? 'agent' : 'document', member, operation: done })
136
+ const commit = (paths: string[], summary: string, body: string, done: RevisionOperation) => this.#revisions.commitWhileLocked({ paths, summary, body, origin: origin(done) })
137
+ const known = listDocuments(config).map(doc => doc.dir)
138
+ const existing = (dir: string) => {
139
+ if (!known.includes(dir)) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
140
+ return dir
141
+ }
142
+ const fresh = (dir: string) => {
143
+ const invalid = invalidDocumentDir(dir)
144
+ if (invalid) throw new RequestError(400, invalid)
145
+ if (!isDocumentDir(config, dir)) throw new RequestError(400, `"${dir}" is not where the workspace configuration's documents are (${config.documents.join(', ')})`)
146
+ const holder = known.find(doc => doc !== '' && (isWithin(dir, doc) || isWithin(doc, dir)))
147
+ if (holder !== undefined) throw new RequestError(409, `${dir} would hold or be inside the document ${holder}`)
148
+ if (existsSync(join(config.root, dir))) throw new RequestError(409, `${dir} exists already`)
149
+ return dir
150
+ }
151
+ switch (operation.op) {
152
+ case 'create': {
153
+ const dir = fresh(operation.dir)
154
+ const templates = listTemplates(config)
155
+ const name = operation.template ?? (templates.includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : templates[0])
156
+ if (!name || !templates.includes(name)) throw new RequestError(400, `no document template ${JSON.stringify(name ?? '')}`)
157
+ const title = operation.title?.trim() || dir.split('/').at(-1)!
158
+ beforeWrite()
159
+ await this.#fill(dir, async () => { await this.#writer.template(dir, name, title, prepared?.documentId); if (prepared?.changes) await this.#writer.write(prepared.changes) })
160
+ const revision = await commit([dir], `Create document ${dir}`, `Title: ${title}\nTemplate: ${name}`, { op: 'create', dir })
161
+ return { revision, dir }
162
+ }
163
+ case 'rename': {
164
+ const from = existing(operation.dir)
165
+ const to = fresh(operation.to)
166
+ beforeWrite()
167
+ mkdirSync(dirname(join(config.root, to)), { recursive: true })
168
+ renameSync(join(config.root, from), join(config.root, to))
169
+ const rewritten = (await this.#writer.relink(from, to)).filter(path => !isWithin(path, to))
170
+ if (prepared?.changes) { await this.#writer.write(prepared.changes); rewritten.push(...prepared.changes.map(change => change.path)) }
171
+ const revision = await commit([from, to, ...rewritten], `Rename document ${from} to ${to}`, rewritten.length ? `Links rewritten in: ${rewritten.join(', ')}` : '', { op: 'rename', dir: from, to })
172
+ return { revision, dir: to }
173
+ }
174
+ case 'copy': {
175
+ const from = existing(operation.dir)
176
+ const to = fresh(operation.to)
177
+ beforeWrite()
178
+ await this.#fill(to, async () => { await this.#writer.copy(from, to, prepared?.documentId, operation.title); if (prepared?.changes) await this.#writer.write(prepared.changes) })
179
+ const revision = await commit([to], `Copy document ${from} to ${to}`, '', { op: 'copy', dir: from, to })
180
+ return { revision, dir: to }
181
+ }
182
+ case 'delete': {
183
+ const dir = existing(operation.dir)
184
+ beforeWrite()
185
+ await this.#remove(dir)
186
+ const revision = await commit([dir], `Delete document ${dir}`, '', { op: 'delete', dir })
187
+ return { revision }
188
+ }
189
+ }
190
+ }
191
+ }