@disco_trooper/apple-notes-mcp 1.2.0 → 1.4.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.
Files changed (39) hide show
  1. package/README.md +136 -24
  2. package/package.json +13 -9
  3. package/src/config/claude.test.ts +47 -0
  4. package/src/config/claude.ts +106 -0
  5. package/src/config/constants.ts +11 -2
  6. package/src/config/paths.test.ts +40 -0
  7. package/src/config/paths.ts +86 -0
  8. package/src/db/arrow-fix.test.ts +101 -0
  9. package/src/db/lancedb.test.ts +209 -2
  10. package/src/db/lancedb.ts +373 -7
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +21 -2
  14. package/src/embeddings/local.ts +61 -10
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/graph/export.test.ts +81 -0
  17. package/src/graph/export.ts +163 -0
  18. package/src/graph/extract.test.ts +90 -0
  19. package/src/graph/extract.ts +52 -0
  20. package/src/graph/queries.test.ts +156 -0
  21. package/src/graph/queries.ts +224 -0
  22. package/src/index.ts +376 -10
  23. package/src/notes/crud.test.ts +148 -3
  24. package/src/notes/crud.ts +250 -5
  25. package/src/notes/read.ts +83 -68
  26. package/src/search/chunk-indexer.test.ts +353 -0
  27. package/src/search/chunk-indexer.ts +254 -0
  28. package/src/search/chunk-search.test.ts +327 -0
  29. package/src/search/chunk-search.ts +298 -0
  30. package/src/search/indexer.ts +151 -109
  31. package/src/search/refresh.test.ts +173 -0
  32. package/src/search/refresh.ts +151 -0
  33. package/src/setup.ts +46 -67
  34. package/src/utils/chunker.test.ts +182 -0
  35. package/src/utils/chunker.ts +170 -0
  36. package/src/utils/content-filter.test.ts +225 -0
  37. package/src/utils/content-filter.ts +275 -0
  38. package/src/utils/runtime.test.ts +70 -0
  39. package/src/utils/runtime.ts +40 -0
package/src/notes/crud.ts CHANGED
@@ -115,20 +115,42 @@ export async function createNote(
115
115
  debug(`Note created: "${title}"`);
116
116
  }
117
117
 
118
+ /**
119
+ * Result of an update operation.
120
+ * Apple Notes may rename the note based on content (first h1 heading).
121
+ */
122
+ export interface UpdateResult {
123
+ /** Original title before update */
124
+ originalTitle: string;
125
+ /** Current title after update (may differ if Apple Notes renamed it) */
126
+ newTitle: string;
127
+ /** Folder containing the note */
128
+ folder: string;
129
+ /** Whether the title changed */
130
+ titleChanged: boolean;
131
+ }
132
+
118
133
  /**
119
134
  * Update an existing note's content.
120
135
  *
136
+ * Note: Apple Notes may automatically rename the note based on the first
137
+ * heading in the content. The returned UpdateResult contains the actual
138
+ * title after the update.
139
+ *
121
140
  * @param title - Note title (supports folder prefix: "Work/My Note")
122
141
  * @param content - New content (Markdown)
142
+ * @returns UpdateResult with original and new title
123
143
  * @throws Error if READONLY_MODE is enabled
124
144
  * @throws Error if note not found or duplicate titles without folder prefix
125
145
  */
