@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.
- package/README.md +994 -0
- package/dist/server.js +690 -0
- package/dist/src/filesystem.js +763 -0
- package/dist/src/filesystem.test.js +946 -0
- package/dist/src/frontmatter.js +129 -0
- package/dist/src/frontmatter.test.js +118 -0
- package/dist/src/integration.test.js +168 -0
- package/dist/src/pathfilter.js +94 -0
- package/dist/src/pathfilter.test.js +264 -0
- package/dist/src/search.js +182 -0
- package/dist/src/search.test.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/uri.js +22 -0
- package/dist/src/uri.test.js +46 -0
- package/package.json +69 -0
|
@@ -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 {};
|