@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.
Files changed (49) hide show
  1. package/README.md +104 -24
  2. package/package.json +11 -12
  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 +254 -2
  10. package/src/db/lancedb.ts +385 -38
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +22 -4
  14. package/src/embeddings/local.ts +57 -17
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/errors/index.test.ts +64 -0
  17. package/src/errors/index.ts +62 -0
  18. package/src/graph/export.test.ts +81 -0
  19. package/src/graph/export.ts +163 -0
  20. package/src/graph/extract.test.ts +90 -0
  21. package/src/graph/extract.ts +52 -0
  22. package/src/graph/queries.test.ts +156 -0
  23. package/src/graph/queries.ts +224 -0
  24. package/src/index.ts +309 -23
  25. package/src/notes/conversion.ts +62 -0
  26. package/src/notes/crud.test.ts +41 -8
  27. package/src/notes/crud.ts +75 -64
  28. package/src/notes/read.test.ts +58 -3
  29. package/src/notes/read.ts +142 -210
  30. package/src/notes/resolve.ts +174 -0
  31. package/src/notes/tables.ts +69 -40
  32. package/src/search/chunk-indexer.test.ts +353 -0
  33. package/src/search/chunk-indexer.ts +207 -0
  34. package/src/search/chunk-search.test.ts +327 -0
  35. package/src/search/chunk-search.ts +298 -0
  36. package/src/search/index.ts +4 -6
  37. package/src/search/indexer.ts +164 -109
  38. package/src/setup.ts +46 -67
  39. package/src/types/index.ts +4 -0
  40. package/src/utils/chunker.test.ts +182 -0
  41. package/src/utils/chunker.ts +170 -0
  42. package/src/utils/content-filter.test.ts +225 -0
  43. package/src/utils/content-filter.ts +275 -0
  44. package/src/utils/debug.ts +0 -2
  45. package/src/utils/runtime.test.ts +70 -0
  46. package/src/utils/runtime.ts +40 -0
  47. package/src/utils/text.test.ts +32 -0
  48. package/CLAUDE.md +0 -56
  49. package/src/server.ts +0 -427
@@ -0,0 +1,62 @@
1
+ /**
2
+ * HTML to Markdown conversion for Apple Notes content.
3
+ */
4
+
5
+ import TurndownService from "turndown";
6
+
7
+ // Initialize Turndown for HTML to Markdown conversion
8
+ const turndownService = new TurndownService({
9
+ headingStyle: "atx",
10
+ codeBlockStyle: "fenced",
11
+ bulletListMarker: "-",
12
+ });
13
+
14
+ // Add custom rule to handle Apple Notes attachment placeholders
15
+ turndownService.addRule("attachments", {
16
+ filter: (node) => {
17
+ // Apple Notes uses object tags for attachments
18
+ return node.nodeName === "OBJECT" || node.nodeName === "IMG";
19
+ },
20
+ replacement: (_content, node) => {
21
+ // Turndown uses its own Node type, cast to access attributes
22
+ const element = node as unknown as {
23
+ getAttribute: (name: string) => string | null;
24
+ };
25
+ const filename =
26
+ element.getAttribute("data-filename") ||
27
+ element.getAttribute("alt") ||
28
+ element.getAttribute("src")?.split("/").pop() ||
29
+ "unknown";
30
+ return `[Attachment: ${filename}]`;
31
+ },
32
+ });
33
+
34
+ /**
35
+ * Convert HTML content to Markdown, handling Apple Notes specifics.
36
+ */
37
+ export function htmlToMarkdown(html: string): string {
38
+ if (!html) return "";
39
+
40
+ // Pre-process: handle Apple Notes specific markup
41
+ let processed = html;
42
+
43
+ // Replace attachment objects with placeholder text before Turndown
44
+ processed = processed.replace(
45
+ /<object[^>]*data-filename="([^"]*)"[^>]*>.*?<\/object>/gi,
46
+ "[Attachment: $1]"
47
+ );
48
+
49
+ // Handle inline images
50
+ processed = processed.replace(
51
+ /<img[^>]*(?:alt="([^"]*)")?[^>]*>/gi,
52
+ (_match, alt) => {
53
+ const filename = alt || "image";
54
+ return `[Attachment: ${filename}]`;
55
+ }
56
+ );
57
+
58
+ // Convert to Markdown
59
+ const markdown = turndownService.turndown(processed);
60
+
61
+ return markdown.trim();
62
+ }
@@ -111,18 +111,45 @@ describe("updateNote", () => {
111
111
  success: true,
112
112
  note: { id: "123", title: "Test", folder: "Work" },
113
113
  });
114
- vi.mocked(runJxa).mockResolvedValueOnce("ok");
114
+ // Mock JXA returning the new title (same as original in this case)
115
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ newTitle: "Test" }));
116
+
117
+ const result = await updateNote("Test", "New Content");
118
+ expect(result).toEqual({
119
+ originalTitle: "Test",
120
+ newTitle: "Test",
121
+ folder: "Work",
122
+ titleChanged: false,
123
+ });
124
+ });
115
125
 
116
- await expect(updateNote("Test", "New Content")).resolves.toBeUndefined();
126
+ it("should detect when Apple Notes renames the note", async () => {
127
+ vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
128
+ success: true,
129
+ note: { id: "123", title: "Original Title", folder: "Work" },
130
+ });
131
+ // Mock JXA returning a different title (Apple Notes renamed it)
132
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ newTitle: "New Heading" }));
133
+
134
+ const result = await updateNote("Original Title", "# New Heading\n\nContent");
135
+ expect(result).toEqual({
136
+ originalTitle: "Original Title",
137
+ newTitle: "New Heading",
138
+ folder: "Work",
139
+ titleChanged: true,
140
+ });
117
141
  });
