@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.
@@ -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
+ });