@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.
Files changed (92) 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/config/templates.ts +0 -5
  26. package/dist/{chunk-5F5RJ2HV.cjs → chunk-7FEANCT6.cjs} +16 -7
  27. package/dist/chunk-AGVER5MX.cjs +1746 -0
  28. package/dist/{chunk-TJ4527KA.cjs → chunk-BL22KD4V.cjs} +324 -950
  29. package/dist/{chunk-56PKXCOX.js → chunk-GWG75A5M.js} +13 -4
  30. package/dist/{chunk-DUTS75WQ.js → chunk-XNO3PCEH.js} +374 -1000
  31. package/dist/chunk-ZEQS64PE.js +1746 -0
  32. package/dist/cli/index.cjs +76 -155
  33. package/dist/cli/index.js +10 -89
  34. package/dist/markdown/index.cjs +8 -12
  35. package/dist/markdown/index.d.cts +1 -1
  36. package/dist/markdown/index.d.ts +1 -1
  37. package/dist/markdown/index.js +7 -11
  38. package/dist/{types-CwBWwNVv.d.ts → types-C_d0fFeD.d.ts} +8 -1
  39. package/dist/{types-CpsEy8lA.d.cts → types-MRBGgJTX.d.cts} +8 -1
  40. package/dist/vitepress/config/index.cjs +402 -178
  41. package/dist/vitepress/config/index.js +392 -168
  42. package/dist/workspace/index.cjs +4 -1202
  43. package/dist/workspace/index.d.cts +2 -2
  44. package/dist/workspace/index.d.ts +2 -2
  45. package/dist/workspace/index.js +4 -1202
  46. package/markdown/components.ts +86 -0
  47. package/markdown/index.ts +17 -0
  48. package/markdown/noteFormatter.test.ts +44 -0
  49. package/markdown/noteFormatter.ts +237 -0
  50. package/package.json +7 -3
  51. package/services/file-watcher/folderChangeHandler.ts +19 -15
  52. package/services/file-watcher/globalUpdateCoordinator.ts +21 -27
  53. package/services/file-watcher/service.ts +1 -6
  54. package/services/index.ts +0 -1
  55. package/services/note/service.ts +4 -6
  56. package/services/readme/service.ts +11 -18
  57. package/services/reconcileToc.ts +20 -0
  58. package/utils/index.ts +0 -9
  59. package/{services/toc → utils}/moveTocInside.test.ts +1 -1
  60. package/vitepress/components/BilibiliOutsidePlayer/BilibiliOutsidePlayer.vue +9 -18
  61. package/vitepress/components/EnWordList/EnWordList.vue +16 -662
  62. package/vitepress/components/Footprints/Footprints.vue +15 -537
  63. package/vitepress/components/Mermaid/Mermaid.vue +13 -588
  64. package/vitepress/components/MindmapPreview/MindmapPreview.vue +12 -434
  65. package/vitepress/components/MindmapPreview/markdown.ts +1 -1
  66. package/vitepress/components/NotesTable/NotesTable.vue +11 -130
  67. package/vitepress/components/constants.ts +2 -2
  68. package/vitepress/components/sidebar.data.ts +8 -5
  69. package/vitepress/configs/markdown.config.ts +170 -26
  70. package/vitepress/plugins/sidebarStructurePlugin.ts +242 -145
  71. package/vitepress/theme/index.ts +9 -13
  72. package/vitepress/theme/styles/base.scss +15 -0
  73. package/workspace/atomic.ts +113 -0
  74. package/workspace/errors.ts +27 -0
  75. package/workspace/index.ts +40 -0
  76. package/workspace/mutationQueue.ts +28 -0
  77. package/workspace/paths.ts +64 -0
  78. package/workspace/reconcile.test.ts +300 -0
  79. package/workspace/reconcile.ts +95 -0
  80. package/workspace/scanner.ts +292 -0
  81. package/workspace/types.ts +224 -0
  82. package/workspace/workspace.test.ts +333 -0
  83. package/workspace/workspace.ts +1020 -0
  84. package/dist/chunk-3R7QSABB.cjs +0 -502
  85. package/dist/chunk-CZXRQPFJ.js +0 -11
  86. package/dist/chunk-HXED7KVT.cjs +0 -11
  87. package/dist/chunk-U6IBLREL.js +0 -502
  88. package/services/toc/index.ts +0 -5
  89. package/services/toc/service.ts +0 -759
  90. package/utils/migrateReadmeToToc.test.ts +0 -111
  91. package/utils/migrateReadmeToToc.ts +0 -135
  92. package/vitepress/components/EnWordList/RightClickMenu.vue +0 -93
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { constants as fsConstants } from 'node:fs'
3
+ import fs from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ import { WorkspaceError } from './errors'
7
+
8
+ export interface AtomicWrite {
9
+ path: string
10
+ data: string | Uint8Array
11
+ }
12
+
13
+ interface OriginalFile {
14
+ path: string
15
+ existed: boolean
16
+ data?: Buffer
17
+ }
18
+
19
+ async function pathExists(filePath: string): Promise<boolean> {
20
+ try {
21
+ await fs.access(filePath, fsConstants.F_OK)
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ async function stageWrite(write: AtomicWrite): Promise<string> {
29
+ await fs.mkdir(path.dirname(write.path), { recursive: true })
30
+ const temporaryPath = path.join(
31
+ path.dirname(write.path),
32
+ `.${path.basename(write.path)}.${randomUUID()}.tmp`,
33
+ )
34
+ const handle = await fs.open(temporaryPath, 'wx')
35
+ try {
36
+ await handle.writeFile(write.data)
37
+ await handle.sync()
38
+ } finally {
39
+ await handle.close()
40
+ }
41
+ return temporaryPath
42
+ }
43
+
44
+ async function restoreOriginals(originals: OriginalFile[]): Promise<void> {
45
+ for (const original of originals) {
46
+ if (original.existed && original.data) {
47
+ const restorePath = await stageWrite({
48
+ path: original.path,
49
+ data: original.data,
50
+ })
51
+ await fs.rename(restorePath, original.path)
52
+ } else {
53
+ await fs.rm(original.path, { force: true })
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Stage every file before replacing any target. If a replacement fails, the
60
+ * captured originals are restored. Directory mutations are deliberately kept
61
+ * outside this helper and validated before they run.
62
+ */
63
+ export async function writeFilesAtomically(
64
+ writes: AtomicWrite[],
65
+ ): Promise<void> {
66
+ const uniqueWrites = new Map<string, AtomicWrite>()
67
+ for (const write of writes) uniqueWrites.set(write.path, write)
68
+ const normalizedWrites = [...uniqueWrites.values()]
69
+
70
+ const originals: OriginalFile[] = []
71
+ const staged: Array<{ target: string; temporary: string }> = []
72
+
73
+ try {
74
+ for (const write of normalizedWrites) {
75
+ const existed = await pathExists(write.path)
76
+ originals.push({
77
+ path: write.path,
78
+ existed,
79
+ data: existed ? await fs.readFile(write.path) : undefined,
80
+ })
81
+ staged.push({
82
+ target: write.path,
83
+ temporary: await stageWrite(write),
84
+ })
85
+ }
86
+
87
+ for (const item of staged) {
88
+ await fs.rename(item.temporary, item.target)
89
+ }
90
+ } catch (error) {
91
+ await Promise.allSettled(
92
+ staged.map((item) => fs.rm(item.temporary, { force: true })),
93
+ )
94
+ try {
95
+ await restoreOriginals(originals)
96
+ } catch (restoreError) {
97
+ throw new WorkspaceError(
98
+ 'FILESYSTEM_ERROR',
99
+ '写入失败,并且无法完整恢复原文件',
100
+ {
101
+ cause: error instanceof Error ? error.message : String(error),
102
+ restoreCause:
103
+ restoreError instanceof Error
104
+ ? restoreError.message
105
+ : String(restoreError),
106
+ },
107
+ )
108
+ }
109
+ throw new WorkspaceError('FILESYSTEM_ERROR', '无法原子写入知识库文件', {
110
+ cause: error instanceof Error ? error.message : String(error),
111
+ })
112
+ }
113
+ }
@@ -0,0 +1,27 @@
1
+ export type WorkspaceErrorCode =
2
+ | 'WORKSPACE_DISPOSED'
3
+ | 'WORKSPACE_INVALID'
4
+ | 'WORKSPACE_READ_ONLY'
5
+ | 'NOTE_NOT_FOUND'
6
+ | 'NOTE_INDEX_EXHAUSTED'
7
+ | 'REVISION_CONFLICT'
8
+ | 'INVALID_TITLE'
9
+ | 'INVALID_TOC_ENTRY'
10
+ | 'INVALID_PATH'
11
+ | 'FILESYSTEM_ERROR'
12
+
13
+ export class WorkspaceError extends Error {
14
+ readonly code: WorkspaceErrorCode
15
+ readonly details?: Record<string, unknown>
16
+
17
+ constructor(
18
+ code: WorkspaceErrorCode,
19
+ message: string,
20
+ details?: Record<string, unknown>,
21
+ ) {
22
+ super(message)
23
+ this.name = 'WorkspaceError'
24
+ this.code = code
25
+ this.details = details
26
+ }
27
+ }
@@ -0,0 +1,40 @@
1
+ import { Workspace } from './workspace'
2
+
3
+ import type { CreateWorkspaceOptions, TNotesWorkspace } from './types'
4
+
5
+ export function createWorkspace(
6
+ options: CreateWorkspaceOptions,
7
+ ): TNotesWorkspace {
8
+ return new Workspace(options)
9
+ }
10
+
11
+ export { WorkspaceError } from './errors'
12
+ export type { WorkspaceErrorCode } from './errors'
13
+ export type {
14
+ AttachmentResult,
15
+ ChangedFile,
16
+ CreateNoteInput,
17
+ CreateTocGroupInput,
18
+ CreateWorkspaceOptions,
19
+ DeletePreviewItem,
20
+ DeleteTocEntryInput,
21
+ DeleteTocEntryPreview,
22
+ KnowledgeBaseSnapshot,
23
+ MoveTocEntryInput,
24
+ MutationResult,
25
+ NoteDocument,
26
+ NotePlacement,
27
+ RenameNoteInput,
28
+ RenameTocGroupInput,
29
+ SaveNoteInput,
30
+ TNotesWorkspace,
31
+ TocEntryRef,
32
+ UpdateNoteConfigInput,
33
+ WorkspaceDiagnostic,
34
+ WorkspaceHealth,
35
+ WorkspaceKnowledgeBaseConfig,
36
+ WorkspaceLogger,
37
+ WorkspaceNoteConfig,
38
+ WorkspaceNoteSummary,
39
+ WriteAttachmentInput,
40
+ } from './types'
@@ -0,0 +1,28 @@
1
+ export class MutationQueue {
2
+ private tail: Promise<void> = Promise.resolve()
3
+ private disposed = false
4
+
5
+ async run<T>(operation: () => Promise<T>): Promise<T> {
6
+ if (this.disposed) {
7
+ throw new Error('Mutation queue has been disposed')
8
+ }
9
+
10
+ const previous = this.tail
11
+ let release!: () => void
12
+ this.tail = new Promise<void>((resolve) => {
13
+ release = resolve
14
+ })
15
+
16
+ await previous
17
+ try {
18
+ return await operation()
19
+ } finally {
20
+ release()
21
+ }
22
+ }
23
+
24
+ async dispose(): Promise<void> {
25
+ this.disposed = true
26
+ await this.tail
27
+ }
28
+ }
@@ -0,0 +1,64 @@
1
+ import path from 'node:path'
2
+
3
+ import { WorkspaceError } from './errors'
4
+
5
+ export interface WorkspacePaths {
6
+ root: string
7
+ config: string
8
+ toc: string
9
+ notes: string
10
+ sidebar: string
11
+ packageJson: string
12
+ }
13
+
14
+ export function createWorkspacePaths(rootPath: string): WorkspacePaths {
15
+ if (!rootPath || !path.isAbsolute(rootPath)) {
16
+ throw new WorkspaceError(
17
+ 'INVALID_PATH',
18
+ '知识库路径必须是非空的绝对路径',
19
+ { rootPath },
20
+ )
21
+ }
22
+
23
+ const root = path.normalize(path.resolve(rootPath))
24
+ return {
25
+ root,
26
+ config: path.join(root, '.tnotes.json'),
27
+ toc: path.join(root, 'TOC.md'),
28
+ notes: path.join(root, 'notes'),
29
+ sidebar: path.join(root, 'sidebar.json'),
30
+ packageJson: path.join(root, 'package.json'),
31
+ }
32
+ }
33
+
34
+ export function assertPathInside(parent: string, target: string): void {
35
+ const relative = path.relative(parent, target)
36
+ if (
37
+ relative === '' ||
38
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
39
+ ) {
40
+ return
41
+ }
42
+
43
+ throw new WorkspaceError('INVALID_PATH', '目标路径超出允许范围', {
44
+ parent,
45
+ target,
46
+ })
47
+ }
48
+
49
+ export function sanitizeFileName(fileName: string): string {
50
+ const value = fileName.trim()
51
+ if (
52
+ !value ||
53
+ value === '.' ||
54
+ value === '..' ||
55
+ value.includes('/') ||
56
+ value.includes('\\') ||
57
+ value.includes('\0')
58
+ ) {
59
+ throw new WorkspaceError('INVALID_PATH', '附件文件名不合法', {
60
+ fileName,
61
+ })
62
+ }
63
+ return value
64
+ }
@@ -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
+ }