Skip to content

Commit 58d0648

Browse files
Merge pull request #35 from JonathanPiaget/31-feature-add-fuzzy-search
31 feature add fuzzy search
2 parents cbc2664 + 9f32540 commit 58d0648

File tree

13 files changed

+564
-186
lines changed

13 files changed

+564
-186
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ A browser extension for efficient bookmark management with search capabilities.
1313
Save bookmarks quickly by opening the extension popup. The popup auto-fills the current page title and URL for easy editing before saving to your chosen folder.
1414

1515
**Folder Search**
16-
Find bookmark folders by typing their names with real-time filtering. Navigate results with arrow keys and expand child folders. The interface highlights matching text and shows breadcrumb paths for easy identification.
16+
Find bookmark folders by typing their names with real-time filtering. Toggle between fuzzy and exact matching for flexible search. Navigate results with arrow keys and expand child folders. The interface highlights matching text and shows breadcrumb paths for easy identification.
1717

1818
**Bookmark Search**
19-
Search for bookmarks within folders using full-text search. View folder contents with the option to display recursive or direct content only.
19+
Search for bookmarks across all folders or within a specific folder. Toggle fuzzy search for typo-tolerant matching. Filter results in real-time with highlighted matches. View folder contents with the option to display recursive or direct content only.
2020