126
- export async function updateNote(title: string, content: string): Promise<void> {
146
+ export async function updateNote(title: string, content: string): Promise<UpdateResult> {
127
147
  checkReadOnly();
128
148
 
129
149
  debug(`Updating note: "${title}"`);
130
150
 
131
151
  const note = await resolveNoteOrThrow(title);
152
+ const originalTitle = note.title;
153
+ const folder = note.folder;
132
154
 
133
155
  // Convert Markdown to HTML
134
156
  const htmlContent = markdownToHtml(content);
@@ -137,7 +159,8 @@ export async function updateNote(title: string, content: string): Promise<void>
137
159
 
138
160
  debug(`HTML content length: ${htmlContent.length}`);
139
161
 
140
- await runJxa(`
162
+ // Update the note and get its new title (Apple Notes may rename it)
163
+ const result = await runJxa(`
141
164
  const app = Application('Notes');
142
165
  const noteId = ${escapedNoteId};
143
166
  const content = ${escapedContent};
@@ -152,10 +175,25 @@ export async function updateNote(title: string, content: string): Promise<void>
152
175
  // Update the body
153
176
  note.body = content;
154
177
 
155
- return "ok";
156
- `);
178
+ // Return the current title (may have changed)
179
+ return JSON.stringify({ newTitle: note.name() });
180
+ `) as string;
181
+
182
+ const { newTitle } = JSON.parse(result);
183
+ const titleChanged = newTitle !== originalTitle;
184
+
185
+ if (titleChanged) {
186
+ debug(`Note renamed by Apple Notes: "${originalTitle}" -> "${newTitle}"`);
187
+ } else {
188
+ debug(`Note updated: "${title}"`);
189
+ }
157
190
 
158
- debug(`Note updated: "${title}"`);
191
+ return {
192
+ originalTitle,
193
+ newTitle,
194
+ folder,
195
+ titleChanged,
196
+ };
159
197
  }
160
198
 
161
199
  /**
@@ -304,3 +342,210 @@ export async function editTable(
304
342
 
305
343
  debug(`Table ${tableIndex} updated in note: "${title}"`);
306
344
  }
345
+
346
+ /**
347
+ * Result of a batch operation.
348
+ */
349
+ export interface BatchResult {
350
+ /** Number of notes successfully processed */
351
+ deleted: number;
352
+ /** Notes that failed to process */
353
+ failed: string[];
354
+ }
355
+
356
+ /**
357
+ * Options for batch delete.
358
+ */
359
+ export interface BatchDeleteOptions {
360
+ /** List of note titles (supports folder/title and id:xxx formats) */
361
+ titles?: string[];
362
+ /** Delete all notes in this folder */
363
+ folder?: string;
364
+ }
365
+
366
+ /**
367
+ * Delete multiple notes at once.
368
+ *
369
+ * @param options - Either titles array OR folder name (not both)
370
+ * @returns BatchResult with deleted count and failed notes
371
+ * @throws Error if READONLY_MODE is enabled
372
+ * @throws Error if both titles and folder provided
373
+ * @throws Error if neither titles nor folder provided
374
+ */
375
+ export async function batchDelete(options: BatchDeleteOptions): Promise<BatchResult> {
376
+ checkReadOnly();
377
+
378
+ const { titles, folder } = options;
379
+
380
+ if (titles && folder) {
381
+ throw new Error("Specify either titles or folder, not both");
382
+ }
383
+
384
+ if (!titles && !folder) {
385
+ throw new Error("Specify either titles or folder");
386
+ }
387
+
388
+ const result: BatchResult = { deleted: 0, failed: [] };
389
+
390
+ if (folder) {
391
+ // Delete all notes in folder via single JXA call
392
+ debug(`Batch deleting all notes in folder: "${folder}"`);
393
+
394
+ const escapedFolder = JSON.stringify(folder);
395
+ const jxaResult = await runJxa(`
396
+ const app = Application('Notes');
397
+ const folderName = ${escapedFolder};
398
+
399
+ const folders = app.folders.whose({name: folderName})();
400
+ if (folders.length === 0) {
401
+ throw new Error("Folder not found: " + folderName);
402
+ }
403
+
404
+ const folder = folders[0];
405
+ const notes = folder.notes();
406
+ let deletedCount = 0;
407
+
408
+ // Delete in reverse order to avoid index shifting
409
+ for (let i = notes.length - 1; i >= 0; i--) {
410
+ try {
411
+ notes[i].delete();
412
+ deletedCount++;
413
+ } catch (e) {
414
+ // Continue on individual failures
415
+ }
416
+ }
417
+
418
+ return JSON.stringify({ deletedCount });
419
+ `);
420
+
421
+ const { deletedCount } = JSON.parse(jxaResult as string);
422
+ result.deleted = deletedCount;
423
+ } else if (titles) {
424
+ // Delete individual notes
425
+ debug(`Batch deleting ${titles.length} notes by title`);
426
+
427
+ for (const title of titles) {
428
+ try {
429
+ await deleteNote(title);
430
+ result.deleted++;
431
+ } catch (error) {
432
+ result.failed.push(title);
433
+ debug(`Failed to delete "${title}":`, error);
434
+ }
435
+ }
436
+ }
437
+
438
+ debug(`Batch delete complete: ${result.deleted} deleted, ${result.failed.length} failed`);
439
+ return result;
440
+ }
441
+
442
+ /**
443
+ * Result of a batch move operation.
444
+ */
445
+ export interface BatchMoveResult {
446
+ /** Number of notes successfully moved */
447
+ moved: number;
448
+ /** Notes that failed to move */
449
+ failed: string[];
450
+ }
451
+
452
+ /**
453
+ * Options for batch move.
454
+ */
455
+ export interface BatchMoveOptions {
456
+ /** List of note titles (supports folder/title and id:xxx formats) */
457
+ titles?: string[];
458
+ /** Move all notes from this folder */
459
+ sourceFolder?: string;
460
+ /** Target folder (required) */
461
+ targetFolder: string;
462
+ }
463
+
464
+ /**
465
+ * Move multiple notes to a target folder.
466
+ *
467
+ * @param options - Either titles array OR sourceFolder (not both) + targetFolder
468
+ * @returns BatchMoveResult with moved count and failed notes
469
+ * @throws Error if READONLY_MODE is enabled
470
+ * @throws Error if both titles and sourceFolder provided
471
+ * @throws Error if neither titles nor sourceFolder provided
472
+ * @throws Error if targetFolder is empty
473
+ */
474
+ export async function batchMove(options: BatchMoveOptions): Promise<BatchMoveResult> {
475
+ checkReadOnly();
476
+
477
+ const { titles, sourceFolder, targetFolder } = options;
478
+
479
+ if (!targetFolder) {
480
+ throw new Error("targetFolder is required");
481
+ }
482
+
483
+ if (titles && sourceFolder) {
484
+ throw new Error("Specify either titles or sourceFolder, not both");
485
+ }
486
+
487
+ if (!titles && !sourceFolder) {
488
+ throw new Error("Specify either titles or sourceFolder");
489
+ }
490
+
491
+ const result: BatchMoveResult = { moved: 0, failed: [] };
492
+
493
+ if (sourceFolder) {
494
+ // Move all notes from source folder via single JXA call
495
+ debug(`Batch moving all notes from "${sourceFolder}" to "${targetFolder}"`);
496
+
497
+ const escapedSource = JSON.stringify(sourceFolder);
498
+ const escapedTarget = JSON.stringify(targetFolder);
499
+ const jxaResult = await runJxa(`
500
+ const app = Application('Notes');
501
+ const sourceName = ${escapedSource};
502
+ const targetName = ${escapedTarget};
503
+
504
+ const sourceFolders = app.folders.whose({name: sourceName})();
505
+ if (sourceFolders.length === 0) {
506
+ throw new Error("Source folder not found: " + sourceName);
507
+ }
508
+
509
+ const targetFolders = app.folders.whose({name: targetName})();
510
+ if (targetFolders.length === 0) {
511
+ throw new Error("Target folder not found: " + targetName);
512
+ }
513
+
514
+ const source = sourceFolders[0];
515
+ const target = targetFolders[0];
516
+ const notes = source.notes();
517
+ let movedCount = 0;
518
+
519
+ // Move in reverse order to avoid index shifting
520
+ for (let i = notes.length - 1; i >= 0; i--) {
521
+ try {
522
+ notes[i].move({to: target});
523
+ movedCount++;
524
+ } catch (e) {
525
+ // Continue on individual failures
526
+ }
527
+ }
528
+
529
+ return JSON.stringify({ movedCount });
530
+ `);
531
+
532
+ const { movedCount } = JSON.parse(jxaResult as string);
533
+ result.moved = movedCount;
534
+ } else if (titles) {
535
+ // Move individual notes
536
+ debug(`Batch moving ${titles.length} notes to "${targetFolder}"`);
537
+
538
+ for (const title of titles) {
539
+ try {
540
+ await moveNote(title, targetFolder);
541
+ result.moved++;
542
+ } catch (error) {
543
+ result.failed.push(title);
544
+ debug(`Failed to move "${title}":`, error);
545
+ }
546
+ }
547
+ }
548
+
549
+ debug(`Batch move complete: ${result.moved} moved, ${result.failed.length} failed`);
550
+ return result;
551
+ }
package/src/notes/read.ts CHANGED
@@ -40,9 +40,19 @@ export interface NoteDetails extends NoteInfo {
40
40
  }
41
41
 
42
42
  // -----------------------------------------------------------------------------
43
- // Internal JXA helpers
43
+ // Internal types and helpers
44
44
  // -----------------------------------------------------------------------------
45
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
+
46
56
  /**
47
57
  * Execute JXA code safely with error handling
48
58
  */
@@ -58,6 +68,21 @@ async function executeJxa<T>(code: string): Promise<T> {
58
68
  }
59
69
  }
