apple-notes-mcp 2.0.1 → 2.1.1

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.
@@ -526,6 +526,19 @@ describe("AppleNotesManager", () => {
526
526
  expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
527
527
  expect(results[1].folder).toBe("Notes");
528
528
  });
529
+ it("deduplicates duplicate note IDs returned by Notes.app", () => {
530
+ mockExecuteAppleScript.mockReturnValue({
531
+ success: true,
532
+ output: [
533
+ ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
534
+ ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
535
+ ].join(R),
536
+ });
537
+ const results = manager.searchNotes("Not uploaded");
538
+ expect(results).toHaveLength(1);
539
+ expect(results[0].title).toBe("Not uploaded");
540
+ expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
541
+ });
529
542
  it("scopes search to specified account", () => {
530
543
  mockExecuteAppleScript.mockReturnValue({
531
544
  success: true,
@@ -1002,7 +1015,11 @@ describe("AppleNotesManager", () => {
1002
1015
  it("returns array of note titles", () => {
1003
1016
  mockExecuteAppleScript.mockReturnValue({
1004
1017
  success: true,
1005
- output: ["Note A", "Note B", "Note C"].join(R),
1018
+ output: [
1019
+ ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1020
+ ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1021
+ ["Note C", "x-coredata://ABC/ICNote/p3"].join(F),
1022
+ ].join(R),
1006
1023
  });
1007
1024
  const titles = manager.listNotes();
1008
1025
  expect(titles).toEqual(["Note A", "Note B", "Note C"]);
@@ -1010,11 +1027,29 @@ describe("AppleNotesManager", () => {
1010
1027
  it("filters out empty entries", () => {
1011
1028
  mockExecuteAppleScript.mockReturnValue({
1012
1029
  success: true,
1013
- output: ["Note A", "", "Note B", "", ""].join(R),
1030
+ output: [
1031
+ ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1032
+ "",
1033
+ ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1034
+ "",
1035
+ "",
1036
+ ].join(R),
1014
1037
  });
1015
1038
  const titles = manager.listNotes();
1016
1039
  expect(titles).toEqual(["Note A", "Note B"]);
1017
1040
  });
1041
+ it("deduplicates duplicate note IDs while preserving separate notes with the same title", () => {
1042
+ mockExecuteAppleScript.mockReturnValue({
1043
+ success: true,
1044
+ output: [
1045
+ ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1046
+ ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1047
+ ["Same Title", "x-coredata://ABC/ICNote/p2"].join(F),
1048
+ ].join(R),
1049
+ });
1050
+ const titles = manager.listNotes();
1051
+ expect(titles).toEqual(["Same Title", "Same Title"]);
1052
+ });
1018
1053
  it("throws on failure rather than returning empty (#19)", () => {
1019
1054
  mockExecuteAppleScript.mockReturnValue({
1020
1055
  success: false,
@@ -1026,7 +1061,10 @@ describe("AppleNotesManager", () => {
1026
1061
  it("filters by folder when specified", () => {
1027
1062
  mockExecuteAppleScript.mockReturnValue({
1028
1063
  success: true,
1029
- output: ["Work Note 1", "Work Note 2"].join(R),
1064
+ output: [
1065
+ ["Work Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1066
+ ["Work Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1067
+ ].join(R),
1030
1068
  });
1031
1069
  manager.listNotes("iCloud", "Work");
1032
1070
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
@@ -1034,7 +1072,10 @@ describe("AppleNotesManager", () => {
1034
1072
  it("uses whose clause when modifiedSince is provided", () => {
1035
1073
  mockExecuteAppleScript.mockReturnValue({
1036
1074
  success: true,
1037
- output: ["Recent Note 1", "Recent Note 2"].join(R),
1075
+ output: [
1076
+ ["Recent Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1077
+ ["Recent Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1078
+ ].join(R),
1038
1079
  });
1039
1080
  const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
1040
1081
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1049,7 +1090,11 @@ describe("AppleNotesManager", () => {
1049
1090
  it("uses repeat loop when limit is provided", () => {
1050
1091
  mockExecuteAppleScript.mockReturnValue({
1051
1092
  success: true,
1052
- output: ["Note 1", "Note 2", "Note 3"].join(R),
1093
+ output: [
1094
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1095
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1096
+ ["Note 3", "x-coredata://ABC/ICNote/p3"].join(F),
1097
+ ].join(R),
1053
1098
  });
1054
1099
  const results = manager.listNotes(undefined, undefined, undefined, 3);
1055
1100
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1059,7 +1104,10 @@ describe("AppleNotesManager", () => {
1059
1104
  it("combines folder, modifiedSince, and limit", () => {
1060
1105
  mockExecuteAppleScript.mockReturnValue({
1061
1106
  success: true,
1062
- output: ["Work Note", "Another Work Note"].join(R),
1107
+ output: [
1108
+ ["Work Note", "x-coredata://ABC/ICNote/p1"].join(F),
1109
+ ["Another Work Note", "x-coredata://ABC/ICNote/p2"].join(F),
1110
+ ].join(R),
1063
1111
  });
1064
1112
  manager.listNotes("iCloud", "Work", "2025-01-01", 10);
1065
1113
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1078,7 +1126,10 @@ describe("AppleNotesManager", () => {
1078
1126
  it("ignores invalid modifiedSince date and falls back to limit-only", () => {
1079
1127
  mockExecuteAppleScript.mockReturnValue({
1080
1128
  success: true,
1081
- output: ["Note 1", "Note 2"].join(R),
1129
+ output: [
1130
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1131
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1132
+ ].join(R),
1082
1133
  });
1083
1134
  const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
1084
1135
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1399,7 +1450,13 @@ describe("AppleNotesManager", () => {
1399
1450
  // Check 3: listAccounts
1400
1451
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1401
1452
  // Check 4: listNotes
1402
- .mockReturnValueOnce({ success: true, output: ["Note 1", "Note 2"].join(R) });
1453
+ .mockReturnValueOnce({
1454
+ success: true,
1455
+ output: [
1456
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1457
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1458
+ ].join(R),
1459
+ });
1403
1460
  const result = manager.healthCheck();
1404
1461
  expect(result.healthy).toBe(true);
1405
1462
  expect(result.checks).toHaveLength(4);
@@ -1498,6 +1555,56 @@ describe("AppleNotesManager", () => {
1498
1555
  expect(stats.accounts[0].name).toBe("iCloud");
1499
1556
  expect(stats.accounts[1].name).toBe("Gmail");
1500
1557
  });
1558
+ it("reports complete coverage when every scope succeeds (#19)", () => {
1559
+ mockExecuteAppleScript
1560
+ .mockReturnValueOnce({ success: true, output: "iCloud" })
1561
+ .mockReturnValueOnce({ success: true, output: ["Notes", "3"].join(F) + R })
1562
+ .mockReturnValueOnce({ success: true, output: ["1", "2", "3"].join(F) });
1563
+ const stats = manager.getNotesStats();
1564
+ expect(stats.coverage.complete).toBe(true);
1565
+ expect(stats.coverage.warnings).toEqual([]);
1566
+ expect(stats.coverage.covered).toBe(stats.coverage.scanned);
1567
+ });
1568
+ it("degrades gracefully when one account fails, with a coverage warning (#19)", () => {
1569
+ mockExecuteAppleScript
1570
+ // listAccounts
1571
+ .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1572
+ // iCloud folder counts succeed
1573
+ .mockReturnValueOnce({ success: true, output: ["Notes", "4"].join(F) + R })
1574
+ // Gmail folder counts FAIL
1575
+ .mockReturnValueOnce({ success: false, output: "", error: "Gmail account is locked" })
1576
+ // getRecentlyModifiedCounts succeed
1577
+ .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1578
+ const stats = manager.getNotesStats();
1579
+ // Healthy account's data is preserved, not discarded
1580
+ expect(stats.totalNotes).toBe(4);
1581
+ expect(stats.accounts).toHaveLength(1);
1582
+ expect(stats.accounts[0].name).toBe("iCloud");
1583
+ // Failure surfaced as a coverage warning
1584
+ expect(stats.coverage.complete).toBe(false);
1585
+ expect(stats.coverage.warnings).toHaveLength(1);
1586
+ expect(stats.coverage.warnings[0].scope).toBe("Gmail");
1587
+ expect(stats.coverage.warnings[0].reason).toContain("locked");
1588
+ });
1589
+ it("flags recent-activity failure as a coverage warning, not fake zeros (#19)", () => {
1590
+ mockExecuteAppleScript
1591
+ .mockReturnValueOnce({ success: true, output: "iCloud" })
1592
+ .mockReturnValueOnce({ success: true, output: ["Notes", "5"].join(F) + R })
1593
+ // getRecentlyModifiedCounts FAILS
1594
+ .mockReturnValueOnce({ success: false, output: "", error: "timed out" });
1595
+ const stats = manager.getNotesStats();
1596
+ expect(stats.totalNotes).toBe(5);
1597
+ expect(stats.recentlyModified.last24h).toBe(0);
1598
+ expect(stats.coverage.complete).toBe(false);
1599
+ expect(stats.coverage.warnings.some((w) => w.scope === "recent-activity")).toBe(true);
1600
+ });
1601
+ it("throws when no account can be read at all (#19)", () => {
1602
+ mockExecuteAppleScript
1603
+ .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1604
+ .mockReturnValueOnce({ success: false, output: "", error: "iCloud unreachable" })
1605
+ .mockReturnValueOnce({ success: false, output: "", error: "Gmail unreachable" });
1606
+ expect(() => manager.getNotesStats()).toThrow(/Failed to read folder stats for any/);
1607
+ });
1501
1608
  });
1502
1609
  // ---------------------------------------------------------------------------
1503
1610
  // Attachment Listing
@@ -1578,50 +1685,23 @@ describe("AppleNotesManager", () => {
1578
1685
  // Batch Operations
1579
1686
  // ---------------------------------------------------------------------------
1580
1687
  describe("batchDeleteNotes", () => {
1581
- // Helper to create getNoteById mock output (matches AppleScript format)
1582
- const noteByIdOutput = (title, passwordProtected = false) => [
1583
- title,
1584
- "x-coredata://ABC/ICNote/p1",
1585
- "Sunday, January 1, 2025 at 1:00:00 PM",
1586
- "Sunday, January 1, 2025 at 1:00:00 PM",
1587
- "false",
1588
- String(passwordProtected),
1589
- ].join(F);
1590
- it("deletes multiple notes successfully", () => {
1591
- // For each note: getNoteById (which isNotePasswordProtectedById also calls), deleteNoteById
1592
- mockExecuteAppleScript
1593
- // First note: getNoteById for existence check
1594
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1595
- // First note: getNoteById for password check (isNotePasswordProtectedById calls getNoteById)
1596
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1597
- // First note: deleteNoteById
1598
- .mockReturnValueOnce({ success: true, output: "" })
1599
- // Second note: getNoteById for existence check
1600
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1601
- // Second note: getNoteById for password check
1602
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1603
- // Second note: deleteNoteById
1604
- .mockReturnValueOnce({ success: true, output: "" });
1605
- const results = manager.batchDeleteNotes([
1606
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1607
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1608
- ]);
1609
- expect(results).toHaveLength(2);
1610
- expect(results[0]).toEqual({
1611
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1612
- success: true,
1613
- });
1614
- expect(results[1]).toEqual({
1615
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1688
+ const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
1689
+ const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
1690
+ it("deletes the whole batch in a single osascript spawn (#26)", () => {
1691
+ // One script handles all ids; per-id status tokens joined by RECORD_SEP.
1692
+ mockExecuteAppleScript.mockReturnValueOnce({
1616
1693
  success: true,
1694
+ output: ["ok", "ok"].join(R) + R,
1617
1695
  });
1696
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1697
+ // Exactly one spawn for N notes, not 3N.
1698
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1699
+ expect(results).toHaveLength(2);
1700
+ expect(results[0]).toEqual({ id: ID1, success: true });
1701
+ expect(results[1]).toEqual({ id: ID2, success: true });
1618
1702
  });
1619
1703
  it("returns error for non-existent note", () => {
1620
- mockExecuteAppleScript.mockReturnValueOnce({
1621
- success: false,
1622
- output: "",
1623
- error: "Not found",
1624
- });
1704
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1625
1705
  const results = manager.batchDeleteNotes([
1626
1706
  "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1627
1707
  ]);
@@ -1632,97 +1712,58 @@ describe("AppleNotesManager", () => {
1632
1712
  });
1633
1713
  });
1634
1714
  it("returns error for password-protected note", () => {
1635
- mockExecuteAppleScript
1636
- // getNoteById for existence check
1637
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1638
- // getNoteById for password check (returns true for passwordProtected)
1639
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1640
- const results = manager.batchDeleteNotes([
1641
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1642
- ]);
1643
- expect(results[0]).toEqual({
1644
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1645
- success: false,
1646
- error: "Note is password-protected",
1647
- });
1715
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
1716
+ const results = manager.batchDeleteNotes([ID1]);
1717
+ expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
1648
1718
  });
1649
- it("handles mixed success and failure", () => {
1650
- mockExecuteAppleScript
1651
- // First note: success
1652
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1653
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1654
- .mockReturnValueOnce({ success: true, output: "" })
1655
- // Second note: not found
1656
- .mockReturnValueOnce({ success: false, output: "", error: "Not found" });
1657
- const results = manager.batchDeleteNotes([
1658
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1659
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1660
- ]);
1661
- expect(results[0]).toEqual({
1662
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1719
+ it("handles mixed success and failure, preserving order", () => {
1720
+ mockExecuteAppleScript.mockReturnValueOnce({
1663
1721
  success: true,
1722
+ output: ["ok", "missing"].join(R) + R,
1664
1723
  });
1665
- expect(results[1]).toEqual({
1666
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1724
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1725
+ expect(results[0]).toEqual({ id: ID1, success: true });
1726
+ expect(results[1]).toEqual({ id: ID2, success: false, error: "Note not found" });
1727
+ });
1728
+ it("fails an invalid id without spawning, isolating it from valid ids", () => {
1729
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "ok" + R });
1730
+ const results = manager.batchDeleteNotes(["not-a-valid-id", ID1]);
1731
+ expect(results[0].success).toBe(false);
1732
+ expect(results[0].error).toMatch(/Invalid note ID/);
1733
+ expect(results[1]).toEqual({ id: ID1, success: true });
1734
+ });
1735
+ it("fails the whole batch when the single script errors", () => {
1736
+ mockExecuteAppleScript.mockReturnValueOnce({
1667
1737
  success: false,
1668
- error: "Note not found",
1738
+ output: "",
1739
+ error: "Notes.app not responding",
1669
1740
  });
1741
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1742
+ expect(results.every((r) => r.success === false)).toBe(true);
1743
+ expect(results[0].error).toContain("Notes.app not responding");
1744
+ });
1745
+ it("returns [] for an empty batch without spawning", () => {
1746
+ const results = manager.batchDeleteNotes([]);
1747
+ expect(results).toEqual([]);
1748
+ expect(mockExecuteAppleScript).not.toHaveBeenCalled();
1670
1749
  });
1671
1750
  });
1672
1751
  describe("batchMoveNotes", () => {
1673
- // Helper to create getNoteById mock output (matches AppleScript format)
1674
- const noteByIdOutput = (title, passwordProtected = false) => [
1675
- title,
1676
- "x-coredata://ABC/ICNote/p1",
1677
- "Sunday, January 1, 2025 at 1:00:00 PM",
1678
- "Sunday, January 1, 2025 at 1:00:00 PM",
1679
- "false",
1680
- String(passwordProtected),
1681
- ].join(F);
1682
- it("moves multiple notes successfully", () => {
1683
- // For each note: getNoteById, getNoteById (password check), getNoteContentById, create, delete
1684
- mockExecuteAppleScript
1685
- // First note: getNoteById for existence check
1686
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1687
- // First note: getNoteById for password check
1688
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1689
- // First note: moveNoteById calls getNoteContentById
1690
- .mockReturnValueOnce({ success: true, output: "<div>Note 1</div><div>Content</div>" })
1691
- // First note: createNote in destination
1692
- .mockReturnValueOnce({ success: true, output: "" })
1693
- // First note: deleteNoteById (original)
1694
- .mockReturnValueOnce({ success: true, output: "" })
1695
- // Second note: getNoteById for existence check
1696
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1697
- // Second note: getNoteById for password check
1698
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1699
- // Second note: moveNoteById calls getNoteContentById
1700
- .mockReturnValueOnce({ success: true, output: "<div>Note 2</div><div>Content</div>" })
1701
- // Second note: createNote in destination
1702
- .mockReturnValueOnce({ success: true, output: "" })
1703
- // Second note: deleteNoteById (original)
1704
- .mockReturnValueOnce({ success: true, output: "" });
1705
- const results = manager.batchMoveNotes([
1706
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1707
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1708
- ], "Archive");
1709
- expect(results).toHaveLength(2);
1710
- expect(results[0]).toEqual({
1711
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1712
- success: true,
1713
- });
1714
- expect(results[1]).toEqual({
1715
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1752
+ const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
1753
+ const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
1754
+ it("moves the whole batch in a single osascript spawn (#26)", () => {
1755
+ mockExecuteAppleScript.mockReturnValueOnce({
1716
1756
  success: true,
1757
+ output: ["ok", "ok"].join(R) + R,
1717
1758
  });
1759
+ const results = manager.batchMoveNotes([ID1, ID2], "Archive");
1760
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1761
+ expect(results).toHaveLength(2);
1762
+ expect(results[0]).toEqual({ id: ID1, success: true });
1763
+ expect(results[1]).toEqual({ id: ID2, success: true });
1718
1764
  });
1719
1765
  it("returns error for non-existent note", () => {
1720
- // getNoteById fails for existence check
1721
- mockExecuteAppleScript.mockReturnValueOnce({
1722
- success: false,
1723
- output: "",
1724
- error: "Not found",
1725
- });
1766
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1726
1767
  const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
1727
1768
  expect(results[0]).toEqual({
1728
1769
  id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
@@ -1731,17 +1772,18 @@ describe("AppleNotesManager", () => {
1731
1772
  });
1732
1773
  });
1733
1774
  it("returns error for password-protected note", () => {
1734
- mockExecuteAppleScript
1735
- // getNoteById for existence check
1736
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1737
- // getNoteById for password check (returns true for passwordProtected)
1738
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1739
- const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
1740
- expect(results[0]).toEqual({
1741
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1742
- success: false,
1743
- error: "Note is password-protected",
1775
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
1776
+ const results = manager.batchMoveNotes([ID1], "Archive");
1777
+ expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
1778
+ });
1779
+ it("maps a per-item move failure to 'Move failed'", () => {
1780
+ mockExecuteAppleScript.mockReturnValueOnce({
1781
+ success: true,
1782
+ output: ["ok", "fail"].join(R) + R,
1744
1783
  });
1784
+ const results = manager.batchMoveNotes([ID1, ID2], "Archive");
1785
+ expect(results[0]).toEqual({ id: ID1, success: true });
1786
+ expect(results[1]).toEqual({ id: ID2, success: false, error: "Move failed" });
1745
1787
  });
1746
1788
  });
1747
1789
  // ---------------------------------------------------------------------------
@@ -1764,7 +1806,10 @@ describe("AppleNotesManager", () => {
1764
1806
  // listFolders for iCloud
1765
1807
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1766
1808
  // listNotes for Notes folder
1767
- .mockReturnValueOnce({ success: true, output: "Test Note" })
1809
+ .mockReturnValueOnce({
1810
+ success: true,
1811
+ output: ["Test Note", "x-coredata://ABC/ICNote/p1"].join(F),
1812
+ })
1768
1813
  // getNoteDetails
1769
1814
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Test Note", false) })
1770
1815
  // getNoteContent
@@ -1789,7 +1834,10 @@ describe("AppleNotesManager", () => {
1789
1834
  // listFolders for iCloud
1790
1835
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1791
1836
  // listNotes for Notes folder
1792
- .mockReturnValueOnce({ success: true, output: "Locked Note" })
1837
+ .mockReturnValueOnce({
1838
+ success: true,
1839
+ output: ["Locked Note", "x-coredata://ABC/ICNote/p1"].join(F),
1840
+ })
1793
1841
  // getNoteDetails (passwordProtected = true)
1794
1842
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Locked Note", true) });
1795
1843
  // No getNoteContent call because note is password-protected
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Inline hashtag extraction for Apple Notes.
3
+ *
4
+ * Apple Notes "tags" are not a first-class AppleScript property — they are
5
+ * inline `#hashtag` tokens typed into the note body. Notes stores the tag
6
+ * relationship in its private Core Data store, which AppleScript does not
7
+ * expose, so the only way to surface a note's tags is to parse them back out
8
+ * of the body text. This module does exactly that.
9
+ *
10
+ * See docs/APPLESCRIPT-LIMITATIONS.md and issue #29.
11
+ */
12
+ /**
13
+ * Strip HTML tags from a Notes body and neutralise numeric character
14
+ * references so they can't masquerade as hashtags (e.g. `&#8217;`).
15
+ */
16
+ function htmlToText(html) {
17
+ return html
18
+ .replace(/<[^>]*>/g, " ") // drop tags
19
+ .replace(/&#x?[0-9a-f]+;/gi, " ") // neutralise numeric entities (&#8217;)
20
+ .replace(/&[a-z]+;/gi, " "); // neutralise named entities (&amp; &nbsp;)
21
+ }
22
+ /**
23
+ * A hashtag token: `#` followed by a run of letters/digits/underscores that
24
+ * contains at least one letter. This matches Apple Notes' own rule — a purely
25
+ * numeric token like `#123` is NOT treated as a tag. The token must not be
26
+ * preceded by a word character, so `foo#bar` and URL fragments like
27
+ * `page.html#section` (preceded by a letter) are ignored.
28
+ */
29
+ const HASHTAG_RE = /(?<![\p{L}\p{N}_])#([\p{L}\p{N}_]*\p{L}[\p{L}\p{N}_]*)/gu;
30
+ /**
31
+ * Extract inline `#hashtag` tokens from a note body (HTML or plain text).
32
+ *
33
+ * - HTML is stripped first, so tags inside `<div>#work</div>` are found.
34
+ * - Pure-number tokens (`#123`) are ignored, matching Notes' behaviour.
35
+ * - Results are de-duplicated case-insensitively, preserving the first-seen
36
+ * casing and document order. The leading `#` is not included.
37
+ *
38
+ * @param body - The note body, as HTML or plain text.
39
+ * @returns Ordered, de-duplicated tag names without the leading `#`.
40
+ */
41
+ export function parseHashtags(body) {
42
+ if (!body)
43
+ return [];
44
+ const text = htmlToText(body);
45
+ const seen = new Set();
46
+ const result = [];
47
+ for (const match of text.matchAll(HASHTAG_RE)) {
48
+ const tag = match[1];
49
+ const key = tag.toLowerCase();
50
+ if (!seen.has(key)) {
51
+ seen.add(key);
52
+ result.push(tag);
53
+ }
54
+ }
55
+ return result;
56
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseHashtags } from "../utils/hashtags.js";
3
+ describe("parseHashtags", () => {
4
+ it("returns [] for empty / nullish input", () => {
5
+ expect(parseHashtags("")).toEqual([]);
6
+ expect(parseHashtags(null)).toEqual([]);
7
+ expect(parseHashtags(undefined)).toEqual([]);
8
+ expect(parseHashtags("no tags here")).toEqual([]);
9
+ });
10
+ it("extracts a single tag", () => {
11
+ expect(parseHashtags("Buy milk #groceries")).toEqual(["groceries"]);
12
+ });
13
+ it("extracts multiple tags in document order", () => {
14
+ expect(parseHashtags("#work then #home then #travel")).toEqual(["work", "home", "travel"]);
15
+ });
16
+ it("strips the leading # and not the rest", () => {
17
+ expect(parseHashtags("#project_alpha")).toEqual(["project_alpha"]);
18
+ });
19
+ it("finds tags inside HTML bodies", () => {
20
+ expect(parseHashtags("<div>Plan <b>#q3</b> launch</div>")).toEqual(["q3"]);
21
+ });
22
+ it("de-duplicates case-insensitively, keeping first-seen casing", () => {
23
+ expect(parseHashtags("#Work and #work and #WORK")).toEqual(["Work"]);
24
+ });
25
+ it("ignores purely numeric tokens (matches Notes behaviour)", () => {
26
+ expect(parseHashtags("ticket #123 and #4you")).toEqual(["4you"]);
27
+ });
28
+ it("does not match mid-word or URL fragments", () => {
29
+ expect(parseHashtags("foo#bar")).toEqual([]);
30
+ expect(parseHashtags("see page.html#section for details")).toEqual([]);
31
+ });
32
+ it("does not treat numeric HTML entities as tags", () => {
33
+ // &#8217; is a right single quote entity, not a #8217 tag
34
+ expect(parseHashtags("It&#8217;s a #plan")).toEqual(["plan"]);
35
+ });
36
+ it("matches a tag at the very start of the body", () => {
37
+ expect(parseHashtags("#start of note")).toEqual(["start"]);
38
+ });
39
+ it("handles tags terminated by punctuation", () => {
40
+ expect(parseHashtags("done: #alpha, #beta; #gamma.")).toEqual(["alpha", "beta", "gamma"]);
41
+ });
42
+ it("supports unicode letters in tags", () => {
43
+ expect(parseHashtags("café trip #café")).toEqual(["café"]);
44
+ });
45
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.0.1",
4
- "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
3
+ "version": "2.1.1",
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",
7
7
  "types": "build/index.d.ts",
@@ -20,19 +20,22 @@
20
20
  "test": "vitest run",
21
21
  "test:watch": "vitest",
22
22
  "test:coverage": "vitest run --coverage",
23
+ "test:integration": "vitest run --config vitest.integration.config.ts",
24
+ "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
23
25
  "lint": "eslint src",
24
26
  "lint:fix": "eslint src --fix",
25
27
  "format": "prettier --write src",
26
28
  "format:check": "prettier --check src",
27
29
  "typecheck": "tsc --noEmit",
28
- "version": "node -e \"const p=require('./package.json'); const f='.claude-plugin/plugin.json'; const c=JSON.parse(require('fs').readFileSync(f,'utf8')); c.version=p.version; require('fs').writeFileSync(f,JSON.stringify(c,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
30
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin .agents/plugins codex .hermes-plugin .antigravity-plugin",
29
31
  "prepublishOnly": "npm run lint && npm run test && npm run build",
30
- "prepare": "husky && npm run build"
32
+ "prepare": "husky; npm run build"
31
33
  },
32
34
  "keywords": [
33
35
  "mcp",
34
36
  "apple-notes",
35
37
  "claude",
38
+ "codex",
36
39
  "ai",
37
40
  "applescript",
38
41
  "macos",
@@ -78,7 +81,7 @@
78
81
  "vitest": "^2.0.0"
79
82
  },
80
83
  "volta": {
81
- "node": "22.13.1"
84
+ "node": "24.17.0"
82
85
  },
83
86
  "lint-staged": {
84
87
  "src/**/*.ts": [