@disco_trooper/apple-notes-mcp 1.0.0
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/CLAUDE.md +56 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +61 -0
- package/src/config/constants.ts +41 -0
- package/src/config/env.test.ts +58 -0
- package/src/config/env.ts +25 -0
- package/src/db/lancedb.test.ts +141 -0
- package/src/db/lancedb.ts +263 -0
- package/src/db/validation.test.ts +76 -0
- package/src/db/validation.ts +57 -0
- package/src/embeddings/index.test.ts +54 -0
- package/src/embeddings/index.ts +111 -0
- package/src/embeddings/local.test.ts +70 -0
- package/src/embeddings/local.ts +191 -0
- package/src/embeddings/openrouter.test.ts +21 -0
- package/src/embeddings/openrouter.ts +285 -0
- package/src/index.ts +387 -0
- package/src/notes/crud.test.ts +199 -0
- package/src/notes/crud.ts +257 -0
- package/src/notes/read.test.ts +131 -0
- package/src/notes/read.ts +504 -0
- package/src/search/index.test.ts +52 -0
- package/src/search/index.ts +283 -0
- package/src/search/indexer.test.ts +42 -0
- package/src/search/indexer.ts +335 -0
- package/src/server.ts +386 -0
- package/src/setup.ts +540 -0
- package/src/types/index.ts +39 -0
- package/src/utils/debug.test.ts +41 -0
- package/src/utils/debug.ts +51 -0
- package/src/utils/errors.test.ts +29 -0
- package/src/utils/errors.ts +46 -0
- package/src/utils/text.ts +23 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple Notes read operations using JXA (JavaScript for Automation)
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions to read notes, folders, and note metadata
|
|
5
|
+
* from Apple Notes using macOS automation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runJxa } from "run-jxa";
|
|
9
|
+
import TurndownService from "turndown";
|
|
10
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
11
|
+
|
|
12
|
+
// Debug logging
|
|
13
|
+
const debug = createDebugLogger("NOTES");
|
|
14
|
+
|
|
15
|
+
// Initialize Turndown for HTML to Markdown conversion
|
|
16
|
+
const turndownService = new TurndownService({
|
|
17
|
+
headingStyle: "atx",
|
|
18
|
+
codeBlockStyle: "fenced",
|
|
19
|
+
bulletListMarker: "-",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Add custom rule to handle Apple Notes attachment placeholders
|
|
23
|
+
turndownService.addRule("attachments", {
|
|
24
|
+
filter: (node) => {
|
|
25
|
+
// Apple Notes uses object tags for attachments
|
|
26
|
+
return node.nodeName === "OBJECT" || node.nodeName === "IMG";
|
|
27
|
+
},
|
|
28
|
+
replacement: (_content, node) => {
|
|
29
|
+
// Turndown uses its own Node type, cast to access attributes
|
|
30
|
+
const element = node as unknown as {
|
|
31
|
+
getAttribute: (name: string) => string | null;
|
|
32
|
+
};
|
|
33
|
+
const filename =
|
|
34
|
+
element.getAttribute("data-filename") ||
|
|
35
|
+
element.getAttribute("alt") ||
|
|
36
|
+
element.getAttribute("src")?.split("/").pop() ||
|
|
37
|
+
"unknown";
|
|
38
|
+
return `[Attachment: ${filename}]`;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// -----------------------------------------------------------------------------
|
|
43
|
+
// Types
|
|
44
|
+
// -----------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface NoteInfo {
|
|
47
|
+
/** Note title */
|
|
48
|
+
title: string;
|
|
49
|
+
/** Folder name containing the note */
|
|
50
|
+
folder: string;
|
|
51
|
+
/** Creation date as ISO string */
|
|
52
|
+
created: string;
|
|
53
|
+
/** Last modification date as ISO string */
|
|
54
|
+
modified: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NoteDetails extends NoteInfo {
|
|
58
|
+
/** Note content as Markdown */
|
|
59
|
+
content: string;
|
|
60
|
+
/** Original HTML content from Apple Notes */
|
|
61
|
+
htmlContent: string;
|
|
62
|
+
/** Note ID for internal reference */
|
|
63
|
+
id: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedNote {
|
|
67
|
+
/** Whether resolution was successful */
|
|
68
|
+
success: boolean;
|
|
69
|
+
/** The matched note (if exactly one match) */
|
|
70
|
+
note?: {
|
|
71
|
+
title: string;
|
|
72
|
+
folder: string;
|
|
73
|
+
id: string;
|
|
74
|
+
};
|
|
75
|
+
/** Error message if resolution failed */
|
|
76
|
+
error?: string;
|
|
77
|
+
/** Suggestions when multiple matches found */
|
|
78
|
+
suggestions?: string[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// -----------------------------------------------------------------------------
|
|
82
|
+
// Internal JXA helpers
|
|
83
|
+
// -----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Execute JXA code safely with error handling
|
|
87
|
+
*/
|
|
88
|
+
async function executeJxa<T>(code: string): Promise<T> {
|
|
89
|
+
try {
|
|
90
|
+
const result = await runJxa(code);
|
|
91
|
+
return result as T;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
debug("JXA execution error:", error);
|
|
94
|
+
throw new Error(
|
|
95
|
+
`JXA execution failed: ${error instanceof Error ? error.message : String(error)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convert HTML content to Markdown, handling Apple Notes specifics
|
|
102
|
+
*/
|
|
103
|
+
function htmlToMarkdown(html: string): string {
|
|
104
|
+
if (!html) return "";
|
|
105
|
+
|
|
106
|
+
// Pre-process: handle Apple Notes specific markup
|
|
107
|
+
let processed = html;
|
|
108
|
+
|
|
109
|
+
// Replace attachment objects with placeholder text before Turndown
|
|
110
|
+
processed = processed.replace(
|
|
111
|
+
/<object[^>]*data-filename="([^"]*)"[^>]*>.*?<\/object>/gi,
|
|
112
|
+
"[Attachment: $1]"
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Handle inline images
|
|
116
|
+
processed = processed.replace(
|
|
117
|
+
/<img[^>]*(?:alt="([^"]*)")?[^>]*>/gi,
|
|
118
|
+
(_match, alt) => {
|
|
119
|
+
const filename = alt || "image";
|
|
120
|
+
return `[Attachment: ${filename}]`;
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Convert to Markdown
|
|
125
|
+
const markdown = turndownService.turndown(processed);
|
|
126
|
+
|
|
127
|
+
return markdown.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// -----------------------------------------------------------------------------
|
|
131
|
+
// Public API
|
|
132
|
+
// -----------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all notes from Apple Notes with metadata
|
|
136
|
+
*
|
|
137
|
+
* @returns Array of note metadata objects
|
|
138
|
+
*/
|
|
139
|
+
export async function getAllNotes(): Promise<NoteInfo[]> {
|
|
140
|
+
debug("Getting all notes...");
|
|
141
|
+
|
|
142
|
+
const jxaCode = `
|
|
143
|
+
const app = Application('Notes');
|
|
144
|
+
app.includeStandardAdditions = true;
|
|
145
|
+
|
|
146
|
+
const allNotes = [];
|
|
147
|
+
const folders = app.folders();
|
|
148
|
+
|
|
149
|
+
for (const folder of folders) {
|
|
150
|
+
const folderName = folder.name();
|
|
151
|
+
const notes = folder.notes();
|
|
152
|
+
|
|
153
|
+
for (const note of notes) {
|
|
154
|
+
try {
|
|
155
|
+
const props = note.properties();
|
|
156
|
+
allNotes.push({
|
|
157
|
+
title: props.name || '',
|
|
158
|
+
folder: folderName,
|
|
159
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
160
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : ''
|
|
161
|
+
});
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Skip notes that can't be accessed
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return JSON.stringify(allNotes);
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
const result = await executeJxa<string>(jxaCode);
|
|
172
|
+
const notes = JSON.parse(result) as NoteInfo[];
|
|
173
|
+
|
|
174
|
+
debug(`Found ${notes.length} notes`);
|
|
175
|
+
return notes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get a note by its title, with full content
|
|
180
|
+
*
|
|
181
|
+
* @param title - The note title (can be "folder/title" format for disambiguation)
|
|
182
|
+
* @returns Note details with content, or null if not found
|
|
183
|
+
*/
|
|
184
|
+
export async function getNoteByTitle(
|
|
185
|
+
title: string
|
|
186
|
+
): Promise<NoteDetails | null> {
|
|
187
|
+
debug(`Getting note by title: ${title}`);
|
|
188
|
+
|
|
189
|
+
// Check for folder/title format
|
|
190
|
+
let targetFolder: string | null = null;
|
|
191
|
+
let targetTitle = title;
|
|
192
|
+
|
|
193
|
+
if (title.includes("/")) {
|
|
194
|
+
const parts = title.split("/");
|
|
195
|
+
targetFolder = parts.slice(0, -1).join("/");
|
|
196
|
+
targetTitle = parts[parts.length - 1];
|
|
197
|
+
debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const escapedTitle = JSON.stringify(targetTitle);
|
|
201
|
+
const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
|
|
202
|
+
|
|
203
|
+
const jxaCode = `
|
|
204
|
+
const app = Application('Notes');
|
|
205
|
+
app.includeStandardAdditions = true;
|
|
206
|
+
|
|
207
|
+
const targetTitle = ${escapedTitle};
|
|
208
|
+
const targetFolder = ${escapedFolder};
|
|
209
|
+
|
|
210
|
+
let foundNotes = [];
|
|
211
|
+
const folders = app.folders();
|
|
212
|
+
|
|
213
|
+
for (const folder of folders) {
|
|
214
|
+
const folderName = folder.name();
|
|
215
|
+
|
|
216
|
+
// Skip if folder filter is specified and doesn't match
|
|
217
|
+
if (targetFolder !== null && folderName !== targetFolder) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const notes = folder.notes.whose({ name: targetTitle });
|
|
222
|
+
|
|
223
|
+
for (let i = 0; i < notes.length; i++) {
|
|
224
|
+
try {
|
|
225
|
+
const note = notes[i];
|
|
226
|
+
const props = note.properties();
|
|
227
|
+
foundNotes.push({
|
|
228
|
+
id: note.id(),
|
|
229
|
+
title: props.name || '',
|
|
230
|
+
folder: folderName,
|
|
231
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
232
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : '',
|
|
233
|
+
htmlContent: note.body()
|
|
234
|
+
});
|
|
235
|
+
} catch (e) {
|
|
236
|
+
// Skip notes that can't be accessed
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return JSON.stringify(foundNotes);
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
const result = await executeJxa<string>(jxaCode);
|
|
245
|
+
const notes = JSON.parse(result) as Array<{
|
|
246
|
+
id: string;
|
|
247
|
+
title: string;
|
|
248
|
+
folder: string;
|
|
249
|
+
created: string;
|
|
250
|
+
modified: string;
|
|
251
|
+
htmlContent: string;
|
|
252
|
+
}>;
|
|
253
|
+
|
|
254
|
+
if (notes.length === 0) {
|
|
255
|
+
debug("Note not found");
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (notes.length > 1) {
|
|
260
|
+
debug(`Multiple notes found with title: ${targetTitle}`);
|
|
261
|
+
// If folder wasn't specified and multiple exist, return the first one
|
|
262
|
+
// but log a warning
|
|
263
|
+
debug(
|
|
264
|
+
"Returning first match. Use folder/title format for disambiguation."
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const note = notes[0];
|
|
269
|
+
const content = htmlToMarkdown(note.htmlContent);
|
|
270
|
+
|
|
271
|
+
debug(`Found note in folder: ${note.folder}`);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
id: note.id,
|
|
275
|
+
title: note.title,
|
|
276
|
+
folder: note.folder,
|
|
277
|
+
created: note.created,
|
|
278
|
+
modified: note.modified,
|
|
279
|
+
content,
|
|
280
|
+
htmlContent: note.htmlContent,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get a note by explicit folder and title (no "/" parsing).
|
|
286
|
+
* Use this when you have folder and title separately to avoid
|
|
287
|
+
* issues with "/" characters in note titles.
|
|
288
|
+
*
|
|
289
|
+
* @param folder - The folder name
|
|
290
|
+
* @param title - The note title (can contain "/" characters)
|
|
291
|
+
* @returns Note details with content, or null if not found
|
|
292
|
+
*/
|
|
293
|
+
export async function getNoteByFolderAndTitle(
|
|
294
|
+
folder: string,
|
|
295
|
+
title: string
|
|
296
|
+
): Promise<NoteDetails | null> {
|
|
297
|
+
debug(`Getting note: folder="${folder}", title="${title}"`);
|
|
298
|
+
|
|
299
|
+
const escapedTitle = JSON.stringify(title);
|
|
300
|
+
const escapedFolder = JSON.stringify(folder);
|
|
301
|
+
|
|
302
|
+
const jxaCode = `
|
|
303
|
+
const app = Application('Notes');
|
|
304
|
+
app.includeStandardAdditions = true;
|
|
305
|
+
|
|
306
|
+
const targetTitle = ${escapedTitle};
|
|
307
|
+
const targetFolder = ${escapedFolder};
|
|
308
|
+
|
|
309
|
+
let foundNotes = [];
|
|
310
|
+
const folders = app.folders();
|
|
311
|
+
|
|
312
|
+
for (const folder of folders) {
|
|
313
|
+
const folderName = folder.name();
|
|
314
|
+
|
|
315
|
+
// Only look in the specified folder
|
|
316
|
+
if (folderName !== targetFolder) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const notes = folder.notes.whose({ name: targetTitle });
|
|
321
|
+
|
|
322
|
+
for (let i = 0; i < notes.length; i++) {
|
|
323
|
+
try {
|
|
324
|
+
const note = notes[i];
|
|
325
|
+
const props = note.properties();
|
|
326
|
+
foundNotes.push({
|
|
327
|
+
id: note.id(),
|
|
328
|
+
title: props.name || '',
|
|
329
|
+
folder: folderName,
|
|
330
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
331
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : '',
|
|
332
|
+
htmlContent: note.body()
|
|
333
|
+
});
|
|
334
|
+
} catch (e) {
|
|
335
|
+
// Skip notes that can't be accessed
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return JSON.stringify(foundNotes);
|
|
341
|
+
`;
|
|
342
|
+
|
|
343
|
+
const result = await executeJxa<string>(jxaCode);
|
|
344
|
+
const notes = JSON.parse(result) as Array<{
|
|
345
|
+
id: string;
|
|
346
|
+
title: string;
|
|
347
|
+
folder: string;
|
|
348
|
+
created: string;
|
|
349
|
+
modified: string;
|
|
350
|
+
htmlContent: string;
|
|
351
|
+
}>;
|
|
352
|
+
|
|
353
|
+
if (notes.length === 0) {
|
|
354
|
+
debug("Note not found");
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const note = notes[0];
|
|
359
|
+
const content = htmlToMarkdown(note.htmlContent);
|
|
360
|
+
|
|
361
|
+
debug(`Found note in folder: ${note.folder}`);
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
id: note.id,
|
|
365
|
+
title: note.title,
|
|
366
|
+
folder: note.folder,
|
|
367
|
+
created: note.created,
|
|
368
|
+
modified: note.modified,
|
|
369
|
+
content,
|
|
370
|
+
htmlContent: note.htmlContent,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Get all folder names from Apple Notes
|
|
376
|
+
*
|
|
377
|
+
* @returns Array of folder names
|
|
378
|
+
*/
|
|
379
|
+
export async function getAllFolders(): Promise<string[]> {
|
|
380
|
+
debug("Getting all folders...");
|
|
381
|
+
|
|
382
|
+
const jxaCode = `
|
|
383
|
+
const app = Application('Notes');
|
|
384
|
+
app.includeStandardAdditions = true;
|
|
385
|
+
|
|
386
|
+
const folders = app.folders();
|
|
387
|
+
const folderNames = [];
|
|
388
|
+
|
|
389
|
+
for (const folder of folders) {
|
|
390
|
+
try {
|
|
391
|
+
folderNames.push(folder.name());
|
|
392
|
+
} catch (e) {
|
|
393
|
+
// Skip folders that can't be accessed
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return JSON.stringify(folderNames);
|
|
398
|
+
`;
|
|
399
|
+
|
|
400
|
+
const result = await executeJxa<string>(jxaCode);
|
|
401
|
+
const folders = JSON.parse(result) as string[];
|
|
402
|
+
|
|
403
|
+
debug(`Found ${folders.length} folders`);
|
|
404
|
+
return folders;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Resolve a note title input to a unique note
|
|
409
|
+
*
|
|
410
|
+
* Handles:
|
|
411
|
+
* - Exact title match
|
|
412
|
+
* - "folder/title" format for disambiguation
|
|
413
|
+
* - Returns suggestions when multiple matches exist
|
|
414
|
+
*
|
|
415
|
+
* @param input - The note title or "folder/title" string
|
|
416
|
+
* @returns Resolution result with success status, note info, or suggestions
|
|
417
|
+
*/
|
|
418
|
+
export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
|
|
419
|
+
debug(`Resolving note title: ${input}`);
|
|
420
|
+
|
|
421
|
+
// Check for folder/title format
|
|
422
|
+
let targetFolder: string | null = null;
|
|
423
|
+
let targetTitle = input;
|
|
424
|
+
|
|
425
|
+
if (input.includes("/")) {
|
|
426
|
+
const parts = input.split("/");
|
|
427
|
+
targetFolder = parts.slice(0, -1).join("/");
|
|
428
|
+
targetTitle = parts[parts.length - 1];
|
|
429
|
+
debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const escapedTitle = JSON.stringify(targetTitle);
|
|
433
|
+
const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
|
|
434
|
+
|
|
435
|
+
const jxaCode = `
|
|
436
|
+
const app = Application('Notes');
|
|
437
|
+
app.includeStandardAdditions = true;
|
|
438
|
+
|
|
439
|
+
const targetTitle = ${escapedTitle};
|
|
440
|
+
const targetFolder = ${escapedFolder};
|
|
441
|
+
|
|
442
|
+
let foundNotes = [];
|
|
443
|
+
const folders = app.folders();
|
|
444
|
+
|
|
445
|
+
for (const folder of folders) {
|
|
446
|
+
const folderName = folder.name();
|
|
447
|
+
|
|
448
|
+
// Skip if folder filter is specified and doesn't match
|
|
449
|
+
if (targetFolder !== null && folderName !== targetFolder) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const notes = folder.notes.whose({ name: targetTitle });
|
|
454
|
+
|
|
455
|
+
for (let i = 0; i < notes.length; i++) {
|
|
456
|
+
try {
|
|
457
|
+
const note = notes[i];
|
|
458
|
+
foundNotes.push({
|
|
459
|
+
id: note.id(),
|
|
460
|
+
title: note.name(),
|
|
461
|
+
folder: folderName
|
|
462
|
+
});
|
|
463
|
+
} catch (e) {
|
|
464
|
+
// Skip notes that can't be accessed
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return JSON.stringify(foundNotes);
|
|
470
|
+
`;
|
|
471
|
+
|
|
472
|
+
const result = await executeJxa<string>(jxaCode);
|
|
473
|
+
const notes = JSON.parse(result) as Array<{
|
|
474
|
+
id: string;
|
|
475
|
+
title: string;
|
|
476
|
+
folder: string;
|
|
477
|
+
}>;
|
|
478
|
+
|
|
479
|
+
if (notes.length === 0) {
|
|
480
|
+
debug("No matching notes found");
|
|
481
|
+
return {
|
|
482
|
+
success: false,
|
|
483
|
+
error: `Note not found: "${input}"`,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (notes.length === 1) {
|
|
488
|
+
debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
|
|
489
|
+
return {
|
|
490
|
+
success: true,
|
|
491
|
+
note: notes[0],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Multiple matches - return suggestions
|
|
496
|
+
const suggestions = notes.map((n) => `${n.folder}/${n.title}`);
|
|
497
|
+
debug(`Multiple matches found: ${suggestions.join(", ")}`);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
success: false,
|
|
501
|
+
error: `Multiple notes found with title "${targetTitle}". Please specify the folder.`,
|
|
502
|
+
suggestions,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { rrfScore, generatePreview, filterByFolder } from "./index.js";
|
|
3
|
+
import type { DBSearchResult } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
describe("rrfScore", () => {
|
|
6
|
+
it("calculates RRF score correctly", () => {
|
|
7
|
+
// RRF formula: 1 / (k + rank) where k = 60
|
|
8
|
+
expect(rrfScore(1)).toBeCloseTo(1 / 61, 5);
|
|
9
|
+
expect(rrfScore(10)).toBeCloseTo(1 / 70, 5);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns smaller scores for higher ranks", () => {
|
|
13
|
+
expect(rrfScore(1)).toBeGreaterThan(rrfScore(10));
|
|
14
|
+
expect(rrfScore(10)).toBeGreaterThan(rrfScore(100));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("generatePreview", () => {
|
|
19
|
+
it("returns full text if shorter than limit", () => {
|
|
20
|
+
expect(generatePreview("Short text")).toBe("Short text");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("truncates long text with ellipsis", () => {
|
|
24
|
+
const text = "a".repeat(300);
|
|
25
|
+
const preview = generatePreview(text);
|
|
26
|
+
expect(preview.length).toBeLessThanOrEqual(203);
|
|
27
|
+
expect(preview).toMatch(/\.\.\.$/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("handles empty content", () => {
|
|
31
|
+
expect(generatePreview("")).toBe("");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("filterByFolder", () => {
|
|
36
|
+
const mockResults: DBSearchResult[] = [
|
|
37
|
+
{ title: "Note 1", folder: "Work", content: "content", score: 1, modified: "2024-01-01" },
|
|
38
|
+
{ title: "Note 2", folder: "Personal", content: "content", score: 0.9, modified: "2024-01-01" },
|
|
39
|
+
{ title: "Note 3", folder: "Work/Projects", content: "content", score: 0.8, modified: "2024-01-01" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
it("filters by exact folder name", () => {
|
|
43
|
+
const filtered = filterByFolder(mockResults, "Work");
|
|
44
|
+
expect(filtered).toHaveLength(1);
|
|
45
|
+
expect(filtered[0].title).toBe("Note 1");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns all results when folder is undefined", () => {
|
|
49
|
+
const filtered = filterByFolder(mockResults, undefined);
|
|
50
|
+
expect(filtered).toHaveLength(3);
|
|
51
|
+
});
|
|
52
|
+
});
|