mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-05 18:20:39 -08:00
* feat: Skip restart if file system watch is enabled - the watcher will handle file changes automatically * fix: sometimes change sync interval not working fixes #310 * fix: Return false on sync failure - no successful changes were made fixes #558 * fix: step that is wrong * feat: monitoring subwiki * AI added waitForSSEReady * Revert "AI added waitForSSEReady" This reverts commit983b1c623c. * fix: error on frontend loading worker thread * fix * Update wiki.ts * auto reload view and click subwiki icon * Refactor sync echo prevention and improve logging Removed frontend-side echo prevention logic in ipcSyncAdaptor, relying solely on backend file exclusion for echo prevention. Improved console log wrappers to preserve native behavior and added a log statement to setupSSE. Updated test steps and file modification logic to better simulate external edits without modifying timestamps. Added internal documentation on sync architecture. * feat: deboucne and prevent data race when write file * Update watch-filesystem-adaptor.ts * rename camelcase * Update filesystemPlugin.feature * Fix sync interval timezone handling and add tests Refactored syncDebounceInterval logic in Sync.tsx to be timezone-independent, ensuring correct interval storage and display across all timezones. Added comprehensive tests in Sync.timezone.test.ts to verify correct behavior and document previous timezone-related bugs. fixes #310 * i18n for notification * Update index.tsx * fix: potential symlinks problem of subwiki * Update Sync.timezone.test.ts * lint * Implement backoff for file existence check Refactor file existence check to use backoff strategy and add directory tree retrieval for error reporting. * Update BACKOFF_OPTIONS with new configuration * Update wiki.ts * remove log * Update wiki.ts * fix: draft not move to sub * Update filesystemPlugin.feature * fix: routing tw logger to file * Update filesystemPlugin.feature * test: use id to check view load and sse load * Optimize test steps and screenshot logic Removed unnecessary short waits in filesystemPlugin.feature and increased wait time for tiddler state to settle. Updated application.ts to skip screenshots for wait steps, reducing redundant screenshots during test execution. * Check if the WebContents is actually loaded and remove fake webContentsViewHelper.new.ts created by AI * Update view.ts * fix: prevent echo by exclude title * test: Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers" * Revert "fix: prevent echo by exclude title" This reverts commit86aa838d24. * fix: when move file to subwiki, delete old file * fix: prevent ipc echo change back to frontend * test: view might take longer to load * fix: minor issues * test: fix cleanup timeout * Update cleanup.ts * feat: capture webview screenshot * Update filesystemPlugin.feature * Update SyncArchitecture.md * rename * test: add some time to easy failed steps * Separate logs by test scenario for easier debugging * Update selectors for add and confirm buttons in tests Changed the CSS selectors for the add tiddler and confirm buttons in the filesystem plugin feature tests to use :has() with icon classes. This improves selector robustness and aligns with UI changes. * Ensure window has focus and is ready * Update window.ts * fix: webview screenshot capture prevent mini window to close * fix: Failed to take screenshot: Error: ENAMETOOLONG: name too long, open '/home/runner/work/TidGi-Desktop/TidGi-Desktop/userData-test/logs/screenshots/Agent workflow - Create notes- update embeddings- then search/2025-10-30T11-46-28-891Z-I type -在 wiki 工作区创建一个名为 AI Agent Guide 的笔记-内容是-智能体是一种可以执行任务的AI系统-它可以使用工具-搜索信息并与用户交互- in -chat input- element with selec-PASSED-page.png' * Update window.ts * feat: remove deprecated symlink subwiki approach * Update wiki.ts * fix: remove AI buggy bring window to front cause mini window test to fail * lint * Adjust wait time for draft saving in filesystemPlugin Increased wait time for file system plugin to save draft. * Adjust wait time for tiddler state stabilization Increased wait time to ensure tiddler state settles properly. * Refactor release workflow to simplify dependency installation Removed installation steps for x64 and arm64 dependencies, and adjusted the build process for plugins and native modules. * Enhance wait for IPC in filesystemPlugin feature Added a wait time to improve reliability of content update verification in CI.
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
import type { Logger } from '$:/core/modules/utils/logger.js';
|
|
import { workspace } from '@services/wiki/wikiWorker/services';
|
|
import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface';
|
|
import { backOff } from 'exponential-backoff';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import type { FileInfo } from 'tiddlywiki';
|
|
import type { Tiddler, Wiki } from 'tiddlywiki';
|
|
import { isFileLockError } from './utilities';
|
|
|
|
export type IFileSystemAdaptorCallback = (error: Error | null | string, fileInfo?: FileInfo | null) => void;
|
|
|
|
/**
|
|
* Base filesystem adaptor that handles tiddler save/delete operations and sub-wiki routing.
|
|
* This class can be used standalone or extended for additional functionality like file watching.
|
|
*/
|
|
export class FileSystemAdaptor {
|
|
name = 'filesystem';
|
|
supportsLazyLoading = false;
|
|
wiki: Wiki;
|
|
boot: typeof $tw.boot;
|
|
logger: Logger;
|
|
workspaceID: string;
|
|
protected subWikisWithTag: IWikiWorkspace[] = [];
|
|
/** Map of tagName -> subWiki for O(1) tag lookup instead of O(n) find */
|
|
protected tagNameToSubWiki: Map<string, IWikiWorkspace> = new Map();
|
|
/** Cached extension filters from $:/config/FileSystemExtensions. Requires restart to reflect changes. */
|
|
protected extensionFilters: string[] | undefined;
|
|
protected watchPathBase!: string;
|
|
|
|
constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) {
|
|
this.wiki = options.wiki;
|
|
this.boot = options.boot ?? $tw.boot;
|
|
this.logger = new $tw.utils.Logger('filesystem', { colour: 'blue' });
|
|
|
|
if (!$tw.node) {
|
|
throw new Error('filesystem adaptor only works in Node.js environment');
|
|
}
|
|
|
|
// Get workspace ID from preloaded tiddler
|
|
this.workspaceID = this.wiki.getTiddlerText('$:/info/tidgi/workspaceID', '');
|
|
|
|
if (this.boot.wikiTiddlersPath) {
|
|
$tw.utils.createDirectory(this.boot.wikiTiddlersPath);
|
|
this.watchPathBase = path.resolve(this.boot.wikiTiddlersPath);
|
|
} else {
|
|
this.logger.alert('filesystem: wikiTiddlersPath is not set!');
|
|
this.watchPathBase = '';
|
|
}
|
|
|
|
// Initialize extension filters cache
|
|
this.initializeExtensionFiltersCache();
|
|
|
|
// Initialize sub-wikis cache
|
|
void this.updateSubWikisCache();
|
|
}
|
|
|
|
/**
|
|
* Initialize and cache extension filters from $:/config/FileSystemExtensions.
|
|
*/
|
|
protected initializeExtensionFiltersCache(): void {
|
|
if (this.wiki.tiddlerExists('$:/config/FileSystemExtensions')) {
|
|
const extensionFiltersText = this.wiki.getTiddlerText('$:/config/FileSystemExtensions', '');
|
|
this.extensionFilters = extensionFiltersText.split('\n').filter(line => line.trim().length > 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the cached sub-wikis list and rebuild tag lookup map
|
|
*/
|
|
protected async updateSubWikisCache(): Promise<void> {
|
|
try {
|
|
if (!this.workspaceID) {
|
|
this.subWikisWithTag = [];
|
|
this.tagNameToSubWiki.clear();
|
|
return;
|
|
}
|
|
|
|
const currentWorkspace = await workspace.get(this.workspaceID);
|
|
if (!currentWorkspace) {
|
|
this.subWikisWithTag = [];
|
|
this.tagNameToSubWiki.clear();
|
|
return;
|
|
}
|
|
|
|
const allWorkspaces = await workspace.getWorkspacesAsList();
|
|
|
|
const subWikisWithTag = allWorkspaces.filter((workspaceItem: IWorkspace) =>
|
|
'isSubWiki' in workspaceItem &&
|
|
workspaceItem.isSubWiki &&
|
|
workspaceItem.mainWikiID === currentWorkspace.id &&
|
|
'tagName' in workspaceItem &&
|
|
workspaceItem.tagName &&
|
|
'wikiFolderLocation' in workspaceItem &&
|
|
workspaceItem.wikiFolderLocation
|
|
) as IWikiWorkspace[];
|
|
|
|
this.subWikisWithTag = subWikisWithTag;
|
|
|
|
this.tagNameToSubWiki.clear();
|
|
for (const subWiki of subWikisWithTag) {
|
|
this.tagNameToSubWiki.set(subWiki.tagName!, subWiki);
|
|
}
|
|
} catch (error) {
|
|
this.logger.alert('filesystem: Failed to update sub-wikis cache:', error);
|
|
}
|
|
}
|
|
|
|
isReady(): boolean {
|
|
return true;
|
|
}
|
|
|
|
getTiddlerInfo(tiddler: Tiddler): FileInfo | undefined {
|
|
const title = tiddler.fields.title;
|
|
return this.boot.files[title];
|
|
}
|
|
|
|
/**
|
|
* Main routing logic: determine where a tiddler should be saved based on its tags.
|
|
* For draft tiddlers, check the original tiddler's tags.
|
|
*/
|
|
async getTiddlerFileInfo(tiddler: Tiddler): Promise<FileInfo | null> {
|
|
if (!this.boot.wikiTiddlersPath) {
|
|
throw new Error('filesystem adaptor requires a valid wiki folder');
|
|
}
|
|
|
|
const title = tiddler.fields.title;
|
|
let tags = tiddler.fields.tags ?? [];
|
|
const fileInfo = this.boot.files[title];
|
|
|
|
try {
|
|
// For draft tiddlers (draft.of field), also check the original tiddler's tags
|
|
// This ensures drafts are saved to the same sub-wiki as their target tiddler
|
|
const draftOf = tiddler.fields['draft.of'];
|
|
if (draftOf && typeof draftOf === 'string' && $tw.wiki) {
|
|
// Get the original tiddler from the wiki
|
|
const originalTiddler = $tw.wiki.getTiddler(draftOf);
|
|
if (originalTiddler) {
|
|
const originalTags = originalTiddler.fields.tags ?? [];
|
|
// Merge tags from the original tiddler with the draft's tags
|
|
tags = [...new Set([...tags, ...originalTags])];
|
|
}
|
|
}
|
|
|
|
let matchingSubWiki: IWikiWorkspace | undefined;
|
|
for (const tag of tags) {
|
|
matchingSubWiki = this.tagNameToSubWiki.get(tag);
|
|
if (matchingSubWiki) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (matchingSubWiki) {
|
|
return this.generateSubWikiFileInfo(tiddler, matchingSubWiki, fileInfo);
|
|
} else {
|
|
return this.generateDefaultFileInfo(tiddler, fileInfo);
|
|
}
|
|
} catch (error) {
|
|
this.logger.alert(`filesystem: Error in getTiddlerFileInfo for "${title}":`, error);
|
|
return this.generateDefaultFileInfo(tiddler, fileInfo);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate file info for sub-wiki directory
|
|
* Handles symlinks correctly across platforms (Windows junctions and Linux symlinks)
|
|
*/
|
|
protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: FileInfo | undefined): FileInfo {
|
|
let targetDirectory = subWiki.wikiFolderLocation;
|
|
|
|
// Resolve symlinks to ensure consistent path handling across platforms
|
|
// On Windows, this resolves junctions; on Linux, this resolves symbolic links
|
|
// This prevents path inconsistencies when the same symlinked directory is referenced differently
|
|
// (e.g., via the symlink path vs the real path)
|
|
try {
|
|
targetDirectory = fs.realpathSync(targetDirectory);
|
|
} catch {
|
|
// If realpath fails, use the original path
|
|
// This can happen if the directory doesn't exist yet
|
|
}
|
|
|
|
$tw.utils.createDirectory(targetDirectory);
|
|
|
|
return $tw.utils.generateTiddlerFileInfo(tiddler, {
|
|
directory: targetDirectory,
|
|
pathFilters: undefined,
|
|
extFilters: this.extensionFilters,
|
|
wiki: this.wiki,
|
|
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as FileInfo,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate file info using default FileSystemPaths logic
|
|
*/
|
|
protected generateDefaultFileInfo(tiddler: Tiddler, fileInfo: FileInfo | undefined): FileInfo {
|
|
let pathFilters: string[] | undefined;
|
|
|
|
if (this.wiki.tiddlerExists('$:/config/FileSystemPaths')) {
|
|
const pathFiltersText = this.wiki.getTiddlerText('$:/config/FileSystemPaths', '');
|
|
pathFilters = pathFiltersText.split('\n').filter(line => line.trim().length > 0);
|
|
}
|
|
|
|
return $tw.utils.generateTiddlerFileInfo(tiddler, {
|
|
directory: this.boot.wikiTiddlersPath ?? '',
|
|
pathFilters,
|
|
extFilters: this.extensionFilters,
|
|
wiki: this.wiki,
|
|
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as FileInfo,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save a tiddler to the filesystem
|
|
* Can be used with callback (legacy) or as async/await
|
|
*/
|
|
async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, _options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
|
|
try {
|
|
const fileInfo = await this.getTiddlerFileInfo(tiddler);
|
|
|
|
if (!fileInfo) {
|
|
const error = new Error('No fileInfo returned from getTiddlerFileInfo');
|
|
callback?.(error);
|
|
throw error;
|
|
}
|
|
|
|
const savedFileInfo = await this.saveTiddlerWithRetry(tiddler, fileInfo);
|
|
|
|
// Save old file info before updating, for cleanup to detect file path changes
|
|
const oldFileInfo = this.boot.files[tiddler.fields.title];
|
|
|
|
this.boot.files[tiddler.fields.title] = {
|
|
...savedFileInfo,
|
|
isEditableFile: savedFileInfo.isEditableFile ?? true,
|
|
};
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const cleanupOptions = {
|
|
adaptorInfo: oldFileInfo, // Old file info to be deleted
|
|
bootInfo: savedFileInfo, // New file info to be kept
|
|
title: tiddler.fields.title,
|
|
};
|
|
$tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: FileInfo) => {
|
|
if (cleanupError) {
|
|
reject(cleanupError);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
callback?.(null, this.boot.files[tiddler.fields.title]);
|
|
} catch (error) {
|
|
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
|
|
callback?.(errorObject);
|
|
throw errorObject;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a tiddler - not needed as all tiddlers are loaded during boot
|
|
*/
|
|
loadTiddler(_title: string, callback: IFileSystemAdaptorCallback): void {
|
|
callback(null, null);
|
|
}
|
|
|
|
/**
|
|
* Delete a tiddler from the filesystem
|
|
* Can be used with callback (legacy) or as async/await
|
|
*/
|
|
async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise<void> {
|
|
const fileInfo = this.boot.files[title];
|
|
|
|
if (!fileInfo) {
|
|
callback?.(null, null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
$tw.utils.deleteTiddlerFile(fileInfo, (error: Error | null, deletedFileInfo?: FileInfo) => {
|
|
if (error) {
|
|
const errorCode = (error as NodeJS.ErrnoException).code;
|
|
const errorSyscall = (error as NodeJS.ErrnoException).syscall;
|
|
if ((errorCode === 'EPERM' || errorCode === 'EACCES') && errorSyscall === 'unlink') {
|
|
this.logger.alert(`Server desynchronized. Error deleting file for deleted tiddler "${title}"`);
|
|
callback?.(null, deletedFileInfo);
|
|
resolve();
|
|
} else {
|
|
reject(error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.removeTiddlerFileInfo(title);
|
|
callback?.(null, null);
|
|
resolve();
|
|
});
|
|
});
|
|
} catch (error) {
|
|
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
|
|
callback?.(errorObject);
|
|
throw errorObject;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove tiddler info from cache
|
|
*/
|
|
removeTiddlerFileInfo(title: string): void {
|
|
if (this.boot.files[title]) {
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
delete this.boot.files[title];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create an info tiddler to notify user about file save errors
|
|
*/
|
|
protected createErrorNotification(title: string, error: Error, retryCount: number): void {
|
|
const errorInfoTitle = `$:/temp/filesystem/error/${title}`;
|
|
const errorTiddler = {
|
|
title: errorInfoTitle,
|
|
text:
|
|
`Failed to save tiddler "${title}" after ${retryCount} retries.\n\nError: ${error.message}\n\nThe file might be locked by another process. Please close any applications using this file and try again.`,
|
|
tags: ['$:/tags/Alert'],
|
|
type: 'text/vnd.tiddlywiki',
|
|
'error-type': 'file-save-error',
|
|
'original-title': title,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
this.wiki.addTiddler(errorTiddler);
|
|
this.logger.alert(`filesystem: Created error notification for "${title}"`);
|
|
}
|
|
|
|
/**
|
|
* Save tiddler with exponential backoff retry for file lock errors
|
|
*/
|
|
protected async saveTiddlerWithRetry(
|
|
tiddler: Tiddler,
|
|
fileInfo: FileInfo,
|
|
options: { maxRetries?: number; initialDelay?: number; maxDelay?: number } = {},
|
|
): Promise<FileInfo> {
|
|
const maxRetries = options.maxRetries ?? 10;
|
|
const initialDelay = options.initialDelay ?? 50;
|
|
const maxDelay = options.maxDelay ?? 2000;
|
|
|
|
try {
|
|
return await backOff(
|
|
async () => {
|
|
return await new Promise<FileInfo>((resolve, reject) => {
|
|
$tw.utils.saveTiddlerToFile(tiddler, fileInfo, (saveError: Error | null, savedFileInfo?: FileInfo) => {
|
|
if (saveError) {
|
|
reject(saveError);
|
|
return;
|
|
}
|
|
if (!savedFileInfo) {
|
|
reject(new Error('No fileInfo returned from saveTiddlerToFile'));
|
|
return;
|
|
}
|
|
resolve(savedFileInfo);
|
|
});
|
|
});
|
|
},
|
|
{
|
|
numOfAttempts: maxRetries,
|
|
startingDelay: initialDelay,
|
|
timeMultiple: 2,
|
|
maxDelay,
|
|
delayFirstAttempt: false,
|
|
jitter: 'none',
|
|
retry: (error: Error, attemptNumber: number) => {
|
|
const errorCode = (error as NodeJS.ErrnoException).code;
|
|
|
|
if (isFileLockError(errorCode)) {
|
|
this.logger.log(
|
|
`filesystem: File "${fileInfo.filepath}" is locked (${errorCode}), retrying (attempt ${attemptNumber}/${maxRetries})`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
this.logger.alert(`filesystem: Error saving "${tiddler.fields.title}":`, error);
|
|
this.createErrorNotification(tiddler.fields.title, error, attemptNumber);
|
|
return false;
|
|
},
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const finalError = new Error(`Failed to save "${tiddler.fields.title}": ${errorMessage}`);
|
|
this.createErrorNotification(tiddler.fields.title, finalError, maxRetries);
|
|
throw finalError;
|
|
}
|
|
}
|
|
}
|