60
70
 
71
+ /**
72
+ * Convert raw JXA note data to NoteDetails with markdown content
73
+ */
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
+ };
84
+ }
85
+
61
86
  // -----------------------------------------------------------------------------
62
87
  // Public API
63
88
  // -----------------------------------------------------------------------------
@@ -180,14 +205,7 @@ export async function getNoteByTitle(
180
205
  `;
181
206
 
182
207
  const result = await executeJxa<string>(jxaCode);
183
- const notes = JSON.parse(result) as Array<{
184
- id: string;
185
- title: string;
186
- folder: string;
187
- created: string;
188
- modified: string;
189
- htmlContent: string;
190
- }>;
208
+ const notes = JSON.parse(result) as RawNoteData[];
191
209
 
192
210
  if (notes.length === 0) {
193
211
  debug("Note not found");
@@ -196,27 +214,11 @@ export async function getNoteByTitle(
196
214
 
197
215
  if (notes.length > 1) {
198
216
  debug(`Multiple notes found with title: ${targetTitle}`);
199
- // If folder wasn't specified and multiple exist, return the first one
200
- // but log a warning
201
- debug(
202
- "Returning first match. Use folder/title format for disambiguation."
203
- );
217
+ debug("Returning first match. Use folder/title format for disambiguation.");
204
218
  }
205
219
 
206
- const note = notes[0];
207
- const content = htmlToMarkdown(note.htmlContent);
208
-
209
- debug(`Found note in folder: ${note.folder}`);
210
-
211
- return {
212
- id: note.id,
213
- title: note.title,
214
- folder: note.folder,
215
- created: note.created,
216
- modified: note.modified,
217
- content,
218
- htmlContent: note.htmlContent,
219
- };
220
+ debug(`Found note in folder: ${notes[0].folder}`);
221
+ return toNoteDetails(notes[0]);
220
222
  }
221
223
 
222
224
  /**
@@ -268,33 +270,15 @@ export async function getNoteById(id: string): Promise<NoteDetails | null> {
268
270
  `;
269
271
 
270
272
  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;
273
+ const note = JSON.parse(result) as RawNoteData | null;
279
274
 
280
275
  if (!note) {
281
276
  debug("Note not found by ID");
282
277
  return null;
283
278
  }
284
279
 
285
- const content = htmlToMarkdown(note.htmlContent);
286
-
287
280
  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
- };
281
+ return toNoteDetails(note);
298
282
  }
