@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,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
|
+
}
|