apple-notes-mcp 1.4.4 → 2.0.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 +97 -6
- package/build/index.js +96 -42
- package/build/services/appleNotesManager.js +261 -147
- package/build/services/appleNotesManager.test.js +204 -67
- 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
|
/**
|
|
@@ -208,8 +225,20 @@ export function generateFallbackId() {
|
|
|
208
225
|
* // Returns: Date object for Dec 27, 2025 3:44:02 PM
|
|
209
226
|
*/
|
|
210
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".
|
|
211
240
|
// Remove the "date " prefix if present
|
|
212
|
-
const withoutPrefix =
|
|
241
|
+
const withoutPrefix = s.replace(/^date\s+/, "");
|
|
213
242
|
// Replace " at " with a space for standard date parsing
|
|
214
243
|
// "Saturday, December 27, 2025 at 3:44:02 PM" ->
|
|
215
244
|
// "Saturday, December 27, 2025 3:44:02 PM"
|
|
@@ -246,6 +275,19 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
|
|
|
246
275
|
`set time of ${varName} to ${timeInSeconds}`,
|
|
247
276
|
].join("\n");
|
|
248
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
|
+
}
|
|
249
291
|
/**
|
|
250
292
|
* Parses AppleScript note properties output into structured data.
|
|
251
293
|
*
|
|
@@ -258,37 +300,22 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
|
|
|
258
300
|
* @returns Parsed properties, or null if format is invalid
|
|
259
301
|
*/
|
|
260
302
|
export function parseNotePropertiesOutput(output) {
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
console.error("Unexpected response format: no comma found in note properties");
|
|
268
|
-
return null;
|
|
269
|
-
}
|
|
270
|
-
const title = output.substring(0, firstComma).trim();
|
|
271
|
-
// Extract ID (between first and second comma)
|
|
272
|
-
const afterTitle = output.substring(firstComma + 1);
|
|
273
|
-
const secondComma = afterTitle.indexOf(",");
|
|
274
|
-
if (secondComma === -1) {
|
|
275
|
-
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");
|
|
276
309
|
return null;
|
|
277
310
|
}
|
|
278
|
-
const id
|
|
279
|
-
// Extract boolean values from the end (shared, passwordProtected)
|
|
280
|
-
// They appear as ", true" or ", false" at the end
|
|
281
|
-
const boolPattern = /, (true|false), (true|false)$/;
|
|
282
|
-
const boolMatch = output.match(boolPattern);
|
|
283
|
-
const shared = boolMatch ? boolMatch[1] === "true" : false;
|
|
284
|
-
const passwordProtected = boolMatch ? boolMatch[2] === "true" : false;
|
|
311
|
+
const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
|
|
285
312
|
return {
|
|
286
|
-
title,
|
|
287
|
-
id,
|
|
288
|
-
created:
|
|
289
|
-
modified:
|
|
290
|
-
shared,
|
|
291
|
-
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",
|
|
292
319
|
};
|
|
293
320
|
}
|
|
294
321
|
/**
|
|
@@ -395,23 +422,6 @@ function buildAppLevelScript(command) {
|
|
|
395
422
|
// =============================================================================
|
|
396
423
|
// Result Parsing Utilities
|
|
397
424
|
// =============================================================================
|
|
398
|
-
/**
|
|
399
|
-
* Parses a comma-separated list from AppleScript output.
|
|
400
|
-
*
|
|
401
|
-
* AppleScript often returns lists as comma-separated strings:
|
|
402
|
-
* "Note 1, Note 2, Note 3"
|
|
403
|
-
*
|
|
404
|
-
* This function splits and cleans the output.
|
|
405
|
-
*
|
|
406
|
-
* @param output - Raw AppleScript output
|
|
407
|
-
* @returns Array of trimmed, non-empty strings
|
|
408
|
-
*/
|
|
409
|
-
function parseCommaSeparatedList(output) {
|
|
410
|
-
return output
|
|
411
|
-
.split(",")
|
|
412
|
-
.map((item) => item.trim())
|
|
413
|
-
.filter((item) => item.length > 0);
|
|
414
|
-
}
|
|
415
425
|
/**
|
|
416
426
|
* Extracts a CoreData ID from AppleScript output.
|
|
417
427
|
*
|
|
@@ -624,7 +634,7 @@ export class AppleNotesManager {
|
|
|
624
634
|
*/
|
|
625
635
|
searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
|
|
626
636
|
const targetAccount = this.resolveAccount(account);
|
|
627
|
-
const safeQuery =
|
|
637
|
+
const safeQuery = escapePlainStringForAppleScript(query);
|
|
628
638
|
const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
|
|
629
639
|
// Build the where clause based on search type
|
|
630
640
|
// AppleScript uses 'name' for title and 'body' for content
|
|
@@ -665,33 +675,33 @@ export class AppleNotesManager {
|
|
|
665
675
|
set noteName to name of n
|
|
666
676
|
set noteId to id of n
|
|
667
677
|
set noteFolder to name of container of n
|
|
668
|
-
set end of resultList to noteName &
|
|
678
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder
|
|
669
679
|
on error
|
|
670
680
|
try
|
|
671
681
|
set noteName to name of n
|
|
672
682
|
set noteId to id of n
|
|
673
|
-
set end of resultList to noteName &
|
|
683
|
+
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & "Notes"
|
|
674
684
|
end try
|
|
675
685
|
end try
|
|
676
686
|
end repeat
|
|
677
|
-
set AppleScript's text item delimiters to
|
|
687
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
678
688
|
return resultList as text
|
|
679
689
|
`;
|
|
680
690
|
const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
|
|
681
691
|
const result = executeAppleScript(script);
|
|
682
692
|
if (!result.success) {
|
|
683
|
-
|
|
684
|
-
|
|
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"}`);
|
|
685
695
|
}
|
|
686
696
|
// Handle empty results
|
|
687
697
|
if (!result.output.trim()) {
|
|
688
698
|
return [];
|
|
689
699
|
}
|
|
690
|
-
// Parse the delimited output:
|
|
691
|
-
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);
|
|
692
702
|
const notes = [];
|
|
693
703
|
for (const item of items) {
|
|
694
|
-
const [title, id, folder] = item.split(
|
|
704
|
+
const [title, id, folder] = item.split(FIELD_SEP);
|
|
695
705
|
if (!title?.trim())
|
|
696
706
|
continue;
|
|
697
707
|
notes.push({
|
|
@@ -728,7 +738,7 @@ export class AppleNotesManager {
|
|
|
728
738
|
*/
|
|
729
739
|
getNoteContent(title, account) {
|
|
730
740
|
const targetAccount = this.resolveAccount(account);
|
|
731
|
-
const safeTitle =
|
|
741
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
732
742
|
// Retrieve the body property of the note
|
|
733
743
|
const getCommand = `get body of note "${safeTitle}"`;
|
|
734
744
|
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
@@ -780,8 +790,11 @@ export class AppleNotesManager {
|
|
|
780
790
|
// Note IDs work at the application level, not scoped to account
|
|
781
791
|
const getCommand = `
|
|
782
792
|
set n to note id "${safeId}"
|
|
783
|
-
set
|
|
784
|
-
|
|
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
|
|
785
798
|
`;
|
|
786
799
|
const script = buildAppLevelScript(getCommand);
|
|
787
800
|
const result = executeAppleScript(script);
|
|
@@ -817,12 +830,15 @@ export class AppleNotesManager {
|
|
|
817
830
|
*/
|
|
818
831
|
getNoteDetails(title, account) {
|
|
819
832
|
const targetAccount = this.resolveAccount(account);
|
|
820
|
-
const safeTitle =
|
|
833
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
821
834
|
// Fetch multiple properties at once
|
|
822
835
|
const getCommand = `
|
|
823
836
|
set n to note "${safeTitle}"
|
|
824
|
-
set
|
|
825
|
-
|
|
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
|
|
826
842
|
`;
|
|
827
843
|
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
828
844
|
const result = executeAppleScript(script);
|
|
@@ -859,7 +875,7 @@ export class AppleNotesManager {
|
|
|
859
875
|
*/
|
|
860
876
|
deleteNote(title, account) {
|
|
861
877
|
const targetAccount = this.resolveAccount(account);
|
|
862
|
-
const safeTitle =
|
|
878
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
863
879
|
const deleteCommand = `delete note "${safeTitle}"`;
|
|
864
880
|
const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
|
|
865
881
|
const result = executeAppleScript(script);
|
|
@@ -915,7 +931,7 @@ export class AppleNotesManager {
|
|
|
915
931
|
validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
|
|
916
932
|
validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
|
|
917
933
|
const targetAccount = this.resolveAccount(account);
|
|
918
|
-
const safeCurrentTitle =
|
|
934
|
+
const safeCurrentTitle = escapePlainStringForAppleScript(title);
|
|
919
935
|
let fullBody;
|
|
920
936
|
if (format === "html") {
|
|
921
937
|
// HTML mode: content is the complete body, escaped only for AppleScript string
|
|
@@ -1026,38 +1042,41 @@ export class AppleNotesManager {
|
|
|
1026
1042
|
repeat with n in ${notesSource}${limitCheck}
|
|
1027
1043
|
set end of resultList to name of n
|
|
1028
1044
|
end repeat
|
|
1029
|
-
set AppleScript's text item delimiters to
|
|
1045
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1030
1046
|
return resultList as text
|
|
1031
1047
|
`;
|
|
1032
1048
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1033
1049
|
const result = executeAppleScript(script);
|
|
1034
1050
|
if (!result.success) {
|
|
1035
|
-
|
|
1036
|
-
return [];
|
|
1051
|
+
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1037
1052
|
}
|
|
1038
1053
|
if (!result.output.trim()) {
|
|
1039
1054
|
return [];
|
|
1040
1055
|
}
|
|
1041
1056
|
return result.output
|
|
1042
|
-
.split(
|
|
1057
|
+
.split(RECORD_SEP)
|
|
1043
1058
|
.map((item) => item.trim())
|
|
1044
1059
|
.filter((item) => item.length > 0);
|
|
1045
1060
|
}
|
|
1046
|
-
// Simple path: no date or limit filters
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
+
`;
|
|
1054
1069
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1055
1070
|
const result = executeAppleScript(script);
|
|
1056
1071
|
if (!result.success) {
|
|
1057
|
-
|
|
1058
|
-
return [];
|
|
1072
|
+
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1059
1073
|
}
|
|
1060
|
-
|
|
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);
|
|
1061
1080
|
}
|
|
1062
1081
|
/**
|
|
1063
1082
|
* Lists all shared (collaborative) notes across all accounts.
|
|
@@ -1084,10 +1103,12 @@ export class AppleNotesManager {
|
|
|
1084
1103
|
set resultList to {}
|
|
1085
1104
|
repeat with n in notes
|
|
1086
1105
|
if shared of n is true then
|
|
1087
|
-
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)
|
|
1088
1109
|
end if
|
|
1089
1110
|
end repeat
|
|
1090
|
-
set AppleScript's text item delimiters to
|
|
1111
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1091
1112
|
return resultList as text
|
|
1092
1113
|
`);
|
|
1093
1114
|
const result = executeAppleScript(script);
|
|
@@ -1099,10 +1120,10 @@ export class AppleNotesManager {
|
|
|
1099
1120
|
if (!output) {
|
|
1100
1121
|
continue;
|
|
1101
1122
|
}
|
|
1102
|
-
// Parse delimited output:
|
|
1103
|
-
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);
|
|
1104
1125
|
for (const item of items) {
|
|
1105
|
-
const parts = item.split(
|
|
1126
|
+
const parts = item.split(FIELD_SEP);
|
|
1106
1127
|
if (parts.length >= 6) {
|
|
1107
1128
|
const title = parts[0].trim();
|
|
1108
1129
|
const id = parts[1].trim();
|
|
@@ -1164,8 +1185,7 @@ export class AppleNotesManager {
|
|
|
1164
1185
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1165
1186
|
const result = executeAppleScript(script);
|
|
1166
1187
|
if (!result.success) {
|
|
1167
|
-
|
|
1168
|
-
return [];
|
|
1188
|
+
throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
|
|
1169
1189
|
}
|
|
1170
1190
|
if (!result.output.trim()) {
|
|
1171
1191
|
return [];
|
|
@@ -1231,7 +1251,7 @@ export class AppleNotesManager {
|
|
|
1231
1251
|
continue;
|
|
1232
1252
|
}
|
|
1233
1253
|
// Folder doesn't exist — create it
|
|
1234
|
-
const segmentName =
|
|
1254
|
+
const segmentName = escapePlainStringForAppleScript(parts[i]);
|
|
1235
1255
|
let createCommand;
|
|
1236
1256
|
if (i === 0) {
|
|
1237
1257
|
createCommand = `make new folder with properties {name:"${segmentName}"}`;
|
|
@@ -1390,15 +1410,22 @@ export class AppleNotesManager {
|
|
|
1390
1410
|
* @returns Array of Account objects
|
|
1391
1411
|
*/
|
|
1392
1412
|
listAccounts() {
|
|
1393
|
-
|
|
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
|
+
`;
|
|
1394
1420
|
const script = buildAppLevelScript(listCommand);
|
|
1395
1421
|
const result = executeAppleScript(script);
|
|
1396
1422
|
if (!result.success) {
|
|
1397
|
-
|
|
1398
|
-
return [];
|
|
1423
|
+
throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
|
|
1399
1424
|
}
|
|
1400
|
-
|
|
1401
|
-
|
|
1425
|
+
const names = result.output
|
|
1426
|
+
.split(RECORD_SEP)
|
|
1427
|
+
.map((s) => s.trim())
|
|
1428
|
+
.filter((s) => s.length > 0);
|
|
1402
1429
|
return names.map((name) => ({ name }));
|
|
1403
1430
|
}
|
|
1404
1431
|
// ===========================================================================
|
|
@@ -1523,25 +1550,36 @@ export class AppleNotesManager {
|
|
|
1523
1550
|
const accounts = this.listAccounts();
|
|
1524
1551
|
const accountStats = [];
|
|
1525
1552
|
let totalNotes = 0;
|
|
1526
|
-
// 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).
|
|
1527
1556
|
for (const account of accounts) {
|
|
1528
|
-
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
|
+
}
|
|
1529
1568
|
const folderStats = [];
|
|
1530
1569
|
let accountTotal = 0;
|
|
1531
|
-
for (const
|
|
1532
|
-
|
|
1533
|
-
|
|
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;
|
|
1534
1575
|
accountTotal += noteCount;
|
|
1535
|
-
folderStats.push({
|
|
1536
|
-
name: folder.name,
|
|
1537
|
-
noteCount,
|
|
1538
|
-
});
|
|
1576
|
+
folderStats.push({ name: (fname ?? "").trim(), noteCount });
|
|
1539
1577
|
}
|
|
1540
1578
|
totalNotes += accountTotal;
|
|
1541
1579
|
accountStats.push({
|
|
1542
1580
|
name: account.name,
|
|
1543
1581
|
totalNotes: accountTotal,
|
|
1544
|
-
folderCount:
|
|
1582
|
+
folderCount: folderStats.length,
|
|
1545
1583
|
folders: folderStats,
|
|
1546
1584
|
});
|
|
1547
1585
|
}
|
|
@@ -1557,51 +1595,41 @@ export class AppleNotesManager {
|
|
|
1557
1595
|
* Helper to get counts of recently modified notes.
|
|
1558
1596
|
*/
|
|
1559
1597
|
getRecentlyModifiedCounts() {
|
|
1560
|
-
//
|
|
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);
|
|
1561
1606
|
const script = `
|
|
1562
1607
|
tell application "Notes"
|
|
1563
|
-
|
|
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
|
|
1564
1614
|
repeat with acct in accounts
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
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))
|
|
1568
1618
|
end repeat
|
|
1569
|
-
|
|
1570
|
-
repeat with d in modDates
|
|
1571
|
-
set output to output & (d as string) & "|||"
|
|
1572
|
-
end repeat
|
|
1573
|
-
return output
|
|
1619
|
+
return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
|
|
1574
1620
|
end tell
|
|
1575
1621
|
`;
|
|
1576
1622
|
const result = executeAppleScript(script);
|
|
1577
|
-
if (!result.success
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const now = new Date();
|
|
1581
|
-
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
1582
|
-
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
1583
|
-
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
1584
|
-
let last24h = 0;
|
|
1585
|
-
let last7d = 0;
|
|
1586
|
-
let last30d = 0;
|
|
1587
|
-
const dateStrings = result.output.split("|||").filter((s) => s.trim());
|
|
1588
|
-
for (const dateStr of dateStrings) {
|
|
1589
|
-
try {
|
|
1590
|
-
const date = new Date(dateStr.trim());
|
|
1591
|
-
if (isNaN(date.getTime()))
|
|
1592
|
-
continue;
|
|
1593
|
-
if (date >= oneDayAgo)
|
|
1594
|
-
last24h++;
|
|
1595
|
-
if (date >= sevenDaysAgo)
|
|
1596
|
-
last7d++;
|
|
1597
|
-
if (date >= thirtyDaysAgo)
|
|
1598
|
-
last30d++;
|
|
1599
|
-
}
|
|
1600
|
-
catch {
|
|
1601
|
-
// Skip invalid date strings
|
|
1602
|
-
}
|
|
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"}`);
|
|
1603
1626
|
}
|
|
1604
|
-
|
|
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]) };
|
|
1605
1633
|
}
|
|
1606
1634
|
// ===========================================================================
|
|
1607
1635
|
// Attachments
|
|
@@ -1631,11 +1659,11 @@ export class AppleNotesManager {
|
|
|
1631
1659
|
set attachId to id of a
|
|
1632
1660
|
set attachName to name of a
|
|
1633
1661
|
set attachType to content identifier of a
|
|
1634
|
-
set end of attachmentList to attachId &
|
|
1662
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
|
|
1635
1663
|
end repeat
|
|
1636
1664
|
set output to ""
|
|
1637
1665
|
repeat with item in attachmentList
|
|
1638
|
-
set output to output & item &
|
|
1666
|
+
set output to output & item & ${AS_RECORD_SEP}
|
|
1639
1667
|
end repeat
|
|
1640
1668
|
return output
|
|
1641
1669
|
end tell
|
|
@@ -1649,9 +1677,9 @@ export class AppleNotesManager {
|
|
|
1649
1677
|
}
|
|
1650
1678
|
// Parse the results
|
|
1651
1679
|
const attachments = [];
|
|
1652
|
-
const items = result.output.split(
|
|
1680
|
+
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
1653
1681
|
for (const item of items) {
|
|
1654
|
-
const parts = item.split(
|
|
1682
|
+
const parts = item.split(FIELD_SEP);
|
|
1655
1683
|
if (parts.length >= 3) {
|
|
1656
1684
|
attachments.push({
|
|
1657
1685
|
id: parts[0].trim(),
|
|
@@ -1671,7 +1699,7 @@ export class AppleNotesManager {
|
|
|
1671
1699
|
*/
|
|
1672
1700
|
listAttachments(title, account) {
|
|
1673
1701
|
const targetAccount = this.resolveAccount(account);
|
|
1674
|
-
const safeTitle =
|
|
1702
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
1675
1703
|
const script = `
|
|
1676
1704
|
tell application "Notes"
|
|
1677
1705
|
tell account "${targetAccount}"
|
|
@@ -1681,11 +1709,11 @@ export class AppleNotesManager {
|
|
|
1681
1709
|
set attachId to id of a
|
|
1682
1710
|
set attachName to name of a
|
|
1683
1711
|
set attachType to content identifier of a
|
|
1684
|
-
set end of attachmentList to attachId &
|
|
1712
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
|
|
1685
1713
|
end repeat
|
|
1686
1714
|
set output to ""
|
|
1687
1715
|
repeat with item in attachmentList
|
|
1688
|
-
set output to output & item &
|
|
1716
|
+
set output to output & item & ${AS_RECORD_SEP}
|
|
1689
1717
|
end repeat
|
|
1690
1718
|
return output
|
|
1691
1719
|
end tell
|
|
@@ -1700,9 +1728,9 @@ export class AppleNotesManager {
|
|
|
1700
1728
|
}
|
|
1701
1729
|
// Parse the results
|
|
1702
1730
|
const attachments = [];
|
|
1703
|
-
const items = result.output.split(
|
|
1731
|
+
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
1704
1732
|
for (const item of items) {
|
|
1705
|
-
const parts = item.split(
|
|
1733
|
+
const parts = item.split(FIELD_SEP);
|
|
1706
1734
|
if (parts.length >= 3) {
|
|
1707
1735
|
attachments.push({
|
|
1708
1736
|
id: parts[0].trim(),
|
|
@@ -1713,6 +1741,92 @@ export class AppleNotesManager {
|
|
|
1713
1741
|
}
|
|
1714
1742
|
return attachments;
|
|
1715
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 = escapePlainStringForAppleScript(attachmentId);
|
|
1763
|
+
const safePath = escapePlainStringForAppleScript(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
|
+
}
|
|
1716
1830
|
// ===========================================================================
|
|
1717
1831
|
// Batch Operations
|
|
1718
1832
|
// ===========================================================================
|