@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,761 @@
1
+ /**
2
+ * services/ReadmeService.ts
3
+ *
4
+ * README 服务 - 封装 README 更新相关的业务逻辑
5
+ */
6
+
7
+ import {
8
+ existsSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ promises as fsPromises,
12
+ } from 'fs'
13
+
14
+ import { ConfigManager } from '../../config/ConfigManager'
15
+ import { ROOT_README_PATH, VP_SIDEBAR_PATH } from '../../config/constants'
16
+ import { NoteIndexCache } from '../../core/NoteIndexCache'
17
+ import { NoteManager } from '../../core/NoteManager'
18
+ import { ReadmeGenerator } from '../../core/ReadmeGenerator'
19
+ import {
20
+ parseNoteLine,
21
+ buildNoteLineMarkdown,
22
+ logger,
23
+ getChangedIds,
24
+ genHierarchicalSidebar,
25
+ processEmptyLines,
26
+ } from '../../utils'
27
+ import { TocService } from '../toc/service'
28
+
29
+ import type { NoteInfo, NoteConfig } from '../../types'
30
+
31
+ /**
32
+ * README 更新选项
33
+ */
34
+ interface UpdateReadmeOptions {
35
+ updateSidebar?: boolean
36
+ updateHome?: boolean
37
+ notes?: NoteInfo[]
38
+ }
39
+
40
+ interface GroupBlock {
41
+ headingIndex: number
42
+ endIndex: number
43
+ level: number
44
+ }
45
+
46
+ type NoteInsertPlacement = 'before' | 'after'
47
+
48
+ interface MoveNoteOptions {
49
+ targetGroupPath?: string[]
50
+ targetNoteIndex?: string
51
+ placement?: NoteInsertPlacement
52
+ }
53
+
54
+ /**
55
+ * README 服务类(单例)
56
+ */
57
+ export class ReadmeService {
58
+ private static instance: ReadmeService
59
+ private noteManager: NoteManager
60
+ private readmeGenerator: ReadmeGenerator
61
+ private configManager: ConfigManager
62
+ private noteIndexCache: NoteIndexCache
63
+ private tocService: TocService
64
+
65
+ private constructor() {
66
+ this.noteManager = NoteManager.getInstance()
67
+ this.readmeGenerator = new ReadmeGenerator()
68
+ this.configManager = ConfigManager.getInstance()
69
+ this.noteIndexCache = NoteIndexCache.getInstance()
70
+ this.tocService = TocService.getInstance()
71
+ }
72
+
73
+ static getInstance(): ReadmeService {
74
+ if (!ReadmeService.instance) {
75
+ ReadmeService.instance = new ReadmeService()
76
+ }
77
+ return ReadmeService.instance
78
+ }
79
+
80
+ /**
81
+ * 更新所有笔记的 README
82
+ * @param options - 更新选项
83
+ */
84
+ async updateAllReadmes(options: UpdateReadmeOptions = {}): Promise<void> {
85
+ const {
86
+ updateSidebar = true,
87
+ updateHome = false,
88
+ notes: providedNotes,
89
+ } = options
90
+
91
+ logger.info('开始更新知识库...')
92
+
93
+ // 使用传入的笔记列表或重新扫描
94
+ const notes = providedNotes ?? this.noteManager.scanNotes()
95
+ logger.info(`扫描到 ${notes.length} 篇笔记`)
96
+
97
+ // 检测变更的笔记(增量更新优化)
98
+ const changedIndexes = await this.getChangedNoteIndexes()
99
+ const shouldIncrementalUpdate =
100
+ changedIndexes.size > 0 && changedIndexes.size < notes.length * 0.3 // 少于30%变更才增量更新
101
+
102
+ let notesToUpdate = notes
103
+ if (shouldIncrementalUpdate) {
104
+ notesToUpdate = notes.filter((note) => changedIndexes.has(note.index))
105
+ logger.info(
106
+ `检测到 ${changedIndexes.size} 篇笔记有变更,使用增量更新模式`,
107
+ )
108
+ } else {
109
+ logger.info('使用全量更新模式')
110
+ }
111
+
112
+ // 并行更新笔记的 README
113
+ const startTime = Date.now()
114
+ await this.updateNoteReadmesInParallel(notesToUpdate)
115
+ const updateTime = Date.now() - startTime
116
+
117
+ logger.info(`更新了 ${notesToUpdate.length} 篇笔记 (耗时 ${updateTime}ms)`)
118
+
119
+ // 更新首页 README(可选,目录已迁移至 TOC.md)
120
+ if (updateHome) {
121
+ await this.updateHomeReadme(notes)
122
+ }
123
+
124
+ // 规范化 TOC.md 并更新 sidebar.json
125
+ if (updateSidebar) {
126
+ await this.tocService.normalizeToc(notes)
127
+ await this.tocService.regenerateSidebar(notes)
128
+ }
129
+
130
+ logger.info('知识库更新完成!')
131
+ }
132
+
133
+ /**
134
+ * 只更新指定笔记的 README(不更新 sidebar、home)
135
+ * @param noteIndexes - 笔记索引数组,例如 ['0001', '0002']
136
+ */
137
+ async updateNoteReadmesOnly(noteIndexes: string[]): Promise<void> {
138
+ if (noteIndexes.length === 0) return
139
+
140
+ // 直接根据 ID 获取笔记信息,避免扫描所有笔记
141
+ const notesToUpdate: NoteInfo[] = []
142
+
143
+ for (const noteIndex of noteIndexes) {
144
+ const note = this.noteManager.getNoteByIndex(noteIndex)
145
+ if (note) {
146
+ notesToUpdate.push(note)
147
+ } else {
148
+ logger.warn(`笔记未找到: ${noteIndex}`)
149
+ }
150
+ }
151
+
152
+ if (notesToUpdate.length === 0) {
153
+ logger.warn('没有找到需要更新的笔记')
154
+ return
155
+ }
156
+
157
+ // 只更新笔记的 README 内容(TOC 等)
158
+ for (const note of notesToUpdate) {
159
+ try {
160
+ this.readmeGenerator.updateNoteReadme(note)
161
+ } catch (error) {
162
+ logger.error(`更新笔记 ${note.dirName} 失败`, error)
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 获取变更的笔记索引集合
169
+ * @returns 变更的笔记索引集合
170
+ */
171
+ private async getChangedNoteIndexes(): Promise<Set<string>> {
172
+ try {
173
+ return getChangedIds()
174
+ } catch (error) {
175
+ // 如果获取失败(比如不在 Git 仓库中),返回空集合,触发全量更新
176
+ return new Set()
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 并行更新多个笔记的 README
182
+ * @param notes - 笔记信息数组
183
+ */
184
+ private async updateNoteReadmesInParallel(notes: NoteInfo[]): Promise<void> {
185
+ const batchSize = 10 // 每批处理 10 个,避免过多并发
186
+ const batches: NoteInfo[][] = []
187
+
188
+ for (let i = 0; i < notes.length; i += batchSize) {
189
+ batches.push(notes.slice(i, i + batchSize))
190
+ }
191
+
192
+ let successCount = 0
193
+ let failCount = 0
194
+
195
+ for (const batch of batches) {
196
+ const results = await Promise.allSettled(
197
+ batch.map((note) =>
198
+ Promise.resolve().then(() => {
199
+ this.readmeGenerator.updateNoteReadme(note)
200
+ }),
201
+ ),
202
+ )
203
+
204
+ for (const result of results) {
205
+ if (result.status === 'fulfilled') {
206
+ successCount++
207
+ } else {
208
+ failCount++
209
+ logger.error('更新笔记失败', result.reason)
210
+ }
211
+ }
212
+ }
213
+
214
+ if (failCount > 0) {
215
+ logger.warn(`更新完成:成功 ${successCount} 篇,失败 ${failCount} 篇`)
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 更新侧边栏配置
221
+ * @param notes - 笔记信息数组
222
+ */
223
+ private async updateSidebar(notes: NoteInfo[]): Promise<void> {
224
+ // 读取 README.md 解析层次结构
225
+ if (!existsSync(ROOT_README_PATH)) {
226
+ logger.error('未找到首页 README,无法生成侧边栏')
227
+ return
228
+ }
229
+
230
+ const content = readFileSync(ROOT_README_PATH, 'utf-8')
231
+ const lines = content.split('\n')
232
+
233
+ // 解析 README.md 的层次结构
234
+
235
+ const itemList: Array<{ text: string; link: string }> = []
236
+ const titles: string[] = []
237
+ const titlesNotesCount: number[] = []
238
+
239
+ let currentNoteCount = 0
240
+ let inTocRegion = false
241
+
242
+ for (const line of lines) {
243
+ // 跳过 toc region
244
+ if (line.includes('<!-- region:toc -->')) {
245
+ inTocRegion = true
246
+ continue
247
+ }
248
+ if (line.includes('<!-- endregion:toc -->')) {
249
+ inTocRegion = false
250
+ continue
251
+ }
252
+ if (inTocRegion) {
253
+ continue
254
+ }
255
+
256
+ // 匹配笔记链接: - [x] [0001. xxx](https://github.com/...)
257
+ const parsed = parseNoteLine(line)
258
+ if (parsed.isMatch && parsed.noteIndex) {
259
+ // 通过笔记索引查找对应的笔记信息
260
+ const note = notes.find((n) => n.index === parsed.noteIndex)
261
+ if (!note) {
262
+ logger.warn(`未找到笔记索引: ${parsed.noteIndex}`)
263
+ continue
264
+ }
265
+
266
+ // 获取笔记配置,添加状态 emoji
267
+ let statusEmoji = '⏰ ' // 默认未完成
268
+ if (note?.config) {
269
+ if (note.config.done) {
270
+ statusEmoji = '✅ '
271
+ }
272
+ }
273
+
274
+ // 处理笔记 ID 显示
275
+ const sidebarShowNoteId = this.configManager.get('sidebarShowNoteId')
276
+ let displayText = note.dirName
277
+ if (!sidebarShowNoteId) {
278
+ // 移除笔记 ID (0001. )
279
+ displayText = note.dirName.replace(/^\d{4}\.\s/, '')
280
+ }
281
+
282
+ itemList.push({
283
+ text: statusEmoji + displayText,
284
+ link: `/notes/${note.dirName}/README`,
285
+ })
286
+ currentNoteCount++
287
+ continue
288
+ }
289
+
290
+ // 匹配标题: ## xxx
291
+ const titleMatch = line.match(/^(#{2,})\s+(.+)$/)
292
+ if (titleMatch) {
293
+ // 保存上一个标题的笔记数量
294
+ if (titles.length > 0) {
295
+ titlesNotesCount.push(currentNoteCount)
296
+ }
297
+
298
+ titles.push(line)
299
+ currentNoteCount = 0
300
+ }
301
+ }
302
+
303
+ // 保存最后一个标题的笔记数量
304
+ if (titles.length > 0) {
305
+ titlesNotesCount.push(currentNoteCount)
306
+ }
307
+
308
+ // Sidebar 默认全部折叠
309
+ const sidebarIsCollapsed = true
310
+ const hierarchicalSidebar = genHierarchicalSidebar(
311
+ itemList,
312
+ titles,
313
+ titlesNotesCount,
314
+ sidebarIsCollapsed,
315
+ )
316
+
317
+ // 写入 sidebar.json
318
+ writeFileSync(
319
+ VP_SIDEBAR_PATH,
320
+ JSON.stringify(hierarchicalSidebar, null, 2),
321
+ 'utf-8',
322
+ )
323
+
324
+ logger.info('已更新侧边栏配置')
325
+ }
326
+
327
+ /**
328
+ * 更新首页 README
329
+ * @param notes - 笔记信息数组
330
+ */
331
+ private async updateHomeReadme(notes: NoteInfo[]): Promise<void> {
332
+ this.readmeGenerator.updateHomeReadme(notes, ROOT_README_PATH)
333
+ }
334
+
335
+ /**
336
+ * 增量更新首页 README 中的单个笔记
337
+ * @param noteIndex - 笔记索引
338
+ * @param updates - 需要更新的配置字段
339
+ */
340
+ async updateNoteInReadme(
341
+ noteIndex: string,
342
+ updates: Partial<NoteConfig>,
343
+ ): Promise<void> {
344
+ const item = this.noteIndexCache.getByNoteIndex(noteIndex)
345
+ if (!item) {
346
+ logger.warn(`尝试更新不存在的笔记: ${noteIndex}`)
347
+ return
348
+ }
349
+
350
+ // 读取 README.md
351
+ const content = await fsPromises.readFile(ROOT_README_PATH, 'utf-8')
352
+ const lines = content.split('\n')
353
+ const repoOwner = this.configManager.get('author')
354
+ const repoName = this.configManager.get('repoName')
355
+
356
+ // 合并缓存配置和传入的更新
357
+ const mergedConfig = { ...item.noteConfig, ...updates }
358
+
359
+ // 构建一个临时的 NoteInfo 对象用于生成 markdown
360
+ const tempNoteInfo: NoteInfo = {
361
+ index: noteIndex,
362
+ dirName: item.folderName,
363
+ path: '',
364
+ readmePath: '',
365
+ configPath: '',
366
+ config: mergedConfig,
367
+ }
368
+
369
+ let updated = false
370
+
371
+ // 遍历所有行,更新所有引用该笔记的地方
372
+ for (let i = 0; i < lines.length; i++) {
373
+ const parsed = parseNoteLine(lines[i])
374
+ if (parsed.noteIndex === noteIndex) {
375
+ lines[i] = buildNoteLineMarkdown(tempNoteInfo, repoOwner, repoName)
376
+ updated = true
377
+ }
378
+ }
379
+
380
+ if (updated) {
381
+ await fsPromises.writeFile(ROOT_README_PATH, lines.join('\n'), 'utf-8')
382
+ logger.info(`增量更新 README.md 中的笔记: ${noteIndex}`)
383
+ } else {
384
+ logger.warn(`README.md 中未找到笔记: ${noteIndex}`)
385
+ }
386
+ }
387
+
388
+ /**
389
+ * 从首页 README 中删除笔记
390
+ * @param noteIndex - 笔记索引
391
+ */
392
+ async deleteNoteFromReadme(noteIndex: string): Promise<void> {
393
+ const content = await fsPromises.readFile(ROOT_README_PATH, 'utf-8')
394
+ const lines = content.split('\n')
395
+ const linesToRemove: number[] = []
396
+
397
+ // 查找所有引用该笔记的行
398
+ for (let i = 0; i < lines.length; i++) {
399
+ const parsed = parseNoteLine(lines[i])
400
+ if (parsed.noteIndex === noteIndex) {
401
+ linesToRemove.push(i)
402
+ }
403
+ }
404
+
405
+ if (linesToRemove.length > 0) {
406
+ // 从后往前删除,避免索引问题
407
+ for (let i = linesToRemove.length - 1; i >= 0; i--) {
408
+ lines.splice(linesToRemove[i], 1)
409
+ }
410
+
411
+ await fsPromises.writeFile(ROOT_README_PATH, lines.join('\n'), 'utf-8')
412
+ logger.info(
413
+ `从 README.md 中删除笔记: ${noteIndex} (${linesToRemove.length} 处引用)`,
414
+ )
415
+ } else {
416
+ logger.warn(`README.md 中未找到笔记: ${noteIndex}`)
417
+ }
418
+ }
419
+
420
+ /**
421
+ * 在首页 README 末尾添加新笔记
422
+ * @param noteIndex - 笔记索引
423
+ */
424
+ async appendNoteToReadme(noteIndex: string): Promise<void> {
425
+ const item = this.noteIndexCache.getByNoteIndex(noteIndex)
426
+ if (!item) {
427
+ logger.warn(`尝试添加不存在的笔记: ${noteIndex}`)
428
+ return
429
+ }
430
+
431
+ const content = await fsPromises.readFile(ROOT_README_PATH, 'utf-8')
432
+ const lines = content.split('\n')
433
+ const repoOwner = this.configManager.get('author')
434
+ const repoName = this.configManager.get('repoName')
435
+
436
+ // 构建临时 NoteInfo
437
+ const tempNoteInfo: NoteInfo = {
438
+ index: noteIndex,
439
+ dirName: item.folderName,
440
+ path: '',
441
+ readmePath: '',
442
+ configPath: '',
443
+ config: item.noteConfig,
444
+ }
445
+
446
+ // 在末尾添加笔记行
447
+ const noteLine = buildNoteLineMarkdown(tempNoteInfo, repoOwner, repoName)
448
+ lines.push(noteLine)
449
+
450
+ await fsPromises.writeFile(ROOT_README_PATH, lines.join('\n'), 'utf-8')
451
+ logger.info(`在 README.md 末尾添加笔记: ${noteIndex}`)
452
+ }
453
+
454
+ /**
455
+ * 重命名根 README 中的目录标题
456
+ */
457
+ async renameGroupInReadme(
458
+ groupPath: string[],
459
+ newTitle: string,
460
+ ): Promise<void> {
461
+ this.assertGroupPath(groupPath)
462
+ const cleanTitle = this.cleanHeadingTitle(newTitle)
463
+
464
+ const lines = await this.readHomeReadmeLines()
465
+ const block = this.findGroupBlock(lines, groupPath)
466
+ const headingMark = '#'.repeat(block.level)
467
+ lines[block.headingIndex] = `${headingMark} ${cleanTitle}`
468
+
469
+ await this.writeHomeReadmeLines(lines)
470
+ logger.info(`重命名目录: ${groupPath.join(' / ')} -> ${cleanTitle}`)
471
+ }
472
+
473
+ /**
474
+ * 删除根 README 中的目录块,返回块内所有笔记编号
475
+ */
476
+ async deleteGroupFromReadme(groupPath: string[]): Promise<string[]> {
477
+ this.assertGroupPath(groupPath)
478
+
479
+ const lines = await this.readHomeReadmeLines()
480
+ const block = this.findGroupBlock(lines, groupPath)
481
+ const noteIndexes = this.collectNoteIndexes(
482
+ lines,
483
+ block.headingIndex,
484
+ block.endIndex,
485
+ )
486
+
487
+ lines.splice(block.headingIndex, block.endIndex - block.headingIndex)
488
+ await this.writeHomeReadmeLines(lines)
489
+
490
+ logger.info(
491
+ `删除目录: ${groupPath.join(' / ')} (${noteIndexes.length} 篇笔记)`,
492
+ )
493
+
494
+ return noteIndexes
495
+ }
496
+
497
+ /**
498
+ * 在目录开头插入笔记链接
499
+ */
500
+ async insertNotesAtGroupStart(
501
+ groupPath: string[],
502
+ notes: NoteInfo[],
503
+ ): Promise<void> {
504
+ this.assertGroupPath(groupPath)
505
+ if (notes.length === 0) return
506
+
507
+ const lines = await this.readHomeReadmeLines()
508
+ const block = this.findGroupBlock(lines, groupPath)
509
+ const noteLines = this.buildNoteLines(notes)
510
+ let insertIndex = block.headingIndex + 1
511
+
512
+ while (insertIndex < block.endIndex && lines[insertIndex].trim() === '') {
513
+ insertIndex++
514
+ }
515
+
516
+ lines.splice(insertIndex, 0, ...noteLines)
517
+ await this.writeHomeReadmeLines(lines)
518
+
519
+ logger.info(
520
+ `在目录开头插入笔记: ${groupPath.join(' / ')} (${notes.length} 篇)`,
521
+ )
522
+ }
523
+
524
+ /**
525
+ * 在指定笔记上方或下方插入笔记链接
526
+ */
527
+ async insertNotesAroundNote(
528
+ targetNoteIndex: string,
529
+ notes: NoteInfo[],
530
+ placement: NoteInsertPlacement,
531
+ ): Promise<void> {
532
+ if (notes.length === 0) return
533
+
534
+ const lines = await this.readHomeReadmeLines()
535
+ const noteLineIndex = this.findNoteLineIndex(lines, targetNoteIndex)
536
+ const insertIndex =
537
+ placement === 'before' ? noteLineIndex : noteLineIndex + 1
538
+ const noteLines = this.buildNoteLines(notes)
539
+
540
+ lines.splice(insertIndex, 0, ...noteLines)
541
+ await this.writeHomeReadmeLines(lines)
542
+
543
+ logger.info(
544
+ `在笔记 ${targetNoteIndex} ${placement === 'before' ? '上方' : '下方'}插入 ${notes.length} 篇笔记`,
545
+ )
546
+ }
547
+
548
+ /**
549
+ * 移动笔记引用到目录开头,或移动到另一篇笔记前后
550
+ */
551
+ async moveNoteInReadme(
552
+ noteIndex: string,
553
+ options: MoveNoteOptions,
554
+ ): Promise<void> {
555
+ const lines = await this.readHomeReadmeLines()
556
+ const sourceIndex = this.findNoteLineIndex(lines, noteIndex)
557
+ const [noteLine] = lines.splice(sourceIndex, 1)
558
+
559
+ if (options.targetGroupPath) {
560
+ const block = this.findGroupBlock(lines, options.targetGroupPath)
561
+ let insertIndex = block.headingIndex + 1
562
+
563
+ while (insertIndex < block.endIndex && lines[insertIndex].trim() === '') {
564
+ insertIndex++
565
+ }
566
+
567
+ lines.splice(insertIndex, 0, noteLine)
568
+ await this.writeHomeReadmeLines(lines)
569
+ return
570
+ }
571
+
572
+ if (!options.targetNoteIndex) {
573
+ throw new Error('缺少目标笔记或目标目录')
574
+ }
575
+ if (options.targetNoteIndex === noteIndex) {
576
+ throw new Error('不能把笔记移动到自身附近')
577
+ }
578
+
579
+ const targetIndex = this.findNoteLineIndex(lines, options.targetNoteIndex)
580
+ const insertIndex =
581
+ options.placement === 'after' ? targetIndex + 1 : targetIndex
582
+ lines.splice(insertIndex, 0, noteLine)
583
+ await this.writeHomeReadmeLines(lines)
584
+ }
585
+
586
+ /**
587
+ * 移动目录块到同级目录前后
588
+ */
589
+ async moveGroupInReadme(
590
+ groupPath: string[],
591
+ targetGroupPath: string[],
592
+ placement: NoteInsertPlacement = 'before',
593
+ ): Promise<void> {
594
+ this.assertGroupPath(groupPath)
595
+ this.assertGroupPath(targetGroupPath)
596
+
597
+ if (this.isSamePath(groupPath, targetGroupPath)) {
598
+ throw new Error('不能把目录移动到自身附近')
599
+ }
600
+ if (groupPath.length !== targetGroupPath.length) {
601
+ throw new Error('第一版拖拽仅支持同级目录排序')
602
+ }
603
+
604
+ const lines = await this.readHomeReadmeLines()
605
+ const sourceBlock = this.findGroupBlock(lines, groupPath)
606
+ const movingLines = lines.splice(
607
+ sourceBlock.headingIndex,
608
+ sourceBlock.endIndex - sourceBlock.headingIndex,
609
+ )
610
+ const targetBlock = this.findGroupBlock(lines, targetGroupPath)
611
+ const insertIndex =
612
+ placement === 'after' ? targetBlock.endIndex : targetBlock.headingIndex
613
+
614
+ lines.splice(insertIndex, 0, ...movingLines)
615
+ await this.writeHomeReadmeLines(lines)
616
+ }
617
+
618
+ /**
619
+ * 更新 TOC.md 和 sidebar,不重写每篇笔记 README
620
+ */
621
+ async refreshHomeReadmeAndSidebar(notes?: NoteInfo[]): Promise<void> {
622
+ const allNotes =
623
+ notes ??
624
+ (this.noteIndexCache.isInitialized()
625
+ ? this.noteIndexCache.toNoteInfoList()
626
+ : this.noteManager.scanNotes())
627
+
628
+ await this.tocService.normalizeToc(allNotes)
629
+ await this.tocService.regenerateSidebar(allNotes)
630
+ }
631
+
632
+ /**
633
+ * 重新生成 sidebar.json(基于当前 TOC.md)
634
+ * @param notes - 可选的笔记列表,不传则内部扫描
635
+ */
636
+ async regenerateSidebar(notes?: NoteInfo[]): Promise<void> {
637
+ await this.tocService.regenerateSidebar(notes)
638
+ logger.info('重新生成 sidebar.json')
639
+ }
640
+
641
+ private async readHomeReadmeLines(): Promise<string[]> {
642
+ const content = await fsPromises.readFile(ROOT_README_PATH, 'utf-8')
643
+ return content.split('\n')
644
+ }
645
+
646
+ private async writeHomeReadmeLines(lines: string[]): Promise<void> {
647
+ const content = processEmptyLines(lines).join('\n')
648
+ await fsPromises.writeFile(ROOT_README_PATH, content, 'utf-8')
649
+ }
650
+
651
+ private assertGroupPath(groupPath: string[]): void {
652
+ if (!Array.isArray(groupPath) || groupPath.length === 0) {
653
+ throw new Error('目录路径不能为空')
654
+ }
655
+ }
656
+
657
+ private cleanHeadingTitle(title: string): string {
658
+ const cleanTitle = title.trim().replace(/^#+\s*/, '')
659
+ if (!cleanTitle) {
660
+ throw new Error('目录名称不能为空')
661
+ }
662
+ if (/\r|\n/.test(cleanTitle)) {
663
+ throw new Error('目录名称不能包含换行')
664
+ }
665
+ return cleanTitle
666
+ }
667
+
668
+ private getHeadingInfo(line: string): { level: number; text: string } | null {
669
+ const match = line.match(/^(#{2,})\s+(.+)$/)
670
+ if (!match) return null
671
+
672
+ return {
673
+ level: match[1].length,
674
+ text: match[2].trim(),
675
+ }
676
+ }
677
+
678
+ private findGroupBlock(lines: string[], groupPath: string[]): GroupBlock {
679
+ const stack: Array<{ level: number; text: string }> = []
680
+
681
+ for (let i = 0; i < lines.length; i++) {
682
+ const heading = this.getHeadingInfo(lines[i])
683
+ if (!heading) continue
684
+
685
+ while (
686
+ stack.length > 0 &&
687
+ stack[stack.length - 1].level >= heading.level
688
+ ) {
689
+ stack.pop()
690
+ }
691
+
692
+ stack.push(heading)
693
+
694
+ const currentPath = stack.map((item) => item.text)
695
+ if (this.isSamePath(currentPath, groupPath)) {
696
+ return {
697
+ headingIndex: i,
698
+ endIndex: this.findGroupEndIndex(lines, i, heading.level),
699
+ level: heading.level,
700
+ }
701
+ }
702
+ }
703
+
704
+ throw new Error(`未找到目录: ${groupPath.join(' / ')}`)
705
+ }
706
+
707
+ private findGroupEndIndex(
708
+ lines: string[],
709
+ headingIndex: number,
710
+ level: number,
711
+ ): number {
712
+ for (let i = headingIndex + 1; i < lines.length; i++) {
713
+ const heading = this.getHeadingInfo(lines[i])
714
+ if (heading && heading.level <= level) {
715
+ return i
716
+ }
717
+ }
718
+
719
+ return lines.length
720
+ }
721
+
722
+ private isSamePath(left: string[], right: string[]): boolean {
723
+ if (left.length !== right.length) return false
724
+
725
+ return left.every((item, index) => item === right[index])
726
+ }
727
+
728
+ private collectNoteIndexes(
729
+ lines: string[],
730
+ startIndex: number,
731
+ endIndex: number,
732
+ ): string[] {
733
+ const noteIndexes: string[] = []
734
+
735
+ for (let i = startIndex; i < endIndex; i++) {
736
+ const parsed = parseNoteLine(lines[i])
737
+ if (parsed.noteIndex) {
738
+ noteIndexes.push(parsed.noteIndex)
739
+ }
740
+ }
741
+
742
+ return [...new Set(noteIndexes)]
743
+ }
744
+
745
+ private findNoteLineIndex(lines: string[], noteIndex: string): number {
746
+ for (let i = 0; i < lines.length; i++) {
747
+ const parsed = parseNoteLine(lines[i])
748
+ if (parsed.noteIndex === noteIndex) {
749
+ return i
750
+ }
751
+ }
752
+
753
+ throw new Error(`README.md 中未找到笔记: ${noteIndex}`)
754
+ }
755
+
756
+ private buildNoteLines(notes: NoteInfo[]): string[] {
757
+ const repoOwner = this.configManager.get('author')
758
+ const repoName = this.configManager.get('repoName')
759
+ return notes.map((note) => buildNoteLineMarkdown(note, repoOwner, repoName))
760
+ }
761
+ }