fix: load sub-wiki content and prevent echo

This commit is contained in:
linonetwo 2025-12-05 17:41:32 +08:00
parent cd96e60eb9
commit 09a2c7a005
26 changed files with 388 additions and 175 deletions

View file

@ -61,11 +61,15 @@
"SyncedWorkspace": "云端同步知识库", "SyncedWorkspace": "云端同步知识库",
"SyncedWorkspaceDescription": "同步到在线存储服务例如Github需要你登录存储服务或输入登录凭证并有良好的网络连接。可以跨设备同步数据在使用了值得信任的存储服务的情况下数据仍归你所有。而且文件夹被不慎删除后还可以从在线服务重新下载数据到本地。", "SyncedWorkspaceDescription": "同步到在线存储服务例如Github需要你登录存储服务或输入登录凭证并有良好的网络连接。可以跨设备同步数据在使用了值得信任的存储服务的情况下数据仍归你所有。而且文件夹被不慎删除后还可以从在线服务重新下载数据到本地。",
"TagName": "标签名", "TagName": "标签名",
"TagNameHelp": "加上此标签的笔记将会自动被放入这个子知识库内(可先不填,之后右键点击这个工作区的图标选择编辑工作区修改)", "TagNameHelp": "加上这些标签之一的笔记将会自动被放入这个子知识库内(可先不填,之后右键点击这个工作区的图标选择编辑工作区修改)",
"TagNameHelpForMain": "带有标签的新条目将优先保存在此工作区", "TagNameHelpForMain": "带有这些标签的新条目将优先保存在此工作区",
"IncludeTagTree": "包括整个标签树", "IncludeTagTree": "包括整个标签树",
"IncludeTagTreeHelp": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此子工作区里", "IncludeTagTreeHelp": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此子工作区里",
"IncludeTagTreeHelpForMain": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此工作区里", "IncludeTagTreeHelpForMain": "勾选后,只要标签的标签的标签(任意级……)是这个,就会被划分到此工作区里",
"UseFilter": "使用筛选器",
"UseFilterHelp": "用筛选器表达式而不是标签来匹配条目,决定是否存入当前工作区",
"FilterExpression": "筛选器表达式",
"FilterExpressionHelp": "每行一个TiddlyWiki筛选器表达式任一匹配即存入此工作区。例如 [in-tagtree-of[Calendar]!tag[Public]]",
"SubWorkspaceOptions": "子工作区设置", "SubWorkspaceOptions": "子工作区设置",
"SubWorkspaceOptionsDescriptionForMain": "配置此主工作区优先保存哪些条目。设置标签后,带有此标签的新条目会优先保存在主工作区,而非子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", "SubWorkspaceOptionsDescriptionForMain": "配置此主工作区优先保存哪些条目。设置标签后,带有此标签的新条目会优先保存在主工作区,而非子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先",
"SubWorkspaceOptionsDescriptionForSub": "配置此子工作区保存哪些条目。带有指定标签的新条目将被保存到此子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先", "SubWorkspaceOptionsDescriptionForSub": "配置此子工作区保存哪些条目。带有指定标签的新条目将被保存到此子工作区。匹配顺序按侧边栏从上到下,先匹配到的工作区优先",

View file

