apple-notes-mcp 2.0.1 → 2.1.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 +38 -7
- package/build/index.js +14 -2
- 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 +7 -4
package/README.md
CHANGED
|
@@ -41,6 +41,17 @@ Install as a Claude Code plugin for automatic configuration and enhanced AI beha
|
|
|
41
41
|
|
|
42
42
|
This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.
|
|
43
43
|
|
|
44
|
+
### Using the Codex Marketplace
|
|
45
|
+
|
|
46
|
+
The same plugin is available for Codex. Add the marketplace and install the plugin:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
codex plugin marketplace add sweetrb/apple-notes-mcp
|
|
50
|
+
codex plugin add apple-notes@apple-notes-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The Codex plugin runs the published `apple-notes-mcp` server through `npx` and ships the same Apple Notes skill, so behavior matches the Claude Code plugin.
|
|
54
|
+
|
|
44
55
|
### Manual Installation
|
|
45
56
|
|
|
46
57
|
**1. Install the server:**
|
|
@@ -102,6 +113,15 @@ Resources expose read-only context the client can attach without a tool call:
|
|
|
102
113
|
`notes://note/{id}` template (returns the note as Markdown). Prompts package
|
|
103
114
|
common workflows: `find-note`, `weekly-review`, `new-meeting-note`.
|
|
104
115
|
|
|
116
|
+
### Known limitations
|
|
117
|
+
|
|
118
|
+
A few Notes UI features are not exposed to AppleScript and therefore cannot be
|
|
119
|
+
supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
|
|
120
|
+
for the investigation and verification behind each:
|
|
121
|
+
|
|
122
|
+
- **Pinned notes** — Notes has no scriptable `pinned` property, so pin state can be neither read nor set.
|
|
123
|
+
- **Note-to-note links** — there is no `applenotes://` deep link or link property; the only stable handle is the `x-coredata://` note id.
|
|
124
|
+
|
|
105
125
|
---
|
|
106
126
|
|
|
107
127
|
## Tool Reference
|
|
@@ -224,7 +244,10 @@ Retrieves the full content of a specific note.
|
|
|
224
244
|
}
|
|
225
245
|
```
|
|
226
246
|
|
|
227
|
-
**Returns:** The HTML content of the note, or error if not found.
|
|
247
|
+
**Returns:** The HTML content of the note, or error if not found. The
|
|
248
|
+
`structuredContent` also includes `hashtags` — any inline `#hashtag` tags parsed
|
|
249
|
+
from the body. Apple Notes tags are inline hashtags, not a scriptable property;
|
|
250
|
+
see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--hashtags-29). Smart Folders are not scriptable.
|
|
228
251
|
|
|
229
252
|
---
|
|
230
253
|
|
|
@@ -946,12 +969,20 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
|
|
|
946
969
|
## Development
|
|
947
970
|
|
|
948
971
|
```bash
|
|
949
|
-
npm install
|
|
950
|
-
npm run build
|
|
951
|
-
npm test
|
|
952
|
-
npm run
|
|
953
|
-
npm run
|
|
954
|
-
|
|
972
|
+
npm install # Install dependencies
|
|
973
|
+
npm run build # Compile TypeScript
|
|
974
|
+
npm test # Run unit test suite (mocked AppleScript)
|
|
975
|
+
npm run test:integration # Run integration tests against real Notes.app
|
|
976
|
+
npm run test:all # Unit + integration
|
|
977
|
+
npm run lint # Check code style
|
|
978
|
+
npm run format # Format code
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
The integration suite (`test/integration.test.ts`) drives the real
|
|
982
|
+
`AppleNotesManager → AppleScript → Notes.app` stack — creating, reading,
|
|
983
|
+
searching, and deleting throwaway notes. Its live tests self-skip when no
|
|
984
|
+
writable Notes account is available (e.g. CI), so it is safe to run anywhere;
|
|
985
|
+
the pure path-safety and hashtag tests always run.
|
|
955
986
|
|
|
956
987
|
---
|
|
957
988
|
|
package/build/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { AppleNotesManager } from "./services/appleNotesManager.js";
|
|
|
27
27
|
import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
|
|
28
28
|
import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
|
|
29
29
|
import { detectChecklistAttempt } from "./utils/contentWarnings.js";
|
|
30
|
+
import { parseHashtags } from "./utils/hashtags.js";
|
|
30
31
|
import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
|
|
31
32
|
import { loadFileConfig } from "./services/fileConfig.js";
|
|
32
33
|
import { registerResourcesAndPrompts } from "./tools/resourcesAndPrompts.js";
|
|
@@ -200,7 +201,8 @@ server.tool("get-note-content", {
|
|
|
200
201
|
if (!content) {
|
|
201
202
|
return errorResponse(`Failed to read content of note "${note.title}"`);
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
+
const hashtags = parseHashtags(content);
|
|
205
|
+
return successResponse(content, { title: note.title, content, hashtags });
|
|
204
206
|
}
|
|
205
207
|
// Fall back to title-based lookup
|
|
206
208
|
if (!title) {
|
|
@@ -218,7 +220,8 @@ server.tool("get-note-content", {
|
|
|
218
220
|
if (!content) {
|
|
219
221
|
return errorResponse(`Failed to read content of note "${title}"`);
|
|
220
222
|
}
|
|
221
|
-
|
|
223
|
+
const hashtags = parseHashtags(content);
|
|
224
|
+
return successResponse(content, { title, content, hashtags });
|
|
222
225
|
}, "Error retrieving note content"));
|
|
223
226
|
// --- get-note-by-id ---
|
|
224
227
|
server.tool("get-note-by-id", {
|
|
@@ -599,6 +602,15 @@ server.tool("get-notes-stats", {}, withErrorHandling(() => {
|
|
|
599
602
|
lines.push(` Last 24 hours: ${stats.recentlyModified.last24h}`);
|
|
600
603
|
lines.push(` Last 7 days: ${stats.recentlyModified.last7d}`);
|
|
601
604
|
lines.push(` Last 30 days: ${stats.recentlyModified.last30d}`);
|
|
605
|
+
// Partial-coverage diagnostics (#19): if some scopes couldn't be read, say so
|
|
606
|
+
// explicitly so the numbers above aren't mistaken for a complete picture.
|
|
607
|
+
if (!stats.coverage.complete) {
|
|
608
|
+
lines.push(``);
|
|
609
|
+
lines.push(`⚠️ Partial results: read ${stats.coverage.covered}/${stats.coverage.scanned} scopes. Counts above exclude:`);
|
|
610
|
+
for (const w of stats.coverage.warnings) {
|
|
611
|
+
lines.push(` - ${w.scope}: ${w.reason}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
602
614
|
return successResponse(lines.join("\n"), { ...stats });
|
|
603
615
|
}, "Error getting notes statistics"));
|
|
604
616
|
// --- list-attachments ---
|
|
@@ -664,24 +664,26 @@ export class AppleNotesManager {
|
|
|
664
664
|
? `
|
|
665
665
|
if (count of resultList) >= ${safeLimit} then exit repeat`
|
|
666
666
|
: "";
|
|
667
|
-
// Get names, IDs, and folder for each matching note
|
|
668
|
-
//
|
|
669
|
-
//
|
|
667
|
+
// Get names, IDs, and folder for each matching note.
|
|
668
|
+
// Notes.app can return the same CoreData note more than once when asking
|
|
669
|
+
// an account for all notes, so dedupe on note ID before adding results.
|
|
670
670
|
const searchCommand = `
|
|
671
671
|
${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
|
|
672
672
|
set resultList to {}
|
|
673
|
-
|
|
673
|
+
set seenIds to {}
|
|
674
|
+
repeat with n in matchingNotes
|
|
674
675
|
try
|
|
675
676
|
set noteName to name of n
|
|
676
677
|
set noteId to id of n
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
678
|
+
if seenIds does not contain noteId then
|
|
679
|
+
set end of seenIds to noteId
|
|
680
|
+
try
|
|
681
|
+
set noteFolder to name of container of n
|
|
682
|
+
on error
|
|
683
|
+
set noteFolder to "Notes"
|
|
684
|
+
end try
|
|
685
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder${limitCheck}
|
|
686
|
+
end if
|
|
685
687
|
end try
|
|
686
688
|
end repeat
|
|
687
689
|
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
@@ -700,12 +702,17 @@ export class AppleNotesManager {
|
|
|
700
702
|
// Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
|
|
701
703
|
const items = result.output.split(RECORD_SEP);
|
|
702
704
|
const notes = [];
|
|
705
|
+
const seenIds = new Set();
|
|
703
706
|
for (const item of items) {
|
|
704
707
|
const [title, id, folder] = item.split(FIELD_SEP);
|
|
705
708
|
if (!title?.trim())
|
|
706
709
|
continue;
|
|
710
|
+
const noteId = id?.trim() || generateFallbackId();
|
|
711
|
+
if (seenIds.has(noteId))
|
|
712
|
+
continue;
|
|
713
|
+
seenIds.add(noteId);
|
|
707
714
|
notes.push({
|
|
708
|
-
id:
|
|
715
|
+
id: noteId,
|
|
709
716
|
title: title.trim(),
|
|
710
717
|
content: "", // Not fetched in search
|
|
711
718
|
tags: [],
|
|
@@ -1032,15 +1039,24 @@ export class AppleNotesManager {
|
|
|
1032
1039
|
notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
|
|
1033
1040
|
}
|
|
1034
1041
|
}
|
|
1035
|
-
// Build the limit check
|
|
1042
|
+
// Build the limit check. Check after appending so deduped results,
|
|
1043
|
+
// rather than duplicate AppleScript references, determine the limit.
|
|
1036
1044
|
const limitCheck = safeLimit !== undefined
|
|
1037
1045
|
? `
|
|
1038
1046
|
if (count of resultList) >= ${safeLimit} then exit repeat`
|
|
1039
1047
|
: "";
|
|
1040
1048
|
const listCommand = `
|
|
1041
1049
|
${dateSetup}set resultList to {}
|
|
1042
|
-
|
|
1043
|
-
|
|
1050
|
+
set seenIds to {}
|
|
1051
|
+
repeat with n in ${notesSource}
|
|
1052
|
+
try
|
|
1053
|
+
set noteName to name of n
|
|
1054
|
+
set noteId to id of n
|
|
1055
|
+
if seenIds does not contain noteId then
|
|
1056
|
+
set end of seenIds to noteId
|
|
1057
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId${limitCheck}
|
|
1058
|
+
end if
|
|
1059
|
+
end try
|
|
1044
1060
|
end repeat
|
|
1045
1061
|
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1046
1062
|
return resultList as text
|
|
@@ -1053,16 +1069,36 @@ export class AppleNotesManager {
|
|
|
1053
1069
|
if (!result.output.trim()) {
|
|
1054
1070
|
return [];
|
|
1055
1071
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1072
|
+
const seenIds = new Set();
|
|
1073
|
+
const titles = [];
|
|
1074
|
+
for (const item of result.output.split(RECORD_SEP)) {
|
|
1075
|
+
const [title, id] = item.split(FIELD_SEP);
|
|
1076
|
+
if (!title?.trim())
|
|
1077
|
+
continue;
|
|
1078
|
+
const noteId = id?.trim() || generateFallbackId();
|
|
1079
|
+
if (seenIds.has(noteId))
|
|
1080
|
+
continue;
|
|
1081
|
+
seenIds.add(noteId);
|
|
1082
|
+
titles.push(title.trim());
|
|
1083
|
+
}
|
|
1084
|
+
return titles;
|
|
1060
1085
|
}
|
|
1061
|
-
// Simple path: no date or limit filters.
|
|
1062
|
-
//
|
|
1086
|
+
// Simple path: no date or limit filters. Use a repeat loop so duplicate
|
|
1087
|
+
// CoreData note references can be deduped by ID before returning titles.
|
|
1063
1088
|
const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
|
|
1064
1089
|
const listCommand = `
|
|
1065
|
-
set resultList to
|
|
1090
|
+
set resultList to {}
|
|
1091
|
+
set seenIds to {}
|
|
1092
|
+
repeat with n in ${notesRef}
|
|
1093
|
+
try
|
|
1094
|
+
set noteName to name of n
|
|
1095
|
+
set noteId to id of n
|
|
1096
|
+
if seenIds does not contain noteId then
|
|
1097
|
+
set end of seenIds to noteId
|
|
1098
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId
|
|
1099
|
+
end if
|
|
1100
|
+
end try
|
|
1101
|
+
end repeat
|
|
1066
1102
|
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1067
1103
|
return resultList as text
|
|
1068
1104
|
`;
|
|
@@ -1073,10 +1109,19 @@ export class AppleNotesManager {
|
|
|
1073
1109
|
}
|
|
1074
1110
|
if (!result.output.trim())
|
|
1075
1111
|
return [];
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1112
|
+
const seenIds = new Set();
|
|
1113
|
+
const titles = [];
|
|
1114
|
+
for (const item of result.output.split(RECORD_SEP)) {
|
|
1115
|
+
const [title, id] = item.split(FIELD_SEP);
|
|
1116
|
+
if (!title?.trim())
|
|
1117
|
+
continue;
|
|
1118
|
+
const noteId = id?.trim() || generateFallbackId();
|
|
1119
|
+
if (seenIds.has(noteId))
|
|
1120
|
+
continue;
|
|
1121
|
+
seenIds.add(noteId);
|
|
1122
|
+
titles.push(title.trim());
|
|
1123
|
+
}
|
|
1124
|
+
return titles;
|
|
1080
1125
|
}
|
|
1081
1126
|
/**
|
|
1082
1127
|
* Lists all shared (collaborative) notes across all accounts.
|
|
@@ -1549,10 +1594,16 @@ export class AppleNotesManager {
|
|
|
1549
1594
|
getNotesStats() {
|
|
1550
1595
|
const accounts = this.listAccounts();
|
|
1551
1596
|
const accountStats = [];
|
|
1597
|
+
const warnings = [];
|
|
1552
1598
|
let totalNotes = 0;
|
|
1553
1599
|
// Collect stats per account with ONE bounded script per account (#20/#26):
|
|
1554
1600
|
// count notes server-side per folder instead of fetching every note's name
|
|
1555
1601
|
// (unbounded) via a listNotes call per folder (N+1 osascript spawns).
|
|
1602
|
+
//
|
|
1603
|
+
// Per-account failures degrade gracefully (#19): a single unreachable or
|
|
1604
|
+
// locked account is recorded as a coverage warning and skipped, rather than
|
|
1605
|
+
// discarding the stats for every healthy account. Only a total wipeout
|
|
1606
|
+
// (no account readable) is escalated to a thrown error below.
|
|
1556
1607
|
for (const account of accounts) {
|
|
1557
1608
|
const countScript = buildAccountScopedScript({ account: account.name }, `
|
|
1558
1609
|
set out to ""
|
|
@@ -1563,7 +1614,8 @@ export class AppleNotesManager {
|
|
|
1563
1614
|
`);
|
|
1564
1615
|
const res = executeAppleScript(countScript);
|
|
1565
1616
|
if (!res.success) {
|
|
1566
|
-
|
|
1617
|
+
warnings.push({ scope: account.name, reason: res.error ?? "unknown error" });
|
|
1618
|
+
continue;
|
|
1567
1619
|
}
|
|
1568
1620
|
const folderStats = [];
|
|
1569
1621
|
let accountTotal = 0;
|
|
@@ -1583,12 +1635,33 @@ export class AppleNotesManager {
|
|
|
1583
1635
|
folders: folderStats,
|
|
1584
1636
|
});
|
|
1585
1637
|
}
|
|
1586
|
-
//
|
|
1587
|
-
|
|
1638
|
+
// If every account failed, there is no data to report — surface the error
|
|
1639
|
+
// (#19) rather than returning a deceptively empty stats object.
|
|
1640
|
+
if (accounts.length > 0 && accountStats.length === 0) {
|
|
1641
|
+
throw new Error(`Failed to read folder stats for any of ${accounts.length} account(s): ${warnings
|
|
1642
|
+
.map((w) => `${w.scope} (${w.reason})`)
|
|
1643
|
+
.join("; ")}`);
|
|
1644
|
+
}
|
|
1645
|
+
// Get recently modified notes counts. A failure here is non-fatal — record a
|
|
1646
|
+
// coverage warning and report zeros, flagged as not-covered (#19), instead of
|
|
1647
|
+
// passing off fake zero activity as real.
|
|
1648
|
+
const recent = this.getRecentlyModifiedCounts();
|
|
1649
|
+
if (recent.error) {
|
|
1650
|
+
warnings.push({ scope: "recent-activity", reason: recent.error });
|
|
1651
|
+
}
|
|
1652
|
+
// scopes = each account + the recent-activity scan
|
|
1653
|
+
const scanned = accounts.length + 1;
|
|
1654
|
+
const covered = scanned - warnings.length;
|
|
1588
1655
|
return {
|
|
1589
1656
|
totalNotes,
|
|
1590
1657
|
accounts: accountStats,
|
|
1591
|
-
recentlyModified,
|
|
1658
|
+
recentlyModified: recent.counts,
|
|
1659
|
+
coverage: {
|
|
1660
|
+
complete: warnings.length === 0,
|
|
1661
|
+
scanned,
|
|
1662
|
+
covered,
|
|
1663
|
+
warnings,
|
|
1664
|
+
},
|
|
1592
1665
|
};
|
|
1593
1666
|
}
|
|
1594
1667
|
/**
|
|
@@ -1621,15 +1694,22 @@ export class AppleNotesManager {
|
|
|
1621
1694
|
`;
|
|
1622
1695
|
const result = executeAppleScript(script);
|
|
1623
1696
|
if (!result.success) {
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1697
|
+
// Non-fatal (#19): report the error to the caller so it becomes a coverage
|
|
1698
|
+
// warning, with zeroed counts, instead of throwing away the whole stats
|
|
1699
|
+
// result or passing off fake zero activity as real.
|
|
1700
|
+
return {
|
|
1701
|
+
counts: { last24h: 0, last7d: 0, last30d: 0 },
|
|
1702
|
+
error: result.error ?? "unknown error",
|
|
1703
|
+
};
|
|
1626
1704
|
}
|
|
1627
1705
|
const parts = result.output.trim().split(FIELD_SEP);
|
|
1628
1706
|
const toInt = (s) => {
|
|
1629
1707
|
const n = parseInt((s ?? "").trim(), 10);
|
|
1630
1708
|
return Number.isFinite(n) ? n : 0;
|
|
1631
1709
|
};
|
|
1632
|
-
return {
|
|
1710
|
+
return {
|
|
1711
|
+
counts: { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) },
|
|
1712
|
+
};
|
|
1633
1713
|
}
|
|
1634
1714
|
// ===========================================================================
|
|
1635
1715
|
// Attachments
|
|
@@ -1858,29 +1938,94 @@ export class AppleNotesManager {
|
|
|
1858
1938
|
* ```
|
|
1859
1939
|
*/
|
|
1860
1940
|
batchDeleteNotes(ids) {
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1941
|
+
if (ids.length === 0)
|
|
1942
|
+
return [];
|
|
1943
|
+
// Collapse the whole batch into ONE osascript spawn (#26): a single
|
|
1944
|
+
// app-level script loops over every id, with a per-id `try` so one bad note
|
|
1945
|
+
// can't abort the rest. The old path spawned 3 processes per note
|
|
1946
|
+
// (getNoteById + isNotePasswordProtectedById + deleteNoteById) — i.e. 3N
|
|
1947
|
+
// spawns for N notes. This is one spawn total, with the same per-item
|
|
1948
|
+
// isolation and result semantics.
|
|
1949
|
+
const results = new Array(ids.length);
|
|
1950
|
+
const runnable = [];
|
|
1951
|
+
ids.forEach((id, i) => {
|
|
1952
|
+
try {
|
|
1953
|
+
runnable.push({ index: i, safe: sanitizeId(id) });
|
|
1868
1954
|
}
|
|
1869
|
-
|
|
1870
|
-
results
|
|
1871
|
-
continue;
|
|
1955
|
+
catch (e) {
|
|
1956
|
+
results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
|
|
1872
1957
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1958
|
+
});
|
|
1959
|
+
if (runnable.length > 0) {
|
|
1960
|
+
const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
|
|
1961
|
+
const script = buildAppLevelScript(`
|
|
1962
|
+
set out to ""
|
|
1963
|
+
repeat with rawId in {${idList}}
|
|
1964
|
+
set theId to (rawId as text)
|
|
1965
|
+
set noteRef to missing value
|
|
1966
|
+
try
|
|
1967
|
+
set noteRef to note id theId
|
|
1968
|
+
end try
|
|
1969
|
+
if noteRef is missing value then
|
|
1970
|
+
set out to out & "missing" & ${AS_RECORD_SEP}
|
|
1971
|
+
else
|
|
1972
|
+
set isPw to false
|
|
1973
|
+
try
|
|
1974
|
+
set isPw to (password protected of noteRef)
|
|
1975
|
+
end try
|
|
1976
|
+
if isPw then
|
|
1977
|
+
set out to out & "pw" & ${AS_RECORD_SEP}
|
|
1978
|
+
else
|
|
1979
|
+
try
|
|
1980
|
+
delete noteRef
|
|
1981
|
+
set out to out & "ok" & ${AS_RECORD_SEP}
|
|
1982
|
+
on error
|
|
1983
|
+
set out to out & "fail" & ${AS_RECORD_SEP}
|
|
1984
|
+
end try
|
|
1985
|
+
end if
|
|
1986
|
+
end if
|
|
1987
|
+
end repeat
|
|
1988
|
+
return out
|
|
1989
|
+
`);
|
|
1990
|
+
const res = executeAppleScript(script);
|
|
1991
|
+
if (!res.success) {
|
|
1992
|
+
// Whole-batch failure (e.g. Notes.app not responding): can't isolate,
|
|
1993
|
+
// so mark every runnable note as failed with the underlying error.
|
|
1994
|
+
for (const r of runnable) {
|
|
1995
|
+
results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch delete failed");
|
|
1996
|
+
}
|
|
1877
1997
|
}
|
|
1878
1998
|
else {
|
|
1879
|
-
|
|
1999
|
+
const statuses = res.output
|
|
2000
|
+
.split(RECORD_SEP)
|
|
2001
|
+
.map((s) => s.trim())
|
|
2002
|
+
.filter((s) => s.length > 0);
|
|
2003
|
+
runnable.forEach((r, k) => {
|
|
2004
|
+
results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
|
|
2005
|
+
});
|
|
1880
2006
|
}
|
|
1881
2007
|
}
|
|
1882
2008
|
return results;
|
|
1883
2009
|
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Maps a per-item status token emitted by a batch AppleScript loop to a
|
|
2012
|
+
* BatchResult, preserving the human-readable error messages of the original
|
|
2013
|
+
* per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
|
|
2014
|
+
*/
|
|
2015
|
+
mapBatchStatus(id, status, op) {
|
|
2016
|
+
switch (status) {
|
|
2017
|
+
case "ok":
|
|
2018
|
+
return this.createBatchResult(id, true);
|
|
2019
|
+
case "pw":
|
|
2020
|
+
return this.createBatchResult(id, false, "Note is password-protected");
|
|
2021
|
+
case "missing":
|
|
2022
|
+
return this.createBatchResult(id, false, "Note not found");
|
|
2023
|
+
case "fail":
|
|
2024
|
+
return this.createBatchResult(id, false, op === "delete" ? "Deletion failed" : "Move failed");
|
|
2025
|
+
default:
|
|
2026
|
+
return this.createBatchResult(id, false, "Unknown error");
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
1884
2029
|
/**
|
|
1885
2030
|
* Moves multiple notes to a folder by their IDs.
|
|
1886
2031
|
*
|
|
@@ -1901,25 +2046,76 @@ export class AppleNotesManager {
|
|
|
1901
2046
|
* ```
|
|
1902
2047
|
*/
|
|
1903
2048
|
batchMoveNotes(ids, folder, account) {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2049
|
+
if (ids.length === 0)
|
|
2050
|
+
return [];
|
|
2051
|
+
// Collapse the whole batch into ONE osascript spawn (#26). The old path
|
|
2052
|
+
// spawned 5+ processes per note (getNoteById + isNotePasswordProtectedById +
|
|
2053
|
+
// moveNoteById's copy-then-delete fan-out). This uses the native `move`
|
|
2054
|
+
// command — which preserves the note's identity and metadata rather than
|
|
2055
|
+
// copy+delete — inside a single app-level loop with per-id `try` isolation.
|
|
2056
|
+
const targetAccount = this.resolveAccount(account);
|
|
2057
|
+
const safeAccount = sanitizeAccountName(targetAccount);
|
|
2058
|
+
// buildFolderReference validates the (single, shared) destination path; a
|
|
2059
|
+
// malformed folder is a precondition error for the whole call, so let it throw.
|
|
2060
|
+
const destFolderRef = `${buildFolderReference(folder)} of account "${safeAccount}"`;
|
|
2061
|
+
const results = new Array(ids.length);
|
|
2062
|
+
const runnable = [];
|
|
2063
|
+
ids.forEach((id, i) => {
|
|
2064
|
+
try {
|
|
2065
|
+
runnable.push({ index: i, safe: sanitizeId(id) });
|
|
1911
2066
|
}
|
|
1912
|
-
|
|
1913
|
-
results
|
|
1914
|
-
continue;
|
|
2067
|
+
catch (e) {
|
|
2068
|
+
results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
|
|
1915
2069
|
}
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
2070
|
+
});
|
|
2071
|
+
if (runnable.length > 0) {
|
|
2072
|
+
const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
|
|
2073
|
+
const script = buildAppLevelScript(`
|
|
2074
|
+
set destFolder to ${destFolderRef}
|
|
2075
|
+
set out to ""
|
|
2076
|
+
repeat with rawId in {${idList}}
|
|
2077
|
+
set theId to (rawId as text)
|
|
2078
|
+
set noteRef to missing value
|
|
2079
|
+
try
|
|
2080
|
+
set noteRef to note id theId
|
|
2081
|
+
end try
|
|
2082
|
+
if noteRef is missing value then
|
|
2083
|
+
set out to out & "missing" & ${AS_RECORD_SEP}
|
|
2084
|
+
else
|
|
2085
|
+
set isPw to false
|
|
2086
|
+
try
|
|
2087
|
+
set isPw to (password protected of noteRef)
|
|
2088
|
+
end try
|
|
2089
|
+
if isPw then
|
|
2090
|
+
set out to out & "pw" & ${AS_RECORD_SEP}
|
|
2091
|
+
else
|
|
2092
|
+
try
|
|
2093
|
+
move noteRef to destFolder
|
|
2094
|
+
set out to out & "ok" & ${AS_RECORD_SEP}
|
|
2095
|
+
on error
|
|
2096
|
+
set out to out & "fail" & ${AS_RECORD_SEP}
|
|
2097
|
+
end try
|
|
2098
|
+
end if
|
|
2099
|
+
end if
|
|
2100
|
+
end repeat
|
|
2101
|
+
return out
|
|
2102
|
+
`);
|
|
2103
|
+
const res = executeAppleScript(script);
|
|
2104
|
+
if (!res.success) {
|
|
2105
|
+
// Whole-batch failure (e.g. destination folder unresolved, Notes not
|
|
2106
|
+
// responding): can't isolate, so fail every runnable note.
|
|
2107
|
+
for (const r of runnable) {
|
|
2108
|
+
results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch move failed");
|
|
2109
|
+
}
|
|
1920
2110
|
}
|
|
1921
2111
|
else {
|
|
1922
|
-
|
|
2112
|
+
const statuses = res.output
|
|
2113
|
+
.split(RECORD_SEP)
|
|
2114
|
+
.map((s) => s.trim())
|
|
2115
|
+
.filter((s) => s.length > 0);
|
|
2116
|
+
runnable.forEach((r, k) => {
|
|
2117
|
+
results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "move");
|
|
2118
|
+
});
|
|
1923
2119
|
}
|
|
1924
2120
|
}
|
|
1925
2121
|
return results;
|
|
@@ -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.0
|
|
4
|
-
"description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and Codex",
|
|
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/plugin.json .claude-plugin/marketplace.json .agents/plugins/marketplace.json codex/.codex-plugin/plugin.json",
|
|
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",
|