apple-notes-mcp 1.3.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.
package/README.md CHANGED
@@ -77,13 +77,13 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
77
77
 
78
78
  | Feature | Description |
79
79
  |---------|-------------|
80
- | **Create Notes** | Create notes with titles, content, and optional organization |
80
+ | **Create Notes** | Create notes with titles, content, and optional folder/account targeting |
81
81
  | **Search Notes** | Find notes by title or search within note content |
82
82
  | **Read Notes** | Retrieve note content and metadata |
83
83
  | **Update Notes** | Modify existing notes (title and/or content) |
84
84
  | **Delete Notes** | Remove notes (moves to Recently Deleted) |
85
- | **Move Notes** | Organize notes into folders |
86
- | **Folder Management** | Create, list, and delete folders |
85
+ | **Move Notes** | Organize notes into folders (supports nested paths) |
86
+ | **Folder Management** | Create, list, and delete folders with full hierarchical path support |
87
87
  | **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
88
88
  | **Batch Operations** | Delete or move multiple notes at once |
89
89
  | **Checklist State** | Read checklist done/undone state directly from the Notes database |
@@ -107,10 +107,12 @@ Creates a new note in Apple Notes.
107
107
 
108
108
  | Parameter | Type | Required | Description |
109
109
  |-----------|------|----------|-------------|
110
- | `title` | string | Yes | The title of the note (becomes first line) |
111
- | `content` | string | Yes | The body content of the note |
110
+ | `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
111
+ | `content` | string | Yes | The body content of the note (do not repeat the title here) |
112
112
  | `tags` | string[] | No | Tags for organization (stored in metadata) |
113
- | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content is used as raw HTML for rich formatting |
113
+ | `folder` | string | No | Folder to create the note in. Supports nested paths like `"Work/Clients"`. Defaults to account root |
114
+ | `account` | string | No | Account name (defaults to iCloud) |
115
+ | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. In both formats, the title is automatically prepended as `<h1>`. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities |
114
116
 
115
117
  **Example:**
116
118
  ```json
@@ -121,15 +123,26 @@ Creates a new note in Apple Notes.
121
123
  }
122
124
  ```
123
125
 
126
+ **Example - Create in a specific folder:**
127
+ ```json
128
+ {
129
+ "title": "Client Meeting",
130
+ "content": "Discussed project timeline",
131
+ "folder": "Work/Clients"
132
+ }
133
+ ```
134
+
124
135
  **Example - HTML formatting:**
125
136
  ```json
126
137
  {
127
138
  "title": "Status Report",
128
- "content": "<h1>Status Report</h1><h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
139
+ "content": "<h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
129
140
  "format": "html"
130
141
  }
131
142
  ```
132
143
 
144
+ > **Note:** The title is automatically prepended as `<h1>` in both plaintext and HTML formats. Do not include a `<h1>` title tag in the `content` parameter, or the title will appear twice.
145
+
133
146
  **Returns:** Confirmation message with note title and ID. Save the ID for subsequent operations like `update-note`, `delete-note`, etc.
134
147
 
135
148
  ---
@@ -143,7 +156,7 @@ Searches for notes by title or content.
143
156
  | `query` | string | Yes | Text to search for |
144
157
  | `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
145
158
  | `account` | string | No | Account to search in (defaults to iCloud) |
146
- | `folder` | string | No | Limit search to a specific folder |
159
+ | `folder` | string | No | Limit search to a specific folder (supports nested paths like `"Work/Clients"`) |
147
160
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
148
161
  | `limit` | number | No | Maximum number of results to return |
149
162
 
@@ -293,7 +306,7 @@ Updates an existing note's content and/or title.
293
306
  ```json
294
307
  {
295
308
  "id": "x-coredata://ABC123/ICNote/p456",
296
- "newContent": "<h1>Updated Report</h1><p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
309
+ "newContent": "<p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
297
310
  "format": "html"
298
311
  }
299
312
  ```
@@ -340,7 +353,7 @@ Moves a note to a different folder.
340
353
  |-----------|------|----------|-------------|
341
354
  | `id` | string | No | Note ID (preferred - more reliable than title) |
342
355
  | `title` | string | No | Title of the note to move (use `id` instead when available) |
343
- | `folder` | string | Yes | Name of the destination folder |
356
+ | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |
344
357
  | `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
345
358
 
346
359
  **Note:** Either `id` or `title` must be provided. Using `id` is recommended.
@@ -374,7 +387,7 @@ Lists all notes, optionally filtered by folder, date, and limit.
374
387
  | Parameter | Type | Required | Description |
375
388
  |-----------|------|----------|-------------|
376
389
  | `account` | string | No | Account to list notes from (defaults to iCloud) |
377
- | `folder` | string | No | Filter to notes in this folder only |
390
+ | `folder` | string | No | Filter to notes in this folder only (supports nested paths like `"Work/Clients"`) |
378
391
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
379
392
  | `limit` | number | No | Maximum number of notes to return |
380
393
 
@@ -406,7 +419,7 @@ Lists all notes, optionally filtered by folder, date, and limit.
406
419
 
407
420
  #### `list-folders`
408
421
 
409
- Lists all folders in an account.
422
+ Lists all folders in an account with full hierarchical paths.
410
423
 
411
424
  | Parameter | Type | Required | Description |
412
425
  |-----------|------|----------|-------------|
@@ -417,7 +430,7 @@ Lists all folders in an account.
417
430
  {}
418
431
  ```
419
432
 
420
- **Returns:** List of folder names.
433
+ **Returns:** List of folder paths. Nested folders are shown as full paths (e.g., `Work/Clients/Omnia`). Duplicate folder names are disambiguated by their full path. Literal slashes in folder names are escaped as `\/` (e.g., `Spain\/Portugal 2023`).
421
434
 
422
435
  ---
423
436
 
@@ -447,7 +460,7 @@ Deletes a folder.
447
460
 
448
461
  | Parameter | Type | Required | Description |
449
462
  |-----------|------|----------|-------------|
450
- | `name` | string | Yes | Name of the folder to delete |
463
+ | `name` | string | Yes | Name or path of the folder to delete (supports nested paths like `"Work/Old"`) |
451
464
  | `account` | string | No | Account containing the folder (defaults to iCloud) |
452
465
 
453
466
  **Example:**
@@ -499,7 +512,7 @@ Moves multiple notes to a folder.
499
512
  | Parameter | Type | Required | Description |
500
513
  |-----------|------|----------|-------------|
501
514
  | `ids` | string[] | Yes | Array of note IDs to move |
502
- | `folder` | string | Yes | Destination folder name |
515
+ | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |
503
516
  | `account` | string | No | Account containing the folder |
504
517
 
505
518
  **Returns:** Summary of successes and failures.
@@ -658,6 +671,14 @@ AI: [calls create-folder with name="Archive"]
658
671
  User: "Move my old meeting notes to Archive"
659
672
  AI: [calls move-note with title="Old Meeting Notes", folder="Archive"]
660
673
  "Moved 'Old Meeting Notes' to 'Archive'"
674
+
675
+ User: "What folders do I have?"
676
+ AI: [calls list-folders]
677
+ "You have 5 folders: Work, Work/Clients, Work/Clients/Omnia, Archive, Recipes"
678
+
679
+ User: "Create a note in Work/Clients about Acme Corp"
680
+ AI: [calls create-note with title="Acme Corp", content="...", folder="Work/Clients"]
681
+ "Created 'Acme Corp' in Work/Clients"
661
682
  ```
662
683
 
663
684
  ---
@@ -791,7 +812,7 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
791
812
  ```bash