299
283
 
300
284
  /**
@@ -357,34 +341,65 @@ export async function getNoteByFolderAndTitle(
357
341
  `;
358
342
 
359
343
  const result = await executeJxa<string>(jxaCode);
360
- const notes = JSON.parse(result) as Array<{
361
- id: string;
362
- title: string;
363
- folder: string;
364
- created: string;
365
- modified: string;
366
- htmlContent: string;
367
- }>;
344
+ const notes = JSON.parse(result) as RawNoteData[];
368
345
 
369
346
  if (notes.length === 0) {
370
347
  debug("Note not found");
371
348
  return null;
372
349
  }
373
350
 
374
- const note = notes[0];
375
- const content = htmlToMarkdown(note.htmlContent);
351
+ debug(`Found note in folder: ${notes[0].folder}`);
352
+ return toNoteDetails(notes[0]);
353
+ }
376
354
 
377
- debug(`Found note in folder: ${note.folder}`);
355
+ /**
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.
359
+ *
360
+ * @returns Array of note details with content
361
+ */
362
+ export async function getAllNotesWithContent(): Promise<NoteDetails[]> {
363
+ debug("Getting all notes with content (single JXA call)...");
378
364
 
379
- return {
380
- id: note.id,
381
- title: note.title,
382
- folder: note.folder,
383
- created: note.created,
384
- modified: note.modified,
385
- content,
386
- htmlContent: note.htmlContent,
387
- };
365
+ const jxaCode = `
366
+ const app = Application('Notes');
367
+ app.includeStandardAdditions = true;
368
+
369
+ const allNotes = [];
370
+ const folders = app.folders();
371
+
372
+ for (const folder of folders) {
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
+ }
391
+ }
392
+ }
393
+
394
+ return JSON.stringify(allNotes);
395
+ `;
396
+
397
+ const result = await executeJxa<string>(jxaCode);
398
+ const notes = JSON.parse(result) as RawNoteData[];
399
+
400
+ debug(`Fetched ${notes.length} notes with content`);
401
+
402
+ return notes.map(toNoteDetails);
388
403
  }
389
404
 
390
405
  /**