@lincyaw/workbench 0.1.0 → 0.2.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/package.json +27 -22
- package/src/MediaClipEditor.tsx +122 -0
- package/src/StepProductionEditor.tsx +130 -0
- package/src/approvals.tsx +60 -0
- package/src/assistant-mascot.ts +62 -0
- package/src/audio-recording-messages.ts +44 -0
- package/src/audio-recording.ts +192 -0
- package/src/audio-wav.ts +25 -0
- package/src/audio-waveform.ts +110 -0
- package/src/audition.ts +84 -0
- package/src/authoring-client.ts +136 -0
- package/src/authoring-draft-fragment.ts +28 -0
- package/src/base-controls.tsx +47 -0
- package/src/caption-timing-drafts.ts +61 -0
- package/src/caption-timing-messages.ts +14 -0
- package/src/caption-timing.css +20 -0
- package/src/caption-timing.ts +14 -0
- package/src/component-draft-actions.ts +54 -0
- package/src/component-draft-inspection.ts +35 -0
- package/src/component-properties.css +24 -0
- package/src/component-properties.ts +45 -0
- package/src/component-property-drafts.ts +273 -0
- package/src/component-property-messages.ts +31 -0
- package/src/components.tsx +547 -90
- package/src/context.tsx +27 -0
- package/src/diagnostics.ts +113 -0
- package/src/document-draft-store.ts +179 -0
- package/src/document-drafts.ts +178 -0
- package/src/document-editor/BlockNoteSurface.tsx +209 -0
- package/src/document-editor/ComponentMaterials.tsx +62 -0
- package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
- package/src/document-editor/DocumentBlockFallback.tsx +31 -0
- package/src/document-editor/DocumentBlockPreview.tsx +239 -0
- package/src/document-editor/DocumentComments.tsx +282 -0
- package/src/document-editor/DocumentEditor.tsx +192 -0
- package/src/document-editor/DocumentStepNarration.tsx +93 -0
- package/src/document-editor/NarrationEditor.tsx +110 -0
- package/src/document-editor/NewCommentDrafts.tsx +224 -0
- package/src/document-editor/authoring.tsx +259 -0
- package/src/document-editor/block-playback.ts +90 -0
- package/src/document-editor/block-preview-messages.ts +13 -0
- package/src/document-editor/blocknote-messages.ts +98 -0
- package/src/document-editor/clipboard.ts +40 -0
- package/src/document-editor/colors.ts +57 -0
- package/src/document-editor/comment-draft-actions.ts +58 -0
- package/src/document-editor/comment-draft-inspection.ts +38 -0
- package/src/document-editor/comment-drafts.ts +361 -0
- package/src/document-editor/comments.ts +410 -0
- package/src/document-editor/file-input.ts +71 -0
- package/src/document-editor/focus.ts +54 -0
- package/src/document-editor/inline.ts +104 -0
- package/src/document-editor/math.tsx +63 -0
- package/src/document-editor/messages.ts +14 -0
- package/src/document-editor/narration-schema.tsx +85 -0
- package/src/document-editor/narration.ts +37 -0
- package/src/document-editor/popover.tsx +107 -0
- package/src/document-editor/projection.ts +423 -0
- package/src/document-editor/rich-text.ts +152 -0
- package/src/document-editor/schema.tsx +117 -0
- package/src/document-editor/selection.ts +152 -0
- package/src/document-editor/style.css +9 -0
- package/src/document-editor/styles.d.ts +1 -0
- package/src/document-editor/table-identities.ts +67 -0
- package/src/document-preview.ts +112 -0
- package/src/document-return-point.ts +59 -0
- package/src/document-session.ts +678 -0
- package/src/document-timing-drafts.ts +61 -0
- package/src/document-voices.ts +2 -0
- package/src/history.ts +167 -0
- package/src/index.ts +33 -43
- package/src/jobs.ts +111 -0
- package/src/mascot-preference.ts +37 -0
- package/src/media-drafts.ts +83 -0
- package/src/media-messages.ts +53 -0
- package/src/media-time.ts +12 -0
- package/src/messages.ts +751 -203
- package/src/outline.ts +97 -0
- package/src/panels/ChangeTrail.tsx +79 -0
- package/src/panels/DocumentNarrationPanel.tsx +27 -0
- package/src/panels/DocumentView.tsx +456 -44
- package/src/panels/DocumentVoiceDialog.tsx +179 -0
- package/src/panels/DocumentVoiceField.tsx +35 -0
- package/src/panels/ExportPanel.tsx +133 -0
- package/src/panels/HistoryPanel.tsx +88 -0
- package/src/panels/HomePanel.tsx +184 -0
- package/src/panels/PageDeleteDialog.tsx +33 -0
- package/src/panels/PageRenameDialog.tsx +27 -0
- package/src/panels/PreviewPanel.tsx +79 -0
- package/src/panels/ProductionCaptionTiming.tsx +122 -0
- package/src/panels/ProductionMediaClips.tsx +156 -0
- package/src/panels/ProductionPanel.tsx +272 -0
- package/src/panels/ProductionRecording.tsx +107 -0
- package/src/panels/ProductionRecordingRange.tsx +118 -0
- package/src/panels/ProductionTimeline.tsx +62 -0
- package/src/panels/ProductionTranscription.tsx +140 -0
- package/src/panels/SourceDocumentPanel.tsx +215 -0
- package/src/panels/SpeechSettingsPanel.tsx +64 -0
- package/src/panels/TranscriptionAdoption.tsx +147 -0
- package/src/panels/VoiceCloneDialog.tsx +152 -0
- package/src/panels/VoicePreviewDialog.tsx +74 -0
- package/src/panels/VoiceScopeField.tsx +30 -0
- package/src/panels/VoiceoverDialog.tsx +216 -0
- package/src/places.ts +74 -6
- package/src/preview-playback.ts +125 -0
- package/src/preview-pool.ts +320 -0
- package/src/preview.ts +57 -6
- package/src/production-messages.ts +151 -0
- package/src/production-playback.ts +109 -0
- package/src/production-voice-playback.ts +114 -0
- package/src/recording-adoption.ts +33 -0
- package/src/schema-form/PmSchemaForm.tsx +187 -0
- package/src/schema-form/context.ts +26 -0
- package/src/schema-form/fields.tsx +59 -0
- package/src/schema-form/templates.tsx +50 -0
- package/src/schema-form/validator.ts +36 -0
- package/src/schema-form/widgets.tsx +60 -0
- package/src/shortcuts.ts +8 -0
- package/src/sortable-list.tsx +201 -0
- package/src/speech-settings-messages.ts +40 -0
- package/src/step-production.css +5 -0
- package/src/step-production.ts +51 -0
- package/src/styles.css +757 -361
- package/src/telemetry.ts +152 -0
- package/src/transcription-messages.ts +26 -0
- package/src/transcription-session.ts +159 -0
- package/src/use-recording-adoption.ts +84 -0
- package/src/voice-clone-messages.ts +92 -0
- package/src/voice-drafts.ts +89 -0
- package/src/voice-messages.ts +146 -0
- package/src/voice-snapshot.ts +55 -0
- package/src/voiceover-jobs.ts +87 -0
- package/src/workbench.ts +2328 -0
- package/src/workspace-client.ts +480 -0
- package/src/api.ts +0 -126
- package/src/editor.ts +0 -223
- package/src/merge.ts +0 -30
- package/src/panels/Changes.tsx +0 -128
- package/src/panels/Files.tsx +0 -73
- package/src/panels/Home.tsx +0 -224
- package/src/panels/Preview.tsx +0 -68
- package/src/panels/Problems.tsx +0 -42
- package/src/panels/Source.tsx +0 -118
- package/src/panels/Status.tsx +0 -26
- package/src/react.tsx +0 -59
- package/src/service.ts +0 -82
- package/src/store.ts +0 -599
package/src/context.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, useContext, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { MESSAGES } from './messages.ts'
|
|
4
|
+
import type { WorkbenchMessages } from './messages.ts'
|
|
5
|
+
import type { Workbench, WorkbenchState } from './workbench.ts'
|
|
6
|
+
|
|
7
|
+
const WORKBENCH = createContext<Workbench | undefined>(undefined)
|
|
8
|
+
|
|
9
|
+
/** Gives the panels drawn in it one workbench. */
|
|
10
|
+
export function WorkbenchProvider({ workbench, children }: { workbench: Workbench, children?: ReactNode }) {
|
|
11
|
+
return <WORKBENCH.Provider value={workbench}>{children}</WORKBENCH.Provider>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useWorkbench(): Workbench {
|
|
15
|
+
const workbench = useContext(WORKBENCH)
|
|
16
|
+
if (!workbench) throw new Error('a workbench panel is drawn outside WorkbenchProvider')
|
|
17
|
+
return workbench
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useWorkbenchState<T>(select: (state: WorkbenchState) => T): T {
|
|
21
|
+
const workbench = useWorkbench()
|
|
22
|
+
return useSyncExternalStore(workbench.subscribe, () => select(workbench.getState()))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useMessages(): WorkbenchMessages {
|
|
26
|
+
return MESSAGES[useWorkbenchState(state => state.locale)]
|
|
27
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { errorMessage } from '@lincyaw/kernel'
|
|
2
|
+
import type { Diagnostic, DiagnosticSnapshot, PreviewDiagnostics } from '@lincyaw/kernel'
|
|
3
|
+
import type { WorkbenchStore } from './workbench.ts'
|
|
4
|
+
import type { WorkspaceClient } from './workspace-client.ts'
|
|
5
|
+
|
|
6
|
+
type Repairer = (diagnostics: readonly Diagnostic[]) => void | Promise<void>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The open document's current diagnostics: the snapshots the workspace server pushes (marked unavailable while the
|
|
10
|
+
* stream fails), the preview's runtime findings reported to it, and the repair the conversation is asked for.
|
|
11
|
+
*/
|
|
12
|
+
export class DocumentDiagnostics {
|
|
13
|
+
readonly #client: WorkspaceClient
|
|
14
|
+
readonly #store: WorkbenchStore
|
|
15
|
+
readonly #repairers = new Set<Repairer>()
|
|
16
|
+
#unsubscribe?: () => void
|
|
17
|
+
#scope = 0
|
|
18
|
+
#repairRequest = 0
|
|
19
|
+
#reporting: Promise<void> = Promise.resolve()
|
|
20
|
+
|
|
21
|
+
constructor(client: WorkspaceClient, store: WorkbenchStore) {
|
|
22
|
+
this.#client = client
|
|
23
|
+
this.#store = store
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
subscribe(dir: string): void {
|
|
27
|
+
if (this.#client.capabilities?.diagnostics === 'none') {
|
|
28
|
+
this.#unsubscribe?.(); this.#unsubscribe = undefined
|
|
29
|
+
// An unsupported producer is absent; it has not reported a successful check.
|
|
30
|
+
this.#store.set({ diagnostics: undefined, isDiagnosticsUnavailable: false })
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
const scope = this.#scope
|
|
34
|
+
let streamFailed = false
|
|
35
|
+
let liveReceived = false
|
|
36
|
+
const current = () => scope === this.#scope && this.#store.state.document === dir
|
|
37
|
+
const received = (diagnostics: DiagnosticSnapshot, live = false) => {
|
|
38
|
+
if (!current() || (!live && liveReceived)) return
|
|
39
|
+
if (live) {
|
|
40
|
+
liveReceived = true
|
|
41
|
+
streamFailed = false
|
|
42
|
+
}
|
|
43
|
+
this.#store.set({ diagnostics, isDiagnosticsUnavailable: streamFailed })
|
|
44
|
+
}
|
|
45
|
+
const failed = () => {
|
|
46
|
+
streamFailed = true
|
|
47
|
+
if (current() && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
|
|
48
|
+
}
|
|
49
|
+
this.#unsubscribe = this.#client.subscribeDiagnostics(dir, snapshot => received(snapshot, true), failed)
|
|
50
|
+
void this.#client.readDiagnostics(dir).then(snapshot => received(snapshot), () => {
|
|
51
|
+
if (current() && !this.#store.state.diagnostics && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
retry(): void {
|
|
56
|
+
const document = this.#store.state.document
|
|
57
|
+
if (document === undefined) return
|
|
58
|
+
this.#unsubscribe?.()
|
|
59
|
+
this.#scope++
|
|
60
|
+
this.subscribe(document)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Tells the workspace server a preview's runtime findings, one report at a time. */
|
|
64
|
+
reportPreview(report: PreviewDiagnostics): Promise<void> {
|
|
65
|
+
if (this.#client.capabilities?.diagnostics === 'none') return Promise.resolve()
|
|
66
|
+
const scope = this.#scope
|
|
67
|
+
this.#reporting = this.#reporting.then(async () => {
|
|
68
|
+
try {
|
|
69
|
+
await this.#client.reportDiagnostics(report)
|
|
70
|
+
} catch {
|
|
71
|
+
if (scope === this.#scope && this.#store.state.document === report.document && !this.#store.state.isDiagnosticsUnavailable) this.#store.set({ isDiagnosticsUnavailable: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
return this.#reporting
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
onRepair(repairer: Repairer): () => void {
|
|
78
|
+
this.#repairers.add(repairer)
|
|
79
|
+
this.#store.set({ canRepairDiagnostics: true })
|
|
80
|
+
return () => {
|
|
81
|
+
this.#repairers.delete(repairer)
|
|
82
|
+
this.#store.set({ canRepairDiagnostics: this.#repairers.size > 0 })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async requestRepair(): Promise<void> {
|
|
87
|
+
const { state } = this.#store
|
|
88
|
+
const diagnostics = state.diagnostics?.diagnostics
|
|
89
|
+
if (!diagnostics?.length || this.#repairers.size === 0 || state.isRepairingDiagnostics || state.isDiagnosticsUnavailable) return
|
|
90
|
+
const request = ++this.#repairRequest
|
|
91
|
+
this.#store.set({ isRepairingDiagnostics: true, diagnosticsRepairFailure: undefined })
|
|
92
|
+
try {
|
|
93
|
+
await Promise.all([...this.#repairers].map(repairer => repairer(diagnostics)))
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (request === this.#repairRequest) this.#store.set({ diagnosticsRepairFailure: errorMessage(error) })
|
|
96
|
+
} finally {
|
|
97
|
+
if (request === this.#repairRequest) this.#store.set({ isRepairingDiagnostics: false })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Stops following the document; reports and repairs still under way no longer change the state. */
|
|
102
|
+
stop(): void {
|
|
103
|
+
this.#repairRequest++
|
|
104
|
+
this.#unsubscribe?.()
|
|
105
|
+
this.#unsubscribe = undefined
|
|
106
|
+
this.#scope++
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dispose(): void {
|
|
110
|
+
this.stop()
|
|
111
|
+
this.#repairers.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type DraftStoreResult = 'committed' | 'superseded' | 'failed'
|
|
2
|
+
export interface DraftStoreCommit { generation: number, done: Promise<DraftStoreResult> }
|
|
3
|
+
interface Operation extends DraftStoreCommit {
|
|
4
|
+
key: string
|
|
5
|
+
raw?: string
|
|
6
|
+
markers: readonly [string, string][]
|
|
7
|
+
finish(result: DraftStoreResult): void
|
|
8
|
+
}
|
|
9
|
+
const DATABASE = 'presmium.document-drafts.v1'
|
|
10
|
+
const ENTRIES = 'backups'
|
|
11
|
+
|
|
12
|
+
/** One actor's latest snapshot replaces only its pending snapshot, never its in-flight transaction. */
|
|
13
|
+
export class DocumentDraftStore {
|
|
14
|
+
#database?: Promise<IDBDatabase>
|
|
15
|
+
#channel?: BroadcastChannel
|
|
16
|
+
#closing = false
|
|
17
|
+
#generation = 0
|
|
18
|
+
#flight?: Operation
|
|
19
|
+
#pending?: Operation
|
|
20
|
+
readonly #confirmed = new Map<string, string>()
|
|
21
|
+
readonly #foreign = new Set<string>()
|
|
22
|
+
readonly #scope: string
|
|
23
|
+
readonly #writerOwner?: string
|
|
24
|
+
readonly #family?: 'comment' | 'component'
|
|
25
|
+
|
|
26
|
+
constructor(scope: string, changed?: () => void, notifications?: { owner?: string, family?: 'comment' | 'component' }) {
|
|
27
|
+
this.#scope = scope
|
|
28
|
+
const writerOwner = this.#writerOwner = notifications?.owner, family = this.#family = notifications?.family
|
|
29
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
30
|
+
try {
|
|
31
|
+
this.#channel = new BroadcastChannel(DATABASE)
|
|
32
|
+
this.#channel.onmessage = event => { if (!this.#closing && event.data?.scope === scope && (!family || !event.data.family || event.data.family === family) && (!writerOwner || event.data.owner !== writerOwner)) changed?.() }
|
|
33
|
+
} catch { /* Reopening still reads actual storage when cross-tab notification is unavailable. */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
conflicted(key: string): boolean { return this.#foreign.has(key) }
|
|
38
|
+
owns(key: string, raw: string): boolean { return this.#confirmed.get(key) === raw }
|
|
39
|
+
release(key: string): void { if (this.#flight?.key !== key && this.#pending?.key !== key) this.#confirmed.delete(key) }
|
|
40
|
+
|
|
41
|
+
#db(): Promise<IDBDatabase> {
|
|
42
|
+
if (!this.#database) {
|
|
43
|
+
const work = new Promise<IDBDatabase>((resolve, reject) => {
|
|
44
|
+
let finished = false
|
|
45
|
+
const request = indexedDB.open(DATABASE, 1)
|
|
46
|
+
request.onupgradeneeded = () => { if (!request.result.objectStoreNames.contains(ENTRIES)) request.result.createObjectStore(ENTRIES) }
|
|
47
|
+
request.onerror = () => { finished = true; reject(request.error) }
|
|
48
|
+
request.onblocked = () => { finished = true; reject(new Error('document-draft-storage-blocked')) }
|
|
49
|
+
request.onsuccess = () => {
|
|
50
|
+
const db = request.result
|
|
51
|
+
if (finished) { db.close(); return }
|
|
52
|
+
finished = true
|
|
53
|
+
const reset = () => { if (this.#database === work) this.#database = undefined }
|
|
54
|
+
db.onversionchange = () => { db.close(); reset() }
|
|
55
|
+
db.onclose = reset
|
|
56
|
+
resolve(db)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
this.#database = work
|
|
60
|
+
void work.catch(() => { if (this.#database === work) this.#database = undefined })
|
|
61
|
+
}
|
|
62
|
+
return this.#database
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async read(prefixes: readonly string[], signal?: AbortSignal, maxEntries?: number): Promise<Map<string, unknown>> {
|
|
66
|
+
if (this.#closing) throw new Error('document-draft-storage-closed')
|
|
67
|
+
if (maxEntries !== undefined && (!Number.isSafeInteger(maxEntries) || maxEntries < 1)) throw new Error('document-draft-read-limit')
|
|
68
|
+
signal?.throwIfAborted()
|
|
69
|
+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
70
|
+
const cancel = () => { signal?.removeEventListener('abort', cancel); reject(signal?.reason) }
|
|
71
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
72
|
+
this.#db().then(value => { signal?.removeEventListener('abort', cancel); resolve(value) }, error => { signal?.removeEventListener('abort', cancel); reject(error) })
|
|
73
|
+
})
|
|
74
|
+
signal?.throwIfAborted()
|
|
75
|
+
if (this.#closing) throw new Error('document-draft-storage-closed')
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const values = new Map<string, unknown>(), tx = db.transaction(ENTRIES, 'readonly'), store = tx.objectStore(ENTRIES)
|
|
78
|
+
let exceeded = false
|
|
79
|
+
const cancel = () => { try { tx.abort() } catch { /* The readonly transaction may already have completed. */ } }
|
|
80
|
+
const stop = () => signal?.removeEventListener('abort', cancel)
|
|
81
|
+
signal?.addEventListener('abort', cancel, { once: true })
|
|
82
|
+
tx.oncomplete = () => { stop(); signal?.aborted ? reject(signal.reason) : resolve(values) }
|
|
83
|
+
tx.onabort = () => { stop(); reject(signal?.aborted ? signal.reason : exceeded ? new Error('document-draft-read-limit') : tx.error ?? new Error('document-draft-read-aborted')) }
|
|
84
|
+
tx.onerror = () => { stop(); reject(tx.error) }
|
|
85
|
+
for (const prefix of prefixes) {
|
|
86
|
+
const request = store.openCursor(IDBKeyRange.bound(prefix, `${prefix}\uffff`))
|
|
87
|
+
request.onsuccess = () => {
|
|
88
|
+
const cursor = request.result
|
|
89
|
+
if (cursor) {
|
|
90
|
+
const key = String(cursor.key)
|
|
91
|
+
if (maxEntries !== undefined && values.size >= maxEntries && !values.has(key)) { exceeded = true; tx.abort(); return }
|
|
92
|
+
values.set(key, cursor.value); cursor.continue()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#notify(): void { if (!this.#closing) this.#channel?.postMessage({ scope: this.#scope, ...this.#writerOwner ? { owner: this.#writerOwner } : {}, ...this.#family ? { family: this.#family } : {} }) }
|
|
100
|
+
|
|
101
|
+
async #commit(key: string | undefined, raw: string | undefined, markers: readonly [string, string][]): Promise<{ committed: boolean, foreign: boolean }> {
|
|
102
|
+
try {
|
|
103
|
+
const db = await this.#db()
|
|
104
|
+
return await new Promise(resolve => {
|
|
105
|
+
const tx = db.transaction(ENTRIES, 'readwrite', { durability: 'strict' }), store = tx.objectStore(ENTRIES)
|
|
106
|
+
let foreign = false
|
|
107
|
+
tx.oncomplete = () => resolve({ committed: true, foreign })
|
|
108
|
+
tx.onabort = () => resolve({ committed: false, foreign })
|
|
109
|
+
tx.onerror = () => { /* The abort event delivers the terminal transaction result. */ }
|
|
110
|
+
if (tx.durability !== 'strict') { tx.abort(); return }
|
|
111
|
+
const apply = () => {
|
|
112
|
+
for (const [marker, value] of markers) store.put(value, marker)
|
|
113
|
+
}
|
|
114
|
+
if (key === undefined) { apply(); return }
|
|
115
|
+
const request = store.get(key), expected = this.#confirmed.get(key)
|
|
116
|
+
request.onsuccess = () => {
|
|
117
|
+
foreign = request.result !== expected
|
|
118
|
+
if (raw !== undefined) {
|
|
119
|
+
if (foreign) { tx.abort(); return }
|
|
120
|
+
store.put(raw, key)
|
|
121
|
+
} else if (!foreign) store.delete(key)
|
|
122
|
+
apply()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
} catch { return { committed: false, foreign: false } }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#stage(key: string, raw: string | undefined, markers: readonly [string, string][]): DraftStoreCommit | undefined {
|
|
129
|
+
if (this.#closing) return
|
|
130
|
+
let finish!: (result: DraftStoreResult) => void
|
|
131
|
+
const done = new Promise<DraftStoreResult>(resolve => finish = resolve)
|
|
132
|
+
const next: Operation = { generation: ++this.#generation, key, raw, markers, done, finish }
|
|
133
|
+
this.#pending?.finish('superseded')
|
|
134
|
+
this.#pending = next
|
|
135
|
+
void this.#drain()
|
|
136
|
+
return next
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
put(key: string, raw: string): DraftStoreCommit | undefined { return this.#stage(key, raw, []) }
|
|
140
|
+
remove(key: string, markers: readonly [string, string][]): DraftStoreCommit | undefined { return this.#stage(key, undefined, markers) }
|
|
141
|
+
|
|
142
|
+
async #drain(): Promise<void> {
|
|
143
|
+
if (this.#flight || !this.#pending) return
|
|
144
|
+
const operation = this.#pending
|
|
145
|
+
this.#pending = undefined; this.#flight = operation
|
|
146
|
+
const result = await this.#commit(operation.key, operation.raw, operation.markers)
|
|
147
|
+
if (result.foreign) { this.#foreign.add(operation.key); this.#confirmed.delete(operation.key) }
|
|
148
|
+
if (result.committed) {
|
|
149
|
+
if (operation.raw === undefined) this.#confirmed.delete(operation.key)
|
|
150
|
+
else this.#confirmed.set(operation.key, operation.raw)
|
|
151
|
+
}
|
|
152
|
+
this.#flight = undefined
|
|
153
|
+
operation.finish(result.committed ? 'committed' : 'failed')
|
|
154
|
+
if (result.committed) this.#notify()
|
|
155
|
+
if (this.#pending) void this.#drain()
|
|
156
|
+
else if (this.#closing) this.#closeDatabase()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A candidate copy must be strictly committed before the actor switches to it. */
|
|
160
|
+
async create(key: string, raw: string): Promise<boolean> {
|
|
161
|
+
if (this.#closing) return false
|
|
162
|
+
const result = await this.#commit(key, raw, [])
|
|
163
|
+
if (result.committed) { this.#confirmed.set(key, raw); this.#notify() }
|
|
164
|
+
return result.committed
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async mark(markers: readonly [string, string][]): Promise<boolean> {
|
|
168
|
+
if (this.#closing) return false
|
|
169
|
+
const result = await this.#commit(undefined, undefined, markers)
|
|
170
|
+
if (result.committed) this.#notify()
|
|
171
|
+
return result.committed
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#closeDatabase(): void { void this.#database?.then(db => db.close(), () => {}) }
|
|
175
|
+
close(): void {
|
|
176
|
+
this.#closing = true; this.#channel?.close()
|
|
177
|
+
if (!this.#flight && !this.#pending) this.#closeDatabase()
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { isRecord, validateDocument } from '@lincyaw/kernel'
|
|
2
|
+
import type { TeachingDocument } from '@lincyaw/kernel'
|
|
3
|
+
import { DocumentDraftStore } from './document-draft-store.ts'
|
|
4
|
+
import type { DraftStoreCommit } from './document-draft-store.ts'
|
|
5
|
+
|
|
6
|
+
export interface DocumentDraftReference { id: string, revision: number, token: string }
|
|
7
|
+
export interface DocumentDraft {
|
|
8
|
+
content: TeachingDocument
|
|
9
|
+
baseVersion: string
|
|
10
|
+
baseHash: string
|
|
11
|
+
input: number
|
|
12
|
+
pendingHash?: string
|
|
13
|
+
composing?: boolean
|
|
14
|
+
origins?: DocumentDraftReference[]
|
|
15
|
+
}
|
|
16
|
+
export interface StoredDocumentDraft extends DocumentDraft {
|
|
17
|
+
format: 2
|
|
18
|
+
identity: string
|
|
19
|
+
id: string
|
|
20
|
+
owner: string
|
|
21
|
+
revision: number
|
|
22
|
+
updatedAt: number
|
|
23
|
+
}
|
|
24
|
+
export interface RecoverableDocumentDraft extends StoredDocumentDraft { token: string, source: 'durable' | 'rescue', owned: boolean }
|
|
25
|
+
export interface DocumentDraftChoice extends DocumentDraftReference { updatedAt: number, baseVersion: string, source: 'durable' | 'rescue' }
|
|
26
|
+
export interface DocumentDraftCommit extends DraftStoreCommit { record: StoredDocumentDraft, clearing: boolean }
|
|
27
|
+
|
|
28
|
+
const PREFIX = 'presmium.document.v2:'
|
|
29
|
+
const SETTLED = 'presmium.document-settled.v1:'
|
|
30
|
+
const MAX_CHARS = 8 << 20
|
|
31
|
+
const prefix = (identity: string) => `${PREFIX}${JSON.stringify(identity)}:`
|
|
32
|
+
const markerPrefix = (identity: string) => `${SETTLED}[${JSON.stringify(identity)},`
|
|
33
|
+
const keyOf = (identity: string, id: string) => `${prefix(identity)}${id}`
|
|
34
|
+
const settledKey = (identity: string, value: DocumentDraftReference) => `${SETTLED}${JSON.stringify([identity, value.id, value.revision, value.token])}`
|
|
35
|
+
const idRevision = (value: unknown): value is Record<string, unknown> & { id: string, revision: number } => isRecord(value) && typeof value.id === 'string' && value.id.length > 0 && value.id.length <= 200 && Number.isSafeInteger(value.revision) && (value.revision as number) > 0
|
|
36
|
+
const reference = (value: unknown): value is DocumentDraftReference => idRevision(value) && 'token' in value && typeof value.token === 'string' && /^[a-f0-9]{64}$/.test(value.token)
|
|
37
|
+
const draftShape = (value: unknown): value is DocumentDraft => isRecord(value) && typeof value.baseVersion === 'string' && value.baseVersion.length > 0 && value.baseVersion.length <= 200 && typeof value.baseHash === 'string' && value.baseHash.length > 0
|
|
38
|
+
&& Number.isSafeInteger(value.input) && (value.input as number) >= 0 && (value.pendingHash === undefined || typeof value.pendingHash === 'string')
|
|
39
|
+
&& (value.composing === undefined || typeof value.composing === 'boolean') && (value.origins === undefined || Array.isArray(value.origins) && value.origins.length <= 1024 && value.origins.every(reference))
|
|
40
|
+
|
|
41
|
+
function documentDraft(value: unknown, documentId: string): DocumentDraft | undefined {
|
|
42
|
+
if (!draftShape(value)) return
|
|
43
|
+
const checked = validateDocument(value.content)
|
|
44
|
+
if (!checked.document || checked.document.id !== documentId || checked.issues.some(issue => issue.severity === 'error')) return
|
|
45
|
+
return { content: checked.document, baseVersion: value.baseVersion, baseHash: value.baseHash, input: value.input,
|
|
46
|
+
pendingHash: value.pendingHash, composing: value.composing, origins: value.origins }
|
|
47
|
+
}
|
|
48
|
+
async function tokenOf(raw: string): Promise<string> {
|
|
49
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw))
|
|
50
|
+
return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, '0')).join('')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Strictly committed document backups, with old localStorage copies identified as unconfirmed rescue material. */
|
|
54
|
+
export class DocumentDrafts {
|
|
55
|
+
readonly #owner = crypto.randomUUID()
|
|
56
|
+
readonly #owned = new Map<string, { identity: string, revision: number, raw: string }>()
|
|
57
|
+
#store?: DocumentDraftStore
|
|
58
|
+
#identity?: string
|
|
59
|
+
#closed = false
|
|
60
|
+
readonly #changed?: () => void
|
|
61
|
+
constructor(changed?: () => void) { this.#changed = changed }
|
|
62
|
+
|
|
63
|
+
#storage(identity: string): DocumentDraftStore | undefined {
|
|
64
|
+
if (this.#closed || this.#identity !== undefined && this.#identity !== identity) return
|
|
65
|
+
this.#identity = identity
|
|
66
|
+
return this.#store ??= new DocumentDraftStore(identity, this.#changed)
|
|
67
|
+
}
|
|
68
|
+
matches(identity: string, key: string | null): boolean { return key === null || key.startsWith(prefix(identity)) || key.startsWith(markerPrefix(identity)) }
|
|
69
|
+
|
|
70
|
+
async read(identity: string, documentId: string): Promise<{ records: RecoverableDocumentDraft[], failed: boolean }> {
|
|
71
|
+
const store = this.#storage(identity), values: { key: string, raw: unknown, source: 'durable' | 'rescue' }[] = []
|
|
72
|
+
let failed = false, durable = new Map<string, unknown>()
|
|
73
|
+
if (!store) return { records: [], failed: true }
|
|
74
|
+
try {
|
|
75
|
+
durable = await store.read([prefix(identity), markerPrefix(identity)])
|
|
76
|
+
for (const [key, raw] of durable) if (key.startsWith(prefix(identity))) values.push({ key, raw, source: 'durable' })
|
|
77
|
+
} catch { failed = true }
|
|
78
|
+
// Existing localStorage input is never removed or silently treated as a strict commit.
|
|
79
|
+
try {
|
|
80
|
+
const keys: string[] = []
|
|
81
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
82
|
+
const key = localStorage.key(index)
|
|
83
|
+
if (key?.startsWith(prefix(identity))) keys.push(key)
|
|
84
|
+
}
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
const raw = localStorage.getItem(key)
|
|
87
|
+
if (raw !== null) values.push({ key, raw, source: 'rescue' })
|
|
88
|
+
}
|
|
89
|
+
} catch { failed = true }
|
|
90
|
+
const records = new Map<string, RecoverableDocumentDraft>()
|
|
91
|
+
for (const { key, raw, source } of values) {
|
|
92
|
+
try {
|
|
93
|
+
if (typeof raw !== 'string' || raw.length > MAX_CHARS) throw new Error('document-draft-size')
|
|
94
|
+
const value: unknown = JSON.parse(raw)
|
|
95
|
+
if (!isRecord(value) || value.format !== 2 || value.identity !== identity || !idRevision(value) || key !== keyOf(identity, value.id)
|
|
96
|
+
|| typeof value.owner !== 'string' || !value.owner || value.owner.length > 200 || typeof value.updatedAt !== 'number' || !Number.isSafeInteger(value.updatedAt) || value.updatedAt < 0 || Number.isNaN(new Date(value.updatedAt).getTime())) throw new Error('document-draft-data')
|
|
97
|
+
const draft = documentDraft(value, documentId)
|
|
98
|
+
if (!draft) throw new Error('document-draft-document')
|
|
99
|
+
const token = await tokenOf(raw), marker = settledKey(identity, { id: value.id, revision: value.revision, token })
|
|
100
|
+
if (durable.get(marker) === token) continue
|
|
101
|
+
if (source === 'rescue' && localStorage.getItem(marker) === token) continue
|
|
102
|
+
const id = JSON.stringify([value.id, value.revision, token])
|
|
103
|
+
if (!records.has(id)) records.set(id, { ...draft, format: 2, identity, id: value.id, owner: value.owner, revision: value.revision, updatedAt: value.updatedAt, token, source,
|
|
104
|
+
owned: source === 'durable' && value.owner === this.#owner && (this.#owned.get(value.id)?.raw === raw || store.owns(key, raw)) })
|
|
105
|
+
} catch { failed = true }
|
|
106
|
+
}
|
|
107
|
+
return { records: [...records.values()], failed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#record(identity: string, draft: DocumentDraft, previous?: StoredDocumentDraft): { record: StoredDocumentDraft, raw: string } | undefined {
|
|
111
|
+
const store = this.#storage(identity)
|
|
112
|
+
if (!store || !draftShape(draft)) return
|
|
113
|
+
const owned = previous && this.#owned.get(previous.id)
|
|
114
|
+
if (previous && (!owned || owned.identity !== identity || previous.owner !== this.#owner || previous.revision !== owned.revision)) return
|
|
115
|
+
const changed = previous && store.conflicted(keyOf(identity, previous.id))
|
|
116
|
+
const record: StoredDocumentDraft = { ...draft, format: 2, identity, id: !changed && previous ? previous.id : crypto.randomUUID(), owner: this.#owner, revision: changed ? 1 : (owned?.revision ?? 0) + 1, updatedAt: Date.now() }
|
|
117
|
+
if (!Number.isSafeInteger(record.revision)) return
|
|
118
|
+
try { const raw = JSON.stringify(record); return raw.length <= MAX_CHARS ? { record, raw } : undefined }
|
|
119
|
+
catch { return }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Admission is immediate; only done='committed' confirms a durable backup. */
|
|
123
|
+
write(identity: string, draft: DocumentDraft, previous?: StoredDocumentDraft): DocumentDraftCommit | undefined {
|
|
124
|
+
const next = this.#record(identity, draft, previous)
|
|
125
|
+
if (!next) return
|
|
126
|
+
const commit = this.#store!.put(keyOf(identity, next.record.id), next.raw)
|
|
127
|
+
if (!commit) return
|
|
128
|
+
this.#owned.set(next.record.id, { identity, revision: next.record.revision, raw: next.raw })
|
|
129
|
+
if (previous && previous.id !== next.record.id) this.archive(previous)
|
|
130
|
+
return { ...commit, record: next.record, clearing: false }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async fork(identity: string, draft: DocumentDraft): Promise<StoredDocumentDraft | undefined> {
|
|
134
|
+
const next = this.#record(identity, draft)
|
|
135
|
+
if (!next || !await this.#store!.create(keyOf(identity, next.record.id), next.raw)) return
|
|
136
|
+
this.#owned.set(next.record.id, { identity, revision: next.record.revision, raw: next.raw })
|
|
137
|
+
return next.record
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Immutable, revision-specific markers never delete another tab's newer record. */
|
|
141
|
+
async settle(identity: string, values: readonly DocumentDraftReference[]): Promise<boolean> {
|
|
142
|
+
const store = this.#storage(identity)
|
|
143
|
+
return Boolean(store && await store.mark(values.map(value => [settledKey(identity, value), value.token])))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
remove(identity: string, record: StoredDocumentDraft, origins: readonly DocumentDraftReference[]): DocumentDraftCommit | undefined {
|
|
147
|
+
const owned = this.#owned.get(record.id), store = this.#storage(identity)
|
|
148
|
+
if (!store || !owned || owned.identity !== identity || record.owner !== this.#owner || owned.revision !== record.revision) return
|
|
149
|
+
const commit = store.remove(keyOf(identity, record.id), origins.map(value => [settledKey(identity, value), value.token]))
|
|
150
|
+
if (!commit) return
|
|
151
|
+
void commit.done.then(result => { if (result === 'committed' && this.#owned.get(record.id)?.revision === record.revision) this.#owned.delete(record.id) })
|
|
152
|
+
return { ...commit, record, clearing: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The old tab-local snapshot is removed only after a validated strict copy exists. */
|
|
156
|
+
async migrate(identity: string, documentId: string, legacyKey: string): Promise<'read' | 'write' | undefined> {
|
|
157
|
+
let raw: string | null, draft: DocumentDraft | undefined
|
|
158
|
+
try {
|
|
159
|
+
raw = sessionStorage.getItem(legacyKey)
|
|
160
|
+
if (raw === null) return
|
|
161
|
+
if (raw.length > MAX_CHARS) return 'read'
|
|
162
|
+
draft = documentDraft(JSON.parse(raw), documentId)
|
|
163
|
+
if (!draft) return 'read'
|
|
164
|
+
} catch { return 'read' }
|
|
165
|
+
try {
|
|
166
|
+
const found = (await this.read(identity, documentId)).records.find(record => record.source === 'durable' && record.baseVersion === draft.baseVersion && record.baseHash === draft.baseHash
|
|
167
|
+
&& record.input === draft.input && record.pendingHash === draft.pendingHash && Boolean(record.composing) === Boolean(draft.composing) && JSON.stringify(record.content) === JSON.stringify(draft.content))
|
|
168
|
+
const latest = sessionStorage.getItem(legacyKey)
|
|
169
|
+
if (latest !== raw) return latest === null ? undefined : 'read'
|
|
170
|
+
if (!found && !await this.fork(identity, draft)) return 'write'
|
|
171
|
+
} catch { return 'read' }
|
|
172
|
+
try { if (sessionStorage.getItem(legacyKey) === raw) sessionStorage.removeItem(legacyKey) }
|
|
173
|
+
catch { /* The strict copy exists; keep the old bytes if cleanup is unavailable. */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
close(): void { this.#closed = true; this.#store?.close() }
|
|
177
|
+
archive(record: StoredDocumentDraft): void { this.#owned.delete(record.id); this.#store?.release(keyOf(record.identity, record.id)) }
|
|
178
|
+
}
|