Feat/allow watch fs change on git sync

* feat: Skip restart if file system watch is enabled - the watcher will handle file changes automatically

* fix: sometimes change sync interval not working

fixes #310

* fix: Return false on sync failure - no successful changes were made

fixes #558

* fix: step that is wrong

* feat: monitoring subwiki

* AI added waitForSSEReady

* Revert "AI added waitForSSEReady"

This reverts commit 983b1c623c.

* fix: error on frontend loading worker thread

* fix

* Update wiki.ts

* auto reload view and click subwiki icon

* Refactor sync echo prevention and improve logging

Removed frontend-side echo prevention logic in ipcSyncAdaptor, relying solely on backend file exclusion for echo prevention. Improved console log wrappers to preserve native behavior and added a log statement to setupSSE. Updated test steps and file modification logic to better simulate external edits without modifying timestamps. Added internal documentation on sync architecture.

* feat: deboucne and prevent data race when write file

* Update watch-filesystem-adaptor.ts

* rename camelcase

* Update filesystemPlugin.feature

* Fix sync interval timezone handling and add tests

Refactored syncDebounceInterval logic in Sync.tsx to be timezone-independent, ensuring correct interval storage and display across all timezones. Added comprehensive tests in Sync.timezone.test.ts to verify correct behavior and document previous timezone-related bugs. fixes #310

* i18n for notification

* Update index.tsx

* fix: potential symlinks problem of subwiki

* Update Sync.timezone.test.ts

* lint

* Implement backoff for file existence check

Refactor file existence check to use backoff strategy and add directory tree retrieval for error reporting.

* Update BACKOFF_OPTIONS with new configuration

* Update wiki.ts

* remove log

* Update wiki.ts

* fix: draft not move to sub

* Update filesystemPlugin.feature

* fix: routing tw logger to file

* Update filesystemPlugin.feature

* test: use id to check view load and sse load

* Optimize test steps and screenshot logic

Removed unnecessary short waits in filesystemPlugin.feature and increased wait time for tiddler state to settle. Updated application.ts to skip screenshots for wait steps, reducing redundant screenshots during test execution.

* Check if the WebContents is actually loaded and remove fake webContentsViewHelper.new.ts created by AI

* Update view.ts

* fix: prevent echo by exclude title

* test: Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers"

* Revert "fix: prevent echo by exclude title"

This reverts commit 86aa838d24.

* fix: when move file to subwiki, delete old file

* fix: prevent ipc echo change back to frontend

* test: view might take longer to load

* fix: minor issues

* test: fix cleanup timeout

* Update cleanup.ts

* feat: capture webview screenshot

* Update filesystemPlugin.feature

* Update SyncArchitecture.md

* rename

* test: add some time to easy failed steps

* Separate logs by test scenario for easier debugging

* Update selectors for add and confirm buttons in tests

Changed the CSS selectors for the add tiddler and confirm buttons in the filesystem plugin feature tests to use :has() with icon classes. This improves selector robustness and aligns with UI changes.

* Ensure window has focus and is ready

* Update window.ts

* fix: webview screenshot capture prevent mini window to close

* fix: Failed to take screenshot: Error: ENAMETOOLONG: name too long, open '/home/runner/work/TidGi-Desktop/TidGi-Desktop/userData-test/logs/screenshots/Agent workflow - Create notes- update embeddings- then search/2025-10-30T11-46-28-891Z-I type -在 wiki 工作区创建一个名为 AI Agent Guide 的笔记-内容是-智能体是一种可以执行任务的AI系统-它可以使用工具-搜索信息并与用户交互- in -chat input- element with selec-PASSED-page.png'

* Update window.ts

* feat: remove deprecated symlink subwiki approach

* Update wiki.ts

* fix: remove AI buggy bring window to front cause mini window test to fail

* lint

* Adjust wait time for draft saving in filesystemPlugin

Increased wait time for file system plugin to save draft.

* Adjust wait time for tiddler state stabilization

Increased wait time to ensure tiddler state settles properly.

* Refactor release workflow to simplify dependency installation

Removed installation steps for x64 and arm64 dependencies, and adjusted the build process for plugins and native modules.

* Enhance wait for IPC in filesystemPlugin feature

Added a wait time to improve reliability of content update verification in CI.
This commit is contained in:
lin onetwo 2025-10-31 02:00:40 +08:00 committed by GitHub
parent 7473612cec
commit 7f5e1aa0cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1787 additions and 778 deletions

View file

@ -70,25 +70,6 @@ jobs:
- name: Set up CV dependency for pngquant-bin - name: Set up CV dependency for pngquant-bin
if: matrix.platform == 'win' if: matrix.platform == 'win'
uses: ilammy/msvc-dev-cmd@v1 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) # Install C++ build tools for native modules (Linux only)
# macOS: GitHub Actions runners come with Xcode Command Line Tools pre-installed # macOS: GitHub Actions runners come with Xcode Command Line Tools pre-installed
# Windows: MSVC is set up by msvc-dev-cmd action above # Windows: MSVC is set up by msvc-dev-cmd action above
@ -102,7 +83,12 @@ jobs:
run: pnpm install run: pnpm install
- name: Rebuild native modules for Electron - name: Rebuild native modules for Electron
run: pnpm exec electron-rebuild -f -w better-sqlite3,nsfw 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 }}) - name: Make ${{ matrix.platform }} (${{ matrix.arch }})
run: | run: |
pnpm exec electron-forge make --platform=${{ matrix.platform == 'mac' && 'darwin' || matrix.platform == 'win' && 'win32' || 'linux' }} --arch=${{ matrix.arch }} pnpm exec electron-forge make --platform=${{ matrix.platform == 'mac' && 'darwin' || matrix.platform == 'win' && 'win32' || 'linux' }} --arch=${{ matrix.arch }}

View file

@ -93,6 +93,25 @@ Solution:
node_modules/.bin/electron-rebuild -f -w better-sqlite3 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 <https://github.com/justadudewhohacks/opencv4nodejs/issues/401#issuecomment-463434713> 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 ## During test, The module 'node_modules\better-sqlite3\build\Release\better_sqlite3.node' was compiled against a different Node.js version using
```log ```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版」重启即可。 救急可以用 `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 <https://github.com/justadudewhohacks/opencv4nodejs/issues/401#issuecomment-463434713> if you still have problem rebuild opencv for @nut-tree/nut-js
## Command failed with exit code 1 ## Command failed with exit code 1
When you see an error like: When you see an error like:

View file

@ -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 });`. 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) 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 ## User profile

View file

@ -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)

View file

@ -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: { rules: {
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'unicorn/prevent-abbreviations': 'off', 'unicorn/prevent-abbreviations': 'off',

View file

@ -12,7 +12,7 @@ Feature: Filesystem Plugin
Then the browser view should be loaded and visible Then the browser view should be loaded and visible
And I wait for SSE and watch-fs to be ready And I wait for SSE and watch-fs to be ready
@subwiki @file-watching @subwiki
Scenario: Tiddler with tag saves to sub-wiki folder Scenario: Tiddler with tag saves to sub-wiki folder
# Create sub-workspace linked to the default wiki # Create sub-workspace linked to the default wiki
When I click on an "add workspace button" element with selector "#add-workspace-button" 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 click on a "create sub workspace button" element with selector "button.MuiButton-colorSecondary"
And I switch to "main" window And I switch to "main" window
Then I should see a "SubWiki workspace" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')" 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 # Wait for main wiki to restart after sub-wiki creation
When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" Then I wait for main wiki to restart after sub-wiki creation
And I click on "add tiddler button" element in browser view with selector "button[aria-label='']" 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 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 press "Control+a" in browser view
And I type "TestTag" in "tag input" element in browser view with selector "input.tc-edit-texteditor.tc-popup-handle" And I wait for 0.2 seconds
And I press "Enter" in browser view And I press "Delete" in browser view
And I click on "confirm button" element in browser view with selector "button[aria-label='']" 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"
# Verify the tiddler file exists in sub-wiki folder (not in tiddlers subfolder) # Wait for tiddler state to settle, otherwise it still shows 3 chars (新条目) for a while
Then file "Test Tiddler Title.tid" should exist in "{tmpDir}/SubWiki" 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 @file-watching
Scenario: External file creation syncs to wiki 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 Then I wait for tiddler "WatchTestTiddler" to be added by watch-fs
# Open sidebar "最近" tab to see the timeline # 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 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 # 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')" 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 # 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 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 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 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 Then I should see "Original content" in the browser view content
# Modify the file externally # Modify the file externally
When I modify file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" to contain "Modified content from external editor" 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 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 Then I should see "Modified content from external editor" in the browser view content
# Now delete the file externally # Now delete the file externally
When I delete file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" 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 # 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')" 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 @file-watching
Scenario: External file rename syncs to wiki Scenario: External file rename syncs to wiki
# Create initial file # Create initial file
@ -121,7 +181,7 @@ Feature: Filesystem Plugin
Then I wait for tiddler "NewName" to be updated by watch-fs Then I wait for tiddler "NewName" to be updated by watch-fs
# Navigate to timeline to verify changes # 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 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 # 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')" 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 Then I should see "Content before rename" in the browser view content

View file

@ -292,11 +292,11 @@ Given('I add test ai settings', function() {
fs.writeJsonSync(settingsPath, { ...existing, aiSettings: newAi } as ISettingFile, { spaces: 2 }); fs.writeJsonSync(settingsPath, { ...existing, aiSettings: newAi } as ISettingFile, { spaces: 2 });
}); });
function clearAISettings() { async function clearAISettings() {
if (!fs.existsSync(settingsPath)) return; if (!(await fs.pathExists(settingsPath))) return;
const parsed = fs.readJsonSync(settingsPath) as ISettingFile; const parsed = await fs.readJson(settingsPath) as ISettingFile;
const cleaned = omit(parsed, ['aiSettings']); const cleaned = omit(parsed, ['aiSettings']);
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 }); await fs.writeJson(settingsPath, cleaned, { spaces: 2 });
} }
export { clearAISettings }; export { clearAISettings };

View file

@ -9,6 +9,7 @@ import { MockOAuthServer } from '../supports/mockOAuthServer';
import { MockOpenAIServer } from '../supports/mockOpenAI'; import { MockOpenAIServer } from '../supports/mockOpenAI';
import { makeSlugPath, screenshotsDirectory } from '../supports/paths'; import { makeSlugPath, screenshotsDirectory } from '../supports/paths';
import { getPackedAppPath } from '../supports/paths'; import { getPackedAppPath } from '../supports/paths';
import { captureScreenshot } from '../supports/webContentsViewHelper';
// Backoff configuration for retries // Backoff configuration for retries
const BACKOFF_OPTIONS = { const BACKOFF_OPTIONS = {
@ -199,6 +200,13 @@ AfterStep(async function(this: ApplicationWorld, { pickle, pickleStep, result })
// if (!process.env.CI) return; // if (!process.env.CI) return;
try { 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 // Prefer an existing currentWindow if it's still open
let pageToUse: Page | undefined; 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()); const openPages = this.app.windows().filter(p => !p.isClosed());
if (openPages.length > 0) { if (openPages.length > 0) {
pageToUse = openPages[0]; pageToUse = openPages[0];
this.currentWindow = pageToUse;
} }
} }
const scenarioName = pickle.name; 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; // Limit step text slug to avoid excessively long filenames which can trigger ENAMETOOLONG
const cleanStepText = makeSlugPath(stepText, 120); const cleanStepText = makeSlugPath(stepText, 80);
const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status'; const stepStatus = result && typeof result.status === 'string' ? result.status : 'unknown-status';
const featureDirectory = path.resolve(screenshotsDirectory, cleanScenarioName); 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 timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const screenshotPath = path.resolve(featureDirectory, `${timestamp}-${cleanStepText}-${stepStatus}.jpg`);
// Use conservative screenshot options for CI // Try to capture both WebContentsView and Page screenshots
await pageToUse.screenshot({ path: screenshotPath, fullPage: true, type: 'jpeg', quality: 10 }); 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) { } catch (screenshotError) {
console.warn('Failed to take screenshot:', screenshotError); console.warn('Failed to take screenshot:', screenshotError);
} }

