@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.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
package/src/domain/api.ts DELETED
@@ -1,102 +0,0 @@
1
- import type { Revision } from '@lincyaw/kernel/contracts'
2
- import type { RevisionOperation } from './revisions.ts'
3
-
4
- export const WORKSPACE_ID = 'presmium.workspace'
5
-
6
- export interface WorkspaceDocument {
7
- dir: string
8
- title: string
9
- lang?: string
10
- tags?: string[]
11
- }
12
-
13
- export interface DocumentsAnswer {
14
- title: string
15
- documents: WorkspaceDocument[]
16
- templates: string[]
17
- git: boolean
18
- branch?: string
19
- uncommitted?: number
20
- single: boolean
21
- }
22
-
23
- export type DocumentOperation =
24
- | { op: 'create', dir: string, title?: string, template?: string }
25
- | { op: 'rename', dir: string, to: string }
26
- | { op: 'copy', dir: string, to: string }
27
- | { op: 'delete', dir: string }
28
-
29
- export interface OperationAnswer {
30
- revision?: Revision
31
- dir?: string
32
- }
33
-
34
- export interface HistoryRevision extends Revision {
35
- operation?: RevisionOperation
36
- reverts?: string
37
- redo?: true
38
- }
39
-
40
- export interface RevisionsAnswer {
41
- revisions: HistoryRevision[]
42
- }
43
-
44
- export type RevertAnswer = { ok: true, revision: Revision } | { ok: false, conflicts: string[] }
45
-
46
- export type LockKind = 'agent' | 'revert' | 'operation'
47
- export function lockKind(holder: string): LockKind {
48
- return holder.startsWith('agent:') ? 'agent' : holder.endsWith(':revert') ? 'revert' : 'operation'
49
- }
50
-
51
- export interface LockedAnswer {
52
- error: string
53
- locked: { document: string, holder: string }
54
- }
55
-
56
- export const WORKSPACE_FILE = 'presmium.workspace.yaml'
57
-
58
- export interface SourceFile {
59
- path: string
60
- binary?: true
61
- }
62
-
63
- export interface FilesAnswer {
64
- document: string
65
- home: boolean
66
- documents?: string[]
67
- files: SourceFile[]
68
- truncated?: true
69
- }
70
-
71
- export interface SourceAnswer {
72
- path: string
73
- content: string
74
- version: string
75
- }
76
-
77
- export interface SavedAnswer {
78
- version: string
79
- }
80
-
81
- export interface ConflictAnswer {
82
- error: string
83
- conflict: { version: string, content: string }
84
- }
85
-
86
- export type UncommittedKind = 'added' | 'modified' | 'deleted'
87
-
88
- export interface UncommittedFile {
89
- path: string
90
- kind: UncommittedKind
91
- added?: number
92
- removed?: number
93
- }
94
-
95
- export interface ChangesAnswer {
96
- files: UncommittedFile[]
97
- }
98
-
99
- export interface CommitAnswer {
100
- revision?: Revision
101
- paths: string[]
102
- }
@@ -1,6 +0,0 @@
1
- /** @experimental */
2
- export { WORKSPACE_FILE, WORKSPACE_ID } from './api.ts'
3
- export type { ChangesAnswer, CommitAnswer, ConflictAnswer, DocumentOperation, DocumentsAnswer, FilesAnswer, HistoryRevision, OperationAnswer, RevertAnswer, RevisionsAnswer, SavedAnswer, SourceAnswer, SourceFile, UncommittedFile, UncommittedKind, WorkspaceDocument } from './api.ts'
4
- export { invalidDocumentDir, matchesPattern, movedUrl, rewriteLinks } from './paths.ts'
5
- export { commitMessage, originOf, trailers } from './revisions.ts'
6
- export type { CommitOrigin, RevisionOperation } from './revisions.ts'
@@ -1,185 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import type { Member } from '@lincyaw/kernel'
4
- import type { DocumentOperation, LockedAnswer, OperationAnswer, WorkspaceDocument } from '../domain/api.ts'
5
- import { invalidDocumentDir, within } from '../domain/paths.ts'
6
- import type { CommitOrigin, RevisionOperation } from '../domain/revisions.ts'
7
- import { listDocuments, namedByPatterns } from './config.ts'
8
- import type { WorkspaceConfig } from './config.ts'
9
- import { templatesDir } from './files.ts'
10
- import type { DocumentWriter } from './files.ts'
11
- import type { Git } from './git.ts'
12
- import { lockedMessage } from './locks.ts'
13
- import type { DocumentLocks } from './locks.ts'
14
- import type { Revisions } from './revisions.ts'
15
-
16
- export { BUILTIN_TEMPLATES } from './files.ts'
17
- export const DEFAULT_TEMPLATE = 'default'
18
-
19
- export class RequestError extends Error {
20
- readonly status: number
21
- readonly detail?: Record<string, unknown>
22
- constructor(status: number, message: string, detail?: Record<string, unknown>) {
23
- super(message)
24
- this.status = status
25
- this.detail = detail
26
- }
27
- }
28
-
29
- export function lockedError(document: string, holder: string): RequestError {
30
- return new RequestError(409, lockedMessage(document, holder), { locked: { document, holder } } satisfies Omit<LockedAnswer, 'error'>)
31
- }
32
-
33
- export function makeDocumentDir(root: string, dir: string): void {
34
- const path = join(root, dir)
35
- mkdirSync(dirname(path), { recursive: true })
36
- mkdirSync(path)
37
- }
38
-
39
- export interface DocumentsChange {
40
- removed: string[]
41
- added: string[]
42
- }
43
-
44
- export interface DocumentsOptions {
45
- config: WorkspaceConfig
46
- git: Git
47
- revisions: Revisions
48
- writer: DocumentWriter
49
- changed?: (change: DocumentsChange) => void | Promise<void>
50
- locks?: DocumentLocks
51
- }
52
-
53
- function touched(operation: DocumentOperation): string[] {
54
- return operation.op === 'create' || operation.op === 'delete' ? [operation.dir] : [operation.dir, operation.to]
55
- }
56
-
57
- export class Documents {
58
- readonly config: WorkspaceConfig
59
- readonly #git: Git
60
- readonly #revisions: Revisions
61
- readonly #writer: DocumentWriter
62
- readonly #changed: (change: DocumentsChange) => void | Promise<void>
63
- readonly #locks?: DocumentLocks
64
-
65
- constructor(options: DocumentsOptions) {
66
- this.config = options.config
67
- this.#git = options.git
68
- this.#revisions = options.revisions
69
- this.#writer = options.writer
70
- this.#changed = options.changed ?? (() => {})
71
- this.#locks = options.locks
72
- }
73
-
74
- get writer(): DocumentWriter {
75
- return this.#writer
76
- }
77
-
78
- get locks(): DocumentLocks | undefined {
79
- return this.#locks
80
- }
81
-
82
- list(): WorkspaceDocument[] {
83
- return listDocuments(this.config).map(({ entry: _entry, ...document }) => document)
84
- }
85
-
86
- get templatesDir(): string {
87
- return templatesDir(this.config)
88
- }
89
-
90
- templates(): string[] {
91
- try {
92
- return readdirSync(this.templatesDir).filter(name => statSync(join(this.templatesDir, name)).isDirectory()).sort()
93
- } catch {
94
- return []
95
- }
96
- }
97
-
98
- async apply(operation: DocumentOperation, member: Member): Promise<OperationAnswer> {
99
- if (this.config.single) throw new RequestError(409, 'this workspace is a single deck: it has no document operations')
100
- const claimed = this.#locks?.claimAll(touched(operation), `document:${operation.op}`)
101
- if (claimed && 'holder' in claimed) throw lockedError(claimed.document, claimed.holder)
102
- try {
103
- const answer = await this.#git.exclusive(() => this.#apply(operation, member))
104
- const added = operation.op === 'delete' ? [] : [answer.dir!]
105
- const removed = operation.op === 'rename' || operation.op === 'delete' ? [operation.dir] : []
106
- await this.#changed({ removed, added })
107
- return answer
108
- } finally {
109
- claimed?.release()
110
- }
111
- }
112
-
113
- async #fill(dir: string, fill: () => Promise<void>): Promise<void> {
114
- makeDocumentDir(this.config.root, dir)
115
- try {
116
- await fill()
117
- } catch (error) {
118
- await this.#remove(dir).catch(() => undefined)
119
- throw error
120
- }
121
- }
122
-
123
- async #remove(dir: string): Promise<void> {
124
- for (let attempt = 0; ; attempt++) {
125
- await this.#writer.empty(dir)
126
- try {
127
- rmdirSync(join(this.config.root, dir))
128
- return
129
- } catch (error) {
130
- if (attempt > 0 || (error as NodeJS.ErrnoException).code !== 'ENOTEMPTY') throw new RequestError(409, `${dir} could not be removed: ${error instanceof Error ? error.message : String(error)}`)
131
- }
132
- }
133
- }
134
-
135
- async #apply(operation: DocumentOperation, member: Member): Promise<OperationAnswer> {
136
- const origin = (done: RevisionOperation): CommitOrigin => ({ kind: 'document', member, operation: done })
137
- const known = listDocuments(this.config).map(doc => doc.dir)
138
- const existing = (dir: string) => {
139
- if (!known.includes(dir)) throw new RequestError(404, `no document ${JSON.stringify(dir)}`)
140
- return dir
141
- }
142
- const fresh = (dir: string) => {
143
- const invalid = invalidDocumentDir(dir)
144
- if (invalid) throw new RequestError(400, invalid)
145
- if (!namedByPatterns(this.config, dir)) throw new RequestError(400, `"${dir}" is not where the workspace configuration's documents are (${this.config.documents.join(', ')})`)
146
- const holder = known.find(doc => doc !== '' && (within(dir, doc) || within(doc, dir)))
147
- if (holder !== undefined) throw new RequestError(409, `${dir} would hold or be inside the document ${holder}`)
148
- if (existsSync(join(this.config.root, dir))) throw new RequestError(409, `${dir} exists already`)
149
- return dir
150
- }
151
- switch (operation.op) {
152
- case 'create': {
153
- const dir = fresh(operation.dir)
154
- const name = operation.template ?? (this.templates().includes(DEFAULT_TEMPLATE) ? DEFAULT_TEMPLATE : this.templates()[0])
155
- if (!name || !this.templates().includes(name)) throw new RequestError(400, `no document template ${JSON.stringify(name ?? '')}`)
156
- const title = operation.title?.trim() || dir.split('/').at(-1)!
157
- await this.#fill(dir, () => this.#writer.template(dir, name, title))
158
- const revision = await this.#revisions.commitNow([dir], `Create document ${dir}`, `Title: ${title}\nTemplate: ${name}`, origin({ op: 'create', dir }))
159
- return { revision, dir }
160
- }
161
- case 'rename': {
162
- const from = existing(operation.dir)
163
- const to = fresh(operation.to)
164
- mkdirSync(dirname(join(this.config.root, to)), { recursive: true })
165
- renameSync(join(this.config.root, from), join(this.config.root, to))
166
- const rewritten = (await this.#writer.relink(from, to)).filter(path => !within(path, to))
167
- const revision = await this.#revisions.commitNow([from, to, ...rewritten], `Rename document ${from} to ${to}`, rewritten.length ? `Links rewritten in: ${rewritten.join(', ')}` : '', origin({ op: 'rename', dir: from, to }))
168
- return { revision, dir: to }
169
- }
170
- case 'copy': {
171
- const from = existing(operation.dir)
172
- const to = fresh(operation.to)
173
- await this.#fill(to, () => this.#writer.copy(from, to))
174
- const revision = await this.#revisions.commitNow([to], `Copy document ${from} to ${to}`, '', origin({ op: 'copy', dir: from, to }))
175
- return { revision, dir: to }
176
- }
177
- case 'delete': {
178
- const dir = existing(operation.dir)
179
- await this.#remove(dir)
180
- const revision = await this.#revisions.commitNow([dir], `Delete document ${dir}`, '', origin({ op: 'delete', dir }))
181
- return { revision }
182
- }
183
- }
184
- }
185
- }
package/src/node/files.ts DELETED
@@ -1,252 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
- import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, rmdirSync, rmSync, symlinkSync, writeFileSync, writeSync } from 'node:fs'
3
- import type { Stats } from 'node:fs'
4
- import { join } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import type { DocumentFileChange, DocumentFiles } from '@lincyaw/kernel'
7
- import { rewriteLinks, within } from '../domain/paths.ts'
8
- import { listDocuments, namedByPatterns } from './config.ts'
9
- import type { WorkspaceConfig } from './config.ts'
10
-
11
- export const BUILTIN_TEMPLATES = fileURLToPath(new URL('../../templates/document', import.meta.url))
12
-
13
- const NOT_SOURCE = new Set(['node_modules', 'dist', '.git'])
14
-
15
- export class FilesError extends Error {}
16
-
17
- export interface DocumentWriter extends DocumentFiles {
18
- template(dir: string, template: string, title: string): Promise<void>
19
- copy(from: string, to: string): Promise<void>
20
- relink(from: string, to: string): Promise<string[]>
21
- empty(dir: string): Promise<void>
22
- }
23
-
24
- function partsOf(path: unknown): string[] {
25
- if (typeof path !== 'string' || !path || path.includes('\0') || path.startsWith('/')) throw new FilesError(`not a path in the workspace: ${JSON.stringify(path)}`)
26
- const parts = path.split('/')
27
- if (parts.some(part => part === '' || part === '.' || part === '..')) throw new FilesError(`not a plain path: ${path}`)
28
- if (parts.some(part => part.toLowerCase() === '.git')) throw new FilesError(`a path in a git directory: ${path}`)
29
- return parts
30
- }
31
-
32
- function stat(path: string): Stats | undefined {
33
- try {
34
- return lstatSync(path)
35
- } catch {
36
- return undefined
37
- }
38
- }
39
-
40
- function realDirectory(root: string, dir: string): boolean {
41
- let current = root
42
- for (const part of dir ? dir.split('/') : []) {
43
- current = join(current, part)
44
- if (!stat(current)?.isDirectory()) return false
45
- }
46
- return stat(current)?.isDirectory() ?? false
47
- }
48
-
49
- export function documentDirOf(config: WorkspaceConfig, path: string): string | undefined {
50
- const parts = path.split('/')
51
- for (let i = parts.length - 1; i >= 0; i--) {
52
- const dir = parts.slice(0, i).join('/')
53
- if (namedByPatterns(config, dir)) return dir
54
- }
55
- return undefined
56
- }
57
-
58
- function documentDir(config: WorkspaceConfig, dir: string): string {
59
- if (dir !== '') partsOf(dir)
60
- if (!namedByPatterns(config, dir)) throw new FilesError(`${dir || '.'} is not a document directory of this workspace`)
61
- if (!realDirectory(config.root, dir)) throw new FilesError(`the document directory ${dir || '.'} is not there`)
62
- return dir
63
- }
64
-
65
- function madeParent(root: string, base: string, parts: string[]): string {
66
- let current = join(root, base)
67
- const start = base ? base.split('/').length : 0
68
- for (const part of parts.slice(start, -1)) {
69
- current = join(current, part)
70
- const found = stat(current)
71
- if (found?.isDirectory()) continue
72
- if (found) rmSync(current, { force: true })
73
- mkdirSync(current)
74
- }
75
- return current
76
- }
77
-
78
- function existingParent(root: string, base: string, parts: string[]): string | undefined {
79
- let current = join(root, base)
80
- const start = base ? base.split('/').length : 0
81
- for (const part of parts.slice(start, -1)) {
82
- current = join(current, part)
83
- if (!stat(current)?.isDirectory()) return undefined
84
- }
85
- return current
86
- }
87
-
88
- function replace(parent: string, target: string, make: (temporary: string) => void): void {
89
- const temporary = join(parent, `.presmium-write-${randomBytes(6).toString('hex')}`)
90
- make(temporary)
91
- try {
92
- const found = stat(target)
93
- if (found?.isDirectory()) rmSync(target, { recursive: true, force: true })
94
- renameSync(temporary, target)
95
- } catch (error) {
96
- rmSync(temporary, { force: true })
97
- throw error
98
- }
99
- }
100
-
101
- function prune(root: string, base: string, from: string): void {
102
- const top = join(root, base)
103
- for (let dir = from; dir !== top && dir.startsWith(`${top}/`); dir = join(dir, '..')) {
104
- try {
105
- if (readdirSync(dir).length > 0) return
106
- rmdirSync(dir)
107
- } catch {
108
- return
109
- }
110
- }
111
- }
112
-
113
- function apply(root: string, base: string, change: DocumentFileChange): void {
114
- const parts = partsOf(change.path)
115
- const name = parts.at(-1)!
116
- if ('remove' in change) {
117
- const parent = existingParent(root, base, parts)
118
- if (!parent || !stat(join(parent, name))) return
119
- rmSync(join(parent, name), { recursive: true, force: true })
120
- prune(root, base, parent)
121
- return
122
- }
123
- const parent = madeParent(root, base, parts)
124
- const target = join(parent, name)
125
- if ('link' in change) {
126
- if (typeof change.link !== 'string' || !change.link || change.link.includes('\0')) throw new FilesError(`not a link target: ${change.path}`)
127
- replace(parent, target, temporary => symlinkSync(change.link, temporary))
128
- return
129
- }
130
- if (change.at !== undefined) {
131
- writeAt(parent, target, change.content, change.at)
132
- return
133
- }
134
- replace(parent, target, temporary => writeFileSync(temporary, change.content, { flag: 'wx', mode: change.executable ? 0o777 : 0o666 }))
135
- }
136
-
137
- function writeAt(parent: string, target: string, content: Uint8Array, at: number): void {
138
- if (!Number.isSafeInteger(at) || at < 0) throw new FilesError(`not a position in a file: ${at}`)
139
- if (!stat(target)?.isFile()) replace(parent, target, temporary => writeFileSync(temporary, '', { flag: 'wx', mode: 0o666 }))
140
- const fd = openSync(target, constants.O_WRONLY | constants.O_NOFOLLOW)
141
- try {
142
- const size = fstatSync(fd).size
143
- if (at > size) throw new FilesError(`position ${at} is past the end of the file (${size} bytes)`)
144
- writeSync(fd, content, 0, content.byteLength, at)
145
- } finally {
146
- closeSync(fd)
147
- }
148
- }
149
-
150
- export function writeChanges(config: WorkspaceConfig, changes: DocumentFileChange[], outside = false): void {
151
- const targets = changes.map((change) => {
152
- const parts = partsOf(change.path)
153
- const dir = documentDirOf(config, parts.join('/'))
154
- if (dir === undefined && !outside) throw new FilesError(`${change.path} is in no document directory`)
155
- if (dir !== undefined) documentDir(config, dir)
156
- return { change, base: dir ?? '' }
157
- })
158
- for (const { change, base } of targets) if ('remove' in change) apply(config.root, base, change)
159
- for (const { change, base } of targets) if (!('remove' in change)) apply(config.root, base, change)
160
- }
161
-
162
- const DECK_CONTENT = new Set(['freeze', 'drawings', 'assets', 'speech', 'speech.lock.json', 'anchors.lock.json'])
163
-
164
- export function generatedPath(path: string): boolean {
165
- const parts = path.split('/')
166
- const at = parts.indexOf('.presmium')
167
- return at >= 0 && !DECK_CONTENT.has(parts[at + 1] ?? '')
168
- }
169
-
170
- function sourceEntries(dir: string): { path: string, link?: string, executable?: boolean }[] {
171
- const out: { path: string, link?: string, executable?: boolean }[] = []
172
- const walk = (current: string, rel: string) => {
173
- for (const name of readdirSync(current).sort()) {
174
- if (NOT_SOURCE.has(name)) continue
175
- if (rel === '.presmium' && !DECK_CONTENT.has(name)) continue
176
- const path = join(current, name)
177
- const entry = rel ? `${rel}/${name}` : name
178
- const found = stat(path)
179
- if (!found) continue
180
- if (found.isSymbolicLink()) out.push({ path: entry, link: readlinkSync(path) })
181
- else if (found.isDirectory()) walk(path, entry)
182
- else if (found.isFile()) out.push({ path: entry, executable: (found.mode & 0o111) !== 0 })
183
- }
184
- }
185
- if (stat(dir)?.isDirectory()) walk(dir, '')
186
- return out
187
- }
188
-
189
- export function templatesDir(config: WorkspaceConfig): string {
190
- return config.templates ?? BUILTIN_TEMPLATES
191
- }
192
-
193
- export function templateOf(config: WorkspaceConfig, name: string): string | undefined {
194
- if (!name || name.includes('/') || name.includes('\\') || name.startsWith('.')) return undefined
195
- const dir = join(templatesDir(config), name)
196
- return existsSync(dir) && stat(dir)?.isDirectory() ? dir : undefined
197
- }
198
-
199
- export function fromTemplate(config: WorkspaceConfig, dir: string, template: string, title: string): void {
200
- documentDir(config, dir)
201
- const source = templateOf(config, template)
202
- if (!source) throw new FilesError(`no document template ${JSON.stringify(template)}`)
203
- const quoted = JSON.stringify(title).slice(1, -1)
204
- writeChanges(config, sourceEntries(source).filter(entry => entry.link === undefined).map(({ path, executable }) => {
205
- const content = readFileSync(join(source, path))
206
- return { path: `${dir}/${path}`, content: path.endsWith('.md') ? Buffer.from(content.toString('utf8').replaceAll('{{title}}', () => quoted)) : content, ...executable ? { executable } : {} }
207
- }))
208
- }
209
-
210
- export function copyDocument(config: WorkspaceConfig, from: string, to: string): void {
211
- documentDir(config, from)
212
- documentDir(config, to)
213
- writeChanges(config, sourceEntries(join(config.root, from)).map(({ path, link, executable }): DocumentFileChange => {
214
- if (link !== undefined) return { path: `${to}/${path}`, link }
215
- const content = readFileSync(join(config.root, from, path))
216
- if (path.endsWith('.md')) return { path: `${to}/${path}`, content: Buffer.from(rewriteLinks(content.toString('utf8'), `${to}/${path}`, `${from}/${path}`, from, to)) }
217
- return { path: `${to}/${path}`, content, ...executable ? { executable } : {} }
218
- }))
219
- }
220
-
221
- export function relinkDocuments(config: WorkspaceConfig, from: string, to: string): string[] {
222
- const changes: DocumentFileChange[] = []
223
- for (const doc of listDocuments(config)) {
224
- if (!realDirectory(config.root, doc.dir)) continue
225
- for (const entry of sourceEntries(join(config.root, doc.dir))) {
226
- if (entry.link !== undefined || !entry.path.endsWith('.md')) continue
227
- const path = doc.dir ? `${doc.dir}/${entry.path}` : entry.path
228
- const was = within(path, to) ? `${from}${path.slice(to.length)}` : path
229
- const text = readFileSync(join(config.root, path), 'utf8')
230
- const next = rewriteLinks(text, path, was, from, to)
231
- if (next !== text) changes.push({ path, content: Buffer.from(next) })
232
- }
233
- }
234
- writeChanges(config, changes)
235
- return changes.map(change => change.path)
236
- }
237
-
238
- export function emptyDocument(config: WorkspaceConfig, dir: string): void {
239
- documentDir(config, dir)
240
- const path = join(config.root, dir)
241
- for (const name of readdirSync(path)) rmSync(join(path, name), { recursive: true, force: true })
242
- }
243
-
244
- export function localWriter(config: WorkspaceConfig): DocumentWriter {
245
- return {
246
- write: async changes => writeChanges(config, changes),
247
- template: async (dir, template, title) => fromTemplate(config, dir, template, title),
248
- copy: async (from, to) => copyDocument(config, from, to),
249
- relink: async (from, to) => relinkDocuments(config, from, to),
250
- empty: async dir => emptyDocument(config, dir),
251
- }
252
- }
@@ -1,73 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
- import { homedir } from 'node:os'
4
- import { join } from 'node:path'
5
- import type { Member, ReviewStep } from '@lincyaw/kernel'
6
- import type { SavedGuard } from './git-guard.ts'
7
- import type { GitDirs } from './git-review.ts'
8
-
9
- export interface SessionRecord {
10
- session: string
11
- document: string
12
- member: Member
13
- turns: { from: number, to: number }
14
- opened: string
15
- checkpoints: string[]
16
- steps: ReviewStep[]
17
- }
18
-
19
- export interface ReviewRecord {
20
- deck: string
21
- dirs: GitDirs
22
- snapshot: string
23
- task: string
24
- pid: number
25
- started?: string
26
- guard?: SavedGuard
27
- session?: SessionRecord
28
- }
29
-
30
- export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
31
- return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'presmium', 'reviews')
32
- }
33
-
34
- const fileOf = (deck: string) => join(recordsDir(), `${createHash('sha256').update(deck).digest('hex').slice(0, 24)}.json`)
35
-
36
- export function startTime(pid: number): string | undefined {
37
- try {
38
- const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
39
- return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
40
- } catch {
41
- return undefined
42
- }
43
- }
44
-
45
- export const SELF = { pid: process.pid, started: startTime(process.pid) }
46
-
47
- export function holderRuns(record: ReviewRecord): boolean {
48
- try {
49
- process.kill(record.pid, 0)
50
- } catch (error) {
51
- return (error as NodeJS.ErrnoException).code === 'EPERM'
52
- }
53
- return record.started === undefined || startTime(record.pid) === record.started
54
- }
55
-
56
- export function readRecord(deck: string): ReviewRecord | undefined {
57
- try {
58
- return JSON.parse(readFileSync(fileOf(deck), 'utf8')) as ReviewRecord
59
- } catch {
60
- return undefined
61
- }
62
- }
63
-
64
- export function writeRecord(record: ReviewRecord): void {
65
- const file = fileOf(record.deck)
66
- mkdirSync(recordsDir(), { recursive: true, mode: 0o700 })
67
- writeFileSync(`${file}.${process.pid}`, JSON.stringify(record), { mode: 0o600 })
68
- renameSync(`${file}.${process.pid}`, file)
69
- }
70
-
71
- export function removeRecord(deck: string): void {
72
- rmSync(fileOf(deck), { force: true })
73
- }