@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,407 @@
1
+ /**
2
+ * core/NoteManager.ts
3
+ *
4
+ * 笔记管理器 - 负责笔记的扫描、验证和基本操作
5
+ */
6
+
7
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
8
+ import { join } from 'path'
9
+
10
+ import { NOTES_PATH } from '../config/constants'
11
+ import { logger } from '../utils'
12
+
13
+ import type { NoteInfo, NoteConfig } from '../types'
14
+
15
+ /**
16
+ * 笔记管理器类(单例)
17
+ */
18
+ export class NoteManager {
19
+ private static instance: NoteManager
20
+
21
+ /** 笔记索引正则:4 位数字开头,后接小数点 */
22
+ private static readonly NOTE_INDEX_REGEX = /^(\d{4})\./
23
+
24
+ private constructor() {}
25
+
26
+ /**
27
+ * 从文件夹名称或文本中提取笔记索引
28
+ *
29
+ * @param text - 要解析的文本(通常是文件夹名称)
30
+ * @returns 笔记索引(4 位数字字符串)或 null
31
+ *
32
+ * @example
33
+ * NoteManager.extractNoteIndex('0001. TNotes 简介') // '0001'
34
+ * NoteManager.extractNoteIndex('invalid-folder') // null
35
+ */
36
+ static extractNoteIndex(text: string): string | null {
37
+ const match = text.match(NoteManager.NOTE_INDEX_REGEX)
38
+ return match ? match[1] : null
39
+ }
40
+
41
+ /**
42
+ * 输出无效笔记名称的警告日志
43
+ *
44
+ * @param name - 无效的笔记名称
45
+ */
46
+ static warnInvalidNoteIndex(name: string): void {
47
+ logger.warn(`无效的笔记名: ${name}`)
48
+ logger.warn('笔记名必须以 4 个数字开头')
49
+ logger.warn('范围:0001-9999')
50
+ }
51
+
52
+ static getInstance(): NoteManager {
53
+ if (!NoteManager.instance) {
54
+ NoteManager.instance = new NoteManager()
55
+ }
56
+ return NoteManager.instance
57
+ }
58
+
59
+ /**
60
+ * 获取 notes 目录下所有合法的笔记目录名(已排序)
61
+ * 合法条件:是目录、不以 . 开头、以 4 位数字 + . 开头
62
+ */
63
+ private getNoteDirs(): string[] {
64
+ if (!existsSync(NOTES_PATH)) return []
65
+
66
+ return readdirSync(NOTES_PATH, { withFileTypes: true })
67
+ .filter(
68
+ (entry) =>
69
+ entry.isDirectory() &&
70
+ !entry.name.startsWith('.') &&
71
+ NoteManager.NOTE_INDEX_REGEX.test(entry.name),
72
+ )
73
+ .map((entry) => entry.name)
74
+ .sort()
75
+ }
76
+
77
+ /**
78
+ * 根据目录名构建单条 NoteInfo
79
+ * @returns NoteInfo 或 undefined(README 不存在时)
80
+ */
81
+ private buildNoteInfo(dirName: string): NoteInfo | undefined {
82
+ const notePath = join(NOTES_PATH, dirName)
83
+ const readmePath = join(notePath, 'README.md')
84
+ const configPath = join(notePath, '.tnotes.json')
85
+
86
+ if (!existsSync(readmePath)) {
87
+ logger.warn(`README not found in note: ${dirName}`)
88
+ return undefined
89
+ }
90
+
91
+ let config: NoteConfig | undefined
92
+ if (existsSync(configPath)) {
93
+ config = this.validateAndFixConfig(configPath) || undefined
94
+ }
95
+
96
+ return {
97
+ index: NoteManager.extractNoteIndex(dirName)!,
98
+ path: notePath,
99
+ dirName,
100
+ readmePath,
101
+ configPath,
102
+ config,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 扫描所有笔记并校验数据完整性
108
+ *
109
+ * @returns 笔记信息数组
110
+ */
111
+ scanNotes(): NoteInfo[] {
112
+ const noteDirs = this.getNoteDirs()
113
+ if (noteDirs.length === 0) {
114
+ logger.warn(`${NOTES_PATH} 未检测到笔记目录`)
115
+ return []
116
+ }
117
+
118
+ const notes: NoteInfo[] = []
119
+ for (const dirName of noteDirs) {
120
+ const note = this.buildNoteInfo(dirName)
121
+ if (note) notes.push(note)
122
+ }
123
+
124
+ this.validateNotes(notes)
125
+
126
+ return notes
127
+ }
128
+
129
+ /**
130
+ * 校验笔记数据完整性
131
+ *
132
+ * - 检查 noteIndex 冲突 + config id 缺失/重复
133
+ * - 任一检查失败则终止进程
134
+ */
135
+ private validateNotes(notes: NoteInfo[]): void {
136
+ const errors: string[] = []
137
+ const L1 = ' '.repeat(3)
138
+ const L2 = ' '.repeat(6)
139
+
140
+ // 检查 noteIndex 冲突
141
+ const indexMap = this.buildNoteIndexMap(notes.map((n) => n.dirName))
142
+ for (const [index, dirNames] of indexMap.entries()) {
143
+ if (dirNames.length > 1) {
144
+ errors.push(`⚠️ 检测到重复的笔记编号:`)
145
+ errors.push(`${L1}索引 ${index} 被以下笔记重复使用:`)
146
+ dirNames.forEach((dirName) => errors.push(`${L2}- ${dirName}`))
147
+ }
148
+ }
149
+
150
+ // 检查 config id 缺失
151
+ const missingConfigId: string[] = []
152
+ for (const note of notes) {
153
+ if (!note.config || !note.config.id) {
154
+ missingConfigId.push(note.dirName)
155
+ }
156
+ }
157
+ if (missingConfigId.length > 0) {
158
+ errors.push(`⚠️ 检测到笔记配置 ID 缺失:`)
159
+ missingConfigId.forEach((dirName) => errors.push(`${L2}- ${dirName}`))
160
+ }
161
+
162
+ // 检查 config id 重复
163
+ const configIdMap = new Map<string, string[]>()
164
+ for (const note of notes) {
165
+ if (note.config?.id) {
166
+ if (!configIdMap.has(note.config.id))
167
+ configIdMap.set(note.config.id, [])
168
+ configIdMap.get(note.config.id)!.push(note.dirName)
169
+ }
170
+ }
171
+ for (const [configId, dirNames] of configIdMap.entries()) {
172
+ if (dirNames.length > 1) {
173
+ errors.push(`⚠️ 检测到重复的笔记配置 ID:`)
174
+ errors.push(`${L1}配置 ID ${configId} 被以下笔记重复使用:`)
175
+ dirNames.forEach((dirName) => errors.push(`${L2}- ${dirName}`))
176
+ }
177
+ }
178
+
179
+ if (errors.length > 0) {
180
+ for (const line of errors) {
181
+ logger.error(line)
182
+ }
183
+ logger.error('\n\n请修复上述问题后重新启动服务。\n\n')
184
+ process.exit(1)
185
+ }
186
+ }
187
+
188
+ /**
189
+ * 按 4 位数字编号对目录名分组
190
+ * @param dirNames - 目录名数组
191
+ * @returns 编号 -> 目录名数组 的映射
192
+ */
193
+ private buildNoteIndexMap(dirNames: string[]): Map<string, string[]> {
194
+ const indexMap = new Map<string, string[]>()
195
+ for (const name of dirNames) {
196
+ const index = NoteManager.extractNoteIndex(name)!
197
+ if (!indexMap.has(index)) indexMap.set(index, [])
198
+ indexMap.get(index)!.push(name)
199
+ }
200
+ return indexMap
201
+ }
202
+
203
+ /** 配置字段顺序 */
204
+ private static readonly FIELD_ORDER: readonly string[] = [
205
+ 'bilibili',
206
+ 'tnotes',
207
+ 'yuque',
208
+ 'done',
209
+ 'category',
210
+ 'enableDiscussions',
211
+ 'description',
212
+ 'id',
213
+ 'created_at',
214
+ 'updated_at',
215
+ ]
216
+
217
+ /** 默认配置字段 */
218
+ private static readonly DEFAULT_CONFIG_FIELDS = {
219
+ bilibili: [],
220
+ tnotes: [],
221
+ yuque: [],
222
+ done: false,
223
+ enableDiscussions: false,
224
+ description: '',
225
+ } as const
226
+
227
+ /** 必需字段(不能缺失) */
228
+ private static readonly REQUIRED_FIELDS = ['id'] as const
229
+
230
+ /**
231
+ * 验证并修复配置文件
232
+ * @param configPath - 配置文件路径
233
+ * @returns 修复后的配置对象,失败时返回 null
234
+ */
235
+ private validateAndFixConfig(configPath: string): NoteConfig | null {
236
+ const configContent = readFileSync(configPath, 'utf-8')
237
+ let config: Partial<NoteConfig>
238
+
239
+ try {
240
+ config = JSON.parse(configContent)
241
+ } catch (error) {
242
+ logger.error(`配置文件 JSON 解析失败: ${configPath}`, error)
243
+ return null
244
+ }
245
+
246
+ let needsUpdate = false
247
+
248
+ // 1. 检查必需字段 —— 缺失时直接返回 null,由 validateNotes() 统一报告
249
+ for (const field of NoteManager.REQUIRED_FIELDS) {
250
+ if (!config[field]) {
251
+ return null
252
+ }
253
+ }
254
+
255
+ // 2. 补充缺失的可选字段
256
+ for (const [key, defaultValue] of Object.entries(
257
+ NoteManager.DEFAULT_CONFIG_FIELDS,
258
+ )) {
259
+ if (!(key in config)) {
260
+ ;(config as Record<string, unknown>)[key] = defaultValue
261
+ needsUpdate = true
262
+ logger.info(`补充缺失字段 "${key}": ${configPath}`)
263
+ }
264
+ }
265
+
266
+ // 3. 确保时间戳字段存在
267
+ // 这里仅用 Date.now() 占位,确保字段不缺失。
268
+ // 真实的 git 时间戳由 tn:fix-timestamps 命令统一校准。
269
+ const now = Date.now()
270
+ if (!config.created_at) {
271
+ config.created_at = now
272
+ needsUpdate = true
273
+ logger.info(
274
+ `检测到 ${configPath} 缺失 created_at 字段,请执行 tn:fix-timestamps 校准为笔记首次 git commit 的时间)`,
275
+ )
276
+ }
277
+ if (!config.updated_at) {
278
+ config.updated_at = now
279
+ needsUpdate = true
280
+ logger.info(
281
+ `检测到 ${configPath} 缺失 updated_at 字段,请执行 tn:fix-timestamps 校准为笔记最后一次 git commit 的时间)`,
282
+ )
283
+ }
284
+
285
+ // 4. 按字段顺序排序
286
+ const sortedConfig = this.sortConfigKeys(config as NoteConfig)
287
+
288
+ // 5. 写回文件(如果有变更)
289
+ if (needsUpdate) {
290
+ this.writeNoteConfig(configPath, sortedConfig)
291
+ logger.info(`配置文件已修复: ${configPath}`)
292
+ }
293
+
294
+ return sortedConfig
295
+ }
296
+
297
+ /**
298
+ * 按指定顺序排序配置对象的键
299
+ */
300
+ private sortConfigKeys(config: NoteConfig): NoteConfig {
301
+ const configRecord = config as unknown as Record<string, unknown>
302
+ const sorted: Record<string, unknown> = {}
303
+
304
+ for (const key of NoteManager.FIELD_ORDER) {
305
+ if (key in config) {
306
+ sorted[key] = configRecord[key]
307
+ }
308
+ }
309
+
310
+ for (const key of Object.keys(config)) {
311
+ if (!(key in sorted)) {
312
+ sorted[key] = configRecord[key]
313
+ }
314
+ }
315
+
316
+ return sorted as unknown as NoteConfig
317
+ }
318
+
319
+ /**
320
+ * 序列化 NoteConfig 为格式化的 JSON 字符串
321
+ * 保持字段顺序,使用 2 空格缩进,末尾含换行符
322
+ */
323
+ private serializeNoteConfig(config: NoteConfig): string {
324
+ const sorted = this.sortConfigKeys(config)
325
+ return JSON.stringify(sorted, null, 2) + '\n'
326
+ }
327
+
328
+ /**
329
+ * 统一写入笔记配置文件
330
+ * @param configPath - 配置文件路径
331
+ * @param config - 笔记配置
332
+ */
333
+ writeNoteConfig(configPath: string, config: NoteConfig): void {
334
+ writeFileSync(configPath, this.serializeNoteConfig(config), 'utf-8')
335
+ }
336
+
337
+ /**
338
+ * 验证笔记配置对象的结构合法性
339
+ * @param config - 笔记配置
340
+ * @returns 是否有效
341
+ */
342
+ private validateConfig(config: NoteConfig): boolean {
343
+ if (!config.id) {
344
+ logger.error('Note config missing id')
345
+ return false
346
+ }
347
+
348
+ if (!Array.isArray(config.bilibili)) {
349
+ logger.error(`Invalid bilibili config in note: ${config.id}`)
350
+ return false
351
+ }
352
+
353
+ if (!Array.isArray(config.tnotes)) {
354
+ logger.error(`Invalid tnotes config in note: ${config.id}`)
355
+ return false
356
+ }
357
+
358
+ if (!Array.isArray(config.yuque)) {
359
+ logger.error(`Invalid yuque config in note: ${config.id}`)
360
+ return false
361
+ }
362
+
363
+ if (typeof config.done !== 'boolean') {
364
+ logger.error(`Invalid done status in note: ${config.id}`)
365
+ return false
366
+ }
367
+
368
+ if (typeof config.enableDiscussions !== 'boolean') {
369
+ logger.error(`Invalid enableDiscussions status in note: ${config.id}`)
370
+ return false
371
+ }
372
+
373
+ return true
374
+ }
375
+
376
+ /**
377
+ * 更新笔记配置
378
+ * @param noteInfo - 笔记信息
379
+ * @param config - 新的配置
380
+ */
381
+ updateNoteConfig(noteInfo: NoteInfo, config: NoteConfig): void {
382
+ if (!this.validateConfig(config)) {
383
+ throw new Error(`Invalid config for note: ${noteInfo.dirName}`)
384
+ }
385
+
386
+ config.updated_at = Date.now()
387
+ this.writeNoteConfig(noteInfo.configPath, config)
388
+ logger.info(`Updated config for note: ${noteInfo.dirName}`)
389
+ }
390
+
391
+ /**
392
+ * 获取笔记信息(通过索引)- 直接查找不扫描所有笔记
393
+ * @param noteIndex - 笔记索引
394
+ * @returns 笔记信息,未找到时返回 undefined
395
+ */
396
+ getNoteByIndex(noteIndex: string): NoteInfo | undefined {
397
+ const noteDirs = this.getNoteDirs()
398
+
399
+ for (const dirName of noteDirs) {
400
+ if (NoteManager.extractNoteIndex(dirName) === noteIndex) {
401
+ return this.buildNoteInfo(dirName)
402
+ }
403
+ }
404
+
405
+ return undefined
406
+ }
407
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * core/ProcessManager.ts
3
+ *
4
+ * 进程管理器 - 管理子进程的生命周期(单例模式)
5
+ */
6
+
7
+ import { spawn, ChildProcess } from 'child_process'
8
+
9
+ import { Logger } from '../utils'
10
+
11
+ import type { SpawnOptions } from 'child_process'
12
+
13
+ /** 进程信息接口 */
14
+ interface ProcessInfo {
15
+ id: string
16
+ pid?: number
17
+ command: string
18
+ args: string[]
19
+ startTime: number
20
+ process: ChildProcess
21
+ }
22
+
23
+ /**
24
+ * 进程管理器类
25
+ */
26
+ export class ProcessManager {
27
+ private processes: Map<string, ProcessInfo> = new Map()
28
+ private logger: Logger
29
+
30
+ constructor() {
31
+ this.logger = new Logger({ prefix: 'process' })
32
+
33
+ // 清理进程在程序退出时
34
+ process.on('exit', () => {
35
+ this.killAll()
36
+ })
37
+
38
+ process.on('SIGINT', () => {
39
+ this.killAll()
40
+ process.exit(0)
41
+ })
42
+
43
+ process.on('SIGTERM', () => {
44
+ this.killAll()
45
+ process.exit(0)
46
+ })
47
+ }
48
+
49
+ /**
50
+ * 启动进程
51
+ * @param id - 进程ID
52
+ * @param command - 命令
53
+ * @param args - 参数列表
54
+ * @param options - spawn 选项
55
+ * @returns ProcessInfo
56
+ */
57
+ spawn(
58
+ id: string,
59
+ command: string,
60
+ args: string[] = [],
61
+ options?: SpawnOptions,
62
+ ): ProcessInfo {
63
+ // 如果进程已存在,先停止
64
+ if (this.processes.has(id)) {
65
+ this.logger.warn(`进程 ${id} 已存在,先停止旧进程`)
66
+ this.kill(id)
67
+ }
68
+
69
+ /**
70
+ * 不在这里输出命令日志,由调用方输出更合适,可以看到服务执行过程中的一些实时 log,比如 hmr
71
+ */
72
+ const proc = spawn(command, args, {
73
+ stdio: 'inherit',
74
+ shell: true,
75
+ ...options,
76
+ })
77
+
78
+ const processInfo: ProcessInfo = {
79
+ id,
80
+ pid: proc.pid,
81
+ command,
82
+ args,
83
+ startTime: Date.now(),
84
+ process: proc,
85
+ }
86
+
87
+ this.processes.set(id, processInfo)
88
+
89
+ // 监听进程退出
90
+ proc.on('exit', (code, signal) => {
91
+ this.logger.info(`进程 ${id} 已退出 (code: ${code}, signal: ${signal})`)
92
+ this.processes.delete(id)
93
+ })
94
+
95
+ proc.on('error', (err) => {
96
+ this.logger.error(`进程 ${id} 出错: ${err.message}`)
97
+ this.processes.delete(id)
98
+ })
99
+
100
+ return processInfo
101
+ }
102
+
103
+ /**
104
+ * 停止进程
105
+ * @param id - 进程ID
106
+ * @param signal - 信号(默认为 SIGTERM)
107
+ * @returns 是否成功停止
108
+ */
109
+ kill(id: string, signal: NodeJS.Signals = 'SIGTERM'): boolean {
110
+ const processInfo = this.processes.get(id)
111
+ if (!processInfo) {
112
+ this.logger.warn(`进程 ${id} 不存在`)
113
+ return false
114
+ }
115
+
116
+ this.logger.info(`停止进程: ${id} (PID: ${processInfo.pid})`)
117
+
118
+ try {
119
+ const killed = processInfo.process.kill(signal)
120
+ if (killed) {
121
+ this.processes.delete(id)
122
+ return true
123
+ }
124
+ return false
125
+ } catch (error) {
126
+ this.logger.error(`停止进程 ${id} 失败: ${error}`)
127
+ return false
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 检查进程是否存在
133
+ * @param id - 进程ID
134
+ * @returns 是否存在
135
+ */
136
+ has(id: string): boolean {
137
+ return this.processes.has(id)
138
+ }
139
+
140
+ /**
141
+ * 检查进程是否在运行
142
+ * @param id - 进程 ID
143
+ * @returns 是否在运行
144
+ */
145
+ isRunning(id: string): boolean {
146
+ const processInfo = this.processes.get(id)
147
+ if (!processInfo) return false
148
+
149
+ // 检查进程是否还活着
150
+ try {
151
+ // 发送信号 0 不会真正发送信号,只是检查进程是否存在
152
+ return process.kill(processInfo.pid!, 0)
153
+ } catch {
154
+ return false
155
+ }
156
+ }
157
+
158
+ /**
159
+ * 停止所有进程
160
+ * @param signal - 信号(默认为 SIGTERM)
161
+ */
162
+ killAll(signal: NodeJS.Signals = 'SIGTERM'): void {
163
+ if (this.processes.size === 0) {
164
+ return
165
+ }
166
+
167
+ this.logger.info(`停止所有进程 (${this.processes.size} 个)`)
168
+
169
+ for (const [id, processInfo] of this.processes) {
170
+ try {
171
+ processInfo.process.kill(signal)
172
+ this.logger.info(`已停止进程: ${id}`)
173
+ } catch (error) {
174
+ this.logger.error(`停止进程 ${id} 失败: ${error}`)
175
+ }
176
+ }
177
+
178
+ this.processes.clear()
179
+ }
180
+ }