apple-notes-mcp 1.3.1 → 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 |
@@ -110,6 +110,8 @@ Creates a new note in Apple Notes.
110
110
  | `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
111
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
+ | `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) |
113
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:**
@@ -121,6 +123,15 @@ 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
  {
@@ -145,7 +156,7 @@ Searches for notes by title or content.
145
156
  | `query` | string | Yes | Text to search for |
146
157
  | `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
147
158
  | `account` | string | No | Account to search in (defaults to iCloud) |
148
- | `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"`) |
149
160
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
150
161
  | `limit` | number | No | Maximum number of results to return |
151
162
 
@@ -342,7 +353,7 @@ Moves a note to a different folder.
342
353
  |-----------|------|----------|-------------|
343
354
  | `id` | string | No | Note ID (preferred - more reliable than title) |
344
355
  | `title` | string | No | Title of the note to move (use `id` instead when available) |
345
- | `folder` | string | Yes | Name of the destination folder |
356
+ | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |
346
357
  | `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
347
358
 
348
359
  **Note:** Either `id` or `title` must be provided. Using `id` is recommended.
@@ -376,7 +387,7 @@ Lists all notes, optionally filtered by folder, date, and limit.
376
387
  | Parameter | Type | Required | Description |
377
388
  |-----------|------|----------|-------------|
378
389
  | `account` | string | No | Account to list notes from (defaults to iCloud) |
379
- | `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"`) |
380
391
  | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
381
392
  | `limit` | number | No | Maximum number of notes to return |
382
393
 
@@ -408,7 +419,7 @@ Lists all notes, optionally filtered by folder, date, and limit.
408
419
 
409
420
  #### `list-folders`
410
421
 
411
- Lists all folders in an account.
422
+ Lists all folders in an account with full hierarchical paths.
412
423
 
413
424
  | Parameter | Type | Required | Description |
414
425
  |-----------|------|----------|-------------|
@@ -419,7 +430,7 @@ Lists all folders in an account.
419
430
  {}
420
431
  ```
421
432
 
422
- **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`).
423
434
 
424
435
  ---
425
436
 
@@ -449,7 +460,7 @@ Deletes a folder.
449
460
 
450
461
  | Parameter | Type | Required | Description |
451
462
  |-----------|------|----------|-------------|
452
- | `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"`) |
453
464
  | `account` | string | No | Account containing the folder (defaults to iCloud) |
454
465
 
455
466
  **Example:**
@@ -501,7 +512,7 @@ Moves multiple notes to a folder.
501
512
  | Parameter | Type | Required | Description |
502
513
  |-----------|------|----------|-------------|
503
514
  | `ids` | string[] | Yes | Array of note IDs to move |
504
- | `folder` | string | Yes | Destination folder name |
515
+ | `folder` | string | Yes | Destination folder name or nested path (e.g., `"Work/Clients"`) |
505
516
  | `account` | string | No | Account containing the folder |
506
517
 
507
518
  **Returns:** Summary of successes and failures.
@@ -660,6 +671,14 @@ AI: [calls create-folder with name="Archive"]
660
671
  User: "Move my old meeting notes to Archive"
661
672
  AI: [calls move-note with title="Old Meeting Notes", folder="Archive"]
662
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"
663
682
  ```
664
683
 
665
684
  ---
@@ -793,7 +812,7 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
793
812
  ```bash
794
813
  npm install # Install dependencies
795
814
  npm run build # Compile TypeScript
796
- npm test # Run test suite (217 tests)
815
+ npm test # Run test suite (310 tests)
797
816
  npm run lint # Check code style
798
817
  npm run format # Format code
