diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff83c9e9..69aa3493 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,25 +70,6 @@ jobs: - name: Set up CV dependency for pngquant-bin if: matrix.platform == 'win' uses: ilammy/msvc-dev-cmd@v1 - - # Install dependencies for x64 architectures - - name: Install dependencies (x64) - if: matrix.arch == 'x64' - run: | - ${{ matrix.platform == 'linux' && 'pnpm install && pnpm remove registry-js' || 'pnpm install' }} - env: - npm_config_arch: x64 - - # Install dependencies for arm64 architectures - - name: Install dependencies (arm64) - if: matrix.arch == 'arm64' - run: pnpm install dugite --force - env: - npm_config_arch: ${{ matrix.platform == 'win' && 'ia32' || 'arm64' }} - - - 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 @@ -102,7 +83,12 @@ jobs: run: pnpm install - name: Rebuild native modules for Electron run: pnpm exec electron-rebuild -f -w better-sqlite3,nsfw + - name: Remove windows only deps + if: matrix.platform != 'win' + run: pnpm remove registry-js + - name: Build plugins + run: pnpm run build:plugin - name: Make ${{ matrix.platform }} (${{ matrix.arch }}) run: | pnpm exec electron-forge make --platform=${{ matrix.platform == 'mac' && 'darwin' || matrix.platform == 'win' && 'win32' || 'linux' }} --arch=${{ matrix.arch }} diff --git a/docs/ErrorDuringStart.md b/docs/ErrorDuringStart.md index 8b82a124..719e8bd9 100644 --- a/docs/ErrorDuringStart.md +++ b/docs/ErrorDuringStart.md @@ -93,6 +93,25 @@ Solution: node_modules/.bin/electron-rebuild -f -w better-sqlite3 ``` +## Error: The module '/Users/linonetwo/Desktop/repo/TidGi-Desktop/node_modules/opencv4nodejs-prebuilt/build/Release/opencv4nodejs.node' + +```log +was compiled against a different Node.js version using +NODE_MODULE_VERSION 127. This version of Node.js requires +NODE_MODULE_VERSION 135. Please try re-compiling or re-installing +the module (for instance, using `npm rebuild` or `npm install`). +``` + +(The number above is smaller) + +Don't use `npm rebuild` or `npm install`, it doesn't works, it will still build for nodejs. We need to build with electron: + +```sh +./node_modules/.bin/electron-rebuild +``` + +See if you still have problem rebuild opencv for @nut-tree/nut-js + ## During test, The module 'node_modules\better-sqlite3\build\Release\better_sqlite3.node' was compiled against a different Node.js version using ```log @@ -113,25 +132,6 @@ cross-env ELECTRON_RUN_AS_NODE=true ./node_modules/.bin/electron ./node_modules/ 救急可以用 `chcp 65001 && pnpm run test:unit`,如果有空重启电脑,则在时区设置里找到「系统区域设置」里勾选「Unicode Beta版」,重启即可。 -## Error: The module '/Users/linonetwo/Desktop/repo/TidGi-Desktop/node_modules/opencv4nodejs-prebuilt/build/Release/opencv4nodejs.node' - -```log -was compiled against a different Node.js version using -NODE_MODULE_VERSION 127. This version of Node.js requires -NODE_MODULE_VERSION 135. Please try re-compiling or re-installing -the module (for instance, using `npm rebuild` or `npm install`). -``` - -(The number above is smaller) - -Don't use `npm rebuild` or `npm install`, it doesn't works, it will still build for nodejs. We need to build with electron: - -```sh -./node_modules/.bin/electron-rebuild -``` - -See if you still have problem rebuild opencv for @nut-tree/nut-js - ## Command failed with exit code 1 When you see an error like: diff --git a/docs/Testing.md b/docs/Testing.md index 41e87c48..754ec03a 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -302,7 +302,7 @@ 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. +Only use VSCode tool to read file. Don't ever use shell command to read file. Use shell command to read file may be immediately refused by user, because he don't want to manually approve shell commands. ## User profile diff --git a/docs/internal/IPCSyncAdaptorAndFSAdaptor.md b/docs/internal/IPCSyncAdaptorAndFSAdaptor.md new file mode 100644 index 00000000..52ab2d2a --- /dev/null +++ b/docs/internal/IPCSyncAdaptorAndFSAdaptor.md @@ -0,0 +1,292 @@ +# Sync Architecture: IPC and Watch-FS Plugins + +This document describes how the `tidgi-ipc-syncadaptor` (frontend) and `watch-filesystem-adaptor` (backend) plugins work together to provide real-time bidirectional synchronization between the TiddlyWiki in-memory store and the file system. + +## Architecture Overview + +```chart +Frontend (Browser) Backend (Node.js Worker) +┌─────────────────┐ ┌──────────────────────┐ +│ TiddlyWiki │ │ TiddlyWiki (Server) │ +│ In-Memory Store │◄──────►│ File System Adaptor │ +└────────┬────────┘ └──────────┬───────────┘ + │ │ + │ IPC Sync │ Watch-FS + │ Adaptor │ Adaptor + │ │ + ├─ Save to FS ──────────────►│ + │ │ + │◄───── SSE Events ──────────┤ + │ (File Changes) │ + │ │ + │ ├─ nsfw Watcher + │ │ (File System) + └────────────────────────────┘ +``` + +## Key Design Principles + +### 1. Single Source of Truth: File System + +- Backend watch-fs monitors the file system using `nsfw` library +- All file changes (external edits, saves from frontend) flow through file system +- Backend wiki state reflects file system state + +### 2. Two-Layer Echo Prevention + +**IPC Layer (First Defense)**: + +- `ipcServerRoutes.ts` tracks recently saved tiddlers in `recentlySavedTiddlers` Set +- When `wiki.addTiddler()` triggers change event, filter out tiddlers in the Set +- Prevents frontend from receiving its own save operations as change notifications + +**Watch-FS Layer (Second Defense)**: + +- When saving, watch-fs temporarily excludes file from monitoring for 200ms +- Prevents watcher from detecting the file write operation +- Combined with IPC filtering, ensures no echo even for rapid successive saves + +### 3. SSE-like Change Notification (IPC Observable) + +- Backend sends change events to frontend via IPC (not real SSE, but Observable pattern via `ipc-cat`) +- Frontend subscribes to `getWikiChangeObserver$` observable from `window.observables.wiki` +- Change events trigger tw's `syncFromServer()` to pull updates from backend, it will in return call our `getUpdatedTiddlers` + +## Plugin Responsibilities + +### Frontend: `tidgi-ipc-syncadaptor` + +Purpose: Bridge between frontend TiddlyWiki and backend file system + +IPC Communication Flow: + +- Frontend plugin calls `window.service.wiki` methods (provided by preload script) +- Preload script uses `setupIpcServerRoutesHandlers.ts` (runs in view context) to set up IPC protocol handlers +- These handlers forward requests to `ipcServerRoutes.ts` in wiki worker via IPC +- Uses `tidgi://` custom protocol for RESTful-like API + +Key Functions: + +- `saveTiddler()`: Send tiddler to backend via IPC → `putTiddler` in `ipcServerRoutes.ts` +- `loadTiddler()`: Request tiddler from backend via IPC → `getTiddler` in `ipcServerRoutes.ts` +- `deleteTiddler()`: Request deletion via IPC → `deleteTiddler` in `ipcServerRoutes.ts` +- `setupSSE()`: Subscribe to file change events from backend (via IPC Observable, not real SSE) +- `getUpdatedTiddlers()`: Provide list of changed tiddlers to syncer + +Echo Prevention Strategy: + +Frontend plugin does NOT implement echo detection because: + +1. Cannot distinguish between "save to fs and watch fs echo back" and "external user text edit with unchanged 'modified' timestamp metadata" +2. Echo prevention is handled by backend at two levels: + - **IPC level**: `ipcServerRoutes.ts` filters out change events from IPC saves using `recentlySavedTiddlers` Set + - **Watch-FS level**: `watch-filesystem-adaptor` temporarily excludes files during save operations + +### Backend: `watch-filesystem-adaptor` + +Purpose: Monitor file system and maintain wiki state + +Key Functions: + +- `saveTiddler()`: Write to file system with temporary exclusion +- `deleteTiddler()`: Remove file with temporary exclusion +- `initializeFileWatching()`: Setup `nsfw` watcher for main wiki and sub-wikis +- `handleFileAddOrChange()`: Load changed files into wiki +- `handleFileDelete()`: Remove deleted tiddlers from wiki + +Two-Layer Echo Prevention: + +#### Layer 1: IPC Level (in ipcServerRoutes.ts) + +```typescript +async putTiddler(title, fields) { + // Mark as recently saved to prevent echo + this.recentlySavedTiddlers.add(title); + + // This triggers 'change' event synchronously + this.wikiInstance.wiki.addTiddler(new Tiddler(fields)); + + // Change event handler filters it out and removes the mark +} + +// Change event handler +wiki.addEventListener('change', (changes) => { + const filteredChanges = {}; + for (const title in changes) { + if (this.recentlySavedTiddlers.has(title)) { + // Filter out echo from IPC save + this.recentlySavedTiddlers.delete(title); + continue; + } + filteredChanges[title] = changes[title]; + } + // Only send filtered changes to frontend + observer.next(filteredChanges); +}); +``` + +#### Layer 2: Watch-FS Level (in WatchFileSystemAdaptor.ts) + +```typescript +async saveTiddler(tiddler) { + const filepath = await this.getTiddlerFileInfo(tiddler); + + // 1. Exclude file BEFORE saving + await this.excludeFile(filepath); + + // 2. Save to file system (calls parent saveTiddler) + await super.saveTiddler(tiddler); + + // 3. Re-include after delay + this.scheduleFileInclusion(filepath); + // After 200ms: includeFile(filepath) removes exclusion +} +``` + +File Change Flow: + +```typescript +handleNsfwEvents(events) { + events.forEach(event => { + const filepath = path.join(event.directory, event.file); + + // Skip if file is excluded (being saved) + if (this.excludedFiles.has(filepath)) return; + + // Load changed file into wiki + const tiddler = $tw.loadTiddlersFromFile(filepath); + $tw.syncadaptor.wiki.addTiddler(tiddler); + + // Wiki change event fires → SSE sends to frontend + }); +} +``` + +## Data Flow Examples + +### Example 1: User Edits in Frontend + +```text +1. User clicks save in browser + ├─► Frontend: saveTiddler() called + │ +2. IPC call to backend + ├─► Backend: receives putTiddler request + │ +3. Backend IPC layer: Mark tiddler in recentlySavedTiddlers + ├─► ipcServerRoutes.ts adds title to Set + │ +4. Backend: wiki.addTiddler(tiddler) + ├─► Triggers 'change' event + ├─► Event handler checks recentlySavedTiddlers + ├─► Filters out this change (IPC echo prevention) + ├─► Removes title from recentlySavedTiddlers + │ +5. Backend Watch-FS layer: excludeFile(filepath) + ├─► File added to excludedFiles set + │ +6. Backend: write to file system + ├─► File content updated on disk + │ +7. nsfw detects file change + ├─► But file is in excludedFiles + ├─► Change event ignored (Watch-FS echo prevention) + │ +8. After 200ms delay + ├─► includeFile(filepath) removes exclusion +``` + +### Example 2: External Editor Modifies File + +```text +1. User edits file in VSCode/Vim + ├─► File content changes on disk + │ +2. nsfw detects file change + ├─► File NOT in excludedFiles (not being saved) + │ +3. handleFileAddOrChange() called + ├─► Load tiddler from file + ├─► wiki.addTiddler(tiddler) + │ +4. Wiki fires 'change' event + ├─► ipcServerRoutes.ts checks recentlySavedTiddlers + ├─► Title NOT in Set (external edit, not IPC save) + ├─► Change passes through filter + ├─► IPC Observable sends event to frontend (via ipc-cat) + │ +5. Frontend receives change event + ├─► Adds to updatedTiddlers.modifications + ├─► Triggers syncFromServer() + │ +6. Frontend: loadTiddler() via IPC + ├─► Gets latest tiddler from backend + ├─► Updates frontend wiki + ├─► UI re-renders with new content +``` + +### Example 3: Sub-Wiki Synchronization + +```text +1. Main wiki has sub-wiki folder linked by tag + ├─► watch-fs detects sub-wiki in tiddlywiki.info + │ +2. watch-fs starts additional watcher + ├─► Monitors SubWiki/ folder + │ +3. User saves tiddler with tag in frontend + ├─► Backend determines file should go to SubWiki/ + ├─► Saves to SubWiki/Tiddler.tid + │ +4. Sub-wiki watcher detects new file + ├─► (Excluded during save, so no echo) + │ +5. External edit in SubWiki/Tiddler.tid + ├─► Sub-wiki watcher detects change + ├─► Updates main wiki's in-memory store + ├─► IPC Observable notifies frontend + ├─► Frontend syncs and displays update +``` + +## Key Configuration + +### File Exclusion + +- `FILE_EXCLUSION_CLEANUP_DELAY_MS = 200`: Time to keep file excluded after save +- Prevents echo while allowing quick re-detection of external changes + +### SSE-like Debouncing (IPC Observable) + +- `debounce(syncFromServer, 500)`: Batch multiple file changes +- Reduces unnecessary sync operations + +### Syncer Polling + +- `pollTimerInterval = 2_147_483_647`: Effectively disable polling +- All updates come via IPC Observable (event-driven, not polling) + +## Troubleshooting + +### Changes Not Appearing in Frontend + +1. Check IPC Observable connection: Look for `[test-id-SSE_READY]` in logs +2. Verify watch-fs is running: Look for `[test-id-WATCH_FS_STABILIZED]` +3. Check file exclusion: Should see `[WATCH_FS_EXCLUDE]` and `[WATCH_FS_INCLUDE]` + +### Echo/Duplicate Updates + +1. Verify exclusion timing: 200ms should be sufficient +2. Check for multiple watchers on same path +3. Ensure frontend isn't doing its own timestamp-based filtering + +### Sub-Wiki Not Syncing + +1. Check sub-wiki detection: Look for `[WATCH_FS_SUBWIKI]` logs +2. Verify tiddlywiki.info has correct configuration +3. Check workspace `subWikiFolders` setting + +### Not Recommended + +- ❌ Timestamp-based echo detection (already tried, unreliable) +- ❌ Frontend-side file watching (duplicates backend effort) +- ❌ Polling-based synchronization (SSE is better) diff --git a/eslint.config.mjs b/eslint.config.mjs index 5067fad9..b081d096 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,7 @@ export default [ }, }, { - files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx'], + files: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', "*.env.d.ts"], rules: { '@typescript-eslint/unbound-method': 'off', 'unicorn/prevent-abbreviations': 'off', diff --git a/features/filesystemPlugin.feature b/features/filesystemPlugin.feature index cd07c506..a4aa3309 100644 --- a/features/filesystemPlugin.feature +++ b/features/filesystemPlugin.feature @@ -12,7 +12,7 @@ Feature: Filesystem Plugin Then the browser view should be loaded and visible And I wait for SSE and watch-fs to be ready - @subwiki + @file-watching @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" @@ -27,16 +27,64 @@ Feature: Filesystem Plugin 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='添加条目']" + # Wait for main wiki to restart after sub-wiki creation + Then I wait for main wiki to restart after sub-wiki creation + Then I wait for view to finish loading + # Click SubWiki workspace again to ensure TestTag tiddler is displayed + And I wait for 1 seconds + When I click on a "SubWiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')" + And I wait for 1 seconds + # Verify TestTag tiddler is visible + And I should see "TestTag" in the browser view content + # Create tiddler with tag to test file system plugin + And I click on "add tiddler button" element in browser view with selector "button:has(.tc-image-new-button)" + # Focus on title input, clear it, and type new title in the draft tiddler + And I click on "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" 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" + And I press "Control+a" in browser view + And I wait for 0.2 seconds + And I press "Delete" in browser view + And I type "TestTiddlerTitle" in "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" + # Wait for tiddler state to settle, otherwise it still shows 3 chars (新条目) for a while + And I wait for 2 seconds + Then I should see "16 chars" in the browser view content + # Input tag by typing in the tag input field - use precise selector to target the tag input specifically + And I click on "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']" + And I wait for 0.2 seconds + And I press "Control+a" in browser view + And I wait for 0.2 seconds + And I press "Delete" in browser view + And I wait for 0.2 seconds + And I type "TestTag" in "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']" + # Click the add tag button to confirm the tag (not just typing) + And I wait for 0.2 seconds + And I click on "add tag button" element in browser view with selector "div[data-tiddler-title^='Draft of'] span.tc-add-tag-button button" + # Wait for file system plugin to save the draft tiddler to SubWiki folder, Even 3 second will randomly failed in next step. + And I wait for 4.5 seconds + # Verify the DRAFT tiddler has been routed to sub-wiki immediately after adding the tag + Then file "Draft of '新条目'.tid" should exist in "{tmpDir}/SubWiki" + # Verify the draft file is NOT in main wiki tiddlers folder (it should have been moved to SubWiki) + Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers" + # Click confirm button to save the tiddler + And I click on "confirm button" element in browser view with selector "button:has(.tc-image-done-button)" + And I wait for 1 seconds + # Verify the final tiddler file exists in sub-wiki folder after save + # After confirming the draft, it should be saved as TestTiddlerTitle.tid in SubWiki + Then file "TestTiddlerTitle.tid" should exist in "{tmpDir}/SubWiki" + # Test SSE is still working after SubWiki creation - modify a main wiki tiddler + When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "Main wiki content modified after SubWiki creation" + Then I wait for tiddler "Index" to be updated by watch-fs + # Confirm Index always open + Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']" + Then I should see "Main wiki content modified after SubWiki creation" in the browser view content + # Test modification in sub-workspace via symlink + # Modify the tiddler file externally - need to preserve .tid format with metadata + When I modify file "{tmpDir}/SubWiki/TestTiddlerTitle.tid" to contain "Content modified in SubWiki symlink" + # Wait for watch-fs to detect the change + Then I wait for tiddler "TestTiddlerTitle" to be updated by watch-fs + And I wait for 2 seconds + # Verify the modified content appears in the wiki + Then I should see "Content modified in SubWiki symlink" in the browser view content @file-watching Scenario: External file creation syncs to wiki @@ -53,7 +101,8 @@ Feature: Filesystem Plugin 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 + # wait for tw animation, sidebar need time to show + And I wait for 1 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 @@ -75,11 +124,13 @@ Feature: Filesystem Plugin 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')" + And I wait for 0.5 seconds 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) + # Verify the wiki shows updated content (should auto-refresh), need to wait for IPC, it is slow on CI and will randomly failed + And I wait for 2 seconds 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" @@ -89,6 +140,15 @@ Feature: Filesystem Plugin # 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: Deleting open tiddler file shows missing tiddler message + # Delete the Index.tid file while Index tiddler is open (it's open by default) + When I delete file "{tmpDir}/wiki/tiddlers/Index.tid" + Then I wait for tiddler "Index" to be deleted by watch-fs + And I wait for 0.5 seconds + # Verify the missing tiddler message appears in the tiddler frame + Then I should see "佚失条目 \"Index\"" in the browser view DOM + @file-watching Scenario: External file rename syncs to wiki # Create initial file @@ -121,7 +181,7 @@ Feature: Filesystem Plugin 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 + And I wait for 1 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 diff --git a/features/stepDefinitions/agent.ts b/features/stepDefinitions/agent.ts index dfbd0b17..9e12f944 100644 --- a/features/stepDefinitions/agent.ts +++ b/features/stepDefinitions/agent.ts @@ -292,11 +292,11 @@ Given('I add test ai settings', function() { fs.writeJsonSync(settingsPath, { ...existing, aiSettings: newAi } as ISettingFile, { spaces: 2 }); }); -function clearAISettings() { - if (!fs.existsSync(settingsPath)) return; - const parsed = fs.readJsonSync(settingsPath) as ISettingFile; +async function clearAISettings() { + if (!(await fs.pathExists(settingsPath))) return; + const parsed = await fs.readJson(settingsPath) as ISettingFile; const cleaned = omit(parsed, ['aiSettings']); - fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 }); + await fs.writeJson(settingsPath, cleaned, { spaces: 2 }); } export { clearAISettings }; diff --git a/features/stepDefinitions/application.ts b/features/stepDefinitions/application.ts index 89a33e53..37b796b8 100644 --- a/features/stepDefinitions/application.ts +++ b/features/stepDefinitions/application.ts @@ -9,6 +9,7 @@ import { MockOAuthServer } from '../supports/mockOAuthServer'; import { MockOpenAIServer } from '../supports/mockOpenAI'; import { makeSlugPath, screenshotsDirectory } from '../supports/paths'; import { getPackedAppPath } from '../supports/paths'; +import { captureScreenshot } from '../supports/webContentsViewHelper'; // Backoff configuration for retries const BACKOFF_OPTIONS = { @@ -199,6 +200,13 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) // if (!process.env.CI) return; try { + const stepText = pickleStep.text; + + // Skip screenshots for wait steps to avoid too many screenshots + if (stepText.match(/^I wait for \d+(\.\d+)? seconds?$/i)) { + return; + } + // Prefer an existing currentWindow if it's still open let pageToUse: Page | undefined; @@ -211,15 +219,15 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) const openPages = this.app.windows().filter(p => !p.isClosed()); if (openPages.length > 0) { pageToUse = openPages[0]; - this.currentWindow = pageToUse; } } const scenarioName = pickle.name; - const cleanScenarioName = makeSlugPath(scenarioName); + // Limit scenario slug to avoid extremely long directory names + const cleanScenarioName = makeSlugPath(scenarioName, 60); - const stepText = pickleStep.text; - const cleanStepText = makeSlugPath(stepText, 120); + // Limit step text slug to avoid excessively long filenames which can trigger ENAMETOOLONG + const cleanStepText = makeSlugPath(stepText, 80); const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status'; const featureDirectory = path.resolve(screenshotsDirectory, cleanScenarioName); @@ -239,10 +247,17 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result }) } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const screenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.jpg`); - // Use conservative screenshot options for CI - await pageToUse.screenshot({ path: screenshotPath, fullPage: true, type: 'jpeg', quality: 10 }); + // Try to capture both WebContentsView and Page screenshots + let webViewCaptured = false; + if (this.app) { + const webViewScreenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}-webview.png`); + webViewCaptured = await captureScreenshot(this.app, webViewScreenshotPath); + } + + // Always capture page screenshot (UI chrome/window) + const pageScreenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}${webViewCaptured ? '-page' : ''}.png`); + await pageToUse.screenshot({ path: pageScreenshotPath, fullPage: true, type: 'png' }); } catch (screenshotError) { console.warn('Failed to take screenshot:', screenshotError); } diff --git a/features/stepDefinitions/browserView.ts b/features/stepDefinitions/browserView.ts index f51cce28..d831bda2 100644 --- a/features/stepDefinitions/browserView.ts +++ b/features/stepDefinitions/browserView.ts @@ -5,7 +5,7 @@ import type { ApplicationWorld } from './application'; // Backoff configuration for retries const BACKOFF_OPTIONS = { - numOfAttempts: 8, + numOfAttempts: 10, startingDelay: 100, timeMultiple: 2, }; @@ -60,7 +60,7 @@ Then('I should see {string} in the browser view DOM', async function(this: Appli }); }); -Then('the browser view should be loaded and visible', async function(this: ApplicationWorld) { +Then('the browser view should be loaded and visible', { timeout: 15000 }, async function(this: ApplicationWorld) { if (!this.app) { throw new Error('Application not launched'); } @@ -76,7 +76,7 @@ Then('the browser view should be loaded and visible', async function(this: Appli throw new Error('Browser view not loaded'); } }, - BACKOFF_OPTIONS, + { ...BACKOFF_OPTIONS, numOfAttempts: 15 }, ).catch(() => { throw new Error('Browser view is not loaded or visible after multiple attempts'); }); diff --git a/features/stepDefinitions/cleanup.ts b/features/stepDefinitions/cleanup.ts index 5ae27eee..6f294362 100644 --- a/features/stepDefinitions/cleanup.ts +++ b/features/stepDefinitions/cleanup.ts @@ -6,22 +6,22 @@ import { ApplicationWorld } from './application'; import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; import { clearSubWikiRoutingTestData } from './wiki'; -Before(function(this: ApplicationWorld, { pickle }) { +Before(async 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 }); + if (!(await fs.pathExists(logsDirectory))) { + await fs.ensureDir(logsDirectory); } // Create screenshots subdirectory in logs - if (!fs.existsSync(screenshotsDirectory)) { - fs.mkdirSync(screenshotsDirectory, { recursive: true }); + if (!(await fs.pathExists(screenshotsDirectory))) { + await fs.ensureDir(screenshotsDirectory); } if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { - clearAISettings(); + await clearAISettings(); } if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { - clearTidgiMiniWindowSettings(); + await clearTidgiMiniWindowSettings(); } }); @@ -30,15 +30,17 @@ After(async function(this: ApplicationWorld, { pickle }) { 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(); + await Promise.all( + allWindows.map(async (window) => { + try { + if (!window.isClosed()) { + await window.close(); + } + } catch (error) { + console.error('Error closing window:', error); } - } catch (error) { - console.error('Error closing window:', error); - } - } + }), + ); await this.app.close(); } catch (error) { console.error('Error during cleanup:', error); @@ -48,12 +50,34 @@ After(async function(this: ApplicationWorld, { pickle }) { this.currentWindow = undefined; } if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { - clearTidgiMiniWindowSettings(); + await clearTidgiMiniWindowSettings(); } if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { - clearAISettings(); + await clearAISettings(); } if (pickle.tags.some((tag) => tag.name === '@subwiki')) { - clearSubWikiRoutingTestData(); + await clearSubWikiRoutingTestData(); + } + + // Separate logs by test scenario for easier debugging + try { + const today = new Date().toISOString().split('T')[0]; + const wikiLogFile = `${logsDirectory}/wiki-${today}.log`; + const tidgiLogFile = `${logsDirectory}/TidGi-${today}.log`; + + // Create a sanitized scenario name for the log files + const scenarioName = pickle.name.replace(/[^a-z0-9]/gi, '_').substring(0, 50); + + if (await fs.pathExists(wikiLogFile)) { + const targetWikiLog = `${logsDirectory}/${scenarioName}_wiki.log`; + await fs.move(wikiLogFile, targetWikiLog, { overwrite: true }); + } + + if (await fs.pathExists(tidgiLogFile)) { + const targetTidgiLog = `${logsDirectory}/${scenarioName}_TidGi.log`; + await fs.move(tidgiLogFile, targetTidgiLog, { overwrite: true }); + } + } catch (error) { + console.error('Error moving log files:', error); } }); diff --git a/features/stepDefinitions/tidgiMiniWindow.ts b/features/stepDefinitions/tidgiMiniWindow.ts index 6899bc32..c42d6532 100644 --- a/features/stepDefinitions/tidgiMiniWindow.ts +++ b/features/stepDefinitions/tidgiMiniWindow.ts @@ -37,9 +37,9 @@ Given('I configure tidgi mini window with shortcut', async function() { }); // Cleanup function to be called after tidgi mini window tests (after app closes) -function clearTidgiMiniWindowSettings() { - if (!fs.existsSync(settingsPath)) return; - const parsed = fs.readJsonSync(settingsPath) as ISettingFile; +async function clearTidgiMiniWindowSettings() { + if (!(await fs.pathExists(settingsPath))) return; + const parsed = await fs.readJson(settingsPath) as ISettingFile; // Remove tidgi mini window-related preferences to avoid affecting other tests const cleanedPreferences = omit(parsed.preferences || {}, [ 'tidgiMiniWindow', @@ -68,7 +68,7 @@ function clearTidgiMiniWindowSettings() { } const cleaned = { ...parsed, preferences: cleanedPreferences, workspaces }; - fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 }); + await fs.writeJson(settingsPath, cleaned, { spaces: 2 }); } export { clearTidgiMiniWindowSettings }; diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index 6c1d2771..de79ac6b 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -1,134 +1,53 @@ import { Then, When } from '@cucumber/cucumber'; +import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import path from 'path'; import type { IWorkspace } from '../../src/services/workspaces/interface'; 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`); -} +// Backoff configuration for retries +const BACKOFF_OPTIONS = { + numOfAttempts: 10, + startingDelay: 200, + timeMultiple: 1.5, +}; /** - * Wait for a tiddler to be added by watch-fs. + * Generic function to wait for a log marker to appear in wiki log files. */ -async function waitForTiddlerAdded(tiddlerTitle: string, maxWaitMs = 10000): Promise { +async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): 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; + await backOff( + async () => { + try { + const files = await fs.readdir(logPath); + const logFiles = files.filter(f => f.startsWith(logFilePattern) && f.endsWith('.log')); + + for (const file of logFiles) { + const content = await fs.readFile(path.join(logPath, file), 'utf-8'); + if (content.includes(searchString)) { + return; + } } + } catch { + // Log directory might not exist yet, continue retrying } - } 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`); + throw new Error('Log marker not found yet'); + }, + { + numOfAttempts: Math.ceil(maxWaitMs / 100), + startingDelay: 100, + timeMultiple: 1, + maxDelay: 100, + delayFirstAttempt: false, + jitter: 'none', + }, + ).catch(() => { + throw new Error(errorMessage); + }); } When('I cleanup test wiki so it could create a new one on start', async function() { @@ -162,24 +81,131 @@ When('I cleanup test wiki so it could create a new one on start', async function }); /** - * Verify file exists in directory + * Helper function to get directory tree structure */ -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)); +async function getDirectoryTree(directory: string, prefix = '', maxDepth = 3, currentDepth = 0): Promise { + if (currentDepth >= maxDepth || !(await fs.pathExists(directory))) { + return ''; } - if (!exists) { - throw new Error(`File "${fileName}" not found in directory: ${actualPath}`); + let tree = ''; + try { + const items = await fs.readdir(directory); + for (let index = 0; index < items.length; index++) { + const item = items[index]; + const isLast = index === items.length - 1; + const itemPath = path.join(directory, item); + const connector = isLast ? '└── ' : '├── '; + + try { + const stat = await fs.stat(itemPath); + tree += `${prefix}${connector}${item}${stat.isDirectory() ? '/' : ''}\n`; + + if (stat.isDirectory()) { + const newPrefix = prefix + (isLast ? ' ' : '│ '); + tree += await getDirectoryTree(itemPath, newPrefix, maxDepth, currentDepth + 1); + } + } catch { + tree += `${prefix}${connector}${item} [error reading]\n`; + } + } + } catch { + // Directory not readable + } + + return tree; +} + +/** + * Verify file exists in directory + */ +Then('file {string} should exist in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { + // Replace {tmpDir} with wiki test root (not wiki subfolder) + let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); + + // Resolve symlinks on all platforms to handle sub-wikis correctly + // On Linux, symlinks might point to the real path, so we need to follow them + if (await fs.pathExists(directoryPath)) { + try { + directoryPath = fs.realpathSync(directoryPath); + } catch { + // If realpathSync fails, continue with the original path + } + } + + const filePath = path.join(directoryPath, fileName); + + try { + await backOff( + async () => { + if (await fs.pathExists(filePath)) { + return; + } + throw new Error('File not found yet'); + }, + BACKOFF_OPTIONS, + ); + } catch { + // Get 1 level up from actualPath + const oneLevelsUp = path.resolve(directoryPath, '..'); + const tree = await getDirectoryTree(oneLevelsUp); + + // Also read all .tid files in the actualPath directory + let tidFilesContent = ''; + try { + if (await fs.pathExists(directoryPath)) { + const files = await fs.readdir(directoryPath); + const tidFiles = files.filter(f => f.endsWith('.tid')); + + if (tidFiles.length > 0) { + tidFilesContent = '\n\n.tid files in directory:\n'; + for (const tidFile of tidFiles) { + const tidPath = path.join(directoryPath, tidFile); + const content = await fs.readFile(tidPath, 'utf-8'); + tidFilesContent += `\n=== ${tidFile} ===\n${content}\n`; + } + } + } + } catch (readError) { + tidFilesContent = `\n\nError reading .tid files: ${String(readError)}`; + } + + throw new Error( + `File "${fileName}" not found in directory: ${directoryPath}\n\n` + + `Directory tree (1 level up from ${oneLevelsUp}):\n${tree}${tidFilesContent}`, + ); + } +}); + +Then('file {string} should not exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) { + // Replace {tmpDir} with wiki test root (not wiki subfolder) + let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath); + + // Resolve symlinks on all platforms to handle sub-wikis correctly + if (await fs.pathExists(directoryPath)) { + try { + directoryPath = fs.realpathSync(directoryPath); + } catch { + // If realpathSync fails, continue with the original path + } + } + + const filePath = path.join(directoryPath, fileName); + + try { + await backOff( + async () => { + if (!(await fs.pathExists(filePath))) { + return; + } + throw new Error('File still exists'); + }, + BACKOFF_OPTIONS, + ); + } catch { + throw new Error( + `File "${fileName}" should not exist but was found in directory: ${directoryPath}`, + ); } }); @@ -187,11 +213,11 @@ Then('file {string} should exist in {string}', { timeout: 15000 }, async functio * Cleanup function for sub-wiki routing test * Removes test workspaces created during the test */ -function clearSubWikiRoutingTestData() { - if (!fs.existsSync(settingsPath)) return; +async function clearSubWikiRoutingTestData() { + if (!(await fs.pathExists(settingsPath))) return; type SettingsFile = { workspaces?: Record } & Record; - const settings = fs.readJsonSync(settingsPath) as SettingsFile; + const settings = await fs.readJson(settingsPath) as SettingsFile; const workspaces: Record = settings.workspaces ?? {}; const filtered: Record = {}; @@ -205,48 +231,53 @@ function clearSubWikiRoutingTestData() { } } - fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); + await fs.writeJson(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); + if (await fs.pathExists(wikiPath)) { + await fs.remove(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 SSE and watch-fs to be ready', async function(this: ApplicationWorld) { + await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready within timeout', 15000); + await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready within timeout', 15000); +}); + +Then('I wait for main wiki to restart after sub-wiki creation', async function(this: ApplicationWorld) { + await waitForLogMarker('[test-id-MAIN_WIKI_RESTARTED_AFTER_SUBWIKI]', 'Main wiki did not restart after sub-wiki creation within timeout', 20000, 'TidGi-'); + // Also wait for SSE and watch-fs to be ready after restart + await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready after restart within timeout', 15000); + await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready after restart within timeout', 15000); +}); + +Then('I wait for view to finish loading', async function(this: ApplicationWorld) { + await waitForLogMarker('[test-id-VIEW_LOADED]', 'Browser view did not finish loading within timeout', 10000, 'wiki-'); }); 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}`); - } + await waitForLogMarker( + `[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`, + `Tiddler "${tiddlerTitle}" was not added within timeout`, + ); }); 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}`); - } + await waitForLogMarker( + `[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`, + `Tiddler "${tiddlerTitle}" was not updated within timeout`, + ); }); 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}`); - } + await waitForLogMarker( + `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`, + `Tiddler "${tiddlerTitle}" was not deleted within timeout`, + ); }); // File manipulation step definitions @@ -271,16 +302,28 @@ When('I modify file {string} to contain {string}', async function(this: Applicat // 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'); + // Split by both \n and \r\n to handle different line endings + const lines = fileContent.split(/\r?\n/); + const blankLineIndex = lines.findIndex(line => line.trim() === ''); if (blankLineIndex >= 0) { + // File has headers and content separated by blank line // Keep headers, replace text after blank line const headers = lines.slice(0, blankLineIndex + 1); + + // Note: We intentionally do NOT update the modified field here + // This simulates a real user editing the file in an external editor, + // where the modified field would not be automatically updated + // The echo prevention mechanism should detect this as a real external change + // because the content changed but the modified timestamp stayed the same + fileContent = [...headers, content].join('\n'); } else { - // No headers found, just use content - fileContent = content; + // File has only headers, no content yet (no blank line separator) + // We need to add the blank line separator and the content + // Again, we don't modify the modified field + fileContent = [...lines, '', content].join('\n'); } // Write the modified content back diff --git a/features/stepDefinitions/window.ts b/features/stepDefinitions/window.ts index 5e2e3ee7..1aea1279 100644 --- a/features/stepDefinitions/window.ts +++ b/features/stepDefinitions/window.ts @@ -1,8 +1,8 @@ import { When } from '@cucumber/cucumber'; +import { WebContentsView } from 'electron'; import type { ElectronApplication } from 'playwright'; import type { ApplicationWorld } from './application'; import { checkWindowDimension, checkWindowName } from './application'; -import { WebContentsView } from 'electron'; // Helper function to get browser view info from Electron window async function getBrowserViewInfo( @@ -53,7 +53,7 @@ When('I confirm the {string} window exists', async function(this: ApplicationWor const success = await this.waitForWindowCondition( windowType, - (window) => window !== undefined && !window.isClosed(), + (window) => window !== undefined, ); if (!success) { @@ -83,7 +83,7 @@ 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, + (window, isVisible) => window !== undefined && !isVisible, ); if (!success) { diff --git a/features/supports/paths.ts b/features/supports/paths.ts index a0ef3b65..26820e5a 100644 --- a/features/supports/paths.ts +++ b/features/supports/paths.ts @@ -66,7 +66,7 @@ const unsafeChars = /[^\p{L}\p{N}\s\-_()]/gu; const collapseDashes = /-+/g; const collapseSpaces = /\s+/g; export const makeSlugPath = (input: string | undefined, maxLength = 120) => { - let s = String(input || 'unknown').normalize('NFKC'); + let s = (input || 'unknown').normalize('NFKC'); // remove dots explicitly s = s.replace(/\./g, ''); // replace unsafe characters with dashes diff --git a/features/supports/webContentsViewHelper.new.ts b/features/supports/webContentsViewHelper.new.ts deleted file mode 100644 index c85fb4e2..00000000 --- a/features/supports/webContentsViewHelper.new.ts +++ /dev/null @@ -1,242 +0,0 @@ -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 387238ad..790d528a 100644 --- a/features/supports/webContentsViewHelper.ts +++ b/features/supports/webContentsViewHelper.ts @@ -1,4 +1,5 @@ import { WebContentsView } from 'electron'; +import fs from 'fs-extra'; import type { ElectronApplication } from 'playwright'; /** @@ -94,7 +95,22 @@ export async function getDOMContent(app: ElectronApplication): Promise { const webContentsId = await getFirstWebContentsView(app); - return webContentsId !== null; + if (webContentsId === null) { + return false; + } + + // Check if the WebContents is actually loaded + return await app.evaluate( + async ({ webContents }, id: number) => { + const targetWebContents = webContents.fromId(id); + if (!targetWebContents) { + return false; + } + // Check if the page has finished loading + return !targetWebContents.isLoading() && targetWebContents.getURL() !== '' && targetWebContents.getURL() !== 'about:blank'; + }, + webContentsId, + ); } /** @@ -107,30 +123,37 @@ export async function clickElementWithText( ): 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; + try { + 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) { + return { error: 'Element with text "' + text + '" not found in selector: ' + selector }; + } + + found.click(); + return { success: true }; + } catch (error) { + return { error: error.message || String(error) }; } - - if (!found) { - throw new Error('Element with text "' + text + '" not found in selector: ' + selector); - } - - found.click(); - return true; })() `; - await executeInBrowserView(app, script); + const result = await executeInBrowserView(app, script); + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String(result.error)); + } } /** @@ -139,19 +162,26 @@ export async function clickElementWithText( 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); + try { + const selector = ${JSON.stringify(selector)}; + const elem = document.querySelector(selector); + + if (!elem) { + return { error: 'Element not found: ' + selector }; + } + + elem.click(); + return { success: true }; + } catch (error) { + return { error: error.message || String(error) }; } - - elem.click(); - return true; })() `; - await executeInBrowserView(app, script); + const result = await executeInBrowserView(app, script); + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String(result.error)); + } } /** @@ -163,28 +193,35 @@ export async function typeText(app: ElectronApplication, selector: string, text: const script = ` (function() { - const selector = '${escapedSelector}'; - const text = '${escapedText}'; - const elem = document.querySelector(selector); - - if (!elem) { - throw new Error('Element not found: ' + selector); + try { + const selector = '${escapedSelector}'; + const text = '${escapedText}'; + const elem = document.querySelector(selector); + + if (!elem) { + return { 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 { success: true }; + } catch (error) { + return { error: error.message || String(error) }; } - - 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); + const result = await executeInBrowserView(app, script); + if (result && typeof result === 'object' && 'error' in result) { + throw new Error(String(result.error)); + } } /** @@ -252,3 +289,60 @@ export async function elementExists(app: ElectronApplication, selector: string): return false; } } + +/** + * Capture screenshot of WebContentsView with timeout + * Returns true if screenshot capture started successfully, false if failed or timeout + * File writing continues asynchronously in background if capture succeeds + */ +export async function captureScreenshot(app: ElectronApplication, screenshotPath: string): Promise { + try { + // Add timeout to prevent screenshot from blocking test execution + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve(null); + }, 500); + }); + + const capturePromise = (async () => { + const webContentsId = await getFirstWebContentsView(app); + if (!webContentsId) { + return null; + } + + const pngBufferData = await app.evaluate( + async ({ webContents }, id: number) => { + const targetWebContents = webContents.fromId(id); + if (!targetWebContents || targetWebContents.isDestroyed()) { + return null; + } + + try { + const image = await targetWebContents.capturePage(); + const pngBuffer = image.toPNG(); + return Array.from(pngBuffer); + } catch { + return null; + } + }, + webContentsId, + ); + + return pngBufferData; + })(); + + const result = await Promise.race([capturePromise, timeoutPromise]); + + // If we got the screenshot data, write it to file asynchronously (fire and forget) + if (result && Array.isArray(result)) { + fs.writeFile(screenshotPath, Buffer.from(result)).catch(() => { + // Silently ignore write errors + }); + return true; + } + + return false; + } catch { + return false; + } +} diff --git a/features/tidgiMiniWindow.feature b/features/tidgiMiniWindow.feature index 35ce3132..9c97b3b5 100644 --- a/features/tidgiMiniWindow.feature +++ b/features/tidgiMiniWindow.feature @@ -31,8 +31,8 @@ Feature: TidGi Mini Window Then the browser view should be loaded and visible And I should see "我的 TiddlyWiki" in the browser view content Then I switch to "main" window + And I wait for 0.2 seconds When I press the key combination "CommandOrControl+Shift+M" - And I wait for 2 seconds And I confirm the "tidgiMiniWindow" window exists And I confirm the "tidgiMiniWindow" window not visible diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index 1d892037..f8a2d63a 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -19,7 +19,7 @@ "ExistedWikiLocation": "Existed Wiki Location", "ExtractedWikiFolderName": "Converted WIKI folder name", "GitDefaultBranchDescription": "The default branch of your Git, Github changed it from master to main after that event", - "GitEmailDescription": "Email used for Git commit records, used for counting daily commits on services such as Github", + "GitEmailDescription": "Email used for Git commit records, used for counting daily commits on services such as Github", "GitRepoUrl": "Git repository online address", "GitTokenDescription": "The credentials used to log in to Git. Will expire after a certain period of time", "GitUserNameDescription": "The account name used to log in to Git, note that it is the name part of your repository URL", @@ -114,6 +114,30 @@ "TidGiSupport": "TidGi Support", "TidGiWebsite": "TidGi Website" }, + "Notification": { + "AdjustSchedule": "Adjust schedule...", + "AdjustTime": "Adjust time", + "Custom": "Custom...", + "Pause10Hours": "10 hours", + "Pause12Hours": "12 hours", + "Pause15Minutes": "15 minutes", + "Pause1Hour": "1 hour", + "Pause2Hours": "2 hours", + "Pause30Minutes": "30 minutes", + "Pause45Minutes": "45 minutes", + "Pause4Hours": "4 hours", + "Pause6Hours": "6 hours", + "Pause8Hours": "8 hours", + "PauseBySchedule": "Pause notifications by schedule...", + "PauseNotifications": "Pause notifications", + "Paused": "Notifications paused", + "PausedUntil": "Notifications paused until {{date}}.", + "PauseUntilNextWeek": "Until next week", + "PauseUntilTomorrow": "Until tomorrow", + "Resume": "Resume notifications", + "Resumed": "Notifications resumed", + "NotificationsNowResumed": "Notifications are now resumed." + }, "Delete": "Delete", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "Cannot find the workspace folder that was still there before! \nThe folders that should have existed here may have been removed, or there is no wiki in this folder! \nDo you want to remove the workspace?", diff --git a/localization/locales/fr/translation.json b/localization/locales/fr/translation.json index 91c5afe0..c1fe7d5c 100644 --- a/localization/locales/fr/translation.json +++ b/localization/locales/fr/translation.json @@ -105,6 +105,30 @@ "TidGiSupport": "Support TidGi", "TidGiWebsite": "Site web TidGi" }, + "Notification": { + "AdjustSchedule": "Ajuster le calendrier...", + "AdjustTime": "Ajuster l'heure", + "Custom": "Personnalisé...", + "Pause10Hours": "10 heures", + "Pause12Hours": "12 heures", + "Pause15Minutes": "15 minutes", + "Pause1Hour": "1 heure", + "Pause2Hours": "2 heures", + "Pause30Minutes": "30 minutes", + "Pause45Minutes": "45 minutes", + "Pause4Hours": "4 heures", + "Pause6Hours": "6 heures", + "Pause8Hours": "8 heures", + "PauseBySchedule": "Pause notifications selon le calendrier...", + "PauseNotifications": "Pause notifications", + "Paused": "Notifications en pause", + "PausedUntil": "Notifications en pause jusqu'à {{date}}.", + "PauseUntilNextWeek": "Jusqu'à la semaine prochaine", + "PauseUntilTomorrow": "Jusqu'à demain", + "Resume": "Reprendre les notifications", + "Resumed": "Notifications reprises", + "NotificationsNowResumed": "Les notifications sont maintenant reprises." + }, "Delete": "Supprimer", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "Impossible de trouver le dossier de l'espace de travail qui était encore là avant ! \nLes dossiers qui devraient exister ici ont peut-être été supprimés, ou il n'y a pas de wiki dans ce dossier ! \nVoulez-vous supprimer l'espace de travail ?", diff --git a/localization/locales/ja/translation.json b/localization/locales/ja/translation.json index 3b6ca321..0338e167 100644 --- a/localization/locales/ja/translation.json +++ b/localization/locales/ja/translation.json @@ -105,6 +105,30 @@ "TidGiSupport": "TidGiサポート", "TidGiWebsite": "TidGi公式サイト" }, + "Notification": { + "AdjustSchedule": "スケジュールを調整...", + "AdjustTime": "時間を調整", + "Custom": "カスタム...", + "Pause10Hours": "10時間", + "Pause12Hours": "12時間", + "Pause15Minutes": "15分", + "Pause1Hour": "1時間", + "Pause2Hours": "2時間", + "Pause30Minutes": "30分", + "Pause45Minutes": "45分", + "Pause4Hours": "4時間", + "Pause6Hours": "6時間", + "Pause8Hours": "8時間", + "PauseBySchedule": "スケジュールで通知を一時停止...", + "PauseNotifications": "通知を一時停止", + "Paused": "通知が一時停止されています", + "PausedUntil": "通知は {{date}} まで一時停止されています。", + "PauseUntilNextWeek": "来週まで", + "PauseUntilTomorrow": "明日まで", + "Resume": "通知を再開", + "Resumed": "通知が再開されました", + "NotificationsNowResumed": "通知は現在再開されています。" + }, "Delete": "削除", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "以前そこにあったワークスペースフォルダが見つかりません!\nここに存在するはずのフォルダが移動されたか、このフォルダにWikiがありません!ワークスペースを削除しますか?", diff --git a/localization/locales/ru/translation.json b/localization/locales/ru/translation.json index 91c81e5a..88a8eea7 100644 --- a/localization/locales/ru/translation.json +++ b/localization/locales/ru/translation.json @@ -105,6 +105,30 @@ "TidGiSupport": "Поддержка TidGi", "TidGiWebsite": "Сайт TidGi" }, + "Notification": { + "AdjustSchedule": "Отрегулировать расписание...", + "AdjustTime": "Отрегулировать время", + "Custom": "Пользовательский...", + "Pause10Hours": "10 часов", + "Pause12Hours": "12 часов", + "Pause15Minutes": "15 минут", + "Pause1Hour": "1 час", + "Pause2Hours": "2 часа", + "Pause30Minutes": "30 минут", + "Pause45Minutes": "45 минут", + "Pause4Hours": "4 часа", + "Pause6Hours": "6 часов", + "Pause8Hours": "8 часов", + "PauseBySchedule": "Приостановить уведомления по расписанию...", + "PauseNotifications": "Приостановить уведомления", + "Paused": "Уведомления приостановлены", + "PausedUntil": "Уведомления приостановлены до {{date}}.", + "PauseUntilNextWeek": "До следующей недели", + "PauseUntilTomorrow": "До завтра", + "Resume": "Возобновить уведомления", + "Resumed": "Уведомления возобновлены", + "NotificationsNowResumed": "Уведомления теперь возобновлены." + }, "Delete": "удалить", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "Не удалось найти папку Wiki рабочей области, которая ранее находилась здесь! Возможно, папка Wiki была перемещена или в ней нет содержимого Wiki. Удалить рабочую область?", diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 79ea96be..c85f3955 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -114,6 +114,30 @@ "TidGiSupport": "TidGi 用户支持", "TidGiWebsite": "TidGi 官网" }, + "Notification": { + "AdjustSchedule": "调整计划...", + "AdjustTime": "调整时间", + "Custom": "自定义...", + "Pause10Hours": "10 小时", + "Pause12Hours": "12 小时", + "Pause15Minutes": "15 分钟", + "Pause1Hour": "1 小时", + "Pause2Hours": "2 小时", + "Pause30Minutes": "30 分钟", + "Pause45Minutes": "45 分钟", + "Pause4Hours": "4 小时", + "Pause6Hours": "6 小时", + "Pause8Hours": "8 小时", + "PauseBySchedule": "按计划暂停通知...", + "PauseNotifications": "暂停通知", + "Paused": "通知已暂停", + "PausedUntil": "通知暂停至 {{date}}。", + "PauseUntilNextWeek": "至下周", + "PauseUntilTomorrow": "至明天", + "Resume": "恢复通知", + "Resumed": "通知已恢复", + "NotificationsNowResumed": "通知现已恢复。" + }, "Delete": "删除", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "无法找到之前还在该处的工作区知识库文件夹!本应存在于此处的知识库文件夹可能被移走了,或该文件夹内没有知识库!是否移除工作区?", diff --git a/localization/locales/zh-Hant/translation.json b/localization/locales/zh-Hant/translation.json index 6d420813..de20739b 100644 --- a/localization/locales/zh-Hant/translation.json +++ b/localization/locales/zh-Hant/translation.json @@ -105,6 +105,30 @@ "TidGiSupport": "TidGi 用戶支持", "TidGiWebsite": "TidGi 官網" }, + "Notification": { + "AdjustSchedule": "調整計畫...", + "AdjustTime": "調整時間", + "Custom": "自訂...", + "Pause10Hours": "10 小時", + "Pause12Hours": "12 小時", + "Pause15Minutes": "15 分鐘", + "Pause1Hour": "1 小時", + "Pause2Hours": "2 小時", + "Pause30Minutes": "30 分鐘", + "Pause45Minutes": "45 分鐘", + "Pause4Hours": "4 小時", + "Pause6Hours": "6 小時", + "Pause8Hours": "8 小時", + "PauseBySchedule": "按計畫暫停通知...", + "PauseNotifications": "暫停通知", + "Paused": "通知已暫停", + "PausedUntil": "通知暫停至 {{date}}。", + "PauseUntilNextWeek": "至下週", + "PauseUntilTomorrow": "至明天", + "Resume": "恢復通知", + "Resumed": "通知已恢復", + "NotificationsNowResumed": "通知現已恢復。" + }, "Delete": "刪除", "Dialog": { "CantFindWorkspaceFolderRemoveWorkspace": "無法找到之前還在該處的工作區知識庫文件夾!本應存在於此處的知識庫文件夾可能被移走了,或該文件夾內沒有知識庫!是否移除工作區?", diff --git a/package.json b/package.json index fd03fc05..e52c030a 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,12 @@ "test:unit:coverage": "pnpm run test:unit --coverage", "test:prepare-e2e": "cross-env READ_DOC_BEFORE_USING='docs/Testing.md' && pnpm run clean && pnpm run build:plugin && cross-env NODE_ENV=test DEBUG=electron-forge:* electron-forge package", "test:e2e": "rimraf -- ./userData-test ./wiki-test && cross-env NODE_ENV=test tsx scripts/developmentMkdir.ts && cross-env NODE_ENV=test cucumber-js --config features/cucumber.config.js", + "test:manual-e2e": "pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts", "make": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make", "make:analyze": "cross-env ANALYZE=true pnpm run make", "init:git-submodule": "git submodule update --init --recursive && git submodule update --remote", - "lint": "eslint ./src --ext js,ts,tsx,json", - "lint:fix": "eslint ./src --ext ts,tsx --fix", + "lint": "eslint ./src ./features ./scripts ./localization ./*.ts --ext js,ts,tsx,json", + "lint:fix": "eslint ./src ./features ./scripts ./localization ./*.ts --ext ts,tsx,json --fix", "check": "tsc --noEmit --skipLibCheck", "installType": "typesync" }, diff --git a/scripts/compilePlugins.mjs b/scripts/compilePlugins.mjs index 2dca7a74..d45aa1ae 100644 --- a/scripts/compilePlugins.mjs +++ b/scripts/compilePlugins.mjs @@ -1,14 +1,16 @@ +/* eslint-disable @typescript-eslint/use-unknown-in-catch-callback-variable */ +/* eslint-disable @typescript-eslint/restrict-template-expressions */ +/* eslint-disable no-undef */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable unicorn/prevent-abbreviations */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* 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'; +import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -23,15 +25,15 @@ const nativeNodeModulesPlugin = { // 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')" + "require('nsfw/build/Release/nsfw.node')", ); - + return { contents, loader: 'js', @@ -67,7 +69,7 @@ const PLUGINS = [ name: 'watch-filesystem-adaptor', sourceFolder: '../src/services/wiki/plugin/watchFileSystemAdaptor', entryPoints: [ - 'watch-filesystem-adaptor.ts', + 'loader.ts', ], }, ]; @@ -110,7 +112,7 @@ function getPluginOutputDirs(pluginName) { 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); @@ -142,7 +144,7 @@ async function buildEntryPoints(plugin, outDirs) { } const sourcePath = path.join(__dirname, plugin.sourceFolder); - + await Promise.all( outDirs.flatMap(outDir => plugin.entryPoints.map(entryPoint => @@ -152,7 +154,7 @@ async function buildEntryPoints(plugin, outDirs) { outdir: outDir, }) ) - ) + ), ); } @@ -161,7 +163,7 @@ async function buildEntryPoints(plugin, outDirs) { */ 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}`); @@ -173,19 +175,19 @@ async function copyNonTsFiles(plugin, outDirs) { */ 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}`); } @@ -194,11 +196,11 @@ async function buildPlugin(plugin) { */ async function main() { console.log('Starting plugin compilation...\n'); - + for (const plugin of PLUGINS) { await buildPlugin(plugin); } - + console.log('\n✓ All plugins compiled successfully!'); } diff --git a/scripts/developmentMkdir.ts b/scripts/developmentMkdir.ts index 788e6c29..1adcc7b0 100644 --- a/scripts/developmentMkdir.ts +++ b/scripts/developmentMkdir.ts @@ -3,5 +3,7 @@ import { DEFAULT_FIRST_WIKI_FOLDER_PATH } from '../src/constants/paths'; try { fs.removeSync(DEFAULT_FIRST_WIKI_FOLDER_PATH); -} catch {} +} catch { + // ignore +} fs.mkdirpSync(DEFAULT_FIRST_WIKI_FOLDER_PATH); diff --git a/scripts/start-e2e-app.ts b/scripts/start-e2e-app.ts index b4ed9b5f..f427007b 100644 --- a/scripts/start-e2e-app.ts +++ b/scripts/start-e2e-app.ts @@ -1,4 +1,5 @@ // pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts +/* eslint-disable unicorn/prevent-abbreviations */ import { spawn } from 'child_process'; import { getPackedAppPath } from '../features/supports/paths'; @@ -7,16 +8,16 @@ import { getPackedAppPath } from '../features/supports/paths'; const appPath = getPackedAppPath(); console.log('Starting TidGi E2E app:', appPath); -const env = Object.assign({}, process.env, { +const environment = Object.assign({}, process.env, { NODE_ENV: 'test', LANG: process.env.LANG || 'zh-Hans.UTF-8', LANGUAGE: process.env.LANGUAGE || 'zh-Hans:zh', LC_ALL: process.env.LC_ALL || 'zh-Hans.UTF-8', }); -const child = spawn(appPath, [], { env, stdio: 'inherit' }); +const child = spawn(appPath, [], { env: environment, stdio: 'inherit' }); child.on('exit', code => process.exit(code ?? 0)); -child.on('error', err => { - console.error('Failed to start TidGi app:', err); +child.on('error', error => { + console.error('Failed to start TidGi app:', error); process.exit(1); }); diff --git a/scripts/startMockOpenAI.ts b/scripts/startMockOpenAI.ts index cdb57af1..32ed4f2f 100644 --- a/scripts/startMockOpenAI.ts +++ b/scripts/startMockOpenAI.ts @@ -43,8 +43,10 @@ async function main() { }); // 防止进程退出 - 使用 setInterval 而不是空的 Promise - const keepAlive = setInterval(() => { + // 注意在 e2e 的 CleanUp 里关闭服务器 + setInterval(() => { // 每10秒输出一次状态,确认服务器还在运行 + console.log('Mock OpenAI 服务器仍在运行...'); }, 10000); } catch (error) { console.error('❌ 启动服务器失败:', error); diff --git a/src/components/TokenForm/index.tsx b/src/components/TokenForm/index.tsx index 2107ffbe..f466ac9c 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?: React.Dispatch>; + storageProviderSetter?: (value: SupportedStorageServices) => void; } /** * Create storage provider's token. @@ -58,12 +58,10 @@ interface Props { */ export function TokenForm({ storageProvider, storageProviderSetter }: Props): React.JSX.Element { const { t } = useTranslation(); - let [currentTab, currentTabSetter] = useState(SupportedStorageServices.github); + const [internalTab, internalTabSetter] = useState(SupportedStorageServices.github); // use external controls if provided - if (storageProvider !== undefined && typeof storageProviderSetter === 'function') { - currentTab = storageProvider; - currentTabSetter = storageProviderSetter; - } + const currentTab = storageProvider ?? internalTab; + const currentTabSetter = storageProviderSetter ?? internalTabSetter; // update storageProvider to be an online service, if this Component is opened useEffect(() => { if (storageProvider === SupportedStorageServices.local && typeof storageProviderSetter === 'function') { diff --git a/src/preload/fixer/consoleLogToLogFile.ts b/src/preload/fixer/consoleLogToLogFile.ts new file mode 100644 index 00000000..e0b35484 --- /dev/null +++ b/src/preload/fixer/consoleLogToLogFile.ts @@ -0,0 +1,97 @@ +import { webFrame } from 'electron'; + +export async function consoleLogToLogFile(workspaceName = 'error-no-workspace-name'): Promise { + await webFrame.executeJavaScript(` + (function() { + const workspaceName = ${JSON.stringify(workspaceName)}; + + // Save original console methods - need to bind them to console object + const originalConsole = { + log: console.log.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + info: console.info.bind(console), + debug: console.debug.bind(console) + }; + + // Helper to send logs to backend using logFor + const sendToBackend = (level, args) => { + try { + const message = args.map(arg => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + } + return String(arg); + }).join(' '); + + void window.service.native.logFor(workspaceName, level, message); + } catch (error) { + originalConsole.error('Failed to send log to backend:', error); + } + }; + + // Create wrapper functions that will be called even with Function.apply.call + const createWrapper = (level, originalFn) => { + return function(...args) { + // Call original function + originalFn(...args); + // Send to backend + sendToBackend(level, args); + }; + }; + + // Override console methods with wrappers + console.log = createWrapper('info', originalConsole.log); + console.warn = createWrapper('warn', originalConsole.warn); + console.error = createWrapper('error', originalConsole.error); + console.info = createWrapper('info', originalConsole.info); + console.debug = createWrapper('debug', originalConsole.debug); + + // Important: Preserve the Function.apply.call behavior that TiddlyWiki uses + // This ensures our wrapper is called even when using Function.apply.call(console.log, ...) + ['log', 'warn', 'error', 'info', 'debug'].forEach(method => { + const wrapper = console[method]; + // Make sure the wrapper has the same properties as native console methods + Object.defineProperty(wrapper, 'name', { value: method, configurable: true }); + Object.defineProperty(wrapper, 'length', { value: 0, configurable: true }); + }); + + // Ensure TiddlyWiki Logger uses our hooked console methods + // TiddlyWiki uses Function.apply.call(console.log, console, logMessage) + // Our console hook should already capture these calls, no need to duplicate logging + const ensureTiddlyWikiLoggerUsesHookedConsole = () => { + if (typeof $tw !== 'undefined' && $tw.utils && $tw.utils.Logger) { + // Verify that Logger will use our hooked console by checking if console.log is our wrapper + const isHooked = console.log.toString().includes('sendToBackend') || console.log.name !== 'log'; + if (isHooked) { + originalConsole.log('[CONSOLE_HOOK] TiddlyWiki Logger will use hooked console methods'); + } else { + originalConsole.warn('[CONSOLE_HOOK] Warning: console.log might not be properly hooked for TiddlyWiki'); + } + } + }; + + // Try to verify immediately if $tw is available + ensureTiddlyWikiLoggerUsesHookedConsole(); + + // Also watch for $tw to become available + if (typeof $tw === 'undefined') { + const checkInterval = setInterval(() => { + if (typeof $tw !== 'undefined' && $tw.utils && $tw.utils.Logger) { + ensureTiddlyWikiLoggerUsesHookedConsole(); + clearInterval(checkInterval); + } + }, 100); + + // Stop checking after 10 seconds + setTimeout(() => clearInterval(checkInterval), 10000); + } + + originalConsole.log('[CONSOLE_HOOK] Console logging to backend file enabled for workspace:', workspaceName); + })(); + `); +} diff --git a/src/preload/fixer/fixAlertConfirm.ts b/src/preload/fixer/fixAlertConfirm.ts index a6861acc..3aec4d76 100644 --- a/src/preload/fixer/fixAlertConfirm.ts +++ b/src/preload/fixer/fixAlertConfirm.ts @@ -16,5 +16,18 @@ export async function fixAlertConfirm(): Promise { // native window.confirm returns boolean return Boolean(window.remote.showElectronMessageBoxSync({ message, type: 'question', buttons: [$tw.language.getString('No'), $tw.language.getString('Yes')], defaultId: 1 })); }; + + // Hook TiddlyWiki Logger.prototype.alert to capture alert calls as well + if (typeof $tw !== 'undefined' && $tw.utils && $tw.utils.Logger) { + const OriginalLogger = $tw.utils.Logger; + const originalAlertMethod = OriginalLogger.prototype.alert; + + OriginalLogger.prototype.alert = function(...args) { + const result = originalAlertMethod.apply(this, args); + // Log alert calls to console so they get captured by console hook + console.warn('[TiddlyWiki Alert]', ...args); + return result; + }; + } `); } diff --git a/src/preload/view.ts b/src/preload/view.ts index 3fb9590b..e84949b5 100644 --- a/src/preload/view.ts +++ b/src/preload/view.ts @@ -3,6 +3,8 @@ import '../services/wiki/wikiOperations/executor/wikiOperationInBrowser'; import type { IPossibleWindowMeta, WindowMeta } from '@services/windows/WindowProperties'; import { WindowNames } from '@services/windows/WindowProperties'; import { browserViewMetaData, windowName } from './common/browserViewMetaData'; +import { native } from './common/services'; +import { consoleLogToLogFile } from './fixer/consoleLogToLogFile'; let handled = false; const handleLoaded = (event: string): void => { @@ -18,7 +20,13 @@ const handleLoaded = (event: string): void => { }; async function executeJavaScriptInBrowserView(): Promise { - const { workspaceID } = browserViewMetaData as IPossibleWindowMeta; + const viewMetaData = browserViewMetaData as IPossibleWindowMeta; + const workspaceName = viewMetaData.workspace?.name ?? 'unknown'; + await consoleLogToLogFile(workspaceName); + const workspaceID = viewMetaData.workspace?.id; + + // Log when view is fully loaded for E2E tests + void native.logFor(workspaceName, 'info', `[test-id-VIEW_LOADED] Browser view preload script executed and ready for workspace: ${workspaceName}`); try { await webFrame.executeJavaScript(` diff --git a/src/services/git/index.ts b/src/services/git/index.ts index 090c7614..cb8a03be 100644 --- a/src/services/git/index.ts +++ b/src/services/git/index.ts @@ -131,12 +131,15 @@ export class Git implements IGitService { private readonly getWorkerMessageObserver = (wikiFolderPath: string, resolve: () => void, reject: (error: Error) => void, workspaceID?: string): Observer => ({ next: (messageObject) => { if (messageObject.level === 'error') { + const errorMessage = (messageObject.error).message; // if workspace exists, show notification in workspace, else use dialog instead if (workspaceID === undefined) { - this.createFailedDialog((messageObject.error).message, wikiFolderPath); + this.createFailedDialog(errorMessage, wikiFolderPath); } else { - this.createFailedNotification((messageObject.error).message, workspaceID); + this.createFailedNotification(errorMessage, workspaceID); } + // Reject the promise on error to prevent service restart + reject(messageObject.error); return; } const { message, meta, level } = messageObject; @@ -211,7 +214,8 @@ export class Git implements IGitService { } catch (error: unknown) { const error_ = error as Error; this.createFailedNotification(error_.message, workspaceIDToShowNotification); - return true; + // Return false on sync failure - no successful changes were made + return false; } } diff --git a/src/services/sync/index.ts b/src/services/sync/index.ts index a1caa606..5e637b7c 100644 --- a/src/services/sync/index.ts +++ b/src/services/sync/index.ts @@ -65,7 +65,8 @@ export class Sync implements ISyncService { const hasChanges = await gitService.syncOrForcePull(workspace, syncOrForcePullConfigs); if (isSubWiki) { // after sync this sub wiki, reload its main workspace - if (hasChanges) { + // Skip restart if file system watch is enabled - the watcher will handle file changes automatically + if (hasChanges && !workspace.enableFileSystemWatch) { await workspaceViewService.restartWorkspaceViewService(idToUse); await viewService.reloadViewsWebContents(idToUse); } @@ -88,7 +89,8 @@ export class Sync implements ISyncService { }); const subHasChange = (await Promise.all(subHasChangesPromise)).some(Boolean); // any of main or sub has changes, reload main workspace - if (hasChanges || subHasChange) { + // Skip restart if file system watch is enabled - the watcher will handle file changes automatically + if ((hasChanges || subHasChange) && !workspace.enableFileSystemWatch) { await workspaceViewService.restartWorkspaceViewService(id); await viewService.reloadViewsWebContents(id); } @@ -129,6 +131,9 @@ export class Sync implements ISyncService { return; } const { syncOnInterval, backupOnInterval, id } = workspace; + // Clear existing interval first to avoid duplicates when settings are updated + this.stopIntervalSync(id); + if (syncOnInterval || backupOnInterval) { const syncDebounceInterval = await this.preferenceService.get('syncDebounceInterval'); this.wikiSyncIntervals[id] = setInterval(async () => { diff --git a/src/services/view/index.ts b/src/services/view/index.ts index e4b022db..e4e1bc28 100644 --- a/src/services/view/index.ts +++ b/src/services/view/index.ts @@ -257,7 +257,9 @@ export class View implements IViewService { const { spellcheck } = preferences; const sessionOfView = setupViewSession(workspace, preferences, () => this.preferenceService.getPreferences()); - const browserViewMetaData: IBrowserViewMetaData = { workspaceID: workspace.id }; + const browserViewMetaData: IBrowserViewMetaData = { + workspace, + }; return { devTools: true, spellcheck, diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index 203de559..d715a4fd 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -3,7 +3,7 @@ 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 { copy, exists, mkdir, mkdirs, pathExists, readdir, readFile } from 'fs-extra'; import { inject, injectable } from 'inversify'; import path from 'path'; import { Worker } from 'worker_threads'; @@ -430,31 +430,6 @@ export class Wiki implements IWikiService { logger.info(message, { handler: WikiChannel.createProgress }); }; - private readonly folderToContainSymlinks = 'subwiki'; - /** - * Link a sub wiki to a main wiki, this will create a shortcut folder from main wiki to sub wiki, so when saving files to that shortcut folder, you will actually save file to the sub wiki - * We place symbol-link (short-cuts) in the tiddlers/subwiki/ folder, and ignore this folder in the .gitignore, so this symlink won't be commit to the git, as it contains computer specific path. - * @param {string} mainWikiPath folderPath of a wiki as link's destination - * @param {string} folderName sub-wiki's folder name - * @param {string} newWikiPath sub-wiki's folder path - */ - public async linkWiki(mainWikiPath: string, folderName: string, subWikiPath: string): Promise { - const mainWikiTiddlersFolderSubWikisPath = path.join(mainWikiPath, TIDDLERS_PATH, this.folderToContainSymlinks); - const subwikiSymlinkPath = path.join(mainWikiTiddlersFolderSubWikisPath, folderName); - try { - try { - await remove(subwikiSymlinkPath); - } catch (_error: unknown) { - void _error; - } - await mkdirp(mainWikiTiddlersFolderSubWikisPath); - await createSymlink(subWikiPath, subwikiSymlinkPath, 'junction'); - this.logProgress(i18n.t('AddWorkspace.CreateLinkFromSubWikiToMainWikiSucceed')); - } catch (error: unknown) { - throw new Error(i18n.t('AddWorkspace.CreateLinkFromSubWikiToMainWikiFailed', { subWikiPath, mainWikiTiddlersFolderPath: subwikiSymlinkPath, error })); - } - } - private async createWiki(newFolderPath: string, folderName: string): Promise { this.logProgress(i18n.t('AddWorkspace.StartUsingTemplateToCreateWiki')); const newWikiPath = path.join(newFolderPath, folderName); @@ -487,7 +462,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))) { @@ -503,22 +478,15 @@ export class Wiki implements IWikiService { throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath })); } } - this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki')); - await this.linkWiki(mainWikiPath, folderName, newWikiPath); // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin - // No need to update $:/config/FileSystemPaths manually - + // No need to update $:/config/FileSystemPaths manually or create symlinks this.logProgress(i18n.t('AddWorkspace.SubWikiCreationCompleted')); } - public async removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink = false): Promise { - if (mainWikiToUnLink !== undefined) { - const subWikiName = path.basename(wikiPath); - await shell.trashItem(path.join(mainWikiToUnLink, TIDDLERS_PATH, this.folderToContainSymlinks, subWikiName)); - } - if (!onlyRemoveLink) { - await shell.trashItem(wikiPath); - } + public async removeWiki(wikiPath: string, _mainWikiToUnLink?: string, _onlyRemoveLink = false): Promise { + // Sub-wiki configuration is now handled by FileSystemAdaptor - no symlinks to manage + // Just remove the wiki folder itself + await shell.trashItem(wikiPath); } public async ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise { @@ -640,7 +608,7 @@ export class Wiki implements IWikiService { public async cloneSubWiki( parentFolderLocation: string, wikiFolderName: string, - mainWikiPath: string, + _mainWikiPath: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos, _tagName = '', @@ -660,10 +628,8 @@ export class Wiki implements IWikiService { } const gitService = container.get(serviceIdentifier.Git); await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo); - this.logProgress(i18n.t('AddWorkspace.StartLinkingSubWikiToMainWiki')); - await this.linkWiki(mainWikiPath, wikiFolderName, path.join(parentFolderLocation, wikiFolderName)); // Sub-wiki configuration is now handled by FileSystemAdaptor in watch-filesystem plugin - // No need to update $:/config/FileSystemPaths manually + // No need to update $:/config/FileSystemPaths manually or create symlinks } // wiki-startup.ts @@ -699,7 +665,11 @@ export class Wiki implements IWikiService { if (mainWorkspace === undefined) { throw new SubWikiSMainWikiNotExistError(name ?? id, mainWikiID); } - await this.restartWiki(mainWorkspace); + // Use restartWorkspaceViewService to restart wiki worker and reload frontend view + const workspaceViewService = container.get(serviceIdentifier.WorkspaceView); + await workspaceViewService.restartWorkspaceViewService(mainWikiID); + // Log that main wiki restart is complete after creating sub-wiki + logger.info('[test-id-MAIN_WIKI_RESTARTED_AFTER_SUBWIKI] Main wiki restarted after sub-wiki creation', { mainWikiID, subWikiID: id }); } } else { try { diff --git a/src/services/wiki/interface.ts b/src/services/wiki/interface.ts index c826dacd..ba8463a2 100644 --- a/src/services/wiki/interface.ts +++ b/src/services/wiki/interface.ts @@ -60,7 +60,6 @@ export interface IWikiService { * @param workspaceID You can get this from active workspace */ getWorker(workspaceID: string): WikiWorker | undefined; - linkWiki(mainWikiPath: string, folderName: string, subWikiPath: string): Promise; packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise; removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink?: boolean): Promise; restartWiki(workspace: IWorkspace): Promise; @@ -78,7 +77,7 @@ export interface IWikiService { * Runs wiki related JS script in wiki page to control the wiki. * * Some data may not be available in browser, for example, getTiddlerText will return `null` for the first time, and trigger lazy loading, and return text on second call. In such case, you may want to use `wikiOperationInServer` instead. - * @example `await window.service.wiki.wikiOperationInBrowser('wiki-get-tiddler-text', window.meta().workspaceID, ['TiddlyWikiIconBlack.png'])` + * @example `await window.service.wiki.wikiOperationInBrowser('wiki-get-tiddler-text', window.meta().workspace.id, ['TiddlyWikiIconBlack.png'])` */ wikiOperationInBrowser( operationType: OP, @@ -110,7 +109,6 @@ export const WikiServiceIPCDescriptor = { ensureWikiExist: ProxyPropertyType.Function, extractWikiHTML: ProxyPropertyType.Function, getWikiErrorLogs: ProxyPropertyType.Function, - linkWiki: ProxyPropertyType.Function, getTiddlerFilePath: ProxyPropertyType.Function, packetHTMLFromWikiFolder: ProxyPropertyType.Function, removeWiki: ProxyPropertyType.Function, diff --git a/src/services/wiki/plugin/ipcSyncAdaptor/fix-location-info.ts b/src/services/wiki/plugin/ipcSyncAdaptor/fix-location-info.ts index 07f60d32..89617a0c 100644 --- a/src/services/wiki/plugin/ipcSyncAdaptor/fix-location-info.ts +++ b/src/services/wiki/plugin/ipcSyncAdaptor/fix-location-info.ts @@ -11,7 +11,8 @@ function getInfoTiddlerFields(updateInfoTiddlersCallback: (infos: Array<{ text: // Basics if (!$tw.browser || typeof window === 'undefined') return infoTiddlerFields; const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi'); - const workspaceID = (window.meta() as WindowMeta[WindowNames.view] | undefined)?.workspaceID; + const workspace = (window.meta() as WindowMeta[WindowNames.view] | undefined)?.workspace; + const workspaceID = workspace?.id; infoTiddlerFields.push({ title: '$:/info/tidgi', text: mapBoolean(isInTidGi) }); if (isInTidGi && workspaceID) { infoTiddlerFields.push({ title: '$:/info/tidgi/workspaceID', text: workspaceID }); diff --git a/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts b/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts index 6805155d..e5ad4a9f 100644 --- a/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts +++ b/src/services/wiki/plugin/ipcSyncAdaptor/ipc-syncadaptor.ts @@ -39,7 +39,11 @@ class TidGiIPCSyncAdaptor { this.isLoggedIn = false; this.isReadOnly = false; this.logoutIsAvailable = true; - this.workspaceID = (window.meta() as WindowMeta[WindowNames.view]).workspaceID!; + const workspaceID = (window.meta() as WindowMeta[WindowNames.view]).workspace?.id; + if (workspaceID === undefined) { + throw new Error('TidGiIPCSyncAdaptor: workspaceID is undefined. Cannot initialize sync adaptor without a valid workspace ID.'); + } + this.workspaceID = workspaceID; if (window.observables?.wiki?.getWikiChangeObserver$ !== undefined) { // if install-electron-ipc-cat is faster than us, just subscribe to the observable. Otherwise we normally will wait for it to call us here. this.setupSSE(); @@ -50,6 +54,7 @@ class TidGiIPCSyncAdaptor { * This should be called after install-electron-ipc-cat, so this is called in `$:/plugins/linonetwo/tidgi-ipc-syncadaptor/Startup/install-electron-ipc-cat.js` */ setupSSE() { + console.log('setupSSE called in TidGiIPCSyncAdaptor'); if (window.observables?.wiki?.getWikiChangeObserver$ === undefined) { console.error("getWikiChangeObserver$ is undefined in window.observables.wiki, can't subscribe to server changes."); return; @@ -73,9 +78,12 @@ class TidGiIPCSyncAdaptor { if (!change[title]) { return; } - if (change[title].deleted && !this.recentUpdatedTiddlersFromClient.deletions.includes(title)) { + + if (change[title].deleted) { + // For deletions, we don't need to check modified time this.updatedTiddlers.deletions.push(title); - } else if (change[title].modified && !this.recentUpdatedTiddlersFromClient.modifications.includes(title)) { + } else if (change[title].modified) { + // Add to modifications - watch-fs already filtered out echoes via file exclusion this.updatedTiddlers.modifications.push(title); } }); @@ -89,28 +97,6 @@ class TidGiIPCSyncAdaptor { deletions: [], }; - /** - * We will get echo from the server, for these tiddler changes caused by the client, we remove them from the `updatedTiddlers` so that the client won't get them again from server, which will usually get outdated tiddler (lack 1 or 2 words that user just typed). - */ - recentUpdatedTiddlersFromClient: { deletions: string[]; modifications: string[] } = { - modifications: [], - deletions: [], - }; - - /** - * Add a title as lock to prevent sse echo back. This will auto clear the lock after 2s (this number still needs testing). - * And it only clear one title after 2s, so if you add the same title rapidly, it will prevent sse echo after 2s of last operation, which can prevent last echo, which is what we want. - */ - addRecentUpdatedTiddlersFromClient(type: 'modifications' | 'deletions', title: string) { - this.recentUpdatedTiddlersFromClient[type].push(title); - setTimeout(() => { - const index = this.recentUpdatedTiddlersFromClient[type].indexOf(title); - if (index !== -1) { - this.recentUpdatedTiddlersFromClient[type].splice(index, 1); - } - }, 2000); - } - clearUpdatedTiddlers() { this.updatedTiddlers = { modifications: [], @@ -225,7 +211,6 @@ class TidGiIPCSyncAdaptor { return; } this.logger.log(`saveTiddler ${title}`); - this.addRecentUpdatedTiddlersFromClient('modifications', title); const putTiddlerResponse = await this.wikiService.callWikiIpcServerRoute( this.workspaceID, 'putTiddler', @@ -285,7 +270,7 @@ class TidGiIPCSyncAdaptor { return; } this.logger.log('deleteTiddler'); - this.addRecentUpdatedTiddlersFromClient('deletions', title); + // For deletions, we don't track modified time since the tiddler is being removed const getTiddlerResponse = await this.wikiService.callWikiIpcServerRoute( this.workspaceID, 'deleteTiddler', @@ -334,7 +319,7 @@ class TidGiIPCSyncAdaptor { if ($tw.browser && typeof window !== 'undefined') { const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi'); const servicesExposed = Boolean(window.service.wiki); - const hasWorkspaceIDinMeta = Boolean((window.meta() as WindowMeta[WindowNames.view] | undefined)?.workspaceID); + const hasWorkspaceIDinMeta = Boolean((window.meta() as WindowMeta[WindowNames.view] | undefined)?.workspace?.id); if (isInTidGi && servicesExposed && hasWorkspaceIDinMeta) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access exports.adaptorClass = TidGiIPCSyncAdaptor; diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts index 0dc3f627..eda9230c 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts @@ -2,6 +2,7 @@ import type { Logger } from '$:/core/modules/utils/logger.js'; import { workspace } from '@services/wiki/wikiWorker/services'; import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface'; import { backOff } from 'exponential-backoff'; +import fs from 'fs'; import path from 'path'; import type { FileInfo } from 'tiddlywiki'; import type { Tiddler, Wiki } from 'tiddlywiki'; @@ -116,6 +117,7 @@ export class FileSystemAdaptor { /** * Main routing logic: determine where a tiddler should be saved based on its tags. + * For draft tiddlers, check the original tiddler's tags. */ async getTiddlerFileInfo(tiddler: Tiddler): Promise { if (!this.boot.wikiTiddlersPath) { @@ -123,10 +125,23 @@ export class FileSystemAdaptor { } const title = tiddler.fields.title; - const tags = tiddler.fields.tags ?? []; + let tags = tiddler.fields.tags ?? []; const fileInfo = this.boot.files[title]; try { + // For draft tiddlers (draft.of field), also check the original tiddler's tags + // This ensures drafts are saved to the same sub-wiki as their target tiddler + const draftOf = tiddler.fields['draft.of']; + if (draftOf && typeof draftOf === 'string' && $tw.wiki) { + // Get the original tiddler from the wiki + const originalTiddler = $tw.wiki.getTiddler(draftOf); + if (originalTiddler) { + const originalTags = originalTiddler.fields.tags ?? []; + // Merge tags from the original tiddler with the draft's tags + tags = [...new Set([...tags, ...originalTags])]; + } + } + let matchingSubWiki: IWikiWorkspace | undefined; for (const tag of tags) { matchingSubWiki = this.tagNameToSubWiki.get(tag); @@ -148,9 +163,22 @@ export class FileSystemAdaptor { /** * Generate file info for sub-wiki directory + * Handles symlinks correctly across platforms (Windows junctions and Linux symlinks) */ protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: FileInfo | undefined): FileInfo { - const targetDirectory = subWiki.wikiFolderLocation; + let targetDirectory = subWiki.wikiFolderLocation; + + // Resolve symlinks to ensure consistent path handling across platforms + // On Windows, this resolves junctions; on Linux, this resolves symbolic links + // This prevents path inconsistencies when the same symlinked directory is referenced differently + // (e.g., via the symlink path vs the real path) + try { + targetDirectory = fs.realpathSync(targetDirectory); + } catch { + // If realpath fails, use the original path + // This can happen if the directory doesn't exist yet + } + $tw.utils.createDirectory(targetDirectory); return $tw.utils.generateTiddlerFileInfo(tiddler, { @@ -186,7 +214,7 @@ export class FileSystemAdaptor { * 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 { + async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, _options?: { tiddlerInfo?: Record }): Promise { try { const fileInfo = await this.getTiddlerFileInfo(tiddler); @@ -198,6 +226,9 @@ export class FileSystemAdaptor { const savedFileInfo = await this.saveTiddlerWithRetry(tiddler, fileInfo); + // Save old file info before updating, for cleanup to detect file path changes + const oldFileInfo = this.boot.files[tiddler.fields.title]; + this.boot.files[tiddler.fields.title] = { ...savedFileInfo, isEditableFile: savedFileInfo.isEditableFile ?? true, @@ -205,8 +236,8 @@ export class FileSystemAdaptor { await new Promise((resolve, reject) => { const cleanupOptions = { - adaptorInfo: options?.tiddlerInfo as FileInfo | undefined, - bootInfo: this.boot.files[tiddler.fields.title], + adaptorInfo: oldFileInfo, // Old file info to be deleted + bootInfo: savedFileInfo, // New file info to be kept title: tiddler.fields.title, }; $tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: FileInfo) => { diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts index 76debd54..7e7f9e68 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/InverseFilesIndex.ts @@ -1,7 +1,15 @@ +import type nsfw from 'nsfw'; +import path from 'path'; import type { FileInfo } from 'tiddlywiki'; export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string }; +export interface ISubWikiInfo { + id: string; + path: string; + watcher: nsfw.NSFW; +} + /** * Inverse index for mapping file paths to tiddler information. * Uses Map for better performance with frequent add/delete operations. @@ -11,6 +19,80 @@ export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string }; */ export class InverseFilesIndex { private index: Map = new Map(); + /** Base path for main wiki (e.g., .../wiki/tiddlers) */ + private mainWikiPath: string = ''; + /** Map of sub-wiki ID to sub-wiki information */ + private subWikiMap: Map = new Map(); + /** Temporarily excluded files for main watcher (by absolute path) */ + private mainExcludedFiles: Set = new Set(); + /** Temporarily excluded files for each sub-wiki watcher (by absolute path) */ + private subWikiExcludedFiles: Map> = new Map(); + + /** + * Set the main wiki path + */ + setMainWikiPath(path: string): void { + this.mainWikiPath = path; + } + + /** + * Register a sub-wiki watcher + */ + registerSubWiki(id: string, subWikiPath: string, watcher: nsfw.NSFW): void { + this.subWikiMap.set(id, { id, path: subWikiPath, watcher }); + this.subWikiExcludedFiles.set(id, new Set()); + } + + /** + * Unregister a sub-wiki watcher + */ + unregisterSubWiki(id: string): void { + this.subWikiMap.delete(id); + this.subWikiExcludedFiles.delete(id); + } + + /** + * Get all sub-wiki information + */ + getSubWikis(): ISubWikiInfo[] { + return Array.from(this.subWikiMap.values()); + } + + /** + * Check if an absolute file path belongs to a sub-wiki + * @returns Sub-wiki info if file is in a sub-wiki, undefined otherwise + */ + getSubWikiForFile(absoluteFilePath: string): ISubWikiInfo | undefined { + for (const subWiki of this.subWikiMap.values()) { + if (absoluteFilePath.startsWith(subWiki.path)) { + return subWiki; + } + } + return undefined; + } + + /** + * Calculate relative path for a file based on which wiki it belongs to + * @param absoluteFilePath Absolute file path + * @returns Object containing the relative path and which watcher it belongs to + */ + getRelativePathInfo(absoluteFilePath: string): { relativePath: string; watcherType: 'main' | 'sub'; subWikiId?: string } { + // Check if file belongs to a sub-wiki first + const subWiki = this.getSubWikiForFile(absoluteFilePath); + if (subWiki) { + return { + relativePath: path.relative(subWiki.path, absoluteFilePath), + watcherType: 'sub', + subWikiId: subWiki.id, + }; + } + + // Otherwise, it belongs to main wiki + return { + relativePath: path.relative(this.mainWikiPath, absoluteFilePath), + watcherType: 'main', + }; + } /** * Set or update tiddler information for a file path @@ -87,4 +169,62 @@ export class InverseFilesIndex { values(): IterableIterator { return this.index.values(); } + + /** + * Add a file to the exclusion list for the appropriate watcher + * @param absoluteFilePath Absolute file path to exclude + */ + excludeFile(absoluteFilePath: string): void { + const subWiki = this.getSubWikiForFile(absoluteFilePath); + if (subWiki) { + // File belongs to sub-wiki + const excluded = this.subWikiExcludedFiles.get(subWiki.id); + if (excluded) { + excluded.add(absoluteFilePath); + } + } else { + // File belongs to main wiki + this.mainExcludedFiles.add(absoluteFilePath); + } + } + + /** + * Remove a file from the exclusion list + * @param absoluteFilePath Absolute file path to include + */ + includeFile(absoluteFilePath: string): void { + const subWiki = this.getSubWikiForFile(absoluteFilePath); + if (subWiki) { + // File belongs to sub-wiki + const excluded = this.subWikiExcludedFiles.get(subWiki.id); + if (excluded) { + excluded.delete(absoluteFilePath); + } + } else { + // File belongs to main wiki + this.mainExcludedFiles.delete(absoluteFilePath); + } + } + + /** + * Get excluded paths for the main watcher (relative to main wiki path) + * @param baseExcludedPaths Permanent excluded paths + * @returns Array of paths to exclude + */ + getMainWatcherExcludedPaths(baseExcludedPaths: string[]): string[] { + return [ + ...baseExcludedPaths, + ...Array.from(this.mainExcludedFiles), + ]; + } + + /** + * Get excluded paths for a sub-wiki watcher (absolute paths) + * @param subWikiId Sub-wiki ID + * @returns Array of absolute paths to exclude + */ + getSubWikiExcludedPaths(subWikiId: string): string[] { + const excluded = this.subWikiExcludedFiles.get(subWikiId); + return excluded ? Array.from(excluded) : []; + } } diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts similarity index 67% rename from src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts rename to src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts index 75ef62ca..67f809e3 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { workspace } from '@services/wiki/wikiWorker/services'; import fs from 'fs'; import nsfw from 'nsfw'; @@ -40,21 +39,29 @@ const FILE_EXCLUSION_CLEANUP_DELAY_MS = 200; * 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 { +export class WatchFileSystemAdaptor extends FileSystemAdaptor { name = 'watch-filesystem'; - /** Inverse index: filepath -> tiddler info for fast lookup */ + /** Inverse index: filepath -> tiddler info for fast lookup, also manages sub-wiki info */ private inverseFilesIndex: InverseFilesIndex = new InverseFilesIndex(); - /** NSFW watcher instance */ + /** NSFW watcher instance for main wiki */ private watcher: nsfw.NSFW | undefined; /** Base excluded paths (permanent) */ private baseExcludedPaths: string[] = []; - /** Temporarily excluded files being modified by wiki */ - private temporarilyExcludedFiles: Set = new Set(); + /** + * Track timers for file inclusion to prevent race conditions. + * When saving the same file multiple times rapidly, we need to ensure + * only the last save's timer runs. This Map tracks one timer per file path. + * The timer is managed by scheduleFileInclusion() method. + */ + private inclusionTimers: Map = new Map(); constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) { super(options); this.logger = new $tw.utils.Logger('watch-filesystem', { colour: 'purple' }); + // Initialize main wiki path in index + this.inverseFilesIndex.setMainWikiPath(this.watchPathBase); + // Initialize file watching void this.initializeFileWatching(); } @@ -64,10 +71,8 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { * 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 + // Get file info to calculate path for watching const fileInfo = await this.getTiddlerFileInfo(tiddler); if (!fileInfo) { const error = new Error('No fileInfo returned from getTiddlerFileInfo'); @@ -75,16 +80,28 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { throw error; } - fileRelativePath = path.relative(this.watchPathBase, fileInfo.filepath); + // Get old file info before save (in case file path changes) + const oldFileInfo = this.boot.files[tiddler.fields.title]; - // Exclude file from watching during save - await this.excludeFile(fileRelativePath); + // Log tiddler text for debugging + const textPreview = (tiddler.fields.text ?? '').substring(0, 50); + this.logger.log(`[WATCH_FS_SAVE] Saving "${tiddler.fields.title}", text: ${textPreview}`); - // Call parent's saveTiddler to handle the actual save + // Exclude new file from watching during save + await this.excludeFile(fileInfo.filepath); + + // If file path is changing (e.g., moving to sub-wiki), also exclude the old file + if (oldFileInfo && oldFileInfo.filepath !== fileInfo.filepath) { + this.logger.log(`[WATCH_FS_MOVE] File path changing from ${oldFileInfo.filepath} to ${fileInfo.filepath}`); + await this.excludeFile(oldFileInfo.filepath); + } + + // Call parent's saveTiddler to handle the actual save (including cleanup of old files) await super.saveTiddler(tiddler, undefined, options); // Update inverse index after successful save const finalFileInfo = this.boot.files[tiddler.fields.title]; + const fileRelativePath = path.relative(this.watchPathBase, finalFileInfo.filepath); this.inverseFilesIndex.set(fileRelativePath, { ...finalFileInfo, filepath: fileRelativePath, @@ -94,20 +111,16 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { // 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); + // Schedule file re-inclusion after save AND cleanup complete + // This ensures we don't detect our own file operations + this.scheduleFileInclusion(finalFileInfo.filepath); + + // If old file path was different and we excluded it, schedule its re-inclusion + // The old file should be deleted by now via cleanupTiddlerFiles + if (oldFileInfo && oldFileInfo.filepath !== finalFileInfo.filepath) { + this.scheduleFileInclusion(oldFileInfo.filepath); } + } catch (error) { const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); callback?.(errorObject); throw errorObject; @@ -142,15 +155,11 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { // 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); + // Schedule file re-inclusion after deletion completes + this.scheduleFileInclusion(fileRelativePath); } catch (error) { - // Re-include the file on error - setTimeout(() => { - void this.includeFile(fileRelativePath); - }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + // Schedule file re-inclusion on error to clean up exclusion list + this.scheduleFileInclusion(fileRelativePath); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); callback?.(errorObject); throw errorObject; @@ -210,10 +219,8 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { // 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); - + // Initialize sub-wiki watchers + await this.initializeSubWikiWatchers(); // Log stabilization marker for tests this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized'); } catch (error) { @@ -221,6 +228,63 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { } } + /** + * Initialize watchers for sub-wikis + */ + private async initializeSubWikiWatchers(): Promise { + if (!this.workspaceID) { + return; + } + + try { + // Get sub-wikis for this main wiki + const subWikis = await workspace.getSubWorkspacesAsList(this.workspaceID); + + this.logger.log(`[WATCH_FS_SUBWIKI] Found ${subWikis.length} sub-wikis to watch`); + + // Create watcher for each sub-wiki + for (const subWiki of subWikis) { + // Only watch wiki workspaces + if (!('wikiFolderLocation' in subWiki) || !subWiki.wikiFolderLocation) { + continue; + } + + // Sub-wikis are folders directly, not wiki/tiddlers structure + const subWikiPath = subWiki.wikiFolderLocation; + + // Check if the path exists before trying to watch + if (!fs.existsSync(subWikiPath)) { + this.logger.log(`[WATCH_FS_SUBWIKI] Path does not exist for sub-wiki ${subWiki.name}: ${subWikiPath}`); + continue; + } + + try { + const subWikiWatcher = await nsfw( + subWikiPath, + (events) => { + this.handleNsfwEvents(events); + }, + { + debounceMS: 100, + errorCallback: (error) => { + this.logger.alert(`[WATCH_FS_ERROR] NSFW error for sub-wiki ${subWiki.name}:`, error); + }, + }, + ); + + await subWikiWatcher.start(); + this.inverseFilesIndex.registerSubWiki(subWiki.id, subWikiPath, subWikiWatcher); + + this.logger.log(`[WATCH_FS_SUBWIKI] Watching sub-wiki: ${subWiki.name} at ${subWikiPath}`); + } catch (error) { + this.logger.alert(`[WATCH_FS_ERROR] Failed to watch sub-wiki ${subWiki.name}:`, error); + } + } + } catch (error) { + this.logger.alert('[WATCH_FS_ERROR] Failed to initialize sub-wiki watchers:', error); + } + } + /** * Initialize the inverse files index from boot.files */ @@ -240,36 +304,62 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { * Update watcher's excluded paths with current temporary exclusions */ private async updateWatcherExcludedPaths(): Promise { - if (!this.watcher) { - return; + // Update main watcher + if (this.watcher) { + const allExcludedPaths = this.inverseFilesIndex.getMainWatcherExcludedPaths(this.baseExcludedPaths); + // @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string] + await this.watcher.updateExcludedPaths(allExcludedPaths); } - // 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); + // Update each sub-wiki watcher + for (const subWiki of this.inverseFilesIndex.getSubWikis()) { + const excludedPaths = this.inverseFilesIndex.getSubWikiExcludedPaths(subWiki.id); + // @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string] + await subWiki.watcher.updateExcludedPaths(excludedPaths); + } } /** * Temporarily exclude a file from watching (e.g., during save/delete) + * @param absoluteFilePath Absolute file path */ - private async excludeFile(fileRelativePath: string): Promise { - this.temporarilyExcludedFiles.add(fileRelativePath); + private async excludeFile(absoluteFilePath: string): Promise { + this.logger.log(`[WATCH_FS_EXCLUDE] Excluding file: ${absoluteFilePath}`); + this.inverseFilesIndex.excludeFile(absoluteFilePath); await this.updateWatcherExcludedPaths(); } /** * Remove a file from temporary exclusions + * @param absoluteFilePath Absolute file path */ - private async includeFile(fileRelativePath: string): Promise { - this.temporarilyExcludedFiles.delete(fileRelativePath); + private async includeFile(absoluteFilePath: string): Promise { + this.logger.log(`[WATCH_FS_INCLUDE] Including file: ${absoluteFilePath}`); + this.inverseFilesIndex.includeFile(absoluteFilePath); await this.updateWatcherExcludedPaths(); } + /** + * Schedule file inclusion after a delay, clearing any existing timer for the same file. + * This prevents race conditions when saving the same file multiple times rapidly. + * @param filepath File path to schedule for inclusion + */ + private scheduleFileInclusion(filepath: string): void { + // Clear any existing timer for this file to prevent premature inclusion + const existingTimer = this.inclusionTimers.get(filepath); + if (existingTimer) { + clearTimeout(existingTimer); + } + + // Schedule new timer + const timer = setTimeout(() => { + void this.includeFile(filepath); + this.inclusionTimers.delete(filepath); + }, FILE_EXCLUSION_CLEANUP_DELAY_MS); + + this.inclusionTimers.set(filepath, timer); + } + /** * Handle NSFW file system change events */ @@ -285,20 +375,24 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { fileName = event.newFile; } - // Compute relative and absolute paths + // Compute absolute path const fileAbsolutePath = path.join(directory, fileName); - const fileRelativePath = path.relative(this.watchPathBase, fileAbsolutePath); + + // Determine which wiki this file belongs to and compute relative path accordingly + const subWikiInfo = this.inverseFilesIndex.getSubWikiForFile(fileAbsolutePath); + const basePath = subWikiInfo ? subWikiInfo.path : this.watchPathBase; + const fileRelativePath = path.relative(basePath, fileAbsolutePath); const fileNameBase = path.parse(fileAbsolutePath).name; const fileExtension = path.extname(fileRelativePath); const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki'; const metaFileAbsolutePath = `${fileAbsolutePath}.meta`; - this.logger.log('[WATCH_FS_EVENT]', getActionName(action), fileName); + this.logger.log('[WATCH_FS_EVENT]', getActionName(action), fileName, `(directory: ${directory})`); // Handle different event types if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) { - this.handleFileAddOrChange( + void this.handleFileAddOrChange( fileAbsolutePath, fileRelativePath, metaFileAbsolutePath, @@ -315,20 +409,24 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { // 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 oldSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(oldFileAbsPath); + const oldBasePath = oldSubWikiInfo ? oldSubWikiInfo.path : this.watchPathBase; + const oldFileRelativePath = path.relative(oldBasePath, 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 newSubWikiInfo = this.inverseFilesIndex.getSubWikiForFile(newFileAbsPath); + const newBasePath = newSubWikiInfo ? newSubWikiInfo.path : this.watchPathBase; + const newFileRelativePath = path.relative(newBasePath, newFileAbsPath); const newFileName = event.newFile; const newFileNameBase = path.parse(newFileAbsPath).name; const newFileExtension = path.extname(newFileRelativePath); const newFileMimeType = $tw.utils.getFileExtensionInfo(newFileExtension)?.type ?? 'text/vnd.tiddlywiki'; const newMetaFileAbsPath = `${newFileAbsPath}.meta`; - this.handleFileAddOrChange( + void this.handleFileAddOrChange( newFileAbsPath, newFileRelativePath, newMetaFileAbsPath, @@ -346,7 +444,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { /** * Handle file add or change events */ - private handleFileAddOrChange( + private async handleFileAddOrChange( fileAbsolutePath: string, fileRelativePath: string, metaFileAbsolutePath: string, @@ -355,7 +453,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { fileExtension: string, fileMimeType: string, changeType: 'add' | 'change', - ): void { + ): Promise { // For .meta files, we need to load the corresponding base file let actualFileToLoad = fileAbsolutePath; let actualFileRelativePath = fileRelativePath; @@ -391,13 +489,13 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { const { tiddlers, ...fileDescriptor } = tiddlersDescriptor; // Process each tiddler from the file - tiddlers.forEach((tiddler) => { + for (const tiddler of tiddlers) { // Note: $tw.loadTiddlersFromFile returns tiddlers as plain objects with fields at top level, // not wrapped in a .fields property const tiddlerTitle = tiddler?.title; if (!tiddlerTitle) { - this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title`); - return; + this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title. Tiddler object: ${JSON.stringify(tiddler)}`); + continue; } const isNewFile = !this.inverseFilesIndex.has(actualFileRelativePath); @@ -410,7 +508,6 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { } as IBootFilesIndexItemWithTitle); // Add tiddler to wiki (this will update if it exists or add if new) - $tw.syncadaptor!.wiki.addTiddler(tiddler); // Log appropriate event @@ -419,7 +516,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { } else { this.logger.log(`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`); } - }); + } } /** @@ -488,10 +585,12 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor { this.watcher = undefined; this.logger.log('[WATCH_FS_CLEANUP] Filesystem watcher closed'); } + + // Close all sub-wiki watchers + for (const subWiki of this.inverseFilesIndex.getSubWikis()) { + this.logger.log(`[WATCH_FS_CLEANUP] Closing sub-wiki watcher: ${subWiki.id}`); + await subWiki.watcher.stop(); + this.inverseFilesIndex.unregisterSubWiki(subWiki.id); + } } } - -// Only export in Node.js environment -if ($tw.node) { - exports.adaptorClass = WatchFileSystemAdaptor; -} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/loader.js.meta b/src/services/wiki/plugin/watchFileSystemAdaptor/loader.js.meta new file mode 100644 index 00000000..ebcf5018 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/loader.js.meta @@ -0,0 +1,3 @@ +title: $:/plugins/linonetwo/watch-filesystem-adaptor/loader.js +type: application/javascript +module-type: syncadaptor diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/loader.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/loader.ts new file mode 100644 index 00000000..9a6cbf66 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/loader.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +// Only export in Node.js environment +if ($tw.node) { + const WatchFileSystemAdaptor = require('./WatchFileSystemAdaptor').WatchFileSystemAdaptor; + exports.adaptorClass = WatchFileSystemAdaptor; +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta b/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta deleted file mode 100644 index aa036751..00000000 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.js.meta +++ /dev/null @@ -1,3 +0,0 @@ -title: $:/plugins/linonetwo/watch-filesystem-adaptor/watch-filesystem-adaptor.js -type: application/javascript -module-type: syncadaptor diff --git a/src/services/wiki/wikiWorker/ipcServerRoutes.ts b/src/services/wiki/wikiWorker/ipcServerRoutes.ts index c18dee71..f6e18264 100644 --- a/src/services/wiki/wikiWorker/ipcServerRoutes.ts +++ b/src/services/wiki/wikiWorker/ipcServerRoutes.ts @@ -27,6 +27,8 @@ export class IpcServerRoutes { private wikiInstance!: ITiddlyWiki; private readonly pendingIpcServerRoutesRequests: Array<(value: void | PromiseLike) => void> = []; #readonlyMode = false; + /** Track tiddlers that were just saved via IPC to prevent echo */ + private readonly recentlySavedTiddlers = new Set(); setConfig({ readOnlyMode }: { readOnlyMode?: boolean }) { this.#readonlyMode = Boolean(readOnlyMode); @@ -183,7 +185,16 @@ export class IpcServerRoutes { } } tiddlerFieldsToPut.title = title; + + // Mark this tiddler as recently saved to prevent echo + this.recentlySavedTiddlers.add(title); + this.wikiInstance.wiki.addTiddler(new this.wikiInstance.Tiddler(tiddlerFieldsToPut)); + + // Note: The change event is triggered synchronously by addTiddler + // The event handler in getWikiChangeObserver$ will check recentlySavedTiddlers + // and remove the mark after filtering + const changeCount = this.wikiInstance.wiki.getChangeCount(title).toString(); return { statusCode: 204, headers: { 'Content-Type': 'text/plain', Etag: `"default/${encodeURIComponent(title)}/${changeCount}:"` }, data: 'OK' }; } @@ -225,9 +236,29 @@ export class IpcServerRoutes { observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`)); } this.wikiInstance.wiki.addEventListener('change', (changes) => { - observer.next(changes); + // Filter out tiddlers that were just saved via IPC to prevent echo + const filteredChanges: IChangedTiddlers = {}; + let hasChanges = false; + + for (const title in changes) { + if (this.recentlySavedTiddlers.has(title)) { + // This change was caused by our own putTiddler, skip it to prevent echo + this.recentlySavedTiddlers.delete(title); + continue; + } + filteredChanges[title] = changes[title]; + hasChanges = true; + } + + // Only notify if there are actual changes after filtering + if (hasChanges) { + observer.next(filteredChanges); + } }); - console.log('[test-id-SSE_READY] Wiki change observer registered and ready'); + // Log SSE ready every time a new observer subscribes (including after worker restart) + // Include timestamp to make each log entry unique for test detection + const timestamp = new Date().toISOString(); + console.log(`[test-id-SSE_READY] Wiki change observer registered and ready at ${timestamp}`); }; void getWikiChangeObserverInWorkerIIFE(); }); diff --git a/src/services/windows/WindowProperties.ts b/src/services/windows/WindowProperties.ts index c454957a..cc432550 100644 --- a/src/services/windows/WindowProperties.ts +++ b/src/services/windows/WindowProperties.ts @@ -1,5 +1,6 @@ import type { CreateWorkspaceTabs } from '@/windows/AddWorkspace/constants'; import type { PreferenceSections } from '@services/preferences/interface'; +import { IWorkspace } from '@services/workspaces/interface'; export enum WindowNames { about = 'about', @@ -105,7 +106,7 @@ export interface WindowMeta { [WindowNames.preferences]: IPreferenceWindowMeta; [WindowNames.spellcheck]: undefined; [WindowNames.secondary]: undefined; - [WindowNames.view]: { workspaceID?: string }; + [WindowNames.view]: IBrowserViewMetaData; } export type IPossibleWindowMeta = { windowName: WindowNames; @@ -116,5 +117,5 @@ export type IPossibleWindowMeta { + storageProviderSetter={(nextStorageService) => { workspaceSetter({ ...workspace, storageService: nextStorageService }); - // requestRestartCountDown(); }} /> )} diff --git a/src/windows/Notifications/index.tsx b/src/windows/Notifications/index.tsx index 5be21c8a..a1616458 100644 --- a/src/windows/Notifications/index.tsx +++ b/src/windows/Notifications/index.tsx @@ -15,14 +15,14 @@ import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; -import { WindowNames } from '@services/windows/WindowProperties'; - import { ListItemButton } from '@mui/material'; +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; + import { formatDate } from '@services/libs/formatDate'; import { useNotificationInfoObservable } from '@services/notifications/hooks'; import { usePreferenceObservable } from '@services/preferences/hooks'; import { PreferenceSections } from '@services/preferences/interface'; +import { WindowNames } from '@services/windows/WindowProperties'; import nightBackgroundPng from '../../images/night-background.png'; import { quickShortcuts } from './quickShortcuts'; @@ -38,7 +38,6 @@ const List = styled((props: React.ComponentProps) => ) => )` background: url(${nightBackgroundPng}); height: 210px; @@ -46,16 +45,15 @@ const PausingHeader = styled((props: React.ComponentProps) => < align-items: flex-end; `; -// TODO: handle classes={{ primary: classes.pausingHeaderText }} const PausingHeaderText = styled((props: React.ComponentProps) => )` color: white; `; -const pauseNotification = (tilDate: Date): void => { +const pauseNotification = (tilDate: Date, t: ReturnType['t']): void => { void window.service.preference.set('pauseNotifications', `pause:${tilDate.toString()}`); void window.service.notification.show({ - title: 'Notifications paused', - body: `Notifications paused until ${formatDate(tilDate)}.`, + title: t('Notification.Paused'), + body: t('Notification.PausedUntil', { date: formatDate(tilDate) }), }); void window.remote.closeCurrentWindow(); }; @@ -74,11 +72,11 @@ export default function Notifications(): React.JSX.Element { return ( - + { if (pauseNotificationsInfo === undefined) { return; @@ -91,8 +89,8 @@ export default function Notifications(): React.JSX.Element { await window.service.preference.set('pauseNotifications', undefined); } await window.service.notification.show({ - title: 'Notifications resumed', - body: 'Notifications are now resumed.', + title: t('Notification.Resumed'), + body: t('Notification.NotificationsNowResumed'), }); void window.remote.closeCurrentWindow(); }} @@ -105,20 +103,20 @@ export default function Notifications(): React.JSX.Element { {(popupState) => ( <> - + {quickShortcuts.map((shortcut) => ( { - pauseNotification(shortcut.calcDate()); + pauseNotification(shortcut.calcDate(), t); popupState.close(); }} > - {shortcut.name} + {t(shortcut.key, { defaultValue: shortcut.name })} ))} - Custom... + {t('Notification.Custom', { defaultValue: 'Custom...' })} @@ -139,7 +137,9 @@ export default function Notifications(): React.JSX.Element { { await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); void window.remote.closeCurrentWindow(); @@ -151,15 +151,15 @@ export default function Notifications(): React.JSX.Element { } return ( - Pause notifications}> + {t('Notification.PauseNotifications', { defaultValue: 'Pause notifications' })}}> {quickShortcuts.map((shortcut) => ( { - pauseNotification(shortcut.calcDate()); + pauseNotification(shortcut.calcDate(), t); }} > - + ))} - + { await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); void window.remote.closeCurrentWindow(); @@ -193,9 +193,9 @@ export default function Notifications(): React.JSX.Element { value={new Date()} onChange={(tilDate) => { if (tilDate === null) return; - pauseNotification(tilDate); + pauseNotification(tilDate, t); }} - label='Custom' + label={t('Notification.Custom', { defaultValue: 'Custom' })} open={showDateTimePicker} onOpen={() => { showDateTimePickerSetter(true); diff --git a/src/windows/Notifications/quickShortcuts.ts b/src/windows/Notifications/quickShortcuts.ts index 747ea69a..b3094b9c 100644 --- a/src/windows/Notifications/quickShortcuts.ts +++ b/src/windows/Notifications/quickShortcuts.ts @@ -1,52 +1,75 @@ +import { t } from '@services/libs/i18n/placeholder'; import { addDays, addHours, addMinutes, addWeeks } from 'date-fns'; -export const quickShortcuts = [ +/** + * Quick shortcuts for pausing notifications + * The name keys are used for i18n translation + */ +export interface IQuickShortcut { + name: string; + key: string; + calcDate: () => Date; +} + +export const quickShortcuts: IQuickShortcut[] = [ { name: '15 minutes', + key: t('Notification.Pause15Minutes'), calcDate: () => addMinutes(new Date(), 15), }, { name: '30 minutes', + key: t('Notification.Pause30Minutes'), calcDate: () => addMinutes(new Date(), 30), }, { name: '45 minutes', + key: t('Notification.Pause45Minutes'), calcDate: () => addMinutes(new Date(), 45), }, { name: '1 hour', + key: t('Notification.Pause1Hour'), calcDate: () => addHours(new Date(), 1), }, { name: '2 hours', + key: t('Notification.Pause2Hours'), calcDate: () => addHours(new Date(), 2), }, { name: '4 hours', + key: t('Notification.Pause4Hours'), calcDate: () => addHours(new Date(), 4), }, { name: '6 hours', + key: t('Notification.Pause6Hours'), calcDate: () => addHours(new Date(), 6), }, { name: '8 hours', + key: t('Notification.Pause8Hours'), calcDate: () => addHours(new Date(), 8), }, { name: '10 hours', - calcDate: () => addHours(new Date(), 8), + key: t('Notification.Pause10Hours'), + calcDate: () => addHours(new Date(), 10), }, { name: '12 hours', + key: t('Notification.Pause12Hours'), calcDate: () => addHours(new Date(), 12), }, { name: 'Until tomorrow', + key: t('Notification.PauseUntilTomorrow'), calcDate: () => addDays(new Date(), 1), }, { name: 'Until next week', + key: t('Notification.PauseUntilNextWeek'), calcDate: () => addWeeks(new Date(), 1), }, ]; diff --git a/src/windows/Preferences/sections/Sync.tsx b/src/windows/Preferences/sections/Sync.tsx index a888b3f7..5575edeb 100644 --- a/src/windows/Preferences/sections/Sync.tsx +++ b/src/windows/Preferences/sections/Sync.tsx @@ -1,6 +1,5 @@ import { Divider, List, Switch } from '@mui/material'; import { TimePicker } from '@mui/x-date-pickers/TimePicker'; -import { fromUnixTime, setDate, setMonth, setYear } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { TokenForm } from '../../../components/TokenForm'; @@ -65,12 +64,16 @@ export function Sync(props: Required): React.JSX.Element { openTo='hours' views={['hours', 'minutes', 'seconds']} format='HH:mm:ss' - value={fromUnixTime(preference.syncDebounceInterval / 1000 + new Date().getTimezoneOffset() * 60)} + value={new Date(Date.UTC(1970, 0, 1, 0, 0, 0, preference.syncDebounceInterval))} onChange={async (date) => { if (date === null) throw new Error(`date is null`); - const timeWithoutDate = setDate(setMonth(setYear(date, 1970), 0), 1); - const utcTime = (timeWithoutDate.getTime() / 1000 - new Date().getTimezoneOffset() * 60) * 1000; - await window.service.preference.set('syncDebounceInterval', utcTime); + // Extract hours, minutes, seconds from the date and convert to milliseconds + // This is timezone-independent because we're just extracting time components + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + const intervalMs = (hours * 60 * 60 + minutes * 60 + seconds) * 1000; + await window.service.preference.set('syncDebounceInterval', intervalMs); props.requestRestartCountDown(); }} onClose={async () => { diff --git a/src/windows/Preferences/sections/__tests__/Sync.timezone.test.ts b/src/windows/Preferences/sections/__tests__/Sync.timezone.test.ts new file mode 100644 index 00000000..7746e8ea --- /dev/null +++ b/src/windows/Preferences/sections/__tests__/Sync.timezone.test.ts @@ -0,0 +1,168 @@ +import { setDate, setMonth, setYear } from 'date-fns'; +import { describe, expect, it } from 'vitest'; + +/** + * Test timezone handling for sync interval settings + * This tests the logic from Sync.tsx to ensure it works correctly across timezones + */ +describe('Sync interval timezone handling', () => { + /** + * Helper to simulate the display value calculation from Sync.tsx (new timezone-independent version) + */ + function _calculateDisplayValue(syncDebounceInterval: number): Date { + return new Date(Date.UTC(1970, 0, 1, 0, 0, 0, syncDebounceInterval)); + } + + /** + * Helper to simulate the save value calculation from Sync.tsx (new timezone-independent version) + */ + function calculateSaveValue(date: Date): number { + const hours = date.getHours(); + const minutes = date.getMinutes(); + const seconds = date.getSeconds(); + const intervalMs = (hours * 60 * 60 + minutes * 60 + seconds) * 1000; + return intervalMs; + } + + /** + * OLD BUGGY VERSION - Helper to simulate the old display value calculation + */ + // function calculateDisplayValueOld(syncDebounceInterval: number, timezoneOffset: number): Date { + // return fromUnixTime(syncDebounceInterval / 1000 + timezoneOffset * 60); + // } + + /** + * OLD BUGGY VERSION - Helper to simulate the old save value calculation + */ + function calculateSaveValueOld(date: Date, timezoneOffset: number): number { + const timeWithoutDate = setDate(setMonth(setYear(date, 1970), 0), 1); + const utcTime = (timeWithoutDate.getTime() / 1000 - timezoneOffset * 60) * 1000; + return utcTime; + } + + /** + * Mock getTimezoneOffset for different timezones + * Note: getTimezoneOffset returns negative values for timezones ahead of UTC + * UTC+8 (Beijing/Singapore) = -480 + * UTC+0 (London) = 0 + * UTC-5 (New York) = 300 + */ + const timezones = [ + { name: 'UTC+8 (Beijing)', offset: -480 }, + { name: 'UTC+8 (Singapore)', offset: -480 }, + { name: 'UTC+0 (London)', offset: 0 }, + { name: 'UTC-5 (New York)', offset: 300 }, + { name: 'UTC+5:30 (India)', offset: -330 }, + ]; + + describe('5 minute interval', () => { + const FIVE_MINUTES_MS = 5 * 60 * 1000; // 300000 + + timezones.forEach(({ name, offset }) => { + it(`should correctly handle 5 minute interval in ${name}`, () => { + // Simulate setting 5 minutes in the UI + // User sees 00:05:00 in the TimePicker + const userSelectedDate = new Date(1970, 0, 1, 0, 5, 0, 0); + + // Mock the timezone offset + const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset; + Date.prototype.getTimezoneOffset = () => offset; + + try { + // Calculate what would be saved (new timezone-independent version) + const savedValue = calculateSaveValue(userSelectedDate); + + // The saved value should always be 5 minutes regardless of timezone + expect(savedValue).toBe(FIVE_MINUTES_MS); + } finally { + Date.prototype.getTimezoneOffset = originalGetTimezoneOffset; + } + }); + }); + }); + + describe('30 minute interval (default)', () => { + const THIRTY_MINUTES_MS = 30 * 60 * 1000; // 1800000 + + timezones.forEach(({ name, offset }) => { + it(`should correctly handle 30 minute interval in ${name}`, () => { + const userSelectedDate = new Date(1970, 0, 1, 0, 30, 0, 0); + + const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset; + Date.prototype.getTimezoneOffset = () => offset; + + try { + const savedValue = calculateSaveValue(userSelectedDate); + + expect(savedValue).toBe(THIRTY_MINUTES_MS); + } finally { + Date.prototype.getTimezoneOffset = originalGetTimezoneOffset; + } + }); + }); + }); + + describe('Edge case: problematic value from issue #310', () => { + it('should understand why 2105000 was stored', () => { + // The user reported: syncDebounceInterval: 2105000 + // This equals 2105000 / 1000 / 60 = 35.083... minutes + // This test documents the issue for reference + const _problematicValue = 2105000; + + // This would be caused by timezone offset calculation errors in the old code + // The fix ensures timezone-independent time interval handling + }); + }); + + describe('Real-world scenario: TimePicker behavior', () => { + it('should handle Date object from TimePicker correctly', () => { + // When user selects 00:05:00 in TimePicker, what Date object is created? + // The TimePicker component creates a Date with local time 00:05:00 + const localDate = new Date(1970, 0, 1, 0, 5, 0, 0); + + // Apply the NEW save logic (timezone-independent) + const hours = localDate.getHours(); + const minutes = localDate.getMinutes(); + const seconds = localDate.getSeconds(); + const intervalMs = (hours * 60 * 60 + minutes * 60 + seconds) * 1000; + + // This should equal 5 minutes regardless of timezone + expect(intervalMs).toBe(5 * 60 * 1000); + }); + }); + + describe('OLD BUGGY VERSION - Demonstrates the timezone bug', () => { + it('shows how old code fails in non-UTC+8 timezones', () => { + // Note: This test demonstrates that the old code behavior depends on system timezone + // We create a date at local midnight Jan 1, 1970 at 00:05:00 + const userSelectedDate = new Date(1970, 0, 1, 0, 5, 0, 0); + + // Mock different timezone offsets to show the bug + const originalGetTimezoneOffset = Date.prototype.getTimezoneOffset; + + try { + // In UTC+0 timezone + Date.prototype.getTimezoneOffset = () => 0; // UTC+0 + const savedValueOldUTC0 = calculateSaveValueOld(userSelectedDate, 0); + + // In UTC+8 timezone + Date.prototype.getTimezoneOffset = () => -480; // UTC+8 + const savedValueOldUTC8 = calculateSaveValueOld(userSelectedDate, -480); + + // The key point: old code produces different values for different timezones + // which proves it's timezone-dependent and broken + // The new code should always produce the same value + const savedValueNew = calculateSaveValue(userSelectedDate); + + // New code is always correct: 5 minutes + expect(savedValueNew).toBe(300000); // Always 5 minutes + + // Old code values differ based on timezone offset (demonstrating the bug) + // We just verify that they're not equal (showing the inconsistency) + expect(savedValueOldUTC0).not.toBe(savedValueOldUTC8); + } finally { + Date.prototype.getTimezoneOffset = originalGetTimezoneOffset; + } + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a92bba40..38939d3a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "exclude": ["template/**/*.js", "features/cucumber.config.js", "**/__mocks__/**/*.js"], - "include": ["src", "features", "test", "./*.*.ts", "./*.*.js", "forge.config.ts"], + "include": ["src", "features", "test", "scripts", "./*.*.ts", "./*.*.js", "forge.config.ts"], "ts-node": { "files": true, "compilerOptions": {