792
813
  npm install # Install dependencies
793
814
  npm run build # Compile TypeScript
794
- npm test # Run test suite (217 tests)
815
+ npm test # Run test suite (310 tests)
795
816
  npm run lint # Check code style
796
817
  npm run format # Format code
797
818
  ```
package/build/index.js CHANGED
@@ -119,8 +119,13 @@ server.tool("create-note", {
119
119
  .default("plaintext")
120
120
  .describe("Content format: 'plaintext' (default) or 'html' for rich formatting"),
121
121
  tags: z.array(z.string()).optional().describe("Tags for organization"),
122
- }, withErrorHandling(({ title, content, format = "plaintext", tags = [] }) => {
123
- const note = notesManager.createNote(title, content, tags, undefined, undefined, format);
122
+ folder: z
123
+ .string()
124
+ .optional()
125
+ .describe("Folder to create the note in (supports nested paths like 'Work/Clients')"),
126
+ account: z.string().optional().describe("Account name (defaults to iCloud)"),
127
+ }, withErrorHandling(({ title, content, format = "plaintext", tags = [], folder, account }) => {
128
+ const note = notesManager.createNote(title, content, tags, folder, account, format);
124
129
  if (!note) {
125
130
  return errorResponse(`Failed to create note "${title}". Check that Notes.app is configured and accessible.`);
126
131
  }
@@ -97,6 +97,65 @@ export function escapeHtmlForAppleScript(htmlContent) {
97
97
  // We do NOT re-encode HTML entities since content is already HTML from Notes.app
98
98
  return htmlContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
99
99
  }
100
+ // =============================================================================
101
+ // Input Validation & Sanitization
102
+ // =============================================================================
103
+ /** Maximum allowed length for note titles */
104
+ const MAX_TITLE_LENGTH = 2000;
105
+ /** Maximum allowed length for note content (5 MB of text) */
106
+ const MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
107
+ /** Maximum allowed length for folder names/paths */
108
+ const MAX_FOLDER_PATH_LENGTH = 1000;
109
+ /** Maximum allowed length for account names */
110
+ const MAX_ACCOUNT_LENGTH = 200;
111
+ /** Maximum nesting depth for folder paths */
112
+ const MAX_FOLDER_DEPTH = 20;
113
+ /**
114
+ * Validates and constrains string input length.
115
+ *
116
+ * @param value - The input string
117
+ * @param maxLength - Maximum allowed length
118
+ * @param label - Human-readable label for error messages
119
+ * @returns The validated string
120
+ * @throws Error if input exceeds maximum length
121
+ */
122
+ function validateLength(value, maxLength, label) {
123
+ if (value.length > maxLength) {
124
+ throw new Error(`${label} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
125
+ }
126
+ return value;
127
+ }
128
+ /**
129
+ * Sanitizes a CoreData ID for safe embedding in AppleScript.
130
+ *
131
+ * CoreData IDs follow the pattern: x-coredata://UUID/ICNote/pNNN
132
+ * This function validates the format and escapes the value for AppleScript.
133
+ *
134
+ * @param id - CoreData URL identifier
135
+ * @returns Escaped ID safe for AppleScript string embedding
136
+ * @throws Error if ID format is invalid
137
+ */
138
+ export function sanitizeId(id) {
139
+ // CoreData IDs should match: x-coredata://hex-hex-hex-hex-hex/ICEntity/pDigits
140
+ // or temp-timestamp-counter format from generateFallbackId()
141
+ const coreDataPattern = /^x-coredata:\/\/[0-9A-Fa-f-]+\/IC[A-Za-z]+\/p\d+$/;
142
+ const tempIdPattern = /^temp-\d+-\d+$/;
143
+ if (!coreDataPattern.test(id) && !tempIdPattern.test(id)) {
144
+ throw new Error(`Invalid note ID format: "${id.substring(0, 80)}". Expected CoreData URL (x-coredata://...) or temp ID.`);
145
+ }
146
+ // Even with validation, escape for defense-in-depth
147
+ return escapeForAppleScript(id);
148
+ }
149
+ /**
150
+ * Sanitizes an account name for safe embedding in AppleScript.
151
+ *
152
+ * @param account - Account name string
153
+ * @returns Escaped account name safe for AppleScript string embedding
154
+ */
155
+ function sanitizeAccountName(account) {
156
+ validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
157
+ return escapeForAppleScript(account);
158
+ }
100
159
  /**
101
160
  * Counter for generating unique fallback IDs within the same millisecond.
102
161
  */
@@ -216,6 +275,59 @@ export function parseNotePropertiesOutput(output) {
216
275
  passwordProtected,
217
276
  };
218
277
  }
