@tnotesjs/core 0.7.0 → 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.
Files changed (55) hide show
  1. package/commands/BaseCommand.ts +58 -0
  2. package/commands/build/BuildCommand.ts +25 -0
  3. package/commands/build/PreviewCommand.ts +29 -0
  4. package/commands/build/index.ts +8 -0
  5. package/commands/dev/DevCommand.ts +75 -0
  6. package/commands/dev/index.ts +7 -0
  7. package/commands/git/PullCommand.ts +25 -0
  8. package/commands/git/PushCommand.ts +64 -0
  9. package/commands/git/index.ts +8 -0
  10. package/commands/index.ts +11 -0
  11. package/commands/init-sub-repo/InitSubRepoCommand.ts +206 -0
  12. package/commands/init-sub-repo/index.ts +1 -0
  13. package/commands/misc/HelpCommand.ts +104 -0
  14. package/commands/misc/index.ts +7 -0
  15. package/commands/models.ts +87 -0
  16. package/commands/note/CreateNoteCommand.ts +160 -0
  17. package/commands/note/RenameNoteCommand.ts +147 -0
  18. package/commands/note/UpdateNoteConfigCommand.ts +78 -0
  19. package/commands/note/index.ts +9 -0
  20. package/commands/registry.ts +47 -0
  21. package/commands/update/UpdateCommand.ts +219 -0
  22. package/commands/update/index.ts +7 -0
  23. package/commands/update-completed-count/UpdateCompletedCountCommand.ts +208 -0
  24. package/commands/update-completed-count/index.ts +5 -0
  25. package/dist/markdown/index.cjs +6 -9
  26. package/dist/markdown/index.js +6 -9
  27. package/dist/vitepress/config/index.cjs +214 -58
  28. package/dist/vitepress/config/index.js +208 -52
  29. package/markdown/components.ts +86 -0
  30. package/markdown/index.ts +17 -0
  31. package/markdown/noteFormatter.test.ts +44 -0
  32. package/markdown/noteFormatter.ts +237 -0
  33. package/package.json +7 -3
  34. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +9 -18
  35. package/vitepress/components/EnWordList/EnWordList.vue +16 -662
  36. package/vitepress/components/Footprints/Footprints.vue +15 -537
  37. package/vitepress/components/Mermaid/Mermaid.vue +13 -588
  38. package/vitepress/components/MindmapPreview/MindmapPreview.vue +12 -434
  39. package/vitepress/components/MindmapPreview/markdown.ts +1 -1
  40. package/vitepress/components/NotesTable/NotesTable.vue +11 -130
  41. package/vitepress/configs/markdown.config.ts +170 -26
  42. package/vitepress/theme/index.ts +9 -13
  43. package/vitepress/theme/styles/base.scss +15 -0
  44. package/workspace/atomic.ts +113 -0
  45. package/workspace/errors.ts +27 -0
  46. package/workspace/index.ts +40 -0
  47. package/workspace/mutationQueue.ts +28 -0
  48. package/workspace/paths.ts +64 -0
  49. package/workspace/reconcile.test.ts +300 -0
  50. package/workspace/reconcile.ts +95 -0
  51. package/workspace/scanner.ts +292 -0
  52. package/workspace/types.ts +224 -0
  53. package/workspace/workspace.test.ts +333 -0
  54. package/workspace/workspace.ts +1020 -0
  55. package/vitepress/components/EnWordList/RightClickMenu.vue +0 -93
