fix: prevent echo by exclude title

This commit is contained in:
lin onetwo 2025-10-30 02:34:51 +08:00
parent 1c88aca19b
commit 86aa838d24
3 changed files with 93 additions and 1 deletions

View file

@ -27,6 +27,8 @@ export class InverseFilesIndex {
private mainExcludedFiles: Set<string> = new Set(); private mainExcludedFiles: Set<string> = new Set();
/** Temporarily excluded files for each sub-wiki watcher (by absolute path) */ /** Temporarily excluded files for each sub-wiki watcher (by absolute path) */
private subWikiExcludedFiles: Map<string, Set<string>> = new Map(); private subWikiExcludedFiles: Map<string, Set<string>> = new Map();
/** Temporarily excluded tiddler titles during save/delete operations */
private excludedTiddlerTitles: Set<string> = new Set();
/** /**
* Set the main wiki path * Set the main wiki path
@ -227,4 +229,48 @@ export class InverseFilesIndex {
const excluded = this.subWikiExcludedFiles.get(subWikiId); const excluded = this.subWikiExcludedFiles.get(subWikiId);
return excluded ? Array.from(excluded) : []; return excluded ? Array.from(excluded) : [];
} }
/**
* Add a tiddler title to the exclusion list
* @param title Tiddler title to exclude
*/
excludeTiddlerTitle(title: string): void {
this.excludedTiddlerTitles.add(title);
}
/**
* Remove a tiddler title from the exclusion list
* @param title Tiddler title to include
*/
includeTiddlerTitle(title: string): void {
this.excludedTiddlerTitles.delete(title);
}
/**
* Check if a tiddler title is currently excluded
* @param title Tiddler title
* @returns True if title is excluded
*/
isTiddlerTitleExcluded(title: string): boolean;
/**
* Filter out excluded tiddlers from a changes object
* @param changes Changed tiddlers object
* @returns Filtered changes with excluded tiddlers removed
*/
isTiddlerTitleExcluded(changes: Record<string, unknown>): Record<string, unknown>;
isTiddlerTitleExcluded(input: string | Record<string, unknown>): boolean | Record<string, unknown> {
// Single title check
if (typeof input === 'string') {
return this.excludedTiddlerTitles.has(input);
}
// Filter changes object
const filteredChanges: Record<string, unknown> = {};
for (const title in input) {
if (input[title] && !this.excludedTiddlerTitles.has(title)) {
filteredChanges[title] = input[title];
}
}
return filteredChanges;
}
} }

View file

@ -71,6 +71,10 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
* Can be used with callback (legacy) or as async/await * Can be used with callback (legacy) or as async/await
*/ */
override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> { override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
const title = tiddler.fields.title;
// Exclude title to prevent Wiki change events from being sent to frontend
this.inverseFilesIndex.excludeTiddlerTitle(title);
try { try {
// Get file info to calculate path for watching // Get file info to calculate path for watching
const fileInfo = await this.getTiddlerFileInfo(tiddler); const fileInfo = await this.getTiddlerFileInfo(tiddler);
@ -104,7 +108,14 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Schedule file re-inclusion after save completes // Schedule file re-inclusion after save completes
this.scheduleFileInclusion(fileInfo.filepath); this.scheduleFileInclusion(fileInfo.filepath);
// Remove title from exclusion after delay
setTimeout(() => {
this.inverseFilesIndex.includeTiddlerTitle(title);
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
} catch (error) { } catch (error) {
// Clean up title exclusion on error
this.inverseFilesIndex.includeTiddlerTitle(title);
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
callback?.(errorObject); callback?.(errorObject);
throw errorObject; throw errorObject;
@ -116,9 +127,13 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
* Can be used with callback (legacy) or as async/await * Can be used with callback (legacy) or as async/await
*/ */
override async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise<void> { override async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise<void> {
// Exclude title to prevent Wiki change events from being sent to frontend
this.inverseFilesIndex.excludeTiddlerTitle(title);
const fileInfo = this.boot.files[title]; const fileInfo = this.boot.files[title];
if (!fileInfo) { if (!fileInfo) {
this.inverseFilesIndex.includeTiddlerTitle(title);
callback?.(null, null); callback?.(null, null);
return; return;
} }
@ -141,7 +156,14 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Schedule file re-inclusion after deletion completes // Schedule file re-inclusion after deletion completes
this.scheduleFileInclusion(fileRelativePath); this.scheduleFileInclusion(fileRelativePath);
// Remove title from exclusion after delay
setTimeout(() => {
this.inverseFilesIndex.includeTiddlerTitle(title);
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
} catch (error) { } catch (error) {
// Clean up title exclusion on error
this.inverseFilesIndex.includeTiddlerTitle(title);
// Schedule file re-inclusion on error to clean up exclusion list // Schedule file re-inclusion on error to clean up exclusion list
this.scheduleFileInclusion(fileRelativePath); this.scheduleFileInclusion(fileRelativePath);
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');

View file

@ -225,7 +225,31 @@ export class IpcServerRoutes {
observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`)); observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`));
} }
this.wikiInstance.wiki.addEventListener('change', (changes) => { this.wikiInstance.wiki.addEventListener('change', (changes) => {
observer.next(changes); // Filter out changes for tiddlers that are currently being saved/deleted
// This prevents echo: backend saves file → change event → frontend syncs → loads old file
const syncAdaptor = this.wikiInstance.syncadaptor as {
inverseFilesIndex?: {
isTiddlerTitleExcluded: ((title: string) => boolean) & ((changes: IChangedTiddlers) => IChangedTiddlers);
};
} | undefined;
let filteredChanges: IChangedTiddlers = changes;
// Try to filter out excluded tiddlers if the method exists
if (syncAdaptor?.inverseFilesIndex?.isTiddlerTitleExcluded) {
try {
filteredChanges = syncAdaptor.inverseFilesIndex.isTiddlerTitleExcluded(changes);
} catch (error) {
// If filtering fails, send all changes
console.error('Failed to filter excluded tiddlers:', error);
filteredChanges = changes;
}
}
// Send changes if there are any
if (filteredChanges && Object.keys(filteredChanges).length > 0) {
observer.next(filteredChanges);
}
}); });
// Log SSE ready every time a new observer subscribes (including after worker restart) // Log SSE ready every time a new observer subscribes (including after worker restart)
// Include timestamp to make each log entry unique for test detection // Include timestamp to make each log entry unique for test detection