@@ -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 ;
0 commit comments