Compare commits

...

3 commits

Author SHA1 Message Date
linonetwo
4e0150255a Update FileSystemAdaptor.routing.test.ts 2025-12-06 02:47:02 +08:00
linonetwo
8dfdfeb412 test: unit cover adaptor subwiki routing 2025-12-06 02:27:56 +08:00
linonetwo
a90ccdf44e refactor: remove outdated method signature 2025-12-06 02:10:54 +08:00
11 changed files with 613 additions and 150 deletions

View file

@ -857,7 +857,6 @@ ${tiddler.content}
fileSystemPathFilterEnable: Boolean(options.fileSystemPathFilter),
fileSystemPathFilter: options.fileSystemPathFilter ?? null,
tagNames: [tagName],
tagName: tagName,
userName: '',
order: 1,
port: 5213,

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

@ -482,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))) {
@ -625,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))) {

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

@ -7,6 +7,7 @@ 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';
/**
@ -83,32 +84,10 @@ export class FileSystemAdaptor {
const allWorkspaces = await workspace.getWorkspacesAsList();
// Include both main workspace and sub-wikis for tag-based routing or filter-based routing
const isWikiWorkspaceWithRouting = (workspaceItem: IWorkspace): workspaceItem is IWikiWorkspace => {
if (!('wikiFolderLocation' in workspaceItem) || !workspaceItem.wikiFolderLocation) {
return false;
}
// Check if workspace has routing config (either tagNames or fileSystemPathFilter)
const hasRoutingConfig = ('tagNames' in workspaceItem && workspaceItem.tagNames.length > 0) ||
('fileSystemPathFilterEnable' in workspaceItem && workspaceItem.fileSystemPathFilterEnable && 'fileSystemPathFilter' in workspaceItem &&
workspaceItem.fileSystemPathFilter);
if (!hasRoutingConfig) {
return false;
}
// Include if it's the main workspace
const isMain = workspaceItem.id === currentWorkspace.id;
// Include if it's a sub-wiki of the current main workspace
const isSubWiki = 'isSubWiki' in workspaceItem &&
workspaceItem.isSubWiki &&
workspaceItem.mainWikiID === currentWorkspace.id;
return isMain || isSubWiki;
};
const workspacesWithRouting = allWorkspaces.filter(isWikiWorkspaceWithRouting).sort(workspaceSorter);
// 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.wikisWithRouting = workspacesWithRouting;
} catch (error) {
@ -163,7 +142,7 @@ export class FileSystemAdaptor {
}
// Find matching workspace using the routing logic
const matchingWiki = this.matchTitleToWiki(title, tags);
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)
@ -209,56 +188,6 @@ export class FileSystemAdaptor {
}
}
/**
* 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 - it's a "tag tiddler")
* 2. If includeTagTree is enabled, use in-tagtree-of filter for recursive tag matching
* 3. If fileSystemPathFilterEnable is enabled, use custom filter expressions (one per line, any match wins)
*/
protected matchTitleToWiki(title: string, tags: string[]): IWikiWorkspace | undefined {
for (const wiki of this.wikisWithRouting) {
// Direct tag match - check if any of the tiddler's tags match any of the wiki's tagNames
// Also check if the tiddler's title IS one of the tagNames (it's a "tag tiddler" that defines that tag)
if (wiki.tagNames.length > 0) {
const hasMatchingTag = wiki.tagNames.some(tagName => tags.includes(tagName));
const isTitleATagName = wiki.tagNames.includes(title);
if (hasMatchingTag || isTitleATagName) {
return wiki;
}
}
// Tag tree match if enabled - check all tagNames
if (wiki.includeTagTree && wiki.tagNames.length > 0) {
for (const tagName of wiki.tagNames) {
const result = $tw.wiki.filterTiddlers(
`[in-tagtree-of:inclusive<tagName>]`,
$tw.rootWidget.makeFakeWidgetWithVariables({ tagName }),
$tw.wiki.makeTiddlerIterator([title]),
);
if (result.length > 0) {
return wiki;
}
}
}
// Custom filter match if enabled
if (wiki.fileSystemPathFilterEnable && wiki.fileSystemPathFilter) {
// Split by newlines and try each filter
const filters = wiki.fileSystemPathFilter.split('\n').map(f => f.trim()).filter(f => f.length > 0);
for (const filter of filters) {
const result = $tw.wiki.filterTiddlers(filter, undefined, $tw.wiki.makeTiddlerIterator([title]));
if (result.length > 0) {
return wiki;
}
}
}
}
return undefined;
}
/**
* Generate file info for sub-wiki directory
* Handles symlinks correctly across platforms (Windows junctions and Linux symlinks)

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', () => {
@ -275,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);
@ -319,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);
@ -378,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);
@ -509,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,20 +12,19 @@ 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 and Filter-Based Sub-Wiki Routing
@ -40,52 +39,42 @@ private async getSubWikis(currentWorkspace: IWorkspace): Promise<IWorkspace[]>
* ''Important'': Always recalculates path on save to handle tag changes - old `fileInfo` only used for cleanup
* ''Implementation'':
** Checks tiddler tags/filters against sub-workspace routing rules (in priority order)
** Routes matching tiddlers to sub-wiki's `tiddlers` folder
** 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
@ -96,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,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

@ -73,10 +73,8 @@ export function useCloneWiki(
await window.service.wiki.cloneSubWiki(
form.parentFolderLocation,
form.wikiFolderName,
form.mainWikiToLink.wikiFolderLocation,
form.gitRepoUrl,
form.gitUserInfo!,
form.tagNames[0] ?? '',
);
}
await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.Clone });

View file

@ -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.tagNames[0] ?? '',
true,
);
await window.service.wiki.createSubWiki(parentFolderLocationForExistedFolder, wikiFolderNameForExistedFolder, true);
}
await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.LoadExisting });
} catch (error) {

View file

@ -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.tagNames[0] ?? '');
await window.service.wiki.createSubWiki(form.parentFolderLocation, form.wikiFolderName);
}
await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { notClose: options?.notClose, from: WikiCreationMethod.Create });
} catch (error) {