diff --git a/features/stepDefinitions/wiki.ts b/features/stepDefinitions/wiki.ts index cc3841a8..880e30fd 100644 --- a/features/stepDefinitions/wiki.ts +++ b/features/stepDefinitions/wiki.ts @@ -857,7 +857,6 @@ ${tiddler.content} fileSystemPathFilterEnable: Boolean(options.fileSystemPathFilter), fileSystemPathFilter: options.fileSystemPathFilter ?? null, tagNames: [tagName], - tagName: tagName, userName: '', order: 1, port: 5213, diff --git a/src/pages/Main/__tests__/index.test.tsx b/src/pages/Main/__tests__/index.test.tsx index aff63b12..cb5c9855 100644 --- a/src/pages/Main/__tests__/index.test.tsx +++ b/src/pages/Main/__tests__/index.test.tsx @@ -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 diff --git a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts index 0e094377..cbe49df0 100644 --- a/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts +++ b/src/services/wiki/plugin/watchFileSystemAdaptor/__tests__/FileSystemAdaptor.routing.test.ts @@ -37,6 +37,13 @@ global.$tw = { files: {} as Record, }, utils: mockUtils, + wiki: { + filterTiddlers: vi.fn(() => []), + makeTiddlerIterator: vi.fn((titles) => 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[0] extends Promise ? T : never, + ); + + // Setup mock wiki with workspace ID + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should route to sub-wiki when tiddler matches tag tree', async () => { + const subWiki = { + id: 'sub-wiki-tagtree', + name: 'Sub Wiki TagTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['RootTag'], + includeTagTree: true, + wikiFolderLocation: '/test/wiki/subwiki/tagtree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Mock filterTiddlers to return the tiddler when using in-tagtree-of filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['ChildTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'ChildTiddler', tags: ['ParentTag'] }, // Not directly tagged with RootTag + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because tag tree matching found a match + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/tagtree', + }), + ); + }); + + it('should not match tag tree when includeTagTree is disabled', async () => { + const subWiki = { + id: 'sub-wiki-notree', + name: 'Sub Wiki NoTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['RootTag'], + includeTagTree: false, // Disabled + wikiFolderLocation: '/test/wiki/subwiki/notree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Even if filterTiddlers would return a match, it shouldn't be called + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['ChildTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'ChildTiddler', tags: ['ParentTag'] }, // Not directly tagged with RootTag + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use default directory because includeTagTree is disabled + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + }), + ); + }); + }); + + describe('getTiddlerFileInfo - Custom Filter Routing (fileSystemPathFilter)', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should route to sub-wiki when tiddler matches custom filter', async () => { + const subWiki = { + id: 'sub-wiki-filter', + name: 'Sub Wiki Filter', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['SomeTag'], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/filter', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Mock filterTiddlers to return the tiddler for custom filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn((filter) => { + if (filter === '[has[customfield]]') { + return ['FilterMatchTiddler']; + } + return []; + }); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'FilterMatchTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because custom filter matched + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/filter', + }), + ); + }); + + it('should not match custom filter when fileSystemPathFilterEnable is disabled', async () => { + const subWiki = { + id: 'sub-wiki-filter-disabled', + name: 'Sub Wiki Filter Disabled', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['SomeTag'], + fileSystemPathFilterEnable: false, // Disabled + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/filter-disabled', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'FilterMatchTiddler', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use default directory because filter is disabled + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/tiddlers', + }), + ); + }); + + it('should support multiple filter lines (any match wins)', async () => { + const subWiki = { + id: 'sub-wiki-multifilter', + name: 'Sub Wiki MultiFilter', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: [], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[field1]]\n[has[field2]]', + wikiFolderLocation: '/test/wiki/subwiki/multifilter', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as unknown as IWikiWorkspace[]); + + // Mock filterTiddlers to return match on second filter + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn((filter) => { + if (filter === '[has[field2]]') { + return ['TiddlerWithField2']; + } + return []; + }); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TiddlerWithField2', tags: [] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use sub-wiki directory because second filter line matched + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/multifilter', + }), + ); + }); + }); + + describe('getTiddlerFileInfo - Routing Priority', () => { + beforeEach(async () => { + vi.mocked(workspace.get).mockResolvedValue( + { + id: 'test-workspace', + name: 'Test Workspace', + wikiFolderLocation: '/test/wiki', + } as Parameters[0] extends Promise ? T : never, + ); + + mockWiki = { + getTiddlerText: vi.fn((title) => { + if (title === '$:/info/tidgi/workspaceID') return 'test-workspace'; + return ''; + }), + tiddlerExists: vi.fn(() => false), + addTiddler: vi.fn(), + } as unknown as Wiki; + }); + + it('should prioritize direct tag match over tag tree match', async () => { + const subWiki1 = { + id: 'sub-wiki-direct', + name: 'Sub Wiki Direct Tag', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 0, + tagNames: ['DirectTag'], + includeTagTree: false, + wikiFolderLocation: '/test/wiki/subwiki/direct', + }; + + const subWiki2 = { + id: 'sub-wiki-tagtree', + name: 'Sub Wiki TagTree', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 1, + tagNames: ['RootTag'], + includeTagTree: true, + wikiFolderLocation: '/test/wiki/subwiki/tagtree', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki1, subWiki2] as IWikiWorkspace[]); + + // Mock tag tree matching to return the tiddler + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => ['TestTiddler']); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + // Tiddler has both DirectTag (direct match) and would match RootTag via tag tree + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['DirectTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use direct tag sub-wiki (first match wins, and direct tag check happens before tag tree) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/direct', + }), + ); + }); + + it('should prioritize tag match over custom filter match within same workspace', async () => { + const subWiki = { + id: 'sub-wiki-both', + name: 'Sub Wiki Both', + isSubWiki: true, + mainWikiID: 'test-workspace', + tagNames: ['MatchTag'], + fileSystemPathFilterEnable: true, + fileSystemPathFilter: '[has[customfield]]', + wikiFolderLocation: '/test/wiki/subwiki/both', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki] as IWikiWorkspace[]); + + // Reset filterTiddlers mock + // @ts-expect-error - TiddlyWiki global + global.$tw.wiki.filterTiddlers = vi.fn(() => []); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + // Tiddler has the matching tag + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['MatchTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should match via tag (filter shouldn't even be checked for this tiddler) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/both', + }), + ); + }); + + it('should check workspaces in order and use first match', async () => { + const subWiki1 = { + id: 'sub-wiki-first', + name: 'Sub Wiki First', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 0, + tagNames: ['SharedTag'], + wikiFolderLocation: '/test/wiki/subwiki/first', + }; + + const subWiki2 = { + id: 'sub-wiki-second', + name: 'Sub Wiki Second', + isSubWiki: true, + mainWikiID: 'test-workspace', + order: 1, + tagNames: ['SharedTag'], // Same tag + wikiFolderLocation: '/test/wiki/subwiki/second', + }; + + vi.mocked(workspace.getWorkspacesAsList).mockResolvedValue([subWiki1, subWiki2] as IWikiWorkspace[]); + + adaptor = new FileSystemAdaptor({ + wiki: mockWiki, + // @ts-expect-error - TiddlyWiki global + boot: global.$tw.boot, + }); + + await adaptor['updateSubWikisCache'](); + + const tiddler: Tiddler = { + fields: { title: 'TestTiddler', tags: ['SharedTag'] }, + } as unknown as Tiddler; + + await adaptor.getTiddlerFileInfo(tiddler); + + // Should use first sub-wiki (order 0) + expect(mockUtils.generateTiddlerFileInfo).toHaveBeenCalledWith( + tiddler, + expect.objectContaining({ + directory: '/test/wiki/subwiki/first', + }), + ); + }); + }); });