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 +30 -11
- package/build/index.js +7 -2
- package/build/services/appleNotesManager.js +218 -47
- package/build/services/appleNotesManager.test.js +237 -33
- package/package.json +1 -1
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
|
|
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 |
|
|
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 (
|
|
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
|
-
|
|
123
|
-
|
|
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 "${
|
|
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
|
|
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
|
|
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
|
|
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 "${
|
|
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 "${
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1068
|
+
set resultList to {}
|
|
945
1069
|
repeat with n in notes
|
|
946
1070
|
if shared of n is true then
|
|
947
|
-
set
|
|
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
|
-
|
|
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
|
|
1083
|
+
if (!output) {
|
|
961
1084
|
continue;
|
|
962
1085
|
}
|
|
963
|
-
//
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
972
|
-
const modifiedStr = parts[
|
|
973
|
-
const shared = parts[
|
|
974
|
-
const passwordProtected = parts[
|
|
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
|
|
1003
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
|
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
|
|
1266
|
+
const folderRef = buildFolderReference(destinationFolder);
|
|
1098
1267
|
const safeContent = escapeHtmlForAppleScript(originalContent);
|
|
1099
|
-
const createCommand = `make new note at
|
|
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
|
|
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
|
|
1310
|
+
const folderRef = buildFolderReference(destinationFolder);
|
|
1140
1311
|
const safeContent = escapeHtmlForAppleScript(originalContent);
|
|
1141
|
-
const createCommand = `make new note at
|
|
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 "${
|
|
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 =
|
|
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 & 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://
|
|
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://
|
|
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 & B</div>";
|
|
838
|
-
const result = manager.updateNoteById("x-coredata://
|
|
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://
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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([
|
|
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({
|
|
1336
|
-
|
|
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([
|
|
1426
|
+
const results = manager.batchDeleteNotes([
|
|
1427
|
+
"x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
|
|
1428
|
+
]);
|
|
1345
1429
|
expect(results[0]).toEqual({
|
|
1346
|
-
id: "
|
|
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([
|
|
1441
|
+
const results = manager.batchDeleteNotes([
|
|
1442
|
+
"x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1443
|
+
]);
|
|
1358
1444
|
expect(results[0]).toEqual({
|
|
1359
|
-
id: "
|
|
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([
|
|
1373
|
-
|
|
1374
|
-
|
|
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([
|
|
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({
|
|
1406
|
-
|
|
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(["
|
|
1520
|
+
const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
|
|
1416
1521
|
expect(results[0]).toEqual({
|
|
1417
|
-
id: "
|
|
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(["
|
|
1533
|
+
const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
|
|
1429
1534
|
expect(results[0]).toEqual({
|
|
1430
|
-
id: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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://
|
|
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 (\)
|
|
1727
|
+
expect(escaped).toContain("\");
|
|
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
|
});
|