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
This commit is contained in:
lin onetwo 2025-12-07 03:31:34 +08:00 committed by GitHub
parent 8a84d9b468
commit c2be8e4186
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 2176 additions and 414 deletions

View file

@ -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']"

View file

@ -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']"

View file

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

View file

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

View file

@ -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<void> {
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<string, IWorkspace> = {};
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<string, IWorkspace> } & Record<string, unknown>;
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<string, unknown>)[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<string, IWorkspace> } & Record<string, unknown>;
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<string, unknown>)[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<string, string>[],
) {
// 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<string, IWorkspace> } & Record<string, unknown> = {};
if (await fs.pathExists(settingsPath)) {
settings = await fs.readJson(settingsPath) as { workspaces?: Record<string, IWorkspace> };
}
// 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 };
/**

190
features/subWiki.feature Normal file
View file

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

View file

@ -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<T>(
app: ElectronApplication,
code: string,
): Promise<T | null> {
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],
);
}

42
features/tiddler.feature Normal file
View file

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

View file

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

View file

@ -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",

View file

@ -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é",

View file

@ -19,6 +19,8 @@
"CustomServerUrlDescription": "OAuthサーバーのベースURLhttp://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が開始されていないか、読み込まれていません",

View file

@ -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 после того событ<D18B><D182>я",
@ -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 не запущена или не загружена",

View file

@ -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}}\"",

View file

@ -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": "知識庫 頁面未成功啟動或未成功載入",

View file

@ -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",

10
pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -13,6 +13,10 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
</ThemeProvider>
);
// 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<typeof vi.fn>;
@ -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`);
});
});
});

View file

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

View file

@ -145,6 +145,7 @@ const commonInit = async (): Promise<void> => {
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();

View file

@ -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,
>
<SortableContext items={workspaceIDs} strategy={verticalListSortingStrategy}>
{filteredWorkspacesList
.sort((a, b) => a.order - b.order)
.sort(workspaceSorter)
.map((workspace, index) => (
<SortableWorkspaceSelectorButton
key={`item-${workspace.id}`}

View file

@ -45,7 +45,7 @@ const workspacesSubject = new BehaviorSubject([
mainWikiID: 'workspace-1',
mainWikiToLink: '/path/to/wiki1',
port: 5213,
tagName: 'WorkNotes',
tagNames: ['WorkNotes'],
metadata: { badgeCount: 5 },
},
// Built-in page workspaces generated from pageTypes

View file

@ -363,7 +363,7 @@ export class MenuService implements IMenuService {
submenu: workspaces.map((workspace) => ({
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 () => {

View file

@ -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<void>) | undefi
* @param shortcut The shortcut string (e.g. "CmdOrCtrl+Shift+T")
*/
export async function registerShortcutByKey(key: string, shortcut: string): Promise<void> {
// 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);

View file

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

View file

@ -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<void> {
public async createSubWiki(parentFolderLocation: string, folderName: string, onlyLink = false): Promise<void> {
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<void> {
public async cloneSubWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise<void> {
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 });

View file

@ -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<string | true>;
checkWikiStartLock(wikiFolderLocation: string): boolean;
cloneSubWiki(
parentFolderLocation: string,
wikiFolderName: string,
mainWikiPath: string,
gitRepoUrl: string,
gitUserInfo: IGitUserInfos,
tagName?: string,
): Promise<void>;
cloneSubWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise<void>;
cloneWiki(parentFolderLocation: string, wikiFolderName: string, gitRepoUrl: string, gitUserInfo: IGitUserInfos): Promise<void>;
copyWikiTemplate(newFolderPath: string, folderName: string): Promise<void>;
/**
@ -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<void>;
createSubWiki(parentFolderLocation: string, folderName: string, onlyLink?: boolean): Promise<void>;
ensureWikiExist(wikiPath: string, shouldBeMainWiki: boolean): Promise<void>;
extractWikiHTML(htmlWikiPath: string, saveWikiFolderPath: string): Promise<string | undefined>;
/**

View file

@ -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<string, IWikiWorkspace> = 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<void> {
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<IFileInfo | null> {
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;
}
}
}
/**

View file

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

View file

@ -37,6 +37,13 @@ global.$tw = {
files: {} as Record<string, IFileInfo>,
},
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<typeof workspace.get>[0] extends Promise<infer T> ? 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<typeof workspace.get>[0] extends Promise<infer T> ? 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<typeof workspace.get>[0] extends Promise<infer T> ? 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',
}),
);
});
});
});

View file

@ -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<IWorkspace | undefined>
private async getSubWikis(currentWorkspace: IWorkspace): Promise<IWorkspace[]>
// 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<void> {
// 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<void> {
await this.updateSubWikisCache();
}
private async updateSubWikisCache(): Promise<void> {
// 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

View file

@ -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<string, IFilterOperator>;
exports['in-tagtree-of'] = function inTagTreeOfFilterOperator(
source: (iter: SourceIterator) => void,
operator: IFilterOperatorParameterOperator,
): ReturnType<IFilterOperator> {
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<string>();
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<string>();
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<string>) {
// get tagging[] list at this level
const intermediate = new Set<string>($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);
});
}
}

View file

@ -0,0 +1,4 @@
creator: LinOnetwo
title: $:/plugins/linonetwo/watch-filesystem-adaptor/in-tagtree-of/index.js
type: application/javascript
module-type: filteroperator

View file

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

View file

@ -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<tagName>]`,
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;
}

View file

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

View file

@ -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<typeof TiddlyWiki>,
homePath: string,
subWikis: IWikiWorkspace[],
workspaceName: string,
nativeLogger: {
logFor: (name: string, level: 'info' | 'error', message: string) => Promise<void>;
},
) {
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;
};
}

View file

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

View file

@ -54,7 +54,7 @@ describe('WikiEmbeddingService Integration Tests', () => {
port: 5212,
isSubWiki: false,
mainWikiToLink: null,
tagName: null,
tagNames: [],
lastUrl: null,
active: true,
hibernated: false,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<IWorkspace[]> {
public async getSubWorkspacesAsList(workspaceID: string): Promise<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 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<IWorkspace | undefined> {
@ -225,6 +234,10 @@ export class Workspace implements IWorkspaceService {
backupOnInterval: true,
excludedPlugins: [],
enableHTTPAPI: false,
includeTagTree: false,
fileSystemPathFilterEnable: false,
fileSystemPathFilter: null,
tagNames: [],
};
const fixingValues: Partial<typeof workspaceToSanitize> = {};
// 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<IWorkspace | undefined> {
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]);
}

View file

@ -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<Partial<IWorkspaceMetaData>>;
getNextWorkspace: (id: string) => Promise<IWorkspace | undefined>;
getPreviousWorkspace: (id: string) => Promise<IWorkspace | undefined>;
getSubWorkspacesAsList(workspaceID: string): Promise<IWorkspace[]>;
getSubWorkspacesAsList(workspaceID: string): Promise<IWikiWorkspace[]>;
/**
* Only meant to be used in TidGi's services internally.
*/
getSubWorkspacesAsListSync(workspaceID: string): IWorkspace[];
getSubWorkspacesAsListSync(workspaceID: string): IWikiWorkspace[];
getWorkspaces(): Promise<Record<string, IWorkspace>>;
getWorkspacesAsList(): Promise<IWorkspace[]>;
getWorkspacesWithMetadata(): IWorkspacesWithMetadata;

View file

@ -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<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagName]);
// Open the first tag if available
if (newWorkspace.tagNames.length > 0) {
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagNames[0]]);
}
return;
}
// later process will use the current active workspace
await container.get<IWorkspaceService>(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<IViewService>(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<void> {
const workspace = await container.get<IWorkspaceService>(serviceIdentifier.Workspace).get(workspaceID);
if (workspace === undefined) return;
await this.hideWorkspaceView(workspaceID);
if (isWikiWorkspace(workspace) && workspace.hibernateWhenUnused) {
await this.hibernateWorkspaceView(workspaceID);
}
}

View file

@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
const index = Number(event.target.value);
@ -83,17 +83,23 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone
</MenuItem>
))}
</SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete
<Autocomplete<string, true, false, true>
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) => (
<LocationPickerInput
{...parameters}
error={errorInWhichComponent.tagName}
error={errorInWhichComponent.tagNames}
label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')}
/>

View file

@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
const index = Number(event.target.value);
@ -132,17 +132,23 @@ export function ExistedWikiForm({
</MenuItem>
))}
</SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete
<Autocomplete<string, true, false, true>
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) => (
<LocationPickerInput
{...parameters}
error={errorInWhichComponent.tagName}
error={errorInWhichComponent.tagNames}
label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')}
/>

View file

@ -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<HTMLInputElement | HTMLTextAreaElement>) => {
@ -89,16 +89,22 @@ export function NewWikiForm({
</MenuItem>
))}
</SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete
<Autocomplete<string, true, false, true>
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) => (
<LocationPickerInput
error={errorInWhichComponent.tagName}
error={errorInWhichComponent.tagNames}
{...parameters}
label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')}

View file

@ -47,8 +47,8 @@ const createMockForm = (overrides: Partial<IWikiWorkspaceForm> = {}): 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();
});
});

