@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,215 @@
1
+ /**
2
+ * core/ReadmeGenerator.ts
3
+ *
4
+ * README 生成器 - 负责生成各种 README 内容
5
+ */
6
+
7
+ import { readFileSync, writeFileSync, existsSync } from 'fs'
8
+
9
+ import { TocGenerator } from './TocGenerator'
10
+ import { ConfigManager } from '../config/ConfigManager'
11
+ import { EOL } from '../config/constants'
12
+ import {
13
+ logger,
14
+ parseNoteLine,
15
+ buildNoteLineMarkdown,
16
+ processEmptyLines,
17
+ } from '../utils'
18
+ import { createAddNumberToTitle } from '../utils'
19
+
20
+ import type { NoteInfo } from '../types'
21
+
22
+ /**
23
+ * README 生成器类
24
+ */
25
+ export class ReadmeGenerator {
26
+ private tocGenerator: TocGenerator
27
+ private configManager: ConfigManager
28
+
29
+ constructor() {
30
+ this.tocGenerator = new TocGenerator()
31
+ this.configManager = ConfigManager.getInstance()
32
+ }
33
+
34
+ /**
35
+ * 更新笔记 README
36
+ * @param noteInfo - 笔记信息
37
+ */
38
+ updateNoteReadme(noteInfo: NoteInfo): void {
39
+ if (!noteInfo.config) {
40
+ logger.warn(`笔记 ${noteInfo.dirName} 缺少配置文件`)
41
+ return
42
+ }
43
+
44
+ const content = readFileSync(noteInfo.readmePath, 'utf-8')
45
+
46
+ // 跳过空内容(可能是其他进程写入时的 truncate 中间状态)
47
+ if (content.length === 0) return
48
+
49
+ const lines = content.split(EOL)
50
+
51
+ const repoName = this.configManager.get('repoName')
52
+ this.tocGenerator.updateNoteToc(
53
+ noteInfo.index,
54
+ lines,
55
+ noteInfo.config,
56
+ repoName,
57
+ )
58
+
59
+ const updatedContent = lines.join(EOL)
60
+ writeFileSync(noteInfo.readmePath, updatedContent, 'utf-8')
61
+ }
62
+
63
+ /**
64
+ * 更新首页 README
65
+ * 更新笔记链接的状态标记([x] 或 [ ]),同时更新 TOC 区域
66
+ * @param notes - 笔记信息数组
67
+ * @param homeReadmePath - 首页 README 路径
68
+ */
69
+ updateHomeReadme(notes: NoteInfo[], homeReadmePath: string): void {
70
+ if (!existsSync(homeReadmePath)) {
71
+ logger.error(`根目录下的 README.md 文件未找到:${homeReadmePath}`)
72
+ return
73
+ }
74
+
75
+ const content = readFileSync(homeReadmePath, 'utf-8')
76
+ const lines = content.split(EOL)
77
+
78
+ // 创建笔记配置映射,以笔记索引为键
79
+ const noteByIndexMap = new Map<string, NoteInfo>()
80
+ for (const note of notes) {
81
+ noteByIndexMap.set(note.index, note)
82
+ }
83
+
84
+ // 获取仓库信息
85
+ const repoOwner = this.configManager.get('author')
86
+ const repoName = this.configManager.get('repoName')
87
+
88
+ // 跟踪已存在的笔记索引和要移除的行
89
+ const existingNoteIndexes = new Set<string>()
90
+ const linesToRemove = new Set<number>()
91
+
92
+ // 更新笔记链接的状态标记
93
+ const titles: string[] = []
94
+ const titlesNotesCount: number[] = []
95
+ let inTocRegion = false
96
+ let currentNoteCount = 0
97
+
98
+ // 标题编号器(用于自动更新二级和三级标题前边儿的编号)
99
+ const addNumberToTitle = createAddNumberToTitle()
100
+ const numberedHeaders = ['## ', '### ']
101
+
102
+ for (let i = 0; i < lines.length; i++) {
103
+ const line = lines[i]
104
+
105
+ // 跳过 TOC region (后面会重新生成)
106
+ if (line.includes('<!-- region:toc -->')) {
107
+ inTocRegion = true
108
+ continue
109
+ }
110
+ if (line.includes('<!-- endregion:toc -->')) {
111
+ inTocRegion = false
112
+ continue
113
+ }
114
+ if (inTocRegion) {
115
+ continue
116
+ }
117
+
118
+ // 使用公共方法解析笔记链接
119
+ const parsed = parseNoteLine(line)
120
+ if (parsed.isMatch && parsed.noteIndex) {
121
+ const note = noteByIndexMap.get(parsed.noteIndex)
122
+
123
+ if (!note) {
124
+ // 笔记不存在,标记为移除
125
+ linesToRemove.add(i)
126
+ logger.warn(`移除不存在的笔记: ${parsed.noteIndex}`)
127
+ continue
128
+ }
129
+
130
+ existingNoteIndexes.add(parsed.noteIndex)
131
+ lines[i] = buildNoteLineMarkdown(note, repoOwner, repoName)
132
+ currentNoteCount++
133
+ continue
134
+ }
135
+
136
+ // 匹配标题: ## xxx 或 ### xxx
137
+ const titleMatch = line.match(/^(#{2,})\s+(.+)$/)
138
+ if (titleMatch) {
139
+ // 检查是否是需要编号的标题(2~3 级)
140
+ const isNumberedHeader = numberedHeaders.some((header) =>
141
+ line.startsWith(header),
142
+ )
143
+
144
+ if (isNumberedHeader) {
145
+ // 自动添加编号
146
+ const [numberedTitle] = addNumberToTitle(line)
147
+ lines[i] = numberedTitle
148
+
149
+ // 保存上一个标题的笔记数量
150
+ if (titles.length > 0) {
151
+ titlesNotesCount.push(currentNoteCount)
152
+ }
153
+
154
+ titles.push(numberedTitle)
155
+ currentNoteCount = 0
156
+ } else {
157
+ // 其他级别的标题,不添加编号
158
+ // 保存上一个标题的笔记数量
159
+ if (titles.length > 0) {
160
+ titlesNotesCount.push(currentNoteCount)
161
+ }
162
+
163
+ titles.push(line)
164
+ currentNoteCount = 0
165
+ }
166
+ }
167
+ }
168
+
169
+ // 移除不存在的笔记(从后往前删除,避免索引问题)
170
+ const sortedLinesToRemove = Array.from(linesToRemove).sort((a, b) => b - a)
171
+ for (const lineIndex of sortedLinesToRemove) {
172
+ lines.splice(lineIndex, 1)
173
+ if (currentNoteCount > 0) {
174
+ currentNoteCount--
175
+ }
176
+ }
177
+
178
+ // 查找缺失的笔记(在真实目录中存在但 README 中不存在)
179
+ const missingNotes: NoteInfo[] = []
180
+ for (const note of notes) {
181
+ if (!existingNoteIndexes.has(note.index)) {
182
+ missingNotes.push(note)
183
+ }
184
+ }
185
+
186
+ // 将缺失的笔记添加到结尾
187
+ if (missingNotes.length > 0) {
188
+ logger.info(`添加 ${missingNotes.length} 篇缺失的笔记到 README`)
189
+
190
+ // 按笔记索引排序
191
+ missingNotes.sort((a, b) => a.index.localeCompare(b.index))
192
+
193
+ for (const note of missingNotes) {
194
+ const noteLine = buildNoteLineMarkdown(note, repoOwner, repoName)
195
+ lines.push(noteLine)
196
+ currentNoteCount++
197
+ }
198
+ }
199
+
200
+ // 保存最后一个标题的笔记数量
201
+ if (titles.length > 0) {
202
+ titlesNotesCount.push(currentNoteCount)
203
+ }
204
+
205
+ // 更新 TOC 区域
206
+ this.tocGenerator.updateHomeToc(lines, titles, titlesNotesCount)
207
+
208
+ const processedLines = processEmptyLines(lines)
209
+
210
+ const updatedContent = processedLines.join(EOL)
211
+ writeFileSync(homeReadmePath, updatedContent, 'utf-8')
212
+
213
+ logger.info('已更新首页 README')
214
+ }
215
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * core/TocGenerator.ts
3
+ *
4
+ * 目录生成器 - 负责生成各种目录(TOC)
5
+ */
6
+
7
+ import { EOL } from '../config/constants'
8
+ import {
9
+ generateToc as generateTocUtil,
10
+ createAddNumberToTitle,
11
+ } from '../utils'
12
+
13
+ import type { NoteConfig } from '../types'
14
+
15
+ // URL 常量
16
+ const BILIBILI_VIDEO_BASE_URL = 'https://www.bilibili.com/video/'
17
+ const TNOTES_YUQUE_BASE_URL = 'https://www.yuque.com/tdahuyou/tnotes.yuque/'
18
+
19
+ // 目录开始和结束标记
20
+ const NOTES_TOC_START_TAG = '<!-- region:toc -->'
21
+ const NOTES_TOC_END_TAG = '<!-- endregion:toc -->'
22
+
23
+ /**
24
+ * 目录生成器类
25
+ */
26
+ export class TocGenerator {
27
+ /**
28
+ * 更新笔记目录
29
+ * @param noteIndex - 笔记索引
30
+ * @param lines - 笔记内容行数组
31
+ * @param noteConfig - 笔记配置
32
+ * @param repoName - 仓库名称
33
+ */
34
+ updateNoteToc(
35
+ noteIndex: string,
36
+ lines: string[],
37
+ noteConfig: NoteConfig,
38
+ repoName: string,
39
+ ): void {
40
+ let startLineIdx = -1,
41
+ endLineIdx = -1
42
+ lines.forEach((line, idx) => {
43
+ if (line.startsWith(NOTES_TOC_START_TAG)) startLineIdx = idx
44
+ if (line.startsWith(NOTES_TOC_END_TAG)) endLineIdx = idx
45
+ })
46
+ if (startLineIdx === -1 || endLineIdx === -1) return
47
+
48
+ const titles: string[] = []
49
+ const numberedHeaders = ['## ', '### '] // 2~3 级标题需要编号
50
+ const unnumberedHeaders = ['#### ', '##### ', '###### '] // 4~6 级标题不需要编号
51
+ const addNumberToTitle = createAddNumberToTitle()
52
+
53
+ // 代码块检测状态
54
+ let inCodeBlock = false
55
+ let inHtmlComment = false
56
+
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const line = lines[i]
59
+
60
+ // 检测代码块边界(``` 或 ~~~)
61
+ if (line.trim().startsWith('```') || line.trim().startsWith('~~~')) {
62
+ inCodeBlock = !inCodeBlock
63
+ continue
64
+ }
65
+
66
+ // 检测 HTML 注释边界
67
+ if (line.trim().startsWith('<!--')) {
68
+ inHtmlComment = true
69
+ }
70
+ if (line.trim().includes('-->')) {
71
+ inHtmlComment = false
72
+ continue
73
+ }
74
+
75
+ // 跳过代码块和 HTML 注释内的内容
76
+ if (inCodeBlock || inHtmlComment) {
77
+ continue
78
+ }
79
+
80
+ // 检查是否是需要编号的标题(2~3 级)
81
+ const isNumberedHeader = numberedHeaders.some((header) =>
82
+ line.startsWith(header),
83
+ )
84
+ // 检查是否是不需要编号的标题(4~6 级)
85
+ const isUnnumberedHeader = unnumberedHeaders.some((header) =>
86
+ line.startsWith(header),
87
+ )
88
+
89
+ if (isNumberedHeader) {
90
+ const [numberedTitle] = addNumberToTitle(line)
91
+ titles.push(numberedTitle)
92
+ lines[i] = numberedTitle // 更新原行内容(添加编号)
93
+ } else if (isUnnumberedHeader) {
94
+ // 移除可能存在的旧编号
95
+ const match = line.match(/^(#+)\s*(\d+(\.\d+)*\.\s*)?(.*)/)
96
+ if (match) {
97
+ const headerSymbol = match[1]
98
+ const plainTitle = match[4]
99
+ const cleanTitle = `${headerSymbol} ${plainTitle}`
100
+ titles.push(cleanTitle)
101
+ lines[i] = cleanTitle // 更新原行内容(移除编号)
102
+ } else {
103
+ titles.push(line)
104
+ }
105
+ }
106
+ }
107
+
108
+ const toc = generateTocUtil(titles)
109
+ const bilibiliTOCItems: string[] = []
110
+ const tnotesTOCItems: string[] = []
111
+ const yuqueTOCItems: string[] = []
112
+
113
+ if (noteConfig) {
114
+ if (noteConfig.bilibili.length > 0) {
115
+ noteConfig.bilibili.forEach((bvid, i) => {
116
+ bilibiliTOCItems.push(
117
+ ` - [bilibili.${repoName}.${noteIndex}.${i + 1}](${
118
+ BILIBILI_VIDEO_BASE_URL + bvid
119
+ })`,
120
+ )
121
+ })
122
+ }
123
+ if (noteConfig.tnotes && noteConfig.tnotes.length > 0) {
124
+ // 生成相关知识库标题和链接列表
125
+ tnotesTOCItems.push(
126
+ `- [📒 TNotes(相关知识库)](https://tnotesjs.github.io/TNotes/)`,
127
+ )
128
+ noteConfig.tnotes.forEach((repoName) => {
129
+ tnotesTOCItems.push(
130
+ ` - [TNotes.${repoName}](https://tnotesjs.github.io/TNotes.${repoName}/)`,
131
+ )
132
+ })
133
+ }
134
+ if (noteConfig.yuque.length > 0) {
135
+ noteConfig.yuque.forEach((slug) => {
136
+ yuqueTOCItems.push(
137
+ ` - [TNotes.yuque.${repoName.replace(
138
+ 'TNotes.',
139
+ '',
140
+ )}.${noteIndex}](${TNOTES_YUQUE_BASE_URL + slug})`,
141
+ )
142
+ })
143
+ }
144
+ }
145
+
146
+ const insertTocItems: string[] = []
147
+ const hasExternalResources =
148
+ bilibiliTOCItems.length > 0 ||
149
+ tnotesTOCItems.length > 0 ||
150
+ yuqueTOCItems.length > 0
151
+
152
+ if (hasExternalResources) {
153
+ insertTocItems.push('::: details 📚 相关资源', '')
154
+
155
+ if (bilibiliTOCItems.length > 0) {
156
+ insertTocItems.push(
157
+ `- [📺 bilibili(笔记视频资源)](https://space.bilibili.com/407241004)`,
158
+ ...bilibiliTOCItems,
159
+ )
160
+ }
161
+
162
+ if (tnotesTOCItems.length > 0) {
163
+ insertTocItems.push(...tnotesTOCItems)
164
+ }
165
+
166
+ if (yuqueTOCItems.length > 0) {
167
+ insertTocItems.push(
168
+ `- [📂 TNotes.yuque(笔记附件资源)](${TNOTES_YUQUE_BASE_URL})`,
169
+ ...yuqueTOCItems,
170
+ )
171
+ }
172
+
173
+ insertTocItems.push('', ':::', '')
174
+ }
175
+
176
+ lines.splice(
177
+ startLineIdx + 1,
178
+ endLineIdx - startLineIdx - 1,
179
+ '',
180
+ ...insertTocItems,
181
+ ...toc.replace(new RegExp(`^${EOL}`), '').split(EOL),
182
+ )
183
+ }
184
+
185
+ /**
186
+ * 更新首页目录
187
+ * @param lines - 首页内容行数组
188
+ * @param titles - 标题数组
189
+ * @param _titlesNotesCount - 每个标题下的笔记数量
190
+ */
191
+ updateHomeToc(
192
+ lines: string[],
193
+ titles: string[],
194
+ _titlesNotesCount: number[],
195
+ ): void {
196
+ let startLineIdx = -1,
197
+ endLineIdx = -1
198
+ lines.forEach((line, idx) => {
199
+ if (line.startsWith(NOTES_TOC_START_TAG)) startLineIdx = idx
200
+ if (line.startsWith(NOTES_TOC_END_TAG)) endLineIdx = idx
201
+ })
202
+ if (startLineIdx === -1 || endLineIdx === -1) return
203
+
204
+ const toc = generateTocUtil(titles)
205
+
206
+ lines.splice(
207
+ startLineIdx + 1,
208
+ endLineIdx - startLineIdx - 1,
209
+ ...toc.split(EOL),
210
+ )
211
+ }
212
+ }
package/core/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * core/index.ts
3
+ *
4
+ * Core 层统一导出
5
+ */
6
+
7
+ export { NoteManager } from './NoteManager'
8
+ export { NoteIndexCache } from './NoteIndexCache'
9
+ export { ReadmeGenerator } from './ReadmeGenerator'
10
+ export { GitManager } from './GitManager'
11
+ export { ProcessManager } from './ProcessManager'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tnotesjs/core",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "TNotes 知识库核心框架 —— 基于 VitePress 的笔记管理系统",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.17.1",
@@ -22,7 +22,10 @@
22
22
  "./vitepress/*": "./vitepress/*"
23
23
  },
24
24
  "files": [
25
- "config/defaultConfig.ts",
25
+ "config/",
26
+ "core/",
27
+ "services/",
28
+ "utils/",
26
29
  "dist/",
27
30
  "templates/",
28
31
  "vitepress/",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * services/file-watcher/configChangeHandler.ts
3
+ *
4
+ * 配置变更处理
5
+ */
6
+
7
+ import type { WatchEvent } from './internal'
8
+ import type { WatchState } from './watchState'
9
+ import type { NoteIndexCache } from '../../core/NoteIndexCache'
10
+ import type { Logger } from '../../utils'
11
+ import type { NoteService } from '../note/service'
12
+
13
+ interface ConfigChangeHandlerConfig {
14
+ /** 监听状态管理器 */
15
+ state: WatchState
16
+ /** 笔记服务实例 */
17
+ noteService: NoteService
18
+ /** 笔记索引缓存实例 */
19
+ noteIndexCache: NoteIndexCache
20
+ /** 日志记录器 */
21
+ logger: Logger
22
+ }
23
+
24
+ export class ConfigChangeHandler {
25
+ constructor(private config: ConfigChangeHandlerConfig) {}
26
+
27
+ async handle(events: WatchEvent[]): Promise<string[]> {
28
+ if (events.length === 0) return []
29
+ const changedIndexes: string[] = []
30
+
31
+ const { state, noteService, noteIndexCache, logger } = this.config
32
+
33
+ for (const change of events) {
34
+ // 忽略由 API 主动写入的更新,避免重复触发
35
+ if (noteService.shouldIgnoreConfigChange(change.path)) {
36
+ logger.debug(`忽略 API 写入的配置文件: ${change.path}`)
37
+ continue
38
+ }
39
+
40
+ const snapshot = state.readConfigSnapshot(change.path)
41
+ if (!snapshot) continue
42
+
43
+ const cached = state.getConfigSnapshot(change.path)
44
+ state.setConfigSnapshot(change.path, snapshot)
45
+ noteIndexCache.updateConfig(change.noteIndex, snapshot)
46
+
47
+ if (!cached) continue
48
+
49
+ const statusChanged = cached.done !== snapshot.done
50
+ const otherChanged =
51
+ cached.enableDiscussions !== snapshot.enableDiscussions ||
52
+ cached.description !== snapshot.description
53
+
54
+ if (statusChanged) {
55
+ changedIndexes.push(change.noteIndex)
56
+ logger.info(`检测到配置状态变化: done(${cached.done}→${snapshot.done})`)
57
+ } else if (otherChanged) {
58
+ logger.info('检测到配置非状态字段变化,已刷新缓存(无需全局更新)')
59
+ }
60
+ }
61
+
62
+ return changedIndexes
63
+ }
64
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * services/file-watcher/eventScheduler.ts
3
+ *
4
+ * 事件调度:防抖 + 批量检测 + 队列
5
+ */
6
+
7
+ import type { WatchEvent } from './internal'
8
+
9
+ /** 默认防抖延迟(毫秒) */
10
+ const DEFAULT_DEBOUNCE_MS = 1000
11
+
12
+ /**
13
+ * 默认批量更新检测窗口(毫秒)
14
+ *
15
+ * - 如果在窗口时间内检测到超过阈值个文件变更,则判定为批量更新
16
+ * - 暂定是 1s 内 3 个文件变更的阈值,正常编写笔记的情况下,1s 内不会超过 3 个文件同时变更,通常不会误判
17
+ * - 当批量更新的行为被检测到之后,会暂停监听服务(窗口 + 缓冲时间)后再恢复
18
+ */
19
+ const DEFAULT_BATCH_WINDOW_MS = 1000
20
+
21
+ /** 默认批量更新阈值(文件数) */
22
+ const DEFAULT_BATCH_THRESHOLD = 3
23
+
24
+ /** 默认批量更新安全缓冲(毫秒) */
25
+ const DEFAULT_BATCH_BUFFER_MS = 2000
26
+
27
+ interface EventSchedulerConfig {
28
+ /** 防抖延迟(毫秒),默认 1000 */
29
+ debounceMs?: number
30
+ /** 批量更新检测窗口(毫秒),默认 1000 */
31
+ batchWindowMs?: number
32
+ /** 批量更新阈值(文件数),默认 3 */
33
+ batchThreshold?: number
34
+ /** 批量更新安全缓冲(毫秒),默认 2000 */
35
+ batchBufferMs?: number
36
+ /** 当事件队列需要刷新处理时的回调函数 */
37
+ onFlush: (events: WatchEvent[]) => void
38
+ /** 检测到批量更新时暂停监听服务的回调函数 */
39
+ onPauseForBatch: () => void
40
+ /** 批量更新结束后恢复监听服务的回调函数 */
41
+ onResumeAfterBatch: () => void
42
+ /** 重新初始化调度器的回调函数 */
43
+ reinit: () => void
44
+ }
45
+
46
+ export class EventScheduler {
47
+ /** 待处理的文件变更事件队列 */
48
+ private pendingEvents: Map<string, WatchEvent> = new Map()
49
+
50
+ /** 防抖定时器 */
51
+ private updateTimer: NodeJS.Timeout | null = null
52
+
53
+ /** 批量更新恢复定时器 */
54
+ private batchResumeTimer: NodeJS.Timeout | null = null
55
+
56
+ /** 记录最近的变更时间戳 */
57
+ private recentChanges: number[] = []
58
+
59
+ /** 标记是否正在更新,避免循环触发 - 类似一把更新行为锁 */
60
+ private isUpdating = false
61
+
62
+ private readonly debounceMs: number
63
+ private readonly batchWindowMs: number
64
+ private readonly batchThreshold: number
65
+ private readonly batchBufferMs: number
66
+
67
+ constructor(private config: EventSchedulerConfig) {
68
+ this.debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS
69
+ this.batchWindowMs = config.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS
70
+ this.batchThreshold = config.batchThreshold ?? DEFAULT_BATCH_THRESHOLD
71
+ this.batchBufferMs = config.batchBufferMs ?? DEFAULT_BATCH_BUFFER_MS
72
+ }
73
+
74
+ /**
75
+ * 设置更新状态锁,用于防止在执行耗时更新操作时被新的文件变更事件打断
76
+ *
77
+ * @param flag - true 表示正在更新(锁定),false 表示更新完成(解锁)
78
+ */
79
+ setUpdating(flag: boolean) {
80
+ this.isUpdating = flag
81
+ }
82
+
83
+ /**
84
+ * 获取当前是否处于更新锁定状态
85
+ *
86
+ * @returns true 表示正在执行更新操作(事件处理被暂停),false 表示空闲可处理新事件
87
+ */
88
+ getUpdating() {
89
+ return this.isUpdating
90
+ }
91
+
92
+ /**
93
+ * 将文件变更事件加入待处理队列,并启动防抖定时器
94
+ *
95
+ * - 若同一文件路径的事件已存在,则忽略重复事件(去重)
96
+ * - 每次新事件都会重置防抖计时器,确保在变更停止后才触发处理
97
+ *
98
+ * @param event 文件变更事件
99
+ */
100
+ enqueue(event: WatchEvent) {
101
+ // 事件去重:同一路径的变更只保留一次,降低抖动
102
+ if (this.pendingEvents.has(event.path)) return
103
+ this.pendingEvents.set(event.path, event)
104
+ if (this.updateTimer) clearTimeout(this.updateTimer)
105
+ this.updateTimer = setTimeout(() => this.flush(), this.debounceMs)
106
+ }
107
+
108
+ /**
109
+ * 立即触发事件队列的处理(防抖到期或手动调用)
110
+ *
111
+ * - 若当前正在更新(isUpdating 为 true),则跳过以避免重复处理
112
+ * - 清空待处理事件队列,并通过 onFlush 回调交由上层服务处理
113
+ * - 处理开始后会锁定更新状态,防止处理过程中被新事件打断
114
+ */
115
+ flush() {
116
+ if (this.isUpdating) return
117
+ if (this.pendingEvents.size === 0) return
118
+ const events = Array.from(this.pendingEvents.values())
119
+ this.pendingEvents.clear()
120
+ this.isUpdating = true
121
+ this.config.onFlush(events)
122
+ }
123
+
124
+ /**
125
+ * 记录当前变更时间并检测是否触发批量更新模式
126
+ *
127
+ * - 维护一个滑动时间窗口(BATCH_UPDATE_WINDOW_MS)内的变更记录
128
+ * - 若短时间内(1秒内)变更次数达到阈值(BATCH_UPDATE_THRESHOLD = 3),则判定为批量操作
129
+ * - 触发批量模式后:
130
+ * 1. 清空当前待处理事件队列,避免重复处理
131
+ * 2. 锁定更新状态(isUpdating = true)
132
+ * 3. 暂停监听服务,并在延迟(窗口 + 缓冲时间)后自动恢复
133
+ *
134
+ * @param now 当前时间戳(默认使用 Date.now())
135
+ * @returns true 表示已触发批量更新模式,false 表示仍处于普通监听模式
136
+ */
137
+ recordChangeAndDetectBatch(now: number = Date.now()): boolean {
138
+ // 记录近期变更时间戳,用于检测“短时间高频”场景并切换到批量模式
139
+ this.recentChanges.push(now)
140
+ this.recentChanges = this.recentChanges.filter(
141
+ (t) => now - t < this.batchWindowMs,
142
+ )
143
+
144
+ if (this.recentChanges.length < this.batchThreshold) return false
145
+
146
+ this.pendingEvents.clear()
147
+ this.recentChanges = []
148
+ this.isUpdating = true
149
+ this.config.onPauseForBatch()
150
+
151
+ this.batchResumeTimer = setTimeout(() => {
152
+ // 批量结束后重建状态并恢复监听
153
+ this.batchResumeTimer = null
154
+ this.isUpdating = false
155
+ this.config.reinit()
156
+ this.config.onResumeAfterBatch()
157
+ }, this.batchWindowMs + this.batchBufferMs)
158
+
159
+ return true
160
+ }
161
+
162
+ /**
163
+ * 清理所有定时器,释放资源
164
+ *
165
+ * 在服务停止时调用,防止定时器在服务销毁后仍然触发回调
166
+ */
167
+ clearTimers(): void {
168
+ if (this.updateTimer) {
169
+ clearTimeout(this.updateTimer)
170
+ this.updateTimer = null
171
+ }
172
+ if (this.batchResumeTimer) {
173
+ clearTimeout(this.batchResumeTimer)
174
+ this.batchResumeTimer = null
175
+ }
176
+ this.pendingEvents.clear()
177
+ this.recentChanges = []
178
+ }
179
+ }