@disco_trooper/apple-notes-mcp 1.1.0 → 1.2.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/src/notes/crud.ts CHANGED
@@ -10,6 +10,7 @@ import { marked } from "marked";
10
10
  import { resolveNoteTitle } from "./read.js";
11
11
  import { createDebugLogger } from "../utils/debug.js";
12
12
  import { findTables, updateTableCell } from "./tables.js";
13
+ import { ReadOnlyModeError, NoteNotFoundError, DuplicateNoteError } from "../errors/index.js";
13
14
 
14
15
  // Debug logging
15
16
  const debug = createDebugLogger("CRUD");
@@ -22,25 +23,40 @@ const debug = createDebugLogger("CRUD");
22
23
  */
23
24
  export function checkReadOnly(): void {
24
25
  if (process.env.READONLY_MODE === "true") {
25
- throw new Error("Operation disabled in read-only mode");
26
+ throw new ReadOnlyModeError();
26
27
  }
27
28
  }
28
29
 
29
30
  /**
30
- * Convert Markdown content to HTML for Apple Notes.
31
+ * Resolve note title and throw if not found.
32
+ * Consolidates the repeated error handling pattern.
31
33
  *
32
- * @param markdown - Markdown content
33
- * @returns HTML string
34
+ * @param title - Note title (supports folder prefix)
35
+ * @returns The resolved note with id, title, and folder
36
+ * @throws Error if note not found or duplicates exist without folder prefix
37
+ */
38
+ async function resolveNoteOrThrow(title: string): Promise<{ id: string; title: string; folder: string }> {
39
+ const resolved = await resolveNoteTitle(title);
40
+
41
+ if (!resolved.success || !resolved.note) {
42
+ if (resolved.suggestions && resolved.suggestions.length > 0) {
43
+ throw new DuplicateNoteError(title, resolved.suggestions);
44
+ }
45
+ throw new NoteNotFoundError(title);
46
+ }
47
+
48
+ return resolved.note;
49
+ }
50
+
51
+ /**
52
+ * Convert Markdown content to HTML for Apple Notes.
34
53
  */
