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.
- package/README.md +58 -0
- package/build/index.js +507 -139
- 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
|
@@ -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