View file

@ -5,7 +5,7 @@ import type { ApplicationWorld } from './application';
// Backoff configuration for retries // Backoff configuration for retries
const BACKOFF_OPTIONS = { const BACKOFF_OPTIONS = {
numOfAttempts: 8, numOfAttempts: 10,
startingDelay: 100, startingDelay: 100,
timeMultiple: 2, 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) { if (!this.app) {
throw new Error('Application not launched'); 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'); throw new Error('Browser view not loaded');
} }
}, },
BACKOFF_OPTIONS, { ...BACKOFF_OPTIONS, numOfAttempts: 15 },
).catch(() => { ).catch(() => {
throw new Error('Browser view is not loaded or visible after multiple attempts'); throw new Error('Browser view is not loaded or visible after multiple attempts');
}); });

View file

@ -6,22 +6,22 @@ import { ApplicationWorld } from './application';
import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow'; import { clearTidgiMiniWindowSettings } from './tidgiMiniWindow';
import { clearSubWikiRoutingTestData } from './wiki'; 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 // Create necessary directories under userData-test/logs to match appPaths in dev/test
if (!fs.existsSync(logsDirectory)) { if (!(await fs.pathExists(logsDirectory))) {
fs.mkdirSync(logsDirectory, { recursive: true }); await fs.ensureDir(logsDirectory);
} }
// Create screenshots subdirectory in logs // Create screenshots subdirectory in logs
if (!fs.existsSync(screenshotsDirectory)) { if (!(await fs.pathExists(screenshotsDirectory))) {
fs.mkdirSync(screenshotsDirectory, { recursive: true }); await fs.ensureDir(screenshotsDirectory);
} }
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
clearAISettings(); await clearAISettings();
} }
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
clearTidgiMiniWindowSettings(); await clearTidgiMiniWindowSettings();
} }
}); });
@ -30,7 +30,8 @@ After(async function(this: ApplicationWorld, { pickle }) {
try { try {
// Close all windows including tidgi mini window before closing the app, otherwise it might hang, and refused to exit until ctrl+C // 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(); const allWindows = this.app.windows();
for (const window of allWindows) { await Promise.all(
allWindows.map(async (window) => {
try { try {
if (!window.isClosed()) { if (!window.isClosed()) {
await window.close(); await window.close();
@ -38,7 +39,8 @@ After(async function(this: ApplicationWorld, { pickle }) {
} catch (error) { } catch (error) {
console.error('Error closing window:', error); console.error('Error closing window:', error);
} }
} }),
);
await this.app.close(); await this.app.close();
} catch (error) { } catch (error) {
console.error('Error during cleanup:', error); console.error('Error during cleanup:', error);
@ -48,12 +50,34 @@ After(async function(this: ApplicationWorld, { pickle }) {
this.currentWindow = undefined; this.currentWindow = undefined;
} }
if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) { if (pickle.tags.some((tag) => tag.name === '@tidgi-mini-window')) {
clearTidgiMiniWindowSettings(); await clearTidgiMiniWindowSettings();
} }
if (pickle.tags.some((tag) => tag.name === '@ai-setting')) { if (pickle.tags.some((tag) => tag.name === '@ai-setting')) {
clearAISettings(); await clearAISettings();
} }
if (pickle.tags.some((tag) => tag.name === '@subwiki')) { 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);
} }
}); });

View file

@ -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) // Cleanup function to be called after tidgi mini window tests (after app closes)
function clearTidgiMiniWindowSettings() { async function clearTidgiMiniWindowSettings() {
if (!fs.existsSync(settingsPath)) return; if (!(await fs.pathExists(settingsPath))) return;
const parsed = fs.readJsonSync(settingsPath) as ISettingFile; const parsed = await fs.readJson(settingsPath) as ISettingFile;
// Remove tidgi mini window-related preferences to avoid affecting other tests // Remove tidgi mini window-related preferences to avoid affecting other tests
const cleanedPreferences = omit(parsed.preferences || {}, [ const cleanedPreferences = omit(parsed.preferences || {}, [
'tidgiMiniWindow', 'tidgiMiniWindow',
@ -68,7 +68,7 @@ function clearTidgiMiniWindowSettings() {
} }
const cleaned = { ...parsed, preferences: cleanedPreferences, workspaces }; const cleaned = { ...parsed, preferences: cleanedPreferences, workspaces };
fs.writeJsonSync(settingsPath, cleaned, { spaces: 2 }); await fs.writeJson(settingsPath, cleaned, { spaces: 2 });
} }
export { clearTidgiMiniWindowSettings }; export { clearTidgiMiniWindowSettings };

View file

@ -1,134 +1,53 @@
import { Then, When } from '@cucumber/cucumber'; import { Then, When } from '@cucumber/cucumber';
import { backOff } from 'exponential-backoff';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import type { IWorkspace } from '../../src/services/workspaces/interface'; import type { IWorkspace } from '../../src/services/workspaces/interface';
import { settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; import { settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths';
import type { ApplicationWorld } from './application'; import type { ApplicationWorld } from './application';
/** // Backoff configuration for retries
* Wait for both SSE and watch-fs to be ready and stabilized. const BACKOFF_OPTIONS = {
* This combines the checks for test-id-SSE_READY and test-id-WATCH_FS_STABILIZED markers. numOfAttempts: 10,
*/ startingDelay: 200,
async function waitForSSEAndWatchFsReady(maxWaitMs = 15000): Promise<void> { timeMultiple: 1.5,
const logPath = path.join(process.cwd(), 'userData-test', 'logs'); };
const startTime = Date.now();
let sseReady = false;
let watchFsStabilized = false;
while (Date.now() - startTime < maxWaitMs) {
try {
const files = await fs.readdir(logPath);
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
for (const file of wikiLogFiles) {
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
if (content.includes('[test-id-SSE_READY]')) {
sseReady = true;
}
if (content.includes('[test-id-WATCH_FS_STABILIZED]')) {
watchFsStabilized = true;
}
}
if (sseReady && watchFsStabilized) {
return;
}
} catch {
// Log directory might not exist yet, continue waiting
}
await new Promise(resolve => setTimeout(resolve, 100));
}
const missingServices = [];
if (!sseReady) missingServices.push('SSE');
if (!watchFsStabilized) missingServices.push('watch-fs');
throw new Error(`${missingServices.join(' and ')} did not become ready within timeout`);
}
/** /**
* Wait for a tiddler to be added by watch-fs. * Generic function to wait for a log marker to appear in wiki log files.
*/ */
async function waitForTiddlerAdded(tiddlerTitle: string, maxWaitMs = 10000): Promise<void> { async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): Promise<void> {
const logPath = path.join(process.cwd(), 'userData-test', 'logs'); 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) { await backOff(
async () => {
try { try {
for (const file of wikiLogFiles) { 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'); const content = await fs.readFile(path.join(logPath, file), 'utf-8');
if (content.includes(searchString)) { if (content.includes(searchString)) {
return; return;
} }
} }
} catch { } catch {
// Log directory might not exist yet, continue waiting // Log directory might not exist yet, continue retrying
} }
await new Promise(resolve => setTimeout(resolve, 100)); throw new Error('Log marker not found yet');
} },
{
throw new Error(`Tiddler "${tiddlerTitle}" was not added within timeout`); numOfAttempts: Math.ceil(maxWaitMs / 100),
} startingDelay: 100,
timeMultiple: 1,
/** maxDelay: 100,
* Wait for a tiddler to be updated by watch-fs. delayFirstAttempt: false,
*/ jitter: 'none',
async function waitForTiddlerUpdated(tiddlerTitle: string, maxWaitMs = 10000): Promise<void> { },
const logPath = path.join(process.cwd(), 'userData-test', 'logs'); ).catch(() => {
const startTime = Date.now(); throw new Error(errorMessage);
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<void> {
const logPath = path.join(process.cwd(), 'userData-test', 'logs');
const startTime = Date.now();
const searchString = `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`;
while (Date.now() - startTime < maxWaitMs) {
try {
const files = await fs.readdir(logPath);
const wikiLogFiles = files.filter(f => f.startsWith('wiki-') && f.endsWith('.log'));
for (const file of wikiLogFiles) {
const content = await fs.readFile(path.join(logPath, file), 'utf-8');
if (content.includes(searchString)) {
return;
}
}
} catch {
// Log directory might not exist yet, continue waiting
}
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Tiddler "${tiddlerTitle}" was not deleted within timeout`);
} }
When('I cleanup test wiki so it could create a new one on start', async function() { When('I cleanup test wiki so it could create a new one on start', async function() {
@ -161,25 +80,132 @@ When('I cleanup test wiki so it could create a new one on start', async function
fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 }); fs.writeJsonSync(settingsPath, { ...settings, workspaces: filtered }, { spaces: 2 });
}); });
/**
* Helper function to get directory tree structure
*/
async function getDirectoryTree(directory: string, prefix = '', maxDepth = 3, currentDepth = 0): Promise<string> {
if (currentDepth >= maxDepth || !(await fs.pathExists(directory))) {
return '';
}
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 * Verify file exists in directory
*/ */
Then('file {string} should exist in {string}', { timeout: 15000 }, async function(this: ApplicationWorld, fileName: string, directoryPath: string) { Then('file {string} should exist in {string}', async function(this: ApplicationWorld, fileName: string, simpleDirectoryPath: string) {
// Replace {tmpDir} with wiki test root (not wiki subfolder) // Replace {tmpDir} with wiki test root (not wiki subfolder)
const actualPath = directoryPath.replace('{tmpDir}', wikiTestRootPath); let directoryPath = simpleDirectoryPath.replace('{tmpDir}', wikiTestRootPath);
const filePath = path.join(actualPath, fileName);
let exists = false; // Resolve symlinks on all platforms to handle sub-wikis correctly
for (let index = 0; index < 20; index++) { // 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)) { if (await fs.pathExists(filePath)) {
exists = true; return;
break;
} }
await new Promise(resolve => setTimeout(resolve, 500)); 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)}`;
} }
if (!exists) { throw new Error(
throw new Error(`File "${fileName}" not found in directory: ${actualPath}`); `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 * Cleanup function for sub-wiki routing test
* Removes test workspaces created during the test * Removes test workspaces created during the test
*/ */
function clearSubWikiRoutingTestData() { async function clearSubWikiRoutingTestData() {
if (!fs.existsSync(settingsPath)) return; if (!(await fs.pathExists(settingsPath))) return;
type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>; type SettingsFile = { workspaces?: Record<string, IWorkspace> } & Record<string, unknown>;
const settings = fs.readJsonSync(settingsPath) as SettingsFile; const settings = await fs.readJson(settingsPath) as SettingsFile;
const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {}; const workspaces: Record<string, IWorkspace> = settings.workspaces ?? {};
const filtered: Record<string, IWorkspace> = {}; const filtered: Record<string, IWorkspace> = {};
@ -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 // Remove test wiki folders from filesystem
const testFolders = ['SubWiki']; const testFolders = ['SubWiki'];
for (const folder of testFolders) { for (const folder of testFolders) {
const wikiPath = path.join(wikiTestWikiPath, folder); const wikiPath = path.join(wikiTestWikiPath, folder);
if (fs.existsSync(wikiPath)) { if (await fs.pathExists(wikiPath)) {
fs.removeSync(wikiPath); await fs.remove(wikiPath);
} }
} }
} }
Then('I wait for SSE and watch-fs to be ready', { timeout: 20000 }, async function(this: ApplicationWorld) { Then('I wait for SSE and watch-fs to be ready', async function(this: ApplicationWorld) {
try { await waitForLogMarker('[test-id-WATCH_FS_STABILIZED]', 'watch-fs did not become ready within timeout', 15000);
await waitForSSEAndWatchFsReady(); await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready within timeout', 15000);
} catch (error) { });
throw new Error(`Failed to wait for SSE and watch-fs: ${(error as Error).message}`);
} 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) { Then('I wait for tiddler {string} to be added by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
try { await waitForLogMarker(
await waitForTiddlerAdded(tiddlerTitle); `[test-id-WATCH_FS_TIDDLER_ADDED] ${tiddlerTitle}`,
} catch (error) { `Tiddler "${tiddlerTitle}" was not added within timeout`,
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be added: ${(error as Error).message}`); );
}
}); });
Then('I wait for tiddler {string} to be updated by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { Then('I wait for tiddler {string} to be updated by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
try { await waitForLogMarker(
await waitForTiddlerUpdated(tiddlerTitle); `[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`,
} catch (error) { `Tiddler "${tiddlerTitle}" was not updated within timeout`,
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be updated: ${(error as Error).message}`); );
}
}); });
Then('I wait for tiddler {string} to be deleted by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) { Then('I wait for tiddler {string} to be deleted by watch-fs', async function(this: ApplicationWorld, tiddlerTitle: string) {
try { await waitForLogMarker(
await waitForTiddlerDeleted(tiddlerTitle); `[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`,
} catch (error) { `Tiddler "${tiddlerTitle}" was not deleted within timeout`,
throw new Error(`Failed to wait for tiddler "${tiddlerTitle}" to be deleted: ${(error as Error).message}`); );
}
}); });
// File manipulation step definitions // 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 // TiddlyWiki .tid files have a format: headers followed by blank line and text
// We need to preserve headers and only modify the text part // 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() === ''); const blankLineIndex = lines.findIndex(line => line.trim() === '');
if (blankLineIndex >= 0) { if (blankLineIndex >= 0) {
// File has headers and content separated by blank line
// Keep headers, replace text after blank line // Keep headers, replace text after blank line
const headers = lines.slice(0, blankLineIndex + 1); 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'); fileContent = [...headers, content].join('\n');
} else { } else {
// No headers found, just use content // File has only headers, no content yet (no blank line separator)
fileContent = content; // 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 // Write the modified content back

View file

@ -1,8 +1,8 @@
import { When } from '@cucumber/cucumber'; import { When } from '@cucumber/cucumber';
import { WebContentsView } from 'electron';
import type { ElectronApplication } from 'playwright'; import type { ElectronApplication } from 'playwright';
import type { ApplicationWorld } from './application'; import type { ApplicationWorld } from './application';
import { checkWindowDimension, checkWindowName } from './application'; import { checkWindowDimension, checkWindowName } from './application';
import { WebContentsView } from 'electron';
// Helper function to get browser view info from Electron window // Helper function to get browser view info from Electron window
async function getBrowserViewInfo( async function getBrowserViewInfo(
@ -53,7 +53,7 @@ When('I confirm the {string} window exists', async function(this: ApplicationWor
const success = await this.waitForWindowCondition( const success = await this.waitForWindowCondition(
windowType, windowType,
(window) => window !== undefined && !window.isClosed(), (window) => window !== undefined,
); );
if (!success) { if (!success) {
@ -83,7 +83,7 @@ When('I confirm the {string} window not visible', async function(this: Applicati
const success = await this.waitForWindowCondition( const success = await this.waitForWindowCondition(
windowType, windowType,
(window, isVisible) => window !== undefined && !window.isClosed() && !isVisible, (window, isVisible) => window !== undefined && !isVisible,
); );
if (!success) { if (!success) {

View file

@ -66,7 +66,7 @@ const unsafeChars = /[^\p{L}\p{N}\s\-_()]/gu;
const collapseDashes = /-+/g; const collapseDashes = /-+/g;
const collapseSpaces = /\s+/g; const collapseSpaces = /\s+/g;
export const makeSlugPath = (input: string | undefined, maxLength = 120) => { export const makeSlugPath = (input: string | undefined, maxLength = 120) => {
let s = String(input || 'unknown').normalize('NFKC'); let s = (input || 'unknown').normalize('NFKC');
// remove dots explicitly // remove dots explicitly
s = s.replace(/\./g, ''); s = s.replace(/\./g, '');
// replace unsafe characters with dashes // replace unsafe characters with dashes

View file

@ -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<T>(
app: ElectronApplication,
script: string,
): Promise<T> {
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<string | null> {
try {
return await executeInBrowserView<string>(
app,
'document.body.textContent || document.body.innerText || ""',
);
} catch {
return null;
}
}
/**
* Get DOM content from WebContentsView
*/
export async function getDOMContent(app: ElectronApplication): Promise<string | null> {
try {
return await executeInBrowserView<string>(
app,
'document.documentElement.outerHTML || ""',
);
} catch {
return null;
}
}
/**
* Check if WebContentsView exists and is loaded
*/
export async function isLoaded(app: ElectronApplication): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean>(app, script);
} else {
const script = `document.querySelector('${selector.replace(/'/g, "\\'")}') !== null`;
return await executeInBrowserView<boolean>(app, script);
}
} catch {
return false;
}
}

