diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 9ecc34de..e2a2204c 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -11,8 +11,8 @@ paths: # Paths to ignore within the analyzed paths # (Excludes test files and mock data from security analysis) paths-ignore: - - '**/__tests__/**' - - '**/__mocks__/**' - - '**/*.test.ts' - - '**/*.test.tsx' - - '**/*.spec.ts' + - "**/__tests__/**" + - "**/__mocks__/**" + - "**/*.test.ts" + - "**/*.test.tsx" + - "**/*.spec.ts" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34398b32..ff83c9e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,10 +64,9 @@ jobs: uses: actions/setup-node@v5 with: node-version: lts/* - - name: Install dependencies - run: pnpm install # Windows specific: Set up CV dependency for pngquant-bin + # Also sets up MSVC (Microsoft Visual C++) compiler for native modules (nsfw, better-sqlite3) - name: Set up CV dependency for pngquant-bin if: matrix.platform == 'win' uses: ilammy/msvc-dev-cmd@v1 @@ -89,8 +88,20 @@ jobs: - name: Build plugins run: pnpm run build:plugin + + # Install C++ build tools for native modules (Linux only) + # macOS: GitHub Actions runners come with Xcode Command Line Tools pre-installed + # Windows: MSVC is set up by msvc-dev-cmd action above + - name: Install build dependencies (Linux) + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential + + - name: Install dependencies + run: pnpm install - name: Rebuild native modules for Electron - run: pnpm exec electron-rebuild -f -w better-sqlite3 + run: pnpm exec electron-rebuild -f -w better-sqlite3,nsfw - name: Make ${{ matrix.platform }} (${{ matrix.arch }}) run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ee74c5c..05c4db9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,11 +20,17 @@ jobs: uses: actions/setup-node@v5 with: node-version: lts/* + + # Install C++ build tools for native modules (nsfw, better-sqlite3) + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential - name: Install dependencies run: pnpm install - name: Rebuild native modules for Electron - run: pnpm exec electron-rebuild -f -w better-sqlite3 + run: pnpm exec electron-rebuild -f -w better-sqlite3,nsfw - name: Run linting run: pnpm run lint diff --git a/docs/ErrorDuringStart.md b/docs/ErrorDuringStart.md index 4c3db45d..8b82a124 100644 --- a/docs/ErrorDuringStart.md +++ b/docs/ErrorDuringStart.md @@ -184,17 +184,6 @@ If you don't want to include a polyfill, you can use an empty module like this: Usually because you import the server-side `logger` in renderer process code. You have to use `console` or add new transport in [rendererTransport.ts](src/services/libs/log/rendererTransport.ts). -## Startup stalled at `Launching dev servers for renderer process code` - -Hangs here doesn't mean it stop working, just wait around 2 mins. Webpack dev server is quite slow, but will finally finished. - -If you are not sure, try `pnpm run start:dev:debug-webpack`, which will also enables `WebpackBar` plugin. - -### Why not using Vite? - -1. Wait for to replace `ThreadsPlugin` -2. Need to replace `inversify-inject-decorators` and `typeorm` that uses decorator first - ## Error: ENOTDIR, not a directory at createError or supportedLanguages.json: ENOENT May be `src/constants/paths.ts` have wrong value of `__dirname` or `process.resourcesPath` after package, like being `C:\Users\linonetwo\Documents\repo-c\TidGi-Desktop\out\TidGi-win32-x64\resources\app.asar\xxx` @@ -204,3 +193,9 @@ Check `src/constants/appPaths.ts` and `src/constants/paths.ts` ## error: Your local changes to the following files would be overwritten by checkout Clean up the local `template/wiki` folder. You can simply "Discard change" of that path, using github desktop. + +## Any error that persist and is from old version of package in node_modules + +Like you use `pnpm link`, you update the package on another side, but this side is not changed. + +Try clean the cache by `pnpm run clean:cache`. diff --git a/docs/Testing.md b/docs/Testing.md index 30858408..41e87c48 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -19,6 +19,7 @@ pnpm test:e2e pnpm test:e2e --tags="@smoke" # Or run a single e2e test by `--name` pnpm test:e2e --name "Wiki-search tool usage" # Not `-- --name` , and not `name`, is is just `--name` and have "" around the value, not omitting `--name` +# Don't directly concat filename after pnpm test:e2e, only unit test can do that, e2e test can't. # Run with coverage pnpm test:unit -- --coverage @@ -32,6 +33,10 @@ cross-env NODE_ENV=test pnpm dlx tsx ./scripts/start-e2e-app.ts Except for above parameters, AI agent can't use other parameters, otherwise complex shell command usage or parameters will require human approval and may not passed. +### Long running script + +`prepare` and `test` may run for a long time. Don't execute any shell command like `echo "waiting"` or `Start-Sleep -Seconds 5;`, they are useless, and only will they interrupt the command. You need to check active terminal output in a loop until you see it is truly done. + ## Project Setup Test Configuration: TypeScript-first with `vitest.config.ts` @@ -58,6 +63,8 @@ features/ # E2E tests out/ # `test:prepare-e2e` Bundled production app to test userData-test/ # User setting folder created during `test:e2e` userData-dev/ # User setting folder created during `start:dev` +wiki-test/ # containing wiki folders created during `test:e2e` +wiki-dev/ # containing wiki folders created during `start:dev` ``` ## Writing Unit Tests @@ -295,6 +302,8 @@ When AI is fixing issues, you can let it add more logs for troubleshooting, and If you want to send frontend log to the log file, you can't directly use `import { logger } from '@services/libs/log';` you need to use `void window.service.native.log('error', 'Renderer: xxx', { ...additionalMetadata });`. Otherwise you will get [Can't resolve 'os' error](./ErrorDuringStart.md) +Only use VSCode tool to read file. Don't ever use shell command to read file. + ## User profile When running tests — especially E2E or other tests that start an Electron instance — the test runner will set Electron's `userData` to `userData-test`. This ensures the test process uses a separate configuration and data directory from any development or production TidGi instance, and prevents accidental triggering of Electron's single-instance lock. diff --git a/docs/internal/ServiceIPC.md b/docs/internal/ServiceIPC.md index 600b36af..db042477 100644 --- a/docs/internal/ServiceIPC.md +++ b/docs/internal/ServiceIPC.md @@ -8,6 +8,32 @@ See [this 6aedff4b commit](https://github.com/tiddly-gittly/TidGi-Desktop/commit - [src/services/serviceIdentifier.ts](../../src/services/serviceIdentifier.ts) for IoC id - [src/services/libs/bindServiceAndProxy.ts](../../src/services/libs/bindServiceAndProxy.ts) for dependency injection in inversifyjs +## Register service for worker threads + +If you need to expose a service to worker threads (e.g., for TiddlyWiki plugins running in wiki worker), register it in the `registerServicesForWorkers()` function in [src/services/libs/bindServiceAndProxy.ts](../../src/services/libs/bindServiceAndProxy.ts). + +Example: + +```typescript +function registerServicesForWorkers(workspaceService: IWorkspaceService): void { + registerServiceForWorker('workspace', { + get: workspaceService.get.bind(workspaceService) as (...arguments_: unknown[]) => unknown, + getWorkspacesAsList: workspaceService.getWorkspacesAsList.bind(workspaceService) as (...arguments_: unknown[]) => unknown, + }); +} +``` + +Worker threads can then call these services using: + +```typescript +import { callMainProcessService } from '@services/wiki/wikiWorker/workerServiceCaller'; + +const workspace = await callMainProcessService('workspace', 'get', [workspaceId]); +const allWorkspaces = await callMainProcessService('workspace', 'getWorkspacesAsList', []); +``` + +See [src/services/wiki/wikiWorker/workerServiceCaller.ts](../../src/services/wiki/wikiWorker/workerServiceCaller.ts) for the worker-side implementation. + ## Sync service Some services are sync, like `getSubWorkspacesAsListSync` `getActiveWorkspaceSync` from `src/services/workspaces/index.ts`, they can't be called from renderer, only can be used in the main process. @@ -40,3 +66,30 @@ useObservable(workspace$, workspaceSetter); ``` or in store use rxjs like `window.observables.workspace.get$(id).observe()`. + +## IPC Communication Architecture + +TidGi uses multiple IPC mechanisms for different scenarios: + +### 1. Main Process ↔ Renderer Process (electron-ipc-cat) + +Used for UI-related service calls (e.g., preferences, workspaces, windows). + +- **Registration**: `src/services/libs/bindServiceAndProxy.ts` - `registerProxy()` +- **Renderer access**: `window.service.*` or `window.observables.*` +- **Implementation**: electron-ipc-cat library + +### 2. Main Process ↔ Worker Threads (worker_threads) + +Used for TiddlyWiki plugins running in wiki workers to access TidGi services. + +- **Registration**: `src/services/libs/bindServiceAndProxy.ts` - `registerServicesForWorkers()` +- **Worker access**: `callMainProcessService(serviceName, methodName, args)` +- **Implementation**: Custom worker_threads IPC in `src/services/libs/workerAdapter.ts` +- **Use case**: Watch Filesystem Adaptor plugin querying workspace information + +Key differences: + +- Worker IPC is **method-based** (not proxy-based) +- Worker IPC is **async only** (no observables) +- Worker IPC requires **explicit registration** of each method diff --git a/features/agent.feature b/features/agent.feature index dc42e2cb..1f55e818 100644 --- a/features/agent.feature +++ b/features/agent.feature @@ -43,7 +43,7 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation Scenario: Wiki operation Given I have started the mock OpenAI server | response | stream | - | 先测试失败情况{"workspaceName":"default","operation":"wiki-add-tiddler","title":"testNote","text":"test"} | false | + | 先测试失败情况{"workspaceName":"test-expected-to-fail","operation":"wiki-add-tiddler","title":"testNote","text":"test"} | false | | 然后测试成功情况{"workspaceName":"wiki","operation":"wiki-add-tiddler","title":"test","text":"这是测试内容"}使用启动时自动创建的 wiki 工作区 | false | | 已成功在工作区 wiki 中创建条目 "test"。 | false | # Step 1: Start a fresh tab and run the two-round wiki operation flow @@ -55,13 +55,13 @@ Feature: Agent Workflow - Tool Usage and Multi-Round Conversation # Step 3: Select agent from autocomplete (not new tab) When I click on an "agent suggestion" element with selector '[data-autocomplete-source-id="agentsSource"] .aa-ItemWrapper' And I should see a "message input box" element with selector "[data-testid='agent-message-input']" - # First round: try create note using default workspace (expected to fail) + # First round: try create note using test-expected-to-fail workspace (expected to fail) When I click on a "message input textarea" element with selector "[data-testid='agent-message-input']" When I type "在 wiki 里创建一个新笔记,内容为 test" in "chat input" element with selector "[data-testid='agent-message-input']" And I press "Enter" key Then I should see 6 messages in chat history # Verify there's an error message about workspace not found (in one of the middle messages) - And I should see a "workspace not exist error" element with selector "[data-testid='message-bubble']:has-text('default'):has-text('不存在')" + And I should see a "workspace not exist error" element with selector "[data-testid='message-bubble']:has-text('test-expected-to-fail'):has-text('不存在')" # Verify the last message contains success confirmation And I should see "success in last message and wiki workspace in last message" elements with selectors: | [data-testid='message-bubble']:last-child:has-text('已成功') | diff --git a/features/filesystemPlugin.feature b/features/filesystemPlugin.feature new file mode 100644 index 00000000..cd07c506 --- /dev/null +++ b/features/filesystemPlugin.feature @@ -0,0 +1,156 @@ +Feature: Filesystem Plugin + As a user + I want tiddlers with specific tags to be saved to sub-wikis automatically + So that I can organize content across wikis + + Background: + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + + @subwiki + Scenario: Tiddler with tag saves to sub-wiki folder + # Create sub-workspace linked to the default wiki + When I click on an "add workspace button" element with selector "#add-workspace-button" + And I switch to "addWorkspace" window + # Toggle to sub-workspace mode by clicking the switch + And I click on a "main/sub workspace switch" element with selector "[data-testid='main-sub-workspace-switch']" + # Select the first (default) wiki workspace from dropdown + And I select "wiki" from MUI Select with test id "main-wiki-select" + # Type folder name + And I type "SubWiki" in "sub wiki folder name input" element with selector "input[aria-describedby*='-helper-text'][value='wiki']" + And I type "TestTag" in "tag name input" element with selector "[data-testid='tagname-autocomplete-input']" + And I click on a "create sub workspace button" element with selector "button.MuiButton-colorSecondary" + And I switch to "main" window + Then I should see a "SubWiki workspace" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')" + # Switch to default wiki and create tiddler with tag + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + And I click on "add tiddler button" element in browser view with selector "button[aria-label='添加条目']" + And I wait for 0.2 seconds + And I type "Test Tiddler Title" in "title input" element in browser view with selector "input.tc-titlebar.tc-edit-texteditor" + And I type "TestTag" in "tag input" element in browser view with selector "input.tc-edit-texteditor.tc-popup-handle" + And I press "Enter" in browser view + And I click on "confirm button" element in browser view with selector "button[aria-label='确定对此条目的更改']" + # Verify the tiddler file exists in sub-wiki folder (not in tiddlers subfolder) + Then file "Test Tiddler Title.tid" should exist in "{tmpDir}/SubWiki" + + @file-watching + Scenario: External file creation syncs to wiki + # Create a test tiddler file directly on filesystem + When I create file "{tmpDir}/wiki/tiddlers/WatchTestTiddler.tid" with content: + """ + created: 20250226070000000 + modified: 20250226070000000 + title: WatchTestTiddler + + Initial content from filesystem + """ + # Wait for watch-fs to detect and add the tiddler + Then I wait for tiddler "WatchTestTiddler" to be added by watch-fs + # Open sidebar "最近" tab to see the timeline + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 0.5 seconds + # Click on the tiddler link in timeline to open it + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('WatchTestTiddler')" + # Verify the tiddler content is displayed + Then I should see "Initial content from filesystem" in the browser view content + + @file-watching + Scenario: External file modification and deletion sync to wiki + # Create initial file + When I create file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" with content: + """ + created: 20250226070000000 + modified: 20250226070000000 + title: TestTiddler + + Original content + """ + Then I wait for tiddler "TestTiddler" to be added by watch-fs + # Open the tiddler to view it + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 0.5 seconds + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')" + Then I should see "Original content" in the browser view content + # Modify the file externally + When I modify file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" to contain "Modified content from external editor" + Then I wait for tiddler "TestTiddler" to be updated by watch-fs + # Verify the wiki shows updated content (should auto-refresh) + Then I should see "Modified content from external editor" in the browser view content + # Now delete the file externally + When I delete file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" + Then I wait for tiddler "TestTiddler" to be deleted by watch-fs + # Re-open timeline to see updated list + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + # The timeline should not have a clickable link to TestTiddler anymore + Then I should not see a "TestTiddler timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')" + + @file-watching + Scenario: External file rename syncs to wiki + # Create initial file + When I create file "{tmpDir}/wiki/tiddlers/OldName.tid" with content: + """ + created: 20250226070000000 + modified: 20250226070000000 + title: OldName + + Content before rename + """ + Then I wait for tiddler "OldName" to be added by watch-fs + # Open sidebar to see the timeline + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 0.5 seconds + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('OldName')" + Then I should see "Content before rename" in the browser view content + # Rename the file externally + When I rename file "{tmpDir}/wiki/tiddlers/OldName.tid" to "{tmpDir}/wiki/tiddlers/NewName.tid" + # Update the title field in the renamed file to match the new filename + And I modify file "{tmpDir}/wiki/tiddlers/NewName.tid" to contain: + """ + created: 20250226070000000 + modified: 20250226070000000 + title: NewName + + Content before rename + """ + # Wait for the new tiddler to be detected and synced + Then I wait for tiddler "NewName" to be updated by watch-fs + # Navigate to timeline to verify changes + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 0.5 seconds + # Verify new name appears + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('NewName')" + Then I should see "Content before rename" in the browser view content + + @file-watching + Scenario: External field modification syncs to wiki + # Modify an existing tiddler file by adding a tags field to TiddlyWikiIconBlue.png + When I modify file "{tmpDir}/wiki/tiddlers/TiddlyWikiIconBlue.png.tid" to add field "tags: TestTag" + Then I wait for tiddler "TiddlyWikiIconBlue.png" to be updated by watch-fs + # Open the tiddler to verify the tag was added + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 1 seconds + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TiddlyWikiIconBlue.png')" + And I wait for 1 seconds + # Verify the tag appears in the tiddler using data attribute + Then I should see a "TestTag tag" element in browser view with selector "[data-tiddler-title='TiddlyWikiIconBlue.png'] [data-tag-title='TestTag']" + # Now modify Index.tid by adding a tags field + When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to add field "tags: AnotherTag" + Then I wait for tiddler "Index" to be updated by watch-fs + And I wait for 1 seconds + # Index is displayed by default, verify the AnotherTag appears in Index tiddler + Then I should see a "AnotherTag tag" element in browser view with selector "[data-tiddler-title='Index'] [data-tag-title='AnotherTag']" + # Modify favicon.ico.meta file by adding a tags field + When I modify file "{tmpDir}/wiki/tiddlers/favicon.ico.meta" to add field "tags: IconTag" + Then I wait for tiddler "favicon.ico" to be updated by watch-fs + # Navigate to favicon.ico tiddler + And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + And I wait for 0.5 seconds + And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink[href='#favicon.ico']" + And I wait for 1 seconds + # Verify the IconTag appears in favicon.ico tiddler + Then I should see a "IconTag tag" element in browser view with selector "[data-tiddler-title='favicon.ico'] [data-tag-title='IconTag']" diff --git a/features/logging.feature b/features/logging.feature index b1ec4449..8a092f9b 100644 --- a/features/logging.feature +++ b/features/logging.feature @@ -5,9 +5,10 @@ Feature: Renderer logging to backend (UI-driven) And I wait for the page to load completely @logging - Scenario: Renderer logs appear in backend log file + Scenario: Renderer logs appear in backend log file and Wiki worker logs appear in same log directory When I click on a "settings button" element with selector "#open-preferences-button" When I switch to "preferences" window When I click on a "sync section" element with selector "[data-testid='preference-section-sync']" Then I should find log entries containing - | Preferences section clicked | + | test-id-Preferences section clicked | + | test-id-WorkerServicesReady | diff --git a/features/oauthLogin.feature b/features/oauthLogin.feature index 147d1369..b2cc07ef 100644 --- a/features/oauthLogin.feature +++ b/features/oauthLogin.feature @@ -8,7 +8,7 @@ Feature: OAuth Login Flow And I wait for the page to load completely And I should see a "page body" element with selector "body" - @oauth @pkce + @oauth Scenario: Login with Custom OAuth Server using PKCE # Step 1: Start Mock OAuth Server When I start Mock OAuth Server on port 8888 diff --git a/features/preference.feature b/features/preference.feature index d722a382..c75efaa5 100644 --- a/features/preference.feature +++ b/features/preference.feature @@ -8,7 +8,7 @@ Feature: TidGi Preference And I wait for the page to load completely And I should see a "page body" element with selector "body" - @setup + @ai-setting Scenario: Configure AI provider and default model # Step 1: Configure AI settings first - Open preferences window, wait a second so its URL settle down. When I click on a "settings button" element with selector "#open-preferences-button" diff --git a/features/stepDefinitions/agent.ts b/features/stepDefinitions/agent.ts index bc26d282..dfbd0b17 100644 --- a/features/stepDefinitions/agent.ts +++ b/features/stepDefinitions/agent.ts @@ -1,5 +1,6 @@ import { After, DataTable, Given, Then } from '@cucumber/cucumber'; import { AIGlobalSettings, AIProviderConfig } from '@services/externalAPI/interface'; +import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import { isEqual, omit } from 'lodash'; import path from 'path'; @@ -8,6 +9,13 @@ import { MockOpenAIServer } from '../supports/mockOpenAI'; import { settingsPath } from '../supports/paths'; import type { ApplicationWorld } from './application'; +// Backoff configuration for retries +const BACKOFF_OPTIONS = { + numOfAttempts: 10, + startingDelay: 200, + timeMultiple: 1.5, +}; + /** * Generate deterministic embedding vector based on a semantic tag * This allows us to control similarity in tests without writing full 384-dim vectors @@ -61,9 +69,9 @@ Given('I have started the mock OpenAI server', function(this: ApplicationWorld, // Skip header row for (let index = 1; index < rows.length; index++) { const row = rows[index]; - const response = String(row[0] ?? '').trim(); - const stream = String(row[1] ?? '').trim().toLowerCase() === 'true'; - const embeddingTag = String(row[2] ?? '').trim(); + const response = (row[0] ?? '').trim(); + const stream = (row[1] ?? '').trim().toLowerCase() === 'true'; + const embeddingTag = (row[2] ?? '').trim(); // Generate embedding from semantic tag if provided let embedding: number[] | undefined; @@ -111,43 +119,36 @@ Then('I should see {int} messages in chat history', async function(this: Applica throw new Error('No current window is available'); } - // Use precise selector based on the provided HTML structure const messageSelector = '[data-testid="message-bubble"]'; - try { - // Wait for messages to reach expected count, checking periodically for streaming - for (let attempt = 1; attempt <= expectedCount * 3; attempt++) { - try { - // Wait for at least one message to exist - await currentWindow.waitForSelector(messageSelector, { timeout: 5000 }); + await backOff( + async () => { + // Wait for at least one message to exist + await currentWindow.waitForSelector(messageSelector, { timeout: 5000 }); - // Count current messages - const messages = currentWindow.locator(messageSelector); - const currentCount = await messages.count(); + // Count current messages + const messages = currentWindow.locator(messageSelector); + const currentCount = await messages.count(); - if (currentCount === expectedCount) { - return; - } else if (currentCount > expectedCount) { - throw new Error(`Expected ${expectedCount} messages but found ${currentCount} (too many)`); - } - - // If not enough messages yet, wait a bit more for streaming - if (attempt < expectedCount * 3) { - await currentWindow.waitForTimeout(2000); - } - } catch (timeoutError) { - if (attempt === expectedCount * 3) { - throw timeoutError; - } + if (currentCount === expectedCount) { + return; // Success + } else if (currentCount > expectedCount) { + throw new Error(`Expected ${expectedCount} messages but found ${currentCount} (too many)`); + } else { + // Not enough messages yet, throw to trigger retry + throw new Error(`Expected ${expectedCount} messages but found ${currentCount}`); } + }, + BACKOFF_OPTIONS, + ).catch(async (error: unknown) => { + // Get final count for error message + try { + const finalCount = await currentWindow.locator(messageSelector).count(); + throw new Error(`Could not find expected ${expectedCount} messages. Found ${finalCount}. Error: ${(error as Error).message}`); + } catch { + throw new Error(`Could not find expected ${expectedCount} messages. Error: ${(error as Error).message}`); } - - // Final attempt to get the count - const finalCount = await currentWindow.locator(messageSelector).count(); - throw new Error(`Expected ${expectedCount} messages but found ${finalCount} after waiting for streaming to complete`); - } catch (error) { - throw new Error(`Could not find expected ${expectedCount} messages. Error: ${(error as Error).message}`); - } + }); }); Then('the last AI request should contain system prompt {string}', async function(this: ApplicationWorld, expectedPrompt: string) { diff --git a/features/stepDefinitions/application.ts b/features/stepDefinitions/application.ts index 413fb14c..89a33e53 100644 --- a/features/stepDefinitions/application.ts +++ b/features/stepDefinitions/application.ts @@ -1,4 +1,5 @@ -import { After, AfterStep, Before, setWorldConstructor, When } from '@cucumber/cucumber'; +import { AfterStep, setDefaultTimeout, setWorldConstructor, When } from '@cucumber/cucumber'; +import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import path from 'path'; import { _electron as electron } from 'playwright'; @@ -6,10 +7,15 @@ import type { ElectronApplication, Page } from 'playwright'; import { windowDimension, WindowNames } from '../../src/services/windows/WindowProperties'; import { MockOAuthServer } from '../supports/mockOAuthServer'; import { MockOpenAIServer } from '../supports/mockOpenAI'; -import { logsDirectory, makeSlugPath, screenshotsDirectory } from '../supports/paths'; +import { makeSlugPath, screenshotsDirectory } from '../supports/paths'; import { getPackedAppPath } from '../supports/paths'; -import { clearAISettings } from './agent'; -import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; + +// Backoff configuration for retries +const BACKOFF_OPTIONS = { + numOfAttempts: 8, + startingDelay: 100, + timeMultiple: 2, +}; // Helper function to check if window type is valid and return the corresponding WindowNames export function checkWindowName(windowType: string): WindowNames { @@ -51,23 +57,25 @@ export class ApplicationWorld { async waitForWindowCondition( windowType: string, condition: (window: Page | undefined, isVisible: boolean) => boolean, - maxAttempts: number = 3, - retryInterval: number = 250, ): Promise { if (!this.app) return false; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const targetWindow = await this.findWindowByType(windowType); - const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false; + try { + await backOff( + async () => { + const targetWindow = await this.findWindowByType(windowType); + const visible = targetWindow ? await this.isWindowVisible(targetWindow) : false; - if (condition(targetWindow, visible)) { - return true; - } - - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, retryInterval)); + if (!condition(targetWindow, visible)) { + throw new Error('Condition not met'); + } + }, + BACKOFF_OPTIONS, + ); + return true; + } catch { + return false; } - return false; } // Helper method to find window by type - strict WindowNames matching @@ -158,80 +166,33 @@ export class ApplicationWorld { return this.currentWindow; } - // Use the findWindowByType method with retry logic - for (let attempt = 0; attempt < 3; attempt++) { - try { - const window = await this.findWindowByType(windowType); - if (window) return window; - } catch (error) { - // If it's an invalid window type error, throw immediately - if (error instanceof Error && error.message.includes('is not a valid WindowNames')) { - throw error; - } - } - - // If window not found, wait and retry (except for the last attempt) - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 1000)); + // Use the findWindowByType method with retry logic using backoff + try { + return await backOff( + async () => { + const window = await this.findWindowByType(windowType); + if (!window) { + throw new Error(`Window ${windowType} not found`); + } + return window; + }, + BACKOFF_OPTIONS, + ); + } catch (error) { + // If it's an invalid window type error, re-throw it + if (error instanceof Error && error.message.includes('is not a valid WindowNames')) { + throw error; } + return undefined; } - - return undefined; } } setWorldConstructor(ApplicationWorld); -// setDefaultTimeout(50000); - -Before(function(this: ApplicationWorld, { pickle }) { - // Create necessary directories under userData-test/logs to match appPaths in dev/test - if (!fs.existsSync(logsDirectory)) { - fs.mkdirSync(logsDirectory, { recursive: true }); - } - - // Create screenshots subdirectory in logs - if (!fs.existsSync(screenshotsDirectory)) { - fs.mkdirSync(screenshotsDirectory, { recursive: true }); - } - - if (pickle.tags.some((tag) => tag.name === '@setup')) { - clearAISettings(); - } - if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) { - clearTidgiMiniWindowSettings(); - } -}); - -After(async function(this: ApplicationWorld, { pickle }) { - if (this.app) { - try { - // Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C - const allWindows = this.app.windows(); - for (const window of allWindows) { - try { - if (!window.isClosed()) { - await window.close(); - } - } catch (error) { - console.error('Error closing window:', error); - } - } - await this.app.close(); - } catch (error) { - console.error('Error during cleanup:', error); - } - this.app = undefined; - this.mainWindow = undefined; - this.currentWindow = undefined; - } - if (pickle.tags.some((tag) => tag.name === '@tidgiminiwindow')) { - clearTidgiMiniWindowSettings(); - } - if (pickle.tags.some((tag) => tag.name === '@setup')) { - clearAISettings(); - } -}); +if (process.env.CI) { + setDefaultTimeout(50000); +} AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) { // Only take screenshots in CI environment diff --git a/features/stepDefinitions/browserView.ts b/features/stepDefinitions/browserView.ts index 8393c7f1..f51cce28 100644 --- a/features/stepDefinitions/browserView.ts +++ b/features/stepDefinitions/browserView.ts @@ -1,10 +1,14 @@ -import { Then } from '@cucumber/cucumber'; -import { getDOMContent, getTextContent, isLoaded } from '../supports/webContentsViewHelper'; +import { Then, When } from '@cucumber/cucumber'; +import { backOff } from 'exponential-backoff'; +import { clickElement, clickElementWithText, elementExists, getDOMContent, getTextContent, isLoaded, pressKey, typeText } from '../supports/webContentsViewHelper'; import type { ApplicationWorld } from './application'; -// Constants for retry logic -const MAX_ATTEMPTS = 3; -const RETRY_INTERVAL_MS = 500; +// Backoff configuration for retries +const BACKOFF_OPTIONS = { + numOfAttempts: 8, + startingDelay: 100, + timeMultiple: 2, +}; Then('I should see {string} in the browser view content', async function(this: ApplicationWorld, expectedText: string) { if (!this.app) { @@ -15,29 +19,20 @@ Then('I should see {string} in the browser view content', async function(this: A throw new Error('No current window available'); } - // Retry logic to check for expected text in content - const maxAttempts = MAX_ATTEMPTS; - const retryInterval = RETRY_INTERVAL_MS; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const content = await getTextContent(this.app); - if (content && content.includes(expectedText)) { - return; // Success, exit early - } - - // Wait before retrying (except for the last attempt) - if (attempt < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, retryInterval)); - } - } - - // Final attempt to get content for error message - const finalContent = await getTextContent(this.app); - throw new Error( - `Expected text "${expectedText}" not found in browser view content after ${MAX_ATTEMPTS} attempts. Actual content: ${ - finalContent ? finalContent.substring(0, 200) + '...' : 'null' - }`, - ); + await backOff( + async () => { + const content = await getTextContent(this.app!); + if (!content || !content.includes(expectedText)) { + throw new Error(`Expected text "${expectedText}" not found`); + } + }, + BACKOFF_OPTIONS, + ).catch(async () => { + const finalContent = await getTextContent(this.app!); + throw new Error( + `Expected text "${expectedText}" not found in browser view content. Actual content: ${finalContent ? finalContent.substring(0, 200) + '...' : 'null'}`, + ); + }); }); Then('I should see {string} in the browser view DOM', async function(this: ApplicationWorld, expectedText: string) { @@ -49,29 +44,20 @@ Then('I should see {string} in the browser view DOM', async function(this: Appli throw new Error('No current window available'); } - // Retry logic to check for expected text in DOM - const maxAttempts = MAX_ATTEMPTS; - const retryInterval = RETRY_INTERVAL_MS; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const domContent = await getDOMContent(this.app); - if (domContent && domContent.includes(expectedText)) { - return; // Success, exit early - } - - // Wait before retrying (except for the last attempt) - if (attempt < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, retryInterval)); - } - } - - // Final attempt to get DOM content for error message - const finalDomContent = await getDOMContent(this.app); - throw new Error( - `Expected text "${expectedText}" not found in browser view DOM after ${MAX_ATTEMPTS} attempts. Actual DOM: ${ - finalDomContent ? finalDomContent.substring(0, 200) + '...' : 'null' - }`, - ); + await backOff( + async () => { + const domContent = await getDOMContent(this.app!); + if (!domContent || !domContent.includes(expectedText)) { + throw new Error(`Expected text "${expectedText}" not found in DOM`); + } + }, + BACKOFF_OPTIONS, + ).catch(async () => { + const finalDomContent = await getDOMContent(this.app!); + throw new Error( + `Expected text "${expectedText}" not found in browser view DOM. Actual DOM: ${finalDomContent ? finalDomContent.substring(0, 200) + '...' : 'null'}`, + ); + }); }); Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) { @@ -83,21 +69,125 @@ Then('the browser view should be loaded and visible', async function(this: Appli throw new Error('No current window available'); } - // Retry logic to check if browser view is loaded - const maxAttempts = MAX_ATTEMPTS; - const retryInterval = RETRY_INTERVAL_MS; + await backOff( + async () => { + const isLoadedResult = await isLoaded(this.app!); + if (!isLoadedResult) { + throw new Error('Browser view not loaded'); + } + }, + BACKOFF_OPTIONS, + ).catch(() => { + throw new Error('Browser view is not loaded or visible after multiple attempts'); + }); +}); - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const isLoadedResult = await isLoaded(this.app); - if (isLoadedResult) { - return; // Success, exit early - } - - // Wait before retrying (except for the last attempt) - if (attempt < maxAttempts - 1) { - await new Promise(resolve => setTimeout(resolve, retryInterval)); - } +When('I click on {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) { + if (!this.app) { + throw new Error('Application not launched'); } - throw new Error(`Browser view is not loaded or visible after ${MAX_ATTEMPTS} attempts`); + try { + // Check if selector contains :has-text() pseudo-selector + const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/); + + if (hasTextMatch) { + // Extract base selector and text content + const baseSelector = hasTextMatch[1]; + const textContent = hasTextMatch[2]; + await clickElementWithText(this.app, baseSelector, textContent); + } else { + // Use regular selector + await clickElement(this.app, selector); + } + } catch (error) { + throw new Error(`Failed to click ${elementComment} element with selector "${selector}" in browser view: ${error as Error}`); + } +}); + +When('I type {string} in {string} element in browser view with selector {string}', async function(this: ApplicationWorld, text: string, elementComment: string, selector: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + try { + await typeText(this.app, selector, text); + } catch (error) { + throw new Error(`Failed to type in ${elementComment} element with selector "${selector}" in browser view: ${error as Error}`); + } +}); + +When('I press {string} in browser view', async function(this: ApplicationWorld, key: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + try { + await pressKey(this.app, key); + } catch (error) { + throw new Error(`Failed to press key "${key}" in browser view: ${error as Error}`); + } +}); + +Then('I should not see {string} in the browser view content', async function(this: ApplicationWorld, unexpectedText: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + if (!this.currentWindow) { + throw new Error('No current window available'); + } + + // Wait a bit for UI to update + await new Promise(resolve => setTimeout(resolve, 500)); + + // Check that text does not exist in content + const content = await getTextContent(this.app); + if (content && content.includes(unexpectedText)) { + throw new Error(`Unexpected text "${unexpectedText}" found in browser view content`); + } +}); + +Then('I should not see a(n) {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + if (!this.currentWindow) { + throw new Error('No current window available'); + } + + await backOff( + async () => { + const exists: boolean = await elementExists(this.app!, selector); + if (exists) { + throw new Error('Element still exists'); + } + }, + BACKOFF_OPTIONS, + ).catch(() => { + throw new Error(`Element "${elementComment}" with selector "${selector}" was found in browser view after multiple attempts, but should not be visible`); + }); +}); + +Then('I should see a(n) {string} element in browser view with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + if (!this.currentWindow) { + throw new Error('No current window available'); + } + + await backOff( + async () => { + const exists: boolean = await elementExists(this.app!, selector); + if (!exists) { + throw new Error('Element does not exist yet'); + } + }, + BACKOFF_OPTIONS, + ).catch(() => { + throw new Error(`Element "${elementComment}" with selector "${selector}" not found in browser view after multiple attempts`); + }); }); diff --git a/features/stepDefinitions/cleanup.ts b/features/stepDefinitions/cleanup.ts new file mode 100644 index 00000000..5ae27eee --- /dev/null +++ b/features/stepDefinitions/cleanup.ts @@ -0,0 +1,59 @@ +import { After, Before } from '@cucumber/cucumber'; +import fs from 'fs-extra'; +import { logsDirectory, screenshotsDirectory } from '../supports/paths'; +import { clearAISettings } from './agent'; +import { ApplicationWorld } from './application'; +import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; +import { clearSubWikiRoutingTestData } from './wiki'; + +Before(function(this: ApplicationWorld, { pickle }) { + // Create necessary directories under userData-test/logs to match appPaths in dev/test + if (!fs.existsSync(logsDirectory)) { + fs.mkdirSync(logsDirectory, { recursive: true }); + } + + // Create screenshots subdirectory in logs + if (!fs.existsSync(screenshotsDirectory)) { + fs.mkdirSync(screenshotsDirectory, { recursive: true }); + } + + if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { + clearAISettings(); + } + if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { + clearTidgiMiniWindowSettings(); + } +}); + +After(async function(this: ApplicationWorld, { pickle }) { + if (this.app) { + try { + // Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C + const allWindows = this.app.windows(); + for (const window of allWindows) { + try { + if (!window.isClosed()) { + await window.close(); + } + } catch (error) { + console.error('Error closing window:', error); + } + } + await this.app.close(); + } catch (error) { + console.error('Error during cleanup:', error); + } + this.app = undefined; + this.mainWindow = undefined; + this.currentWindow = undefined; + } + if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { + clearTidgiMiniWindowSettings(); + } + if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { + clearAISettings(); + } + if (pickle.tags.some((tag) => tag.name === '@subwiki')) { + clearSubWikiRoutingTestData(); + } +}); diff --git a/features/stepDefinitions/logging.ts b/features/stepDefinitions/logging.ts index 9bf9a4ba..4a4351d9 100644 --- a/features/stepDefinitions/logging.ts +++ b/features/stepDefinitions/logging.ts @@ -8,14 +8,23 @@ import { ApplicationWorld } from './application'; Then('I should find log entries containing', async function(this: ApplicationWorld, dataTable: DataTable | undefined) { const expectedRows = dataTable?.raw().map((r: string[]) => r[0]); - // Only consider normal daily log files like TidGi-2025-08-27.log and exclude exception logs - const files = fs.readdirSync(logsDirectory).filter((f) => /TidGi-\d{4}-\d{2}-\d{2}\.log$/.test(f)); - const latestLogFilePath = files.length > 0 ? files.sort().reverse()[0] : null; - const content = latestLogFilePath ? fs.readFileSync(path.join(logsDirectory, latestLogFilePath), 'utf8') : ''; + // Consider all log files in logs directory (including wiki logs like wiki-2025-10-25.log) + const files = fs.readdirSync(logsDirectory).filter((f) => f.endsWith('.log')); + + const missing = expectedRows?.filter((expectedRow: string) => { + // Check if any log file contains this expected content + return !files.some((file) => { + try { + const content = fs.readFileSync(path.join(logsDirectory, file), 'utf8'); + return content.includes(expectedRow); + } catch { + return false; + } + }); + }); - const missing = expectedRows?.filter((r: string) => !content.includes(r)); if (missing?.length) { - throw new Error(`Missing expected log messages "${missing.map(item => item.slice(0, 10)).join('...", "')}..." on latest log file: ${latestLogFilePath}`); + throw new Error(`Missing expected log messages in ${files.length} log file(s)`); } }); diff --git a/features/stepDefinitions/ui.ts b/features/stepDefinitions/ui.ts index 5baa463d..50a094e0 100644 --- a/features/stepDefinitions/ui.ts +++ b/features/stepDefinitions/ui.ts @@ -466,3 +466,94 @@ When('I select {string} from MUI Select with test id {string}', async function(t throw new Error(`Failed to select option "${optionValue}" from MUI Select with test id "${testId}": ${String(error)}`); } }); + +// Debug step to print current DOM structure +When('I print current DOM structure', async function(this: ApplicationWorld) { + const currentWindow = this.currentWindow; + if (!currentWindow) { + throw new Error('No current window is available'); + } + + const html = await currentWindow.evaluate(() => { + return document.body.innerHTML; + }); + + console.log('=== Current DOM Structure ==='); + console.log(html.substring(0, 5000)); // Print first 5000 characters + console.log('=== End DOM Structure ==='); +}); + +// Debug step to print DOM structure of a specific element +When('I print DOM structure of element with selector {string}', async function(this: ApplicationWorld, selector: string) { + const currentWindow = this.currentWindow; + if (!currentWindow) { + throw new Error('No current window is available'); + } + + try { + await currentWindow.waitForSelector(selector, { timeout: 5000 }); + + const elementInfo = await currentWindow.evaluate((sel) => { + const element = document.querySelector(sel); + if (!element) { + return { found: false }; + } + + return { + found: true, + outerHTML: element.outerHTML, + innerHTML: element.innerHTML, + attributes: Array.from(element.attributes).map(attribute => ({ + name: attribute.name, + value: attribute.value, + })), + children: Array.from(element.children).map(child => ({ + tagName: child.tagName, + className: child.className, + id: child.id, + attributes: Array.from(child.attributes).map(attribute => ({ + name: attribute.name, + value: attribute.value, + })), + })), + }; + }, selector); + + if (!elementInfo.found) { + console.log(`=== Element "${selector}" not found ===`); + return; + } + + console.log(`=== DOM Structure of "${selector}" ===`); + console.log('Attributes:', JSON.stringify(elementInfo.attributes, null, 2)); + console.log('\nChildren:', JSON.stringify(elementInfo.children, null, 2)); + console.log('\nOuter HTML (first 2000 chars):'); + console.log((elementInfo.outerHTML ?? '').substring(0, 2000)); + console.log('=== End DOM Structure ==='); + } catch (error) { + console.log(`Error inspecting element "${selector}": ${String(error)}`); + } +}); + +// Debug step to print all window URLs +When('I print all window URLs', async function(this: ApplicationWorld) { + if (!this.app) { + throw new Error('Application is not available'); + } + + const allWindows = this.app.windows(); + console.log(`=== Total windows: ${allWindows.length} ===`); + + for (let index = 0; index < allWindows.length; index++) { + const win = allWindows[index]; + try { + const url = win.url(); + const title = await win.title(); + const isClosed = win.isClosed(); + console.log(`Window ${index}: URL=${url}, Title=${title}, Closed=${isClosed}`); + } catch (error) { + console.log(`Window ${index}: Error getting info - ${String(error)}`); + } + } + console.log('=== End Window List ==='); +}); diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index cfae5943..6c1d2771 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -1,11 +1,152 @@ -import { When } from '@cucumber/cucumber'; +import { Then, When } from '@cucumber/cucumber'; import fs from 'fs-extra'; +import path from 'path'; import type { IWorkspace } from '../../src/services/workspaces/interface'; -import { settingsPath, wikiTestWikiPath } from '../supports/paths'; +import { settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; +import type { ApplicationWorld } from './application'; + +/** + * Wait for both SSE and watch-fs to be ready and stabilized. + * This combines the checks for test-id-SSE_READY and test-id-WATCH_FS_STABILIZED markers. + */ +async function waitForSSEAndWatchFsReady(maxWaitMs = 15000): Promise { + const logPath = path.join(process.cwd(), 'userData-test', 'logs'); + const startTime = Date.now(); + let sseReady = false; + let watchFsStabilized = false; + + while (Date.now() - startTime < maxWaitMs) { + try { + const files = await fs.readdir(logPath); + const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log')); + + for (const file of wikiLogFiles) { + const content = await fs.readFile(path.join(logPath, file), 'utf-8'); + if (content.includes('[test-id-SSE_READY]')) { + sseReady = true; + } + if (content.includes('[test-id-WATCH_FS_STABILIZED]')) { + watchFsStabilized = true; + } + } + + if (sseReady && watchFsStabilized) { + return; + } + } catch { + // Log directory might not exist yet, continue waiting + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + const missingServices = []; + if (!sseReady) missingServices.push('SSE'); + if (!watchFsStabilized) missingServices.push('watch-fs'); + throw new Error(`${missingServices.join(' and ')} did not become ready within timeout`); +} + +/** + * Wait for a tiddler to be added by watch-fs. + */ +async function waitForTiddlerAdded(tiddlerTitle: string, maxWaitMs = 10000): Promise { + const logPath = path.join(process.cwd(), 'userData-test', 'logs'); + const startTime = Date.now(); + const searchString = `[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`; + const files = await fs.readdir(logPath); + const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log')); + + while (Date.now() - startTime < maxWaitMs) { + try { + for (const file of wikiLogFiles) { + const content = await fs.readFile(path.join(logPath, file), 'utf-8'); + if (content.includes(searchString)) { + return; + } + } + } catch { + // Log directory might not exist yet, continue waiting + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`Tiddler "${tiddlerTitle}" was not added within timeout`); +} + +/** + * Wait for a tiddler to be updated by watch-fs. + */ +async function waitForTiddlerUpdated(tiddlerTitle: string, maxWaitMs = 10000): Promise { + const logPath = path.join(process.cwd(), 'userData-test', 'logs'); + const startTime = Date.now(); + const searchString = `[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`; + const files = await fs.readdir(logPath); + const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log')); + + while (Date.now() - startTime < maxWaitMs) { + try { + for (const file of wikiLogFiles) { + const content = await fs.readFile(path.join(logPath, file), 'utf-8'); + if (content.includes(searchString)) { + return; + } + } + } catch { + // Log directory might not exist yet, continue waiting + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`Tiddler "${tiddlerTitle}" was not updated within timeout`); +} + +/** + * Wait for a tiddler to be deleted by watch-fs. + */ +async function waitForTiddlerDeleted(tiddlerTitle: string, maxWaitMs = 10000): Promise { + const logPath = path.join(process.cwd(), 'userData-test', 'logs'); + const startTime = Date.now(); + const searchString = `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`; + + while (Date.now() - startTime < maxWaitMs) { + try { + const files = await fs.readdir(logPath); + const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log')); + + for (const file of wikiLogFiles) { + const content = await fs.readFile(path.join(logPath, file), 'utf-8'); + if (content.includes(searchString)) { + return; + } + } + } catch { + // Log directory might not exist yet, continue waiting + } + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`Tiddler "${tiddlerTitle}" was not deleted within timeout`); +} When('I cleanup test wiki so it could create a new one on start', async function() { if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath); + /** + * Clean up wiki log files to prevent reading stale logs from previous scenarios. + * This is critical for tests that wait for log markers like [test-id-WATCH_FS_STABILIZED], + * as Node.js file system caching can cause tests to read old log content. + */ + const logDirectory = path.join(process.cwd(), 'userData-test', 'logs'); + if (fs.existsSync(logDirectory)) { + const logFiles = fs.readdirSync(logDirectory).filter(f => f.startsWith('wiki-') && f.endsWith('.log')); + for (const logFile of logFiles) { + fs.removeSync(path.join(logDirectory, logFile)); + } + } + type SettingsFile = { workspaces?: Record } & Record; if (!fs.existsSync(settingsPath)) return; const settings = fs.readJsonSync(settingsPath) as SettingsFile; @@ -19,3 +160,184 @@ When('I cleanup test wiki so it could create a new one on start', async function } fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); }); + +/** + * Verify file exists in directory + */ +Then('file {string} should exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, directoryPath: string) { + // Replace {tmpDir} with wiki test root (not wiki subfolder) + const actualPath = directoryPath.replace('{tmpDir}', wikiTestRootPath); + const filePath = path.join(actualPath, fileName); + + let exists = false; + for (let index = 0; index < 20; index++) { + if (await fs.pathExists(filePath)) { + exists = true; + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + if (!exists) { + throw new Error(`File "${fileName}" not found in directory: ${actualPath}`); + } +}); + +/** + * Cleanup function for sub-wiki routing test + * Removes test workspaces created during the test + */ +function clearSubWikiRoutingTestData() { + if (!fs.existsSync(settingsPath)) return; + + type SettingsFile = { workspaces?: Record } & Record; + const settings = fs.readJsonSync(settingsPath) as SettingsFile; + const workspaces: Record = settings.workspaces ?? {}; + const filtered: Record = {}; + + // Remove test workspaces (SubWiki, etc from sub-wiki routing tests) + for (const id of Object.keys(workspaces)) { + const ws = workspaces[id]; + const name = ws.name; + // Keep workspaces that don't match test patterns + if (name !== 'SubWiki') { + filtered[id] = ws; + } + } + + fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); + + // Remove test wiki folders from filesystem + const testFolders = ['SubWiki']; + for (const folder of testFolders) { + const wikiPath = path.join(wikiTestWikiPath, folder); + if (fs.existsSync(wikiPath)) { + fs.removeSync(wikiPath); + } + } +} + +Then('I wait for SSE and watch-fs to be ready', { timeout: 20000 }, async function(this: ApplicationWorld) { + try { + await waitForSSEAndWatchFsReady(); + } catch (error) { + throw new Error(`Failed to wait for SSE and watch-fs: ${(error as Error).message}`); + } +}); + +Then('I wait for tiddler {string} to be added by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { + try { + await waitForTiddlerAdded(tiddlerTitle); + } catch (error) { + throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be added: ${(error as Error).message}`); + } +}); + +Then('I wait for tiddler {string} to be updated by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { + try { + await waitForTiddlerUpdated(tiddlerTitle); + } catch (error) { + throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be updated: ${(error as Error).message}`); + } +}); + +Then('I wait for tiddler {string} to be deleted by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { + try { + await waitForTiddlerDeleted(tiddlerTitle); + } catch (error) { + throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be deleted: ${(error as Error).message}`); + } +}); + +// File manipulation step definitions + +When('I create file {string} with content:', async function(this: ApplicationWorld, filePath: string, content: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); + + // Ensure directory exists + await fs.ensureDir(path.dirname(actualPath)); + + // Write the file with the provided content + await fs.writeFile(actualPath, content, 'utf-8'); +}); + +When('I modify file {string} to contain {string}', async function(this: ApplicationWorld, filePath: string, content: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); + + // Read the existing file + let fileContent = await fs.readFile(actualPath, 'utf-8'); + + // TiddlyWiki .tid files have a format: headers followed by blank line and text + // We need to preserve headers and only modify the text part + const lines = fileContent.split('\n'); + const blankLineIndex = lines.findIndex(line => line.trim() === ''); + + if (blankLineIndex >= 0) { + // Keep headers, replace text after blank line + const headers = lines.slice(0, blankLineIndex + 1); + fileContent = [...headers, content].join('\n'); + } else { + // No headers found, just use content + fileContent = content; + } + + // Write the modified content back + await fs.writeFile(actualPath, fileContent, 'utf-8'); +}); + +When('I modify file {string} to contain:', async function(this: ApplicationWorld, filePath: string, content: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); + + // For multi-line content with headers, just write the content directly + // (assumes the content includes all headers and structure) + await fs.writeFile(actualPath, content, 'utf-8'); +}); + +When('I delete file {string}', async function(this: ApplicationWorld, filePath: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); + + // Delete the file + await fs.remove(actualPath); +}); + +When('I rename file {string} to {string}', async function(this: ApplicationWorld, oldPath: string, newPath: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualOldPath = oldPath.replace('{tmpDir}', wikiTestRootPath); + const actualNewPath = newPath.replace('{tmpDir}', wikiTestRootPath); + + // Ensure the target directory exists + await fs.ensureDir(path.dirname(actualNewPath)); + + // Rename/move the file + await fs.rename(actualOldPath, actualNewPath); +}); + +When('I modify file {string} to add field {string}', async function(this: ApplicationWorld, filePath: string, fieldLine: string) { + // Replace {tmpDir} placeholder with actual temp directory + const actualPath = filePath.replace('{tmpDir}', wikiTestRootPath); + + // Read the existing file + const fileContent = await fs.readFile(actualPath, 'utf-8'); + + // TiddlyWiki .tid files have headers followed by a blank line and text + // We need to add the field to the headers section + const lines = fileContent.split('\n'); + const blankLineIndex = lines.findIndex(line => line.trim() === ''); + + if (blankLineIndex >= 0) { + // Insert the new field before the blank line + lines.splice(blankLineIndex, 0, fieldLine); + } else { + // No blank line found, add to the beginning + lines.unshift(fieldLine); + } + + // Write the modified content back + await fs.writeFile(actualPath, lines.join('\n'), 'utf-8'); +}); + +export { clearSubWikiRoutingTestData }; diff --git a/features/stepDefinitions/window.ts b/features/stepDefinitions/window.ts index c2689bf3..5e2e3ee7 100644 --- a/features/stepDefinitions/window.ts +++ b/features/stepDefinitions/window.ts @@ -2,10 +2,7 @@ import { When } from '@cucumber/cucumber'; import type { ElectronApplication } from 'playwright'; import type { ApplicationWorld } from './application'; import { checkWindowDimension, checkWindowName } from './application'; - -// Constants for retry logic -const MAX_ATTEMPTS = 10; -const RETRY_INTERVAL_MS = 1000; +import { WebContentsView } from 'electron'; // Helper function to get browser view info from Electron window async function getBrowserViewInfo( @@ -32,7 +29,7 @@ async function getBrowserViewInfo( for (const view of views) { // Type guard to check if view is a WebContentsView if (view && view.constructor.name === 'WebContentsView') { - const webContentsView = view as unknown as { getBounds: () => { x: number; y: number; width: number; height: number } }; + const webContentsView = view as WebContentsView; const viewBounds = webContentsView.getBounds(); const windowContentBounds = targetWindow.getContentBounds(); @@ -57,8 +54,6 @@ When('I confirm the {string} window exists', async function(this: ApplicationWor const success = await this.waitForWindowCondition( windowType, (window) => window !== undefined && !window.isClosed(), - MAX_ATTEMPTS, - RETRY_INTERVAL_MS, ); if (!success) { @@ -74,12 +69,10 @@ When('I confirm the {string} window visible', async function(this: ApplicationWo const success = await this.waitForWindowCondition( windowType, (window, isVisible) => window !== undefined && !window.isClosed() && isVisible, - MAX_ATTEMPTS, - RETRY_INTERVAL_MS, ); if (!success) { - throw new Error(`${windowType} window was not visible after ${MAX_ATTEMPTS} attempts`); + throw new Error(`${windowType} window was not visible after multiple attempts`); } }); @@ -91,12 +84,10 @@ When('I confirm the {string} window not visible', async function(this: Applicati const success = await this.waitForWindowCondition( windowType, (window, isVisible) => window !== undefined && !window.isClosed() && !isVisible, - MAX_ATTEMPTS, - RETRY_INTERVAL_MS, ); if (!success) { - throw new Error(`${windowType} window was visible or not found after ${MAX_ATTEMPTS} attempts`); + throw new Error(`${windowType} window was visible or not found after multiple attempts`); } }); @@ -108,12 +99,10 @@ When('I confirm the {string} window does not exist', async function(this: Applic const success = await this.waitForWindowCondition( windowType, (window) => window === undefined, - MAX_ATTEMPTS, - RETRY_INTERVAL_MS, ); if (!success) { - throw new Error(`${windowType} window still exists after ${MAX_ATTEMPTS} attempts`); + throw new Error(`${windowType} window still exists after multiple attempts`); } }); diff --git a/features/supports/paths.ts b/features/supports/paths.ts index 5adaa786..a0ef3b65 100644 --- a/features/supports/paths.ts +++ b/features/supports/paths.ts @@ -53,7 +53,8 @@ export const settingsPath = path.resolve(settingsDirectory, 'settings.json'); // Repo root and test wiki paths export const repoRoot = path.resolve(process.cwd()); -export const wikiTestWikiPath = path.resolve(repoRoot, 'wiki-test', 'wiki'); +export const wikiTestRootPath = path.resolve(repoRoot, 'wiki-test'); // Root of all test wikis +export const wikiTestWikiPath = path.resolve(wikiTestRootPath, 'wiki'); // Main test wiki // Archive-safe sanitization: generate a slug that is safe for zipping/unzipping across platforms. // Rules: diff --git a/features/supports/webContentsViewHelper.new.ts b/features/supports/webContentsViewHelper.new.ts new file mode 100644 index 00000000..c85fb4e2 --- /dev/null +++ b/features/supports/webContentsViewHelper.new.ts @@ -0,0 +1,242 @@ +import { WebContentsView } from 'electron'; +import type { ElectronApplication } from 'playwright'; + +/** + * Get the first WebContentsView from current window + * Since we only have one WebContentsView per window in main window, we don't need to loop through all windows + */ +async function getFirstWebContentsView(app: ElectronApplication) { + return await app.evaluate(async ({ BrowserWindow }) => { + const allWindows = BrowserWindow.getAllWindows(); + const mainWindow = allWindows.find(w => !w.isDestroyed() && w.webContents?.getType() === 'window'); + + if (!mainWindow?.contentView || !('children' in mainWindow.contentView)) { + return null; + } + + const children = (mainWindow.contentView as WebContentsView).children as WebContentsView[]; + if (!Array.isArray(children) || children.length === 0) { + return null; + } + + return children[0]?.webContents?.id ?? null; + }); +} + +/** + * Execute JavaScript in the browser view + */ +async function executeInBrowserView( + app: ElectronApplication, + script: string, +): Promise { + const webContentsId = await getFirstWebContentsView(app); + + if (!webContentsId) { + throw new Error('No browser view found'); + } + + return await app.evaluate( + async ({ webContents }, [id, scriptContent]) => { + const targetWebContents = webContents.fromId(id as number); + if (!targetWebContents) { + throw new Error('WebContents not found'); + } + const result: T = await targetWebContents.executeJavaScript(scriptContent as string, true) as T; + return result; + }, + [webContentsId, script], + ); +} + +/** + * Get text content from WebContentsView + */ +export async function getTextContent(app: ElectronApplication): Promise { + try { + return await executeInBrowserView( + app, + 'document.body.textContent || document.body.innerText || ""', + ); + } catch { + return null; + } +} + +/** + * Get DOM content from WebContentsView + */ +export async function getDOMContent(app: ElectronApplication): Promise { + try { + return await executeInBrowserView( + app, + 'document.documentElement.outerHTML || ""', + ); + } catch { + return null; + } +} + +/** + * Check if WebContentsView exists and is loaded + */ +export async function isLoaded(app: ElectronApplication): Promise { + const webContentsId = await getFirstWebContentsView(app); + return webContentsId !== null; +} + +/** + * Click element containing specific text in browser view + */ +export async function clickElementWithText( + app: ElectronApplication, + selector: string, + text: string, +): Promise { + const script = ` + (function() { + const selector = ${JSON.stringify(selector)}; + const text = ${JSON.stringify(text)}; + const elements = document.querySelectorAll(selector); + let found = null; + + for (let i = 0; i < elements.length; i++) { + const elem = elements[i]; + const elemText = elem.textContent || elem.innerText || ''; + if (elemText.trim() === text.trim() || elemText.includes(text)) { + found = elem; + break; + } + } + + if (!found) { + throw new Error('Element with text "' + text + '" not found in selector: ' + selector); + } + + found.click(); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Click element in browser view + */ +export async function clickElement(app: ElectronApplication, selector: string): Promise { + const script = ` + (function() { + const selector = ${JSON.stringify(selector)}; + const elem = document.querySelector(selector); + + if (!elem) { + throw new Error('Element not found: ' + selector); + } + + elem.click(); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Type text in element in browser view + */ +export async function typeText(app: ElectronApplication, selector: string, text: string): Promise { + const escapedSelector = selector.replace(/'/g, "\\'"); + const escapedText = text.replace(/'/g, "\\'").replace(/\n/g, '\\n'); + + const script = ` + (function() { + const selector = '${escapedSelector}'; + const text = '${escapedText}'; + const elem = document.querySelector(selector); + + if (!elem) { + throw new Error('Element not found: ' + selector); + } + + elem.focus(); + if (elem.tagName === 'TEXTAREA' || elem.tagName === 'INPUT') { + elem.value = text; + } else { + elem.textContent = text; + } + + elem.dispatchEvent(new Event('input', { bubbles: true })); + elem.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Press key in browser view + */ +export async function pressKey(app: ElectronApplication, key: string): Promise { + const escapedKey = key.replace(/'/g, "\\'"); + + const script = ` + (function() { + const key = '${escapedKey}'; + + const keydownEvent = new KeyboardEvent('keydown', { + key: key, + code: key, + bubbles: true, + cancelable: true + }); + document.activeElement?.dispatchEvent(keydownEvent); + + const keyupEvent = new KeyboardEvent('keyup', { + key: key, + code: key, + bubbles: true, + cancelable: true + }); + document.activeElement?.dispatchEvent(keyupEvent); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Check if element exists in browser view + */ +export async function elementExists(app: ElectronApplication, selector: string): Promise { + try { + // Check if selector contains :has-text() pseudo-selector + const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/); + + if (hasTextMatch) { + const baseSelector = hasTextMatch[1]; + const textContent = hasTextMatch[2]; + + const script = ` + (function() { + const elements = document.querySelectorAll('${baseSelector.replace(/'/g, "\\'")}'); + for (const el of elements) { + if (el.textContent && el.textContent.includes('${textContent.replace(/'/g, "\\'")}')) { + return true; + } + } + return false; + })() + `; + + return await executeInBrowserView(app, script); + } else { + const script = `document.querySelector('${selector.replace(/'/g, "\\'")}') !== null`; + return await executeInBrowserView(app, script); + } + } catch { + return false; + } +} diff --git a/features/supports/webContentsViewHelper.ts b/features/supports/webContentsViewHelper.ts index 632bceb1..387238ad 100644 --- a/features/supports/webContentsViewHelper.ts +++ b/features/supports/webContentsViewHelper.ts @@ -1,151 +1,254 @@ +import { WebContentsView } from 'electron'; import type { ElectronApplication } from 'playwright'; /** - * Get text content from WebContentsView - * @param app Electron application instance - * @returns Promise Returns text content or null + * Get the first WebContentsView from any window + * Prioritizes main window, but will check all windows if needed */ -export async function getTextContent(app: ElectronApplication): Promise { +async function getFirstWebContentsView(app: ElectronApplication) { return await app.evaluate(async ({ BrowserWindow }) => { - // Get all browser windows - const windows = BrowserWindow.getAllWindows(); + const allWindows = BrowserWindow.getAllWindows(); - for (const window of windows) { - // Get all child views (WebContentsView instances) attached to this window - if (window.contentView && 'children' in window.contentView) { - const views = window.contentView.children || []; + // First try to find main window + const mainWindow = allWindows.find(w => !w.isDestroyed() && w.webContents?.getType() === 'window'); - for (const view of views) { - // Type guard to check if view is a WebContentsView - if (view && view.constructor.name === 'WebContentsView') { - try { - // Cast to WebContentsView type and execute JavaScript - const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise } }; - const content = await webContentsView.webContents.executeJavaScript(` - document.body.textContent || document.body.innerText || '' - `); - if (content && content.trim()) { - return content; - } - } catch { - // Continue to next view if this one fails - continue; - } - } + if (mainWindow?.contentView && 'children' in mainWindow.contentView) { + const children = (mainWindow.contentView as WebContentsView).children as WebContentsView[]; + if (Array.isArray(children) && children.length > 0) { + const webContentsId = children[0]?.webContents?.id; + if (webContentsId) return webContentsId; + } + } + + // If main window doesn't have a WebContentsView, check all windows + for (const window of allWindows) { + if (!window.isDestroyed() && window.contentView && 'children' in window.contentView) { + const children = (window.contentView as WebContentsView).children as WebContentsView[]; + if (Array.isArray(children) && children.length > 0) { + const webContentsId = children[0]?.webContents?.id; + if (webContentsId) return webContentsId; } } } + return null; }); } +/** + * Execute JavaScript in the browser view + */ +async function executeInBrowserView( + app: ElectronApplication, + script: string, +): Promise { + const webContentsId = await getFirstWebContentsView(app); + + if (!webContentsId) { + throw new Error('No WebContentsView found in main window'); + } + + return await app.evaluate( + async ({ webContents }, [id, scriptContent]) => { + const targetWebContents = webContents.fromId(id as number); + if (!targetWebContents) { + throw new Error('WebContents not found'); + } + const result: T = await targetWebContents.executeJavaScript(scriptContent as string, true) as T; + return result; + }, + [webContentsId, script], + ); +} + +/** + * Get text content from WebContentsView + */ +export async function getTextContent(app: ElectronApplication): Promise { + try { + return await executeInBrowserView( + app, + 'document.body.textContent || document.body.innerText || ""', + ); + } catch { + return null; + } +} + /** * Get DOM content from WebContentsView - * @param app Electron application instance - * @returns Promise Returns DOM content or null */ export async function getDOMContent(app: ElectronApplication): Promise { - return await app.evaluate(async ({ BrowserWindow }) => { - // Get all browser windows - const windows = BrowserWindow.getAllWindows(); - - for (const window of windows) { - // Get all child views (WebContentsView instances) attached to this window - if (window.contentView && 'children' in window.contentView) { - const views = window.contentView.children || []; - - for (const view of views) { - // Type guard to check if view is a WebContentsView - if (view && view.constructor.name === 'WebContentsView') { - try { - // Cast to WebContentsView type and execute JavaScript - const webContentsView = view as unknown as { webContents: { executeJavaScript: (script: string) => Promise } }; - const content = await webContentsView.webContents.executeJavaScript(` - document.documentElement.outerHTML || '' - `); - if (content && content.trim()) { - return content; - } - } catch { - // Continue to next view if this one fails - continue; - } - } - } - } - } + try { + return await executeInBrowserView( + app, + 'document.documentElement.outerHTML || ""', + ); + } catch { return null; - }); + } } /** * Check if WebContentsView exists and is loaded - * @param app Electron application instance - * @returns Promise Returns whether it exists and is loaded */ export async function isLoaded(app: ElectronApplication): Promise { - return await app.evaluate(async ({ BrowserWindow }) => { - // Get all browser windows - const windows = BrowserWindow.getAllWindows(); + const webContentsId = await getFirstWebContentsView(app); + return webContentsId !== null; +} - for (const window of windows) { - // Get all child views (WebContentsView instances) attached to this window - if (window.contentView && 'children' in window.contentView) { - const views = window.contentView.children || []; - - for (const view of views) { - // Type guard to check if view is a WebContentsView - if (view && view.constructor.name === 'WebContentsView') { - // If we found a WebContentsView, consider it loaded - return true; - } +/** + * Click element containing specific text in browser view + */ +export async function clickElementWithText( + app: ElectronApplication, + selector: string, + text: string, +): Promise { + const script = ` + (function() { + const selector = ${JSON.stringify(selector)}; + const text = ${JSON.stringify(text)}; + const elements = document.querySelectorAll(selector); + let found = null; + + for (let i = 0; i < elements.length; i++) { + const elem = elements[i]; + const elemText = elem.textContent || elem.innerText || ''; + if (elemText.trim() === text.trim() || elemText.includes(text)) { + found = elem; + break; } } + + if (!found) { + throw new Error('Element with text "' + text + '" not found in selector: ' + selector); + } + + found.click(); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Click element in browser view + */ +export async function clickElement(app: ElectronApplication, selector: string): Promise { + const script = ` + (function() { + const selector = ${JSON.stringify(selector)}; + const elem = document.querySelector(selector); + + if (!elem) { + throw new Error('Element not found: ' + selector); + } + + elem.click(); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Type text in element in browser view + */ +export async function typeText(app: ElectronApplication, selector: string, text: string): Promise { + const escapedSelector = selector.replace(/'/g, "\\'"); + const escapedText = text.replace(/'/g, "\\'").replace(/\n/g, '\\n'); + + const script = ` + (function() { + const selector = '${escapedSelector}'; + const text = '${escapedText}'; + const elem = document.querySelector(selector); + + if (!elem) { + throw new Error('Element not found: ' + selector); + } + + elem.focus(); + if (elem.tagName === 'TEXTAREA' || elem.tagName === 'INPUT') { + elem.value = text; + } else { + elem.textContent = text; + } + + elem.dispatchEvent(new Event('input', { bubbles: true })); + elem.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Press key in browser view + */ +export async function pressKey(app: ElectronApplication, key: string): Promise { + const escapedKey = key.replace(/'/g, "\\'"); + + const script = ` + (function() { + const key = '${escapedKey}'; + + const keydownEvent = new KeyboardEvent('keydown', { + key: key, + code: key, + bubbles: true, + cancelable: true + }); + document.activeElement?.dispatchEvent(keydownEvent); + + const keyupEvent = new KeyboardEvent('keyup', { + key: key, + code: key, + bubbles: true, + cancelable: true + }); + document.activeElement?.dispatchEvent(keyupEvent); + return true; + })() + `; + + await executeInBrowserView(app, script); +} + +/** + * Check if element exists in browser view + */ +export async function elementExists(app: ElectronApplication, selector: string): Promise { + try { + // Check if selector contains :has-text() pseudo-selector + const hasTextMatch = selector.match(/^(.+):has-text\(['"](.+)['"]\)$/); + + if (hasTextMatch) { + const baseSelector = hasTextMatch[1]; + const textContent = hasTextMatch[2]; + + const script = ` + (function() { + const elements = document.querySelectorAll('${baseSelector.replace(/'/g, "\\'")}'); + for (const el of elements) { + if (el.textContent && el.textContent.includes('${textContent.replace(/'/g, "\\'")}')) { + return true; + } + } + return false; + })() + `; + + return await executeInBrowserView(app, script); + } else { + const script = `document.querySelector('${selector.replace(/'/g, "\\'")}') !== null`; + return await executeInBrowserView(app, script); } + } catch { return false; - }); -} - -/** - * Find specified text in WebContentsView - * @param app Electron application instance - * @param expectedText Text to search for - * @param contentType Content type: 'text' or 'dom' - * @returns Promise Returns whether text was found - */ -export async function containsText( - app: ElectronApplication, - expectedText: string, - contentType: 'text' | 'dom' = 'text', -): Promise { - const content = contentType === 'text' - ? await getTextContent(app) - : await getDOMContent(app); - - return content !== null && content.includes(expectedText); -} - -/** - * Get WebContentsView content summary (for error messages) - * @param app Electron application instance - * @param contentType Content type: 'text' or 'dom' - * @param maxLength Maximum length, default 200 - * @returns Promise Returns content summary - */ -export async function getContentSummary( - app: ElectronApplication, - contentType: 'text' | 'dom' = 'text', - maxLength: number = 200, -): Promise { - const content = contentType === 'text' - ? await getTextContent(app) - : await getDOMContent(app); - - if (!content) { - return 'null'; } - - return content.length > maxLength - ? content.substring(0, maxLength) + '...' - : content; } diff --git a/features/tidgiMiniWindow.feature b/features/tidgiMiniWindow.feature index 272fc762..35ce3132 100644 --- a/features/tidgiMiniWindow.feature +++ b/features/tidgiMiniWindow.feature @@ -1,4 +1,4 @@ -@tidgiminiwindow +@tidgi-mini-window Feature: TidGi Mini Window As a user I want to enable and use the TidGi mini window diff --git a/features/tidgiMiniWindowWorkspace.feature b/features/tidgiMiniWindowWorkspace.feature index 10bb2fbd..c52b9bfd 100644 --- a/features/tidgiMiniWindowWorkspace.feature +++ b/features/tidgiMiniWindowWorkspace.feature @@ -1,4 +1,4 @@ -@tidgiminiwindow +@tidgi-mini-window Feature: TidGi Mini Window Workspace Switching As a user with tidgi mini window already enabled I want to test tidgi mini window behavior with different workspace configurations diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index 38da564b..1d892037 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -147,6 +147,8 @@ "DisableAudioTitle": "Disable audio", "DisableNotification": "Prevent workspace from sending notifications.", "DisableNotificationTitle": "Disable notifications", + "EnableFileSystemWatchTitle": "Enable File System Watch (Experimental)", + "EnableFileSystemWatchDescription": "Automatically watch for external file changes and sync to the wiki. This is an experimental feature and may have bugs. You can disable it if you encounter issues. Requires workspace restart to take effect.", "EnableHTTPAPI": "Enable HTTP APIs", "EnableHTTPAPIDescription": "Allow third-party programs such as TidGi-Mobile, Tiddlywiki-Collector webclipper, etc. to read and modify your notes through the HTTP network interface.", "EnableHTTPS": "Enable HTTPS", diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 2063128f..79ea96be 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -147,6 +147,8 @@ "DisableAudioTitle": "关闭声音", "DisableNotification": "阻止工作区的消息提醒", "DisableNotificationTitle": "关闭提醒", + "EnableFileSystemWatchTitle": "启用文件系统监听(实验性)", + "EnableFileSystemWatchDescription": "自动监听外部文件变化并同步到知识库。这是一个实验性功能,可能存在bug,如遇问题可关闭此选项。需要重启工作区生效。", "EnableHTTPAPI": "启用 HTTP API", "EnableHTTPAPIDescription": "允许第三方程序如太记移动端、太记搜藏-剪藏插件等等通过 HTTP 网络接口读取和修改你的笔记。", "EnableHTTPS": "启用HTTPS", @@ -222,7 +224,7 @@ "SubWikiSMainWikiNotExistError": "子知识库所附着的主知识库不存在", "SubWikiSMainWikiNotExistErrorDescription": "子知识库在创建时必须选择一个附着到的主知识库,但是现在这个子知识库所应该附着的主知识库找不到了,无法附着。", "ViewLoadUrlError": "E-9 网页加载失败错误", - "ViewLoadUrlErrorDescription": "E-9 工作区对应的知识库網页加载失败了,但即将重试", + "ViewLoadUrlErrorDescription": "E-9 工作区对应的知识库网页加载失败了,但即将重试", "WikiRuntimeError": "E-13 知识库运行时有错误", "WikiRuntimeErrorDescription": "E-13 知识库运行时有错误,原因请查看 log 文件,并上传提交 issue 以便修复。", "WorkspaceFailedToLoadError": "E-8 工作区加载失败错误", diff --git a/package.json b/package.json index 467bc993..93a0f8b0 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "scripts": { "start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development tsx scripts/developmentMkdir.ts && pnpm run start:dev", "start:dev": "cross-env NODE_ENV=development electron-forge start", - "clean": "rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo", + "clean": "pnpm run clean:cache && rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo", + "clean:cache": "rimraf -- ./node_modules/.vite .vite", "start:dev:debug-worker": "cross-env NODE_ENV=development DEBUG_WORKER=true electron-forge start", "start:dev:debug-main": "cross-env NODE_ENV=development DEBUG_MAIN=true electron-forge start", "start:dev:debug-vite": "cross-env NODE_ENV=development DEBUG=electron-forge:* electron-forge start", @@ -64,7 +65,7 @@ "default-gateway": "6.0.3", "dugite": "2.7.1", "electron-dl": "^4.0.0", - "electron-ipc-cat": "2.1.1", + "electron-ipc-cat": "2.2.1", "electron-settings": "5.0.0", "electron-unhandled": "4.0.1", "electron-window-state": "5.0.3", @@ -90,6 +91,7 @@ "nanoid": "^5.1.6", "new-github-issue-url": "^1.1.0", "node-fetch": "3.3.2", + "nsfw": "^2.2.5", "oidc-client-ts": "^3.3.0", "ollama-ai-provider-v2": "^1.5.1", "react": "19.2.0", @@ -174,7 +176,7 @@ "rimraf": "^6.0.1", "ts-node": "10.9.2", "tsx": "^4.20.6", - "tw5-typed": "^0.6.3", + "tw5-typed": "^0.6.7", "typescript": "5.9.3", "typesync": "0.14.3", "unplugin-swc": "^1.5.8", @@ -184,7 +186,8 @@ }, "pnpm": { "overrides": { - "prebuild-install": "latest" + "prebuild-install": "latest", + "node-addon-api": "^7.1.1" }, "onlyBuiltDependencies": [ "@swc/core", @@ -194,6 +197,7 @@ "electron", "electron-winstaller", "esbuild", + "nsfw", "registry-js", "unrs-resolver" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c67f3678..d68c6221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: prebuild-install: latest + node-addon-api: ^7.1.1 pnpmfileChecksum: sha256-lIFkUl44z62LBhI/qC/00DMf5xie4YLU9ldCFAHgCsA= @@ -113,8 +114,8 @@ importers: specifier: ^4.0.0 version: 4.0.0 electron-ipc-cat: - specifier: 2.1.1 - version: 2.1.1(electron@38.3.0)(rxjs@7.8.2) + specifier: 2.2.1 + version: 2.2.1(electron@38.3.0)(rxjs@7.8.2) electron-settings: specifier: 5.0.0 version: 5.0.0(electron@38.3.0) @@ -190,6 +191,9 @@ importers: node-fetch: specifier: 3.3.2 version: 3.3.2 + nsfw: + specifier: ^2.2.5 + version: 2.2.5 oidc-client-ts: specifier: ^3.3.0 version: 3.3.0 @@ -411,8 +415,8 @@ importers: specifier: ^4.20.6 version: 4.20.6 tw5-typed: - specifier: ^0.6.3 - version: 0.6.3 + specifier: ^0.6.7 + version: 0.6.7 typescript: specifier: 5.9.3 version: 5.9.3 @@ -3621,8 +3625,8 @@ packages: engines: {node: '>= 10.0'} hasBin: true - electron-ipc-cat@2.1.1: - resolution: {integrity: sha512-1cDTPZbPBDUKlpE0w+W5nhIqpTvy2qXu3QB8i5ohVLG5cptD2NQ9wCLE1uIrWxdpT01/TQ4fdCfK/RqGQw9AGQ==} + electron-ipc-cat@2.2.1: + resolution: {integrity: sha512-ybHpgKP42VPwAMsOmmwNc1umT1kzAn2VJcP+XC8awY5N8K0DXnidndLe1gwgXfkouHLbRxzybwCA5vYoSfrHvw==} peerDependencies: electron: '>= 13.0.0' rxjs: '>= 7.5.0' @@ -5427,8 +5431,8 @@ packages: resolution: {integrity: sha512-E4n91K4Nk1Rch2KzD+edU2bfZTP4W42GypAUDXU4vu1A+4u9PvUNDkGI0dXbsy8ZeF3WGj0SD/uHxnXD/sW+3w==} engines: {node: '>=22.12.0'} - node-addon-api@3.2.1: - resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -5502,6 +5506,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nsfw@2.2.5: + resolution: {integrity: sha512-KcZpiMzCfCpEYA2S45pPT9x80HlYf/DyyigrFBpeFYj9O/rBi0hSn4AnRnZ77ppjSmwTzu7wZjNfKIzdTz/PSw==} + engines: {node: '>=10.16.0'} + oauth2-mock-server@8.1.0: resolution: {integrity: sha512-Zi0VMDVCsavanLfW9X82uoniPS1kFGkOKFKKQF0NdrEwG4Uap/ylhYtRGPujZUJ0CmG3h73TsF/sefpCLTaKsQ==} engines: {node: ^20.19 || ^22.12 || ^24} @@ -6863,8 +6871,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw5-typed@0.6.3: - resolution: {integrity: sha512-wee4weepSkhF5b2QAwxhz0Q2TREb7i/2sdhd8fOJk7uy/CO4Ku3gNshCEd2zDunHdpbfgsvOSNMgs8dkugD+uA==} + tw5-typed@0.6.7: + resolution: {integrity: sha512-xUs+WVkfepxLJZ12RRt56vyBwXX2eh+eVM0k2mjalL2LJ6qHKpyG8lPsa0FyC7soDyMJHig+ahqOjyUQlRLPcw==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -11282,7 +11290,7 @@ snapshots: - supports-color optional: true - electron-ipc-cat@2.1.1(electron@38.3.0)(rxjs@7.8.2): + electron-ipc-cat@2.2.1(electron@38.3.0)(rxjs@7.8.2): dependencies: electron: 38.3.0 memize: 2.1.1 @@ -13404,7 +13412,7 @@ snapshots: dependencies: semver: 7.7.3 - node-addon-api@3.2.1: {} + node-addon-api@7.1.1: {} node-api-version@0.2.1: dependencies: @@ -13497,6 +13505,10 @@ snapshots: dependencies: path-key: 3.1.1 + nsfw@2.2.5: + dependencies: + node-addon-api: 7.1.1 + oauth2-mock-server@8.1.0: dependencies: basic-auth: 2.0.1 @@ -14069,7 +14081,7 @@ snapshots: registry-js@1.16.1: dependencies: - node-addon-api: 3.2.1 + node-addon-api: 7.1.1 prebuild-install: 7.1.3 regjsparser@0.12.0: @@ -14912,7 +14924,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tw5-typed@0.6.3: + tw5-typed@0.6.7: dependencies: '@types/codemirror': 5.60.16 '@types/echarts': 4.9.22 diff --git a/scripts/afterPack.ts b/scripts/afterPack.ts index f5a4a71f..df0d84f5 100644 --- a/scripts/afterPack.ts +++ b/scripts/afterPack.ts @@ -50,6 +50,8 @@ export default ( ['app-path', 'main'], // node binary ['better-sqlite3', 'build', 'Release', 'better_sqlite3.node'], + // nsfw native module + ['nsfw', 'build', 'Release', 'nsfw.node'], // Refer to `node_modules\sqlite-vec\index.cjs` for latest file names // sqlite-vec: copy main entry files and platform-specific binary ['sqlite-vec', 'package.json'], diff --git a/scripts/compilePlugins.mjs b/scripts/compilePlugins.mjs index 19afc396..2dca7a74 100644 --- a/scripts/compilePlugins.mjs +++ b/scripts/compilePlugins.mjs @@ -5,47 +5,205 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import esbuild from 'esbuild'; +import fs from 'fs-extra'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { rimraf } from 'rimraf'; -// put it here, so it can be loaded via `'+plugins/linonetwo/tidgi'` in cli, and get copied in scripts/afterPack.js when copying tiddlywiki (no need to copy this plugin again) -const tidgiIpcSyncadaptorOutDir = path.join(__dirname, '../node_modules/tiddlywiki/plugins/linonetwo/tidgi-ipc-syncadaptor'); -// delete if exist -await rimraf(tidgiIpcSyncadaptorOutDir); -await fs.mkdirp(tidgiIpcSyncadaptorOutDir); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * esbuild plugin to handle native .node files and their parent packages + * Rewrites require() calls for .node files to use absolute paths from node_modules + */ +const nativeNodeModulesPlugin = { + name: 'native-node-modules', + setup(build) { + // Rewrite nsfw's require() to use node_modules path + build.onLoad({ filter: /nsfw[/\\]js[/\\]src[/\\]index\.js$/ }, async (args) => { + let contents = await fs.readFile(args.path, 'utf8'); + + // Replace relative path with require from node_modules + // Original: require('../../build/Release/nsfw.node') + // New: require('nsfw/build/Release/nsfw.node') + contents = contents.replace( + /require\(['"]\.\.\/\.\.\/build\/Release\/nsfw\.node['"]\)/g, + "require('nsfw/build/Release/nsfw.node')" + ); + + return { + contents, + loader: 'js', + }; + }); + + // Mark the .node file itself as external + build.onResolve({ filter: /nsfw[/\\]build[/\\]Release[/\\]nsfw\.node$/ }, () => ({ + external: true, + })); + }, +}; + +/** + * Configuration for all plugins to build + */ +const PLUGINS = [ + { + name: 'tidgi-ipc-syncadaptor', + sourceFolder: '../src/services/wiki/plugin/ipcSyncAdaptor', + entryPoints: [ + 'ipc-syncadaptor.ts', + 'electron-ipc-cat.ts', + 'fix-location-info.ts', + ], + }, + { + name: 'tidgi-ipc-syncadaptor-ui', + sourceFolder: '../src/services/wiki/plugin/ipcSyncAdaptorUI', + entryPoints: [], // No TypeScript entry points, just copy files + }, + { + name: 'watch-filesystem-adaptor', + sourceFolder: '../src/services/wiki/plugin/watchFileSystemAdaptor', + entryPoints: [ + 'watch-filesystem-adaptor.ts', + ], + }, +]; + +/** + * Shared esbuild configuration + */ const tsconfigPath = path.join(__dirname, '../tsconfig.json'); -const tidgiIpcSyncadaptorSourceFolder = '../src/services/wiki/plugin/ipcSyncAdaptor'; -const sharedConfig = { +const ESBUILD_CONFIG = { logLevel: 'info', bundle: true, - // use node so we have `exports`, otherwise `module.adaptorClass` in $:/core/modules/startup.js will be undefined - platform: 'node', + platform: 'node', // Use node so we have `exports`, otherwise `module.adaptorClass` will be undefined minify: process.env.NODE_ENV === 'production', - outdir: tidgiIpcSyncadaptorOutDir, tsconfig: tsconfigPath, target: 'ESNEXT', + plugins: [nativeNodeModulesPlugin], }; -await Promise.all([ - esbuild.build({ - ...sharedConfig, - entryPoints: [path.join(__dirname, tidgiIpcSyncadaptorSourceFolder, 'ipc-syncadaptor.ts')], - }), - esbuild.build({ - ...sharedConfig, - entryPoints: [path.join(__dirname, tidgiIpcSyncadaptorSourceFolder, 'electron-ipc-cat.ts')], - }), - esbuild.build({ - ...sharedConfig, - entryPoints: [path.join(__dirname, tidgiIpcSyncadaptorSourceFolder, 'fix-location-info.ts')], - }), -]); -const filterFunc = (src) => { - return !src.endsWith('.ts'); -}; -await fs.copy(path.join(__dirname, tidgiIpcSyncadaptorSourceFolder), tidgiIpcSyncadaptorOutDir, { filter: filterFunc }); -const tidgiIpcSyncadaptorUISourceFolder = '../src/services/wiki/plugin/ipcSyncAdaptorUI'; -const tidgiIpcSyncadaptorUIOutDir = path.join(__dirname, '../node_modules/tiddlywiki/plugins/linonetwo/tidgi-ipc-syncadaptor-ui'); -// delete if exist -await rimraf(tidgiIpcSyncadaptorUIOutDir); -await fs.mkdirp(tidgiIpcSyncadaptorUIOutDir); -await fs.copy(path.join(__dirname, tidgiIpcSyncadaptorUISourceFolder), tidgiIpcSyncadaptorUIOutDir, { filter: filterFunc }); +/** + * Filter function to exclude TypeScript files when copying + */ +const filterNonTsFiles = (src) => !src.endsWith('.ts'); + +/** + * Get all possible output directories for a plugin + * Returns both development node_modules and packaged app directories + */ +function getPluginOutputDirs(pluginName) { + const devOutDir = path.join(__dirname, '../node_modules/tiddlywiki/plugins/linonetwo', pluginName); + const outDirs = [devOutDir]; + + // Check for packaged app directories (created by afterPack.ts) + const outDir = path.join(__dirname, '../out'); + if (fs.existsSync(outDir)) { + // Find all packaged app directories + const packDirs = fs.readdirSync(outDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => { + // In packaged electron app, node_modules is in resources/ + const resourcesPath = path.join(outDir, dirent.name, 'resources/node_modules/tiddlywiki/plugins/linonetwo', pluginName); + return resourcesPath; + }); + + // Only add directories that exist (have been created by afterPack) + packDirs.forEach(dir => { + const parentDir = path.dirname(dir); + if (fs.existsSync(parentDir)) { + outDirs.push(dir); + } + }); + } + + return outDirs; +} + +/** + * Prepare output directories for a plugin + */ +async function prepareOutputDirs(outDirs) { + await Promise.all(outDirs.map(async (outDir) => { + await rimraf(outDir); + await fs.mkdirp(outDir); + })); +} + +/** + * Build TypeScript entry points to all output directories + */ +async function buildEntryPoints(plugin, outDirs) { + if (!plugin.entryPoints || plugin.entryPoints.length === 0) { + return; + } + + const sourcePath = path.join(__dirname, plugin.sourceFolder); + + await Promise.all( + outDirs.flatMap(outDir => + plugin.entryPoints.map(entryPoint => + esbuild.build({ + ...ESBUILD_CONFIG, + entryPoints: [path.join(sourcePath, entryPoint)], + outdir: outDir, + }) + ) + ) + ); +} + +/** + * Copy non-TypeScript files to all output directories + */ +async function copyNonTsFiles(plugin, outDirs) { + const sourcePath = path.join(__dirname, plugin.sourceFolder); + + await Promise.all(outDirs.map(async (outDir) => { + await fs.copy(sourcePath, outDir, { filter: filterNonTsFiles }); + console.log(`✓ Copied ${plugin.name} to: ${outDir}`); + })); +} + +/** + * Build a single plugin to all output directories + */ +async function buildPlugin(plugin) { + console.log(`\nBuilding plugin: ${plugin.name}`); + + const outDirs = getPluginOutputDirs(plugin.name); + console.log(` Output directories: ${outDirs.length}`); + + // Prepare output directories + await prepareOutputDirs(outDirs); + + // Build TypeScript entry points + await buildEntryPoints(plugin, outDirs); + + // Copy non-TypeScript files + await copyNonTsFiles(plugin, outDirs); + + console.log(`✓ Completed ${plugin.name}`); +} + +/** + * Main function to build all plugins + */ +async function main() { + console.log('Starting plugin compilation...\n'); + + for (const plugin of PLUGINS) { + await buildPlugin(plugin); + } + + console.log('\n✓ All plugins compiled successfully!'); +} + +// Run main function +main().catch((error) => { + console.error('Error compiling plugins:', error); + process.exit(1); +}); diff --git a/scripts/start-e2e-app.ts b/scripts/start-e2e-app.ts index 40080173..b4ed9b5f 100644 --- a/scripts/start-e2e-app.ts +++ b/scripts/start-e2e-app.ts @@ -1,4 +1,4 @@ -// pnpm exec cross-env NODE_ENV=test pnpm dlx tsx ./scripts/start-e2e-app.ts +// pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts import { spawn } from 'child_process'; import { getPackedAppPath } from '../features/supports/paths'; diff --git a/src/__tests__/__mocks__/services-container.ts b/src/__tests__/__mocks__/services-container.ts index ac98ea71..fbaf8721 100644 --- a/src/__tests__/__mocks__/services-container.ts +++ b/src/__tests__/__mocks__/services-container.ts @@ -56,7 +56,6 @@ export const serviceInstances: { pickDirectory: vi.fn().mockResolvedValue(['/test/selected/path']), }, wiki: { - getSubWikiPluginContent: vi.fn().mockResolvedValue([]), // generic wikiOperationInServer mock: keep simple, allow test-specific overrides wikiOperationInServer: vi.fn().mockResolvedValue([]) as IWikiService['wikiOperationInServer'], }, @@ -153,6 +152,7 @@ const defaultWorkspaces: IWorkspace[] = [ disableAudio: false, enableHTTPAPI: false, excludedPlugins: [], + enableFileSystemWatch: true, gitUrl: null, hibernateWhenUnused: false, readOnlyMode: false, @@ -183,6 +183,7 @@ const defaultWorkspaces: IWorkspace[] = [ disableAudio: false, enableHTTPAPI: false, excludedPlugins: [], + enableFileSystemWatch: true, gitUrl: null, hibernateWhenUnused: false, readOnlyMode: false, diff --git a/src/components/TokenForm/index.tsx b/src/components/TokenForm/index.tsx index 0b593e78..2107ffbe 100644 --- a/src/components/TokenForm/index.tsx +++ b/src/components/TokenForm/index.tsx @@ -50,7 +50,7 @@ const Tab = styled(TabRaw)` interface Props { storageProvider?: SupportedStorageServices; - storageProviderSetter?: (next: SupportedStorageServices) => void; + storageProviderSetter?: React.Dispatch>; } /** * Create storage provider's token. @@ -62,7 +62,7 @@ export function TokenForm({ storageProvider, storageProviderSetter }: Props): Re // use external controls if provided if (storageProvider !== undefined && typeof storageProviderSetter === 'function') { currentTab = storageProvider; - currentTabSetter = storageProviderSetter as unknown as React.Dispatch>; + currentTabSetter = storageProviderSetter; } // update storageProvider to be an online service, if this Component is opened useEffect(() => { diff --git a/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx b/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx index 742ab21d..63a47416 100644 --- a/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx +++ b/src/pages/Main/WorkspaceIconAndSelector/WorkspaceSelectorBase.tsx @@ -161,7 +161,7 @@ export function WorkspaceSelectorBase({ $transparent={transparentBackground} $addAvatar={id === 'add'} $highlightAdd={index === 0} - id={id === 'add' || id === 'guide' ? 'add-workspace-button' : `workspace-avatar-${id}`} + id={id === 'add' ? 'add-workspace-button' : id === 'guide' ? 'guide-workspace-button' : `workspace-avatar-${id}`} > {id === 'add' ? ( diff --git a/src/services/agentInstance/plugins/wikiSearchPlugin.ts b/src/services/agentInstance/plugins/wikiSearchPlugin.ts index 3c888b51..0fe32763 100644 --- a/src/services/agentInstance/plugins/wikiSearchPlugin.ts +++ b/src/services/agentInstance/plugins/wikiSearchPlugin.ts @@ -19,7 +19,7 @@ import { findPromptById } from '../promptConcat/promptConcat'; import type { AiAPIConfig } from '../promptConcat/promptConcatSchema'; import type { IPrompt } from '../promptConcat/promptConcatSchema'; import { schemaToToolContent } from '../utilities/schemaToToolContent'; -import type { AIResponseContext, PromptConcatPlugin } from './types'; +import type { PromptConcatPlugin } from './types'; /** * Wiki Search Parameter Schema @@ -684,7 +684,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => { } // Set up actions to continue the conversation with tool results - const responseContext = context as unknown as AIResponseContext; + const responseContext = context; if (!responseContext.actions) { responseContext.actions = {}; } @@ -750,7 +750,7 @@ export const wikiSearchPlugin: PromptConcatPlugin = (hooks) => { }); // Set up error response for next round - const responseContext = context as unknown as AIResponseContext; + const responseContext = context; if (!responseContext.actions) { responseContext.actions = {}; } diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts index 0bd1bf0f..cd319295 100644 --- a/src/services/auth/index.ts +++ b/src/services/auth/index.ts @@ -8,7 +8,7 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { SupportedStorageServices } from '@services/types'; import type { IWorkspace } from '@services/workspaces/interface'; import { isWikiWorkspace } from '@services/workspaces/interface'; -import { BrowserWindow } from 'electron'; +import { BrowserWindow, session } from 'electron'; import { injectable } from 'inversify'; import { nanoid } from 'nanoid'; import { BehaviorSubject } from 'rxjs'; @@ -123,7 +123,6 @@ export class Authentication implements IAuthenticationService { * Used during logout to clear "remember me" state */ public async clearCookiesForDomain(domain: string): Promise { - const { session } = await import('electron'); try { const cookies = await session.defaultSession.cookies.get({ domain }); diff --git a/src/services/libs/log/index.ts b/src/services/libs/log/index.ts index 49f7b291..a4f3879f 100644 --- a/src/services/libs/log/index.ts +++ b/src/services/libs/log/index.ts @@ -1,11 +1,10 @@ import { LOG_FOLDER } from '@/constants/appPaths'; +import { serializeError } from 'serialize-error'; import winston, { format } from 'winston'; import 'winston-daily-rotate-file'; import type { TransformableInfo } from 'logform'; -import { serializeError } from 'serialize-error'; import RendererTransport from './rendererTransport'; -export * from './wikiOutput'; /** * Custom formatter to serialize Error objects using serialize-error package. * Falls back to template string if serialization fails. @@ -44,6 +43,44 @@ const logger = winston.createLogger({ }); export { logger }; +/** + * Store for labeled loggers (e.g., per-wiki loggers) + */ +const labeledLoggers = new Map(); + +/** + * Get or create a logger for a specific label (e.g., wiki name) + * Each labeled logger writes to its own log file + * @param label The label for the logger (e.g., wiki workspace name) + * @returns A winston logger instance for the specified label + */ +export function getLoggerForLabel(label: string): winston.Logger { + const existingLogger = labeledLoggers.get(label); + if (existingLogger) { + return existingLogger; + } + + // Create new logger for this label + const labeledLogger = winston.createLogger({ + transports: [ + new winston.transports.Console(), + new winston.transports.DailyRotateFile({ + filename: `${label}-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: false, + maxSize: '20mb', + maxFiles: '14d', + dirname: LOG_FOLDER, + level: 'debug', + }), + ], + format: format.combine(errorSerializer(), format.label({ label }), format.timestamp(), format.json()), + }); + + labeledLoggers.set(label, labeledLogger); + return labeledLogger; +} + /** * Prevent MacOS error `Unhandled Error Error: write EIO at afterWriteDispatched` */ @@ -57,6 +94,20 @@ export function destroyLogger(): void { } catch {} } }); + + // Destroy all labeled loggers + for (const [label, labeledLogger] of labeledLoggers.entries()) { + labeledLogger.transports.forEach((t) => { + if (t) { + try { + labeledLogger.remove(t); + // eslint-disable-next-line no-empty + } catch {} + } + }); + labeledLoggers.delete(label); + } + // Prevent `Error: write EIO at afterWriteDispatched (node:internal/stream_base_commons:159:15)` console.error = () => {}; console.info = () => {}; diff --git a/src/services/libs/log/wikiOutput.ts b/src/services/libs/log/wikiOutput.ts deleted file mode 100644 index ea5e6e40..00000000 --- a/src/services/libs/log/wikiOutput.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { LOG_FOLDER } from '@/constants/appPaths'; -import winston, { format } from 'winston'; -import 'winston-daily-rotate-file'; - -function getWikiLogFileName(workspaceID: string, wikiName: string): string { - const logFileName = wikiName.replaceAll(/["*/:<>?\\|]/g, '_'); - return `${workspaceID}-${logFileName}`; -} -export function getWikiErrorLogFileName(workspaceID: string, wikiName: string): string { - return `error-${getWikiLogFileName(workspaceID, wikiName)}`; -} - -const wikiLoggers: Record = {}; - -/** - * Create log file using winston - * @param {string} wikiName - */ -export function startWikiLogger(workspaceID: string, wikiName: string) { - if (getWikiLogger(workspaceID) !== undefined) { - stopWikiLogger(workspaceID); - } - const wikiLogger = ( - process.env.NODE_ENV === 'test' - ? Object.assign(console, { - emerg: console.error.bind(console), - alert: console.error.bind(console), - crit: console.error.bind(console), - warning: console.warn.bind(console), - notice: console.log.bind(console), - debug: console.log.bind(console), - close: () => {}, - }) - : winston.createLogger({ - transports: [ - new winston.transports.Console(), - new winston.transports.DailyRotateFile({ - filename: `${getWikiLogFileName(workspaceID, wikiName)}-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: false, - maxSize: '20mb', - maxFiles: '14d', - dirname: LOG_FOLDER, - level: 'debug', - }), - ], - exceptionHandlers: [ - new winston.transports.DailyRotateFile({ - filename: `${getWikiErrorLogFileName(workspaceID, wikiName)}-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: false, - maxSize: '20mb', - maxFiles: '14d', - dirname: LOG_FOLDER, - }), - ], - format: format.combine(format.timestamp(), format.json()), - }) - ) as winston.Logger; - wikiLoggers[workspaceID] = wikiLogger; - return wikiLogger; -} - -export function getWikiLogger(workspaceID: string): winston.Logger { - return wikiLoggers[workspaceID]; -} - -export function stopWikiLogger(workspaceID: string) { - wikiLoggers[workspaceID].close(); - try { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete wikiLoggers[workspaceID]; - } catch (_error: unknown) { - void _error; - } -} diff --git a/src/services/libs/workerAdapter.ts b/src/services/libs/workerAdapter.ts index d0fc5c1a..8f73ee71 100644 --- a/src/services/libs/workerAdapter.ts +++ b/src/services/libs/workerAdapter.ts @@ -1,6 +1,9 @@ /** * Utility functions for Native Node.js Worker Threads communication * Replaces threads.js with native worker_threads API + * + * Note: Service registration for workers will be handled by electron-ipc-cat/worker in the future + * This file contains TidGi-specific worker proxy functionality (e.g., git worker) */ import { cloneDeep } from 'lodash'; @@ -168,7 +171,7 @@ export function createWorkerProxy any>): void { // eslint-disable-next-line @typescript-eslint/no-require-imports const { parentPort } = require('worker_threads') as typeof import('worker_threads'); @@ -196,11 +199,10 @@ export function handleWorkerMessages(methods: Record).subscribe({ next: (value: unknown) => { @@ -228,7 +230,7 @@ export function handleWorkerMessages(methods: Record); diff --git a/src/services/native/index.ts b/src/services/native/index.ts index bc067b3b..76e5b291 100644 --- a/src/services/native/index.ts +++ b/src/services/native/index.ts @@ -8,7 +8,7 @@ import { NativeChannel } from '@/constants/channels'; import { ZX_FOLDER } from '@/constants/paths'; import { githubDesktopUrl } from '@/constants/urls'; import { container } from '@services/container'; -import { logger } from '@services/libs/log'; +import { getLoggerForLabel, logger } from '@services/libs/log'; import { getLocalHostUrlWithActualIP, getUrlWithCorrectProtocol, replaceUrlPortWithSettingPort } from '@services/libs/url'; import type { IPreferenceService } from '@services/preferences/interface'; import serviceIdentifier from '@services/serviceIdentifier'; @@ -63,7 +63,7 @@ export class NativeService implements INativeService { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters public async registerKeyboardShortcut(serviceName: keyof typeof serviceIdentifier, methodName: keyof T, shortcut: string): Promise { try { - const key = `${serviceName as unknown as string}.${methodName as unknown as string}`; + const key = `${serviceName}.${String(methodName)}`; logger.info('Starting keyboard shortcut registration', { key, shortcut, serviceName, methodName, function: 'NativeService.registerKeyboardShortcut' }); // Save to preferences @@ -87,7 +87,7 @@ export class NativeService implements INativeService { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters public async unregisterKeyboardShortcut(serviceName: keyof typeof serviceIdentifier, methodName: keyof T): Promise { try { - const key = `${serviceName as unknown as string}.${methodName as unknown as string}`; + const key = `${serviceName}.${String(methodName)}`; // Get the current shortcut string before removing from preferences const shortcuts = await this.getKeyboardShortcuts(); @@ -480,4 +480,9 @@ ${message.message} logger.warn(`This url can't be loaded in-wiki. Try loading url as-is.`, { url: urlWithFileProtocol, function: 'formatFileUrlToAbsolutePath' }); return urlWithFileProtocol; } + + public async logFor(label: string, level: 'error' | 'warn' | 'info' | 'debug', message: string, meta?: Record): Promise { + const labeledLogger = getLoggerForLabel(label); + labeledLogger.log(level, message, meta); + } } diff --git a/src/services/native/interface.ts b/src/services/native/interface.ts index 06c0eb2b..3f72fb9e 100644 --- a/src/services/native/interface.ts +++ b/src/services/native/interface.ts @@ -77,6 +77,15 @@ export interface INativeService { */ getLocalHostUrlWithActualInfo(urlToReplace: string, workspaceID: string): Promise; log(level: string, message: string, meta?: Record): Promise; + /** + * Log a message for a specific label (e.g., wiki name) + * Each label gets its own log file in the wikis subdirectory + * @param label The label for the log (e.g., wiki workspace name) + * @param level Log level (error, warn, info, debug) + * @param message Log message + * @param meta Optional metadata + */ + logFor(label: string, level: 'error' | 'warn' | 'info' | 'debug', message: string, meta?: Record): Promise; mkdir(absoulutePath: string): Promise; /** * Move a file or directory. The directory can have contents. @@ -136,6 +145,7 @@ export const NativeServiceIPCDescriptor = { formatFileUrlToAbsolutePath: ProxyPropertyType.Function, getLocalHostUrlWithActualInfo: ProxyPropertyType.Function, log: ProxyPropertyType.Function, + logFor: ProxyPropertyType.Function, mkdir: ProxyPropertyType.Function, movePath: ProxyPropertyType.Function, moveToTrash: ProxyPropertyType.Function, diff --git a/src/services/theme/hooks.ts b/src/services/theme/hooks.ts index 4cb18f43..a32ecd4a 100644 --- a/src/services/theme/hooks.ts +++ b/src/services/theme/hooks.ts @@ -4,6 +4,6 @@ import type { ITheme } from './interface'; export function useThemeObservable(): ITheme | undefined { const [theme, themeSetter] = useState(); - useObservable(window.observables.theme.theme$, themeSetter as unknown as (value: ITheme | undefined) => void); + useObservable(window.observables.theme.theme$, themeSetter); return theme; } diff --git a/src/services/updater/hooks.ts b/src/services/updater/hooks.ts index 850dcef1..9c1505c5 100644 --- a/src/services/updater/hooks.ts +++ b/src/services/updater/hooks.ts @@ -7,7 +7,7 @@ import { IUpdaterStatus } from './interface'; export function useUpdaterObservable(): IUpdaterMetaData | undefined { const [updaterMetaData, updaterMetaDataSetter] = useState(); - useObservable(window.observables.updater.updaterMetaData$, updaterMetaDataSetter as unknown as (value: IUpdaterMetaData | undefined) => void); + useObservable(window.observables.updater.updaterMetaData$, updaterMetaDataSetter); return updaterMetaData; } diff --git a/src/services/view/index.ts b/src/services/view/index.ts index b4801c47..e4b022db 100644 --- a/src/services/view/index.ts +++ b/src/services/view/index.ts @@ -1,6 +1,6 @@ import { container } from '@services/container'; import { getPreloadPath } from '@services/windows/viteEntry'; -import { BrowserWindow, ipcMain, WebContentsView, WebPreferences } from 'electron'; +import { BrowserWindow, WebContentsView, WebPreferences } from 'electron'; import { inject, injectable } from 'inversify'; import type { IMenuService } from '@services/menu/interface'; @@ -10,7 +10,7 @@ import type { IWindowService } from '@services/windows/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; -import { MetaDataChannel, ViewChannel, WindowChannel } from '@/constants/channels'; +import { MetaDataChannel, WindowChannel } from '@/constants/channels'; import { getDefaultTidGiUrl } from '@/constants/urls'; import { isMac, isWin } from '@/helpers/system'; import type { IAuthenticationService } from '@services/auth/interface'; @@ -39,7 +39,6 @@ export class View implements IViewService { @inject(serviceIdentifier.NativeService) private readonly nativeService: INativeService, @inject(serviceIdentifier.MenuService) private readonly menuService: IMenuService, ) { - this.initIPCHandlers(); } // Circular dependency services - use container.get() when needed @@ -51,28 +50,10 @@ export class View implements IViewService { return container.get(serviceIdentifier.Workspace); } - private get workspaceViewService(): IWorkspaceViewService { - return container.get(serviceIdentifier.WorkspaceView); - } - public async initialize(): Promise { await this.registerMenu(); } - private initIPCHandlers(): void { - ipcMain.handle(ViewChannel.onlineStatusChanged, async (_event, _online: boolean) => { - // try to fix when wifi status changed when wiki startup, causing wiki not loaded properly. - // if (online) { - // await this.reloadViewsWebContentsIfDidFailLoad(); - // } - // /** - // * fixLocalIpNotAccessible. try to fix when network changed cause old local ip not accessible, need to generate a new ip and reload the view - // * Do this for all workspace and all views... - // */ - // await this.workspaceViewService.restartAllWorkspaceView(); - }); - } - private async registerMenu(): Promise { const workspaceService = container.get(serviceIdentifier.Workspace); const preferenceService = container.get(serviceIdentifier.Preference); diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index db746b55..203de559 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -1,13 +1,14 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ import { createWorkerProxy, terminateWorker } from '@services/libs/workerAdapter'; import { dialog, shell } from 'electron'; +import { attachWorker } from 'electron-ipc-cat/server'; import { backOff } from 'exponential-backoff'; import { copy, createSymlink, exists, mkdir, mkdirp, mkdirs, pathExists, readdir, readFile, remove } from 'fs-extra'; import { inject, injectable } from 'inversify'; import path from 'path'; import { Worker } from 'worker_threads'; // @ts-expect-error - Vite worker import with ?nodeWorker query -import WikiWorkerFactory from './wikiWorker?nodeWorker'; +import WikiWorkerFactory from './wikiWorker/index?nodeWorker'; import { container } from '@services/container'; @@ -16,7 +17,7 @@ import { TIDDLERS_PATH, TIDDLYWIKI_PACKAGE_FOLDER, TIDDLYWIKI_TEMPLATE_FOLDER_PA import type { IAuthenticationService } from '@services/auth/interface'; import type { IGitService, IGitUserInfos } from '@services/git/interface'; import { i18n } from '@services/libs/i18n'; -import { getWikiErrorLogFileName, logger, startWikiLogger } from '@services/libs/log'; +import { logger } from '@services/libs/log'; import serviceIdentifier from '@services/serviceIdentifier'; import type { IViewService } from '@services/view/interface'; import type { IWindowService } from '@services/windows/interface'; @@ -29,8 +30,6 @@ import type { IChangedTiddlers } from 'tiddlywiki'; import { AlreadyExistError, CopyWikiTemplateError, DoubleWikiInstanceError, HTMLCanNotLoadError, SubWikiSMainWikiNotExistError, WikiRuntimeError } from './error'; import type { IWikiService } from './interface'; import { WikiControlActions } from './interface'; -import { getSubWikiPluginContent, updateSubWikiPluginContent } from './plugin/subWikiPlugin'; -import type { ISubWikiPluginContent } from './plugin/subWikiPlugin'; import type { IStartNodeJSWikiConfigs, WikiWorker } from './wikiWorker'; import type { IpcServerRouteMethods, IpcServerRouteNames } from './wikiWorker/ipcServerRoutes'; @@ -41,6 +40,7 @@ import { defaultServerIP } from '@/constants/urls'; import type { IDatabaseService } from '@services/database/interface'; import type { IPreferenceService } from '@services/preferences/interface'; import type { ISyncService } from '@services/sync/interface'; +import { serializeError } from 'serialize-error'; import { wikiWorkerStartedEventName } from './constants'; import type { IWorkerWikiOperations } from './wikiOperations/executor/wikiOperationInServer'; import { getSendWikiOperationsToBrowser } from './wikiOperations/sender/sendWikiOperationsToBrowser'; @@ -55,10 +55,6 @@ export class Wiki implements IWikiService { ) { } - public async getSubWikiPluginContent(mainWikiPath: string): Promise { - return await getSubWikiPluginContent(mainWikiPath); - } - // handlers public async copyWikiTemplate(newFolderPath: string, folderName: string): Promise { logger.info('starting', { @@ -87,8 +83,7 @@ export class Wiki implements IWikiService { } // key is same to workspace id, so we can get this worker by workspace id - private wikiWorkers: Partial> = {}; - private nativeWorkers: Partial> = {}; + private wikiWorkers: Partial void; nativeWorker: Worker; proxy: WikiWorker }>> = {}; public getWorker(id: string): WikiWorker | undefined { return this.wikiWorkers[id]?.proxy; @@ -151,32 +146,40 @@ export class Wiki implements IWikiService { tiddlyWikiPort: port, tokenAuth, userName, + workspace, }; logger.debug('initializing wikiWorker for workspace', { workspaceID, function: 'Wiki.startWiki', }); - // Create native worker using Vite's ?nodeWorker import + // Create native nodejs worker using Vite's ?nodeWorker import // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const nativeWorker = WikiWorkerFactory() as Worker; - const worker = createWorkerProxy(nativeWorker); + const wikiWorker = WikiWorkerFactory() as Worker; + + // Attach worker to all registered services (from bindServiceAndProxy) + const detachWorker = attachWorker(wikiWorker); + + const worker = createWorkerProxy(wikiWorker); logger.debug(`wikiWorker initialized`, { function: 'Wiki.startWiki' }); - this.wikiWorkers[workspaceID] = { proxy: worker, nativeWorker }; + this.wikiWorkers[workspaceID] = { proxy: worker, nativeWorker: wikiWorker, detachWorker }; this.wikiWorkerStartedEventTarget.dispatchEvent(new Event(wikiWorkerStartedEventName(workspaceID))); - const wikiLogger = startWikiLogger(workspaceID, name); - const loggerMeta = { worker: 'NodeJSWiki', homePath: wikiFolderLocation }; + + // Notify worker that services are ready to use + worker.notifyServicesReady(); + + const loggerMeta = { worker: 'NodeJSWiki', homePath: wikiFolderLocation, workspaceID }; await new Promise((resolve, reject) => { // Handle worker errors - nativeWorker.on('error', (error: Error) => { - wikiLogger.error(error.message, { function: 'Worker.error' }); + wikiWorker.on('error', (error: Error) => { + logger.error(error.message, { function: 'Worker.error', ...loggerMeta }); reject(new WikiRuntimeError(error, name, false)); }); // Handle worker exit - nativeWorker.on('exit', (code) => { + wikiWorker.on('exit', (code) => { delete this.wikiWorkers[workspaceID]; const warningMessage = `NodeJSWiki ${workspaceID} Worker stopped with code ${code}`; logger.info(warningMessage, loggerMeta); @@ -188,9 +191,9 @@ export class Wiki implements IWikiService { }); // Handle worker messages (for logging) - nativeWorker.on('message', (message: unknown) => { + wikiWorker.on('message', (message: unknown) => { if (message && typeof message === 'object' && 'log' in message) { - wikiLogger.info('Worker message', { data: message }); + logger.info('Worker message', { data: message, ...loggerMeta }); } }); @@ -251,8 +254,6 @@ export class Wiki implements IWikiService { reject(new WikiRuntimeError(new Error(message.message), wikiFolderLocation, false, { ...workspace })); } } - } else if (message.type === 'stderr' || message.type === 'stdout') { - wikiLogger.info(message.message, { function: 'startNodeJSWiki' }); } }); }); @@ -372,8 +373,10 @@ export class Wiki implements IWikiService { } public async stopWiki(id: string): Promise { - const worker = this.getWorker(id); - const nativeWorker = this.getNativeWorker(id); + const workerData = this.wikiWorkers[id]; + const worker = workerData?.proxy; + const nativeWorker = workerData?.nativeWorker; + const detachWorker = workerData?.detachWorker; if (worker === undefined || nativeWorker === undefined) { logger.warn(`No wiki for ${id}. No running worker, means maybe tiddlywiki server in this workspace failed to start`, { @@ -387,10 +390,15 @@ export class Wiki implements IWikiService { syncService.stopIntervalSync(id); try { - logger.debug(`worker.beforeExit for ${id}`); - worker.beforeExit(); - logger.debug(`terminateWorker for ${id}`); + logger.info(`worker.beforeExit for ${id}`); + await worker.beforeExit(); + logger.info(`terminateWorker for ${id}`); await terminateWorker(nativeWorker); + // Detach worker from service message handlers + if (detachWorker !== undefined) { + logger.info(`detachWorker for ${id}`); + detachWorker(); + } } catch (error) { logger.error('wiki worker stop failed', { function: 'stopWiki', error }); } @@ -479,7 +487,7 @@ export class Wiki implements IWikiService { this.logProgress(i18n.t('AddWorkspace.WikiTemplateCopyCompleted') + newWikiPath); } - public async createSubWiki(parentFolderLocation: string, folderName: string, subWikiFolderName: string, mainWikiPath: string, tagName = '', onlyLink = false): Promise { + public async createSubWiki(parentFolderLocation: string, folderName: string, _subWikiFolderName: string, mainWikiPath: string, _tagName = '', onlyLink = false): Promise { this.logProgress(i18n.t('AddWorkspace.StartCreatingSubWiki')); const newWikiPath = path.join(parentFolderLocation, folderName); if (!(await pathExists(parentFolderLocation))) { @@ -497,10 +505,8 @@ export class Wiki implements IWikiService { } this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki')); await this.linkWiki(mainWikiPath, folderName, newWikiPath); - if (typeof tagName === 'string' && tagName.length > 0) { - this.logProgress(i18n.t('AddWorkspace.AddFileSystemPath')); - updateSubWikiPluginContent(mainWikiPath, newWikiPath, { tagName, subWikiFolderName }); - } + // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin + // No need to update $:/config/FileSystemPaths manually this.logProgress(i18n.t('AddWorkspace.SubWikiCreationCompleted')); } @@ -637,7 +643,7 @@ export class Wiki implements IWikiService { mainWikiPath: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos, - tagName = '', + _tagName = '', ): Promise { this.logProgress(i18n.t('AddWorkspace.StartCloningSubWiki')); const newWikiPath = path.join(parentFolderLocation, wikiFolderName); @@ -656,10 +662,8 @@ export class Wiki implements IWikiService { await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo); this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki')); await this.linkWiki(mainWikiPath, wikiFolderName, path.join(parentFolderLocation, wikiFolderName)); - if (typeof tagName === 'string' && tagName.length > 0) { - this.logProgress(i18n.t('AddWorkspace.AddFileSystemPath')); - updateSubWikiPluginContent(mainWikiPath, newWikiPath, { tagName, subWikiFolderName: wikiFolderName }); - } + // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin + // No need to update $:/config/FileSystemPaths manually } // wiki-startup.ts @@ -747,12 +751,6 @@ export class Wiki implements IWikiService { await syncService.startIntervalSyncIfNeeded(workspace); } - public async updateSubWikiPluginContent(mainWikiPath: string, subWikiPath: string, newConfig?: IWorkspace, oldConfig?: IWorkspace): Promise { - const newConfigTyped = newConfig && isWikiWorkspace(newConfig) ? newConfig : undefined; - const oldConfigTyped = oldConfig && isWikiWorkspace(oldConfig) ? oldConfig : undefined; - updateSubWikiPluginContent(mainWikiPath, subWikiPath, newConfigTyped, oldConfigTyped); - } - public async wikiOperationInBrowser( operationType: OP, workspaceID: string, @@ -767,9 +765,7 @@ export class Wiki implements IWikiService { throw new TypeError(`${operationType} gets no useful handler`); } if (!Array.isArray(arguments_)) { - // TODO: better type handling here - // eslint-disable-next-line @typescript-eslint/no-explicit-any - throw new TypeError(`${(arguments_ as any) ?? ''} (${typeof arguments_}) is not a good argument array for ${operationType}`); + throw new TypeError(`${JSON.stringify((arguments_ as unknown) ?? '')} (${typeof arguments_}) is not a good argument array for ${operationType}`); } // @ts-expect-error A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556) this maybe a bug of ts... try remove this comment after upgrade ts. And the result become void is weird too. @@ -817,12 +813,24 @@ export class Wiki implements IWikiService { } } - public async getWikiErrorLogs(workspaceID: string, wikiName: string): Promise<{ content: string; filePath: string }> { - const filePath = path.join(LOG_FOLDER, getWikiErrorLogFileName(workspaceID, wikiName)); - const content = await readFile(filePath, 'utf8'); - return { - content, - filePath, - }; + public async getWikiErrorLogs(_workspaceID: string, wikiName: string): Promise<{ content: string; filePath: string }> { + // All logs (including errors) are now in the labeled logger file + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const logFileName = `${wikiName}-${today}.log`; + const filePath = path.join(LOG_FOLDER, logFileName); + + try { + const content = await readFile(filePath, 'utf8'); + return { + content, + filePath, + }; + } catch (error) { + // Log file doesn't exist yet or can't be read + return { + content: 'Unexpected error:' + JSON.stringify(serializeError(error)), + filePath, + }; + } } } diff --git a/src/services/wiki/interface.ts b/src/services/wiki/interface.ts index 50a3531f..c826dacd 100644 --- a/src/services/wiki/interface.ts +++ b/src/services/wiki/interface.ts @@ -4,7 +4,6 @@ import type { IWorkspace } from '@services/workspaces/interface'; import { ProxyPropertyType } from 'electron-ipc-cat/common'; import type { Observable } from 'rxjs'; import type { IChangedTiddlers } from 'tiddlywiki'; -import type { ISubWikiPluginContent } from './plugin/subWikiPlugin'; import type { IWorkerWikiOperations } from './wikiOperations/executor/wikiOperationInServer'; import type { ISendWikiOperationsToBrowser } from './wikiOperations/sender/sendWikiOperationsToBrowser'; import type { WikiWorker } from './wikiWorker'; @@ -47,7 +46,6 @@ export interface IWikiService { createSubWiki(parentFolderLocation: string, folderName: string, subWikiFolderName: string, mainWikiPath: string, tagName?: string, onlyLink?: boolean): Promise; ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise; extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath: string): Promise; - getSubWikiPluginContent(mainWikiPath: string): Promise; /** * Get tiddler's absolute path. So you can open image or PDF in OS native viewer or some else usage like this, using `window?.service?.native?.openPath?.(filePath)` * @returns absolute path like `'/Users/linonetwo/Desktop/repo/TiddlyGit-Desktop/wiki-dev/wiki/tiddlers/Index.tid'` @@ -76,7 +74,6 @@ export interface IWikiService { startWiki(workspaceID: string, userName: string): Promise; stopAllWiki(): Promise; stopWiki(workspaceID: string): Promise; - updateSubWikiPluginContent(mainWikiPath: string, subWikiPath: string, newConfig?: IWorkspace, oldConfig?: IWorkspace): Promise; /** * Runs wiki related JS script in wiki page to control the wiki. * @@ -112,7 +109,6 @@ export const WikiServiceIPCDescriptor = { createSubWiki: ProxyPropertyType.Function, ensureWikiExist: ProxyPropertyType.Function, extractWikiHTML: ProxyPropertyType.Function, - getSubWikiPluginContent: ProxyPropertyType.Function, getWikiErrorLogs: ProxyPropertyType.Function, linkWiki: ProxyPropertyType.Function, getTiddlerFilePath: ProxyPropertyType.Function, @@ -123,7 +119,6 @@ export const WikiServiceIPCDescriptor = { startWiki: ProxyPropertyType.Function, stopAllWiki: ProxyPropertyType.Function, stopWiki: ProxyPropertyType.Function, - updateSubWikiPluginContent: ProxyPropertyType.Function, wikiOperationInBrowser: ProxyPropertyType.Function, wikiOperationInServer: ProxyPropertyType.Function, wikiStartup: ProxyPropertyType.Function, @@ -133,11 +128,7 @@ export const WikiServiceIPCDescriptor = { // Workers -export type IWikiMessage = IWikiLogMessage | IWikiControlMessage; -export interface IWikiLogMessage { - message: string; - type: 'stdout' | 'stderr'; -} +export type IWikiMessage = IWikiControlMessage; export enum WikiControlActions { /** wiki is booted */ booted = 'tw-booted', diff --git a/src/services/wiki/plugin/subWikiPlugin.ts b/src/services/wiki/plugin/subWikiPlugin.ts deleted file mode 100644 index a6a7755d..00000000 --- a/src/services/wiki/plugin/subWikiPlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { TIDDLERS_PATH } from '@/constants/paths'; -import { logger } from '@services/libs/log'; -import { IWikiWorkspace } from '@services/workspaces/interface'; -import fs from 'fs-extra'; -import { compact, drop, take } from 'lodash'; -import path from 'path'; - -/** - * [in-tagtree-of[APrivateContent]]:and[search-replace:g[/],[_]search-replace:g[:],[_]addprefix[/]addprefix[private-wiki]addprefix[/]addprefix[subwiki]] - */ -const REPLACE_SYSTEM_TIDDLER_SYMBOL = 'search-replace:g[/],[_]search-replace:g[:],[_]'; -const getMatchPart = (tagToMatch: string): string => `in-tagtree-of[${tagToMatch}]`; -const andPart = ']:and['; -const getPathPart = (subWikiFolderName: string, subWikiPathDirectoryName: string): string => - `${REPLACE_SYSTEM_TIDDLER_SYMBOL}addprefix[/]addprefix[${subWikiPathDirectoryName}]addprefix[/]addprefix[${subWikiFolderName}]]`; -const getTagNameFromMatchPart = (matchPart: string): string => - matchPart.replace(/\[(!is\[system]\s*)?in-tagtree-of\[/, '').replace(/](search-replace:g\[\/],\[_]search-replace:g\[:],\[_])?.*/, ''); -const getFolderNamePathPart = (pathPart: string): string => pathPart.replace(']addprefix[/]addprefix[subwiki]]', '').replace(/.+addprefix\[/, ''); - -/** - * We have a tiddler in the sub-wiki plugin that overwrite the system tiddler $:/config/FileSystemPaths - * @param mainWikiPath subwiki's main wiki's absolute path. - * @returns - */ -function getFileSystemPathsTiddlerPath(mainWikiPath: string): string { - return path.join(mainWikiPath, TIDDLERS_PATH, 'FileSystemPaths.tid'); -} - -const emptyFileSystemPathsTiddler = `title: $:/config/FileSystemPaths -`; - -/** - * update $:/config/FileSystemPaths programmatically to make private tiddlers goto the sub-wiki - * @param {string} mainWikiPath main wiki's location path - * @param {string} subWikiPath sub wiki's location path - * @param {Object} newConfig { "tagName": Tag to indicate that a tiddler belongs to a sub-wiki, "subWikiFolderName": folder name containing all subwiki, default to `/subwiki` } - * @param {Object} oldConfig if you need to replace a line, you need to pass-in what old line looks like, so here we can find and replace it - */ -export function updateSubWikiPluginContent( - mainWikiPath: string, - subWikiPath: string, - newConfig?: Pick, - oldConfig?: Pick, -): void { - const FileSystemPathsTiddlerPath = getFileSystemPathsTiddlerPath(mainWikiPath); - - // Read file content atomically - re-read just before write to minimize race condition window - const readFileContent = () => fs.existsSync(FileSystemPathsTiddlerPath) ? fs.readFileSync(FileSystemPathsTiddlerPath, 'utf8') : emptyFileSystemPathsTiddler; - const FileSystemPathsFile = readFileContent(); - let newFileSystemPathsFile = ''; - // ignore the tags, title and type, 3 lines, and an empty line - const header = take(FileSystemPathsFile.split('\n\n'), 1); - const FileSystemPaths = compact(drop(FileSystemPathsFile.split('\n\n'), 1)); - const subWikiPathDirectoryName = path.basename(subWikiPath); - // if newConfig is undefined, but oldConfig is provided, we delete the old config - if (newConfig === undefined) { - if (oldConfig === undefined) { - throw new Error('Both newConfig and oldConfig are not provided in the updateSubWikiPluginContent() for\n' + JSON.stringify(mainWikiPath)); - } - const { tagName, subWikiFolderName } = oldConfig; - if (typeof tagName !== 'string' || typeof subWikiFolderName !== 'string') { - throw new Error('tagName or subWikiFolderName is not string for in the updateSubWikiPluginContent() for\n' + JSON.stringify(mainWikiPath)); - } - // find the old line, delete it - const newFileSystemPaths = FileSystemPaths.filter((line) => !(line.includes(getMatchPart(tagName)) && line.includes(getPathPart(subWikiFolderName, subWikiPathDirectoryName)))); - - newFileSystemPathsFile = `${header.join('\n')}\n\n${newFileSystemPaths.join('\n')}`; - } else { - // if this config already exists, just return - const { tagName, subWikiFolderName } = newConfig; - if (typeof tagName !== 'string' || typeof subWikiFolderName !== 'string') { - throw new Error('tagName or subWikiFolderName is not string for in the updateSubWikiPluginContent() for\n' + JSON.stringify(mainWikiPath)); - } - if (FileSystemPaths.some((line) => line.includes(tagName) && line.includes(subWikiFolderName))) { - return; - } - // prepare new line - const newConfigLine = '[' + getMatchPart(tagName) + andPart + getPathPart(subWikiFolderName, subWikiPathDirectoryName); - // if we are just to add a new config, just append it to the end of the file - const oldConfigTagName = oldConfig?.tagName; - if (oldConfig !== undefined && typeof oldConfigTagName === 'string' && typeof oldConfig.subWikiFolderName === 'string') { - // find the old line, replace it with the new line - const newFileSystemPaths = FileSystemPaths.map((line) => { - if (line.includes(oldConfigTagName) && line.includes(oldConfig.subWikiFolderName)) { - return newConfigLine; - } - return line; - }); - - newFileSystemPathsFile = `${header.join('\n')}\n\n${newFileSystemPaths.join('\n')}`; - } else { - newFileSystemPathsFile = `${FileSystemPathsFile}\n${newConfigLine}`; - } - } - - // Helper function to recalculate file content from fresh data - const recalculateContent = (freshFileContent: string): string => { - const lines = freshFileContent.split('\n'); - const freshHeader = lines.filter((line) => line.startsWith('\\')); - const freshFileSystemPaths = lines.filter((line) => !line.startsWith('\\') && line.length > 0); - - if (newConfig === undefined) { - // Delete operation - if (oldConfig === undefined || typeof oldConfig.tagName !== 'string' || typeof oldConfig.subWikiFolderName !== 'string') { - throw new Error('Invalid oldConfig in delete operation'); - } - const { tagName: oldTagName, subWikiFolderName: oldSubWikiFolderName } = oldConfig; - const newPaths = freshFileSystemPaths.filter((line) => - !(line.includes(getMatchPart(oldTagName)) && line.includes(getPathPart(oldSubWikiFolderName, subWikiPathDirectoryName))) - ); - return `${freshHeader.join('\n')}\n\n${newPaths.join('\n')}`; - } else { - // Add or update operation - const { tagName: newTagName, subWikiFolderName: newSubWikiFolderName } = newConfig; - if (typeof newTagName !== 'string' || typeof newSubWikiFolderName !== 'string') { - throw new Error('Invalid newConfig in add/update operation'); - } - - const newConfigLine = '[' + getMatchPart(newTagName) + andPart + getPathPart(newSubWikiFolderName, subWikiPathDirectoryName); - - if (oldConfig !== undefined && typeof oldConfig.tagName === 'string' && typeof oldConfig.subWikiFolderName === 'string') { - // Update: replace old line with new line - const { tagName: oldTagName, subWikiFolderName: oldSubWikiFolderName } = oldConfig; - const newPaths = freshFileSystemPaths.map((line) => { - if (line.includes(oldTagName) && line.includes(oldSubWikiFolderName)) { - return newConfigLine; - } - return line; - }); - return `${freshHeader.join('\n')}\n\n${newPaths.join('\n')}`; - } else { - // Add: append new line - return `${freshFileContent}\n${newConfigLine}`; - } - } - }; - - // Retry mechanism to handle race conditions - const MAX_RETRIES = 3; - let retryCount = 0; - let success = false; - - while (retryCount < MAX_RETRIES && !success) { - try { - const currentContent = readFileContent(); - - // If file hasn't changed since initial read, write our calculated content - if (currentContent === FileSystemPathsFile) { - fs.writeFileSync(FileSystemPathsTiddlerPath, newFileSystemPathsFile); - success = true; - } else if (retryCount < MAX_RETRIES - 1) { - // File was modified by another process, retry with fresh data to avoid data loss - console.warn(`[subWikiPlugin] File was modified during update, retrying with fresh data (attempt ${retryCount + 1}/${MAX_RETRIES})`); - - // Recalculate content based on fresh file data - newFileSystemPathsFile = recalculateContent(currentContent); - - retryCount++; - } else { - // Final attempt: recalculate one last time and write - console.error('[subWikiPlugin] Max retries reached, forcing write with latest data. Concurrent modifications may be lost.'); - newFileSystemPathsFile = recalculateContent(currentContent); - fs.writeFileSync(FileSystemPathsTiddlerPath, newFileSystemPathsFile); - success = true; - } - } catch (error) { - console.error('[subWikiPlugin] Error writing file:', error); - throw error; - } - } -} - -/** - * "Sub-Wiki Plugin"'s content. Not about plugin content of a sub-wiki, sorry. - * This is about tag-subwiki pair, we put tiddler with certain tag into a subwiki according to these pairs. - */ -export interface ISubWikiPluginContent { - folderName: string; - tagName: string; -} -/** - * Get "Sub-Wiki Plugin"'s content - * @param mainWikiPath subwiki's main wiki's absolute path. - * @returns ISubWikiPluginContent - */ -export async function getSubWikiPluginContent(mainWikiPath: string): Promise { - if (mainWikiPath.length === 0) return []; - const FileSystemPathsTiddlerPath = getFileSystemPathsTiddlerPath(mainWikiPath); - try { - const FileSystemPathsFile = await fs.readFile(FileSystemPathsTiddlerPath, 'utf8'); - const FileSystemPaths = compact(drop(FileSystemPathsFile.split('\n\n'), 1)); - return FileSystemPaths.map((line) => ({ - tagName: getTagNameFromMatchPart(line), - folderName: getFolderNamePathPart(line), - })).filter((item) => item.folderName.length > 0 && item.tagName.length > 0); - } catch (error) { - logger.error((error as Error).message, { error, function: 'getSubWikiPluginContent' }); - return []; - } -} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts new file mode 100644 index 00000000..0dc3f627 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts @@ -0,0 +1,365 @@ +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 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 = 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 { + 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. + */ + async getTiddlerFileInfo(tiddler: Tiddler): Promise { + if (!this.boot.wikiTiddlersPath) { + throw new Error('filesystem adaptor requires a valid wiki folder'); + } + + const title = tiddler.fields.title; + const tags = tiddler.fields.tags ?? []; + const fileInfo = this.boot.files[title]; + + try { + 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 + */ + protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: FileInfo | undefined): FileInfo { + const targetDirectory = subWiki.wikiFolderLocation; + $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 }): Promise { + 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); + + this.boot.files[tiddler.fields.title] = { + ...savedFileInfo, + isEditableFile: savedFileInfo.isEditableFile ?? true, + }; + + await new Promise((resolve, reject) => { + const cleanupOptions = { + adaptorInfo: options?.tiddlerInfo as FileInfo | undefined, + bootInfo: this.boot.files[tiddler.fields.title], + 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 { + const fileInfo = this.boot.files[title]; + + if (!fileInfo) { + callback?.(null, null); + return; + } + + try { + await new Promise((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 { + const maxRetries = options.maxRetries ?? 10; + const initialDelay = options.initialDelay ?? 50; + const maxDelay = options.maxDelay ?? 2000; + + try { + return await backOff( + async () => { + return await new Promise((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; + } + } +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts new file mode 100644 index 00000000..76debd54 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts @@ -0,0 +1,90 @@ +import type { FileInfo } from 'tiddlywiki'; + +export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string }; + +/** + * Inverse index for mapping file paths to tiddler information. + * Uses Map for better performance with frequent add/delete operations. + * + * This index enables O(1) lookups of tiddler information by file path, + * which is critical for the file watcher to efficiently process file change events. + */ +export class InverseFilesIndex { + private index: Map = new Map(); + + /** + * Set or update tiddler information for a file path + */ + set(filePath: string, fileDescriptor: IBootFilesIndexItemWithTitle): void { + this.index.set(filePath, fileDescriptor); + } + + /** + * Get tiddler information by file path + * @returns The file descriptor or undefined if not found + */ + get(filePath: string): IBootFilesIndexItemWithTitle | undefined { + return this.index.get(filePath); + } + + /** + * Check if a file path exists in the index + */ + has(filePath: string): boolean { + return this.index.has(filePath); + } + + /** + * Remove a file path from the index + */ + delete(filePath: string): boolean { + return this.index.delete(filePath); + } + + /** + * Get tiddler title by file path + * @throws Error if file path not found in index + */ + getTitleByPath(filePath: string): string { + const item = this.index.get(filePath); + if (!item) { + throw new Error(`${filePath}\n↑ not existed in InverseFilesIndex`); + } + return item.tiddlerTitle; + } + + /** + * Clear all entries from the index + */ + clear(): void { + this.index.clear(); + } + + /** + * Get the number of entries in the index + */ + get size(): number { + return this.index.size; + } + + /** + * Iterate over all entries + */ + entries(): IterableIterator<[string, IBootFilesIndexItemWithTitle]> { + return this.index.entries(); + } + + /** + * Iterate over all file paths + */ + keys(): IterableIterator { + return this.index.keys(); + } + + /** + * Iterate over all file descriptors + */ + values(): IterableIterator { + return this.index.values(); + } +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts new file mode 100644 index 00000000..134f9f2c --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.basic.test.ts @@ -0,0 +1,201 @@ +import { workspace } from '@services/wiki/wikiWorker/services'; +import path from 'path'; +import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileSystemAdaptor } from '../FileSystemAdaptor'; + +// Mock the workspace service +vi.mock('@services/wiki/wikiWorker/services', () => ({ + workspace: { + get: vi.fn(), + getWorkspacesAsList: vi.fn(), + }, +})); + +// Mock TiddlyWiki global +const mockLogger = { + log: vi.fn(), + alert: vi.fn(), +}; + +const mockUtils = { + Logger: vi.fn(() => mockLogger), + createDirectory: vi.fn(), + generateTiddlerFileInfo: vi.fn(), + saveTiddlerToFile: vi.fn(), + deleteTiddlerFile: vi.fn(), + cleanupTiddlerFiles: vi.fn(), + getFileExtensionInfo: vi.fn(() => ({ type: 'application/x-tiddler' })), +}; + +// Setup TiddlyWiki global +// @ts-expect-error - Setting up global for testing +global.$tw = { + node: true, + boot: { + wikiTiddlersPath: '/test/wiki/tiddlers', + files: {} as Record, + }, + utils: mockUtils, +}; + +describe('FileSystemAdaptor - Basic Functionality', () => { + let adaptor: FileSystemAdaptor; + let mockWiki: Wiki; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset boot.files + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files = {}; + + // Setup mock wiki + mockWiki = { + getTiddlerText: vi.fn(() => ''), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + // Reset workspace mocks + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + }); + + describe('Constructor & Initialization', () => { + it('should initialize with correct properties', () => { + expect(adaptor.name).toBe('filesystem'); + expect(adaptor.supportsLazyLoading).toBe(false); + expect(adaptor.wiki).toBe(mockWiki); + }); + + it('should set watchPathBase from wikiTiddlersPath', () => { + expect(adaptor['watchPathBase']).toBe(path.resolve('/test/wiki/tiddlers')); + }); + + it('should create directory for wikiTiddlersPath', () => { + expect(mockUtils.createDirectory).toHaveBeenCalledWith('/test/wiki/tiddlers'); + }); + + it('should throw error in non-Node.js environment', () => { + // @ts-expect-error - TiddlyWiki global + const originalNode = global.$tw.node; + // @ts-expect-error - TiddlyWiki global + global.$tw.node = false; + + expect(() => { + new FileSystemAdaptor({ wiki: mockWiki }); + }).toThrow('filesystem adaptor only works in Node.js environment'); + + // @ts-expect-error - TiddlyWiki global + global.$tw.node = originalNode; + }); + + it('should initialize extension filters from wiki config', () => { + const wikiWithConfig = { + getTiddlerText: vi.fn(() => '.tid\n.json\n.png'), + tiddlerExists: vi.fn((title) => title === '$:/config/FileSystemExtensions'), + addTiddler: vi.fn(), + } as unknown as Wiki; + + const adaptorWithFilters = new FileSystemAdaptor({ + wiki: wikiWithConfig, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + expect(adaptorWithFilters['extensionFilters']).toEqual(['.tid', '.json', '.png']); + }); + + it('should filter out empty lines from extension filters', () => { + const wikiWithConfig = { + getTiddlerText: vi.fn(() => '.tid\n\n.json\n \n.png'), + tiddlerExists: vi.fn((title) => title === '$:/config/FileSystemExtensions'), + addTiddler: vi.fn(), + } as unknown as Wiki; + + const adaptorWithFilters = new FileSystemAdaptor({ + wiki: wikiWithConfig, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + expect(adaptorWithFilters['extensionFilters']).toEqual(['.tid', '.json', '.png']); + }); + }); + + describe('getTiddlerInfo', () => { + it('should return file info for existing tiddler', () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const tiddler = { fields: { title: 'TestTiddler' } } as Tiddler; + const result = adaptor.getTiddlerInfo(tiddler); + + expect(result).toBe(fileInfo); + }); + + it('should return undefined for non-existent tiddler', () => { + const tiddler = { fields: { title: 'NonExistent' } } as Tiddler; + const result = adaptor.getTiddlerInfo(tiddler); + + expect(result).toBeUndefined(); + }); + }); + + describe('isReady', () => { + it('should always return true', () => { + expect(adaptor.isReady()).toBe(true); + }); + }); + + describe('loadTiddler', () => { + it('should call callback with null (not needed during runtime)', () => { + const callback = vi.fn(); + adaptor.loadTiddler('TestTiddler', callback); + + expect(callback).toHaveBeenCalledWith(null, null); + }); + }); + + describe('removeTiddlerFileInfo', () => { + it('should remove tiddler from boot.files', () => { + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = { + filepath: '/test/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + adaptor.removeTiddlerFileInfo('TestTiddler'); + + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeUndefined(); + }); + + it('should handle removing non-existent tiddler', () => { + expect(() => { + adaptor.removeTiddlerFileInfo('NonExistent'); + }).not.toThrow(); + }); + }); +}); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts new file mode 100644 index 00000000..63f8d956 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.delete.test.ts @@ -0,0 +1,324 @@ +import { workspace } from '@services/wiki/wikiWorker/services'; +import type { FileInfo, Wiki } from 'tiddlywiki'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileSystemAdaptor } from '../FileSystemAdaptor'; + +// Mock the workspace service +vi.mock('@services/wiki/wikiWorker/services', () => ({ + workspace: { + get: vi.fn(), + getWorkspacesAsList: vi.fn(), + }, +})); + +// Mock TiddlyWiki global +const mockLogger = { + log: vi.fn(), + alert: vi.fn(), +}; + +const mockUtils = { + Logger: vi.fn(() => mockLogger), + createDirectory: vi.fn(), + generateTiddlerFileInfo: vi.fn(), + saveTiddlerToFile: vi.fn(), + deleteTiddlerFile: vi.fn(), + cleanupTiddlerFiles: vi.fn(), + getFileExtensionInfo: vi.fn(() => ({ type: 'application/x-tiddler' })), +}; + +// Setup TiddlyWiki global +// @ts-expect-error - Setting up global for testing +global.$tw = { + node: true, + boot: { + wikiTiddlersPath: '/test/wiki/tiddlers', + files: {} as Record, + }, + utils: mockUtils, +}; + +describe('FileSystemAdaptor - Delete Operations', () => { + let adaptor: FileSystemAdaptor; + let mockWiki: Wiki; + + beforeEach(() => { + vi.clearAllMocks(); + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files = {}; + + mockWiki = { + getTiddlerText: vi.fn(() => ''), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + }); + + describe('deleteTiddler - Callback Mode', () => { + it('should delete tiddler and call callback on success', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(null, fileInfo); + }); + + const callback = vi.fn(); + + await adaptor.deleteTiddler('TestTiddler', callback); + + expect(callback).toHaveBeenCalledWith(null, null); + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeUndefined(); + }); + + it('should call callback immediately when tiddler not found', async () => { + const callback = vi.fn(); + + await adaptor.deleteTiddler('NonExistent', callback); + + expect(callback).toHaveBeenCalledWith(null, null); + expect(mockUtils.deleteTiddlerFile).not.toHaveBeenCalled(); + }); + + it('should handle EPERM error gracefully', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const error: NodeJS.ErrnoException = new Error('Permission denied'); + error.code = 'EPERM'; + error.syscall = 'unlink'; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(error, fileInfo); + }); + + const callback = vi.fn(); + + await adaptor.deleteTiddler('TestTiddler', callback); + + // Should succeed despite error + expect(callback).toHaveBeenCalledWith(null, fileInfo); + expect(mockLogger.alert).toHaveBeenCalledWith( + expect.stringContaining('Server desynchronized'), + ); + // File info should NOT be removed for EPERM errors + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeDefined(); + }); + + it('should handle EACCES error gracefully', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const error: NodeJS.ErrnoException = new Error('Access denied'); + error.code = 'EACCES'; + error.syscall = 'unlink'; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(error, fileInfo); + }); + + const callback = vi.fn(); + + await adaptor.deleteTiddler('TestTiddler', callback); + + expect(callback).toHaveBeenCalledWith(null, fileInfo); + expect(mockLogger.alert).toHaveBeenCalled(); + }); + + it('should propagate non-permission errors', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const error = new Error('Disk full'); + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(error); + }); + + const callback = vi.fn(); + + await expect(adaptor.deleteTiddler('TestTiddler', callback)).rejects.toThrow('Disk full'); + + expect(callback).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should not treat EPERM as graceful if syscall is not unlink', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const error: NodeJS.ErrnoException = new Error('Permission denied'); + error.code = 'EPERM'; + error.syscall = 'read'; // Different syscall + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(error); + }); + + const callback = vi.fn(); + + await expect(adaptor.deleteTiddler('TestTiddler', callback)).rejects.toThrow(); + expect(callback).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('deleteTiddler - Async/Await Mode', () => { + it('should resolve successfully without callback', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(null, fileInfo); + }); + + await expect(adaptor.deleteTiddler('TestTiddler')).resolves.toBeUndefined(); + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeUndefined(); + }); + + it('should resolve immediately for non-existent tiddler', async () => { + await expect(adaptor.deleteTiddler('NonExistent')).resolves.toBeUndefined(); + expect(mockUtils.deleteTiddlerFile).not.toHaveBeenCalled(); + }); + + it('should reject on non-permission errors', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(new Error('IO error')); + }); + + await expect(adaptor.deleteTiddler('TestTiddler')).rejects.toThrow('IO error'); + }); + + it('should handle permission errors gracefully even without callback', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + const error: NodeJS.ErrnoException = new Error('Permission denied'); + error.code = 'EPERM'; + error.syscall = 'unlink'; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb(error, fileInfo); + }); + + // Should not throw + await expect(adaptor.deleteTiddler('TestTiddler')).resolves.toBeUndefined(); + expect(mockLogger.alert).toHaveBeenCalled(); + }); + }); + + describe('deleteTiddler - Error Conversion', () => { + it('should convert string errors to Error objects', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb('String error' as unknown as Error); + }); + + const callback = vi.fn(); + + await expect(adaptor.deleteTiddler('TestTiddler', callback)).rejects.toThrow('String error'); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + message: 'String error', + })); + }); + + it('should convert unknown errors to Error objects', async () => { + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = fileInfo; + + mockUtils.deleteTiddlerFile.mockImplementation((_f, cb) => { + cb({ weird: 'object' } as unknown as Error); + }); + + const callback = vi.fn(); + + await expect(adaptor.deleteTiddler('TestTiddler', callback)).rejects.toThrow('Unknown error'); + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Unknown error', + })); + }); + }); +}); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts new file mode 100644 index 00000000..8f282664 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts @@ -0,0 +1,497 @@ +import { workspace } from '@services/wiki/wikiWorker/services'; +import type { IWikiWorkspace } from '@services/workspaces/interface'; +import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileSystemAdaptor } from '../FileSystemAdaptor'; + +// Mock the workspace service +vi.mock('@services/wiki/wikiWorker/services', () => ({ + workspace: { + get: vi.fn(), + getWorkspacesAsList: vi.fn(), + }, +})); + +// Mock TiddlyWiki global +const mockLogger = { + log: vi.fn(), + alert: vi.fn(), +}; + +const mockUtils = { + Logger: vi.fn(() => mockLogger), + createDirectory: vi.fn(), + generateTiddlerFileInfo: vi.fn(), + saveTiddlerToFile: vi.fn(), + deleteTiddlerFile: vi.fn(), + cleanupTiddlerFiles: vi.fn(), + getFileExtensionInfo: vi.fn(() => ({ type: 'application/x-tiddler' })), +}; + +// Setup TiddlyWiki global +// @ts-expect-error - Setting up global for testing +global.$tw = { + node: true, + boot: { + wikiTiddlersPath: '/test/wiki/tiddlers', + files: {} as Record, + }, + utils: mockUtils, +}; + +describe('FileSystemAdaptor - Routing Logic', () => { + let adaptor: FileSystemAdaptor; + let mockWiki: Wiki; + + beforeEach(() => { + vi.clearAllMocks(); + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files = {}; + + mockWiki = { + getTiddlerText: vi.fn(() => ''), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + }); + + describe('getTiddlerFileInfo - Default Routing', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + }); + + it('should generate file info for tiddler without tags', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + const result = await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + pathFilters: undefined, + wiki: mockWiki, + }), + ); + expect(result).toBeTruthy(); + }); + + it('should use FileSystemPaths config when available', async () => { + vi.mocked(mockWiki.tiddlerExists).mockImplementation((title) => title === '$:/config/FileSystemPaths'); + vi.mocked(mockWiki.getTiddlerText).mockReturnValue('[tag[Journal]]/journal/\n[tag[Task]]/tasks/'); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + pathFilters: ['[tag[Journal]]/journal/', '[tag[Task]]/tasks/'], + }), + ); + }); + + it('should filter out empty lines from FileSystemPaths', async () => { + vi.mocked(mockWiki.tiddlerExists).mockImplementation((title) => title === '$:/config/FileSystemPaths'); + vi.mocked(mockWiki.getTiddlerText).mockReturnValue('[tag[Journal]]/journal/\n\n[tag[Task]]/tasks/\n '); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + pathFilters: ['[tag[Journal]]/journal/', '[tag[Task]]/tasks/'], + }), + ); + }); + + it('should pass extension filters to generateTiddlerFileInfo', async () => { + const wikiWithConfig = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/config/FileSystemExtensions') return '.tid\n.json'; + return ''; + }), + tiddlerExists: vi.fn((title) => title === '$:/config/FileSystemExtensions'), + addTiddler: vi.fn(), + } as unknown as Wiki; + + adaptor = new FileSystemAdaptor({ + wiki: wikiWithConfig, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + extFilters: ['.tid', '.json'], + }), + ); + }); + + it('should pass existing fileInfo with overwrite flag', async () => { + const existingFileInfo: FileInfo = { + filepath: '/test/old.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = existingFileInfo; + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + fileInfo: expect.objectContaining({ + overwrite: true, + }), + }), + ); + }); + + it('should throw error when wikiTiddlersPath is not set', async () => { + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.wikiTiddlersPath = undefined; + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as unknown as Tiddler; + + await expect(adaptor.getTiddlerFileInfo(tiddler)).rejects.toThrow( + 'filesystem adaptor requires a valid wiki folder', + ); + + // Restore for other tests + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.wikiTiddlersPath = '/test/wiki/tiddlers'; + }); + }); + + describe('getTiddlerFileInfo - Sub-Wiki Routing', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + // Setup mock wiki with workspace ID + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should route to sub-wiki when tiddler has matching tag', async () => { + const subWiki = { + id: 'sub-wiki-1', + name: 'Sub Wiki', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagName: 'SubWikiTag', + wikiFolderLocation: '/test/wiki/subwiki/sub1', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update and wait for it + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['SubWikiTag', 'OtherTag'] }, + } as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockUtils.createDirectory).toHaveBeenCalledWith('/test/wiki/subwiki/sub1'); + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/sub1', + pathFilters: undefined, // Sub-wikis don't use path filters + }), + ); + }); + + it('should use first matching tag when multiple sub-wiki tags exist', async () => { + const subWiki1 = { + id: 'sub-1', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagName: 'Tag1', + wikiFolderLocation: '/test/wiki/sub1', + }; + + const subWiki2 = { + id: 'sub-2', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagName: 'Tag2', + wikiFolderLocation: '/test/wiki/sub2', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki1, subWiki2] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update and wait for it + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['Tag1', 'Tag2'] }, + } as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use Tag1's directory (first match) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/sub1', + }), + ); + }); + + it('should use default path when no matching sub-wiki (various scenarios)', async () => { + // Test scenario 1: Tag doesn't match + const subWikiWithDifferentTag = { + id: 'sub-wiki-1', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagName: 'SubWikiTag', + wikiFolderLocation: '/test/wiki/subwiki', + }; + + // Test scenario 2: Sub-wiki without tagName + const subWikiWithoutTag = { + id: 'sub-wiki-2', + isSubWiki: true, + mainWikiID: 'test-workspace', + wikiFolderLocation: '/test/wiki/subwiki2', + }; + + // Test scenario 3: Sub-wiki from different main wiki + const otherMainWikiSubWiki = { + id: 'sub-wiki-3', + isSubWiki: true, + mainWikiID: 'other-workspace', + tagName: 'AnotherTag', + wikiFolderLocation: '/test/otherwiki/subwiki', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([ + subWikiWithDifferentTag, + subWikiWithoutTag, + otherMainWikiSubWiki, + ] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update and wait for it + await adaptor['updateSubWikisCache'](); + + // Tiddler with unmatched tags + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['UnmatchedTag'] }, + } as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use default directory in all scenarios + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + }), + ); + }); + }); + + describe('getTiddlerFileInfo - Error Handling', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + }); + + it('should fallback to default routing on error', async () => { + mockUtils.generateTiddlerFileInfo + .mockImplementationOnce(() => { + throw new Error('Test error'); + }) + .mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + const result = await adaptor.getTiddlerFileInfo(tiddler); + + expect(mockLogger.alert).toHaveBeenCalledWith( + expect.stringContaining('Error in getTiddlerFileInfo'), + expect.any(Error), + ); + expect(result).toBeTruthy(); + // Should have tried twice: once failed, once fallback + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledTimes(2); + }); + }); + + describe('updateSubWikisCache', () => { + it('should clear cache when workspaceID is empty', async () => { + const wikiWithoutID = { + getTiddlerText: vi.fn(() => ''), // Empty workspace ID + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + adaptor = new FileSystemAdaptor({ + wiki: wikiWithoutID, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update and wait for it + await adaptor['updateSubWikisCache'](); + + expect(adaptor['subWikisWithTag']).toEqual([]); + expect(adaptor['tagNameToSubWiki'].size).toBe(0); + }); + + it('should clear cache when currentWorkspace is not found', async () => { + vi.mocked(workspace.get).mockResolvedValue(undefined); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update and wait for it + await adaptor['updateSubWikisCache'](); + + expect(adaptor['subWikisWithTag']).toEqual([]); + expect(adaptor['tagNameToSubWiki'].size).toBe(0); + }); + + it('should handle errors in updateSubWikisCache gracefully', async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockRejectedValue(new Error('Database error')); + + const wikiWithID = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + adaptor = new FileSystemAdaptor({ + wiki: wikiWithID, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + // Manually trigger cache update to catch the error + await adaptor['updateSubWikisCache'](); + + // The error should have been logged + expect(mockLogger.alert).toHaveBeenCalledWith( + expect.stringContaining('Failed to update sub-wikis cache'), + expect.any(Error), + ); + }); + }); +}); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts new file mode 100644 index 00000000..be977eaa --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.save.test.ts @@ -0,0 +1,415 @@ +import { workspace } from '@services/wiki/wikiWorker/services'; +import type { FileInfo, Tiddler, Wiki } from 'tiddlywiki'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileSystemAdaptor } from '../FileSystemAdaptor'; + +// Mock the workspace service +vi.mock('@services/wiki/wikiWorker/services', () => ({ + workspace: { + get: vi.fn(), + getWorkspacesAsList: vi.fn(), + }, +})); + +// Mock TiddlyWiki global +const mockLogger = { + log: vi.fn(), + alert: vi.fn(), +}; + +const mockUtils = { + Logger: vi.fn(() => mockLogger), + createDirectory: vi.fn(), + generateTiddlerFileInfo: vi.fn(), + saveTiddlerToFile: vi.fn(), + deleteTiddlerFile: vi.fn(), + cleanupTiddlerFiles: vi.fn(), + getFileExtensionInfo: vi.fn(() => ({ type: 'application/x-tiddler' })), +}; + +// Setup TiddlyWiki global +// @ts-expect-error - Setting up global for testing +global.$tw = { + node: true, + boot: { + wikiTiddlersPath: '/test/wiki/tiddlers', + files: {} as Record, + }, + utils: mockUtils, +}; + +describe('FileSystemAdaptor - Save Operations', () => { + let adaptor: FileSystemAdaptor; + let mockWiki: Wiki; + + beforeEach(() => { + vi.clearAllMocks(); + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files = {}; + + mockWiki = { + getTiddlerText: vi.fn(() => ''), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + }); + + describe('saveTiddler - Callback Mode', () => { + it('should save tiddler and call callback on success', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', text: 'Test content' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(null, fileInfo); + }); + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(null, fileInfo); + }); + + const callback = vi.fn(); + + await adaptor.saveTiddler(tiddler, callback); + + expect(callback).toHaveBeenCalledWith( + null, + expect.objectContaining({ + filepath: '/test/wiki/tiddlers/test.tid', + }), + ); + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeDefined(); + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler'].isEditableFile).toBe(true); + }); + + it('should call callback with error when getTiddlerFileInfo returns null', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(null); + + const callback = vi.fn(); + + await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow( + 'No fileInfo returned from getTiddlerFileInfo', + ); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'No fileInfo returned from getTiddlerFileInfo', + }), + ); + }); + + it('should call callback with error when saveTiddlerToFile fails', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + const saveError = new Error('File write failed'); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(saveError); + }); + + const callback = vi.fn(); + + await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow(); + + expect(callback).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should handle cleanup errors', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(null, fileInfo); + }); + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(new Error('Cleanup failed')); + }); + + const callback = vi.fn(); + + await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow('Cleanup failed'); + expect(callback).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('saveTiddler - Async/Await Mode', () => { + it('should resolve successfully without callback', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(null, fileInfo); + }); + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(null, fileInfo); + }); + + await expect(adaptor.saveTiddler(tiddler)).resolves.toBeUndefined(); + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler']).toBeDefined(); + }); + + it('should reject when getTiddlerFileInfo returns null', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(null); + + await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow( + 'No fileInfo returned from getTiddlerFileInfo', + ); + }); + + it('should reject when saveTiddlerToFile fails', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(new Error('Write failed')); + }); + + await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); + }); + + it('should preserve isEditableFile from existing fileInfo', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + isEditableFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(null, fileInfo); + }); + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(null, fileInfo); + }); + + await adaptor.saveTiddler(tiddler); + + // @ts-expect-error - TiddlyWiki global + expect(global.$tw.boot.files['TestTiddler'].isEditableFile).toBe(false); + }); + }); + + describe('saveTiddler - File Lock Retry', () => { + it('should retry on EBUSY error', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + + let attemptCount = 0; + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + attemptCount++; + if (attemptCount < 3) { + const error: NodeJS.ErrnoException = new Error('File is busy'); + error.code = 'EBUSY'; + cb(error); + } else { + cb(null, fileInfo); + } + }); + + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(null, fileInfo); + }); + + const callback = vi.fn(); + + await adaptor.saveTiddler(tiddler, callback); + + expect(attemptCount).toBeGreaterThan(1); + expect(callback).toHaveBeenCalledWith(null, expect.any(Object)); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('is locked'), + ); + }); + + it('should retry on EPERM error', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + const fileInfo: FileInfo = { + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + mockUtils.generateTiddlerFileInfo.mockReturnValue(fileInfo); + + let attemptCount = 0; + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + attemptCount++; + if (attemptCount < 2) { + const error: NodeJS.ErrnoException = new Error('Permission denied'); + error.code = 'EPERM'; + cb(error); + } else { + cb(null, fileInfo); + } + }); + + mockUtils.cleanupTiddlerFiles.mockImplementation((_opts, cb) => { + cb(null, fileInfo); + }); + + await adaptor.saveTiddler(tiddler); + + expect(attemptCount).toBe(2); + }); + + it('should give up after max retries on persistent lock', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + const error: NodeJS.ErrnoException = new Error('File is locked'); + error.code = 'EBUSY'; + cb(error); + }); + + const callback = vi.fn(); + + await expect(adaptor.saveTiddler(tiddler, callback)).rejects.toThrow(); + + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Failed to save'), + })); + expect(mockUtils.saveTiddlerToFile).toHaveBeenCalledTimes(10); // Default max retries + expect(mockWiki.addTiddler).toHaveBeenCalled(); // Error notification created + }); + + it('should not retry on non-lock errors', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + const diskFullError = new Error('Disk full'); + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(diskFullError); + }); + + await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); + + // Should only try once for non-lock errors + expect(mockUtils.saveTiddlerToFile).toHaveBeenCalledTimes(1); + expect(mockWiki.addTiddler).toHaveBeenCalled(); // Error notification created + }); + + it('should create error notification with correct details', async () => { + const tiddler: Tiddler = { + fields: { title: 'TestTiddler' }, + } as Tiddler; + + mockUtils.generateTiddlerFileInfo.mockReturnValue({ + filepath: '/test/wiki/tiddlers/test.tid', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + mockUtils.saveTiddlerToFile.mockImplementation((_t, _f, cb) => { + cb(new Error('Test error')); + }); + + await expect(adaptor.saveTiddler(tiddler)).rejects.toThrow(); + + expect(mockWiki.addTiddler).toHaveBeenCalledWith( + expect.objectContaining({ + title: '$:/temp/filesystem/error/TestTiddler', + tags: ['$:/tags/Alert'], + 'error-type': 'file-save-error', + 'original-title': 'TestTiddler', + text: expect.stringContaining('Failed to save tiddler "TestTiddler"'), + }), + ); + }); + }); +}); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/InverseFilesIndex.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/InverseFilesIndex.test.ts new file mode 100644 index 00000000..b12a56da --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/InverseFilesIndex.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest'; +import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from '../InverseFilesIndex'; + +describe('InverseFilesIndex', () => { + describe('Basic Operations', () => { + it('should initialize with empty index', () => { + const index = new InverseFilesIndex(); + expect(index.size).toBe(0); + }); + + it('should set and get file descriptor', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'TestTiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set('test/path.tid', fileDescriptor); + + expect(index.size).toBe(1); + expect(index.get('test/path.tid')).toEqual(fileDescriptor); + }); + + it('should return undefined for non-existent path', () => { + const index = new InverseFilesIndex(); + expect(index.get('non-existent')).toBeUndefined(); + }); + + it('should check if path exists', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'TestTiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + expect(index.has('test/path.tid')).toBe(false); + + index.set('test/path.tid', fileDescriptor); + + expect(index.has('test/path.tid')).toBe(true); + }); + + it('should delete file descriptor', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'TestTiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set('test/path.tid', fileDescriptor); + expect(index.has('test/path.tid')).toBe(true); + + const deleted = index.delete('test/path.tid'); + + expect(deleted).toBe(true); + expect(index.has('test/path.tid')).toBe(false); + expect(index.size).toBe(0); + }); + + it('should return false when deleting non-existent path', () => { + const index = new InverseFilesIndex(); + const deleted = index.delete('non-existent'); + expect(deleted).toBe(false); + }); + + it('should update existing file descriptor', () => { + const index = new InverseFilesIndex(); + const fileDescriptor1: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'TestTiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + const fileDescriptor2: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'UpdatedTiddler', + type: 'application/x-tiddler', + hasMetaFile: true, + }; + + index.set('test/path.tid', fileDescriptor1); + expect(index.get('test/path.tid')?.tiddlerTitle).toBe('TestTiddler'); + + index.set('test/path.tid', fileDescriptor2); + expect(index.get('test/path.tid')?.tiddlerTitle).toBe('UpdatedTiddler'); + expect(index.size).toBe(1); // Size should remain 1 + }); + }); + + describe('getTitleByPath', () => { + it('should get tiddler title by file path', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: 'test/path.tid', + tiddlerTitle: 'TestTiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set('test/path.tid', fileDescriptor); + + expect(index.getTitleByPath('test/path.tid')).toBe('TestTiddler'); + }); + + it('should throw error when path does not exist', () => { + const index = new InverseFilesIndex(); + + expect(() => { + index.getTitleByPath('non-existent'); + }).toThrow('non-existent\n↑ not existed in InverseFilesIndex'); + }); + }); + + describe('Bulk Operations', () => { + it('should clear all entries', () => { + const index = new InverseFilesIndex(); + + index.set('path1.tid', { + filepath: 'path1.tid', + tiddlerTitle: 'Tiddler1', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + index.set('path2.tid', { + filepath: 'path2.tid', + tiddlerTitle: 'Tiddler2', + type: 'application/x-tiddler', + hasMetaFile: false, + }); + + expect(index.size).toBe(2); + + index.clear(); + + expect(index.size).toBe(0); + expect(index.has('path1.tid')).toBe(false); + expect(index.has('path2.tid')).toBe(false); + }); + + it('should handle multiple file descriptors', () => { + const index = new InverseFilesIndex(); + const descriptors: Array<[string, IBootFilesIndexItemWithTitle]> = [ + ['path1.tid', { filepath: 'path1.tid', tiddlerTitle: 'Tiddler1', type: 'application/x-tiddler', hasMetaFile: false }], + ['path2.tid', { filepath: 'path2.tid', tiddlerTitle: 'Tiddler2', type: 'application/x-tiddler', hasMetaFile: false }], + ['path3.tid', { filepath: 'path3.tid', tiddlerTitle: 'Tiddler3', type: 'application/x-tiddler', hasMetaFile: false }], + ]; + + for (const [path, descriptor] of descriptors) { + index.set(path, descriptor); + } + + expect(index.size).toBe(3); + expect(index.getTitleByPath('path1.tid')).toBe('Tiddler1'); + expect(index.getTitleByPath('path2.tid')).toBe('Tiddler2'); + expect(index.getTitleByPath('path3.tid')).toBe('Tiddler3'); + }); + }); + + describe('Iteration', () => { + it('should iterate over entries', () => { + const index = new InverseFilesIndex(); + const descriptors = new Map([ + ['path1.tid', { filepath: 'path1.tid', tiddlerTitle: 'Tiddler1', type: 'application/x-tiddler', hasMetaFile: false }], + ['path2.tid', { filepath: 'path2.tid', tiddlerTitle: 'Tiddler2', type: 'application/x-tiddler', hasMetaFile: false }], + ]); + + for (const [path, descriptor] of descriptors) { + index.set(path, descriptor); + } + + const entries = Array.from(index.entries()); + + expect(entries).toHaveLength(2); + expect(entries[0][0]).toBe('path1.tid'); + expect(entries[0][1].tiddlerTitle).toBe('Tiddler1'); + expect(entries[1][0]).toBe('path2.tid'); + expect(entries[1][1].tiddlerTitle).toBe('Tiddler2'); + }); + + it('should iterate over keys', () => { + const index = new InverseFilesIndex(); + + index.set('path1.tid', { filepath: 'path1.tid', tiddlerTitle: 'Tiddler1', type: 'application/x-tiddler', hasMetaFile: false }); + index.set('path2.tid', { filepath: 'path2.tid', tiddlerTitle: 'Tiddler2', type: 'application/x-tiddler', hasMetaFile: false }); + + const keys = Array.from(index.keys()); + + expect(keys).toEqual(['path1.tid', 'path2.tid']); + }); + + it('should iterate over values', () => { + const index = new InverseFilesIndex(); + + index.set('path1.tid', { filepath: 'path1.tid', tiddlerTitle: 'Tiddler1', type: 'application/x-tiddler', hasMetaFile: false }); + index.set('path2.tid', { filepath: 'path2.tid', tiddlerTitle: 'Tiddler2', type: 'application/x-tiddler', hasMetaFile: false }); + + const values = Array.from(index.values()); + + expect(values).toHaveLength(2); + expect(values[0].tiddlerTitle).toBe('Tiddler1'); + expect(values[1].tiddlerTitle).toBe('Tiddler2'); + }); + }); + + describe('Edge Cases', () => { + it('should handle special characters in file paths', () => { + const index = new InverseFilesIndex(); + const specialPath = 'test/path with spaces/file-name.tid'; + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: specialPath, + tiddlerTitle: 'Special Tiddler', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set(specialPath, fileDescriptor); + + expect(index.has(specialPath)).toBe(true); + expect(index.getTitleByPath(specialPath)).toBe('Special Tiddler'); + }); + + it('should handle unicode characters in tiddler titles', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: 'test/unicode.tid', + tiddlerTitle: '测试条目 🎉', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set('test/unicode.tid', fileDescriptor); + + expect(index.getTitleByPath('test/unicode.tid')).toBe('测试条目 🎉'); + }); + + it('should handle empty string as file path', () => { + const index = new InverseFilesIndex(); + const fileDescriptor: IBootFilesIndexItemWithTitle = { + filepath: '', + tiddlerTitle: 'EmptyPath', + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + index.set('', fileDescriptor); + + expect(index.has('')).toBe(true); + expect(index.getTitleByPath('')).toBe('EmptyPath'); + }); + }); + + describe('Performance', () => { + it('should handle large number of entries efficiently', () => { + const index = new InverseFilesIndex(); + const count = 1000; + + // Add entries + for (let i = 0; i < count; i++) { + index.set(`path${i}.tid`, { + filepath: `path${i}.tid`, + tiddlerTitle: `Tiddler${i}`, + type: 'application/x-tiddler', + hasMetaFile: false, + }); + } + + expect(index.size).toBe(count); + + // Random access should be fast (O(1)) + expect(index.has('path500.tid')).toBe(true); + expect(index.getTitleByPath('path500.tid')).toBe('Tiddler500'); + + // Delete operations should be fast + index.delete('path500.tid'); + expect(index.has('path500.tid')).toBe(false); + expect(index.size).toBe(count - 1); + }); + }); +}); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid b/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid new file mode 100644 index 00000000..ab96806d --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid @@ -0,0 +1,98 @@ +title: $:/plugins/linonetwo/watch-filesystem-adaptor/changelog +type: text/vnd.tiddlywiki + +!! Changelog + +!!! Based On + +Official TiddlyWiki filesystem adaptor: +https://github.com/TiddlyWiki/TiddlyWiki5/blob/master/plugins/tiddlywiki/filesystem/filesystemadaptor.js + +Version: TiddlyWiki v5.3.x (as of 2025-10-24) + +!!! Key Modifications + +!!!! 1. Dynamic Workspace Information via IPC + +* ''Original'': Uses static `$:/config/FileSystemPaths` tiddler for routing +* ''Modified'': Queries workspace information from main process via worker threads IPC +* ''Reason'': Eliminates need for complex string manipulation of `FileSystemPaths` configuration + +```typescript +// Added: Worker service caller integration +import { callMainProcessService } from '@services/wiki/wikiWorker/workerServiceCaller'; +import type { IWorkspace } from '@services/workspaces/interface'; + +// Added: Methods to query workspace dynamically +private async getCurrentWorkspace(): Promise +private async getSubWikis(currentWorkspace: IWorkspace): Promise +``` + +!!!! 2. Tag-Based Sub-Wiki Routing + +* ''Original'': Routes based on filter expressions in `FileSystemPaths` +* ''Modified'': Automatically routes tiddlers to sub-wikis based on tag matching +* ''Modified'': Made `getTiddlerFileInfo`, `saveTiddler`, and `deleteTiddler` async for cleaner code +* ''Modified'': Caches sub-wikis list to avoid repeated IPC calls on every save operation +* ''Implementation'': +** Checks tiddler tags against sub-workspace `tagName` fields +** Routes matching tiddlers to sub-wiki's `tiddlers` folder +** Falls back to default `FileSystemPaths` logic for non-matching tiddlers +** Loads sub-wikis cache on initialization +** Currently loads sub-wikis once, future enhancements can watch for workspace changes + +```typescript +// Modified: getTiddlerFileInfo is now async (safe since callers only use callback) +async getTiddlerFileInfo(tiddler: Tiddler, callback: IFileSystemAdaptorCallback): Promise { + // Direct async/await instead of nested void IIFE + const currentWorkspace = await this.getCurrentWorkspace(); + const subWikis = this.getSubWikis(); // Uses cache instead of IPC + const matchingSubWiki = subWikis.find(...); + + if (matchingSubWiki) { + this.routeToSubWorkspace(...); + } else { + this.useDefaultFileSystemLogic(...); + } +} + +// Added: Caching mechanism +private subWikis: IWorkspace[] = []; + +private async initializeSubWikisCache(): Promise { + await this.updateSubWikisCache(); +} + +private async updateSubWikisCache(): Promise { + // Load sub-wikis once and cache them + const allWorkspaces = await callMainProcessService(...); + this.subWikis = allWorkspaces.filter(...); +} +``` + +!!!! 3. Separated Routing Logic + +* ''Added'': `routeToSubWorkspace()` method for sub-wiki routing +* ''Added'': `useDefaultFileSystemLogic()` method for standard routing +* ''Reason'': Better code organization and maintainability + +!!! Future Compatibility Notes + +When updating from upstream TiddlyWiki filesystem adaptor: + +# Review changes to core methods: `saveTiddler`, `deleteTiddler`, `getTiddlerInfo` +# Preserve our IPC-based workspace querying logic +# Preserve tag-based routing in `getTiddlerFileInfo` +# Update type definitions if TiddlyWiki's FileInfo interface changes +# Test sub-wiki routing functionality after merge + +!!! Testing Checklist + +When validating this adaptor: + +* [ ] Tiddlers with matching tags route to correct sub-wiki +* [ ] Tiddlers without matching tags use default FileSystemPaths +* [ ] IPC communication works correctly in worker thread +* [ ] Error handling falls back gracefully +* [ ] File operations (save/delete) work in both main and sub-wikis +* [ ] Workspace ID caching reduces IPC overhead diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/plugin.info b/src/services/wiki/plugin/watchFileSystemAdaptor/plugin.info new file mode 100644 index 00000000..b545400d --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/plugin.info @@ -0,0 +1,13 @@ +{ + "title": "$:/plugins/linonetwo/watch-filesystem-adaptor", + "name": "Watch Filesystem Adaptor", + "description": "Enhanced filesystem adaptor that routes tiddlers to sub-wikis based on tags", + "author": "LinOnetwo", + "core-version": ">=5.1.22", + "plugin-type": "plugin", + "stability": "STABILITY_2_STABLE", + "version": "1.0.0", + "dependents": [], + "list": "readme", + "plugin-priority": 5 +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid b/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid new file mode 100644 index 00000000..d72c0b6a --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid @@ -0,0 +1,24 @@ +title: $:/plugins/linonetwo/watch-filesystem-adaptor/readme +type: text/vnd.tiddlywiki + +!! Watch Filesystem Adaptor + +This plugin provides an enhanced filesystem adaptor that automatically routes tiddlers to sub-wikis based on their tags. + +!!! How It Works + +# Queries workspace information from TidGi's main process via IPC +# Checks each tiddler's tags against sub-workspace `tagName` fields +# Routes tiddlers with matching tags to the corresponding sub-wiki's tiddlers folder +# Falls back to standard `$:/config/FileSystemPaths` logic for non-matching tiddlers + +!!! Advantages + +* No need to manually edit `$:/config/FileSystemPaths` +* Automatically stays in sync with workspace configuration +* More robust than string manipulation +* Works seamlessly with TidGi's workspace management + +!!! Technical Details + +This adaptor runs in the wiki worker (Node.js) and communicates with TidGi's main process using worker threads IPC to access workspace service methods. It dynamically resolves the current workspace and its sub-wikis, then routes tiddlers based on their tags. diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/utilities.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/utilities.ts new file mode 100644 index 00000000..3dd79d18 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/utilities.ts @@ -0,0 +1,31 @@ +import nsfw from 'nsfw'; + +/** + * Get human-readable action name from nsfw action code + */ +export function getActionName(action: number): string { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (action === nsfw.actions.CREATED) { + return 'add'; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (action === nsfw.actions.DELETED) { + return 'unlink'; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (action === nsfw.actions.MODIFIED) { + return 'change'; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (action === nsfw.actions.RENAMED) { + return 'rename'; + } + return 'unknown'; +} + +/** + * Check if error is a file lock error that should be retried + */ +export function isFileLockError(errorCode: string | undefined): boolean { + return errorCode === 'EBUSY' || errorCode === 'EPERM' || errorCode === 'EACCES' || errorCode === 'EAGAIN'; +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta b/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta new file mode 100644 index 00000000..aa036751 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta @@ -0,0 +1,3 @@ +title: $:/plugins/linonetwo/watch-filesystem-adaptor/watch-filesystem-adaptor.js +type: application/javascript +module-type: syncadaptor diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts new file mode 100644 index 00000000..75ef62ca --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts @@ -0,0 +1,497 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { 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 in milliseconds before re-including a file in the watcher after save/delete operations. + * This prevents race conditions where the watcher might detect our own file changes: + * - File write operations may not be atomic and can trigger partial write events + * - Some filesystems buffer writes and flush asynchronously + * - The watcher needs time to process the excludeFile() call before the actual file operation completes + * 200ms provides a safe margin for most filesystems while keeping UI responsiveness. + */ +const FILE_EXCLUSION_CLEANUP_DELAY_MS = 200; + +/** + * 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 watcher's excludedPaths + * - watcher.updateExcludedPaths() is called to dynamically exclude the file from watching + * - Perform file write/delete operation (file changes are not detected by nsfw) + * - Update inverseFilesIndex immediately after successful operation + * - Call includeFile() after a short delay to remove file from excludedPaths + * - File is re-included in watching, ready to detect external changes + * + * 2. When external changes occur: + * - nsfw detects file changes (only for non-excluded files) + * - Load file and sync to wiki via addTiddler/deleteTiddler + * - Update inverseFilesIndex to track the change + * + * This approach uses nsfw's native updateExcludedPaths() API for precise, per-file exclusion. + * Unlike pause/resume (which blocks all events) or mutex locks (which require checking every event), + * this method dynamically adjusts the watcher's exclusion list to prevent events at the source. + * This ensures user's concurrent external file modifications are still detected while our own operations are ignored. + */ +class WatchFileSystemAdaptor extends FileSystemAdaptor { + name = 'watch-filesystem'; + /** Inverse index: filepath -> tiddler info for fast lookup */ + private inverseFilesIndex: InverseFilesIndex = new InverseFilesIndex(); + /** NSFW watcher instance */ + private watcher: nsfw.NSFW | undefined; + /** Base excluded paths (permanent) */ + private baseExcludedPaths: string[] = []; + /** Temporarily excluded files being modified by wiki */ + private temporarilyExcludedFiles: Set = new Set(); + + constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) { + super(options); + this.logger = new $tw.utils.Logger('watch-filesystem', { colour: 'purple' }); + + // 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 { + let fileRelativePath: string | null = null; + + try { + // Get file info to calculate relative path for watching + const fileInfo = await this.getTiddlerFileInfo(tiddler); + if (!fileInfo) { + const error = new Error('No fileInfo returned from getTiddlerFileInfo'); + callback?.(error); + throw error; + } + + fileRelativePath = path.relative(this.watchPathBase, fileInfo.filepath); + + // Exclude file from watching during save + await this.excludeFile(fileRelativePath); + + // Call parent's saveTiddler to handle the actual save + await super.saveTiddler(tiddler, undefined, options); + + // Update inverse index after successful save + const finalFileInfo = this.boot.files[tiddler.fields.title]; + this.inverseFilesIndex.set(fileRelativePath, { + ...finalFileInfo, + filepath: fileRelativePath, + tiddlerTitle: tiddler.fields.title, + }); + + // Notify callback if provided + callback?.(null, finalFileInfo); + + // Re-include the file after a short delay + setTimeout(() => { + if (fileRelativePath) { + void this.includeFile(fileRelativePath); + } + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + } catch (error) { + // Re-include the file on error + if (fileRelativePath) { + const pathToInclude = fileRelativePath; + setTimeout(() => { + void this.includeFile(pathToInclude); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + } + 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 + await this.excludeFile(fileRelativePath); + + // 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); + + // Re-include the file after a delay (cleanup the exclusion list) + setTimeout(() => { + void this.includeFile(fileRelativePath); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + } catch (error) { + // Re-include the file on error + setTimeout(() => { + void this.includeFile(fileRelativePath); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + 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(); + + this.logger.log('[WATCH_FS_READY] Filesystem watcher is ready'); + this.logger.log('[WATCH_FS_READY] Watching path:', this.watchPathBase); + + // Log stabilization marker for tests + this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized'); + } catch (error) { + this.logger.alert('[WATCH_FS_ERROR] Failed to initialize file watching:', 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 }); + } + } + } + + /** + * Update watcher's excluded paths with current temporary exclusions + */ + private async updateWatcherExcludedPaths(): Promise { + if (!this.watcher) { + return; + } + + // Combine base excluded paths with temporarily excluded files + const allExcludedPaths = [ + ...this.baseExcludedPaths, + ...Array.from(this.temporarilyExcludedFiles).map(relativePath => path.join(this.watchPathBase, relativePath)), + ]; + + // @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string] + await this.watcher.updateExcludedPaths(allExcludedPaths); + } + + /** + * Temporarily exclude a file from watching (e.g., during save/delete) + */ + private async excludeFile(fileRelativePath: string): Promise { + this.temporarilyExcludedFiles.add(fileRelativePath); + await this.updateWatcherExcludedPaths(); + } + + /** + * Remove a file from temporary exclusions + */ + private async includeFile(fileRelativePath: string): Promise { + this.temporarilyExcludedFiles.delete(fileRelativePath); + await this.updateWatcherExcludedPaths(); + } + + /** + * Handle NSFW file system change events + */ + private handleNsfwEvents(events: nsfw.FileChangeEvent[]): void { + 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 relative and absolute paths + const fileAbsolutePath = path.join(directory, fileName); + const fileRelativePath = path.relative(this.watchPathBase, 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); + + // Handle different event types + if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) { + this.handleFileAddOrChange( + fileAbsolutePath, + fileRelativePath, + metaFileAbsolutePath, + fileName, + fileNameBase, + fileExtension, + fileMimeType, + action === nsfw.actions.CREATED ? 'add' : 'change', + ); + } else if (action === nsfw.actions.DELETED) { + this.handleFileDelete(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 oldFileRelativePath = path.relative(this.watchPathBase, oldFileAbsPath); + const oldFileExtension = path.extname(oldFileRelativePath); + this.handleFileDelete(oldFileAbsPath, oldFileRelativePath, oldFileExtension); + + const newDirectory = 'newDirectory' in event ? event.newDirectory : directory; + const newFileAbsPath = path.join(newDirectory, event.newFile); + const newFileRelativePath = path.relative(this.watchPathBase, 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`; + + this.handleFileAddOrChange( + newFileAbsPath, + newFileRelativePath, + newMetaFileAbsPath, + newFileName, + newFileNameBase, + newFileExtension, + newFileMimeType, + 'add', + ); + } + } + } + } + + /** + * Handle file add or change events + */ + private handleFileAddOrChange( + fileAbsolutePath: string, + fileRelativePath: string, + metaFileAbsolutePath: string, + fileName: string, + fileNameBase: string, + fileExtension: string, + fileMimeType: string, + changeType: 'add' | 'change', + ): void { + // 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 + tiddlers.forEach((tiddler) => { + // 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`); + return; + } + + 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}`); + } else { + this.logger.log(`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`); + } + }); + } + + /** + * 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}`); + + // 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 { + 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'); + } + } +} + +// Only export in Node.js environment +if ($tw.node) { + exports.adaptorClass = WatchFileSystemAdaptor; +} diff --git a/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts b/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts index 45f52c97..55c79b4c 100644 --- a/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts +++ b/src/services/wiki/wikiOperations/executor/wikiOperationInServer.ts @@ -106,9 +106,7 @@ export class WikiOperationsInWikiWorker { throw new TypeError(`${operationType} gets no useful handler`); } if (!Array.isArray(arguments_)) { - // TODO: better type handling here - // eslint-disable-next-line @typescript-eslint/no-explicit-any - throw new TypeError(`${(arguments_ as any) ?? ''} (${typeof arguments_}) is not a good argument array for ${operationType}`); + throw new TypeError(`${JSON.stringify((arguments_ as unknown) ?? '')} (${typeof arguments_}) is not a good argument array for ${operationType}`); } // @ts-expect-error A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556) this maybe a bug of ts... try remove this comment after upgrade ts. And the result become void is weird too. diff --git a/src/services/wiki/wikiWorker.ts b/src/services/wiki/wikiWorker.ts deleted file mode 100644 index 3c03f408..00000000 --- a/src/services/wiki/wikiWorker.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Worker environment is not part of electron environment, so don't import "@/constants/paths" here, as its process.resourcesPath will become undefined and throw Errors. - * - * Don't use i18n and logger in worker thread. For example, 12b93020, will throw error "Electron failed to install correctly, please delete node_modules/electron and try installing again ...worker.js..." - */ -import { uninstall } from '@/helpers/installV8Cache'; -import './wikiWorker/preload'; -import 'source-map-support/register'; -import { handleWorkerMessages } from '@services/libs/workerAdapter'; -import { mkdtemp } from 'fs-extra'; -import { tmpdir } from 'os'; -import path from 'path'; -import { Observable } from 'rxjs'; - -import { IZxWorkerMessage, ZxWorkerControlActions } from './interface'; -import { executeScriptInTWContext, executeScriptInZxScriptContext, extractTWContextScripts, type IVariableContextList } from './plugin/zxPlugin'; -import { wikiOperationsInWikiWorker } from './wikiOperations/executor/wikiOperationInServer'; -import { getWikiInstance } from './wikiWorker/globals'; -import { extractWikiHTML, packetHTMLFromWikiFolder } from './wikiWorker/htmlWiki'; -import { ipcServerRoutesMethods } from './wikiWorker/ipcServerRoutes'; -import { startNodeJSWiki } from './wikiWorker/startNodeJSWiki'; - -export interface IStartNodeJSWikiConfigs { - authToken?: string; - constants: { TIDDLYWIKI_PACKAGE_FOLDER: string }; - enableHTTPAPI: boolean; - excludedPlugins: string[]; - homePath: string; - https?: { - enabled: boolean; - tlsCert?: string | undefined; - tlsKey?: string | undefined; - }; - isDev: boolean; - openDebugger?: boolean; - readOnlyMode?: boolean; - rootTiddler?: string; - tiddlyWikiHost: string; - tiddlyWikiPort: number; - tokenAuth?: boolean; - userName: string; -} - -export type IZxFileInput = { fileContent: string; fileName: string } | { filePath: string }; -function executeZxScript(file: IZxFileInput, zxPath: string): Observable { - /** this will be observed in src/services/native/index.ts */ - return new Observable((observer) => { - observer.next({ type: 'control', actions: ZxWorkerControlActions.start }); - - let filePathToExecute: string; - void (async function executeZxScriptIIFE() { - try { - if ('fileName' in file) { - // codeblock mode, eval a string that might have different contexts separated by TW_SCRIPT_SEPARATOR - const temporaryDirectory = await mkdtemp(`${tmpdir()}${path.sep}`); - filePathToExecute = path.join(temporaryDirectory, file.fileName); - const scriptsInDifferentContext = extractTWContextScripts(file.fileContent); - /** - * Store each script's variable context in an array, so that we can restore them later in next context. - * Key is the variable name, value is the variable value. - */ - const variableContextList: IVariableContextList = []; - for (const [index, scriptInContext] of scriptsInDifferentContext.entries()) { - switch (scriptInContext.context) { - case 'zx': { - await executeScriptInZxScriptContext({ zxPath, filePathToExecute }, observer, scriptInContext.content, variableContextList, index); - break; - } - case 'tw-server': { - const wikiInstance = getWikiInstance(); - if (wikiInstance === undefined) { - observer.next({ type: 'stderr', message: `Error in executeZxScript(): $tw is undefined` }); - break; - } - executeScriptInTWContext(scriptInContext.content, observer, wikiInstance, variableContextList, index); - break; - } - } - } - } else if ('filePath' in file) { - // simple mode, only execute a designated file - filePathToExecute = file.filePath; - await executeScriptInZxScriptContext({ zxPath, filePathToExecute }, observer); - } - } catch (error) { - const message = `zx script's executeZxScriptIIFE() failed with error ${(error as Error).message} ${(error as Error).stack ?? ''}`; - observer.next({ type: 'control', actions: ZxWorkerControlActions.error, message }); - } - })(); - }); -} - -function beforeExit(): void { - uninstall?.uninstall(); -} - -const wikiWorker = { - startNodeJSWiki, - getTiddlerFileMetadata: (tiddlerTitle: string) => getWikiInstance()?.boot.files[tiddlerTitle], - executeZxScript, - extractWikiHTML, - packetHTMLFromWikiFolder, - beforeExit, - wikiOperation: wikiOperationsInWikiWorker.wikiOperation.bind(wikiOperationsInWikiWorker), - ...ipcServerRoutesMethods, -}; -export type WikiWorker = typeof wikiWorker; - -// Initialize worker message handling -handleWorkerMessages(wikiWorker); diff --git a/src/services/wiki/wikiWorker/index.ts b/src/services/wiki/wikiWorker/index.ts index a03f9c2b..25e22205 100644 --- a/src/services/wiki/wikiWorker/index.ts +++ b/src/services/wiki/wikiWorker/index.ts @@ -3,21 +3,25 @@ * * Don't use i18n and logger in worker thread. For example, 12b93020, will throw error "Electron failed to install correctly, please delete node_modules/electron and try installing again ...worker.js..." */ -import { uninstall } from '@/helpers/installV8Cache'; import './preload'; import 'source-map-support/register'; +import { uninstall } from '@/helpers/installV8Cache'; + import { handleWorkerMessages } from '@services/libs/workerAdapter'; import { mkdtemp } from 'fs-extra'; import { tmpdir } from 'os'; import path from 'path'; import { Observable } from 'rxjs'; +import type { IWikiWorkspace } from '@services/workspaces/interface'; +import type { SyncAdaptor } from 'tiddlywiki'; import { IZxWorkerMessage, ZxWorkerControlActions } from '../interface'; import { executeScriptInTWContext, executeScriptInZxScriptContext, extractTWContextScripts, type IVariableContextList } from '../plugin/zxPlugin'; import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer'; import { getWikiInstance } from './globals'; import { extractWikiHTML, packetHTMLFromWikiFolder } from './htmlWiki'; import { ipcServerRoutesMethods } from './ipcServerRoutes'; +import { notifyServicesReady } from './servicesReady'; import { startNodeJSWiki } from './startNodeJSWiki'; export interface IStartNodeJSWikiConfigs { @@ -39,6 +43,7 @@ export interface IStartNodeJSWikiConfigs { tiddlyWikiPort: number; tokenAuth?: boolean; userName: string; + workspace: IWikiWorkspace; } export type IZxFileInput = { fileContent: string; fileName: string } | { filePath: string }; @@ -90,8 +95,15 @@ function executeZxScript(file: IZxFileInput, zxPath: string): Observable { uninstall?.uninstall(); + // Cleanup watch-filesystem adaptor + const wikiInstance = getWikiInstance(); + // Call our custom cleanup method if it exists `src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts` + const syncAdaptor = wikiInstance?.syncadaptor as SyncAdaptor & { cleanup?: () => Promise }; + if (syncAdaptor?.cleanup) { + await syncAdaptor.cleanup(); + } } const wikiWorker = { @@ -101,6 +113,7 @@ const wikiWorker = { extractWikiHTML, packetHTMLFromWikiFolder, beforeExit, + notifyServicesReady, wikiOperation: wikiOperationsInWikiWorker.wikiOperation.bind(wikiOperationsInWikiWorker), ...ipcServerRoutesMethods, }; diff --git a/src/services/wiki/wikiWorker/ipcServerRoutes.ts b/src/services/wiki/wikiWorker/ipcServerRoutes.ts index 6d8bd138..c18dee71 100644 --- a/src/services/wiki/wikiWorker/ipcServerRoutes.ts +++ b/src/services/wiki/wikiWorker/ipcServerRoutes.ts @@ -227,6 +227,7 @@ export class IpcServerRoutes { this.wikiInstance.wiki.addEventListener('change', (changes) => { observer.next(changes); }); + console.log('[test-id-SSE_READY] Wiki change observer registered and ready'); }; void getWikiChangeObserverInWorkerIIFE(); }); diff --git a/src/services/wiki/wikiWorker/services.ts b/src/services/wiki/wikiWorker/services.ts new file mode 100644 index 00000000..2ab9e590 --- /dev/null +++ b/src/services/wiki/wikiWorker/services.ts @@ -0,0 +1,96 @@ +/** + * Worker-side service proxies, similar to preload/common/services.ts + * Auto-creates proxies for all registered services and attaches to global.service + */ + +import { createWorkerProxy, type WorkerProxy } from 'electron-ipc-cat/worker'; +import { Observable } from 'rxjs'; + +import { AgentBrowserServiceIPCDescriptor, type IAgentBrowserService } from '@services/agentBrowser/interface'; +import { AgentDefinitionServiceIPCDescriptor, type IAgentDefinitionService } from '@services/agentDefinition/interface'; +import { AgentInstanceServiceIPCDescriptor, type IAgentInstanceService } from '@services/agentInstance/interface'; +import { AuthenticationServiceIPCDescriptor, type IAuthenticationService } from '@services/auth/interface'; +import { ContextServiceIPCDescriptor, type IContextService } from '@services/context/interface'; +import { DatabaseServiceIPCDescriptor, type IDatabaseService } from '@services/database/interface'; +import { DeepLinkServiceIPCDescriptor, type IDeepLinkService } from '@services/deepLink/interface'; +import { ExternalAPIServiceIPCDescriptor, type IExternalAPIService } from '@services/externalAPI/interface'; +import { GitServiceIPCDescriptor, type IGitService } from '@services/git/interface'; +import { type IMenuService, MenuServiceIPCDescriptor } from '@services/menu/interface'; +import { type INativeService, NativeServiceIPCDescriptor } from '@services/native/interface'; +import { type INotificationService, NotificationServiceIPCDescriptor } from '@services/notifications/interface'; +import { type IPreferenceService, PreferenceServiceIPCDescriptor } from '@services/preferences/interface'; +import { type ISyncService, SyncServiceIPCDescriptor } from '@services/sync/interface'; +import { type ISystemPreferenceService, SystemPreferenceServiceIPCDescriptor } from '@services/systemPreferences/interface'; +import { type IThemeService, ThemeServiceIPCDescriptor } from '@services/theme/interface'; +import { type IUpdaterService, UpdaterServiceIPCDescriptor } from '@services/updater/interface'; +import { type IViewService, ViewServiceIPCDescriptor } from '@services/view/interface'; +import { type IWikiService, WikiServiceIPCDescriptor } from '@services/wiki/interface'; +import { type IWikiEmbeddingService, WikiEmbeddingServiceIPCDescriptor } from '@services/wikiEmbedding/interface'; +import { type IWikiGitWorkspaceService, WikiGitWorkspaceServiceIPCDescriptor } from '@services/wikiGitWorkspace/interface'; +import { type IWindowService, WindowServiceIPCDescriptor } from '@services/windows/interface'; +import { type IWorkspaceService, WorkspaceServiceIPCDescriptor } from '@services/workspaces/interface'; +import { type IWorkspaceViewService, WorkspaceViewServiceIPCDescriptor } from '@services/workspacesView/interface'; + +// Create service proxies +export const agentBrowser = createWorkerProxy>(AgentBrowserServiceIPCDescriptor, Observable); +export const agentDefinition = createWorkerProxy>(AgentDefinitionServiceIPCDescriptor, Observable); +export const agentInstance = createWorkerProxy>(AgentInstanceServiceIPCDescriptor, Observable); +export const authentication = createWorkerProxy>(AuthenticationServiceIPCDescriptor, Observable); +export const context = createWorkerProxy>(ContextServiceIPCDescriptor, Observable); +export const database = createWorkerProxy>(DatabaseServiceIPCDescriptor, Observable); +export const deepLink = createWorkerProxy>(DeepLinkServiceIPCDescriptor, Observable); +export const externalAPI = createWorkerProxy>(ExternalAPIServiceIPCDescriptor, Observable); +export const git = createWorkerProxy>(GitServiceIPCDescriptor, Observable); +export const menu = createWorkerProxy>(MenuServiceIPCDescriptor, Observable); +export const native = createWorkerProxy>(NativeServiceIPCDescriptor, Observable); +export const notification = createWorkerProxy>(NotificationServiceIPCDescriptor, Observable); +export const preference = createWorkerProxy>(PreferenceServiceIPCDescriptor, Observable); +export const sync = createWorkerProxy>(SyncServiceIPCDescriptor, Observable); +export const systemPreference = createWorkerProxy>(SystemPreferenceServiceIPCDescriptor, Observable); +export const theme = createWorkerProxy>(ThemeServiceIPCDescriptor, Observable); +export const updater = createWorkerProxy>(UpdaterServiceIPCDescriptor, Observable); +export const view = createWorkerProxy>(ViewServiceIPCDescriptor, Observable); +export const wiki = createWorkerProxy>(WikiServiceIPCDescriptor, Observable); +export const wikiEmbedding = createWorkerProxy>(WikiEmbeddingServiceIPCDescriptor, Observable); +export const wikiGitWorkspace = createWorkerProxy>(WikiGitWorkspaceServiceIPCDescriptor, Observable); +export const window = createWorkerProxy>(WindowServiceIPCDescriptor, Observable); +export const workspace = createWorkerProxy>(WorkspaceServiceIPCDescriptor, Observable); +export const workspaceView = createWorkerProxy>(WorkspaceViewServiceIPCDescriptor, Observable); + +/** + * All service proxies collected in one object + * Auto-attached to global.service when this module is imported + */ +export const service = { + agentBrowser, + agentDefinition, + agentInstance, + authentication, + context, + database, + deepLink, + externalAPI, + git, + menu, + native, + notification, + preference, + sync, + systemPreference, + theme, + updater, + view, + wiki, + wikiEmbedding, + wikiGitWorkspace, + window, + workspace, + workspaceView, +} as const; + +// Auto-attach to global when imported (worker thread only) +if (typeof global !== 'undefined') { + // Use type assertion to avoid circular reference + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (global as any).service = service; +} diff --git a/src/services/wiki/wikiWorker/servicesReady.ts b/src/services/wiki/wikiWorker/servicesReady.ts new file mode 100644 index 00000000..7b48e190 --- /dev/null +++ b/src/services/wiki/wikiWorker/servicesReady.ts @@ -0,0 +1,35 @@ +/** + * Worker services readiness state management + * Separated from services.ts to avoid circular dependencies and bundling issues + */ + +/** + * Callbacks to execute when worker services are ready + */ +const onServicesReadyCallbacks: Array<() => void> = []; +let servicesReady = false; + +/** + * Register a callback to be called when worker services are ready to use + */ +export function onWorkerServicesReady(callback: () => void): void { + if (servicesReady) { + // Already ready, call immediately + callback(); + } else { + onServicesReadyCallbacks.push(callback); + } +} + +/** + * Mark worker services as ready and execute all pending callbacks + * This should be called by main process after attachWorker is complete + */ +export function notifyServicesReady(): void { + console.log('[servicesReady] Worker services marked as ready'); + servicesReady = true; + onServicesReadyCallbacks.forEach(callback => { + callback(); + }); + onServicesReadyCallbacks.length = 0; // Clear callbacks +} diff --git a/src/services/wiki/wikiWorker/startNodeJSWiki.ts b/src/services/wiki/wikiWorker/startNodeJSWiki.ts index 90d7052a..344cb9de 100644 --- a/src/services/wiki/wikiWorker/startNodeJSWiki.ts +++ b/src/services/wiki/wikiWorker/startNodeJSWiki.ts @@ -1,3 +1,8 @@ +// Auto-attach services to global.service - MUST import before using services +import './services'; +import { native } from './services'; +import { onWorkerServicesReady } from './servicesReady'; + import { getTidGiAuthHeaderWithToken } from '@/constants/auth'; import { defaultServerIP } from '@/constants/urls'; import intercept from 'intercept-stdout'; @@ -8,7 +13,7 @@ import { Observable } from 'rxjs'; import { TiddlyWiki } from 'tiddlywiki'; import { IWikiMessage, WikiControlActions } from '../interface'; import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer'; -import { IStartNodeJSWikiConfigs } from '.'; +import type { IStartNodeJSWikiConfigs } from '../wikiWorker'; import { setWikiInstance } from './globals'; import { ipcServerRoutes } from './ipcServerRoutes'; import { authTokenIsProvided } from './wikiWorkerUtilities'; @@ -28,7 +33,32 @@ export function startNodeJSWiki({ tiddlyWikiPort = 5112, tokenAuth, userName, + workspace, }: IStartNodeJSWikiConfigs): Observable { + // Wait for services to be ready before using intercept with logFor + onWorkerServicesReady(() => { + void native.logFor(workspace.name, 'info', 'test-id-WorkerServicesReady'); + const textDecoder = new TextDecoder(); + intercept( + (newStdOut: string | Uint8Array) => { + const message = typeof newStdOut === 'string' ? newStdOut : textDecoder.decode(newStdOut); + // Send to main process logger if services are ready + void native.logFor(workspace.name, 'info', message).catch((error: unknown) => { + console.error('[intercept] Failed to send stdout to main process:', error, message, JSON.stringify(workspace)); + }); + return message; + }, + (newStdError: string | Uint8Array) => { + const message = typeof newStdError === 'string' ? newStdError : textDecoder.decode(newStdError); + // Send to main process logger if services are ready + void native.logFor(workspace.name, 'error', message).catch((error: unknown) => { + console.error('[intercept] Failed to send stderr to main process:', error, message); + }); + return message; + }, + ); + }); + if (openDebugger === true) { inspector.open(); inspector.waitForDebugger(); @@ -40,18 +70,6 @@ export function startNodeJSWiki({ // mark isDev as used to satisfy lint when not needed directly void isDev; observer.next({ type: 'control', actions: WikiControlActions.start, argv: fullBootArgv }); - intercept( - (newStdOut: string | Uint8Array) => { - const message = typeof newStdOut === 'string' ? newStdOut : new TextDecoder().decode(newStdOut); - observer.next({ type: 'stdout', message }); - return message; - }, - (newStdError: string | Uint8Array) => { - const message = typeof newStdError === 'string' ? newStdError : new TextDecoder().decode(newStdError); - observer.next({ type: 'control', source: 'intercept', actions: WikiControlActions.error, message, argv: fullBootArgv }); - return message; - }, - ); try { const wikiInstance = TiddlyWiki(); @@ -62,6 +80,12 @@ export function startNodeJSWiki({ wikiInstance.boot.extraPlugins = [ // add tiddly filesystem back if is not readonly https://github.com/Jermolene/TiddlyWiki5/issues/4484#issuecomment-596779416 readOnlyMode === true ? undefined : 'plugins/tiddlywiki/filesystem', + /** + * Enhanced filesystem adaptor that routes tiddlers to sub-wikis based on tags. + * Replaces the complex string manipulation of $:/config/FileSystemPaths with direct IPC calls to workspace service. + * Only enabled in non-readonly mode since it handles filesystem operations. + */ + readOnlyMode === true ? undefined : 'plugins/linonetwo/watch-filesystem-adaptor', /** * Install $:/plugins/linonetwo/tidgi instead of +plugins/tiddlywiki/tiddlyweb to speedup (without JSON.parse) and fix http errors when network change. * See scripts/compilePlugins.mjs for how it is built. @@ -83,6 +107,8 @@ export function startNodeJSWiki({ if (readOnlyMode === true) { wikiInstance.preloadTiddler({ title: '$:/info/tidgi/readOnlyMode', text: 'yes' }); } + // Preload workspace ID for filesystem adaptor + wikiInstance.preloadTiddler({ title: '$:/info/tidgi/workspaceID', text: workspace.id }); /** * Use authenticated-user-header to provide `TIDGI_AUTH_TOKEN_HEADER` as header key to receive a value as username (we use it as token). * diff --git a/src/services/wikiEmbedding/__tests__/index.test.ts b/src/services/wikiEmbedding/__tests__/index.test.ts index 44809d5c..fbf08ff1 100644 --- a/src/services/wikiEmbedding/__tests__/index.test.ts +++ b/src/services/wikiEmbedding/__tests__/index.test.ts @@ -64,6 +64,7 @@ describe('WikiEmbeddingService Integration Tests', () => { disableAudio: false, enableHTTPAPI: false, excludedPlugins: [], + enableFileSystemWatch: true, gitUrl: null, hibernateWhenUnused: false, readOnlyMode: false, diff --git a/src/services/wikiGitWorkspace/index.ts b/src/services/wikiGitWorkspace/index.ts index ade43c0e..49832548 100644 --- a/src/services/wikiGitWorkspace/index.ts +++ b/src/services/wikiGitWorkspace/index.ts @@ -153,6 +153,7 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService { mainWikiID: null, excludedPlugins: [], enableHTTPAPI: false, + enableFileSystemWatch: true, lastNodeJSArgv: [], homeUrl: '', gitUrl: null, @@ -230,8 +231,7 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService { throw new Error(`workspace.mainWikiToLink is null in WikiGitWorkspace.removeWorkspace ${JSON.stringify(workspace)}`); } await wikiService.removeWiki(wikiFolderLocation, mainWikiToLink, onlyRemoveWorkspace); - // remove folderName from fileSystemPaths - await wikiService.updateSubWikiPluginContent(mainWikiToLink, wikiFolderLocation, undefined, workspace); + // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin } else { // is main wiki, also delete all sub wikis const subWikis = workspaceService.getSubWorkspacesAsListSync(id); diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index a88bc977..ba7f58e5 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -267,7 +267,7 @@ export class Workspace implements IWorkspaceService { existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && typeof tagName === 'string' && tagName.length > 0 && existedWorkspace.tagName !== tagName ) { - const { mainWikiToLink, wikiFolderLocation } = existedWorkspace; + const { mainWikiToLink } = existedWorkspace; if (typeof mainWikiToLink !== 'string') { throw new TypeError( `mainWikiToLink is null in reactBeforeWorkspaceChanged when try to updateSubWikiPluginContent, workspacesID: ${id}\n${ @@ -278,10 +278,7 @@ export class Workspace implements IWorkspaceService { ); } const wikiService = container.get(serviceIdentifier.Wiki); - await wikiService.updateSubWikiPluginContent(mainWikiToLink, wikiFolderLocation, newWorkspaceConfig, { - ...newWorkspaceConfig, - tagName: existedWorkspace.tagName, - }); + // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin await wikiService.wikiStartup(newWorkspaceConfig); } } @@ -447,6 +444,7 @@ export class Workspace implements IWorkspaceService { transparentBackground: false, enableHTTPAPI: false, excludedPlugins: [], + enableFileSystemWatch: true, }; await this.set(newID, newWorkspace); diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index 5d7e55c5..e058992d 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -149,6 +149,12 @@ export interface IWikiWorkspace extends IDedicatedWorkspace { * folder path for this wiki workspace */ wikiFolderLocation: string; + /** + * Enable file system watching (experimental feature using chokidar) + * When enabled, external file changes will be synced to the wiki automatically + * This is an experimental feature and may have bugs + */ + enableFileSystemWatch: boolean; } export type IWorkspace = IWikiWorkspace | IDedicatedWorkspace; diff --git a/src/windows/AddWorkspace/CloneWikiForm.tsx b/src/windows/AddWorkspace/CloneWikiForm.tsx index d193f7e4..cdac5d4a 100644 --- a/src/windows/AddWorkspace/CloneWikiForm.tsx +++ b/src/windows/AddWorkspace/CloneWikiForm.tsx @@ -6,12 +6,17 @@ import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { useAvailableTags } from './useAvailableTags'; import { useValidateCloneWiki } from './useCloneWiki'; import type { IWikiWorkspaceFormProps } from './useForm'; export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichComponent, errorInWhichComponentSetter }: IWikiWorkspaceFormProps): React.JSX.Element { const { t } = useTranslation(); useValidateCloneWiki(isCreateMainWorkspace, form, errorInWhichComponentSetter); + + // Fetch all tags from main wiki for autocomplete suggestions + const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + return ( @@ -61,7 +66,7 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone ${form.mainWikiToLink.wikiFolderLocation}/tiddlers/${form.wikiFolderName}`} value={form.mainWikiToLinkIndex} onChange={(event: React.ChangeEvent) => { - const index = event.target.value as unknown as number; + const index = Number(event.target.value); const selectedWorkspace = form.mainWorkspaceList[index]; if (selectedWorkspace && isWikiWorkspace(selectedWorkspace)) { form.mainWikiToLinkSetter({ @@ -80,7 +85,7 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone fileSystemPath.tagName)} + options={availableTags} value={form.tagName} onInputChange={(_event: React.SyntheticEvent, value: string) => { form.tagNameSetter(value); diff --git a/src/windows/AddWorkspace/Description.tsx b/src/windows/AddWorkspace/Description.tsx index 4fb20510..9e829b63 100644 --- a/src/windows/AddWorkspace/Description.tsx +++ b/src/windows/AddWorkspace/Description.tsx @@ -4,16 +4,9 @@ import { useTranslation } from 'react-i18next'; import FormControlLabel from '@mui/material/FormControlLabel'; import Paper from '@mui/material/Paper'; -import SwitchRaw from '@mui/material/Switch'; +import Switch from '@mui/material/Switch'; import Typography from '@mui/material/Typography'; -const Switch = styled(SwitchRaw)` - & span.MuiSwitch-track, - & > span:not(.Mui-checked) span.MuiSwitch-thumb { - background-color: #1976d2; - } -`; - const Container = styled(Paper)` background-color: ${({ theme }) => theme.palette.background.paper}; color: ${({ theme }) => theme.palette.text.primary}; @@ -42,6 +35,7 @@ export function MainSubWikiDescription({ onChange={(event: React.ChangeEvent) => { isCreateMainWorkspaceSetter(event.target.checked); }} + data-testid='main-sub-workspace-switch' /> } label={label} @@ -76,6 +70,7 @@ export function SyncedWikiDescription({ onChange={(event: React.ChangeEvent) => { isCreateSyncedWorkspaceSetter(event.target.checked); }} + data-testid='synced-local-workspace-switch' /> } label={label} diff --git a/src/windows/AddWorkspace/ExistedWikiForm.tsx b/src/windows/AddWorkspace/ExistedWikiForm.tsx index e45b983d..f0f50314 100644 --- a/src/windows/AddWorkspace/ExistedWikiForm.tsx +++ b/src/windows/AddWorkspace/ExistedWikiForm.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { useAvailableTags } from './useAvailableTags'; import { useValidateExistedWiki } from './useExistedWiki'; import type { IWikiWorkspaceFormProps } from './useForm'; @@ -17,6 +18,10 @@ export function ExistedWikiForm({ errorInWhichComponentSetter, }: IWikiWorkspaceFormProps & { isCreateSyncedWorkspace: boolean }): React.JSX.Element { const { t } = useTranslation(); + + // Fetch all tags from main wiki for autocomplete suggestions + const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + const { wikiFolderLocation, wikiFolderNameSetter, @@ -27,7 +32,6 @@ export function ExistedWikiForm({ mainWikiToLinkIndex, mainWikiToLinkSetter, mainWorkspaceList, - fileSystemPaths, tagName, tagNameSetter, } = form; @@ -82,7 +86,7 @@ export function ExistedWikiForm({ ${mainWikiToLink.wikiFolderLocation}/tiddlers/${wikiFolderName}`} value={mainWikiToLinkIndex} onChange={(event: React.ChangeEvent) => { - const index = event.target.value as unknown as number; + const index = Number(event.target.value); const selectedWorkspace = mainWorkspaceList[index]; if (selectedWorkspace && isWikiWorkspace(selectedWorkspace)) { mainWikiToLinkSetter({ @@ -101,7 +105,7 @@ export function ExistedWikiForm({ fileSystemPath.tagName)} + options={availableTags} value={tagName} onInputChange={(_event: React.SyntheticEvent, value: string) => { tagNameSetter(value); diff --git a/src/windows/AddWorkspace/NewWikiForm.tsx b/src/windows/AddWorkspace/NewWikiForm.tsx index 85c865d2..fff585f2 100644 --- a/src/windows/AddWorkspace/NewWikiForm.tsx +++ b/src/windows/AddWorkspace/NewWikiForm.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { useAvailableTags } from './useAvailableTags'; import type { IWikiWorkspaceFormProps } from './useForm'; import { useValidateNewWiki } from './useNewWiki'; @@ -17,6 +18,10 @@ export function NewWikiForm({ }: IWikiWorkspaceFormProps & { isCreateSyncedWorkspace: boolean }): React.JSX.Element { const { t } = useTranslation(); useValidateNewWiki(isCreateMainWorkspace, isCreateSyncedWorkspace, form, errorInWhichComponentSetter); + + // Fetch all tags from main wiki for autocomplete suggestions + const availableTags = useAvailableTags(form.mainWikiToLink.id, !isCreateMainWorkspace); + return ( @@ -67,7 +72,7 @@ export function NewWikiForm({ value={form.mainWikiToLinkIndex} slotProps={{ htmlInput: { 'data-testid': 'main-wiki-select' } }} onChange={(event: React.ChangeEvent) => { - const index = event.target.value as unknown as number; + const index = Number(event.target.value); const selectedWorkspace = form.mainWorkspaceList[index]; if (selectedWorkspace && isWikiWorkspace(selectedWorkspace)) { form.mainWikiToLinkSetter({ @@ -86,7 +91,7 @@ export function NewWikiForm({ fileSystemPath.tagName)} + options={availableTags} value={form.tagName} onInputChange={(_event: React.SyntheticEvent, value: string) => { form.tagNameSetter(value); diff --git a/src/windows/AddWorkspace/__tests__/NewWikiForm.test.tsx b/src/windows/AddWorkspace/__tests__/NewWikiForm.test.tsx index 1306af06..2a137ba1 100644 --- a/src/windows/AddWorkspace/__tests__/NewWikiForm.test.tsx +++ b/src/windows/AddWorkspace/__tests__/NewWikiForm.test.tsx @@ -1,11 +1,10 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; import { IGitUserInfos } from '@services/git/interface'; import { SupportedStorageServices } from '@services/types'; -import { ISubWikiPluginContent } from '@services/wiki/plugin/subWikiPlugin'; import { IWorkspace } from '@services/workspaces/interface'; import { NewWikiForm } from '../NewWikiForm'; import { IErrorInWhichComponent, IWikiWorkspaceForm } from '../useForm'; @@ -48,11 +47,6 @@ const createMockForm = (overrides: Partial = {}): IWikiWorks metadata: {}, } as unknown as IWorkspace, ], - fileSystemPaths: [ - { tagName: 'TagA', folderName: 'FolderA' } as ISubWikiPluginContent, - { tagName: 'TagB', folderName: 'FolderB' } as ISubWikiPluginContent, - ], - fileSystemPathsSetter: vi.fn(), tagName: '', tagNameSetter: vi.fn(), gitRepoUrl: '', @@ -85,14 +79,22 @@ describe('NewWikiForm Component', () => { }); // Helper function to render component with default props - const renderNewWikiForm = (overrides = {}) => { + // This async version waits for useAvailableTags hook to complete its async operations + const renderNewWikiForm = async (overrides = {}) => { const props = createMockProps(overrides); - return render(); + const result = render(); + // Wait for any async state updates to complete + await waitFor(() => { + // The component is considered stable when it's fully rendered + // We don't need to wait for any specific element, just let React settle + // This empty waitFor will ensure all effects are processed, to remove `act(...)` warnings + }); + return result; }; describe('Basic Rendering Tests', () => { - it('should render main workspace form with basic fields', () => { - renderNewWikiForm({ + it('should render main workspace form with basic fields', async () => { + await renderNewWikiForm({ isCreateMainWorkspace: true, }); // Should render parent folder input @@ -106,8 +108,8 @@ describe('NewWikiForm Component', () => { expect(screen.queryAllByRole('combobox', { name: 'AddWorkspace.TagName' }).length).toBe(0); }); - it('should render sub workspace form with all fields', () => { - renderNewWikiForm({ + it('should render sub workspace form with all fields', async () => { + await renderNewWikiForm({ isCreateMainWorkspace: false, }); // Should render basic fields @@ -118,13 +120,13 @@ describe('NewWikiForm Component', () => { expect(screen.getAllByRole('combobox', { name: 'AddWorkspace.TagName' })[0]).toBeInTheDocument(); }); - it('should display correct initial values', () => { + it('should display correct initial values', async () => { const form = createMockForm({ parentFolderLocation: '/custom/path', wikiFolderName: 'my-wiki', }); - renderNewWikiForm({ + await renderNewWikiForm({ form, isCreateMainWorkspace: false, }); @@ -142,7 +144,7 @@ describe('NewWikiForm Component', () => { parentFolderLocationSetter: mockSetter, }); - renderNewWikiForm({ form }); + await renderNewWikiForm({ form }); const input = screen.getAllByRole('textbox', { name: 'AddWorkspace.WorkspaceParentFolder' })[0]; await user.clear(input); @@ -160,7 +162,7 @@ describe('NewWikiForm Component', () => { wikiFolderNameSetter: mockSetter, }); - renderNewWikiForm({ form }); + await renderNewWikiForm({ form }); const input = screen.getAllByRole('textbox', { name: 'AddWorkspace.WorkspaceFolderNameToCreate' })[0]; await user.clear(input); @@ -178,7 +180,7 @@ describe('NewWikiForm Component', () => { parentFolderLocationSetter: mockSetter, }); - renderNewWikiForm({ form }); + await renderNewWikiForm({ form }); const button = screen.getAllByRole('button', { name: 'AddWorkspace.Choose' })[0]; await user.click(button); @@ -195,7 +197,7 @@ describe('NewWikiForm Component', () => { tagNameSetter: mockSetter, }); - renderNewWikiForm({ + await renderNewWikiForm({ form, isCreateMainWorkspace: false, }); @@ -209,8 +211,8 @@ describe('NewWikiForm Component', () => { }); describe('Error State Tests', () => { - it('should display errors on form fields when provided', () => { - renderNewWikiForm({ + it('should display errors on form fields when provided', async () => { + await renderNewWikiForm({ errorInWhichComponent: { parentFolderLocation: true, wikiFolderName: true, @@ -224,8 +226,8 @@ describe('NewWikiForm Component', () => { expect(wikiNameInputs.some(input => input.getAttribute('aria-invalid') === 'true')).toBe(true); }); - it('should display errors on sub workspace fields when provided', () => { - renderNewWikiForm({ + it('should display errors on sub workspace fields when provided', async () => { + await renderNewWikiForm({ isCreateMainWorkspace: false, errorInWhichComponent: { mainWikiToLink: true, @@ -242,23 +244,24 @@ describe('NewWikiForm Component', () => { }); describe('Props and State Tests', () => { - it('should render without errors when required props are provided', () => { - expect(() => { - renderNewWikiForm(); - }).not.toThrow(); + it('should render without errors when required props are provided', async () => { + // Just render successfully without throwing + await renderNewWikiForm(); + // If we got here without errors, the test passes + expect(screen.getAllByRole('textbox').length).toBeGreaterThan(0); }); - it('should show helper text for wiki folder location', () => { + it('should show helper text for wiki folder location', async () => { const form = createMockForm({ wikiFolderLocation: '/test/parent/my-wiki', }); - renderNewWikiForm({ form }); + await renderNewWikiForm({ form }); expect(screen.getByText('AddWorkspace.CreateWiki/test/parent/my-wiki')).toBeInTheDocument(); }); - it('should show helper text for sub workspace linking', () => { + it('should show helper text for sub workspace linking', async () => { const form = createMockForm({ wikiFolderName: 'sub-wiki', mainWikiToLink: { @@ -268,7 +271,7 @@ describe('NewWikiForm Component', () => { }, }); - renderNewWikiForm({ + await renderNewWikiForm({ form, isCreateMainWorkspace: false, }); diff --git a/src/windows/AddWorkspace/useAvailableTags.ts b/src/windows/AddWorkspace/useAvailableTags.ts new file mode 100644 index 00000000..eb55726d --- /dev/null +++ b/src/windows/AddWorkspace/useAvailableTags.ts @@ -0,0 +1,36 @@ +import { WikiChannel } from '@/constants/channels'; +import { useEffect, useState } from 'react'; + +/** + * Fetch all tags from a wiki for autocomplete suggestions + * @param workspaceID The workspace ID to fetch tags from + * @param enabled Whether to fetch tags (e.g., only for sub-wikis) + * @returns Array of available tag names + */ +export function useAvailableTags(workspaceID: string | undefined, enabled: boolean): string[] { + const [availableTags, setAvailableTags] = useState([]); + + useEffect(() => { + if (enabled && workspaceID) { + void (async () => { + try { + const tags = await window.service.wiki.wikiOperationInServer( + WikiChannel.runFilter, + workspaceID, + ['[all[tags]]'], + ); + if (Array.isArray(tags)) { + setAvailableTags(tags); + } + } catch { + // If wiki is not running or error occurs, just use empty array + setAvailableTags([]); + } + })(); + } else { + setAvailableTags([]); + } + }, [enabled, workspaceID]); + + return availableTags; +} diff --git a/src/windows/AddWorkspace/useForm.ts b/src/windows/AddWorkspace/useForm.ts index 58c48622..3dad0ba3 100644 --- a/src/windows/AddWorkspace/useForm.ts +++ b/src/windows/AddWorkspace/useForm.ts @@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'; import { usePromiseValue } from '@/helpers/useServiceValue'; import { useStorageServiceUserInfoObservable } from '@services/auth/hooks'; import { SupportedStorageServices } from '@services/types'; -import type { ISubWikiPluginContent } from '@services/wiki/plugin/subWikiPlugin'; import type { INewWikiWorkspaceConfig, IWikiWorkspace } from '@services/workspaces/interface'; import { isWikiWorkspace } from '@services/workspaces/interface'; import type { INewWikiRequiredFormData } from './useNewWiki'; @@ -62,13 +61,6 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { mainWikiToLinkSetter({ wikiFolderLocation: selectedWorkspace.wikiFolderLocation, port: selectedWorkspace.port, id: selectedWorkspace.id }); } }, [mainWorkspaceList, mainWikiToLinkIndex]); - /** - * For sub-wiki, we need `fileSystemPaths` which is a TiddlyWiki concept that tells wiki where to put sub-wiki files. - */ - const [fileSystemPaths, fileSystemPathsSetter] = useState([]); - useEffect(() => { - void window.service.wiki.getSubWikiPluginContent(mainWikiToLink.wikiFolderLocation).then(fileSystemPathsSetter); - }, [mainWikiToLink]); /** * For creating new wiki, we use parentFolderLocation to determine in which folder we create the new wiki folder. * New folder will basically be created in `${parentFolderLocation}/${wikiFolderName}` @@ -133,8 +125,6 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { mainWikiToLinkSetter, tagName, tagNameSetter, - fileSystemPaths, - fileSystemPathsSetter, gitRepoUrl, gitRepoUrlSetter, parentFolderLocation, @@ -182,6 +172,7 @@ export function workspaceConfigFromForm(form: INewWikiRequiredFormData, isCreate userName: undefined, excludedPlugins: [], enableHTTPAPI: false, + enableFileSystemWatch: true, lastNodeJSArgv: [], }; } diff --git a/src/windows/EditWorkspace/index.tsx b/src/windows/EditWorkspace/index.tsx index 84fae713..bf2bc275 100644 --- a/src/windows/EditWorkspace/index.tsx +++ b/src/windows/EditWorkspace/index.tsx @@ -20,7 +20,6 @@ import { useTranslation } from 'react-i18next'; import defaultIcon from '../../images/default-icon.png'; import { usePromiseValue } from '@/helpers/useServiceValue'; -import type { ISubWikiPluginContent } from '@services/wiki/plugin/subWikiPlugin'; import { WindowMeta, WindowNames } from '@services/windows/WindowProperties'; import { useWorkspaceObservable } from '@services/workspaces/hooks'; import { useForm } from './useForm'; @@ -34,6 +33,7 @@ import { isWikiWorkspace, nonConfigFields } from '@services/workspaces/interface import { isEqual, omit } from 'lodash'; import { SyncedWikiDescription } from '../AddWorkspace/Description'; import { GitRepoUrlForm } from '../AddWorkspace/GitRepoUrlForm'; +import { useAvailableTags } from '../AddWorkspace/useAvailableTags'; import { ServerOptions } from './server'; const OptionsAccordion = styled((props: React.ComponentProps) => )` @@ -158,6 +158,7 @@ export default function EditWorkspace(): React.JSX.Element { const backupOnInterval = isWiki ? workspace.backupOnInterval : false; const disableAudio = isWiki ? workspace.disableAudio : false; const disableNotifications = isWiki ? workspace.disableNotifications : false; + const enableFileSystemWatch = isWiki ? workspace.enableFileSystemWatch : false; const gitUrl = isWiki ? workspace.gitUrl : null; const hibernateWhenUnused = isWiki ? workspace.hibernateWhenUnused : false; const homeUrl = isWiki ? workspace.homeUrl : ''; @@ -171,13 +172,11 @@ export default function EditWorkspace(): React.JSX.Element { const userName = isWiki ? workspace.userName : ''; const lastUrl = isWiki ? workspace.lastUrl : null; const wikiFolderLocation = isWiki ? workspace.wikiFolderLocation : ''; - const fileSystemPaths = usePromiseValue( - async () => (mainWikiToLink ? await window.service.wiki.getSubWikiPluginContent(mainWikiToLink) : []), - [], - [mainWikiToLink], - )!; const fallbackUserName = usePromiseValue(async () => (await window.service.auth.get('userName'))!, ''); + // Fetch all tags from main wiki for autocomplete suggestions + const availableTags = useAvailableTags(mainWikiToLink ?? undefined, isSubWiki); + const rememberLastPageVisited = usePromiseValue(async () => await window.service.preference.get('rememberLastPageVisited')); if (workspaceID === undefined) { return Error {workspaceID ?? '-'} not exists; @@ -299,7 +298,7 @@ export default function EditWorkspace(): React.JSX.Element { {isSubWiki && ( fileSystemPath.tagName)} + options={availableTags} value={tagName} onInputChange={(_event: React.SyntheticEvent, value: string) => { void _event; @@ -450,6 +449,25 @@ export default function EditWorkspace(): React.JSX.Element { > + + ) => { + workspaceSetter({ ...workspace, enableFileSystemWatch: event.target.checked }, true); + }} + /> + } + > + + )} {!isSubWiki && rememberLastPageVisited && ( diff --git a/src/windows/Preferences/SectionsSideBar.tsx b/src/windows/Preferences/SectionsSideBar.tsx index c2948007..d0867e25 100644 --- a/src/windows/Preferences/SectionsSideBar.tsx +++ b/src/windows/Preferences/SectionsSideBar.tsx @@ -52,7 +52,7 @@ export function SectionSideBar(props: ISectionProps): React.JSX.Element { onClick={() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Log for E2E test - void window.service.native.log('debug', 'Preferences section clicked', { section: sectionKey }); + void window.service.native.log('debug', 'test-id-Preferences section clicked', { section: sectionKey }); }} data-testid={`preference-section-${sectionKey}`} > diff --git a/template/wiki b/template/wiki index 1ea80618..e7d18ab7 160000 --- a/template/wiki +++ b/template/wiki @@ -1 +1 @@ -Subproject commit 1ea80618e04b848572535a827b5a1fb663fdaa1d +Subproject commit e7d18ab75be6ff8c2a068c13110e4a72fe3ac088