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.
- package/README.md +64 -9
- package/build/index.js +40 -28
- package/build/services/appleNotesManager.js +260 -64
- package/build/services/appleNotesManager.test.js +187 -139
- package/build/utils/hashtags.js +56 -0
- package/build/utils/hashtags.test.js +45 -0
- package/package.json +8 -5
|
@@ -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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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({
|
|
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
|
-
|
|
1582
|
-
const
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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({
|
|
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({
|
|
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. `’`).
|
|
15
|
+
*/
|
|
16
|
+
function htmlToText(html) {
|
|
17
|
+
return html
|
|
18
|
+
.replace(/<[^>]*>/g, " ") // drop tags
|
|
19
|
+
.replace(/&#x?[0-9a-f]+;/gi, " ") // neutralise numeric entities (’)
|
|
20
|
+
.replace(/&[a-z]+;/gi, " "); // neutralise named entities (& )
|
|
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
|
+
// ’ is a right single quote entity, not a #8217 tag
|
|
34
|
+
expect(parseHashtags("It’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.
|
|
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 -
|
|
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
|
|
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": "
|
|
84
|
+
"node": "24.17.0"
|
|
82
85
|
},
|
|
83
86
|
"lint-staged": {
|
|
84
87
|
"src/**/*.ts": [
|