View file

@ -1,4 +1,5 @@
import { WebContentsView } from 'electron'; import { WebContentsView } from 'electron';
import fs from 'fs-extra';
import type { ElectronApplication } from 'playwright'; import type { ElectronApplication } from 'playwright';
/** /**
@ -94,7 +95,22 @@ export async function getDOMContent(app: ElectronApplication): Promise<string |
*/ */
export async function isLoaded(app: ElectronApplication): Promise<boolean> { export async function isLoaded(app: ElectronApplication): Promise<boolean> {
const webContentsId = await getFirstWebContentsView(app); 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,6 +123,7 @@ export async function clickElementWithText(
): Promise<void> { ): Promise<void> {
const script = ` const script = `
(function() { (function() {
try {
const selector = ${JSON.stringify(selector)}; const selector = ${JSON.stringify(selector)};
const text = ${JSON.stringify(text)}; const text = ${JSON.stringify(text)};
const elements = document.querySelectorAll(selector); const elements = document.querySelectorAll(selector);
@ -122,15 +139,21 @@ export async function clickElementWithText(
} }
if (!found) { if (!found) {
throw new Error('Element with text "' + text + '" not found in selector: ' + selector); return { error: 'Element with text "' + text + '" not found in selector: ' + selector };
} }
found.click(); found.click();
return true; return { success: true };
} catch (error) {
return { error: error.message || String(error) };
}
})() })()
`; `;
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<void> { export async function clickElement(app: ElectronApplication, selector: string): Promise<void> {
const script = ` const script = `
(function() { (function() {
try {
const selector = ${JSON.stringify(selector)}; const selector = ${JSON.stringify(selector)};
const elem = document.querySelector(selector); const elem = document.querySelector(selector);
if (!elem) { if (!elem) {
throw new Error('Element not found: ' + selector); return { error: 'Element not found: ' + selector };
} }
elem.click(); elem.click();
return true; return { success: true };
} catch (error) {
return { error: error.message || String(error) };
}
})() })()
`; `;
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,12 +193,13 @@ export async function typeText(app: ElectronApplication, selector: string, text:
const script = ` const script = `
(function() { (function() {
try {
const selector = '${escapedSelector}'; const selector = '${escapedSelector}';
const text = '${escapedText}'; const text = '${escapedText}';
const elem = document.querySelector(selector); const elem = document.querySelector(selector);
if (!elem) { if (!elem) {
throw new Error('Element not found: ' + selector); return { error: 'Element not found: ' + selector };
} }
elem.focus(); elem.focus();
@ -180,11 +211,17 @@ export async function typeText(app: ElectronApplication, selector: string, text:
elem.dispatchEvent(new Event('input', { bubbles: true })); elem.dispatchEvent(new Event('input', { bubbles: true }));
elem.dispatchEvent(new Event('change', { bubbles: true })); elem.dispatchEvent(new Event('change', { bubbles: true }));
return true; return { success: true };
} catch (error) {
return { error: error.message || String(error) };
}
})() })()
`; `;
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; 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<boolean> {
try {
// Add timeout to prevent screenshot from blocking test execution
const timeoutPromise = new Promise<null>((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;
}
}

View file

@ -31,8 +31,8 @@ Feature: TidGi Mini Window
Then the browser view should be loaded and visible Then the browser view should be loaded and visible
And I should see " TiddlyWiki" in the browser view content And I should see " TiddlyWiki" in the browser view content
Then I switch to "main" window Then I switch to "main" window
And I wait for 0.2 seconds
When I press the key combination "CommandOrControl+Shift+M" 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 exists
And I confirm the "tidgiMiniWindow" window not visible And I confirm the "tidgiMiniWindow" window not visible

View file

@ -114,6 +114,30 @@
"TidGiSupport": "TidGi Support", "TidGiSupport": "TidGi Support",
"TidGiWebsite": "TidGi Website" "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", "Delete": "Delete",
"Dialog": { "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?", "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?",

View file

@ -105,6 +105,30 @@
"TidGiSupport": "Support TidGi", "TidGiSupport": "Support TidGi",
"TidGiWebsite": "Site web 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", "Delete": "Supprimer",
"Dialog": { "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 ?", "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 ?",

View file

@ -105,6 +105,30 @@
"TidGiSupport": "TidGiサポート", "TidGiSupport": "TidGiサポート",
"TidGiWebsite": "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": "削除", "Delete": "削除",
"Dialog": { "Dialog": {
"CantFindWorkspaceFolderRemoveWorkspace": "以前そこにあったワークスペースフォルダが見つかりません!\nここに存在するはずのフォルダが移動されたか、このフォルダにWikiがありませんワークスペースを削除しますか", "CantFindWorkspaceFolderRemoveWorkspace": "以前そこにあったワークスペースフォルダが見つかりません!\nここに存在するはずのフォルダが移動されたか、このフォルダにWikiがありませんワークスペースを削除しますか",

View file

@ -105,6 +105,30 @@
"TidGiSupport": "Поддержка TidGi", "TidGiSupport": "Поддержка TidGi",
"TidGiWebsite": "Сайт 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": "удалить", "Delete": "удалить",
"Dialog": { "Dialog": {
"CantFindWorkspaceFolderRemoveWorkspace": "Не удалось найти папку Wiki рабочей области, которая ранее находилась здесь! Возможно, папка Wiki была перемещена или в ней нет содержимого Wiki. Удалить рабочую область?", "CantFindWorkspaceFolderRemoveWorkspace": "Не удалось найти папку Wiki рабочей области, которая ранее находилась здесь! Возможно, папка Wiki была перемещена или в ней нет содержимого Wiki. Удалить рабочую область?",

View file

@ -114,6 +114,30 @@
"TidGiSupport": "TidGi 用户支持", "TidGiSupport": "TidGi 用户支持",
"TidGiWebsite": "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": "删除", "Delete": "删除",
"Dialog": { "Dialog": {
"CantFindWorkspaceFolderRemoveWorkspace": "无法找到之前还在该处的工作区知识库文件夹!本应存在于此处的知识库文件夹可能被移走了,或该文件夹内没有知识库!是否移除工作区?", "CantFindWorkspaceFolderRemoveWorkspace": "无法找到之前还在该处的工作区知识库文件夹!本应存在于此处的知识库文件夹可能被移走了,或该文件夹内没有知识库!是否移除工作区?",

View file

@ -105,6 +105,30 @@
"TidGiSupport": "TidGi 用戶支持", "TidGiSupport": "TidGi 用戶支持",
"TidGiWebsite": "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": "刪除", "Delete": "刪除",
"Dialog": { "Dialog": {
"CantFindWorkspaceFolderRemoveWorkspace": "無法找到之前還在該處的工作區知識庫文件夾!本應存在於此處的知識庫文件夾可能被移走了,或該文件夾內沒有知識庫!是否移除工作區?", "CantFindWorkspaceFolderRemoveWorkspace": "無法找到之前還在該處的工作區知識庫文件夾!本應存在於此處的知識庫文件夾可能被移走了,或該文件夾內沒有知識庫!是否移除工作區?",

View file

@ -20,11 +20,12 @@
"test:unit:coverage": "pnpm run test:unit --coverage", "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: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: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": "pnpm run build:plugin && cross-env NODE_ENV=production electron-forge make",
"make:analyze": "cross-env ANALYZE=true pnpm run make", "make:analyze": "cross-env ANALYZE=true pnpm run make",
"init:git-submodule": "git submodule update --init --recursive && git submodule update --remote", "init:git-submodule": "git submodule update --init --recursive && git submodule update --remote",
"lint": "eslint ./src --ext js,ts,tsx,json", "lint": "eslint ./src ./features ./scripts ./localization ./*.ts --ext js,ts,tsx,json",
"lint:fix": "eslint ./src --ext ts,tsx --fix", "lint:fix": "eslint ./src ./features ./scripts ./localization ./*.ts --ext ts,tsx,json --fix",
"check": "tsc --noEmit --skipLibCheck", "check": "tsc --noEmit --skipLibCheck",
"installType": "typesync" "installType": "typesync"
}, },

View file

@ -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-argument */
/* eslint-disable @typescript-eslint/no-unsafe-return */ /* 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-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
import esbuild from 'esbuild'; import esbuild from 'esbuild';
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
import { rimraf } from 'rimraf'; import { rimraf } from 'rimraf';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -29,7 +31,7 @@ const nativeNodeModulesPlugin = {
// New: require('nsfw/build/Release/nsfw.node') // New: require('nsfw/build/Release/nsfw.node')
contents = contents.replace( contents = contents.replace(
/require\(['"]\.\.\/\.\.\/build\/Release\/nsfw\.node['"]\)/g, /require\(['"]\.\.\/\.\.\/build\/Release\/nsfw\.node['"]\)/g,
"require('nsfw/build/Release/nsfw.node')" "require('nsfw/build/Release/nsfw.node')",
); );
return { return {
@ -67,7 +69,7 @@ const PLUGINS = [
name: 'watch-filesystem-adaptor', name: 'watch-filesystem-adaptor',
sourceFolder: '../src/services/wiki/plugin/watchFileSystemAdaptor', sourceFolder: '../src/services/wiki/plugin/watchFileSystemAdaptor',
entryPoints: [ entryPoints: [
'watch-filesystem-adaptor.ts', 'loader.ts',
], ],
}, },
]; ];
@ -152,7 +154,7 @@ async function buildEntryPoints(plugin, outDirs) {
outdir: outDir, outdir: outDir,
}) })
) )
) ),
); );
} }

View file

@ -3,5 +3,7 @@ import { DEFAULT_FIRST_WIKI_FOLDER_PATH } from '../src/constants/paths';
try { try {
fs.removeSync(DEFAULT_FIRST_WIKI_FOLDER_PATH); fs.removeSync(DEFAULT_FIRST_WIKI_FOLDER_PATH);
} catch {} } catch {
// ignore
}
fs.mkdirpSync(DEFAULT_FIRST_WIKI_FOLDER_PATH); fs.mkdirpSync(DEFAULT_FIRST_WIKI_FOLDER_PATH);

View file

@ -1,4 +1,5 @@
// pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts // pnpm exec cross-env NODE_ENV=test tsx ./scripts/start-e2e-app.ts
/* eslint-disable unicorn/prevent-abbreviations */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { getPackedAppPath } from '../features/supports/paths'; import { getPackedAppPath } from '../features/supports/paths';
@ -7,16 +8,16 @@ import { getPackedAppPath } from '../features/supports/paths';
const appPath = getPackedAppPath(); const appPath = getPackedAppPath();
console.log('Starting TidGi E2E app:', appPath); console.log('Starting TidGi E2E app:', appPath);
const env = Object.assign({}, process.env, { const environment = Object.assign({}, process.env, {
NODE_ENV: 'test', NODE_ENV: 'test',
LANG: process.env.LANG || 'zh-Hans.UTF-8', LANG: process.env.LANG || 'zh-Hans.UTF-8',
LANGUAGE: process.env.LANGUAGE || 'zh-Hans:zh', LANGUAGE: process.env.LANGUAGE || 'zh-Hans:zh',
LC_ALL: process.env.LC_ALL || 'zh-Hans.UTF-8', 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('exit', code => process.exit(code ?? 0));
child.on('error', err => { child.on('error', error => {
console.error('Failed to start TidGi app:', err); console.error('Failed to start TidGi app:', error);
process.exit(1); process.exit(1);
}); });

View file

@ -43,8 +43,10 @@ async function main() {
}); });
// 防止进程退出 - 使用 setInterval 而不是空的 Promise // 防止进程退出 - 使用 setInterval 而不是空的 Promise
const keepAlive = setInterval(() => { // 注意在 e2e 的 CleanUp 里关闭服务器
setInterval(() => {
// 每10秒输出一次状态确认服务器还在运行 // 每10秒输出一次状态确认服务器还在运行
console.log('Mock OpenAI 服务器仍在运行...');
}, 10000); }, 10000);
} catch (error) { } catch (error) {
console.error('❌ 启动服务器失败:', error); console.error('❌ 启动服务器失败:', error);

View file

@ -50,7 +50,7 @@ const Tab = styled(TabRaw)`
interface Props { interface Props {
storageProvider?: SupportedStorageServices; storageProvider?: SupportedStorageServices;
storageProviderSetter?: React.Dispatch<React.SetStateAction<SupportedStorageServices>>; storageProviderSetter?: (value: SupportedStorageServices) => void;
} }
/** /**
* Create storage provider's token. * Create storage provider's token.
@ -58,12 +58,10 @@ interface Props {
*/ */
export function TokenForm({ storageProvider, storageProviderSetter }: Props): React.JSX.Element { export function TokenForm({ storageProvider, storageProviderSetter }: Props): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
let [currentTab, currentTabSetter] = useState<SupportedStorageServices>(SupportedStorageServices.github); const [internalTab, internalTabSetter] = useState<SupportedStorageServices>(SupportedStorageServices.github);
// use external controls if provided // use external controls if provided
if (storageProvider !== undefined && typeof storageProviderSetter === 'function') { const currentTab = storageProvider ?? internalTab;
currentTab = storageProvider; const currentTabSetter = storageProviderSetter ?? internalTabSetter;
currentTabSetter = storageProviderSetter;
}
// update storageProvider to be an online service, if this Component is opened // update storageProvider to be an online service, if this Component is opened
useEffect(() => { useEffect(() => {
if (storageProvider === SupportedStorageServices.local && typeof storageProviderSetter === 'function') { if (storageProvider === SupportedStorageServices.local && typeof storageProviderSetter === 'function') {

View file

@ -0,0 +1,97 @@
import { webFrame } from 'electron';
export async function consoleLogToLogFile(workspaceName = 'error-no-workspace-name'): Promise<void> {
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);
})();
`);
}

View file

@ -16,5 +16,18 @@ export async function fixAlertConfirm(): Promise<void> {
// native window.confirm returns boolean // native window.confirm returns boolean
return Boolean(window.remote.showElectronMessageBoxSync({ message, type: 'question', buttons: [$tw.language.getString('No'), $tw.language.getString('Yes')], defaultId: 1 })); 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;
};
}
`); `);
} }

View file

@ -3,6 +3,8 @@ import '../services/wiki/wikiOperations/executor/wikiOperationInBrowser';
import type { IPossibleWindowMeta, WindowMeta } from '@services/windows/WindowProperties'; import type { IPossibleWindowMeta, WindowMeta } from '@services/windows/WindowProperties';
import { WindowNames } from '@services/windows/WindowProperties'; import { WindowNames } from '@services/windows/WindowProperties';
import { browserViewMetaData, windowName } from './common/browserViewMetaData'; import { browserViewMetaData, windowName } from './common/browserViewMetaData';
import { native } from './common/services';
import { consoleLogToLogFile } from './fixer/consoleLogToLogFile';
let handled = false; let handled = false;
const handleLoaded = (event: string): void => { const handleLoaded = (event: string): void => {
@ -18,7 +20,13 @@ const handleLoaded = (event: string): void => {
}; };
async function executeJavaScriptInBrowserView(): Promise<void> { async function executeJavaScriptInBrowserView(): Promise<void> {
const { workspaceID } = browserViewMetaData as IPossibleWindowMeta<WindowMeta[WindowNames.view]>; const viewMetaData = browserViewMetaData as IPossibleWindowMeta<WindowMeta[WindowNames.view]>;
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 { try {
await webFrame.executeJavaScript(` await webFrame.executeJavaScript(`

View file

@ -131,12 +131,15 @@ export class Git implements IGitService {
private readonly getWorkerMessageObserver = (wikiFolderPath: string, resolve: () => void, reject: (error: Error) => void, workspaceID?: string): Observer<IGitLogMessage> => ({ private readonly getWorkerMessageObserver = (wikiFolderPath: string, resolve: () => void, reject: (error: Error) => void, workspaceID?: string): Observer<IGitLogMessage> => ({
next: (messageObject) => { next: (messageObject) => {
if (messageObject.level === 'error') { if (messageObject.level === 'error') {
const errorMessage = (messageObject.error).message;
// if workspace exists, show notification in workspace, else use dialog instead // if workspace exists, show notification in workspace, else use dialog instead
if (workspaceID === undefined) { if (workspaceID === undefined) {
this.createFailedDialog((messageObject.error).message, wikiFolderPath); this.createFailedDialog(errorMessage, wikiFolderPath);
} else { } else {
this.createFailedNotification((messageObject.error).message, workspaceID); this.createFailedNotification(errorMessage, workspaceID);
} }
// Reject the promise on error to prevent service restart
reject(messageObject.error);
return; return;
} }
const { message, meta, level } = messageObject; const { message, meta, level } = messageObject;
@ -211,7 +214,8 @@ export class Git implements IGitService {
} catch (error: unknown) { } catch (error: unknown) {
const error_ = error as Error; const error_ = error as Error;
this.createFailedNotification(error_.message, workspaceIDToShowNotification); this.createFailedNotification(error_.message, workspaceIDToShowNotification);
return true; // Return false on sync failure - no successful changes were made
return false;
} }
} }

View file

@ -65,7 +65,8 @@ export class Sync implements ISyncService {
const hasChanges = await gitService.syncOrForcePull(workspace, syncOrForcePullConfigs); const hasChanges = await gitService.syncOrForcePull(workspace, syncOrForcePullConfigs);
if (isSubWiki) { if (isSubWiki) {
// after sync this sub wiki, reload its main workspace // 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 workspaceViewService.restartWorkspaceViewService(idToUse);
await viewService.reloadViewsWebContents(idToUse); await viewService.reloadViewsWebContents(idToUse);
} }
@ -88,7 +89,8 @@ export class Sync implements ISyncService {
}); });
const subHasChange = (await Promise.all(subHasChangesPromise)).some(Boolean); const subHasChange = (await Promise.all(subHasChangesPromise)).some(Boolean);
// any of main or sub has changes, reload main workspace // 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 workspaceViewService.restartWorkspaceViewService(id);
await viewService.reloadViewsWebContents(id); await viewService.reloadViewsWebContents(id);
} }
@ -129,6 +131,9 @@ export class Sync implements ISyncService {
return; return;
} }
const { syncOnInterval, backupOnInterval, id } = workspace; const { syncOnInterval, backupOnInterval, id } = workspace;
// Clear existing interval first to avoid duplicates when settings are updated
this.stopIntervalSync(id);
if (syncOnInterval || backupOnInterval) { if (syncOnInterval || backupOnInterval) {
const syncDebounceInterval = await this.preferenceService.get('syncDebounceInterval'); const syncDebounceInterval = await this.preferenceService.get('syncDebounceInterval');
this.wikiSyncIntervals[id] = setInterval(async () => { this.wikiSyncIntervals[id] = setInterval(async () => {

View file

@ -257,7 +257,9 @@ export class View implements IViewService {
const { spellcheck } = preferences; const { spellcheck } = preferences;
const sessionOfView = setupViewSession(workspace, preferences, () => this.preferenceService.getPreferences()); const sessionOfView = setupViewSession(workspace, preferences, () => this.preferenceService.getPreferences());
const browserViewMetaData: IBrowserViewMetaData = { workspaceID: workspace.id }; const browserViewMetaData: IBrowserViewMetaData = {
workspace,
};
return { return {
devTools: true, devTools: true,
spellcheck, spellcheck,

View file

@ -3,7 +3,7 @@ import { createWorkerProxy, terminateWorker } from '@services/libs/workerAdapter
import { dialog, shell } from 'electron'; import { dialog, shell } from 'electron';
import { attachWorker } from 'electron-ipc-cat/server'; import { attachWorker } from 'electron-ipc-cat/server';
import { backOff } from 'exponential-backoff'; 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 { inject, injectable } from 'inversify';
import path from 'path'; import path from 'path';
import { Worker } from 'worker_threads'; import { Worker } from 'worker_threads';
@ -430,31 +430,6 @@ export class Wiki implements IWikiService {
logger.info(message, { handler: WikiChannel.createProgress }); 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<void> {
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<void> { private async createWiki(newFolderPath: string, folderName: string): Promise<void> {
this.logProgress(i18n.t('AddWorkspace.StartUsingTemplateToCreateWiki')); this.logProgress(i18n.t('AddWorkspace.StartUsingTemplateToCreateWiki'));
const newWikiPath = path.join(newFolderPath, folderName); const newWikiPath = path.join(newFolderPath, folderName);
@ -487,7 +462,7 @@ export class Wiki implements IWikiService {
this.logProgress(i18n.t('AddWorkspace.WikiTemplateCopyCompleted') + newWikiPath); this.logProgress(i18n.t('AddWorkspace.WikiTemplateCopyCompleted') + newWikiPath);
} }
public async createSubWiki(parentFolderLocation: string, folderName: string, _subWikiFolderName: string, mainWikiPath: string, _tagName = '', onlyLink = false): Promise<void> { public async createSubWiki(parentFolderLocation: string, folderName: string, _subWikiFolderName: string, _mainWikiPath: string, _tagName = '', onlyLink = false): Promise<void> {
this.logProgress(i18n.t('AddWorkspace.StartCreatingSubWiki')); this.logProgress(i18n.t('AddWorkspace.StartCreatingSubWiki'));
const newWikiPath = path.join(parentFolderLocation, folderName); const newWikiPath = path.join(parentFolderLocation, folderName);
if (!(await pathExists(parentFolderLocation))) { if (!(await pathExists(parentFolderLocation))) {
@ -503,23 +478,16 @@ export class Wiki implements IWikiService {
throw new Error(i18n.t('AddWorkspace.CantCreateFolderHere', { newWikiPath })); 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 // 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')); this.logProgress(i18n.t('AddWorkspace.SubWikiCreationCompleted'));
} }
public async removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink = false): Promise<void> { public async removeWiki(wikiPath: string, _mainWikiToUnLink?: string, _onlyRemoveLink = false): Promise<void> {
if (mainWikiToUnLink !== undefined) { // Sub-wiki configuration is now handled by FileSystemAdaptor - no symlinks to manage
const subWikiName = path.basename(wikiPath); // Just remove the wiki folder itself
await shell.trashItem(path.join(mainWikiToUnLink, TIDDLERS_PATH, this.folderToContainSymlinks, subWikiName));
}
if (!onlyRemoveLink) {
await shell.trashItem(wikiPath); await shell.trashItem(wikiPath);
} }
}
public async ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise<void> { public async ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise<void> {
logger.debug('checking wiki folder', { logger.debug('checking wiki folder', {
@ -640,7 +608,7 @@ export class Wiki implements IWikiService {
public async cloneSubWiki( public async cloneSubWiki(
parentFolderLocation: string, parentFolderLocation: string,
wikiFolderName: string, wikiFolderName: string,
mainWikiPath: string, _mainWikiPath: string,
gitRepoUrl: string, gitRepoUrl: string,
gitUserInfo: IGitUserInfos, gitUserInfo: IGitUserInfos,
_tagName = '', _tagName = '',
@ -660,10 +628,8 @@ export class Wiki implements IWikiService {
} }
const gitService = container.get<IGitService>(serviceIdentifier.Git); const gitService = container.get<IGitService>(serviceIdentifier.Git);
await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo); 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 // 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 // wiki-startup.ts
@ -699,7 +665,11 @@ export class Wiki implements IWikiService {
if (mainWorkspace === undefined) { if (mainWorkspace === undefined) {
throw new SubWikiSMainWikiNotExistError(name ?? id, mainWikiID); throw new SubWikiSMainWikiNotExistError(name ?? id, mainWikiID);
} }
await this.restartWiki(mainWorkspace); // Use restartWorkspaceViewService to restart wiki worker and reload frontend view
const workspaceViewService = container.get<IWorkspaceViewService>(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 { } else {
try { try {

View file

@ -60,7 +60,6 @@ export interface IWikiService {
* @param workspaceID You can get this from active workspace * @param workspaceID You can get this from active workspace
*/ */
getWorker(workspaceID: string): WikiWorker | undefined; getWorker(workspaceID: string): WikiWorker | undefined;
linkWiki(mainWikiPath: string, folderName: string, subWikiPath: string): Promise<void>;
packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise<void>; packetHTMLFromWikiFolder(wikiFolderLocation: string, pathOfNewHTML: string): Promise<void>;
removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink?: boolean): Promise<void>; removeWiki(wikiPath: string, mainWikiToUnLink?: string, onlyRemoveLink?: boolean): Promise<void>;
restartWiki(workspace: IWorkspace): Promise<void>; restartWiki(workspace: IWorkspace): Promise<void>;
@ -78,7 +77,7 @@ export interface IWikiService {
* Runs wiki related JS script in wiki page to control the wiki. * 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. * 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<OP extends keyof ISendWikiOperationsToBrowser>( wikiOperationInBrowser<OP extends keyof ISendWikiOperationsToBrowser>(
operationType: OP, operationType: OP,
@ -110,7 +109,6 @@ export const WikiServiceIPCDescriptor = {
ensureWikiExist: ProxyPropertyType.Function, ensureWikiExist: ProxyPropertyType.Function,
extractWikiHTML: ProxyPropertyType.Function, extractWikiHTML: ProxyPropertyType.Function,
getWikiErrorLogs: ProxyPropertyType.Function, getWikiErrorLogs: ProxyPropertyType.Function,
linkWiki: ProxyPropertyType.Function,
getTiddlerFilePath: ProxyPropertyType.Function, getTiddlerFilePath: ProxyPropertyType.Function,
packetHTMLFromWikiFolder: ProxyPropertyType.Function, packetHTMLFromWikiFolder: ProxyPropertyType.Function,
removeWiki: ProxyPropertyType.Function, removeWiki: ProxyPropertyType.Function,

View file

@ -11,7 +11,8 @@ function getInfoTiddlerFields(updateInfoTiddlersCallback: (infos: Array<{ text:
// Basics // Basics
if (!$tw.browser || typeof window === 'undefined') return infoTiddlerFields; if (!$tw.browser || typeof window === 'undefined') return infoTiddlerFields;
const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi'); 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) }); infoTiddlerFields.push({ title: '$:/info/tidgi', text: mapBoolean(isInTidGi) });
if (isInTidGi && workspaceID) { if (isInTidGi && workspaceID) {
infoTiddlerFields.push({ title: '$:/info/tidgi/workspaceID', text: workspaceID }); infoTiddlerFields.push({ title: '$:/info/tidgi/workspaceID', text: workspaceID });

View file

@ -39,7 +39,11 @@ class TidGiIPCSyncAdaptor {
this.isLoggedIn = false; this.isLoggedIn = false;
this.isReadOnly = false; this.isReadOnly = false;
this.logoutIsAvailable = true; 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 (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. // 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(); 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` * 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() { setupSSE() {
console.log('setupSSE called in TidGiIPCSyncAdaptor');
if (window.observables?.wiki?.getWikiChangeObserver$ === undefined) { if (window.observables?.wiki?.getWikiChangeObserver$ === undefined) {
console.error("getWikiChangeObserver$ is undefined in window.observables.wiki, can't subscribe to server changes."); console.error("getWikiChangeObserver$ is undefined in window.observables.wiki, can't subscribe to server changes.");
return; return;
@ -73,9 +78,12 @@ class TidGiIPCSyncAdaptor {
if (!change[title]) { if (!change[title]) {
return; 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); 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); this.updatedTiddlers.modifications.push(title);
} }
}); });
@ -89,28 +97,6 @@ class TidGiIPCSyncAdaptor {
deletions: [], 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() { clearUpdatedTiddlers() {
this.updatedTiddlers = { this.updatedTiddlers = {
modifications: [], modifications: [],
@ -225,7 +211,6 @@ class TidGiIPCSyncAdaptor {
return; return;
} }
this.logger.log(`saveTiddler ${title}`); this.logger.log(`saveTiddler ${title}`);
this.addRecentUpdatedTiddlersFromClient('modifications', title);
const putTiddlerResponse = await this.wikiService.callWikiIpcServerRoute( const putTiddlerResponse = await this.wikiService.callWikiIpcServerRoute(
this.workspaceID, this.workspaceID,
'putTiddler', 'putTiddler',
@ -285,7 +270,7 @@ class TidGiIPCSyncAdaptor {
return; return;
} }
this.logger.log('deleteTiddler'); 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( const getTiddlerResponse = await this.wikiService.callWikiIpcServerRoute(
this.workspaceID, this.workspaceID,
'deleteTiddler', 'deleteTiddler',
@ -334,7 +319,7 @@ class TidGiIPCSyncAdaptor {
if ($tw.browser && typeof window !== 'undefined') { if ($tw.browser && typeof window !== 'undefined') {
const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi'); const isInTidGi = typeof document !== 'undefined' && document.location.protocol.startsWith('tidgi');
const servicesExposed = Boolean(window.service.wiki); 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) { if (isInTidGi && servicesExposed && hasWorkspaceIDinMeta) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
exports.adaptorClass = TidGiIPCSyncAdaptor; exports.adaptorClass = TidGiIPCSyncAdaptor;

View file

@ -2,6 +2,7 @@ import type { Logger } from '$:/core/modules/utils/logger.js';
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface'; import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface';
import { backOff } from 'exponential-backoff'; import { backOff } from 'exponential-backoff';
import fs from 'fs';
import path from 'path'; import path from 'path';
import type { FileInfo } from 'tiddlywiki'; import type { FileInfo } from 'tiddlywiki';
import type { Tiddler, Wiki } 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. * Main routing logic: determine where a tiddler should be saved based on its tags.
* For draft tiddlers, check the original tiddler's tags.
*/ */
async getTiddlerFileInfo(tiddler: Tiddler): Promise<FileInfo | null> { async getTiddlerFileInfo(tiddler: Tiddler): Promise<FileInfo | null> {
if (!this.boot.wikiTiddlersPath) { if (!this.boot.wikiTiddlersPath) {
@ -123,10 +125,23 @@ export class FileSystemAdaptor {
} }
const title = tiddler.fields.title; const title = tiddler.fields.title;
const tags = tiddler.fields.tags ?? []; let tags = tiddler.fields.tags ?? [];
const fileInfo = this.boot.files[title]; const fileInfo = this.boot.files[title];
try { 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; let matchingSubWiki: IWikiWorkspace | undefined;
for (const tag of tags) { for (const tag of tags) {
matchingSubWiki = this.tagNameToSubWiki.get(tag); matchingSubWiki = this.tagNameToSubWiki.get(tag);
@ -148,9 +163,22 @@ export class FileSystemAdaptor {
/** /**
* Generate file info for sub-wiki directory * 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 { 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); $tw.utils.createDirectory(targetDirectory);
return $tw.utils.generateTiddlerFileInfo(tiddler, { return $tw.utils.generateTiddlerFileInfo(tiddler, {
@ -186,7 +214,7 @@ export class FileSystemAdaptor {
* Save a tiddler to the filesystem * Save a tiddler to the filesystem
* Can be used with callback (legacy) or as async/await * Can be used with callback (legacy) or as async/await
*/ */
async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> { async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, _options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
try { try {
const fileInfo = await this.getTiddlerFileInfo(tiddler); const fileInfo = await this.getTiddlerFileInfo(tiddler);
@ -198,6 +226,9 @@ export class FileSystemAdaptor {
const savedFileInfo = await this.saveTiddlerWithRetry(tiddler, fileInfo); 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] = { this.boot.files[tiddler.fields.title] = {
...savedFileInfo, ...savedFileInfo,
isEditableFile: savedFileInfo.isEditableFile ?? true, isEditableFile: savedFileInfo.isEditableFile ?? true,
@ -205,8 +236,8 @@ export class FileSystemAdaptor {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const cleanupOptions = { const cleanupOptions = {
adaptorInfo: options?.tiddlerInfo as FileInfo | undefined, adaptorInfo: oldFileInfo, // Old file info to be deleted
bootInfo: this.boot.files[tiddler.fields.title], bootInfo: savedFileInfo, // New file info to be kept
title: tiddler.fields.title, title: tiddler.fields.title,
}; };
$tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: FileInfo) => { $tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: FileInfo) => {

View file

@ -1,7 +1,15 @@
import type nsfw from 'nsfw';
import path from 'path';
import type { FileInfo } from 'tiddlywiki'; import type { FileInfo } from 'tiddlywiki';
export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string }; 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. * Inverse index for mapping file paths to tiddler information.
* Uses Map for better performance with frequent add/delete operations. * Uses Map for better performance with frequent add/delete operations.
@ -11,6 +19,80 @@ export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string };
*/ */
export class InverseFilesIndex { export class InverseFilesIndex {
private index: Map<string, IBootFilesIndexItemWithTitle> = new Map(); private index: Map<string, IBootFilesIndexItemWithTitle> = 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<string, ISubWikiInfo> = new Map();
/** Temporarily excluded files for main watcher (by absolute path) */
private mainExcludedFiles: Set<string> = new Set();
/** Temporarily excluded files for each sub-wiki watcher (by absolute path) */
private subWikiExcludedFiles: Map<string, Set<string>> = 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 * Set or update tiddler information for a file path
@ -87,4 +169,62 @@ export class InverseFilesIndex {
values(): IterableIterator<IBootFilesIndexItemWithTitle> { values(): IterableIterator<IBootFilesIndexItemWithTitle> {
return this.index.values(); 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) : [];
}
} }

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { workspace } from '@services/wiki/wikiWorker/services'; import { workspace } from '@services/wiki/wikiWorker/services';
import fs from 'fs'; import fs from 'fs';
import nsfw from 'nsfw'; 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 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. * 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'; 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(); private inverseFilesIndex: InverseFilesIndex = new InverseFilesIndex();
/** NSFW watcher instance */ /** NSFW watcher instance for main wiki */
private watcher: nsfw.NSFW | undefined; private watcher: nsfw.NSFW | undefined;
/** Base excluded paths (permanent) */ /** Base excluded paths (permanent) */
private baseExcludedPaths: string[] = []; private baseExcludedPaths: string[] = [];
/** Temporarily excluded files being modified by wiki */ /**
private temporarilyExcludedFiles: Set<string> = 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<string, NodeJS.Timeout> = new Map();
constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) { constructor(options: { boot?: typeof $tw.boot; wiki: Wiki }) {
super(options); super(options);
this.logger = new $tw.utils.Logger('watch-filesystem', { colour: 'purple' }); this.logger = new $tw.utils.Logger('watch-filesystem', { colour: 'purple' });
// Initialize main wiki path in index
this.inverseFilesIndex.setMainWikiPath(this.watchPathBase);
// Initialize file watching // Initialize file watching
void this.initializeFileWatching(); void this.initializeFileWatching();
} }
@ -64,10 +71,8 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
* Can be used with callback (legacy) or as async/await * Can be used with callback (legacy) or as async/await
*/ */
override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> { override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
let fileRelativePath: string | null = null;
try { try {
// Get file info to calculate relative path for watching // Get file info to calculate path for watching
const fileInfo = await this.getTiddlerFileInfo(tiddler); const fileInfo = await this.getTiddlerFileInfo(tiddler);
if (!fileInfo) { if (!fileInfo) {
const error = new Error('No fileInfo returned from getTiddlerFileInfo'); const error = new Error('No fileInfo returned from getTiddlerFileInfo');
@ -75,16 +80,28 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
throw error; 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 // Log tiddler text for debugging
await this.excludeFile(fileRelativePath); 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); await super.saveTiddler(tiddler, undefined, options);
// Update inverse index after successful save // Update inverse index after successful save
const finalFileInfo = this.boot.files[tiddler.fields.title]; const finalFileInfo = this.boot.files[tiddler.fields.title];
const fileRelativePath = path.relative(this.watchPathBase, finalFileInfo.filepath);
this.inverseFilesIndex.set(fileRelativePath, { this.inverseFilesIndex.set(fileRelativePath, {
...finalFileInfo, ...finalFileInfo,
filepath: fileRelativePath, filepath: fileRelativePath,
@ -94,20 +111,16 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Notify callback if provided // Notify callback if provided
callback?.(null, finalFileInfo); callback?.(null, finalFileInfo);
// Re-include the file after a short delay // Schedule file re-inclusion after save AND cleanup complete
setTimeout(() => { // This ensures we don't detect our own file operations
if (fileRelativePath) { this.scheduleFileInclusion(finalFileInfo.filepath);
void this.includeFile(fileRelativePath);
// 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);
} }
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
} catch (error) { } catch (error) {
// Re-include the file on error
if (fileRelativePath) {
const pathToInclude = fileRelativePath;
setTimeout(() => {
void this.includeFile(pathToInclude);
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
}
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
callback?.(errorObject); callback?.(errorObject);
throw errorObject; throw errorObject;
@ -142,15 +155,11 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Notify callback if provided // Notify callback if provided
callback?.(null, null); callback?.(null, null);
// Re-include the file after a delay (cleanup the exclusion list) // Schedule file re-inclusion after deletion completes
setTimeout(() => { this.scheduleFileInclusion(fileRelativePath);
void this.includeFile(fileRelativePath);
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
} catch (error) { } catch (error) {
// Re-include the file on error // Schedule file re-inclusion on error to clean up exclusion list
setTimeout(() => { this.scheduleFileInclusion(fileRelativePath);
void this.includeFile(fileRelativePath);
}, FILE_EXCLUSION_CLEANUP_DELAY_MS);
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error'); const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
callback?.(errorObject); callback?.(errorObject);
throw errorObject; throw errorObject;
@ -210,10 +219,8 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Start watching // Start watching
await this.watcher.start(); await this.watcher.start();
// Initialize sub-wiki watchers
this.logger.log('[WATCH_FS_READY] Filesystem watcher is ready'); await this.initializeSubWikiWatchers();
this.logger.log('[WATCH_FS_READY] Watching path:', this.watchPathBase);
// Log stabilization marker for tests // Log stabilization marker for tests
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized'); this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized');
} catch (error) { } catch (error) {
@ -221,6 +228,63 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
} }
} }
/**
* Initialize watchers for sub-wikis
*/
private async initializeSubWikiWatchers(): Promise<void> {
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 * 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 * Update watcher's excluded paths with current temporary exclusions
*/ */
private async updateWatcherExcludedPaths(): Promise<void> { private async updateWatcherExcludedPaths(): Promise<void> {
if (!this.watcher) { // Update main watcher
return; if (this.watcher) {
} const allExcludedPaths = this.inverseFilesIndex.getMainWatcherExcludedPaths(this.baseExcludedPaths);
// 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] // @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string]
await this.watcher.updateExcludedPaths(allExcludedPaths); 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) * Temporarily exclude a file from watching (e.g., during save/delete)
* @param absoluteFilePath Absolute file path
*/ */
private async excludeFile(fileRelativePath: string): Promise<void> { private async excludeFile(absoluteFilePath: string): Promise<void> {
this.temporarilyExcludedFiles.add(fileRelativePath); this.logger.log(`[WATCH_FS_EXCLUDE] Excluding file: ${absoluteFilePath}`);
this.inverseFilesIndex.excludeFile(absoluteFilePath);
await this.updateWatcherExcludedPaths(); await this.updateWatcherExcludedPaths();
} }
/** /**
* Remove a file from temporary exclusions * Remove a file from temporary exclusions
* @param absoluteFilePath Absolute file path
*/ */
private async includeFile(fileRelativePath: string): Promise<void> { private async includeFile(absoluteFilePath: string): Promise<void> {
this.temporarilyExcludedFiles.delete(fileRelativePath); this.logger.log(`[WATCH_FS_INCLUDE] Including file: ${absoluteFilePath}`);
this.inverseFilesIndex.includeFile(absoluteFilePath);
await this.updateWatcherExcludedPaths(); 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 * Handle NSFW file system change events
*/ */
@ -285,20 +375,24 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
fileName = event.newFile; fileName = event.newFile;
} }
// Compute relative and absolute paths // Compute absolute path
const fileAbsolutePath = path.join(directory, fileName); 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 fileNameBase = path.parse(fileAbsolutePath).name;
const fileExtension = path.extname(fileRelativePath); const fileExtension = path.extname(fileRelativePath);
const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki'; const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki';
const metaFileAbsolutePath = `${fileAbsolutePath}.meta`; 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 // Handle different event types
if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) { if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) {
this.handleFileAddOrChange( void this.handleFileAddOrChange(
fileAbsolutePath, fileAbsolutePath,
fileRelativePath, fileRelativePath,
metaFileAbsolutePath, metaFileAbsolutePath,
@ -315,20 +409,24 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Handle as delete old + create new // Handle as delete old + create new
if ('oldFile' in event && 'newFile' in event) { if ('oldFile' in event && 'newFile' in event) {
const oldFileAbsPath = path.join(directory, event.oldFile); 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); const oldFileExtension = path.extname(oldFileRelativePath);
this.handleFileDelete(oldFileAbsPath, oldFileRelativePath, oldFileExtension); this.handleFileDelete(oldFileAbsPath, oldFileRelativePath, oldFileExtension);
const newDirectory = 'newDirectory' in event ? event.newDirectory : directory; const newDirectory = 'newDirectory' in event ? event.newDirectory : directory;
const newFileAbsPath = path.join(newDirectory, event.newFile); 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 newFileName = event.newFile;
const newFileNameBase = path.parse(newFileAbsPath).name; const newFileNameBase = path.parse(newFileAbsPath).name;
const newFileExtension = path.extname(newFileRelativePath); const newFileExtension = path.extname(newFileRelativePath);
const newFileMimeType = $tw.utils.getFileExtensionInfo(newFileExtension)?.type ?? 'text/vnd.tiddlywiki'; const newFileMimeType = $tw.utils.getFileExtensionInfo(newFileExtension)?.type ?? 'text/vnd.tiddlywiki';
const newMetaFileAbsPath = `${newFileAbsPath}.meta`; const newMetaFileAbsPath = `${newFileAbsPath}.meta`;
this.handleFileAddOrChange( void this.handleFileAddOrChange(
newFileAbsPath, newFileAbsPath,
newFileRelativePath, newFileRelativePath,
newMetaFileAbsPath, newMetaFileAbsPath,
@ -346,7 +444,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
/** /**
* Handle file add or change events * Handle file add or change events
*/ */
private handleFileAddOrChange( private async handleFileAddOrChange(
fileAbsolutePath: string, fileAbsolutePath: string,
fileRelativePath: string, fileRelativePath: string,
metaFileAbsolutePath: string, metaFileAbsolutePath: string,
@ -355,7 +453,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
fileExtension: string, fileExtension: string,
fileMimeType: string, fileMimeType: string,
changeType: 'add' | 'change', changeType: 'add' | 'change',
): void { ): Promise<void> {
// For .meta files, we need to load the corresponding base file // For .meta files, we need to load the corresponding base file
let actualFileToLoad = fileAbsolutePath; let actualFileToLoad = fileAbsolutePath;
let actualFileRelativePath = fileRelativePath; let actualFileRelativePath = fileRelativePath;
@ -391,13 +489,13 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
const { tiddlers, ...fileDescriptor } = tiddlersDescriptor; const { tiddlers, ...fileDescriptor } = tiddlersDescriptor;
// Process each tiddler from the file // 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, // Note: $tw.loadTiddlersFromFile returns tiddlers as plain objects with fields at top level,
// not wrapped in a .fields property // not wrapped in a .fields property
const tiddlerTitle = tiddler?.title; const tiddlerTitle = tiddler?.title;
if (!tiddlerTitle) { if (!tiddlerTitle) {
this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title`); this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title. Tiddler object: ${JSON.stringify(tiddler)}`);
return; continue;
} }
const isNewFile = !this.inverseFilesIndex.has(actualFileRelativePath); const isNewFile = !this.inverseFilesIndex.has(actualFileRelativePath);
@ -410,7 +508,6 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
} as IBootFilesIndexItemWithTitle); } as IBootFilesIndexItemWithTitle);
// Add tiddler to wiki (this will update if it exists or add if new) // Add tiddler to wiki (this will update if it exists or add if new)
$tw.syncadaptor!.wiki.addTiddler(tiddler); $tw.syncadaptor!.wiki.addTiddler(tiddler);
// Log appropriate event // Log appropriate event
@ -419,7 +516,7 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
} else { } else {
this.logger.log(`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`); this.logger.log(`[test-id-WATCH_FS_TIDDLER_UPDATED] ${tiddlerTitle}`);
} }
}); }
} }
/** /**
@ -488,10 +585,12 @@ class WatchFileSystemAdaptor extends FileSystemAdaptor {
this.watcher = undefined; this.watcher = undefined;
this.logger.log('[WATCH_FS_CLEANUP] Filesystem watcher closed'); 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;
}

View file

@ -0,0 +1,3 @@
title: $:/plugins/linonetwo/watch-filesystem-adaptor/loader.js
type: application/javascript
module-type: syncadaptor

View file

@ -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;
}

View file

@ -1,3 +0,0 @@
title: $:/plugins/linonetwo/watch-filesystem-adaptor/watch-filesystem-adaptor.js
type: application/javascript
module-type: syncadaptor

View file

@ -27,6 +27,8 @@ export class IpcServerRoutes {
private wikiInstance!: ITiddlyWiki; private wikiInstance!: ITiddlyWiki;
private readonly pendingIpcServerRoutesRequests: Array<(value: void | PromiseLike<void>) => void> = []; private readonly pendingIpcServerRoutesRequests: Array<(value: void | PromiseLike<void>) => void> = [];
#readonlyMode = false; #readonlyMode = false;
/** Track tiddlers that were just saved via IPC to prevent echo */
private readonly recentlySavedTiddlers = new Set<string>();
setConfig({ readOnlyMode }: { readOnlyMode?: boolean }) { setConfig({ readOnlyMode }: { readOnlyMode?: boolean }) {
this.#readonlyMode = Boolean(readOnlyMode); this.#readonlyMode = Boolean(readOnlyMode);
@ -183,7 +185,16 @@ export class IpcServerRoutes {
} }
} }
tiddlerFieldsToPut.title = title; 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)); 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(); const changeCount = this.wikiInstance.wiki.getChangeCount(title).toString();
return { statusCode: 204, headers: { 'Content-Type': 'text/plain', Etag: `"default/${encodeURIComponent(title)}/${changeCount}:"` }, data: 'OK' }; 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.`)); observer.error(new Error(`this.wikiInstance is undefined, maybe something went wrong between waitForIpcServerRoutesAvailable and return new Observable.`));
} }
this.wikiInstance.wiki.addEventListener('change', (changes) => { this.wikiInstance.wiki.addEventListener('change', (changes) => {
observer.next(changes); // Filter out 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(); void getWikiChangeObserverInWorkerIIFE();
}); });

View file

@ -1,5 +1,6 @@
import type { CreateWorkspaceTabs } from '@/windows/AddWorkspace/constants'; import type { CreateWorkspaceTabs } from '@/windows/AddWorkspace/constants';
import type { PreferenceSections } from '@services/preferences/interface'; import type { PreferenceSections } from '@services/preferences/interface';
import { IWorkspace } from '@services/workspaces/interface';
export enum WindowNames { export enum WindowNames {
about = 'about', about = 'about',
@ -105,7 +106,7 @@ export interface WindowMeta {
[WindowNames.preferences]: IPreferenceWindowMeta; [WindowNames.preferences]: IPreferenceWindowMeta;
[WindowNames.spellcheck]: undefined; [WindowNames.spellcheck]: undefined;
[WindowNames.secondary]: undefined; [WindowNames.secondary]: undefined;
[WindowNames.view]: { workspaceID?: string }; [WindowNames.view]: IBrowserViewMetaData;
} }
export type IPossibleWindowMeta<M extends WindowMeta[WindowNames] = WindowMeta[WindowNames.main]> = { export type IPossibleWindowMeta<M extends WindowMeta[WindowNames] = WindowMeta[WindowNames.main]> = {
windowName: WindowNames; windowName: WindowNames;
@ -116,5 +117,5 @@ export type IPossibleWindowMeta<M extends WindowMeta[WindowNames] = WindowMeta[W
*/ */
export interface IBrowserViewMetaData { export interface IBrowserViewMetaData {
isPopup?: boolean; isPopup?: boolean;
workspaceID?: string; workspace?: IWorkspace;
} }

View file

@ -291,6 +291,7 @@ export const WorkspaceServiceIPCDescriptor = {
getMetaData: ProxyPropertyType.Function, getMetaData: ProxyPropertyType.Function,
getNextWorkspace: ProxyPropertyType.Function, getNextWorkspace: ProxyPropertyType.Function,
getPreviousWorkspace: ProxyPropertyType.Function, getPreviousWorkspace: ProxyPropertyType.Function,
getSubWorkspacesAsList: ProxyPropertyType.Function,
getWorkspaces: ProxyPropertyType.Function, getWorkspaces: ProxyPropertyType.Function,
getWorkspacesAsList: ProxyPropertyType.Function, getWorkspacesAsList: ProxyPropertyType.Function,
getWorkspacesWithMetadata: ProxyPropertyType.Function, getWorkspacesWithMetadata: ProxyPropertyType.Function,

View file

@ -318,9 +318,8 @@ export default function EditWorkspace(): React.JSX.Element {
{isCreateSyncedWorkspace && ( {isCreateSyncedWorkspace && (
<TokenForm <TokenForm
storageProvider={storageService} storageProvider={storageService}
storageProviderSetter={(nextStorageService: SupportedStorageServices) => { storageProviderSetter={(nextStorageService) => {
workspaceSetter({ ...workspace, storageService: nextStorageService }); workspaceSetter({ ...workspace, storageService: nextStorageService });
// requestRestartCountDown();
}} }}
/> />
)} )}

View file

@ -15,14 +15,14 @@ import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 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 { ListItemButton } from '@mui/material';
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
import { formatDate } from '@services/libs/formatDate'; import { formatDate } from '@services/libs/formatDate';
import { useNotificationInfoObservable } from '@services/notifications/hooks'; import { useNotificationInfoObservable } from '@services/notifications/hooks';
import { usePreferenceObservable } from '@services/preferences/hooks'; import { usePreferenceObservable } from '@services/preferences/hooks';
import { PreferenceSections } from '@services/preferences/interface'; import { PreferenceSections } from '@services/preferences/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import nightBackgroundPng from '../../images/night-background.png'; import nightBackgroundPng from '../../images/night-background.png';
import { quickShortcuts } from './quickShortcuts'; import { quickShortcuts } from './quickShortcuts';
@ -38,7 +38,6 @@ const List = styled((props: React.ComponentProps<typeof ListRaw>) => <ListRaw de
width: 100%; width: 100%;
`; `;
// TODO: handle classes={{ root: classes.pausingHeader }}
const PausingHeader = styled((props: React.ComponentProps<typeof ListItem>) => <ListItem {...props} />)` const PausingHeader = styled((props: React.ComponentProps<typeof ListItem>) => <ListItem {...props} />)`
background: url(${nightBackgroundPng}); background: url(${nightBackgroundPng});
height: 210px; height: 210px;
@ -46,16 +45,15 @@ const PausingHeader = styled((props: React.ComponentProps<typeof ListItem>) => <
align-items: flex-end; align-items: flex-end;
`; `;
// TODO: handle classes={{ primary: classes.pausingHeaderText }}
const PausingHeaderText = styled((props: React.ComponentProps<typeof ListItemText>) => <ListItemText {...props} />)` const PausingHeaderText = styled((props: React.ComponentProps<typeof ListItemText>) => <ListItemText {...props} />)`
color: white; color: white;
`; `;
const pauseNotification = (tilDate: Date): void => { const pauseNotification = (tilDate: Date, t: ReturnType<typeof useTranslation>['t']): void => {
void window.service.preference.set('pauseNotifications', `pause:${tilDate.toString()}`); void window.service.preference.set('pauseNotifications', `pause:${tilDate.toString()}`);
void window.service.notification.show({ void window.service.notification.show({
title: 'Notifications paused', title: t('Notification.Paused'),
body: `Notifications paused until ${formatDate(tilDate)}.`, body: t('Notification.PausedUntil', { date: formatDate(tilDate) }),
}); });
void window.remote.closeCurrentWindow(); void window.remote.closeCurrentWindow();
}; };
@ -74,11 +72,11 @@ export default function Notifications(): React.JSX.Element {
return ( return (
<List> <List>
<PausingHeader> <PausingHeader>
<PausingHeaderText primary={`Notifications paused until ${formatDate(new Date(pauseNotificationsInfo.tilDate))}.`} /> <PausingHeaderText primary={t('Notification.PausedUntil', { date: formatDate(new Date(pauseNotificationsInfo.tilDate)) })} />
</PausingHeader> </PausingHeader>
<ListItemButton> <ListItemButton>
<ListItemText <ListItemText
primary='Resume notifications' primary={t('Notification.Resume')}
onClick={async () => { onClick={async () => {
if (pauseNotificationsInfo === undefined) { if (pauseNotificationsInfo === undefined) {
return; return;
@ -91,8 +89,8 @@ export default function Notifications(): React.JSX.Element {
await window.service.preference.set('pauseNotifications', undefined); await window.service.preference.set('pauseNotifications', undefined);
} }
await window.service.notification.show({ await window.service.notification.show({
title: 'Notifications resumed', title: t('Notification.Resumed'),
body: 'Notifications are now resumed.', body: t('Notification.NotificationsNowResumed'),
}); });
void window.remote.closeCurrentWindow(); void window.remote.closeCurrentWindow();
}} }}
@ -105,20 +103,20 @@ export default function Notifications(): React.JSX.Element {
{(popupState) => ( {(popupState) => (
<> <>
<ListItemButton {...bindTrigger(popupState)}> <ListItemButton {...bindTrigger(popupState)}>
<ListItemText primary='Adjust time' /> <ListItemText primary={t('Notification.AdjustTime')} />
<ChevronRightIcon color='action' /> <ChevronRightIcon color='action' />
</ListItemButton> </ListItemButton>
<Menu {...bindMenu(popupState)}> <Menu {...bindMenu(popupState)}>
{quickShortcuts.map((shortcut) => ( {quickShortcuts.map((shortcut) => (
<MenuItem <MenuItem
dense dense
key={shortcut.name} key={shortcut.key}
onClick={() => { onClick={() => {
pauseNotification(shortcut.calcDate()); pauseNotification(shortcut.calcDate(), t);
popupState.close(); popupState.close();
}} }}
> >
{shortcut.name} {t(shortcut.key, { defaultValue: shortcut.name })}
</MenuItem> </MenuItem>
))} ))}
<MenuItem <MenuItem
@ -128,7 +126,7 @@ export default function Notifications(): React.JSX.Element {
popupState.close(); popupState.close();
}} }}
> >
Custom... {t('Notification.Custom', { defaultValue: 'Custom...' })}
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>
@ -139,7 +137,9 @@ export default function Notifications(): React.JSX.Element {
<Divider /> <Divider />
<ListItemButton> <ListItemButton>
<ListItemText <ListItemText
primary={pauseNotificationsInfo.reason === 'scheduled' ? 'Adjust schedule...' : 'Pause notifications by schedule...'} primary={pauseNotificationsInfo.reason === 'scheduled'
? t('Notification.AdjustSchedule', { defaultValue: 'Adjust schedule...' })
: t('Notification.PauseBySchedule', { defaultValue: 'Pause notifications by schedule...' })}
onClick={async () => { onClick={async () => {
await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications });
void window.remote.closeCurrentWindow(); void window.remote.closeCurrentWindow();
@ -151,15 +151,15 @@ export default function Notifications(): React.JSX.Element {
} }
return ( return (
<List subheader={<ListSubheader component='div'>Pause notifications</ListSubheader>}> <List subheader={<ListSubheader component='div'>{t('Notification.PauseNotifications', { defaultValue: 'Pause notifications' })}</ListSubheader>}>
{quickShortcuts.map((shortcut) => ( {quickShortcuts.map((shortcut) => (
<ListItemButton <ListItemButton
key={shortcut.name} key={shortcut.key}
onClick={() => { onClick={() => {
pauseNotification(shortcut.calcDate()); pauseNotification(shortcut.calcDate(), t);
}} }}
> >
<ListItemText primary={shortcut.name} /> <ListItemText primary={t(shortcut.key, { defaultValue: shortcut.name })} />
</ListItemButton> </ListItemButton>
))} ))}
<ListItemButton <ListItemButton
@ -167,12 +167,12 @@ export default function Notifications(): React.JSX.Element {
showDateTimePickerSetter(true); showDateTimePickerSetter(true);
}} }}
> >
<ListItemText primary='Custom...' /> <ListItemText primary={t('Notification.Custom', { defaultValue: 'Custom...' })} />
</ListItemButton> </ListItemButton>
<Divider /> <Divider />
<ListItemButton> <ListItemButton>
<ListItemText <ListItemText
primary='Pause notifications by schedule...' primary={t('Notification.PauseBySchedule', { defaultValue: 'Pause notifications by schedule...' })}
onClick={async () => { onClick={async () => {
await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications }); await window.service.window.open(WindowNames.preferences, { preferenceGotoTab: PreferenceSections.notifications });
void window.remote.closeCurrentWindow(); void window.remote.closeCurrentWindow();
@ -193,9 +193,9 @@ export default function Notifications(): React.JSX.Element {
value={new Date()} value={new Date()}
onChange={(tilDate) => { onChange={(tilDate) => {
if (tilDate === null) return; if (tilDate === null) return;
pauseNotification(tilDate); pauseNotification(tilDate, t);
}} }}
label='Custom' label={t('Notification.Custom', { defaultValue: 'Custom' })}
open={showDateTimePicker} open={showDateTimePicker}
onOpen={() => { onOpen={() => {
showDateTimePickerSetter(true); showDateTimePickerSetter(true);

View file

@ -1,52 +1,75 @@
import { t } from '@services/libs/i18n/placeholder';
import { addDays, addHours, addMinutes, addWeeks } from 'date-fns'; 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', name: '15 minutes',
key: t('Notification.Pause15Minutes'),
calcDate: () => addMinutes(new Date(), 15), calcDate: () => addMinutes(new Date(), 15),
}, },
{ {
name: '30 minutes', name: '30 minutes',
key: t('Notification.Pause30Minutes'),
calcDate: () => addMinutes(new Date(), 30), calcDate: () => addMinutes(new Date(), 30),
}, },
{ {
name: '45 minutes', name: '45 minutes',
key: t('Notification.Pause45Minutes'),
calcDate: () => addMinutes(new Date(), 45), calcDate: () => addMinutes(new Date(), 45),
}, },
{ {
name: '1 hour', name: '1 hour',
key: t('Notification.Pause1Hour'),
calcDate: () => addHours(new Date(), 1), calcDate: () => addHours(new Date(), 1),
}, },
{ {
name: '2 hours', name: '2 hours',
key: t('Notification.Pause2Hours'),
calcDate: () => addHours(new Date(), 2), calcDate: () => addHours(new Date(), 2),
}, },
{ {
name: '4 hours', name: '4 hours',
key: t('Notification.Pause4Hours'),
calcDate: () => addHours(new Date(), 4), calcDate: () => addHours(new Date(), 4),
}, },
{ {
name: '6 hours', name: '6 hours',
key: t('Notification.Pause6Hours'),
calcDate: () => addHours(new Date(), 6), calcDate: () => addHours(new Date(), 6),
}, },
{ {
name: '8 hours', name: '8 hours',
key: t('Notification.Pause8Hours'),
calcDate: () => addHours(new Date(), 8), calcDate: () => addHours(new Date(), 8),
}, },
{ {
name: '10 hours', name: '10 hours',
calcDate: () => addHours(new Date(), 8), key: t('Notification.Pause10Hours'),
calcDate: () => addHours(new Date(), 10),
}, },
{ {
name: '12 hours', name: '12 hours',
key: t('Notification.Pause12Hours'),
calcDate: () => addHours(new Date(), 12), calcDate: () => addHours(new Date(), 12),
}, },
{ {
name: 'Until tomorrow', name: 'Until tomorrow',
key: t('Notification.PauseUntilTomorrow'),
calcDate: () => addDays(new Date(), 1), calcDate: () => addDays(new Date(), 1),
}, },
{ {
name: 'Until next week', name: 'Until next week',
key: t('Notification.PauseUntilNextWeek'),
calcDate: () => addWeeks(new Date(), 1), calcDate: () => addWeeks(new Date(), 1),
}, },
]; ];

View file

@ -1,6 +1,5 @@
import { Divider, List, Switch } from '@mui/material'; import { Divider, List, Switch } from '@mui/material';
import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import { TimePicker } from '@mui/x-date-pickers/TimePicker';
import { fromUnixTime, setDate, setMonth, setYear } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TokenForm } from '../../../components/TokenForm'; import { TokenForm } from '../../../components/TokenForm';
@ -65,12 +64,16 @@ export function Sync(props: Required<ISectionProps>): React.JSX.Element {
openTo='hours' openTo='hours'
views={['hours', 'minutes', 'seconds']} views={['hours', 'minutes', 'seconds']}
format='HH:mm:ss' 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) => { onChange={async (date) => {
if (date === null) throw new Error(`date is null`); if (date === null) throw new Error(`date is null`);
const timeWithoutDate = setDate(setMonth(setYear(date, 1970), 0), 1); // Extract hours, minutes, seconds from the date and convert to milliseconds
const utcTime = (timeWithoutDate.getTime() / 1000 - new Date().getTimezoneOffset() * 60) * 1000; // This is timezone-independent because we're just extracting time components
await window.service.preference.set('syncDebounceInterval', utcTime); 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(); props.requestRestartCountDown();
}} }}
onClose={async () => { onClose={async () => {

View file

@ -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;
}
});
});
});

View file

@ -1,6 +1,6 @@
{ {
"exclude": ["template/**/*.js", "features/cucumber.config.js", "**/__mocks__/**/*.js"], "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": { "ts-node": {
"files": true, "files": true,
"compilerOptions": { "compilerOptions": {