@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.
- 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/package.json +5 -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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* services/file-watcher/renameDetector.ts
|
|
3
|
+
*
|
|
4
|
+
* 文件夹重命名/删除检测
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
import { join } from 'path'
|
|
9
|
+
|
|
10
|
+
import { NoteManager } from '../../core/NoteManager'
|
|
11
|
+
|
|
12
|
+
import type { Logger } from '../../utils'
|
|
13
|
+
|
|
14
|
+
const FOLDER_RENAME_DETECT_WINDOW_MS = 500
|
|
15
|
+
|
|
16
|
+
interface RenameDetectorDeps {
|
|
17
|
+
/** 笔记目录路径 */
|
|
18
|
+
notesDir: string
|
|
19
|
+
/** 目录缓存操作 */
|
|
20
|
+
dirCache: {
|
|
21
|
+
has: (name: string) => boolean
|
|
22
|
+
add: (name: string) => void
|
|
23
|
+
delete: (name: string) => void
|
|
24
|
+
}
|
|
25
|
+
/** 日志记录器 */
|
|
26
|
+
logger: Logger
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RenameDetectorCallbacks {
|
|
30
|
+
/** 笔记删除事件回调 */
|
|
31
|
+
onDelete: (oldName: string) => void
|
|
32
|
+
/** 笔记重命名事件回调 */
|
|
33
|
+
onRename: (oldName: string, newName: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface RenameDetectorConfig
|
|
37
|
+
extends RenameDetectorDeps, RenameDetectorCallbacks {}
|
|
38
|
+
|
|
39
|
+
export interface PendingRename {
|
|
40
|
+
oldName: string
|
|
41
|
+
time: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class RenameDetector {
|
|
45
|
+
/** 待处理的文件夹重命名 */
|
|
46
|
+
private pendingFolderRename: PendingRename | null = null
|
|
47
|
+
|
|
48
|
+
/** 文件夹重命名检测定时器 */
|
|
49
|
+
private folderRenameTimer: NodeJS.Timeout | null = null
|
|
50
|
+
|
|
51
|
+
constructor(private config: RenameDetectorConfig) {}
|
|
52
|
+
|
|
53
|
+
handleFsRename(folderName: string) {
|
|
54
|
+
const { notesDir, dirCache, logger, onDelete, onRename } = this.config
|
|
55
|
+
|
|
56
|
+
const folderPath = join(notesDir, folderName)
|
|
57
|
+
const folderExists = existsSync(folderPath)
|
|
58
|
+
const noteIndex = NoteManager.extractNoteIndex(folderName)
|
|
59
|
+
if (!noteIndex) {
|
|
60
|
+
logger.warn(`无法从文件夹名称提取笔记索引: ${folderName}`)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!folderExists) {
|
|
65
|
+
// 第一次收到“删除”,先假设是重命名的起点,延迟一段时间再决策
|
|
66
|
+
if (dirCache.has(folderName)) {
|
|
67
|
+
logger.info(`检测到文件夹删除/重命名: ${folderName}`)
|
|
68
|
+
this.pendingFolderRename = { oldName: folderName, time: Date.now() }
|
|
69
|
+
if (this.folderRenameTimer) clearTimeout(this.folderRenameTimer)
|
|
70
|
+
this.folderRenameTimer = setTimeout(() => {
|
|
71
|
+
if (this.pendingFolderRename) {
|
|
72
|
+
logger.warn(`检测到笔记删除: ${this.pendingFolderRename.oldName}`)
|
|
73
|
+
onDelete(this.pendingFolderRename.oldName)
|
|
74
|
+
}
|
|
75
|
+
this.pendingFolderRename = null
|
|
76
|
+
this.folderRenameTimer = null
|
|
77
|
+
}, FOLDER_RENAME_DETECT_WINDOW_MS)
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// folder exists
|
|
83
|
+
if (!dirCache.has(folderName)) {
|
|
84
|
+
logger.info(`检测到文件夹创建/重命名: ${folderName}`)
|
|
85
|
+
if (
|
|
86
|
+
this.pendingFolderRename &&
|
|
87
|
+
Date.now() - this.pendingFolderRename.time <
|
|
88
|
+
FOLDER_RENAME_DETECT_WINDOW_MS
|
|
89
|
+
) {
|
|
90
|
+
const oldName = this.pendingFolderRename.oldName
|
|
91
|
+
const oldNoteIndex = NoteManager.extractNoteIndex(oldName)
|
|
92
|
+
if (oldNoteIndex && oldNoteIndex === noteIndex) {
|
|
93
|
+
logger.info(`检测到文件夹重命名: ${oldName} → ${folderName}`)
|
|
94
|
+
if (this.folderRenameTimer) {
|
|
95
|
+
clearTimeout(this.folderRenameTimer)
|
|
96
|
+
this.folderRenameTimer = null
|
|
97
|
+
}
|
|
98
|
+
onRename(oldName, folderName)
|
|
99
|
+
this.pendingFolderRename = null
|
|
100
|
+
} else if (oldNoteIndex && oldNoteIndex !== noteIndex) {
|
|
101
|
+
logger.warn(`索引冲突,回退: ${oldName} -> ${folderName}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 新文件夹:无论是否匹配 rename,都把目录缓存补上,必要时移除旧目录
|
|
106
|
+
dirCache.add(folderName)
|
|
107
|
+
if (this.pendingFolderRename) {
|
|
108
|
+
dirCache.delete(this.pendingFolderRename.oldName)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
clearTimers() {
|
|
114
|
+
if (this.folderRenameTimer) {
|
|
115
|
+
clearTimeout(this.folderRenameTimer)
|
|
116
|
+
this.folderRenameTimer = null
|
|
117
|
+
}
|
|
118
|
+
this.pendingFolderRename = null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* services/file-watcher/service.ts
|
|
3
|
+
*
|
|
4
|
+
* 文件监听服务
|
|
5
|
+
*
|
|
6
|
+
* - 监听笔记文件标题的变化并自动更新 toc
|
|
7
|
+
* - 监听笔记配置文件的变化并自动更新笔记的状态
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { request as httpRequest } from 'http'
|
|
11
|
+
|
|
12
|
+
import { ConfigChangeHandler } from './configChangeHandler'
|
|
13
|
+
import { EventScheduler } from './eventScheduler'
|
|
14
|
+
import { FolderChangeHandler } from './folderChangeHandler'
|
|
15
|
+
import { FsWatcherAdapter } from './fsWatcherAdapter'
|
|
16
|
+
import { GlobalUpdateCoordinator } from './globalUpdateCoordinator'
|
|
17
|
+
import { safeExecute } from './internal'
|
|
18
|
+
import { WATCH_EVENT_TYPES } from './internal'
|
|
19
|
+
import { ReadmeChangeHandler } from './readmeChangeHandler'
|
|
20
|
+
import { RenameDetector } from './renameDetector'
|
|
21
|
+
import { WatchState } from './watchState'
|
|
22
|
+
import { NOTES_DIR_PATH, port, repoName } from '../../config/constants'
|
|
23
|
+
import { NoteIndexCache } from '../../core/NoteIndexCache'
|
|
24
|
+
import { logger } from '../../utils'
|
|
25
|
+
import { NoteService } from '../note/service'
|
|
26
|
+
import { ReadmeService } from '../readme/service'
|
|
27
|
+
import { TocService } from '../toc/service'
|
|
28
|
+
|
|
29
|
+
import type { WatchEvent } from './internal'
|
|
30
|
+
|
|
31
|
+
const NOTES_DIR_NOT_SET_ERROR = 'NOTES_DIR_PATH 未设置,无法启动文件监听'
|
|
32
|
+
|
|
33
|
+
const UPDATE_UNLOCK_DELAY_MS = 500
|
|
34
|
+
|
|
35
|
+
/** vite 端 fileWatcherBridgePlugin 暴露的 broadcast 接口路径 */
|
|
36
|
+
const RENAME_BROADCAST_PATH = '/__tnotes_broadcast_rename'
|
|
37
|
+
const SEARCH_REINDEX_PATH = '/__tnotes_search_reindex'
|
|
38
|
+
|
|
39
|
+
export class FileWatcherService {
|
|
40
|
+
private static instance: FileWatcherService | null = null
|
|
41
|
+
|
|
42
|
+
private watchState!: WatchState
|
|
43
|
+
private scheduler!: EventScheduler
|
|
44
|
+
private renameDetector!: RenameDetector
|
|
45
|
+
private configHandler!: ConfigChangeHandler
|
|
46
|
+
private readmeHandler!: ReadmeChangeHandler
|
|
47
|
+
private coordinator!: GlobalUpdateCoordinator
|
|
48
|
+
private folderHandler!: FolderChangeHandler
|
|
49
|
+
private adapter!: FsWatcherAdapter
|
|
50
|
+
private noteService!: NoteService
|
|
51
|
+
private readmeService!: ReadmeService
|
|
52
|
+
private tocService!: TocService
|
|
53
|
+
private noteIndexCache!: NoteIndexCache
|
|
54
|
+
private unlockTimer: NodeJS.Timeout | null = null
|
|
55
|
+
|
|
56
|
+
constructor(private notesDir: string = NOTES_DIR_PATH) {
|
|
57
|
+
if (!this.notesDir) {
|
|
58
|
+
throw new Error(NOTES_DIR_NOT_SET_ERROR)
|
|
59
|
+
}
|
|
60
|
+
this.init()
|
|
61
|
+
FileWatcherService.instance = this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static getInstance(): FileWatcherService | null {
|
|
65
|
+
return FileWatcherService.instance
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private init(): void {
|
|
69
|
+
this.noteService = NoteService.getInstance()
|
|
70
|
+
this.readmeService = ReadmeService.getInstance()
|
|
71
|
+
this.tocService = TocService.getInstance()
|
|
72
|
+
this.noteIndexCache = NoteIndexCache.getInstance()
|
|
73
|
+
|
|
74
|
+
this.watchState = this.initWatchState()
|
|
75
|
+
this.scheduler = this.initScheduler()
|
|
76
|
+
this.folderHandler = this.initFolderHandler()
|
|
77
|
+
this.renameDetector = this.initRenameDetector()
|
|
78
|
+
this.configHandler = this.initConfigHandler()
|
|
79
|
+
this.readmeHandler = this.initReadmeHandler()
|
|
80
|
+
this.coordinator = this.initCoordinator()
|
|
81
|
+
this.adapter = this.initAdapter()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private initWatchState(): WatchState {
|
|
85
|
+
const watchState = new WatchState({ notesDir: this.notesDir, logger })
|
|
86
|
+
watchState.initializeFromDisk()
|
|
87
|
+
return watchState
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private initScheduler(): EventScheduler {
|
|
91
|
+
return new EventScheduler({
|
|
92
|
+
onFlush: (events) => this.handleFileChange(events),
|
|
93
|
+
onPauseForBatch: () => logger.warn('监听服务暂停 3s 等待批量更新完成...'),
|
|
94
|
+
onResumeAfterBatch: () => logger.info('恢复自动监听'),
|
|
95
|
+
reinit: () => this.watchState.initializeFromDisk(),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private initFolderHandler(): FolderChangeHandler {
|
|
100
|
+
return new FolderChangeHandler({
|
|
101
|
+
notesDir: this.notesDir,
|
|
102
|
+
watchState: this.watchState,
|
|
103
|
+
scheduler: this.scheduler,
|
|
104
|
+
noteService: this.noteService,
|
|
105
|
+
readmeService: this.readmeService,
|
|
106
|
+
tocService: this.tocService,
|
|
107
|
+
noteIndexCache: this.noteIndexCache,
|
|
108
|
+
logger,
|
|
109
|
+
onRenameSuccess: (payload) => {
|
|
110
|
+
// file-watcher 进程与 Vite dev server 进程不同,无法直接调 ws.send。
|
|
111
|
+
// 通过 HTTP 调 vite 子进程暴露的 broadcast 接口,由其转发为 WS 事件。
|
|
112
|
+
void this.broadcastRename(payload)
|
|
113
|
+
},
|
|
114
|
+
onNoteStructureChanged: (reason) => {
|
|
115
|
+
void this.broadcastSearchReindex(reason)
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async broadcastSearchReindex(reason: string): Promise<void> {
|
|
121
|
+
const path = `/${repoName}${SEARCH_REINDEX_PATH}`
|
|
122
|
+
const body = JSON.stringify({ reason })
|
|
123
|
+
const hosts = ['127.0.0.1', '::1']
|
|
124
|
+
|
|
125
|
+
let lastError: unknown = null
|
|
126
|
+
for (const host of hosts) {
|
|
127
|
+
try {
|
|
128
|
+
await this.postOnce(host, path, body)
|
|
129
|
+
return
|
|
130
|
+
} catch (error) {
|
|
131
|
+
lastError = error
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
logger.warn(
|
|
135
|
+
`广播搜索索引重建失败 (POST http://[127.0.0.1|::1]:${port}${path}): ${String(lastError)}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async broadcastRename(payload: {
|
|
140
|
+
oldFolder: string
|
|
141
|
+
newFolder: string
|
|
142
|
+
noteIndex: string
|
|
143
|
+
}): Promise<void> {
|
|
144
|
+
// 注意:用 Node 原生 http.request,而不是 Node 18 的全局 fetch。
|
|
145
|
+
// Windows 上 undici fetch 在某些 Node 18.x 版本下访问 127.0.0.1 偶发报
|
|
146
|
+
// `TypeError: fetch failed` (cause 通常是 ECONNREFUSED 错误传递异常)。
|
|
147
|
+
// http.request 的行为更可预期,且不依赖 Happy Eyeballs / DNS。
|
|
148
|
+
//
|
|
149
|
+
// 另外:Vite dev server 默认 host=`localhost`,在 Node 18 + Windows 下
|
|
150
|
+
// 可能仅绑定到 IPv6 `::1` 而非 IPv4 `127.0.0.1`,所以这里依次尝试两者。
|
|
151
|
+
const path = `/${repoName}${RENAME_BROADCAST_PATH}`
|
|
152
|
+
const body = JSON.stringify(payload)
|
|
153
|
+
const hosts = ['127.0.0.1', '::1']
|
|
154
|
+
|
|
155
|
+
let lastError: unknown = null
|
|
156
|
+
for (const host of hosts) {
|
|
157
|
+
try {
|
|
158
|
+
await this.postOnce(host, path, body)
|
|
159
|
+
return
|
|
160
|
+
} catch (error) {
|
|
161
|
+
lastError = error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
logger.warn(
|
|
165
|
+
`广播重命名事件失败 (POST http://[127.0.0.1|::1]:${port}${path}): ${String(lastError)}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private postOnce(host: string, path: string, body: string): Promise<void> {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const req = httpRequest(
|
|
172
|
+
{
|
|
173
|
+
host,
|
|
174
|
+
port,
|
|
175
|
+
path,
|
|
176
|
+
method: 'POST',
|
|
177
|
+
family: host.includes(':') ? 6 : 4,
|
|
178
|
+
headers: {
|
|
179
|
+
'Content-Type': 'application/json',
|
|
180
|
+
'Content-Length': Buffer.byteLength(body),
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
(res) => {
|
|
184
|
+
// 消费响应体,确保 socket 被释放
|
|
185
|
+
res.on('data', () => {})
|
|
186
|
+
res.on('end', () => {
|
|
187
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
188
|
+
reject(new Error(`HTTP ${res.statusCode}`))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
resolve()
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
req.on('error', reject)
|
|
196
|
+
req.write(body)
|
|
197
|
+
req.end()
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private initRenameDetector(): RenameDetector {
|
|
202
|
+
return new RenameDetector({
|
|
203
|
+
notesDir: this.notesDir,
|
|
204
|
+
dirCache: {
|
|
205
|
+
has: (name) => this.watchState.hasNoteDir(name),
|
|
206
|
+
add: (name) => this.watchState.addNoteDir(name),
|
|
207
|
+
delete: (name) => this.watchState.deleteNoteDir(name),
|
|
208
|
+
},
|
|
209
|
+
logger,
|
|
210
|
+
onDelete: (oldName) => this.folderHandler.handleFolderDeletion(oldName),
|
|
211
|
+
onRename: (oldName, newName) =>
|
|
212
|
+
this.folderHandler.handleFolderRenameUpdate(oldName, newName),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private initConfigHandler(): ConfigChangeHandler {
|
|
217
|
+
return new ConfigChangeHandler({
|
|
218
|
+
state: this.watchState,
|
|
219
|
+
noteService: this.noteService,
|
|
220
|
+
noteIndexCache: this.noteIndexCache,
|
|
221
|
+
logger,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private initReadmeHandler(): ReadmeChangeHandler {
|
|
226
|
+
return new ReadmeChangeHandler({ noteService: this.noteService })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private initCoordinator(): GlobalUpdateCoordinator {
|
|
230
|
+
return new GlobalUpdateCoordinator({
|
|
231
|
+
readmeService: this.readmeService,
|
|
232
|
+
tocService: this.tocService,
|
|
233
|
+
noteIndexCache: this.noteIndexCache,
|
|
234
|
+
logger,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private initAdapter(): FsWatcherAdapter {
|
|
239
|
+
return new FsWatcherAdapter({
|
|
240
|
+
notesDir: this.notesDir,
|
|
241
|
+
isUpdating: () => this.scheduler.getUpdating(),
|
|
242
|
+
onRename: (folderName) => this.renameDetector.handleFsRename(folderName),
|
|
243
|
+
onNoteEvent: (event) => this.onNoteEvent(event),
|
|
244
|
+
logger,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
start(): void {
|
|
249
|
+
this.watchState.initializeFromDisk()
|
|
250
|
+
this.adapter.start()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
stop(): void {
|
|
254
|
+
this.adapter.stop()
|
|
255
|
+
this.scheduler.clearTimers()
|
|
256
|
+
this.renameDetector.clearTimers()
|
|
257
|
+
this.folderHandler.clearTimers()
|
|
258
|
+
if (this.unlockTimer) {
|
|
259
|
+
clearTimeout(this.unlockTimer)
|
|
260
|
+
this.unlockTimer = null
|
|
261
|
+
}
|
|
262
|
+
logger.info('文件监听服务已停止')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
pause(): void {
|
|
266
|
+
this.scheduler.setUpdating(true)
|
|
267
|
+
logger.info('文件监听已暂停')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
resume(): void {
|
|
271
|
+
this.watchState.initializeFromDisk()
|
|
272
|
+
this.scheduler.setUpdating(false)
|
|
273
|
+
logger.info('文件监听已恢复')
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
isWatching(): boolean {
|
|
277
|
+
return this.adapter.isWatching()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 挂起文件监听(关闭 fs.watch 句柄),用于需要操作文件夹的场景(如重命名)
|
|
282
|
+
* Windows 上 fs.watch 会锁住文件夹句柄,必须先关闭才能重命名
|
|
283
|
+
*/
|
|
284
|
+
suspend(): void {
|
|
285
|
+
this.adapter.stop()
|
|
286
|
+
this.scheduler.setUpdating(true)
|
|
287
|
+
logger.info('文件监听已挂起(fs.watch 已关闭)')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 恢复文件监听(重新启动 fs.watch)
|
|
292
|
+
*/
|
|
293
|
+
unsuspend(): void {
|
|
294
|
+
this.watchState.initializeFromDisk()
|
|
295
|
+
this.scheduler.setUpdating(false)
|
|
296
|
+
this.adapter.start()
|
|
297
|
+
logger.info('文件监听已恢复(fs.watch 已重启)')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// #region - 私有实现
|
|
301
|
+
|
|
302
|
+
private onNoteEvent(event: WatchEvent): void {
|
|
303
|
+
if (!this.isNoteFile(event.path)) return
|
|
304
|
+
if (!this.watchState.updateFileHash(event.path)) return
|
|
305
|
+
if (this.scheduler.recordChangeAndDetectBatch()) return
|
|
306
|
+
this.scheduler.enqueue(event)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async handleFileChange(events: WatchEvent[]): Promise<void> {
|
|
310
|
+
try {
|
|
311
|
+
// 优先处理配置状态变更;仅当配置未变更时再处理 README 内容更新
|
|
312
|
+
const configChanges = events.filter(
|
|
313
|
+
(e) => e.type === WATCH_EVENT_TYPES.CONFIG,
|
|
314
|
+
)
|
|
315
|
+
const readmeChanges = events.filter(
|
|
316
|
+
(e) => e.type === WATCH_EVENT_TYPES.README,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const changedNoteIndexes = await this.configHandler.handle(configChanges)
|
|
320
|
+
|
|
321
|
+
if (changedNoteIndexes.length > 0) {
|
|
322
|
+
await safeExecute(
|
|
323
|
+
'配置变更更新',
|
|
324
|
+
() => this.coordinator.applyConfigUpdates(changedNoteIndexes),
|
|
325
|
+
logger,
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await safeExecute(
|
|
331
|
+
'README 变更更新',
|
|
332
|
+
async () => {
|
|
333
|
+
await this.readmeHandler.handle(readmeChanges)
|
|
334
|
+
await this.coordinator.updateNoteReadmesOnly(events)
|
|
335
|
+
},
|
|
336
|
+
logger,
|
|
337
|
+
)
|
|
338
|
+
} finally {
|
|
339
|
+
if (this.unlockTimer) clearTimeout(this.unlockTimer)
|
|
340
|
+
this.unlockTimer = setTimeout(() => {
|
|
341
|
+
this.unlockTimer = null
|
|
342
|
+
this.scheduler.setUpdating(false)
|
|
343
|
+
}, UPDATE_UNLOCK_DELAY_MS)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private isNoteFile(filePath: string): boolean {
|
|
348
|
+
return filePath.endsWith('README.md') || filePath.endsWith('.tnotes.json')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// #endregion - 私有实现
|
|
352
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* services/file-watcher/watchState.ts
|
|
3
|
+
*
|
|
4
|
+
* 监听状态存储:哈希缓存、配置缓存、目录缓存
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'crypto'
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
11
|
+
import type { ConfigSnapshot } from './internal'
|
|
12
|
+
import type { NoteConfig } from '../../types'
|
|
13
|
+
import type { Logger } from '../../utils'
|
|
14
|
+
|
|
15
|
+
interface WatchStateConfig {
|
|
16
|
+
/** 笔记目录路径 */
|
|
17
|
+
notesDir: string
|
|
18
|
+
/** 日志记录器 */
|
|
19
|
+
logger: Logger
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WatchState {
|
|
23
|
+
/** 文件哈希缓存 */
|
|
24
|
+
private fileHashes = new Map<string, string>()
|
|
25
|
+
|
|
26
|
+
/** 笔记目录缓存 */
|
|
27
|
+
private noteDirCache = new Set<string>()
|
|
28
|
+
|
|
29
|
+
/** 笔记配置缓存 */
|
|
30
|
+
private configCache = new Map<string, ConfigSnapshot>()
|
|
31
|
+
|
|
32
|
+
constructor(private config: WatchStateConfig) {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 获取指定文件的 MD5 哈希值,若文件不存在或读取失败返回 null
|
|
36
|
+
*
|
|
37
|
+
* @param filePath 文件路径
|
|
38
|
+
* @returns 文件哈希
|
|
39
|
+
*/
|
|
40
|
+
private getFileHash(filePath: string): string | null {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(filePath)) return null
|
|
43
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
44
|
+
// 跳过空内容(可能是其他进程写入时的 truncate 中间状态)
|
|
45
|
+
if (content.length === 0) return null
|
|
46
|
+
return createHash('md5').update(content).digest('hex')
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 更新文件哈希缓存,只有当文件内容发生变化时才更新并返回 true
|
|
54
|
+
*
|
|
55
|
+
* @param filePath 文件路径
|
|
56
|
+
* @returns 是否发生变化
|
|
57
|
+
*/
|
|
58
|
+
updateFileHash(filePath: string): boolean {
|
|
59
|
+
const current = this.getFileHash(filePath)
|
|
60
|
+
if (!current) return false
|
|
61
|
+
const prev = this.fileHashes.get(filePath)
|
|
62
|
+
if (prev === current) return false
|
|
63
|
+
this.fileHashes.set(filePath, current)
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 检查指定名称的笔记目录是否已存在于缓存中
|
|
69
|
+
*
|
|
70
|
+
* @param name 笔记目录名称
|
|
71
|
+
* @returns 若存在则返回 true,否则返回 false
|
|
72
|
+
*/
|
|
73
|
+
hasNoteDir(name: string) {
|
|
74
|
+
return this.noteDirCache.has(name)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 将指定名称的笔记目录添加到缓存中
|
|
79
|
+
*
|
|
80
|
+
* @param name 笔记目录名称
|
|
81
|
+
*/
|
|
82
|
+
addNoteDir(name: string) {
|
|
83
|
+
this.noteDirCache.add(name)
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 从缓存中移除指定名称的笔记目录
|
|
87
|
+
*
|
|
88
|
+
* @param name 笔记目录名称
|
|
89
|
+
*/
|
|
90
|
+
deleteNoteDir(name: string) {
|
|
91
|
+
this.noteDirCache.delete(name)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 清空所有缓存数据,包括文件哈希、笔记目录和配置快照
|
|
96
|
+
*/
|
|
97
|
+
clearAll(): void {
|
|
98
|
+
this.fileHashes.clear()
|
|
99
|
+
this.noteDirCache.clear()
|
|
100
|
+
this.configCache.clear()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 清除指定笔记目录相关的缓存数据,包括 README.md 和 .tnotes.json 的文件哈希及配置快照
|
|
105
|
+
*
|
|
106
|
+
* @param noteDirName 笔记目录名称
|
|
107
|
+
*/
|
|
108
|
+
clearNoteCaches(noteDirName: string): void {
|
|
109
|
+
const readmePath = join(this.config.notesDir, noteDirName, 'README.md')
|
|
110
|
+
const configPath = join(this.config.notesDir, noteDirName, '.tnotes.json')
|
|
111
|
+
this.fileHashes.delete(readmePath)
|
|
112
|
+
this.fileHashes.delete(configPath)
|
|
113
|
+
this.configCache.delete(configPath)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 获取指定配置文件路径对应的配置快照
|
|
118
|
+
*
|
|
119
|
+
* @param configPath 配置文件路径(通常为 .tnotes.json 的绝对路径)
|
|
120
|
+
* @returns 配置快照,若不存在则返回 undefined
|
|
121
|
+
*/
|
|
122
|
+
getConfigSnapshot(configPath: string): ConfigSnapshot | undefined {
|
|
123
|
+
return this.configCache.get(configPath)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 设置指定配置文件路径的配置快照到缓存中
|
|
128
|
+
*
|
|
129
|
+
* @param configPath 配置文件路径(通常为 .tnotes.json 的绝对路径)
|
|
130
|
+
* @param snapshot 配置快照对象
|
|
131
|
+
*/
|
|
132
|
+
setConfigSnapshot(configPath: string, snapshot: ConfigSnapshot): void {
|
|
133
|
+
this.configCache.set(configPath, snapshot)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 读取指定配置文件的快照
|
|
138
|
+
*
|
|
139
|
+
* 解析 .tnotes.json 配置文件,提取 done、enableDiscussions、description 字段。
|
|
140
|
+
*
|
|
141
|
+
* @param configPath 配置文件路径(通常为 .tnotes.json 的绝对路径)
|
|
142
|
+
* @returns 配置快照,若文件不存在或解析失败则返回 null
|
|
143
|
+
*/
|
|
144
|
+
readConfigSnapshot(configPath: string): ConfigSnapshot | null {
|
|
145
|
+
try {
|
|
146
|
+
if (!existsSync(configPath)) return null
|
|
147
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
148
|
+
const config = JSON.parse(content) as Partial<NoteConfig>
|
|
149
|
+
return {
|
|
150
|
+
done: Boolean(config.done),
|
|
151
|
+
enableDiscussions: Boolean(config.enableDiscussions),
|
|
152
|
+
description: config.description || '',
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.config.logger.error(`[读取配置快照] ${error}`)
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 从磁盘初始化监听状态缓存:
|
|
162
|
+
* 遍历笔记根目录下的所有子目录,将每个笔记目录的 README.md 和 .tnotes.json
|
|
163
|
+
* 的哈希值及配置快照加载到缓存中。
|
|
164
|
+
*/
|
|
165
|
+
initializeFromDisk(): void {
|
|
166
|
+
try {
|
|
167
|
+
const noteDirs = readdirSync(this.config.notesDir)
|
|
168
|
+
this.clearAll()
|
|
169
|
+
|
|
170
|
+
for (const noteDir of noteDirs) {
|
|
171
|
+
const noteDirPath = join(this.config.notesDir, noteDir)
|
|
172
|
+
if (!statSync(noteDirPath).isDirectory()) continue
|
|
173
|
+
|
|
174
|
+
this.noteDirCache.add(noteDir)
|
|
175
|
+
|
|
176
|
+
const readmePath = join(noteDirPath, 'README.md')
|
|
177
|
+
const readmeHash = this.getFileHash(readmePath)
|
|
178
|
+
if (readmeHash) this.fileHashes.set(readmePath, readmeHash)
|
|
179
|
+
|
|
180
|
+
const configPath = join(noteDirPath, '.tnotes.json')
|
|
181
|
+
const configHash = this.getFileHash(configPath)
|
|
182
|
+
if (configHash) {
|
|
183
|
+
this.fileHashes.set(configPath, configHash)
|
|
184
|
+
const snapshot = this.readConfigSnapshot(configPath)
|
|
185
|
+
if (snapshot) this.configCache.set(configPath, snapshot)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.config.logger.warn(
|
|
190
|
+
`[initializeFromDisk] 初始化监听状态失败: ${error}`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|