@@ -0,0 +1,300 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { createWorkspace } from './index'
7
+ import { planReconcile } from './reconcile'
8
+
9
+ import type { KnowledgeBaseSnapshot } from './types'
10
+ import type { TocTreeNode } from '../utils/tocHelpers'
11
+
12
+ const temporaryDirectories: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(
16
+ temporaryDirectories.splice(0).map((directory) =>
17
+ fs.rm(directory, { recursive: true, force: true }),
18
+ ),
19
+ )
20
+ })
21
+
22
+ function knowledgeBaseConfig(id: string, repoName: string) {
23
+ return {
24
+ id,
25
+ author: 'tnotesjs',
26
+ repoName,
27
+ keywords: [repoName],
28
+ sidebarShowNoteId: false,
29
+ ignore_dirs: [],
30
+ socialLinks: [],
31
+ menuItems: [],
32
+ root_item: {
33
+ title: repoName,
34
+ completed_notes_count: {},
35
+ details: repoName,
36
+ link: '/',
37
+ },
38
+ }
39
+ }
40
+
41
+ function noteConfig(id: string, done = false) {
42
+ return {
43
+ bilibili: [],
44
+ tnotes: [],
45
+ yuque: [],
46
+ done,
47
+ enableDiscussions: false,
48
+ description: '',
49
+ id,
50
+ }
51
+ }
52
+
53
+ async function createFixture(input?: {
54
+ notes?: Array<{
55
+ index: string
56
+ title: string
57
+ id: string
58
+ done?: boolean
59
+ }>
60
+ toc?: string
61
+ }) {
62
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'tnotes-core-reconcile-'))
63
+ temporaryDirectories.push(root)
64
+ const notes =
65
+ input?.notes ?? [
66
+ { index: '0016', title: 'Alpha', id: 'note-alpha' },
67
+ { index: '0017', title: 'Beta', id: 'note-beta' },
68
+ ]
69
+ await fs.mkdir(path.join(root, 'notes'))
70
+ await fs.writeFile(
71
+ path.join(root, '.tnotes.json'),
72
+ JSON.stringify(knowledgeBaseConfig('kb-reconcile', 'TNotes.reconcile'), null, 2),
73
+ )
74
+ await fs.writeFile(path.join(root, 'README.md'), 'legacy root readme\n')
75
+ for (const note of notes) {
76
+ const directory = path.join(root, 'notes', `${note.index}. ${note.title}`)
77
+ await fs.mkdir(directory)
78
+ await fs.writeFile(
79
+ path.join(directory, '.tnotes.json'),
80
+ `${JSON.stringify(noteConfig(note.id, note.done), null, 2)}\n`,
81
+ )
82
+ await fs.writeFile(
83
+ path.join(directory, 'README.md'),
84
+ `# ${note.title}\n\n## Intro\n`,
85
+ )
86
+ }
87
+ await fs.writeFile(
88
+ path.join(root, 'TOC.md'),
89
+ input?.toc ?? `${notes.map((note) => `- [ ] ${note.index}. ${note.title}`).join('\n')}\n`,
90
+ )
91
+ await fs.writeFile(path.join(root, 'sidebar.json'), '[]\n')
92
+ return root
93
+ }
94
+
95
+ /** A minimal but fully-shaped snapshot for pure planner tests. */
96
+ function snapshotWith(input: {
97
+ notes: Array<{ index: string; title: string; dirName: string; uuid: string; config: { done: boolean } }>
98
+ toc: TocTreeNode[]
99
+ diagnostics: KnowledgeBaseSnapshot['health']['diagnostics']
100
+ }): KnowledgeBaseSnapshot {
101
+ return {
102
+ id: 'kb-reconcile',
103
+ rootPath: '/tmp/fixture',
104
+ config: knowledgeBaseConfig('kb-reconcile', 'TNotes.reconcile'),
105
+ health: { status: 'invalid' as const, diagnostics: input.diagnostics },
106
+ toc: input.toc,
107
+ sidebar: [],
108
+ notes: input.notes.map((note) => ({
109
+ uuid: note.uuid,
110
+ index: note.index,
111
+ title: note.title,
112
+ dirName: note.dirName,
113
+ directoryPath: `/tmp/fixture/notes/${note.dirName}`,
114
+ readmePath: `/tmp/fixture/notes/${note.dirName}/README.md`,
115
+ configPath: `/tmp/fixture/notes/${note.dirName}/.tnotes.json`,
116
+ config: note.config,
117
+ revision: 'rev',
118
+ })),
119
+ revision: 'rev-snapshot',
120
+ }
121
+ }
122
+
123
+ describe('planReconcile', () => {
124
+ it('缺 config 的目录进入 trashDirs;其余保持不变', () => {
125
+ const plan = planReconcile(
126
+ snapshotWith({
127
+ notes: [],
128
+ toc: [],
129
+ diagnostics: [
130
+ {
131
+ code: 'NOTE_CONFIG_MISSING',
132
+ message: '笔记 X 缺少 .tnotes.json',
133
+ severity: 'error',
134
+ path: 'notes/0005. X/.tnotes.json',
135
+ },
136
+ {
137
+ code: 'NOTE_INDEX_DUPLICATE',
138
+ message: '笔记编号 0005 重复',
139
+ severity: 'error',
140
+ path: 'notes/0005. X',
141
+ },
142
+ ],
143
+ }),
144
+ )
145
+ expect(plan.trashDirs).toEqual(['0005. X'])
146
+ })
147
+
148
+ it('保留分组结构;丢弃无法解析的笔记行;按需追加缺失的合法笔记(根级、按索引排序)', () => {
149
+ const plan = planReconcile(
150
+ snapshotWith({
151
+ notes: [
152
+ { index: '0016', title: 'A', dirName: '0016. A', uuid: 'u1', config: { done: false } },
153
+ { index: '0024', title: 'New', dirName: '0024. New', uuid: 'u24', config: { done: false } },
154
+ ],
155
+ toc: [
156
+ {
157
+ kind: 'folder',
158
+ title: '组件',
159
+ indent: 0,
160
+ tocLineIndex: 0,
161
+ children: [
162
+ { kind: 'note', noteIndex: '0016', indent: 1, tocLineIndex: 1, children: [] },
163
+ // 目录已不存在(0017 不在 notes 里)→ 应被丢弃
164
+ { kind: 'note', noteIndex: '0017', indent: 1, tocLineIndex: 2, children: [] },
165
+ ],
166
+ },
167
+ ],
168
+ diagnostics: [],
169
+ }),
170
+ )
171
+ expect(plan.trashDirs).toEqual([])
172
+ const folder = plan.tree[0]
173
+ expect(folder.kind).toBe('folder')
174
+ if (folder.kind !== 'folder') return
175
+ expect(folder.children.map((node) => (node.kind === 'note' ? node.noteIndex : null))).toEqual([
176
+ '0016',
177
+ ])
178
+ // 追加到根级、按索引排序
179
+ expect(plan.tree.at(-1)).toMatchObject({ kind: 'note', noteIndex: '0024', indent: 0 })
180
+ })
181
+ })
182
+
183
+ describe('workspace.toc.reconcileFromFiles', () => {
184
+ it('软删缺配置目录 + 补缺失行 + 丢弃失联行,幂等', async () => {
185
+ const root = await createFixture({
186
+ // 0024 合法但不在 TOC;0010 在 TOC 但目录不存在
187
+ notes: [
188
+ { index: '0016', title: 'Alpha', id: 'note-alpha' },
189
+ { index: '0017', title: 'Beta', id: 'note-beta' },
190
+ { index: '0024', title: 'Gamma', id: 'note-gamma' },
191
+ ],
192
+ toc: [
193
+ '- 组件',
194
+ ' - [ ] 0016. Alpha',
195
+ ' - [ ] 0017. Beta',
196
+ '- [ ] 0010. 幽灵笔记',
197
+ '',
198
+ ].join('\n'),
199
+ })
200
+ // 手动制造一个缺 config 的目录
201
+ const brokenDir = path.join(root, 'notes', '0005. Broken')
202
+ await fs.mkdir(brokenDir)
203
+ await fs.writeFile(path.join(brokenDir, 'README.md'), '# Broken\n')
204
+
205
+ const workspace = createWorkspace({ rootPath: root })
206
+ const result = await workspace.toc.reconcileFromFiles()
207
+
208
+ // 1) 软删:目录进了 .trash
209
+ expect(await exists(path.join(brokenDir))).toBe(false)
210
+ expect(await exists(path.join(root, 'notes', '.trash', '0005. Broken'))).toBe(true)
211
+ expect(result.changedFiles.some((file) => file.kind === 'trashed')).toBe(true)
212
+
213
+ // 2) TOC:0016/0017 保留(组内)+ 0024 追加根级 + 0010 失联行被清
214
+ const toc = await fs.readFile(path.join(root, 'TOC.md'), 'utf-8')
215
+ expect(toc).toContain('0016. Alpha')
216
+ expect(toc).toContain('0017. Beta')
217
+ expect(toc).toContain('- [ ] 0024. Gamma')
218
+ expect(toc).not.toContain('0010')
219
+ expect(toc).not.toContain('0005. Broken')
220
+
221
+ // 3) sidebar 重建
222
+ const sidebar = await fs.readFile(path.join(root, 'sidebar.json'), 'utf-8')
223
+ expect(sidebar).not.toBe('[]\n')
224
+
225
+ // 4) 愈合后健康恢复
226
+ expect(result.value.health.status).toBe('ready')
227
+
228
+ // 5) 幂等:第二次不再产生变更
229
+ const second = await workspace.toc.reconcileFromFiles()
230
+ expect(second.changedFiles).toEqual([])
231
+
232
+ await workspace.dispose()
233
+ })
234
+
235
+ it('TOC.md 缺失时从零构建(全部合法笔记排根级)', async () => {
236
+ const root = await createFixture({
237
+ notes: [
238
+ { index: '0001', title: 'Alpha', id: 'note-alpha' },
239
+ { index: '0002', title: 'Beta', id: 'note-beta' },
240
+ ],
241
+ })
242
+ await fs.rm(path.join(root, 'TOC.md'))
243
+
244
+ const workspace = createWorkspace({ rootPath: root })
245
+ const result = await workspace.toc.reconcileFromFiles()
246
+ const toc = await fs.readFile(path.join(root, 'TOC.md'), 'utf-8')
247
+ expect(toc).toContain('- [ ] 0001. Alpha')
248
+ expect(toc).toContain('- [ ] 0002. Beta')
249
+ expect(result.value.health.status).toBe('ready')
250
+ await workspace.dispose()
251
+ })
252
+
253
+ it('根配置缺失时拒绝(WORKSPACE_INVALID),不静默改动', async () => {
254
+ const root = await createFixture()
255
+ await fs.rm(path.join(root, '.tnotes.json'))
256
+
257
+ const workspace = createWorkspace({ rootPath: root })
258
+ await expect(workspace.toc.reconcileFromFiles()).rejects.toThrow(/知识库配置异常/)
259
+ await workspace.dispose()
260
+ })
261
+
262
+ it('同名笔记二次软删不报错,进入带时间戳的目标(不覆盖第一次回收内容)', async () => {
263
+ const root = await createFixture({
264
+ notes: [{ index: '0001', title: 'Alpha', id: 'note-alpha' }],
265
+ })
266
+ const workspace = createWorkspace({ rootPath: root })
267
+ const noteDir = path.join(root, 'notes', '0001. Alpha')
268
+
269
+ // 第一次破坏:config 丢失 → 软删为 .trash/0001. Alpha
270
+ await fs.rm(path.join(noteDir, '.tnotes.json'))
271
+ await workspace.toc.reconcileFromFiles()
272
+ expect(await exists(path.join(root, 'notes', '.trash', '0001. Alpha'))).toBe(true)
273
+
274
+ // 重建同名合法笔记,再破坏 → 不应报错,目标带时间戳
275
+ await fs.mkdir(noteDir, { recursive: true })
276
+ await fs.writeFile(
277
+ path.join(noteDir, '.tnotes.json'),
278
+ `${JSON.stringify(noteConfig('note-alpha-2'), null, 2)}\n`,
279
+ )
280
+ await fs.writeFile(path.join(noteDir, 'README.md'), '# Alpha\n')
281
+ await fs.rm(path.join(noteDir, '.tnotes.json'))
282
+ const result = await workspace.toc.reconcileFromFiles()
283
+
284
+ const trashEntries = await fs.readdir(path.join(root, 'notes', '.trash'))
285
+ expect(trashEntries.length).toBe(2)
286
+ expect(trashEntries).toContain('0001. Alpha')
287
+ expect(trashEntries.some((name) => name.startsWith('0001. Alpha-'))).toBe(true)
288
+ expect(result.changedFiles.some((file) => file.kind === 'trashed')).toBe(true)
289
+ await workspace.dispose()
290
+ })
291
+ })
292
+
293
+ async function exists(target: string): Promise<boolean> {
294
+ try {
295
+ await fs.stat(target)
296
+ return true
297
+ } catch {
298
+ return false
299
+ }
300
+ }
@@ -0,0 +1,95 @@
1
+ import type { KnowledgeBaseSnapshot } from './types'
2
+ import type { TocTreeNode } from '../utils/tocHelpers'
3
+
4
+ /**
5
+ * Diagnostics that mark a note directory as invalid enough to soft-delete.
6
+ * - NOTE_CONFIG_MISSING: notes/<dir>/.tnotes.json absent
7
+ * - NOTE_CONFIG_INVALID: config unparsable or missing a valid `id`
8
+ * Others (duplicate index/id, missing README) are reported but NOT auto-deleted —
9
+ * duplicates cannot be auto-resolved, and README absence is not our rule.
10
+ */
11
+ const NOTE_CONFIG_DIAGNOSTIC_CODES = new Set([
12
+ 'NOTE_CONFIG_MISSING',
13
+ 'NOTE_CONFIG_INVALID'
14
+ ])
15
+
16
+ export interface ReconcilePlan {
17
+ /** Note dir names to soft-delete (move to notes/.trash/), sorted. */
18
+ trashDirs: string[]
19
+ /**
20
+ * Rebuilt TOC tree: keeps the existing structure/order/folders, but note rows
21
+ * not resolvable against the live notes dir are dropped by the serializer and
22
+ * valid notes missing from the TOC are appended at root level (by index).
23
+ */
24
+ tree: TocTreeNode[]
25
+ }
26
+
27
+ /** Directory name from a note-config diagnostic path (`…/notes/<dir>/.tnotes.json`). */
28
+ function dirFromConfigDiagnostic(path: string | null | undefined): string | null {
29
+ if (!path) return null
30
+ const match = path.replace(/\\/g, '/').match(/(?:^|\/)notes\/([^/]+)\/\.tnotes\.json$/)
31
+ return match ? match[1] : null
32
+ }
33
+
34
+ /**
35
+ * Pure planner: derives the reconcile outcome from one scanned snapshot.
36
+ * The filesystem is the truth; TNotes note indexes are unique + immutable, so
37
+ * note rows are matched to disk directories by index.
38
+ */
39
+ export function planReconcile(snapshot: KnowledgeBaseSnapshot): ReconcilePlan {
40
+ const seen = new Set<string>()
41
+ const trashDirs: string[] = []
42
+ for (const diagnostic of snapshot.health.diagnostics) {
43
+ if (!NOTE_CONFIG_DIAGNOSTIC_CODES.has(diagnostic.code)) continue
44
+ const dir = dirFromConfigDiagnostic(diagnostic.path)
45
+ if (dir && !seen.has(dir)) {
46
+ seen.add(dir)
47
+ trashDirs.push(dir)
48
+ }
49
+ }
50
+ trashDirs.sort((a, b) => a.localeCompare(b))
51
+
52
+ const validIndexes = new Set(snapshot.notes.map((note) => note.index))
53
+
54
+ // Clean tree: keep folders (even if emptied), drop note rows that no longer
55
+ // resolve against the live notes dir.
56
+ const clean = (nodes: TocTreeNode[]): TocTreeNode[] => {
57
+ const next: TocTreeNode[] = []
58
+ for (const node of nodes) {
59
+ if (node.kind === 'folder') {
60
+ next.push({ ...node, children: clean(node.children) })
61
+ continue
62
+ }
63
+ if (validIndexes.has(node.noteIndex)) {
64
+ next.push({ ...node, children: clean(node.children) })
65
+ }
66
+ }
67
+ return next
68
+ }
69
+
70
+ const present = new Set<string>()
71
+ const walk = (nodes: TocTreeNode[]): void => {
72
+ for (const node of nodes) {
73
+ if (node.kind === 'note') present.add(node.noteIndex)
74
+ walk(node.children)
75
+ }
76
+ }
77
+ walk(snapshot.toc)
78
+
79
+ const missing = snapshot.notes
80
+ .filter((note) => !present.has(note.index))
81
+ .sort((left, right) => left.index.localeCompare(right.index))
82
+
83
+ const tree: TocTreeNode[] = [
84
+ ...clean(snapshot.toc),
85
+ ...missing.map((note) => ({
86
+ kind: 'note' as const,
87
+ noteIndex: note.index,
88
+ indent: 0,
89
+ tocLineIndex: 0,
90
+ children: [] as TocTreeNode[]
91
+ }))
92
+ ]
93
+
94
+ return { trashDirs, tree }
95
+ }
@@ -0,0 +1,292 @@
1
+ import { createHash } from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ import {
6
+ buildSidebarFromTocTree,
7
+ parseTocToTree,
8
+ } from '../utils/tocHelpers'
9
+
10
+ import type { WorkspacePaths } from './paths'
11
+ import type {
12
+ KnowledgeBaseSnapshot,
13
+ WorkspaceDiagnostic,
14
+ WorkspaceKnowledgeBaseConfig,
15
+ WorkspaceNoteConfig,
16
+ WorkspaceNoteSummary,
17
+ } from './types'
18
+ import type { NoteInfo } from '../types/note'
19
+
20
+ const NOTE_DIRECTORY_PATTERN = /^(\d{4})\.\s*(.+)$/
21
+ const CURRENT_SCHEMA_VERSION = 1
22
+
23
+ function digest(...values: Array<string | Buffer>): string {
24
+ const hash = createHash('sha256')
25
+ for (const value of values) {
26
+ hash.update(value)
27
+ hash.update('\0')
28
+ }
29
+ return hash.digest('hex')
30
+ }
31
+
32
+ async function readText(filePath: string): Promise<string | null> {
33
+ try {
34
+ return await fs.readFile(filePath, 'utf8')
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ function parseJsonObject<T extends Record<string, unknown>>(
41
+ content: string,
42
+ ): T | null {
43
+ try {
44
+ const value = JSON.parse(content) as unknown
45
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
46
+ ? (value as T)
47
+ : null
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function noteInfoFromSummary(note: WorkspaceNoteSummary): NoteInfo {
54
+ return {
55
+ index: note.index,
56
+ path: note.directoryPath,
57
+ dirName: note.dirName,
58
+ readmePath: note.readmePath,
59
+ configPath: note.configPath,
60
+ config: note.config,
61
+ }
62
+ }
63
+
64
+ export interface ScannedWorkspace {
65
+ snapshot: KnowledgeBaseSnapshot
66
+ configText: string | null
67
+ tocText: string | null
68
+ }
69
+
70
+ export async function scanWorkspace(
71
+ paths: WorkspacePaths,
72
+ ): Promise<ScannedWorkspace> {
73
+ const diagnostics: WorkspaceDiagnostic[] = []
74
+ let rootIsDirectory = false
75
+ try {
76
+ rootIsDirectory = (await fs.stat(paths.root)).isDirectory()
77
+ } catch {
78
+ // Handled as a diagnostic below.
79
+ }
80
+ if (!rootIsDirectory) {
81
+ diagnostics.push({
82
+ code: 'ROOT_NOT_DIRECTORY',
83
+ message: '知识库根目录不存在或不是目录',
84
+ severity: 'error',
85
+ path: paths.root,
86
+ })
87
+ }
88
+
89
+ const configText = await readText(paths.config)
90
+ let config: WorkspaceKnowledgeBaseConfig | null = null
91
+ if (configText === null) {
92
+ diagnostics.push({
93
+ code: 'CONFIG_MISSING',
94
+ message: '缺少知识库配置 .tnotes.json',
95
+ severity: 'error',
96
+ path: paths.config,
97
+ })
98
+ } else {
99
+ config = parseJsonObject<WorkspaceKnowledgeBaseConfig>(configText)
100
+ if (!config) {
101
+ diagnostics.push({
102
+ code: 'CONFIG_INVALID_JSON',
103
+ message: '知识库配置不是有效的 JSON 对象',
104
+ severity: 'error',
105
+ path: paths.config,
106
+ })
107
+ } else {
108
+ if (typeof config.id !== 'string' || !config.id.trim()) {
109
+ diagnostics.push({
110
+ code: 'CONFIG_ID_MISSING',
111
+ message: '知识库配置缺少稳定的 id',
112
+ severity: 'error',
113
+ path: paths.config,
114
+ })
115
+ }
116
+ if (typeof config.repoName !== 'string' || !config.repoName.trim()) {
117
+ diagnostics.push({
118
+ code: 'CONFIG_REPO_NAME_MISSING',
119
+ message: '知识库配置缺少 repoName',
120
+ severity: 'error',
121
+ path: paths.config,
122
+ })
123
+ }
124
+ }
125
+ }
126
+
127
+ let notesDirectoryExists = false
128
+ try {
129
+ notesDirectoryExists = (await fs.stat(paths.notes)).isDirectory()
130
+ } catch {
131
+ // Handled below.
132
+ }
133
+ if (!notesDirectoryExists) {
134
+ diagnostics.push({
135
+ code: 'NOTES_DIRECTORY_MISSING',
136
+ message: '缺少 notes 目录',
137
+ severity: 'error',
138
+ path: paths.notes,
139
+ })
140
+ }
141
+
142
+ const tocText = await readText(paths.toc)
143
+ if (tocText === null) {
144
+ diagnostics.push({
145
+ code: 'TOC_MISSING',
146
+ message: '缺少目录真相源 TOC.md',
147
+ severity: 'error',
148
+ path: paths.toc,
149
+ })
150
+ }
151
+
152
+ const notes: WorkspaceNoteSummary[] = []
153
+ const usedIndexes = new Map<string, string>()
154
+ const usedIds = new Map<string, string>()
155
+ if (notesDirectoryExists) {
156
+ const entries = await fs.readdir(paths.notes, { withFileTypes: true })
157
+ entries.sort((left, right) => left.name.localeCompare(right.name))
158
+ for (const entry of entries) {
159
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
160
+ const match = NOTE_DIRECTORY_PATTERN.exec(entry.name)
161
+ if (!match) continue
162
+
163
+ const [, index, rawTitle] = match
164
+ const title = rawTitle.trim()
165
+ const directoryPath = path.join(paths.notes, entry.name)
166
+ const readmePath = path.join(directoryPath, 'README.md')
167
+ const configPath = path.join(directoryPath, '.tnotes.json')
168
+ const readme = await readText(readmePath)
169
+ const noteConfigText = await readText(configPath)
170
+
171
+ if (readme === null) {
172
+ diagnostics.push({
173
+ code: 'NOTE_README_MISSING',
174
+ message: `笔记 ${entry.name} 缺少 README.md`,
175
+ severity: 'error',
176
+ path: readmePath,
177
+ })
178
+ continue
179
+ }
180
+ if (noteConfigText === null) {
181
+ diagnostics.push({
182
+ code: 'NOTE_CONFIG_MISSING',
183
+ message: `笔记 ${entry.name} 缺少 .tnotes.json`,
184
+ severity: 'error',
185
+ path: configPath,
186
+ })
187
+ continue
188
+ }
189
+
190
+ const noteConfig = parseJsonObject<WorkspaceNoteConfig>(noteConfigText)
191
+ if (!noteConfig || typeof noteConfig.id !== 'string' || !noteConfig.id) {
192
+ diagnostics.push({
193
+ code: 'NOTE_CONFIG_INVALID',
194
+ message: `笔记 ${entry.name} 的配置无法解析或缺少 id`,
195
+ severity: 'error',
196
+ path: configPath,
197
+ })
198
+ continue
199
+ }
200
+
201
+ const existingIndex = usedIndexes.get(index)
202
+ if (existingIndex) {
203
+ diagnostics.push({
204
+ code: 'NOTE_INDEX_DUPLICATE',
205
+ message: `笔记编号 ${index} 重复:${existingIndex}、${entry.name}`,
206
+ severity: 'error',
207
+ path: directoryPath,
208
+ })
209
+ } else {
210
+ usedIndexes.set(index, entry.name)
211
+ }
212
+ const existingId = usedIds.get(noteConfig.id)
213
+ if (existingId) {
214
+ diagnostics.push({
215
+ code: 'NOTE_ID_DUPLICATE',
216
+ message: `笔记 id ${noteConfig.id} 重复:${existingId}、${entry.name}`,
217
+ severity: 'error',
218
+ path: configPath,
219
+ })
220
+ } else {
221
+ usedIds.set(noteConfig.id, entry.name)
222
+ }
223
+
224
+ notes.push({
225
+ uuid: noteConfig.id,
226
+ index,
227
+ title,
228
+ dirName: entry.name,
229
+ directoryPath,
230
+ readmePath,
231
+ configPath,
232
+ config: noteConfig,
233
+ revision: digest(entry.name, readme, noteConfigText),
234
+ })
235
+ }
236
+ }
237
+
238
+ const noteInfos = notes.map(noteInfoFromSummary)
239
+ const toc = tocText ? parseTocToTree(tocText.split('\n'), noteInfos) : []
240
+ const sidebar = buildSidebarFromTocTree(toc, noteInfos, {
241
+ sidebarShowNoteId: config?.sidebarShowNoteId ?? true,
242
+ sidebarIsCollapsed: true,
243
+ })
244
+
245
+ const schemaVersion = config?.schemaVersion
246
+ const futureSchema =
247
+ typeof schemaVersion === 'number' && schemaVersion > CURRENT_SCHEMA_VERSION
248
+ if (futureSchema) {
249
+ diagnostics.push({
250
+ code: 'FUTURE_SCHEMA',
251
+ message: `知识库 schemaVersion ${schemaVersion} 高于当前支持版本 ${CURRENT_SCHEMA_VERSION}`,
252
+ severity: 'error',
253
+ path: paths.config,
254
+ })
255
+ }
256
+
257
+ const health = futureSchema
258
+ ? { status: 'future-schema' as const, diagnostics }
259
+ : diagnostics.some((diagnostic) => diagnostic.severity === 'error')
260
+ ? { status: 'invalid' as const, diagnostics }
261
+ : { status: 'ready' as const, diagnostics }
262
+
263
+ const id =
264
+ config && typeof config.id === 'string' && config.id.trim()
265
+ ? config.id
266
+ : `path-${digest(paths.root).slice(0, 24)}`
267
+ const revision = digest(
268
+ paths.root,
269
+ configText ?? '',
270
+ tocText ?? '',
271
+ ...notes.map((note) => `${note.uuid}:${note.revision}`),
272
+ )
273
+
274
+ return {
275
+ snapshot: {
276
+ id,
277
+ rootPath: paths.root,
278
+ config,
279
+ health,
280
+ toc,
281
+ sidebar,
282
+ notes,
283
+ revision,
284
+ },
285
+ configText,
286
+ tocText,
287
+ }
288
+ }
289
+
290
+ export function toNoteInfo(note: WorkspaceNoteSummary): NoteInfo {
291
+ return noteInfoFromSummary(note)
292
+ }