@bitbonsai/mcpvault 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ import { test, expect, describe } from "vitest";
2
+ import { PathFilter } from "./pathfilter.js";
3
+ describe("PathFilter", () => {
4
+ // ============================================================================
5
+ // BASIC FUNCTIONALITY
6
+ // ============================================================================
7
+ test("allows markdown files by default", () => {
8
+ const filter = new PathFilter();
9
+ expect(filter.isAllowed("notes/test.md")).toBe(true);
10
+ expect(filter.isAllowed("test.markdown")).toBe(true);
11
+ expect(filter.isAllowed("folder/subfolder/note.txt")).toBe(true);
12
+ });
13
+ test("blocks .obsidian directory", () => {
14
+ const filter = new PathFilter();
15
+ expect(filter.isAllowed(".obsidian")).toBe(false);
16
+ expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
17
+ expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
18
+ });
19
+ test("blocks .git directory", () => {
20
+ const filter = new PathFilter();
21
+ expect(filter.isAllowed(".git")).toBe(false);
22
+ expect(filter.isAllowed(".git/config")).toBe(false);
23
+ expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
24
+ });
25
+ test("blocks node_modules", () => {
26
+ const filter = new PathFilter();
27
+ expect(filter.isAllowed("node_modules")).toBe(false);
28
+ expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
29
+ });
30
+ test("blocks system files", () => {
31
+ const filter = new PathFilter();
32
+ expect(filter.isAllowed(".DS_Store")).toBe(false);
33
+ expect(filter.isAllowed("Thumbs.db")).toBe(false);
34
+ });
35
+ test("blocks non-allowed extensions", () => {
36
+ const filter = new PathFilter();
37
+ expect(filter.isAllowed("script.js")).toBe(false);
38
+ expect(filter.isAllowed("data.json")).toBe(false);
39
+ expect(filter.isAllowed("image.png")).toBe(false);
40
+ });
41
+ test("allows non-note files for directory listing", () => {
42
+ const filter = new PathFilter();
43
+ expect(filter.isAllowedForListing("image.png")).toBe(true);
44
+ expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
45
+ expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
46
+ });
47
+ test("blocks restricted paths in directory listing", () => {
48
+ const filter = new PathFilter();
49
+ expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
50
+ expect(filter.isAllowedForListing(".git/config")).toBe(false);
51
+ expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
52
+ expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
53
+ });
54
+ // ============================================================================
55
+ // REGEX SPECIAL CHARACTERS - SECURITY TESTS
56
+ // ============================================================================
57
+ describe("regex special characters in paths", () => {
58
+ test("handles dots in filenames literally", () => {
59
+ const filter = new PathFilter();
60
+ // Dots should be literal, not regex wildcards
61
+ expect(filter.isAllowed("file.name.md")).toBe(true);
62
+ expect(filter.isAllowed("v1.0.0-notes.md")).toBe(true);
63
+ });
64
+ test("handles parentheses in paths", () => {
65
+ const filter = new PathFilter();
66
+ expect(filter.isAllowed("notes/(archived)/old.md")).toBe(true);
67
+ expect(filter.isAllowed("project (copy).md")).toBe(true);
68
+ });
69
+ test("handles square brackets in paths", () => {
70
+ const filter = new PathFilter();
71
+ expect(filter.isAllowed("notes/[2024]/january.md")).toBe(true);
72
+ expect(filter.isAllowed("[inbox]/task.md")).toBe(true);
73
+ });
74
+ test("handles curly braces in paths", () => {
75
+ const filter = new PathFilter();
76
+ expect(filter.isAllowed("templates/{daily}.md")).toBe(true);
77
+ });
78
+ test("handles plus signs in paths", () => {
79
+ const filter = new PathFilter();
80
+ expect(filter.isAllowed("C++/notes.md")).toBe(true);
81
+ expect(filter.isAllowed("topic+subtopic.md")).toBe(true);
82
+ });
83
+ test("handles question marks in paths", () => {
84
+ const filter = new PathFilter();
85
+ // Question mark is a glob wildcard, but in actual filenames should work
86
+ expect(filter.isAllowed("FAQ?.md")).toBe(true);
87
+ });
88
+ test("handles asterisks in filenames", () => {
89
+ const filter = new PathFilter();
90
+ // Asterisk in filename (rare but valid on Unix)
91
+ expect(filter.isAllowed("important*.md")).toBe(true);
92
+ expect(filter.isAllowed("file*name.md")).toBe(true);
93
+ expect(filter.isAllowed("notes/todo*.md")).toBe(true);
94
+ });
95
+ test("asterisk in custom ignored pattern works as glob", () => {
96
+ const filter = new PathFilter({
97
+ ignoredPatterns: ["temp*/**"]
98
+ });
99
+ // Pattern uses * as wildcard - should match temp, temp1, temporary, etc.
100
+ expect(filter.isAllowed("temp/file.md")).toBe(false);
101
+ expect(filter.isAllowed("temp1/file.md")).toBe(false);
102
+ expect(filter.isAllowed("temporary/file.md")).toBe(false);
103
+ // Should NOT match "atemp" (pattern starts with temp)
104
+ expect(filter.isAllowed("atemp/file.md")).toBe(true);
105
+ });
106
+ test("double asterisk ** matches nested paths", () => {
107
+ const filter = new PathFilter({
108
+ ignoredPatterns: ["archive/**"]
109
+ });
110
+ expect(filter.isAllowed("archive/old.md")).toBe(false);
111
+ expect(filter.isAllowed("archive/2024/jan/note.md")).toBe(false);
112
+ expect(filter.isAllowed("other/archive/note.md")).toBe(true);
113
+ });
114
+ test("handles pipe character in paths", () => {
115
+ const filter = new PathFilter();
116
+ expect(filter.isAllowed("option|choice.md")).toBe(true);
117
+ });
118
+ test("handles caret in paths", () => {
119
+ const filter = new PathFilter();
120
+ expect(filter.isAllowed("version^2.md")).toBe(true);
121
+ });
122
+ test("handles dollar sign in paths", () => {
123
+ const filter = new PathFilter();
124
+ expect(filter.isAllowed("price$100.md")).toBe(true);
125
+ expect(filter.isAllowed("$HOME/notes.md")).toBe(true);
126
+ });
127
+ test("handles backslash (Windows paths)", () => {
128
+ const filter = new PathFilter();
129
+ // Backslashes should be normalized to forward slashes
130
+ expect(filter.isAllowed("folder\\subfolder\\note.md")).toBe(true);
131
+ });
132
+ });
133
+ // ============================================================================
134
+ // CUSTOM IGNORED PATTERNS WITH SPECIAL CHARS
135
+ // ============================================================================
136
+ describe("custom patterns with special characters", () => {
137
+ test("custom pattern with dots is treated literally", () => {
138
+ const filter = new PathFilter({
139
+ ignoredPatterns: ["backup.2024/**"]
140
+ });
141
+ expect(filter.isAllowed("backup.2024/notes.md")).toBe(false);
142
+ // "backup_2024" should NOT match "backup.2024" pattern
143
+ expect(filter.isAllowed("backup_2024/notes.md")).toBe(true);
144
+ });
145
+ test("custom pattern with parentheses works", () => {
146
+ const filter = new PathFilter({
147
+ ignoredPatterns: ["(archive)/**"]
148
+ });
149
+ expect(filter.isAllowed("(archive)/old.md")).toBe(false);
150
+ expect(filter.isAllowed("archive/old.md")).toBe(true);
151
+ });
152
+ test("custom pattern with brackets works", () => {
153
+ const filter = new PathFilter({
154
+ ignoredPatterns: ["[trash]/**"]
155
+ });
156
+ expect(filter.isAllowed("[trash]/deleted.md")).toBe(false);
157
+ expect(filter.isAllowed("trash/deleted.md")).toBe(true);
158
+ });
159
+ });
160
+ // ============================================================================
161
+ // PATH TRAVERSAL ATTEMPTS
162
+ // ============================================================================
163
+ describe("path traversal prevention", () => {
164
+ test("blocks obvious traversal patterns", () => {
165
+ const filter = new PathFilter({
166
+ ignoredPatterns: ["../**"]
167
+ });
168
+ expect(filter.isAllowed("../secret.md")).toBe(false);
169
+ expect(filter.isAllowed("../../etc/passwd")).toBe(false);
170
+ });
171
+ test("handles encoded traversal attempts", () => {
172
+ const filter = new PathFilter();
173
+ // These should be allowed by PathFilter (path validation is in FileSystem)
174
+ // but filter shouldn't crash on unusual characters
175
+ expect(() => filter.isAllowed("%2e%2e/secret.md")).not.toThrow();
176
+ expect(() => filter.isAllowed("..%2fnotes.md")).not.toThrow();
177
+ });
178
+ });
179
+ test("allows Obsidian first-party file types", () => {
180
+ const filter = new PathFilter();
181
+ expect(filter.isAllowed("_Bases/daily-notes.base")).toBe(true);
182
+ expect(filter.isAllowed("canvas/mindmap.canvas")).toBe(true);
183
+ });
184
+ // ============================================================================
185
+ // FILTER PATHS BATCH OPERATION
186
+ // ============================================================================
187
+ describe("filterPaths", () => {
188
+ test("filters array of paths correctly", () => {
189
+ const filter = new PathFilter();
190
+ const paths = [
191
+ "notes/valid.md",
192
+ ".obsidian/config.json",
193
+ "archive/old.md",
194
+ ".git/HEAD",
195
+ "readme.txt"
196
+ ];
197
+ const allowed = filter.filterPaths(paths);
198
+ expect(allowed).toEqual([
199
+ "notes/valid.md",
200
+ "archive/old.md",
201
+ "readme.txt"
202
+ ]);
203
+ });
204
+ test("handles empty array", () => {
205
+ const filter = new PathFilter();
206
+ expect(filter.filterPaths([])).toEqual([]);
207
+ });
208
+ test("handles array with all blocked paths", () => {
209
+ const filter = new PathFilter();
210
+ const paths = [
211
+ ".obsidian/app.json",
212
+ ".git/config",
213
+ "node_modules/pkg/index.js"
214
+ ];
215
+ expect(filter.filterPaths(paths)).toEqual([]);
216
+ });
217
+ });
218
+ // ============================================================================
219
+ // EDGE CASES
220
+ // ============================================================================
221
+ describe("edge cases", () => {
222
+ test("handles empty path", () => {
223
+ const filter = new PathFilter();
224
+ expect(() => filter.isAllowed("")).not.toThrow();
225
+ });
226
+ test("handles path with only extension", () => {
227
+ const filter = new PathFilter();
228
+ expect(filter.isAllowed(".md")).toBe(true);
229
+ });
230
+ test("handles very long paths", () => {
231
+ const filter = new PathFilter();
232
+ const longPath = "a/".repeat(100) + "note.md";
233
+ expect(() => filter.isAllowed(longPath)).not.toThrow();
234
+ expect(filter.isAllowed(longPath)).toBe(true);
235
+ });
236
+ test("handles unicode characters in paths", () => {
237
+ const filter = new PathFilter();
238
+ expect(filter.isAllowed("notes/日本語.md")).toBe(true);
239
+ expect(filter.isAllowed("émojis/🎉.md")).toBe(true);
240
+ expect(filter.isAllowed("中文/笔记.md")).toBe(true);
241
+ });
242
+ test("handles spaces in paths", () => {
243
+ const filter = new PathFilter();
244
+ expect(filter.isAllowed("my notes/important file.md")).toBe(true);
245
+ });
246
+ test("handles directories (no extension)", () => {
247
+ const filter = new PathFilter();
248
+ // Directories should be allowed (no extension check)
249
+ expect(filter.isAllowed("folder/subfolder/")).toBe(true);
250
+ expect(filter.isAllowed("notes")).toBe(true);
251
+ });
252
+ test("handles directories with dots in their names", () => {
253
+ const filter = new PathFilter();
254
+ // Folders with dots should be allowed (common pattern: "1. Project", "2.5 Notes")
255
+ expect(filter.isAllowed("1. Project")).toBe(true);
256
+ expect(filter.isAllowed("2. Archive")).toBe(true);
257
+ expect(filter.isAllowed("3.5 Research")).toBe(true);
258
+ expect(filter.isAllowed("1. Project/subfolder")).toBe(true);
259
+ expect(filter.isAllowed("1. Project/note.md")).toBe(true);
260
+ // But files in those folders should still need proper extensions
261
+ expect(filter.isAllowed("1. Project/file.js")).toBe(false);
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,182 @@
1
+ import { join, resolve } from 'path';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { generateObsidianUri } from './uri.js';
4
+ export class SearchService {
5
+ pathFilter;
6
+ vaultPath;
7
+ constructor(vaultPath, pathFilter) {
8
+ this.pathFilter = pathFilter;
9
+ this.vaultPath = resolve(vaultPath);
10
+ }
11
+ async search(params) {
12
+ const { query, limit = 5, searchContent = true, searchFrontmatter = false, caseSensitive = false } = params;
13
+ if (!query || query.trim().length === 0) {
14
+ throw new Error('Search query cannot be empty');
15
+ }
16
+ const maxLimit = Math.min(limit, 20);
17
+ // Corpus stats for reranking
18
+ let totalDocLength = 0;
19
+ let docCount = 0;
20
+ const termDocFreq = new Map();
21
+ const candidates = [];
22
+ const searchQuery = caseSensitive ? query : query.toLowerCase();
23
+ const terms = searchQuery.split(/\s+/).filter(t => t.length > 0);
24
+ const scoringTerms = terms.length > 1 ? [...terms, searchQuery] : terms;
25
+ // Recursively find all .md files
26
+ const markdownFiles = await this.findMarkdownFiles(this.vaultPath);
27
+ // Pre-filter by pathFilter before I/O
28
+ const prefixLen = this.vaultPath.length + 1;
29
+ const allowedFiles = [];
30
+ for (const fullPath of markdownFiles) {
31
+ const relativePath = fullPath.substring(prefixLen).replace(/\\/g, '/');
32
+ if (this.pathFilter.isAllowed(relativePath)) {
33
+ allowedFiles.push({ fullPath, relativePath });
34
+ }
35
+ }
36
+ // Read files in parallel batches
37
+ const BATCH_SIZE = 5;
38
+ for (let start = 0; start < allowedFiles.length; start += BATCH_SIZE) {
39
+ const batch = allowedFiles.slice(start, start + BATCH_SIZE);
40
+ const contents = await Promise.all(batch.map(f => readFile(f.fullPath, 'utf-8').catch(() => null)));
41
+ for (let i = 0; i < batch.length; i++) {
42
+ const content = contents[i];
43
+ if (content === null || content === undefined)
44
+ continue;
45
+ const { relativePath } = batch[i];
46
+ let searchableText = '';
47
+ // Prepare search text based on options
48
+ if (searchContent && searchFrontmatter) {
49
+ searchableText = content;
50
+ }
51
+ else if (searchContent) {
52
+ // Remove frontmatter from search
53
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
54
+ searchableText = frontmatterMatch ? content.slice(frontmatterMatch[0].length) : content;
55
+ }
56
+ else if (searchFrontmatter) {
57
+ // Search only frontmatter
58
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
59
+ searchableText = frontmatterMatch ? frontmatterMatch[1] || '' : '';
60
+ }
61
+ const searchIn = caseSensitive ? searchableText : searchableText.toLowerCase();
62
+ // Collect corpus stats for reranking
63
+ const docLength = searchIn.split(/\s+/).filter(w => w.length > 0).length;
64
+ totalDocLength += docLength;
65
+ docCount++;
66
+ for (const term of scoringTerms) {
67
+ if (searchIn.includes(term)) {
68
+ termDocFreq.set(term, (termDocFreq.get(term) || 0) + 1);
69
+ }
70
+ }
71
+ // Extract title from filename
72
+ const title = relativePath.split('/').pop()?.replace(/\.md$/, '') || relativePath;
73
+ // Check filename match (any term)
74
+ const filenameToSearch = caseSensitive ? title : title.toLowerCase();
75
+ const filenameMatch = terms.some(term => filenameToSearch.includes(term));
76
+ // Check content match (any term)
77
+ const termIndices = terms.map(term => searchIn.indexOf(term));
78
+ const anyTermFound = termIndices.some(idx => idx !== -1);
79
+ const firstIndex = anyTermFound
80
+ ? Math.min(...termIndices.filter(idx => idx !== -1))
81
+ : -1;
82
+ if (firstIndex !== -1 || filenameMatch) {
83
+ let excerpt;
84
+ let matchCount = 0;
85
+ let lineNumber = 0;
86
+ const termFreqs = new Map();
87
+ if (firstIndex !== -1) {
88
+ // Find the term that matched first for excerpt
89
+ const firstTermIdx = termIndices.indexOf(firstIndex);
90
+ const firstTerm = terms[firstTermIdx];
91
+ // Extract excerpt around first content match
92
+ const excerptStart = Math.max(0, firstIndex - 21);
93
+ const excerptEnd = Math.min(searchableText.length, firstIndex + firstTerm.length + 21);
94
+ excerpt = searchableText.slice(excerptStart, excerptEnd).trim();
95
+ // Add ellipsis if excerpt is truncated
96
+ if (excerptStart > 0)
97
+ excerpt = '...' + excerpt;
98
+ if (excerptEnd < searchableText.length)
99
+ excerpt = excerpt + '...';
100
+ // Count total content matches across all terms
101
+ for (const term of scoringTerms) {
102
+ let count = 0;
103
+ let searchIndex = 0;
104
+ while ((searchIndex = searchIn.indexOf(term, searchIndex)) !== -1) {
105
+ count++;
106
+ searchIndex += term.length;
107
+ }
108
+ termFreqs.set(term, count);
109
+ matchCount += count;
110
+ }
111
+ // Find line number of first match
112
+ const lines = searchableText.slice(0, firstIndex).split('\n');
113
+ lineNumber = lines.length;
114
+ }
115
+ else {
116
+ // Filename-only match: use beginning of content as excerpt
117
+ excerpt = searchableText.slice(0, 50).trim();
118
+ if (searchableText.length > 50)
119
+ excerpt = excerpt + '...';
120
+ matchCount = 0;
121
+ lineNumber = 0;
122
+ }
123
+ // Add filename match to count
124
+ if (filenameMatch)
125
+ matchCount++;
126
+ candidates.push({
127
+ result: {
128
+ p: relativePath,
129
+ t: title,
130
+ ex: excerpt,
131
+ mc: matchCount,
132
+ ln: lineNumber,
133
+ uri: generateObsidianUri(this.vaultPath, relativePath)
134
+ },
135
+ termFreqs,
136
+ docLength
137
+ });
138
+ }
139
+ }
140
+ }
141
+ const results = this.rerank(candidates, scoringTerms, termDocFreq, docCount, totalDocLength, maxLimit);
142
+ return results;
143
+ }
144
+ async findMarkdownFiles(dirPath) {
145
+ const markdownFiles = [];
146
+ try {
147
+ const entries = await readdir(dirPath, { withFileTypes: true });
148
+ for (const entry of entries) {
149
+ const fullPath = join(dirPath, entry.name);
150
+ if (entry.isDirectory()) {
151
+ // Recursively search subdirectories
152
+ const subFiles = await this.findMarkdownFiles(fullPath);
153
+ markdownFiles.push(...subFiles);
154
+ }
155
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
156
+ markdownFiles.push(fullPath);
157
+ }
158
+ }
159
+ }
160
+ catch (error) {
161
+ // Skip directories that can't be read
162
+ }
163
+ return markdownFiles;
164
+ }
165
+ rerank(candidates, terms, termDocFreq, docCount, totalDocLength, maxLimit) {
166
+ const avgdl = docCount > 0 ? totalDocLength / docCount : 1;
167
+ const k1 = 1.2;
168
+ const b = 0.75;
169
+ const scored = candidates.map(c => {
170
+ let score = 0;
171
+ for (const term of terms) {
172
+ const tf = c.termFreqs.get(term) || 0;
173
+ const df = termDocFreq.get(term) || 0;
174
+ const idf = Math.log(1 + (docCount - df + 0.5) / (df + 0.5));
175
+ score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * c.docLength / avgdl));
176
+ }
177
+ return { score, result: c.result };
178
+ });
179
+ scored.sort((a, b) => b.score - a.score);
180
+ return scored.slice(0, maxLimit).map(s => s.result);
181
+ }
182
+ }
@@ -0,0 +1,212 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { SearchService } from "./search.js";
3
+ import { PathFilter } from "./pathfilter.js";
4
+ import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
5
+ import { join } from "path";
6
+ import { tmpdir } from "os";
7
+ let testVaultPath;
8
+ let searchService;
9
+ beforeEach(async () => {
10
+ testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-search-"));
11
+ searchService = new SearchService(testVaultPath, new PathFilter());
12
+ });
13
+ afterEach(async () => {
14
+ try {
15
+ await rm(testVaultPath, { recursive: true });
16
+ }
17
+ catch {
18
+ // Ignore cleanup errors
19
+ }
20
+ });
21
+ // Helper to write a note directly to disk
22
+ async function writeNote(path, content) {
23
+ const fullPath = join(testVaultPath, path);
24
+ const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
25
+ if (dir !== testVaultPath) {
26
+ await mkdir(dir, { recursive: true });
27
+ }
28
+ await writeFile(fullPath, content);
29
+ }
30
+ describe("SearchService", () => {
31
+ // ============================================================================
32
+ // BASIC SEARCH
33
+ // ============================================================================
34
+ test("finds notes matching a query", async () => {
35
+ await writeNote("alpha.md", "# Alpha\n\nThis note has bananas.");
36
+ await writeNote("beta.md", "# Beta\n\nThis note has oranges.");
37
+ const results = await searchService.search({ query: "bananas" });
38
+ expect(results).toHaveLength(1);
39
+ expect(results[0].p).toBe("alpha.md");
40
+ });
41
+ test("returns empty array when no matches", async () => {
42
+ await writeNote("note.md", "# Note\n\nNothing relevant here.");
43
+ const results = await searchService.search({ query: "zzzznotfound" });
44
+ expect(results).toHaveLength(0);
45
+ });
46
+ test("returns empty array for empty vault", async () => {
47
+ const results = await searchService.search({ query: "anything" });
48
+ expect(results).toHaveLength(0);
49
+ });
50
+ test("throws on empty query", async () => {
51
+ await expect(searchService.search({ query: "" }))
52
+ .rejects.toThrow(/empty/);
53
+ });
54
+ test("throws on whitespace-only query", async () => {
55
+ await expect(searchService.search({ query: " " }))
56
+ .rejects.toThrow(/empty/);
57
+ });
58
+ // ============================================================================
59
+ // LIMIT
60
+ // ============================================================================
61
+ test("respects limit parameter", async () => {
62
+ for (let i = 0; i < 5; i++) {
63
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
64
+ }
65
+ const results = await searchService.search({ query: "keyword", limit: 2 });
66
+ expect(results).toHaveLength(2);
67
+ });
68
+ test("caps limit at 20", async () => {
69
+ for (let i = 0; i < 25; i++) {
70
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
71
+ }
72
+ const results = await searchService.search({ query: "keyword", limit: 100 });
73
+ expect(results.length).toBeLessThanOrEqual(20);
74
+ });
75
+ test("defaults limit to 5", async () => {
76
+ for (let i = 0; i < 10; i++) {
77
+ await writeNote(`note-${i}.md`, `# Note ${i}\n\nkeyword here.`);
78
+ }
79
+ const results = await searchService.search({ query: "keyword" });
80
+ expect(results).toHaveLength(5);
81
+ });
82
+ // ============================================================================
83
+ // CASE SENSITIVITY
84
+ // ============================================================================
85
+ test("case-insensitive search by default", async () => {
86
+ await writeNote("upper.md", "# Upper\n\nBANANA is great.");
87
+ await writeNote("lower.md", "# Lower\n\nbanana is great.");
88
+ await writeNote("mixed.md", "# Mixed\n\nBanana is great.");
89
+ const results = await searchService.search({ query: "banana", limit: 10 });
90
+ expect(results).toHaveLength(3);
91
+ });
92
+ test("case-sensitive search when enabled", async () => {
93
+ await writeNote("upper.md", "# Upper\n\nBANANA is great.");
94
+ await writeNote("lower.md", "# Lower\n\nbanana is great.");
95
+ const results = await searchService.search({
96
+ query: "BANANA",
97
+ caseSensitive: true,
98
+ limit: 10
99
+ });
100
+ expect(results).toHaveLength(1);
101
+ expect(results[0].p).toBe("upper.md");
102
+ });
103
+ // ============================================================================
104
+ // FRONTMATTER SEARCH
105
+ // ============================================================================
106
+ test("excludes frontmatter from content-only search", async () => {
107
+ await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
108
+ const results = await searchService.search({
109
+ query: "uniquetag",
110
+ searchContent: true,
111
+ searchFrontmatter: false,
112
+ limit: 10
113
+ });
114
+ expect(results).toHaveLength(0);
115
+ });
116
+ test("searches frontmatter when enabled", async () => {
117
+ await writeNote("note.md", "---\ntags: [uniquetag]\n---\n\n# Note\n\nNo tag here.");
118
+ const results = await searchService.search({
119
+ query: "uniquetag",
120
+ searchFrontmatter: true,
121
+ limit: 10
122
+ });
123
+ expect(results).toHaveLength(1);
124
+ expect(results[0].p).toBe("note.md");
125
+ });
126
+ test("searches both content and frontmatter together", async () => {
127
+ await writeNote("fm-only.md", "---\nstatus: special\n---\n\n# Note\n\nPlain body.");
128
+ await writeNote("content-only.md", "# Note\n\nThis is special content.");
129
+ const results = await searchService.search({
130
+ query: "special",
131
+ searchContent: true,
132
+ searchFrontmatter: true,
133
+ limit: 10
134
+ });
135
+ expect(results).toHaveLength(2);
136
+ });
137
+ // ============================================================================
138
+ // FILENAME MATCHING
139
+ // ============================================================================
140
+ test("matches by filename when content has no match", async () => {
141
+ await writeNote("Recipes.md", "Some unrelated content about cooking.");
142
+ const results = await searchService.search({ query: "recipes", limit: 10 });
143
+ expect(results).toHaveLength(1);
144
+ expect(results[0].p).toBe("Recipes.md");
145
+ expect(results[0].t).toBe("Recipes");
146
+ });
147
+ // ============================================================================
148
+ // MULTI-TERM SEARCH
149
+ // ============================================================================
150
+ test("multi-term search matches notes with any term", async () => {
151
+ await writeNote("cats.md", "# Cats\n\nI love cats.");
152
+ await writeNote("dogs.md", "# Dogs\n\nI love dogs.");
153
+ await writeNote("fish.md", "# Fish\n\nI love fish.");
154
+ const results = await searchService.search({ query: "cats dogs", limit: 10 });
155
+ const paths = results.map(r => r.p);
156
+ expect(paths).toContain("cats.md");
157
+ expect(paths).toContain("dogs.md");
158
+ expect(paths).not.toContain("fish.md");
159
+ });
160
+ // ============================================================================
161
+ // RANKING
162
+ // ============================================================================
163
+ test("ranks notes with more matches higher", async () => {
164
+ await writeNote("few.md", "# Few\n\napple once.");
165
+ await writeNote("many.md", "# Many\n\napple apple apple apple apple.");
166
+ const results = await searchService.search({ query: "apple", limit: 10 });
167
+ expect(results).toHaveLength(2);
168
+ expect(results[0].p).toBe("many.md");
169
+ });
170
+ // ============================================================================
171
+ // RESULT SHAPE
172
+ // ============================================================================
173
+ test("results include expected fields", async () => {
174
+ await writeNote("folder/note.md", "# My Note\n\nSome content with target word.");
175
+ const results = await searchService.search({ query: "target", limit: 10 });
176
+ expect(results).toHaveLength(1);
177
+ const r = results[0];
178
+ expect(r.p).toBe("folder/note.md");
179
+ expect(r.t).toBe("note");
180
+ expect(r.ex).toBeDefined();
181
+ expect(r.mc).toBeGreaterThanOrEqual(1);
182
+ expect(r.ln).toBeGreaterThanOrEqual(1);
183
+ expect(r.uri).toMatch(/^obsidian:\/\//);
184
+ });
185
+ test("excerpt contains context around match", async () => {
186
+ await writeNote("note.md", "# Note\n\nSome words before target some words after.");
187
+ const results = await searchService.search({ query: "target", limit: 10 });
188
+ expect(results[0].ex).toContain("target");
189
+ });
190
+ // ============================================================================
191
+ // PATH FILTERING
192
+ // ============================================================================
193
+ test("excludes notes in filtered directories", async () => {
194
+ await writeNote("visible.md", "# Visible\n\nkeyword here.");
195
+ await mkdir(join(testVaultPath, ".obsidian"), { recursive: true });
196
+ await writeFile(join(testVaultPath, ".obsidian/config.md"), "keyword here.");
197
+ const results = await searchService.search({ query: "keyword", limit: 10 });
198
+ expect(results).toHaveLength(1);
199
+ expect(results[0].p).toBe("visible.md");
200
+ });
201
+ // ============================================================================
202
+ // TRAILING SLASH IN VAULT PATH
203
+ // ============================================================================
204
+ test("vault path with trailing slash does not truncate result paths", async () => {
205
+ const trailingSlashService = new SearchService(testVaultPath + "/", new PathFilter());
206
+ await mkdir(join(testVaultPath, "sessions"), { recursive: true });
207
+ await writeNote("sessions/foo-bar.md", "# Foo Bar\n\nSome content here.");
208
+ const results = await trailingSlashService.search({ query: "foo", limit: 5 });
209
+ expect(results).toHaveLength(1);
210
+ expect(results[0].p).toBe("sessions/foo-bar.md");
211
+ });
212
+ });
@@ -0,0 +1 @@
1
+ export {};