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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",