apple-notes-mcp 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -9
- package/build/index.js +40 -28
- package/build/services/appleNotesManager.js +260 -64
- package/build/services/appleNotesManager.test.js +187 -139
- package/build/utils/hashtags.js +56 -0
- package/build/utils/hashtags.test.js +45 -0
- package/package.json +8 -5
|
@@ -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;
|