@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/site.ts
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
3
|
-
import { compileTranslations,
|
|
3
|
+
import { compileTranslations, directoryAsset, fileHash, lineContaining, loadPlugins, nodeVitePlugins, portable, publishesDirectory, viewFor } from '@lincyaw/compiler'
|
|
4
4
|
import type { Compilation } from '@lincyaw/compiler'
|
|
5
|
-
import
|
|
6
|
-
import type {
|
|
5
|
+
import { DOCUMENT_ENTRY, isRecord, NON_FILE_URL, WEB_RENDERER_PACKAGE } from '@lincyaw/kernel'
|
|
6
|
+
import type { DocumentSummary, Finding, IRDocument, RenderContext, Renderer, SiteDocument, SiteSpec, SourcePos } from '@lincyaw/kernel'
|
|
7
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
7
8
|
import type { WorkspaceConfig } from './config.ts'
|
|
8
9
|
|
|
9
|
-
const WEB_PACKAGE = '@lincyaw/web'
|
|
10
|
-
|
|
11
10
|
export async function webRenderer(root: string, bundled?: string): Promise<Renderer> {
|
|
12
|
-
const { plugins, problems } = await loadPlugins(root, [
|
|
11
|
+
const { plugins, problems } = await loadPlugins(root, [WEB_RENDERER_PACKAGE], { workspaceRoot: root, listed: [], bundled })
|
|
13
12
|
const renderer = plugins[0]?.plugin?.renderer
|
|
14
|
-
if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${
|
|
13
|
+
if (!renderer?.buildSite || !renderer.devSite) throw new Error(`the web renderer (${WEB_RENDERER_PACKAGE}) is not installed: ${problems.map(p => p.message).join('; ') || 'it has no workspace site'}`)
|
|
15
14
|
return renderer
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
/** The document as the workspace site has it, every form's blocks in it: its plugins named by their directories. */
|
|
18
|
+
export function siteDocumentIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
|
|
19
19
|
const dirs = doc.config.plugins.flatMap((name) => {
|
|
20
20
|
const loaded = compilation.plugins.find(plugin => plugin.name === name)
|
|
21
21
|
return loaded ? [loaded.dir] : []
|
|
22
22
|
})
|
|
23
|
-
return
|
|
23
|
+
return { ...doc, config: { ...doc.config, plugins: dirs } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The document's web form on the workspace site. */
|
|
27
|
+
export function siteIR(compilation: Compilation, doc: IRDocument = compilation.doc): IRDocument {
|
|
28
|
+
return viewFor(siteDocumentIR(compilation, doc), 'web')
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export async function siteTranslations(compilation: Compilation): Promise<{ translations: NonNullable<SiteDocument['translations']>, findings: Finding[] }> {
|
|
@@ -29,16 +34,23 @@ export async function siteTranslations(compilation: Compilation): Promise<{ tran
|
|
|
29
34
|
return { translations: translations.map(({ lang, doc }) => ({ lang, doc: siteIR(compilation, doc) })), findings }
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
export function siteDocument(document:
|
|
33
|
-
return { dir: document.dir, title: document.title, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {} }
|
|
37
|
+
export function siteDocument(document: DocumentSummary, doc?: IRDocument, translations?: SiteDocument['translations'], compilation?: Compilation): SiteDocument {
|
|
38
|
+
return { dir: document.dir, title: document.title, description: document.description, ...document.lang ? { lang: document.lang } : {}, ...document.tags ? { tags: document.tags } : {}, ...doc ? { doc } : {}, ...translations?.length ? { translations } : {}, ...compilation ? { nodeVitePlugins: nodeVitePlugins(compilation) } : {} }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function refreshDocumentVitePlugins(site: SiteSpec, dir: string, compilation: Compilation): void {
|
|
42
|
+
let document = site.documents.find(document => document.dir === dir)
|
|
43
|
+
if (!document) {
|
|
44
|
+
document = { dir, title: compilation.doc.meta.title, lang: compilation.doc.meta.lang }
|
|
45
|
+
site.documents.push(document)
|
|
46
|
+
}
|
|
47
|
+
document.nodeVitePlugins = nodeVitePlugins(compilation)
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
export function siteSpec(config: WorkspaceConfig, documents: SiteDocument[]): SiteSpec {
|
|
37
51
|
return { title: config.title, documents, ...typeof config.site.home === 'string' ? { home: config.site.home } : {}, config: config.site }
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
|
|
41
|
-
|
|
42
54
|
export interface SiteContextOptions {
|
|
43
55
|
root: string
|
|
44
56
|
outDir: string
|
|
@@ -60,7 +72,6 @@ export function siteContext(options: SiteContextOptions): RenderContext {
|
|
|
60
72
|
pluginDirs: {},
|
|
61
73
|
renderers: ['web'],
|
|
62
74
|
base: options.base ?? '/',
|
|
63
|
-
vitePlugins: [],
|
|
64
75
|
extensionFor: () => undefined,
|
|
65
76
|
portable: (value, from) => portable(value, from, root),
|
|
66
77
|
localAsset: (source, url) => publishedAsset(options, source, url, ctx.report),
|
|
@@ -72,15 +83,15 @@ export function siteContext(options: SiteContextOptions): RenderContext {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
function deckOf(file: string, root: string): string | undefined {
|
|
75
|
-
for (let dir = dirname(file);
|
|
76
|
-
if (existsSync(join(dir,
|
|
86
|
+
for (let dir = dirname(file); isInside(dir, root); dir = dirname(dir)) {
|
|
87
|
+
if (existsSync(join(dir, DOCUMENT_ENTRY))) return dir
|
|
77
88
|
if (dir === root) return undefined
|
|
78
89
|
}
|
|
79
90
|
return undefined
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
function publishedAsset(options: SiteContextOptions, source: SourcePos, url: string, report: (finding: Finding) => void): { file: string, name: string } | undefined {
|
|
83
|
-
if (
|
|
94
|
+
if (NON_FILE_URL.test(url)) return undefined
|
|
84
95
|
const path = url.replace(/[?#].*$/s, '')
|
|
85
96
|
if (!path || path.startsWith('/')) return undefined
|
|
86
97
|
let name = path
|
|
@@ -88,7 +99,7 @@ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: str
|
|
|
88
99
|
name = decodeURIComponent(path)
|
|
89
100
|
} catch {
|
|
90
101
|
}
|
|
91
|
-
const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line:
|
|
102
|
+
const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineContaining(source, url) } })
|
|
92
103
|
const target = resolve(dirname(source.file), name)
|
|
93
104
|
const stat = statSync(target, { throwIfNoEntry: false })
|
|
94
105
|
if (!stat) {
|
|
@@ -97,25 +108,25 @@ function publishedAsset(options: SiteContextOptions, source: SourcePos, url: str
|
|
|
97
108
|
}
|
|
98
109
|
const root = realpathSync(options.root)
|
|
99
110
|
const real = realpathSync(target)
|
|
100
|
-
if (!
|
|
111
|
+
if (!isInside(real, root)) {
|
|
101
112
|
if (stat.isFile()) warn(`${url} is not published: the file lies outside the workspace`)
|
|
102
113
|
return undefined
|
|
103
114
|
}
|
|
104
115
|
if (stat.isFile()) return { file: target, name: `${fileHash(target).slice(0, 8)}-${basename(target)}` }
|
|
105
116
|
const deck = deckOf(source.file, options.root)
|
|
106
117
|
if (!deck || !publishesDirectory(target, deck)) return undefined
|
|
107
|
-
const
|
|
118
|
+
const document: unknown = JSON.parse(readFileSync(join(deck, DOCUMENT_ENTRY), 'utf8'))
|
|
119
|
+
const lang = isRecord(document) && isRecord(document.metadata) ? document.metadata.lang : undefined
|
|
108
120
|
return directoryAsset(target, deck, join(options.workDir, 'files'), typeof lang === 'string' ? lang : undefined, message => warn(`${url} is not published: ${message}`))
|
|
109
121
|
}
|
|
110
122
|
|
|
111
|
-
export function
|
|
123
|
+
export function untranslatedFindings(compilation: Compilation, root: string): Finding[] {
|
|
112
124
|
const { translations } = compilation.doc.config
|
|
113
125
|
if (!translations) return []
|
|
114
|
-
const text = readFileSync(compilation.entry, 'utf8')
|
|
115
126
|
return Object.entries(translations).map(([lang, entry]) => ({
|
|
116
127
|
checkId: 'presmium.translations',
|
|
117
128
|
severity: 'warning' as const,
|
|
118
129
|
message: `translation "${lang}" (${entry}) is not in the workspace site of presmium serve, which shows the originals; presmium build at the workspace root publishes it (or presmium build ${relative(root, compilation.deckDir).split(sep).join('/') || '.'} for this document alone)`,
|
|
119
|
-
source: { file: compilation.entry, line:
|
|
130
|
+
source: { file: compilation.entry, line: 1 },
|
|
120
131
|
}))
|
|
121
132
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { WORKSPACE_CONFIG } from '@lincyaw/compiler'
|
|
5
|
+
import { DOCUMENT_ENTRY, validateDocument } from '@lincyaw/kernel'
|
|
6
|
+
import type { ConflictAnswer, DocumentFileChange, FilesAnswer, Member, Revision, SavedAnswer, SourceAnswer, SourceFile, SourceTransactionAnswer, SourceTransactionChange } from '@lincyaw/kernel'
|
|
7
|
+
import { isWithin } from '../domain/paths.ts'
|
|
8
|
+
import { listDocumentEntries, readDocumentSummary } from './config.ts'
|
|
9
|
+
import type { DocumentEntry, WorkspaceConfig } from './config.ts'
|
|
10
|
+
import { RequestError } from './errors.ts'
|
|
11
|
+
import { lockedError } from './locks.ts'
|
|
12
|
+
import type { DocumentLocks } from './locks.ts'
|
|
13
|
+
import { documentDirOf, FilesError, writeChanges } from './writer.ts'
|
|
14
|
+
import type { DocumentWriter } from './writer.ts'
|
|
15
|
+
|
|
16
|
+
export const MAX_SOURCE = 2 << 20
|
|
17
|
+
export const MAX_SOURCE_CHANGES = 16
|
|
18
|
+
const MAX_FILES = 2000
|
|
19
|
+
const HIDDEN_FROM_EDITOR = new Set(['node_modules', 'dist', '.git', '.presmium'])
|
|
20
|
+
const BINARY = /\.(?:png|jpe?g|gif|webp|avif|ico|bmp|pdf|wasm|zip|gz|tgz|bz2|xz|7z|woff2?|ttf|otf|eot|mp3|mp4|m4a|wav|ogg|oga|webm|mov|avi|mkv|bin|so|dylib|dll|exe|pyc|xlsx?|docx?|pptx?|parquet|npy|npz|pkl|db|sqlite)$/i
|
|
21
|
+
|
|
22
|
+
export function validateDocumentSource(path: string, content: string, before?: Uint8Array): void {
|
|
23
|
+
if (path !== DOCUMENT_ENTRY && !path.endsWith(`/${DOCUMENT_ENTRY}`)) return
|
|
24
|
+
let value: unknown
|
|
25
|
+
try { value = JSON.parse(content.replace(/^\uFEFF/, '')) }
|
|
26
|
+
catch { throw new RequestError(422, 'document-invalid', { issues: [{ path: '', code: 'json-syntax', message: 'The document is not valid JSON.', severity: 'error' }] }) }
|
|
27
|
+
const checked = validateDocument(value)
|
|
28
|
+
if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) throw new RequestError(422, 'document-invalid', { issues: checked.issues })
|
|
29
|
+
if (before) {
|
|
30
|
+
let previous: unknown
|
|
31
|
+
try { previous = JSON.parse(Buffer.from(before).toString('utf8').replace(/^\uFEFF/, '')) } catch {}
|
|
32
|
+
if (previous && typeof previous === 'object' && 'id' in previous && typeof previous.id === 'string' && previous.id !== checked.document.id) throw new RequestError(422, 'document-identity-changed')
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function versionOf(bytes: Uint8Array | string): string {
|
|
37
|
+
return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SourceEditorOptions {
|
|
41
|
+
config: WorkspaceConfig
|
|
42
|
+
writer: DocumentWriter
|
|
43
|
+
locks?: DocumentLocks
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Scope {
|
|
47
|
+
document: string
|
|
48
|
+
home: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SourceTransactionHistory {
|
|
52
|
+
run<T>(work: () => Promise<T>): Promise<T>
|
|
53
|
+
commit(paths: string[], lang: string | undefined): Promise<Revision | undefined>
|
|
54
|
+
}
|
|
55
|
+
interface TransactionFile { path: string, content: string | Uint8Array, base: string | null }
|
|
56
|
+
|
|
57
|
+
/** The source files the browser's editor lists, opens, creates and saves, as far as the member and the page's document may. */
|
|
58
|
+
export class SourceEditor {
|
|
59
|
+
readonly #config: WorkspaceConfig
|
|
60
|
+
readonly #writer: DocumentWriter
|
|
61
|
+
readonly #locks?: DocumentLocks
|
|
62
|
+
|
|
63
|
+
constructor(options: SourceEditorOptions) {
|
|
64
|
+
this.#config = options.config
|
|
65
|
+
this.#writer = options.writer
|
|
66
|
+
this.#locks = options.locks
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
scope(document: string): Scope {
|
|
70
|
+
return { document, home: document === '' && !this.#config.singleDeck }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
roots(scope: Scope): string[] {
|
|
74
|
+
if (!scope.home) return [scope.document]
|
|
75
|
+
return ['']
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
canEdit(scope: Scope, path: string, member: Member): boolean {
|
|
79
|
+
return this.#canEdit(scope, path, member)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#canEdit(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): boolean {
|
|
83
|
+
if (path === WORKSPACE_CONFIG) return (scope.home || (this.#config.singleDeck && scope.document === '')) && member.roles.includes('owner')
|
|
84
|
+
if (scope.home && member.roles.includes('owner')) return true
|
|
85
|
+
const dir = documentDirOf(this.#config, path)
|
|
86
|
+
if (dir === undefined) return scope.home && member.roles.includes('owner')
|
|
87
|
+
return scope.home ? (documents ?? listDocumentEntries(this.#config)).some(doc => doc.dir === dir) : dir === scope.document
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
files(scope: Scope, member: Member): FilesAnswer {
|
|
91
|
+
const files: SourceFile[] = []
|
|
92
|
+
const documents = scope.home ? listDocumentEntries(this.#config) : undefined
|
|
93
|
+
let truncated = false
|
|
94
|
+
const walk = (dir: string, owner: string | undefined) => {
|
|
95
|
+
let names: string[]
|
|
96
|
+
try {
|
|
97
|
+
names = readdirSync(join(this.#config.root, dir)).sort()
|
|
98
|
+
} catch {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
if (HIDDEN_FROM_EDITOR.has(name) || name.startsWith('.')) continue
|
|
103
|
+
const path = dir ? `${dir}/${name}` : name
|
|
104
|
+
const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
|
|
105
|
+
if (!found || found.isSymbolicLink()) continue
|
|
106
|
+
if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
|
|
107
|
+
walk(path, owner)
|
|
108
|
+
} else if (found.isFile() && this.#canEdit(scope, path, member, documents)) {
|
|
109
|
+
if (files.length >= MAX_FILES) {
|
|
110
|
+
truncated = true
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
|
|
118
|
+
return { document: scope.document, home: scope.home, ...documents ? { documents: documents.map(doc => doc.dir) } : {}, files, ...truncated ? { truncated: true as const } : {} }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#documentHolding(path: string, documents: readonly DocumentEntry[]): DocumentEntry | undefined {
|
|
122
|
+
let found: DocumentEntry | undefined
|
|
123
|
+
for (const doc of documents) if (isWithin(path, doc.dir) && (!found || doc.dir.length > found.dir.length)) found = doc
|
|
124
|
+
return found
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Checks path and document policy without requiring a file to exist. */
|
|
128
|
+
assertEditable(scope: Scope, path: string, member: Member): void {
|
|
129
|
+
this.#assertEditable(scope, path, member)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#assertEditable(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): void {
|
|
133
|
+
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\\') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || HIDDEN_FROM_EDITOR.has(part))) throw new RequestError(400, 'workspace-path-invalid', { path: String(path).slice(0, 500) })
|
|
134
|
+
if (!this.#canEdit(scope, path, member, documents)) throw new RequestError(403, 'workspace-file-forbidden', { path })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A regular file under the same path and document policy as source editing. */
|
|
138
|
+
readableFile(scope: Scope, path: string, member: Member): string {
|
|
139
|
+
return this.#readableFile(scope, path, member)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#readableFile(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): string {
|
|
143
|
+
this.#assertEditable(scope, path, member, documents)
|
|
144
|
+
const file = join(this.#config.root, path)
|
|
145
|
+
let real: string
|
|
146
|
+
try {
|
|
147
|
+
real = realpathSync(file)
|
|
148
|
+
} catch {
|
|
149
|
+
throw new RequestError(404, 'workspace-file-missing', { path })
|
|
150
|
+
}
|
|
151
|
+
if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, 'workspace-file-irregular', { path })
|
|
152
|
+
return file
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#readBytes(scope: Scope, path: string, member: Member, documents?: readonly DocumentEntry[]): Buffer {
|
|
156
|
+
const file = this.#readableFile(scope, path, member, documents)
|
|
157
|
+
const bytes = readFileSync(file)
|
|
158
|
+
if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
|
|
159
|
+
return bytes
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
read(scope: Scope, path: string, member: Member): SourceAnswer {
|
|
163
|
+
const bytes = this.#readBytes(scope, path, member)
|
|
164
|
+
if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
|
|
165
|
+
let content: string
|
|
166
|
+
try {
|
|
167
|
+
content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
|
|
168
|
+
} catch {
|
|
169
|
+
throw new RequestError(415, `${path} is not UTF-8 text`)
|
|
170
|
+
}
|
|
171
|
+
return { path, content, version: versionOf(bytes) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async create(scope: Scope, path: string, member: Member): Promise<SourceAnswer> {
|
|
175
|
+
const documents = listDocumentEntries(this.#config)
|
|
176
|
+
this.#assertEditable(scope, path, member, documents)
|
|
177
|
+
if (path === DOCUMENT_ENTRY || path.endsWith(`/${DOCUMENT_ENTRY}`)) throw new RequestError(422, 'create-a-document-with-valid-content')
|
|
178
|
+
if (BINARY.test(path)) throw new RequestError(415, `${path} is not a text file`)
|
|
179
|
+
const document = this.#documentHolding(path, documents)?.dir
|
|
180
|
+
const scopeDir = document ?? ''
|
|
181
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:create') : () => {}
|
|
182
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
183
|
+
try {
|
|
184
|
+
await this.#writer.create(path, Buffer.alloc(0), document === undefined)
|
|
185
|
+
return { path, content: '', version: versionOf('') }
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new RequestError(409, `${path} already exists`, { exists: true })
|
|
188
|
+
if (error instanceof FilesError) throw new RequestError(403, error.message)
|
|
189
|
+
throw error
|
|
190
|
+
} finally {
|
|
191
|
+
release()
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Saves related sources as one authoring change; any failed write or commit restores all preimages. */
|
|
196
|
+
async transaction(scope: Scope, changes: SourceTransactionChange[], member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
|
|
197
|
+
return this.#transaction(scope, changes, member, history)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Checks the same scope, size, CAS and candidate rules without writing or committing. */
|
|
201
|
+
async planTransaction(scope: Scope, changes: SourceTransactionChange[], member: Member): Promise<SourceTransactionAnswer> {
|
|
202
|
+
return this.#transaction(scope, changes, member, undefined, undefined, true)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A validated authoring document and one new original attachment share CAS, rollback and history. */
|
|
206
|
+
async transactionWithBinary(scope: Scope, document: SourceTransactionChange, attachment: { path: string, content: Uint8Array }, member: Member, history?: SourceTransactionHistory): Promise<SourceTransactionAnswer> {
|
|
207
|
+
if (document.path !== DOCUMENT_ENTRY && !document.path.endsWith(`/${DOCUMENT_ENTRY}`) || document.base === null) throw new RequestError(400, 'document-transaction-required')
|
|
208
|
+
if (!attachment.content.byteLength || attachment.content.byteLength > 16 << 20) throw new RequestError(413, 'production.asset-size')
|
|
209
|
+
return this.#transaction(scope, [document, { ...attachment, content: new Uint8Array(attachment.content), base: null }], member, history, attachment.path)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #transaction(scope: Scope, changes: TransactionFile[], member: Member, history?: SourceTransactionHistory, binaryPath?: string, dryRun = false): Promise<SourceTransactionAnswer> {
|
|
213
|
+
if (changes.length === 0 || changes.length > MAX_SOURCE_CHANGES) throw new RequestError(400, `a source transaction takes 1 to ${MAX_SOURCE_CHANGES} files`)
|
|
214
|
+
const paths = new Set<string>()
|
|
215
|
+
const known = listDocumentEntries(this.#config)
|
|
216
|
+
let bytes = 0
|
|
217
|
+
const documents = changes.map(change => {
|
|
218
|
+
this.#assertEditable(scope, change.path, member, known)
|
|
219
|
+
if (paths.has(change.path)) throw new RequestError(400, `a source path occurs twice: ${change.path}`)
|
|
220
|
+
paths.add(change.path)
|
|
221
|
+
if (change.path !== binaryPath) {
|
|
222
|
+
if (typeof change.content !== 'string' || BINARY.test(change.path) || change.content.includes('\0')) throw new RequestError(415, `${change.path} is not a text file`)
|
|
223
|
+
bytes += Buffer.byteLength(change.content, 'utf8')
|
|
224
|
+
}
|
|
225
|
+
return this.#documentHolding(change.path, known)
|
|
226
|
+
})
|
|
227
|
+
if (bytes > MAX_SOURCE) throw new RequestError(413, `the transaction is larger than the editor saves (${MAX_SOURCE} bytes)`)
|
|
228
|
+
for (const path of paths) if ([...paths].some(other => path !== other && path.startsWith(`${other}/`))) throw new RequestError(400, `a source file cannot also be a parent directory: ${path}`)
|
|
229
|
+
const document = documents[0]
|
|
230
|
+
if (!document || documents.some(found => found?.dir !== document.dir)) throw new RequestError(400, 'a source transaction must stay in one document')
|
|
231
|
+
const scopeDir = document.dir
|
|
232
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:transaction') : () => {}
|
|
233
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
234
|
+
const work = async (): Promise<SourceTransactionAnswer> => {
|
|
235
|
+
// Read every preimage before creating directories or writing any file.
|
|
236
|
+
const snapshots = changes.map(change => {
|
|
237
|
+
const written = typeof change.content === 'string' ? Buffer.from(change.content, 'utf8') : Buffer.from(change.content)
|
|
238
|
+
if (change.base !== null) {
|
|
239
|
+
const before = this.#readBytes(scope, change.path, member, known)
|
|
240
|
+
const version = versionOf(before)
|
|
241
|
+
if (version !== change.base) throw new RequestError(409, `${change.path} changed since it was opened`, { conflict: { path: change.path, version, content: before.toString('utf8') } })
|
|
242
|
+
if (BINARY.test(change.path) || before.includes(0)) throw new RequestError(415, `${change.path} is not a text file`)
|
|
243
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(before) } catch { throw new RequestError(415, `${change.path} is not UTF-8 text`) }
|
|
244
|
+
const executable = (lstatSync(join(this.#config.root, change.path)).mode & 0o111) !== 0
|
|
245
|
+
return { path: change.path, before, written, executable }
|
|
246
|
+
}
|
|
247
|
+
const file = join(this.#config.root, change.path)
|
|
248
|
+
let parent = this.#config.root
|
|
249
|
+
for (const part of change.path.split('/').slice(0, -1)) {
|
|
250
|
+
parent = join(parent, part)
|
|
251
|
+
const found = lstatSync(parent, { throwIfNoEntry: false })
|
|
252
|
+
if (found && (!found.isDirectory() || realpathSync(parent) !== parent)) throw new RequestError(403, `${change.path} has no regular workspace parent`)
|
|
253
|
+
if (!found) break
|
|
254
|
+
}
|
|
255
|
+
if (lstatSync(file, { throwIfNoEntry: false })) throw new RequestError(409, `${change.path} already exists`, { exists: true })
|
|
256
|
+
return { path: change.path, before: undefined, written, executable: false }
|
|
257
|
+
})
|
|
258
|
+
for (const snapshot of snapshots) if (snapshot.path !== binaryPath) validateDocumentSource(snapshot.path, snapshot.written.toString('utf8'), snapshot.before)
|
|
259
|
+
if (dryRun) return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })) }
|
|
260
|
+
const created: string[] = []
|
|
261
|
+
const edited = snapshots.filter(snapshot => snapshot.before && !snapshot.written.equals(snapshot.before))
|
|
262
|
+
const touched = snapshots.filter(snapshot => !snapshot.before || !snapshot.written.equals(snapshot.before)).map(snapshot => snapshot.path)
|
|
263
|
+
const lang = history && touched.length ? readDocumentSummary(this.#config, document).lang : undefined
|
|
264
|
+
const write = (next: DocumentFileChange[]) => this.#writer.write(next)
|
|
265
|
+
try {
|
|
266
|
+
// Exclusive creation prevents replacing a file created outside serve after validation.
|
|
267
|
+
for (const snapshot of snapshots) if (!snapshot.before) {
|
|
268
|
+
await this.#writer.create(snapshot.path, snapshot.written)
|
|
269
|
+
created.push(snapshot.path)
|
|
270
|
+
}
|
|
271
|
+
if (edited.length) await write(edited.map(snapshot => ({ path: snapshot.path, content: snapshot.written, ...snapshot.executable ? { executable: true } : {} })))
|
|
272
|
+
const revision = history && touched.length ? await history.commit(touched, lang) : undefined
|
|
273
|
+
return { versions: snapshots.map(snapshot => ({ path: snapshot.path, version: versionOf(snapshot.written) })), ...revision ? { revision } : {} }
|
|
274
|
+
} catch (error) {
|
|
275
|
+
try {
|
|
276
|
+
await write([
|
|
277
|
+
...snapshots.flatMap(snapshot => snapshot.before ? [{ path: snapshot.path, content: snapshot.before, ...snapshot.executable ? { executable: true } : {} }] : []),
|
|
278
|
+
...created.map(path => ({ path, remove: true as const })),
|
|
279
|
+
])
|
|
280
|
+
} catch (rollback) {
|
|
281
|
+
throw new Error(`source transaction failed (${error instanceof Error ? error.message : String(error)}); restoring sources also failed (${rollback instanceof Error ? rollback.message : String(rollback)})`)
|
|
282
|
+
}
|
|
283
|
+
throw error
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
return await (history ? history.run(work) : work())
|
|
288
|
+
} finally {
|
|
289
|
+
release()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Writes the text over the version the editor opened. `commit` makes a change a revision while the document's lock
|
|
295
|
+
* is still held; when it fails, the file is put back as it was and the save fails.
|
|
296
|
+
*/
|
|
297
|
+
async save(scope: Scope, path: string, content: string, base: string, member: Member, commit?: (path: string, lang: string | undefined) => Promise<Revision | undefined>): Promise<SavedAnswer> {
|
|
298
|
+
const documents = listDocumentEntries(this.#config)
|
|
299
|
+
const document = this.#documentHolding(path, documents)
|
|
300
|
+
const outside = document === undefined
|
|
301
|
+
const scopeDir = document?.dir ?? ''
|
|
302
|
+
const release = this.#locks ? this.#locks.claim(scopeDir, 'editor:save') : () => {}
|
|
303
|
+
if (!release) throw lockedError(scopeDir, this.#locks!.holder(scopeDir)!)
|
|
304
|
+
try {
|
|
305
|
+
const bytes = this.#readBytes(scope, path, member, documents)
|
|
306
|
+
const now = versionOf(bytes)
|
|
307
|
+
if (now !== base) {
|
|
308
|
+
const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
|
|
309
|
+
throw new RequestError(409, `${path} changed since it was opened`, conflict)
|
|
310
|
+
}
|
|
311
|
+
const written = Buffer.from(content, 'utf8')
|
|
312
|
+
validateDocumentSource(path, content, bytes)
|
|
313
|
+
if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
|
|
314
|
+
const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
|
|
315
|
+
const lang = commit && !written.equals(bytes) && document ? readDocumentSummary(this.#config, document).lang : undefined
|
|
316
|
+
const write = async (next: DocumentFileChange) => (outside ? writeChanges(this.#config, [next], true) : await this.#writer.write([next]))
|
|
317
|
+
await write(change)
|
|
318
|
+
let revision: Revision | undefined
|
|
319
|
+
if (commit && !written.equals(bytes)) {
|
|
320
|
+
try {
|
|
321
|
+
revision = await commit(path, lang)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
await write({ ...change, content: bytes })
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return { version: versionOf(written), ...revision ? { revision } : {} }
|
|
328
|
+
} finally {
|
|
329
|
+
release()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
2
|
+
import type { Route, SpeechSettingsChange } from '@lincyaw/kernel'
|
|
3
|
+
import { OperatorSpeechSettings, SpeechSettingsError } from '@lincyaw/kernel/node'
|
|
4
|
+
|
|
5
|
+
/** Global operator route: works before opening a document and never returns credential values. */
|
|
6
|
+
export function speechSettingsRoute(directory: string, fallback: (name: string) => string | undefined): Route {
|
|
7
|
+
const settings = new OperatorSpeechSettings(directory, fallback)
|
|
8
|
+
return { role: 'owner', maxBody: 16384, handle: async request => {
|
|
9
|
+
try {
|
|
10
|
+
if (request.method === 'GET') return jsonResponse(settings.read(), 200, { 'cache-control': 'no-store' })
|
|
11
|
+
if (request.method !== 'POST') return problemResponse(405, 'GET or POST')
|
|
12
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
13
|
+
if (!isRecord(body) || typeof body.revision !== 'string' || !['save', 'clear'].includes(String(body.action)) || Object.keys(body).some(key => !['action', 'revision', 'apiKey', 'appId'].includes(key))
|
|
14
|
+
|| body.action === 'save' && (body.apiKey !== undefined && typeof body.apiKey !== 'string' || body.appId !== undefined && typeof body.appId !== 'string')) return problemResponse(400, 'speech-settings-invalid')
|
|
15
|
+
return jsonResponse(await settings.change(body as unknown as SpeechSettingsChange), 200, { 'cache-control': 'no-store' })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const code = error instanceof SpeechSettingsError ? error.code : 'speech-settings-unavailable'
|
|
18
|
+
return problemResponse(code === 'speech-settings-invalid' ? 400 : code === 'speech-settings-conflict' || code === 'speech-settings-busy' ? 409 : 503, code)
|
|
19
|
+
}
|
|
20
|
+
} }
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { WorkspaceConfig } from './config.ts'
|
|
5
|
+
|
|
6
|
+
const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_TEMPLATE = 'default'
|
|
9
|
+
|
|
10
|
+
function templatesDir(config: WorkspaceConfig): string {
|
|
11
|
+
return config.templates ?? BUILTIN_TEMPLATES
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function listTemplates(config: WorkspaceConfig): string[] {
|
|
15
|
+
const dir = templatesDir(config)
|
|
16
|
+
try {
|
|
17
|
+
return readdirSync(dir).filter(name => statSync(join(dir, name)).isDirectory()).sort()
|
|
18
|
+
} catch {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function findTemplate(config: WorkspaceConfig, name: string): string | undefined {
|
|
24
|
+
if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
|
|
25
|
+
const dir = join(templatesDir(config), name)
|
|
26
|
+
return existsSync(dir) && lstatSync(dir, { throwIfNoEntry: false })?.isDirectory() ? dir : undefined
|
|
27
|
+
}
|