@tnotesjs/core 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/BaseCommand.ts +58 -0
- package/commands/build/BuildCommand.ts +25 -0
- package/commands/build/PreviewCommand.ts +29 -0
- package/commands/build/index.ts +8 -0
- package/commands/dev/DevCommand.ts +75 -0
- package/commands/dev/index.ts +7 -0
- package/commands/git/PullCommand.ts +25 -0
- package/commands/git/PushCommand.ts +64 -0
- package/commands/git/index.ts +8 -0
- package/commands/index.ts +11 -0
- package/commands/init-sub-repo/InitSubRepoCommand.ts +206 -0
- package/commands/init-sub-repo/index.ts +1 -0
- package/commands/misc/HelpCommand.ts +104 -0
- package/commands/misc/index.ts +7 -0
- package/commands/models.ts +87 -0
- package/commands/note/CreateNoteCommand.ts +160 -0
- package/commands/note/RenameNoteCommand.ts +147 -0
- package/commands/note/UpdateNoteConfigCommand.ts +78 -0
- package/commands/note/index.ts +9 -0
- package/commands/registry.ts +47 -0
- package/commands/update/UpdateCommand.ts +219 -0
- package/commands/update/index.ts +7 -0
- package/commands/update-completed-count/UpdateCompletedCountCommand.ts +208 -0
- package/commands/update-completed-count/index.ts +5 -0
- package/config/templates.ts +0 -5
- package/dist/{chunk-5F5RJ2HV.cjs → chunk-7FEANCT6.cjs} +16 -7
- package/dist/chunk-AGVER5MX.cjs +1746 -0
- package/dist/{chunk-TJ4527KA.cjs → chunk-BL22KD4V.cjs} +324 -950
- package/dist/{chunk-56PKXCOX.js → chunk-GWG75A5M.js} +13 -4
- package/dist/{chunk-DUTS75WQ.js → chunk-XNO3PCEH.js} +374 -1000
- package/dist/chunk-ZEQS64PE.js +1746 -0
- package/dist/cli/index.cjs +76 -155
- package/dist/cli/index.js +10 -89
- package/dist/markdown/index.cjs +8 -12
- package/dist/markdown/index.d.cts +1 -1
- package/dist/markdown/index.d.ts +1 -1
- package/dist/markdown/index.js +7 -11
- package/dist/{types-CwBWwNVv.d.ts → types-C_d0fFeD.d.ts} +8 -1
- package/dist/{types-CpsEy8lA.d.cts → types-MRBGgJTX.d.cts} +8 -1
- package/dist/vitepress/config/index.cjs +402 -178
- package/dist/vitepress/config/index.js +392 -168
- package/dist/workspace/index.cjs +4 -1202
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.ts +2 -2
- package/dist/workspace/index.js +4 -1202
- package/markdown/components.ts +86 -0
- package/markdown/index.ts +17 -0
- package/markdown/noteFormatter.test.ts +44 -0
- package/markdown/noteFormatter.ts +237 -0
- package/package.json +7 -3
- package/services/file-watcher/folderChangeHandler.ts +19 -15
- package/services/file-watcher/globalUpdateCoordinator.ts +21 -27
- package/services/file-watcher/service.ts +1 -6
- package/services/index.ts +0 -1
- package/services/note/service.ts +4 -6
- package/services/readme/service.ts +11 -18
- package/services/reconcileToc.ts +20 -0
- package/utils/index.ts +0 -9
- package/{services/toc → utils}/moveTocInside.test.ts +1 -1
- package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +9 -18
- package/vitepress/components/EnWordList/EnWordList.vue +16 -662
- package/vitepress/components/Footprints/Footprints.vue +15 -537
- package/vitepress/components/Mermaid/Mermaid.vue +13 -588
- package/vitepress/components/MindmapPreview/MindmapPreview.vue +12 -434
- package/vitepress/components/MindmapPreview/markdown.ts +1 -1
- package/vitepress/components/NotesTable/NotesTable.vue +11 -130
- package/vitepress/components/constants.ts +2 -2
- package/vitepress/components/sidebar.data.ts +8 -5
- package/vitepress/configs/markdown.config.ts +170 -26
- package/vitepress/plugins/sidebarStructurePlugin.ts +242 -145
- package/vitepress/theme/index.ts +9 -13
- package/vitepress/theme/styles/base.scss +15 -0
- package/workspace/atomic.ts +113 -0
- package/workspace/errors.ts +27 -0
- package/workspace/index.ts +40 -0
- package/workspace/mutationQueue.ts +28 -0
- package/workspace/paths.ts +64 -0
- package/workspace/reconcile.test.ts +300 -0
- package/workspace/reconcile.ts +95 -0
- package/workspace/scanner.ts +292 -0
- package/workspace/types.ts +224 -0
- package/workspace/workspace.test.ts +333 -0
- package/workspace/workspace.ts +1020 -0
- package/dist/chunk-3R7QSABB.cjs +0 -502
- package/dist/chunk-CZXRQPFJ.js +0 -11
- package/dist/chunk-HXED7KVT.cjs +0 -11
- package/dist/chunk-U6IBLREL.js +0 -502
- package/services/toc/index.ts +0 -5
- package/services/toc/service.ts +0 -759
- package/utils/migrateReadmeToToc.test.ts +0 -111
- package/utils/migrateReadmeToToc.ts +0 -135
- package/vitepress/components/EnWordList/RightClickMenu.vue +0 -93
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import { writeFilesAtomically } from './atomic'
|
|
7
|
+
import { WorkspaceError } from './errors'
|
|
8
|
+
import { MutationQueue } from './mutationQueue'
|
|
9
|
+
import {
|
|
10
|
+
assertPathInside,
|
|
11
|
+
createWorkspacePaths,
|
|
12
|
+
sanitizeFileName,
|
|
13
|
+
} from './paths'
|
|
14
|
+
import { planReconcile } from './reconcile'
|
|
15
|
+
import { scanWorkspace, toNoteInfo } from './scanner'
|
|
16
|
+
import { getNewNoteReadmeBody } from '../config/templates'
|
|
17
|
+
import { formatTNotesNote } from '../markdown/noteFormatter'
|
|
18
|
+
import {
|
|
19
|
+
adjustTocLineIndexAfterSubtreeRemoval,
|
|
20
|
+
buildFolderTocLine,
|
|
21
|
+
buildSidebarFromTocTree,
|
|
22
|
+
buildTocLine,
|
|
23
|
+
collectNoteIndexesInSubtree,
|
|
24
|
+
findFolderLineIndex,
|
|
25
|
+
findTocLineIndex,
|
|
26
|
+
getTocEntrySubtreeRange,
|
|
27
|
+
parseTocLine,
|
|
28
|
+
parseTocToTree,
|
|
29
|
+
processTocEmptyLines,
|
|
30
|
+
renameFolderLine,
|
|
31
|
+
serializeTocTree,
|
|
32
|
+
TOC_INDENT_SPACES,
|
|
33
|
+
} from '../utils/tocHelpers'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
import type { WorkspacePaths } from './paths'
|
|
37
|
+
import type {
|
|
38
|
+
AttachmentResult,
|
|
39
|
+
ChangedFile,
|
|
40
|
+
CreateNoteInput,
|
|
41
|
+
CreateTocGroupInput,
|
|
42
|
+
CreateWorkspaceOptions,
|
|
43
|
+
DeleteTocEntryInput,
|
|
44
|
+
DeleteTocEntryPreview,
|
|
45
|
+
KnowledgeBaseSnapshot,
|
|
46
|
+
MoveTocEntryInput,
|
|
47
|
+
MutationResult,
|
|
48
|
+
NoteDocument,
|
|
49
|
+
NotePlacement,
|
|
50
|
+
RenameNoteInput,
|
|
51
|
+
RenameTocGroupInput,
|
|
52
|
+
SaveNoteInput,
|
|
53
|
+
TNotesWorkspace,
|
|
54
|
+
TocEntryRef,
|
|
55
|
+
UpdateNoteConfigInput,
|
|
56
|
+
WorkspaceKnowledgeBaseConfig,
|
|
57
|
+
WorkspaceLogger,
|
|
58
|
+
WorkspaceNoteConfig,
|
|
59
|
+
WorkspaceNoteSummary,
|
|
60
|
+
WriteAttachmentInput,
|
|
61
|
+
} from './types'
|
|
62
|
+
import type { NoteInfo } from '../types/note'
|
|
63
|
+
|
|
64
|
+
const NOTE_CONFIG_FIELD_ORDER = [
|
|
65
|
+
'bilibili',
|
|
66
|
+
'tnotes',
|
|
67
|
+
'yuque',
|
|
68
|
+
'done',
|
|
69
|
+
'category',
|
|
70
|
+
'enableDiscussions',
|
|
71
|
+
'description',
|
|
72
|
+
'id',
|
|
73
|
+
] as const
|
|
74
|
+
|
|
75
|
+
function validateTitle(title: string): string {
|
|
76
|
+
const value = title.trim()
|
|
77
|
+
if (
|
|
78
|
+
!value ||
|
|
79
|
+
/[\\/\0\r\n]/.test(value) ||
|
|
80
|
+
/[. ]$/.test(value) ||
|
|
81
|
+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value)
|
|
82
|
+
) {
|
|
83
|
+
throw new WorkspaceError('INVALID_TITLE', '笔记或分组标题不合法', {
|
|
84
|
+
title,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serializeNoteConfig(config: WorkspaceNoteConfig): string {
|
|
91
|
+
const record = config as Record<string, unknown>
|
|
92
|
+
const sorted: Record<string, unknown> = {}
|
|
93
|
+
for (const field of NOTE_CONFIG_FIELD_ORDER) {
|
|
94
|
+
if (field in record) sorted[field] = record[field]
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(record)) {
|
|
97
|
+
if (!(key in sorted)) sorted[key] = value
|
|
98
|
+
}
|
|
99
|
+
return `${JSON.stringify(sorted, null, 2)}\n`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeTocContent(lines: string[]): string {
|
|
103
|
+
const content = processTocEmptyLines(lines).join('\n')
|
|
104
|
+
return content.endsWith('\n') ? content : `${content}\n`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toNoteInfos(notes: WorkspaceNoteSummary[]): NoteInfo[] {
|
|
108
|
+
return notes.map(toNoteInfo)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sidebarContent(
|
|
112
|
+
tocLines: string[],
|
|
113
|
+
notes: WorkspaceNoteSummary[],
|
|
114
|
+
config: WorkspaceKnowledgeBaseConfig,
|
|
115
|
+
): string {
|
|
116
|
+
const noteInfos = toNoteInfos(notes)
|
|
117
|
+
const tree = parseTocToTree(tocLines, noteInfos)
|
|
118
|
+
return JSON.stringify(
|
|
119
|
+
buildSidebarFromTocTree(tree, noteInfos, {
|
|
120
|
+
sidebarShowNoteId: config.sidebarShowNoteId ?? true,
|
|
121
|
+
sidebarIsCollapsed: true,
|
|
122
|
+
}),
|
|
123
|
+
null,
|
|
124
|
+
2,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function requireConfig(
|
|
129
|
+
snapshot: KnowledgeBaseSnapshot,
|
|
130
|
+
): WorkspaceKnowledgeBaseConfig {
|
|
131
|
+
if (snapshot.health.status === 'future-schema') {
|
|
132
|
+
throw new WorkspaceError(
|
|
133
|
+
'WORKSPACE_READ_ONLY',
|
|
134
|
+
'知识库由更新版本的 Core 创建,当前版本只允许读取',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
if (snapshot.health.status !== 'ready' || !snapshot.config) {
|
|
138
|
+
throw new WorkspaceError('WORKSPACE_INVALID', '知识库配置异常,禁止修改', {
|
|
139
|
+
diagnostics: snapshot.health.diagnostics,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return snapshot.config
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function findNote(
|
|
146
|
+
snapshot: KnowledgeBaseSnapshot,
|
|
147
|
+
noteUuid: string,
|
|
148
|
+
): WorkspaceNoteSummary {
|
|
149
|
+
const note = snapshot.notes.find((item) => item.uuid === noteUuid)
|
|
150
|
+
if (!note) {
|
|
151
|
+
throw new WorkspaceError('NOTE_NOT_FOUND', `未找到笔记:${noteUuid}`, {
|
|
152
|
+
noteUuid,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
return note
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function assertRevision(actual: string, expected: string, subject: string) {
|
|
159
|
+
if (actual !== expected) {
|
|
160
|
+
throw new WorkspaceError(
|
|
161
|
+
'REVISION_CONFLICT',
|
|
162
|
+
`${subject} 已被其他程序修改,请先处理外部变更`,
|
|
163
|
+
{ actual, expected },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function allocateNoteIndex(notes: WorkspaceNoteSummary[]): string {
|
|
169
|
+
const used = new Set(
|
|
170
|
+
notes
|
|
171
|
+
.map((note) => Number.parseInt(note.index, 10))
|
|
172
|
+
.filter((value) => Number.isInteger(value) && value >= 1 && value <= 9999),
|
|
173
|
+
)
|
|
174
|
+
for (let index = 1; index <= 9999; index++) {
|
|
175
|
+
if (!used.has(index)) return String(index).padStart(4, '0')
|
|
176
|
+
}
|
|
177
|
+
throw new WorkspaceError(
|
|
178
|
+
'NOTE_INDEX_EXHAUSTED',
|
|
179
|
+
'所有笔记编号(0001-9999)均已使用',
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolveEntryLine(
|
|
184
|
+
lines: string[],
|
|
185
|
+
snapshot: KnowledgeBaseSnapshot,
|
|
186
|
+
entry: TocEntryRef,
|
|
187
|
+
): number {
|
|
188
|
+
if (entry.type === 'line') {
|
|
189
|
+
if (entry.tocLineIndex < 0 || entry.tocLineIndex >= lines.length) {
|
|
190
|
+
throw new WorkspaceError('INVALID_TOC_ENTRY', 'TOC 行索引无效', {
|
|
191
|
+
tocLineIndex: entry.tocLineIndex,
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
return entry.tocLineIndex
|
|
195
|
+
}
|
|
196
|
+
if (entry.type === 'folder') {
|
|
197
|
+
try {
|
|
198
|
+
return findFolderLineIndex(lines, entry.folderPath)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new WorkspaceError('INVALID_TOC_ENTRY', '未找到 TOC 分组', {
|
|
201
|
+
folderPath: entry.folderPath,
|
|
202
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const note = findNote(snapshot, entry.noteUuid)
|
|
207
|
+
try {
|
|
208
|
+
return findTocLineIndex(lines, note.index)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new WorkspaceError('INVALID_TOC_ENTRY', '未在 TOC 中找到笔记', {
|
|
211
|
+
noteUuid: entry.noteUuid,
|
|
212
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function placementTargetLine(
|
|
218
|
+
lines: string[],
|
|
219
|
+
snapshot: KnowledgeBaseSnapshot,
|
|
220
|
+
placement: Exclude<NotePlacement, { type: 'root' }>,
|
|
221
|
+
): number {
|
|
222
|
+
if (placement.type === 'note') {
|
|
223
|
+
return resolveEntryLine(lines, snapshot, {
|
|
224
|
+
type: 'note',
|
|
225
|
+
noteUuid: placement.targetNoteUuid,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
return resolveEntryLine(lines, snapshot, {
|
|
229
|
+
type: 'folder',
|
|
230
|
+
folderPath: placement.folderPath,
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function insertLineAtPlacement(
|
|
235
|
+
lines: string[],
|
|
236
|
+
snapshot: KnowledgeBaseSnapshot,
|
|
237
|
+
placement: NotePlacement | undefined,
|
|
238
|
+
buildLine: (indent: number) => string,
|
|
239
|
+
): void {
|
|
240
|
+
const resolvedPlacement = placement ?? { type: 'root', placement: 'end' }
|
|
241
|
+
if (resolvedPlacement.type === 'root') {
|
|
242
|
+
if (resolvedPlacement.placement === 'start') {
|
|
243
|
+
const firstContent = lines.findIndex((line) => parseTocLine(line).isMatch)
|
|
244
|
+
lines.splice(firstContent >= 0 ? firstContent : lines.length, 0, buildLine(0))
|
|
245
|
+
} else {
|
|
246
|
+
let insertAt = lines.length
|
|
247
|
+
while (insertAt > 0 && lines[insertAt - 1] === '') insertAt--
|
|
248
|
+
lines.splice(insertAt, 0, buildLine(0))
|
|
249
|
+
}
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const targetLine = placementTargetLine(lines, snapshot, resolvedPlacement)
|
|
254
|
+
const target = parseTocLine(lines[targetLine])
|
|
255
|
+
if (!target.isMatch) {
|
|
256
|
+
throw new WorkspaceError('INVALID_TOC_ENTRY', '目标 TOC 条目无效')
|
|
257
|
+
}
|
|
258
|
+
if (resolvedPlacement.placement === 'inside') {
|
|
259
|
+
const range = getTocEntrySubtreeRange(lines, targetLine)
|
|
260
|
+
lines.splice(range.end, 0, buildLine(target.indentLevel + 1))
|
|
261
|
+
} else if (resolvedPlacement.placement === 'before') {
|
|
262
|
+
lines.splice(targetLine, 0, buildLine(target.indentLevel))
|
|
263
|
+
} else {
|
|
264
|
+
const range = getTocEntrySubtreeRange(lines, targetLine)
|
|
265
|
+
lines.splice(range.end, 0, buildLine(target.indentLevel))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function adjustIndent(lines: string[], delta: number): string[] {
|
|
270
|
+
return lines.map((line) => {
|
|
271
|
+
const parsed = parseTocLine(line)
|
|
272
|
+
if (!parsed.isMatch) return line
|
|
273
|
+
const indent = Math.max(0, parsed.indentLevel + delta)
|
|
274
|
+
return `${' '.repeat(indent * TOC_INDENT_SPACES)}${line.trimStart()}`
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function listFilesRecursively(directoryPath: string): Promise<string[]> {
|
|
279
|
+
const result: string[] = []
|
|
280
|
+
const entries = await fs.readdir(directoryPath, { withFileTypes: true })
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
const entryPath = path.join(directoryPath, entry.name)
|
|
283
|
+
if (entry.isDirectory()) {
|
|
284
|
+
result.push(...(await listFilesRecursively(entryPath)))
|
|
285
|
+
} else {
|
|
286
|
+
result.push(entryPath)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export class Workspace implements TNotesWorkspace {
|
|
293
|
+
readonly notes: TNotesWorkspace['notes']
|
|
294
|
+
readonly toc: TNotesWorkspace['toc']
|
|
295
|
+
readonly attachments: TNotesWorkspace['attachments']
|
|
296
|
+
|
|
297
|
+
private readonly paths: WorkspacePaths
|
|
298
|
+
private readonly logger: WorkspaceLogger
|
|
299
|
+
private readonly queue = new MutationQueue()
|
|
300
|
+
private readonly prettierByDefault: boolean
|
|
301
|
+
private disposed = false
|
|
302
|
+
|
|
303
|
+
constructor(options: CreateWorkspaceOptions) {
|
|
304
|
+
this.paths = createWorkspacePaths(options.rootPath)
|
|
305
|
+
this.logger = options.logger ?? {}
|
|
306
|
+
this.prettierByDefault = options.format?.prettier ?? true
|
|
307
|
+
|
|
308
|
+
this.notes = {
|
|
309
|
+
read: (noteUuid) => this.readNote(noteUuid),
|
|
310
|
+
save: (input) => this.saveNote(input),
|
|
311
|
+
create: (input) => this.createNote(input),
|
|
312
|
+
rename: (input) => this.renameNote(input),
|
|
313
|
+
updateConfig: (input) => this.updateNoteConfig(input),
|
|
314
|
+
}
|
|
315
|
+
this.toc = {
|
|
316
|
+
move: (input) => this.moveTocEntry(input),
|
|
317
|
+
createGroup: (input) => this.createTocGroup(input),
|
|
318
|
+
renameGroup: (input) => this.renameTocGroup(input),
|
|
319
|
+
previewDelete: (entry) => this.previewDelete(entry),
|
|
320
|
+
deleteEntry: (input) => this.deleteTocEntry(input),
|
|
321
|
+
setDone: (input) => this.updateNoteConfig(input),
|
|
322
|
+
reconcileFromFiles: () => this.reconcileTocFromFiles(),
|
|
323
|
+
}
|
|
324
|
+
this.attachments = {
|
|
325
|
+
writeLocal: (input) => this.writeLocalAttachment(input),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async inspect(): Promise<KnowledgeBaseSnapshot> {
|
|
330
|
+
this.assertActive()
|
|
331
|
+
return (await scanWorkspace(this.paths)).snapshot
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async refresh(): Promise<KnowledgeBaseSnapshot> {
|
|
335
|
+
return this.inspect()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Files-first TOC reconcile (0004): align TOC.md + sidebar.json with the
|
|
340
|
+
* disk truth. Valid notes missing from the TOC are appended at root level
|
|
341
|
+
* (by index); note dirs whose config is missing/invalid are soft-deleted to
|
|
342
|
+
* notes/.trash/. Idempotent: runs again without changes -> no file writes.
|
|
343
|
+
*/
|
|
344
|
+
private async reconcileTocFromFiles(): Promise<
|
|
345
|
+
MutationResult<KnowledgeBaseSnapshot>
|
|
346
|
+
> {
|
|
347
|
+
return this.queue.run(async () => {
|
|
348
|
+
this.assertActive()
|
|
349
|
+
const scanned = await scanWorkspace(this.paths)
|
|
350
|
+
const { snapshot } = scanned
|
|
351
|
+
if (!snapshot.config) {
|
|
352
|
+
throw new WorkspaceError(
|
|
353
|
+
'WORKSPACE_INVALID',
|
|
354
|
+
'知识库配置异常,禁止修改',
|
|
355
|
+
{ diagnostics: snapshot.health.diagnostics },
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const plan = planReconcile(snapshot)
|
|
360
|
+
const changedFiles: ChangedFile[] = []
|
|
361
|
+
|
|
362
|
+
// 1) soft-delete invalid note dirs -> notes/.trash/
|
|
363
|
+
if (plan.trashDirs.length > 0) {
|
|
364
|
+
const trashDir = path.join(this.paths.notes, '.trash')
|
|
365
|
+
await fs.mkdir(trashDir, { recursive: true })
|
|
366
|
+
for (const dir of plan.trashDirs) {
|
|
367
|
+
const source = path.join(this.paths.notes, dir)
|
|
368
|
+
let target = path.join(trashDir, dir)
|
|
369
|
+
try {
|
|
370
|
+
await fs.stat(target)
|
|
371
|
+
target = path.join(trashDir, `${dir}-${Date.now()}`)
|
|
372
|
+
} catch {
|
|
373
|
+
// name free
|
|
374
|
+
}
|
|
375
|
+
await fs.rename(source, target)
|
|
376
|
+
changedFiles.push({ path: target, previousPath: source, kind: 'trashed' })
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 2) rebuild TOC.md + sidebar.json, write only when changed
|
|
381
|
+
const noteInfos = snapshot.notes.map(toNoteInfo)
|
|
382
|
+
const configByIndex = new Map(
|
|
383
|
+
snapshot.notes.map((note) => [note.index, { done: note.config.done }]),
|
|
384
|
+
)
|
|
385
|
+
const lines = serializeTocTree(plan.tree, noteInfos, configByIndex)
|
|
386
|
+
const nextToc = normalizeTocContent(lines)
|
|
387
|
+
if (nextToc !== (scanned.tocText ?? '')) {
|
|
388
|
+
changedFiles.push({ path: this.paths.toc, kind: 'updated' })
|
|
389
|
+
await writeFilesAtomically([{ path: this.paths.toc, data: nextToc }])
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Sidebar must be derived from the FINAL TOC content so tocLineIndex
|
|
393
|
+
// values in the sidebar match the actually written TOC.md (idempotency).
|
|
394
|
+
const finalTocTree = parseTocToTree(
|
|
395
|
+
nextToc.split('\n'),
|
|
396
|
+
noteInfos,
|
|
397
|
+
)
|
|
398
|
+
const sidebar = buildSidebarFromTocTree(finalTocTree, noteInfos, {
|
|
399
|
+
sidebarShowNoteId: snapshot.config.sidebarShowNoteId ?? true,
|
|
400
|
+
sidebarIsCollapsed: true,
|
|
401
|
+
})
|
|
402
|
+
const nextSidebar = `${JSON.stringify(sidebar, null, 2)}\n`
|
|
403
|
+
let currentSidebar = ''
|
|
404
|
+
try {
|
|
405
|
+
currentSidebar = await fs.readFile(this.paths.sidebar, 'utf-8')
|
|
406
|
+
} catch {
|
|
407
|
+
// sidebar may not exist yet
|
|
408
|
+
}
|
|
409
|
+
if (nextSidebar !== currentSidebar) {
|
|
410
|
+
changedFiles.push({ path: this.paths.sidebar, kind: 'updated' })
|
|
411
|
+
await writeFilesAtomically([{ path: this.paths.sidebar, data: nextSidebar }])
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const refreshed = await this.inspect()
|
|
415
|
+
return {
|
|
416
|
+
value: refreshed,
|
|
417
|
+
changedFiles,
|
|
418
|
+
snapshotRevision: refreshed.revision,
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async reconcileTocCompletion(): Promise<
|
|
424
|
+
MutationResult<KnowledgeBaseSnapshot>
|
|
425
|
+
> {
|
|
426
|
+
return this.queue.run(async () => {
|
|
427
|
+
const scanned = await this.scanReady()
|
|
428
|
+
const { snapshot, tocText } = scanned
|
|
429
|
+
const config = requireConfig(snapshot)
|
|
430
|
+
const lines = (tocText ?? '').split('\n')
|
|
431
|
+
const completedByIndex = new Map<string, boolean>()
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
const parsed = parseTocLine(line)
|
|
434
|
+
if (parsed.noteIndex && !completedByIndex.has(parsed.noteIndex)) {
|
|
435
|
+
completedByIndex.set(parsed.noteIndex, parsed.completed)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const changedFiles: ChangedFile[] = []
|
|
440
|
+
const writes: Array<{ path: string; data: string }> = []
|
|
441
|
+
const updatedNotes = snapshot.notes.map((note) => {
|
|
442
|
+
const completed = completedByIndex.get(note.index)
|
|
443
|
+
if (completed === undefined || completed === note.config.done) return note
|
|
444
|
+
const updatedConfig = { ...note.config, done: completed }
|
|
445
|
+
writes.push({ path: note.configPath, data: serializeNoteConfig(updatedConfig) })
|
|
446
|
+
changedFiles.push({ path: note.configPath, kind: 'updated' })
|
|
447
|
+
return { ...note, config: updatedConfig }
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
if (writes.length > 0) {
|
|
451
|
+
writes.push({
|
|
452
|
+
path: this.paths.sidebar,
|
|
453
|
+
data: sidebarContent(lines, updatedNotes, config),
|
|
454
|
+
})
|
|
455
|
+
changedFiles.push({ path: this.paths.sidebar, kind: 'updated' })
|
|
456
|
+
await writeFilesAtomically(writes)
|
|
457
|
+
}
|
|
458
|
+
const value = await this.inspect()
|
|
459
|
+
return { value, changedFiles, snapshotRevision: value.revision }
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async dispose(): Promise<void> {
|
|
464
|
+
if (this.disposed) return
|
|
465
|
+
this.disposed = true
|
|
466
|
+
await this.queue.dispose()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private assertActive(): void {
|
|
470
|
+
if (this.disposed) {
|
|
471
|
+
throw new WorkspaceError('WORKSPACE_DISPOSED', '工作区实例已经释放')
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private async scanReady() {
|
|
476
|
+
this.assertActive()
|
|
477
|
+
const scanned = await scanWorkspace(this.paths)
|
|
478
|
+
requireConfig(scanned.snapshot)
|
|
479
|
+
return scanned
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private async readNote(noteUuid: string): Promise<NoteDocument> {
|
|
483
|
+
const snapshot = await this.inspect()
|
|
484
|
+
const note = findNote(snapshot, noteUuid)
|
|
485
|
+
return { ...note, content: await fs.readFile(note.readmePath, 'utf8') }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private async saveNote(
|
|
489
|
+
input: SaveNoteInput,
|
|
490
|
+
): Promise<MutationResult<NoteDocument>> {
|
|
491
|
+
return this.queue.run(async () => {
|
|
492
|
+
const { snapshot } = await this.scanReady()
|
|
493
|
+
const config = requireConfig(snapshot)
|
|
494
|
+
const note = findNote(snapshot, input.noteUuid)
|
|
495
|
+
assertRevision(note.revision, input.expectedRevision, '笔记')
|
|
496
|
+
const formatted = await formatTNotesNote({
|
|
497
|
+
content: input.content,
|
|
498
|
+
noteIndex: note.index,
|
|
499
|
+
title: note.title,
|
|
500
|
+
repoOwner: config.author,
|
|
501
|
+
repoName: config.repoName,
|
|
502
|
+
noteConfig: note.config,
|
|
503
|
+
prettier: input.prettier ?? this.prettierByDefault,
|
|
504
|
+
})
|
|
505
|
+
const current = await fs.readFile(note.readmePath, 'utf8')
|
|
506
|
+
const changedFiles: ChangedFile[] = []
|
|
507
|
+
if (formatted.content !== current) {
|
|
508
|
+
await writeFilesAtomically([
|
|
509
|
+
{ path: note.readmePath, data: formatted.content },
|
|
510
|
+
])
|
|
511
|
+
changedFiles.push({ path: note.readmePath, kind: 'updated' })
|
|
512
|
+
}
|
|
513
|
+
const value = await this.readNote(input.noteUuid)
|
|
514
|
+
const refreshed = await this.inspect()
|
|
515
|
+
return { value, changedFiles, snapshotRevision: refreshed.revision }
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async createNote(
|
|
520
|
+
input: CreateNoteInput,
|
|
521
|
+
): Promise<MutationResult<NoteDocument>> {
|
|
522
|
+
return this.queue.run(async () => {
|
|
523
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
524
|
+
const config = requireConfig(snapshot)
|
|
525
|
+
if (input.expectedSnapshotRevision) {
|
|
526
|
+
assertRevision(
|
|
527
|
+
snapshot.revision,
|
|
528
|
+
input.expectedSnapshotRevision,
|
|
529
|
+
'知识库目录',
|
|
530
|
+
)
|
|
531
|
+
}
|
|
532
|
+
const title = validateTitle(input.title)
|
|
533
|
+
const index = allocateNoteIndex(snapshot.notes)
|
|
534
|
+
const dirName = `${index}. ${title}`
|
|
535
|
+
const directoryPath = path.join(this.paths.notes, dirName)
|
|
536
|
+
assertPathInside(this.paths.notes, directoryPath)
|
|
537
|
+
try {
|
|
538
|
+
await fs.mkdir(directoryPath)
|
|
539
|
+
} catch (error) {
|
|
540
|
+
throw new WorkspaceError('FILESYSTEM_ERROR', '无法创建笔记目录', {
|
|
541
|
+
directoryPath,
|
|
542
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const noteConfig: WorkspaceNoteConfig = {
|
|
547
|
+
bilibili: [],
|
|
548
|
+
tnotes: [],
|
|
549
|
+
yuque: [],
|
|
550
|
+
done: false,
|
|
551
|
+
enableDiscussions: input.config?.enableDiscussions ?? false,
|
|
552
|
+
description: input.config?.description ?? '',
|
|
553
|
+
id: randomUUID(),
|
|
554
|
+
}
|
|
555
|
+
const readmePath = path.join(directoryPath, 'README.md')
|
|
556
|
+
const configPath = path.join(directoryPath, '.tnotes.json')
|
|
557
|
+
const formatted = await formatTNotesNote({
|
|
558
|
+
content: getNewNoteReadmeBody(),
|
|
559
|
+
noteIndex: index,
|
|
560
|
+
title,
|
|
561
|
+
repoOwner: config.author,
|
|
562
|
+
repoName: config.repoName,
|
|
563
|
+
noteConfig,
|
|
564
|
+
prettier: this.prettierByDefault,
|
|
565
|
+
})
|
|
566
|
+
const newNote: WorkspaceNoteSummary = {
|
|
567
|
+
uuid: noteConfig.id,
|
|
568
|
+
index,
|
|
569
|
+
title,
|
|
570
|
+
dirName,
|
|
571
|
+
directoryPath,
|
|
572
|
+
readmePath,
|
|
573
|
+
configPath,
|
|
574
|
+
config: noteConfig,
|
|
575
|
+
revision: '',
|
|
576
|
+
}
|
|
577
|
+
const tocLines = (tocText ?? '').split('\n')
|
|
578
|
+
insertLineAtPlacement(tocLines, snapshot, input.placement, (indent) =>
|
|
579
|
+
buildTocLine(toNoteInfo(newNote), indent, false),
|
|
580
|
+
)
|
|
581
|
+
const updatedNotes = [...snapshot.notes, newNote]
|
|
582
|
+
const normalizedToc = normalizeTocContent(tocLines)
|
|
583
|
+
const normalizedLines = normalizedToc.split('\n')
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
await writeFilesAtomically([
|
|
587
|
+
{ path: readmePath, data: formatted.content },
|
|
588
|
+
{ path: configPath, data: serializeNoteConfig(noteConfig) },
|
|
589
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
590
|
+
{
|
|
591
|
+
path: this.paths.sidebar,
|
|
592
|
+
data: sidebarContent(normalizedLines, updatedNotes, config),
|
|
593
|
+
},
|
|
594
|
+
])
|
|
595
|
+
} catch (error) {
|
|
596
|
+
await fs.rm(directoryPath, { recursive: true, force: true })
|
|
597
|
+
throw error
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const value = await this.readNote(noteConfig.id)
|
|
601
|
+
const refreshed = await this.inspect()
|
|
602
|
+
return {
|
|
603
|
+
value,
|
|
604
|
+
changedFiles: [
|
|
605
|
+
{ path: directoryPath, kind: 'created' },
|
|
606
|
+
{ path: readmePath, kind: 'created' },
|
|
607
|
+
{ path: configPath, kind: 'created' },
|
|
608
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
609
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
610
|
+
],
|
|
611
|
+
snapshotRevision: refreshed.revision,
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async renameNote(
|
|
617
|
+
input: RenameNoteInput,
|
|
618
|
+
): Promise<MutationResult<NoteDocument>> {
|
|
619
|
+
return this.queue.run(async () => {
|
|
620
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
621
|
+
const config = requireConfig(snapshot)
|
|
622
|
+
const note = findNote(snapshot, input.noteUuid)
|
|
623
|
+
assertRevision(note.revision, input.expectedRevision, '笔记')
|
|
624
|
+
const title = validateTitle(input.title)
|
|
625
|
+
const newDirName = `${note.index}. ${title}`
|
|
626
|
+
if (newDirName === note.dirName) {
|
|
627
|
+
const value = await this.readNote(note.uuid)
|
|
628
|
+
return { value, changedFiles: [], snapshotRevision: snapshot.revision }
|
|
629
|
+
}
|
|
630
|
+
const newDirectoryPath = path.join(this.paths.notes, newDirName)
|
|
631
|
+
assertPathInside(this.paths.notes, newDirectoryPath)
|
|
632
|
+
try {
|
|
633
|
+
await fs.access(newDirectoryPath)
|
|
634
|
+
throw new WorkspaceError('INVALID_TITLE', '目标笔记目录已经存在', {
|
|
635
|
+
newDirectoryPath,
|
|
636
|
+
})
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (error instanceof WorkspaceError) throw error
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const currentContent = await fs.readFile(note.readmePath, 'utf8')
|
|
642
|
+
const formatted = await formatTNotesNote({
|
|
643
|
+
content: currentContent,
|
|
644
|
+
noteIndex: note.index,
|
|
645
|
+
title,
|
|
646
|
+
repoOwner: config.author,
|
|
647
|
+
repoName: config.repoName,
|
|
648
|
+
noteConfig: note.config,
|
|
649
|
+
prettier: this.prettierByDefault,
|
|
650
|
+
})
|
|
651
|
+
const renamedNote: WorkspaceNoteSummary = {
|
|
652
|
+
...note,
|
|
653
|
+
title,
|
|
654
|
+
dirName: newDirName,
|
|
655
|
+
directoryPath: newDirectoryPath,
|
|
656
|
+
readmePath: path.join(newDirectoryPath, 'README.md'),
|
|
657
|
+
configPath: path.join(newDirectoryPath, '.tnotes.json'),
|
|
658
|
+
}
|
|
659
|
+
const tocLines = (tocText ?? '').split('\n')
|
|
660
|
+
for (let index = 0; index < tocLines.length; index++) {
|
|
661
|
+
const parsed = parseTocLine(tocLines[index])
|
|
662
|
+
if (parsed.noteIndex === note.index) {
|
|
663
|
+
tocLines[index] = buildTocLine(
|
|
664
|
+
toNoteInfo(renamedNote),
|
|
665
|
+
parsed.indentLevel,
|
|
666
|
+
parsed.completed,
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const normalizedToc = normalizeTocContent(tocLines)
|
|
671
|
+
const updatedNotes = snapshot.notes.map((item) =>
|
|
672
|
+
item.uuid === note.uuid ? renamedNote : item,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
await fs.rename(note.directoryPath, newDirectoryPath)
|
|
676
|
+
try {
|
|
677
|
+
await writeFilesAtomically([
|
|
678
|
+
{ path: renamedNote.readmePath, data: formatted.content },
|
|
679
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
680
|
+
{
|
|
681
|
+
path: this.paths.sidebar,
|
|
682
|
+
data: sidebarContent(normalizedToc.split('\n'), updatedNotes, config),
|
|
683
|
+
},
|
|
684
|
+
])
|
|
685
|
+
} catch (error) {
|
|
686
|
+
await fs.rename(newDirectoryPath, note.directoryPath)
|
|
687
|
+
throw error
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const value = await this.readNote(note.uuid)
|
|
691
|
+
const refreshed = await this.inspect()
|
|
692
|
+
return {
|
|
693
|
+
value,
|
|
694
|
+
changedFiles: [
|
|
695
|
+
{
|
|
696
|
+
path: newDirectoryPath,
|
|
697
|
+
previousPath: note.directoryPath,
|
|
698
|
+
kind: 'renamed',
|
|
699
|
+
},
|
|
700
|
+
{ path: renamedNote.readmePath, kind: 'updated' },
|
|
701
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
702
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
703
|
+
],
|
|
704
|
+
snapshotRevision: refreshed.revision,
|
|
705
|
+
}
|
|
706
|
+
})
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private async updateNoteConfig(
|
|
710
|
+
input: UpdateNoteConfigInput,
|
|
711
|
+
): Promise<MutationResult<NoteDocument>> {
|
|
712
|
+
return this.queue.run(async () => {
|
|
713
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
714
|
+
const config = requireConfig(snapshot)
|
|
715
|
+
const note = findNote(snapshot, input.noteUuid)
|
|
716
|
+
assertRevision(note.revision, input.expectedRevision, '笔记')
|
|
717
|
+
const updatedConfig = { ...note.config, ...input.updates }
|
|
718
|
+
const writes: Array<{ path: string; data: string }> = [
|
|
719
|
+
{ path: note.configPath, data: serializeNoteConfig(updatedConfig) },
|
|
720
|
+
]
|
|
721
|
+
const changedFiles: ChangedFile[] = [
|
|
722
|
+
{ path: note.configPath, kind: 'updated' },
|
|
723
|
+
]
|
|
724
|
+
|
|
725
|
+
if (typeof input.updates.done === 'boolean') {
|
|
726
|
+
const lines = (tocText ?? '').split('\n')
|
|
727
|
+
let updated = false
|
|
728
|
+
const tempNote = { ...note, config: updatedConfig }
|
|
729
|
+
for (let index = 0; index < lines.length; index++) {
|
|
730
|
+
const parsed = parseTocLine(lines[index])
|
|
731
|
+
if (parsed.noteIndex === note.index) {
|
|
732
|
+
lines[index] = buildTocLine(
|
|
733
|
+
toNoteInfo(tempNote),
|
|
734
|
+
parsed.indentLevel,
|
|
735
|
+
input.updates.done,
|
|
736
|
+
)
|
|
737
|
+
updated = true
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (updated) {
|
|
741
|
+
const normalizedToc = normalizeTocContent(lines)
|
|
742
|
+
const updatedNotes = snapshot.notes.map((item) =>
|
|
743
|
+
item.uuid === note.uuid ? tempNote : item,
|
|
744
|
+
)
|
|
745
|
+
writes.push(
|
|
746
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
747
|
+
{
|
|
748
|
+
path: this.paths.sidebar,
|
|
749
|
+
data: sidebarContent(normalizedToc.split('\n'), updatedNotes, config),
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
changedFiles.push(
|
|
753
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
754
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
await writeFilesAtomically(writes)
|
|
760
|
+
const value = await this.readNote(note.uuid)
|
|
761
|
+
const refreshed = await this.inspect()
|
|
762
|
+
return { value, changedFiles, snapshotRevision: refreshed.revision }
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private async moveTocEntry(
|
|
767
|
+
input: MoveTocEntryInput,
|
|
768
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>> {
|
|
769
|
+
return this.queue.run(async () => {
|
|
770
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
771
|
+
const config = requireConfig(snapshot)
|
|
772
|
+
assertRevision(snapshot.revision, input.expectedSnapshotRevision, '知识库目录')
|
|
773
|
+
const lines = (tocText ?? '').split('\n')
|
|
774
|
+
const sourceLine = resolveEntryLine(lines, snapshot, input.source)
|
|
775
|
+
const targetLine = resolveEntryLine(lines, snapshot, input.target)
|
|
776
|
+
const sourceRange = getTocEntrySubtreeRange(lines, sourceLine)
|
|
777
|
+
if (targetLine >= sourceRange.start && targetLine < sourceRange.end) {
|
|
778
|
+
throw new WorkspaceError(
|
|
779
|
+
'INVALID_TOC_ENTRY',
|
|
780
|
+
'不能把目录条目移动到自身或自身子树内',
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
const moving = lines.splice(
|
|
784
|
+
sourceRange.start,
|
|
785
|
+
sourceRange.end - sourceRange.start,
|
|
786
|
+
)
|
|
787
|
+
const adjustedTarget = adjustTocLineIndexAfterSubtreeRemoval(
|
|
788
|
+
targetLine,
|
|
789
|
+
sourceRange.start,
|
|
790
|
+
sourceRange.end,
|
|
791
|
+
)
|
|
792
|
+
const target = parseTocLine(lines[adjustedTarget])
|
|
793
|
+
if (!target.isMatch) {
|
|
794
|
+
throw new WorkspaceError('INVALID_TOC_ENTRY', '移动目标无效')
|
|
795
|
+
}
|
|
796
|
+
let insertAt: number
|
|
797
|
+
let indent: number
|
|
798
|
+
if (input.placement === 'inside') {
|
|
799
|
+
insertAt = getTocEntrySubtreeRange(lines, adjustedTarget).end
|
|
800
|
+
indent = target.indentLevel + 1
|
|
801
|
+
} else if (input.placement === 'before') {
|
|
802
|
+
insertAt = adjustedTarget
|
|
803
|
+
indent = target.indentLevel
|
|
804
|
+
} else {
|
|
805
|
+
insertAt = getTocEntrySubtreeRange(lines, adjustedTarget).end
|
|
806
|
+
indent = target.indentLevel
|
|
807
|
+
}
|
|
808
|
+
const oldIndent = parseTocLine(moving[0]).indentLevel
|
|
809
|
+
lines.splice(insertAt, 0, ...adjustIndent(moving, indent - oldIndent))
|
|
810
|
+
const normalizedToc = normalizeTocContent(lines)
|
|
811
|
+
await writeFilesAtomically([
|
|
812
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
813
|
+
{
|
|
814
|
+
path: this.paths.sidebar,
|
|
815
|
+
data: sidebarContent(normalizedToc.split('\n'), snapshot.notes, config),
|
|
816
|
+
},
|
|
817
|
+
])
|
|
818
|
+
const value = await this.inspect()
|
|
819
|
+
return {
|
|
820
|
+
value,
|
|
821
|
+
changedFiles: [
|
|
822
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
823
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
824
|
+
],
|
|
825
|
+
snapshotRevision: value.revision,
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private async createTocGroup(
|
|
831
|
+
input: CreateTocGroupInput,
|
|
832
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>> {
|
|
833
|
+
return this.queue.run(async () => {
|
|
834
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
835
|
+
const config = requireConfig(snapshot)
|
|
836
|
+
assertRevision(snapshot.revision, input.expectedSnapshotRevision, '知识库目录')
|
|
837
|
+
const title = validateTitle(input.title)
|
|
838
|
+
const lines = (tocText ?? '').split('\n')
|
|
839
|
+
insertLineAtPlacement(lines, snapshot, input.placement, (indent) =>
|
|
840
|
+
buildFolderTocLine(title, indent),
|
|
841
|
+
)
|
|
842
|
+
const normalizedToc = normalizeTocContent(lines)
|
|
843
|
+
await writeFilesAtomically([
|
|
844
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
845
|
+
{
|
|
846
|
+
path: this.paths.sidebar,
|
|
847
|
+
data: sidebarContent(normalizedToc.split('\n'), snapshot.notes, config),
|
|
848
|
+
},
|
|
849
|
+
])
|
|
850
|
+
const value = await this.inspect()
|
|
851
|
+
return {
|
|
852
|
+
value,
|
|
853
|
+
changedFiles: [
|
|
854
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
855
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
856
|
+
],
|
|
857
|
+
snapshotRevision: value.revision,
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private async renameTocGroup(
|
|
863
|
+
input: RenameTocGroupInput,
|
|
864
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>> {
|
|
865
|
+
return this.queue.run(async () => {
|
|
866
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
867
|
+
const config = requireConfig(snapshot)
|
|
868
|
+
assertRevision(snapshot.revision, input.expectedSnapshotRevision, '知识库目录')
|
|
869
|
+
const lines = (tocText ?? '').split('\n')
|
|
870
|
+
const lineIndex = resolveEntryLine(lines, snapshot, {
|
|
871
|
+
type: 'folder',
|
|
872
|
+
folderPath: input.folderPath,
|
|
873
|
+
})
|
|
874
|
+
const updatedLines = renameFolderLine(lines, lineIndex, validateTitle(input.title))
|
|
875
|
+
const normalizedToc = normalizeTocContent(updatedLines)
|
|
876
|
+
await writeFilesAtomically([
|
|
877
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
878
|
+
{
|
|
879
|
+
path: this.paths.sidebar,
|
|
880
|
+
data: sidebarContent(normalizedToc.split('\n'), snapshot.notes, config),
|
|
881
|
+
},
|
|
882
|
+
])
|
|
883
|
+
const value = await this.inspect()
|
|
884
|
+
return {
|
|
885
|
+
value,
|
|
886
|
+
changedFiles: [
|
|
887
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
888
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
889
|
+
],
|
|
890
|
+
snapshotRevision: value.revision,
|
|
891
|
+
}
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private async previewDelete(entry: TocEntryRef): Promise<DeleteTocEntryPreview> {
|
|
896
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
897
|
+
const lines = (tocText ?? '').split('\n')
|
|
898
|
+
const lineIndex = resolveEntryLine(lines, snapshot, entry)
|
|
899
|
+
const indexes = collectNoteIndexesInSubtree(lines, lineIndex)
|
|
900
|
+
const notes = indexes
|
|
901
|
+
.map((index) => snapshot.notes.find((note) => note.index === index))
|
|
902
|
+
.filter((note): note is WorkspaceNoteSummary => Boolean(note))
|
|
903
|
+
const filePaths = (
|
|
904
|
+
await Promise.all(notes.map((note) => listFilesRecursively(note.directoryPath)))
|
|
905
|
+
).flat()
|
|
906
|
+
return {
|
|
907
|
+
entry,
|
|
908
|
+
notes: notes.map((note) => ({
|
|
909
|
+
noteUuid: note.uuid,
|
|
910
|
+
index: note.index,
|
|
911
|
+
title: note.title,
|
|
912
|
+
directoryPath: note.directoryPath,
|
|
913
|
+
})),
|
|
914
|
+
filePaths,
|
|
915
|
+
directoryPaths: notes.map((note) => note.directoryPath),
|
|
916
|
+
snapshotRevision: snapshot.revision,
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private async deleteTocEntry(
|
|
921
|
+
input: DeleteTocEntryInput,
|
|
922
|
+
): Promise<MutationResult<KnowledgeBaseSnapshot>> {
|
|
923
|
+
return this.queue.run(async () => {
|
|
924
|
+
const { snapshot, tocText } = await this.scanReady()
|
|
925
|
+
const config = requireConfig(snapshot)
|
|
926
|
+
assertRevision(snapshot.revision, input.expectedSnapshotRevision, '知识库目录')
|
|
927
|
+
const lines = (tocText ?? '').split('\n')
|
|
928
|
+
const lineIndex = resolveEntryLine(lines, snapshot, input.entry)
|
|
929
|
+
const range = getTocEntrySubtreeRange(lines, lineIndex)
|
|
930
|
+
const indexes = collectNoteIndexesInSubtree(lines, lineIndex)
|
|
931
|
+
const notes = indexes
|
|
932
|
+
.map((index) => snapshot.notes.find((note) => note.index === index))
|
|
933
|
+
.filter((note): note is WorkspaceNoteSummary => Boolean(note))
|
|
934
|
+
lines.splice(range.start, range.end - range.start)
|
|
935
|
+
const remainingNotes = snapshot.notes.filter(
|
|
936
|
+
(note) => !indexes.includes(note.index),
|
|
937
|
+
)
|
|
938
|
+
const normalizedToc = normalizeTocContent(lines)
|
|
939
|
+
const movedDirectories: Array<{ original: string; temporary: string }> = []
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
for (const note of notes) {
|
|
943
|
+
const temporary = path.join(
|
|
944
|
+
this.paths.notes,
|
|
945
|
+
`.${path.basename(note.directoryPath)}.desk-delete-${randomUUID()}`,
|
|
946
|
+
)
|
|
947
|
+
await fs.rename(note.directoryPath, temporary)
|
|
948
|
+
movedDirectories.push({ original: note.directoryPath, temporary })
|
|
949
|
+
}
|
|
950
|
+
await writeFilesAtomically([
|
|
951
|
+
{ path: this.paths.toc, data: normalizedToc },
|
|
952
|
+
{
|
|
953
|
+
path: this.paths.sidebar,
|
|
954
|
+
data: sidebarContent(normalizedToc.split('\n'), remainingNotes, config),
|
|
955
|
+
},
|
|
956
|
+
])
|
|
957
|
+
} catch (error) {
|
|
958
|
+
for (const moved of movedDirectories.reverse()) {
|
|
959
|
+
await fs.rename(moved.temporary, moved.original)
|
|
960
|
+
}
|
|
961
|
+
throw error
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
for (const moved of movedDirectories) {
|
|
965
|
+
await fs.rm(moved.temporary, { recursive: true, force: true })
|
|
966
|
+
}
|
|
967
|
+
const value = await this.inspect()
|
|
968
|
+
return {
|
|
969
|
+
value,
|
|
970
|
+
changedFiles: [
|
|
971
|
+
...notes.map<ChangedFile>((note) => ({
|
|
972
|
+
path: note.directoryPath,
|
|
973
|
+
kind: 'deleted',
|
|
974
|
+
})),
|
|
975
|
+
{ path: this.paths.toc, kind: 'updated' },
|
|
976
|
+
{ path: this.paths.sidebar, kind: 'updated' },
|
|
977
|
+
],
|
|
978
|
+
snapshotRevision: value.revision,
|
|
979
|
+
}
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private async writeLocalAttachment(
|
|
984
|
+
input: WriteAttachmentInput,
|
|
985
|
+
): Promise<MutationResult<AttachmentResult>> {
|
|
986
|
+
return this.queue.run(async () => {
|
|
987
|
+
const { snapshot } = await this.scanReady()
|
|
988
|
+
const note = findNote(snapshot, input.noteUuid)
|
|
989
|
+
const assetsPath = path.join(note.directoryPath, 'assets')
|
|
990
|
+
assertPathInside(note.directoryPath, assetsPath)
|
|
991
|
+
const requestedName = sanitizeFileName(input.fileName)
|
|
992
|
+
const extension = path.extname(requestedName)
|
|
993
|
+
const base = path.basename(requestedName, extension)
|
|
994
|
+
let candidate = requestedName
|
|
995
|
+
let suffix = 1
|
|
996
|
+
while (true) {
|
|
997
|
+
try {
|
|
998
|
+
await fs.access(path.join(assetsPath, candidate))
|
|
999
|
+
candidate = `${base}-${suffix}${extension}`
|
|
1000
|
+
suffix++
|
|
1001
|
+
} catch {
|
|
1002
|
+
break
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const absolutePath = path.join(assetsPath, candidate)
|
|
1006
|
+
assertPathInside(assetsPath, absolutePath)
|
|
1007
|
+
await writeFilesAtomically([{ path: absolutePath, data: input.data }])
|
|
1008
|
+
const value = {
|
|
1009
|
+
absolutePath,
|
|
1010
|
+
markdownPath: `./assets/${candidate}`,
|
|
1011
|
+
}
|
|
1012
|
+
const refreshed = await this.inspect()
|
|
1013
|
+
return {
|
|
1014
|
+
value,
|
|
1015
|
+
changedFiles: [{ path: absolutePath, kind: 'created' }],
|
|
1016
|
+
snapshotRevision: refreshed.revision,
|
|
1017
|
+
}
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
}
|