@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.
Files changed (62) hide show
  1. package/config/ConfigManager.ts +137 -0
  2. package/config/constants.ts +121 -0
  3. package/config/index.ts +25 -0
  4. package/config/templates.ts +49 -0
  5. package/core/GitManager.ts +513 -0
  6. package/core/NoteIndexCache.ts +194 -0
  7. package/core/NoteManager.ts +407 -0
  8. package/core/ProcessManager.ts +180 -0
  9. package/core/ReadmeGenerator.ts +215 -0
  10. package/core/TocGenerator.ts +212 -0
  11. package/core/index.ts +11 -0
  12. package/package.json +5 -2
  13. package/services/file-watcher/configChangeHandler.ts +64 -0
  14. package/services/file-watcher/eventScheduler.ts +179 -0
  15. package/services/file-watcher/folderChangeHandler.ts +325 -0
  16. package/services/file-watcher/fsWatcherAdapter.ts +128 -0
  17. package/services/file-watcher/globalUpdateCoordinator.ts +60 -0
  18. package/services/file-watcher/index.ts +7 -0
  19. package/services/file-watcher/internal.ts +79 -0
  20. package/services/file-watcher/readmeChangeHandler.ts +28 -0
  21. package/services/file-watcher/renameDetector.ts +120 -0
  22. package/services/file-watcher/service.ts +352 -0
  23. package/services/file-watcher/watchState.ts +194 -0
  24. package/services/git/index.ts +7 -0
  25. package/services/git/service.ts +114 -0
  26. package/services/index.ts +15 -0
  27. package/services/init-sub-repo/index.ts +2 -0
  28. package/services/init-sub-repo/initSubRepoLogic.test.ts +162 -0
  29. package/services/init-sub-repo/initSubRepoLogic.ts +304 -0
  30. package/services/init-sub-repo/service.ts +101 -0
  31. package/services/note/index.ts +7 -0
  32. package/services/note/service.ts +362 -0
  33. package/services/readme/index.ts +7 -0
  34. package/services/readme/service.ts +761 -0
  35. package/services/timestamp/index.ts +7 -0
  36. package/services/timestamp/service.ts +465 -0
  37. package/services/toc/index.ts +5 -0
  38. package/services/toc/moveTocInside.test.ts +73 -0
  39. package/services/toc/service.ts +759 -0
  40. package/services/vitepress/index.ts +7 -0
  41. package/services/vitepress/service.ts +339 -0
  42. package/utils/errorHandler.ts +174 -0
  43. package/utils/file.ts +17 -0
  44. package/utils/genHierarchicalSidebar.ts +69 -0
  45. package/utils/generateAnchor.ts +24 -0
  46. package/utils/getChangedIds.ts +35 -0
  47. package/utils/index.ts +71 -0
  48. package/utils/logger.ts +231 -0
  49. package/utils/markdown.ts +75 -0
  50. package/utils/migrateReadmeToToc.test.ts +111 -0
  51. package/utils/migrateReadmeToToc.ts +135 -0
  52. package/utils/parseArgs.ts +90 -0
  53. package/utils/parseReadmeCompletedNotes.test.ts +90 -0
  54. package/utils/parseReadmeCompletedNotes.ts +108 -0
  55. package/utils/portUtils.ts +113 -0
  56. package/utils/readmeHelpers.ts +190 -0
  57. package/utils/runCommand.ts +29 -0
  58. package/utils/tocHelpers.test.ts +278 -0
  59. package/utils/tocHelpers.ts +855 -0
  60. package/utils/tocNodeId.test.ts +60 -0
  61. package/utils/tocNodeId.ts +97 -0
  62. 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
+ }