Skip to content

Commit fbfe757

Browse files
authored
Merge pull request #760 from callumalpass/issue584
feat: add hierarchical tag support with exclusion patterns
2 parents 2ebb52c + 75d4c13 commit fbfe757

File tree

3 files changed

+150
-29
lines changed

3 files changed

+150
-29
lines changed

src/suggest/FileSuggestHelper.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { parseFrontMatterAliases } from "obsidian";
33
import { scoreMultiword } from "../utils/fuzzyMatch";
44
import { parseDisplayFieldsRow } from "../utils/projectAutosuggestDisplayFieldsParser";
55
import { getProjectPropertyFilter, matchesProjectProperty } from "../utils/projectFilterUtils";
6+
import { FilterUtils } from "../utils/FilterUtils";
67

78
export interface FileSuggestionItem {
89
insertText: string; // usually basename
@@ -59,8 +60,8 @@ export const FileSuggestHelper = {
5960
: [frontmatterTags].filter(Boolean)),
6061
];
6162

62-
// Check if file has ANY of the required tags
63-
const hasRequiredTag = requiredTags.some((reqTag) => allTags.includes(reqTag));
63+
// Check if file has ANY of the required tags using hierarchical matching with proper exclusion handling
64+
const hasRequiredTag = FilterUtils.matchesTagConditions(allTags, requiredTags);
6465
if (!hasRequiredTag) {
6566
continue; // Skip this file
6667
}

src/utils/FilterUtils.ts

