feat(filter): enhance file filter manager and settings UI

- Add comprehensive file filter manager with pattern, path, and folder rules
- Implement filter statistics tracking (matched/excluded files)
- Add file filter settings tab with rule management UI
- Support whitelist/blacklist modes for filtering
- Remove deprecated advanced.excludePatterns in favor of filter rules
This commit is contained in:
Quorafind 2025-08-23 11:25:02 +08:00
parent d4d9d02ba5
commit c7db2b5c65
4 changed files with 105 additions and 70 deletions

View file

@ -63,6 +63,10 @@ export function renderFileFilterSettingsTab(
})
);
// Filter scope selection has been deprecated; use per-rule scope instead
// This block intentionally left as a no-op to maintain layout spacing.
// Filter rules section
new Setting(containerEl)
.setName(t("File Filter Rules"))
@ -106,6 +110,20 @@ export function renderFileFilterSettingsTab(
renderRules();
});
// Rule scope dropdown (per-rule)
const scopeContainer = ruleContainer.createDiv({ cls: "file-filter-rule-scope" });
scopeContainer.createEl("label", { text: t("Scope:") });
new DropdownComponent(scopeContainer)
.addOption("both", t("Both"))
.addOption("inline", t("Inline"))
.addOption("file", t("File"))
.setValue((rule as any).scope || "both")
.onChange(async (value: "both" | "inline" | "file") => {
(rule as any).scope = value;
await settingTab.plugin.saveSettings();
debouncedUpdateStats();
});
// Path input
const pathContainer = ruleContainer.createDiv({
cls: "file-filter-rule-path",
@ -238,6 +256,8 @@ export function renderFileFilterSettingsTab(
);
// Manual refresh button for statistics
new Setting(containerEl)
.setName(t("Refresh Statistics"))
.setDesc(t("Manually refresh filter statistics to see current data"))

View file

@ -14,7 +14,6 @@ import type {
FileTaskPropertiesConfig,
RelationshipsConfig,
PerformanceConfig,
AdvancedConfig,
StatusMappingConfig
} from "../../types/file-source";
@ -51,7 +50,8 @@ export const DEFAULT_FILE_TASK_PROPERTIES: FileTaskPropertiesConfig = {
contentSource: "filename",
stripExtension: true,
defaultStatus: " ",
defaultPriority: undefined
defaultPriority: undefined,
preferFrontmatterTitle: true
};
/** Default configuration for relationships */
@ -68,12 +68,6 @@ export const DEFAULT_PERFORMANCE_CONFIG: PerformanceConfig = {
cacheTTL: 300000 // 5 minutes
};
/** Default configuration for advanced features */
export const DEFAULT_ADVANCED_CONFIG: AdvancedConfig = {
excludePatterns: ["**/.obsidian/**", "**/node_modules/**"],
maxFileSize: 1048576 // 1MB
};
/** Default status mapping configuration */
export const DEFAULT_STATUS_MAPPING_CONFIG: StatusMappingConfig = {
enabled: true,
@ -157,7 +151,6 @@ export const DEFAULT_FILE_SOURCE_CONFIG: FileSourceConfiguration = {
fileTaskProperties: DEFAULT_FILE_TASK_PROPERTIES,
relationships: DEFAULT_RELATIONSHIPS_CONFIG,
performance: DEFAULT_PERFORMANCE_CONFIG,
advanced: DEFAULT_ADVANCED_CONFIG,
statusMapping: DEFAULT_STATUS_MAPPING_CONFIG
};
@ -286,25 +279,6 @@ export class FileSourceConfig {
}
}
// Validate advanced config
if (config.advanced) {
const advanced = config.advanced;
if (advanced.maxFileSize && advanced.maxFileSize < 1024) {
errors.push("Maximum file size must be at least 1KB");
}
if (advanced.customRecognitionFunction) {
// Basic validation for custom function
try {
// Check if it's valid JavaScript function syntax
new Function('filePath', 'fileContent', 'fileCache', advanced.customRecognitionFunction);
} catch (error) {
errors.push("Custom recognition function has invalid syntax");
}
}
}
// Validate status mapping config
if (config.statusMapping?.enabled) {
const mapping = config.statusMapping;
@ -357,10 +331,6 @@ export class FileSourceConfig {
...DEFAULT_PERFORMANCE_CONFIG,
...partial.performance
},
advanced: {
...DEFAULT_ADVANCED_CONFIG,
...partial.advanced
},
statusMapping: {
...DEFAULT_STATUS_MAPPING_CONFIG,
...partial.statusMapping

View file

@ -15,6 +15,7 @@ export interface FilterRule {
type: "file" | "folder" | "pattern";
path: string;
enabled: boolean;
scope?: "both" | "inline" | "file"; // per-rule scope
}
/**
@ -112,11 +113,19 @@ class PathTrie {
*/
export class FileFilterManager {
private config: FileFilterConfig;
private folderTrie: PathTrie = new PathTrie();
private fileSet: Set<string> = new Set();
private patternRegexes: RegExp[] = [];
private folderTrie: PathTrie = new PathTrie(); // global (legacy)
private fileSet: Set<string> = new Set(); // global (legacy)
private patternRegexes: RegExp[] = []; // global (legacy)
private cache: Map<string, boolean> = new Map();
// Scoped indexes for per-rule scope control
private folderTrieInline: PathTrie = new PathTrie();
private folderTrieFile: PathTrie = new PathTrie();
private fileSetInline: Set<string> = new Set();
private fileSetFile: Set<string> = new Set();
private patternRegexesInline: RegExp[] = [];
private patternRegexesFile: RegExp[] = [];
constructor(config: FileFilterConfig) {
this.config = config;
this.rebuildIndexes();
@ -134,7 +143,7 @@ export class FileFilterManager {
/**
* Check if a file should be included in indexing
*/
shouldIncludeFile(file: TFile): boolean {
shouldIncludeFile(file: TFile, scope: "both" | "inline" | "file" = "both"): boolean {
if (!this.config.enabled) {
return true;
}
@ -146,7 +155,7 @@ export class FileFilterManager {
return this.cache.get(filePath)!;
}
const result = this.evaluateFile(filePath);
const result = this.evaluateFile(filePath, scope);
this.cache.set(filePath, result);
return result;
}
@ -154,7 +163,7 @@ export class FileFilterManager {
/**
* Check if a folder should be included in indexing
*/
shouldIncludeFolder(folder: TFolder): boolean {
shouldIncludeFolder(folder: TFolder, scope: "both" | "inline" | "file" = "both"): boolean {
if (!this.config.enabled) {
return true;
}
@ -166,7 +175,7 @@ export class FileFilterManager {
return this.cache.get(folderPath)!;
}
const result = this.evaluateFolder(folderPath);
const result = this.evaluateFolder(folderPath, scope);
this.cache.set(folderPath, result);
return result;
}
@ -174,7 +183,7 @@ export class FileFilterManager {
/**
* Check if a path should be included (generic method)
*/
shouldIncludePath(path: string): boolean {
shouldIncludePath(path: string, scope: "both" | "inline" | "file" = "both"): boolean {
if (!this.config.enabled) {
return true;
}
@ -184,7 +193,7 @@ export class FileFilterManager {
return this.cache.get(path)!;
}
const result = this.evaluatePath(path);
const result = this.evaluatePath(path, scope);
this.cache.set(path, result);
return result;
}
@ -210,8 +219,8 @@ export class FileFilterManager {
/**
* Evaluate if a file should be included
*/
private evaluateFile(filePath: string): boolean {
const matches = this.pathMatches(filePath);
private evaluateFile(filePath: string, scope: "both" | "inline" | "file" = "both"): boolean {
const matches = this.pathMatches(filePath, scope);
if (this.config.mode === FilterMode.WHITELIST) {
return matches;
@ -223,8 +232,8 @@ export class FileFilterManager {
/**
* Evaluate if a folder should be included
*/
private evaluateFolder(folderPath: string): boolean {
const matches = this.pathMatches(folderPath);
private evaluateFolder(folderPath: string, scope: "both" | "inline" | "file" = "both"): boolean {
const matches = this.pathMatches(folderPath, scope);
if (this.config.mode === FilterMode.WHITELIST) {
return matches;
@ -236,8 +245,8 @@ export class FileFilterManager {
/**
* Evaluate if a path should be included (generic)
*/
private evaluatePath(path: string): boolean {
const matches = this.pathMatches(path);
private evaluatePath(path: string, scope: "both" | "inline" | "file" = "both"): boolean {
const matches = this.pathMatches(path, scope);
if (this.config.mode === FilterMode.WHITELIST) {
return matches;
@ -249,21 +258,26 @@ export class FileFilterManager {
/**
* Check if a path matches any filter rule
*/
private pathMatches(path: string): boolean {
private pathMatches(path: string, scope: "both" | "inline" | "file"): boolean {
const normalizedPath = this.normalizePath(path);
// Pick the right indexes based on scope
const fileSet = scope === "file" ? this.fileSetFile : scope === "inline" ? this.fileSetInline : this.fileSet;
const folderTrie = scope === "file" ? this.folderTrieFile : scope === "inline" ? this.folderTrieInline : this.folderTrie;
const patternRegexes = scope === "file" ? this.patternRegexesFile : scope === "inline" ? this.patternRegexesInline : this.patternRegexes;
// Check exact file matches
if (this.fileSet.has(normalizedPath)) {
if (fileSet.has(normalizedPath)) {
return true;
}
// Check folder matches (including parent folders)
if (this.folderTrie.contains(normalizedPath)) {
if (folderTrie.contains(normalizedPath)) {
return true;
}
// Check pattern matches
for (const regex of this.patternRegexes) {
for (const regex of patternRegexes) {
if (regex.test(normalizedPath)) {
return true;
}
@ -276,32 +290,62 @@ export class FileFilterManager {
* Rebuild internal indexes when configuration changes
*/
private rebuildIndexes(): void {
// Clear legacy and scoped indexes
this.folderTrie.clear();
this.fileSet.clear();
this.patternRegexes = [];
this.folderTrieInline.clear();
this.folderTrieFile.clear();
this.fileSetInline.clear();
this.fileSetFile.clear();
this.patternRegexesInline = [];
this.patternRegexesFile = [];
for (const rule of this.config.rules) {
if (!rule.enabled) continue;
const scope = rule.scope || "both";
switch (rule.type) {
case "file":
this.fileSet.add(this.normalizePath(rule.path));
break;
case "folder":
this.folderTrie.insert(rule.path, true);
break;
case "pattern":
try {
// Convert glob pattern to regex
const regexPattern = this.globToRegex(rule.path);
this.patternRegexes.push(new RegExp(regexPattern, "i"));
} catch (error) {
console.warn(
`Invalid pattern rule: ${rule.path}`,
error
);
}
break;
const addTo = (bucket: "both" | "inline" | "file") => {
switch (rule.type) {
case "file":
(bucket === "file"
? this.fileSetFile
: bucket === "inline"
? this.fileSetInline
: this.fileSet
).add(this.normalizePath(rule.path));
break;
case "folder":
(bucket === "file"
? this.folderTrieFile
: bucket === "inline"
? this.folderTrieInline
: this.folderTrie
).insert(rule.path, true);
break;
case "pattern":
try {
const regexPattern = this.globToRegex(rule.path);
(bucket === "file"
? this.patternRegexesFile
: bucket === "inline"
? this.patternRegexesInline
: this.patternRegexes
).push(new RegExp(regexPattern, "i"));
} catch (error) {
console.warn(`Invalid pattern rule: ${rule.path}`, error);
}
break;
}
};
// Add to legacy 'both' indexes if scope is both, else add to specific
if (scope === "both") {
addTo("both");
} else if (scope === "inline") {
addTo("inline");
} else if (scope === "file") {
addTo("file");
}
}
}

View file

@ -25,6 +25,7 @@
.file-filter-rule-type,
.file-filter-rule-path,
.file-filter-rule-scope,
.file-filter-rule-enabled {
display: flex;
flex-direction: column;