mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-06 02:30:47 -08:00
Compare commits
3 commits
a1f99632a6
...
4e0150255a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0150255a | ||
|
|
8dfdfeb412 | ||
|
|
a90ccdf44e |
11 changed files with 613 additions and 150 deletions
|
|
@ -857,7 +857,6 @@ ${tiddler.content}
|
|||
fileSystemPathFilterEnable: Boolean(options.fileSystemPathFilter),
|
||||
fileSystemPathFilter: options.fileSystemPathFilter ?? null,
|
||||
tagNames: [tagName],
|
||||
tagName: tagName,
|
||||
userName: '',
|
||||
order: 1,
|
||||
port: 5213,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(...);
|
||||
// Uses pure functions from routingUtils.ts for matching logic
|
||||
import { matchTiddlerToWorkspace, isWikiWorkspaceWithRouting } from './routingUtils';
|
||||
|
||||
if (matchingSubWiki) {
|
||||
this.routeToSubWorkspace(...);
|
||||
} else {
|
||||
this.useDefaultFileSystemLogic(...);
|
||||
}
|
||||
}
|
||||
// Match tiddler to workspace
|
||||
const matchingWiki = matchTiddlerToWorkspace(title, tags, this.wikisWithRouting, $tw.wiki, $tw.rootWidget);
|
||||
|
||||
// Added: Caching mechanism
|
||||
private subWikis: IWorkspace[] = [];
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue