@disco_trooper/apple-notes-mcp 1.1.0 → 1.3.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/README.md +104 -24
- package/package.json +11 -12
- package/src/config/claude.test.ts +47 -0
- package/src/config/claude.ts +106 -0
- package/src/config/constants.ts +11 -2
- package/src/config/paths.test.ts +40 -0
- package/src/config/paths.ts +86 -0
- package/src/db/arrow-fix.test.ts +101 -0
- package/src/db/lancedb.test.ts +254 -2
- package/src/db/lancedb.ts +385 -38
- package/src/embeddings/cache.test.ts +150 -0
- package/src/embeddings/cache.ts +204 -0
- package/src/embeddings/index.ts +22 -4
- package/src/embeddings/local.ts +57 -17
- package/src/embeddings/openrouter.ts +233 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- package/src/graph/export.test.ts +81 -0
- package/src/graph/export.ts +163 -0
- package/src/graph/extract.test.ts +90 -0
- package/src/graph/extract.ts +52 -0
- package/src/graph/queries.test.ts +156 -0
- package/src/graph/queries.ts +224 -0
- package/src/index.ts +309 -23
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +41 -8
- package/src/notes/crud.ts +75 -64
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +142 -210
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.ts +69 -40
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +207 -0
- package/src/search/chunk-search.test.ts +327 -0
- package/src/search/chunk-search.ts +298 -0
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +164 -109
- package/src/setup.ts +46 -67
- package/src/types/index.ts +4 -0
- package/src/utils/chunker.test.ts +182 -0
- package/src/utils/chunker.ts +170 -0
- package/src/utils/content-filter.test.ts +225 -0
- package/src/utils/content-filter.ts +275 -0
- package/src/utils/debug.ts +0 -2
- package/src/utils/runtime.test.ts +70 -0
- package/src/utils/runtime.ts +40 -0
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -427
package/src/notes/read.ts
CHANGED
|
@@ -6,39 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { runJxa } from "run-jxa";
|
|
9
|
-
import TurndownService from "turndown";
|
|
10
9
|
import { createDebugLogger } from "../utils/debug.js";
|
|
10
|
+
import { htmlToMarkdown } from "./conversion.js";
|
|
11
|
+
|
|
12
|
+
// Re-export for backwards compatibility
|
|
13
|
+
export { resolveNoteTitle, type ResolvedNote } from "./resolve.js";
|
|
11
14
|
|
|
12
15
|
// Debug logging
|
|
13
16
|
const debug = createDebugLogger("NOTES");
|
|
14
17
|
|
|
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
18
|
// -----------------------------------------------------------------------------
|
|
43
19
|
// Types
|
|
44
20
|
// -----------------------------------------------------------------------------
|
|
@@ -63,25 +39,20 @@ export interface NoteDetails extends NoteInfo {
|
|
|
63
39
|
id: string;
|
|
64
40
|
}
|
|
65
41
|
|
|
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
42
|
// -----------------------------------------------------------------------------
|
|
82
|
-
// Internal
|
|
43
|
+
// Internal types and helpers
|
|
83
44
|
// -----------------------------------------------------------------------------
|
|
84
45
|
|
|
46
|
+
/** Raw note data from JXA before markdown conversion */
|
|
47
|
+
interface RawNoteData {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
folder: string;
|
|
51
|
+
created: string;
|
|
52
|
+
modified: string;
|
|
53
|
+
htmlContent: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
85
56
|
/**
|
|
86
57
|
* Execute JXA code safely with error handling
|
|
87
58
|
*/
|
|
@@ -98,33 +69,18 @@ async function executeJxa<T>(code: string): Promise<T> {
|
|
|
98
69
|
}
|
|
99
70
|
|
|
100
71
|
/**
|
|
101
|
-
* Convert
|
|
72
|
+
* Convert raw JXA note data to NoteDetails with markdown content
|
|
102
73
|
*/
|
|
103
|
-
function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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();
|
|
74
|
+
function toNoteDetails(raw: RawNoteData): NoteDetails {
|
|
75
|
+
return {
|
|
76
|
+
id: raw.id,
|
|
77
|
+
title: raw.title,
|
|
78
|
+
folder: raw.folder,
|
|
79
|
+
created: raw.created,
|
|
80
|
+
modified: raw.modified,
|
|
81
|
+
content: htmlToMarkdown(raw.htmlContent),
|
|
82
|
+
htmlContent: raw.htmlContent,
|
|
83
|
+
};
|
|
128
84
|
}
|
|
129
85
|
|
|
130
86
|
// -----------------------------------------------------------------------------
|
|
@@ -178,7 +134,7 @@ export async function getAllNotes(): Promise<NoteInfo[]> {
|
|
|
178
134
|
/**
|
|
179
135
|
* Get a note by its title, with full content
|
|
180
136
|
*
|
|
181
|
-
* @param title - The note title (can be "folder/title"
|
|
137
|
+
* @param title - The note title (can be "folder/title" or "id:xxx" format)
|
|
182
138
|
* @returns Note details with content, or null if not found
|
|
183
139
|
*/
|
|
184
140
|
export async function getNoteByTitle(
|
|
@@ -186,6 +142,13 @@ export async function getNoteByTitle(
|
|
|
186
142
|
): Promise<NoteDetails | null> {
|
|
187
143
|
debug(`Getting note by title: ${title}`);
|
|
188
144
|
|
|
145
|
+
// Check for id:xxx format for direct ID lookup
|
|
146
|
+
if (title.startsWith("id:")) {
|
|
147
|
+
const noteId = title.slice(3);
|
|
148
|
+
debug(`ID prefix detected, looking up note by ID: ${noteId}`);
|
|
149
|
+
return getNoteById(noteId);
|
|
150
|
+
}
|
|
151
|
+
|
|
189
152
|
// Check for folder/title format
|
|
190
153
|
let targetFolder: string | null = null;
|
|
191
154
|
let targetTitle = title;
|
|
@@ -242,14 +205,7 @@ export async function getNoteByTitle(
|
|
|
242
205
|
`;
|
|
243
206
|
|
|
244
207
|
const result = await executeJxa<string>(jxaCode);
|
|
245
|
-
const notes = JSON.parse(result) as
|
|
246
|
-
id: string;
|
|
247
|
-
title: string;
|
|
248
|
-
folder: string;
|
|
249
|
-
created: string;
|
|
250
|
-
modified: string;
|
|
251
|
-
htmlContent: string;
|
|
252
|
-
}>;
|
|
208
|
+
const notes = JSON.parse(result) as RawNoteData[];
|
|
253
209
|
|
|
254
210
|
if (notes.length === 0) {
|
|
255
211
|
debug("Note not found");
|
|
@@ -258,27 +214,71 @@ export async function getNoteByTitle(
|
|
|
258
214
|
|
|
259
215
|
if (notes.length > 1) {
|
|
260
216
|
debug(`Multiple notes found with title: ${targetTitle}`);
|
|
261
|
-
|
|
262
|
-
// but log a warning
|
|
263
|
-
debug(
|
|
264
|
-
"Returning first match. Use folder/title format for disambiguation."
|
|
265
|
-
);
|
|
217
|
+
debug("Returning first match. Use folder/title format for disambiguation.");
|
|
266
218
|
}
|
|
267
219
|
|
|
268
|
-
|
|
269
|
-
|
|
220
|
+
debug(`Found note in folder: ${notes[0].folder}`);
|
|
221
|
+
return toNoteDetails(notes[0]);
|
|
222
|
+
}
|
|
270
223
|
|
|
271
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Get a note by its Apple Notes ID.
|
|
226
|
+
* Use this for precise access when title-based lookup is ambiguous.
|
|
227
|
+
*
|
|
228
|
+
* @param id - The Apple Notes unique identifier
|
|
229
|
+
* @returns Note details with content, or null if not found
|
|
230
|
+
*/
|
|
231
|
+
export async function getNoteById(id: string): Promise<NoteDetails | null> {
|
|
232
|
+
debug(`Getting note by ID: ${id}`);
|
|
272
233
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
234
|
+
const escapedId = JSON.stringify(id);
|
|
235
|
+
|
|
236
|
+
const jxaCode = `
|
|
237
|
+
const app = Application('Notes');
|
|
238
|
+
app.includeStandardAdditions = true;
|
|
239
|
+
|
|
240
|
+
const targetId = ${escapedId};
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const note = app.notes.byId(targetId);
|
|
244
|
+
const props = note.properties();
|
|
245
|
+
|
|
246
|
+
// Find the folder this note belongs to
|
|
247
|
+
let folderName = 'Notes';
|
|
248
|
+
const folders = app.folders();
|
|
249
|
+
for (const folder of folders) {
|
|
250
|
+
const notes = folder.notes();
|
|
251
|
+
for (let i = 0; i < notes.length; i++) {
|
|
252
|
+
if (notes[i].id() === targetId) {
|
|
253
|
+
folderName = folder.name();
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return JSON.stringify({
|
|
260
|
+
id: note.id(),
|
|
261
|
+
title: props.name || '',
|
|
262
|
+
folder: folderName,
|
|
263
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
264
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : '',
|
|
265
|
+
htmlContent: note.body()
|
|
266
|
+
});
|
|
267
|
+
} catch (e) {
|
|
268
|
+
return JSON.stringify(null);
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
const result = await executeJxa<string>(jxaCode);
|
|
273
|
+
const note = JSON.parse(result) as RawNoteData | null;
|
|
274
|
+
|
|
275
|
+
if (!note) {
|
|
276
|
+
debug("Note not found by ID");
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
debug(`Found note: ${note.title} in folder: ${note.folder}`);
|
|
281
|
+
return toNoteDetails(note);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
/**
|
|
@@ -341,164 +341,96 @@ export async function getNoteByFolderAndTitle(
|
|
|
341
341
|
`;
|
|
342
342
|
|
|
343
343
|
const result = await executeJxa<string>(jxaCode);
|
|
344
|
-
const notes = JSON.parse(result) as
|
|
345
|
-
id: string;
|
|
346
|
-
title: string;
|
|
347
|
-
folder: string;
|
|
348
|
-
created: string;
|
|
349
|
-
modified: string;
|
|
350
|
-
htmlContent: string;
|
|
351
|
-
}>;
|
|
344
|
+
const notes = JSON.parse(result) as RawNoteData[];
|
|
352
345
|
|
|
353
346
|
if (notes.length === 0) {
|
|
354
347
|
debug("Note not found");
|
|
355
348
|
return null;
|
|
356
349
|
}
|
|
357
350
|
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
};
|
|
351
|
+
debug(`Found note in folder: ${notes[0].folder}`);
|
|
352
|
+
return toNoteDetails(notes[0]);
|
|
372
353
|
}
|
|
373
354
|
|
|
374
355
|
/**
|
|
375
|
-
* Get all
|
|
356
|
+
* Get all notes with full content in a single JXA call.
|
|
357
|
+
* This is much faster than calling getNoteByFolderAndTitle for each note
|
|
358
|
+
* because it avoids the JXA process spawn overhead per note.
|
|
376
359
|
*
|
|
377
|
-
* @returns Array of
|
|
360
|
+
* @returns Array of note details with content
|
|
378
361
|
*/
|
|
379
|
-
export async function
|
|
380
|
-
debug("Getting all
|
|
362
|
+
export async function getAllNotesWithContent(): Promise<NoteDetails[]> {
|
|
363
|
+
debug("Getting all notes with content (single JXA call)...");
|
|
381
364
|
|
|
382
365
|
const jxaCode = `
|
|
383
366
|
const app = Application('Notes');
|
|
384
367
|
app.includeStandardAdditions = true;
|
|
385
368
|
|
|
369
|
+
const allNotes = [];
|
|
386
370
|
const folders = app.folders();
|
|
387
|
-
const folderNames = [];
|
|
388
371
|
|
|
389
372
|
for (const folder of folders) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
373
|
+
const folderName = folder.name();
|
|
374
|
+
const notes = folder.notes();
|
|
375
|
+
|
|
376
|
+
for (let i = 0; i < notes.length; i++) {
|
|
377
|
+
try {
|
|
378
|
+
const note = notes[i];
|
|
379
|
+
const props = note.properties();
|
|
380
|
+
allNotes.push({
|
|
381
|
+
id: note.id(),
|
|
382
|
+
title: props.name || '',
|
|
383
|
+
folder: folderName,
|
|
384
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
385
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : '',
|
|
386
|
+
htmlContent: note.body()
|
|
387
|
+
});
|
|
388
|
+
} catch (e) {
|
|
389
|
+
// Skip notes that can't be accessed
|
|
390
|
+
}
|
|
394
391
|
}
|
|
395
392
|
}
|
|
396
393
|
|
|
397
|
-
return JSON.stringify(
|
|
394
|
+
return JSON.stringify(allNotes);
|
|
398
395
|
`;
|
|
399
396
|
|
|
400
397
|
const result = await executeJxa<string>(jxaCode);
|
|
401
|
-
const
|
|
398
|
+
const notes = JSON.parse(result) as RawNoteData[];
|
|
402
399
|
|
|
403
|
-
debug(`
|
|
404
|
-
|
|
400
|
+
debug(`Fetched ${notes.length} notes with content`);
|
|
401
|
+
|
|
402
|
+
return notes.map(toNoteDetails);
|
|
405
403
|
}
|
|
406
404
|
|
|
407
405
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* Handles:
|
|
411
|
-
* - Exact title match
|
|
412
|
-
* - "folder/title" format for disambiguation
|
|
413
|
-
* - Returns suggestions when multiple matches exist
|
|
406
|
+
* Get all folder names from Apple Notes
|
|
414
407
|
*
|
|
415
|
-
* @
|
|
416
|
-
* @returns Resolution result with success status, note info, or suggestions
|
|
408
|
+
* @returns Array of folder names
|
|
417
409
|
*/
|
|
418
|
-
export async function
|
|
419
|
-
debug(
|
|
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";
|
|
410
|
+
export async function getAllFolders(): Promise<string[]> {
|
|
411
|
+
debug("Getting all folders...");
|
|
434
412
|
|
|
435
413
|
const jxaCode = `
|
|
436
414
|
const app = Application('Notes');
|
|
437
415
|
app.includeStandardAdditions = true;
|
|
438
416
|
|
|
439
|
-
const targetTitle = ${escapedTitle};
|
|
440
|
-
const targetFolder = ${escapedFolder};
|
|
441
|
-
|
|
442
|
-
let foundNotes = [];
|
|
443
417
|
const folders = app.folders();
|
|
418
|
+
const folderNames = [];
|
|
444
419
|
|
|
445
420
|
for (const folder of folders) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
}
|
|
421
|
+
try {
|
|
422
|
+
folderNames.push(folder.name());
|
|
423
|
+
} catch (e) {
|
|
424
|
+
// Skip folders that can't be accessed
|
|
466
425
|
}
|
|
467
426
|
}
|
|
468
427
|
|
|
469
|
-
return JSON.stringify(
|
|
428
|
+
return JSON.stringify(folderNames);
|
|
470
429
|
`;
|
|
471
430
|
|
|
472
431
|
const result = await executeJxa<string>(jxaCode);
|
|
473
|
-
const
|
|
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(", ")}`);
|
|
432
|
+
const folders = JSON.parse(result) as string[];
|
|
498
433
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
error: `Multiple notes found with title "${targetTitle}". Please specify the folder.`,
|
|
502
|
-
suggestions,
|
|
503
|
-
};
|
|
434
|
+
debug(`Found ${folders.length} folders`);
|
|
435
|
+
return folders;
|
|
504
436
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note title resolution and disambiguation.
|
|
3
|
+
*
|
|
4
|
+
* Handles resolving user input (title, folder/title, id:xxx) to a specific note.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runJxa } from "run-jxa";
|
|
8
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
9
|
+
import type { NoteSuggestion } from "../errors/index.js";
|
|
10
|
+
import { getNoteById } from "./read.js";
|
|
11
|
+
|
|
12
|
+
const debug = createDebugLogger("NOTES");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Result of resolving a note title to a specific note.
|
|
16
|
+
*/
|
|
17
|
+
export interface ResolvedNote {
|
|
18
|
+
/** Whether resolution was successful */
|
|
19
|
+
success: boolean;
|
|
20
|
+
/** The matched note (if exactly one match) */
|
|
21
|
+
note?: {
|
|
22
|
+
title: string;
|
|
23
|
+
folder: string;
|
|
24
|
+
id: string;
|
|
25
|
+
};
|
|
26
|
+
/** Error message if resolution failed */
|
|
27
|
+
error?: string;
|
|
28
|
+
/** Rich suggestions when multiple matches found (includes ID and created date) */
|
|
29
|
+
suggestions?: NoteSuggestion[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Execute JXA code safely with error handling.
|
|
34
|
+
*/
|
|
35
|
+
async function executeJxa<T>(code: string): Promise<T> {
|
|
36
|
+
try {
|
|
37
|
+
const result = await runJxa(code);
|
|
38
|
+
return result as T;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
debug("JXA execution error:", error);
|
|
41
|
+
throw new Error(
|
|
42
|
+
`JXA execution failed: ${error instanceof Error ? error.message : String(error)}`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a note title input to a unique note.
|
|
49
|
+
*
|
|
50
|
+
* Handles:
|
|
51
|
+
* - "id:xxx" format for direct ID lookup
|
|
52
|
+
* - Exact title match
|
|
53
|
+
* - "folder/title" format for disambiguation
|
|
54
|
+
* - Returns suggestions when multiple matches exist
|
|
55
|
+
*
|
|
56
|
+
* @param input - The note title, "folder/title", or "id:xxx" string
|
|
57
|
+
* @returns Resolution result with success status, note info, or suggestions
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
|
|
60
|
+
debug(`Resolving note title: ${input}`);
|
|
61
|
+
|
|
62
|
+
// Check for id:xxx format for direct ID lookup
|
|
63
|
+
if (input.startsWith("id:")) {
|
|
64
|
+
const noteId = input.slice(3);
|
|
65
|
+
debug(`ID prefix detected, looking up note by ID: ${noteId}`);
|
|
66
|
+
const noteDetails = await getNoteById(noteId);
|
|
67
|
+
if (!noteDetails) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: `Note not found with ID: "${noteId}"`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
note: {
|
|
76
|
+
id: noteDetails.id,
|
|
77
|
+
title: noteDetails.title,
|
|
78
|
+
folder: noteDetails.folder,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check for folder/title format
|
|
84
|
+
let targetFolder: string | null = null;
|
|
85
|
+
let targetTitle = input;
|
|
86
|
+
|
|
87
|
+
if (input.includes("/")) {
|
|
88
|
+
const parts = input.split("/");
|
|
89
|
+
targetFolder = parts.slice(0, -1).join("/");
|
|
90
|
+
targetTitle = parts[parts.length - 1];
|
|
91
|
+
debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const escapedTitle = JSON.stringify(targetTitle);
|
|
95
|
+
const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
|
|
96
|
+
|
|
97
|
+
const jxaCode = `
|
|
98
|
+
const app = Application('Notes');
|
|
99
|
+
app.includeStandardAdditions = true;
|
|
100
|
+
|
|
101
|
+
const targetTitle = ${escapedTitle};
|
|
102
|
+
const targetFolder = ${escapedFolder};
|
|
103
|
+
|
|
104
|
+
let foundNotes = [];
|
|
105
|
+
const folders = app.folders();
|
|
106
|
+
|
|
107
|
+
for (const folder of folders) {
|
|
108
|
+
const folderName = folder.name();
|
|
109
|
+
|
|
110
|
+
// Skip if folder filter is specified and doesn't match
|
|
111
|
+
if (targetFolder !== null && folderName !== targetFolder) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const notes = folder.notes.whose({ name: targetTitle });
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < notes.length; i++) {
|
|
118
|
+
try {
|
|
119
|
+
const note = notes[i];
|
|
120
|
+
const props = note.properties();
|
|
121
|
+
foundNotes.push({
|
|
122
|
+
id: note.id(),
|
|
123
|
+
title: note.name(),
|
|
124
|
+
folder: folderName,
|
|
125
|
+
created: props.creationDate ? props.creationDate.toISOString() : ''
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Skip notes that can't be accessed
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return JSON.stringify(foundNotes);
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
const result = await executeJxa<string>(jxaCode);
|
|
137
|
+
const notes = JSON.parse(result) as Array<{
|
|
138
|
+
id: string;
|
|
139
|
+
title: string;
|
|
140
|
+
folder: string;
|
|
141
|
+
created: string;
|
|
142
|
+
}>;
|
|
143
|
+
|
|
144
|
+
if (notes.length === 0) {
|
|
145
|
+
debug("No matching notes found");
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
error: `Note not found: "${input}"`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (notes.length === 1) {
|
|
153
|
+
debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
note: notes[0],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Multiple matches - return rich suggestions with ID and created date
|
|
161
|
+
const suggestions: NoteSuggestion[] = notes.map((n) => ({
|
|
162
|
+
id: n.id,
|
|
163
|
+
folder: n.folder,
|
|
164
|
+
title: n.title,
|
|
165
|
+
created: n.created,
|
|
166
|
+
}));
|
|
167
|
+
debug(`Multiple matches found: ${suggestions.map(s => `${s.folder}/${s.title}`).join(", ")}`);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: `Multiple notes found with title "${targetTitle}". Use ID prefix to specify.`,
|
|
172
|
+
suggestions,
|
|
173
|
+
};
|
|
174
|
+
}
|