Lines changed: 141 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,9 @@ export class FilterUtils {
395395
case "is-not":
396396
return !this.isEqual(taskValue, conditionValue, property);
397397
case "contains":
398-
return this.contains(taskValue, conditionValue);
398+
return this.contains(taskValue, conditionValue, property);
399399
case "does-not-contain":
400-
return !this.contains(taskValue, conditionValue);
400+
return !this.contains(taskValue, conditionValue, property);
401401
case "is-before":
402402
return this.isBefore(taskValue, conditionValue);
403403
case "is-after":
@@ -475,46 +475,162 @@ export class FilterUtils {
475475
}
476476

477477
/**
478-
* Contains comparison for text and arrays
478+
* Check if a tag matches another tag using hierarchical matching rules with substring fallback.
479+
* Supports Obsidian nested tags where 't/ef' matches 't/ef/project', 't/ef/task', etc.
480+
* Also supports substring matching for backward compatibility.
481+
* This function only handles positive matching - exclusion logic is handled by callers.
482+
*
483+
* @param taskTag - The tag from the task (e.g., 't/ef/project')
484+
* @param conditionTag - The condition tag without hyphen prefix (e.g., 't/ef')
485+
* @returns true if the tag matches according to hierarchical rules or substring matching
486+
*/
487+
static matchesHierarchicalTag(taskTag: string, conditionTag: string): boolean {
488+
if (!taskTag || !conditionTag) return false;
489+
490+
const taskTagLower = taskTag.toLowerCase();
491+
const conditionTagLower = conditionTag.toLowerCase();
492+
493+
// Exact match
494+
if (taskTagLower === conditionTagLower) {
495+
return true;
496+
}
497+
498+
// Check if taskTag is a child of conditionTag
499+
// 't/ef/project' should match when searching for 't/ef'
500+
if (taskTagLower.startsWith(conditionTagLower + '/')) {
501+
return true; // Hierarchical child match
502+
}
503+
504+
// Fallback to substring matching for backward compatibility
505+
// This allows 'proj' to match 'project/alpha' and 'urgent' to match 'priority/urgent'
506+
if (taskTagLower.includes(conditionTagLower)) {
507+
return true; // Substring match
508+
}
509+
510+
return false;
511+
}
512+
513+
/**
514+
* Enhanced tag matching that supports both inclusion and exclusion patterns.
515+
* Handles arrays of tag conditions with proper exclusion semantics.
516+
*
517+
* @param taskTags - Array of tags from the task
518+
* @param conditionTags - Array of condition tags (may include '-' prefix for exclusions)
519+
* @returns true if task tags match the conditions (all inclusions met, no exclusions found)
520+
*/
521+
static matchesTagConditions(taskTags: string[], conditionTags: string[]): boolean {
522+
if (!Array.isArray(taskTags) || !Array.isArray(conditionTags)) return false;
523+
if (conditionTags.length === 0) return true; // No conditions means match
524+
525+
const inclusions: string[] = [];
526+
const exclusions: string[] = [];
527+
528+
// Separate inclusion and exclusion patterns
529+
for (const condTag of conditionTags) {
530+
if (typeof condTag === 'string' && condTag.startsWith('-')) {
531+
const excludePattern = condTag.slice(1);
532+
if (excludePattern) {
533+
exclusions.push(excludePattern);
534+
}
535+
} else if (typeof condTag === 'string') {
536+
inclusions.push(condTag);
537+
}
538+
}
539+
540+
// Check exclusions first - if any excluded tag is found, reject
541+
for (const excludePattern of exclusions) {
542+
const hasExcludedTag = taskTags.some(taskTag =>
543+
this.matchesHierarchicalTag(taskTag, excludePattern)
544+
);
545+
if (hasExcludedTag) {
546+
return false; // Excluded tag found
547+
}
548+
}
549+
550+
// If there are inclusion patterns, at least one must match
551+
if (inclusions.length > 0) {
552+
return inclusions.some(includePattern =>
553+
taskTags.some(taskTag =>
554+
this.matchesHierarchicalTag(taskTag, includePattern)
555+
)
556+
);
557+
}
558+
559+
// If only exclusions were specified and none matched, include the item
560+
return true;
561+
}
562+
563+
/**
564+
* Enhanced contains comparison for text and arrays with hierarchical tag support
479565
*/
480566
private static contains(
481567
taskValue: TaskPropertyValue,
482-
conditionValue: TaskPropertyValue
568+
conditionValue: TaskPropertyValue,
569+
property?: FilterProperty
483570
): boolean {
484571
if (Array.isArray(taskValue)) {
485572
// Array contains should be substring-based on each item when condition is string
486573
if (Array.isArray(conditionValue)) {
487574
// Any condition token partially matches any haystack token
488-
return conditionValue.some((cv) =>
489-
taskValue.some(
490-
(tv) =>
491-
typeof tv === "string" &&
492-
typeof cv === "string" &&
493-
tv.toLowerCase().includes(cv.toLowerCase())
494-
)
495-
);
575+
if (property === "tags") {
576+
// Use hierarchical tag matching for tags with proper exclusion handling
577+
const taskTags = taskValue.filter((tv): tv is string => typeof tv === "string");
578+
const condTags = conditionValue.filter((cv): cv is string => typeof cv === "string");
579+
return FilterUtils.matchesTagConditions(taskTags, condTags);
580+
} else {
581+
// Use default substring matching for other properties
582+
return conditionValue.some((cv) =>
583+
taskValue.some(
584+
(tv) =>
585+
typeof tv === "string" &&
586+
typeof cv === "string" &&
587+
tv.toLowerCase().includes(cv.toLowerCase())
588+
)
589+
);
590+
}
496591
} else {
497592
const cond =
498593
typeof conditionValue === "string"
499-
? conditionValue.toLowerCase()
500-
: String(conditionValue ?? "").toLowerCase();
501-
return taskValue.some(
502-
(tv) => typeof tv === "string" && tv.toLowerCase().includes(cond)
503-
);
594+
? conditionValue
595+
: String(conditionValue ?? "");
596+
if (property === "tags") {
597+
// Use hierarchical tag matching for tags with proper exclusion handling
598+
const taskTags = taskValue.filter((tv): tv is string => typeof tv === "string");
599+
return FilterUtils.matchesTagConditions(taskTags, [cond]);
600+
} else {
601+
// Use default substring matching for other properties
602+
const condLower = cond.toLowerCase();
603+
return taskValue.some(
604+
(tv) => typeof tv === "string" && tv.toLowerCase().includes(condLower)
605+
);
606+
}
504607
}
505608
} else if (typeof taskValue === "string") {
506609
if (Array.isArray(conditionValue)) {
507610
// Task has string, condition is array
508-
return conditionValue.some(
509-
(cv) =>
510-
typeof cv === "string" && taskValue.toLowerCase().includes(cv.toLowerCase())
511-
);
611+
if (property === "tags") {
612+
// Use hierarchical tag matching for tags with proper exclusion handling
613+
const condTags = conditionValue.filter((cv): cv is string => typeof cv === "string");
614+
return FilterUtils.matchesTagConditions([taskValue], condTags);
615+
} else {
616+
// Use default substring matching for other properties
617+
return conditionValue.some(
618+
(cv) =>
619+
typeof cv === "string" && taskValue.toLowerCase().includes(cv.toLowerCase())
620+
);
621+
}
512622
} else {
513623
// Both strings
514-
return (
515-
typeof conditionValue === "string" &&
516-
taskValue.toLowerCase().includes(conditionValue.toLowerCase())
517-
);
624+
if (property === "tags" && typeof conditionValue === "string") {
625+
// Use hierarchical tag matching for tags with proper exclusion handling
626+
return FilterUtils.matchesTagConditions([taskValue], [conditionValue]);
627+
} else {
628+
// Use default substring matching for other properties
629+
return (
630+
typeof conditionValue === "string" &&
631+
taskValue.toLowerCase().includes(conditionValue.toLowerCase())
632+
);
633+
}
518634
}
519635
}
520636
return false;

src/utils/MinimalNativeCache.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { TFile, App, Events, EventRef, parseLinktext } from "obsidian";
33
import { TaskInfo, NoteInfo, TaskDependency } from "../types";
44
import { FieldMapper } from "../services/FieldMapper";
5+
import { FilterUtils } from "./FilterUtils";
56
import {
67
getTodayString,
78
isBeforeDateSafe,
@@ -118,8 +119,11 @@ export class MinimalNativeCache extends Events {
118119
}
119120
return this.comparePropertyValues(frontmatterValue, propValue);
120121
} else {
121-
// Fallback to legacy tag-based method
122-
return Array.isArray(frontmatter.tags) && frontmatter.tags.includes(this.taskTag);
122+
// Fallback to legacy tag-based method with hierarchical support
123+
if (!Array.isArray(frontmatter.tags)) return false;
124+
return frontmatter.tags.some((tag: string) =>
125+
FilterUtils.matchesHierarchicalTag(tag, this.taskTag)
126+
);
123127
}
124128
}
125129

0 commit comments

Comments
 (0)