@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,257 @@
1
+ /**
2
+ * Apple Notes CRUD operations using JXA (JavaScript for Automation).
3
+ *
4
+ * All write operations respect READONLY_MODE environment variable.
5
+ * Content is converted from Markdown to HTML before writing to Apple Notes.
6
+ */
7
+
8
+ import { runJxa } from "run-jxa";
9
+ import { marked } from "marked";
10
+ import { resolveNoteTitle } from "./read.js";
11
+ import { createDebugLogger } from "../utils/debug.js";
12
+
13
+ // Debug logging
14
+ const debug = createDebugLogger("CRUD");
15
+
16
+ /**
17
+ * Check if READONLY_MODE is enabled and throw if so.
18
+ * Call this at the start of every write operation.
19
+ *
20
+ * @throws Error if READONLY_MODE=true
21
+ */
22
+ export function checkReadOnly(): void {
23
+ if (process.env.READONLY_MODE === "true") {
24
+ throw new Error("Operation disabled in read-only mode");
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Convert Markdown content to HTML for Apple Notes.
30
+ *
31
+ * @param markdown - Markdown content
32
+ * @returns HTML string
33
+ */
34
+ function markdownToHtml(markdown: string): string {
35
+ // Configure marked for clean HTML output
36
+ const html = marked.parse(markdown, {
37
+ async: false,
38
+ gfm: true,
39
+ breaks: true,
40
+ }) as string;
41
+
42
+ return html;
43
+ }
44
+
45
+ /**
46
+ * Create a new note in Apple Notes.
47
+ *
48
+ * @param title - Note title
49
+ * @param content - Note content (Markdown)
50
+ * @param folder - Optional target folder (defaults to Notes)
51
+ * @throws Error if READONLY_MODE is enabled
52
+ * @throws Error if note with same title already exists
53
+ */
54
+ export async function createNote(
55
+ title: string,
56
+ content: string,
57
+ folder?: string
58
+ ): Promise<void> {
59
+ checkReadOnly();
60
+
61
+ debug(`Creating note: "${title}" in folder: "${folder || "Notes"}"`);
62
+
63
+ // Convert Markdown to HTML
64
+ const htmlContent = markdownToHtml(content);
65
+ const escapedTitle = JSON.stringify(title);
66
+ const escapedContent = JSON.stringify(htmlContent);
67
+ const escapedFolder = folder ? JSON.stringify(folder) : "null";
68
+
69
+ debug(`HTML content length: ${htmlContent.length}`);
70
+
71
+ await runJxa(`
72
+ const app = Application('Notes');
73
+ const title = ${escapedTitle};
74
+ const content = ${escapedContent};
75
+ const folderName = ${escapedFolder};
76
+
77
+ let targetFolder = null;
78
+
79
+ if (folderName) {
80
+ // Find the specified folder
81
+ const folders = app.folders.whose({name: folderName})();
82
+ if (folders.length === 0) {
83
+ throw new Error("Folder not found: " + folderName);
84
+ }
85
+ targetFolder = folders[0];
86
+ } else {
87
+ // Use default folder (first account's default folder)
88
+ targetFolder = app.defaultAccount().defaultFolder();
89
+ }
90
+
91
+ // Create the note in the target folder
92
+ const note = app.Note({name: title, body: content});
93
+ targetFolder.notes.push(note);
94
+
95
+ return "ok";
96
+ `);
97
+
98
+ debug(`Note created: "${title}"`);
99
+ }
100
+
101
+ /**
102
+ * Update an existing note's content.
103
+ *
104
+ * @param title - Note title (supports folder prefix: "Work/My Note")
105
+ * @param content - New content (Markdown)
106
+ * @throws Error if READONLY_MODE is enabled
107
+ * @throws Error if note not found or duplicate titles without folder prefix
108
+ */
109
+ export async function updateNote(title: string, content: string): Promise<void> {
110
+ checkReadOnly();
111
+
112
+ debug(`Updating note: "${title}"`);
113
+
114
+ // Resolve the note to get its ID
115
+ const resolved = await resolveNoteTitle(title);
116
+
117
+ if (!resolved.success || !resolved.note) {
118
+ if (resolved.suggestions && resolved.suggestions.length > 0) {
119
+ throw new Error(
120
+ `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
121
+ );
122
+ }
123
+ throw new Error(resolved.error || `Note not found: "${title}"`);
124
+ }
125
+
126
+ // Convert Markdown to HTML
127
+ const htmlContent = markdownToHtml(content);
128
+ const escapedNoteId = JSON.stringify(resolved.note.id);
129
+ const escapedContent = JSON.stringify(htmlContent);
130
+
131
+ debug(`HTML content length: ${htmlContent.length}`);
132
+
133
+ await runJxa(`
134
+ const app = Application('Notes');
135
+ const noteId = ${escapedNoteId};
136
+ const content = ${escapedContent};
137
+
138
+ // Find the note by ID
139
+ const note = app.notes.byId(noteId);
140
+
141
+ if (!note.exists()) {
142
+ throw new Error("Note no longer exists");
143
+ }
144
+
145
+ // Update the body
146
+ note.body = content;
147
+
148
+ return "ok";
149
+ `);
150
+
151
+ debug(`Note updated: "${title}"`);
152
+ }
153
+
154
+ /**
155
+ * Delete a note from Apple Notes.
156
+ *
157
+ * IMPORTANT: The caller must verify that confirm=true before calling this function.
158
+ * This function does NOT check the confirm parameter.
159
+ *
160
+ * @param title - Note title (supports folder prefix: "Work/My Note")
161
+ * @throws Error if READONLY_MODE is enabled
162
+ * @throws Error if note not found or duplicate titles without folder prefix
163
+ */
164
+ export async function deleteNote(title: string): Promise<void> {
165
+ checkReadOnly();
166
+
167
+ debug(`Deleting note: "${title}"`);
168
+
169
+ // Resolve the note to get its ID
170
+ const resolved = await resolveNoteTitle(title);
171
+
172
+ if (!resolved.success || !resolved.note) {
173
+ if (resolved.suggestions && resolved.suggestions.length > 0) {
174
+ throw new Error(
175
+ `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
176
+ );
177
+ }
178
+ throw new Error(resolved.error || `Note not found: "${title}"`);
179
+ }
180
+
181
+ const escapedNoteId = JSON.stringify(resolved.note.id);
182
+
183
+ await runJxa(`
184
+ const app = Application('Notes');
185
+ const noteId = ${escapedNoteId};
186
+
187
+ // Find the note by ID
188
+ const note = app.notes.byId(noteId);
189
+
190
+ if (!note.exists()) {
191
+ throw new Error("Note no longer exists");
192
+ }
193
+
194
+ // Delete the note
195
+ note.delete();
196
+
197
+ return "ok";
198
+ `);
199
+
200
+ debug(`Note deleted: "${title}"`);
201
+ }
202
+
203
+ /**
204
+ * Move a note to a different folder.
205
+ *
206
+ * @param title - Note title (supports folder prefix: "Work/My Note")
207
+ * @param folder - Target folder name
208
+ * @throws Error if READONLY_MODE is enabled
209
+ * @throws Error if note not found or target folder not found
210
+ */
211
+ export async function moveNote(title: string, folder: string): Promise<void> {
212
+ checkReadOnly();
213
+
214
+ debug(`Moving note: "${title}" to folder: "${folder}"`);
215
+
216
+ // Resolve the note to get its ID
217
+ const resolved = await resolveNoteTitle(title);
218
+
219
+ if (!resolved.success || !resolved.note) {
220
+ if (resolved.suggestions && resolved.suggestions.length > 0) {
221
+ throw new Error(
222
+ `${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
223
+ );
224
+ }
225
+ throw new Error(resolved.error || `Note not found: "${title}"`);
226
+ }
227
+
228
+ const escapedNoteId = JSON.stringify(resolved.note.id);
229
+ const escapedFolder = JSON.stringify(folder);
230
+
231
+ await runJxa(`
232
+ const app = Application('Notes');
233
+ const noteId = ${escapedNoteId};
234
+ const folderName = ${escapedFolder};
235
+
236
+ // Find the target folder
237
+ const folders = app.folders.whose({name: folderName})();
238
+ if (folders.length === 0) {
239
+ throw new Error("Folder not found: " + folderName);
240
+ }
241
+ const targetFolder = folders[0];
242
+
243
+ // Find the note by ID
244
+ const note = app.notes.byId(noteId);
245
+
246
+ if (!note.exists()) {
247
+ throw new Error("Note no longer exists");
248
+ }
249
+
250
+ // Move the note to the target folder
251
+ note.move({to: targetFolder});
252
+
253
+ return "ok";
254
+ `);
255
+
256
+ debug(`Note moved: "${title}" -> "${folder}"`);
257
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock run-jxa before importing read module
4
+ vi.mock("run-jxa", () => ({
5
+ runJxa: vi.fn(),
6
+ }));
7
+
8
+ import { runJxa } from "run-jxa";
9
+ import { getAllNotes, getNoteByTitle, getAllFolders, resolveNoteTitle } from "./read.js";
10
+
11
+ describe("getAllNotes", () => {
12
+ beforeEach(() => {
13
+ vi.clearAllMocks();
14
+ });
15
+
16
+ it("should return empty array when no notes exist", async () => {
17
+ vi.mocked(runJxa).mockResolvedValueOnce("[]");
18
+
19
+ const notes = await getAllNotes();
20
+ expect(notes).toEqual([]);
21
+ });
22
+
23
+ it("should return notes with metadata", async () => {
24
+ const mockNotes = [
25
+ { title: "Note 1", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-02T00:00:00Z" },
26
+ { title: "Note 2", folder: "Personal", created: "2024-01-03T00:00:00Z", modified: "2024-01-04T00:00:00Z" },
27
+ ];
28
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
29
+
30
+ const notes = await getAllNotes();
31
+ expect(notes).toHaveLength(2);
32
+ expect(notes[0].title).toBe("Note 1");
33
+ expect(notes[1].folder).toBe("Personal");
34
+ });
35
+ });
36
+
37
+ describe("getNoteByTitle", () => {
38
+ beforeEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ it("should return null when note not found", async () => {
43
+ vi.mocked(runJxa).mockResolvedValueOnce("[]");
44
+
45
+ const note = await getNoteByTitle("Missing Note");
46
+ expect(note).toBeNull();
47
+ });
48
+
49
+ it("should return note with content", async () => {
50
+ const mockNotes = [{
51
+ id: "123",
52
+ title: "My Note",
53
+ folder: "Work",
54
+ created: "2024-01-01T00:00:00Z",
55
+ modified: "2024-01-02T00:00:00Z",
56
+ htmlContent: "<p>Hello World</p>",
57
+ }];
58
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
59
+
60
+ const note = await getNoteByTitle("My Note");
61
+ expect(note).not.toBeNull();
62
+ expect(note?.title).toBe("My Note");
63
+ expect(note?.content).toContain("Hello World");
64
+ });
65
+
66
+ it("should handle folder/title format", async () => {
67
+ const mockNotes = [{
68
+ id: "123",
69
+ title: "Note",
70
+ folder: "Work",
71
+ created: "2024-01-01T00:00:00Z",
72
+ modified: "2024-01-02T00:00:00Z",
73
+ htmlContent: "<p>Content</p>",
74
+ }];
75
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
76
+
77
+ const note = await getNoteByTitle("Work/Note");
78
+ expect(note).not.toBeNull();
79
+ expect(note?.folder).toBe("Work");
80
+ });
81
+ });
82
+
83
+ describe("getAllFolders", () => {
84
+ beforeEach(() => {
85
+ vi.clearAllMocks();
86
+ });
87
+
88
+ it("should return folder names", async () => {
89
+ const mockFolders = ["Work", "Personal", "Archive"];
90
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockFolders));
91
+
92
+ const folders = await getAllFolders();
93
+ expect(folders).toEqual(["Work", "Personal", "Archive"]);
94
+ });
95
+ });
96
+
97
+ describe("resolveNoteTitle", () => {
98
+ beforeEach(() => {
99
+ vi.clearAllMocks();
100
+ });
101
+
102
+ it("should return error when no notes found", async () => {
103
+ vi.mocked(runJxa).mockResolvedValueOnce("[]");
104
+
105
+ const result = await resolveNoteTitle("Missing");
106
+ expect(result.success).toBe(false);
107
+ expect(result.error).toContain("not found");
108
+ });
109
+
110
+ it("should return note when exactly one match", async () => {
111
+ const mockNotes = [{ id: "123", title: "My Note", folder: "Work" }];
112
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
113
+
114
+ const result = await resolveNoteTitle("My Note");
115
+ expect(result.success).toBe(true);
116
+ expect(result.note?.id).toBe("123");
117
+ });
118
+
119
+ it("should return suggestions when multiple matches", async () => {
120
+ const mockNotes = [
121
+ { id: "123", title: "Note", folder: "Work" },
122
+ { id: "456", title: "Note", folder: "Personal" },
123
+ ];
124
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
125
+
126
+ const result = await resolveNoteTitle("Note");
127
+ expect(result.success).toBe(false);
128
+ expect(result.suggestions).toHaveLength(2);
129
+ expect(result.suggestions).toContain("Work/Note");
130
+ });
131
+ });