@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/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
|
-
}
|
package/src/domain/index.ts
DELETED
|
@@ -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'
|
package/src/node/documents.ts
DELETED
|
@@ -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
|
-
}
|