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