From c2be8e4186df2f304bc67d2aa2b85be8242516b0 Mon Sep 17 00:00:00 2001 From: lin onetwo Date: Sun, 7 Dec 2025 03:31:34 +0800 Subject: [PATCH] Fix/sub wiki tag tree (#667) * fix: different out path on macos * fix: let go hibernation promise, so it's faster on macos * log marker [test-id-TIDGI_MINI_WINDOW_CREATED] * Skip registerShortcutByKey in test * fix: mini window on mac * Remove useless log marker * Update handleAttachToTidgiMiniWindow.ts * fix: log marker check all log files * lint * fix: open in new window now showing wiki title * Update package.json * feat: basic load and save to sub wiki using in-tag-tree-of * fix: load sub-wiki content and prevent echo * fix: test and ui logic * test: refactor subwiki test logic to a file * refactor: shorten steps by using dedicated step, and test underlying micro steps * fix: review * refactor: remove outdated method signature * test: unit cover adaptor subwiki routing * Update FileSystemAdaptor.routing.test.ts * fix: merge issue --- features/filesystemPlugin.feature | 126 +---- features/hibernation.feature | 18 +- features/stepDefinitions/application.ts | 2 + features/stepDefinitions/browserView.ts | 145 +++++- features/stepDefinitions/wiki.ts | 349 ++++++++++++- features/subWiki.feature | 190 +++++++ features/supports/webContentsViewHelper.ts | 27 + features/tiddler.feature | 42 ++ features/tidgiMiniWindowWorkspace.feature | 14 +- localization/locales/en/translation.json | 11 + localization/locales/fr/translation.json | 11 + localization/locales/ja/translation.json | 11 + localization/locales/ru/translation.json | 11 + localization/locales/zh-Hans/translation.json | 13 +- localization/locales/zh-Hant/translation.json | 11 + package.json | 4 +- pnpm-lock.yaml | 10 +- src/__tests__/__mocks__/services-container.ts | 4 +- .../KeyboardShortcutRegister.test.tsx | 23 +- src/constants/paths.ts | 9 +- src/main.ts | 1 + .../SortableWorkspaceSelectorList.tsx | 3 +- src/pages/Main/__tests__/index.test.tsx | 2 +- src/services/menu/index.ts | 2 +- .../native/keyboardShortcutHelpers.ts | 6 + src/services/view/setupViewEventHandlers.ts | 6 +- src/services/wiki/index.ts | 19 +- src/services/wiki/interface.ts | 11 +- .../FileSystemAdaptor.ts | 167 ++++-- .../WatchFileSystemAdaptor.ts | 2 +- .../FileSystemAdaptor.routing.test.ts | 482 +++++++++++++++++- .../watchFileSystemAdaptor/changelog.tid | 79 ++- .../watchFileSystemAdaptor/in-tagtree-of.ts | 93 ++++ .../in-tagtree-of.ts.meta | 4 + .../plugin/watchFileSystemAdaptor/readme.tid | 25 +- .../routingUtilities.ts | 148 ++++++ src/services/wiki/wikiWorker/index.ts | 6 + .../loadWikiTiddlersWithSubWikis.ts | 83 +++ .../wiki/wikiWorker/startNodeJSWiki.ts | 16 + .../wikiEmbedding/__tests__/index.test.ts | 2 +- src/services/wikiGitWorkspace/index.ts | 5 +- .../windows/handleAttachToTidgiMiniWindow.ts | 22 +- src/services/windows/index.ts | 3 +- .../workspaces/getWorkspaceMenuTemplate.ts | 4 +- src/services/workspaces/index.ts | 46 +- src/services/workspaces/interface.ts | 26 +- src/services/workspacesView/index.ts | 41 +- src/windows/AddWorkspace/CloneWikiForm.tsx | 22 +- src/windows/AddWorkspace/ExistedWikiForm.tsx | 26 +- src/windows/AddWorkspace/NewWikiForm.tsx | 22 +- .../__tests__/NewWikiForm.test.tsx | 14 +- src/windows/AddWorkspace/useCloneWiki.ts | 4 +- src/windows/AddWorkspace/useExistedWiki.ts | 11 +- src/windows/AddWorkspace/useForm.ts | 11 +- src/windows/AddWorkspace/useNewWiki.ts | 4 +- src/windows/EditWorkspace/index.tsx | 137 ++++- .../__tests__/TidGiMiniWindow.test.tsx | 4 +- 57 files changed, 2176 insertions(+), 414 deletions(-) create mode 100644 features/subWiki.feature create mode 100644 features/tiddler.feature create mode 100644 src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts create mode 100644 src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts.meta create mode 100644 src/services/wiki/plugin/watchFileSystemAdaptor/routingUtilities.ts create mode 100644 src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts diff --git a/features/filesystemPlugin.feature b/features/filesystemPlugin.feature index 206f8e84..31e8358a 100644 --- a/features/filesystemPlugin.feature +++ b/features/filesystemPlugin.feature @@ -12,83 +12,6 @@ Feature: Filesystem Plugin Then the browser view should be loaded and visible And I wait for SSE and watch-fs to be ready - @file-watching @subwiki - Scenario: Tiddler with tag saves to sub-wiki folder - # Create sub-workspace linked to the default wiki - When I click on an "add workspace button" element with selector "#add-workspace-button" - And I switch to "addWorkspace" window - # Toggle to sub-workspace mode by clicking the switch - And I click on a "main/sub workspace switch" element with selector "[data-testid='main-sub-workspace-switch']" - # Select the first (default) wiki workspace from dropdown - And I select "wiki" from MUI Select with test id "main-wiki-select" - # Type folder name - And I type "SubWiki" in "sub wiki folder name input" element with selector "input[aria-describedby*='-helper-text'][value='wiki']" - And I type "TestTag" in "tag name input" element with selector "[data-testid='tagname-autocomplete-input']" - And I click on a "create sub workspace button" element with selector "button.MuiButton-colorSecondary" - And I switch to "main" window - Then I should see a "SubWiki workspace" element with selector "div[data-testid^='workspace-']:has-text('SubWiki')" - # Wait for main wiki to restart after sub-wiki creation - Then I wait for "main wiki restarted after sub-wiki creation" log marker "[test-id-MAIN_WIKI_RESTARTED_AFTER_SUBWIKI]" - And I wait for "watch-fs stabilized after restart" log marker "[test-id-WATCH_FS_STABILIZED]" - And I wait for "SSE ready after restart" log marker "[test-id-SSE_READY]" - Then I wait for "view loaded" log marker "[test-id-VIEW_LOADED]" - # 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 press "Control+a" in browser view - And I wait for 0.2 seconds - And I press "Delete" in browser view - And I type "TestTiddlerTitle" in "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" - # Wait for tiddler state to settle, otherwise it still shows 3 chars (新条目) for a while - And I wait for 2 seconds - Then I should see "16 chars" in the browser view content - # Input tag by typing in the tag input field - use precise selector to target the tag input specifically - And I click on "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']" - And I wait for 0.2 seconds - And I press "Control+a" in browser view - And I wait for 0.2 seconds - And I press "Delete" in browser view - And I wait for 0.2 seconds - And I type "TestTag" in "tag input" element in browser view with selector "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']" - # Click the add tag button to confirm the tag (not just typing) - And I wait for 0.2 seconds - And I click on "add tag button" element in browser view with selector "div[data-tiddler-title^='Draft of'] span.tc-add-tag-button button" - # Wait for file system plugin to save the draft tiddler to SubWiki folder, Even 3 second will randomly failed in next step. - And I wait for 4.5 seconds - # Verify the DRAFT tiddler has been routed to sub-wiki immediately after adding the tag - Then file "Draft of '新条目'.tid" should exist in "{tmpDir}/SubWiki" - # Verify the draft file is NOT in main wiki tiddlers folder (it should have been moved to SubWiki) - Then file "Draft of '新条目'.tid" should not exist in "{tmpDir}/wiki/tiddlers" - # Click confirm button to save the tiddler - And I click on "confirm button" element in browser view with selector "button:has(.tc-image-done-button)" - And I wait for 1 seconds - # Verify the final tiddler file exists in sub-wiki folder after save - # After confirming the draft, it should be saved as TestTiddlerTitle.tid in SubWiki - Then file "TestTiddlerTitle.tid" should exist in "{tmpDir}/SubWiki" - # Test SSE is still working after SubWiki creation - modify a main wiki tiddler - When I modify file "{tmpDir}/wiki/tiddlers/Index.tid" to contain "Main wiki content modified after SubWiki creation" - Then I wait for tiddler "Index" to be updated by watch-fs - # Confirm Index always open - Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']" - Then I should see "Main wiki content modified after SubWiki creation" in the browser view content - # Test modification in sub-workspace via symlink - # Modify the tiddler file externally - need to preserve .tid format with metadata - When I modify file "{tmpDir}/SubWiki/TestTiddlerTitle.tid" to contain "Content modified in SubWiki symlink" - # Wait for watch-fs to detect the change - And I wait for 1 seconds for "watch-fs to detect file change" - Then I wait for tiddler "TestTiddlerTitle" to be updated by watch-fs - And I wait for 2 seconds - # Verify the modified content appears in the wiki - Then I should see "Content modified in SubWiki symlink" in the browser view content - @file-watching Scenario: External file creation syncs to wiki # Create a test tiddler file directly on filesystem @@ -102,12 +25,9 @@ Feature: Filesystem Plugin """ # Wait for watch-fs to detect and add the tiddler Then I wait for tiddler "WatchTestTiddler" to be added by watch-fs - # Open sidebar "最近" tab to see the timeline - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" - # wait for tw animation, sidebar need time to show - And I wait for 1 seconds - # Click on the tiddler link in timeline to open it - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('WatchTestTiddler')" + # Open the tiddler directly + When I open tiddler "WatchTestTiddler" in browser view + And I wait for 0.5 seconds # Verify the tiddler content is displayed Then I should see "Initial content from filesystem" in the browser view content @@ -123,10 +43,8 @@ Feature: Filesystem Plugin Original content """ Then I wait for tiddler "TestTiddler" to be added by watch-fs - # Open the tiddler to view it - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" - And I wait for 0.5 seconds - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')" + # Open the tiddler directly + When I open tiddler "TestTiddler" in browser view And I wait for 0.5 seconds Then I should see "Original content" in the browser view content # Modify the file externally @@ -138,10 +56,9 @@ Feature: Filesystem Plugin # Now delete the file externally When I delete file "{tmpDir}/wiki/tiddlers/TestTiddler.tid" Then I wait for tiddler "TestTiddler" to be deleted by watch-fs - # Re-open timeline to see updated list - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" - # The timeline should not have a clickable link to TestTiddler anymore - Then I should not see a "TestTiddler timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TestTiddler')" + And I wait for 0.5 seconds + # The tiddler should show missing message + Then I should see "佚失条目" in the browser view content @file-watching Scenario: Deleting open tiddler file shows missing tiddler message @@ -164,10 +81,9 @@ Feature: Filesystem Plugin Content before rename """ Then I wait for tiddler "OldName" to be added by watch-fs - # Open sidebar to see the timeline - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + # Open the tiddler directly + When I open tiddler "OldName" in browser view And I wait for 0.5 seconds - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('OldName')" Then I should see "Content before rename" in the browser view content # Rename the file externally When I rename file "{tmpDir}/wiki/tiddlers/OldName.tid" to "{tmpDir}/wiki/tiddlers/NewName.tid" @@ -182,11 +98,9 @@ Feature: Filesystem Plugin """ # Wait for the new tiddler to be detected and synced Then I wait for tiddler "NewName" to be updated by watch-fs - # Navigate to timeline to verify changes - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" - And I wait for 1 seconds - # Verify new name appears - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('NewName')" + # Open the renamed tiddler directly + When I open tiddler "NewName" in browser view + And I wait for 0.5 seconds Then I should see "Content before rename" in the browser view content @file-watching @@ -194,11 +108,9 @@ Feature: Filesystem Plugin # Modify an existing tiddler file by adding a tags field to TiddlyWikiIconBlue.png When I modify file "{tmpDir}/wiki/tiddlers/TiddlyWikiIconBlue.png.tid" to add field "tags: TestTag" Then I wait for tiddler "TiddlyWikiIconBlue.png" to be updated by watch-fs - # Open the tiddler to verify the tag was added - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" - And I wait for 1 seconds - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink:has-text('TiddlyWikiIconBlue.png')" - And I wait for 1 seconds + # Open the tiddler directly to verify the tag was added + When I open tiddler "TiddlyWikiIconBlue.png" in browser view + And I wait for 0.5 seconds # Verify the tag appears in the tiddler using data attribute Then I should see a "TestTag tag" element in browser view with selector "[data-tiddler-title='TiddlyWikiIconBlue.png'] [data-tag-title='TestTag']" # Now modify Index.tid by adding a tags field @@ -210,10 +122,8 @@ Feature: Filesystem Plugin # Modify favicon.ico.meta file by adding a tags field When I modify file "{tmpDir}/wiki/tiddlers/favicon.ico.meta" to add field "tags: IconTag" Then I wait for tiddler "favicon.ico" to be updated by watch-fs - # Navigate to favicon.ico tiddler - And I click on "sidebar tab" element in browser view with selector "div.tc-tab-buttons.tc-sidebar-tabs-main > button:has-text('最近')" + # Open the favicon.ico tiddler directly + When I open tiddler "favicon.ico" in browser view And I wait for 0.5 seconds - And I click on "timeline link" element in browser view with selector "div.tc-timeline a.tc-tiddlylink[href='#favicon.ico']" - And I wait for 1 seconds # Verify the IconTag appears in favicon.ico tiddler Then I should see a "IconTag tag" element in browser view with selector "[data-tiddler-title='favicon.ico'] [data-tag-title='IconTag']" diff --git a/features/hibernation.feature b/features/hibernation.feature index 014060d3..0cffb21e 100644 --- a/features/hibernation.feature +++ b/features/hibernation.feature @@ -14,8 +14,8 @@ Feature: Workspace Hibernation And I wait for 1 seconds for "wiki2 workspace icon to appear" Then I should see a "wiki2 workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki2')" - Scenario: Hibernate both workspaces and verify switching with wake up (issues #556 and #593) - # Enable hibernation for both wiki workspaces + Scenario: Hibernate both workspaces and verify switching with wake up + # Enable hibernation for both wiki workspaces (issues #556 and #593) # Enable for wiki When I open edit workspace window for workspace with name "wiki" And I switch to "editWorkspace" window @@ -54,18 +54,32 @@ Feature: Workspace Hibernation And I wait for 0.2 seconds Then I should see a "WikiTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='WikiTestTiddler']" # Switch to wiki2 - wiki should hibernate, wiki2 should load + # Clear previous VIEW_LOADED markers before waiting for a new one + And I clear log lines containing "[test-id-VIEW_LOADED]" When I click on a "wiki2 workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki2')" Then the browser view should be loaded and visible + # Wait for view to be fully loaded + Then I wait for "view loaded" log marker "[test-id-VIEW_LOADED]" + # Wait for TiddlyWiki to fully render the page (site title appears) + Then I wait for "site title" element in browser view with selector "h1.tc-site-title" # Verify wiki workspace is now hibernated (icon should be grayed out) + # UI updates via observable, faster than waiting for log markers Then I should see a "wiki workspace hibernated icon" element with selector "div[data-testid^='workspace-']:has-text('wiki')[data-hibernated='true']" # Verify we're in wiki2 by checking Index tiddler (default open) - not WikiTestTiddler Then I should see a "Index tiddler" element in browser view with selector "div[data-tiddler-title='Index']" Then I should not see a "WikiTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='WikiTestTiddler']" # Switch back to wiki - wiki2 should hibernate, wiki should wake up (reproduces issue #556) # This also tests issue #593 - browser view should persist after wake up + # Clear previous VIEW_LOADED markers before waiting for a new one + And I clear log lines containing "[test-id-VIEW_LOADED]" When I click on a "wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" Then the browser view should be loaded and visible + # Wait for view to be fully loaded + Then I wait for "view loaded" log marker "[test-id-VIEW_LOADED]" + # Wait for TiddlyWiki to fully render the page (site title appears) + Then I wait for "site title" element in browser view with selector "h1.tc-site-title" # Verify wiki2 workspace is now hibernated + # UI updates via observable, faster than waiting for log markers Then I should see a "wiki2 workspace hibernated icon" element with selector "div[data-testid^='workspace-']:has-text('wiki2')[data-hibernated='true']" # Verify wiki workspace is no longer hibernated Then I should see a "wiki workspace active icon" element with selector "div[data-testid^='workspace-']:has-text('wiki')[data-hibernated='false'][data-active='true']" diff --git a/features/stepDefinitions/application.ts b/features/stepDefinitions/application.ts index 54e80267..1780a88b 100644 --- a/features/stepDefinitions/application.ts +++ b/features/stepDefinitions/application.ts @@ -319,6 +319,8 @@ When('I launch the TidGi application', async function(this: ApplicationWorld) { ELECTRON_DISABLE_HARDWARE_ACCELERATION: 'true', }), }, + // Set cwd to repo root so process.cwd() in app returns the correct path for userData-test + cwd: process.cwd(), timeout: 30000, // Increase timeout to 30 seconds for CI }); diff --git a/features/stepDefinitions/browserView.ts b/features/stepDefinitions/browserView.ts index d831bda2..232c122a 100644 --- a/features/stepDefinitions/browserView.ts +++ b/features/stepDefinitions/browserView.ts @@ -1,6 +1,16 @@ import { Then, When } from '@cucumber/cucumber'; import { backOff } from 'exponential-backoff'; -import { clickElement, clickElementWithText, elementExists, getDOMContent, getTextContent, isLoaded, pressKey, typeText } from '../supports/webContentsViewHelper'; +import { + clickElement, + clickElementWithText, + elementExists, + executeTiddlyWikiCode, + getDOMContent, + getTextContent, + isLoaded, + pressKey, + typeText, +} from '../supports/webContentsViewHelper'; import type { ApplicationWorld } from './application'; // Backoff configuration for retries @@ -105,6 +115,28 @@ When('I click on {string} element in browser view with selector {string}', async } }); +Then('I wait for {string} element in browser view with selector {string}', { timeout: 15000 }, async function( + this: ApplicationWorld, + elementComment: string, + selector: string, +) { + if (!this.app) { + throw new Error('Application not launched'); + } + + await backOff( + async () => { + const exists = await elementExists(this.app!, selector); + if (!exists) { + throw new Error(`Element "${elementComment}" with selector "${selector}" not found yet`); + } + }, + { ...BACKOFF_OPTIONS, numOfAttempts: 20, startingDelay: 200 }, + ).catch(() => { + throw new Error(`Element "${elementComment}" with selector "${selector}" did not appear in browser view after multiple attempts`); + }); +}); + When('I type {string} in {string} element in browser view with selector {string}', async function(this: ApplicationWorld, text: string, elementComment: string, selector: string) { if (!this.app) { throw new Error('Application not launched'); @@ -191,3 +223,114 @@ Then('I should see a(n) {string} element in browser view with selector {string}' throw new Error(`Element "${elementComment}" with selector "${selector}" not found in browser view after multiple attempts`); }); }); + +When('I open tiddler {string} in browser view', async function(this: ApplicationWorld, tiddlerTitle: string) { + if (!this.app) { + throw new Error('Application not launched'); + } + + try { + // Use TiddlyWiki's addToStory API to open the tiddler + await executeTiddlyWikiCode(this.app, `$tw.wiki.addToStory("${tiddlerTitle.replace(/"/g, '\\"')}")`); + } catch (error) { + throw new Error(`Failed to open tiddler "${tiddlerTitle}" in browser view: ${error as Error}`); + } +}); + +/** + * Create a new tiddler with title and optional tags via TiddlyWiki UI. + * This step handles all the UI interactions: click add button, set title, add tags, and confirm. + */ +When('I create a tiddler {string} with tag {string} in browser view', { timeout: 20000 }, async function( + this: ApplicationWorld, + tiddlerTitle: string, + tagName: string, +) { + if (!this.app) { + throw new Error('Application not launched'); + } + + // Click add tiddler button + await clickElement(this.app, 'button:has(.tc-image-new-button)'); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Click on title input + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor"); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Select all and delete to clear the default title + await pressKey(this.app, 'Control+a'); + await new Promise(resolve => setTimeout(resolve, 100)); + await pressKey(this.app, 'Delete'); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Type the tiddler title + await typeText(this.app, "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor", tiddlerTitle); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Click on tag input + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']"); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Type the tag name + await typeText(this.app, "div[data-tiddler-title^='Draft of'] div.tc-edit-add-tag-ui input.tc-edit-texteditor[placeholder='标签名称']", tagName); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Click add tag button + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] span.tc-add-tag-button button"); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Click confirm button to save + await clickElement(this.app, 'button:has(.tc-image-done-button)'); + await new Promise(resolve => setTimeout(resolve, 500)); +}); + +/** + * Create a new tiddler with title and custom field via TiddlyWiki UI. + */ +When('I create a tiddler {string} with field {string} set to {string} in browser view', { timeout: 20000 }, async function( + this: ApplicationWorld, + tiddlerTitle: string, + fieldName: string, + fieldValue: string, +) { + if (!this.app) { + throw new Error('Application not launched'); + } + + // Click add tiddler button + await clickElement(this.app, 'button:has(.tc-image-new-button)'); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Click on title input + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor"); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Select all and delete to clear the default title + await pressKey(this.app, 'Control+a'); + await new Promise(resolve => setTimeout(resolve, 100)); + await pressKey(this.app, 'Delete'); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Type the tiddler title + await typeText(this.app, "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor", tiddlerTitle); + await new Promise(resolve => setTimeout(resolve, 500)); + + // Add the custom field + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-name-wrapper input"); + await new Promise(resolve => setTimeout(resolve, 200)); + await typeText(this.app, "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-name-wrapper input", fieldName); + await new Promise(resolve => setTimeout(resolve, 200)); + + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-value input"); + await new Promise(resolve => setTimeout(resolve, 200)); + await typeText(this.app, "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-value input", fieldValue); + await new Promise(resolve => setTimeout(resolve, 200)); + + await clickElement(this.app, "div[data-tiddler-title^='Draft of'] .tc-edit-field-add button"); + await new Promise(resolve => setTimeout(resolve, 300)); + + // Click confirm button to save + await clickElement(this.app, 'button:has(.tc-image-done-button)'); + await new Promise(resolve => setTimeout(resolve, 500)); +}); diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index c85074d8..d750342f 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -1,10 +1,10 @@ -import { Then, When } from '@cucumber/cucumber'; +import { DataTable, Given, Then, When } from '@cucumber/cucumber'; import { exec as gitExec } from 'dugite'; import { backOff } from 'exponential-backoff'; import fs from 'fs-extra'; import path from 'path'; import type { IWorkspace } from '../../src/services/workspaces/interface'; -import { settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; +import { settingsDirectory, settingsPath, wikiTestRootPath, wikiTestWikiPath } from '../supports/paths'; import type { ApplicationWorld } from './application'; // Backoff configuration for retries @@ -19,13 +19,15 @@ const BACKOFF_OPTIONS = { */ export async function waitForLogMarker(searchString: string, errorMessage: string, maxWaitMs = 10000, logFilePattern = 'wiki-'): Promise { const logPath = path.join(process.cwd(), 'userData-test', 'logs'); + // Support multiple patterns separated by '|' + const patterns = logFilePattern.split('|'); try { await backOff( async () => { try { const files = await fs.readdir(logPath); - const logFiles = files.filter(f => f.startsWith(logFilePattern) && f.endsWith('.log')); + const logFiles = files.filter(f => patterns.some(p => f.startsWith(p)) && f.endsWith('.log')); for (const file of logFiles) { const content = await fs.readFile(path.join(logPath, file), 'utf-8'); @@ -54,8 +56,24 @@ export async function waitForLogMarker(searchString: string, errorMessage: strin } When('I cleanup test wiki so it could create a new one on start', async function() { + // Clean up main wiki folder if (fs.existsSync(wikiTestWikiPath)) fs.removeSync(wikiTestWikiPath); + // Clean up all sub-wiki folders in wiki-test directory (SubWiki*, SubWikiPreload, SubWikiTagTree, SubWikiFilter, etc.) + if (fs.existsSync(wikiTestRootPath)) { + const entries = fs.readdirSync(wikiTestRootPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'wiki') { + const subWikiPath = path.join(wikiTestRootPath, entry.name); + try { + fs.removeSync(subWikiPath); + } catch (error) { + console.warn(`Failed to remove sub-wiki folder ${entry.name}:`, error); + } + } + } + } + /** * Clean up log files to prevent reading stale logs from previous scenarios. * This is critical for tests that wait for log markers like [test-id-WATCH_FS_STABILIZED] or [test-id-git-commit-complete], @@ -108,9 +126,11 @@ When('I cleanup test wiki so it could create a new one on start', async function const filtered: Record = {}; for (const id of Object.keys(workspaces)) { const ws = workspaces[id]; - const name = ws.name; - if (name === 'wiki' || id === 'wiki') continue; - filtered[id] = ws; + // Keep only page-type workspaces (agent, help, guide, add), remove all wiki workspaces + // This includes main wiki and all sub-wikis + if ('pageType' in ws && ws.pageType) { + filtered[id] = ws; + } } // Write with exponential backoff retry logic to handle file locks @@ -264,6 +284,97 @@ Then('file {string} should not exist in {string}', { timeout: 15000 }, async fun } }); +/** + * Verify that a workspace in settings.json has a specific property set to a specific value + */ +Then('settings.json should have workspace {string} with {string} set to {string}', { timeout: 10000 }, async function( + this: ApplicationWorld, + workspaceName: string, + propertyName: string, + expectedValue: string, +) { + await backOff( + async () => { + if (!await fs.pathExists(settingsPath)) { + throw new Error(`settings.json not found at ${settingsPath}`); + } + + type SettingsFile = { workspaces?: Record } & Record; + const settings = await fs.readJson(settingsPath) as SettingsFile; + + if (!settings.workspaces) { + throw new Error('No workspaces found in settings.json'); + } + + // Find the workspace by name + const workspace = Object.values(settings.workspaces).find(ws => ws.name === workspaceName); + if (!workspace) { + const existingNames = Object.values(settings.workspaces).map(ws => ws.name).join(', '); + throw new Error(`Workspace "${workspaceName}" not found in settings.json. Existing workspaces: ${existingNames}`); + } + + // Get the property value + const actualValue = (workspace as unknown as Record)[propertyName]; + + // Convert expected value to appropriate type for comparison + let parsedExpectedValue: unknown = expectedValue; + if (expectedValue === 'true') parsedExpectedValue = true; + else if (expectedValue === 'false') parsedExpectedValue = false; + else if (expectedValue === 'null') parsedExpectedValue = null; + else if (!isNaN(Number(expectedValue))) parsedExpectedValue = Number(expectedValue); + + if (actualValue !== parsedExpectedValue) { + throw new Error(`Expected "${propertyName}" to be "${expectedValue}" but got "${String(actualValue)}"`); + } + }, + BACKOFF_OPTIONS, + ); +}); + +/** + * Verify that a workspace in settings.json has a property array that contains a specific value + */ +Then('settings.json should have workspace {string} with {string} containing {string}', { timeout: 10000 }, async function( + this: ApplicationWorld, + workspaceName: string, + propertyName: string, + expectedValue: string, +) { + await backOff( + async () => { + if (!await fs.pathExists(settingsPath)) { + throw new Error(`settings.json not found at ${settingsPath}`); + } + + type SettingsFile = { workspaces?: Record } & Record; + const settings = await fs.readJson(settingsPath) as SettingsFile; + + if (!settings.workspaces) { + throw new Error('No workspaces found in settings.json'); + } + + // Find the workspace by name + const workspace = Object.values(settings.workspaces).find(ws => ws.name === workspaceName); + if (!workspace) { + const existingNames = Object.values(settings.workspaces).map(ws => ws.name).join(', '); + throw new Error(`Workspace "${workspaceName}" not found in settings.json. Existing workspaces: ${existingNames}`); + } + + // Get the property value + const actualValue = (workspace as unknown as Record)[propertyName]; + + if (!Array.isArray(actualValue)) { + throw new Error(`Expected "${propertyName}" to be an array but got "${typeof actualValue}"`); + } + + if (!actualValue.includes(expectedValue)) { + throw new Error(`Expected "${propertyName}" to contain "${expectedValue}" but got [${actualValue.join(', ')}]`); + } + }, + BACKOFF_OPTIONS, + ); +}); + /** * Cleanup function for sub-wiki routing test * Removes test workspaces created during the test @@ -318,17 +429,11 @@ async function clearGitTestData() { * @param description - Human-readable description of what we're waiting for (comes first for readability) * @param marker - The test-id marker to look for in logs * - * This searches in both TidGi- and wiki- log files with appropriate timeouts + * This searches in TidGi- log files by default */ Then('I wait for {string} log marker {string}', async function(this: ApplicationWorld, description: string, marker: string) { - // Determine timeout and log prefix based on operation type - const isGitOperation = marker.includes('git-') || marker.includes('revert'); - const isWikiRestart = marker.includes('MAIN_WIKI_RESTARTED'); - const isWorkspaceOperation = marker.includes('WORKSPACE_'); - const isRevert = marker.includes('revert'); - const timeout = isRevert ? 30000 : (isWikiRestart ? 25000 : (isGitOperation ? 25000 : 15000)); - const logPrefix = (isGitOperation || isWikiRestart || isWorkspaceOperation) ? 'TidGi-' : undefined; - await waitForLogMarker(marker, `Log marker "${marker}" not found. Expected: ${description}`, timeout, logPrefix); + // Search in both TidGi- and wiki log files (wiki logs include wiki- and wiki2- etc.) + await waitForLogMarker(marker, `Log marker "${marker}" not found. Expected: ${description}`, 10000, 'TidGi-|wiki'); }); /** @@ -340,6 +445,32 @@ Then('I wait for SSE and watch-fs to be ready', async function(this: Application await waitForLogMarker('[test-id-SSE_READY]', 'SSE backend did not become ready within timeout', 20000); }); +/** + * Remove log lines containing specific text from all log files (TidGi- and wiki- prefixed). + * This is useful when you need to wait for a log marker that may have appeared earlier in the scenario, + * and you want to ensure you're waiting for a new occurrence of that marker. + * @param marker - The text pattern to remove from log files + */ +When('I clear log lines containing {string}', async function(this: ApplicationWorld, marker: string) { + const logDirectory = path.join(process.cwd(), 'userData-test', 'logs'); + if (!fs.existsSync(logDirectory)) return; + + // Clear from both TidGi- and wiki- prefixed log files + const logFiles = fs.readdirSync(logDirectory).filter(f => (f.startsWith('TidGi-') || f.startsWith('wiki')) && f.endsWith('.log')); + + for (const logFile of logFiles) { + const logPath = path.join(logDirectory, logFile); + try { + const content = fs.readFileSync(logPath, 'utf-8'); + const lines = content.split('\n'); + const filteredLines = lines.filter(line => !line.includes(marker)); + fs.writeFileSync(logPath, filteredLines.join('\n'), 'utf-8'); + } catch { + // Ignore errors if file is locked or doesn't exist + } + } +}); + /** * Convenience steps for waiting for tiddler operations detected by watch-fs * These use dynamic markers that include the tiddler name @@ -623,6 +754,194 @@ async function clearHibernationTestData() { } } +/** + * Setup a sub-wiki with optional settings and multiple pre-existing tiddlers. + * This creates the sub-wiki folder, tiddler files, and settings configuration + * so the app loads everything on first startup. + * + * @param subWikiName - Name of the sub-wiki folder + * @param tagName - Tag name for the sub-wiki routing + * @param options - Optional settings: includeTagTree, fileSystemPathFilter + * @param tiddlers - Array of {title, tags, content} objects from DataTable.hashes() + */ +async function setupSubWiki( + subWikiName: string, + tagName: string, + options: { + includeTagTree?: boolean; + fileSystemPathFilter?: string; + }, + tiddlers: Record[], +) { + // 1. Create sub-wiki folder + const subWikiPath = path.join(wikiTestRootPath, subWikiName); + await fs.ensureDir(subWikiPath); + + // 2. Create tiddler files + const now = new Date(); + const timestamp = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 17); + + for (const tiddler of tiddlers) { + const tiddlerFilePath = path.join(subWikiPath, `${tiddler.title}.tid`); + const tiddlerFileContent = `created: ${timestamp} +modified: ${timestamp} +tags: ${tiddler.tags} +title: ${tiddler.title} + +${tiddler.content} +`; + await fs.writeFile(tiddlerFilePath, tiddlerFileContent, 'utf-8'); + } + + // 3. Create main wiki folder structure (if not exists) + const mainWikiPath = wikiTestWikiPath; + const templatePath = path.join(process.cwd(), 'template', 'wiki'); + if (!await fs.pathExists(mainWikiPath)) { + await fs.copy(templatePath, mainWikiPath); + // Remove .git from template + await fs.remove(path.join(mainWikiPath, '.git')).catch(() => {/* ignore */}); + } + + // 4. Update settings.json with both main wiki and sub-wiki workspaces + await fs.ensureDir(settingsDirectory); + let settings: { workspaces?: Record } & Record = {}; + if (await fs.pathExists(settingsPath)) { + settings = await fs.readJson(settingsPath) as { workspaces?: Record }; + } + + // Generate unique IDs + const mainWikiId = 'main-wiki-test-id'; + const subWikiId = `sub-wiki-${subWikiName}-test-id`; + + // Create main wiki workspace if not exists + if (!settings.workspaces) { + settings.workspaces = {}; + } + + // Check if main wiki already exists + const existingMainWiki = Object.values(settings.workspaces).find( + ws => 'wikiFolderLocation' in ws && ws.wikiFolderLocation === mainWikiPath, + ); + + const mainWikiIdToUse = existingMainWiki?.id ?? mainWikiId; + + if (!existingMainWiki) { + settings.workspaces[mainWikiId] = { + id: mainWikiId, + name: 'wiki', + wikiFolderLocation: mainWikiPath, + isSubWiki: false, + storageService: 'local', + backupOnInterval: true, + excludedPlugins: [], + enableHTTPAPI: false, + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null, + tagNames: [], + userName: '', + order: 0, + port: 5212, + readOnlyMode: false, + tokenAuth: false, + tagName: null, + mainWikiToLink: null, + mainWikiID: null, + enableFileSystemWatch: true, + lastNodeJSArgv: [], + homeUrl: `tidgi://${mainWikiId}`, + gitUrl: null, + active: true, + hibernated: false, + hibernateWhenUnused: false, + lastUrl: null, + picturePath: null, + subWikiFolderName: 'subwiki', + syncOnInterval: false, + syncOnStartup: true, + transparentBackground: false, + } as unknown as IWorkspace; + } + + // Create sub-wiki workspace with optional settings + settings.workspaces[subWikiId] = { + id: subWikiId, + name: subWikiName, + wikiFolderLocation: subWikiPath, + isSubWiki: true, + mainWikiToLink: mainWikiPath, + mainWikiID: mainWikiIdToUse, + storageService: 'local', + backupOnInterval: true, + excludedPlugins: [], + enableHTTPAPI: false, + includeTagTree: options.includeTagTree ?? false, + fileSystemPathFilterEnable: Boolean(options.fileSystemPathFilter), + fileSystemPathFilter: options.fileSystemPathFilter ?? null, + tagNames: [tagName], + userName: '', + order: 1, + port: 5213, + readOnlyMode: false, + tokenAuth: false, + enableFileSystemWatch: true, + lastNodeJSArgv: [], + homeUrl: `tidgi://${subWikiId}`, + gitUrl: null, + active: false, + hibernated: false, + hibernateWhenUnused: false, + lastUrl: null, + picturePath: null, + subWikiFolderName: 'subwiki', + syncOnInterval: false, + syncOnStartup: true, + transparentBackground: false, + } as unknown as IWorkspace; + + await fs.writeJson(settingsPath, settings, { spaces: 2 }); +} + +/** + * Setup a sub-wiki with tiddlers (basic, no special options) + */ +Given('I setup a sub-wiki {string} with tag {string} and tiddlers:', async function( + this: ApplicationWorld, + subWikiName: string, + tagName: string, + dataTable: DataTable, +) { + const rows = dataTable.hashes(); + await setupSubWiki(subWikiName, tagName, {}, rows); +}); + +/** + * Setup a sub-wiki with includeTagTree enabled and tiddlers + */ +Given('I setup a sub-wiki {string} with tag {string} and includeTagTree enabled and tiddlers:', async function( + this: ApplicationWorld, + subWikiName: string, + tagName: string, + dataTable: DataTable, +) { + const rows = dataTable.hashes(); + await setupSubWiki(subWikiName, tagName, { includeTagTree: true }, rows); +}); + +/** + * Setup a sub-wiki with custom filter and tiddlers + */ +Given('I setup a sub-wiki {string} with tag {string} and filter {string} and tiddlers:', async function( + this: ApplicationWorld, + subWikiName: string, + tagName: string, + filter: string, + dataTable: DataTable, +) { + const rows = dataTable.hashes(); + await setupSubWiki(subWikiName, tagName, { fileSystemPathFilter: filter }, rows); +}); + export { clearGitTestData, clearHibernationTestData, clearSubWikiRoutingTestData, clearTestIdLogs }; /** diff --git a/features/subWiki.feature b/features/subWiki.feature new file mode 100644 index 00000000..25292dda --- /dev/null +++ b/features/subWiki.feature @@ -0,0 +1,190 @@ +Feature: Sub-Wiki Functionality + As a user + I want sub-wikis to properly load tiddlers on startup + And I want to use tag tree filtering to organize tiddlers into sub-wikis + So that my content is automatically organized + + + @file-watching @subwiki + Scenario: Tiddler with tag saves to sub-wiki folder + # Setup: Create sub-wiki with tag BEFORE launching the app (fast setup) + Given I cleanup test wiki so it could create a new one on start + And I setup a sub-wiki "SubWiki" with tag "TestTag" and tiddlers: + | title | tags | content | + | TestTag | TestTag | Tag tiddler stub | + When I launch the TidGi application + And I wait for the page to load completely + Then I should see "page body and workspaces" elements with selectors: + | div[data-testid^='workspace-']:has-text('wiki') | + | div[data-testid^='workspace-']:has-text('SubWiki') | + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + # Create tiddler with tag to test routing to sub-wiki folder + When I create a tiddler "TestTiddlerTitle" with tag "TestTag" in browser view + And I wait for 3 seconds for "tiddler to be saved and routed to sub-wiki" + # Verify the tiddler file exists in sub-wiki folder after save + Then file "TestTiddlerTitle.tid" should exist in "{tmpDir}/SubWiki" + # Verify tiddler is NOT in main wiki tiddlers folder + Then file "TestTiddlerTitle.tid" should not exist in "{tmpDir}/wiki/tiddlers" + # Test SSE is still working - 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 + 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-wiki folder + When I modify file "{tmpDir}/SubWiki/TestTiddlerTitle.tid" to contain "Content modified in SubWiki folder" + And I wait for 2 seconds for "watch-fs to detect file change in sub-wiki" + When I open tiddler "TestTiddlerTitle" in browser view + And I wait for 1 seconds + Then I should see "Content modified in SubWiki folder" in the browser view content + + @subwiki @subwiki-load + Scenario: Sub-wiki tiddlers are loaded on initial wiki startup + # Setup: Create sub-wiki folder and settings BEFORE launching the app + Given I cleanup test wiki so it could create a new one on start + And I setup a sub-wiki "SubWikiPreload" with tag "PreloadTag" and tiddlers: + | title | tags | content | + | PreExistingTiddler | PreloadTag | Content from pre-existing sub-wiki tiddler | + # Now launch the app - it should load both main wiki and sub-wiki tiddlers + When I launch the TidGi application + And I wait for the page to load completely + Then I should see "page body and workspaces" elements with selectors: + | div[data-testid^='workspace-']:has-text('wiki') | + | div[data-testid^='workspace-']:has-text('SubWikiPreload') | + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + # Open the tiddler directly using TiddlyWiki API + When I open tiddler "PreExistingTiddler" in browser view + And I wait for 0.5 seconds + # Verify the tiddler content is displayed + Then I should see "Content from pre-existing sub-wiki tiddler" in the browser view content + # Verify the tiddler has the correct tag + Then I should see a "PreloadTag tag" element in browser view with selector "[data-tiddler-title='PreExistingTiddler'] [data-tag-title='PreloadTag']" + + @subwiki @subwiki-tagtree + Scenario: Tiddlers matching tag tree are routed to sub-wiki with includeTagTree enabled + # Setup: Create sub-wiki with includeTagTree enabled, and pre-existing tag hierarchy A->B + # TagTreeRoot is the sub-wiki's tagName + # TiddlerA has tag "TagTreeRoot" (direct child) + # TiddlerB has tag "TiddlerA" (grandchild via tag tree) + Given I cleanup test wiki so it could create a new one on start + And I setup a sub-wiki "SubWikiTagTree" with tag "TagTreeRoot" and includeTagTree enabled and tiddlers: + | title | tags | content | + | TiddlerA | TagTreeRoot | TiddlerA with TagTreeRoot tag | + | TiddlerB | TiddlerA | TiddlerB with TiddlerA tag | + # Now launch the app + When I launch the TidGi application + And I wait for the page to load completely + Then I should see "page body and workspaces" elements with selectors: + | div[data-testid^='workspace-']:has-text('wiki') | + | div[data-testid^='workspace-']:has-text('SubWikiTagTree') | + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + # Verify TiddlerA and TiddlerB were loaded from sub-wiki by opening them + When I open tiddler "TiddlerA" in browser view + And I wait for 0.5 seconds + Then I should see "TiddlerA with TagTreeRoot tag" in the browser view content + When I open tiddler "TiddlerB" in browser view + And I wait for 0.5 seconds + Then I should see "TiddlerB with TiddlerA tag" in the browser view content + # Create TiddlerC with tag TiddlerB (testing tag tree routing: TiddlerC -> TiddlerB -> TiddlerA -> TagTreeRoot) + When I create a tiddler "TiddlerC" with tag "TiddlerB" in browser view + And I wait for 3 seconds for "TiddlerC to be saved via tag tree routing" + # Verify TiddlerC is saved to sub-wiki via tag tree (TiddlerB -> TiddlerA -> TagTreeRoot) + Then file "TiddlerC.tid" should exist in "{tmpDir}/SubWikiTagTree" + Then file "TiddlerC.tid" should not exist in "{tmpDir}/wiki/tiddlers" + + @subwiki @subwiki-filter + Scenario: Tiddlers matching custom filter are routed to sub-wiki + # Setup: Create sub-wiki with custom filter that matches tiddlers with "filtertest" field + Given I cleanup test wiki so it could create a new one on start + And I setup a sub-wiki "SubWikiFilter" with tag "FilterTag" and filter "[has[filtertest]]" and tiddlers: + | title | tags | content | + | FilterTiddlerA | FilterTag | TiddlerA matched by filter | + When I launch the TidGi application + And I wait for the page to load completely + Then I should see "page body and workspaces" elements with selectors: + | div[data-testid^='workspace-']:has-text('wiki') | + | div[data-testid^='workspace-']:has-text('SubWikiFilter') | + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + # Create a tiddler with the "filtertest" field to test filter routing + When I create a tiddler "FilterMatchTiddler" with field "filtertest" set to "yes" in browser view + And I wait for 3 seconds for "FilterMatchTiddler to be saved via filter routing" + # Verify FilterMatchTiddler is saved to sub-wiki via filter + Then file "FilterMatchTiddler.tid" should exist in "{tmpDir}/SubWikiFilter" + Then file "FilterMatchTiddler.tid" should not exist in "{tmpDir}/wiki/tiddlers" + + @subwiki @subwiki-settings-ui + Scenario: Sub-wiki settings UI can enable includeTagTree option + # This tests the EditWorkspace UI for setting includeTagTree via the new switch + Given I cleanup test wiki so it could create a new one on start + And I setup a sub-wiki "SubWikiSettings" with tag "SettingsTag" and tiddlers: + | title | tags | content | + | SettingsTiddler | SettingsTag | Settings test tiddler | + When I launch the TidGi application + And I wait for the page to load completely + Then I should see "page body and workspaces" elements with selectors: + | div[data-testid^='workspace-']:has-text('wiki') | + | div[data-testid^='workspace-']:has-text('SubWikiSettings') | + # Open the edit workspace window using existing step + When I open edit workspace window for workspace with name "SubWikiSettings" + And I switch to "editWorkspace" window + And I wait for the page to load completely + And I wait for 1 seconds for "page to fully render" + # For sub-wikis, the accordion is defaultExpanded, so we should see the switch immediately + Then I should see a "sub-workspace options accordion" element with selector "[data-testid='preference-section-subWorkspaceOptions']" + # The includeTagTree switch should be visible for sub-wikis (accordion is already expanded) + Then I should see a "includeTagTree switch" element with selector "[data-testid='include-tag-tree-switch']" + # Enable includeTagTree option + When I click on a "includeTagTree switch" element with selector "[data-testid='include-tag-tree-switch']" + And I wait for 0.5 seconds + # Save the changes by clicking the save button + When I click on a "save button" element with selector "[data-testid='edit-workspace-save-button']" + Then I should not see a "save button" element with selector "[data-testid='edit-workspace-save-button']" + And I wait for 0.5 seconds for "settings to be written" + # Verify the setting was saved to settings.json + Then settings.json should have workspace "SubWikiSettings" with "includeTagTree" set to "true" + + @subwiki @subwiki-create-ui + Scenario: Create sub-wiki workspace via UI + # This tests creating a sub-wiki through the Add Workspace UI + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + # Create sub-workspace via UI + When I click on an "add workspace button" element with selector "#add-workspace-button" + And I switch to "addWorkspace" window + # Toggle to sub-workspace mode by clicking the switch + And I click on a "main/sub workspace switch" element with selector "[data-testid='main-sub-workspace-switch']" + # Select the first (default) wiki workspace from dropdown + And I select "wiki" from MUI Select with test id "main-wiki-select" + # Type folder name + And I type "SubWikiUI" in "sub wiki folder name input" element with selector "input[aria-describedby*='-helper-text'][value='wiki']" + # Add tag using Autocomplete - type and press Enter to add the tag + And I type "UITestTag" in "tag name input" element with selector "[data-testid='tagname-autocomplete-input']" + And I press "Enter" key + And I click on a "create sub workspace button" element with selector "button.MuiButton-colorSecondary" + And I switch to "main" window + Then I should see a "SubWikiUI workspace" element with selector "div[data-testid^='workspace-']:has-text('SubWikiUI')" + # Wait for main wiki to restart after sub-wiki creation + Then I wait for "main wiki restarted after sub-wiki creation" log marker "[test-id-MAIN_WIKI_RESTARTED_AFTER_SUBWIKI]" + And I wait for "watch-fs stabilized after restart" log marker "[test-id-WATCH_FS_STABILIZED]" + And I wait for "SSE ready after restart" log marker "[test-id-SSE_READY]" + Then I wait for "view loaded" log marker "[test-id-VIEW_LOADED]" + # Wait for TiddlyWiki to fully render the page (site title appears) + Then I wait for "site title" element in browser view with selector "h1.tc-site-title" + # Click SubWikiUI workspace to see the missing tag tiddler message + When I click on a "SubWikiUI workspace button" element with selector "div[data-testid^='workspace-']:has-text('SubWikiUI')" + # Verify UITestTag text is visible (missing tiddler message shows the title) + Then I should see "UITestTag" in the browser view content + # Verify the sub-wiki was created in settings.json + Then settings.json should have workspace "SubWikiUI" with "tagNames" containing "UITestTag" diff --git a/features/supports/webContentsViewHelper.ts b/features/supports/webContentsViewHelper.ts index 790d528a..53f42b0f 100644 --- a/features/supports/webContentsViewHelper.ts +++ b/features/supports/webContentsViewHelper.ts @@ -346,3 +346,30 @@ export async function captureScreenshot(app: ElectronApplication, screenshotPath return false; } } + +/** + * Execute TiddlyWiki code in the browser view + * Useful for directly manipulating the wiki, e.g., opening tiddlers + */ +export async function executeTiddlyWikiCode( + app: ElectronApplication, + code: string, +): Promise { + const webContentsId = await getFirstWebContentsView(app); + + if (!webContentsId) { + throw new Error('No WebContentsView found'); + } + + return await app.evaluate( + async ({ webContents }, [id, codeContent]) => { + const targetWebContents = webContents.fromId(id as number); + if (!targetWebContents) { + throw new Error('WebContents not found'); + } + const result: T = await targetWebContents.executeJavaScript(codeContent as string, true) as T; + return result; + }, + [webContentsId, code], + ); +} diff --git a/features/tiddler.feature b/features/tiddler.feature new file mode 100644 index 00000000..ccd54647 --- /dev/null +++ b/features/tiddler.feature @@ -0,0 +1,42 @@ +Feature: Tiddler Creation and Editing + As a user + I want to create and edit tiddlers in the wiki + So that I can manage my content + + Background: + Given I cleanup test wiki so it could create a new one on start + And I launch the TidGi application + And I wait for the page to load completely + Then I should see a "default wiki workspace" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + When I click on a "default wiki workspace button" element with selector "div[data-testid^='workspace-']:has-text('wiki')" + Then the browser view should be loaded and visible + And I wait for SSE and watch-fs to be ready + + @tiddler @tiddler-create + Scenario: Create a new tiddler with tag and custom field via UI + # These are micro steps of `When I create a tiddler "MyTestTiddler" with field "customfield" set to "customvalue" in browser view` and `When I create a tiddler "MyTestTiddler" with tag "MyTestTag" in browser view` + # Click add tiddler button + 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 + 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 press "Control+a" in browser view + And I press "Delete" in browser view + And I type "MyTestTiddler" in "title input" element in browser view with selector "div[data-tiddler-title^='Draft of'] input.tc-titlebar.tc-edit-texteditor" + # Add a tag + 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 type "MyTestTag" 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='标签名称']" + 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" + # Add a custom field + And I click on "add field name input" element in browser view with selector "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-name-wrapper input" + And I type "customfield" in "add field name input" element in browser view with selector "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-name-wrapper input" + And I click on "add field value input" element in browser view with selector "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-value input" + And I type "customvalue" in "add field value input" element in browser view with selector "div[data-tiddler-title^='Draft of'] .tc-edit-field-add-value input" + And I click on "add field button" element in browser view with selector "div[data-tiddler-title^='Draft of'] .tc-edit-field-add button" + # Confirm to save the tiddler + And I click on "confirm button" element in browser view with selector "button:has(.tc-image-done-button)" + # Verify the tiddler was created and is visible + Then I should see a "MyTestTiddler tiddler" element in browser view with selector "div[data-tiddler-title='MyTestTiddler']" + # Verify the tag was added + Then I should see a "MyTestTag tag" element in browser view with selector "[data-tiddler-title='MyTestTiddler'] [data-tag-title='MyTestTag']" + # Verify the tiddler file was created + Then file "MyTestTiddler.tid" should exist in "{tmpDir}/wiki/tiddlers" diff --git a/features/tidgiMiniWindowWorkspace.feature b/features/tidgiMiniWindowWorkspace.feature index 6a268b01..de0e6eb9 100644 --- a/features/tidgiMiniWindowWorkspace.feature +++ b/features/tidgiMiniWindowWorkspace.feature @@ -10,13 +10,18 @@ Feature: TidGi Mini Window Workspace Switching Then I launch the TidGi application And I wait for the page to load completely Then I switch to "main" window + # Wait for git init to complete (early sync point, ~1s after app start) + Then I wait for "git init complete" log marker "[test-id-git-init-complete]" + # Wait for wiki worker to start (~3s after git-init-complete) + Then I wait for "wiki worker started" log marker "[test-id-WIKI_WORKER_STARTED]" + # Wait for all workspace views to be initialized (~4s after wiki worker started) + Then I wait for "all workspace views initialized" log marker "[test-id-ALL_WORKSPACE_VIEW_INITIALIZED]" Scenario: TidGi mini window syncs with main window switching to agent workspace # Switch main window to agent workspace When I click on an "agent workspace button" element with selector "[data-testid='workspace-agent']" # Verify tidgi mini window exists in background (created but not visible) - # Wait longer for window creation in full test run - And I wait for 1 seconds for "tidgi mini window to be created" + Then I wait for "tidgi mini window created" log marker "[test-id-TIDGI_MINI_WINDOW_CREATED]" Then I confirm the "tidgiMiniWindow" window exists And I confirm the "tidgiMiniWindow" window not visible When I press the key combination "CommandOrControl+Shift+M" @@ -51,12 +56,17 @@ Feature: TidGi Mini Window Workspace Switching And I wait for 0.2 seconds Then I switch to "preferences" window When I press the key combination "CommandOrControl+Shift+M" + And I wait for 1 seconds And I confirm the "tidgiMiniWindow" window not visible # Get the first wiki workspace ID and select it And I select "wiki" from MUI Select with test id "tidgi-mini-window-fixed-workspace-select" And I wait for 0.2 seconds + # Clear previous occurrences of the log marker before waiting for a new one + And I clear log lines containing "[test-id-TIDGI_MINI_WINDOW_SHOWN]" # Open tidgi mini window again - should show wiki workspace with browser view When I press the key combination "CommandOrControl+Shift+M" + # Wait for the view to be loaded and window to be shown + Then I wait for "tidgi mini window shown" log marker "[test-id-TIDGI_MINI_WINDOW_SHOWN]" And I confirm the "tidgiMiniWindow" window visible And I confirm the "tidgiMiniWindow" window browser view is positioned within visible window bounds Then I switch to "tidgiMiniWindow" window diff --git a/localization/locales/en/translation.json b/localization/locales/en/translation.json index bbe97c54..16276665 100644 --- a/localization/locales/en/translation.json +++ b/localization/locales/en/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "Base URL of the OAuth server (e.g., http://127.0.0.1:8888)", "ExistedWikiLocation": "Existed Wiki Location", "ExtractedWikiFolderName": "Converted WIKI folder name", + "FilterExpression": "filter expression", + "FilterExpressionHelp": "One TiddlyWiki filter expression per line; any match will be saved to this workspace. For example: [in-tagtree-of[Calendar]!tag[Public]]", "GitBranch": "Git Branch", "GitBranchDescription": "Git branch to use (default: main)", "GitDefaultBranchDescription": "The default branch of your Git, Github changed it from master to main after that event", @@ -30,6 +32,9 @@ "GitUserName": "Git Username", "GitUserNameDescription": "The account name used to log in to Git, note that it is the name part of your repository URL", "ImportWiki": "Import Wiki: ", + "IncludeTagTree": "Include Entire Tag Tree", + "IncludeTagTreeHelp": "When enabled, tiddlers whose tag (or tag's tag, recursively) matches this sub-wiki's tag will also be saved to this sub-wiki", + "IncludeTagTreeHelpForMain": "When checked, any label whose parent label (at any level...) is this one will be categorized into this workspace.", "LocalWikiHtml": "path to html file", "LocalWorkspace": "Local Workspace", "LocalWorkspaceDescription": "Only use locally, fully control your own data. TidGi will create a local git backup system for you, allowing you to go back to the previous versions of tiddlers, but all contents will be lost when the local folder is deleted.", @@ -56,13 +61,19 @@ "SubWikiCreationCompleted": "Sub Wiki is created", "SubWorkspace": "Sub Workspace", "SubWorkspaceDescription": "It must be attached to a main repository, which can be used to store private content, Note two points: the sub-knowledge base cannot be placed in the main knowledge base folder; the sub-knowledge base is generally used to synchronize data to a private Github repository, which can only be read and written by me, so the repository address cannot be the same as the main knowledge base.\nThe sub-knowledge base takes effect by creating a soft link (shortcut) to the main knowledge base. After the link is created, the content in the sub-knowledge base can be seen in the main knowledge base.", + "SubWorkspaceOptions": "Sub-Workspace Options", + "SubWorkspaceOptionsDescriptionForMain": "Configure which tiddlers this main workspace prioritizes. When a tag is set, new tiddlers with this tag will be saved to the main workspace instead of sub-workspaces", + "SubWorkspaceOptionsDescriptionForSub": "Configure which tiddlers are saved to this sub-workspace. New tiddlers with the specified tag will be saved here", "SubWorkspaceWillLinkTo": "Sub-Workspace will link to", "SwitchCreateNewOrOpenExisted": "Switch to create a new or open an existing WIKI", "SyncedWorkspace": "Synced Workspace", "SyncedWorkspaceDescription": "To synchronize to an online storage service (such as Github), you need to login to a storage service or enter your login credentials, and have a good network connection. You can sync data across devices, and you still own the data when you use a trusted storage service. And even after the folder is accidentally deleted, you can still download the data from the online service to the local again.", "TagName": "Tag Name", "TagNameHelp": "Tiddlers with this Tag will be add to this sub-wiki (you can add or change this Tag later, by right-click workspace Icon and choose Edit Workspace)", + "TagNameHelpForMain": "New entries with this tag will be prioritized for storage in this workspace.", "ThisPathIsNotAWikiFolder": "The directory is not a Wiki folder \"{{wikiPath}}\"", + "UseFilter": "Use filters", + "UseFilterHelp": "Use filter expressions instead of tags to match entries and determine whether to save them in the current workspace.", "WaitForLogin": "Wait for Login", "WikiExisted": "Wiki already exists at this location \"{{newWikiPath}}\"", "WikiNotStarted": "Wiki is not started or not loaded", diff --git a/localization/locales/fr/translation.json b/localization/locales/fr/translation.json index 476c6846..af477da4 100644 --- a/localization/locales/fr/translation.json +++ b/localization/locales/fr/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "URL de base du serveur OAuth (par exemple : http://127.0.0.1:8888)", "ExistedWikiLocation": "Emplacement du Wiki existant", "ExtractedWikiFolderName": "Nom du dossier WIKI converti", + "FilterExpression": "expression du filtre", + "FilterExpressionHelp": "Un filtre d'expression TiddlyWiki par ligne, toute correspondance sera enregistrée dans cet espace de travail. Par exemple : [in-tagtree-of[Calendar]!tag[Public]]", "GitBranch": "Branche Git", "GitBranchDescription": "Branche Git à utiliser (par défaut : main)", "GitDefaultBranchDescription": "La branche par défaut de votre Git, Github l'a changée de master à main après cet événement", @@ -30,6 +32,9 @@ "GitUserName": "Nom d'utilisateur Git", "GitUserNameDescription": "Le nom de compte utilisé pour se connecter à Git. Pas le surnom", "ImportWiki": "Importer un Wiki : ", + "IncludeTagTree": "Inclure l'arbre d'étiquettes entier", + "IncludeTagTreeHelp": "Lorsqu'activé, les tiddlers dont l'étiquette (ou l'étiquette de l'étiquette, récursivement) correspond à l'étiquette de ce sous-wiki seront également enregistrés dans ce sous-wiki", + "IncludeTagTreeHelpForMain": "Une fois coché, tout étiquette dont l'étiquette (à n'importe quel niveau...) est celle-ci sera classée dans cet espace de travail.", "LocalWikiHtml": "chemin vers le fichier html", "LocalWorkspace": "Espace de travail local", "LocalWorkspaceDescription": "Utilisation uniquement locale, contrôle total de vos propres données. TidGi créera un système de sauvegarde git local pour vous, vous permettant de revenir aux versions précédentes des tiddlers, mais tout le contenu sera perdu lorsque le dossier local sera supprimé.", @@ -56,13 +61,19 @@ "SubWikiCreationCompleted": "Le sous-wiki est créé", "SubWorkspace": "Espace de travail secondaire", "SubWorkspaceDescription": "Il doit être attaché à un dépôt principal, qui peut être utilisé pour stocker du contenu privé. Notez deux points : la base de connaissances secondaire ne peut pas être placée dans le dossier de la base de connaissances principale ; la base de connaissances secondaire est généralement utilisée pour synchroniser les données avec un dépôt Github privé, qui ne peut être lu et écrit que par moi, donc l'adresse du dépôt ne peut pas être la même que celle de la base de connaissances principale.\nLa base de connaissances secondaire prend effet en créant un lien symbolique (raccourci) vers la base de connaissances principale. Après la création du lien, le contenu de la base de connaissances secondaire peut être vu dans la base de connaissances principale.", + "SubWorkspaceOptions": "Paramètres du sous-espace de travail", + "SubWorkspaceOptionsDescriptionForMain": "Configurez les tiddlers que cet espace de travail principal priorise. Lorsqu'une étiquette est définie, les nouveaux tiddlers avec cette étiquette seront enregistrés dans l'espace principal au lieu des sous-espaces", + "SubWorkspaceOptionsDescriptionForSub": "Configurez les tiddlers enregistrés dans ce sous-espace de travail. Les nouveaux tiddlers avec l'étiquette spécifiée seront enregistrés ici", "SubWorkspaceWillLinkTo": "L'espace de travail secondaire sera lié à", "SwitchCreateNewOrOpenExisted": "Passer à la création d'un nouveau Wiki ou à l'ouverture d'un Wiki existant", "SyncedWorkspace": "Espace de travail synchronisé", "SyncedWorkspaceDescription": "Pour synchroniser avec un service de stockage en ligne (comme Github), vous devez vous connecter à un service de stockage ou entrer vos informations d'identification, et avoir une bonne connexion réseau. Vous pouvez synchroniser les données entre les appareils, et vous possédez toujours les données lorsque vous utilisez un service de stockage de confiance. Et même après la suppression accidentelle du dossier, vous pouvez toujours télécharger les données du service en ligne vers le local à nouveau.", "TagName": "Nom de l'étiquette", "TagNameHelp": "Les tiddlers avec cette étiquette seront ajoutés à ce sous-wiki (vous pouvez ajouter ou modifier cette étiquette plus tard, en cliquant avec le bouton droit sur l'icône de l'espace de travail et en choisissant Modifier l'espace de travail)", + "TagNameHelpForMain": "Les nouvelles entrées avec cette étiquette seront prioritairement enregistrées dans cet espace de travail.", "ThisPathIsNotAWikiFolder": "Le répertoire n'est pas un dossier Wiki \"{{wikiPath}}\"", + "UseFilter": "utiliser un filtre", + "UseFilterHelp": "Utilisez des expressions de filtre plutôt que des étiquettes pour correspondre aux entrées et décider si elles doivent être stockées dans l'espace de travail actuel.", "WaitForLogin": "Attendre la connexion", "WikiExisted": "Le Wiki existe déjà à cet emplacement \"{{newWikiPath}}\"", "WikiNotStarted": "Le Wiki n'est pas démarré ou n'est pas chargé", diff --git a/localization/locales/ja/translation.json b/localization/locales/ja/translation.json index 82c22038..d8813658 100644 --- a/localization/locales/ja/translation.json +++ b/localization/locales/ja/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "OAuthサーバーのベースURL(例:http://127.0.0.1:8888)", "ExistedWikiLocation": "既存のWikiの場所", "ExtractedWikiFolderName": "変換されたWIKIフォルダ名", + "FilterExpression": "フィルター式", + "FilterExpressionHelp": "TiddlyWikiフィルター式を1行ずつ入力してください。いずれかが一致すると、このワークスペースに保存されます。例:[in-tagtree-of[Calendar]!tag[Public]]", "GitBranch": "Git ブランチ", "GitBranchDescription": "使用するGitブランチ(デフォルト:main)", "GitDefaultBranchDescription": "Gitのデフォルトブランチ。Githubはそのイベント後にmasterからmainに変更しました", @@ -30,6 +32,9 @@ "GitUserName": "Git ユーザー名", "GitUserNameDescription": "Gitにログインするために使用されるアカウント名。ニックネームではありません", "ImportWiki": "Wikiをインポート: ", + "IncludeTagTree": "タグツリー全体を含める", + "IncludeTagTreeHelp": "有効にすると、タグ(またはタグのタグ、再帰的に)がこのサブWikiのタグに一致するTiddlerもこのサブWikiに保存されます", + "IncludeTagTreeHelpForMain": "チェックを入れると、タグのタグのタグ(任意のレベル…)がこれである限り、このワークスペースに分類されます。", "LocalWikiHtml": "htmlファイルへのパス", "LocalWorkspace": "ローカルワークスペース", "LocalWorkspaceDescription": "ローカルでのみ使用し、データを完全に管理します。TidGiはローカルGitバックアップシステムを作成し、以前のバージョンに戻ることができますが、ローカルフォルダが削除されるとすべての内容が失われます。", @@ -56,13 +61,19 @@ "SubWikiCreationCompleted": "サブWikiが作成されました", "SubWorkspace": "サブワークスペース", "SubWorkspaceDescription": "メインリポジトリに付随する必要があり、プライベートコンテンツを保存するために使用できます。注意点は2つあります:サブナレッジベースはメインナレッジベースフォルダ内に配置できません;サブナレッジベースは一般的にプライベートGithubリポジトリにデータを同期するために使用され、私だけが読み書きできます。そのため、リポジトリアドレスはメインナレッジベースと同じにすることはできません。\nサブナレッジベースはメインナレッジベースへのソフトリンク(ショートカット)を作成することで有効になります。リンクが作成されると、メインナレッジベース内でサブナレッジベースの内容を見ることができます。", + "SubWorkspaceOptions": "子ワークスペース設定", + "SubWorkspaceOptionsDescriptionForMain": "このメインワークスペースが優先的に保存するTiddlerを設定します。タグを設定すると、このタグを持つ新しいTiddlerはサブワークスペースではなくメインワークスペースに保存されます", + "SubWorkspaceOptionsDescriptionForSub": "このサブワークスペースに保存されるTiddlerを設定します。指定されたタグを持つ新しいTiddlerはここに保存されます", "SubWorkspaceWillLinkTo": "サブワークスペースは次にリンクされます", "SwitchCreateNewOrOpenExisted": "新しいWikiを作成するか、既存のWikiを開くかを切り替える", "SyncedWorkspace": "同期されたワークスペース", "SyncedWorkspaceDescription": "オンラインストレージサービス(Githubなど)に同期するには、ストレージサービスにログインするか、ログイン資格情報を入力し、良好なネットワーク接続が必要です。デバイス間でデータを同期でき、信頼できるストレージサービスを使用している場合でもデータはあなたのものです。フォルダが誤って削除された場合でも、オンラインサービスからデータを再度ローカルにダウンロードできます。", "TagName": "タグ名", "TagNameHelp": "このタグを持つTiddlerはこのサブWikiに追加されます(後で右クリックしてワークスペースアイコンを選択し、ワークスペースを編集することでこのタグを追加または変更できます)", + "TagNameHelpForMain": "このタグが付いた新しいエントリは、このワークスペースに優先的に保存されます", "ThisPathIsNotAWikiFolder": "このディレクトリはWikiフォルダではありません \"{{wikiPath}}\"", + "UseFilter": "フィルターを使用する", + "UseFilterHelp": "フィルター式を使用してエントリをマッチングし、現在のワークスペースに保存するかどうかを決定します。タグではなくフィルター式を用います。", "WaitForLogin": "ログインを待っています", "WikiExisted": "この場所にWikiが既に存在します \"{{newWikiPath}}\"", "WikiNotStarted": "Wikiが開始されていないか、読み込まれていません", diff --git a/localization/locales/ru/translation.json b/localization/locales/ru/translation.json index 0107f7df..dd2248a9 100644 --- a/localization/locales/ru/translation.json +++ b/localization/locales/ru/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "Базовый URL сервера OAuth (например: http://127.0.0.1:8888)", "ExistedWikiLocation": "Местоположение существующей Wiki", "ExtractedWikiFolderName": "Имя папки извлеченной WIKI", + "FilterExpression": "выражение фильтра", + "FilterExpressionHelp": "Каждая строка содержит выражение фильтра TiddlyWiki. Если хотя бы одно из них совпадает, запись сохраняется в этой рабочей области. Например: [in-tagtree-of[Calendar]!tag[Public]].", "GitBranch": "Ветка Git", "GitBranchDescription": "Используемая ветка Git (по умолчанию: main)", "GitDefaultBranchDescription": "Основная ветка вашего Git, Github изменил ее с master на main после того событ��я", @@ -30,6 +32,9 @@ "GitUserName": "Имя пользователя Git", "GitUserNameDescription": "Имя учетной записи, используемое для входа в Git. Не псевдоним", "ImportWiki": "Импортировать Wiki: ", + "IncludeTagTree": "Включить все дерево тегов", + "IncludeTagTreeHelp": "При включении тидлеры, чей тег (или тег тега, рекурсивно) соответствует тегу этой под-Wiki, также будут сохранены в этой под-Wiki", + "IncludeTagTreeHelpForMain": "После выбора этой опции все метки (любого уровня вложенности), которые имеют данную метку, будут отнесены к этой рабочей области.", "LocalWikiHtml": "путь к html файлу", "LocalWorkspace": "Локальное рабочее пространство", "LocalWorkspaceDescription": "Используется только локально, полностью контролируйте свои данные. TidGi создаст для вас локальную систему резервного копирования git, позволяющую вернуться к предыдущим версиям тидлеров, но все содержимое будет потеряно при удалении локальной папки.", @@ -56,13 +61,19 @@ "SubWikiCreationCompleted": "Под-Wiki создана", "SubWorkspace": "Подрабочее пространство", "SubWorkspaceDescription": "Должен быть привязан к основному репозиторию, который можно использовать для хранения личного контента. Обратите внимание на два момента: подбаза знаний не может быть размещена в папке основной базы знаний; подбаза знаний обычно используется для синхронизации данных с частным репозиторием Github, который может быть доступен только мне, поэтому адрес репозитория не может совпадать с адресом основной базы знаний.\nПодбаза знаний вступает в силу путем создания символической ссылки (ярлыка) на основную базу знаний. После создания ссылки содержимое подбазы знаний можно увидеть в основной базе знаний.", + "SubWorkspaceOptions": "Настройки подрабочей области", + "SubWorkspaceOptionsDescriptionForMain": "Настройте, какие тидлеры это основное рабочее пространство сохраняет приоритетно. При установке тега новые тидлеры с этим тегом будут сохраняться в основном рабочем пространстве", + "SubWorkspaceOptionsDescriptionForSub": "Настройте, какие тидлеры сохраняются в этом под-рабочем пространстве. Новые тидлеры с указанным тегом будут сохраняться здесь", "SubWorkspaceWillLinkTo": "Подрабочее пространство будет привязано к", "SwitchCreateNewOrOpenExisted": "Переключиться на создание новой или открытие существующей WIKI", "SyncedWorkspace": "Синхронизированное рабочее пространство", "SyncedWorkspaceDescription": "Для синхронизации с онлайн-сервисом хранения (например, Github) необходимо войти в сервис хранения или ввести свои учетные данные и иметь хорошее сетевое соединение. Вы можете синхронизировать данные между устройствами, и вы все равно будете владеть данными, используя надежный сервис хранения. И даже после случайного удаления папки вы все равно сможете загрузить данные с онлайн-сервиса на локальный компьютер.", "TagName": "Имя тега", "TagNameHelp": "Тидлеры с этим тегом будут добавлены в эту под-Wiki (вы можете добавить или изменить этот тег позже, щелкнув правой кнопкой мыши значок рабочего пространства и выбрав Настроить рабочее пространство)", + "TagNameHelpForMain": "Новые записи с этой меткой будут сохраняться в первую очередь в этой рабочей области.", "ThisPathIsNotAWikiFolder": "Каталог не является папкой Wiki \"{{wikiPath}}\"", + "UseFilter": "использовать фильтр", + "UseFilterHelp": "Используйте выражения фильтров вместо меток для сопоставления записей и определения, следует ли сохранять их в текущей рабочей области.", "WaitForLogin": "Ожидание входа", "WikiExisted": "Wiki уже существует в этом месте \"{{newWikiPath}}\"", "WikiNotStarted": "Wiki не запущена или не загружена", diff --git a/localization/locales/zh-Hans/translation.json b/localization/locales/zh-Hans/translation.json index 34de9f90..a0f5efd2 100644 --- a/localization/locales/zh-Hans/translation.json +++ b/localization/locales/zh-Hans/translation.json @@ -61,7 +61,18 @@ "SyncedWorkspace": "云端同步知识库", "SyncedWorkspaceDescription": "同步到在线存储服务(例如Github),需要你登录存储服务或输入登录凭证,并有良好的网络连接。可以跨设备同步数据,在使用了值得信任的存储服务的情况下,数据仍归你所有。而且文件夹被不慎删除后,还可以从在线服务重新下载数据到本地。", "TagName": "标签名", - "TagNameHelp": "加上此标签的笔记将会自动被放入这个子知识库内(可先不填,之后右键点击这个工作区的图标选择编辑工作区修改)", + "TagNameHelp": "加上这些标签之一的笔记将会自动被放入这个子知识库内(可先不填,之后右键点击这个工作区的图标选择编辑工作区修改)", + "TagNameHelpForMain": "带有这些标签的新条目将优先保存在此工作区", + "IncludeTagTree": "包括整个标签树", + "IncludeTagTreeHelp": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此子工作区里", + "IncludeTagTreeHelpForMain": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此工作区里", + "UseFilter": "使用筛选器", + "UseFilterHelp": "用筛选器表达式而不是标签来匹配条目,决定是否存入当前工作区", + "FilterExpression": "筛选器表达式", + "FilterExpressionHelp": "每行一个TiddlyWiki筛选器表达式,任一匹配即存入此工作区。例如 [in-tagtree-of[Calendar]!tag[Public]]", + "SubWorkspaceOptions": "子工作区设置", + "SubWorkspaceOptionsDescriptionForMain": "配置此主工作区优先保存哪些条目。设置标签后,带有此标签的新条目会优先保存在主工作区,而非子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", + "SubWorkspaceOptionsDescriptionForSub": "配置此子工作区保存哪些条目。带有指定标签的新条目将被保存到此子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", "ThisPathIsNotAWikiFolder": "该目录不是一个知识库文件夹 \"{{wikiPath}}\"", "WaitForLogin": "等待登录", "WikiExisted": "知识库已经存在于该位置 \"{{newWikiPath}}\"", diff --git a/localization/locales/zh-Hant/translation.json b/localization/locales/zh-Hant/translation.json index 20ed36c0..855694e2 100644 --- a/localization/locales/zh-Hant/translation.json +++ b/localization/locales/zh-Hant/translation.json @@ -19,6 +19,8 @@ "CustomServerUrlDescription": "OAuth 伺服器的基礎 URL(例如:http://127.0.0.1:8888)", "ExistedWikiLocation": "現有的知識庫的位置", "ExtractedWikiFolderName": "轉換後的知識庫文件夾名稱", + "FilterExpression": "篩選器表達式", + "FilterExpressionHelp": "每行一個TiddlyWiki篩選器表達式,任一匹配即存入此工作區。例如 [in-tagtree-of[Calendar]!tag[Public]]", "GitBranch": "Git 分支", "GitBranchDescription": "要使用的 Git 分支(預設:main)", "GitDefaultBranchDescription": "你的Git的預設分支,Github在黑命貴事件後將其從master改為了main", @@ -30,6 +32,9 @@ "GitUserName": "Git 使用者名稱", "GitUserNameDescription": "用於登入Git的帳戶名,注意是你的倉庫網址中你的名字部分", "ImportWiki": "導入知識庫: ", + "IncludeTagTree": "包括整個標籤樹", + "IncludeTagTreeHelp": "勾選後,只要標籤的標籤的標籤(任意級……)是這個,就會被劃分到此子工作區裡", + "IncludeTagTreeHelpForMain": "勾選後,只要標籤的標籤的標籤(任意級……)是這個,就會被劃分到此工作區裡", "LocalWikiHtml": "HTML文件的路徑", "LocalWorkspace": "本地知識庫", "LocalWorkspaceDescription": "僅在本地使用,完全掌控自己的數據。太記會為你創建一個本地的 git 備份系統,讓你可以回退到之前的版本,但當文件夾被刪除時所有內容還是會遺失。", @@ -56,13 +61,19 @@ "SubWikiCreationCompleted": "子知識庫創建完畢", "SubWorkspace": "子知識庫", "SubWorkspaceDescription": "必須依附於一個主知識庫,可用於存放私有內容。注意兩點:子知識庫不能放在主知識庫文件夾內;子知識庫一般用於同步數據到一個私有的Github倉庫內,僅本人可讀寫,故倉庫地址不能與主知識庫一樣。\n子知識庫透過創建一個到主知識庫的軟連結(捷徑)來生效,創建連結後主知識庫內便可看到子知識庫內的內容了。", + "SubWorkspaceOptions": "子工作區設定", + "SubWorkspaceOptionsDescriptionForMain": "配置此主工作區優先保存哪些條目。設置標籤後,帶有此標籤的新條目會優先保存在主工作區,而非子工作區", + "SubWorkspaceOptionsDescriptionForSub": "配置此子工作區保存哪些條目。帶有指定標籤的新條目將被保存到此子工作區", "SubWorkspaceWillLinkTo": "子知識庫將連結到", "SwitchCreateNewOrOpenExisted": "切換創建新的還是打開現有的知識庫", "SyncedWorkspace": "雲端同步知識庫", "SyncedWorkspaceDescription": "同步到在線儲存服務(例如Github),需要你登錄儲存服務或輸入登錄憑證,並有良好的網路連接。可以跨設備同步數據,在使用了值得信任的儲存服務的情況下,數據仍歸你所有。而且文件夾被不慎刪除後,還可以從在線服務重新下載數據到本地。", "TagName": "標籤名", "TagNameHelp": "加上此標籤的筆記將會自動被放入這個子知識庫內(可先不填,之後右鍵點擊這個工作區的圖示選擇編輯工作區修改)", + "TagNameHelpForMain": "帶有此標籤的新條目將優先保存在此工作區", "ThisPathIsNotAWikiFolder": "該目錄不是一個知識庫文件夾 \"{{wikiPath}}\"", + "UseFilter": "使用篩選器", + "UseFilterHelp": "用篩選器運算式而不是標籤來匹配條目,決定是否存入當前工作區", "WaitForLogin": "等待登錄", "WikiExisted": "知識庫已經存在於該位置 \"{{newWikiPath}}\"", "WikiNotStarted": "知識庫 頁面未成功啟動或未成功載入", diff --git a/package.json b/package.json index 7ab912f9..c7909b90 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.", "version": "0.13.0-prerelease11", "license": "MPL 2.0", - "packageManager": "pnpm@10.18.2", + "packageManager": "pnpm@10.24.0", "scripts": { "start:init": "pnpm run clean && pnpm run init:git-submodule && pnpm run build:plugin && cross-env NODE_ENV=development tsx scripts/developmentMkdir.ts && pnpm run start:dev", "start:dev": "cross-env NODE_ENV=development electron-forge start", @@ -179,7 +179,7 @@ "rimraf": "^6.1.2", "ts-node": "10.9.2", "tsx": "^4.20.6", - "tw5-typed": "^1.0.5", + "tw5-typed": "^1.1.1", "typescript": "5.9.3", "typesync": "0.14.3", "unplugin-swc": "^1.5.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb0253c8..8c63a40b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -418,8 +418,8 @@ importers: specifier: ^4.20.6 version: 4.20.6 tw5-typed: - specifier: ^1.0.5 - version: 1.0.5 + specifier: ^1.1.1 + version: 1.1.1 typescript: specifier: 5.9.3 version: 5.9.3 @@ -7092,8 +7092,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw5-typed@1.0.5: - resolution: {integrity: sha512-LUNQnzkqt7QhIb10VDLtyWBqmGAxQpU9Xh6sPam9I7Ras388X7WToyiAhgQuC6jNO238GeanPLx8CF+nhTZ2PQ==} + tw5-typed@1.1.1: + resolution: {integrity: sha512-hjuWQgG6grHRyOesOldwOuHIxTB2DuUKoSA8M2QiJoNgKSjBOFQ9jytWEEzgsPhhLOpGowE0bIxiyLQ89LbL1w==} type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -15312,7 +15312,7 @@ snapshots: dependencies: safe-buffer: 5.2.1 - tw5-typed@1.0.5: + tw5-typed@1.1.1: dependencies: '@types/codemirror': 5.60.17 '@types/echarts': 5.0.0 diff --git a/src/__tests__/__mocks__/services-container.ts b/src/__tests__/__mocks__/services-container.ts index fbaf8721..f417993d 100644 --- a/src/__tests__/__mocks__/services-container.ts +++ b/src/__tests__/__mocks__/services-container.ts @@ -142,7 +142,7 @@ const defaultWorkspaces: IWorkspace[] = [ port: 5212, isSubWiki: false, mainWikiToLink: null, - tagName: null, + tagNames: [], lastUrl: null, active: true, hibernated: false, @@ -173,7 +173,7 @@ const defaultWorkspaces: IWorkspace[] = [ port: 5213, isSubWiki: false, mainWikiToLink: null, - tagName: null, + tagNames: [], lastUrl: null, active: true, hibernated: false, diff --git a/src/components/__tests__/KeyboardShortcutRegister.test.tsx b/src/components/__tests__/KeyboardShortcutRegister.test.tsx index 47d0706e..9227c131 100644 --- a/src/components/__tests__/KeyboardShortcutRegister.test.tsx +++ b/src/components/__tests__/KeyboardShortcutRegister.test.tsx @@ -13,6 +13,10 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( ); +// Helper to get the correct modifier key based on platform +// On macOS, ctrlKey is displayed as 'Cmd', on other platforms as 'Ctrl' +const getCtrlModifier = () => process.platform === 'darwin' ? 'Cmd' : 'Ctrl'; + describe('KeyboardShortcutRegister Component', () => { let mockOnChange: ReturnType; @@ -158,6 +162,7 @@ describe('KeyboardShortcutRegister Component', () => { const dialogContent = screen.getByTestId('shortcut-dialog-content'); // Simulate keyboard event with Ctrl+Shift+T + // On macOS, ctrlKey is displayed as 'Cmd' fireEvent.keyDown(dialogContent, { key: 'T', ctrlKey: true, @@ -167,7 +172,7 @@ describe('KeyboardShortcutRegister Component', () => { await waitFor(() => { const display = screen.getByTestId('shortcut-display'); - expect(display).toHaveTextContent('Ctrl+Shift+T'); + expect(display).toHaveTextContent(`${getCtrlModifier()}+Shift+T`); }); }); @@ -284,7 +289,7 @@ describe('KeyboardShortcutRegister Component', () => { await waitFor(() => { const display = screen.getByTestId('shortcut-display'); - expect(display).toHaveTextContent('Ctrl+A'); + expect(display).toHaveTextContent(`${getCtrlModifier()}+A`); }); // Press second combination - should replace @@ -297,7 +302,7 @@ describe('KeyboardShortcutRegister Component', () => { await waitFor(() => { const display = screen.getByTestId('shortcut-display'); - expect(display).toHaveTextContent('Ctrl+Shift+B'); + expect(display).toHaveTextContent(`${getCtrlModifier()}+Shift+B`); }); }); }); @@ -372,14 +377,14 @@ describe('KeyboardShortcutRegister Component', () => { await waitFor(() => { const display = screen.getByTestId('shortcut-display'); - expect(display).toHaveTextContent('Ctrl+N'); + expect(display).toHaveTextContent(`${getCtrlModifier()}+N`); }); // Press Enter to confirm fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' }); await waitFor(() => { - expect(mockOnChange).toHaveBeenCalledWith('Ctrl+N'); + expect(mockOnChange).toHaveBeenCalledWith(`${getCtrlModifier()}+N`); }); }); @@ -406,7 +411,7 @@ describe('KeyboardShortcutRegister Component', () => { await waitFor(() => { const display = screen.getByTestId('shortcut-display'); - expect(display).toHaveTextContent('Ctrl+B'); + expect(display).toHaveTextContent(`${getCtrlModifier()}+B`); }); // Press ESC to cancel without saving @@ -485,6 +490,8 @@ describe('KeyboardShortcutRegister Component', () => { }); // Simulate Ctrl+X key press on document + // On macOS, ctrlKey is displayed as 'Cmd', on other platforms as 'Ctrl' + const expectedModifier = process.platform === 'darwin' ? 'Cmd' : 'Ctrl'; fireEvent.keyDown(document, { key: 'X', ctrlKey: true, @@ -493,14 +500,14 @@ describe('KeyboardShortcutRegister Component', () => { // Wait for the key combination to be processed await waitFor(() => { - expect(screen.getByText('Ctrl+X')).toBeInTheDocument(); + expect(screen.getByText(`${expectedModifier}+X`)).toBeInTheDocument(); }); // Press Enter to confirm fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' }); await waitFor(() => { - expect(customOnChange).toHaveBeenCalledWith('Ctrl+X'); + expect(customOnChange).toHaveBeenCalledWith(`${expectedModifier}+X`); }); }); }); diff --git a/src/constants/paths.ts b/src/constants/paths.ts index 4429d03c..8636355c 100644 --- a/src/constants/paths.ts +++ b/src/constants/paths.ts @@ -81,9 +81,12 @@ export const LOCALIZATION_FOLDER = isPackaged // Default wiki locations // For E2E tests, always use project root's wiki-test (outside asar) -// process.resourcesPath: out/TidGi-.../resources -> need ../../.. to get to project root -export const DEFAULT_FIRST_WIKI_FOLDER_PATH = isTest && isPackaged - ? path.resolve(process.resourcesPath, '..', '..', '..', testWikiFolderName) // E2E packaged: project root +// Windows/Linux: process.resourcesPath = out/TidGi-.../resources -> need ../../.. (3 levels) to get to project root +// macOS: process.resourcesPath = out/TidGi-darwin-x64/TidGi.app/Contents/Resources -> need ../../../../../ (5 levels) to get to project root +export const DEFAULT_FIRST_WIKI_FOLDER_PATH = (isTest && isPackaged) + ? (isMac + ? path.resolve(process.resourcesPath, '..', '..', '..', '..', '..', testWikiFolderName) // macOS E2E: 5 levels up + : path.resolve(process.resourcesPath, '..', '..', '..', testWikiFolderName)) // Windows/Linux E2E: 3 levels up : isTest ? path.resolve(__dirname, '..', '..', testWikiFolderName) // E2E dev: project root : isDevelopmentOrTest diff --git a/src/main.ts b/src/main.ts index 2fc442a8..d0bf0afd 100755 --- a/src/main.ts +++ b/src/main.ts @@ -145,6 +145,7 @@ const commonInit = async (): Promise => { await workspaceService.initializeDefaultPageWorkspaces(); // perform wiki startup and git sync for each workspace await workspaceViewService.initializeAllWorkspaceView(); + logger.info('[test-id-ALL_WORKSPACE_VIEW_INITIALIZED] All workspace views initialized'); // Process any pending deep link after workspaces are initialized await deepLinkService.processPendingDeepLink(); diff --git a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx index 2227e6ec..f9b48759 100644 --- a/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx +++ b/src/pages/Main/WorkspaceIconAndSelector/SortableWorkspaceSelectorList.tsx @@ -6,6 +6,7 @@ import { useMemo } from 'react'; import { PageType } from '@/constants/pageTypes'; import { WindowNames } from '@services/windows/WindowProperties'; import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface'; +import { workspaceSorter } from '@services/workspaces/utilities'; import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton'; export interface ISortableListProps { @@ -62,7 +63,7 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText, > {filteredWorkspacesList - .sort((a, b) => a.order - b.order) + .sort(workspaceSorter) .map((workspace, index) => ( ({ label: i18n.t('WorkspaceSelector.OpenWorkspaceTagTiddler', { tagName: isWikiWorkspace(workspace) - ? (workspace.tagName ?? (workspace.isSubWiki ? workspace.name : `${workspace.name} ${i18n.t('WorkspaceSelector.DefaultTiddlers')}`)) + ? (workspace.tagNames[0] ?? (workspace.isSubWiki ? workspace.name : `${workspace.name} ${i18n.t('WorkspaceSelector.DefaultTiddlers')}`)) : workspace.name, }), click: async () => { diff --git a/src/services/native/keyboardShortcutHelpers.ts b/src/services/native/keyboardShortcutHelpers.ts index 0b2a0551..8b6c49f3 100644 --- a/src/services/native/keyboardShortcutHelpers.ts +++ b/src/services/native/keyboardShortcutHelpers.ts @@ -1,3 +1,4 @@ +import { isTest } from '@/constants/environment'; import { container } from '@services/container'; import { logger } from '@services/libs/log'; import serviceIdentifier from '@services/serviceIdentifier'; @@ -82,6 +83,11 @@ export function getShortcutCallback(key: string): (() => Promise) | undefi * @param shortcut The shortcut string (e.g. "CmdOrCtrl+Shift+T") */ export async function registerShortcutByKey(key: string, shortcut: string): Promise { + // Skip in test, we use `src/helpers/testKeyboardShortcuts.ts` for test environment + if (isTest) { + logger.info('Skipping shortcut registration in test environment', { key, shortcut, function: 'registerShortcutByKey' }); + return; + } // Unregister any existing shortcut first if (globalShortcut.isRegistered(shortcut)) { globalShortcut.unregister(shortcut); diff --git a/src/services/view/setupViewEventHandlers.ts b/src/services/view/setupViewEventHandlers.ts index 2646db54..9c5309ec 100644 --- a/src/services/view/setupViewEventHandlers.ts +++ b/src/services/view/setupViewEventHandlers.ts @@ -138,7 +138,7 @@ export default function setupViewEventHandlers( void throttledDidFinishedLoad('did-finish-load'); }); view.webContents.on('did-stop-loading', () => { - logger.debug('did-stop-loading called'); + logger.debug(`did-stop-loading called ${workspace.id}`); void throttledDidFinishedLoad('did-stop-loading'); }); view.webContents.on('dom-ready', () => { @@ -224,7 +224,9 @@ export default function setupViewEventHandlers( if (workspaceObject === undefined) { return; } - if (workspaceObject.active) { + // For main/tidgiMiniWindow, only update title if workspace is active + // For secondary/other windows, always update title regardless of active status + if (windowName === WindowNames.secondary || workspaceObject.active) { browserWindow.setTitle(title); } }); diff --git a/src/services/wiki/index.ts b/src/services/wiki/index.ts index 343a5d93..1572276b 100644 --- a/src/services/wiki/index.ts +++ b/src/services/wiki/index.ts @@ -134,6 +134,10 @@ export class Wiki implements IWikiService { }); } const shouldUseDarkColors = await this.themeService.shouldUseDarkColors(); + + // Get sub-wikis for this main wiki to load their tiddlers + const subWikis = await workspaceService.getSubWorkspacesAsList(workspaceID); + const workerData: IStartNodeJSWikiConfigs = { authToken, constants: { TIDDLYWIKI_PACKAGE_FOLDER: getTiddlyWikiBootPath(wikiFolderLocation) }, @@ -146,6 +150,7 @@ export class Wiki implements IWikiService { readOnlyMode, rootTiddler, shouldUseDarkColors, + subWikis, tiddlyWikiHost: defaultServerIP, tiddlyWikiPort: port, tokenAuth, @@ -477,7 +482,7 @@ export class Wiki implements IWikiService { this.logProgress(i18n.t('AddWorkspace.WikiTemplateCopyCompleted') + newWikiPath); } - public async createSubWiki(parentFolderLocation: string, folderName: string, _subWikiFolderName: string, _mainWikiPath: string, _tagName = '', onlyLink = false): Promise { + public async createSubWiki(parentFolderLocation: string, folderName: string, onlyLink = false): Promise { this.logProgress(i18n.t('AddWorkspace.StartCreatingSubWiki')); const newWikiPath = path.join(parentFolderLocation, folderName); if (!(await pathExists(parentFolderLocation))) { @@ -620,14 +625,7 @@ export class Wiki implements IWikiService { await gitService.clone(gitRepoUrl, path.join(parentFolderLocation, wikiFolderName), gitUserInfo); } - public async cloneSubWiki( - parentFolderLocation: string, - wikiFolderName: string, - _mainWikiPath: string, - gitRepoUrl: string, - gitUserInfo: IGitUserInfos, - _tagName = '', - ): Promise { + public async cloneSubWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise { this.logProgress(i18n.t('AddWorkspace.StartCloningSubWiki')); const newWikiPath = path.join(parentFolderLocation, wikiFolderName); if (!(await pathExists(parentFolderLocation))) { @@ -692,8 +690,9 @@ export class Wiki implements IWikiService { function: 'startWiki', }); await this.startWiki(id, userName); - logger.debug('done', { + logger.info('[test-id-WIKI_WORKER_STARTED] Wiki worker started successfully', { function: 'startWiki', + workspaceId: id, }); } catch (error) { logger.warn('startWiki failed', { function: 'startWiki', error }); diff --git a/src/services/wiki/interface.ts b/src/services/wiki/interface.ts index ba8463a2..a536c498 100644 --- a/src/services/wiki/interface.ts +++ b/src/services/wiki/interface.ts @@ -26,14 +26,7 @@ export interface IWikiService { /** return true if wiki does existed and folder is a valid tiddlywiki folder, return error message (a string) if there is an error checking wiki existence */ checkWikiExist(workspace: IWorkspace, options?: { shouldBeMainWiki?: boolean; showDialog?: boolean }): Promise; checkWikiStartLock(wikiFolderLocation: string): boolean; - cloneSubWiki( - parentFolderLocation: string, - wikiFolderName: string, - mainWikiPath: string, - gitRepoUrl: string, - gitUserInfo: IGitUserInfos, - tagName?: string, - ): Promise; + cloneSubWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise; cloneWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise; copyWikiTemplate(newFolderPath: string, folderName: string): Promise; /** @@ -43,7 +36,7 @@ export interface IWikiService { * @param mainWikiToLink * @param onlyLink not creating new subwiki folder, just link existed subwiki folder to main wiki folder */ - createSubWiki(parentFolderLocation: string, folderName: string, subWikiFolderName: string, mainWikiPath: string, tagName?: string, onlyLink?: boolean): Promise; + createSubWiki(parentFolderLocation: string, folderName: string, onlyLink?: boolean): Promise; ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise; extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath: string): Promise; /** diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts index 02e0e351..7bd7a761 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/FileSystemAdaptor.ts @@ -1,11 +1,13 @@ import type { Logger } from '$:/core/modules/utils/logger.js'; import { workspace } from '@services/wiki/wikiWorker/services'; import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface'; +import { workspaceSorter } from '@services/workspaces/utilities'; import { backOff } from 'exponential-backoff'; import fs from 'fs'; import path from 'path'; import type { IFileInfo } from 'tiddlywiki'; import type { Tiddler, Wiki } from 'tiddlywiki'; +import { isWikiWorkspaceWithRouting, matchTiddlerToWorkspace } from './routingUtilities'; import { isFileLockError } from './utilities'; /** @@ -19,9 +21,8 @@ export class FileSystemAdaptor { boot: typeof $tw.boot; logger: Logger; workspaceID: string; - protected subWikisWithTag: IWikiWorkspace[] = []; - /** Map of tagName -> subWiki for O(1) tag lookup instead of O(n) find */ - protected tagNameToSubWiki: Map = new Map(); + /** All workspaces (main + sub-wikis) that have tagName or filter configured, sorted by order */ + protected wikisWithRouting: IWikiWorkspace[] = []; /** Cached extension filters from $:/config/FileSystemExtensions. Requires restart to reflect changes. */ protected extensionFilters: string[] | undefined; protected watchPathBase!: string; @@ -64,41 +65,31 @@ export class FileSystemAdaptor { } /** - * Update the cached sub-wikis list and rebuild tag lookup map + * Update the cached workspaces list (main + sub-wikis) and rebuild tag lookup map. + * Sorted by order to ensure consistent priority when matching tags. + * Main workspace can also have tagName/includeTagTree for priority routing. */ protected async updateSubWikisCache(): Promise { try { if (!this.workspaceID) { - this.subWikisWithTag = []; - this.tagNameToSubWiki.clear(); + this.wikisWithRouting = []; return; } const currentWorkspace = await workspace.get(this.workspaceID); if (!currentWorkspace) { - this.subWikisWithTag = []; - this.tagNameToSubWiki.clear(); + this.wikisWithRouting = []; return; } const allWorkspaces = await workspace.getWorkspacesAsList(); - const subWikisWithTag = allWorkspaces.filter((workspaceItem: IWorkspace) => - 'isSubWiki' in workspaceItem && - workspaceItem.isSubWiki && - workspaceItem.mainWikiID === currentWorkspace.id && - 'tagName' in workspaceItem && - workspaceItem.tagName && - 'wikiFolderLocation' in workspaceItem && - workspaceItem.wikiFolderLocation - ) as IWikiWorkspace[]; + // Filter to wiki workspaces with routing config (main or sub-wikis) + const workspacesWithRouting = allWorkspaces + .filter((w: IWorkspace): w is IWikiWorkspace => isWikiWorkspaceWithRouting(w, currentWorkspace.id)) + .sort(workspaceSorter); - this.subWikisWithTag = subWikisWithTag; - - this.tagNameToSubWiki.clear(); - for (const subWiki of subWikisWithTag) { - this.tagNameToSubWiki.set(subWiki.tagName!, subWiki); - } + this.wikisWithRouting = workspacesWithRouting; } catch (error) { this.logger.alert('filesystem: Failed to update sub-wikis cache:', error); } @@ -116,6 +107,16 @@ export class FileSystemAdaptor { /** * Main routing logic: determine where a tiddler should be saved based on its tags. * For draft tiddlers, check the original tiddler's tags. + * + * Priority: + * 1. Direct tag match with sub-wiki tagNames + * 2. If includeTagTree is enabled, use in-tagtree-of filter for recursive tag matching + * 3. If fileSystemPathFilterEnable is enabled, use custom filterExpression + * 4. Fall back to TiddlyWiki's FileSystemPaths logic + * + * IMPORTANT: We check if the target directory has changed. Only when directory changes + * do we regenerate the file path. This prevents echo loops where slightly different + * filenames trigger constant saves. */ async getTiddlerFileInfo(tiddler: Tiddler): Promise { if (!this.boot.wikiTiddlersPath) { @@ -124,7 +125,7 @@ export class FileSystemAdaptor { const title = tiddler.fields.title; let tags = tiddler.fields.tags ?? []; - const fileInfo = this.boot.files[title]; + const existingFileInfo = this.boot.files[title]; try { // For draft tiddlers (draft.of field), also check the original tiddler's tags @@ -140,30 +141,64 @@ export class FileSystemAdaptor { } } - let matchingSubWiki: IWikiWorkspace | undefined; - for (const tag of tags) { - matchingSubWiki = this.tagNameToSubWiki.get(tag); - if (matchingSubWiki) { - break; + // Find matching workspace using the routing logic + const matchingWiki = matchTiddlerToWorkspace(title, tags, this.wikisWithRouting, $tw.wiki, $tw.rootWidget); + + // Determine the target directory based on routing + // Sub-wikis store tiddlers directly in their root folder (not in /tiddlers subfolder) + // Only the main wiki uses /tiddlers because it has other meta files like .github + let targetDirectory: string; + if (matchingWiki) { + targetDirectory = matchingWiki.wikiFolderLocation; + // Resolve symlinks + try { + targetDirectory = fs.realpathSync(targetDirectory); + } catch { + // If realpath fails, use original + } + } else { + targetDirectory = this.boot.wikiTiddlersPath; + } + + // Check if existing file is already in the correct directory + // If so, just return the existing fileInfo to avoid echo loops + if (existingFileInfo?.filepath) { + const existingDirectory = path.dirname(existingFileInfo.filepath); + // For sub-wikis, check if file is in that wiki's folder (or subfolder) + // For main wiki, check if file is in main wiki's tiddlers folder (or subfolder) + const normalizedExisting = path.normalize(existingDirectory); + const normalizedTarget = path.normalize(targetDirectory); + + // Check if existing file is within the target directory tree + if (normalizedExisting.startsWith(normalizedTarget) || normalizedExisting === normalizedTarget) { + // File is already in correct location, return existing fileInfo with overwrite flag + return { ...existingFileInfo, overwrite: true }; } } - if (matchingSubWiki) { - return this.generateSubWikiFileInfo(tiddler, matchingSubWiki, fileInfo); + // Directory has changed (or no existing file), generate new file info + if (matchingWiki) { + return this.generateSubWikiFileInfo(tiddler, matchingWiki); } else { - return this.generateDefaultFileInfo(tiddler, fileInfo); + return this.generateDefaultFileInfo(tiddler); } } catch (error) { this.logger.alert(`filesystem: Error in getTiddlerFileInfo for "${title}":`, error); - return this.generateDefaultFileInfo(tiddler, fileInfo); + return this.generateDefaultFileInfo(tiddler); } } /** * Generate file info for sub-wiki directory * Handles symlinks correctly across platforms (Windows junctions and Linux symlinks) + * + * CRITICAL: We must temporarily remove the tiddler from boot.files before calling + * generateTiddlerFileInfo, otherwise TiddlyWiki will use the old path as a base + * and FileSystemPaths filters will apply repeatedly, causing path accumulation. */ - protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: IFileInfo | undefined): IFileInfo { + protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace): IFileInfo { + // Sub-wikis store tiddlers directly in their root folder (not in /tiddlers subfolder) + // Only the main wiki uses /tiddlers because it has other meta files like .github let targetDirectory = subWiki.wikiFolderLocation; // Resolve symlinks to ensure consistent path handling across platforms @@ -179,19 +214,38 @@ export class FileSystemAdaptor { $tw.utils.createDirectory(targetDirectory); - return $tw.utils.generateTiddlerFileInfo(tiddler, { - directory: targetDirectory, - pathFilters: undefined, - extFilters: this.extensionFilters, - wiki: this.wiki, - fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo, - }); + const title = tiddler.fields.title; + const oldFileInfo = this.boot.files[title]; + + // Temporarily remove from boot.files to force fresh path generation + if (oldFileInfo) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.boot.files[title]; + } + + try { + return $tw.utils.generateTiddlerFileInfo(tiddler, { + directory: targetDirectory, + pathFilters: undefined, + extFilters: this.extensionFilters, + wiki: this.wiki, + }); + } finally { + // Restore old fileInfo for potential cleanup in saveTiddler + if (oldFileInfo) { + this.boot.files[title] = oldFileInfo; + } + } } /** * Generate file info using default FileSystemPaths logic + * + * CRITICAL: We must temporarily remove the tiddler from boot.files before calling + * generateTiddlerFileInfo, otherwise TiddlyWiki will use the old path as a base + * and FileSystemPaths filters will apply repeatedly, causing path accumulation. */ - protected generateDefaultFileInfo(tiddler: Tiddler, fileInfo: IFileInfo | undefined): IFileInfo { + protected generateDefaultFileInfo(tiddler: Tiddler): IFileInfo { let pathFilters: string[] | undefined; if (this.wiki.tiddlerExists('$:/config/FileSystemPaths')) { @@ -199,13 +253,28 @@ export class FileSystemAdaptor { pathFilters = pathFiltersText.split('\n').filter(line => line.trim().length > 0); } - return $tw.utils.generateTiddlerFileInfo(tiddler, { - directory: this.boot.wikiTiddlersPath ?? '', - pathFilters, - extFilters: this.extensionFilters, - wiki: this.wiki, - fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo, - }); + const title = tiddler.fields.title; + const oldFileInfo = this.boot.files[title]; + + // Temporarily remove from boot.files to force fresh path generation + if (oldFileInfo) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this.boot.files[title]; + } + + try { + return $tw.utils.generateTiddlerFileInfo(tiddler, { + directory: this.boot.wikiTiddlersPath ?? '', + pathFilters, + extFilters: this.extensionFilters, + wiki: this.wiki, + }); + } finally { + // Restore old fileInfo for potential cleanup in saveTiddler + if (oldFileInfo) { + this.boot.files[title] = oldFileInfo; + } + } } /** diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts index d6db767a..7632b86e 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/WatchFileSystemAdaptor.ts @@ -274,7 +274,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor { // Initialize sub-wiki watchers await this.initializeSubWikiWatchers(); // Log stabilization marker for tests - this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized', { level: 'debug' }); + this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized'); } catch (error) { this.logger.alert('WatchFileSystemAdaptor Failed to initialize file watching:', error); } diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts index 99bea2bd..0654e8ca 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts @@ -37,6 +37,13 @@ global.$tw = { files: {} as Record, }, utils: mockUtils, + wiki: { + filterTiddlers: vi.fn(() => []), + makeTiddlerIterator: vi.fn((titles: string[]) => titles), + }, + rootWidget: { + makeFakeWidgetWithVariables: vi.fn(() => ({})), + }, }; describe('FileSystemAdaptor - Routing Logic', () => { @@ -165,9 +172,31 @@ describe('FileSystemAdaptor - Routing Logic', () => { ); }); - it('should pass existing fileInfo with overwrite flag', async () => { + it('should return existing fileInfo with overwrite flag when file is in correct directory', async () => { const existingFileInfo: IFileInfo = { - filepath: '/test/old.tid', + filepath: '/test/wiki/tiddlers/old.tid', // Already in the correct tiddlers directory + type: 'application/x-tiddler', + hasMetaFile: false, + }; + + // @ts-expect-error - TiddlyWiki global + global.$tw.boot.files['TestTiddler'] = existingFileInfo; + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: [] }, + } as unknown as Tiddler; + + const result = await adaptor.getTiddlerFileInfo(tiddler); + + // Should return the existing fileInfo with overwrite flag, not call generateTiddlerFileInfo + expect(result).toEqual({ ...existingFileInfo, overwrite: true }); + // Should NOT call generateTiddlerFileInfo since file is already in correct location + expect(mockUtils.generateTiddlerFileInfo).not.toHaveBeenCalled(); + }); + + it('should regenerate fileInfo when file is in wrong directory', async () => { + const existingFileInfo: IFileInfo = { + filepath: '/wrong/directory/old.tid', // In wrong directory type: 'application/x-tiddler', hasMetaFile: false, }; @@ -181,14 +210,8 @@ describe('FileSystemAdaptor - Routing Logic', () => { await adaptor.getTiddlerFileInfo(tiddler); - expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( - tiddler, - expect.objectContaining({ - fileInfo: expect.objectContaining({ - overwrite: true, - }), - }), - ); + // Should call generateTiddlerFileInfo since file needs to be moved + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalled(); }); it('should throw error when wikiTiddlersPath is not set', async () => { @@ -242,7 +265,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { name: 'Sub Wiki', isSubWiki: true, mainWikiID: 'test-workspace', - tagName: 'SubWikiTag', + tagNames: ['SubWikiTag'], wikiFolderLocation: '/test/wiki/subwiki/sub1', }; @@ -259,7 +282,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler', tags: ['SubWikiTag', 'OtherTag'] }, - } as Tiddler; + } as unknown as Tiddler; await adaptor.getTiddlerFileInfo(tiddler); @@ -278,7 +301,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { id: 'sub-1', isSubWiki: true, mainWikiID: 'test-workspace', - tagName: 'Tag1', + tagNames: ['Tag1'], wikiFolderLocation: '/test/wiki/sub1', }; @@ -286,7 +309,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { id: 'sub-2', isSubWiki: true, mainWikiID: 'test-workspace', - tagName: 'Tag2', + tagNames: ['Tag2'], wikiFolderLocation: '/test/wiki/sub2', }; @@ -303,7 +326,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { const tiddler: Tiddler = { fields: { title: 'TestTiddler', tags: ['Tag1', 'Tag2'] }, - } as Tiddler; + } as unknown as Tiddler; await adaptor.getTiddlerFileInfo(tiddler); @@ -322,15 +345,16 @@ describe('FileSystemAdaptor - Routing Logic', () => { id: 'sub-wiki-1', isSubWiki: true, mainWikiID: 'test-workspace', - tagName: 'SubWikiTag', + tagNames: ['SubWikiTag'], wikiFolderLocation: '/test/wiki/subwiki', }; - // Test scenario 2: Sub-wiki without tagName + // Test scenario 2: Sub-wiki without tagNames const subWikiWithoutTag = { id: 'sub-wiki-2', isSubWiki: true, mainWikiID: 'test-workspace', + tagNames: [], wikiFolderLocation: '/test/wiki/subwiki2', }; @@ -339,7 +363,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { id: 'sub-wiki-3', isSubWiki: true, mainWikiID: 'other-workspace', - tagName: 'AnotherTag', + tagNames: ['AnotherTag'], wikiFolderLocation: '/test/otherwiki/subwiki', }; @@ -361,7 +385,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { // Tiddler with unmatched tags const tiddler: Tiddler = { fields: { title: 'TestTiddler', tags: ['UnmatchedTag'] }, - } as Tiddler; + } as unknown as Tiddler; await adaptor.getTiddlerFileInfo(tiddler); @@ -438,8 +462,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { // Manually trigger cache update and wait for it await adaptor['updateSubWikisCache'](); - expect(adaptor['subWikisWithTag']).toEqual([]); - expect(adaptor['tagNameToSubWiki'].size).toBe(0); + expect(adaptor['wikisWithRouting']).toEqual([]); }); it('should clear cache when currentWorkspace is not found', async () => { @@ -454,8 +477,7 @@ describe('FileSystemAdaptor - Routing Logic', () => { // Manually trigger cache update and wait for it await adaptor['updateSubWikisCache'](); - expect(adaptor['subWikisWithTag']).toEqual([]); - expect(adaptor['tagNameToSubWiki'].size).toBe(0); + expect(adaptor['wikisWithRouting']).toEqual([]); }); it('should handle errors in updateSubWikisCache gracefully', async () => { @@ -494,4 +516,418 @@ describe('FileSystemAdaptor - Routing Logic', () => { ); }); }); + + describe('getTiddlerFileInfo - Tag Tree Routing (includeTagTree)', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + // Setup mock wiki with workspace ID + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should route to sub-wiki when tiddler matches tag tree', async () => { + const subWiki = { + id: 'sub-wiki-tagtree', + name: 'Sub Wiki TagTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['RootTag'], + includeTagTree: true, + wikiFolderLocation: '/test/wiki/subwiki/tagtree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Mock filterTiddlers to return the tiddler when using in-tagtree-of filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['ChildTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'ChildTiddler', tags: ['ParentTag'] }, // Not directly tagged with RootTag + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because tag tree matching found a match + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/tagtree', + }), + ); + }); + + it('should not match tag tree when includeTagTree is disabled', async () => { + const subWiki = { + id: 'sub-wiki-notree', + name: 'Sub Wiki NoTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['RootTag'], + includeTagTree: false, // Disabled + wikiFolderLocation: '/test/wiki/subwiki/notree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Even if filterTiddlers would return a match, it shouldn't be called + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['ChildTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'ChildTiddler', tags: ['ParentTag'] }, // Not directly tagged with RootTag + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use default directory because includeTagTree is disabled + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + }), + ); + }); + }); + + describe('getTiddlerFileInfo - Custom Filter Routing (fileSystemPathFilter)', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should route to sub-wiki when tiddler matches custom filter', async () => { + const subWiki = { + id: 'sub-wiki-filter', + name: 'Sub Wiki Filter', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['SomeTag'], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/filter', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Mock filterTiddlers to return the tiddler for custom filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn((filter) => { + if (filter === '[has[customfield]]') { + return ['FilterMatchTiddler']; + } + return []; + }); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'FilterMatchTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because custom filter matched + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/filter', + }), + ); + }); + + it('should not match custom filter when fileSystemPathFilterEnable is disabled', async () => { + const subWiki = { + id: 'sub-wiki-filter-disabled', + name: 'Sub Wiki Filter Disabled', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['SomeTag'], + fileSystemPathFilterEnable: false, // Disabled + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/filter-disabled', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'FilterMatchTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use default directory because filter is disabled + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + }), + ); + }); + + it('should support multiple filter lines (any match wins)', async () => { + const subWiki = { + id: 'sub-wiki-multifilter', + name: 'Sub Wiki MultiFilter', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: [], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[field1]]\n[has[field2]]', + wikiFolderLocation: '/test/wiki/subwiki/multifilter', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as unknown as IWikiWorkspace[]); + + // Mock filterTiddlers to return match on second filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn((filter) => { + if (filter === '[has[field2]]') { + return ['TiddlerWithField2']; + } + return []; + }); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TiddlerWithField2', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because second filter line matched + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/multifilter', + }), + ); + }); + }); + + describe('getTiddlerFileInfo - Routing Priority', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should prioritize direct tag match over tag tree match', async () => { + const subWiki1 = { + id: 'sub-wiki-direct', + name: 'Sub Wiki Direct Tag', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 0, + tagNames: ['DirectTag'], + includeTagTree: false, + wikiFolderLocation: '/test/wiki/subwiki/direct', + }; + + const subWiki2 = { + id: 'sub-wiki-tagtree', + name: 'Sub Wiki TagTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 1, + tagNames: ['RootTag'], + includeTagTree: true, + wikiFolderLocation: '/test/wiki/subwiki/tagtree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki1, subWiki2] as IWikiWorkspace[]); + + // Mock tag tree matching to return the tiddler + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['TestTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + // Tiddler has both DirectTag (direct match) and would match RootTag via tag tree + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['DirectTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use direct tag sub-wiki (first match wins, and direct tag check happens before tag tree) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/direct', + }), + ); + }); + + it('should prioritize tag match over custom filter match within same workspace', async () => { + const subWiki = { + id: 'sub-wiki-both', + name: 'Sub Wiki Both', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['MatchTag'], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/both', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Reset filterTiddlers mock + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => []); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + // Tiddler has the matching tag + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['MatchTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should match via tag (filter shouldn't even be checked for this tiddler) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/both', + }), + ); + }); + + it('should check workspaces in order and use first match', async () => { + const subWiki1 = { + id: 'sub-wiki-first', + name: 'Sub Wiki First', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 0, + tagNames: ['SharedTag'], + wikiFolderLocation: '/test/wiki/subwiki/first', + }; + + const subWiki2 = { + id: 'sub-wiki-second', + name: 'Sub Wiki Second', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 1, + tagNames: ['SharedTag'], // Same tag + wikiFolderLocation: '/test/wiki/subwiki/second', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki1, subWiki2] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['SharedTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use first sub-wiki (order 0) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/first', + }), + ); + }); + }); }); diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid b/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid index ab96806d..75c4e880 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/changelog.tid @@ -12,76 +12,69 @@ Version: TiddlyWiki v5.3.x (as of 2025-10-24) !!! Key Modifications -!!!! 1. Dynamic Workspace Information via IPC +!!!! 1. Dynamic Workspace Information via Worker Services * ''Original'': Uses static `$:/config/FileSystemPaths` tiddler for routing -* ''Modified'': Queries workspace information from main process via worker threads IPC +* ''Modified'': Queries workspace information from main process via worker thread services * ''Reason'': Eliminates need for complex string manipulation of `FileSystemPaths` configuration ```typescript -// Added: Worker service caller integration -import { callMainProcessService } from '@services/wiki/wikiWorker/workerServiceCaller'; -import type { IWorkspace } from '@services/workspaces/interface'; +// Added: Worker service integration +import { workspace } from '@services/wiki/wikiWorker/services'; -// Added: Methods to query workspace dynamically -private async getCurrentWorkspace(): Promise -private async getSubWikis(currentWorkspace: IWorkspace): Promise +// Queries workspace data dynamically +const currentWorkspace = await workspace.get(this.workspaceID); +const allWorkspaces = await workspace.getWorkspacesAsList(); ``` -!!!! 2. Tag-Based Sub-Wiki Routing +!!!! 2. Tag-Based and Filter-Based Sub-Wiki Routing * ''Original'': Routes based on filter expressions in `FileSystemPaths` -* ''Modified'': Automatically routes tiddlers to sub-wikis based on tag matching +* ''Modified'': Automatically routes tiddlers to sub-wikis based on: +** Multiple tag names (`tagNames: string[]`) - tiddler matches if any of its tags matches any of workspace's `tagNames` +** Tag tree matching (`includeTagTree`) - recursive tag hierarchy matching using `in-tagtree-of` filter +** Custom filter expressions (`fileSystemPathFilter`) - user-defined TiddlyWiki filters (one per line, any match wins) * ''Modified'': Made `getTiddlerFileInfo`, `saveTiddler`, and `deleteTiddler` async for cleaner code * ''Modified'': Caches sub-wikis list to avoid repeated IPC calls on every save operation +* ''Important'': Always recalculates path on save to handle tag changes - old `fileInfo` only used for cleanup * ''Implementation'': -** Checks tiddler tags against sub-workspace `tagName` fields -** Routes matching tiddlers to sub-wiki's `tiddlers` folder -** Falls back to default `FileSystemPaths` logic for non-matching tiddlers +** Checks tiddler tags/filters against sub-workspace routing rules (in priority order) +** Routes matching tiddlers directly to sub-wiki's root folder (not `/tiddlers` subfolder) +** Falls back to TiddlyWiki's `$:/config/FileSystemPaths` logic for non-matching tiddlers ** Loads sub-wikis cache on initialization ** Currently loads sub-wikis once, future enhancements can watch for workspace changes ```typescript -// Modified: getTiddlerFileInfo is now async (safe since callers only use callback) -async getTiddlerFileInfo(tiddler: Tiddler, callback: IFileSystemAdaptorCallback): Promise { - // Direct async/await instead of nested void IIFE - const currentWorkspace = await this.getCurrentWorkspace(); - const subWikis = this.getSubWikis(); // Uses cache instead of IPC - const matchingSubWiki = subWikis.find(...); - - if (matchingSubWiki) { - this.routeToSubWorkspace(...); - } else { - this.useDefaultFileSystemLogic(...); - } -} +// Uses pure functions from routingUtils.ts for matching logic +import { matchTiddlerToWorkspace, isWikiWorkspaceWithRouting } from './routingUtils'; -// Added: Caching mechanism -private subWikis: IWorkspace[] = []; +// Match tiddler to workspace +const matchingWiki = matchTiddlerToWorkspace(title, tags, this.wikisWithRouting, $tw.wiki, $tw.rootWidget); -private async initializeSubWikisCache(): Promise { - await this.updateSubWikisCache(); -} - -private async updateSubWikisCache(): Promise { - // Load sub-wikis once and cache them - const allWorkspaces = await callMainProcessService(...); - this.subWikis = allWorkspaces.filter(...); +// Generate file info based on routing result +if (matchingWiki) { + return this.generateSubWikiFileInfo(tiddler, matchingWiki); +} else { + return this.generateDefaultFileInfo(tiddler); } ``` -!!!! 3. Separated Routing Logic +!!!! 3. Separated Routing Logic into Pure Functions -* ''Added'': `routeToSubWorkspace()` method for sub-wiki routing -* ''Added'': `useDefaultFileSystemLogic()` method for standard routing -* ''Reason'': Better code organization and maintainability +* ''Added'': `routingUtils.ts` with pure functions for routing logic: +** `isWikiWorkspaceWithRouting()` - checks if workspace has routing config +** `matchTiddlerToWorkspace()` - matches tiddler to workspace based on routing rules +** `matchesDirectTag()` - checks direct tag match +** `matchesTagTree()` - checks tag tree match using in-tagtree-of filter +** `matchesCustomFilter()` - checks custom filter match +* ''Reason'': Better code organization, testability, and maintainability !!! Future Compatibility Notes When updating from upstream TiddlyWiki filesystem adaptor: # Review changes to core methods: `saveTiddler`, `deleteTiddler`, `getTiddlerInfo` -# Preserve our IPC-based workspace querying logic +# Preserve our worker-service-based workspace querying logic # Preserve tag-based routing in `getTiddlerFileInfo` # Update type definitions if TiddlyWiki's FileInfo interface changes # Test sub-wiki routing functionality after merge @@ -92,7 +85,7 @@ When validating this adaptor: * [ ] Tiddlers with matching tags route to correct sub-wiki * [ ] Tiddlers without matching tags use default FileSystemPaths -* [ ] IPC communication works correctly in worker thread +* [ ] Worker service communication works correctly * [ ] Error handling falls back gracefully * [ ] File operations (save/delete) work in both main and sub-wikis -* [ ] Workspace ID caching reduces IPC overhead +* [ ] Workspace caching reduces service call overhead diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts new file mode 100644 index 00000000..8b095e66 --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts @@ -0,0 +1,93 @@ +/** + Finds out where a tiddler originates from, is it in a tag tree with xxx as root? + + based on: + + - https://github.com/tiddly-gittly/in-tagtree-of/ + - https://github.com/bimlas/tw5-kin-filter/blob/master/plugins/kin-filter/kin.js + - https://talk.tiddlywiki.org/t/recursive-filter-operators-to-show-all-tiddlers-beneath-a-tag-and-all-tags-above-a-tiddler/3814 +*/ + +import type { IFilterOperator, IFilterOperatorParameterOperator, SourceIterator, Tiddler } from 'tiddlywiki'; + +declare const exports: Record; +exports['in-tagtree-of'] = function inTagTreeOfFilterOperator( + source: (iter: SourceIterator) => void, + operator: IFilterOperatorParameterOperator, +): ReturnType { + const rootTiddler = operator.operand; + /** + * By default we check tiddler passed-in is tagged with the operand (or is its child), we output the tiddler passed-in, otherwise output empty. + * But if `isInclusive` is true, if tiddler operand itself is passed-in, we output it, even if the operand itself is not tagged with itself. + */ + const isInclusive = operator.suffix === 'inclusive'; + /** + * If add `!` prefix, means output the input if input is not in rootTiddlerChildren + */ + const isNotInTagTreeOf = operator.prefix === '!'; + + const sourceTiddlers = new Set(); + let firstTiddler: Tiddler | undefined; + source((tiddler, title) => { + sourceTiddlers.add(title); + if (firstTiddler === undefined) { + firstTiddler = tiddler; + } + }); + + // optimize for fileSystemPath and cascade usage, where input will only be one tiddler, and often is just tagged with the rootTiddler + if (sourceTiddlers.size === 1 && !isNotInTagTreeOf) { + const [theOnlyTiddlerTitle] = sourceTiddlers; + if (firstTiddler?.fields?.tags?.includes(rootTiddler) === true) { + return [theOnlyTiddlerTitle]; + } + if (isInclusive && theOnlyTiddlerTitle === rootTiddler) { + return [theOnlyTiddlerTitle]; + } + } + + const rootTiddlerChildren = $tw.wiki.getGlobalCache(`in-tagtree-of-${rootTiddler}`, () => { + const results = new Set(); + getTiddlersRecursively(rootTiddler, results); + return results; + }); + + if (isInclusive) { + rootTiddlerChildren.add(rootTiddler); + } + if (isNotInTagTreeOf) { + const sourceTiddlerCheckedToNotBeChildrenOfRootTiddler: string[] = [...sourceTiddlers].filter(title => !rootTiddlerChildren.has(title)); + return sourceTiddlerCheckedToNotBeChildrenOfRootTiddler; + } + const sourceTiddlerCheckedToBeChildrenOfRootTiddler: string[] = [...sourceTiddlers].filter(title => rootTiddlerChildren.has(title)); + return sourceTiddlerCheckedToBeChildrenOfRootTiddler; +}; + +function getTiddlersRecursively(title: string, results: Set) { + // get tagging[] list at this level + const intermediate = new Set($tw.wiki.getTiddlersWithTag(title)); + // remove any TiddlersWithTag in intermediate that are already in the results set to avoid loops + // code adapted from $tw.utils.pushTop + if (intermediate.size > 0) { + if (results.size > 0) { + if (results.size < intermediate.size) { + results.forEach(alreadyExisted => { + if (intermediate.has(alreadyExisted)) { + intermediate.delete(alreadyExisted); + } + }); + } else { + intermediate.forEach(alreadyExisted => { + if (results.has(alreadyExisted)) { + intermediate.delete(alreadyExisted); + } + }); + } + } + // add the remaining intermediate results and traverse the hierarchy further + intermediate.forEach((title) => results.add(title)); + intermediate.forEach((title) => { + getTiddlersRecursively(title, results); + }); + } +} diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts.meta b/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts.meta new file mode 100644 index 00000000..44b05ceb --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts.meta @@ -0,0 +1,4 @@ +creator: LinOnetwo +title: $:/plugins/linonetwo/watch-filesystem-adaptor/in-tagtree-of/index.js +type: application/javascript +module-type: filteroperator \ No newline at end of file diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid b/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid index d72c0b6a..7125f734 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/readme.tid @@ -3,22 +3,39 @@ type: text/vnd.tiddlywiki !! Watch Filesystem Adaptor -This plugin provides an enhanced filesystem adaptor that automatically routes tiddlers to sub-wikis based on their tags. +This plugin provides an enhanced filesystem adaptor that automatically routes tiddlers to sub-wikis based on their tags or custom filters. !!! How It Works # Queries workspace information from TidGi's main process via IPC -# Checks each tiddler's tags against sub-workspace `tagName` fields -# Routes tiddlers with matching tags to the corresponding sub-wiki's tiddlers folder +# Checks each tiddler against sub-workspace routing rules (any match wins): +#* Direct tag match - if any of the tiddler's tags match any of the workspace's `tagNames` +#* Tag tiddlers - if a tiddler's title IS one of the `tagNames`, it's also routed (e.g., tiddler "Test" goes to sub-wiki with tagNames=["Test"]) +#* Tag tree (`includeTagTree`) - if enabled, uses `in-tagtree-of` filter for recursive tag hierarchy matching +#* Custom filter expressions (`fileSystemPathFilter`) - if enabled, uses custom TiddlyWiki filter expressions (one per line, any match wins) +# Routes tiddlers with matching rules to the corresponding sub-wiki's root folder # Falls back to standard `$:/config/FileSystemPaths` logic for non-matching tiddlers +# Only moves files when target directory changes (avoids echo loops) +# Existing tiddlers in wrong location will be moved when modified + +!!! Directory Structure + +* ''Main wiki'': tiddlers are stored in `wiki/tiddlers/` (because main wiki has other meta files like `.github`) +* ''Sub-wikis'': tiddlers are stored directly in the sub-wiki root folder (e.g., `wiki-sub/`) !!! Advantages * No need to manually edit `$:/config/FileSystemPaths` * Automatically stays in sync with workspace configuration +* Supports multiple tags per workspace +* Supports tag hierarchy matching +* Supports custom TiddlyWiki filter expressions +* All routing methods work together (tag match, tag tree, custom filter) +* Handles tag changes - moves tiddlers when tags are modified +* Tag tiddlers (tiddlers whose title matches a tag name) are also routed correctly * More robust than string manipulation * Works seamlessly with TidGi's workspace management !!! Technical Details -This adaptor runs in the wiki worker (Node.js) and communicates with TidGi's main process using worker threads IPC to access workspace service methods. It dynamically resolves the current workspace and its sub-wikis, then routes tiddlers based on their tags. +This adaptor runs in the wiki worker (Node.js) and communicates with TidGi's main process using worker threads IPC to access workspace service methods. It dynamically resolves the current workspace and its sub-wikis, then routes tiddlers based on their tags or custom filters. diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/routingUtilities.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/routingUtilities.ts new file mode 100644 index 00000000..2fe4868e --- /dev/null +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/routingUtilities.ts @@ -0,0 +1,148 @@ +import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface'; + +/** + * Check if a workspace has routing configuration (tagNames or fileSystemPathFilter). + */ +export function hasRoutingConfig(workspaceItem: IWorkspace): boolean { + const hasTagNames = 'tagNames' in workspaceItem && Array.isArray(workspaceItem.tagNames) && workspaceItem.tagNames.length > 0; + const hasFilter = 'fileSystemPathFilterEnable' in workspaceItem && + workspaceItem.fileSystemPathFilterEnable && + 'fileSystemPathFilter' in workspaceItem && + Boolean(workspaceItem.fileSystemPathFilter); + return hasTagNames || hasFilter; +} + +/** + * Check if a workspace is a wiki workspace with routing configuration. + * This filters to wiki workspaces that are either the main workspace or sub-wikis of it. + */ +export function isWikiWorkspaceWithRouting( + workspaceItem: IWorkspace, + mainWorkspaceId: string, +): workspaceItem is IWikiWorkspace { + // Must have wiki folder location + if (!('wikiFolderLocation' in workspaceItem) || !workspaceItem.wikiFolderLocation) { + return false; + } + + // Must have routing config + if (!hasRoutingConfig(workspaceItem)) { + return false; + } + + // Include if it's the main workspace + const isMain = workspaceItem.id === mainWorkspaceId; + + // Include if it's a sub-wiki of the current main workspace + const isSubWiki = 'isSubWiki' in workspaceItem && + workspaceItem.isSubWiki && + 'mainWikiID' in workspaceItem && + workspaceItem.mainWikiID === mainWorkspaceId; + + return isMain || isSubWiki; +} + +/** + * Check if a tiddler matches a workspace's direct tag routing. + * Returns true if: + * - Any of the tiddler's tags match any of the workspace's tagNames + * - The tiddler's title IS one of the tagNames (it's a "tag tiddler") + */ +export function matchesDirectTag( + tiddlerTitle: string, + tiddlerTags: string[], + workspaceTagNames: string[], +): boolean { + if (workspaceTagNames.length === 0) { + return false; + } + + const hasMatchingTag = workspaceTagNames.some(tagName => tiddlerTags.includes(tagName)); + const isTitleATagName = workspaceTagNames.includes(tiddlerTitle); + + return hasMatchingTag || isTitleATagName; +} + +/** + * Check if a tiddler matches a workspace's tag tree routing. + * Uses TiddlyWiki's in-tagtree-of filter for recursive tag hierarchy matching. + */ +export function matchesTagTree( + tiddlerTitle: string, + workspaceTagNames: string[], + wiki: typeof $tw.wiki, + rootWidget: typeof $tw.rootWidget, +): boolean { + for (const tagName of workspaceTagNames) { + const result = wiki.filterTiddlers( + `[in-tagtree-of:inclusive]`, + rootWidget.makeFakeWidgetWithVariables({ tagName }), + wiki.makeTiddlerIterator([tiddlerTitle]), + ); + if (result.length > 0) { + return true; + } + } + return false; +} + +/** + * Check if a tiddler matches a workspace's custom filter routing. + * Filters are separated by newlines; any match wins. + */ +export function matchesCustomFilter( + tiddlerTitle: string, + filterExpression: string, + wiki: typeof $tw.wiki, +): boolean { + const filters = filterExpression.split('\n').map(f => f.trim()).filter(f => f.length > 0); + + for (const filter of filters) { + const result = wiki.filterTiddlers(filter, undefined, wiki.makeTiddlerIterator([tiddlerTitle])); + if (result.length > 0) { + return true; + } + } + + return false; +} + +/** + * Match a tiddler to a workspace based on routing rules. + * Checks workspaces in order (priority) and returns the first match. + * + * For each workspace, checks in order (any match wins): + * 1. Direct tag match (including if tiddler's title IS one of the tagNames) + * 2. If includeTagTree is enabled, use in-tagtree-of filter for recursive tag matching + * 3. If fileSystemPathFilterEnable is enabled, use custom filter expressions + */ +export function matchTiddlerToWorkspace( + tiddlerTitle: string, + tiddlerTags: string[], + workspacesWithRouting: IWikiWorkspace[], + wiki: typeof $tw.wiki, + rootWidget: typeof $tw.rootWidget, +): IWikiWorkspace | undefined { + for (const workspace of workspacesWithRouting) { + // 1. Direct tag match + if (matchesDirectTag(tiddlerTitle, tiddlerTags, workspace.tagNames)) { + return workspace; + } + + // 2. Tag tree match (if enabled) + if (workspace.includeTagTree && workspace.tagNames.length > 0) { + if (matchesTagTree(tiddlerTitle, workspace.tagNames, wiki, rootWidget)) { + return workspace; + } + } + + // 3. Custom filter match (if enabled) + if (workspace.fileSystemPathFilterEnable && workspace.fileSystemPathFilter) { + if (matchesCustomFilter(tiddlerTitle, workspace.fileSystemPathFilter, wiki)) { + return workspace; + } + } + } + + return undefined; +} diff --git a/src/services/wiki/wikiWorker/index.ts b/src/services/wiki/wikiWorker/index.ts index eecc5c24..e0b5af7c 100644 --- a/src/services/wiki/wikiWorker/index.ts +++ b/src/services/wiki/wikiWorker/index.ts @@ -39,6 +39,12 @@ export interface IStartNodeJSWikiConfigs { readOnlyMode?: boolean; rootTiddler?: string; shouldUseDarkColors: boolean; + /** + * Sub-wikis to load their tiddlers into the main wiki. + * Sorted by order (lower = higher priority). + * Note: Tag-based routing is handled separately by FileSystemAdaptor. + */ + subWikis?: IWikiWorkspace[]; tiddlyWikiHost: string; tiddlyWikiPort: number; tokenAuth?: boolean; diff --git a/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts b/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts new file mode 100644 index 00000000..d19c1cc5 --- /dev/null +++ b/src/services/wiki/wikiWorker/loadWikiTiddlersWithSubWikis.ts @@ -0,0 +1,83 @@ +import type { IWikiWorkspace } from '@services/workspaces/interface'; +import type { TiddlyWiki } from 'tiddlywiki'; + +/** + * Factory function to create a custom loadWikiTiddlers function that loads sub-wiki tiddlers. + * This ensures sub-wiki tiddlers are loaded into the main wiki's $tw.boot.files + * and $tw.wiki, making them available alongside main wiki tiddlers. + * + * TiddlyWiki's includeWikis mechanism normally requires modifying tiddlywiki.info, + * but we dynamically inject sub-wikis based on workspace configuration instead. + * This wraps TiddlyWiki's original loadWikiTiddlers to dynamically inject sub-wiki tiddlers + * after the main wiki is loaded, without modifying tiddlywiki.info. + * + * @param wikiInstance - The TiddlyWiki instance + * @param homePath - Main wiki home path + * @param subWikis - Array of sub-wiki workspaces sorted by order (priority) + * @param workspaceName - Workspace name for logging + * @param nativeLogger - Logger function + */ +export function createLoadWikiTiddlersWithSubWikis( + wikiInstance: ReturnType, + homePath: string, + subWikis: IWikiWorkspace[], + workspaceName: string, + nativeLogger: { + logFor: (name: string, level: 'info' | 'error', message: string) => Promise; + }, +) { + const originalLoadWikiTiddlers = wikiInstance.loadWikiTiddlers.bind(wikiInstance); + + return function loadWikiTiddlersWithSubWikis( + wikiPath: string, + options?: { parentPaths?: string[]; readOnly?: boolean }, + ) { + // Call original function first to load main wiki + const wikiInfo = originalLoadWikiTiddlers(wikiPath, options); + + // Only inject sub-wikis when loading the main wiki (not when loading included wikis) + if (wikiPath !== homePath || !wikiInfo || subWikis.length === 0) { + return wikiInfo; + } + for (const subWiki of subWikis) { + // Sub-wikis store tiddlers directly in their root folder (not in /tiddlers subfolder) + // Only the main wiki uses /tiddlers because it has other meta files like .github + const subWikiTiddlersPath = subWiki.wikiFolderLocation; + + try { + // Load tiddlers from sub-wiki directory + const tiddlerFiles = wikiInstance.loadTiddlersFromPath(subWikiTiddlersPath); + + for (const tiddlerFile of tiddlerFiles) { + // Register file info for filesystem adaptor (so tiddlers save back to correct location) + if (tiddlerFile.filepath) { + for (const tiddler of tiddlerFile.tiddlers) { + wikiInstance.boot.files[tiddler.title] = { + filepath: tiddlerFile.filepath, + type: tiddlerFile.type ?? 'application/x-tiddler', + hasMetaFile: tiddlerFile.hasMetaFile ?? false, + isEditableFile: tiddlerFile.isEditableFile ?? true, + }; + } + } + // Add tiddlers to wiki + wikiInstance.wiki.addTiddlers(tiddlerFile.tiddlers); + } + + void nativeLogger.logFor( + workspaceName, + 'info', + `Loaded sub-wiki tiddlers from: ${subWikiTiddlersPath}`, + ); + } catch (error) { + void nativeLogger.logFor( + workspaceName, + 'error', + `Failed to load sub-wiki tiddlers from ${subWikiTiddlersPath}: ${(error as Error).message}`, + ); + } + } + + return wikiInfo; + }; +} diff --git a/src/services/wiki/wikiWorker/startNodeJSWiki.ts b/src/services/wiki/wikiWorker/startNodeJSWiki.ts index 57acf660..5889b243 100644 --- a/src/services/wiki/wikiWorker/startNodeJSWiki.ts +++ b/src/services/wiki/wikiWorker/startNodeJSWiki.ts @@ -18,6 +18,7 @@ import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOpera import type { IStartNodeJSWikiConfigs } from '../wikiWorker'; import { setWikiInstance } from './globals'; import { ipcServerRoutes } from './ipcServerRoutes'; +import { createLoadWikiTiddlersWithSubWikis } from './loadWikiTiddlersWithSubWikis'; import { authTokenIsProvided } from './wikiWorkerUtilities'; export function startNodeJSWiki({ @@ -32,6 +33,7 @@ export function startNodeJSWiki({ readOnlyMode, rootTiddler = '$:/core/save/all', shouldUseDarkColors, + subWikis = [], tiddlyWikiHost = defaultServerIP, tiddlyWikiPort = 5112, tokenAuth, @@ -102,6 +104,20 @@ export function startNodeJSWiki({ setWikiInstance(wikiInstance); process.env.TIDDLYWIKI_PLUGIN_PATH = path.resolve(homePath, 'plugins'); process.env.TIDDLYWIKI_THEME_PATH = path.resolve(homePath, 'themes'); + + /** + * Hook loadWikiTiddlers to inject sub-wiki tiddlers after main wiki is loaded. + */ + if (subWikis.length > 0) { + wikiInstance.loadWikiTiddlers = createLoadWikiTiddlersWithSubWikis( + wikiInstance, + homePath, + subWikis, + workspace.name, + native, + ); + } + // don't add `+` prefix to plugin name here. `+` only used in args[0], but we are not prepend this list to the args list. wikiInstance.boot.extraPlugins = [ // add tiddly filesystem back if is not readonly https://github.com/Jermolene/TiddlyWiki5/issues/4484#issuecomment-596779416 diff --git a/src/services/wikiEmbedding/__tests__/index.test.ts b/src/services/wikiEmbedding/__tests__/index.test.ts index fbf08ff1..63dc24fb 100644 --- a/src/services/wikiEmbedding/__tests__/index.test.ts +++ b/src/services/wikiEmbedding/__tests__/index.test.ts @@ -54,7 +54,7 @@ describe('WikiEmbeddingService Integration Tests', () => { port: 5212, isSubWiki: false, mainWikiToLink: null, - tagName: null, + tagNames: [], lastUrl: null, active: true, hibernated: false, diff --git a/src/services/wikiGitWorkspace/index.ts b/src/services/wikiGitWorkspace/index.ts index ca73887f..ce492200 100644 --- a/src/services/wikiGitWorkspace/index.ts +++ b/src/services/wikiGitWorkspace/index.ts @@ -150,12 +150,15 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService { backupOnInterval: true, readOnlyMode: false, tokenAuth: false, - tagName: null, + tagNames: [], mainWikiToLink: null, mainWikiID: null, excludedPlugins: [], enableHTTPAPI: false, enableFileSystemWatch: true, + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null, lastNodeJSArgv: [], homeUrl: '', gitUrl: null, diff --git a/src/services/windows/handleAttachToTidgiMiniWindow.ts b/src/services/windows/handleAttachToTidgiMiniWindow.ts index 3417a790..abd03e8f 100644 --- a/src/services/windows/handleAttachToTidgiMiniWindow.ts +++ b/src/services/windows/handleAttachToTidgiMiniWindow.ts @@ -1,3 +1,4 @@ +import { isTest } from '@/constants/environment'; import { TIDGI_MINI_WINDOW_ICON_PATH } from '@/constants/paths'; import { isMac } from '@/helpers/system'; import { container } from '@services/container'; @@ -96,18 +97,19 @@ export async function handleAttachToTidgiMiniWindow( }); } }); - tidgiMiniWindow.on('hide', async () => { - // on mac, calling `tidgiMiniWindow.app.hide()` with main window open will bring background main window up, which we don't want. We want to bring previous other app up. So close main window first. - if (isMac) { - const mainWindow = windowService.get(WindowNames.main); - if (mainWindow?.isVisible() === true) { - await windowService.hide(WindowNames.main); - } - } - }); + // This will close main and preference window when mini window closed, thus make it impossible to test keyboard shortcut to open mini window again, make e2e test fail on mac. So commented out. + // tidgiMiniWindow.on('hide', async () => { + // // on mac, calling `tidgiMiniWindow.app.hide()` with main window open will bring background main window up, which we don't want. We want to bring previous other app up. So close main window first. + // if (isMac) { + // const mainWindow = windowService.get(WindowNames.main); + // if (mainWindow?.isVisible() === true) { + // await windowService.hide(WindowNames.main); + // } + // } + // }); // https://github.com/maxogden/menubar/issues/120 tidgiMiniWindow.on('after-hide', () => { - if (isMac) { + if (isMac && !isTest) { tidgiMiniWindow.app.hide(); } }); diff --git a/src/services/windows/index.ts b/src/services/windows/index.ts index a88715fc..0adac234 100644 --- a/src/services/windows/index.ts +++ b/src/services/windows/index.ts @@ -414,6 +414,7 @@ export class Window implements IWindowService { // Use menuBar.showWindow() instead of direct window.show() for proper tidgi mini window behavior void this.tidgiMiniWindowMenubar.showWindow(); + logger.info('[test-id-TIDGI_MINI_WINDOW_SHOWN] TidGi mini window showWindow called', { function: 'openTidgiMiniWindow' }); } return; } @@ -421,7 +422,7 @@ export class Window implements IWindowService { // Create tidgi mini window (create and open when enableIt is true) await this.open(WindowNames.tidgiMiniWindow); if (enableIt) { - logger.debug('TidGi mini window enabled', { function: 'openTidgiMiniWindow' }); + logger.debug('[test-id-TIDGI_MINI_WINDOW_CREATED] TidGi mini window enabled', { function: 'openTidgiMiniWindow' }); // After creating the tidgi mini window, show it if requested if (showWindow && this.tidgiMiniWindowMenubar) { logger.debug('Showing newly created tidgi mini window', { function: 'openTidgiMiniWindow' }); diff --git a/src/services/workspaces/getWorkspaceMenuTemplate.ts b/src/services/workspaces/getWorkspaceMenuTemplate.ts index 90365a2f..ce8f44f3 100644 --- a/src/services/workspaces/getWorkspaceMenuTemplate.ts +++ b/src/services/workspaces/getWorkspaceMenuTemplate.ts @@ -131,12 +131,12 @@ export async function getWorkspaceMenuTemplate( }]; } - const { hibernated, tagName, isSubWiki, wikiFolderLocation, gitUrl, storageService, port, enableHTTPAPI, lastUrl, homeUrl } = workspace; + const { hibernated, tagNames, isSubWiki, wikiFolderLocation, gitUrl, storageService, port, enableHTTPAPI, lastUrl, homeUrl } = workspace; const template: MenuItemConstructorOptions[] = [ { label: t('WorkspaceSelector.OpenWorkspaceTagTiddler', { - tagName: tagName ?? (isSubWiki ? name : `${name} ${t('WorkspaceSelector.DefaultTiddlers')}`), + tagName: tagNames[0] ?? (isSubWiki ? name : `${name} ${t('WorkspaceSelector.DefaultTiddlers')}`), }), click: async () => { await service.workspace.openWorkspaceTiddler(workspace); diff --git a/src/services/workspaces/index.ts b/src/services/workspaces/index.ts index ba7f58e5..51e9541e 100644 --- a/src/services/workspaces/index.ts +++ b/src/services/workspaces/index.ts @@ -24,7 +24,16 @@ import type { IViewService } from '@services/view/interface'; import type { IWikiService } from '@services/wiki/interface'; import { WindowNames } from '@services/windows/WindowProperties'; import type { IWorkspaceViewService } from '@services/workspacesView/interface'; -import type { IDedicatedWorkspace, INewWikiWorkspaceConfig, IWorkspace, IWorkspaceMetaData, IWorkspaceService, IWorkspacesWithMetadata, IWorkspaceWithMetadata } from './interface'; +import type { + IDedicatedWorkspace, + INewWikiWorkspaceConfig, + IWikiWorkspace, + IWorkspace, + IWorkspaceMetaData, + IWorkspaceService, + IWorkspacesWithMetadata, + IWorkspaceWithMetadata, +} from './interface'; import { isWikiWorkspace } from './interface'; import { registerMenu } from './registerMenu'; import { workspaceSorter } from './utilities'; @@ -136,18 +145,18 @@ export class Workspace implements IWorkspaceService { return Object.values(this.getWorkspacesSync()).sort(workspaceSorter); } - public async getSubWorkspacesAsList(workspaceID: string): Promise { + public async getSubWorkspacesAsList(workspaceID: string): Promise { const workspace = this.getSync(workspaceID); if (workspace === undefined || !isWikiWorkspace(workspace)) return []; if (workspace.isSubWiki) return []; - return this.getWorkspacesAsListSync().filter((w) => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); + return this.getWorkspacesAsListSync().filter((w): w is IWikiWorkspace => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); } - public getSubWorkspacesAsListSync(workspaceID: string): IWorkspace[] { + public getSubWorkspacesAsListSync(workspaceID: string): IWikiWorkspace[] { const workspace = this.getSync(workspaceID); if (workspace === undefined || !isWikiWorkspace(workspace)) return []; if (workspace.isSubWiki) return []; - return this.getWorkspacesAsListSync().filter((w) => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); + return this.getWorkspacesAsListSync().filter((w): w is IWikiWorkspace => isWikiWorkspace(w) && w.mainWikiID === workspaceID).sort(workspaceSorter); } public async get(id: string): Promise { @@ -225,6 +234,10 @@ export class Workspace implements IWorkspaceService { backupOnInterval: true, excludedPlugins: [], enableHTTPAPI: false, + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null, + tagNames: [], }; const fixingValues: Partial = {}; // we add mainWikiID in creation, we fix this value for old existed workspaces @@ -234,9 +247,11 @@ export class Workspace implements IWorkspaceService { fixingValues.mainWikiID = mainWorkspace.id; } } - // fix WikiChannel.openTiddler in src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts have \n on the end - if (workspaceToSanitize.tagName?.endsWith('\n') === true) { - fixingValues.tagName = workspaceToSanitize.tagName.replaceAll('\n', ''); + // Migrate old tagName (string) to tagNames (string[]) + + const legacyTagName = (workspaceToSanitize as { tagName?: string | null }).tagName; + if (legacyTagName && (!workspaceToSanitize.tagNames || workspaceToSanitize.tagNames.length === 0)) { + fixingValues.tagNames = [legacyTagName.replaceAll('\n', '')]; } // before 0.8.0, tidgi was loading http content, so lastUrl will be http protocol, but later we switch to tidgi:// protocol, so old value can't be used. if (!workspaceToSanitize.lastUrl?.startsWith('tidgi')) { @@ -261,11 +276,11 @@ export class Workspace implements IWorkspaceService { if (!isWikiWorkspace(newWorkspaceConfig)) return; const existedWorkspace = this.getSync(newWorkspaceConfig.id); - const { id, tagName } = newWorkspaceConfig; - // when update tagName of subWiki + const { id, tagNames } = newWorkspaceConfig; + // when update tagNames of subWiki if ( - existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && typeof tagName === 'string' && tagName.length > 0 && - existedWorkspace.tagName !== tagName + existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && tagNames.length > 0 && + JSON.stringify(existedWorkspace.tagNames) !== JSON.stringify(tagNames) ) { const { mainWikiToLink } = existedWorkspace; if (typeof mainWikiToLink !== 'string') { @@ -289,7 +304,7 @@ export class Workspace implements IWorkspaceService { public async getByWikiName(wikiName: string): Promise { return (await this.getWorkspacesAsList()) - .sort((a, b) => a.order - b.order) + .sort(workspaceSorter) .find((workspace) => workspace.name === wikiName); } @@ -536,7 +551,7 @@ export class Workspace implements IWorkspaceService { // Only handle wiki workspaces if (!isWikiWorkspace(workspace)) return; - const { isSubWiki, mainWikiID, tagName } = workspace; + const { isSubWiki, mainWikiID, tagNames } = workspace; logger.log('debug', 'openWorkspaceTiddler', { workspace }); // If is main wiki, open the wiki, and open provided title, or simply switch to it if no title provided @@ -558,7 +573,8 @@ export class Workspace implements IWorkspaceService { if (oldActiveWorkspace?.id !== mainWikiID) { await workspaceViewService.setActiveWorkspaceView(mainWikiID); } - const subWikiTag = title ?? tagName; + // Use provided title, or first tag name, or nothing + const subWikiTag = title ?? tagNames[0]; if (subWikiTag) { await wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [subWikiTag]); } diff --git a/src/services/workspaces/interface.ts b/src/services/workspaces/interface.ts index 1b0102ab..beb9a9d6 100644 --- a/src/services/workspaces/interface.ts +++ b/src/services/workspaces/interface.ts @@ -136,9 +136,27 @@ export interface IWikiWorkspace extends IDedicatedWorkspace { */ syncOnStartup: boolean; /** - * Tag name in tiddlywiki's filesystemPath, tiddler with this tag will be save into this subwiki + * Tag names in tiddlywiki's filesystemPath, tiddlers with any of these tags will be saved into this subwiki */ - tagName: string | null; + tagNames: string[]; + /** + * When enabled, tiddlers that are indirectly tagged (tag of tag of tag...) with any of this sub-wiki's tagNames + * will also be saved to this sub-wiki. Uses the in-tagtree-of filter operator. + * Applies when creating new tiddlers and when modifying existing ones (e.g., when tags change). + */ + includeTagTree: boolean; + /** + * When enabled, also use fileSystemPathFilter expressions to match tiddlers, in addition to tagName/includeTagTree matching. + * This allows more complex matching logic using TiddlyWiki filter expressions. + */ + fileSystemPathFilterEnable: boolean; + /** + * TiddlyWiki filter expressions to match tiddlers for this workspace (one per line). + * Example: `[in-tagtree-of[Calendar]!tag[Public]!tag[Draft]]` + * Any matching filter will route the tiddler to this workspace. + * Only used when fileSystemPathFilterEnable is true. + */ + fileSystemPathFilter: string | null; /** * Use authenticated-user-header to provide `TIDGI_AUTH_TOKEN_HEADER` as header key to receive a value as username (we use it as token) */ @@ -240,11 +258,11 @@ export interface IWorkspaceService { getMetaData: (id: string) => Promise>; getNextWorkspace: (id: string) => Promise; getPreviousWorkspace: (id: string) => Promise; - getSubWorkspacesAsList(workspaceID: string): Promise; + getSubWorkspacesAsList(workspaceID: string): Promise; /** * Only meant to be used in TidGi's services internally. */ - getSubWorkspacesAsListSync(workspaceID: string): IWorkspace[]; + getSubWorkspacesAsListSync(workspaceID: string): IWikiWorkspace[]; getWorkspaces(): Promise>; getWorkspacesAsList(): Promise; getWorkspacesWithMetadata(): IWorkspacesWithMetadata; diff --git a/src/services/workspacesView/index.ts b/src/services/workspacesView/index.ts index 0b1530e3..f29ce09f 100644 --- a/src/services/workspacesView/index.ts +++ b/src/services/workspacesView/index.ts @@ -22,6 +22,7 @@ import { isWikiWorkspace } from '@services/workspaces/interface'; import { DELAY_MENU_REGISTER } from '@/constants/parameters'; import type { ISyncService } from '@services/sync/interface'; +import { workspaceSorter } from '@services/workspaces/utilities'; import type { IInitializeWorkspaceOptions, IWorkspaceViewService } from './interface'; import { registerMenu } from './registerMenu'; import { getTidgiMiniWindowTargetWorkspace } from './utilities'; @@ -49,9 +50,8 @@ export class WorkspaceView implements IWorkspaceViewService { workspacesList.filter((workspace) => isWikiWorkspace(workspace) && !workspace.isSubWiki && !workspace.pageType).forEach((workspace) => { wikiService.setWikiStartLockOn(workspace.id); }); - // sorting (-1 will make a in the front, b in the back) const sortedList = workspacesList - .sort((a, b) => a.order - b.order) // sort by order, 1-2<0, so first will be the first + .sort(workspaceSorter) .sort((a, b) => (a.active && !b.active ? -1 : 0)) // put active wiki first .sort((a, b) => (isWikiWorkspace(a) && a.isSubWiki && (!isWikiWorkspace(b) || !b.isSubWiki) ? -1 : 0)); // put subwiki on top, they can't restart wiki, so need to sync them first, then let main wiki restart the wiki // revert this after tw can reload tid from fs await mapSeries(sortedList, async (workspace) => { @@ -361,13 +361,21 @@ export class WorkspaceView implements IWorkspaceViewService { if (isWikiWorkspace(newWorkspace) && newWorkspace.isSubWiki && typeof newWorkspace.mainWikiID === 'string') { logger.debug(`${nextWorkspaceID} is a subwiki, set its main wiki ${newWorkspace.mainWikiID} to active instead.`); await this.setActiveWorkspaceView(newWorkspace.mainWikiID); - if (typeof newWorkspace.tagName === 'string') { - await container.get(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagName]); + // Open the first tag if available + if (newWorkspace.tagNames.length > 0) { + await container.get(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagNames[0]]); } return; } // later process will use the current active workspace await container.get(serviceIdentifier.Workspace).setActiveWorkspace(nextWorkspaceID, oldActiveWorkspace?.id); + + // Schedule hibernation of old workspace before waking up new workspace + // This prevents blocking when wakeUp calls loadURL + if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { + void this.hibernateWorkspace(oldActiveWorkspace.id); + } + if (isWikiWorkspace(newWorkspace) && newWorkspace.hibernated) { await this.wakeUpWorkspaceView(nextWorkspaceID); } @@ -383,6 +391,12 @@ export class WorkspaceView implements IWorkspaceViewService { } try { + // Schedule hibernation of old workspace before loading new workspace + // This prevents blocking on loadURL and allows faster UI updates + if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { + void this.hibernateWorkspace(oldActiveWorkspace.id); + } + await container.get(serviceIdentifier.View).setActiveViewForAllBrowserViews(nextWorkspaceID); await this.realignActiveWorkspace(nextWorkspaceID); } catch (error) { @@ -392,13 +406,18 @@ export class WorkspaceView implements IWorkspaceViewService { }); throw error; } - // if we are switching to a new workspace, we hide and/or hibernate old view, and activate new view - // This must happen after view setup succeeds to avoid issues with workspace that hasn't started yet - if (oldActiveWorkspace !== undefined && oldActiveWorkspace.id !== nextWorkspaceID) { - await this.hideWorkspaceView(oldActiveWorkspace.id); - if (isWikiWorkspace(oldActiveWorkspace) && oldActiveWorkspace.hibernateWhenUnused) { - await this.hibernateWorkspaceView(oldActiveWorkspace.id); - } + } + + /** + * This promise could be `void` to let go, not blocking other logic like switch to new workspace, and hibernate workspace on background. + */ + private async hibernateWorkspace(workspaceID: string): Promise { + const workspace = await container.get(serviceIdentifier.Workspace).get(workspaceID); + if (workspace === undefined) return; + + await this.hideWorkspaceView(workspaceID); + if (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused) { + await this.hibernateWorkspaceView(workspaceID); } } diff --git a/src/windows/AddWorkspace/CloneWikiForm.tsx b/src/windows/AddWorkspace/CloneWikiForm.tsx index cdac5d4a..f64688f4 100644 --- a/src/windows/AddWorkspace/CloneWikiForm.tsx +++ b/src/windows/AddWorkspace/CloneWikiForm.tsx @@ -1,10 +1,10 @@ import FolderIcon from '@mui/icons-material/Folder'; -import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; +import { Autocomplete, AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; -import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect } from './FormComponents'; import { useAvailableTags } from './useAvailableTags'; import { useValidateCloneWiki } from './useCloneWiki'; @@ -63,7 +63,7 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone label={t('AddWorkspace.MainWorkspaceLocation')} helperText={form.mainWikiToLink.wikiFolderLocation && `${t('AddWorkspace.SubWorkspaceWillLinkTo')} - ${form.mainWikiToLink.wikiFolderLocation}/tiddlers/${form.wikiFolderName}`} + ${form.mainWikiToLink.wikiFolderLocation}`} value={form.mainWikiToLinkIndex} onChange={(event: React.ChangeEvent) => { const index = Number(event.target.value); @@ -83,17 +83,23 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone ))} - + multiple freeSolo options={availableTags} - value={form.tagName} - onInputChange={(_event: React.SyntheticEvent, value: string) => { - form.tagNameSetter(value); + value={form.tagNames} + onChange={(_event, newValue) => { + form.tagNamesSetter(newValue); + }} + slotProps={{ + chip: { + variant: 'outlined', + }, }} renderInput={(parameters: AutocompleteRenderInputParams) => ( diff --git a/src/windows/AddWorkspace/ExistedWikiForm.tsx b/src/windows/AddWorkspace/ExistedWikiForm.tsx index 3cee68bc..15192af4 100644 --- a/src/windows/AddWorkspace/ExistedWikiForm.tsx +++ b/src/windows/AddWorkspace/ExistedWikiForm.tsx @@ -1,10 +1,10 @@ import FolderIcon from '@mui/icons-material/Folder'; -import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; +import { Autocomplete, AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; -import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect } from './FormComponents'; import { useAvailableTags } from './useAvailableTags'; import { useValidateExistedWiki } from './useExistedWiki'; @@ -32,8 +32,8 @@ export function ExistedWikiForm({ mainWikiToLinkIndex, mainWikiToLinkSetter, mainWorkspaceList, - tagName, - tagNameSetter, + tagNames, + tagNamesSetter, } = form; // Local state for the full path input - like NewWikiForm's direct state binding @@ -112,7 +112,7 @@ export function ExistedWikiForm({ label={t('AddWorkspace.MainWorkspaceLocation')} helperText={mainWikiToLink.wikiFolderLocation && `${t('AddWorkspace.SubWorkspaceWillLinkTo')} - ${mainWikiToLink.wikiFolderLocation}/tiddlers/${wikiFolderName}`} + ${mainWikiToLink.wikiFolderLocation}`} value={mainWikiToLinkIndex} onChange={(event: React.ChangeEvent) => { const index = Number(event.target.value); @@ -132,17 +132,23 @@ export function ExistedWikiForm({ ))} - + multiple freeSolo options={availableTags} - value={tagName} - onInputChange={(_event: React.SyntheticEvent, value: string) => { - tagNameSetter(value); + value={tagNames} + onChange={(_event, newValue) => { + tagNamesSetter(newValue); + }} + slotProps={{ + chip: { + variant: 'outlined', + }, }} renderInput={(parameters: AutocompleteRenderInputParams) => ( diff --git a/src/windows/AddWorkspace/NewWikiForm.tsx b/src/windows/AddWorkspace/NewWikiForm.tsx index fff585f2..4c0c39d5 100644 --- a/src/windows/AddWorkspace/NewWikiForm.tsx +++ b/src/windows/AddWorkspace/NewWikiForm.tsx @@ -1,9 +1,9 @@ import FolderIcon from '@mui/icons-material/Folder'; -import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; +import { Autocomplete, AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { isWikiWorkspace } from '@services/workspaces/interface'; -import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect, SubWikiTagAutoComplete } from './FormComponents'; +import { CreateContainer, LocationPickerButton, LocationPickerContainer, LocationPickerInput, SoftLinkToMainWikiSelect } from './FormComponents'; import { useAvailableTags } from './useAvailableTags'; import type { IWikiWorkspaceFormProps } from './useForm'; @@ -68,7 +68,7 @@ export function NewWikiForm({ label={t('AddWorkspace.MainWorkspaceLocation')} helperText={form.mainWikiToLink.wikiFolderLocation && `${t('AddWorkspace.SubWorkspaceWillLinkTo')} - ${form.mainWikiToLink.wikiFolderLocation}/tiddlers/subwiki/${form.wikiFolderName}`} + ${form.mainWikiToLink.wikiFolderLocation}`} value={form.mainWikiToLinkIndex} slotProps={{ htmlInput: { 'data-testid': 'main-wiki-select' } }} onChange={(event: React.ChangeEvent) => { @@ -89,16 +89,22 @@ export function NewWikiForm({ ))} - + multiple freeSolo options={availableTags} - value={form.tagName} - onInputChange={(_event: React.SyntheticEvent, value: string) => { - form.tagNameSetter(value); + value={form.tagNames} + onChange={(_event, newValue) => { + form.tagNamesSetter(newValue); + }} + slotProps={{ + chip: { + variant: 'outlined', + }, }} renderInput={(parameters: AutocompleteRenderInputParams) => ( = {}): IWikiWorks metadata: {}, } as unknown as IWorkspace, ], - tagName: '', - tagNameSetter: vi.fn(), + tagNames: [] as string[], + tagNamesSetter: vi.fn(), gitRepoUrl: '', gitRepoUrlSetter: vi.fn(), gitUserInfo: undefined as IGitUserInfos | undefined, @@ -194,7 +194,7 @@ describe('NewWikiForm Component', () => { const user = userEvent.setup(); const mockSetter = vi.fn(); const form = createMockForm({ - tagNameSetter: mockSetter, + tagNamesSetter: mockSetter, }); await renderNewWikiForm({ @@ -206,7 +206,7 @@ describe('NewWikiForm Component', () => { const tagInput = screen.getByTestId('tagname-autocomplete-input'); await user.type(tagInput, 'MyTag'); await user.keyboard('{enter}'); - expect(mockSetter).toHaveBeenCalledWith('MyTag'); + expect(mockSetter).toHaveBeenCalledWith(['MyTag']); }); }); @@ -231,7 +231,7 @@ describe('NewWikiForm Component', () => { isCreateMainWorkspace: false, errorInWhichComponent: { mainWikiToLink: true, - tagName: true, + tagNames: true, }, }); @@ -276,11 +276,11 @@ describe('NewWikiForm Component', () => { isCreateMainWorkspace: false, }); - // Because the text is rendered with a template literal and newlines, we need to use a regex + // The helper text shows the main wiki location that will be linked expect(screen.getByText((content, _element) => { // The actual text might have whitespace and newlines const normalized = content.replace(/\s+/g, ' ').trim(); - return normalized === 'AddWorkspace.SubWorkspaceWillLinkTo /main/wiki/tiddlers/subwiki/sub-wiki'; + return normalized === 'AddWorkspace.SubWorkspaceWillLinkTo /main/wiki'; })).toBeInTheDocument(); }); }); diff --git a/src/windows/AddWorkspace/useCloneWiki.ts b/src/windows/AddWorkspace/useCloneWiki.ts index c8747852..e382954e 100644 --- a/src/windows/AddWorkspace/useCloneWiki.ts +++ b/src/windows/AddWorkspace/useCloneWiki.ts @@ -48,7 +48,7 @@ export function useValidateCloneWiki( form.gitRepoUrl, form.gitUserInfo, form.mainWikiToLink.wikiFolderLocation, - form.tagName, + form.tagNames, errorInWhichComponentSetter, ]); return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter]; @@ -73,10 +73,8 @@ export function useCloneWiki( await window.service.wiki.cloneSubWiki( form.parentFolderLocation, form.wikiFolderName, - form.mainWikiToLink.wikiFolderLocation, form.gitRepoUrl, form.gitUserInfo!, - form.tagName, ); } await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.Clone }); diff --git a/src/windows/AddWorkspace/useExistedWiki.ts b/src/windows/AddWorkspace/useExistedWiki.ts index 63e7f65b..15dd2c54 100644 --- a/src/windows/AddWorkspace/useExistedWiki.ts +++ b/src/windows/AddWorkspace/useExistedWiki.ts @@ -46,7 +46,7 @@ export function useValidateExistedWiki( form.gitRepoUrl, form.gitUserInfo, form.mainWikiToLink.wikiFolderLocation, - form.tagName, + form.tagNames, errorInWhichComponentSetter, ]); return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter]; @@ -81,14 +81,7 @@ export function useExistedWiki( ); } await window.service.wiki.ensureWikiExist(form.wikiFolderLocation, false); - await window.service.wiki.createSubWiki( - parentFolderLocationForExistedFolder, - wikiFolderNameForExistedFolder, - 'subwiki', - form.mainWikiToLink.wikiFolderLocation, - form.tagName, - true, - ); + await window.service.wiki.createSubWiki(parentFolderLocationForExistedFolder, wikiFolderNameForExistedFolder, true); } await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.LoadExisting }); } catch (error) { diff --git a/src/windows/AddWorkspace/useForm.ts b/src/windows/AddWorkspace/useForm.ts index 3dad0ba3..40f6fd03 100644 --- a/src/windows/AddWorkspace/useForm.ts +++ b/src/windows/AddWorkspace/useForm.ts @@ -50,7 +50,7 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { return firstMainWiki ? { wikiFolderLocation: firstMainWiki.wikiFolderLocation, port: firstMainWiki.port, id: firstMainWiki.id } : { wikiFolderLocation: '', port: 0, id: '' }; }, ); - const [tagName, tagNameSetter] = useState(''); + const [tagNames, tagNamesSetter] = useState([]); let mainWikiToLinkIndex = mainWorkspaceList.findIndex((workspace) => workspace.id === mainWikiToLink.id); if (mainWikiToLinkIndex < 0) { mainWikiToLinkIndex = 0; @@ -123,8 +123,8 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) { wikiPortSetter, mainWikiToLink, mainWikiToLinkSetter, - tagName, - tagNameSetter, + tagNames, + tagNamesSetter, gitRepoUrl, gitRepoUrlSetter, parentFolderLocation, @@ -162,7 +162,7 @@ export function workspaceConfigFromForm(form: INewWikiRequiredFormData, isCreate mainWikiID: isCreateMainWorkspace ? null : form.mainWikiToLink.id, name: form.wikiFolderName, storageService: form.storageProvider, - tagName: isCreateMainWorkspace ? null : form.tagName, + tagNames: isCreateMainWorkspace ? [] : form.tagNames, port: form.wikiPort, wikiFolderLocation: form.wikiFolderLocation!, backupOnInterval: true, @@ -173,6 +173,9 @@ export function workspaceConfigFromForm(form: INewWikiRequiredFormData, isCreate excludedPlugins: [], enableHTTPAPI: false, enableFileSystemWatch: true, + includeTagTree: false, + fileSystemPathFilterEnable: false, + fileSystemPathFilter: null, lastNodeJSArgv: [], }; } diff --git a/src/windows/AddWorkspace/useNewWiki.ts b/src/windows/AddWorkspace/useNewWiki.ts index b6ec5168..7ef21188 100644 --- a/src/windows/AddWorkspace/useNewWiki.ts +++ b/src/windows/AddWorkspace/useNewWiki.ts @@ -51,7 +51,7 @@ export function useValidateNewWiki( form.gitRepoUrl, form.gitUserInfo, form.mainWikiToLink.wikiFolderLocation, - form.tagName, + form.tagNames, errorInWhichComponentSetter, ]); @@ -80,7 +80,7 @@ export function useNewWiki( await window.service.wiki.copyWikiTemplate(form.parentFolderLocation, form.wikiFolderName); } } else { - await window.service.wiki.createSubWiki(form.parentFolderLocation, form.wikiFolderName, 'subwiki', form.mainWikiToLink.wikiFolderLocation, form.tagName); + await window.service.wiki.createSubWiki(form.parentFolderLocation, form.wikiFolderName); } await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { notClose: options?.notClose, from: WikiCreationMethod.Create }); } catch (error) { diff --git a/src/windows/EditWorkspace/index.tsx b/src/windows/EditWorkspace/index.tsx index 6e479501..ea9a7a7f 100644 --- a/src/windows/EditWorkspace/index.tsx +++ b/src/windows/EditWorkspace/index.tsx @@ -167,7 +167,10 @@ export default function EditWorkspace(): React.JSX.Element { const storageService = isWiki ? workspace.storageService : SupportedStorageServices.github; const syncOnInterval = isWiki ? workspace.syncOnInterval : false; const syncOnStartup = isWiki ? workspace.syncOnStartup : false; - const tagName = isWiki ? workspace.tagName : null; + const tagNames = isWiki ? workspace.tagNames : []; + const includeTagTree = isWiki ? workspace.includeTagTree : false; + const fileSystemPathFilterEnable = isWiki ? workspace.fileSystemPathFilterEnable : false; + const fileSystemPathFilter = isWiki ? workspace.fileSystemPathFilter : null; const transparentBackground = isWiki ? workspace.transparentBackground : false; const userName = isWiki ? workspace.userName : ''; const lastUrl = isWiki ? workspace.lastUrl : null; @@ -177,6 +180,16 @@ export default function EditWorkspace(): React.JSX.Element { // Fetch all tags from main wiki for autocomplete suggestions const availableTags = useAvailableTags(mainWikiToLink ?? undefined, isSubWiki); + // Check if there are sub-workspaces for this main workspace + const hasSubWorkspaces = usePromiseValue(async () => { + if (isSubWiki) return false; + const subWorkspaces = await window.service.workspace.getSubWorkspacesAsList(workspaceID); + return subWorkspaces.length > 0; + }, false); + + // Show sub-workspace routing options for sub-wikis, or for main wikis that have sub-workspaces + const showSubWorkspaceRouting = isSubWiki || hasSubWorkspaces; + const rememberLastPageVisited = usePromiseValue(async () => await window.service.preference.get('rememberLastPageVisited')); if (workspaceID === undefined) { return Error {workspaceID ?? '-'} not exists; @@ -309,31 +322,19 @@ export default function EditWorkspace(): React.JSX.Element { disabled /> )} - ) => { - workspaceSetter({ ...workspace, userName: event.target.value }, true); - }} - label={t('AddWorkspace.WorkspaceUserName')} - placeholder={fallbackUserName} - value={userName} - /> - - {isSubWiki && ( - { - void _event; - workspaceSetter({ ...workspace, tagName: value }, true); + {!isSubWiki && ( + ) => { + workspaceSetter({ ...workspace, userName: event.target.value }, true); }} - renderInput={(parameters: AutocompleteRenderInputParams) => ( - - )} + label={t('AddWorkspace.WorkspaceUserName')} + placeholder={fallbackUserName} + value={userName} /> )} + { @@ -418,6 +419,96 @@ export default function EditWorkspace(): React.JSX.Element { )} + {showSubWorkspaceRouting && ( + + + } data-testid='preference-section-subWorkspaceOptions'> + {t('AddWorkspace.SubWorkspaceOptions')} + + + + + {isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')} + + { + void _event; + workspaceSetter({ ...workspace, tagNames: newValue }, true); + }} + slotProps={{ + chip: { + variant: 'outlined', + }, + }} + renderInput={(parameters: AutocompleteRenderInputParams) => ( + + )} + /> + + ) => { + workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true); + }} + /> + } + > + + + ) => { + workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true); + }} + /> + } + > + + + + {fileSystemPathFilterEnable && ( + ) => { + workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true); + }} + label={t('AddWorkspace.FilterExpression')} + helperText={t('AddWorkspace.FilterExpressionHelp')} + sx={{ mb: 2 }} + /> + )} + + + )} } data-testid='preference-section-miscOptions'> diff --git a/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx b/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx index 7a859421..e6360bd5 100644 --- a/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx +++ b/src/windows/Preferences/sections/__tests__/TidGiMiniWindow.test.tsx @@ -27,7 +27,7 @@ const mockWorkspaces: IWorkspace[] = [ port: 5212, isSubWiki: false, mainWikiToLink: null, - tagName: null, + tagNames: [], lastUrl: null, active: true, hibernated: false, @@ -57,7 +57,7 @@ const mockWorkspaces: IWorkspace[] = [ port: 5213, isSubWiki: false, mainWikiToLink: null, - tagName: null, + tagNames: [], lastUrl: null, active: false, hibernated: false,