@tnotesjs/core 0.2.0 → 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/dist/chunk-5QN44ES5.js +6050 -0
- package/dist/chunk-5Y5IYS6C.js +200 -0
- package/dist/cli/index.js +986 -0
- package/dist/index.js +8 -0
- package/dist/vitepress/config/index.js +1686 -0
- package/package.json +6 -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,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
|
+
}
|