apple-notes-mcp 2.0.0 → 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 +269 -73
- package/build/services/appleNotesManager.test.js +196 -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 ---
|
|
@@ -634,7 +634,7 @@ export class AppleNotesManager {
|
|
|
634
634
|
*/
|
|
635
635
|
searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
|
|
636
636
|
const targetAccount = this.resolveAccount(account);
|
|
637
|
-
const safeQuery =
|
|
637
|
+
const safeQuery = escapePlainStringForAppleScript(query);
|
|
638
638
|
const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
|
|
639
639
|
// Build the where clause based on search type
|
|
640
640
|
// AppleScript uses 'name' for title and 'body' for content
|
|
@@ -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: [],
|
|
@@ -738,7 +745,7 @@ export class AppleNotesManager {
|
|
|
738
745
|
*/
|
|
739
746
|
getNoteContent(title, account) {
|
|
740
747
|
const targetAccount = this.resolveAccount(account);
|
|
741
|
-
const safeTitle =
|
|
748
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
742
749
|
// Retrieve the body property of the note
|
|
743
750
|
const getCommand = `get body of note "${safeTitle}"`;
|
|
744
751
|
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
@@ -830,7 +837,7 @@ export class AppleNotesManager {
|
|
|
830
837
|
*/
|
|
831
838
|
getNoteDetails(title, account) {
|
|
832
839
|
const targetAccount = this.resolveAccount(account);
|
|
833
|
-
const safeTitle =
|
|
840
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
834
841
|
// Fetch multiple properties at once
|
|
835
842
|
const getCommand = `
|
|
836
843
|
set n to note "${safeTitle}"
|
|
@@ -875,7 +882,7 @@ export class AppleNotesManager {
|
|
|
875
882
|
*/
|
|
876
883
|
deleteNote(title, account) {
|
|
877
884
|
const targetAccount = this.resolveAccount(account);
|
|
878
|
-
const safeTitle =
|
|
885
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
879
886
|
const deleteCommand = `delete note "${safeTitle}"`;
|
|
880
887
|
const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
|
|
881
888
|
const result = executeAppleScript(script);
|
|
@@ -931,7 +938,7 @@ export class AppleNotesManager {
|
|
|
931
938
|
validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
|
|
932
939
|
validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
|
|
933
940
|
const targetAccount = this.resolveAccount(account);
|
|
934
|
-
const safeCurrentTitle =
|
|
941
|
+
const safeCurrentTitle = escapePlainStringForAppleScript(title);
|
|
935
942
|
let fullBody;
|
|
936
943
|
if (format === "html") {
|
|
937
944
|
// HTML mode: content is the complete body, escaped only for AppleScript string
|
|
@@ -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.
|
|
@@ -1251,7 +1296,7 @@ export class AppleNotesManager {
|
|
|
1251
1296
|
continue;
|
|
1252
1297
|
}
|
|
1253
1298
|
// Folder doesn't exist — create it
|
|
1254
|
-
const segmentName =
|
|
1299
|
+
const segmentName = escapePlainStringForAppleScript(parts[i]);
|
|
1255
1300
|
let createCommand;
|
|
1256
1301
|
if (i === 0) {
|
|
1257
1302
|
createCommand = `make new folder with properties {name:"${segmentName}"}`;
|
|
@@ -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
|
|
@@ -1699,7 +1779,7 @@ export class AppleNotesManager {
|
|
|
1699
1779
|
*/
|
|
1700
1780
|
listAttachments(title, account) {
|
|
1701
1781
|
const targetAccount = this.resolveAccount(account);
|
|
1702
|
-
const safeTitle =
|
|
1782
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
1703
1783
|
const script = `
|
|
1704
1784
|
tell application "Notes"
|
|
1705
1785
|
tell account "${targetAccount}"
|
|
@@ -1759,8 +1839,8 @@ export class AppleNotesManager {
|
|
|
1759
1839
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1760
1840
|
}
|
|
1761
1841
|
const safeNoteId = sanitizeId(noteId);
|
|
1762
|
-
const safeAttId =
|
|
1763
|
-
const safePath =
|
|
1842
|
+
const safeAttId = escapePlainStringForAppleScript(attachmentId);
|
|
1843
|
+
const safePath = escapePlainStringForAppleScript(abs);
|
|
1764
1844
|
const script = `
|
|
1765
1845
|
tell application "Notes"
|
|
1766
1846
|
set theNote to note id "${safeNoteId}"
|
|
@@ -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,
|
|
@@ -633,6 +646,15 @@ describe("AppleNotesManager", () => {
|
|
|
633
646
|
const content = manager.getNoteContent("Missing Note");
|
|
634
647
|
expect(content).toBe("");
|
|
635
648
|
});
|
|
649
|
+
it("looks up titles containing & literally, not HTML-escaped (regression)", () => {
|
|
650
|
+
// Bug found in live testing: titles with "&" were HTML-escaped to "&"
|
|
651
|
+
// in the `note "..."` lookup, so the note could never be found.
|
|
652
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "<div>x</div>" });
|
|
653
|
+
manager.getNoteContent("Tom & Jerry", "iCloud");
|
|
654
|
+
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
655
|
+
expect(script).toContain("Tom & Jerry");
|
|
656
|
+
expect(script).not.toContain("Tom & Jerry");
|
|
657
|
+
});
|
|
636
658
|
it("uses specified account", () => {
|
|
637
659
|
mockExecuteAppleScript.mockReturnValue({
|
|
638
660
|
success: true,
|
|
@@ -993,7 +1015,11 @@ describe("AppleNotesManager", () => {
|
|
|
993
1015
|
it("returns array of note titles", () => {
|
|
994
1016
|
mockExecuteAppleScript.mockReturnValue({
|
|
995
1017
|
success: true,
|
|
996
|
-
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),
|
|
997
1023
|
});
|
|
998
1024
|
const titles = manager.listNotes();
|
|
999
1025
|
expect(titles).toEqual(["Note A", "Note B", "Note C"]);
|
|
@@ -1001,11 +1027,29 @@ describe("AppleNotesManager", () => {
|
|
|
1001
1027
|
it("filters out empty entries", () => {
|
|
1002
1028
|
mockExecuteAppleScript.mockReturnValue({
|
|
1003
1029
|
success: true,
|
|
1004
|
-
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),
|
|
1005
1037
|
});
|
|
1006
1038
|
const titles = manager.listNotes();
|
|
1007
1039
|
expect(titles).toEqual(["Note A", "Note B"]);
|
|
1008
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
|
+
});
|
|
1009
1053
|
it("throws on failure rather than returning empty (#19)", () => {
|
|
1010
1054
|
mockExecuteAppleScript.mockReturnValue({
|
|
1011
1055
|
success: false,
|
|
@@ -1017,7 +1061,10 @@ describe("AppleNotesManager", () => {
|
|
|
1017
1061
|
it("filters by folder when specified", () => {
|
|
1018
1062
|
mockExecuteAppleScript.mockReturnValue({
|
|
1019
1063
|
success: true,
|
|
1020
|
-
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),
|
|
1021
1068
|
});
|
|
1022
1069
|
manager.listNotes("iCloud", "Work");
|
|
1023
1070
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
|
|
@@ -1025,7 +1072,10 @@ describe("AppleNotesManager", () => {
|
|
|
1025
1072
|
it("uses whose clause when modifiedSince is provided", () => {
|
|
1026
1073
|
mockExecuteAppleScript.mockReturnValue({
|
|
1027
1074
|
success: true,
|
|
1028
|
-
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),
|
|
1029
1079
|
});
|
|
1030
1080
|
const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
|
|
1031
1081
|
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
@@ -1040,7 +1090,11 @@ describe("AppleNotesManager", () => {
|
|
|
1040
1090
|
it("uses repeat loop when limit is provided", () => {
|
|
1041
1091
|
mockExecuteAppleScript.mockReturnValue({
|
|
1042
1092
|
success: true,
|
|
1043
|
-
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),
|
|
1044
1098
|
});
|
|
1045
1099
|
const results = manager.listNotes(undefined, undefined, undefined, 3);
|
|
1046
1100
|
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
@@ -1050,7 +1104,10 @@ describe("AppleNotesManager", () => {
|
|
|
1050
1104
|
it("combines folder, modifiedSince, and limit", () => {
|
|
1051
1105
|
mockExecuteAppleScript.mockReturnValue({
|
|
1052
1106
|
success: true,
|
|
1053
|
-
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),
|
|
1054
1111
|
});
|
|
1055
1112
|
manager.listNotes("iCloud", "Work", "2025-01-01", 10);
|
|
1056
1113
|
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
@@ -1069,7 +1126,10 @@ describe("AppleNotesManager", () => {
|
|
|
1069
1126
|
it("ignores invalid modifiedSince date and falls back to limit-only", () => {
|
|
1070
1127
|
mockExecuteAppleScript.mockReturnValue({
|
|
1071
1128
|
success: true,
|
|
1072
|
-
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),
|
|
1073
1133
|
});
|
|
1074
1134
|
const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
|
|
1075
1135
|
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
@@ -1390,7 +1450,13 @@ describe("AppleNotesManager", () => {
|
|
|
1390
1450
|
// Check 3: listAccounts
|
|
1391
1451
|
.mockReturnValueOnce({ success: true, output: "iCloud" })
|
|
1392
1452
|
// Check 4: listNotes
|
|
1393
|
-
.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
|
+
});
|
|
1394
1460
|
const result = manager.healthCheck();
|
|
1395
1461
|
expect(result.healthy).toBe(true);
|
|
1396
1462
|
expect(result.checks).toHaveLength(4);
|
|
@@ -1489,6 +1555,56 @@ describe("AppleNotesManager", () => {
|
|
|
1489
1555
|
expect(stats.accounts[0].name).toBe("iCloud");
|
|
1490
1556
|
expect(stats.accounts[1].name).toBe("Gmail");
|
|
1491
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
|
+
});
|
|
1492
1608
|
});
|
|
1493
1609
|
// ---------------------------------------------------------------------------
|
|
1494
1610
|
// Attachment Listing
|
|
@@ -1569,50 +1685,23 @@ describe("AppleNotesManager", () => {
|
|
|
1569
1685
|
// Batch Operations
|
|
1570
1686
|
// ---------------------------------------------------------------------------
|
|
1571
1687
|
describe("batchDeleteNotes", () => {
|
|
1572
|
-
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
"Sunday, January 1, 2025 at 1:00:00 PM",
|
|
1578
|
-
"false",
|
|
1579
|
-
String(passwordProtected),
|
|
1580
|
-
].join(F);
|
|
1581
|
-
it("deletes multiple notes successfully", () => {
|
|
1582
|
-
// For each note: getNoteById (which isNotePasswordProtectedById also calls), deleteNoteById
|
|
1583
|
-
mockExecuteAppleScript
|
|
1584
|
-
// First note: getNoteById for existence check
|
|
1585
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1586
|
-
// First note: getNoteById for password check (isNotePasswordProtectedById calls getNoteById)
|
|
1587
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1588
|
-
// First note: deleteNoteById
|
|
1589
|
-
.mockReturnValueOnce({ success: true, output: "" })
|
|
1590
|
-
// Second note: getNoteById for existence check
|
|
1591
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
|
|
1592
|
-
// Second note: getNoteById for password check
|
|
1593
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
|
|
1594
|
-
// Second note: deleteNoteById
|
|
1595
|
-
.mockReturnValueOnce({ success: true, output: "" });
|
|
1596
|
-
const results = manager.batchDeleteNotes([
|
|
1597
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1598
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
|
|
1599
|
-
]);
|
|
1600
|
-
expect(results).toHaveLength(2);
|
|
1601
|
-
expect(results[0]).toEqual({
|
|
1602
|
-
id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1603
|
-
success: true,
|
|
1604
|
-
});
|
|
1605
|
-
expect(results[1]).toEqual({
|
|
1606
|
-
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({
|
|
1607
1693
|
success: true,
|
|
1694
|
+
output: ["ok", "ok"].join(R) + R,
|
|
1608
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 });
|
|
1609
1702
|
});
|
|
1610
1703
|
it("returns error for non-existent note", () => {
|
|
1611
|
-
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1612
|
-
success: false,
|
|
1613
|
-
output: "",
|
|
1614
|
-
error: "Not found",
|
|
1615
|
-
});
|
|
1704
|
+
mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
|
|
1616
1705
|
const results = manager.batchDeleteNotes([
|
|
1617
1706
|
"x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
|
|
1618
1707
|
]);
|
|
@@ -1623,97 +1712,58 @@ describe("AppleNotesManager", () => {
|
|
|
1623
1712
|
});
|
|
1624
1713
|
});
|
|
1625
1714
|
it("returns error for password-protected note", () => {
|
|
1626
|
-
mockExecuteAppleScript
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
// getNoteById for password check (returns true for passwordProtected)
|
|
1630
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
|
|
1631
|
-
const results = manager.batchDeleteNotes([
|
|
1632
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1633
|
-
]);
|
|
1634
|
-
expect(results[0]).toEqual({
|
|
1635
|
-
id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1636
|
-
success: false,
|
|
1637
|
-
error: "Note is password-protected",
|
|
1638
|
-
});
|
|
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" });
|
|
1639
1718
|
});
|
|
1640
|
-
it("handles mixed success and failure", () => {
|
|
1641
|
-
mockExecuteAppleScript
|
|
1642
|
-
// First note: success
|
|
1643
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1644
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1645
|
-
.mockReturnValueOnce({ success: true, output: "" })
|
|
1646
|
-
// Second note: not found
|
|
1647
|
-
.mockReturnValueOnce({ success: false, output: "", error: "Not found" });
|
|
1648
|
-
const results = manager.batchDeleteNotes([
|
|
1649
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1650
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
|
|
1651
|
-
]);
|
|
1652
|
-
expect(results[0]).toEqual({
|
|
1653
|
-
id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1719
|
+
it("handles mixed success and failure, preserving order", () => {
|
|
1720
|
+
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1654
1721
|
success: true,
|
|
1722
|
+
output: ["ok", "missing"].join(R) + R,
|
|
1655
1723
|
});
|
|
1656
|
-
|
|
1657
|
-
|
|
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({
|
|
1658
1737
|
success: false,
|
|
1659
|
-
|
|
1738
|
+
output: "",
|
|
1739
|
+
error: "Notes.app not responding",
|
|
1660
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();
|
|
1661
1749
|
});
|
|
1662
1750
|
});
|
|
1663
1751
|
describe("batchMoveNotes", () => {
|
|
1664
|
-
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
"Sunday, January 1, 2025 at 1:00:00 PM",
|
|
1669
|
-
"Sunday, January 1, 2025 at 1:00:00 PM",
|
|
1670
|
-
"false",
|
|
1671
|
-
String(passwordProtected),
|
|
1672
|
-
].join(F);
|
|
1673
|
-
it("moves multiple notes successfully", () => {
|
|
1674
|
-
// For each note: getNoteById, getNoteById (password check), getNoteContentById, create, delete
|
|
1675
|
-
mockExecuteAppleScript
|
|
1676
|
-
// First note: getNoteById for existence check
|
|
1677
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1678
|
-
// First note: getNoteById for password check
|
|
1679
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
|
|
1680
|
-
// First note: moveNoteById calls getNoteContentById
|
|
1681
|
-
.mockReturnValueOnce({ success: true, output: "<div>Note 1</div><div>Content</div>" })
|
|
1682
|
-
// First note: createNote in destination
|
|
1683
|
-
.mockReturnValueOnce({ success: true, output: "" })
|
|
1684
|
-
// First note: deleteNoteById (original)
|
|
1685
|
-
.mockReturnValueOnce({ success: true, output: "" })
|
|
1686
|
-
// Second note: getNoteById for existence check
|
|
1687
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
|
|
1688
|
-
// Second note: getNoteById for password check
|
|
1689
|
-
.mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
|
|
1690
|
-
// Second note: moveNoteById calls getNoteContentById
|
|
1691
|
-
.mockReturnValueOnce({ success: true, output: "<div>Note 2</div><div>Content</div>" })
|
|
1692
|
-
// Second note: createNote in destination
|
|
1693
|
-
.mockReturnValueOnce({ success: true, output: "" })
|
|
1694
|
-
// Second note: deleteNoteById (original)
|
|
1695
|
-
.mockReturnValueOnce({ success: true, output: "" });
|
|
1696
|
-
const results = manager.batchMoveNotes([
|
|
1697
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1698
|
-
"x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
|
|
1699
|
-
], "Archive");
|
|
1700
|
-
expect(results).toHaveLength(2);
|
|
1701
|
-
expect(results[0]).toEqual({
|
|
1702
|
-
id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
|
|
1703
|
-
success: true,
|
|
1704
|
-
});
|
|
1705
|
-
expect(results[1]).toEqual({
|
|
1706
|
-
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({
|
|
1707
1756
|
success: true,
|
|
1757
|
+
output: ["ok", "ok"].join(R) + R,
|
|
1708
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 });
|
|
1709
1764
|
});
|
|
1710
1765
|
it("returns error for non-existent note", () => {
|
|
1711
|
-
|
|
1712
|
-
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1713
|
-
success: false,
|
|
1714
|
-
output: "",
|
|
1715
|
-
error: "Not found",
|
|
1716
|
-
});
|
|
1766
|
+
mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
|
|
1717
1767
|
const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
|
|
1718
1768
|
expect(results[0]).toEqual({
|
|
1719
1769
|
id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
|
|
@@ -1722,17 +1772,18 @@ describe("AppleNotesManager", () => {
|
|
|
1722
1772
|
});
|
|
1723
1773
|
});
|
|
1724
1774
|
it("returns error for password-protected note", () => {
|
|
1725
|
-
mockExecuteAppleScript
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
success: false,
|
|
1734
|
-
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,
|
|
1735
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" });
|
|
1736
1787
|
});
|
|
1737
1788
|
});
|
|
1738
1789
|
// ---------------------------------------------------------------------------
|
|
@@ -1755,7 +1806,10 @@ describe("AppleNotesManager", () => {
|
|
|
1755
1806
|
// listFolders for iCloud
|
|
1756
1807
|
.mockReturnValueOnce({ success: true, output: "id1\tNotes" })
|
|
1757
1808
|
// listNotes for Notes folder
|
|
1758
|
-
.mockReturnValueOnce({
|
|
1809
|
+
.mockReturnValueOnce({
|
|
1810
|
+
success: true,
|
|
1811
|
+
output: ["Test Note", "x-coredata://ABC/ICNote/p1"].join(F),
|
|
1812
|
+
})
|
|
1759
1813
|
// getNoteDetails
|
|
1760
1814
|
.mockReturnValueOnce({ success: true, output: noteDetailsOutput("Test Note", false) })
|
|
1761
1815
|
// getNoteContent
|
|
@@ -1780,7 +1834,10 @@ describe("AppleNotesManager", () => {
|
|
|
1780
1834
|
// listFolders for iCloud
|
|
1781
1835
|
.mockReturnValueOnce({ success: true, output: "id1\tNotes" })
|
|
1782
1836
|
// listNotes for Notes folder
|
|
1783
|
-
.mockReturnValueOnce({
|
|
1837
|
+
.mockReturnValueOnce({
|
|
1838
|
+
success: true,
|
|
1839
|
+
output: ["Locked Note", "x-coredata://ABC/ICNote/p1"].join(F),
|
|
1840
|
+
})
|
|
1784
1841
|
// getNoteDetails (passwordProtected = true)
|
|
1785
1842
|
.mockReturnValueOnce({ success: true, output: noteDetailsOutput("Locked Note", true) });
|
|
1786
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.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",
|