118
142
 
119
143
  it("should include suggestions in error when multiple notes found", async () => {
120
144
  vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
121
145
  success: false,
122
146
  error: "Multiple notes found",
123
- suggestions: ["Work/Note", "Personal/Note"],
147
+ suggestions: [
148
+ { id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
149
+ { id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
150
+ ],
124
151
  });
125
- await expect(updateNote("Note", "Content")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
152
+ await expect(updateNote("Note", "Content")).rejects.toThrow("Use ID prefix");
126
153
  });
127
154
  });
128
155
 
@@ -159,9 +186,12 @@ describe("deleteNote", () => {
159
186
  vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
160
187
  success: false,
161
188
  error: "Multiple notes found",
162
- suggestions: ["Work/Note", "Personal/Note"],
189
+ suggestions: [
190
+ { id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
191
+ { id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
192
+ ],
163
193
  });
164
- await expect(deleteNote("Note")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
194
+ await expect(deleteNote("Note")).rejects.toThrow("Use ID prefix");
165
195
  });
166
196
  });
167
197
 
@@ -198,9 +228,12 @@ describe("moveNote", () => {
198
228
  vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
199
229
  success: false,
200
230
  error: "Multiple notes found",
201
- suggestions: ["Work/Note", "Personal/Note"],
231
+ suggestions: [
232
+ { id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
233
+ { id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
234
+ ],
202
235
  });
203
- await expect(moveNote("Note", "Archive")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
236
+ await expect(moveNote("Note", "Archive")).rejects.toThrow("Use ID prefix");
204
237
  });
205
238
  });
206
239
 
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
  /**
@@ -99,39 +115,52 @@ export async function createNote(
99
115
  debug(`Note created: "${title}"`);
100
116
  }
101
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
+
102
133
  /**
103
134
  * Update an existing note's content.
104
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
+ *
105
140
  * @param title - Note title (supports folder prefix: "Work/My Note")
106
141
  * @param content - New content (Markdown)
142
+ * @returns UpdateResult with original and new title
107
143
  * @throws Error if READONLY_MODE is enabled
108
144
  * @throws Error if note not found or duplicate titles without folder prefix
109
145
  */
110
- export async function updateNote(title: string, content: string): Promise<void> {
146
+ export async function updateNote(title: string, content: string): Promise<UpdateResult> {
111
147
  checkReadOnly();
112
148
 
113
149
  debug(`Updating note: "${title}"`);
114
150
 
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
- }
151
+ const note = await resolveNoteOrThrow(title);
152
+ const originalTitle = note.title;
153
+ const folder = note.folder;
126
154
 
127
155
  // Convert Markdown to HTML
128
156
  const htmlContent = markdownToHtml(content);
129
- const escapedNoteId = JSON.stringify(resolved.note.id);
157
+ const escapedNoteId = JSON.stringify(note.id);
130
158
  const escapedContent = JSON.stringify(htmlContent);
131
159
 
132
160
  debug(`HTML content length: ${htmlContent.length}`);
133
161
 
134
- await runJxa(`
162
+ // Update the note and get its new title (Apple Notes may rename it)
163
+ const result = await runJxa(`
135
164
  const app = Application('Notes');
136
165
  const noteId = ${escapedNoteId};
137
166
  const content = ${escapedContent};
@@ -146,10 +175,25 @@ export async function updateNote(title: string, content: string): Promise<void>
146
175
  // Update the body
147
176
  note.body = content;
148
177
 
149
- return "ok";
150
- `);
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;
151
184
 
152
- debug(`Note updated: "${title}"`);
185
+ if (titleChanged) {
186
+ debug(`Note renamed by Apple Notes: "${originalTitle}" -> "${newTitle}"`);
187
+ } else {
188
+ debug(`Note updated: "${title}"`);
189
+ }
190
+
191
+ return {
192
+ originalTitle,
193
+ newTitle,
194
+ folder,
195
+ titleChanged,
196
+ };
153
197
  }
154
198
 
155
199
  /**
@@ -167,19 +211,8 @@ export async function deleteNote(title: string): Promise<void> {
167
211
 
168
212
  debug(`Deleting note: "${title}"`);
169
213
 
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);
214
+ const note = await resolveNoteOrThrow(title);
215
+ const escapedNoteId = JSON.stringify(note.id);
183
216
 
184
217
  await runJxa(`
185
218
  const app = Application('Notes');
@@ -214,19 +247,8 @@ export async function moveNote(title: string, folder: string): Promise<void> {
214
247
 
215
248
  debug(`Moving note: "${title}" to folder: "${folder}"`);
216
249
 
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);
250
+ const note = await resolveNoteOrThrow(title);
251
+ const escapedNoteId = JSON.stringify(note.id);
230
252
  const escapedFolder = JSON.stringify(folder);
231
253
 
232
254
  await runJxa(`
@@ -279,19 +301,8 @@ export async function editTable(
279
301
 
280
302
  debug(`Editing table ${tableIndex} in note: "${title}"`);
281
303
 
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);
304
+ const note = await resolveNoteOrThrow(title);
305
+ const escapedNoteId = JSON.stringify(note.id);
295
306
  const htmlResult = await runJxa(`
296
307
  const app = Application('Notes');
297
308
  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
  });