35
54
  function markdownToHtml(markdown: string): string {
36
- // Configure marked for clean HTML output
37
- const html = marked.parse(markdown, {
55
+ return marked.parse(markdown, {
38
56
  async: false,
39
57
  gfm: true,
40
58
  breaks: true,
41
59
  }) as string;
42
-
43
- return html;
44
60
  }
45
61
 
46
62
  /**
@@ -112,21 +128,11 @@ export async function updateNote(title: string, content: string): Promise<void>
112
128
 
113
129
  debug(`Updating note: "${title}"`);
114
130
 
115
- // Resolve the note to get its ID
116
- const resolved = await resolveNoteTitle(title);
117
-
118
- if (!resolved.success || !resolved.note) {
119
- if (resolved.suggestions && resolved.suggestions.length > 0) {
120
- throw new Error(
121
- `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
122
- );
123
- }
124
- throw new Error(resolved.error || `Note not found: "${title}"`);
125
- }
131
+ const note = await resolveNoteOrThrow(title);
126
132
 
127
133
  // Convert Markdown to HTML
128
134
  const htmlContent = markdownToHtml(content);
129
- const escapedNoteId = JSON.stringify(resolved.note.id);
135
+ const escapedNoteId = JSON.stringify(note.id);
130
136
  const escapedContent = JSON.stringify(htmlContent);
131
137
 
132
138
  debug(`HTML content length: ${htmlContent.length}`);
@@ -167,19 +173,8 @@ export async function deleteNote(title: string): Promise<void> {
167
173
 
168
174
  debug(`Deleting note: "${title}"`);
169
175
 
170
- // Resolve the note to get its ID
171
- const resolved = await resolveNoteTitle(title);
172
-
173
- if (!resolved.success || !resolved.note) {
174
- if (resolved.suggestions && resolved.suggestions.length > 0) {
175
- throw new Error(
176
- `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
177
- );
178
- }
179
- throw new Error(resolved.error || `Note not found: "${title}"`);
180
- }
181
-
182
- const escapedNoteId = JSON.stringify(resolved.note.id);
176
+ const note = await resolveNoteOrThrow(title);
177
+ const escapedNoteId = JSON.stringify(note.id);
183
178
 
184
179
  await runJxa(`
185
180
  const app = Application('Notes');
@@ -214,19 +209,8 @@ export async function moveNote(title: string, folder: string): Promise<void> {
214
209
 
215
210
  debug(`Moving note: "${title}" to folder: "${folder}"`);
216
211
 
217
- // Resolve the note to get its ID
218
- const resolved = await resolveNoteTitle(title);
219
-
220
- if (!resolved.success || !resolved.note) {
221
- if (resolved.suggestions && resolved.suggestions.length > 0) {
222
- throw new Error(
223
- `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
224
- );
225
- }
226
- throw new Error(resolved.error || `Note not found: "${title}"`);
227
- }
228
-
229
- const escapedNoteId = JSON.stringify(resolved.note.id);
212
+ const note = await resolveNoteOrThrow(title);
213
+ const escapedNoteId = JSON.stringify(note.id);
230
214
  const escapedFolder = JSON.stringify(folder);
231
215
 
232
216
  await runJxa(`
@@ -279,19 +263,8 @@ export async function editTable(
279
263
 
280
264
  debug(`Editing table ${tableIndex} in note: "${title}"`);
281
265
 
282
- // Resolve the note
283
- const resolved = await resolveNoteTitle(title);
284
- if (!resolved.success || !resolved.note) {
285
- if (resolved.suggestions && resolved.suggestions.length > 0) {
286
- throw new Error(
287
- `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
288
- );
289
- }
290
- throw new Error(resolved.error || `Note not found: "${title}"`);
291
- }
292
-
293
- // Get current HTML content
294
- const escapedNoteId = JSON.stringify(resolved.note.id);
266
+ const note = await resolveNoteOrThrow(title);
267
+ const escapedNoteId = JSON.stringify(note.id);
295
268
  const htmlResult = await runJxa(`
296
269
  const app = Application('Notes');
297
270
  const note = app.notes.byId(${escapedNoteId});
@@ -118,14 +118,69 @@ describe("resolveNoteTitle", () => {
118
118
 
119
119
  it("should return suggestions when multiple matches", async () => {
120
120
  const mockNotes = [
121
- { id: "123", title: "Note", folder: "Work" },
122
- { id: "456", title: "Note", folder: "Personal" },
121
+ { id: "123", title: "Note", folder: "Work", created: "2026-01-09T10:00:00.000Z" },
122
+ { id: "456", title: "Note", folder: "Personal", created: "2026-01-09T11:00:00.000Z" },
123
123
  ];
124
124
  vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
125
125
 
126
126
  const result = await resolveNoteTitle("Note");
127
127
  expect(result.success).toBe(false);
128
128
  expect(result.suggestions).toHaveLength(2);
129
- expect(result.suggestions).toContain("Work/Note");
129
+ expect(result.suggestions?.[0].id).toBe("123");
130
+ expect(result.suggestions?.[0].folder).toBe("Work");
131
+ expect(result.suggestions?.[1].id).toBe("456");
132
+ });
133
+
134
+ it("should resolve note by ID prefix", async () => {
135
+ const mockNote = {
136
+ id: "x-coredata://123",
137
+ title: "ID Note",
138
+ folder: "Work",
139
+ created: "2026-01-09T10:00:00.000Z",
140
+ modified: "2026-01-09T11:00:00.000Z",
141
+ htmlContent: "<p>Content</p>",
142
+ };
143
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNote));
144
+
145
+ const result = await resolveNoteTitle("id:x-coredata://123");
146
+ expect(result.success).toBe(true);
147
+ expect(result.note?.id).toBe("x-coredata://123");
148
+ });
149
+
150
+ it("should return error for invalid ID", async () => {
151
+ vi.mocked(runJxa).mockResolvedValueOnce("null");
152
+
153
+ const result = await resolveNoteTitle("id:invalid-id");
154
+ expect(result.success).toBe(false);
155
+ expect(result.error).toContain("not found");
156
+ });
157
+ });
158
+
159
+ describe("getNoteByTitle with ID prefix", () => {
160
+ beforeEach(() => {
161
+ vi.clearAllMocks();
162
+ });
163
+
164
+ it("should route id: prefix to ID lookup", async () => {
165
+ const mockNote = {
166
+ id: "x-coredata://abc",
167
+ title: "My Note",
168
+ folder: "Work",
169
+ created: "2026-01-09T10:00:00.000Z",
170
+ modified: "2026-01-09T11:00:00.000Z",
171
+ htmlContent: "<p>Hello</p>",
172
+ };
173
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNote));
174
+
175
+ const note = await getNoteByTitle("id:x-coredata://abc");
176
+ expect(note).not.toBeNull();
177
+ expect(note?.id).toBe("x-coredata://abc");
178
+ });
179
+
180
+ it("should return null for non-existent ID", async () => {
181
+ vi.mocked(runJxa).mockResolvedValueOnce("null");
182
+
183
+ const note = await getNoteByTitle("id:nonexistent");
184
+ expect(note).toBeNull();
130
185
  });
131
186
  });
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,21 +39,6 @@ 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
43
  // Internal JXA helpers
83
44
  // -----------------------------------------------------------------------------
@@ -97,36 +58,6 @@ async function executeJxa<T>(code: string): Promise<T> {
97
58
  }
98
59
  }
99
60
 
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
61
  // -----------------------------------------------------------------------------
131
62
  // Public API
132
63
  // -----------------------------------------------------------------------------
@@ -178,7 +109,7 @@ export async function getAllNotes(): Promise<NoteInfo[]> {
178
109
  /**
179
110
  * Get a note by its title, with full content
180
111
  *
181
- * @param title - The note title (can be "folder/title" format for disambiguation)
112
+ * @param title - The note title (can be "folder/title" or "id:xxx" format)
182
113
  * @returns Note details with content, or null if not found
183
114
  */
184
115
  export async function getNoteByTitle(
@@ -186,6 +117,13 @@ export async function getNoteByTitle(
186
117
  ): Promise<NoteDetails | null> {
187
118
  debug(`Getting note by title: ${title}`);
188
119
 
120
+ // Check for id:xxx format for direct ID lookup
121
+ if (title.startsWith("id:")) {
122
+ const noteId = title.slice(3);
123
+ debug(`ID prefix detected, looking up note by ID: ${noteId}`);
124
+ return getNoteById(noteId);
125
+ }
126
+
189
127
  // Check for folder/title format
190
128
  let targetFolder: string | null = null;
191
129
  let targetTitle = title;
@@ -281,6 +219,84 @@ export async function getNoteByTitle(
281
219
  };
282
220
  }
283
221
 
222
+ /**
223
+ * Get a note by its Apple Notes ID.
224
+ * Use this for precise access when title-based lookup is ambiguous.
225
+ *
226
+ * @param id - The Apple Notes unique identifier
227
+ * @returns Note details with content, or null if not found
228
+ */
229
+ export async function getNoteById(id: string): Promise<NoteDetails | null> {
230
+ debug(`Getting note by ID: ${id}`);
231
+
232
+ const escapedId = JSON.stringify(id);
233
+
234
+ const jxaCode = `
235
+ const app = Application('Notes');
236
+ app.includeStandardAdditions = true;
237
+
238
+ const targetId = ${escapedId};
239
+
240
+ try {
241
+ const note = app.notes.byId(targetId);
242
+ const props = note.properties();
243
+
244
+ // Find the folder this note belongs to
245
+ let folderName = 'Notes';
246
+ const folders = app.folders();
247
+ for (const folder of folders) {
248
+ const notes = folder.notes();
249
+ for (let i = 0; i < notes.length; i++) {
250
+ if (notes[i].id() === targetId) {
251
+ folderName = folder.name();
252
+ break;
253
+ }
254
+ }
255
+ }
256
+
257
+ return JSON.stringify({
258
+ id: note.id(),
259
+ title: props.name || '',
260
+ folder: folderName,
261
+ created: props.creationDate ? props.creationDate.toISOString() : '',
262
+ modified: props.modificationDate ? props.modificationDate.toISOString() : '',
263
+ htmlContent: note.body()
264
+ });
265
+ } catch (e) {
266
+ return JSON.stringify(null);
267
+ }
268
+ `;
269
+
270
+ const result = await executeJxa<string>(jxaCode);
271
+ const note = JSON.parse(result) as {
272
+ id: string;
273
+ title: string;
274
+ folder: string;
275
+ created: string;
276
+ modified: string;
277
+ htmlContent: string;
278
+ } | null;
279
+
280
+ if (!note) {
281
+ debug("Note not found by ID");
282
+ return null;
283
+ }
284
+
285
+ const content = htmlToMarkdown(note.htmlContent);
286
+
287
+ debug(`Found note: ${note.title} in folder: ${note.folder}`);
288
+
289
+ return {
290
+ id: note.id,
291
+ title: note.title,
292
+ folder: note.folder,
293
+ created: note.created,
294
+ modified: note.modified,
295
+ content,
296
+ htmlContent: note.htmlContent,
297
+ };
298
+ }
299
+
284
300
  /**
285
301
  * Get a note by explicit folder and title (no "/" parsing).
286
302
  * Use this when you have folder and title separately to avoid
@@ -403,102 +419,3 @@ export async function getAllFolders(): Promise<string[]> {
403
419
  debug(`Found ${folders.length} folders`);
404
420
  return folders;
405
421
  }
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,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
+ }