278
+ /**
279
+ * Splits a folder path on unescaped `/` separators.
280
+ *
281
+ * Folder names may contain literal slashes (e.g., "Spain/Portugal 2023").
282
+ * In path strings these are escaped as `\/`. This function splits only on
283
+ * unescaped `/` and restores the literal slashes in each segment.
284
+ *
285
+ * @param folderPath - Folder path with `/` as hierarchy separator and `\/` for literal slashes
286
+ * @returns Array of folder name segments
287
+ */
288
+ export function splitFolderPath(folderPath) {
289
+ // Split on `/` that is NOT preceded by `\`
290
+ // We use a negative lookbehind to avoid splitting on escaped slashes
291
+ const parts = folderPath.split(/(?<!\\)\//);
292
+ // Unescape `\/` → `/` in each segment
293
+ return parts.map((p) => p.replace(/\\\//g, "/")).filter((p) => p.length > 0);
294
+ }
295
+ /**
296
+ * Escapes literal slashes in a folder name for use in path strings.
297
+ *
298
+ * @param name - Raw folder name (may contain `/`)
299
+ * @returns Folder name with `/` escaped as `\/`
300
+ */
301
+ function escapeFolderName(name) {
302
+ return name.replace(/\//g, "\\/");
303
+ }
304
+ /**
305
+ * Builds an AppleScript folder reference from a path string.
306
+ *
307
+ * Converts a folder path like "Work/Clients/Omnia" into the nested
308
+ * AppleScript syntax: `folder "Omnia" of folder "Clients" of folder "Work"`.
309
+ *
310
+ * A simple folder name like "Work" returns `folder "Work"`.
311
+ * Literal slashes in folder names must be escaped as `\/` (e.g., "Travel/Spain\/Portugal").
312
+ *
313
+ * @param folderPath - Folder name or slash-separated path (e.g., "Work/Clients")
314
+ * @returns AppleScript folder reference string
315
+ */
316
+ export function buildFolderReference(folderPath) {
317
+ validateLength(folderPath, MAX_FOLDER_PATH_LENGTH, "Folder path");
318
+ const parts = splitFolderPath(folderPath);
319
+ if (parts.length > MAX_FOLDER_DEPTH) {
320
+ throw new Error(`Folder path exceeds maximum nesting depth of ${MAX_FOLDER_DEPTH} (got ${parts.length})`);
321
+ }
322
+ if (parts.length === 0) {
323
+ throw new Error("Folder path is empty");
324
+ }
325
+ // Build inside-out: last part is innermost, first part is outermost
326
+ return parts
327
+ .reverse()
328
+ .map((part) => `folder "${escapeForAppleScript(part)}"`)
329
+ .join(" of ");
330
+ }
219
331
  /**
220
332
  * Builds an AppleScript command wrapped in account context.
221
333
  *
@@ -235,9 +347,10 @@ export function parseNotePropertiesOutput(output) {
235
347
  * @returns Complete AppleScript ready for execution
236
348
  */
237
349
  function buildAccountScopedScript(scope, command) {
350
+ const safeAccount = sanitizeAccountName(scope.account);
238
351
  return `
239
352
  tell application "Notes"
240
- tell account "${scope.account}"
353
+ tell account "${safeAccount}"
241
354
  ${command}
242
355
  end tell
243
356
  end tell
@@ -399,32 +512,43 @@ export class AppleNotesManager {
399
512
  * // Create in a different account
400
513
  * const gmail = manager.createNote("Draft", "...", [], undefined, "Gmail");
401
514
  *
402
- * // Create with HTML formatting
403
- * const html = manager.createNote("Report", "<h1>Report</h1><p>Details here</p>",
515
+ * // Create with HTML formatting (no need for <h1> — title is auto-prepended)
516
+ * const html = manager.createNote("Report", "<p>Details here</p>",
404
517
  * [], undefined, undefined, "html");
405
518
  * ```
406
519
  */
407
520
  createNote(title, content, tags = [], folder, account, format = "plaintext") {
521
+ validateLength(title, MAX_TITLE_LENGTH, "Note title");
522
+ validateLength(content, MAX_CONTENT_LENGTH, "Note content");
408
523
  const targetAccount = this.resolveAccount(account);
409
- // Escape content for AppleScript embedding
410
- const safeTitle = escapeForAppleScript(title);
411
- const safeContent = format === "html" ? escapeHtmlForAppleScript(content) : escapeForAppleScript(content);
524
+ // Build body HTML: title as <h1>, content follows.
525
+ // We set only 'body' (not 'name') to avoid title duplication —
526
+ // Notes.app auto-uses the first line of body as the note's display title.
527
+ const htmlTitle = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
528
+ const bodyContent = format === "html"
529
+ ? content
530
+ : content
531
+ .replace(/&/g, "&amp;")
532
+ .replace(/\\/g, "&#92;")
533
+ .replace(/</g, "&lt;")
534
+ .replace(/>/g, "&gt;")
535
+ .replace(/\n/g, "<br>")
536
+ .replace(/\t/g, "<br>");
537
+ const safeBody = escapeHtmlForAppleScript(`<h1>${htmlTitle}</h1>${bodyContent}`);
412
538
  // Build the AppleScript command
413
- // Notes.app uses 'name' for the title and 'body' for content
414
- // We capture the ID of the newly created note
415
539
  let createCommand;
416
540
  if (folder) {
417
- // Create note in specific folder
418
- const safeFolder = escapeForAppleScript(folder);
541
+ // Create note in specific folder (supports nested paths like "Work/Clients")
542
+ const folderRef = buildFolderReference(folder);
419
543
  createCommand = `
420
- set newNote to make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}
544
+ set newNote to make new note at ${folderRef} with properties {body:"${safeBody}"}
421
545
  return id of newNote
422
546
  `;
423
547
  }
424
548
  else {
425
549
  // Create note in default location
426
550
  createCommand = `
427
- set newNote to make new note with properties {name:"${safeTitle}", body:"${safeContent}"}
551
+ set newNote to make new note with properties {body:"${safeBody}"}
428
552
  return id of newNote
429
553
  `;
430
554
  }
@@ -506,7 +630,7 @@ export class AppleNotesManager {
506
630
  }
507
631
  const whereClause = whereParts.join(" and ");
508
632
  // Build the notes source - either all notes or notes in a specific folder
509
- const notesSource = folder ? `notes of folder "${escapeForAppleScript(folder)}"` : "notes";
633
+ const notesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
510
634
  // Build the limit logic for the repeat loop
511
635
  // Note: The limit only reduces iteration over already-matched results from the whose clause,
512
636
  // not the query itself. It controls output size, not AppleScript query performance.
@@ -613,8 +737,9 @@ export class AppleNotesManager {
613
737
  * @returns HTML content of the note, or empty string if not found
614
738
  */
615
739
  getNoteContentById(id) {
740
+ const safeId = sanitizeId(id);
616
741
  // Note IDs work at the application level, not scoped to account
617
- const getCommand = `get body of note id "${id}"`;
742
+ const getCommand = `get body of note id "${safeId}"`;
618
743
  const script = buildAppLevelScript(getCommand);
619
744
  const result = executeAppleScript(script);
620
745
  if (!result.success) {
@@ -635,9 +760,10 @@ export class AppleNotesManager {
635
760
  * @returns Note object with metadata, or null if not found
636
761
  */
637
762
  getNoteById(id) {
763
+ const safeId = sanitizeId(id);
638
764
  // Note IDs work at the application level, not scoped to account
639
765
  const getCommand = `
640
- set n to note id "${id}"
766
+ set n to note id "${safeId}"
641
767
  set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
642
768
  return noteProps
643
769
  `;
@@ -737,7 +863,8 @@ export class AppleNotesManager {
737
863
  * @returns true if deletion succeeded, false otherwise
738
864
  */
739
865
  deleteNoteById(id) {
740
- const deleteCommand = `delete note id "${id}"`;
866
+ const safeId = sanitizeId(id);
867
+ const deleteCommand = `delete note id "${safeId}"`;
741
868
  const script = buildAppLevelScript(deleteCommand);
742
869
  const result = executeAppleScript(script);
743
870
  if (!result.success) {
@@ -768,6 +895,9 @@ export class AppleNotesManager {
768
895
  * @returns true if update succeeded, false otherwise
769
896
  */
770
897
  updateNote(title, newTitle, newContent, account, format = "plaintext") {
898
+ if (newTitle)
899
+ validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
900
+ validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
771
901
  const targetAccount = this.resolveAccount(account);
772
902
  const safeCurrentTitle = escapeForAppleScript(title);
773
903
  let fullBody;
@@ -811,6 +941,9 @@ export class AppleNotesManager {
811
941
  * @returns true if update succeeded, false otherwise
812
942
  */
813
943
  updateNoteById(id, newTitle, newContent, format = "plaintext") {
944
+ if (newTitle)
945
+ validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
946
+ validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
814
947
  let fullBody;
815
948
  if (format === "html") {
816
949
  // HTML mode: content is the complete body, escaped only for AppleScript string
@@ -832,7 +965,8 @@ export class AppleNotesManager {
832
965
  const safeContent = escapeForAppleScript(newContent);
833
966
  fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
834
967
  }
835
- const updateCommand = `set body of note id "${id}" to "${fullBody}"`;
968
+ const safeId = sanitizeId(id);
969
+ const updateCommand = `set body of note id "${safeId}" to "${fullBody}"`;
836
970
  const script = buildAppLevelScript(updateCommand);
837
971
  const result = executeAppleScript(script);
838
972
  if (!result.success) {
@@ -855,9 +989,7 @@ export class AppleNotesManager {
855
989
  const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
856
990
  // When date or limit filters are needed, use a repeat loop for fine-grained control
857
991
  if (modifiedSince || safeLimit !== undefined) {
858
- const baseNotesSource = folder
859
- ? `notes of folder "${escapeForAppleScript(folder)}"`
860
- : "notes";
992
+ const baseNotesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
861
993
  // Use whose clause for date filtering (locale-safe, no sort order assumption)
862
994
  let dateSetup = "";
863
995
  let notesSource = baseNotesSource;
@@ -898,8 +1030,7 @@ export class AppleNotesManager {
898
1030
  // Simple path: no date or limit filters
899
1031
  let listCommand;
900
1032
  if (folder) {
901
- const safeFolder = escapeForAppleScript(folder);
902
- listCommand = `get name of notes of folder "${safeFolder}"`;
1033
+ listCommand = `get name of notes of ${buildFolderReference(folder)}`;
903
1034
  }
904
1035
  else {
905
1036
  listCommand = `get name of notes`;
@@ -931,38 +1062,38 @@ export class AppleNotesManager {
931
1062
  // Query each account for shared notes
932
1063
  const accounts = this.listAccounts();
933
1064
  for (const account of accounts) {
1065
+ // Use delimited output to avoid fragile comma-based parsing.
1066
+ // Format: name|||id|||createdDate|||modifiedDate|||shared|||passwordProtected
934
1067
  const script = buildAccountScopedScript({ account: account.name }, `
935
- set sharedList to {}
1068
+ set resultList to {}
936
1069
  repeat with n in notes
937
1070
  if shared of n is true then
938
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
939
- set end of sharedList to noteProps
1071
+ set end of resultList to (name of n) & "|||" & (id of n) & "|||" & (creation date of n as text) & "|||" & (modification date of n as text) & "|||" & (shared of n as text) & "|||" & (password protected of n as text)
940
1072
  end if
941
1073
  end repeat
942
- return sharedList
1074
+ set AppleScript's text item delimiters to "|||ITEM|||"
1075
+ return resultList as text
943
1076
  `);
944
1077
  const result = executeAppleScript(script);
945
1078
  if (!result.success) {
946
1079
  console.error(`Failed to list shared notes for ${account.name}:`, result.error);
947
1080
  continue;
948
1081
  }
949
- // Parse the result - format is: {{name, id, date, date, bool, bool}, {...}, ...}
950
1082
  const output = result.output.trim();
951
- if (!output || output === "{}" || output === "{}") {
1083
+ if (!output) {
952
1084
  continue;
953
1085
  }
954
- // Extract individual note data using regex
955
- const notePattern = /\{([^{}]+)\}/g;
956
- let match;
957
- while ((match = notePattern.exec(output)) !== null) {
958
- const parts = match[1].split(", ");
1086
+ // Parse delimited output: "name|||id|||created|||modified|||shared|||pp|||ITEM|||..."
1087
+ const items = output.split("|||ITEM|||");
1088
+ for (const item of items) {
1089
+ const parts = item.split("|||");
959
1090
  if (parts.length >= 6) {
960
1091
  const title = parts[0].trim();
961
1092
  const id = parts[1].trim();
962
- const createdStr = parts.slice(2, parts.length - 3).join(", ");
963
- const modifiedStr = parts[parts.length - 3];
964
- const shared = parts[parts.length - 2] === "true";
965
- const passwordProtected = parts[parts.length - 1] === "true";
1093
+ const createdStr = parts[2].trim();
1094
+ const modifiedStr = parts[3].trim();
1095
+ const shared = parts[4].trim() === "true";
1096
+ const passwordProtected = parts[5].trim() === "true";
966
1097
  sharedNotes.push({
967
1098
  id,
968
1099
  title,
@@ -983,26 +1114,74 @@ export class AppleNotesManager {
983
1114
  // Folder Operations
984
1115
  // ===========================================================================
985
1116
  /**
986
- * Lists all folders in an account.
1117
+ * Lists all folders in an account with full hierarchical paths.
1118
+ *
1119
+ * Each folder's `name` field contains the full path (e.g., "Work/Clients/Omnia")
1120
+ * so that duplicate folder names (e.g., multiple "Archive" folders) are
1121
+ * distinguishable and can be used directly in other operations.
987
1122
  *
988
1123
  * @param account - Account to list folders from (defaults to iCloud)
989
- * @returns Array of Folder objects
1124
+ * @returns Array of Folder objects with path-based names
990
1125
  */
991
1126
  listFolders(account) {
992
1127
  const targetAccount = this.resolveAccount(account);
993
- // Get folder names (simpler than getting full objects)
994
- const listCommand = `get name of folders`;
1128
+ // Get each folder's ID, name, and parent ID in a single AppleScript call.
1129
+ // Each line: "id\tname\tparentId" for subfolders, "id\tname" for top-level.
1130
+ // Using IDs enables correct tree building even with duplicate folder names.
1131
+ const listCommand = `
1132
+ set folderList to ""
1133
+ set allFolders to every folder
1134
+ repeat with f in allFolders
1135
+ set fRef to contents of f
1136
+ set cRef to container of fRef
1137
+ if folderList is not "" then
1138
+ set folderList to folderList & linefeed
1139
+ end if
1140
+ if class of cRef is folder then
1141
+ set folderList to folderList & (id of fRef) & tab & (name of fRef) & tab & (id of cRef)
1142
+ else
1143
+ set folderList to folderList & (id of fRef) & tab & (name of fRef)
1144
+ end if
1145
+ end repeat
1146
+ return folderList
1147
+ `;
995
1148
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
996
1149
  const result = executeAppleScript(script);
997
1150
  if (!result.success) {
998
1151
  console.error("Failed to list folders:", result.error);
999
1152
  return [];
1000
1153
  }
1001
- // Convert names to Folder objects
1002
- const names = parseCommaSeparatedList(result.output);
1003
- return names.map((name) => ({
1004
- id: "", // Would require additional query to get
1005
- name,
1154
+ if (!result.output.trim()) {
1155
+ return [];
1156
+ }
1157
+ // Parse "id\tname[\tparentId]" lines
1158
+ const entries = result.output.split("\n").map((line) => {
1159
+ const parts = line.split("\t");
1160
+ return {
1161
+ id: (parts[0] || "").trim(),
1162
+ name: (parts[1] || "").trim(),
1163
+ parentId: (parts[2] || "").trim(),
1164
+ };
1165
+ });
1166
+ // Build an ID-to-entry map for efficient parent lookups
1167
+ const byId = new Map(entries.map((e) => [e.id, e]));
1168
+ // Build full path by walking up the parent chain using unique IDs
1169
+ // Build full path by walking up the parent chain using unique IDs.
1170
+ // Literal slashes in folder names are escaped as `\/` so they don't
1171
+ // collide with the `/` path separator.
1172
+ const buildPath = (entry) => {
1173
+ const safeName = escapeFolderName(entry.name);
1174
+ if (!entry.parentId)
1175
+ return safeName;
1176
+ const parent = byId.get(entry.parentId);
1177
+ if (parent) {
1178
+ return buildPath(parent) + "/" + safeName;
1179
+ }
1180
+ return safeName;
1181
+ };
1182
+ return entries.map((entry) => ({
1183
+ id: entry.id,
1184
+ name: buildPath(entry),
1006
1185
  account: targetAccount,
1007
1186
  }));
1008
1187
  }
@@ -1042,8 +1221,7 @@ export class AppleNotesManager {
1042
1221
  */
1043
1222
  deleteFolder(name, account) {
1044
1223
  const targetAccount = this.resolveAccount(account);
1045
- const safeName = escapeForAppleScript(name);
1046
- const deleteCommand = `delete folder "${safeName}"`;
1224
+ const deleteCommand = `delete ${buildFolderReference(name)}`;
1047
1225
  const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
1048
1226
  const result = executeAppleScript(script);
1049
1227
  if (!result.success) {
@@ -1085,9 +1263,9 @@ export class AppleNotesManager {
1085
1263
  }
1086
1264
  // Step 3: Create a copy in the destination folder
1087
1265
  // Content is already HTML from getNoteContent(), so use escapeHtmlForAppleScript()
1088
- const safeFolder = escapeForAppleScript(destinationFolder);
1266
+ const folderRef = buildFolderReference(destinationFolder);
1089
1267
  const safeContent = escapeHtmlForAppleScript(originalContent);
1090
- const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
1268
+ const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1091
1269
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1092
1270
  const copyResult = executeAppleScript(script);
1093
1271
  if (!copyResult.success) {
@@ -1095,7 +1273,8 @@ export class AppleNotesManager {
1095
1273
  return false;
1096
1274
  }
1097
1275
  // Step 4: Delete the original by ID (not by title, since there are now two notes with the same title)
1098
- const deleteCommand = `delete note id "${originalNote.id}"`;
1276
+ const safeOrigId = sanitizeId(originalNote.id);
1277
+ const deleteCommand = `delete note id "${safeOrigId}"`;
1099
1278
  const deleteScript = buildAppLevelScript(deleteCommand);
1100
1279
  const deleteResult = executeAppleScript(deleteScript);
1101
1280
  if (!deleteResult.success) {
@@ -1119,6 +1298,7 @@ export class AppleNotesManager {
1119
1298
  */
1120
1299
  moveNoteById(id, destinationFolder, account) {
1121
1300
  const targetAccount = this.resolveAccount(account);
1301
+ const safeId = sanitizeId(id);
1122
1302
  // Step 1: Retrieve the original note's content by ID
1123
1303
  const originalContent = this.getNoteContentById(id);
1124
1304
  if (!originalContent) {
@@ -1127,9 +1307,9 @@ export class AppleNotesManager {
1127
1307
  }
1128
1308
  // Step 2: Create a copy in the destination folder
1129
1309
  // Content is already HTML from getNoteContentById(), so use escapeHtmlForAppleScript()
1130
- const safeFolder = escapeForAppleScript(destinationFolder);
1310
+ const folderRef = buildFolderReference(destinationFolder);
1131
1311
  const safeContent = escapeHtmlForAppleScript(originalContent);
1132
- const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
1312
+ const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1133
1313
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1134
1314
  const copyResult = executeAppleScript(script);
1135
1315
  if (!copyResult.success) {
@@ -1137,7 +1317,7 @@ export class AppleNotesManager {
1137
1317
  return false;
1138
1318
  }
1139
1319
  // Step 3: Delete the original by ID
1140
- const deleteCommand = `delete note id "${id}"`;
1320
+ const deleteCommand = `delete note id "${safeId}"`;
1141
1321
  const deleteScript = buildAppLevelScript(deleteCommand);
1142
1322
  const deleteResult = executeAppleScript(deleteScript);
1143
1323
  if (!deleteResult.success) {
@@ -1390,7 +1570,7 @@ export class AppleNotesManager {
1390
1570
  * ```
1391
1571
  */
1392
1572
  listAttachmentsById(id) {
1393
- const safeId = escapeForAppleScript(id);
1573
+ const safeId = sanitizeId(id);
1394
1574
  const script = `
1395
1575
  tell application "Notes"
1396
1576
  set theNote to note id "${safeId}"
@@ -11,7 +11,7 @@
11
11
  * - Script generation is verified by checking for expected AppleScript patterns
12
12
  */
13
13
  import { describe, it, expect, vi, beforeEach } from "vitest";
14
- import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, buildAppleScriptDateVar, parseAppleScriptDate, } from "./appleNotesManager.js";
14
+ import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, buildAppleScriptDateVar, buildFolderReference, splitFolderPath, parseAppleScriptDate, sanitizeId, } from "./appleNotesManager.js";
15
15
  // Mock the AppleScript execution module
16
16
  // This prevents actual osascript calls during testing
17
17
  vi.mock("@/utils/applescript.js", () => ({
@@ -210,6 +210,46 @@ describe("parseAppleScriptDate", () => {
210
210
  });
211
211
  });
212
212
  // =============================================================================
213
+ // buildFolderReference Tests
214
+ // =============================================================================
215
+ describe("splitFolderPath", () => {
216
+ it("splits simple path on /", () => {
217
+ expect(splitFolderPath("Work/Clients")).toEqual(["Work", "Clients"]);
218
+ });
219
+ it("returns single segment for a name without /", () => {
220
+ expect(splitFolderPath("Work")).toEqual(["Work"]);
221
+ });
222
+ it("preserves escaped slashes in folder names", () => {
223
+ expect(splitFolderPath("Travel/Spain\\/Portugal 2023")).toEqual([
224
+ "Travel",
225
+ "Spain/Portugal 2023",
226
+ ]);
227
+ });
228
+ it("handles multiple escaped slashes", () => {
229
+ expect(splitFolderPath("A\\/B/C\\/D")).toEqual(["A/B", "C/D"]);
230
+ });
231
+ });
232
+ describe("buildFolderReference", () => {
233
+ it("returns simple folder reference for a single name", () => {
234
+ expect(buildFolderReference("Work")).toBe('folder "Work"');
235
+ });
236
+ it("returns nested folder reference for a path", () => {
237
+ expect(buildFolderReference("Work/Clients")).toBe('folder "Clients" of folder "Work"');
238
+ });
239
+ it("handles deeply nested paths", () => {
240
+ expect(buildFolderReference("Work/Clients/Omnia")).toBe('folder "Omnia" of folder "Clients" of folder "Work"');
241
+ });
242
+ it("handles special characters in folder names", () => {
243
+ const result = buildFolderReference("Food & Drink/🥘 Recipes");
244
+ expect(result).toContain('folder "🥘 Recipes"');
245
+ expect(result).toContain('folder "Food &amp; Drink"');
246
+ });
247
+ it("handles escaped slashes in folder names", () => {
248
+ const result = buildFolderReference("Travel/Spain\\/Portugal 2023");
249
+ expect(result).toBe('folder "Spain/Portugal 2023" of folder "Travel"');
250
+ });
251
+ });
252
+ // =============================================================================
213
253
  // buildAppleScriptDateVar Tests
214
254
  // =============================================================================
215
255
  describe("buildAppleScriptDateVar", () => {
@@ -333,6 +373,62 @@ describe("AppleNotesManager", () => {
333
373
  // Double quotes must be escaped for AppleScript string embedding
334
374
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('<div class=\\"test\\">Content</div>'));
335
375
  });
376
+ it("sets title as h1 in body, not as name property", () => {
377
+ mockExecuteAppleScript.mockReturnValue({
378
+ success: true,
379
+ output: "note id x-coredata://12345/ICNote/p203",
380
+ });
381
+ manager.createNote("My Title", "Body content");
382
+ const script = mockExecuteAppleScript.mock.calls[0][0];
383
+ // Title must appear as h1 in body
384
+ expect(script).toContain("<h1>My Title</h1>");
385
+ // name property must NOT be set (causes title duplication in Notes.app)
386
+ expect(script).not.toContain('name:"My Title"');
387
+ });
388
+ it("HTML-encodes special chars in title for h1 tag", () => {
389
+ mockExecuteAppleScript.mockReturnValue({
390
+ success: true,
391
+ output: "note id x-coredata://12345/ICNote/p204",
392
+ });
393
+ manager.createNote("Q&A: <Hello> World", "Content");
394
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<h1>Q&amp;A: &lt;Hello&gt; World</h1>"));
395
+ });
396
+ it("HTML-encodes special chars in plaintext content", () => {
397
+ mockExecuteAppleScript.mockReturnValue({
398
+ success: true,
399
+ output: "note id x-coredata://12345/ICNote/p205",
400
+ });
401
+ manager.createNote("Title", "Price: <10 & >5\nNext line");
402
+ const script = mockExecuteAppleScript.mock.calls[0][0];
403
+ expect(script).toContain("Price: &lt;10 &amp; &gt;5<br>Next line");
404
+ });
405
+ it("prepends h1 title before html content", () => {
406
+ mockExecuteAppleScript.mockReturnValue({
407
+ success: true,
408
+ output: "note id x-coredata://12345/ICNote/p206",
409
+ });
410
+ manager.createNote("Report", "<h2>Section</h2><div>Details</div>", [], undefined, undefined, "html");
411
+ const script = mockExecuteAppleScript.mock.calls[0][0];
412
+ expect(script).toContain("<h1>Report</h1><h2>Section</h2><div>Details</div>");
413
+ });
414
+ it("encodes backslashes as HTML entities in plaintext content", () => {
415
+ mockExecuteAppleScript.mockReturnValue({
416
+ success: true,
417
+ output: "note id x-coredata://12345/ICNote/p207",
418
+ });
419
+ manager.createNote("Title", "path\\to\\file");
420
+ const script = mockExecuteAppleScript.mock.calls[0][0];
421
+ expect(script).toContain("path&#92;to&#92;file");
422
+ });
423
+ it("converts tabs to br in plaintext content", () => {
424
+ mockExecuteAppleScript.mockReturnValue({
425
+ success: true,
426
+ output: "note id x-coredata://12345/ICNote/p208",
427
+ });
428
+ manager.createNote("Title", "col1\tcol2\tcol3");
429
+ const script = mockExecuteAppleScript.mock.calls[0][0];
430
+ expect(script).toContain("col1<br>col2<br>col3");
431
+ });
336
432
  });
337
433
  // ---------------------------------------------------------------------------
338
434
  // Note Search
@@ -571,7 +667,7 @@ describe("AppleNotesManager", () => {
571
667
  output: "",
572
668
  error: "Note not found",
573
669
  });
574
- const result = manager.isNotePasswordProtectedById("x-coredata://invalid");
670
+ const result = manager.isNotePasswordProtectedById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
575
671
  expect(result).toBe(false);
576
672
  });
577
673
  });
@@ -597,7 +693,7 @@ describe("AppleNotesManager", () => {
597
693
  output: "",
598
694
  error: "Can't get note id",
599
695
  });
600
- const result = manager.getNoteById("x-coredata://invalid");
696
+ const result = manager.getNoteById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
601
697
  expect(result).toBeNull();
602
698
  });
603
699
  it("returns null when response format is unexpected (no commas)", () => {
@@ -779,7 +875,7 @@ describe("AppleNotesManager", () => {
779
875
  output: "",
780
876
  });
781
877
  const htmlContent = "<h1>My Title</h1><div>A &amp; B</div>";
782
- const result = manager.updateNoteById("x-coredata://abc/123", undefined, htmlContent, "html");
878
+ const result = manager.updateNoteById("x-coredata://ABC00000-0000-0000-0000-000000000001/ICNote/p123", undefined, htmlContent, "html");
783
879
  expect(result).toBe(true);
784
880
  // HTML mode: content passed directly, no <div> wrapper
785
881
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
@@ -792,7 +888,7 @@ describe("AppleNotesManager", () => {
792
888
  output: "",
793
889
  });
794
890
  const htmlContent = "<h1>Title</h1><div>Body</div>";
795
- manager.updateNoteById("x-coredata://abc/456", undefined, htmlContent, "html");
891
+ manager.updateNoteById("x-coredata://ABC00000-0000-0000-0000-000000000002/ICNote/p456", undefined, htmlContent, "html");
796
892
  // In HTML mode, getNoteById should NOT be called (it would trigger
797
893
  // an additional executeAppleScript call). Only one call should happen:
798
894
  // the update itself.
@@ -896,21 +992,54 @@ describe("AppleNotesManager", () => {
896
992
  // Folder Operations
897
993
  // ---------------------------------------------------------------------------
898
994
  describe("listFolders", () => {
899
- it("returns array of Folder objects", () => {
995
+ it("returns array of Folder objects with paths", () => {
900
996
  mockExecuteAppleScript.mockReturnValue({
901
997
  success: true,
902
- output: "Notes, Archive, Work",
998
+ output: "id1\tNotes\nid2\tArchive\nid3\tWork",
903
999
  });
904
1000
  const folders = manager.listFolders();
905
1001
  expect(folders).toHaveLength(3);
906
1002
  expect(folders[0].name).toBe("Notes");
907
1003
  expect(folders[1].name).toBe("Archive");
908
1004
  expect(folders[2].name).toBe("Work");
1005
+ expect(folders[0].id).toBe("id1");
1006
+ });
1007
+ it("includes parent folder in path", () => {
1008
+ mockExecuteAppleScript.mockReturnValue({
1009
+ success: true,
1010
+ output: "id1\tDev\nid2\tAccessibility\tid1\nid3\tWork\nid4\tClients\tid3",
1011
+ });
1012
+ const folders = manager.listFolders();
1013
+ expect(folders).toHaveLength(4);
1014
+ expect(folders[0].name).toBe("Dev");
1015
+ expect(folders[1].name).toBe("Dev/Accessibility");
1016
+ expect(folders[2].name).toBe("Work");
1017
+ expect(folders[3].name).toBe("Work/Clients");
1018
+ });
1019
+ it("disambiguates duplicate folder names using IDs", () => {
1020
+ mockExecuteAppleScript.mockReturnValue({
1021
+ success: true,
1022
+ output: "id1\tFinance\nid2\tArchive\tid1\nid3\tTravel\nid4\tTrips\tid3\nid5\tArchive\tid4",
1023
+ });
1024
+ const folders = manager.listFolders();
1025
+ expect(folders).toHaveLength(5);
1026
+ expect(folders[1].name).toBe("Finance/Archive");
1027
+ expect(folders[4].name).toBe("Travel/Trips/Archive");
1028
+ });
1029
+ it("escapes slashes in folder names", () => {
1030
+ mockExecuteAppleScript.mockReturnValue({
1031
+ success: true,
1032
+ output: "id1\tTravel\nid2\tSpain/Portugal 2023\tid1",
1033
+ });
1034
+ const folders = manager.listFolders();
1035
+ expect(folders).toHaveLength(2);
1036
+ expect(folders[0].name).toBe("Travel");
1037
+ expect(folders[1].name).toBe("Travel/Spain\\/Portugal 2023");
909
1038
  });
910
1039
  it("includes account in Folder objects", () => {
911
1040
  mockExecuteAppleScript.mockReturnValue({
912
1041
  success: true,
913
- output: "Notes",
1042
+ output: "id1\tNotes",
914
1043
  });
915
1044
  const folders = manager.listFolders("Gmail");
916
1045
  expect(folders[0].account).toBe("Gmail");
@@ -1133,7 +1262,7 @@ describe("AppleNotesManager", () => {
1133
1262
  // listAccounts
1134
1263
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1135
1264
  // listFolders for iCloud
1136
- .mockReturnValueOnce({ success: true, output: "Notes, Work" })
1265
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes\nid2\tWork" })
1137
1266
  // listNotes for Notes folder
1138
1267
  .mockReturnValueOnce({ success: true, output: "Note 1, Note 2, Note 3" })
1139
1268
  // listNotes for Work folder
@@ -1151,7 +1280,7 @@ describe("AppleNotesManager", () => {
1151
1280
  it("returns zero counts when no notes exist", () => {
1152
1281
  mockExecuteAppleScript
1153
1282
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1154
- .mockReturnValueOnce({ success: true, output: "Notes" })
1283
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1155
1284
  .mockReturnValueOnce({ success: true, output: "" })
1156
1285
  .mockReturnValueOnce({ success: true, output: "" });
1157
1286
  const stats = manager.getNotesStats();
@@ -1165,11 +1294,11 @@ describe("AppleNotesManager", () => {
1165
1294
  // listAccounts
1166
1295
  .mockReturnValueOnce({ success: true, output: "iCloud, Gmail" })
1167
1296
  // listFolders for iCloud
1168
- .mockReturnValueOnce({ success: true, output: "Notes" })
1297
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1169
1298
  // listNotes for iCloud/Notes
1170
1299
  .mockReturnValueOnce({ success: true, output: "Note 1" })
1171
1300
  // listFolders for Gmail
1172
- .mockReturnValueOnce({ success: true, output: "Notes" })
1301
+ .mockReturnValueOnce({ success: true, output: "id2\tNotes" })
1173
1302
  // listNotes for Gmail/Notes
1174
1303
  .mockReturnValueOnce({ success: true, output: "Email Note" })
1175
1304
  // getRecentlyModifiedCounts
@@ -1274,10 +1403,19 @@ describe("AppleNotesManager", () => {
1274
1403
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1275
1404
  // Second note: deleteNoteById
1276
1405
  .mockReturnValueOnce({ success: true, output: "" });
1277
- const results = manager.batchDeleteNotes(["id1", "id2"]);
1406
+ const results = manager.batchDeleteNotes([
1407
+ "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1408
+ "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1409
+ ]);
1278
1410
  expect(results).toHaveLength(2);
1279
- expect(results[0]).toEqual({ id: "id1", success: true });
1280
- expect(results[1]).toEqual({ id: "id2", success: true });
1411
+ expect(results[0]).toEqual({
1412
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1413
+ success: true,
1414
+ });
1415
+ expect(results[1]).toEqual({
1416
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1417
+ success: true,
1418
+ });
1281
1419
  });
1282
1420
  it("returns error for non-existent note", () => {
1283
1421
  mockExecuteAppleScript.mockReturnValueOnce({
@@ -1285,9 +1423,11 @@ describe("AppleNotesManager", () => {
1285
1423
  output: "",
1286
1424
  error: "Not found",
1287
1425
  });
1288
- const results = manager.batchDeleteNotes(["nonexistent"]);
1426
+ const results = manager.batchDeleteNotes([
1427
+ "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1428
+ ]);
1289
1429
  expect(results[0]).toEqual({
1290
- id: "nonexistent",
1430
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1291
1431
  success: false,
1292
1432
  error: "Note not found",
1293
1433
  });
@@ -1298,9 +1438,11 @@ describe("AppleNotesManager", () => {
1298
1438
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1299
1439
  // getNoteById for password check (returns true for passwordProtected)
1300
1440
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1301
- const results = manager.batchDeleteNotes(["id1"]);
1441
+ const results = manager.batchDeleteNotes([
1442
+ "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1443
+ ]);
1302
1444
  expect(results[0]).toEqual({
1303
- id: "id1",
1445
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1304
1446
  success: false,
1305
1447
  error: "Note is password-protected",
1306
1448
  });
@@ -1313,9 +1455,19 @@ describe("AppleNotesManager", () => {
1313
1455
  .mockReturnValueOnce({ success: true, output: "" })
1314
1456
  // Second note: not found
1315
1457
  .mockReturnValueOnce({ success: false, output: "", error: "Not found" });
1316
- const results = manager.batchDeleteNotes(["id1", "id2"]);
1317
- expect(results[0]).toEqual({ id: "id1", success: true });
1318
- expect(results[1]).toEqual({ id: "id2", success: false, error: "Note not found" });
1458
+ const results = manager.batchDeleteNotes([
1459
+ "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1460
+ "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1461
+ ]);
1462
+ expect(results[0]).toEqual({
1463
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1464
+ success: true,
1465
+ });
1466
+ expect(results[1]).toEqual({
1467
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1468
+ success: false,
1469
+ error: "Note not found",
1470
+ });
1319
1471
  });
1320
1472
  });
1321
1473
  describe("batchMoveNotes", () => {
@@ -1344,10 +1496,19 @@ describe("AppleNotesManager", () => {
1344
1496
  .mockReturnValueOnce({ success: true, output: "" })
1345
1497
  // Second note: deleteNoteById (original)
1346
1498
  .mockReturnValueOnce({ success: true, output: "" });
1347
- const results = manager.batchMoveNotes(["id1", "id2"], "Archive");
1499
+ const results = manager.batchMoveNotes([
1500
+ "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1501
+ "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1502
+ ], "Archive");
1348
1503
  expect(results).toHaveLength(2);
1349
- expect(results[0]).toEqual({ id: "id1", success: true });
1350
- expect(results[1]).toEqual({ id: "id2", success: true });
1504
+ expect(results[0]).toEqual({
1505
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1506
+ success: true,
1507
+ });
1508
+ expect(results[1]).toEqual({
1509
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1510
+ success: true,
1511
+ });
1351
1512
  });
1352
1513
  it("returns error for non-existent note", () => {
1353
1514
  // getNoteById fails for existence check
@@ -1356,9 +1517,9 @@ describe("AppleNotesManager", () => {
1356
1517
  output: "",
1357
1518
  error: "Not found",
1358
1519
  });
1359
- const results = manager.batchMoveNotes(["nonexistent"], "Archive");
1520
+ const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
1360
1521
  expect(results[0]).toEqual({
1361
- id: "nonexistent",
1522
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1362
1523
  success: false,
1363
1524
  error: "Note not found",
1364
1525
  });
@@ -1369,9 +1530,9 @@ describe("AppleNotesManager", () => {
1369
1530
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1370
1531
  // getNoteById for password check (returns true for passwordProtected)
1371
1532
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1372
- const results = manager.batchMoveNotes(["id1"], "Archive");
1533
+ const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
1373
1534
  expect(results[0]).toEqual({
1374
- id: "id1",
1535
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1375
1536
  success: false,
1376
1537
  error: "Note is password-protected",
1377
1538
  });
@@ -1388,7 +1549,7 @@ describe("AppleNotesManager", () => {
1388
1549
  // listAccounts
1389
1550
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1390
1551
  // listFolders for iCloud
1391
- .mockReturnValueOnce({ success: true, output: "Notes" })
1552
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1392
1553
  // listNotes for Notes folder
1393
1554
  .mockReturnValueOnce({ success: true, output: "Test Note" })
1394
1555
  // getNoteDetails
@@ -1413,7 +1574,7 @@ describe("AppleNotesManager", () => {
1413
1574
  // listAccounts
1414
1575
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1415
1576
  // listFolders for iCloud
1416
- .mockReturnValueOnce({ success: true, output: "Notes" })
1577
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1417
1578
  // listNotes for Notes folder
1418
1579
  .mockReturnValueOnce({ success: true, output: "Locked Note" })
1419
1580
  // getNoteDetails (passwordProtected = true)
@@ -1429,7 +1590,7 @@ describe("AppleNotesManager", () => {
1429
1590
  // listAccounts
1430
1591
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1431
1592
  // listFolders for iCloud
1432
- .mockReturnValueOnce({ success: true, output: "Notes" })
1593
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1433
1594
  // listNotes returns empty
1434
1595
  .mockReturnValueOnce({ success: true, output: "" });
1435
1596
  const result = manager.exportNotesAsJson();
@@ -1486,7 +1647,7 @@ describe("AppleNotesManager", () => {
1486
1647
  output: "",
1487
1648
  error: "Note not found",
1488
1649
  });
1489
- const markdown = manager.getNoteMarkdownById("x-coredata://invalid");
1650
+ const markdown = manager.getNoteMarkdownById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
1490
1651
  expect(markdown).toBe("");
1491
1652
  });
1492
1653
  it("enriches markdown with checklist state when available", () => {
@@ -1523,4 +1684,103 @@ describe("AppleNotesManager", () => {
1523
1684
  expect(markdown).not.toContain("[ ]");
1524
1685
  });
1525
1686
  });
1687
+ // ===========================================================================
1688
+ // Security Tests
1689
+ // ===========================================================================
1690
+ describe("sanitizeId", () => {
1691
+ it("accepts valid CoreData IDs", () => {
1692
+ const id = "x-coredata://12345ABC-DEF0-1234-5678-9ABCDEF01234/ICNote/p100";
1693
+ expect(sanitizeId(id)).toBe(id);
1694
+ });
1695
+ it("accepts temp IDs from generateFallbackId", () => {
1696
+ expect(sanitizeId("temp-1704067200000-0")).toBe("temp-1704067200000-0");
1697
+ expect(sanitizeId("temp-1704067200000-42")).toBe("temp-1704067200000-42");
1698
+ });
1699
+ it("rejects IDs with AppleScript injection", () => {
1700
+ expect(() => sanitizeId('x-coredata://test" & do shell script "rm -rf ~" & "')).toThrow("Invalid note ID format");
1701
+ });
1702
+ it("rejects IDs with double-quote breakout", () => {
1703
+ expect(() => sanitizeId('x-coredata://test"; delete note id "dummy" & "')).toThrow("Invalid note ID format");
1704
+ });
1705
+ it("rejects arbitrary strings", () => {
1706
+ expect(() => sanitizeId("not-a-valid-id")).toThrow("Invalid note ID format");
1707
+ });
1708
+ it("rejects empty string", () => {
1709
+ expect(() => sanitizeId("")).toThrow("Invalid note ID format");
1710
+ });
1711
+ it("accepts various ICEntity types", () => {
1712
+ expect(sanitizeId("x-coredata://ABC123/ICFolder/p50")).toBe("x-coredata://ABC123/ICFolder/p50");
1713
+ expect(sanitizeId("x-coredata://ABC123/ICAttachment/p1")).toBe("x-coredata://ABC123/ICAttachment/p1");
1714
+ });
1715
+ });
1716
+ describe("escapeForAppleScript - injection prevention", () => {
1717
+ it("escapes double quotes to prevent AppleScript string breakout", () => {
1718
+ const malicious = 'Hello "World" end tell';
1719
+ const escaped = escapeForAppleScript(malicious);
1720
+ expect(escaped).toContain('\\"');
1721
+ expect(escaped).not.toContain('"World"');
1722
+ });
1723
+ it("escapes backslashes to prevent escape sequence injection", () => {
1724
+ const malicious = "path\\to\\file";
1725
+ const escaped = escapeForAppleScript(malicious);
1726
+ // Backslashes should be encoded as HTML entities (&#92;)
1727
+ expect(escaped).toContain("&#92;");
1728
+ });
1729
+ it("handles combined injection payload", () => {
1730
+ const payload = '" & do shell script "echo pwned" & "';
1731
+ const escaped = escapeForAppleScript(payload);
1732
+ // All double quotes must be escaped with backslash
1733
+ // Count unescaped double quotes — there should be none
1734
+ const unescapedQuotes = escaped.replace(/\\"/g, "").match(/"/g);
1735
+ expect(unescapedQuotes).toBeNull();
1736
+ });
1737
+ });
1738
+ describe("buildFolderReference - input validation", () => {
1739
+ it("rejects empty folder paths", () => {
1740
+ expect(() => buildFolderReference("")).toThrow("Folder path is empty");
1741
+ });
1742
+ it("rejects paths that are only slashes", () => {
1743
+ expect(() => buildFolderReference("///")).toThrow("Folder path is empty");
1744
+ });
1745
+ it("rejects excessively deep folder nesting", () => {
1746
+ const deepPath = Array(25).fill("folder").join("/");
1747
+ expect(() => buildFolderReference(deepPath)).toThrow("maximum nesting depth");
1748
+ });
1749
+ it("rejects excessively long folder paths", () => {
1750
+ const longPath = "a".repeat(1001);
1751
+ expect(() => buildFolderReference(longPath)).toThrow("maximum length");
1752
+ });
1753
+ it("escapes folder names with double quotes", () => {
1754
+ const result = buildFolderReference('My "Special" Folder');
1755
+ expect(result).toContain('\\"');
1756
+ expect(result).not.toContain('"Special"');
1757
+ });
1758
+ it("handles folder names with emoji", () => {
1759
+ const result = buildFolderReference("Food & Drink/\uD83C\uDF72 Recipes");
1760
+ expect(result).toContain("folder");
1761
+ expect(result).toContain("of");
1762
+ });
1763
+ });
1764
+ describe("ID-based operations sanitize input", () => {
1765
+ it("getNoteById rejects malformed IDs", () => {
1766
+ expect(() => {
1767
+ manager.getNoteById('malicious" & do shell script "echo pwned');
1768
+ }).toThrow("Invalid note ID format");
1769
+ });
1770
+ it("deleteNoteById rejects malformed IDs", () => {
1771
+ expect(() => {
1772
+ manager.deleteNoteById('x-coredata://test"; delete note 1 & "');
1773
+ }).toThrow("Invalid note ID format");
1774
+ });
1775
+ it("getNoteContentById rejects malformed IDs", () => {
1776
+ expect(() => {
1777
+ manager.getNoteContentById("arbitrary string");
1778
+ }).toThrow("Invalid note ID format");
1779
+ });
1780
+ it("updateNoteById rejects malformed IDs", () => {
1781
+ expect(() => {
1782
+ manager.updateNoteById("not-valid", undefined, "content");
1783
+ }).toThrow("Invalid note ID format");
1784
+ });
1785
+ });
1526
1786
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",