@tnotesjs/core 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/ConfigManager.ts +137 -0
- package/config/constants.ts +121 -0
- package/config/index.ts +25 -0
- package/config/templates.ts +49 -0
- package/core/GitManager.ts +513 -0
- package/core/NoteIndexCache.ts +194 -0
- package/core/NoteManager.ts +407 -0
- package/core/ProcessManager.ts +180 -0
- package/core/ReadmeGenerator.ts +215 -0
- package/core/TocGenerator.ts +212 -0
- package/core/index.ts +11 -0
- package/package.json +5 -2
- package/services/file-watcher/configChangeHandler.ts +64 -0
- package/services/file-watcher/eventScheduler.ts +179 -0
- package/services/file-watcher/folderChangeHandler.ts +325 -0
- package/services/file-watcher/fsWatcherAdapter.ts +128 -0
- package/services/file-watcher/globalUpdateCoordinator.ts +60 -0
- package/services/file-watcher/index.ts +7 -0
- package/services/file-watcher/internal.ts +79 -0
- package/services/file-watcher/readmeChangeHandler.ts +28 -0
- package/services/file-watcher/renameDetector.ts +120 -0
- package/services/file-watcher/service.ts +352 -0
- package/services/file-watcher/watchState.ts +194 -0
- package/services/git/index.ts +7 -0
- package/services/git/service.ts +114 -0
- package/services/index.ts +15 -0
- package/services/init-sub-repo/index.ts +2 -0
- package/services/init-sub-repo/initSubRepoLogic.test.ts +162 -0
- package/services/init-sub-repo/initSubRepoLogic.ts +304 -0
- package/services/init-sub-repo/service.ts +101 -0
- package/services/note/index.ts +7 -0
- package/services/note/service.ts +362 -0
- package/services/readme/index.ts +7 -0
- package/services/readme/service.ts +761 -0
- package/services/timestamp/index.ts +7 -0
- package/services/timestamp/service.ts +465 -0
- package/services/toc/index.ts +5 -0
- package/services/toc/moveTocInside.test.ts +73 -0
- package/services/toc/service.ts +759 -0
- package/services/vitepress/index.ts +7 -0
- package/services/vitepress/service.ts +339 -0
- package/utils/errorHandler.ts +174 -0
- package/utils/file.ts +17 -0
- package/utils/genHierarchicalSidebar.ts +69 -0
- package/utils/generateAnchor.ts +24 -0
- package/utils/getChangedIds.ts +35 -0
- package/utils/index.ts +71 -0
- package/utils/logger.ts +231 -0
- package/utils/markdown.ts +75 -0
- package/utils/migrateReadmeToToc.test.ts +111 -0
- package/utils/migrateReadmeToToc.ts +135 -0
- package/utils/parseArgs.ts +90 -0
- package/utils/parseReadmeCompletedNotes.test.ts +90 -0
- package/utils/parseReadmeCompletedNotes.ts +108 -0
- package/utils/portUtils.ts +113 -0
- package/utils/readmeHelpers.ts +190 -0
- package/utils/runCommand.ts +29 -0
- package/utils/tocHelpers.test.ts +278 -0
- package/utils/tocHelpers.ts +855 -0
- package/utils/tocNodeId.test.ts +60 -0
- package/utils/tocNodeId.ts +97 -0
- package/utils/validators.ts +102 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/tocHelpers.ts
|
|
3
|
+
*
|
|
4
|
+
* 根目录 TOC.md 解析、序列化与 sidebar 树构建
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { computeSidebarNodeId } from './tocNodeId'
|
|
8
|
+
|
|
9
|
+
import type { NoteInfo, NoteConfig } from '../types'
|
|
10
|
+
|
|
11
|
+
/** 每级缩进空格数 */
|
|
12
|
+
export const TOC_INDENT_SPACES = 2
|
|
13
|
+
|
|
14
|
+
export type TocLineKind = 'folder' | 'note' | 'unknown'
|
|
15
|
+
|
|
16
|
+
/** TOC 行解析结果 */
|
|
17
|
+
export interface ParsedTocLine {
|
|
18
|
+
kind: TocLineKind
|
|
19
|
+
isMatch: boolean
|
|
20
|
+
indentLevel: number
|
|
21
|
+
noteIndex: string | null
|
|
22
|
+
folderTitle: string | null
|
|
23
|
+
completed: boolean
|
|
24
|
+
rawLine: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 目录节点 */
|
|
28
|
+
export interface TocFolderNode {
|
|
29
|
+
kind: 'folder'
|
|
30
|
+
title: string
|
|
31
|
+
indent: number
|
|
32
|
+
tocLineIndex: number
|
|
33
|
+
children: TocTreeNode[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 笔记节点(可有 children,语雀式父笔记) */
|
|
37
|
+
export interface TocNoteNode {
|
|
38
|
+
kind: 'note'
|
|
39
|
+
noteIndex: string
|
|
40
|
+
indent: number
|
|
41
|
+
tocLineIndex: number
|
|
42
|
+
children: TocTreeNode[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TocTreeNode = TocFolderNode | TocNoteNode
|
|
46
|
+
|
|
47
|
+
/** @deprecated 使用 TocTreeNode */
|
|
48
|
+
export type TocNode = TocTreeNode
|
|
49
|
+
|
|
50
|
+
/** Sidebar 项(与 VitePress sidebar 结构一致) */
|
|
51
|
+
export interface TocSidebarItem {
|
|
52
|
+
text: string
|
|
53
|
+
link?: string
|
|
54
|
+
collapsed?: boolean
|
|
55
|
+
items?: TocSidebarItem[]
|
|
56
|
+
/** 纯目录节点路径(从根到当前目录的标题链,仅 UI 展示) */
|
|
57
|
+
folderPath?: string[]
|
|
58
|
+
/** TOC.md 中对应行的 0-based 行号(CRUD/拖拽主键) */
|
|
59
|
+
tocLineIndex?: number
|
|
60
|
+
/** dev 拖拽用确定性 nodeId(不写 TOC.md) */
|
|
61
|
+
nodeId?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** legacy:- [x] [0001. 标题](/notes/...) */
|
|
65
|
+
const TOC_LEGACY_FULL_REGEX =
|
|
66
|
+
/^( *)(-\s+\[(x| )\])\s+\[(\d{4}\.[^\]]+)\]\(([^)]+)\)/
|
|
67
|
+
|
|
68
|
+
/** canonical 笔记:- [x] 0001. 标题 或 - [ ] 0001 */
|
|
69
|
+
const TOC_NOTE_LINE_REGEX =
|
|
70
|
+
/^( *)(-\s+\[(x| )\])\s+(\d{4})(?:\.\s*(.*))?\s*$/
|
|
71
|
+
|
|
72
|
+
/** 目录:- 标题(无 checkbox) */
|
|
73
|
+
const TOC_FOLDER_LINE_REGEX = /^( *)(-\s+(?!\[(?:x| )\]).+?)\s*$/
|
|
74
|
+
|
|
75
|
+
function extractNoteIndexFromTitle(text: string): string | null {
|
|
76
|
+
const match = text.match(/^(\d{4})\./)
|
|
77
|
+
return match ? match[1] : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseIndent(spaces: string | undefined): number {
|
|
81
|
+
return Math.floor((spaces?.length ?? 0) / TOC_INDENT_SPACES)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 解析 TOC.md 单行
|
|
86
|
+
*/
|
|
87
|
+
export function parseTocLine(line: string | undefined | null): ParsedTocLine {
|
|
88
|
+
const rawLine = line ?? ''
|
|
89
|
+
const empty: ParsedTocLine = {
|
|
90
|
+
kind: 'unknown',
|
|
91
|
+
isMatch: false,
|
|
92
|
+
indentLevel: 0,
|
|
93
|
+
noteIndex: null,
|
|
94
|
+
folderTitle: null,
|
|
95
|
+
completed: false,
|
|
96
|
+
rawLine,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (line == null) return empty
|
|
100
|
+
|
|
101
|
+
const legacyMatch = line.match(TOC_LEGACY_FULL_REGEX)
|
|
102
|
+
if (legacyMatch) {
|
|
103
|
+
const [, spaces, , statusChar, titleText] = legacyMatch
|
|
104
|
+
const noteIndex = extractNoteIndexFromTitle(titleText)
|
|
105
|
+
if (!noteIndex) return empty
|
|
106
|
+
return {
|
|
107
|
+
kind: 'note',
|
|
108
|
+
isMatch: true,
|
|
109
|
+
indentLevel: parseIndent(spaces),
|
|
110
|
+
noteIndex,
|
|
111
|
+
folderTitle: null,
|
|
112
|
+
completed: statusChar === 'x',
|
|
113
|
+
rawLine,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const noteMatch = line.match(TOC_NOTE_LINE_REGEX)
|
|
118
|
+
if (noteMatch) {
|
|
119
|
+
const [, spaces, , statusChar, noteIndex] = noteMatch
|
|
120
|
+
return {
|
|
121
|
+
kind: 'note',
|
|
122
|
+
isMatch: true,
|
|
123
|
+
indentLevel: parseIndent(spaces),
|
|
124
|
+
noteIndex,
|
|
125
|
+
folderTitle: null,
|
|
126
|
+
completed: statusChar === 'x',
|
|
127
|
+
rawLine,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const folderMatch = line.match(TOC_FOLDER_LINE_REGEX)
|
|
132
|
+
if (folderMatch) {
|
|
133
|
+
const [, spaces, titlePart] = folderMatch
|
|
134
|
+
const title = titlePart.replace(/^-\s+/, '').trim()
|
|
135
|
+
if (!title) return empty
|
|
136
|
+
return {
|
|
137
|
+
kind: 'folder',
|
|
138
|
+
isMatch: true,
|
|
139
|
+
indentLevel: parseIndent(spaces),
|
|
140
|
+
noteIndex: null,
|
|
141
|
+
folderTitle: title,
|
|
142
|
+
completed: false,
|
|
143
|
+
rawLine,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return empty
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function isTocContentLine(line: string): boolean {
|
|
151
|
+
return parseTocLine(line).isMatch
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 从 notes 列表按索引查找笔记
|
|
156
|
+
*/
|
|
157
|
+
export function resolveNoteFromIndex(
|
|
158
|
+
index: string,
|
|
159
|
+
notes: NoteInfo[],
|
|
160
|
+
): NoteInfo | undefined {
|
|
161
|
+
return notes.find((n) => n.index === index)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 根据笔记配置决定 checkbox 状态
|
|
166
|
+
*/
|
|
167
|
+
export function getTocLineCompleted(
|
|
168
|
+
note: NoteInfo,
|
|
169
|
+
configOverride?: Partial<NoteConfig>,
|
|
170
|
+
): boolean {
|
|
171
|
+
const config = configOverride
|
|
172
|
+
? { ...note.config, ...configOverride }
|
|
173
|
+
: note.config
|
|
174
|
+
return config?.done ?? false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 去掉 dirName 的编号前缀,用作目录标题 */
|
|
178
|
+
export function folderTitleFromNoteDirName(dirName: string): string {
|
|
179
|
+
return dirName.replace(/^\d{4}\.\s*/, '').trim() || dirName
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 构建目录 TOC 行
|
|
184
|
+
*/
|
|
185
|
+
export function buildFolderTocLine(title: string, indentLevel: number): string {
|
|
186
|
+
const indent = ' '.repeat(indentLevel * TOC_INDENT_SPACES)
|
|
187
|
+
return `${indent}- ${title}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 构建笔记 TOC 行(无 link)
|
|
192
|
+
*/
|
|
193
|
+
export function buildTocLine(
|
|
194
|
+
note: NoteInfo,
|
|
195
|
+
indentLevel: number,
|
|
196
|
+
completed?: boolean,
|
|
197
|
+
): string {
|
|
198
|
+
const indent = ' '.repeat(indentLevel * TOC_INDENT_SPACES)
|
|
199
|
+
const status = (completed ?? getTocLineCompleted(note)) ? 'x' : ' '
|
|
200
|
+
return `${indent}- [${status}] ${note.dirName}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface MutableTreeNode {
|
|
204
|
+
kind: 'folder' | 'note'
|
|
205
|
+
title?: string
|
|
206
|
+
noteIndex?: string
|
|
207
|
+
indent: number
|
|
208
|
+
tocLineIndex: number
|
|
209
|
+
children: MutableTreeNode[]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function mutableToTreeNode(node: MutableTreeNode): TocTreeNode {
|
|
213
|
+
if (node.kind === 'folder') {
|
|
214
|
+
return {
|
|
215
|
+
kind: 'folder',
|
|
216
|
+
title: node.title!,
|
|
217
|
+
indent: node.indent,
|
|
218
|
+
tocLineIndex: node.tocLineIndex,
|
|
219
|
+
children: node.children.map(mutableToTreeNode),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
kind: 'note',
|
|
224
|
+
noteIndex: node.noteIndex!,
|
|
225
|
+
indent: node.indent,
|
|
226
|
+
tocLineIndex: node.tocLineIndex,
|
|
227
|
+
children: node.children.map(mutableToTreeNode),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildMutableTreeFromFlat(
|
|
232
|
+
flatNodes: Array<{
|
|
233
|
+
kind: 'folder' | 'note'
|
|
234
|
+
title?: string
|
|
235
|
+
noteIndex?: string
|
|
236
|
+
indent: number
|
|
237
|
+
tocLineIndex: number
|
|
238
|
+
}>,
|
|
239
|
+
): MutableTreeNode[] {
|
|
240
|
+
const roots: MutableTreeNode[] = []
|
|
241
|
+
const stack: MutableTreeNode[] = []
|
|
242
|
+
|
|
243
|
+
for (const item of flatNodes) {
|
|
244
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= item.indent) {
|
|
245
|
+
stack.pop()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const node: MutableTreeNode = {
|
|
249
|
+
kind: item.kind,
|
|
250
|
+
title: item.title,
|
|
251
|
+
noteIndex: item.noteIndex,
|
|
252
|
+
indent: item.indent,
|
|
253
|
+
tocLineIndex: item.tocLineIndex,
|
|
254
|
+
children: [],
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (stack.length === 0) {
|
|
258
|
+
roots.push(node)
|
|
259
|
+
} else {
|
|
260
|
+
stack[stack.length - 1].children.push(node)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
stack.push(node)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return roots
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @deprecated 不再自动拆分父笔记;保留供旧测试/脚本引用
|
|
271
|
+
*/
|
|
272
|
+
export function migrateLegacyNoteParents(
|
|
273
|
+
roots: MutableTreeNode[],
|
|
274
|
+
_notes: NoteInfo[],
|
|
275
|
+
): TocTreeNode[] {
|
|
276
|
+
return roots.map(mutableToTreeNode)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 将 flat 行解析为可变森林
|
|
281
|
+
*/
|
|
282
|
+
export function parseTocToMutableTree(
|
|
283
|
+
lines: string[],
|
|
284
|
+
notes: NoteInfo[],
|
|
285
|
+
): MutableTreeNode[] {
|
|
286
|
+
const flatNodes: Array<{
|
|
287
|
+
kind: 'folder' | 'note'
|
|
288
|
+
title?: string
|
|
289
|
+
noteIndex?: string
|
|
290
|
+
indent: number
|
|
291
|
+
tocLineIndex: number
|
|
292
|
+
}> = []
|
|
293
|
+
|
|
294
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
295
|
+
const line = lines[lineIndex]
|
|
296
|
+
const parsed = parseTocLine(line)
|
|
297
|
+
if (!parsed.isMatch) continue
|
|
298
|
+
|
|
299
|
+
if (parsed.kind === 'folder') {
|
|
300
|
+
flatNodes.push({
|
|
301
|
+
kind: 'folder',
|
|
302
|
+
title: parsed.folderTitle!,
|
|
303
|
+
indent: parsed.indentLevel,
|
|
304
|
+
tocLineIndex: lineIndex,
|
|
305
|
+
})
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!parsed.noteIndex) continue
|
|
310
|
+
if (!resolveNoteFromIndex(parsed.noteIndex, notes)) continue
|
|
311
|
+
|
|
312
|
+
flatNodes.push({
|
|
313
|
+
kind: 'note',
|
|
314
|
+
noteIndex: parsed.noteIndex,
|
|
315
|
+
indent: parsed.indentLevel,
|
|
316
|
+
tocLineIndex: lineIndex,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return buildMutableTreeFromFlat(flatNodes)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 将 flat 行解析为 canonical 森林
|
|
325
|
+
*/
|
|
326
|
+
export function parseTocToTree(
|
|
327
|
+
lines: string[],
|
|
328
|
+
notes: NoteInfo[],
|
|
329
|
+
): TocTreeNode[] {
|
|
330
|
+
return parseTocToMutableTree(lines, notes).map(mutableToTreeNode)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @deprecated 使用 parseTocToTree;保留供过渡期调用
|
|
335
|
+
*/
|
|
336
|
+
export function buildTreeFromFlatNodes(flatNodes: TocNode[]): TocTreeNode[] {
|
|
337
|
+
const mutable: MutableTreeNode[] = flatNodes.map((node) => {
|
|
338
|
+
if (node.kind === 'folder') {
|
|
339
|
+
return {
|
|
340
|
+
kind: 'folder',
|
|
341
|
+
title: node.title,
|
|
342
|
+
indent: node.indent,
|
|
343
|
+
tocLineIndex: node.tocLineIndex,
|
|
344
|
+
children: flattenMutableFromTree(node.children),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
kind: 'note',
|
|
349
|
+
noteIndex: node.noteIndex,
|
|
350
|
+
indent: node.indent,
|
|
351
|
+
tocLineIndex: node.tocLineIndex,
|
|
352
|
+
children: flattenMutableFromTree(node.children),
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
return mutable.map(mutableToTreeNode)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function flattenMutableFromTree(nodes: TocTreeNode[]): MutableTreeNode[] {
|
|
359
|
+
const result: MutableTreeNode[] = []
|
|
360
|
+
for (const node of nodes) {
|
|
361
|
+
if (node.kind === 'folder') {
|
|
362
|
+
result.push({
|
|
363
|
+
kind: 'folder',
|
|
364
|
+
title: node.title,
|
|
365
|
+
indent: node.indent,
|
|
366
|
+
tocLineIndex: node.tocLineIndex,
|
|
367
|
+
children: flattenMutableFromTree(node.children),
|
|
368
|
+
})
|
|
369
|
+
} else {
|
|
370
|
+
result.push({
|
|
371
|
+
kind: 'note',
|
|
372
|
+
noteIndex: node.noteIndex,
|
|
373
|
+
indent: node.indent,
|
|
374
|
+
tocLineIndex: node.tocLineIndex,
|
|
375
|
+
children: flattenMutableFromTree(node.children),
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return result
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 深度优先序列化 TOC 树为 flat 行
|
|
384
|
+
*/
|
|
385
|
+
export function serializeTocTree(
|
|
386
|
+
tree: TocTreeNode[],
|
|
387
|
+
notes: NoteInfo[],
|
|
388
|
+
configByIndex?: Map<string, Partial<NoteConfig>>,
|
|
389
|
+
): string[] {
|
|
390
|
+
const lines: string[] = []
|
|
391
|
+
|
|
392
|
+
function walk(nodes: TocTreeNode[]) {
|
|
393
|
+
for (const node of nodes) {
|
|
394
|
+
if (node.kind === 'folder') {
|
|
395
|
+
lines.push(buildFolderTocLine(node.title, node.indent))
|
|
396
|
+
walk(node.children)
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const note = resolveNoteFromIndex(node.noteIndex, notes)
|
|
401
|
+
if (!note) continue
|
|
402
|
+
const override = configByIndex?.get(node.noteIndex)
|
|
403
|
+
const completed =
|
|
404
|
+
override !== undefined
|
|
405
|
+
? getTocLineCompleted(note, override)
|
|
406
|
+
: getTocLineCompleted(note)
|
|
407
|
+
lines.push(buildTocLine(note, node.indent, completed))
|
|
408
|
+
walk(node.children)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
walk(tree)
|
|
413
|
+
return lines
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 从 flat 解析结果提取笔记条目
|
|
418
|
+
*/
|
|
419
|
+
export function parseTocLinesToFlatNodes(lines: string[]): Array<{
|
|
420
|
+
noteIndex: string
|
|
421
|
+
indent: number
|
|
422
|
+
completed: boolean
|
|
423
|
+
}> {
|
|
424
|
+
const result: Array<{
|
|
425
|
+
noteIndex: string
|
|
426
|
+
indent: number
|
|
427
|
+
completed: boolean
|
|
428
|
+
}> = []
|
|
429
|
+
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
const parsed = parseTocLine(line)
|
|
432
|
+
if (parsed.kind !== 'note' || !parsed.noteIndex) continue
|
|
433
|
+
result.push({
|
|
434
|
+
noteIndex: parsed.noteIndex,
|
|
435
|
+
indent: parsed.indentLevel,
|
|
436
|
+
completed: parsed.completed,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return result
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 笔记行子树范围 [start, end)
|
|
445
|
+
*/
|
|
446
|
+
export function getSubtreeLineRange(
|
|
447
|
+
lines: string[],
|
|
448
|
+
startLineIndex: number,
|
|
449
|
+
): { start: number; end: number } {
|
|
450
|
+
const parsed = parseTocLine(lines[startLineIndex])
|
|
451
|
+
if (parsed.kind !== 'note') {
|
|
452
|
+
return { start: startLineIndex, end: startLineIndex + 1 }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const baseIndent = parsed.indentLevel
|
|
456
|
+
let end = startLineIndex + 1
|
|
457
|
+
|
|
458
|
+
for (let i = startLineIndex + 1; i < lines.length; i++) {
|
|
459
|
+
const next = parseTocLine(lines[i])
|
|
460
|
+
if (next.isMatch && next.indentLevel <= baseIndent) break
|
|
461
|
+
end = i + 1
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { start: startLineIndex, end }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* folder / note 通用子树范围 [start, end)
|
|
469
|
+
*/
|
|
470
|
+
/**
|
|
471
|
+
* 从 lines 中移除 [removedStart, removedEnd) 后,校正基于原行号的索引。
|
|
472
|
+
*/
|
|
473
|
+
export function adjustTocLineIndexAfterSubtreeRemoval(
|
|
474
|
+
lineIndex: number,
|
|
475
|
+
removedStart: number,
|
|
476
|
+
removedEnd: number,
|
|
477
|
+
): number {
|
|
478
|
+
if (lineIndex >= removedEnd) {
|
|
479
|
+
return lineIndex - (removedEnd - removedStart)
|
|
480
|
+
}
|
|
481
|
+
return lineIndex
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function getTocEntrySubtreeRange(
|
|
485
|
+
lines: string[],
|
|
486
|
+
lineIndex: number,
|
|
487
|
+
): { start: number; end: number } {
|
|
488
|
+
const parsed = parseTocLine(lines[lineIndex])
|
|
489
|
+
if (!parsed.isMatch) {
|
|
490
|
+
return { start: lineIndex, end: lineIndex + 1 }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const baseIndent = parsed.indentLevel
|
|
494
|
+
let end = lineIndex + 1
|
|
495
|
+
|
|
496
|
+
for (let i = lineIndex + 1; i < lines.length; i++) {
|
|
497
|
+
const next = parseTocLine(lines[i])
|
|
498
|
+
if (next.isMatch && next.indentLevel <= baseIndent) break
|
|
499
|
+
end = i + 1
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { start: lineIndex, end }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 收集子树内所有笔记编号(含根若为 note)
|
|
507
|
+
*/
|
|
508
|
+
export function collectNoteIndexesInSubtree(
|
|
509
|
+
lines: string[],
|
|
510
|
+
lineIndex: number,
|
|
511
|
+
): string[] {
|
|
512
|
+
const { start, end } = getTocEntrySubtreeRange(lines, lineIndex)
|
|
513
|
+
const indexes: string[] = []
|
|
514
|
+
|
|
515
|
+
for (let i = start; i < end; i++) {
|
|
516
|
+
const parsed = parseTocLine(lines[i])
|
|
517
|
+
if (parsed.noteIndex) {
|
|
518
|
+
indexes.push(parsed.noteIndex)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return indexes
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 重命名目录行(仅改标题,保留缩进)
|
|
527
|
+
*/
|
|
528
|
+
export function renameFolderLine(
|
|
529
|
+
lines: string[],
|
|
530
|
+
lineIndex: number,
|
|
531
|
+
newTitle: string,
|
|
532
|
+
): string[] {
|
|
533
|
+
const parsed = parseTocLine(lines[lineIndex])
|
|
534
|
+
if (parsed.kind !== 'folder') {
|
|
535
|
+
throw new Error(`TOC 行 ${lineIndex} 不是目录行`)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const trimmed = newTitle.trim()
|
|
539
|
+
if (!trimmed) {
|
|
540
|
+
throw new Error('目录标题不能为空')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const result = [...lines]
|
|
544
|
+
result[lineIndex] = buildFolderTocLine(trimmed, parsed.indentLevel)
|
|
545
|
+
return result
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 删除子树前,获取子树之前文档顺序中的最后一篇笔记 index
|
|
550
|
+
*/
|
|
551
|
+
export function getPreviousNoteIndexOutsideSubtree(
|
|
552
|
+
lines: string[],
|
|
553
|
+
lineIndex: number,
|
|
554
|
+
): string | null {
|
|
555
|
+
const { start } = getTocEntrySubtreeRange(lines, lineIndex)
|
|
556
|
+
let lastBefore: string | null = null
|
|
557
|
+
|
|
558
|
+
for (let i = 0; i < start; i++) {
|
|
559
|
+
const parsed = parseTocLine(lines[i])
|
|
560
|
+
if (parsed.noteIndex) {
|
|
561
|
+
lastBefore = parsed.noteIndex
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return lastBefore
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 判断 noteIndex 是否位于指定 TOC 行子树内
|
|
570
|
+
*/
|
|
571
|
+
export function isNoteIndexInSubtree(
|
|
572
|
+
lines: string[],
|
|
573
|
+
lineIndex: number,
|
|
574
|
+
noteIndex: string,
|
|
575
|
+
): boolean {
|
|
576
|
+
const indexes = collectNoteIndexesInSubtree(lines, lineIndex)
|
|
577
|
+
return indexes.includes(noteIndex)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 目录行子树范围 [start, end)
|
|
582
|
+
*/
|
|
583
|
+
export function getFolderSubtreeRange(
|
|
584
|
+
lines: string[],
|
|
585
|
+
startLineIndex: number,
|
|
586
|
+
): { start: number; end: number } {
|
|
587
|
+
const parsed = parseTocLine(lines[startLineIndex])
|
|
588
|
+
if (parsed.kind !== 'folder') {
|
|
589
|
+
return { start: startLineIndex, end: startLineIndex + 1 }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const baseIndent = parsed.indentLevel
|
|
593
|
+
let end = startLineIndex + 1
|
|
594
|
+
|
|
595
|
+
for (let i = startLineIndex + 1; i < lines.length; i++) {
|
|
596
|
+
const next = parseTocLine(lines[i])
|
|
597
|
+
if (next.isMatch && next.indentLevel <= baseIndent) break
|
|
598
|
+
end = i + 1
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { start: startLineIndex, end }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* 按标题路径查找目录行索引
|
|
606
|
+
*/
|
|
607
|
+
export function findFolderLineIndex(
|
|
608
|
+
lines: string[],
|
|
609
|
+
folderPath: string[],
|
|
610
|
+
): number {
|
|
611
|
+
const target = folderPath.join('/')
|
|
612
|
+
const stack: Array<{ title: string; indent: number }> = []
|
|
613
|
+
|
|
614
|
+
for (let i = 0; i < lines.length; i++) {
|
|
615
|
+
const parsed = parseTocLine(lines[i])
|
|
616
|
+
if (!parsed.isMatch) continue
|
|
617
|
+
|
|
618
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= parsed.indentLevel) {
|
|
619
|
+
stack.pop()
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (parsed.kind === 'folder') {
|
|
623
|
+
const path = [...stack.map((s) => s.title), parsed.folderTitle!].join('/')
|
|
624
|
+
if (path === target) return i
|
|
625
|
+
stack.push({ title: parsed.folderTitle!, indent: parsed.indentLevel })
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
throw new Error(`TOC.md 中未找到目录: ${folderPath.join(' > ')}`)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* 查找 noteIndex 对应的首行索引
|
|
634
|
+
*/
|
|
635
|
+
export function findTocLineIndex(lines: string[], noteIndex: string): number {
|
|
636
|
+
for (let i = 0; i < lines.length; i++) {
|
|
637
|
+
const parsed = parseTocLine(lines[i])
|
|
638
|
+
if (parsed.noteIndex === noteIndex) return i
|
|
639
|
+
}
|
|
640
|
+
throw new Error(`TOC.md 中未找到笔记: ${noteIndex}`)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* 查找任意 TOC 行(笔记或目录)索引
|
|
645
|
+
*/
|
|
646
|
+
export function findTocEntryLineIndex(
|
|
647
|
+
lines: string[],
|
|
648
|
+
target:
|
|
649
|
+
| { targetType: 'note'; noteIndex: string }
|
|
650
|
+
| { targetType: 'folder'; folderPath: string[] }
|
|
651
|
+
| { targetType: 'line'; tocLineIndex: number },
|
|
652
|
+
): number {
|
|
653
|
+
if (target.targetType === 'line') {
|
|
654
|
+
return target.tocLineIndex
|
|
655
|
+
}
|
|
656
|
+
if (target.targetType === 'note') {
|
|
657
|
+
return findTocLineIndex(lines, target.noteIndex)
|
|
658
|
+
}
|
|
659
|
+
return findFolderLineIndex(lines, target.folderPath)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* 按 TOC 文档顺序获取被删笔记的上一项索引;首项返回 null(回退 README)
|
|
664
|
+
*/
|
|
665
|
+
export function getPreviousTocNoteIndex(
|
|
666
|
+
lines: string[],
|
|
667
|
+
noteIndex: string,
|
|
668
|
+
): string | null {
|
|
669
|
+
const flat = parseTocLinesToFlatNodes(lines)
|
|
670
|
+
const index = flat.findIndex((node) => node.noteIndex === noteIndex)
|
|
671
|
+
if (index === -1) {
|
|
672
|
+
throw new Error(`TOC.md 中未找到笔记: ${noteIndex}`)
|
|
673
|
+
}
|
|
674
|
+
if (index <= 0) return null
|
|
675
|
+
return flat[index - 1].noteIndex
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 合并连续空行,移除相邻 TOC 行之间的空行
|
|
680
|
+
*/
|
|
681
|
+
export function processTocEmptyLines(lines: string[]): string[] {
|
|
682
|
+
const result: string[] = []
|
|
683
|
+
let previousEmpty = false
|
|
684
|
+
|
|
685
|
+
for (let i = 0; i < lines.length; i++) {
|
|
686
|
+
const line = lines[i]
|
|
687
|
+
if (line === '') {
|
|
688
|
+
const prev = i > 0 ? lines[i - 1] : null
|
|
689
|
+
const next = i < lines.length - 1 ? lines[i + 1] : null
|
|
690
|
+
if (prev && next && isTocContentLine(prev) && isTocContentLine(next)) {
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
if (!previousEmpty) {
|
|
694
|
+
result.push(line)
|
|
695
|
+
previousEmpty = true
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
result.push(line)
|
|
699
|
+
previousEmpty = false
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return result
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 从 TOC.md 内容解析完成笔记数量
|
|
708
|
+
*/
|
|
709
|
+
export function parseTocCompletedNotes(content: string): {
|
|
710
|
+
completedCount: number
|
|
711
|
+
totalCount: number
|
|
712
|
+
notes: Array<{ noteIndex: string; completed: boolean; line: string }>
|
|
713
|
+
} {
|
|
714
|
+
const lines = content.split('\n')
|
|
715
|
+
const noteMap = new Map<
|
|
716
|
+
string,
|
|
717
|
+
{ noteIndex: string; completed: boolean; line: string }
|
|
718
|
+
>()
|
|
719
|
+
|
|
720
|
+
for (const line of lines) {
|
|
721
|
+
const parsed = parseTocLine(line)
|
|
722
|
+
if (parsed.kind !== 'note' || !parsed.noteIndex) continue
|
|
723
|
+
|
|
724
|
+
const noteIndex = parsed.noteIndex
|
|
725
|
+
const completed = parsed.completed
|
|
726
|
+
|
|
727
|
+
if (noteMap.has(noteIndex)) {
|
|
728
|
+
const existing = noteMap.get(noteIndex)!
|
|
729
|
+
if (existing.completed !== completed) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`发现相同编号 ${noteIndex} 的笔记有不同的完成状态:\n` +
|
|
732
|
+
` 第一次出现: ${existing.line}\n` +
|
|
733
|
+
` 第二次出现: ${line.trim()}`,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
noteMap.set(noteIndex, {
|
|
740
|
+
noteIndex,
|
|
741
|
+
completed,
|
|
742
|
+
line: line.trim(),
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const notes = Array.from(noteMap.values())
|
|
747
|
+
return {
|
|
748
|
+
completedCount: notes.filter((n) => n.completed).length,
|
|
749
|
+
totalCount: notes.length,
|
|
750
|
+
notes,
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* 从 TOC 树构建 VitePress sidebar 结构
|
|
756
|
+
*/
|
|
757
|
+
export function buildSidebarFromTocTree(
|
|
758
|
+
tree: TocTreeNode[],
|
|
759
|
+
notes: NoteInfo[],
|
|
760
|
+
options: {
|
|
761
|
+
sidebarShowNoteId: boolean
|
|
762
|
+
sidebarIsCollapsed?: boolean
|
|
763
|
+
},
|
|
764
|
+
parentFolderPath: string[] = [],
|
|
765
|
+
): TocSidebarItem[] {
|
|
766
|
+
const collapsed = options.sidebarIsCollapsed ?? true
|
|
767
|
+
|
|
768
|
+
function mapNote(
|
|
769
|
+
node: TocNoteNode,
|
|
770
|
+
currentFolderPath: string[],
|
|
771
|
+
): TocSidebarItem | null {
|
|
772
|
+
const note = resolveNoteFromIndex(node.noteIndex, notes)
|
|
773
|
+
if (!note) return null
|
|
774
|
+
|
|
775
|
+
let statusEmoji = '⏰ '
|
|
776
|
+
if (note.config?.done) {
|
|
777
|
+
statusEmoji = '✅ '
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
let displayText = note.dirName
|
|
781
|
+
if (!options.sidebarShowNoteId) {
|
|
782
|
+
displayText = note.dirName.replace(/^\d{4}\.\s/, '')
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const childItems = node.children
|
|
786
|
+
.map((child) => mapNode(child, currentFolderPath))
|
|
787
|
+
.filter((item): item is TocSidebarItem => item !== null)
|
|
788
|
+
|
|
789
|
+
if (childItems.length > 0) {
|
|
790
|
+
const item: TocSidebarItem = {
|
|
791
|
+
text: statusEmoji + displayText,
|
|
792
|
+
link: `/notes/${note.dirName}/README`,
|
|
793
|
+
collapsed,
|
|
794
|
+
items: childItems,
|
|
795
|
+
tocLineIndex: node.tocLineIndex,
|
|
796
|
+
}
|
|
797
|
+
item.nodeId = computeSidebarNodeId(item)
|
|
798
|
+
return item
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const item: TocSidebarItem = {
|
|
802
|
+
text: statusEmoji + displayText,
|
|
803
|
+
link: `/notes/${note.dirName}/README`,
|
|
804
|
+
tocLineIndex: node.tocLineIndex,
|
|
805
|
+
}
|
|
806
|
+
item.nodeId = computeSidebarNodeId(item)
|
|
807
|
+
return item
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function mapFolder(
|
|
811
|
+
node: TocFolderNode,
|
|
812
|
+
folderPath: string[],
|
|
813
|
+
): TocSidebarItem | null {
|
|
814
|
+
const childItems = node.children
|
|
815
|
+
.map((child) => mapNode(child, folderPath))
|
|
816
|
+
.filter((item): item is TocSidebarItem => item !== null)
|
|
817
|
+
|
|
818
|
+
if (childItems.length === 0) {
|
|
819
|
+
const item: TocSidebarItem = {
|
|
820
|
+
text: node.title,
|
|
821
|
+
collapsed,
|
|
822
|
+
items: [],
|
|
823
|
+
folderPath,
|
|
824
|
+
tocLineIndex: node.tocLineIndex,
|
|
825
|
+
}
|
|
826
|
+
item.nodeId = computeSidebarNodeId(item)
|
|
827
|
+
return item
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const item: TocSidebarItem = {
|
|
831
|
+
text: node.title,
|
|
832
|
+
collapsed,
|
|
833
|
+
items: childItems,
|
|
834
|
+
folderPath,
|
|
835
|
+
tocLineIndex: node.tocLineIndex,
|
|
836
|
+
}
|
|
837
|
+
item.nodeId = computeSidebarNodeId(item)
|
|
838
|
+
return item
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function mapNode(
|
|
842
|
+
node: TocTreeNode,
|
|
843
|
+
currentFolderPath: string[],
|
|
844
|
+
): TocSidebarItem | null {
|
|
845
|
+
if (node.kind === 'folder') {
|
|
846
|
+
const folderPath = [...currentFolderPath, node.title]
|
|
847
|
+
return mapFolder(node, folderPath)
|
|
848
|
+
}
|
|
849
|
+
return mapNote(node, currentFolderPath)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return tree
|
|
853
|
+
.map((node) => mapNode(node, parentFolderPath))
|
|
854
|
+
.filter((item): item is TocSidebarItem => item !== null)
|
|
855
|
+
}
|