@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.10
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 +7 -7
- package/package.json +6 -2
- package/src/access.ts +18 -0
- package/src/anchors.ts +5 -1
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +0 -8
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +5 -82
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +4 -5
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +305 -310
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +1 -51
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +82 -70
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +28 -278
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +8 -0
- package/src/execution.ts +5 -1
- package/src/fingerprint.ts +1 -1
- package/src/follow.ts +26 -30
- package/src/git.ts +1 -1
- package/src/index.ts +66 -13
- package/src/ir.ts +54 -5
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +38 -25
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +4 -8
- package/src/logging.ts +1 -1
- package/src/{markdown.ts → lower-myst.ts} +2 -1
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -133
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1234 -0
- package/src/props.ts +17 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +12 -7
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +3 -3
- package/src/services.ts +6 -6
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- package/src/voice.ts +86 -0
package/src/contracts.ts
CHANGED
|
@@ -1,285 +1,35 @@
|
|
|
1
|
-
import type { AgentTool } from './contracts/agent.ts'
|
|
2
|
-
import type { EnvironmentProvider, EnvironmentSpec, Executor } from './contracts/execution.ts'
|
|
3
|
-
import type { NodeServiceContext, ServerContext, ServerContribution } from './contracts/server.ts'
|
|
4
|
-
import { defineService } from './contracts/service.ts'
|
|
5
|
-
import type { ServiceToken } from './contracts/service.ts'
|
|
6
|
-
import type { DirectiveSpec, IRDocument, IRPass, Renderer, RendererContribution, RoleSpec } from './ir.ts'
|
|
7
|
-
|
|
8
1
|
export * from './contracts/agent.ts'
|
|
2
|
+
export * from './contracts/authoring.ts'
|
|
3
|
+
export * from './contracts/document-reads.ts'
|
|
4
|
+
export * from './contracts/anchors.ts'
|
|
5
|
+
export * from './contracts/checks.ts'
|
|
6
|
+
export * from './contracts/classroom.ts'
|
|
7
|
+
export * from './contracts/diagnostics.ts'
|
|
9
8
|
export * from './contracts/execution.ts'
|
|
9
|
+
export * from './contracts/locale.ts'
|
|
10
|
+
export * from './contracts/offline.ts'
|
|
10
11
|
export * from './contracts/operations.ts'
|
|
12
|
+
export * from './contracts/plugin.ts'
|
|
13
|
+
export * from './contracts/presenter-panels.ts'
|
|
14
|
+
export * from './contracts/production.ts'
|
|
15
|
+
export * from './contracts/production-assets.ts'
|
|
16
|
+
export * from './contracts/document-audio.ts'
|
|
17
|
+
export * from './contracts/document-voice.ts'
|
|
18
|
+
export * from './contracts/voiceover-execution.ts'
|
|
11
19
|
export * from './contracts/preview.ts'
|
|
20
|
+
export * from './contracts/document.ts'
|
|
12
21
|
export * from './contracts/server.ts'
|
|
13
22
|
export * from './contracts/service.ts'
|
|
14
23
|
export * from './contracts/speech.ts'
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
export
|
|
26
|
-
|
|
27
|
-
version: string
|
|
28
|
-
kernel: string
|
|
29
|
-
provides?: ServiceToken<unknown>[]
|
|
30
|
-
requires?: ServiceRequirement[]
|
|
31
|
-
capabilities?: Capability[]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export type PresentationMode = 'slides' | 'document'
|
|
35
|
-
|
|
36
|
-
export interface Logger {
|
|
37
|
-
debug(...args: unknown[]): void
|
|
38
|
-
info(...args: unknown[]): void
|
|
39
|
-
warn(...args: unknown[]): void
|
|
40
|
-
error(...args: unknown[]): void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type Unsubscribe = () => void
|
|
44
|
-
|
|
45
|
-
export interface DomainEvent<Type extends string = string, Payload = unknown> {
|
|
46
|
-
type: Type
|
|
47
|
-
payload: Payload
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface EventBus {
|
|
51
|
-
emit(event: DomainEvent): void
|
|
52
|
-
on<E extends DomainEvent>(type: E['type'], handler: (event: E) => void): Unsubscribe
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface PluginContext {
|
|
56
|
-
config: Record<string, unknown>
|
|
57
|
-
mode: PresentationMode
|
|
58
|
-
provide<T>(token: ServiceToken<T>, impl: T): void
|
|
59
|
-
get<T>(token: ServiceToken<T>): T
|
|
60
|
-
tryGet<T>(token: ServiceToken<T>): T | undefined
|
|
61
|
-
events: EventBus
|
|
62
|
-
log: Logger
|
|
63
|
-
environments?: Readonly<Record<string, EnvironmentSpec>>
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export interface PluginDefinition {
|
|
67
|
-
manifest: PluginManifest
|
|
68
|
-
setup(ctx: PluginContext): void | Promise<void>
|
|
69
|
-
dispose?(): void | Promise<void>
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type PluginStatus = 'started' | 'degraded' | 'disabled' | 'failed'
|
|
73
|
-
|
|
74
|
-
export interface PluginReport {
|
|
75
|
-
id: string
|
|
76
|
-
status: PluginStatus
|
|
77
|
-
reason?: string
|
|
78
|
-
missing?: string[]
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface StartReport {
|
|
82
|
-
order: string[]
|
|
83
|
-
plugins: PluginReport[]
|
|
84
|
-
cycles: string[][]
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export type KernelEvent =
|
|
88
|
-
| DomainEvent<'kernel.plugin.started', { id: string }>
|
|
89
|
-
| DomainEvent<'kernel.plugin.degraded', { id: string; missing: string[] }>
|
|
90
|
-
| DomainEvent<'kernel.plugin.disabled', { id: string; reason: string }>
|
|
91
|
-
| DomainEvent<'kernel.plugin.failed', { id: string; error: string }>
|
|
92
|
-
|
|
93
|
-
export type Locale = 'zh-CN' | 'en'
|
|
94
|
-
|
|
95
|
-
export const DEFAULT_LANG = 'zh-CN'
|
|
96
|
-
|
|
97
|
-
export function localeOf(lang: string | null | undefined): Locale | undefined {
|
|
98
|
-
const tag = lang?.trim().toLowerCase() ?? ''
|
|
99
|
-
if (tag === 'zh' || tag.startsWith('zh-')) return 'zh-CN'
|
|
100
|
-
if (tag === 'en' || tag.startsWith('en-')) return 'en'
|
|
101
|
-
return undefined
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function deckLocale(lang?: string): Locale {
|
|
105
|
-
return localeOf(lang || DEFAULT_LANG) ?? 'en'
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export interface StepRange {
|
|
109
|
-
from: number
|
|
110
|
-
to?: number
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface StepState {
|
|
114
|
-
active: boolean
|
|
115
|
-
reached: boolean
|
|
116
|
-
done: boolean
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function stepStateAt(range: StepRange, step: number): StepState {
|
|
120
|
-
const reached = step >= range.from
|
|
121
|
-
const done = range.to !== undefined && step >= range.to
|
|
122
|
-
return { active: reached && !done, reached, done }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface Cursor {
|
|
126
|
-
slide: number
|
|
127
|
-
step: number
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export type AnchorString = `${string}:${string}`
|
|
131
|
-
|
|
132
|
-
export type ResolutionStatus = 'exact' | 'moved' | 'fuzzy' | 'lost'
|
|
133
|
-
|
|
134
|
-
export interface ResolvedLocation {
|
|
135
|
-
file: string
|
|
136
|
-
startLine: number
|
|
137
|
-
endLine: number
|
|
138
|
-
text: string
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface Resolution {
|
|
142
|
-
status: ResolutionStatus
|
|
143
|
-
location?: ResolvedLocation
|
|
144
|
-
fingerprint?: string
|
|
145
|
-
candidates?: ResolvedLocation[]
|
|
146
|
-
message?: string
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export interface SourceReader {
|
|
150
|
-
read(file: string, ref?: string): Promise<string | undefined>
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export interface RecordedAnchor {
|
|
154
|
-
fingerprint: string
|
|
155
|
-
file: string
|
|
156
|
-
startLine: number
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export interface AnchorResolver {
|
|
160
|
-
scheme: string
|
|
161
|
-
resolve(anchor: AnchorString, src: SourceReader, recorded?: RecordedAnchor, ref?: string): Promise<Resolution>
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
export interface AnchorResolverRegistry {
|
|
165
|
-
register(resolver: AnchorResolver): Unsubscribe
|
|
166
|
-
resolve(anchor: AnchorString, src: SourceReader, recorded?: RecordedAnchor, ref?: string): Promise<Resolution>
|
|
167
|
-
schemes(): string[]
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export interface AnchorLockFile {
|
|
171
|
-
version: 1
|
|
172
|
-
anchors: Record<string, RecordedAnchor>
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function isRenderable(resolution: Resolution): boolean {
|
|
176
|
-
return resolution.status === 'exact' || resolution.status === 'moved'
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export type Severity = 'error' | 'warning' | 'info'
|
|
180
|
-
|
|
181
|
-
export interface Finding {
|
|
182
|
-
checkId: string
|
|
183
|
-
severity: Severity
|
|
184
|
-
message: string
|
|
185
|
-
source?: { file: string; line?: number }
|
|
186
|
-
hint?: string
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export interface CheckContext {
|
|
190
|
-
deckDir: string
|
|
191
|
-
workspaceRoot: string
|
|
192
|
-
headmatter: Record<string, unknown>
|
|
193
|
-
src: SourceReader
|
|
194
|
-
resolvers: AnchorResolverRegistry
|
|
195
|
-
lock: AnchorLockFile
|
|
196
|
-
ir: IRDocument
|
|
197
|
-
service?<T>(token: ServiceToken<T>): T | undefined
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export interface Check {
|
|
201
|
-
id: string
|
|
202
|
-
run(ctx: CheckContext): Promise<Finding[]>
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export interface PresmiumNodePlugin {
|
|
206
|
-
id: string
|
|
207
|
-
version?: string
|
|
208
|
-
kernel?: string
|
|
209
|
-
resolvers?: AnchorResolver[]
|
|
210
|
-
checks?: Check[]
|
|
211
|
-
anchors?(ctx: CheckContext): AnchorString[] | Promise<AnchorString[]>
|
|
212
|
-
directives?: DirectiveSpec[]
|
|
213
|
-
passes?: IRPass[]
|
|
214
|
-
executors?: Executor[]
|
|
215
|
-
renderer?: Renderer
|
|
216
|
-
rendererExtensions?: Record<string, RendererContribution>
|
|
217
|
-
vitePlugins?(ctx: { deckDir: string, workspaceRoot: string, config: Record<string, unknown> }): unknown[]
|
|
218
|
-
services?(ctx: NodeServiceContext): [ServiceToken<unknown>, unknown][]
|
|
219
|
-
environments?: Record<string, EnvironmentProvider>
|
|
220
|
-
server?(ctx: ServerContext): ServerContribution | Promise<ServerContribution>
|
|
221
|
-
roles?: RoleSpec[]
|
|
222
|
-
tools?: AgentTool[]
|
|
223
|
-
operator?: boolean
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export type Role = 'presenter' | 'learner'
|
|
227
|
-
|
|
228
|
-
export interface Participant {
|
|
229
|
-
id: string
|
|
230
|
-
name: string
|
|
231
|
-
role: Role
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export type SessionStatus = 'idle' | 'active' | 'ended'
|
|
235
|
-
|
|
236
|
-
export interface SessionSnapshot {
|
|
237
|
-
sessionId?: string
|
|
238
|
-
status: SessionStatus
|
|
239
|
-
me?: Participant
|
|
240
|
-
participants: Participant[]
|
|
241
|
-
presenterCursor?: Cursor
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export interface SessionService {
|
|
245
|
-
snapshot(): SessionSnapshot
|
|
246
|
-
subscribe(listener: (snapshot: SessionSnapshot) => void): Unsubscribe
|
|
247
|
-
create(name: string): Promise<string>
|
|
248
|
-
join(sessionId: string, name: string): Promise<void>
|
|
249
|
-
end(): Promise<void>
|
|
250
|
-
leave(): void
|
|
251
|
-
archive?(): Promise<string>
|
|
252
|
-
readonly personal?: ClassroomStore
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export const SESSION = defineService<SessionService>('presmium.session')
|
|
256
|
-
|
|
257
|
-
export interface StoredItem<T> {
|
|
258
|
-
id: string
|
|
259
|
-
value: T
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export interface ClassroomStore {
|
|
263
|
-
list<T>(collection: string): StoredItem<T>[]
|
|
264
|
-
get<T>(collection: string, id: string): T | undefined
|
|
265
|
-
put<T>(collection: string, id: string, value: T): void
|
|
266
|
-
remove(collection: string, id: string): void
|
|
267
|
-
subscribe(collection: string, listener: () => void): Unsubscribe
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export const CLASSROOM_STORE = defineService<ClassroomStore>('presmium.classroomStore')
|
|
271
|
-
|
|
272
|
-
export interface PresenterPanel {
|
|
273
|
-
id: string
|
|
274
|
-
title: string
|
|
275
|
-
component: unknown
|
|
276
|
-
order?: number
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export interface PresenterPanels {
|
|
280
|
-
register(panel: PresenterPanel): Unsubscribe
|
|
281
|
-
list(): PresenterPanel[]
|
|
282
|
-
subscribe(listener: () => void): Unsubscribe
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
export const PRESENTER_PANELS = defineService<PresenterPanels>('presmium.presenterPanels')
|
|
24
|
+
export * from './contracts/speech-settings.ts'
|
|
25
|
+
export * from './contracts/transcription.ts'
|
|
26
|
+
export * from './contracts/transcription-adoption.ts'
|
|
27
|
+
export * from './contracts/voice.ts'
|
|
28
|
+
export * from './contracts/steps.ts'
|
|
29
|
+
export * from './contracts/workspace.ts'
|
|
30
|
+
export * from './contracts/document-comments.ts'
|
|
31
|
+
export * from './contracts/authoring-actions.ts'
|
|
32
|
+
export * from './contracts/document-history.ts'
|
|
33
|
+
export * from './contracts/authoring-clients.ts'
|
|
34
|
+
export * from './contracts/projects.ts'
|
|
35
|
+
export * from './contracts/document-image.ts'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Diagnostic, DiagnosticOrigin, Finding } from './contracts.ts'
|
|
2
|
+
import { fnv1a64 } from './fingerprint.ts'
|
|
3
|
+
|
|
4
|
+
export function diagnosticOf(document: string, origin: DiagnosticOrigin, finding: Finding & Pick<Diagnostic, 'authoringSource'>, renderer?: 'web' | 'slides', revision?: string): Diagnostic {
|
|
5
|
+
const id = fnv1a64(JSON.stringify([document, origin, renderer, finding.checkId, finding.severity, finding.message, finding.source?.file, finding.source?.line, finding.hint, ...finding.authoringSource ? [finding.authoringSource] : []]))
|
|
6
|
+
return { ...finding, ...finding.source ? { source: { ...finding.source } } : {}, ...finding.authoringSource ? { authoringSource: { ...finding.authoringSource } } : {}, id, document, origin, ...renderer ? { renderer } : {}, ...revision ? { revision } : {} }
|
|
7
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { applyDocumentCaptionTiming } from './document-caption-timings.ts'
|
|
2
|
+
import { hasRole } from './access.ts'
|
|
3
|
+
import { applyDocumentCommentAction, DocumentCommentError, validateDocumentCommentAnchor } from './document-comments.ts'
|
|
4
|
+
import { changeDocumentVoice, documentVoiceStepIds, previewDocumentVoiceChange } from './document-voices.ts'
|
|
5
|
+
import { applyDocumentComponentAction } from './document-components.ts'
|
|
6
|
+
import type { DocumentAuthoringAction, DocumentAuthoringActionEffect } from './contracts/authoring-actions.ts'
|
|
7
|
+
import type { DocumentCommentContext } from './contracts/document-comments.ts'
|
|
8
|
+
import type { TeachingDocument } from './contracts/document.ts'
|
|
9
|
+
|
|
10
|
+
/** Fixed context is supplied by the owning adapter exactly once per semantic plan. */
|
|
11
|
+
export function applyDocumentAuthoringAction(document: TeachingDocument, action: DocumentAuthoringAction, context: DocumentCommentContext): { content: TeachingDocument, effect: DocumentAuthoringActionEffect } {
|
|
12
|
+
if (!context.member || !hasRole(context.member, 'editor')) throw new DocumentCommentError(403, 'authoring-permission-denied')
|
|
13
|
+
if (action.type === 'component.parameters.patch' || action.type === 'component.step-parameters.patch' || action.type === 'component.step-parameters.reset' || action.type === 'component.definition.use') {
|
|
14
|
+
const { content, effect } = applyDocumentComponentAction(document, action)
|
|
15
|
+
return { content, effect: { kind: 'component', value: effect } }
|
|
16
|
+
}
|
|
17
|
+
if (action.type === 'caption.timing.set' || action.type === 'caption.timing.reset') {
|
|
18
|
+
const result = applyDocumentCaptionTiming(document, action)
|
|
19
|
+
return { content: result.content, effect: { kind: 'caption', value: result.effect } }
|
|
20
|
+
}
|
|
21
|
+
if (action.type === 'voice.set') {
|
|
22
|
+
if (action.source !== null && action.source.type !== 'tts' && action.source.type !== 'none') throw new DocumentCommentError(422, 'voice-recording-requires-adoption')
|
|
23
|
+
const content = changeDocumentVoice(document, action.scope, action.source, { replaceOverrides: action.replaceOverrides }), recordings = { preserved: 0, removed: 0 }
|
|
24
|
+
const check = (before: typeof document.production.defaults.voiceSource, after: typeof before) => { if (before?.type === 'recording') { if (after?.type === 'recording' && before.assetId === after.assetId) recordings.preserved++; else recordings.removed++ } }
|
|
25
|
+
if (action.scope.kind === 'document') check(document.production.defaults.voiceSource, content.production.defaults.voiceSource)
|
|
26
|
+
const pages = action.scope.kind === 'document' ? document.sectionOrder : action.scope.kind === 'page' ? [action.scope.sectionId] : []
|
|
27
|
+
for (const id of pages) check(document.production.sectionOverrides?.[id]?.voiceSource, content.production.sectionOverrides?.[id]?.voiceSource)
|
|
28
|
+
for (const id of documentVoiceStepIds(document, action.scope)) check(document.production.stepOverrides[id]?.voiceSource, content.production.stepOverrides[id]?.voiceSource)
|
|
29
|
+
return { content, effect: { kind: 'voice', scope: action.scope.kind, value: previewDocumentVoiceChange(document, action.scope, action.source, { replaceOverrides: action.replaceOverrides }), recordings } }
|
|
30
|
+
}
|
|
31
|
+
if (action.type === 'comment.create') validateDocumentCommentAnchor(document, action.anchor ?? undefined)
|
|
32
|
+
let source = document.comments ?? {}, detached = false
|
|
33
|
+
if (action.type !== 'comment.create' && Object.hasOwn(source, action.threadId)) {
|
|
34
|
+
const thread = source[action.threadId]!
|
|
35
|
+
try { validateDocumentCommentAnchor(document, thread.anchor) } catch (error) {
|
|
36
|
+
if (!(error instanceof DocumentCommentError) || error.status !== 422) throw error
|
|
37
|
+
const { anchor: _anchor, ...unanchored } = thread
|
|
38
|
+
source = { ...source, [thread.id]: unanchored }; detached = true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const { threads, effect } = applyDocumentCommentAction(source, action, context)
|
|
42
|
+
if (detached) { effect.anchorDetached = true; effect.changed = true }
|
|
43
|
+
const content = { ...document }
|
|
44
|
+
if (Object.keys(threads).length) content.comments = threads
|
|
45
|
+
else delete content.comments
|
|
46
|
+
return { content, effect: { kind: 'comment', value: effect } }
|
|
47
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fnv1a64 } from './fingerprint.ts'
|
|
2
|
+
import type { CaptionTiming, NarrationNode, TeachingDocument } from './contracts/document.ts'
|
|
3
|
+
|
|
4
|
+
export const CAPTION_TIMING_MAX_SEGMENTS = 32
|
|
5
|
+
export type DocumentCaptionTimingAction = { type: 'caption.timing.set', stepId: string, value: CaptionTiming } | { type: 'caption.timing.reset', stepId: string }
|
|
6
|
+
export class DocumentCaptionTimingError extends Error {
|
|
7
|
+
readonly status: number
|
|
8
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
9
|
+
}
|
|
10
|
+
export function narrationCaptionText(content: readonly NarrationNode[]): string {
|
|
11
|
+
return content.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
|
|
12
|
+
}
|
|
13
|
+
/** No second editable caption text. Separators are part of this exact display projection. */
|
|
14
|
+
export function stepCaptionText(document: TeachingDocument, stepId: string): string {
|
|
15
|
+
return (document.steps[stepId]?.narrationIds ?? []).map(id => narrationCaptionText(document.narrations[id]?.content ?? [])).join('\n')
|
|
16
|
+
}
|
|
17
|
+
export function captionSourceFingerprint(document: TeachingDocument, stepId: string): string {
|
|
18
|
+
const step = document.steps[stepId]
|
|
19
|
+
if (!step) throw new DocumentCaptionTimingError(404, 'caption-step-missing')
|
|
20
|
+
const timing = { ...document.production.defaults, ...document.production.sectionOverrides?.[step.sectionId], ...document.production.stepOverrides[stepId] }
|
|
21
|
+
return fnv1a64(JSON.stringify({ document: document.id, stepId, sectionId: step.sectionId, lang: document.metadata.lang, timing,
|
|
22
|
+
narrations: step.narrationIds.map(id => ({ id, content: document.narrations[id]?.content,
|
|
23
|
+
start: document.production.stepOverrides[stepId]?.narrationStart ?? document.production.narrationOverrides?.[id]?.narrationStart ?? timing.narrationStart })),
|
|
24
|
+
pauses: step.narrationIds.flatMap(id => document.narrations[id]?.content.flatMap(node => node.type === 'cue' ? [[node.id, document.production.cueTimings?.[node.id]]] : []) ?? []),
|
|
25
|
+
}))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Unicode grapheme partitions, complete original text and monotonic author times; no interpolation. */
|
|
29
|
+
export function captionTimingProblem(value: CaptionTiming, text: string): string | undefined {
|
|
30
|
+
if (!text.trim() || !Number.isSafeInteger(value.durationMs) || value.durationMs <= 0) return 'caption-timing-unavailable'
|
|
31
|
+
if (!value.segments.length || value.segments.length > CAPTION_TIMING_MAX_SEGMENTS) return 'caption-segment-limit'
|
|
32
|
+
const boundaries = new Set([0, text.length, ...Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), part => part.index)])
|
|
33
|
+
const ids = new Set<string>()
|
|
34
|
+
let offset = 0, end = 0
|
|
35
|
+
for (const segment of value.segments) {
|
|
36
|
+
if (!segment.id || ids.has(segment.id)) return 'caption-segment-id-invalid'
|
|
37
|
+
ids.add(segment.id)
|
|
38
|
+
if (!Number.isSafeInteger(segment.from) || !Number.isSafeInteger(segment.to) || segment.from !== offset || segment.to <= segment.from
|
|
39
|
+
|| !boundaries.has(segment.from) || !boundaries.has(segment.to) || !text.slice(segment.from, segment.to).trim()) return 'caption-text-range-invalid'
|
|
40
|
+
if (!Number.isSafeInteger(segment.startMs) || !Number.isSafeInteger(segment.endMs) || segment.startMs < end || segment.endMs <= segment.startMs || segment.endMs > value.durationMs) return 'caption-time-range-invalid'
|
|
41
|
+
offset = segment.to; end = segment.endMs
|
|
42
|
+
}
|
|
43
|
+
return offset === text.length ? undefined : 'caption-text-incomplete'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The shared semantic mutation uses the existing document transaction and its complete source CAS. */
|
|
47
|
+
export function applyDocumentCaptionTiming(document: TeachingDocument, action: DocumentCaptionTimingAction) {
|
|
48
|
+
const step = document.steps[action.stepId]
|
|
49
|
+
if (!step) throw new DocumentCaptionTimingError(404, 'caption-step-missing')
|
|
50
|
+
const content = structuredClone(document)
|
|
51
|
+
if (action.type === 'caption.timing.reset') {
|
|
52
|
+
if (content.production.captionTimings) delete content.production.captionTimings[action.stepId]
|
|
53
|
+
} else {
|
|
54
|
+
const value = action.value
|
|
55
|
+
if (JSON.stringify(value.narrationIds) !== JSON.stringify(step.narrationIds) || value.sourceFingerprint !== captionSourceFingerprint(document, step.id)) throw new DocumentCaptionTimingError(409, 'caption-source-changed')
|
|
56
|
+
const issue = captionTimingProblem(value, stepCaptionText(document, step.id))
|
|
57
|
+
if (issue) throw new DocumentCaptionTimingError(422, issue)
|
|
58
|
+
if (step.narrationIds.some(id => document.narrations[id]?.content.some(node => node.type === 'cue' && node.action === 'next'))) throw new DocumentCaptionTimingError(422, 'caption-cross-step-unsupported')
|
|
59
|
+
content.production.captionTimings ??= {}
|
|
60
|
+
Object.defineProperty(content.production.captionTimings, step.id, { value: structuredClone(value), configurable: true, enumerable: true, writable: true })
|
|
61
|
+
}
|
|
62
|
+
return { content, effect: { stepId: step.id, segments: action.type === 'caption.timing.set' ? action.value.segments.length : 0,
|
|
63
|
+
changed: JSON.stringify(content.production.captionTimings) !== JSON.stringify(document.production.captionTimings), mode: action.type === 'caption.timing.set' ? 'manual' as const : 'automatic' as const } }
|
|
64
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Semantic colours an author picks for text and backgrounds; the saved value is the design token itself. */
|
|
2
|
+
export type DocumentColorName = 'muted' | 'accent' | 'ok' | 'warn' | 'danger'
|
|
3
|
+
export type DocumentColorKind = 'text' | 'background'
|
|
4
|
+
|
|
5
|
+
const token = (name: string) => `var(--pm-color-${name})`
|
|
6
|
+
const BACKGROUND_TOKENS: Record<DocumentColorName, string> = { muted: 'muted-soft', accent: 'accent-soft', ok: 'ok-soft', warn: 'warn-soft', danger: 'danger-soft' }
|
|
7
|
+
|
|
8
|
+
export const DOCUMENT_COLOR_NAMES: readonly DocumentColorName[] = ['muted', 'accent', 'ok', 'warn', 'danger']
|
|
9
|
+
|
|
10
|
+
export function documentColorToken(name: DocumentColorName, kind: DocumentColorKind): string {
|
|
11
|
+
return token(kind === 'text' ? name : BACKGROUND_TOKENS[name])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Earlier editors saved BlockNote's palette names; they are shown as the nearest semantic colour.
|
|
15
|
+
const LEGACY_NAMES: Record<string, DocumentColorName> = { gray: 'muted', brown: 'warn', red: 'danger', orange: 'warn', yellow: 'warn', green: 'ok', blue: 'accent', purple: 'accent', pink: 'danger' }
|
|
16
|
+
|
|
17
|
+
// The grey background was first saved as the page-inset token `sunk`, which hardly shows in dark mode.
|
|
18
|
+
const LEGACY_MUTED_BACKGROUND = token('sunk')
|
|
19
|
+
|
|
20
|
+
/** The CSS colour that paints a saved document colour value; the saved value itself is never rewritten. */
|
|
21
|
+
export function documentColorValue(value: string, kind: DocumentColorKind): string {
|
|
22
|
+
if (kind === 'background' && value === LEGACY_MUTED_BACKGROUND) return documentColorToken('muted', kind)
|
|
23
|
+
const legacy = LEGACY_NAMES[value]
|
|
24
|
+
return legacy ? documentColorToken(legacy, kind) : value
|
|
25
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { hasRole } from './access.ts'
|
|
2
|
+
import type { DocumentCommentAction, DocumentCommentContext, DocumentCommentEffect } from './contracts/document-comments.ts'
|
|
3
|
+
import type { DocumentComment, DocumentThread, InlineNode, JsonObject, ListItemNode, RichTextNode, TableCellNode, TableRowNode, TeachingDocument } from './contracts/document.ts'
|
|
4
|
+
|
|
5
|
+
export class DocumentCommentError extends Error {
|
|
6
|
+
readonly status: number
|
|
7
|
+
constructor(status: number, code: string) { super(code); this.status = status; this.name = 'DocumentCommentError' }
|
|
8
|
+
}
|
|
9
|
+
function fail(status: number, code: string): never { throw new DocumentCommentError(status, code) }
|
|
10
|
+
|
|
11
|
+
/** Canonical anchor v1 expands only nonempty styled-text links; other inline values are atoms. */
|
|
12
|
+
export function isDocumentTextLink(node: InlineNode): node is Extract<InlineNode, { type: 'link' }> & { children: Extract<InlineNode, { type: 'text' | 'break' }>[] } {
|
|
13
|
+
return node.type === 'link' && node.children.length > 0 && node.children.every(child => child.type === 'text' || child.type === 'break')
|
|
14
|
+
}
|
|
15
|
+
function inlineText(nodes: readonly InlineNode[]): string {
|
|
16
|
+
return nodes.map(node => node.type === 'text' ? node.text : node.type === 'break' ? '\n' : isDocumentTextLink(node) ? inlineText(node.children) : '\uFFFC').join('')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only addressable canonical text identities, in native document order; opaque data is never traversed. */
|
|
20
|
+
export function documentCommentTargets(document: TeachingDocument): Map<string, { order: number, text: string }> {
|
|
21
|
+
const targets = new Map<string, { order: number, text: string }>()
|
|
22
|
+
const add = (id: string, text: string) => { targets.set(id, { order: targets.size, text }) }
|
|
23
|
+
const rich = (node: RichTextNode): void => {
|
|
24
|
+
if (node.type === 'paragraph' || node.type === 'heading') add(node.id, inlineText(node.children))
|
|
25
|
+
else if (node.type === 'code') add(node.id, node.value)
|
|
26
|
+
else if (node.type === 'list') for (const item of node.children) {
|
|
27
|
+
const [first, ...rest] = item.children
|
|
28
|
+
add(item.id, first?.type === 'paragraph' ? inlineText(first.children) : '')
|
|
29
|
+
;(first?.type === 'paragraph' ? rest : item.children).forEach(rich)
|
|
30
|
+
}
|
|
31
|
+
else if (node.type === 'blockquote') {
|
|
32
|
+
const [first, ...rest] = node.children
|
|
33
|
+
if (first?.type === 'paragraph') { add(node.id, inlineText(first.children)); rest.forEach(rich) }
|
|
34
|
+
else node.children.forEach(rich)
|
|
35
|
+
} else if (node.type === 'table') for (const row of node.children) for (const cell of row.children) add(cell.id, inlineText(cell.children))
|
|
36
|
+
}
|
|
37
|
+
const block = (id: string): void => {
|
|
38
|
+
const value = document.blocks[id]
|
|
39
|
+
if (value?.type === 'text') value.content.forEach(rich)
|
|
40
|
+
else if (value?.type === 'code' && value.value !== undefined) add(id, value.value)
|
|
41
|
+
else if (value?.type === 'container' || value?.type === 'component') value.children?.forEach(block)
|
|
42
|
+
}
|
|
43
|
+
for (const id of document.sectionOrder) { const page = document.sections[id]!; add(`page:${id}`, page.title); page.children.forEach(block) }
|
|
44
|
+
return targets
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** No ordinal, offset clamping, surrogate splitting or guessed fallback identity. */
|
|
48
|
+
export function validateDocumentCommentAnchor(document: TeachingDocument, anchor: DocumentThread['anchor']): void {
|
|
49
|
+
if (!anchor) return
|
|
50
|
+
const targets = documentCommentTargets(document), a = targets.get(anchor.from.blockId), b = targets.get(anchor.to.blockId)
|
|
51
|
+
if (!a || !b) fail(422, 'comment-anchor-target-unavailable')
|
|
52
|
+
for (const [point, target] of [[anchor.from, a], [anchor.to, b]] as const) {
|
|
53
|
+
const offset = point.offset
|
|
54
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > target.text.length
|
|
55
|
+
|| offset > 0 && offset < target.text.length && /[\uD800-\uDBFF]/.test(target.text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(target.text[offset]!)) fail(422, 'comment-anchor-offset-invalid')
|
|
56
|
+
}
|
|
57
|
+
if (a.order > b.order || a.order === b.order && anchor.from.offset >= anchor.to.offset) fail(422, 'comment-anchor-range-invalid')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Shared native/editor permissions: editors may delete any discussion, but edit their own comment; a document owner may manage earlier authors. */
|
|
61
|
+
export function applyDocumentCommentAction(source: NonNullable<TeachingDocument['comments']>, action: DocumentCommentAction, context: DocumentCommentContext): { threads: NonNullable<TeachingDocument['comments']>, effect: DocumentCommentEffect } {
|
|
62
|
+
const member = context.member
|
|
63
|
+
if (!member?.id || !hasRole(member, 'editor')) fail(403, 'comment-permission-denied')
|
|
64
|
+
if (!Number.isFinite(Date.parse(context.now))) fail(422, 'comment-clock-invalid')
|
|
65
|
+
const allocate = () => { const id = context.allocate(); if (!id || id.length > 200 || ['__proto__', 'prototype', 'constructor'].includes(id)) fail(422, 'comment-id-invalid'); return id }
|
|
66
|
+
const content = (value: RichTextNode[]): RichTextNode[] => {
|
|
67
|
+
const result = structuredClone(value)
|
|
68
|
+
const fresh = (node: RichTextNode | ListItemNode | TableCellNode | TableRowNode): void => {
|
|
69
|
+
node.id = allocate()
|
|
70
|
+
if (node.type === 'list' || node.type === 'listItem' || node.type === 'blockquote' || node.type === 'table' || node.type === 'tableRow') node.children.forEach(fresh)
|
|
71
|
+
else if (node.type === 'extension') node.fallback.forEach(fresh)
|
|
72
|
+
}
|
|
73
|
+
if (context.allocateContentIds) result.forEach(fresh)
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
const create = (value: RichTextNode[], metadata?: JsonObject): DocumentComment => ({ id: allocate(), type: 'comment', userId: member.id, createdAt: context.now, updatedAt: context.now, content: content(value), reactions: [], ...metadata ? { metadata: structuredClone(metadata) } : {} })
|
|
77
|
+
let thread: DocumentThread, commentId: string | undefined, changed = true
|
|
78
|
+
if (action.type === 'comment.create') {
|
|
79
|
+
const first = create(action.content, action.commentMetadata)
|
|
80
|
+
thread = { id: allocate(), type: 'thread', createdAt: context.now, updatedAt: context.now, resolved: false, comments: [first], ...action.anchor ? { anchor: structuredClone(action.anchor) } : {}, ...action.metadata ? { metadata: structuredClone(action.metadata) } : {} }
|
|
81
|
+
if (Object.hasOwn(source, thread.id)) fail(409, 'comment-id-repeated')
|
|
82
|
+
commentId = first.id
|
|
83
|
+
} else {
|
|
84
|
+
if (!Object.hasOwn(source, action.threadId)) fail(404, 'document-thread-missing')
|
|
85
|
+
if (source[action.threadId]!.deletedAt) fail(409, 'document-thread-deleted')
|
|
86
|
+
thread = structuredClone(source[action.threadId]!)
|
|
87
|
+
const find = (id: string) => { const comment = thread.comments.find(value => value.id === id); if (!comment) return fail(404, 'document-comment-missing'); if (comment.deletedAt) return fail(409, 'document-comment-deleted'); return comment }
|
|
88
|
+
switch (action.type) {
|
|
89
|
+
case 'comment.reply': { const comment = create(action.content, action.metadata); thread.comments.push(comment); commentId = comment.id; break }
|
|
90
|
+
case 'comment.update': {
|
|
91
|
+
const comment = find(action.commentId); if (comment.userId !== member.id && !hasRole(member, 'owner')) fail(403, 'comment-author-required')
|
|
92
|
+
commentId = comment.id; comment.content = content(action.content); comment.updatedAt = context.now
|
|
93
|
+
if (action.metadata) comment.metadata = structuredClone(action.metadata)
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
case 'comment.react': {
|
|
97
|
+
const comment = find(action.commentId), reaction = comment.reactions.find(value => value.emoji === action.emoji)
|
|
98
|
+
if (!action.emoji || action.emoji.length > 32) fail(422, 'comment-reaction-invalid')
|
|
99
|
+
commentId = comment.id; changed = Boolean(reaction?.userIds.includes(member.id)) !== action.active
|
|
100
|
+
if (changed && action.active) { if (reaction) reaction.userIds.push(member.id); else comment.reactions.push({ emoji: action.emoji, createdAt: context.now, userIds: [member.id] }) }
|
|
101
|
+
else if (changed && reaction) { reaction.userIds = reaction.userIds.filter(id => id !== member.id); comment.reactions = comment.reactions.filter(value => value.userIds.length) }
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case 'comment.resolve': case 'comment.reopen':
|
|
105
|
+
changed = thread.resolved !== (action.type === 'comment.resolve')
|
|
106
|
+
if (changed) { thread.resolved = action.type === 'comment.resolve'; thread.resolvedBy = member.id; thread.resolvedUpdatedAt = context.now }
|
|
107
|
+
break
|
|
108
|
+
case 'comment.delete':
|
|
109
|
+
if (action.commentId) { find(action.commentId); commentId = action.commentId; thread.comments = thread.comments.filter(value => value.id !== commentId) }
|
|
110
|
+
else thread.comments = []
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
if (changed) thread.updatedAt = context.now
|
|
114
|
+
}
|
|
115
|
+
const threads = changed ? { ...source } : source, removed = !thread.comments.length
|
|
116
|
+
if (changed) { if (removed) delete threads[thread.id]; else Object.defineProperty(threads, thread.id, { value: thread, enumerable: true, configurable: true, writable: true }) }
|
|
117
|
+
return { threads, effect: { threadId: thread.id, ...commentId ? { commentId } : {}, changed, anchored: !removed && Boolean(thread.anchor), threadRemoved: removed, removedComments: Math.max(0, (source[thread.id]?.comments.length ?? 0) - thread.comments.length), comments: thread.comments.length, reactions: thread.comments.reduce((sum, comment) => sum + comment.reactions.length, 0) } }
|
|
118
|
+
}
|