2121
**Current Page Detection**
2222
See the full path where the current page is already bookmarked, and quickly delete it directly from the popup if needed.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@wxt-dev/i18n": "^0.2.4",
23+
"fuzzysort": "^3.1.0",
2324
"vue": "^3.5.26"
2425
},
2526
"devDependencies": {

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 63 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,110 @@
11
import { describe, expect, it } from 'vitest';
22
import { ref } from 'vue';
3-
import {
4-
createNestedFolders,
5-
createSpecialCharFolders,
6-
createWorkBookmarks,
7-
} from '../../test-utils/bookmarkFactory';
3+
import { createWorkBookmarks } from '../../test-utils/bookmarkFactory';
84
import { useFolderSearch } from '../useFolderSearch';
5+
import type { BookmarkFolder } from '../useFolderTree';
96

107
describe('useFolderSearch', () => {
11-
describe('searchFolders', () => {
8+
describe('exact mode', () => {
129
it('returns empty results when query is empty', () => {
13-
const allFolders = ref(createWorkBookmarks());
14-
const { searchQuery, searchResults, searchFolders } =
15-
useFolderSearch(allFolders);
10+
const { searchQuery, searchResults, searchFolders, isFuzzyEnabled } =
11+
useFolderSearch(ref(createWorkBookmarks()));
1612

13+
isFuzzyEnabled.value = false;
1714
searchQuery.value = '';
1815
searchFolders();
1916

2017
expect(searchResults.value).toEqual([]);
2118
});
2219

23-
it('finds folders by name (case-insensitive)', () => {
24-
const allFolders = ref(createWorkBookmarks());
25-
const { searchQuery, searchResults, searchFolders } =
26-
useFolderSearch(allFolders);
20+
it('finds folders case-insensitively with null indexes', () => {
21+
const { searchQuery, searchResults, searchFolders, isFuzzyEnabled } =
22+
useFolderSearch(ref(createWorkBookmarks()));
2723

24+
isFuzzyEnabled.value = false;
2825
searchQuery.value = 'WORK';
2926
searchFolders();
3027

3128
expect(searchResults.value).toHaveLength(2);
32-
expect(searchResults.value[0].title).toBe('Work Projects');
33-
expect(searchResults.value[1].title).toBe('work notes');
29+
expect(searchResults.value[0].folder.title).toBe('Work Projects');
30+
expect(searchResults.value[0].indexes).toBeNull();
3431
});
32+
});
3533

36-
it('finds folders with special characters', () => {
37-
const allFolders = ref(createSpecialCharFolders());
38-
const { searchQuery, searchResults, searchFolders } =
39-
useFolderSearch(allFolders);
40-
41-
searchQuery.value = '$100';
34+
describe('fuzzy mode', () => {
35+
it('finds folders with fuzzy matching and returns indexes', () => {
36+
const folders: BookmarkFolder[] = [
37+
{ id: '1', title: 'kotlin-lang-lambda', path: '' },
38+
{ id: '2', title: 'javascript-tutorial', path: '' },
39+
];
40+
const { searchQuery, searchResults, searchFolders, isFuzzyEnabled } =
41+
useFolderSearch(ref(folders));
42+
43+
isFuzzyEnabled.value = true;
44+
searchQuery.value = 'ktln';
4245
searchFolders();
4346

4447
expect(searchResults.value).toHaveLength(1);
45-
expect(searchResults.value[0].title).toBe('Price: $100 (USD)');
48+
expect(searchResults.value[0].folder.title).toBe('kotlin-lang-lambda');
49+
expect(searchResults.value[0].indexes!.length).toBeGreaterThan(0);
4650
});
4751

48-
it('searches within nested folder structures', () => {
49-
const allFolders = ref(createNestedFolders());
50-
const { searchQuery, searchResults, searchFolders } =
51-
useFolderSearch(allFolders);
52+
it('filters out poor matches based on threshold', () => {
53+
const folders: BookmarkFolder[] = [{ id: '1', title: 'xyz', path: '' }];
54+
const { searchQuery, searchResults, searchFolders, isFuzzyEnabled } =
55+
useFolderSearch(ref(folders));
5256

53-
searchQuery.value = 'Fiction';
57+
isFuzzyEnabled.value = true;
58+
searchQuery.value = 'abc';
5459
searchFolders();
5560

56-
expect(searchResults.value.length).toBeGreaterThan(0);
57-
expect(searchResults.value.some((r) => r.title === 'Fiction')).toBe(true);
61+
expect(searchResults.value).toHaveLength(0);
5862
});
5963
});
6064

6165
describe('highlightText', () => {
62-
it('returns unhighlighted text when query is empty', () => {
63-
const allFolders = ref([]);
64-
const { highlightText } = useFolderSearch(allFolders);
65-
66-
const result = highlightText('Sample Text', '');
67-
68-
expect(result).toEqual([{ text: 'Sample Text', highlighted: false }]);
66+
it('highlights by indexes when provided (fuzzy)', () => {
67+
const { highlightText } = useFolderSearch(ref([]));
68+
69+
const result = highlightText('Hello', '', [0, 2, 4]);
70+
71+
expect(result).toEqual([
72+
{ text: 'H', highlighted: true },
73+
{ text: 'e', highlighted: false },
74+
{ text: 'l', highlighted: true },
75+
{ text: 'l', highlighted: false },
76+
{ text: 'o', highlighted: true },
77+
]);
6978
});
7079

71-
it('highlights matching text correctly', () => {
72-
const allFolders = ref([]);
73-
const { highlightText } = useFolderSearch(allFolders);
80+
it('highlights by substring when indexes is null (exact)', () => {
81+
const { highlightText } = useFolderSearch(ref([]));
7482

75-
const result = highlightText('Hello World', 'World');
83+
const result = highlightText('Hello World', 'World', null);
7684

77-
expect(result).toHaveLength(2);
78-
expect(result[0]).toEqual({ text: 'Hello ', highlighted: false });
79-
expect(result[1]).toEqual({ text: 'World', highlighted: true });
85+
expect(result).toEqual([
86+
{ text: 'Hello ', highlighted: false },
87+
{ text: 'World', highlighted: true },
88+
]);
8089
});
8190

82-
it('escapes special regex characters', () => {
83-
const allFolders = ref([]);
84-
const { highlightText } = useFolderSearch(allFolders);
91+
it('groups consecutive highlighted characters', () => {
92+
const { highlightText } = useFolderSearch(ref([]));
8593

86-
const result = highlightText('Cost: $100 (USD)', '$100');
94+
const result = highlightText('Hello', '', [0, 1, 2]);
8795

88-
expect(result).toContainEqual({ text: '$100', highlighted: true });
96+
expect(result).toEqual([
97+
{ text: 'Hel', highlighted: true },
98+
{ text: 'lo', highlighted: false },
99+
]);
89100
});
90101

91-
it('highlights text case-insensitively', () => {
92-
const allFolders = ref([]);
93-
const { highlightText } = useFolderSearch(allFolders);
102+
it('preserves original case in substring mode', () => {
103+
const { highlightText } = useFolderSearch(ref([]));
94104

95-
const result = highlightText('JavaScript Tutorial', 'script');
105+
const result = highlightText('JavaScript', 'script', null);
96106

97-
expect(
98-
result.some((part) => part.highlighted && part.text === 'Script'),
99-
).toBe(true);
107+
expect(result).toContainEqual({ text: 'Script', highlighted: true });
100108
});
101109
});
102110
});

src/composables/useBookmarkFolder.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,38 @@ export interface UseBookmarkFolderReturn {
1616
isLoading: Ref<boolean>;
1717
error: Ref<string | null>;
1818
loadBookmarks: (folderId: string, recursive?: boolean) => Promise<void>;
19+
loadAllBookmarks: () => Promise<void>;
1920
}
2021

22+
const fetchBookmarksFromNode = async (
23+
node: Browser.bookmarks.BookmarkTreeNode,
24+
basePath = '',
25+
): Promise<BookmarkItem[]> => {
26+
const children =
27+
node.children || (await browser.bookmarks.getChildren(node.id));
28+
const results: BookmarkItem[] = [];
29+
30+
for (const child of children) {
31+
if (child.url) {
32+
results.push({
33+
id: child.id,
34+
title: child.title,
35+
url: child.url,
36+
parentId: child.parentId || node.id,
37+
parentPath: basePath,
38+
dateAdded: child.dateAdded,
39+
});
40+
} else if (child.title) {
41+
const subfolderPath = basePath
42+
? `${basePath} > ${child.title}`
43+
: child.title;
44+
const subBookmarks = await fetchBookmarksFromNode(child, subfolderPath);
45+
results.push(...subBookmarks);
46+
}
47+
}
48+
return results;
49+
};
50+
2151
export function useBookmarkFolder(
2252
folderMap: Ref<Map<string, BookmarkFolder>>,
2353
): UseBookmarkFolderReturn {
@@ -102,10 +132,40 @@ export function useBookmarkFolder(
102132
}
103133
};
104134

135+
const loadAllBookmarks = async (): Promise<void> => {
136+
isLoading.value = true;
137+
error.value = null;
138+
139+
try {
140+
const [tree] = await browser.bookmarks.getTree();
141+
const rootFolders = tree.children || [];
142+
const allResults: BookmarkItem[] = [];
143+
144+
for (const rootFolder of rootFolders) {
145+
if (!rootFolder.url && rootFolder.id !== '0') {
146+
const subBookmarks = await fetchBookmarksFromNode(
147+
rootFolder,
148+
rootFolder.title,
149+
);
150+
allResults.push(...subBookmarks);
151+
}
152+
}
153+
154+
bookmarks.value = allResults;
155+
} catch (err) {
156+
error.value = 'errorLoadingBookmarks';
157+
bookmarks.value = [];
158+
throw err;
159+
} finally {
160+
isLoading.value = false;
161+
}
162+
};
163+
105164
return {
106165
bookmarks,
107166
isLoading,
108167
error,
109168
loadBookmarks,
169+
loadAllBookmarks,
110170
};
111171
}

0 commit comments

Comments
 (0)