apple-notes-mcp 1.4.3 → 2.0.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 +97 -6
- package/build/index.js +96 -42
- package/build/services/appleNotesManager.js +272 -142
- package/build/services/appleNotesManager.test.js +196 -68
- package/build/services/attachmentSave.test.js +85 -0
- package/build/services/fileConfig.js +51 -0
- package/build/services/fileConfig.test.js +48 -0
- package/build/tools/doctor.js +50 -0
- package/build/tools/doctor.test.js +42 -0
- package/build/tools/resourcesAndPrompts.js +70 -0
- package/build/tools/resourcesAndPrompts.test.js +63 -0
- package/build/utils/applescript.js +47 -3
- package/build/utils/applescript.test.js +29 -1
- package/build/utils/attachmentFs.js +59 -0
- package/build/utils/attachmentFs.test.js +46 -0
- package/build/utils/jxa.js +17 -0
- package/build/utils/jxa.test.js +20 -1
- package/package.json +1 -1
|
@@ -16,8 +16,25 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { executeAppleScript } from "../utils/applescript.js";
|
|
18
18
|
import { getChecklistItems } from "../utils/checklistParser.js";
|
|
19
|
+
import { assertSafeSavePath, readFileBase64, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
|
|
20
|
+
import { existsSync } from "fs";
|
|
19
21
|
import TurndownService from "turndown";
|
|
20
22
|
// =============================================================================
|
|
23
|
+
// Result delimiters (#18)
|
|
24
|
+
//
|
|
25
|
+
// AppleScript output is delimited with ASCII control characters that cannot
|
|
26
|
+
// appear in user-entered note titles, folder names, or body text — unlike the
|
|
27
|
+
// old printable "|||" / "," / "ITEM" tokens, which collide with ordinary
|
|
28
|
+
// content (a note titled "Groceries, etc." used to split into phantom notes).
|
|
29
|
+
// FIELD_SEP (US, \x1f) separates fields within a record
|
|
30
|
+
// RECORD_SEP (RS, \x1e) separates records within a list
|
|
31
|
+
// In AppleScript these are emitted via `ASCII character 31 / 30`.
|
|
32
|
+
// =============================================================================
|
|
33
|
+
const FIELD_SEP = "\x1f";
|
|
34
|
+
const RECORD_SEP = "\x1e";
|
|
35
|
+
const AS_FIELD_SEP = "(ASCII character 31)";
|
|
36
|
+
const AS_RECORD_SEP = "(ASCII character 30)";
|
|
37
|
+
// =============================================================================
|
|
21
38
|
// Text Processing Utilities
|
|
22
39
|
// =============================================================================
|
|
23
40
|
/**
|
|
@@ -97,6 +114,22 @@ export function escapeHtmlForAppleScript(htmlContent) {
|
|
|
97
114
|
// We do NOT re-encode HTML entities since content is already HTML from Notes.app
|
|
98
115
|
return htmlContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
99
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Escapes a plain (non-HTML) string for safe embedding in an AppleScript string literal.
|
|
119
|
+
*
|
|
120
|
+
* Use this for folder names, account names, and other metadata that Apple Notes
|
|
121
|
+
* stores as plain text — NOT for note body content (use escapeForAppleScript instead).
|
|
122
|
+
* HTML-encoding ampersands here would produce `folder "R&D"`, which Apple Notes
|
|
123
|
+
* would fail to match against the real folder named "R&D".
|
|
124
|
+
*
|
|
125
|
+
* @param text - Plain string (folder name, account name, etc.)
|
|
126
|
+
* @returns String safe for AppleScript string embedding
|
|
127
|
+
*/
|
|
128
|
+
export function escapePlainStringForAppleScript(text) {
|
|
129
|
+
if (!text)
|
|
130
|
+
return "";
|
|
131
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
132
|
+
}
|
|
100
133
|
// =============================================================================
|
|
101
134
|
// Input Validation & Sanitization
|
|
102
135
|
// =============================================================================
|
|
@@ -154,7 +187,7 @@ export function sanitizeId(id) {
|
|
|
154
187
|
*/
|
|
155
188
|
function sanitizeAccountName(account) {
|
|
156
189
|
validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
|
|
157
|
-
return
|
|
190
|
+
return escapePlainStringForAppleScript(account);
|
|
158
191
|
}
|
|
159
192
|
/**
|
|
160
193
|
* Counter for generating unique fallback IDs within the same millisecond.
|
|
@@ -192,8 +225,20 @@ export function generateFallbackId() {
|
|
|
192
225
|
* // Returns: Date object for Dec 27, 2025 3:44:02 PM
|
|
193
226
|
*/
|
|
194
227
|
export function parseAppleScriptDate(appleScriptDate) {
|
|
228
|
+
const s = appleScriptDate.trim();
|
|
229
|
+
// Locale-independent numeric form emitted by our producers (#25): "Y-M-D-H-m-s"
|
|
230
|
+
// built from AppleScript date components, so it never depends on the system's
|
|
231
|
+
// date-format locale (the old `date as text` form did, silently falling back
|
|
232
|
+
// to "now" on non-US Macs).
|
|
233
|
+
const numeric = s.match(/^(\d{1,5})-(\d{1,2})-(\d{1,2})-(\d{1,2})-(\d{1,2})-(\d{1,2})$/);
|
|
234
|
+
if (numeric) {
|
|
235
|
+
const [, y, mo, d, h, mi, se] = numeric;
|
|
236
|
+
const dt = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se));
|
|
237
|
+
return isNaN(dt.getTime()) ? new Date() : dt;
|
|
238
|
+
}
|
|
239
|
+
// Legacy en-US verbose form: "date Saturday, December 27, 2025 at 3:44:02 PM".
|
|
195
240
|
// Remove the "date " prefix if present
|
|
196
|
-
const withoutPrefix =
|
|
241
|
+
const withoutPrefix = s.replace(/^date\s+/, "");
|
|
197
242
|
// Replace " at " with a space for standard date parsing
|
|
198
243
|
// "Saturday, December 27, 2025 at 3:44:02 PM" ->
|
|
199
244
|
// "Saturday, December 27, 2025 3:44:02 PM"
|
|
@@ -230,6 +275,19 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
|
|
|
230
275
|
`set time of ${varName} to ${timeInSeconds}`,
|
|
231
276
|
].join("\n");
|
|
232
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Builds a locale-independent AppleScript expression that renders a date variable
|
|
280
|
+
* as "Y-M-D-H-m-s" from its numeric components (#25), parsed by
|
|
281
|
+
* {@link parseAppleScriptDate}. Avoids `(someDate as text)`, whose format depends
|
|
282
|
+
* on the system locale.
|
|
283
|
+
*
|
|
284
|
+
* @param v - name of an AppleScript variable already holding a date
|
|
285
|
+
*/
|
|
286
|
+
export function asDatePartsExpr(v) {
|
|
287
|
+
return (`((year of ${v}) as text) & "-" & ((month of ${v}) as integer as text) & "-" & ` +
|
|
288
|
+
`((day of ${v}) as text) & "-" & ((hours of ${v}) as text) & "-" & ` +
|
|
289
|
+
`((minutes of ${v}) as text) & "-" & ((seconds of ${v}) as text)`);
|
|
290
|
+
}
|
|
233
291
|
/**
|
|
234
292
|
* Parses AppleScript note properties output into structured data.
|
|
235
293
|
*
|
|
@@ -242,37 +300,22 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
|
|
|
242
300
|
* @returns Parsed properties, or null if format is invalid
|
|
243
301
|
*/
|
|
244
302
|
export function parseNotePropertiesOutput(output) {
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
console.error("Unexpected response format: no comma found in note properties");
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
const title = output.substring(0, firstComma).trim();
|
|
255
|
-
// Extract ID (between first and second comma)
|
|
256
|
-
const afterTitle = output.substring(firstComma + 1);
|
|
257
|
-
const secondComma = afterTitle.indexOf(",");
|
|
258
|
-
if (secondComma === -1) {
|
|
259
|
-
console.error("Unexpected response format: missing second comma in note properties");
|
|
303
|
+
// Fields are control-char delimited (#18): title, id, created, modified,
|
|
304
|
+
// shared, passwordProtected — robust against commas in titles, unlike the
|
|
305
|
+
// old comma/regex parsing.
|
|
306
|
+
const parts = output.split(FIELD_SEP);
|
|
307
|
+
if (parts.length < 6) {
|
|
308
|
+
console.error("Unexpected response format: expected 6 delimited note properties");
|
|
260
309
|
return null;
|
|
261
310
|
}
|
|
262
|
-
const id
|
|
263
|
-
// Extract boolean values from the end (shared, passwordProtected)
|
|
264
|
-
// They appear as ", true" or ", false" at the end
|
|
265
|
-
const boolPattern = /, (true|false), (true|false)$/;
|
|
266
|
-
const boolMatch = output.match(boolPattern);
|
|
267
|
-
const shared = boolMatch ? boolMatch[1] === "true" : false;
|
|
268
|
-
const passwordProtected = boolMatch ? boolMatch[2] === "true" : false;
|
|
311
|
+
const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
|
|
269
312
|
return {
|
|
270
|
-
title,
|
|
271
|
-
id,
|
|
272
|
-
created:
|
|
273
|
-
modified:
|
|
274
|
-
shared,
|
|
275
|
-
passwordProtected,
|
|
313
|
+
title: title.trim(),
|
|
314
|
+
id: id.trim(),
|
|
315
|
+
created: createdStr?.trim() ? parseAppleScriptDate(createdStr.trim()) : new Date(),
|
|
316
|
+
modified: modifiedStr?.trim() ? parseAppleScriptDate(modifiedStr.trim()) : new Date(),
|
|
317
|
+
shared: sharedStr?.trim() === "true",
|
|
318
|
+
passwordProtected: ppStr?.trim() === "true",
|
|
276
319
|
};
|
|
277
320
|
}
|
|
278
321
|
/**
|
|
@@ -325,7 +368,7 @@ export function buildFolderReference(folderPath) {
|
|
|
325
368
|
// Build inside-out: last part is innermost, first part is outermost
|
|
326
369
|
return parts
|
|
327
370
|
.reverse()
|
|
328
|
-
.map((part) => `folder "${
|
|
371
|
+
.map((part) => `folder "${escapePlainStringForAppleScript(part)}"`)
|
|
329
372
|
.join(" of ");
|
|
330
373
|
}
|
|
331
374
|
/**
|
|
@@ -379,23 +422,6 @@ function buildAppLevelScript(command) {
|
|
|
379
422
|
// =============================================================================
|
|
380
423
|
// Result Parsing Utilities
|
|
381
424
|
// =============================================================================
|
|
382
|
-
/**
|
|
383
|
-
* Parses a comma-separated list from AppleScript output.
|
|
384
|
-
*
|
|
385
|
-
* AppleScript often returns lists as comma-separated strings:
|
|
386
|
-
* "Note 1, Note 2, Note 3"
|
|
387
|
-
*
|
|
388
|
-
* This function splits and cleans the output.
|
|
389
|
-
*
|
|
390
|
-
* @param output - Raw AppleScript output
|
|
391
|
-
* @returns Array of trimmed, non-empty strings
|
|
392
|
-
*/
|
|
393
|
-
function parseCommaSeparatedList(output) {
|
|
394
|
-
return output
|
|
395
|
-
.split(",")
|
|
396
|
-
.map((item) => item.trim())
|
|
397
|
-
.filter((item) => item.length > 0);
|
|
398
|
-
}
|
|
399
425
|
/**
|
|
400
426
|
* Extracts a CoreData ID from AppleScript output.
|
|
401
427
|
*
|
|
@@ -649,33 +675,33 @@ export class AppleNotesManager {
|
|
|
649
675
|
set noteName to name of n
|
|
650
676
|
set noteId to id of n
|
|
651
677
|
set noteFolder to name of container of n
|
|
652
|
-
set end of resultList to noteName &
|
|
678
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder
|
|
653
679
|
on error
|
|
654
680
|
try
|
|
655
681
|
set noteName to name of n
|
|
656
682
|
set noteId to id of n
|
|
657
|
-
set end of resultList to noteName &
|
|
683
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & "Notes"
|
|
658
684
|
end try
|
|
659
685
|
end try
|
|
660
686
|
end repeat
|
|
661
|
-
set AppleScript's text item delimiters to
|
|
687
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
662
688
|
return resultList as text
|
|
663
689
|
`;
|
|
664
690
|
const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
|
|
665
691
|
const result = executeAppleScript(script);
|
|
666
692
|
if (!result.success) {
|
|
667
|
-
|
|
668
|
-
|
|
693
|
+
// Surface the failure (#19) — an empty array would look like "no matches".
|
|
694
|
+
throw new Error(`Failed to search notes for "${query}": ${result.error ?? "unknown error"}`);
|
|
669
695
|
}
|
|
670
696
|
// Handle empty results
|
|
671
697
|
if (!result.output.trim()) {
|
|
672
698
|
return [];
|
|
673
699
|
}
|
|
674
|
-
// Parse the delimited output:
|
|
675
|
-
const items = result.output.split(
|
|
700
|
+
// Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
|
|
701
|
+
const items = result.output.split(RECORD_SEP);
|
|
676
702
|
const notes = [];
|
|
677
703
|
for (const item of items) {
|
|
678
|
-
const [title, id, folder] = item.split(
|
|
704
|
+
const [title, id, folder] = item.split(FIELD_SEP);
|
|
679
705
|
if (!title?.trim())
|
|
680
706
|
continue;
|
|
681
707
|
notes.push({
|
|
@@ -764,8 +790,11 @@ export class AppleNotesManager {
|
|
|
764
790
|
// Note IDs work at the application level, not scoped to account
|
|
765
791
|
const getCommand = `
|
|
766
792
|
set n to note id "${safeId}"
|
|
767
|
-
set
|
|
768
|
-
|
|
793
|
+
set cd to creation date of n
|
|
794
|
+
set md to modification date of n
|
|
795
|
+
set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
|
|
796
|
+
set AppleScript's text item delimiters to ${AS_FIELD_SEP}
|
|
797
|
+
return noteProps as text
|
|
769
798
|
`;
|
|
770
799
|
const script = buildAppLevelScript(getCommand);
|
|
771
800
|
const result = executeAppleScript(script);
|
|
@@ -805,8 +834,11 @@ export class AppleNotesManager {
|
|
|
805
834
|
// Fetch multiple properties at once
|
|
806
835
|
const getCommand = `
|
|
807
836
|
set n to note "${safeTitle}"
|
|
808
|
-
set
|
|
809
|
-
|
|
837
|
+
set cd to creation date of n
|
|
838
|
+
set md to modification date of n
|
|
839
|
+
set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
|
|
840
|
+
set AppleScript's text item delimiters to ${AS_FIELD_SEP}
|
|
841
|
+
return noteProps as text
|
|
810
842
|
`;
|
|
811
843
|
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
812
844
|
const result = executeAppleScript(script);
|
|
@@ -1010,38 +1042,41 @@ export class AppleNotesManager {
|
|
|
1010
1042
|
repeat with n in ${notesSource}${limitCheck}
|
|
1011
1043
|
set end of resultList to name of n
|
|
1012
1044
|
end repeat
|
|
1013
|
-
set AppleScript's text item delimiters to
|
|
1045
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1014
1046
|
return resultList as text
|
|
1015
1047
|
`;
|
|
1016
1048
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1017
1049
|
const result = executeAppleScript(script);
|
|
1018
1050
|
if (!result.success) {
|
|
1019
|
-
|
|
1020
|
-
return [];
|
|
1051
|
+
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1021
1052
|
}
|
|
1022
1053
|
if (!result.output.trim()) {
|
|
1023
1054
|
return [];
|
|
1024
1055
|
}
|
|
1025
1056
|
return result.output
|
|
1026
|
-
.split(
|
|
1057
|
+
.split(RECORD_SEP)
|
|
1027
1058
|
.map((item) => item.trim())
|
|
1028
1059
|
.filter((item) => item.length > 0);
|
|
1029
1060
|
}
|
|
1030
|
-
// Simple path: no date or limit filters
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1061
|
+
// Simple path: no date or limit filters. Coerce the name list to text with a
|
|
1062
|
+
// control-char record separator so titles containing commas don't split (#18).
|
|
1063
|
+
const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
|
|
1064
|
+
const listCommand = `
|
|
1065
|
+
set resultList to name of ${notesRef}
|
|
1066
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1067
|
+
return resultList as text
|
|
1068
|
+
`;
|
|
1038
1069
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1039
1070
|
const result = executeAppleScript(script);
|
|
1040
1071
|
if (!result.success) {
|
|
1041
|
-
|
|
1042
|
-
return [];
|
|
1072
|
+
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1043
1073
|
}
|
|
1044
|
-
|
|
1074
|
+
if (!result.output.trim())
|
|
1075
|
+
return [];
|
|
1076
|
+
return result.output
|
|
1077
|
+
.split(RECORD_SEP)
|
|
1078
|
+
.map((item) => item.trim())
|
|
1079
|
+
.filter((item) => item.length > 0);
|
|
1045
1080
|
}
|
|
1046
1081
|
/**
|
|
1047
1082
|
* Lists all shared (collaborative) notes across all accounts.
|
|
@@ -1068,10 +1103,12 @@ export class AppleNotesManager {
|
|
|
1068
1103
|
set resultList to {}
|
|
1069
1104
|
repeat with n in notes
|
|
1070
1105
|
if shared of n is true then
|
|
1071
|
-
set
|
|
1106
|
+
set cd to creation date of n
|
|
1107
|
+
set md to modification date of n
|
|
1108
|
+
set end of resultList to (name of n) & ${AS_FIELD_SEP} & (id of n) & ${AS_FIELD_SEP} & ${asDatePartsExpr("cd")} & ${AS_FIELD_SEP} & ${asDatePartsExpr("md")} & ${AS_FIELD_SEP} & (shared of n as text) & ${AS_FIELD_SEP} & (password protected of n as text)
|
|
1072
1109
|
end if
|
|
1073
1110
|
end repeat
|
|
1074
|
-
set AppleScript's text item delimiters to
|
|
1111
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1075
1112
|
return resultList as text
|
|
1076
1113
|
`);
|
|
1077
1114
|
const result = executeAppleScript(script);
|
|
@@ -1083,10 +1120,10 @@ export class AppleNotesManager {
|
|
|
1083
1120
|
if (!output) {
|
|
1084
1121
|
continue;
|
|
1085
1122
|
}
|
|
1086
|
-
// Parse delimited output:
|
|
1087
|
-
const items = output.split(
|
|
1123
|
+
// Parse control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
|
|
1124
|
+
const items = output.split(RECORD_SEP);
|
|
1088
1125
|
for (const item of items) {
|
|
1089
|
-
const parts = item.split(
|
|
1126
|
+
const parts = item.split(FIELD_SEP);
|
|
1090
1127
|
if (parts.length >= 6) {
|
|
1091
1128
|
const title = parts[0].trim();
|
|
1092
1129
|
const id = parts[1].trim();
|
|
@@ -1148,8 +1185,7 @@ export class AppleNotesManager {
|
|
|
1148
1185
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1149
1186
|
const result = executeAppleScript(script);
|
|
1150
1187
|
if (!result.success) {
|
|
1151
|
-
|
|
1152
|
-
return [];
|
|
1188
|
+
throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
|
|
1153
1189
|
}
|
|
1154
1190
|
if (!result.output.trim()) {
|
|
1155
1191
|
return [];
|
|
@@ -1374,15 +1410,22 @@ export class AppleNotesManager {
|
|
|
1374
1410
|
* @returns Array of Account objects
|
|
1375
1411
|
*/
|
|
1376
1412
|
listAccounts() {
|
|
1377
|
-
|
|
1413
|
+
// Coerce the name list to text with a control-char record separator so an
|
|
1414
|
+
// account name containing a comma can't split into phantom accounts (#18).
|
|
1415
|
+
const listCommand = `
|
|
1416
|
+
set resultList to name of accounts
|
|
1417
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1418
|
+
return resultList as text
|
|
1419
|
+
`;
|
|
1378
1420
|
const script = buildAppLevelScript(listCommand);
|
|
1379
1421
|
const result = executeAppleScript(script);
|
|
1380
1422
|
if (!result.success) {
|
|
1381
|
-
|
|
1382
|
-
return [];
|
|
1423
|
+
throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
|
|
1383
1424
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1425
|
+
const names = result.output
|
|
1426
|
+
.split(RECORD_SEP)
|
|
1427
|
+
.map((s) => s.trim())
|
|
1428
|
+
.filter((s) => s.length > 0);
|
|
1386
1429
|
return names.map((name) => ({ name }));
|
|
1387
1430
|
}
|
|
1388
1431
|
// ===========================================================================
|
|
@@ -1507,25 +1550,36 @@ export class AppleNotesManager {
|
|
|
1507
1550
|
const accounts = this.listAccounts();
|
|
1508
1551
|
const accountStats = [];
|
|
1509
1552
|
let totalNotes = 0;
|
|
1510
|
-
// Collect stats per account
|
|
1553
|
+
// Collect stats per account with ONE bounded script per account (#20/#26):
|
|
1554
|
+
// count notes server-side per folder instead of fetching every note's name
|
|
1555
|
+
// (unbounded) via a listNotes call per folder (N+1 osascript spawns).
|
|
1511
1556
|
for (const account of accounts) {
|
|
1512
|
-
const
|
|
1557
|
+
const countScript = buildAccountScopedScript({ account: account.name }, `
|
|
1558
|
+
set out to ""
|
|
1559
|
+
repeat with fldr in folders
|
|
1560
|
+
set out to out & (name of fldr) & ${AS_FIELD_SEP} & (count of notes of fldr) & ${AS_RECORD_SEP}
|
|
1561
|
+
end repeat
|
|
1562
|
+
return out
|
|
1563
|
+
`);
|
|
1564
|
+
const res = executeAppleScript(countScript);
|
|
1565
|
+
if (!res.success) {
|
|
1566
|
+
throw new Error(`Failed to read folder stats for "${account.name}": ${res.error ?? "unknown error"}`);
|
|
1567
|
+
}
|
|
1513
1568
|
const folderStats = [];
|
|
1514
1569
|
let accountTotal = 0;
|
|
1515
|
-
for (const
|
|
1516
|
-
|
|
1517
|
-
|
|
1570
|
+
for (const rec of res.output.split(RECORD_SEP)) {
|
|
1571
|
+
if (!rec.trim())
|
|
1572
|
+
continue;
|
|
1573
|
+
const [fname, cnt] = rec.split(FIELD_SEP);
|
|
1574
|
+
const noteCount = parseInt((cnt ?? "").trim(), 10) || 0;
|
|
1518
1575
|
accountTotal += noteCount;
|
|
1519
|
-
folderStats.push({
|
|
1520
|
-
name: folder.name,
|
|
1521
|
-
noteCount,
|
|
1522
|
-
});
|
|
1576
|
+
folderStats.push({ name: (fname ?? "").trim(), noteCount });
|
|
1523
1577
|
}
|
|
1524
1578
|
totalNotes += accountTotal;
|
|
1525
1579
|
accountStats.push({
|
|
1526
1580
|
name: account.name,
|
|
1527
1581
|
totalNotes: accountTotal,
|
|
1528
|
-
folderCount:
|
|
1582
|
+
folderCount: folderStats.length,
|
|
1529
1583
|
folders: folderStats,
|
|
1530
1584
|
});
|
|
1531
1585
|
}
|
|
@@ -1541,51 +1595,41 @@ export class AppleNotesManager {
|
|
|
1541
1595
|
* Helper to get counts of recently modified notes.
|
|
1542
1596
|
*/
|
|
1543
1597
|
getRecentlyModifiedCounts() {
|
|
1544
|
-
//
|
|
1598
|
+
// Count server-side with locale-safe date variables (#20/#25): instead of
|
|
1599
|
+
// streaming every note's modification date to JS (unbounded, ENOBUFS-prone,
|
|
1600
|
+
// locale-fragile), let AppleScript count matches via a `whose` filter — three
|
|
1601
|
+
// counts per account, regardless of library size.
|
|
1602
|
+
const now = new Date();
|
|
1603
|
+
const d1 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
1604
|
+
const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
1605
|
+
const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
1545
1606
|
const script = `
|
|
1546
1607
|
tell application "Notes"
|
|
1547
|
-
|
|
1608
|
+
${buildAppleScriptDateVar(d1, "d1")}
|
|
1609
|
+
${buildAppleScriptDateVar(d7, "d7")}
|
|
1610
|
+
${buildAppleScriptDateVar(d30, "d30")}
|
|
1611
|
+
set c1 to 0
|
|
1612
|
+
set c7 to 0
|
|
1613
|
+
set c30 to 0
|
|
1548
1614
|
repeat with acct in accounts
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
end repeat
|
|
1553
|
-
set output to ""
|
|
1554
|
-
repeat with d in modDates
|
|
1555
|
-
set output to output & (d as string) & "|||"
|
|
1615
|
+
set c1 to c1 + (count of (notes of acct whose modification date >= d1))
|
|
1616
|
+
set c7 to c7 + (count of (notes of acct whose modification date >= d7))
|
|
1617
|
+
set c30 to c30 + (count of (notes of acct whose modification date >= d30))
|
|
1556
1618
|
end repeat
|
|
1557
|
-
return
|
|
1619
|
+
return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
|
|
1558
1620
|
end tell
|
|
1559
1621
|
`;
|
|
1560
1622
|
const result = executeAppleScript(script);
|
|
1561
|
-
if (!result.success
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
const now = new Date();
|
|
1565
|
-
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
1566
|
-
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
1567
|
-
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
1568
|
-
let last24h = 0;
|
|
1569
|
-
let last7d = 0;
|
|
1570
|
-
let last30d = 0;
|
|
1571
|
-
const dateStrings = result.output.split("|||").filter((s) => s.trim());
|
|
1572
|
-
for (const dateStr of dateStrings) {
|
|
1573
|
-
try {
|
|
1574
|
-
const date = new Date(dateStr.trim());
|
|
1575
|
-
if (isNaN(date.getTime()))
|
|
1576
|
-
continue;
|
|
1577
|
-
if (date >= oneDayAgo)
|
|
1578
|
-
last24h++;
|
|
1579
|
-
if (date >= sevenDaysAgo)
|
|
1580
|
-
last7d++;
|
|
1581
|
-
if (date >= thirtyDaysAgo)
|
|
1582
|
-
last30d++;
|
|
1583
|
-
}
|
|
1584
|
-
catch {
|
|
1585
|
-
// Skip invalid date strings
|
|
1586
|
-
}
|
|
1623
|
+
if (!result.success) {
|
|
1624
|
+
// Surface the failure (#19) rather than reporting fake zero recent activity.
|
|
1625
|
+
throw new Error(`Failed to read recent activity: ${result.error ?? "unknown error"}`);
|
|
1587
1626
|
}
|
|
1588
|
-
|
|
1627
|
+
const parts = result.output.trim().split(FIELD_SEP);
|
|
1628
|
+
const toInt = (s) => {
|
|
1629
|
+
const n = parseInt((s ?? "").trim(), 10);
|
|
1630
|
+
return Number.isFinite(n) ? n : 0;
|
|
1631
|
+
};
|
|
1632
|
+
return { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) };
|
|
1589
1633
|
}
|
|
1590
1634
|
// ===========================================================================
|
|
1591
1635
|
// Attachments
|
|
@@ -1615,11 +1659,11 @@ export class AppleNotesManager {
|
|
|
1615
1659
|
set attachId to id of a
|
|
1616
1660
|
set attachName to name of a
|
|
1617
1661
|
set attachType to content identifier of a
|
|
1618
|
-
set end of attachmentList to attachId &
|
|
1662
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
|
|
1619
1663
|
end repeat
|
|
1620
1664
|
set output to ""
|
|
1621
1665
|
repeat with item in attachmentList
|
|
1622
|
-
set output to output & item &
|
|
1666
|
+
set output to output & item & ${AS_RECORD_SEP}
|
|
1623
1667
|
end repeat
|
|
1624
1668
|
return output
|
|
1625
1669
|
end tell
|
|
@@ -1633,9 +1677,9 @@ export class AppleNotesManager {
|
|
|
1633
1677
|
}
|
|
1634
1678
|
// Parse the results
|
|
1635
1679
|
const attachments = [];
|
|
1636
|
-
const items = result.output.split(
|
|
1680
|
+
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
1637
1681
|
for (const item of items) {
|
|
1638
|
-
const parts = item.split(
|
|
1682
|
+
const parts = item.split(FIELD_SEP);
|
|
1639
1683
|
if (parts.length >= 3) {
|
|
1640
1684
|
attachments.push({
|
|
1641
1685
|
id: parts[0].trim(),
|
|
@@ -1665,11 +1709,11 @@ export class AppleNotesManager {
|
|
|
1665
1709
|
set attachId to id of a
|
|
1666
1710
|
set attachName to name of a
|
|
1667
1711
|
set attachType to content identifier of a
|
|
1668
|
-
set end of attachmentList to attachId &
|
|
1712
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
|
|
1669
1713
|
end repeat
|
|
1670
1714
|
set output to ""
|
|
1671
1715
|
repeat with item in attachmentList
|
|
1672
|
-
set output to output & item &
|
|
1716
|
+
set output to output & item & ${AS_RECORD_SEP}
|
|
1673
1717
|
end repeat
|
|
1674
1718
|
return output
|
|
1675
1719
|
end tell
|
|
@@ -1684,9 +1728,9 @@ export class AppleNotesManager {
|
|
|
1684
1728
|
}
|
|
1685
1729
|
// Parse the results
|
|
1686
1730
|
const attachments = [];
|
|
1687
|
-
const items = result.output.split(
|
|
1731
|
+
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
1688
1732
|
for (const item of items) {
|
|
1689
|
-
const parts = item.split(
|
|
1733
|
+
const parts = item.split(FIELD_SEP);
|
|
1690
1734
|
if (parts.length >= 3) {
|
|
1691
1735
|
attachments.push({
|
|
1692
1736
|
id: parts[0].trim(),
|
|
@@ -1697,6 +1741,92 @@ export class AppleNotesManager {
|
|
|
1697
1741
|
}
|
|
1698
1742
|
return attachments;
|
|
1699
1743
|
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Saves a single attachment of a note (identified by attachment id) to a file
|
|
1746
|
+
* on disk via Notes.app's AppleScript `save` (#27).
|
|
1747
|
+
*
|
|
1748
|
+
* @param noteId - CoreData URL identifier for the note
|
|
1749
|
+
* @param attachmentId - id of the attachment (from list-attachments)
|
|
1750
|
+
* @param savePath - absolute destination file path (within home / temp / /Volumes)
|
|
1751
|
+
* @returns { success, savedPath?, name?, contentType?, error? }
|
|
1752
|
+
*/
|
|
1753
|
+
saveAttachmentById(noteId, attachmentId, savePath) {
|
|
1754
|
+
let abs;
|
|
1755
|
+
try {
|
|
1756
|
+
abs = assertSafeSavePath(savePath);
|
|
1757
|
+
}
|
|
1758
|
+
catch (e) {
|
|
1759
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1760
|
+
}
|
|
1761
|
+
const safeNoteId = sanitizeId(noteId);
|
|
1762
|
+
const safeAttId = escapeForAppleScript(attachmentId);
|
|
1763
|
+
const safePath = escapeForAppleScript(abs);
|
|
1764
|
+
const script = `
|
|
1765
|
+
tell application "Notes"
|
|
1766
|
+
set theNote to note id "${safeNoteId}"
|
|
1767
|
+
set theAttachment to missing value
|
|
1768
|
+
repeat with a in attachments of theNote
|
|
1769
|
+
if (id of a as text) is "${safeAttId}" then
|
|
1770
|
+
set theAttachment to a
|
|
1771
|
+
exit repeat
|
|
1772
|
+
end if
|
|
1773
|
+
end repeat
|
|
1774
|
+
if theAttachment is missing value then
|
|
1775
|
+
return "ERR${AS_FIELD_SEP}attachment not found"
|
|
1776
|
+
end if
|
|
1777
|
+
save theAttachment in (POSIX file "${safePath}")
|
|
1778
|
+
return "OK${AS_FIELD_SEP}" & (name of theAttachment) & "${AS_FIELD_SEP}" & (content identifier of theAttachment)
|
|
1779
|
+
end tell
|
|
1780
|
+
`;
|
|
1781
|
+
const result = executeAppleScript(script);
|
|
1782
|
+
if (!result.success) {
|
|
1783
|
+
return { success: false, error: result.error ?? "unknown error" };
|
|
1784
|
+
}
|
|
1785
|
+
const parts = (result.output ?? "").trim().split(FIELD_SEP);
|
|
1786
|
+
if (parts[0] !== "OK") {
|
|
1787
|
+
return { success: false, error: parts[1]?.trim() || "attachment not found" };
|
|
1788
|
+
}
|
|
1789
|
+
if (!existsSync(abs) || fileSize(abs) === 0) {
|
|
1790
|
+
return { success: false, error: `Notes reported success but no file was written to ${abs}` };
|
|
1791
|
+
}
|
|
1792
|
+
return {
|
|
1793
|
+
success: true,
|
|
1794
|
+
savedPath: abs,
|
|
1795
|
+
name: parts[1]?.trim(),
|
|
1796
|
+
contentType: parts[2]?.trim(),
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Fetches a note attachment as base64 (#27). Exports to a private temp file,
|
|
1801
|
+
* reads it, then deletes the temp copy.
|
|
1802
|
+
*
|
|
1803
|
+
* @param noteId - CoreData URL identifier for the note
|
|
1804
|
+
* @param attachmentId - id of the attachment
|
|
1805
|
+
* @returns { success, name?, contentType?, base64?, bytes?, error? }
|
|
1806
|
+
*/
|
|
1807
|
+
getAttachmentBase64ById(noteId, attachmentId) {
|
|
1808
|
+
const dir = makeTempDir();
|
|
1809
|
+
try {
|
|
1810
|
+
const dest = `${dir}/attachment.bin`;
|
|
1811
|
+
const saved = this.saveAttachmentById(noteId, attachmentId, dest);
|
|
1812
|
+
if (!saved.success || !saved.savedPath) {
|
|
1813
|
+
return { success: false, error: saved.error };
|
|
1814
|
+
}
|
|
1815
|
+
return {
|
|
1816
|
+
success: true,
|
|
1817
|
+
name: saved.name,
|
|
1818
|
+
contentType: saved.contentType,
|
|
1819
|
+
base64: readFileBase64(saved.savedPath),
|
|
1820
|
+
bytes: fileSize(saved.savedPath),
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
catch (e) {
|
|
1824
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
1825
|
+
}
|
|
1826
|
+
finally {
|
|
1827
|
+
cleanupTempDir(dir);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1700
1830
|
// ===========================================================================
|
|
1701
1831
|
// Batch Operations
|
|
1702
1832
|
// ===========================================================================
|