@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/session.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { existsSync, statSync, watch } from 'node:fs'
|
|
2
|
-
import type { FSWatcher } from 'node:fs'
|
|
3
|
-
import { basename, dirname, join } from 'node:path'
|
|
4
|
-
import type { Compilation, Compiler } from '@lincyaw/compiler'
|
|
5
|
-
import { errorMessage } from '@lincyaw/kernel'
|
|
6
|
-
import type { Logger } from '@lincyaw/kernel'
|
|
7
|
-
|
|
8
|
-
export class DocumentSession {
|
|
9
|
-
readonly dir: string
|
|
10
|
-
readonly compiler: Compiler
|
|
11
|
-
#latest?: Compilation
|
|
12
|
-
#first?: Promise<Compilation>
|
|
13
|
-
#running: Promise<unknown> = Promise.resolve()
|
|
14
|
-
#queued?: Promise<Compilation>
|
|
15
|
-
readonly #listeners = new Set<(compilation: Compilation, changed: string[]) => void | Promise<void>>()
|
|
16
|
-
#changedFiles = new Set<string>()
|
|
17
|
-
readonly #watchers = new Map<string, FSWatcher>()
|
|
18
|
-
#relevant = new Map<string, Set<string>>()
|
|
19
|
-
#timer?: NodeJS.Timeout
|
|
20
|
-
#closed = false
|
|
21
|
-
readonly #log: Logger
|
|
22
|
-
|
|
23
|
-
constructor(dir: string, compiler: Compiler, log: Logger) {
|
|
24
|
-
this.dir = dir
|
|
25
|
-
this.compiler = compiler
|
|
26
|
-
this.#log = log
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
ready(): Promise<Compilation> {
|
|
30
|
-
if (this.#latest) return Promise.resolve(this.#latest)
|
|
31
|
-
if (!this.#first) {
|
|
32
|
-
const first = this.recompile()
|
|
33
|
-
first.catch(() => {
|
|
34
|
-
if (this.#first === first) this.#first = undefined
|
|
35
|
-
})
|
|
36
|
-
this.#first = first
|
|
37
|
-
}
|
|
38
|
-
return this.#first
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
get latest(): Compilation | undefined {
|
|
42
|
-
return this.#latest
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
onCompile(listener: (compilation: Compilation, changed: string[]) => void | Promise<void>): () => void {
|
|
46
|
-
this.#listeners.add(listener)
|
|
47
|
-
return () => this.#listeners.delete(listener)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
recompile(): Promise<Compilation> {
|
|
51
|
-
if (this.#queued) return this.#queued
|
|
52
|
-
const next = this.#running.then(async () => {
|
|
53
|
-
this.#queued = undefined
|
|
54
|
-
const started = Date.now()
|
|
55
|
-
const changed = [...this.#changedFiles]
|
|
56
|
-
this.#changedFiles.clear()
|
|
57
|
-
const compilation = await this.compiler.compile()
|
|
58
|
-
this.#latest = compilation
|
|
59
|
-
for (const listener of this.#listeners) {
|
|
60
|
-
try {
|
|
61
|
-
await listener(compilation, changed)
|
|
62
|
-
} catch (error) {
|
|
63
|
-
this.#log.error(`after compiling ${this.dir || '.'}: ${errorMessage(error)}`)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
this.#watch(compilation, started)
|
|
67
|
-
return compilation
|
|
68
|
-
})
|
|
69
|
-
this.#queued = next
|
|
70
|
-
this.#running = next.catch((error: unknown) => this.#log.error(`compiling ${this.dir || '.'} failed: ${errorMessage(error)}`))
|
|
71
|
-
return next
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
close(): void {
|
|
75
|
-
this.#closed = true
|
|
76
|
-
clearTimeout(this.#timer)
|
|
77
|
-
for (const watcher of this.#watchers.values()) watcher.close()
|
|
78
|
-
this.#watchers.clear()
|
|
79
|
-
this.#listeners.clear()
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
#watch(compilation: Compilation, started: number): void {
|
|
83
|
-
if (this.#closed) return
|
|
84
|
-
const files = new Set([...compilation.files, ...compilation.dependencies.files])
|
|
85
|
-
const next = new Map<string, Set<string>>()
|
|
86
|
-
for (const file of files) {
|
|
87
|
-
let dir = dirname(file)
|
|
88
|
-
let name = basename(file)
|
|
89
|
-
while (!existsSync(dir) && dirname(dir) !== dir) {
|
|
90
|
-
name = basename(dir)
|
|
91
|
-
dir = dirname(dir)
|
|
92
|
-
}
|
|
93
|
-
next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
|
|
94
|
-
}
|
|
95
|
-
this.#relevant = next
|
|
96
|
-
for (const [dir, watcher] of this.#watchers) {
|
|
97
|
-
if (next.has(dir)) continue
|
|
98
|
-
watcher.close()
|
|
99
|
-
this.#watchers.delete(dir)
|
|
100
|
-
}
|
|
101
|
-
for (const dir of next.keys()) {
|
|
102
|
-
if (this.#watchers.has(dir)) continue
|
|
103
|
-
try {
|
|
104
|
-
this.#watchers.set(dir, watch(dir, (_event, name) => {
|
|
105
|
-
if (name && this.#relevant.get(dir)?.has(basename(name))) this.#changed(join(dir, name))
|
|
106
|
-
}))
|
|
107
|
-
} catch {
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
const modified = [...files].filter((file) => {
|
|
111
|
-
const time = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
112
|
-
return time >= started - 20 && time <= Date.now()
|
|
113
|
-
})
|
|
114
|
-
for (const file of modified) this.#changed(file)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
#changed(file: string): void {
|
|
118
|
-
if (this.#closed) return
|
|
119
|
-
this.#changedFiles.add(file)
|
|
120
|
-
clearTimeout(this.#timer)
|
|
121
|
-
this.#timer = setTimeout(() => void this.recompile().catch(() => undefined), 50)
|
|
122
|
-
}
|
|
123
|
-
}
|
package/src/node/source.ts
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto'
|
|
2
|
-
import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
4
|
-
import type { DocumentFileChange, Member } from '@lincyaw/kernel'
|
|
5
|
-
import type { ConflictAnswer, FilesAnswer, SourceAnswer, SourceFile } from '../domain/api.ts'
|
|
6
|
-
import { WORKSPACE_FILE } from '../domain/api.ts'
|
|
7
|
-
import { within } from '../domain/paths.ts'
|
|
8
|
-
import { listDocuments } from './config.ts'
|
|
9
|
-
import type { WorkspaceConfig } from './config.ts'
|
|
10
|
-
import { lockedError, RequestError } from './documents.ts'
|
|
11
|
-
import { documentDirOf, writeChanges } from './files.ts'
|
|
12
|
-
import type { DocumentWriter } from './files.ts'
|
|
13
|
-
import type { DocumentLocks } from './locks.ts'
|
|
14
|
-
|
|
15
|
-
export const MAX_SOURCE = 2 << 20
|
|
16
|
-
const MAX_FILES = 2000
|
|
17
|
-
const NOT_SOURCE = new Set(['node_modules', 'dist', '.git', '.presmium'])
|
|
18
|
-
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
|
|
19
|
-
|
|
20
|
-
export function versionOf(bytes: Uint8Array | string): string {
|
|
21
|
-
return createHash('sha256').update(bytes).digest('hex').slice(0, 20)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface SourceOptions {
|
|
25
|
-
config: WorkspaceConfig
|
|
26
|
-
writer: DocumentWriter
|
|
27
|
-
locks?: DocumentLocks
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface Scope {
|
|
31
|
-
document: string
|
|
32
|
-
home: boolean
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export class SourceFiles {
|
|
36
|
-
readonly #config: WorkspaceConfig
|
|
37
|
-
readonly #writer: DocumentWriter
|
|
38
|
-
readonly #locks?: DocumentLocks
|
|
39
|
-
|
|
40
|
-
constructor(options: SourceOptions) {
|
|
41
|
-
this.#config = options.config
|
|
42
|
-
this.#writer = options.writer
|
|
43
|
-
this.#locks = options.locks
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
scope(document: string): Scope {
|
|
47
|
-
return { document, home: document === '' && !this.#config.single }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
roots(scope: Scope): string[] {
|
|
51
|
-
if (!scope.home) return [scope.document]
|
|
52
|
-
return ['']
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
allows(scope: Scope, path: string, member: Member): boolean {
|
|
56
|
-
if (path === WORKSPACE_FILE) return (scope.home || (this.#config.single && scope.document === '')) && member.roles.includes('owner')
|
|
57
|
-
if (scope.home && member.roles.includes('owner')) return true
|
|
58
|
-
const dir = documentDirOf(this.#config, path)
|
|
59
|
-
if (dir === undefined) return scope.home && member.roles.includes('owner')
|
|
60
|
-
return scope.home ? listDocuments(this.#config).some(doc => doc.dir === dir) : dir === scope.document
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
files(scope: Scope, member: Member): FilesAnswer {
|
|
64
|
-
const files: SourceFile[] = []
|
|
65
|
-
let truncated = false
|
|
66
|
-
const walk = (dir: string, owner: string | undefined) => {
|
|
67
|
-
let names: string[]
|
|
68
|
-
try {
|
|
69
|
-
names = readdirSync(join(this.#config.root, dir)).sort()
|
|
70
|
-
} catch {
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
for (const name of names) {
|
|
74
|
-
if (NOT_SOURCE.has(name) || name.startsWith('.')) continue
|
|
75
|
-
const path = dir ? `${dir}/${name}` : name
|
|
76
|
-
const found = lstatSync(join(this.#config.root, path), { throwIfNoEntry: false })
|
|
77
|
-
if (!found || found.isSymbolicLink()) continue
|
|
78
|
-
if (found.isDirectory() && (scope.home || documentDirOf(this.#config, `${path}/x`) === owner)) {
|
|
79
|
-
walk(path, owner)
|
|
80
|
-
} else if (found.isFile() && this.allows(scope, path, member)) {
|
|
81
|
-
if (files.length >= MAX_FILES) {
|
|
82
|
-
truncated = true
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
files.push({ path, ...BINARY.test(name) ? { binary: true as const } : {} })
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
walk(scope.home ? '' : scope.document, scope.home ? undefined : scope.document)
|
|
90
|
-
const documents = scope.home ? listDocuments(this.#config).map(doc => doc.dir) : undefined
|
|
91
|
-
return { document: scope.document, home: scope.home, ...documents ? { documents } : {}, files, ...truncated ? { truncated: true as const } : {} }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
#bytes(scope: Scope, path: string, member: Member): Buffer {
|
|
95
|
-
if (typeof path !== 'string' || !path || path.startsWith('/') || path.includes('\0') || path.split('/').some(part => part === '' || part === '.' || part === '..' || NOT_SOURCE.has(part))) throw new RequestError(400, `not a source path: ${JSON.stringify(path)}`)
|
|
96
|
-
if (!this.allows(scope, path, member)) throw new RequestError(403, `${path} is not a file this page edits`)
|
|
97
|
-
const file = join(this.#config.root, path)
|
|
98
|
-
let real: string
|
|
99
|
-
try {
|
|
100
|
-
real = realpathSync(file)
|
|
101
|
-
} catch {
|
|
102
|
-
throw new RequestError(404, `no file ${path}`)
|
|
103
|
-
}
|
|
104
|
-
if (real !== file || !lstatSync(file).isFile()) throw new RequestError(403, `${path} is not a regular file of the workspace`)
|
|
105
|
-
const bytes = readFileSync(file)
|
|
106
|
-
if (bytes.byteLength > MAX_SOURCE) throw new RequestError(413, `${path} is larger than the editor opens (${MAX_SOURCE} bytes)`)
|
|
107
|
-
return bytes
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
read(scope: Scope, path: string, member: Member): SourceAnswer {
|
|
111
|
-
const bytes = this.#bytes(scope, path, member)
|
|
112
|
-
if (BINARY.test(path) || bytes.includes(0)) throw new RequestError(415, `${path} is not a text file`)
|
|
113
|
-
let content: string
|
|
114
|
-
try {
|
|
115
|
-
content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
116
|
-
} catch {
|
|
117
|
-
throw new RequestError(415, `${path} is not UTF-8 text`)
|
|
118
|
-
}
|
|
119
|
-
return { path, content, version: versionOf(bytes) }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async save(scope: Scope, path: string, content: string, base: string, member: Member): Promise<string> {
|
|
123
|
-
const document = listDocuments(this.#config).sort((a, b) => b.dir.length - a.dir.length).find(doc => within(path, doc.dir))?.dir
|
|
124
|
-
const outside = document === undefined
|
|
125
|
-
const release = outside || !this.#locks ? () => {} : this.#locks.claim(document, 'editor:save')
|
|
126
|
-
if (!release) throw lockedError(document!, this.#locks!.holder(document!) ?? '')
|
|
127
|
-
try {
|
|
128
|
-
const bytes = this.#bytes(scope, path, member)
|
|
129
|
-
const now = versionOf(bytes)
|
|
130
|
-
if (now !== base) {
|
|
131
|
-
const conflict: Omit<ConflictAnswer, 'error'> = { conflict: { version: now, content: bytes.toString('utf8') } }
|
|
132
|
-
throw new RequestError(409, `${path} changed since it was opened`, conflict)
|
|
133
|
-
}
|
|
134
|
-
const written = Buffer.from(content, 'utf8')
|
|
135
|
-
if (written.byteLength > MAX_SOURCE) throw new RequestError(413, `the text is larger than the editor saves (${MAX_SOURCE} bytes)`)
|
|
136
|
-
const change: DocumentFileChange = { path, content: written, ...(lstatSync(join(this.#config.root, path)).mode & 0o111) !== 0 ? { executable: true } : {} }
|
|
137
|
-
if (outside) writeChanges(this.#config, [change], true)
|
|
138
|
-
else await this.#writer.write([change])
|
|
139
|
-
return versionOf(written)
|
|
140
|
-
} finally {
|
|
141
|
-
release()
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
inScope(scope: Scope, path: string): boolean {
|
|
146
|
-
return this.roots(scope).some(root => root === WORKSPACE_FILE ? path === root : within(path, root))
|
|
147
|
-
}
|
|
148
|
-
}
|