apple-notes-mcp 2.2.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.
@@ -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.2.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",