diff --git a/docs/implementation/SEARCH_IMPROVEMENTS.md b/docs/implementation/SEARCH_IMPROVEMENTS.md index bdd7b64..b776d00 100644 --- a/docs/implementation/SEARCH_IMPROVEMENTS.md +++ b/docs/implementation/SEARCH_IMPROVEMENTS.md @@ -9,8 +9,8 @@ This document describes the improvements made to the folder search functionality **Solution**: Implemented intelligent parent-child deduplication that: - Prioritizes parent folder matches over their children -- Deprioritizes child folders unless they have additional keyword matches beyond what the parent has -- Reduces child folder scores to 10% of their original score when they don't add new information +- Completely excludes child folders unless they have additional keyword matches beyond what the parent has +- Only shows children when they contain additional matched keywords not found in the parent path ### Issue #8: Out-of-Order Keyword Matching **Problem**: Search only worked when keywords were in the correct order. For example, searching "bar foo" would not match "foo/bar/baz". @@ -54,24 +54,36 @@ Each folder is scored based on multiple factors: After initial scoring, the algorithm applies deduplication: ```typescript -// Check if this child has additional matches beyond the parent -const parentKeywordMatches = keywords.filter(k => - parentPath.toLowerCase().includes(k) -); -const childKeywordMatches = keywords.filter(k => - folderPath.toLowerCase().includes(k) -); +// Count total occurrences of all keywords in both parent and child +let parentOccurrences = 0; +let childOccurrences = 0; -// If child doesn't have more keyword matches than parent, deprioritize it -if (childKeywordMatches.length <= parentKeywordMatches.length) { - item.score = item.score * 0.1; +for (const keyword of keywords) { + // Count occurrences of this keyword in parent + let pos = 0; + while ((pos = parentLower.indexOf(keyword, pos)) !== -1) { + parentOccurrences++; + pos += keyword.length; + } + + // Count occurrences of this keyword in child + pos = 0; + while ((pos = childLower.indexOf(keyword, pos)) !== -1) { + childOccurrences++; + pos += keyword.length; + } +} + +// If child doesn't have more keyword occurrences than parent, exclude it +if (childOccurrences <= parentOccurrences) { + shouldExclude = true; } ``` This ensures that when searching for "abc": -- "abc" gets high priority -- "abc/xxx" gets significantly lower priority (unless "xxx" is also a keyword) -- "abc/xxx/abc" would still rank high because it has an additional "abc" match +- "abc" is shown (1 occurrence) +- "abc/xxx" is completely hidden (still 1 occurrence of "abc") +- "abc/xxx/abc" would be shown (2 occurrences of "abc") #### 4. Result Limiting @@ -101,20 +113,33 @@ Results are sorted by final score and limited to the `maxResults` setting. **After (new behavior):** 1. `abc` (score: 350) 2. `projects/abc` (score: 150) -3. `abc/xxx` (score: 34) ← Deprioritized to 10% -4. `abc/yyy` (score: 34) ← Deprioritized to 10% -5. `abc/zzz` (score: 34) ← Deprioritized to 10% +3. (Other non-related folders...) + +Note: `abc/xxx`, `abc/yyy`, `abc/zzz` are completely excluded from results ### Example 3: Additional Keyword in Child **Query:** "abc test" **Results:** -1. `abc/test` (score: 450) - Matches both keywords, high score -2. `abc` (score: 350) - Only matches "abc" -3. `projects/abc/test` (score: 340) - Matches both but deeper -4. `abc/notes` (score: 34) - Only matches "abc", child of matching parent +1. `abc/test` (score: 450) - Matches both keywords, shown +2. `abc` (score: 350) - Only matches "abc", shown +3. `projects/abc/test` (score: 340) - Matches both, shown +4. Other folders matching both keywords... -Note: `abc/test` ranks highest because it matches BOTH keywords even though it's a child of `abc`. +Note: `abc/test` is shown because it has 2 total keyword occurrences ("abc" + "test"), more than its parent `abc` which only has 1. However, `abc/notes` would be excluded because it still only has 1 occurrence of "abc", the same count as its parent. + +### Example 4: Repeated Keywords +**Query:** "ai prompt" + +**Folder Structure:** +- `AI/Prompt engineering` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences +- `AI/Prompt engineering/Prompt engineering` - Contains "ai" (1x) and "prompt" (2x) = 3 total occurrences +- `AI/Prompt engineering/asset` - Contains "ai" (1x) and "prompt" (1x) = 2 total occurrences + +**Results:** +1. `AI/Prompt engineering` - Shown (2 occurrences) +2. `AI/Prompt engineering/Prompt engineering` - Shown (3 occurrences, more than parent) +3. `AI/Prompt engineering/asset` - Hidden (2 occurrences, same as parent) ## Technical Notes diff --git a/src/folderSuggestModal.ts b/src/folderSuggestModal.ts index 7e5f993..190d05a 100644 --- a/src/folderSuggestModal.ts +++ b/src/folderSuggestModal.ts @@ -288,13 +288,15 @@ export class FolderSuggestModal extends FuzzySuggestModal { } // Store match positions for highlighting (using original path positions) - // We need to find the keyword in the original case-sensitive path + // Find ALL occurrences of this keyword in the original case-sensitive path const originalPathLower = originalPath.toLowerCase(); - const matchIndex = originalPathLower.indexOf(keyword); - - // Record character positions for this keyword - for (let i = 0; i < keyword.length; i++) { - matchPositions.push([matchIndex + i, matchIndex + i]); + let pos = 0; + while ((pos = originalPathLower.indexOf(keyword, pos)) !== -1) { + // Record character positions for this occurrence + for (let i = 0; i < keyword.length; i++) { + matchPositions.push([pos + i, pos + i]); + } + pos += keyword.length; // Move past this occurrence to find the next one } // Calculate score for this keyword @@ -360,7 +362,7 @@ export class FolderSuggestModal extends FuzzySuggestModal { }); // Apply parent folder deduplication for Issue #4 - // When a parent folder matches, deprioritize its immediate children + // When a parent folder matches, completely exclude its children unless they have additional keyword matches const results: FuzzyMatch[] = []; const parentPaths = new Set(); @@ -374,7 +376,7 @@ export class FolderSuggestModal extends FuzzySuggestModal { const folderPath = item.folder.path; // Check if this folder's parent is already in results - let shouldInclude = true; + let shouldExclude = false; let isChildOfMatch = false; for (const parentPath of parentPaths) { @@ -382,48 +384,60 @@ export class FolderSuggestModal extends FuzzySuggestModal { if (folderPath.startsWith(parentPath + "/")) { isChildOfMatch = true; - // Check if this child has additional matches beyond the parent - const parentKeywordMatches = keywords.filter((k) => - parentPath.toLowerCase().includes(k), - ); - const childKeywordMatches = keywords.filter((k) => - folderPath.toLowerCase().includes(k), - ); + // Check if this child has additional keyword occurrences beyond the parent + // Count total occurrences of all keywords in both parent and child + const parentLower = parentPath.toLowerCase(); + const childLower = folderPath.toLowerCase(); - // If child doesn't have more keyword matches than parent, deprioritize it - if ( - childKeywordMatches.length <= - parentKeywordMatches.length - ) { - // Lower the score significantly to push it down - item.score = item.score * 0.1; - item.match.match.score = item.score; + let parentOccurrences = 0; + let childOccurrences = 0; + + for (const keyword of keywords) { + // Count occurrences of this keyword in parent + let pos = 0; + while ( + (pos = parentLower.indexOf(keyword, pos)) !== -1 + ) { + parentOccurrences++; + pos += keyword.length; + } + + // Count occurrences of this keyword in child + pos = 0; + while ( + (pos = childLower.indexOf(keyword, pos)) !== -1 + ) { + childOccurrences++; + pos += keyword.length; + } + } + + // If child doesn't have more keyword occurrences than parent, exclude it + if (childOccurrences <= parentOccurrences) { + shouldExclude = true; } break; } } - results.push(item.match); + // Only include if not excluded + if (!shouldExclude) { + results.push(item.match); - // Add this folder to parent paths for future checks - if (!isChildOfMatch) { - parentPaths.add(folderPath); + // Add this folder to parent paths for future checks + if (!isChildOfMatch) { + parentPaths.add(folderPath); + } } } - // Re-sort after deduplication adjustments - const finalResults = results.sort((a, b) => { - // Keep separators at original positions - if ((a.item as any)._isSeparator || (b.item as any)._isSeparator) { - return 0; - } - return (b.match?.score || 0) - (a.match?.score || 0); - }); - // Limit results - const limitedResults = finalResults.slice(0, this.limit); + const limitedResults = results.slice(0, this.limit); - log(`Found ${limitedResults.length} matching folders`, this.plugin); + log( + `Found ${limitedResults.length} matching folders (filtered from ${foldersWithScore.length} total matches)`, + this.plugin, + ); return limitedResults; }