refactor: improve folder search by excluding redundant child matches instead of deprioritizing

This commit is contained in:
wz 2025-09-30 22:42:43 +02:00
parent 89df8eb024
commit 658adcbfe4
2 changed files with 100 additions and 61 deletions

View file

@ -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

View file

@ -288,13 +288,15 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
}
// 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<TFolder> {
});
// 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<TFolder>[] = [];
const parentPaths = new Set<string>();
@ -374,7 +376,7 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
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<TFolder> {
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;
}