@ -142,7 +142,7 @@ const defaultWorkspaces: IWorkspace[] = [
port: 5212, port: 5212,
isSubWiki: false, isSubWiki: false,
mainWikiToLink: null, mainWikiToLink: null,
tagName: null, tagNames: [],
lastUrl: null, lastUrl: null,
active: true, active: true,
hibernated: false, hibernated: false,
@ -173,7 +173,7 @@ const defaultWorkspaces: IWorkspace[] = [
port: 5213, port: 5213,
isSubWiki: false, isSubWiki: false,
mainWikiToLink: null, mainWikiToLink: null,
tagName: null, tagNames: [],
lastUrl: null, lastUrl: null,
active: true, active: true,
hibernated: false, hibernated: false,

View file

@ -6,8 +6,8 @@ import { useMemo } from 'react';
import { PageType } from '@/constants/pageTypes'; import { PageType } from '@/constants/pageTypes';
import { WindowNames } from '@services/windows/WindowProperties'; import { WindowNames } from '@services/windows/WindowProperties';
import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface'; import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton';
import { workspaceSorter } from '@services/workspaces/utilities'; import { workspaceSorter } from '@services/workspaces/utilities';
import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton';
export interface ISortableListProps { export interface ISortableListProps {
showSideBarIcon: boolean; showSideBarIcon: boolean;

View file

@ -363,7 +363,7 @@ export class MenuService implements IMenuService {
submenu: workspaces.map((workspace) => ({ submenu: workspaces.map((workspace) => ({
label: i18n.t('WorkspaceSelector.OpenWorkspaceTagTiddler', { label: i18n.t('WorkspaceSelector.OpenWorkspaceTagTiddler', {
tagName: isWikiWorkspace(workspace) 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, : workspace.name,
}), }),
click: async () => { click: async () => {

View file

@ -87,33 +87,41 @@ export class FileSystemAdaptor {
const allWorkspaces = await workspace.getWorkspacesAsList(); const allWorkspaces = await workspace.getWorkspacesAsList();
// Include both main workspace and sub-wikis for tag-based routing // Include both main workspace and sub-wikis for tag-based routing or filter-based routing
const isWikiWorkspaceWithTag = (workspaceItem: IWorkspace): workspaceItem is IWikiWorkspace => { const isWikiWorkspaceWithRouting = (workspaceItem: IWorkspace): workspaceItem is IWikiWorkspace => {
// Include if it's the main workspace with tagName if (!('wikiFolderLocation' in workspaceItem) || !workspaceItem.wikiFolderLocation) {
const isMainWithTag = workspaceItem.id === currentWorkspace.id && return false;
'tagName' in workspaceItem && }
workspaceItem.tagName &&
'wikiFolderLocation' in workspaceItem &&
workspaceItem.wikiFolderLocation;
// Include if it's a sub-wiki with tagName // Check if workspace has routing config (either tagNames or fileSystemPathFilter)
const isSubWikiWithTag = 'isSubWiki' in workspaceItem && 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.isSubWiki &&
workspaceItem.mainWikiID === currentWorkspace.id && workspaceItem.mainWikiID === currentWorkspace.id;
'tagName' in workspaceItem &&
workspaceItem.tagName &&
'wikiFolderLocation' in workspaceItem &&
workspaceItem.wikiFolderLocation;
return Boolean(isMainWithTag) || Boolean(isSubWikiWithTag); return isMain || isSubWiki;
}; };
const workspacesWithTag = allWorkspaces.filter(isWikiWorkspaceWithTag).sort(workspaceSorter); const workspacesWithTag = allWorkspaces.filter(isWikiWorkspaceWithRouting).sort(workspaceSorter);
this.wikisWithTag = workspacesWithTag; this.wikisWithTag = workspacesWithTag;
this.tagNameToWiki.clear(); this.tagNameToWiki.clear();
for (const workspaceWithTag of workspacesWithTag) { for (const workspaceWithTag of workspacesWithTag) {
this.tagNameToWiki.set(workspaceWithTag.tagName!, workspaceWithTag); // Build map for all tag names in this workspace
for (const tagName of workspaceWithTag.tagNames) {
this.tagNameToWiki.set(tagName, workspaceWithTag);
}
} }
} catch (error) { } catch (error) {
this.logger.alert('filesystem: Failed to update sub-wikis cache:', error); this.logger.alert('filesystem: Failed to update sub-wikis cache:', error);
@ -133,10 +141,15 @@ export class FileSystemAdaptor {
* Main routing logic: determine where a tiddler should be saved based on its tags. * Main routing logic: determine where a tiddler should be saved based on its tags.
* For draft tiddlers, check the original tiddler's tags. * For draft tiddlers, check the original tiddler's tags.
* *
* For existing tiddlers (already in boot.files), we use the existing file path. * Priority:
* For new tiddlers, we check: * 1. If fileSystemPathFilterEnable is enabled, use custom filterExpression
* 1. Direct tag match with sub-wiki tagName * 2. Direct tag match with sub-wiki tagNames
* 2. If includeTagTree is enabled, use in-tagtree-of filter for recursive tag matching * 3. If includeTagTree is enabled, use in-tagtree-of filter for recursive tag matching
* 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> { async getTiddlerFileInfo(tiddler: Tiddler): Promise<IFileInfo | null> {
if (!this.boot.wikiTiddlersPath) { if (!this.boot.wikiTiddlersPath) {
@ -145,7 +158,7 @@ export class FileSystemAdaptor {
const title = tiddler.fields.title; const title = tiddler.fields.title;
let tags = tiddler.fields.tags ?? []; let tags = tiddler.fields.tags ?? [];
const fileInfo = this.boot.files[title]; const existingFileInfo = this.boot.files[title];
try { try {
// For draft tiddlers (draft.of field), also check the original tiddler's tags // For draft tiddlers (draft.of field), also check the original tiddler's tags
@ -161,49 +174,95 @@ export class FileSystemAdaptor {
} }
} }
// First try direct tag match (O(1) lookup) // Find matching workspace using the routing logic
let matchingSubWiki: IWikiWorkspace | undefined; const matchingWiki = this.matchTitleToWiki(title, tags);
for (const tag of tags) {
matchingSubWiki = this.tagNameToWiki.get(tag); // Determine the target directory based on routing
if (matchingSubWiki) { // Sub-wikis store tiddlers directly in their root folder (not in /tiddlers subfolder)
break; // 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 existingDir = 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(existingDir);
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 no direct match, try in-tagtree-of for sub-wikis with includeTagTree enabled // Directory has changed (or no existing file), generate new file info
// Only for new tiddlers (no existing fileInfo) to save CPU if (matchingWiki) {
if (!matchingSubWiki) { return this.generateSubWikiFileInfo(tiddler, matchingWiki);
matchingSubWiki = this.matchTitleToWikiByTagTree(title);
}
if (matchingSubWiki) {
return this.generateSubWikiFileInfo(tiddler, matchingSubWiki, fileInfo);
} else { } else {
return this.generateDefaultFileInfo(tiddler, fileInfo); return this.generateDefaultFileInfo(tiddler);
} }
} catch (error) { } catch (error) {
this.logger.alert(`filesystem: Error in getTiddlerFileInfo for "${title}":`, error); this.logger.alert(`filesystem: Error in getTiddlerFileInfo for "${title}":`, error);
return this.generateDefaultFileInfo(tiddler, fileInfo); return this.generateDefaultFileInfo(tiddler);
} }
} }
/** /**
* Find matching sub-wiki using in-tagtree-of filter for sub-wikis with includeTagTree enabled. * Match a tiddler to a workspace based on routing rules.
* Iterates through sub-wikis sorted by order (priority). * Checks workspaces in order (priority) and returns the first match.
*
* For each workspace:
* 1. If fileSystemPathFilterEnable is enabled, use custom filter expressions (one per line, any match wins)
* 2. Else try direct tag match (including if tiddler's title IS one of the tagNames - it's a "tag tiddler")
* 3. Else if includeTagTree is enabled, use in-tagtree-of filter
*/ */
protected matchTitleToWikiByTagTree(title: string): IWikiWorkspace | undefined { protected matchTitleToWiki(title: string, tags: string[]): IWikiWorkspace | undefined {
for (const subWiki of this.wikisWithTag) { for (const wiki of this.wikisWithTag) {
if (!subWiki.includeTagTree || !subWiki.tagName) { // If fileSystemPathFilterEnable is enabled, use the custom filter expressions
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;
}
}
continue; continue;
} }
/**
* Use build-in in-tagtree-of (at `src/services/wiki/plugin/watchFileSystemAdaptor/in-tagtree-of.ts`) filter // Direct tag match - check if any of the tiddler's tags match any of the wiki's tagNames
* to check if tiddler is in tag tree. // Also check if the tiddler's title IS one of the tagNames (it's a "tag tiddler" that defines that tag)
* The filter returns the title if it's in the tag tree, empty otherwise if (wiki.tagNames.length > 0) {
*/ const hasMatchingTag = wiki.tagNames.some(tagName => tags.includes(tagName));
const result = $tw.wiki.filterTiddlers(`[in-tagtree-of:inclusive[${subWiki.tagName}]]`, undefined, $tw.wiki.makeTiddlerIterator([title])); const isTitleATagName = wiki.tagNames.includes(title);
if (result.length > 0) { if (hasMatchingTag || isTitleATagName) {
return subWiki; 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}]]`, undefined, $tw.wiki.makeTiddlerIterator([title]));
if (result.length > 0) {
return wiki;
}
}
} }
} }
return undefined; return undefined;
@ -212,8 +271,14 @@ export class FileSystemAdaptor {
/** /**
* Generate file info for sub-wiki directory * Generate file info for sub-wiki directory
* Handles symlinks correctly across platforms (Windows junctions and Linux symlinks) * 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; let targetDirectory = subWiki.wikiFolderLocation;
// Resolve symlinks to ensure consistent path handling across platforms // Resolve symlinks to ensure consistent path handling across platforms
@ -229,19 +294,38 @@ export class FileSystemAdaptor {
$tw.utils.createDirectory(targetDirectory); $tw.utils.createDirectory(targetDirectory);
return $tw.utils.generateTiddlerFileInfo(tiddler, { const title = tiddler.fields.title;
directory: targetDirectory, const oldFileInfo = this.boot.files[title];
pathFilters: undefined,
extFilters: this.extensionFilters, // Temporarily remove from boot.files to force fresh path generation
wiki: this.wiki, if (oldFileInfo) {
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo, // 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 * 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; let pathFilters: string[] | undefined;
if (this.wiki.tiddlerExists('$:/config/FileSystemPaths')) { if (this.wiki.tiddlerExists('$:/config/FileSystemPaths')) {
@ -249,13 +333,28 @@ export class FileSystemAdaptor {
pathFilters = pathFiltersText.split('\n').filter(line => line.trim().length > 0); pathFilters = pathFiltersText.split('\n').filter(line => line.trim().length > 0);
} }
return $tw.utils.generateTiddlerFileInfo(tiddler, { const title = tiddler.fields.title;
directory: this.boot.wikiTiddlersPath ?? '', const oldFileInfo = this.boot.files[title];
pathFilters,
extFilters: this.extensionFilters, // Temporarily remove from boot.files to force fresh path generation
wiki: this.wiki, if (oldFileInfo) {
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo, // 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 // Initialize sub-wiki watchers
await this.initializeSubWikiWatchers(); await this.initializeSubWikiWatchers();
// Log stabilization marker for tests // 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) { } catch (error) {
this.logger.alert('WatchFileSystemAdaptor Failed to initialize file watching:', error); this.logger.alert('WatchFileSystemAdaptor Failed to initialize file watching:', error);
} }

View file

@ -242,7 +242,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
name: 'Sub Wiki', name: 'Sub Wiki',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'test-workspace', mainWikiID: 'test-workspace',
tagName: 'SubWikiTag', tagNames: ['SubWikiTag'],
wikiFolderLocation: '/test/wiki/subwiki/sub1', wikiFolderLocation: '/test/wiki/subwiki/sub1',
}; };
@ -278,7 +278,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
id: 'sub-1', id: 'sub-1',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'test-workspace', mainWikiID: 'test-workspace',
tagName: 'Tag1', tagNames: ['Tag1'],
wikiFolderLocation: '/test/wiki/sub1', wikiFolderLocation: '/test/wiki/sub1',
}; };
@ -286,7 +286,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
id: 'sub-2', id: 'sub-2',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'test-workspace', mainWikiID: 'test-workspace',
tagName: 'Tag2', tagNames: ['Tag2'],
wikiFolderLocation: '/test/wiki/sub2', wikiFolderLocation: '/test/wiki/sub2',
}; };
@ -322,15 +322,16 @@ describe('FileSystemAdaptor - Routing Logic', () => {
id: 'sub-wiki-1', id: 'sub-wiki-1',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'test-workspace', mainWikiID: 'test-workspace',
tagName: 'SubWikiTag', tagNames: ['SubWikiTag'],
wikiFolderLocation: '/test/wiki/subwiki', wikiFolderLocation: '/test/wiki/subwiki',
}; };
// Test scenario 2: Sub-wiki without tagName // Test scenario 2: Sub-wiki without tagNames
const subWikiWithoutTag = { const subWikiWithoutTag = {
id: 'sub-wiki-2', id: 'sub-wiki-2',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'test-workspace', mainWikiID: 'test-workspace',
tagNames: [],
wikiFolderLocation: '/test/wiki/subwiki2', wikiFolderLocation: '/test/wiki/subwiki2',
}; };
@ -339,7 +340,7 @@ describe('FileSystemAdaptor - Routing Logic', () => {
id: 'sub-wiki-3', id: 'sub-wiki-3',
isSubWiki: true, isSubWiki: true,
mainWikiID: 'other-workspace', mainWikiID: 'other-workspace',
tagName: 'AnotherTag', tagNames: ['AnotherTag'],
wikiFolderLocation: '/test/otherwiki/subwiki', wikiFolderLocation: '/test/otherwiki/subwiki',
}; };

View file

@ -28,16 +28,20 @@ private async getCurrentWorkspace(): Promise<IWorkspace | undefined>
private async getSubWikis(currentWorkspace: IWorkspace): Promise<IWorkspace[]> private async getSubWikis(currentWorkspace: IWorkspace): Promise<IWorkspace[]>
``` ```
!!!! 2. Tag-Based Sub-Wiki Routing !!!! 2. Tag-Based and Filter-Based Sub-Wiki Routing
* ''Original'': Routes based on filter expressions in `FileSystemPaths` * ''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'': Made `getTiddlerFileInfo`, `saveTiddler`, and `deleteTiddler` async for cleaner code
* ''Modified'': Caches sub-wikis list to avoid repeated IPC calls on every save operation * ''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'': * ''Implementation'':
** Checks tiddler tags against sub-workspace `tagName` fields ** Checks tiddler tags/filters against sub-workspace routing rules (in priority order)
** Routes matching tiddlers to sub-wiki's `tiddlers` folder ** Routes matching tiddlers to sub-wiki's `tiddlers` folder
** Falls back to default `FileSystemPaths` logic for non-matching tiddlers ** Falls back to TiddlyWiki's `$:/config/FileSystemPaths` logic for non-matching tiddlers
** Loads sub-wikis cache on initialization ** Loads sub-wikis cache on initialization
** Currently loads sub-wikis once, future enhancements can watch for workspace changes ** Currently loads sub-wikis once, future enhancements can watch for workspace changes

View file

@ -3,22 +3,38 @@ type: text/vnd.tiddlywiki
!! Watch Filesystem Adaptor !! 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 !!! How It Works
# Queries workspace information from TidGi's main process via IPC # Queries workspace information from TidGi's main process via IPC
# Checks each tiddler's tags against sub-workspace `tagName` fields # Checks each tiddler's tags/filters against sub-workspace routing rules:
# Routes tiddlers with matching tags to the corresponding sub-wiki's tiddlers folder #* Multiple tag names (`tagNames`) - matches if any tag matches
#* 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`) - recursive tag hierarchy using `in-tagtree-of`
#* Custom filter expressions (`fileSystemPathFilter`) - 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 # 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 !!! Advantages
* No need to manually edit `$:/config/FileSystemPaths` * No need to manually edit `$:/config/FileSystemPaths`
* Automatically stays in sync with workspace configuration * Automatically stays in sync with workspace configuration
* Supports multiple tags per workspace
* Supports tag hierarchy matching
* Supports custom TiddlyWiki filter expressions
* 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 * More robust than string manipulation
* Works seamlessly with TidGi's workspace management * Works seamlessly with TidGi's workspace management
!!! Technical Details !!! 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

@ -36,15 +36,14 @@ export function createLoadWikiTiddlersWithSubWikis(
// Call original function first to load main wiki // Call original function first to load main wiki
const wikiInfo = originalLoadWikiTiddlers(wikiPath, options); const wikiInfo = originalLoadWikiTiddlers(wikiPath, options);
// defensive check // Only inject sub-wikis when loading the main wiki (not when loading included wikis)
if (wikiPath === homePath && wikiInfo && subWikis.length > 0) { if (wikiPath !== homePath || !wikiInfo || subWikis.length === 0) {
return; return wikiInfo;
} }
for (const subWiki of subWikis) { for (const subWiki of subWikis) {
const subWikiTiddlersPath = path.resolve( // Sub-wikis store tiddlers directly in their root folder (not in /tiddlers subfolder)
subWiki.wikiFolderLocation, // Only the main wiki uses /tiddlers because it has other meta files like .github
wikiInstance.config.wikiTiddlersSubDir, const subWikiTiddlersPath = subWiki.wikiFolderLocation;
);
try { try {
// Load tiddlers from sub-wiki directory // Load tiddlers from sub-wiki directory

View file

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

View file

@ -150,13 +150,15 @@ export class WikiGitWorkspace implements IWikiGitWorkspaceService {
backupOnInterval: true, backupOnInterval: true,
readOnlyMode: false, readOnlyMode: false,
tokenAuth: false, tokenAuth: false,
tagName: null, tagNames: [],
mainWikiToLink: null, mainWikiToLink: null,
mainWikiID: null, mainWikiID: null,
excludedPlugins: [], excludedPlugins: [],
enableHTTPAPI: false, enableHTTPAPI: false,
enableFileSystemWatch: true, enableFileSystemWatch: true,
includeTagTree: false, includeTagTree: false,
fileSystemPathFilterEnable: false,
fileSystemPathFilter: null,
lastNodeJSArgv: [], lastNodeJSArgv: [],
homeUrl: '', homeUrl: '',
gitUrl: null, gitUrl: null,

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[] = [ const template: MenuItemConstructorOptions[] = [
{ {
label: t('WorkspaceSelector.OpenWorkspaceTagTiddler', { label: t('WorkspaceSelector.OpenWorkspaceTagTiddler', {
tagName: tagName ?? (isSubWiki ? name : `${name} ${t('WorkspaceSelector.DefaultTiddlers')}`), tagName: tagNames[0] ?? (isSubWiki ? name : `${name} ${t('WorkspaceSelector.DefaultTiddlers')}`),
}), }),
click: async () => { click: async () => {
await service.workspace.openWorkspaceTiddler(workspace); await service.workspace.openWorkspaceTiddler(workspace);

View file

@ -235,6 +235,9 @@ export class Workspace implements IWorkspaceService {
excludedPlugins: [], excludedPlugins: [],
enableHTTPAPI: false, enableHTTPAPI: false,
includeTagTree: false, includeTagTree: false,
fileSystemPathFilterEnable: false,
fileSystemPathFilter: null,
tagNames: [],
}; };
const fixingValues: Partial<typeof workspaceToSanitize> = {}; const fixingValues: Partial<typeof workspaceToSanitize> = {};
// we add mainWikiID in creation, we fix this value for old existed workspaces // we add mainWikiID in creation, we fix this value for old existed workspaces
@ -244,9 +247,11 @@ export class Workspace implements IWorkspaceService {
fixingValues.mainWikiID = mainWorkspace.id; fixingValues.mainWikiID = mainWorkspace.id;
} }
} }
// fix WikiChannel.openTiddler in src/services/wiki/wikiOperations/executor/wikiOperationInBrowser.ts have \n on the end // Migrate old tagName (string) to tagNames (string[])
if (workspaceToSanitize.tagName?.endsWith('\n') === true) {
fixingValues.tagName = workspaceToSanitize.tagName.replaceAll('\n', ''); 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. // 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')) { if (!workspaceToSanitize.lastUrl?.startsWith('tidgi')) {
@ -271,11 +276,11 @@ export class Workspace implements IWorkspaceService {
if (!isWikiWorkspace(newWorkspaceConfig)) return; if (!isWikiWorkspace(newWorkspaceConfig)) return;
const existedWorkspace = this.getSync(newWorkspaceConfig.id); const existedWorkspace = this.getSync(newWorkspaceConfig.id);
const { id, tagName } = newWorkspaceConfig; const { id, tagNames } = newWorkspaceConfig;
// when update tagName of subWiki // when update tagNames of subWiki
if ( if (
existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && typeof tagName === 'string' && tagName.length > 0 && existedWorkspace !== undefined && isWikiWorkspace(existedWorkspace) && existedWorkspace.isSubWiki && tagNames.length > 0 &&
existedWorkspace.tagName !== tagName JSON.stringify(existedWorkspace.tagNames) !== JSON.stringify(tagNames)
) { ) {
const { mainWikiToLink } = existedWorkspace; const { mainWikiToLink } = existedWorkspace;
if (typeof mainWikiToLink !== 'string') { if (typeof mainWikiToLink !== 'string') {
@ -546,7 +551,7 @@ export class Workspace implements IWorkspaceService {
// Only handle wiki workspaces // Only handle wiki workspaces
if (!isWikiWorkspace(workspace)) return; if (!isWikiWorkspace(workspace)) return;
const { isSubWiki, mainWikiID, tagName } = workspace; const { isSubWiki, mainWikiID, tagNames } = workspace;
logger.log('debug', 'openWorkspaceTiddler', { 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 // If is main wiki, open the wiki, and open provided title, or simply switch to it if no title provided
@ -568,7 +573,8 @@ export class Workspace implements IWorkspaceService {
if (oldActiveWorkspace?.id !== mainWikiID) { if (oldActiveWorkspace?.id !== mainWikiID) {
await workspaceViewService.setActiveWorkspaceView(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) { if (subWikiTag) {
await wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [subWikiTag]); await wikiService.wikiOperationInBrowser(WikiChannel.openTiddler, mainWikiID, [subWikiTag]);
} }

View file

@ -136,15 +136,27 @@ export interface IWikiWorkspace extends IDedicatedWorkspace {
*/ */
syncOnStartup: boolean; 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 this sub-wiki's tagName * 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. * will also be saved to this sub-wiki. Uses the in-tagtree-of filter operator.
* Only applies when creating new tiddlers, not when modifying existing ones. * Only applies when creating new tiddlers, not when modifying existing ones.
*/ */
includeTagTree: boolean; includeTagTree: boolean;
/**
* When enabled, use fileSystemPathFilter instead of tagName/includeTagTree to match tiddlers.
* 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) * Use authenticated-user-header to provide `TIDGI_AUTH_TOKEN_HEADER` as header key to receive a value as username (we use it as token)
*/ */

View file

@ -361,8 +361,9 @@ export class WorkspaceView implements IWorkspaceViewService {
if (isWikiWorkspace(newWorkspace) && newWorkspace.isSubWiki && typeof newWorkspace.mainWikiID === 'string') { if (isWikiWorkspace(newWorkspace) && newWorkspace.isSubWiki && typeof newWorkspace.mainWikiID === 'string') {
logger.debug(`${nextWorkspaceID} is a subwiki, set its main wiki ${newWorkspace.mainWikiID} to active instead.`); logger.debug(`${nextWorkspaceID} is a subwiki, set its main wiki ${newWorkspace.mainWikiID} to active instead.`);
await this.setActiveWorkspaceView(newWorkspace.mainWikiID); await this.setActiveWorkspaceView(newWorkspace.mainWikiID);
if (typeof newWorkspace.tagName === 'string') { // Open the first tag if available
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagName]); if (newWorkspace.tagNames.length > 0) {
await container.get<IWikiService>(serviceIdentifier.Wiki).wikiOperationInBrowser(WikiChannel.openTiddler, newWorkspace.mainWikiID, [newWorkspace.tagNames[0]]);
} }
return; return;
} }

View file

@ -1,10 +1,10 @@
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import { Autocomplete, AutocompleteRenderInputParams, Chip, MenuItem, Typography } from '@mui/material';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isWikiWorkspace } from '@services/workspaces/interface'; 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 { useAvailableTags } from './useAvailableTags';
import { useValidateCloneWiki } from './useCloneWiki'; import { useValidateCloneWiki } from './useCloneWiki';
@ -83,17 +83,23 @@ export function CloneWikiForm({ form, isCreateMainWorkspace, errorInWhichCompone
</MenuItem> </MenuItem>
))} ))}
</SoftLinkToMainWikiSelect> </SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete <Autocomplete<string, true, false, true>
multiple
freeSolo freeSolo
options={availableTags} options={availableTags}
value={form.tagName} value={form.tagNames}
onInputChange={(_event: React.SyntheticEvent, value: string) => { onChange={(_event, newValue) => {
form.tagNameSetter(value); form.tagNamesSetter(newValue);
}} }}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip variant='outlined' label={option} key={key} {...tagProps} />;
})}
renderInput={(parameters: AutocompleteRenderInputParams) => ( renderInput={(parameters: AutocompleteRenderInputParams) => (
<LocationPickerInput <LocationPickerInput
{...parameters} {...parameters}
error={errorInWhichComponent.tagName} error={errorInWhichComponent.tagNames}
label={t('AddWorkspace.TagName')} label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')} helperText={t('AddWorkspace.TagNameHelp')}
/> />

View file

@ -1,10 +1,10 @@
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import { Autocomplete, AutocompleteRenderInputParams, Chip, MenuItem, Typography } from '@mui/material';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isWikiWorkspace } from '@services/workspaces/interface'; 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 { useAvailableTags } from './useAvailableTags';
import { useValidateExistedWiki } from './useExistedWiki'; import { useValidateExistedWiki } from './useExistedWiki';
@ -32,8 +32,8 @@ export function ExistedWikiForm({
mainWikiToLinkIndex, mainWikiToLinkIndex,
mainWikiToLinkSetter, mainWikiToLinkSetter,
mainWorkspaceList, mainWorkspaceList,
tagName, tagNames,
tagNameSetter, tagNamesSetter,
} = form; } = form;
// Local state for the full path input - like NewWikiForm's direct state binding // Local state for the full path input - like NewWikiForm's direct state binding
@ -132,17 +132,23 @@ export function ExistedWikiForm({
</MenuItem> </MenuItem>
))} ))}
</SoftLinkToMainWikiSelect> </SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete <Autocomplete<string, true, false, true>
multiple
freeSolo freeSolo
options={availableTags} options={availableTags}
value={tagName} value={tagNames}
onInputChange={(_event: React.SyntheticEvent, value: string) => { onChange={(_event, newValue) => {
tagNameSetter(value); tagNamesSetter(newValue);
}} }}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip variant='outlined' label={option} key={key} {...tagProps} />;
})}
renderInput={(parameters: AutocompleteRenderInputParams) => ( renderInput={(parameters: AutocompleteRenderInputParams) => (
<LocationPickerInput <LocationPickerInput
{...parameters} {...parameters}
error={errorInWhichComponent.tagName} error={errorInWhichComponent.tagNames}
label={t('AddWorkspace.TagName')} label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')} helperText={t('AddWorkspace.TagNameHelp')}
/> />

View file

@ -1,9 +1,9 @@
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import { AutocompleteRenderInputParams, MenuItem, Typography } from '@mui/material'; import { Autocomplete, AutocompleteRenderInputParams, Chip, MenuItem, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isWikiWorkspace } from '@services/workspaces/interface'; 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 { useAvailableTags } from './useAvailableTags';
import type { IWikiWorkspaceFormProps } from './useForm'; import type { IWikiWorkspaceFormProps } from './useForm';
@ -89,16 +89,22 @@ export function NewWikiForm({
</MenuItem> </MenuItem>
))} ))}
</SoftLinkToMainWikiSelect> </SoftLinkToMainWikiSelect>
<SubWikiTagAutoComplete <Autocomplete<string, true, false, true>
multiple
freeSolo freeSolo
options={availableTags} options={availableTags}
value={form.tagName} value={form.tagNames}
onInputChange={(_event: React.SyntheticEvent, value: string) => { onChange={(_event, newValue) => {
form.tagNameSetter(value); form.tagNamesSetter(newValue);
}} }}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip variant='outlined' label={option} key={key} {...tagProps} />;
})}
renderInput={(parameters: AutocompleteRenderInputParams) => ( renderInput={(parameters: AutocompleteRenderInputParams) => (
<LocationPickerInput <LocationPickerInput
error={errorInWhichComponent.tagName} error={errorInWhichComponent.tagNames}
{...parameters} {...parameters}
label={t('AddWorkspace.TagName')} label={t('AddWorkspace.TagName')}
helperText={t('AddWorkspace.TagNameHelp')} helperText={t('AddWorkspace.TagNameHelp')}

View file

@ -47,8 +47,8 @@ const createMockForm = (overrides: Partial<IWikiWorkspaceForm> = {}): IWikiWorks
metadata: {}, metadata: {},
} as unknown as IWorkspace, } as unknown as IWorkspace,
], ],
tagName: '', tagNames: [] as string[],
tagNameSetter: vi.fn(), tagNamesSetter: vi.fn(),
gitRepoUrl: '', gitRepoUrl: '',
gitRepoUrlSetter: vi.fn(), gitRepoUrlSetter: vi.fn(),
gitUserInfo: undefined as IGitUserInfos | undefined, gitUserInfo: undefined as IGitUserInfos | undefined,
@ -194,7 +194,7 @@ describe('NewWikiForm Component', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const mockSetter = vi.fn(); const mockSetter = vi.fn();
const form = createMockForm({ const form = createMockForm({
tagNameSetter: mockSetter, tagNamesSetter: mockSetter,
}); });
await renderNewWikiForm({ await renderNewWikiForm({
@ -206,7 +206,7 @@ describe('NewWikiForm Component', () => {
const tagInput = screen.getByTestId('tagname-autocomplete-input'); const tagInput = screen.getByTestId('tagname-autocomplete-input');
await user.type(tagInput, 'MyTag'); await user.type(tagInput, 'MyTag');
await user.keyboard('{enter}'); await user.keyboard('{enter}');
expect(mockSetter).toHaveBeenCalledWith('MyTag'); expect(mockSetter).toHaveBeenCalledWith(['MyTag']);
}); });
}); });
@ -231,7 +231,7 @@ describe('NewWikiForm Component', () => {
isCreateMainWorkspace: false, isCreateMainWorkspace: false,
errorInWhichComponent: { errorInWhichComponent: {
mainWikiToLink: true, mainWikiToLink: true,
tagName: true, tagNames: true,
}, },
}); });

View file

@ -48,7 +48,7 @@ export function useValidateCloneWiki(
form.gitRepoUrl, form.gitRepoUrl,
form.gitUserInfo, form.gitUserInfo,
form.mainWikiToLink.wikiFolderLocation, form.mainWikiToLink.wikiFolderLocation,
form.tagName, form.tagNames,
errorInWhichComponentSetter, errorInWhichComponentSetter,
]); ]);
return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter]; return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter];
@ -76,7 +76,7 @@ export function useCloneWiki(
form.mainWikiToLink.wikiFolderLocation, form.mainWikiToLink.wikiFolderLocation,
form.gitRepoUrl, form.gitRepoUrl,
form.gitUserInfo!, form.gitUserInfo!,
form.tagName, form.tagNames[0] ?? '',
); );
} }
await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.Clone }); await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { from: WikiCreationMethod.Clone });

View file

@ -46,7 +46,7 @@ export function useValidateExistedWiki(
form.gitRepoUrl, form.gitRepoUrl,
form.gitUserInfo, form.gitUserInfo,
form.mainWikiToLink.wikiFolderLocation, form.mainWikiToLink.wikiFolderLocation,
form.tagName, form.tagNames,
errorInWhichComponentSetter, errorInWhichComponentSetter,
]); ]);
return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter]; return [hasError, wikiCreationMessage, wikiCreationMessageSetter, hasErrorSetter];
@ -86,7 +86,7 @@ export function useExistedWiki(
wikiFolderNameForExistedFolder, wikiFolderNameForExistedFolder,
'subwiki', 'subwiki',
form.mainWikiToLink.wikiFolderLocation, form.mainWikiToLink.wikiFolderLocation,
form.tagName, form.tagNames[0] ?? '',
true, true,
); );
} }

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: '' }; 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); let mainWikiToLinkIndex = mainWorkspaceList.findIndex((workspace) => workspace.id === mainWikiToLink.id);
if (mainWikiToLinkIndex < 0) { if (mainWikiToLinkIndex < 0) {
mainWikiToLinkIndex = 0; mainWikiToLinkIndex = 0;
@ -123,8 +123,8 @@ export function useWikiWorkspaceForm(options?: { fromExisted: boolean }) {
wikiPortSetter, wikiPortSetter,
mainWikiToLink, mainWikiToLink,
mainWikiToLinkSetter, mainWikiToLinkSetter,
tagName, tagNames,
tagNameSetter, tagNamesSetter,
gitRepoUrl, gitRepoUrl,
gitRepoUrlSetter, gitRepoUrlSetter,
parentFolderLocation, parentFolderLocation,
@ -162,7 +162,7 @@ export function workspaceConfigFromForm(form: INewWikiRequiredFormData, isCreate
mainWikiID: isCreateMainWorkspace ? null : form.mainWikiToLink.id, mainWikiID: isCreateMainWorkspace ? null : form.mainWikiToLink.id,
name: form.wikiFolderName, name: form.wikiFolderName,
storageService: form.storageProvider, storageService: form.storageProvider,
tagName: isCreateMainWorkspace ? null : form.tagName, tagNames: isCreateMainWorkspace ? [] : form.tagNames,
port: form.wikiPort, port: form.wikiPort,
wikiFolderLocation: form.wikiFolderLocation!, wikiFolderLocation: form.wikiFolderLocation!,
backupOnInterval: true, backupOnInterval: true,
@ -174,6 +174,8 @@ export function workspaceConfigFromForm(form: INewWikiRequiredFormData, isCreate
enableHTTPAPI: false, enableHTTPAPI: false,
enableFileSystemWatch: true, enableFileSystemWatch: true,
includeTagTree: false, includeTagTree: false,
fileSystemPathFilterEnable: false,
fileSystemPathFilter: null,
lastNodeJSArgv: [], lastNodeJSArgv: [],
}; };
} }

View file

@ -51,7 +51,7 @@ export function useValidateNewWiki(
form.gitRepoUrl, form.gitRepoUrl,
form.gitUserInfo, form.gitUserInfo,
form.mainWikiToLink.wikiFolderLocation, form.mainWikiToLink.wikiFolderLocation,
form.tagName, form.tagNames,
errorInWhichComponentSetter, errorInWhichComponentSetter,
]); ]);
@ -80,7 +80,7 @@ export function useNewWiki(
await window.service.wiki.copyWikiTemplate(form.parentFolderLocation, form.wikiFolderName); await window.service.wiki.copyWikiTemplate(form.parentFolderLocation, form.wikiFolderName);
} }
} else { } 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, 'subwiki', form.mainWikiToLink.wikiFolderLocation, form.tagNames[0] ?? '');
} }
await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { notClose: options?.notClose, from: WikiCreationMethod.Create }); await callWikiInitialization(newWorkspaceConfig, wikiCreationMessageSetter, t, form.gitUserInfo, { notClose: options?.notClose, from: WikiCreationMethod.Create });
} catch (error) { } catch (error) {

View file

@ -7,6 +7,7 @@ import {
Autocomplete, Autocomplete,
AutocompleteRenderInputParams, AutocompleteRenderInputParams,
Button as ButtonRaw, Button as ButtonRaw,
Chip,
Divider, Divider,
Paper, Paper,
Switch, Switch,
@ -167,8 +168,10 @@ export default function EditWorkspace(): React.JSX.Element {
const storageService = isWiki ? workspace.storageService : SupportedStorageServices.github; const storageService = isWiki ? workspace.storageService : SupportedStorageServices.github;
const syncOnInterval = isWiki ? workspace.syncOnInterval : false; const syncOnInterval = isWiki ? workspace.syncOnInterval : false;
const syncOnStartup = isWiki ? workspace.syncOnStartup : 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 includeTagTree = isWiki ? workspace.includeTagTree : false;
const fileSystemPathFilterEnable = isWiki ? workspace.fileSystemPathFilterEnable : false;
const fileSystemPathFilter = isWiki ? workspace.fileSystemPathFilter : null;
const transparentBackground = isWiki ? workspace.transparentBackground : false; const transparentBackground = isWiki ? workspace.transparentBackground : false;
const userName = isWiki ? workspace.userName : ''; const userName = isWiki ? workspace.userName : '';
const lastUrl = isWiki ? workspace.lastUrl : null; const lastUrl = isWiki ? workspace.lastUrl : null;
@ -428,22 +431,6 @@ export default function EditWorkspace(): React.JSX.Element {
<Typography variant='body2' color='textSecondary' sx={{ mb: 2 }}> <Typography variant='body2' color='textSecondary' sx={{ mb: 2 }}>
{isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')} {isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')}
</Typography> </Typography>
<Autocomplete
freeSolo
options={availableTags}
value={tagName}
onInputChange={(_event: React.SyntheticEvent, value: string) => {
void _event;
workspaceSetter({ ...workspace, tagName: value }, true);
}}
renderInput={(parameters: AutocompleteRenderInputParams) => (
<TextField
{...parameters}
label={t('AddWorkspace.TagName')}
helperText={isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')}
/>
)}
/>
<List> <List>
<ListItem <ListItem
disableGutters disableGutters
@ -451,19 +438,81 @@ export default function EditWorkspace(): React.JSX.Element {
<Switch <Switch
edge='end' edge='end'
color='primary' color='primary'
checked={includeTagTree} checked={fileSystemPathFilterEnable}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true); workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true);
}} }}
/> />
} }
> >
<ListItemText <ListItemText
primary={t('AddWorkspace.IncludeTagTree')} primary={t('AddWorkspace.UseFilter')}
secondary={isSubWiki ? t('AddWorkspace.IncludeTagTreeHelp') : t('AddWorkspace.IncludeTagTreeHelpForMain')} secondary={t('AddWorkspace.UseFilterHelp')}
/> />
</ListItem> </ListItem>
</List> </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 }}
/>
)
: (
<>
<Autocomplete
multiple
freeSolo
options={availableTags}
value={tagNames}
onChange={(_event: React.SyntheticEvent, newValue: string[]) => {
void _event;
workspaceSetter({ ...workspace, tagNames: newValue }, true);
}}
renderTags={(value: string[], getTagProps) =>
value.map((option: string, index: number) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip variant='outlined' label={option} key={key} {...tagProps} />;
})}
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}
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>
</List>
</>
)}
</AccordionDetails> </AccordionDetails>
</OptionsAccordion> </OptionsAccordion>
)} )}

View file

@ -27,7 +27,7 @@ const mockWorkspaces: IWorkspace[] = [
port: 5212, port: 5212,
isSubWiki: false, isSubWiki: false,
mainWikiToLink: null, mainWikiToLink: null,
tagName: null, tagNames: [],
lastUrl: null, lastUrl: null,
active: true, active: true,
hibernated: false, hibernated: false,
@ -57,7 +57,7 @@ const mockWorkspaces: IWorkspace[] = [
port: 5213, port: 5213,
isSubWiki: false, isSubWiki: false,
mainWikiToLink: null, mainWikiToLink: null,
tagName: null, tagNames: [],
lastUrl: null, lastUrl: null,
active: false, active: false,
hibernated: false, hibernated: false,