799
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
@@ -405,6 +518,8 @@ export class AppleNotesManager {
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
524
  // Build body HTML: title as <h1>, content follows.
410
525
  // We set only 'body' (not 'name') to avoid title duplication —
@@ -423,10 +538,10 @@ export class AppleNotesManager {
423
538
  // Build the AppleScript command
424
539
  let createCommand;
425
540
  if (folder) {
426
- // Create note in specific folder
427
- const safeFolder = escapeForAppleScript(folder);
541
+ // Create note in specific folder (supports nested paths like "Work/Clients")
542
+ const folderRef = buildFolderReference(folder);
428
543
  createCommand = `
429
- set newNote to make new note at folder "${safeFolder}" with properties {body:"${safeBody}"}
544
+ set newNote to make new note at ${folderRef} with properties {body:"${safeBody}"}
430
545
  return id of newNote
431
546
  `;
432
547
  }
@@ -515,7 +630,7 @@ export class AppleNotesManager {
515
630
  }
516
631
  const whereClause = whereParts.join(" and ");
517
632
  // Build the notes source - either all notes or notes in a specific folder
518
- const notesSource = folder ? `notes of folder "${escapeForAppleScript(folder)}"` : "notes";
633
+ const notesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
519
634
  // Build the limit logic for the repeat loop
520
635
  // Note: The limit only reduces iteration over already-matched results from the whose clause,
521
636
  // not the query itself. It controls output size, not AppleScript query performance.
@@ -622,8 +737,9 @@ export class AppleNotesManager {
622
737
  * @returns HTML content of the note, or empty string if not found
623
738
  */
624
739
  getNoteContentById(id) {
740
+ const safeId = sanitizeId(id);
625
741
  // Note IDs work at the application level, not scoped to account
626
- const getCommand = `get body of note id "${id}"`;
742
+ const getCommand = `get body of note id "${safeId}"`;
627
743
  const script = buildAppLevelScript(getCommand);
628
744
  const result = executeAppleScript(script);
629
745
  if (!result.success) {
@@ -644,9 +760,10 @@ export class AppleNotesManager {
644
760
  * @returns Note object with metadata, or null if not found
645
761
  */
646
762
  getNoteById(id) {
763
+ const safeId = sanitizeId(id);
647
764
  // Note IDs work at the application level, not scoped to account
648
765
  const getCommand = `
649
- set n to note id "${id}"
766
+ set n to note id "${safeId}"
650
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}
651
768
  return noteProps
652
769
  `;
@@ -746,7 +863,8 @@ export class AppleNotesManager {
746
863
  * @returns true if deletion succeeded, false otherwise
747
864
  */
748
865
  deleteNoteById(id) {
749
- const deleteCommand = `delete note id "${id}"`;
866
+ const safeId = sanitizeId(id);
867
+ const deleteCommand = `delete note id "${safeId}"`;
750
868
  const script = buildAppLevelScript(deleteCommand);
751
869
  const result = executeAppleScript(script);
752
870
  if (!result.success) {
@@ -777,6 +895,9 @@ export class AppleNotesManager {
777
895
  * @returns true if update succeeded, false otherwise
778
896
  */
779
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");
780
901
  const targetAccount = this.resolveAccount(account);
781
902
  const safeCurrentTitle = escapeForAppleScript(title);
782
903
  let fullBody;
@@ -820,6 +941,9 @@ export class AppleNotesManager {
820
941
  * @returns true if update succeeded, false otherwise
821
942
  */
822
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");
823
947
  let fullBody;
824
948
  if (format === "html") {
825
949
  // HTML mode: content is the complete body, escaped only for AppleScript string
@@ -841,7 +965,8 @@ export class AppleNotesManager {
841
965
  const safeContent = escapeForAppleScript(newContent);
842
966
  fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
843
967
  }
844
- 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}"`;
845
970
  const script = buildAppLevelScript(updateCommand);
846
971
  const result = executeAppleScript(script);
847
972
  if (!result.success) {
@@ -864,9 +989,7 @@ export class AppleNotesManager {
864
989
  const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
865
990
  // When date or limit filters are needed, use a repeat loop for fine-grained control
866
991
  if (modifiedSince || safeLimit !== undefined) {
867
- const baseNotesSource = folder
868
- ? `notes of folder "${escapeForAppleScript(folder)}"`
869
- : "notes";
992
+ const baseNotesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
870
993
  // Use whose clause for date filtering (locale-safe, no sort order assumption)
871
994
  let dateSetup = "";
872
995
  let notesSource = baseNotesSource;
@@ -907,8 +1030,7 @@ export class AppleNotesManager {
907
1030
  // Simple path: no date or limit filters
908
1031
  let listCommand;
909
1032
  if (folder) {
910
- const safeFolder = escapeForAppleScript(folder);
911
- listCommand = `get name of notes of folder "${safeFolder}"`;
1033
+ listCommand = `get name of notes of ${buildFolderReference(folder)}`;
912
1034
  }
913
1035
  else {
914
1036
  listCommand = `get name of notes`;
@@ -940,38 +1062,38 @@ export class AppleNotesManager {
940
1062
  // Query each account for shared notes
941
1063
  const accounts = this.listAccounts();
942
1064
  for (const account of accounts) {
1065
+ // Use delimited output to avoid fragile comma-based parsing.
1066
+ // Format: name|||id|||createdDate|||modifiedDate|||shared|||passwordProtected
943
1067
  const script = buildAccountScopedScript({ account: account.name }, `
944
- set sharedList to {}
1068
+ set resultList to {}
945
1069
  repeat with n in notes
946
1070
  if shared of n is true then
947
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
948
- 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)
949
1072
  end if
950
1073
  end repeat
951
- return sharedList
1074
+ set AppleScript's text item delimiters to "|||ITEM|||"
1075
+ return resultList as text
952
1076
  `);
953
1077
  const result = executeAppleScript(script);
954
1078
  if (!result.success) {
955
1079
  console.error(`Failed to list shared notes for ${account.name}:`, result.error);
956
1080
  continue;
957
1081
  }
958
- // Parse the result - format is: {{name, id, date, date, bool, bool}, {...}, ...}
959
1082
  const output = result.output.trim();
960
- if (!output || output === "{}" || output === "{}") {
1083
+ if (!output) {
961
1084
  continue;
962
1085
  }
963
- // Extract individual note data using regex
964
- const notePattern = /\{([^{}]+)\}/g;
965
- let match;
966
- while ((match = notePattern.exec(output)) !== null) {
967
- 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("|||");
968
1090
  if (parts.length >= 6) {
969
1091
  const title = parts[0].trim();
970
1092
  const id = parts[1].trim();
971
- const createdStr = parts.slice(2, parts.length - 3).join(", ");
972
- const modifiedStr = parts[parts.length - 3];
973
- const shared = parts[parts.length - 2] === "true";
974
- 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";
975
1097
  sharedNotes.push({
976
1098
  id,
977
1099
  title,
@@ -992,26 +1114,74 @@ export class AppleNotesManager {
992
1114
  // Folder Operations
993
1115
  // ===========================================================================
994
1116
  /**
995
- * 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.
996
1122
  *
997
1123
  * @param account - Account to list folders from (defaults to iCloud)
998
- * @returns Array of Folder objects
1124
+ * @returns Array of Folder objects with path-based names
999
1125
  */
1000
1126
  listFolders(account) {
1001
1127
  const targetAccount = this.resolveAccount(account);
1002
- // Get folder names (simpler than getting full objects)
1003
- 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
+ `;
1004
1148
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1005
1149
  const result = executeAppleScript(script);
1006
1150
  if (!result.success) {
1007
1151
  console.error("Failed to list folders:", result.error);
1008
1152
  return [];
1009
1153
  }
1010
- // Convert names to Folder objects
1011
- const names = parseCommaSeparatedList(result.output);
1012
- return names.map((name) => ({
1013
- id: "", // Would require additional query to get
1014
- 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),
1015
1185
  account: targetAccount,
1016
1186
  }));
1017
1187
  }
@@ -1051,8 +1221,7 @@ export class AppleNotesManager {
1051
1221
  */
1052
1222
  deleteFolder(name, account) {
1053
1223
  const targetAccount = this.resolveAccount(account);
1054
- const safeName = escapeForAppleScript(name);
1055
- const deleteCommand = `delete folder "${safeName}"`;
1224
+ const deleteCommand = `delete ${buildFolderReference(name)}`;
1056
1225
  const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
1057
1226
  const result = executeAppleScript(script);
1058
1227
  if (!result.success) {
@@ -1094,9 +1263,9 @@ export class AppleNotesManager {
1094
1263
  }
1095
1264
  // Step 3: Create a copy in the destination folder
1096
1265
  // Content is already HTML from getNoteContent(), so use escapeHtmlForAppleScript()
1097
- const safeFolder = escapeForAppleScript(destinationFolder);
1266
+ const folderRef = buildFolderReference(destinationFolder);
1098
1267
  const safeContent = escapeHtmlForAppleScript(originalContent);
1099
- const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
1268
+ const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1100
1269
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1101
1270
  const copyResult = executeAppleScript(script);
1102
1271
  if (!copyResult.success) {
@@ -1104,7 +1273,8 @@ export class AppleNotesManager {
1104
1273
  return false;
1105
1274
  }
1106
1275
  // Step 4: Delete the original by ID (not by title, since there are now two notes with the same title)
1107
- const deleteCommand = `delete note id "${originalNote.id}"`;
1276
+ const safeOrigId = sanitizeId(originalNote.id);
1277
+ const deleteCommand = `delete note id "${safeOrigId}"`;
1108
1278
  const deleteScript = buildAppLevelScript(deleteCommand);
1109
1279
  const deleteResult = executeAppleScript(deleteScript);
1110
1280
  if (!deleteResult.success) {
@@ -1128,6 +1298,7 @@ export class AppleNotesManager {
1128
1298
  */
1129
1299
  moveNoteById(id, destinationFolder, account) {
1130
1300
  const targetAccount = this.resolveAccount(account);
1301
+ const safeId = sanitizeId(id);
1131
1302
  // Step 1: Retrieve the original note's content by ID
1132
1303
  const originalContent = this.getNoteContentById(id);
1133
1304
  if (!originalContent) {
@@ -1136,9 +1307,9 @@ export class AppleNotesManager {
1136
1307
  }
1137
1308
  // Step 2: Create a copy in the destination folder
1138
1309
  // Content is already HTML from getNoteContentById(), so use escapeHtmlForAppleScript()
1139
- const safeFolder = escapeForAppleScript(destinationFolder);
1310
+ const folderRef = buildFolderReference(destinationFolder);
1140
1311
  const safeContent = escapeHtmlForAppleScript(originalContent);
1141
- const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
1312
+ const createCommand = `make new note at ${folderRef} with properties {body:"${safeContent}"}`;
1142
1313
  const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1143
1314
  const copyResult = executeAppleScript(script);
1144
1315
  if (!copyResult.success) {
@@ -1146,7 +1317,7 @@ export class AppleNotesManager {
1146
1317
  return false;
1147
1318
  }
1148
1319
  // Step 3: Delete the original by ID
1149
- const deleteCommand = `delete note id "${id}"`;
1320
+ const deleteCommand = `delete note id "${safeId}"`;
1150
1321
  const deleteScript = buildAppLevelScript(deleteCommand);
1151
1322
  const deleteResult = executeAppleScript(deleteScript);
1152
1323
  if (!deleteResult.success) {
@@ -1399,7 +1570,7 @@ export class AppleNotesManager {
1399
1570
  * ```
1400
1571
  */
1401
1572
  listAttachmentsById(id) {
1402
- const safeId = escapeForAppleScript(id);
1573
+ const safeId = sanitizeId(id);
1403
1574
  const script = `
1404
1575
  tell application "Notes"
1405
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", () => {
@@ -627,7 +667,7 @@ describe("AppleNotesManager", () => {
627
667
  output: "",
628
668
  error: "Note not found",
629
669
  });
630
- const result = manager.isNotePasswordProtectedById("x-coredata://invalid");
670
+ const result = manager.isNotePasswordProtectedById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
631
671
  expect(result).toBe(false);
632
672
  });
633
673
  });
@@ -653,7 +693,7 @@ describe("AppleNotesManager", () => {
653
693
  output: "",
654
694
  error: "Can't get note id",
655
695
  });
656
- const result = manager.getNoteById("x-coredata://invalid");
696
+ const result = manager.getNoteById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
657
697
  expect(result).toBeNull();
658
698
  });
659
699
  it("returns null when response format is unexpected (no commas)", () => {
@@ -835,7 +875,7 @@ describe("AppleNotesManager", () => {
835
875
  output: "",
836
876
  });
837
877
  const htmlContent = "<h1>My Title</h1><div>A &amp; B</div>";
838
- 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");
839
879
  expect(result).toBe(true);
840
880
  // HTML mode: content passed directly, no <div> wrapper
841
881
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
@@ -848,7 +888,7 @@ describe("AppleNotesManager", () => {
848
888
  output: "",
849
889
  });
850
890
  const htmlContent = "<h1>Title</h1><div>Body</div>";
851
- manager.updateNoteById("x-coredata://abc/456", undefined, htmlContent, "html");
891
+ manager.updateNoteById("x-coredata://ABC00000-0000-0000-0000-000000000002/ICNote/p456", undefined, htmlContent, "html");
852
892
  // In HTML mode, getNoteById should NOT be called (it would trigger
853
893
  // an additional executeAppleScript call). Only one call should happen:
854
894
  // the update itself.
@@ -952,21 +992,54 @@ describe("AppleNotesManager", () => {
952
992
  // Folder Operations
953
993
  // ---------------------------------------------------------------------------
954
994
  describe("listFolders", () => {
955
- it("returns array of Folder objects", () => {
995
+ it("returns array of Folder objects with paths", () => {
956
996
  mockExecuteAppleScript.mockReturnValue({
957
997
  success: true,
958
- output: "Notes, Archive, Work",
998
+ output: "id1\tNotes\nid2\tArchive\nid3\tWork",
959
999
  });
960
1000
  const folders = manager.listFolders();
961
1001
  expect(folders).toHaveLength(3);
962
1002
  expect(folders[0].name).toBe("Notes");
963
1003
  expect(folders[1].name).toBe("Archive");
964
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");
965
1038
  });
966
1039
  it("includes account in Folder objects", () => {
967
1040
  mockExecuteAppleScript.mockReturnValue({
968
1041
  success: true,
969
- output: "Notes",
1042
+ output: "id1\tNotes",
970
1043
  });
971
1044
  const folders = manager.listFolders("Gmail");
972
1045
  expect(folders[0].account).toBe("Gmail");
@@ -1189,7 +1262,7 @@ describe("AppleNotesManager", () => {
1189
1262
  // listAccounts
1190
1263
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1191
1264
  // listFolders for iCloud
1192
- .mockReturnValueOnce({ success: true, output: "Notes, Work" })
1265
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes\nid2\tWork" })
1193
1266
  // listNotes for Notes folder
1194
1267
  .mockReturnValueOnce({ success: true, output: "Note 1, Note 2, Note 3" })
1195
1268
  // listNotes for Work folder
@@ -1207,7 +1280,7 @@ describe("AppleNotesManager", () => {
1207
1280
  it("returns zero counts when no notes exist", () => {
1208
1281
  mockExecuteAppleScript
1209
1282
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1210
- .mockReturnValueOnce({ success: true, output: "Notes" })
1283
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1211
1284
  .mockReturnValueOnce({ success: true, output: "" })
1212
1285
  .mockReturnValueOnce({ success: true, output: "" });
1213
1286
  const stats = manager.getNotesStats();
@@ -1221,11 +1294,11 @@ describe("AppleNotesManager", () => {
1221
1294
  // listAccounts
1222
1295
  .mockReturnValueOnce({ success: true, output: "iCloud, Gmail" })
1223
1296
  // listFolders for iCloud
1224
- .mockReturnValueOnce({ success: true, output: "Notes" })
1297
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1225
1298
  // listNotes for iCloud/Notes
1226
1299
  .mockReturnValueOnce({ success: true, output: "Note 1" })
1227
1300
  // listFolders for Gmail
1228
- .mockReturnValueOnce({ success: true, output: "Notes" })
1301
+ .mockReturnValueOnce({ success: true, output: "id2\tNotes" })
1229
1302
  // listNotes for Gmail/Notes
1230
1303
  .mockReturnValueOnce({ success: true, output: "Email Note" })
1231
1304
  // getRecentlyModifiedCounts
@@ -1330,10 +1403,19 @@ describe("AppleNotesManager", () => {
1330
1403
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1331
1404
  // Second note: deleteNoteById
1332
1405
  .mockReturnValueOnce({ success: true, output: "" });
1333
- 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
+ ]);
1334
1410
  expect(results).toHaveLength(2);
1335
- expect(results[0]).toEqual({ id: "id1", success: true });
1336
- 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
+ });
1337
1419
  });
1338
1420
  it("returns error for non-existent note", () => {
1339
1421
  mockExecuteAppleScript.mockReturnValueOnce({
@@ -1341,9 +1423,11 @@ describe("AppleNotesManager", () => {
1341
1423
  output: "",
1342
1424
  error: "Not found",
1343
1425
  });
1344
- const results = manager.batchDeleteNotes(["nonexistent"]);
1426
+ const results = manager.batchDeleteNotes([
1427
+ "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1428
+ ]);
1345
1429
  expect(results[0]).toEqual({
1346
- id: "nonexistent",
1430
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1347
1431
  success: false,
1348
1432
  error: "Note not found",
1349
1433
  });
@@ -1354,9 +1438,11 @@ describe("AppleNotesManager", () => {
1354
1438
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1355
1439
  // getNoteById for password check (returns true for passwordProtected)
1356
1440
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1357
- const results = manager.batchDeleteNotes(["id1"]);
1441
+ const results = manager.batchDeleteNotes([
1442
+ "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1443
+ ]);
1358
1444
  expect(results[0]).toEqual({
1359
- id: "id1",
1445
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1360
1446
  success: false,
1361
1447
  error: "Note is password-protected",
1362
1448
  });
@@ -1369,9 +1455,19 @@ describe("AppleNotesManager", () => {
1369
1455
  .mockReturnValueOnce({ success: true, output: "" })
1370
1456
  // Second note: not found
1371
1457
  .mockReturnValueOnce({ success: false, output: "", error: "Not found" });
1372
- const results = manager.batchDeleteNotes(["id1", "id2"]);
1373
- expect(results[0]).toEqual({ id: "id1", success: true });
1374
- 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
+ });
1375
1471
  });
1376
1472
  });
1377
1473
  describe("batchMoveNotes", () => {
@@ -1400,10 +1496,19 @@ describe("AppleNotesManager", () => {
1400
1496
  .mockReturnValueOnce({ success: true, output: "" })
1401
1497
  // Second note: deleteNoteById (original)
1402
1498
  .mockReturnValueOnce({ success: true, output: "" });
1403
- 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");
1404
1503
  expect(results).toHaveLength(2);
1405
- expect(results[0]).toEqual({ id: "id1", success: true });
1406
- 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
+ });
1407
1512
  });
1408
1513
  it("returns error for non-existent note", () => {
1409
1514
  // getNoteById fails for existence check
@@ -1412,9 +1517,9 @@ describe("AppleNotesManager", () => {
1412
1517
  output: "",
1413
1518
  error: "Not found",
1414
1519
  });
1415
- const results = manager.batchMoveNotes(["nonexistent"], "Archive");
1520
+ const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
1416
1521
  expect(results[0]).toEqual({
1417
- id: "nonexistent",
1522
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1418
1523
  success: false,
1419
1524
  error: "Note not found",
1420
1525
  });
@@ -1425,9 +1530,9 @@ describe("AppleNotesManager", () => {
1425
1530
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1426
1531
  // getNoteById for password check (returns true for passwordProtected)
1427
1532
  .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1428
- const results = manager.batchMoveNotes(["id1"], "Archive");
1533
+ const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
1429
1534
  expect(results[0]).toEqual({
1430
- id: "id1",
1535
+ id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1431
1536
  success: false,
1432
1537
  error: "Note is password-protected",
1433
1538
  });
@@ -1444,7 +1549,7 @@ describe("AppleNotesManager", () => {
1444
1549
  // listAccounts
1445
1550
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1446
1551
  // listFolders for iCloud
1447
- .mockReturnValueOnce({ success: true, output: "Notes" })
1552
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1448
1553
  // listNotes for Notes folder
1449
1554
  .mockReturnValueOnce({ success: true, output: "Test Note" })
1450
1555
  // getNoteDetails
@@ -1469,7 +1574,7 @@ describe("AppleNotesManager", () => {
1469
1574
  // listAccounts
1470
1575
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1471
1576
  // listFolders for iCloud
1472
- .mockReturnValueOnce({ success: true, output: "Notes" })
1577
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1473
1578
  // listNotes for Notes folder
1474
1579
  .mockReturnValueOnce({ success: true, output: "Locked Note" })
1475
1580
  // getNoteDetails (passwordProtected = true)
@@ -1485,7 +1590,7 @@ describe("AppleNotesManager", () => {
1485
1590
  // listAccounts
1486
1591
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1487
1592
  // listFolders for iCloud
1488
- .mockReturnValueOnce({ success: true, output: "Notes" })
1593
+ .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1489
1594
  // listNotes returns empty
1490
1595
  .mockReturnValueOnce({ success: true, output: "" });
1491
1596
  const result = manager.exportNotesAsJson();
@@ -1542,7 +1647,7 @@ describe("AppleNotesManager", () => {
1542
1647
  output: "",
1543
1648
  error: "Note not found",
1544
1649
  });
1545
- const markdown = manager.getNoteMarkdownById("x-coredata://invalid");
1650
+ const markdown = manager.getNoteMarkdownById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
1546
1651
  expect(markdown).toBe("");
1547
1652
  });
1548
1653
  it("enriches markdown with checklist state when available", () => {
@@ -1579,4 +1684,103 @@ describe("AppleNotesManager", () => {
1579
1684
  expect(markdown).not.toContain("[ ]");
1580
1685
  });
1581
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
+ });
1582
1786
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "1.3.1",
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",