apple-notes-mcp 2.3.0 → 2.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 +58 -0
- package/build/index.js +124 -1
- package/build/services/__fixtures__/notesNormalizedHtml.js +32 -0
- package/build/services/appleNotesManager.js +132 -0
- package/build/services/appleNotesManager.test.js +109 -0
- package/build/services/notesHtmlMarkdown.test.js +55 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -267,6 +267,22 @@ see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--h
|
|
|
267
267
|
|
|
268
268
|
---
|
|
269
269
|
|
|
270
|
+
#### `get-note-plaintext`
|
|
271
|
+
|
|
272
|
+
Retrieves a note's body as plain text, with no HTML markup.
|
|
273
|
+
|
|
274
|
+
| Parameter | Type | Required | Description |
|
|
275
|
+
|-----------|------|----------|-------------|
|
|
276
|
+
| `id` | string | No | Note ID (preferred - more reliable than title) |
|
|
277
|
+
| `title` | string | No | Note title (use `id` instead when available) |
|
|
278
|
+
| `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
|
|
279
|
+
|
|
280
|
+
**Note:** Either `id` or `title` must be provided. This reads the note's native `plaintext` property, so it skips the HTML-to-text conversion that `get-note-content` plus a Markdown pass would do. Use `get-note-content` when you need the HTML, or `get-note-markdown` when you want Markdown with checklist state.
|
|
281
|
+
|
|
282
|
+
**Returns:** The plain-text content of the note in `structuredContent.plaintext`, or error if not found.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
270
286
|
#### `get-note-details`
|
|
271
287
|
|
|
272
288
|
Retrieves metadata about a note (without full content).
|
|
@@ -376,6 +392,8 @@ Updates an existing note's content and/or title.
|
|
|
376
392
|
|
|
377
393
|
**Note:** `newContent` **replaces the entire note body** — it is not appended. To preserve existing content, read it first (e.g. with `get-note-content`) and include it in `newContent`.
|
|
378
394
|
|
|
395
|
+
**Attachments:** A full-body replace can drop embedded files, images, scans, PDFs, or audio. When a note may hold attachments, run [`list-attachments`](#list-attachments) first, and either save them with `save-attachment` or build a new note rather than overwriting. See the skill's [Attachment-Safe Updates](skills/apple-notes/SKILL.md#attachment-safe-updates) guidance.
|
|
396
|
+
|
|
379
397
|
---
|
|
380
398
|
|
|
381
399
|
#### `delete-note`
|
|
@@ -551,6 +569,19 @@ Deletes a folder.
|
|
|
551
569
|
|
|
552
570
|
---
|
|
553
571
|
|
|
572
|
+
#### `show-folder`
|
|
573
|
+
|
|
574
|
+
Reveals a folder in Notes.app using its unique CoreData identifier.
|
|
575
|
+
|
|
576
|
+
| Parameter | Type | Required | Description |
|
|
577
|
+
|-----------|------|----------|-------------|
|
|
578
|
+
| `id` | string | Yes | The folder's CoreData identifier (from `list-folders`) |
|
|
579
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
580
|
+
|
|
581
|
+
**Returns:** Confirmation that Notes.app accepted the show command.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
554
585
|
### Account Operations
|
|
555
586
|
|
|
556
587
|
#### `list-accounts`
|
|
@@ -578,6 +609,19 @@ Returns the default account and folder Notes.app uses for newly created notes.
|
|
|
578
609
|
|
|
579
610
|
---
|
|
580
611
|
|
|
612
|
+
#### `show-account`
|
|
613
|
+
|
|
614
|
+
Reveals an account in Notes.app using its unique CoreData identifier.
|
|
615
|
+
|
|
616
|
+
| Parameter | Type | Required | Description |
|
|
617
|
+
|-----------|------|----------|-------------|
|
|
618
|
+
| `id` | string | Yes | The account's CoreData identifier (from `list-accounts`) |
|
|
619
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
620
|
+
|
|
621
|
+
**Returns:** Confirmation that Notes.app accepted the show command.
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
581
625
|
### Batch Operations
|
|
582
626
|
|
|
583
627
|
#### `batch-delete-notes`
|
|
@@ -703,6 +747,20 @@ Returns a note attachment's bytes as base64, without writing to disk (the read c
|
|
|
703
747
|
|
|
704
748
|
---
|
|
705
749
|
|
|
750
|
+
#### `show-attachment`
|
|
751
|
+
|
|
752
|
+
Reveals one note attachment in Notes.app. Attachments are elements of a note, so this takes both the note id and the attachment id (the same pair used by `save-attachment` / `fetch-attachment`).
|
|
753
|
+
|
|
754
|
+
| Parameter | Type | Required | Description |
|
|
755
|
+
|-----------|------|----------|-------------|
|
|
756
|
+
| `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
|
|
757
|
+
| `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
|
|
758
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
759
|
+
|
|
760
|
+
**Returns:** Confirmation that Notes.app revealed the attachment.
|
|
761
|
+
|
|
762
|
+
---
|
|
763
|
+
|
|
706
764
|
### Diagnostics
|
|
707
765
|
|
|
708
766
|
#### `health-check`
|
package/build/index.js
CHANGED
|
@@ -254,6 +254,54 @@ server.registerTool("get-note-content", {
|
|
|
254
254
|
const hashtags = parseHashtags(content);
|
|
255
255
|
return successResponse(content, { title, content, hashtags });
|
|
256
256
|
}, "Error retrieving note content"));
|
|
257
|
+
// --- get-note-plaintext ---
|
|
258
|
+
server.registerTool("get-note-plaintext", {
|
|
259
|
+
description: "Use when: reading one note's body as plain text with no HTML, by id (preferred) or title.\nReturns: the note's plaintext exactly as Notes exposes it.\nDo not use when: you need the HTML body (get-note-content) or Markdown with checklist state (get-note-markdown).\nNote: this reads the note's native plaintext property, so it skips the HTML-to-text conversion; password-protected notes must be unlocked in Notes.app first.",
|
|
260
|
+
inputSchema: {
|
|
261
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
262
|
+
title: z.string().optional().describe("Note title (use id instead when available)"),
|
|
263
|
+
account: z
|
|
264
|
+
.string()
|
|
265
|
+
.optional()
|
|
266
|
+
.describe("Account name (defaults to iCloud, ignored if id is provided)"),
|
|
267
|
+
},
|
|
268
|
+
outputSchema: {
|
|
269
|
+
title: z.string().optional(),
|
|
270
|
+
plaintext: z.string().optional(),
|
|
271
|
+
},
|
|
272
|
+
}, withErrorHandling(({ id, title, account }) => {
|
|
273
|
+
// Prefer ID-based lookup if provided
|
|
274
|
+
if (id) {
|
|
275
|
+
const note = notesManager.getNoteById(id);
|
|
276
|
+
if (!note) {
|
|
277
|
+
return errorResponse(`Note with ID "${id}" not found`);
|
|
278
|
+
}
|
|
279
|
+
if (note.passwordProtected) {
|
|
280
|
+
return errorResponse(`Note "${note.title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
|
|
281
|
+
}
|
|
282
|
+
const plaintext = notesManager.getNotePlaintextById(id);
|
|
283
|
+
if (!plaintext) {
|
|
284
|
+
return errorResponse(`Failed to read plaintext of note "${note.title}"`);
|
|
285
|
+
}
|
|
286
|
+
return successResponse(plaintext, { title: note.title, plaintext });
|
|
287
|
+
}
|
|
288
|
+
// Fall back to title-based lookup
|
|
289
|
+
if (!title) {
|
|
290
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
291
|
+
}
|
|
292
|
+
const note = notesManager.getNoteDetails(title, account);
|
|
293
|
+
if (!note) {
|
|
294
|
+
return errorResponse(`Note "${title}" not found`);
|
|
295
|
+
}
|
|
296
|
+
if (note.passwordProtected) {
|
|
297
|
+
return errorResponse(`Note "${title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
|
|
298
|
+
}
|
|
299
|
+
const plaintext = notesManager.getNotePlaintext(title, account);
|
|
300
|
+
if (!plaintext) {
|
|
301
|
+
return errorResponse(`Failed to read plaintext of note "${title}"`);
|
|
302
|
+
}
|
|
303
|
+
return successResponse(plaintext, { title, plaintext });
|
|
304
|
+
}, "Error retrieving note plaintext"));
|
|
257
305
|
// --- get-note-by-id ---
|
|
258
306
|
server.registerTool("get-note-by-id", {
|
|
259
307
|
description: "Use when: you have a note id and need its metadata only.\nReturns: id, title, created, modified, shared, passwordProtected.\nDo not use when: you need the body text (get-note-content) or only have a title (get-note-details).",
|
|
@@ -335,9 +383,51 @@ server.registerTool("show-note", {
|
|
|
335
383
|
}
|
|
336
384
|
return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
|
|
337
385
|
}, "Error showing note"));
|
|
386
|
+
// --- show-folder ---
|
|
387
|
+
server.registerTool("show-folder", {
|
|
388
|
+
description: "Use when: the user wants to reveal a known folder in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the folder list (list-folders).\nNote: this opens or focuses the Notes UI. Get the id from list-folders.",
|
|
389
|
+
inputSchema: {
|
|
390
|
+
id: z.string().min(1, "Folder ID is required"),
|
|
391
|
+
separately: z
|
|
392
|
+
.boolean()
|
|
393
|
+
.optional()
|
|
394
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
395
|
+
},
|
|
396
|
+
outputSchema: {
|
|
397
|
+
id: z.string().optional(),
|
|
398
|
+
separately: z.boolean().optional(),
|
|
399
|
+
},
|
|
400
|
+
}, withErrorHandling(({ id, separately = false }) => {
|
|
401
|
+
const success = notesManager.showFolderById(id, separately);
|
|
402
|
+
if (!success) {
|
|
403
|
+
return errorResponse(`Failed to show folder with ID "${id}"`);
|
|
404
|
+
}
|
|
405
|
+
return successResponse(`Shown folder with ID "${id}" in Notes.app`, { id, separately });
|
|
406
|
+
}, "Error showing folder"));
|
|
407
|
+
// --- show-account ---
|
|
408
|
+
server.registerTool("show-account", {
|
|
409
|
+
description: "Use when: the user wants to reveal a known account in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the account list (list-accounts).\nNote: this opens or focuses the Notes UI. Get the id from list-accounts.",
|
|
410
|
+
inputSchema: {
|
|
411
|
+
id: z.string().min(1, "Account ID is required"),
|
|
412
|
+
separately: z
|
|
413
|
+
.boolean()
|
|
414
|
+
.optional()
|
|
415
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
416
|
+
},
|
|
417
|
+
outputSchema: {
|
|
418
|
+
id: z.string().optional(),
|
|
419
|
+
separately: z.boolean().optional(),
|
|
420
|
+
},
|
|
421
|
+
}, withErrorHandling(({ id, separately = false }) => {
|
|
422
|
+
const success = notesManager.showAccountById(id, separately);
|
|
423
|
+
if (!success) {
|
|
424
|
+
return errorResponse(`Failed to show account with ID "${id}"`);
|
|
425
|
+
}
|
|
426
|
+
return successResponse(`Shown account with ID "${id}" in Notes.app`, { id, separately });
|
|
427
|
+
}, "Error showing account"));
|
|
338
428
|
// --- update-note ---
|
|
339
429
|
server.registerTool("update-note", {
|
|
340
|
-
description: "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body — it does not append. Read the note first if you need to preserve existing text. Edits to shared notes are immediately visible to all collaborators.",
|
|
430
|
+
description: "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body — it does not append. Read the note first if you need to preserve existing text, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.",
|
|
341
431
|
inputSchema: {
|
|
342
432
|
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
343
433
|
title: z.string().optional().describe("Current note title (use id instead when available)"),
|
|
@@ -1048,6 +1138,39 @@ server.registerTool("fetch-attachment", {
|
|
|
1048
1138
|
}
|
|
1049
1139
|
return successResponse(`Fetched "${r.name ?? "attachment"}" (${r.contentType ?? "unknown type"}, ${r.bytes ?? 0} bytes) as base64.`, { name: r.name, contentType: r.contentType, bytes: r.bytes, base64: r.base64 });
|
|
1050
1140
|
}, "Error fetching attachment"));
|
|
1141
|
+
// --- show-attachment ---
|
|
1142
|
+
server.registerTool("show-attachment", {
|
|
1143
|
+
description: "Use when: the user wants to reveal one note attachment in Notes.app.\nReturns: confirmation that Notes.app revealed the attachment.\nDo not use when: you want the bytes (fetch-attachment) or a file on disk (save-attachment).\nNote: this opens or focuses the Notes UI. Get the ids from list-attachments first.",
|
|
1144
|
+
inputSchema: {
|
|
1145
|
+
noteId: z
|
|
1146
|
+
.string()
|
|
1147
|
+
.min(1, "noteId is required")
|
|
1148
|
+
.describe("CoreData note id (from search/list)"),
|
|
1149
|
+
attachmentId: z
|
|
1150
|
+
.string()
|
|
1151
|
+
.min(1, "attachmentId is required")
|
|
1152
|
+
.describe("Attachment id (from list-attachments)"),
|
|
1153
|
+
separately: z
|
|
1154
|
+
.boolean()
|
|
1155
|
+
.optional()
|
|
1156
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
1157
|
+
},
|
|
1158
|
+
outputSchema: {
|
|
1159
|
+
noteId: z.string().optional(),
|
|
1160
|
+
attachmentId: z.string().optional(),
|
|
1161
|
+
separately: z.boolean().optional(),
|
|
1162
|
+
},
|
|
1163
|
+
}, withErrorHandling(({ noteId, attachmentId, separately = false }) => {
|
|
1164
|
+
const success = notesManager.showAttachmentById(noteId, attachmentId, separately);
|
|
1165
|
+
if (!success) {
|
|
1166
|
+
return errorResponse(`Failed to show attachment "${attachmentId}" on note "${noteId}"`);
|
|
1167
|
+
}
|
|
1168
|
+
return successResponse(`Shown attachment "${attachmentId}" in Notes.app`, {
|
|
1169
|
+
noteId,
|
|
1170
|
+
attachmentId,
|
|
1171
|
+
separately,
|
|
1172
|
+
});
|
|
1173
|
+
}, "Error showing attachment"));
|
|
1051
1174
|
// --- export-notes-json ---
|
|
1052
1175
|
server.registerTool("export-notes-json", {
|
|
1053
1176
|
description: "Use when: exporting the entire notes library as structured JSON for backup or bulk processing.\nReturns: a summary plus the full JSON of all notes, folders, and accounts.\nDo not use when: you need a single note (get-note-content) — this reads everything and can be large.\nRead-only.",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const NOTES_NORMALIZED_HTML_FIXTURES = [
|
|
2
|
+
{
|
|
3
|
+
name: "headingAndParagraphs",
|
|
4
|
+
description: "An <h1> title and two <div> paragraphs separated by a spacer row",
|
|
5
|
+
html: "<div><h1>Meeting Notes</h1></div><div>First line.</div><div><br></div><div>Second line.</div>",
|
|
6
|
+
expectedMarkdown: "# Meeting Notes\n\nFirst line.\n \n\nSecond line.",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "bulletList",
|
|
10
|
+
description: "An <h2> section heading followed by a native <ul> bullet list",
|
|
11
|
+
html: "<div><h2>Tasks</h2></div><ul><li>Buy milk</li><li>Walk dog</li></ul>",
|
|
12
|
+
expectedMarkdown: "## Tasks\n\n- Buy milk\n- Walk dog",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "inlineEmphasis",
|
|
16
|
+
description: "Inline <b> and <i> emphasis inside a paragraph div",
|
|
17
|
+
html: "<div>This is <b>bold</b> and <i>italic</i> text.</div>",
|
|
18
|
+
expectedMarkdown: "This is **bold** and _italic_ text.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "codeSpan",
|
|
22
|
+
description: "A <tt> code span — the tag is dropped, only its text survives",
|
|
23
|
+
html: "<div>Run <tt>npm install</tt> first.</div>",
|
|
24
|
+
expectedMarkdown: "Run npm install first.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "spacerRuns",
|
|
28
|
+
description: "Consecutive <div><br></div> spacer rows between two paragraphs",
|
|
29
|
+
html: "<div>A</div><div><br></div><div><br></div><div>B</div>",
|
|
30
|
+
expectedMarkdown: "A\n \n\n \n\nB",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
@@ -781,6 +781,53 @@ export class AppleNotesManager {
|
|
|
781
781
|
}
|
|
782
782
|
return result.output;
|
|
783
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Retrieves the plain-text content of a note by its exact title.
|
|
786
|
+
*
|
|
787
|
+
* Reads the note's `plaintext` property, which Notes derives from the body
|
|
788
|
+
* with all HTML markup removed. This is the text Notes itself exposes, so it
|
|
789
|
+
* is more faithful than converting the HTML body and skips the markup
|
|
790
|
+
* round-trip entirely.
|
|
791
|
+
*
|
|
792
|
+
* @param title - Exact title of the note
|
|
793
|
+
* @param account - Account to search in (defaults to iCloud)
|
|
794
|
+
* @returns Plain-text content of the note, or empty string if not found
|
|
795
|
+
*/
|
|
796
|
+
getNotePlaintext(title, account) {
|
|
797
|
+
const targetAccount = this.resolveAccount(account);
|
|
798
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
799
|
+
const getCommand = `get plaintext of note "${safeTitle}"`;
|
|
800
|
+
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
801
|
+
const result = executeAppleScript(script);
|
|
802
|
+
if (!result.success) {
|
|
803
|
+
console.error(`Failed to get plaintext of note "${title}":`, result.error);
|
|
804
|
+
return "";
|
|
805
|
+
}
|
|
806
|
+
return result.output;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Retrieves the plain-text content of a note by its CoreData ID.
|
|
810
|
+
*
|
|
811
|
+
* Reads the read-only `plaintext` property (the body with HTML removed). More
|
|
812
|
+
* reliable than getNotePlaintext() because IDs are unique across accounts.
|
|
813
|
+
*
|
|
814
|
+
* Note: Password-protected notes will fail with an AppleScript error. Callers
|
|
815
|
+
* should check for password protection beforehand using getNoteById().
|
|
816
|
+
*
|
|
817
|
+
* @param id - CoreData URL identifier for the note
|
|
818
|
+
* @returns Plain-text content of the note, or empty string if not found
|
|
819
|
+
*/
|
|
820
|
+
getNotePlaintextById(id) {
|
|
821
|
+
const safeId = sanitizeId(id);
|
|
822
|
+
const getCommand = `get plaintext of note id "${safeId}"`;
|
|
823
|
+
const script = buildAppLevelScript(getCommand);
|
|
824
|
+
const result = executeAppleScript(script);
|
|
825
|
+
if (!result.success) {
|
|
826
|
+
console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
|
|
827
|
+
return "";
|
|
828
|
+
}
|
|
829
|
+
return result.output;
|
|
830
|
+
}
|
|
784
831
|
/**
|
|
785
832
|
* Retrieves a note by its unique CoreData ID.
|
|
786
833
|
*
|
|
@@ -1604,6 +1651,91 @@ export class AppleNotesManager {
|
|
|
1604
1651
|
}
|
|
1605
1652
|
return true;
|
|
1606
1653
|
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Reveals a folder in the Notes.app UI by its id.
|
|
1656
|
+
*
|
|
1657
|
+
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1658
|
+
* folders as well as notes. This opens or focuses the Notes UI on the folder.
|
|
1659
|
+
*
|
|
1660
|
+
* @param id - CoreData identifier for the folder (from list-folders)
|
|
1661
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1662
|
+
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1663
|
+
*/
|
|
1664
|
+
showFolderById(id, separately = false) {
|
|
1665
|
+
const safeId = sanitizeId(id);
|
|
1666
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1667
|
+
const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
|
|
1668
|
+
if (!result.success) {
|
|
1669
|
+
console.error(`Failed to show folder with ID "${id}":`, result.error);
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Reveals an account in the Notes.app UI by its id.
|
|
1676
|
+
*
|
|
1677
|
+
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1678
|
+
* accounts as well as notes. This opens or focuses the Notes UI on the account.
|
|
1679
|
+
*
|
|
1680
|
+
* @param id - CoreData identifier for the account (from list-accounts)
|
|
1681
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1682
|
+
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1683
|
+
*/
|
|
1684
|
+
showAccountById(id, separately = false) {
|
|
1685
|
+
const safeId = sanitizeId(id);
|
|
1686
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1687
|
+
const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
|
|
1688
|
+
if (!result.success) {
|
|
1689
|
+
console.error(`Failed to show account with ID "${id}":`, result.error);
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
return true;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Reveals an attachment in the Notes.app UI.
|
|
1696
|
+
*
|
|
1697
|
+
* Attachments are elements of a note, so they cannot be referenced at the
|
|
1698
|
+
* application level by id alone. This resolves the attachment within its note
|
|
1699
|
+
* (the same lookup used by save-attachment) and then runs the Notes `show`
|
|
1700
|
+
* command on it, opening or focusing the Notes UI on the attachment.
|
|
1701
|
+
*
|
|
1702
|
+
* @param noteId - CoreData identifier for the note containing the attachment
|
|
1703
|
+
* @param attachmentId - id of the attachment (from list-attachments)
|
|
1704
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1705
|
+
* @returns true if Notes.app revealed the attachment, false otherwise
|
|
1706
|
+
*/
|
|
1707
|
+
showAttachmentById(noteId, attachmentId, separately = false) {
|
|
1708
|
+
const safeNoteId = sanitizeId(noteId);
|
|
1709
|
+
const safeAttId = escapePlainStringForAppleScript(attachmentId);
|
|
1710
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1711
|
+
const script = `
|
|
1712
|
+
tell application "Notes"
|
|
1713
|
+
set theNote to note id "${safeNoteId}"
|
|
1714
|
+
set theAttachment to missing value
|
|
1715
|
+
repeat with a in attachments of theNote
|
|
1716
|
+
if (id of a as text) is "${safeAttId}" then
|
|
1717
|
+
set theAttachment to a
|
|
1718
|
+
exit repeat
|
|
1719
|
+
end if
|
|
1720
|
+
end repeat
|
|
1721
|
+
if theAttachment is missing value then
|
|
1722
|
+
return "ERR${AS_FIELD_SEP}attachment not found"
|
|
1723
|
+
end if
|
|
1724
|
+
show theAttachment${separatelyClause}
|
|
1725
|
+
return "OK"
|
|
1726
|
+
end tell
|
|
1727
|
+
`;
|
|
1728
|
+
const result = executeAppleScript(script);
|
|
1729
|
+
if (!result.success) {
|
|
1730
|
+
console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if ((result.output ?? "").trim().startsWith("ERR")) {
|
|
1734
|
+
console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1607
1739
|
// ===========================================================================
|
|
1608
1740
|
// Health Check
|
|
1609
1741
|
// ===========================================================================
|
|
@@ -664,6 +664,45 @@ describe("AppleNotesManager", () => {
|
|
|
664
664
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
|
|
665
665
|
});
|
|
666
666
|
});
|
|
667
|
+
describe("getNotePlaintext", () => {
|
|
668
|
+
it("reads the note's plaintext property by title", () => {
|
|
669
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
670
|
+
success: true,
|
|
671
|
+
output: "Shopping List\n- Eggs\n- Milk",
|
|
672
|
+
});
|
|
673
|
+
const text = manager.getNotePlaintext("Shopping List");
|
|
674
|
+
expect(text).toBe("Shopping List\n- Eggs\n- Milk");
|
|
675
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note "Shopping List"'));
|
|
676
|
+
});
|
|
677
|
+
it("returns empty string when the note is not found", () => {
|
|
678
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
679
|
+
success: false,
|
|
680
|
+
output: "",
|
|
681
|
+
error: 'Can\'t get note "Missing"',
|
|
682
|
+
});
|
|
683
|
+
expect(manager.getNotePlaintext("Missing Note")).toBe("");
|
|
684
|
+
});
|
|
685
|
+
it("uses the specified account", () => {
|
|
686
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "Content" });
|
|
687
|
+
manager.getNotePlaintext("My Note", "Gmail");
|
|
688
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe("getNotePlaintextById", () => {
|
|
692
|
+
it("reads the note's plaintext property by id at the application level", () => {
|
|
693
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "Just the text" });
|
|
694
|
+
const text = manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1");
|
|
695
|
+
expect(text).toBe("Just the text");
|
|
696
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note id "x-coredata://ABC/ICNote/p1"'));
|
|
697
|
+
});
|
|
698
|
+
it("returns empty string when Notes.app rejects the read", () => {
|
|
699
|
+
mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
|
|
700
|
+
expect(manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1")).toBe("");
|
|
701
|
+
});
|
|
702
|
+
it("rejects malformed IDs", () => {
|
|
703
|
+
expect(() => manager.getNotePlaintextById("arbitrary string")).toThrow();
|
|
704
|
+
});
|
|
705
|
+
});
|
|
667
706
|
// ---------------------------------------------------------------------------
|
|
668
707
|
// Password Protection Helpers
|
|
669
708
|
// ---------------------------------------------------------------------------
|
|
@@ -1615,6 +1654,76 @@ describe("AppleNotesManager", () => {
|
|
|
1615
1654
|
expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
|
|
1616
1655
|
});
|
|
1617
1656
|
});
|
|
1657
|
+
describe("showFolderById", () => {
|
|
1658
|
+
it("shows a folder by id", () => {
|
|
1659
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1660
|
+
expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(true);
|
|
1661
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show folder id "x-coredata://ABC/ICFolder/p1"'));
|
|
1662
|
+
});
|
|
1663
|
+
it("can request a separate window", () => {
|
|
1664
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1665
|
+
manager.showFolderById("x-coredata://ABC/ICFolder/p1", true);
|
|
1666
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1667
|
+
});
|
|
1668
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1669
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1670
|
+
success: false,
|
|
1671
|
+
output: "",
|
|
1672
|
+
error: "no such folder",
|
|
1673
|
+
});
|
|
1674
|
+
expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(false);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
describe("showAccountById", () => {
|
|
1678
|
+
it("shows an account by id", () => {
|
|
1679
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1680
|
+
expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(true);
|
|
1681
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show account id "x-coredata://ABC/ICAccount/p1"'));
|
|
1682
|
+
});
|
|
1683
|
+
it("can request a separate window", () => {
|
|
1684
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1685
|
+
manager.showAccountById("x-coredata://ABC/ICAccount/p1", true);
|
|
1686
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1687
|
+
});
|
|
1688
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1689
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1690
|
+
success: false,
|
|
1691
|
+
output: "",
|
|
1692
|
+
error: "no such account",
|
|
1693
|
+
});
|
|
1694
|
+
expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(false);
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
describe("showAttachmentById", () => {
|
|
1698
|
+
it("resolves the attachment within its note and shows it", () => {
|
|
1699
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
|
|
1700
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(true);
|
|
1701
|
+
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
1702
|
+
expect(script).toContain('set theNote to note id "x-coredata://ABC/ICNote/p1"');
|
|
1703
|
+
expect(script).toContain('is "att-123"');
|
|
1704
|
+
expect(script).toContain("show theAttachment");
|
|
1705
|
+
});
|
|
1706
|
+
it("can request a separate window", () => {
|
|
1707
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
|
|
1708
|
+
manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123", true);
|
|
1709
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1710
|
+
});
|
|
1711
|
+
it("returns false when the attachment is not found on the note", () => {
|
|
1712
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1713
|
+
success: true,
|
|
1714
|
+
output: `ERR${F}attachment not found`,
|
|
1715
|
+
});
|
|
1716
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "missing")).toBe(false);
|
|
1717
|
+
});
|
|
1718
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1719
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1720
|
+
success: false,
|
|
1721
|
+
output: "",
|
|
1722
|
+
error: "no such note",
|
|
1723
|
+
});
|
|
1724
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(false);
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1618
1727
|
// ---------------------------------------------------------------------------
|
|
1619
1728
|
// Health Check
|
|
1620
1729
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { NOTES_NORMALIZED_HTML_FIXTURES } from "../services/__fixtures__/notesNormalizedHtml.js";
|
|
3
|
+
// Mock the same seams the main manager test does: AppleScript execution and the
|
|
4
|
+
// SQLite-backed checklist reader (no Full Disk Access in unit tests).
|
|
5
|
+
vi.mock("@/utils/applescript.js", () => ({
|
|
6
|
+
executeAppleScript: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("@/utils/checklistParser.js", () => ({
|
|
9
|
+
getChecklistItems: vi.fn().mockReturnValue({ items: null }),
|
|
10
|
+
}));
|
|
11
|
+
import { executeAppleScript } from "../utils/applescript.js";
|
|
12
|
+
import { AppleNotesManager } from "../services/appleNotesManager.js";
|
|
13
|
+
const mockExecuteAppleScript = vi.mocked(executeAppleScript);
|
|
14
|
+
const NOTE_ID = "x-coredata://ABC/ICNote/p1";
|
|
15
|
+
/**
|
|
16
|
+
* Regression coverage for Notes-normalized HTML -> Markdown conversion.
|
|
17
|
+
*
|
|
18
|
+
* The fixtures encode the HTML shape Apple Notes returns and the Markdown the
|
|
19
|
+
* server currently emits. Routing through getNoteMarkdownById exercises the real
|
|
20
|
+
* Turndown pipeline (including the notesDivs rule) with AppleScript mocked to
|
|
21
|
+
* return the fixture body.
|
|
22
|
+
*/
|
|
23
|
+
describe("Notes-normalized HTML to Markdown", () => {
|
|
24
|
+
let manager;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
manager = new AppleNotesManager();
|
|
28
|
+
});
|
|
29
|
+
for (const fixture of NOTES_NORMALIZED_HTML_FIXTURES) {
|
|
30
|
+
it(`converts ${fixture.name} (${fixture.description})`, () => {
|
|
31
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: fixture.html });
|
|
32
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
33
|
+
expect(markdown).toBe(fixture.expectedMarkdown);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
it("documents that a <div><br></div> spacer leaves a two-space line", () => {
|
|
37
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
38
|
+
success: true,
|
|
39
|
+
output: "<div>A</div><div><br></div><div>B</div>",
|
|
40
|
+
});
|
|
41
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
42
|
+
// The spacer survives as a stray " " line — the Markdown-side fingerprint of
|
|
43
|
+
// the whitespace-accumulation behavior CLAUDE.md warns about.
|
|
44
|
+
expect(markdown).toBe("A\n \n\nB");
|
|
45
|
+
});
|
|
46
|
+
it("documents that <tt> is dropped, keeping only its text", () => {
|
|
47
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
48
|
+
success: true,
|
|
49
|
+
output: "<div>Run <tt>npm install</tt> first.</div>",
|
|
50
|
+
});
|
|
51
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
52
|
+
expect(markdown).toBe("Run npm install first.");
|
|
53
|
+
expect(markdown).not.toContain("`");
|
|
54
|
+
});
|
|
55
|
+
});
|
package/package.json
CHANGED