import { git, workspace } from '@services/wiki/wikiWorker/services'; import fs from 'fs'; import nsfw from 'nsfw'; import path from 'path'; import type { Tiddler, Wiki } from 'tiddlywiki'; import { FileSystemAdaptor, type IFileSystemAdaptorCallback } from './FileSystemAdaptor'; import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex'; import { getActionName } from './utilities'; /** * Delay before actually processing file deletion. * This handles git operations that delete-then-recreate files (e.g., revert, checkout). * If a file is recreated within this window, we treat it as modification instead of delete+create. */ const FILE_DELETION_DELAY_MS = 100; /** * Delay before re-including file after save/delete operations. * Must be longer than nsfw's debounceMS (100ms) to ensure all file system events * from our own operations are in the debounce queue before we re-include the file. * This prevents echo: events generated by our save -> queued in nsfw debounce -> * we re-include too early -> events fire -> processed as external changes. */ const FILE_INCLUSION_DELAY_MS = 150; /** * Delay before notifying git service about file changes. * This prevents excessive git status checks when multiple files change rapidly. * The delay aggregates multiple file changes into a single notification. * Increased to 1000ms to prevent refresh storms during git operations like discard. */ const GIT_NOTIFICATION_DELAY_MS = 1000; /** * Enhanced filesystem adaptor that extends FileSystemAdaptor with file watching capabilities. * * Architecture: * 1. When wiki saves/deletes tiddlers: * - saveTiddler/deleteTiddler calls excludeFile() to add file to internal exclusion list * - Perform file write/delete operation * - nsfw detects file changes but handleNsfwEvents filters out excluded files * - Update inverseFilesIndex immediately after successful operation * - Call includeFile() immediately to remove file from exclusion list (no delay) * - File is re-included in watching, ready to detect external changes * * 2. When external changes occur: * - nsfw detects file changes * - handleNsfwEvents checks if file is excluded; if not, process the event * - For DELETE events: delay processing to handle git revert/checkout scenarios * - If file is recreated within delay window (e.g., git revert), cancel deletion * - Load file and sync to wiki via addTiddler/deleteTiddler * - Update inverseFilesIndex to track the change * * This approach uses our own exclusion list instead of nsfw's updateExcludedPaths() API. * This allows us to: * - Filter events at the handler level with more control * - Delay DELETE events to handle git operations that delete-then-recreate files * - Remove exclusions immediately after our own operations complete (no delay needed) */ export class WatchFileSystemAdaptor extends FileSystemAdaptor { name = 'watch-filesystem'; /** Inverse index: filepath -> tiddler info for fast lookup, also manages sub-wiki info */ private inverseFilesIndex: InverseFilesIndex = new InverseFilesIndex(); /** NSFW watcher instance for main wiki */ private watcher: nsfw.NSFW | undefined; /** Base excluded paths (permanent) */ private baseExcludedPaths: string[] = []; /** * Track pending file deletions to handle git revert/checkout scenarios. * Maps absolute file path to deletion timer. * When a file is deleted then quickly recreated (e.g., git revert), * we cancel the deletion and treat the recreation as a modification. */ private pendingDeletions: Map = new Map(); /** * Track pending file inclusions to prevent memory leaks. * Maps absolute file path to inclusion timer. * Cleared during cleanup to prevent orphaned timeouts. */ private pendingInclusions: Map = new Map(); /** * Timer for debouncing git notifications. * Aggregates multiple file changes into a single git status check. */ private gitNotificationTimer: NodeJS.Timeout | undefined; constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) { super(options); this.logger = new $tw.utils.Logger('watch-filesystem', { colour: 'purple' }); // Initialize main wiki path in index this.inverseFilesIndex.setMainWikiPath(this.watchPathBase); // Initialize file watching void this.initializeFileWatching(); } /** * Save a tiddler to the filesystem (with file watching support) * Can be used with callback (legacy) or as async/await */ override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record }): Promise { try { // Get existing file info (if tiddler already exists on disk) const oldFileInfo = this.boot.files[tiddler.fields.title]; // For new tiddlers, pre-calculate the file path and exclude it to prevent echo // Must exclude both the main file and its .meta file to prevent watch-fs from detecting our own save operations // This is critical for plugin JSON files which always have a separate .meta file let excludedNewFilePath: string | undefined; if (!oldFileInfo) { try { const newFileInfo = await this.getTiddlerFileInfo(tiddler); if (newFileInfo?.filepath) { this.excludeFile(newFileInfo.filepath); // Also exclude the .meta file if it exists const metaFilePath = `${newFileInfo.filepath}.meta`; this.excludeFile(metaFilePath); excludedNewFilePath = newFileInfo.filepath; } } catch (error) { this.logger.alert(`[WATCH_FS_ERROR] Failed to pre-calculate file path for new tiddler: ${tiddler.fields.title}`, error); } } // Exclude old file path before save (if it exists) if (oldFileInfo) { this.excludeFile(oldFileInfo.filepath); // Also exclude the .meta file if it exists const metaFilePath = `${oldFileInfo.filepath}.meta`; this.excludeFile(metaFilePath); this.logger.log(`[WATCH_FS_SAVE] Excluded existing file: ${oldFileInfo.filepath}`); } // Call parent's saveTiddler to handle the actual save (including cleanup of old files) await super.saveTiddler(tiddler, undefined, options); // Update inverse index after successful save const finalFileInfo = this.boot.files[tiddler.fields.title]; const fileRelativePath = path.relative(this.watchPathBase, finalFileInfo.filepath); this.inverseFilesIndex.set(fileRelativePath, { ...finalFileInfo, filepath: fileRelativePath, tiddlerTitle: tiddler.fields.title, }); // Notify callback if provided callback?.(null, finalFileInfo); // Schedule re-inclusion after delay to avoid echo this.scheduleFileInclusion(finalFileInfo.filepath); // Also re-include the .meta file this.scheduleFileInclusion(`${finalFileInfo.filepath}.meta`); // For edge case, rarely if we wrongly pre-excluded a new file path and it's different from the final path that tw decided to use, re-include it to revoke the influence if (excludedNewFilePath && excludedNewFilePath !== finalFileInfo.filepath) { this.scheduleFileInclusion(excludedNewFilePath); this.scheduleFileInclusion(`${excludedNewFilePath}.meta`); } // If old file path was different and we excluded it, re-include it // The old file should be deleted by now via cleanupTiddlerFiles if (oldFileInfo && oldFileInfo.filepath !== finalFileInfo.filepath) { this.scheduleFileInclusion(oldFileInfo.filepath); this.scheduleFileInclusion(`${oldFileInfo.filepath}.meta`); } } catch (error) { const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); callback?.(errorObject); throw errorObject; } } /** * Delete a tiddler from the filesystem (with file watching support) * Can be used with callback (legacy) or as async/await */ override async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise { const fileInfo = this.boot.files[title]; if (!fileInfo) { callback?.(null, null); return; } // Calculate relative path for watching const fileRelativePath = path.relative(this.watchPathBase, fileInfo.filepath); try { // Exclude file before deletion this.excludeFile(fileInfo.filepath); // Call parent's deleteTiddler to handle the actual deletion await super.deleteTiddler(title, undefined, _options); // Update inverse index after successful deletion this.inverseFilesIndex.delete(fileRelativePath); // Notify callback if provided callback?.(null, null); // Schedule re-inclusion after delay to avoid echo this.scheduleFileInclusion(fileInfo.filepath); } catch (error) { // Re-include file on error to clean up exclusion list this.scheduleFileInclusion(fileInfo.filepath); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); callback?.(errorObject); throw errorObject; } } /** * Initialize file system watching */ private async initializeFileWatching(): Promise { if (!this.watchPathBase) { return; } // Check if file system watch is enabled for this workspace if (this.workspaceID) { try { const currentWorkspace = await workspace.get(this.workspaceID); if (currentWorkspace && 'enableFileSystemWatch' in currentWorkspace && !currentWorkspace.enableFileSystemWatch) { this.logger.log('[WATCH_FS_DISABLED] File system watching is disabled for this workspace'); return; } } catch (error) { this.logger.alert('[WATCH_FS_ERROR] Failed to check enableFileSystemWatch setting:', error); return; } } // Initialize inverse index from boot.files this.initializeInverseFilesIndex(); // Setup base excluded paths (permanent exclusions) this.baseExcludedPaths = [ path.join(this.watchPathBase, 'subwiki'), path.join(this.watchPathBase, '.git'), path.join(this.watchPathBase, '$__StoryList'), path.join(this.watchPathBase, '.DS_Store'), ]; // Setup nsfw watcher try { this.watcher = await nsfw( this.watchPathBase, (events) => { this.handleNsfwEvents(events); }, { debounceMS: 100, errorCallback: (error) => { this.logger.alert('[WATCH_FS_ERROR] NSFW error:', error); }, // Start with base excluded paths // @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string] excludedPaths: [...this.baseExcludedPaths], }, ); // Start watching await this.watcher.start(); // Initialize sub-wiki watchers await this.initializeSubWikiWatchers(); // Log stabilization marker for tests this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized', { level: 'debug' }); } catch (error) { this.logger.alert('[WATCH_FS_ERROR] Failed to initialize file watching:', error); } } /** * Initialize watchers for sub-wikis */ private async initializeSubWikiWatchers(): Promise { if (!this.workspaceID) { return; } try { // Get sub-wikis for this main wiki const subWikis = await workspace.getSubWorkspacesAsList(this.workspaceID); this.logger.log(`[WATCH_FS_SUBWIKI] Found ${subWikis.length} sub-wikis to watch`); // Create watcher for each sub-wiki for (const subWiki of subWikis) { // Only watch wiki workspaces if (!('wikiFolderLocation' in subWiki) || !subWiki.wikiFolderLocation) { continue; } // Sub-wikis are folders directly, not wiki/tiddlers structure const subWikiPath = subWiki.wikiFolderLocation; // Check if the path exists before trying to watch if (!fs.existsSync(subWikiPath)) { this.logger.log(`[WATCH_FS_SUBWIKI] Path does not exist for sub-wiki ${subWiki.name}: ${subWikiPath}`); continue; } try { const subWikiWatcher = await nsfw( subWikiPath, (events) => { this.handleNsfwEvents(events); }, { debounceMS: 100, errorCallback: (error) => { this.logger.alert(`[WATCH_FS_ERROR] NSFW error for sub-wiki ${subWiki.name}:`, error); }, }, ); await subWikiWatcher.start(); this.inverseFilesIndex.registerSubWiki(subWiki.id, subWikiPath, subWikiWatcher); this.logger.log(`[WATCH_FS_SUBWIKI] Watching sub-wiki: ${subWiki.name} at ${subWikiPath}`); } catch (error) { this.logger.alert(`[WATCH_FS_ERROR] Failed to watch sub-wiki ${subWiki.name}:`, error); } } } catch (error) { this.logger.alert('[WATCH_FS_ERROR] Failed to initialize sub-wiki watchers:', error); } } /** * Initialize the inverse files index from boot.files */ private initializeInverseFilesIndex(): void { const initialLoadedFiles = this.boot.files; // Initialize the inverse index for (const tiddlerTitle in initialLoadedFiles) { if (Object.hasOwn(initialLoadedFiles, tiddlerTitle)) { const fileDescriptor = initialLoadedFiles[tiddlerTitle]; const fileRelativePath = path.relative(this.watchPathBase, fileDescriptor.filepath); this.inverseFilesIndex.set(fileRelativePath, { ...fileDescriptor, filepath: fileRelativePath, tiddlerTitle }); } } } /** * Temporarily exclude a file from watching (e.g., during save/delete) * We maintain our own exclusion list and filter events in handleNsfwEvents. * @param absoluteFilePath Absolute file path */ private excludeFile(absoluteFilePath: string): void { this.logger.log(`[WATCH_FS_EXCLUDE] Excluding file: ${absoluteFilePath}`); this.inverseFilesIndex.excludeFile(absoluteFilePath); } /** * Schedule a file to be re-included after a delay. * The delay ensures nsfw's debounce window has passed before we start detecting changes again. * @param absoluteFilePath Absolute file path */ private scheduleFileInclusion(absoluteFilePath: string): void { // Clear any existing inclusion timer for this file const existingTimer = this.pendingInclusions.get(absoluteFilePath); if (existingTimer) { clearTimeout(existingTimer); } const timer = setTimeout(() => { this.logger.log(`[WATCH_FS_INCLUDE] Including file: ${absoluteFilePath}`); this.inverseFilesIndex.includeFile(absoluteFilePath); this.pendingInclusions.delete(absoluteFilePath); // Notify git service when file is included after being saved // This ensures that frontend-initiated changes also trigger git log refresh this.scheduleGitNotification(); }, FILE_INCLUSION_DELAY_MS); this.pendingInclusions.set(absoluteFilePath, timer); } /** * Cancel a pending deletion for a file (e.g., when file is recreated after deletion) * @param fileAbsolutePath Absolute file path */ private cancelPendingDeletion(fileAbsolutePath: string): void { const existingTimer = this.pendingDeletions.get(fileAbsolutePath); if (existingTimer) { clearTimeout(existingTimer); this.pendingDeletions.delete(fileAbsolutePath); this.logger.log(`[WATCH_FS_CANCEL_DELETE] Cancelled pending deletion for: ${fileAbsolutePath}`); } } /** * Schedule a file deletion with delay to handle git revert/checkout scenarios. * If the file is recreated within the delay window, the deletion is cancelled. * @param fileAbsolutePath Absolute file path * @param fileRelativePath Relative file path * @param fileExtension File extension */ private scheduleDeletion(fileAbsolutePath: string, fileRelativePath: string, fileExtension: string): void { // Clear any existing deletion timer for this file this.cancelPendingDeletion(fileAbsolutePath); // Schedule the deletion const timer = setTimeout(() => { this.handleFileDelete(fileAbsolutePath, fileRelativePath, fileExtension); this.pendingDeletions.delete(fileAbsolutePath); }, FILE_DELETION_DELAY_MS); this.pendingDeletions.set(fileAbsolutePath, timer); this.logger.log(`[WATCH_FS_SCHEDULE_DELETE] Scheduled deletion for: ${fileAbsolutePath}`); } /** * Handle NSFW file system change events */ private handleNsfwEvents(events: nsfw.FileChangeEvent[]): void { let hasFileChanges = false; for (const event of events) { const { action, directory } = event; // Get file name from event let fileName = ''; if ('file' in event) { fileName = event.file; } else if ('newFile' in event) { fileName = event.newFile; } // Compute absolute path const fileAbsolutePath = path.join(directory, fileName); // Check if this file is in our exclusion list - if so, skip processing const subWikiForExclusion = this.inverseFilesIndex.getSubWikiForFile(fileAbsolutePath); const isExcluded = subWikiForExclusion ? this.inverseFilesIndex.isSubWikiFileExcluded(subWikiForExclusion.id, fileAbsolutePath) : this.inverseFilesIndex.isMainFileExcluded(fileAbsolutePath); if (isExcluded) { this.logger.log(`[WATCH_FS_SKIP_EXCLUDED] Skipping excluded file: ${fileAbsolutePath}`); continue; } // Mark that we have file changes to notify git hasFileChanges = true; // Determine which wiki this file belongs to and compute relative path accordingly const subWikiInfo = this.inverseFilesIndex.getSubWikiForFile(fileAbsolutePath); const basePath = subWikiInfo ? subWikiInfo.path : this.watchPathBase; const fileRelativePath = path.relative(basePath, fileAbsolutePath); const fileNameBase = path.parse(fileAbsolutePath).name; const fileExtension = path.extname(fileRelativePath); const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki'; const metaFileAbsolutePath = `${fileAbsolutePath}.meta`; this.logger.log('[WATCH_FS_EVENT]', getActionName(action), fileName, `(directory: ${directory})`); // Handle different event types if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) { // Skip if it's a directory (nsfw sometimes reports directory changes) try { const stats = fs.statSync(fileAbsolutePath); if (stats.isDirectory()) { this.logger.log(`[WATCH_FS_SKIP_DIR] Skipping directory: ${fileAbsolutePath}`); continue; } } catch { // File might have been deleted already, skip continue; } // Cancel any pending deletion for this file (e.g., git revert scenario) this.cancelPendingDeletion(fileAbsolutePath); void this.handleFileAddOrChange( fileAbsolutePath, fileRelativePath, metaFileAbsolutePath, fileName, fileNameBase, fileExtension, fileMimeType, action === nsfw.actions.CREATED ? 'add' : 'change', ); } else if (action === nsfw.actions.DELETED) { // Delay deletion to handle git revert/checkout scenarios this.scheduleDeletion(fileAbsolutePath, fileRelativePath, fileExtension); } else if (action === nsfw.actions.RENAMED) { // NSFW provides rename events with oldFile/newFile // Handle as delete old + create new if ('oldFile' in event && 'newFile' in event) { const oldFileAbsPath = path.join(directory, event.oldFile); const oldSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(oldFileAbsPath); const oldBasePath = oldSubWikiInfo ? oldSubWikiInfo.path : this.watchPathBase; const oldFileRelativePath = path.relative(oldBasePath, oldFileAbsPath); const oldFileExtension = path.extname(oldFileRelativePath); // For rename, we can delete immediately since we know it's a rename operation this.handleFileDelete(oldFileAbsPath, oldFileRelativePath, oldFileExtension); const newDirectory = 'newDirectory' in event ? event.newDirectory : directory; const newFileAbsPath = path.join(newDirectory, event.newFile); const newSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(newFileAbsPath); const newBasePath = newSubWikiInfo ? newSubWikiInfo.path : this.watchPathBase; const newFileRelativePath = path.relative(newBasePath, newFileAbsPath); const newFileName = event.newFile; const newFileNameBase = path.parse(newFileAbsPath).name; const newFileExtension = path.extname(newFileRelativePath); const newFileMimeType = $tw.utils.getFileExtensionInfo(newFileExtension)?.type ?? 'text/vnd.tiddlywiki'; const newMetaFileAbsPath = `${newFileAbsPath}.meta`; void this.handleFileAddOrChange( newFileAbsPath, newFileRelativePath, newMetaFileAbsPath, newFileName, newFileNameBase, newFileExtension, newFileMimeType, 'add', ); } } } // Notify git service about file changes with debounce if (hasFileChanges) { this.scheduleGitNotification(); } } /** * Schedule a debounced notification to git service about file changes. * Multiple file changes are aggregated into a single notification. */ private scheduleGitNotification(): void { // Clear existing timer if (this.gitNotificationTimer) { clearTimeout(this.gitNotificationTimer); } // Schedule new notification this.gitNotificationTimer = setTimeout(() => { // Notify git service about file changes // Pass the wiki folder (parent of tiddlers folder) as wikiFolderLocation // watchPathBase is wiki/tiddlers, but wikiFolderLocation should be wiki const wikiFolderLocation = path.dirname(this.watchPathBase); try { (git.notifyFileChange as ((path: string, options?: { onlyWhenGitLogOpened?: boolean }) => void))( wikiFolderLocation, { onlyWhenGitLogOpened: true }, ); this.logger.log(`[WATCH_FS_GIT_NOTIFY] Notified git service about file changes in ${wikiFolderLocation}`); } catch (error) { this.logger.alert('[WATCH_FS_GIT_NOTIFY_ERROR] Failed to notify git service:', error); } this.gitNotificationTimer = undefined; }, GIT_NOTIFICATION_DELAY_MS); } /** * Handle file add or change events */ private async handleFileAddOrChange( fileAbsolutePath: string, fileRelativePath: string, metaFileAbsolutePath: string, fileName: string, fileNameBase: string, fileExtension: string, fileMimeType: string, changeType: 'add' | 'change', ): Promise { // For .meta files, we need to load the corresponding base file let actualFileToLoad = fileAbsolutePath; let actualFileRelativePath = fileRelativePath; if (fileExtension === '.meta') { // Remove .meta extension to get the actual file path actualFileToLoad = fileAbsolutePath.slice(0, -5); // Remove '.meta' actualFileRelativePath = fileRelativePath.slice(0, -5); // Remove '.meta' } // Get tiddler from disk let tiddlersDescriptor: ReturnType; try { tiddlersDescriptor = $tw.loadTiddlersFromFile(actualFileToLoad); } catch (error) { this.logger.alert('[WATCH_FS_LOAD_ERROR] Failed to load file:', actualFileToLoad, error); return; } // Create .meta file for non-tiddler files (images, videos, etc.) // For files without .meta, TiddlyWiki needs metadata to properly index them const ignoredExtension = ['tid', 'json', 'meta']; const isCreatingNewNonTiddlerFile = changeType === 'add' && !fs.existsSync(metaFileAbsolutePath) && !ignoredExtension.includes(fileExtension.slice(1)); if (isCreatingNewNonTiddlerFile) { const createdTime = $tw.utils.formatDateString(new Date(), '[UTC]YYYY0MM0DD0hh0mm0ss0XXX'); fs.writeFileSync( metaFileAbsolutePath, `caption: ${fileNameBase}\ncreated: ${createdTime}\nmodified: ${createdTime}\ntitle: ${fileName}\ntype: ${fileMimeType}\n`, ); // After creating .meta, continue to process the file normally // TiddlyWiki will detect the .meta file on next event } const { tiddlers, ...fileDescriptor } = tiddlersDescriptor; // Process each tiddler from the file for (const tiddler of tiddlers) { // Note: $tw.loadTiddlersFromFile returns tiddlers as plain objects with fields at top level, // not wrapped in a .fields property const tiddlerTitle = tiddler?.title; if (!tiddlerTitle) { this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title. Tiddler object: ${JSON.stringify(tiddler)}`); continue; } const isNewFile = !this.inverseFilesIndex.has(actualFileRelativePath); // Update inverse index first this.inverseFilesIndex.set(actualFileRelativePath, { ...fileDescriptor, filepath: actualFileRelativePath, tiddlerTitle, } as IBootFilesIndexItemWithTitle); // Add tiddler to wiki (this will update if it exists or add if new) $tw.syncadaptor!.wiki.addTiddler(tiddler); // Log appropriate event if (isNewFile) { this.logger.log(`[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`, { level: 'debug' }); } else { this.logger.log(`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`, { level: 'debug' }); } } } /** * Handle file delete events */ private handleFileDelete(fileAbsolutePath: string, fileRelativePath: string, _fileExtension: string): void { // Try to get tiddler title from filepath // If file is not in index, try to extract title from filename let tiddlerTitle: string; if (this.inverseFilesIndex.has(fileRelativePath)) { // File is in our inverse index try { tiddlerTitle = this.inverseFilesIndex.getTitleByPath(fileRelativePath); } catch { // fatal error, shutting down. if (this.watcher) { void this.watcher.stop(); } throw new Error(`${fileRelativePath}\n↑ not existed in watch-fs plugin's FileSystemMonitor's inverseFilesIndex`); } } else { // File not in index - try to extract title from filename // This handles edge cases like manually deleted files or index inconsistencies const fileNameWithoutExtension = path.basename(fileRelativePath, path.extname(fileRelativePath)); tiddlerTitle = fileNameWithoutExtension; } // Check if tiddler exists in wiki before trying to delete if (!$tw.syncadaptor!.wiki.tiddlerExists(tiddlerTitle)) { // Tiddler doesn't exist in wiki, nothing to delete return; } // Remove tiddler from wiki this.removeTiddlerFileInfo(tiddlerTitle); // Delete the tiddler from wiki to trigger change event $tw.syncadaptor!.wiki.deleteTiddler(tiddlerTitle); this.logger.log(`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`, { level: 'debug' }); // Delete system tiddler empty file if exists try { if ( fileAbsolutePath.startsWith('$') && fs.existsSync(fileAbsolutePath) && fs.readFileSync(fileAbsolutePath, 'utf-8').length === 0 ) { fs.unlinkSync(fileAbsolutePath); } } catch (error) { this.logger.alert('Error cleaning up empty file:', error); } // Update inverse index this.inverseFilesIndex.delete(fileRelativePath); } /** * Cleanup method to properly close watcher when wiki is shutting down */ public async cleanup(): Promise { // Clear git notification timer if (this.gitNotificationTimer) { clearTimeout(this.gitNotificationTimer); this.gitNotificationTimer = undefined; } // Clear all pending deletion timers for (const timer of this.pendingDeletions.values()) { clearTimeout(timer); } this.pendingDeletions.clear(); // Clear all pending inclusion timers for (const timer of this.pendingInclusions.values()) { clearTimeout(timer); } this.pendingInclusions.clear(); if (this.watcher) { this.logger.log('[WATCH_FS_CLEANUP] Closing filesystem watcher'); await this.watcher.stop(); this.watcher = undefined; this.logger.log('[WATCH_FS_CLEANUP] Filesystem watcher closed'); } // Close all sub-wiki watchers for (const subWiki of this.inverseFilesIndex.getSubWikis()) { this.logger.log(`[WATCH_FS_CLEANUP] Closing sub-wiki watcher: ${subWiki.id}`); await subWiki.watcher.stop(); this.inverseFilesIndex.unregisterSubWiki(subWiki.id); } } }