View file

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

View file

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

View file

@ -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<string>('');
const [tagNames, tagNamesSetter] = useState<string[]>([]);
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: [],
};
}

View file

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

View file

@ -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 <Root>Error {workspaceID ?? '-'} not exists</Root>;
@ -309,31 +322,19 @@ export default function EditWorkspace(): React.JSX.Element {
disabled
/>
)}
<TextField
helperText={t('AddWorkspace.WorkspaceUserNameDetail')}
fullWidth
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, userName: event.target.value }, true);
}}
label={t('AddWorkspace.WorkspaceUserName')}
placeholder={fallbackUserName}
value={userName}
/>
<Divider />
{isSubWiki && (
<Autocomplete
freeSolo
options={availableTags}
value={tagName}
onInputChange={(_event: React.SyntheticEvent, value: string) => {
void _event;
workspaceSetter({ ...workspace, tagName: value }, true);
{!isSubWiki && (
<TextField
helperText={t('AddWorkspace.WorkspaceUserNameDetail')}
fullWidth
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, userName: event.target.value }, true);
}}
renderInput={(parameters: AutocompleteRenderInputParams) => (
<TextField {...parameters} label={t('AddWorkspace.TagName')} helperText={t('AddWorkspace.TagNameHelp')} />
)}
label={t('AddWorkspace.WorkspaceUserName')}
placeholder={fallbackUserName}
value={userName}
/>
)}
<Divider />
<SyncedWikiDescription
isCreateSyncedWorkspace={isCreateSyncedWorkspace}
isCreateSyncedWorkspaceSetter={(isSynced: boolean) => {
@ -418,6 +419,96 @@ export default function EditWorkspace(): React.JSX.Element {
)}
</AccordionDetails>
</OptionsAccordion>
{showSubWorkspaceRouting && (
<OptionsAccordion defaultExpanded={isSubWiki}>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-subWorkspaceOptions'>
{t('AddWorkspace.SubWorkspaceOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<Typography variant='body2' color='textSecondary' sx={{ mb: 2 }}>
{isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')}
</Typography>
<Autocomplete
multiple
freeSolo
options={availableTags}
value={tagNames}
onChange={(_event: React.SyntheticEvent, newValue: string[]) => {
void _event;
workspaceSetter({ ...workspace, tagNames: newValue }, true);
}}
slotProps={{
chip: {
variant: 'outlined',
},
}}
renderInput={(parameters: AutocompleteRenderInputParams) => (
<TextField
{...parameters}
label={t('AddWorkspace.TagName')}
helperText={isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')}
/>
)}
/>
<List>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={includeTagTree}
data-testid='include-tag-tree-switch'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.IncludeTagTree')}
secondary={isSubWiki ? t('AddWorkspace.IncludeTagTreeHelp') : t('AddWorkspace.IncludeTagTreeHelpForMain')}
/>
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={fileSystemPathFilterEnable}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.UseFilter')}
secondary={t('AddWorkspace.UseFilterHelp')}
/>
</ListItem>
</List>
{fileSystemPathFilterEnable && (
<TextField
fullWidth
multiline
minRows={2}
maxRows={10}
value={fileSystemPathFilter ?? ''}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true);
}}
label={t('AddWorkspace.FilterExpression')}
helperText={t('AddWorkspace.FilterExpressionHelp')}
sx={{ mb: 2 }}
/>
)}
</AccordionDetails>
</OptionsAccordion>
)}
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-miscOptions'>

View file

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