@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2
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/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
package/src/node/routes.ts
CHANGED
|
@@ -1,41 +1,70 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
|
-
import {
|
|
4
|
-
import type { Member, PresmiumNodePlugin, RequestContext, Route, ServerContribution } from '@lincyaw/kernel'
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
11
|
-
import {
|
|
3
|
+
import { DOCUMENT_ENTRY, fnv1a64, hasRole, isRecord, jsonResponse, problemResponse, PRODUCTION_AUDIO_AUTHORING, pushStream, SPEECH, sseResponse } from '@lincyaw/kernel'
|
|
4
|
+
import type { ChangesAnswer, CommitAnswer, CompileJob, DocumentsAnswer, JobsAnswer, Member, PresmiumNodePlugin, RequestContext, Route, SavedAnswer, ServerContribution, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
|
|
5
|
+
import { hasBearerToken } from '@lincyaw/server'
|
|
6
|
+
import { saveSummary } from '../domain/commit-message.ts'
|
|
7
|
+
import { documentSummaries, listDocuments } from './config.ts'
|
|
8
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
9
|
+
import { documentsDiff } from './document-operations.ts'
|
|
10
|
+
import type { DocumentOperations, DocumentsChange } from './document-operations.ts'
|
|
11
|
+
import { catchRequestErrors, RequestError } from './errors.ts'
|
|
12
|
+
import { lockedError } from './locks.ts'
|
|
12
13
|
import type { DocumentLocks } from './locks.ts'
|
|
13
14
|
import type { Revisions } from './revisions.ts'
|
|
14
15
|
import { reviewRoutes } from './review/routes.ts'
|
|
15
|
-
import type {
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
16
|
+
import type { AgentSessionReviews } from './review/agent-session-reviews.ts'
|
|
17
|
+
import { MAX_SOURCE, MAX_SOURCE_CHANGES, validateDocumentSource } from './source-editor.ts'
|
|
18
|
+
import type { SourceEditor } from './source-editor.ts'
|
|
19
|
+
import { listTemplates } from './templates.ts'
|
|
20
|
+
import type { Jobs } from './jobs.ts'
|
|
21
|
+
import type { WorkbenchApi } from './workbench-api.ts'
|
|
22
|
+
import { documentEditorRoutes } from './document-editor.ts'
|
|
23
|
+
import type { DocumentEditor } from './document-editor.ts'
|
|
24
|
+
import { createDocumentAudioAuthoring } from './document-audio.ts'
|
|
25
|
+
import { createDocumentImageAuthoring } from './document-images.ts'
|
|
26
|
+
import { createDocumentTranscriptions, documentTranscriptionRoutes } from './document-transcription.ts'
|
|
27
|
+
import type { DocumentTranscriptions, ProductionAudioAuthoring, SpeechService } from '@lincyaw/kernel'
|
|
28
|
+
import { documentDirOf } from './writer.ts'
|
|
29
|
+
import { createDocumentHistory } from './document-history.ts'
|
|
18
30
|
|
|
19
31
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
20
32
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
service?: WorkspaceService
|
|
28
|
-
token?: string
|
|
29
|
-
reviews?: Reviews
|
|
33
|
+
export const WORKSPACE_ID = 'presmium.workspace'
|
|
34
|
+
|
|
35
|
+
/** Host-level author routes and compiled workspace routes retain the same launcher authorization. */
|
|
36
|
+
export function authorizeWorkspaceRoutes(routes: Record<string, Route>, launcherToken?: string): Record<string, Route> {
|
|
37
|
+
if (!launcherToken) return routes
|
|
38
|
+
return Object.fromEntries(Object.entries(routes).map(([key, route]) => [key, { ...route, handle: (request, ctx) => hasBearerToken(request, launcherToken) ? route.handle(request, ctx) : Promise.resolve(problemResponse(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>')) } satisfies Route]))
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
/** The workspace's routes; the history, the document operations and the reviews are there only in a git repository. */
|
|
42
|
+
export interface WorkspaceRoutesOptions {
|
|
43
|
+
config: WorkspaceConfig
|
|
44
|
+
editor: SourceEditor
|
|
45
|
+
documentEditor: DocumentEditor
|
|
46
|
+
locks: DocumentLocks
|
|
47
|
+
revisions?: Revisions
|
|
48
|
+
operations?: DocumentOperations
|
|
49
|
+
reviews?: AgentSessionReviews
|
|
50
|
+
documentsChanged(change: DocumentsChange): Promise<void> | void
|
|
51
|
+
workbench?: WorkbenchApi
|
|
52
|
+
jobs?: Jobs
|
|
53
|
+
launcherToken?: string
|
|
54
|
+
audio?: ProductionAudioAuthoring
|
|
55
|
+
speech?: SpeechService
|
|
56
|
+
/** Supplied by a workspace host so remounting contributions does not replace or cancel tasks. */
|
|
57
|
+
transcriptions?: DocumentTranscriptions
|
|
58
|
+
transcriptionAdoptionRoutes?: Record<string, Route>
|
|
59
|
+
audioAuthoringRoutes?: Record<string, Route>
|
|
60
|
+
imageAuthoringRoutes?: Record<string, Route>
|
|
61
|
+
historyRoutes?: Record<string, Route>
|
|
62
|
+
authoringClientRoutes?: Record<string, Route>
|
|
63
|
+
projectRoutes?: Record<string, Route>
|
|
64
|
+
speechSettings?: Route
|
|
36
65
|
}
|
|
37
66
|
|
|
38
|
-
function
|
|
67
|
+
function toRendererIds(body: unknown): string[] | undefined {
|
|
39
68
|
if (!isRecord(body) || !Array.isArray(body.to)) return undefined
|
|
40
69
|
const ids = body.to.filter((id): id is string => typeof id === 'string' && /^[\w.-]+$/.test(id))
|
|
41
70
|
return ids.length > 0 ? ids : undefined
|
|
@@ -43,197 +72,258 @@ function rendererIds(body: unknown): string[] | undefined {
|
|
|
43
72
|
|
|
44
73
|
const NO_GIT = 'the workspace is not a git repository: run git init in it to use the document operations and the history'
|
|
45
74
|
|
|
46
|
-
function operationOf(body: unknown): DocumentOperation | undefined {
|
|
47
|
-
if (!isRecord(body) || typeof body.dir !== 'string') return undefined
|
|
48
|
-
const text = (value: unknown) => (typeof value === 'string' ? value.trim() : undefined)
|
|
49
|
-
const dir = body.dir.trim().replace(/^\/+|\/+$/g, '')
|
|
50
|
-
switch (body.op) {
|
|
51
|
-
case 'create': return { op: 'create', dir, ...text(body.title) ? { title: text(body.title) } : {}, ...text(body.template) ? { template: text(body.template) } : {} }
|
|
52
|
-
case 'rename':
|
|
53
|
-
case 'copy': return typeof body.to === 'string' ? { op: body.op, dir, to: body.to.trim().replace(/^\/+|\/+$/g, '') } : undefined
|
|
54
|
-
case 'delete': return { op: 'delete', dir }
|
|
55
|
-
default: return undefined
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
75
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} catch (error) {
|
|
63
|
-
if (error instanceof RequestError) return json({ error: error.message, ...error.detail }, error.status)
|
|
64
|
-
return problem(500, errorMessage(error))
|
|
65
|
-
}
|
|
76
|
+
function toChosenPaths(body: unknown, uncommitted: string[]): string[] {
|
|
77
|
+
const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
|
|
78
|
+
return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
function
|
|
69
|
-
return
|
|
81
|
+
function toSaveMessage(value: unknown): string {
|
|
82
|
+
return typeof value === 'string' ? [...(value.trim().split(/\r?\n/)[0] ?? '').trim()].slice(0, 200).join('') : ''
|
|
70
83
|
}
|
|
71
84
|
|
|
72
|
-
function chosenPaths(body: unknown, uncommitted: string[]): string[] {
|
|
73
|
-
const asked = isRecord(body) && Array.isArray(body.paths) ? body.paths.filter((path): path is string => typeof path === 'string') : undefined
|
|
74
|
-
return asked ? uncommitted.filter(path => asked.includes(path)) : uncommitted
|
|
75
|
-
}
|
|
76
85
|
|
|
77
|
-
export function
|
|
78
|
-
const {
|
|
79
|
-
const listed = () =>
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
if (!revisions) return problem(409, NO_GIT)
|
|
86
|
+
export function workspaceRoutes(options: WorkspaceRoutesOptions): ServerContribution {
|
|
87
|
+
const { config, editor, locks, revisions, operations, reviews, workbench } = options
|
|
88
|
+
const listed = () => listDocuments(config).map(doc => doc.dir)
|
|
89
|
+
const scopeOf = (ctx: RequestContext) => editor.scope(ctx.document)
|
|
90
|
+
const uncommitted = async (ctx: RequestContext) => (revisions ? await revisions.uncommittedFiles(editor.roots(scopeOf(ctx))) : [])
|
|
91
|
+
const withChosenChanges = async (request: Request, ctx: RequestContext, holder: string, work: (revisions: Revisions, paths: string[], member: Member, body: Record<string, unknown>) => Promise<Response>) => {
|
|
92
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
93
|
+
if (!revisions) return problemResponse(409, NO_GIT)
|
|
86
94
|
const body: unknown = await request.json().catch(() => ({}))
|
|
87
|
-
const paths =
|
|
88
|
-
if (paths.length === 0) return
|
|
89
|
-
const claimed = locks
|
|
90
|
-
if (
|
|
95
|
+
const paths = toChosenPaths(body, (await uncommitted(ctx)).map(file => file.path))
|
|
96
|
+
if (paths.length === 0) return problemResponse(409, 'no changes to take: every file is as committed')
|
|
97
|
+
const claimed = locks.claimAll(paths.map(path => documentDirOf(config, path) ?? ''), holder)
|
|
98
|
+
if ('holder' in claimed) throw lockedError(claimed.document, claimed.holder)
|
|
91
99
|
try {
|
|
92
|
-
return await work(paths, ctx.member, isRecord(body) ? body : {})
|
|
100
|
+
return await work(revisions, paths, ctx.member, isRecord(body) ? body : {})
|
|
93
101
|
} finally {
|
|
94
|
-
claimed
|
|
102
|
+
claimed.release()
|
|
95
103
|
}
|
|
96
104
|
}
|
|
105
|
+
const ownedHistory = options.historyRoutes ? undefined : createDocumentHistory({ config, editor, locks, revisions, changed: (before, after) => options.documentsChanged(documentsDiff(before, after)), reverted: (target, revision) => reviews?.reverted(target, revision) })
|
|
106
|
+
const ownedTranscriptions = options.transcriptions ? undefined : createDocumentTranscriptions({ root: config.root, editor, audio: options.audio, speech: options.speech })
|
|
107
|
+
const transcriptions = options.transcriptions ?? ownedTranscriptions!
|
|
108
|
+
const ownedAudio = options.audioAuthoringRoutes ? undefined : createDocumentAudioAuthoring({ root: config.root, editor, revisions, audio: options.audio, reviewRequired: config.settings['presmium.assistant']?.review === 'required' })
|
|
97
109
|
const routes: Record<string, Route> = {
|
|
110
|
+
...options.speechSettings ? { 'speech-settings': options.speechSettings } : {},
|
|
111
|
+
...documentEditorRoutes({ root: config.root, editor, documentEditor: options.documentEditor, workbench }),
|
|
112
|
+
...(options.audioAuthoringRoutes ?? ownedAudio!.routes),
|
|
113
|
+
...(options.imageAuthoringRoutes ?? createDocumentImageAuthoring({ root: config.root, editor, revisions, reviewRequired: config.settings['presmium.assistant']?.review === 'required' }).routes),
|
|
114
|
+
...documentTranscriptionRoutes(transcriptions),
|
|
115
|
+
...options.transcriptionAdoptionRoutes,
|
|
98
116
|
documents: {
|
|
99
117
|
role: 'reader',
|
|
100
|
-
handle: (request, ctx) =>
|
|
118
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
101
119
|
if (request.method === 'GET') {
|
|
102
|
-
const status = revisions ? await revisions.status(
|
|
103
|
-
return
|
|
120
|
+
const status = revisions ? await revisions.status(editor.roots(scopeOf(ctx))) : {}
|
|
121
|
+
return jsonResponse({ title: config.title, documents: documentSummaries(config), templates: listTemplates(config), git: Boolean(revisions), ...status, single: config.singleDeck } satisfies DocumentsAnswer)
|
|
104
122
|
}
|
|
105
|
-
if (request.method !== 'POST') return
|
|
106
|
-
|
|
107
|
-
if (!ctx.member.roles.some(role => role === 'editor' || role === 'owner')) return problem(403, 'document operations need the role editor')
|
|
108
|
-
if (!revisions) return problem(409, NO_GIT)
|
|
109
|
-
const operation = operationOf(await request.json().catch(() => undefined))
|
|
110
|
-
if (!operation) return problem(400, '{ op: create | rename | copy | delete, dir, to?, title?, template? }')
|
|
111
|
-
return json(await documents.apply(operation, ctx.member))
|
|
123
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
124
|
+
return problemResponse(409, 'project-plan-required')
|
|
112
125
|
}),
|
|
113
126
|
},
|
|
114
|
-
|
|
127
|
+
...(options.historyRoutes ?? ownedHistory!.routes),
|
|
128
|
+
...options.authoringClientRoutes,
|
|
129
|
+
...options.projectRoutes,
|
|
130
|
+
'files': {
|
|
115
131
|
role: 'editor',
|
|
116
|
-
handle: (request, ctx) =>
|
|
117
|
-
if (request.method !== 'GET') return
|
|
118
|
-
|
|
119
|
-
const params = new URL(request.url).searchParams
|
|
120
|
-
const limit = Number(params.get('limit'))
|
|
121
|
-
const document = params.get('document') ?? ((options.document ?? ctx.document) || (documents.config.single ? '.' : undefined))
|
|
122
|
-
return json({ revisions: await revisions.history({ ...document ? { document } : {}, ...params.get('task') ? { task: params.get('task')! } : {}, ...Number.isInteger(limit) && limit > 0 ? { limit } : {} }) } satisfies RevisionsAnswer)
|
|
132
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
133
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
134
|
+
return jsonResponse(editor.files(scopeOf(ctx), ctx.member))
|
|
123
135
|
}),
|
|
124
136
|
},
|
|
125
|
-
'
|
|
137
|
+
'source': {
|
|
126
138
|
role: 'editor',
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
maxBody: MAX_SOURCE * 3,
|
|
140
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
141
|
+
const scope = scopeOf(ctx)
|
|
142
|
+
if (request.method === 'GET') return jsonResponse(editor.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
|
|
143
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
130
144
|
const body: unknown = await request.json().catch(() => undefined)
|
|
131
|
-
if (!isRecord(body) || typeof body.
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const before = listed()
|
|
136
|
-
const result = await revisions.revert(body.id, ctx.member)
|
|
137
|
-
if (result.ok) {
|
|
138
|
-
await options.documentsChanged?.(difference(before, listed()))
|
|
139
|
-
const undone = trailers(result.revision.message)['Presmium-Revert']?.[0] ?? body.id
|
|
140
|
-
void options.reviews?.reverted(undone, result.revision.id).catch(() => undefined)
|
|
141
|
-
}
|
|
142
|
-
return json(result satisfies RevertAnswer, result.ok ? 200 : 409)
|
|
143
|
-
} finally {
|
|
144
|
-
claimed?.release()
|
|
145
|
-
}
|
|
145
|
+
if (!isRecord(body) || typeof body.path !== 'string' || typeof body.content !== 'string' || typeof body.base !== 'string') return problemResponse(400, '{ path, content, base: <the version opened>, message? }')
|
|
146
|
+
const message = toSaveMessage(body.message)
|
|
147
|
+
const commit = revisions && (async (path: string, lang: string | undefined) => (await revisions.ignores(path) ? undefined : revisions.commit({ files: [path], message: message || saveSummary(path, lang), origin: { kind: 'editor', member: ctx.member } })))
|
|
148
|
+
return jsonResponse(await editor.save(scope, body.path, body.content, body.base, ctx.member, commit) satisfies SavedAnswer)
|
|
146
149
|
}),
|
|
147
150
|
},
|
|
148
|
-
'
|
|
151
|
+
'source/create': {
|
|
149
152
|
role: 'editor',
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
maxBody: 8192,
|
|
154
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
155
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
156
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
157
|
+
if (!isRecord(body) || typeof body.path !== 'string') return problemResponse(400, '{ path: <new text file> }')
|
|
158
|
+
return jsonResponse(await editor.create(scopeOf(ctx), body.path, ctx.member), 201)
|
|
153
159
|
}),
|
|
154
160
|
},
|
|
155
|
-
'source': {
|
|
161
|
+
'source/transaction': {
|
|
156
162
|
role: 'editor',
|
|
157
|
-
maxBody: MAX_SOURCE * 3,
|
|
158
|
-
handle: (request, ctx) =>
|
|
159
|
-
|
|
160
|
-
if (request.method === 'GET') return json(source.read(scope, new URL(request.url).searchParams.get('path') ?? '', ctx.member))
|
|
161
|
-
if (request.method !== 'POST') return problem(405, 'GET or POST')
|
|
163
|
+
maxBody: MAX_SOURCE * 3 + 8192,
|
|
164
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
165
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
162
166
|
const body: unknown = await request.json().catch(() => undefined)
|
|
163
|
-
if (!isRecord(body) ||
|
|
164
|
-
|
|
167
|
+
if (!isRecord(body) || !Array.isArray(body.changes) || body.changes.length === 0 || body.changes.length > MAX_SOURCE_CHANGES || !body.changes.every(change => isRecord(change) && typeof change.path === 'string' && typeof change.content === 'string' && (change.base === null || (typeof change.base === 'string' && change.base.length > 0 && change.base.length <= 200)))) return problemResponse(400, '{ changes: [{ path, content, base: <the version opened, or null for a new file> }], message? }')
|
|
168
|
+
const changes = body.changes as SourceTransactionChange[]
|
|
169
|
+
const message = toSaveMessage(body.message)
|
|
170
|
+
const history = revisions ? {
|
|
171
|
+
run: <T>(work: () => Promise<T>) => revisions.transaction(work),
|
|
172
|
+
commit: async (paths: string[], lang: string | undefined) => {
|
|
173
|
+
const ignored = (await Promise.all(paths.map(async path => await revisions.ignores(path) ? path : undefined))).filter((path): path is string => path !== undefined)
|
|
174
|
+
if (ignored.length) throw new RequestError(409, `a source transaction cannot record ignored files: ${ignored.join(', ')}`, { ignored })
|
|
175
|
+
return revisions.commitWhileLocked({ paths, summary: message || saveSummary(paths[0]!, lang), body: '', origin: { kind: 'editor', member: ctx.member } })
|
|
176
|
+
},
|
|
177
|
+
} : undefined
|
|
178
|
+
return jsonResponse(await editor.transaction(scopeOf(ctx), changes, ctx.member, history) satisfies SourceTransactionAnswer)
|
|
165
179
|
}),
|
|
166
180
|
},
|
|
167
181
|
'changes': {
|
|
168
182
|
role: 'editor',
|
|
169
|
-
handle: (request, ctx) =>
|
|
170
|
-
if (request.method !== 'GET') return
|
|
171
|
-
return
|
|
183
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
184
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
185
|
+
return jsonResponse({ files: await uncommitted(ctx) } satisfies ChangesAnswer)
|
|
172
186
|
}),
|
|
173
187
|
},
|
|
174
188
|
'changes/commit': {
|
|
175
189
|
role: 'editor',
|
|
176
|
-
handle: (request, ctx) =>
|
|
190
|
+
handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:commit', async (history, paths, member, body) => {
|
|
177
191
|
const message = typeof body.message === 'string' ? body.message.trim().slice(0, 4000) : ''
|
|
178
|
-
if (!message) return
|
|
179
|
-
const
|
|
180
|
-
|
|
192
|
+
if (!message) return problemResponse(400, '{ message: what the change is, paths? }')
|
|
193
|
+
for (const path of paths) {
|
|
194
|
+
if (!path.endsWith(`/${DOCUMENT_ENTRY}`) && path !== DOCUMENT_ENTRY) continue
|
|
195
|
+
const source = editor.read(scopeOf(ctx), path, member)
|
|
196
|
+
validateDocumentSource(path, source.content)
|
|
197
|
+
}
|
|
198
|
+
const revision = await history.commit({ files: paths, message, origin: { kind: 'editor', member } })
|
|
199
|
+
return jsonResponse({ ...revision ? { revision } : {}, paths } satisfies CommitAnswer)
|
|
181
200
|
})),
|
|
182
201
|
},
|
|
183
|
-
...
|
|
184
|
-
...options.
|
|
202
|
+
...workbench ? workbenchRoutes(workbench) : {},
|
|
203
|
+
...options.jobs ? jobRoutes(options.jobs, editor) : {},
|
|
204
|
+
...reviews ? reviewRoutes(reviews) : {},
|
|
185
205
|
'changes/discard': {
|
|
186
206
|
role: 'editor',
|
|
187
|
-
handle: (request, ctx) =>
|
|
207
|
+
handle: (request, ctx) => catchRequestErrors(() => withChosenChanges(request, ctx, 'editor:discard', async (history, paths) => {
|
|
188
208
|
const before = listed()
|
|
189
|
-
await
|
|
190
|
-
await options.documentsChanged
|
|
191
|
-
return
|
|
209
|
+
await history.discard(paths)
|
|
210
|
+
await options.documentsChanged(documentsDiff(before, listed()))
|
|
211
|
+
return jsonResponse({ paths } satisfies CommitAnswer)
|
|
192
212
|
})),
|
|
193
213
|
},
|
|
194
214
|
}
|
|
195
|
-
|
|
196
|
-
if (token) {
|
|
197
|
-
for (const [key, route] of Object.entries(routes)) {
|
|
198
|
-
routes[key] = { ...route, handle: (request, ctx) => (authorized(request, token) ? route.handle(request, ctx) : Promise.resolve(problem(401, 'the workspace service\'s routes need its token: Authorization: Bearer <PRESMIUM_SERVICE_TOKEN>'))) }
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return { routes, ...options.service ? { proxies: options.service.proxies } : {} }
|
|
215
|
+
return { routes: authorizeWorkspaceRoutes(routes, options.launcherToken), close: async () => { await Promise.all([ownedTranscriptions?.close(), ownedAudio?.close(), ownedHistory?.close()]) } }
|
|
202
216
|
}
|
|
203
217
|
|
|
204
|
-
function
|
|
218
|
+
function workbenchRoutes(workbench: WorkbenchApi): Record<string, Route> {
|
|
205
219
|
return {
|
|
220
|
+
'voice-capabilities': {
|
|
221
|
+
role: 'editor',
|
|
222
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
223
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
224
|
+
return jsonResponse(await workbench.voiceCapabilities(ctx.document, ctx.member, request.signal))
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
'voice-catalog': {
|
|
228
|
+
role: 'editor',
|
|
229
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
230
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
231
|
+
const params = new URL(request.url).searchParams
|
|
232
|
+
return jsonResponse(await workbench.voiceCatalog(ctx.document, ctx.member, params.get('engine') || undefined, params.get('lang') || undefined, request.signal))
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
206
235
|
'ir': {
|
|
207
236
|
role: 'editor',
|
|
208
|
-
handle: (request, ctx) =>
|
|
237
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
238
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
239
|
+
const ir = await workbench.ir(ctx.document)
|
|
240
|
+
const capabilities = createHash('sha256').update(JSON.stringify([ir.sourceCapabilities ?? {}, ir.sourceFingerprints ?? {}, ir.authoring ?? {}])).digest('hex').slice(0, 8)
|
|
241
|
+
const etag = `"${ir.revision}-${capabilities}"`
|
|
242
|
+
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'no-store' } })
|
|
243
|
+
const response = jsonResponse(ir)
|
|
244
|
+
response.headers.set('ETag', etag)
|
|
245
|
+
response.headers.set('Cache-Control', 'no-store')
|
|
246
|
+
return response
|
|
247
|
+
}),
|
|
209
248
|
},
|
|
210
249
|
'ir/events': {
|
|
211
250
|
role: 'editor',
|
|
212
|
-
handle: async (request, ctx) => (request.method === 'GET' ?
|
|
251
|
+
handle: async (request, ctx) => (request.method === 'GET' ? sseResponse(workbench.events(ctx.document, request.signal), request.signal) : problemResponse(405, 'GET')),
|
|
213
252
|
},
|
|
214
253
|
'preview': {
|
|
215
254
|
role: 'editor',
|
|
216
|
-
handle: (request, ctx) =>
|
|
255
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
256
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
257
|
+
const params = new URL(request.url).searchParams
|
|
258
|
+
const renderer = params.get('view') ?? undefined
|
|
259
|
+
if (renderer !== undefined && renderer !== 'web' && renderer !== 'slides') return problemResponse(400, 'view must be web or slides')
|
|
260
|
+
return jsonResponse(await workbench.preview(ctx.document, renderer, params.get('prefetch') === '1'))
|
|
261
|
+
}),
|
|
217
262
|
},
|
|
218
263
|
'build': {
|
|
219
264
|
role: 'editor',
|
|
220
|
-
handle: (request, ctx) =>
|
|
221
|
-
if (request.method !== 'POST') return
|
|
222
|
-
return
|
|
265
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
266
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
267
|
+
return jsonResponse(await workbench.build(ctx.document, toRendererIds(await request.json().catch(() => undefined))))
|
|
223
268
|
}),
|
|
224
269
|
},
|
|
225
270
|
'check': {
|
|
226
271
|
role: 'editor',
|
|
227
|
-
handle: (request, ctx) =>
|
|
272
|
+
handle: (request, ctx) => catchRequestErrors(async () => (request.method === 'POST' ? jsonResponse(await workbench.check(ctx.document)) : problemResponse(405, 'POST'))),
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The document's builds and exports (`jobs`): `GET` what it can be made into and its jobs, `POST { target }` starts one;
|
|
279
|
+
* `jobs/cancel` (`POST { id }`); `jobs/events`, each change of the document's jobs as it happens (SSE, event `job`).
|
|
280
|
+
*/
|
|
281
|
+
function jobRoutes(jobs: Jobs, editor: SourceEditor): Record<string, Route> {
|
|
282
|
+
const own = (document: string, id: unknown) => {
|
|
283
|
+
const job = typeof id === 'string' ? jobs.get(id) : undefined
|
|
284
|
+
return job?.document === document ? job : undefined
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
'jobs': {
|
|
288
|
+
role: 'editor',
|
|
289
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
290
|
+
if (request.method === 'GET') return jsonResponse({ targets: await jobs.targets(ctx.document), jobs: jobs.list(ctx.document) } satisfies JobsAnswer)
|
|
291
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET, POST')
|
|
292
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
293
|
+
if (!isRecord(body) || typeof body.target !== 'string') return problemResponse(400, '{ target: a renderer or an export of the document }')
|
|
294
|
+
if (!isRecord(body.source) || typeof body.source.version !== 'string' || !body.source.version || body.source.version.length > 128 || typeof body.source.fingerprint !== 'string' || !body.source.fingerprint || body.source.fingerprint.length > 128) return problemResponse(400, 'job-source-required')
|
|
295
|
+
const source = editor.read(editor.scope(ctx.document), ctx.document ? `${ctx.document}/${DOCUMENT_ENTRY}` : DOCUMENT_ENTRY, ctx.member)
|
|
296
|
+
if (source.version !== body.source.version || fnv1a64(source.content) !== body.source.fingerprint) throw new RequestError(409, 'job-source-changed')
|
|
297
|
+
return jsonResponse(await jobs.start(ctx.document, body.target, { version: body.source.version, fingerprint: body.source.fingerprint }))
|
|
298
|
+
}),
|
|
299
|
+
},
|
|
300
|
+
'jobs/cancel': {
|
|
301
|
+
role: 'editor',
|
|
302
|
+
handle: (request, ctx) => catchRequestErrors(async () => {
|
|
303
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
304
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
305
|
+
const job = own(ctx.document, isRecord(body) ? body.id : undefined)
|
|
306
|
+
return job ? jsonResponse(jobs.cancel(job.id)) : problemResponse(404, 'no such job of the document')
|
|
307
|
+
}),
|
|
308
|
+
},
|
|
309
|
+
'jobs/events': {
|
|
310
|
+
role: 'editor',
|
|
311
|
+
handle: async (request, ctx) => {
|
|
312
|
+
if (request.method !== 'GET') return problemResponse(405, 'GET')
|
|
313
|
+
const events = pushStream<{ event: string, data: CompileJob }>(push => jobs.subscribe((job) => {
|
|
314
|
+
if (job.document === ctx.document) push({ event: 'job', data: job })
|
|
315
|
+
}), { signal: request.signal })
|
|
316
|
+
return sseResponse(events, request.signal)
|
|
317
|
+
},
|
|
228
318
|
},
|
|
229
319
|
}
|
|
230
320
|
}
|
|
231
321
|
|
|
232
|
-
export function
|
|
322
|
+
export function workspaceRoutesPlugin(options: WorkspaceRoutesOptions, borrowedRoutes?: Record<string, Route>): PresmiumNodePlugin {
|
|
233
323
|
return {
|
|
234
324
|
id: WORKSPACE_ID,
|
|
235
325
|
version,
|
|
236
326
|
kernel: '^1.0.0',
|
|
237
|
-
server:
|
|
327
|
+
server: ctx => borrowedRoutes ? { routes: borrowedRoutes } : workspaceRoutes({ ...options, audio: ctx.service(PRODUCTION_AUDIO_AUTHORING), speech: ctx.service(SPEECH) }),
|
|
238
328
|
}
|
|
239
329
|
}
|