apple-notes-mcp 1.1.1 → 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/README.md +39 -0
- package/build/index.js +83 -14
- package/build/services/appleNotesManager.js +174 -28
- package/build/services/appleNotesManager.test.js +45 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,17 @@ Install the apple-notes-mcp MCP server so you can help me manage my Apple Notes
|
|
|
30
30
|
|
|
31
31
|
Claude will handle the installation and configuration automatically.
|
|
32
32
|
|
|
33
|
+
### Using the Plugin Marketplace
|
|
34
|
+
|
|
35
|
+
Install as a Claude Code plugin for automatic configuration and enhanced AI behavior:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
/plugin marketplace add sweetrb/apple-notes-mcp
|
|
39
|
+
/plugin install apple-notes
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.
|
|
43
|
+
|
|
33
44
|
### Manual Installation
|
|
34
45
|
|
|
35
46
|
**1. Install the server:**
|
|
@@ -471,6 +482,29 @@ If installed from source, use this configuration:
|
|
|
471
482
|
| No rich formatting | Content is HTML; complex formatting may not render |
|
|
472
483
|
| Title matching | Most operations require exact title matches |
|
|
473
484
|
|
|
485
|
+
### Backslash Escaping (Important for AI Agents)
|
|
486
|
+
|
|
487
|
+
When sending content containing backslashes (`\`) to this MCP server, **you must escape them as `\\`** in the JSON parameters.
|
|
488
|
+
|
|
489
|
+
**Why:** The MCP protocol uses JSON for parameter passing. In JSON, a single backslash is an escape character. To include a literal backslash in content, it must be escaped as `\\`.
|
|
490
|
+
|
|
491
|
+
**Example - Shell command with escaped path:**
|
|
492
|
+
```json
|
|
493
|
+
{
|
|
494
|
+
"title": "Install Script",
|
|
495
|
+
"content": "cp ~/Library/Mobile\\\\ Documents/file.txt ~/.config/"
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The `\\\\` in JSON becomes `\\` in the actual string, which represents a single `\` in the note.
|
|
500
|
+
|
|
501
|
+
**Common patterns requiring escaping:**
|
|
502
|
+
- Shell escaped spaces: `Mobile\ Documents` → `Mobile\\\\ Documents` in JSON
|
|
503
|
+
- Windows paths: `C:\Users\` → `C:\\\\Users\\\\` in JSON
|
|
504
|
+
- Regex patterns: `\d+` → `\\\\d+` in JSON
|
|
505
|
+
|
|
506
|
+
**If you see errors** when creating/updating notes with backslashes, double-check that backslashes are properly escaped in the JSON payload.
|
|
507
|
+
|
|
474
508
|
---
|
|
475
509
|
|
|
476
510
|
## Troubleshooting
|
|
@@ -490,6 +524,11 @@ If installed from source, use this configuration:
|
|
|
490
524
|
- Check if the note is in a different account
|
|
491
525
|
- Use `list-notes` to see available notes
|
|
492
526
|
|
|
527
|
+
### Note creation/update fails silently with backslashes
|
|
528
|
+
- Content containing `\` characters requires JSON escaping
|
|
529
|
+
- Use `\\` to represent each literal backslash
|
|
530
|
+
- See "Backslash Escaping" section under Known Limitations
|
|
531
|
+
|
|
493
532
|
---
|
|
494
533
|
|
|
495
534
|
## Development
|
package/build/index.js
CHANGED
|
@@ -31,7 +31,7 @@ import { AppleNotesManager } from "./services/appleNotesManager.js";
|
|
|
31
31
|
*/
|
|
32
32
|
const server = new McpServer({
|
|
33
33
|
name: "apple-notes",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.2.0",
|
|
35
35
|
description: "MCP server for managing Apple Notes - create, search, update, and organize notes",
|
|
36
36
|
});
|
|
37
37
|
/**
|
|
@@ -113,7 +113,7 @@ server.tool("create-note", {
|
|
|
113
113
|
if (!note) {
|
|
114
114
|
return errorResponse(`Failed to create note "${title}". Check that Notes.app is configured and accessible.`);
|
|
115
115
|
}
|
|
116
|
-
return successResponse(`Note created: "${note.title}"`);
|
|
116
|
+
return successResponse(`Note created: "${note.title}" [id: ${note.id}]`);
|
|
117
117
|
}, "Error creating note"));
|
|
118
118
|
// --- search-notes ---
|
|
119
119
|
server.tool("search-notes", {
|
|
@@ -126,22 +126,42 @@ server.tool("search-notes", {
|
|
|
126
126
|
if (notes.length === 0) {
|
|
127
127
|
return successResponse(`No notes found matching "${query}" in ${searchType}`);
|
|
128
128
|
}
|
|
129
|
-
// Format each note with folder info, highlighting Recently Deleted
|
|
129
|
+
// Format each note with ID and folder info, highlighting Recently Deleted
|
|
130
130
|
const noteList = notes
|
|
131
131
|
.map((n) => {
|
|
132
|
+
const idSuffix = n.id ? ` [id: ${n.id}]` : "";
|
|
132
133
|
if (n.folder === "Recently Deleted") {
|
|
133
|
-
return ` - ${n.title} [DELETED]`;
|
|
134
|
+
return ` - ${n.title} [DELETED]${idSuffix}`;
|
|
134
135
|
}
|
|
135
136
|
else if (n.folder) {
|
|
136
|
-
return ` - ${n.title} (${n.folder})`;
|
|
137
|
+
return ` - ${n.title} (${n.folder})${idSuffix}`;
|
|
137
138
|
}
|
|
138
|
-
return ` - ${n.title}`;
|
|
139
|
+
return ` - ${n.title}${idSuffix}`;
|
|
139
140
|
})
|
|
140
141
|
.join("\n");
|
|
141
142
|
return successResponse(`Found ${notes.length} notes (searched ${searchType}):\n${noteList}`);
|
|
142
143
|
}, "Error searching notes"));
|
|
143
144
|
// --- get-note-content ---
|
|
144
|
-
server.tool("get-note-content",
|
|
145
|
+
server.tool("get-note-content", {
|
|
146
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
147
|
+
title: z.string().optional().describe("Note title (use id instead when available)"),
|
|
148
|
+
account: z
|
|
149
|
+
.string()
|
|
150
|
+
.optional()
|
|
151
|
+
.describe("Account name (defaults to iCloud, ignored if id is provided)"),
|
|
152
|
+
}, withErrorHandling(({ id, title, account }) => {
|
|
153
|
+
// Prefer ID-based lookup if provided
|
|
154
|
+
if (id) {
|
|
155
|
+
const content = notesManager.getNoteContentById(id);
|
|
156
|
+
if (!content) {
|
|
157
|
+
return errorResponse(`Note with ID "${id}" not found`);
|
|
158
|
+
}
|
|
159
|
+
return successResponse(content);
|
|
160
|
+
}
|
|
161
|
+
// Fall back to title-based lookup
|
|
162
|
+
if (!title) {
|
|
163
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
164
|
+
}
|
|
145
165
|
const content = notesManager.getNoteContent(title, account);
|
|
146
166
|
if (!content) {
|
|
147
167
|
return errorResponse(`Note "${title}" not found`);
|
|
@@ -187,11 +207,28 @@ server.tool("get-note-details", noteTitleSchema, withErrorHandling(({ title, acc
|
|
|
187
207
|
}, "Error retrieving note details"));
|
|
188
208
|
// --- update-note ---
|
|
189
209
|
server.tool("update-note", {
|
|
190
|
-
|
|
210
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
211
|
+
title: z.string().optional().describe("Current note title (use id instead when available)"),
|
|
191
212
|
newTitle: z.string().optional().describe("New title for the note"),
|
|
192
213
|
newContent: z.string().min(1, "New content is required"),
|
|
193
|
-
account: z
|
|
194
|
-
|
|
214
|
+
account: z
|
|
215
|
+
.string()
|
|
216
|
+
.optional()
|
|
217
|
+
.describe("Account containing the note (ignored if id is provided)"),
|
|
218
|
+
}, withErrorHandling(({ id, title, newTitle, newContent, account }) => {
|
|
219
|
+
// Prefer ID-based update if provided
|
|
220
|
+
if (id) {
|
|
221
|
+
const success = notesManager.updateNoteById(id, newTitle, newContent);
|
|
222
|
+
if (!success) {
|
|
223
|
+
return errorResponse(`Failed to update note with ID "${id}". Note may not exist.`);
|
|
224
|
+
}
|
|
225
|
+
const displayTitle = newTitle || "(title preserved)";
|
|
226
|
+
return successResponse(`Note updated: "${displayTitle}"`);
|
|
227
|
+
}
|
|
228
|
+
// Fall back to title-based update
|
|
229
|
+
if (!title) {
|
|
230
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
231
|
+
}
|
|
195
232
|
const success = notesManager.updateNote(title, newTitle, newContent, account);
|
|
196
233
|
if (!success) {
|
|
197
234
|
return errorResponse(`Failed to update note "${title}". Note may not exist.`);
|
|
@@ -200,7 +237,26 @@ server.tool("update-note", {
|
|
|
200
237
|
return successResponse(`Note updated: "${finalTitle}"`);
|
|
201
238
|
}, "Error updating note"));
|
|
202
239
|
// --- delete-note ---
|
|
203
|
-
server.tool("delete-note",
|
|
240
|
+
server.tool("delete-note", {
|
|
241
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
242
|
+
title: z.string().optional().describe("Note title (use id instead when available)"),
|
|
243
|
+
account: z
|
|
244
|
+
.string()
|
|
245
|
+
.optional()
|
|
246
|
+
.describe("Account name (defaults to iCloud, ignored if id is provided)"),
|
|
247
|
+
}, withErrorHandling(({ id, title, account }) => {
|
|
248
|
+
// Prefer ID-based deletion if provided
|
|
249
|
+
if (id) {
|
|
250
|
+
const success = notesManager.deleteNoteById(id);
|
|
251
|
+
if (!success) {
|
|
252
|
+
return errorResponse(`Failed to delete note with ID "${id}". Note may not exist.`);
|
|
253
|
+
}
|
|
254
|
+
return successResponse(`Note deleted (by ID)`);
|
|
255
|
+
}
|
|
256
|
+
// Fall back to title-based deletion
|
|
257
|
+
if (!title) {
|
|
258
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
259
|
+
}
|
|
204
260
|
const success = notesManager.deleteNote(title, account);
|
|
205
261
|
if (!success) {
|
|
206
262
|
return errorResponse(`Failed to delete note "${title}". Note may not exist.`);
|
|
@@ -209,10 +265,23 @@ server.tool("delete-note", noteTitleSchema, withErrorHandling(({ title, account
|
|
|
209
265
|
}, "Error deleting note"));
|
|
210
266
|
// --- move-note ---
|
|
211
267
|
server.tool("move-note", {
|
|
212
|
-
|
|
268
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
269
|
+
title: z.string().optional().describe("Note title (use id instead when available)"),
|
|
213
270
|
folder: z.string().min(1, "Destination folder is required"),
|
|
214
|
-
account: z.string().optional().describe("Account containing the note"),
|
|
215
|
-
}, withErrorHandling(({ title, folder, account }) => {
|
|
271
|
+
account: z.string().optional().describe("Account containing the note/folder"),
|
|
272
|
+
}, withErrorHandling(({ id, title, folder, account }) => {
|
|
273
|
+
// Prefer ID-based move if provided
|
|
274
|
+
if (id) {
|
|
275
|
+
const success = notesManager.moveNoteById(id, folder, account);
|
|
276
|
+
if (!success) {
|
|
277
|
+
return errorResponse(`Failed to move note with ID "${id}" to folder "${folder}". Note or folder may not exist.`);
|
|
278
|
+
}
|
|
279
|
+
return successResponse(`Note moved to "${folder}" (by ID)`);
|
|
280
|
+
}
|
|
281
|
+
// Fall back to title-based move
|
|
282
|
+
if (!title) {
|
|
283
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
284
|
+
}
|
|
216
285
|
const success = notesManager.moveNote(title, folder, account);
|
|
217
286
|
if (!success) {
|
|
218
287
|
return errorResponse(`Failed to move note "${title}" to folder "${folder}". Note or folder may not exist.`);
|
|
@@ -46,16 +46,23 @@ export function escapeForAppleScript(text) {
|
|
|
46
46
|
if (!text) {
|
|
47
47
|
return "";
|
|
48
48
|
}
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
// Step
|
|
55
|
-
|
|
49
|
+
// Content goes inside AppleScript double-quoted strings: body:"content here"
|
|
50
|
+
// Within double-quoted AppleScript strings, we need to escape:
|
|
51
|
+
// 1. Backslashes (\ → \\) - AppleScript escape character
|
|
52
|
+
// 2. Double quotes (" → \") - String delimiter
|
|
53
|
+
// Single quotes do NOT need escaping in double-quoted AppleScript strings.
|
|
54
|
+
// Step 1: Encode HTML ampersands FIRST (before adding any HTML entities)
|
|
55
|
+
let escaped = text.replace(/&/g, "&");
|
|
56
|
+
// Step 2: Encode backslashes as HTML entities
|
|
57
|
+
// This avoids AppleScript escaping issues since Notes stores HTML
|
|
58
|
+
// Must happen AFTER ampersand encoding (so \ doesn't become \)
|
|
59
|
+
// and BEFORE double-quote escaping (so \" doesn't become \")
|
|
60
|
+
escaped = escaped.replace(/\\/g, "\");
|
|
61
|
+
// Step 3: Escape double quotes for AppleScript strings
|
|
62
|
+
// The backslash in \" is for AppleScript, not content, so it's added AFTER
|
|
63
|
+
// backslash encoding to avoid being HTML-encoded
|
|
56
64
|
escaped = escaped.replace(/"/g, '\\"');
|
|
57
|
-
// Step
|
|
58
|
-
// Apple Notes stores content as HTML, so we convert:
|
|
65
|
+
// Step 4: Convert control characters to HTML for Notes.app
|
|
59
66
|
// - Newlines (\n) to <br> tags
|
|
60
67
|
// - Tabs (\t) to <br> tags (better than for readability)
|
|
61
68
|
escaped = escaped.replace(/\n/g, "<br>");
|
|
@@ -255,15 +262,22 @@ export class AppleNotesManager {
|
|
|
255
262
|
const safeContent = escapeForAppleScript(content);
|
|
256
263
|
// Build the AppleScript command
|
|
257
264
|
// Notes.app uses 'name' for the title and 'body' for content
|
|
265
|
+
// We capture the ID of the newly created note
|
|
258
266
|
let createCommand;
|
|
259
267
|
if (folder) {
|
|
260
268
|
// Create note in specific folder
|
|
261
269
|
const safeFolder = escapeForAppleScript(folder);
|
|
262
|
-
createCommand = `
|
|
270
|
+
createCommand = `
|
|
271
|
+
set newNote to make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}
|
|
272
|
+
return id of newNote
|
|
273
|
+
`;
|
|
263
274
|
}
|
|
264
275
|
else {
|
|
265
276
|
// Create note in default location
|
|
266
|
-
createCommand = `
|
|
277
|
+
createCommand = `
|
|
278
|
+
set newNote to make new note with properties {name:"${safeTitle}", body:"${safeContent}"}
|
|
279
|
+
return id of newNote
|
|
280
|
+
`;
|
|
267
281
|
}
|
|
268
282
|
// Execute the script
|
|
269
283
|
const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
|
|
@@ -272,11 +286,12 @@ export class AppleNotesManager {
|
|
|
272
286
|
console.error(`Failed to create note "${title}":`, result.error);
|
|
273
287
|
return null;
|
|
274
288
|
}
|
|
275
|
-
//
|
|
276
|
-
|
|
289
|
+
// Extract the CoreData ID from the response
|
|
290
|
+
const noteId = result.output.trim();
|
|
291
|
+
// Return a Note object representing the created note with real ID
|
|
277
292
|
const now = new Date();
|
|
278
293
|
return {
|
|
279
|
-
id: Date.now().toString(),
|
|
294
|
+
id: noteId || Date.now().toString(), // Use real ID, fallback to timestamp
|
|
280
295
|
title,
|
|
281
296
|
content,
|
|
282
297
|
tags,
|
|
@@ -314,15 +329,25 @@ export class AppleNotesManager {
|
|
|
314
329
|
const whereClause = searchContent
|
|
315
330
|
? `body contains "${safeQuery}"`
|
|
316
331
|
: `name contains "${safeQuery}"`;
|
|
317
|
-
// Get names and folder for each matching note
|
|
318
|
-
// We use a repeat loop to get
|
|
332
|
+
// Get names, IDs, and folder for each matching note
|
|
333
|
+
// We use a repeat loop to get all properties, separated by a delimiter
|
|
334
|
+
// Note: Some notes may have inaccessible containers, so we wrap in try/on error
|
|
319
335
|
const searchCommand = `
|
|
320
336
|
set matchingNotes to notes where ${whereClause}
|
|
321
337
|
set resultList to {}
|
|
322
338
|
repeat with n in matchingNotes
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
339
|
+
try
|
|
340
|
+
set noteName to name of n
|
|
341
|
+
set noteId to id of n
|
|
342
|
+
set noteFolder to name of container of n
|
|
343
|
+
set end of resultList to noteName & "|||" & noteId & "|||" & noteFolder
|
|
344
|
+
on error
|
|
345
|
+
try
|
|
346
|
+
set noteName to name of n
|
|
347
|
+
set noteId to id of n
|
|
348
|
+
set end of resultList to noteName & "|||" & noteId & "|||" & "Notes"
|
|
349
|
+
end try
|
|
350
|
+
end try
|
|
326
351
|
end repeat
|
|
327
352
|
set AppleScript's text item delimiters to "|||ITEM|||"
|
|
328
353
|
return resultList as text
|
|
@@ -337,15 +362,15 @@ export class AppleNotesManager {
|
|
|
337
362
|
if (!result.output.trim()) {
|
|
338
363
|
return [];
|
|
339
364
|
}
|
|
340
|
-
// Parse the delimited output: "name|||folder|||ITEM|||name|||folder..."
|
|
365
|
+
// Parse the delimited output: "name|||id|||folder|||ITEM|||name|||id|||folder..."
|
|
341
366
|
const items = result.output.split("|||ITEM|||");
|
|
342
367
|
const notes = [];
|
|
343
368
|
for (const item of items) {
|
|
344
|
-
const [title, folder] = item.split("|||");
|
|
369
|
+
const [title, id, folder] = item.split("|||");
|
|
345
370
|
if (!title?.trim())
|
|
346
371
|
continue;
|
|
347
372
|
notes.push({
|
|
348
|
-
id: Date.now().toString(),
|
|
373
|
+
id: id?.trim() || Date.now().toString(), // Use real ID, fallback to timestamp
|
|
349
374
|
title: title.trim(),
|
|
350
375
|
content: "", // Not fetched in search
|
|
351
376
|
tags: [],
|
|
@@ -385,6 +410,26 @@ export class AppleNotesManager {
|
|
|
385
410
|
}
|
|
386
411
|
return result.output;
|
|
387
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Retrieves the HTML content of a note by its CoreData ID.
|
|
415
|
+
*
|
|
416
|
+
* This is more reliable than getNoteContent() because IDs are unique
|
|
417
|
+
* across all accounts, while titles can be duplicated.
|
|
418
|
+
*
|
|
419
|
+
* @param id - CoreData URL identifier for the note
|
|
420
|
+
* @returns HTML content of the note, or empty string if not found
|
|
421
|
+
*/
|
|
422
|
+
getNoteContentById(id) {
|
|
423
|
+
// Note IDs work at the application level, not scoped to account
|
|
424
|
+
const getCommand = `get body of note id "${id}"`;
|
|
425
|
+
const script = buildAppLevelScript(getCommand);
|
|
426
|
+
const result = executeAppleScript(script);
|
|
427
|
+
if (!result.success) {
|
|
428
|
+
console.error(`Failed to get content of note with ID "${id}":`, result.error);
|
|
429
|
+
return "";
|
|
430
|
+
}
|
|
431
|
+
return result.output;
|
|
432
|
+
}
|
|
388
433
|
/**
|
|
389
434
|
* Retrieves a note by its unique CoreData ID.
|
|
390
435
|
*
|
|
@@ -518,6 +563,25 @@ export class AppleNotesManager {
|
|
|
518
563
|
}
|
|
519
564
|
return true;
|
|
520
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* Deletes a note by its CoreData ID.
|
|
568
|
+
*
|
|
569
|
+
* This is more reliable than deleteNote() because IDs are unique
|
|
570
|
+
* across all accounts, while titles can be duplicated.
|
|
571
|
+
*
|
|
572
|
+
* @param id - CoreData URL identifier for the note
|
|
573
|
+
* @returns true if deletion succeeded, false otherwise
|
|
574
|
+
*/
|
|
575
|
+
deleteNoteById(id) {
|
|
576
|
+
const deleteCommand = `delete note id "${id}"`;
|
|
577
|
+
const script = buildAppLevelScript(deleteCommand);
|
|
578
|
+
const result = executeAppleScript(script);
|
|
579
|
+
if (!result.success) {
|
|
580
|
+
console.error(`Failed to delete note with ID "${id}":`, result.error);
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
521
585
|
/**
|
|
522
586
|
* Updates an existing note's content and optionally its title.
|
|
523
587
|
*
|
|
@@ -549,6 +613,41 @@ export class AppleNotesManager {
|
|
|
549
613
|
}
|
|
550
614
|
return true;
|
|
551
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Updates an existing note by its CoreData ID.
|
|
618
|
+
*
|
|
619
|
+
* This is more reliable than updateNote() because IDs are unique,
|
|
620
|
+
* while titles can be duplicated.
|
|
621
|
+
*
|
|
622
|
+
* @param id - CoreData URL identifier for the note
|
|
623
|
+
* @param newTitle - New title (optional, keeps existing if not provided)
|
|
624
|
+
* @param newContent - New content for the note body
|
|
625
|
+
* @returns true if update succeeded, false otherwise
|
|
626
|
+
*/
|
|
627
|
+
updateNoteById(id, newTitle, newContent) {
|
|
628
|
+
// First get the current title if newTitle is not provided
|
|
629
|
+
let effectiveTitle = newTitle;
|
|
630
|
+
if (!effectiveTitle) {
|
|
631
|
+
const note = this.getNoteById(id);
|
|
632
|
+
if (!note) {
|
|
633
|
+
console.error(`Cannot update note: note with ID "${id}" not found`);
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
effectiveTitle = note.title;
|
|
637
|
+
}
|
|
638
|
+
const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
|
|
639
|
+
const safeContent = escapeForAppleScript(newContent);
|
|
640
|
+
// Apple Notes uses HTML body; first <div> becomes the title
|
|
641
|
+
const fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
|
|
642
|
+
const updateCommand = `set body of note id "${id}" to "${fullBody}"`;
|
|
643
|
+
const script = buildAppLevelScript(updateCommand);
|
|
644
|
+
const result = executeAppleScript(script);
|
|
645
|
+
if (!result.success) {
|
|
646
|
+
console.error(`Failed to update note with ID "${id}":`, result.error);
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
}
|
|
552
651
|
/**
|
|
553
652
|
* Lists all notes in an account, optionally filtered by folder.
|
|
554
653
|
*
|
|
@@ -667,13 +766,19 @@ export class AppleNotesManager {
|
|
|
667
766
|
*/
|
|
668
767
|
moveNote(title, destinationFolder, account) {
|
|
669
768
|
const targetAccount = this.resolveAccount(account);
|
|
670
|
-
// Step 1:
|
|
769
|
+
// Step 1: Get the original note's ID first (before creating a copy with the same title)
|
|
770
|
+
const originalNote = this.getNoteDetails(title, targetAccount);
|
|
771
|
+
if (!originalNote) {
|
|
772
|
+
console.error(`Cannot move note "${title}": note not found`);
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
// Step 2: Retrieve the original note's content
|
|
671
776
|
const originalContent = this.getNoteContent(title, targetAccount);
|
|
672
777
|
if (!originalContent) {
|
|
673
778
|
console.error(`Cannot move note "${title}": failed to retrieve content`);
|
|
674
779
|
return false;
|
|
675
780
|
}
|
|
676
|
-
// Step
|
|
781
|
+
// Step 3: Create a copy in the destination folder
|
|
677
782
|
// We need to escape the HTML content for AppleScript embedding
|
|
678
783
|
const safeFolder = escapeForAppleScript(destinationFolder);
|
|
679
784
|
const safeContent = originalContent.replace(/"/g, '\\"').replace(/'/g, "'\\''");
|
|
@@ -684,16 +789,57 @@ export class AppleNotesManager {
|
|
|
684
789
|
console.error(`Cannot move note "${title}": failed to create in destination folder:`, copyResult.error);
|
|
685
790
|
return false;
|
|
686
791
|
}
|
|
687
|
-
// Step
|
|
688
|
-
const
|
|
689
|
-
|
|
792
|
+
// Step 4: Delete the original by ID (not by title, since there are now two notes with the same title)
|
|
793
|
+
const deleteCommand = `delete note id "${originalNote.id}"`;
|
|
794
|
+
const deleteScript = buildAppLevelScript(deleteCommand);
|
|
795
|
+
const deleteResult = executeAppleScript(deleteScript);
|
|
796
|
+
if (!deleteResult.success) {
|
|
690
797
|
// The note was copied successfully but we couldn't delete the original.
|
|
691
798
|
// This is still a partial success - the note exists in the new location.
|
|
692
|
-
console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted
|
|
799
|
+
console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
|
|
693
800
|
return true;
|
|
694
801
|
}
|
|
695
802
|
return true;
|
|
696
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* Moves a note to a different folder by its CoreData ID.
|
|
806
|
+
*
|
|
807
|
+
* This is more reliable than moveNote() because IDs are unique,
|
|
808
|
+
* while titles can be duplicated.
|
|
809
|
+
*
|
|
810
|
+
* @param id - CoreData URL identifier for the note
|
|
811
|
+
* @param destinationFolder - Name of the folder to move to
|
|
812
|
+
* @param account - Account containing the destination folder (defaults to iCloud)
|
|
813
|
+
* @returns true if move succeeded (or copy succeeded but delete failed)
|
|
814
|
+
*/
|
|
815
|
+
moveNoteById(id, destinationFolder, account) {
|
|
816
|
+
const targetAccount = this.resolveAccount(account);
|
|
817
|
+
// Step 1: Retrieve the original note's content by ID
|
|
818
|
+
const originalContent = this.getNoteContentById(id);
|
|
819
|
+
if (!originalContent) {
|
|
820
|
+
console.error(`Cannot move note: note with ID "${id}" not found`);
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
// Step 2: Create a copy in the destination folder
|
|
824
|
+
const safeFolder = escapeForAppleScript(destinationFolder);
|
|
825
|
+
const safeContent = originalContent.replace(/"/g, '\\"').replace(/'/g, "'\\''");
|
|
826
|
+
const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
|
|
827
|
+
const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
|
|
828
|
+
const copyResult = executeAppleScript(script);
|
|
829
|
+
if (!copyResult.success) {
|
|
830
|
+
console.error(`Cannot move note: failed to create in destination folder:`, copyResult.error);
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
// Step 3: Delete the original by ID
|
|
834
|
+
const deleteCommand = `delete note id "${id}"`;
|
|
835
|
+
const deleteScript = buildAppLevelScript(deleteCommand);
|
|
836
|
+
const deleteResult = executeAppleScript(deleteScript);
|
|
837
|
+
if (!deleteResult.success) {
|
|
838
|
+
console.error(`Note was copied to "${destinationFolder}" but original could not be deleted:`, deleteResult.error);
|
|
839
|
+
return true; // Partial success - note exists in new location
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
697
843
|
// ===========================================================================
|
|
698
844
|
// Account Operations
|
|
699
845
|
// ===========================================================================
|
|
@@ -32,16 +32,15 @@ describe("escapeForAppleScript", () => {
|
|
|
32
32
|
expect(escapeForAppleScript(undefined)).toBe("");
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
-
describe("single quote
|
|
36
|
-
it("
|
|
37
|
-
// Single quotes
|
|
38
|
-
// Need to become: '\'' (end quote, escaped quote, start quote)
|
|
35
|
+
describe("single quote handling", () => {
|
|
36
|
+
it("preserves single quotes (no escaping needed in AppleScript double-quoted strings)", () => {
|
|
37
|
+
// Single quotes don't need escaping inside AppleScript double-quoted strings
|
|
39
38
|
const result = escapeForAppleScript("it's working");
|
|
40
|
-
expect(result).toBe("it'
|
|
39
|
+
expect(result).toBe("it's working");
|
|
41
40
|
});
|
|
42
41
|
it("handles multiple single quotes", () => {
|
|
43
42
|
const result = escapeForAppleScript("Rob's mom's note");
|
|
44
|
-
expect(result).toBe("Rob'
|
|
43
|
+
expect(result).toBe("Rob's mom's note");
|
|
45
44
|
});
|
|
46
45
|
});
|
|
47
46
|
describe("double quote escaping (AppleScript strings)", () => {
|
|
@@ -52,7 +51,7 @@ describe("escapeForAppleScript", () => {
|
|
|
52
51
|
});
|
|
53
52
|
it("handles mixed quotes", () => {
|
|
54
53
|
const result = escapeForAppleScript('He said "it\'s fine"');
|
|
55
|
-
expect(result).toBe(
|
|
54
|
+
expect(result).toBe('He said \\"it\'s fine\\"');
|
|
56
55
|
});
|
|
57
56
|
});
|
|
58
57
|
describe("control character conversion (HTML for Notes.app)", () => {
|
|
@@ -73,7 +72,7 @@ describe("escapeForAppleScript", () => {
|
|
|
73
72
|
it("handles real-world note content", () => {
|
|
74
73
|
const content = 'John\'s "Meeting Notes"\n- Item 1\n- Item 2';
|
|
75
74
|
const result = escapeForAppleScript(content);
|
|
76
|
-
expect(result).toBe(
|
|
75
|
+
expect(result).toBe('John\'s \\"Meeting Notes\\"<br>- Item 1<br>- Item 2');
|
|
77
76
|
});
|
|
78
77
|
});
|
|
79
78
|
describe("unicode and special characters", () => {
|
|
@@ -90,29 +89,34 @@ describe("escapeForAppleScript", () => {
|
|
|
90
89
|
expect(result).toBe("Café résumé naïve");
|
|
91
90
|
});
|
|
92
91
|
it("handles backslashes", () => {
|
|
92
|
+
// Backslashes are HTML-encoded to avoid AppleScript escaping issues
|
|
93
93
|
const result = escapeForAppleScript("path\\to\\file");
|
|
94
|
-
expect(result).toBe("path
|
|
94
|
+
expect(result).toBe("path\to\file");
|
|
95
|
+
});
|
|
96
|
+
it("handles ampersands", () => {
|
|
97
|
+
// Ampersands are HTML-encoded for Notes.app (& becomes &)
|
|
98
|
+
const result = escapeForAppleScript("A && B & C");
|
|
99
|
+
expect(result).toBe("A && B & C");
|
|
95
100
|
});
|
|
96
101
|
it("handles angle brackets (HTML-like content)", () => {
|
|
97
|
-
// Single quotes
|
|
102
|
+
// Single quotes pass through unchanged
|
|
98
103
|
const result = escapeForAppleScript("<script>alert('xss')</script>");
|
|
99
|
-
expect(result).toBe("<script>alert('
|
|
104
|
+
expect(result).toBe("<script>alert('xss')</script>");
|
|
100
105
|
});
|
|
101
106
|
});
|
|
102
107
|
describe("boundary conditions", () => {
|
|
103
108
|
it("handles very short strings", () => {
|
|
104
109
|
expect(escapeForAppleScript("a")).toBe("a");
|
|
105
|
-
expect(escapeForAppleScript("'")).toBe("'
|
|
110
|
+
expect(escapeForAppleScript("'")).toBe("'");
|
|
106
111
|
expect(escapeForAppleScript('"')).toBe('\\"');
|
|
107
112
|
});
|
|
108
113
|
it("handles string with only whitespace", () => {
|
|
109
114
|
expect(escapeForAppleScript(" ")).toBe(" ");
|
|
110
115
|
});
|
|
111
116
|
it("handles multiple consecutive special characters", () => {
|
|
112
|
-
//
|
|
113
|
-
// Three double quotes become three \" sequences
|
|
117
|
+
// Single quotes pass through, double quotes are escaped
|
|
114
118
|
const result = escapeForAppleScript("'''\"\"\"");
|
|
115
|
-
expect(result).toBe("'
|
|
119
|
+
expect(result).toBe("'''\\\"\\\"\\\"");
|
|
116
120
|
});
|
|
117
121
|
});
|
|
118
122
|
});
|
|
@@ -227,15 +231,18 @@ describe("AppleNotesManager", () => {
|
|
|
227
231
|
it("returns array of matching notes with folder info", () => {
|
|
228
232
|
mockExecuteAppleScript.mockReturnValue({
|
|
229
233
|
success: true,
|
|
230
|
-
output: "Meeting Notes|||Work|||ITEM|||Project Plan|||Notes|||ITEM|||Weekly Review|||Archive",
|
|
234
|
+
output: "Meeting Notes|||x-coredata://ABC/ICNote/p1|||Work|||ITEM|||Project Plan|||x-coredata://ABC/ICNote/p2|||Notes|||ITEM|||Weekly Review|||x-coredata://ABC/ICNote/p3|||Archive",
|
|
231
235
|
});
|
|
232
236
|
const results = manager.searchNotes("notes");
|
|
233
237
|
expect(results).toHaveLength(3);
|
|
234
238
|
expect(results[0].title).toBe("Meeting Notes");
|
|
239
|
+
expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
|
|
235
240
|
expect(results[0].folder).toBe("Work");
|
|
236
241
|
expect(results[1].title).toBe("Project Plan");
|
|
242
|
+
expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
|
|
237
243
|
expect(results[1].folder).toBe("Notes");
|
|
238
244
|
expect(results[2].title).toBe("Weekly Review");
|
|
245
|
+
expect(results[2].id).toBe("x-coredata://ABC/ICNote/p3");
|
|
239
246
|
expect(results[2].folder).toBe("Archive");
|
|
240
247
|
});
|
|
241
248
|
it("returns empty array when no matches found", () => {
|
|
@@ -258,7 +265,7 @@ describe("AppleNotesManager", () => {
|
|
|
258
265
|
it("searches content when searchContent is true", () => {
|
|
259
266
|
mockExecuteAppleScript.mockReturnValue({
|
|
260
267
|
success: true,
|
|
261
|
-
output: "Note with keyword|||Notes",
|
|
268
|
+
output: "Note with keyword|||x-coredata://ABC/ICNote/p1|||Notes",
|
|
262
269
|
});
|
|
263
270
|
manager.searchNotes("project alpha", true);
|
|
264
271
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('body contains "project alpha"'));
|
|
@@ -266,7 +273,7 @@ describe("AppleNotesManager", () => {
|
|
|
266
273
|
it("searches titles when searchContent is false", () => {
|
|
267
274
|
mockExecuteAppleScript.mockReturnValue({
|
|
268
275
|
success: true,
|
|
269
|
-
output: "Project Alpha Notes|||Notes",
|
|
276
|
+
output: "Project Alpha Notes|||x-coredata://ABC/ICNote/p1|||Notes",
|
|
270
277
|
});
|
|
271
278
|
manager.searchNotes("Project Alpha", false);
|
|
272
279
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('name contains "Project Alpha"'));
|
|
@@ -274,13 +281,15 @@ describe("AppleNotesManager", () => {
|
|
|
274
281
|
it("identifies notes in Recently Deleted folder", () => {
|
|
275
282
|
mockExecuteAppleScript.mockReturnValue({
|
|
276
283
|
success: true,
|
|
277
|
-
output: "Old Note|||Recently Deleted|||ITEM|||Active Note|||Notes",
|
|
284
|
+
output: "Old Note|||x-coredata://ABC/ICNote/p1|||Recently Deleted|||ITEM|||Active Note|||x-coredata://ABC/ICNote/p2|||Notes",
|
|
278
285
|
});
|
|
279
286
|
const results = manager.searchNotes("note");
|
|
280
287
|
expect(results).toHaveLength(2);
|
|
281
288
|
expect(results[0].title).toBe("Old Note");
|
|
289
|
+
expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
|
|
282
290
|
expect(results[0].folder).toBe("Recently Deleted");
|
|
283
291
|
expect(results[1].title).toBe("Active Note");
|
|
292
|
+
expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
|
|
284
293
|
expect(results[1].folder).toBe("Notes");
|
|
285
294
|
});
|
|
286
295
|
it("scopes search to specified account", () => {
|
|
@@ -592,8 +601,12 @@ describe("AppleNotesManager", () => {
|
|
|
592
601
|
// ---------------------------------------------------------------------------
|
|
593
602
|
describe("moveNote", () => {
|
|
594
603
|
it("returns true when move completes successfully", () => {
|
|
595
|
-
// Mock sequence: getNoteContent -> createNote -> deleteNote
|
|
604
|
+
// Mock sequence: getNoteDetails -> getNoteContent -> createNote -> deleteNote
|
|
596
605
|
mockExecuteAppleScript
|
|
606
|
+
.mockReturnValueOnce({
|
|
607
|
+
success: true,
|
|
608
|
+
output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
|
|
609
|
+
})
|
|
597
610
|
.mockReturnValueOnce({
|
|
598
611
|
success: true,
|
|
599
612
|
output: "<div>Note Title</div><div>Content</div>",
|
|
@@ -608,9 +621,9 @@ describe("AppleNotesManager", () => {
|
|
|
608
621
|
});
|
|
609
622
|
const result = manager.moveNote("My Note", "Archive");
|
|
610
623
|
expect(result).toBe(true);
|
|
611
|
-
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(
|
|
624
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(4);
|
|
612
625
|
});
|
|
613
|
-
it("returns false when source note cannot be
|
|
626
|
+
it("returns false when source note cannot be found", () => {
|
|
614
627
|
mockExecuteAppleScript.mockReturnValueOnce({
|
|
615
628
|
success: false,
|
|
616
629
|
output: "",
|
|
@@ -618,10 +631,14 @@ describe("AppleNotesManager", () => {
|
|
|
618
631
|
});
|
|
619
632
|
const result = manager.moveNote("Missing Note", "Archive");
|
|
620
633
|
expect(result).toBe(false);
|
|
621
|
-
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to
|
|
634
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to get details
|
|
622
635
|
});
|
|
623
636
|
it("returns false when copy to destination fails", () => {
|
|
624
637
|
mockExecuteAppleScript
|
|
638
|
+
.mockReturnValueOnce({
|
|
639
|
+
success: true,
|
|
640
|
+
output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
|
|
641
|
+
})
|
|
625
642
|
.mockReturnValueOnce({
|
|
626
643
|
success: true,
|
|
627
644
|
output: "<div>Content</div>",
|
|
@@ -633,11 +650,15 @@ describe("AppleNotesManager", () => {
|
|
|
633
650
|
});
|
|
634
651
|
const result = manager.moveNote("My Note", "Nonexistent Folder");
|
|
635
652
|
expect(result).toBe(false);
|
|
636
|
-
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(
|
|
653
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(3); // Details + Read + failed create
|
|
637
654
|
});
|
|
638
655
|
it("returns true even if delete fails (note exists in new location)", () => {
|
|
639
656
|
// This is partial success - note was copied but original couldn't be deleted
|
|
640
657
|
mockExecuteAppleScript
|
|
658
|
+
.mockReturnValueOnce({
|
|
659
|
+
success: true,
|
|
660
|
+
output: "My Note, x-coredata://ABC/ICNote/p123, date Monday January 1 2024, date Monday January 1 2024, false, false",
|
|
661
|
+
})
|
|
641
662
|
.mockReturnValueOnce({
|
|
642
663
|
success: true,
|
|
643
664
|
output: "<div>Content</div>",
|