apple-notes-mcp 2.5.7 → 2.5.9
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 +9 -5
- package/build/index.js +42755 -1080
- package/package.json +3 -3
- package/build/index.test.js +0 -446
- package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
- package/build/services/appleNotesManager.js +0 -2634
- package/build/services/appleNotesManager.test.js +0 -2416
- package/build/services/attachmentSave.test.js +0 -85
- package/build/services/fileConfig.js +0 -51
- package/build/services/fileConfig.test.js +0 -48
- package/build/services/notesHtmlMarkdown.test.js +0 -55
- package/build/tools/doctor.js +0 -50
- package/build/tools/doctor.test.js +0 -42
- package/build/tools/resourcesAndPrompts.js +0 -70
- package/build/tools/resourcesAndPrompts.test.js +0 -63
- package/build/types.js +0 -13
- package/build/utils/applescript.js +0 -421
- package/build/utils/applescript.test.js +0 -342
- package/build/utils/attachmentFs.js +0 -97
- package/build/utils/attachmentFs.test.js +0 -69
- package/build/utils/checklistParser.js +0 -259
- package/build/utils/checklistParser.test.js +0 -230
- package/build/utils/contentWarnings.js +0 -44
- package/build/utils/contentWarnings.test.js +0 -52
- package/build/utils/hashtags.js +0 -56
- package/build/utils/hashtags.test.js +0 -45
- package/build/utils/jxa.js +0 -139
- package/build/utils/jxa.test.js +0 -134
- package/build/utils/noteMetadata.js +0 -135
- package/build/utils/noteMetadata.test.js +0 -106
- package/build/utils/protobuf.js +0 -151
- package/build/utils/protobuf.test.js +0 -138
- package/build/utils/syncDetection.js +0 -242
- package/build/utils/syncDetection.test.js +0 -228
|
@@ -1,2634 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Apple Notes Manager
|
|
3
|
-
*
|
|
4
|
-
* A comprehensive service for managing Apple Notes through AppleScript.
|
|
5
|
-
* This module provides a clean TypeScript interface over the Notes.app
|
|
6
|
-
* AppleScript dictionary, handling all the complexity of script generation,
|
|
7
|
-
* text escaping, and result parsing.
|
|
8
|
-
*
|
|
9
|
-
* Architecture:
|
|
10
|
-
* - Text escaping is handled by dedicated helper functions
|
|
11
|
-
* - AppleScript generation uses template builders for consistency
|
|
12
|
-
* - All public methods return typed results (no raw strings)
|
|
13
|
-
* - Error handling is consistent across all operations
|
|
14
|
-
*
|
|
15
|
-
* @module services/appleNotesManager
|
|
16
|
-
*/
|
|
17
|
-
import { executeAppleScript } from "../utils/applescript.js";
|
|
18
|
-
import { getChecklistItems } from "../utils/checklistParser.js";
|
|
19
|
-
import { assertSafeSavePath, readFileBase64Capped, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
|
|
20
|
-
import { existsSync } from "fs";
|
|
21
|
-
import TurndownService from "turndown";
|
|
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
|
-
// =============================================================================
|
|
38
|
-
// Text Processing Utilities
|
|
39
|
-
// =============================================================================
|
|
40
|
-
/**
|
|
41
|
-
* Escapes text for safe embedding in AppleScript string literals.
|
|
42
|
-
*
|
|
43
|
-
* AppleScript strings use double quotes, so we need to escape:
|
|
44
|
-
* 1. Double quotes (") - escaped as \"
|
|
45
|
-
* 2. Backslashes (\) - already handled by shell escaping
|
|
46
|
-
*
|
|
47
|
-
* Additionally, since our AppleScript is passed through the shell via
|
|
48
|
-
* `osascript -e '...'`, we need to handle single quotes in the content.
|
|
49
|
-
*
|
|
50
|
-
* Finally, Apple Notes uses HTML internally, so we convert control
|
|
51
|
-
* characters to their HTML equivalents.
|
|
52
|
-
*
|
|
53
|
-
* @param text - Raw text to escape
|
|
54
|
-
* @returns Text safe for AppleScript string embedding
|
|
55
|
-
*
|
|
56
|
-
* @example
|
|
57
|
-
* escapeForAppleScript("Hello \"World\"")
|
|
58
|
-
* // Returns: Hello \"World\"
|
|
59
|
-
*
|
|
60
|
-
* escapeForAppleScript("Line 1\nLine 2")
|
|
61
|
-
* // Returns: Line 1<br>Line 2
|
|
62
|
-
*/
|
|
63
|
-
export function escapeForAppleScript(text) {
|
|
64
|
-
// Guard against null/undefined - return empty string
|
|
65
|
-
if (!text) {
|
|
66
|
-
return "";
|
|
67
|
-
}
|
|
68
|
-
// Content goes inside AppleScript double-quoted strings: body:"content here"
|
|
69
|
-
// Within double-quoted AppleScript strings, we need to escape:
|
|
70
|
-
// 1. Backslashes (\ → \\) - AppleScript escape character
|
|
71
|
-
// 2. Double quotes (" → \") - String delimiter
|
|
72
|
-
// Single quotes do NOT need escaping in double-quoted AppleScript strings.
|
|
73
|
-
// Step 1: Encode HTML ampersands FIRST (before adding any HTML entities)
|
|
74
|
-
let escaped = text.replace(/&/g, "&");
|
|
75
|
-
// Step 2: Encode backslashes as HTML entities
|
|
76
|
-
// This avoids AppleScript escaping issues since Notes stores HTML
|
|
77
|
-
// Must happen AFTER ampersand encoding (so \ doesn't become &#92;)
|
|
78
|
-
// and BEFORE double-quote escaping (so \" doesn't become \")
|
|
79
|
-
escaped = escaped.replace(/\\/g, "\");
|
|
80
|
-
// Step 3: Escape double quotes for AppleScript strings
|
|
81
|
-
// The backslash in \" is for AppleScript, not content, so it's added AFTER
|
|
82
|
-
// backslash encoding to avoid being HTML-encoded
|
|
83
|
-
escaped = escaped.replace(/"/g, '\\"');
|
|
84
|
-
// Step 4: Convert control characters to HTML for Notes.app
|
|
85
|
-
// - Newlines (\n) to <br> tags
|
|
86
|
-
// - Tabs (\t) to <br> tags (better than for readability)
|
|
87
|
-
escaped = escaped.replace(/\n/g, "<br>");
|
|
88
|
-
escaped = escaped.replace(/\t/g, "<br>");
|
|
89
|
-
return escaped;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Escapes already-HTML content for embedding in AppleScript string literals.
|
|
93
|
-
*
|
|
94
|
-
* Unlike escapeForAppleScript(), this function is designed for content that
|
|
95
|
-
* is already HTML (e.g., from getNoteContent()). It only escapes the
|
|
96
|
-
* AppleScript string delimiter (double quotes) and handles backslashes,
|
|
97
|
-
* without re-encoding HTML entities.
|
|
98
|
-
*
|
|
99
|
-
* @param htmlContent - HTML content from Notes.app
|
|
100
|
-
* @returns Content safe for AppleScript string embedding
|
|
101
|
-
*
|
|
102
|
-
* @example
|
|
103
|
-
* escapeHtmlForAppleScript('<div>Hello "World"</div>')
|
|
104
|
-
* // Returns: <div>Hello \"World\"</div>
|
|
105
|
-
*/
|
|
106
|
-
export function escapeHtmlForAppleScript(htmlContent) {
|
|
107
|
-
if (!htmlContent) {
|
|
108
|
-
return "";
|
|
109
|
-
}
|
|
110
|
-
// For already-HTML content, we only need to:
|
|
111
|
-
// 1. Escape backslashes for AppleScript (\ → \\)
|
|
112
|
-
// 2. Escape double quotes for AppleScript (" → \")
|
|
113
|
-
//
|
|
114
|
-
// We do NOT re-encode HTML entities since content is already HTML from Notes.app
|
|
115
|
-
return htmlContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
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
|
-
}
|
|
133
|
-
// =============================================================================
|
|
134
|
-
// Input Validation & Sanitization
|
|
135
|
-
// =============================================================================
|
|
136
|
-
/** Maximum allowed length for note titles */
|
|
137
|
-
const MAX_TITLE_LENGTH = 2000;
|
|
138
|
-
/** Maximum allowed length for note content (5 MB of text) */
|
|
139
|
-
const MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
|
|
140
|
-
/** Maximum allowed length for folder names/paths */
|
|
141
|
-
const MAX_FOLDER_PATH_LENGTH = 1000;
|
|
142
|
-
/** Maximum allowed length for account names */
|
|
143
|
-
const MAX_ACCOUNT_LENGTH = 200;
|
|
144
|
-
/** Maximum nesting depth for folder paths */
|
|
145
|
-
const MAX_FOLDER_DEPTH = 20;
|
|
146
|
-
/**
|
|
147
|
-
* Validates and constrains string input length.
|
|
148
|
-
*
|
|
149
|
-
* @param value - The input string
|
|
150
|
-
* @param maxLength - Maximum allowed length
|
|
151
|
-
* @param label - Human-readable label for error messages
|
|
152
|
-
* @returns The validated string
|
|
153
|
-
* @throws Error if input exceeds maximum length
|
|
154
|
-
*/
|
|
155
|
-
function validateLength(value, maxLength, label) {
|
|
156
|
-
if (value.length > maxLength) {
|
|
157
|
-
throw new Error(`${label} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
|
|
158
|
-
}
|
|
159
|
-
return value;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Sanitizes a CoreData ID for safe embedding in AppleScript.
|
|
163
|
-
*
|
|
164
|
-
* CoreData IDs follow the pattern: x-coredata://UUID/ICNote/pNNN
|
|
165
|
-
* This function validates the format and escapes the value for AppleScript.
|
|
166
|
-
*
|
|
167
|
-
* @param id - CoreData URL identifier
|
|
168
|
-
* @returns Escaped ID safe for AppleScript string embedding
|
|
169
|
-
* @throws Error if ID format is invalid
|
|
170
|
-
*/
|
|
171
|
-
export function sanitizeId(id) {
|
|
172
|
-
// CoreData IDs should match: x-coredata://hex-hex-hex-hex-hex/ICEntity/pDigits
|
|
173
|
-
// or temp-timestamp-counter format from generateFallbackId()
|
|
174
|
-
const coreDataPattern = /^x-coredata:\/\/[0-9A-Fa-f-]+\/IC[A-Za-z]+\/p\d+$/;
|
|
175
|
-
const tempIdPattern = /^temp-\d+-\d+$/;
|
|
176
|
-
if (!coreDataPattern.test(id) && !tempIdPattern.test(id)) {
|
|
177
|
-
throw new Error(`Invalid note ID format: "${id.substring(0, 80)}". Expected CoreData URL (x-coredata://...) or temp ID.`);
|
|
178
|
-
}
|
|
179
|
-
// Even with validation, escape for defense-in-depth
|
|
180
|
-
return escapeForAppleScript(id);
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Sanitizes an account name for safe embedding in AppleScript.
|
|
184
|
-
*
|
|
185
|
-
* @param account - Account name string
|
|
186
|
-
* @returns Escaped account name safe for AppleScript string embedding
|
|
187
|
-
*/
|
|
188
|
-
function sanitizeAccountName(account) {
|
|
189
|
-
validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
|
|
190
|
-
return escapePlainStringForAppleScript(account);
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Counter for generating unique fallback IDs within the same millisecond.
|
|
194
|
-
*/
|
|
195
|
-
let fallbackIdCounter = 0;
|
|
196
|
-
/**
|
|
197
|
-
* Generates a unique fallback ID when AppleScript doesn't return a valid ID.
|
|
198
|
-
*
|
|
199
|
-
* This creates a temporary ID that's unique within this session. Format:
|
|
200
|
-
* "temp-{timestamp}-{counter}"
|
|
201
|
-
*
|
|
202
|
-
* @returns A unique temporary ID string
|
|
203
|
-
*
|
|
204
|
-
* @example
|
|
205
|
-
* generateFallbackId() // Returns: "temp-1704067200000-0"
|
|
206
|
-
* generateFallbackId() // Returns: "temp-1704067200000-1"
|
|
207
|
-
*/
|
|
208
|
-
export function generateFallbackId() {
|
|
209
|
-
return `temp-${Date.now()}-${fallbackIdCounter++}`;
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Converts AppleScript date representation to JavaScript Date.
|
|
213
|
-
*
|
|
214
|
-
* AppleScript returns dates in a verbose format like:
|
|
215
|
-
* "date Saturday, December 27, 2025 at 3:44:02 PM"
|
|
216
|
-
*
|
|
217
|
-
* This function extracts the parseable portion and converts it
|
|
218
|
-
* to a JavaScript Date object.
|
|
219
|
-
*
|
|
220
|
-
* @param appleScriptDate - Date string from AppleScript
|
|
221
|
-
* @returns Parsed Date, or current date if parsing fails
|
|
222
|
-
*
|
|
223
|
-
* @example
|
|
224
|
-
* parseAppleScriptDate("date Saturday, December 27, 2025 at 3:44:02 PM")
|
|
225
|
-
* // Returns: Date object for Dec 27, 2025 3:44:02 PM
|
|
226
|
-
*/
|
|
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".
|
|
240
|
-
// Remove the "date " prefix if present
|
|
241
|
-
const withoutPrefix = s.replace(/^date\s+/, "");
|
|
242
|
-
// Replace " at " with a space for standard date parsing
|
|
243
|
-
// "Saturday, December 27, 2025 at 3:44:02 PM" ->
|
|
244
|
-
// "Saturday, December 27, 2025 3:44:02 PM"
|
|
245
|
-
const normalized = withoutPrefix.replace(" at ", " ");
|
|
246
|
-
// Attempt to parse - JavaScript's Date constructor handles this format
|
|
247
|
-
const parsed = new Date(normalized);
|
|
248
|
-
// Return parsed date if valid, otherwise current date as fallback
|
|
249
|
-
return isNaN(parsed.getTime()) ? new Date() : parsed;
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Generates AppleScript code that creates a date variable with the given values.
|
|
253
|
-
*
|
|
254
|
-
* This approach is locale-independent, unlike `date "M/D/YYYY"` coercion which
|
|
255
|
-
* depends on the system's date format settings and would fail on non-US locales.
|
|
256
|
-
*
|
|
257
|
-
* @param date - JavaScript Date object
|
|
258
|
-
* @param varName - AppleScript variable name to assign (default: "thresholdDate")
|
|
259
|
-
* @returns AppleScript code that sets up the date variable
|
|
260
|
-
*
|
|
261
|
-
* @example
|
|
262
|
-
* buildAppleScriptDateVar(new Date("2025-06-15T00:00:00"))
|
|
263
|
-
* // Returns multi-line AppleScript that sets thresholdDate to June 15, 2025 midnight
|
|
264
|
-
*/
|
|
265
|
-
export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
|
|
266
|
-
const year = date.getFullYear();
|
|
267
|
-
const month = date.getMonth() + 1;
|
|
268
|
-
const day = date.getDate();
|
|
269
|
-
const timeInSeconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
|
|
270
|
-
return [
|
|
271
|
-
`set ${varName} to current date`,
|
|
272
|
-
`set year of ${varName} to ${year}`,
|
|
273
|
-
`set month of ${varName} to ${month}`,
|
|
274
|
-
`set day of ${varName} to ${day}`,
|
|
275
|
-
`set time of ${varName} to ${timeInSeconds}`,
|
|
276
|
-
].join("\n");
|
|
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
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Parses AppleScript note properties output into structured data.
|
|
293
|
-
*
|
|
294
|
-
* AppleScript returns note properties in a format like:
|
|
295
|
-
* "title, id, date DayName, Month Day, Year at Time, date..., bool, bool"
|
|
296
|
-
*
|
|
297
|
-
* Dates contain commas, so we use regex to extract them safely.
|
|
298
|
-
*
|
|
299
|
-
* @param output - Raw AppleScript output string
|
|
300
|
-
* @returns Parsed properties, or null if format is invalid
|
|
301
|
-
*/
|
|
302
|
-
export function parseNotePropertiesOutput(output) {
|
|
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");
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
|
|
312
|
-
return {
|
|
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",
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Splits a folder path on unescaped `/` separators.
|
|
323
|
-
*
|
|
324
|
-
* Folder names may contain literal slashes (e.g., "Spain/Portugal 2023").
|
|
325
|
-
* In path strings these are escaped as `\/`. This function splits only on
|
|
326
|
-
* unescaped `/` and restores the literal slashes in each segment.
|
|
327
|
-
*
|
|
328
|
-
* @param folderPath - Folder path with `/` as hierarchy separator and `\/` for literal slashes
|
|
329
|
-
* @returns Array of folder name segments
|
|
330
|
-
*/
|
|
331
|
-
export function splitFolderPath(folderPath) {
|
|
332
|
-
// Split on `/` that is NOT preceded by `\`
|
|
333
|
-
// We use a negative lookbehind to avoid splitting on escaped slashes
|
|
334
|
-
const parts = folderPath.split(/(?<!\\)\//);
|
|
335
|
-
// Unescape `\/` → `/` in each segment
|
|
336
|
-
return parts.map((p) => p.replace(/\\\//g, "/")).filter((p) => p.length > 0);
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Escapes literal slashes in a folder name for use in path strings.
|
|
340
|
-
*
|
|
341
|
-
* @param name - Raw folder name (may contain `/`)
|
|
342
|
-
* @returns Folder name with `/` escaped as `\/`
|
|
343
|
-
*/
|
|
344
|
-
function escapeFolderName(name) {
|
|
345
|
-
return name.replace(/\//g, "\\/");
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* Builds an AppleScript folder reference from a path string.
|
|
349
|
-
*
|
|
350
|
-
* Converts a folder path like "Work/Clients/Omnia" into the nested
|
|
351
|
-
* AppleScript syntax: `folder "Omnia" of folder "Clients" of folder "Work"`.
|
|
352
|
-
*
|
|
353
|
-
* A simple folder name like "Work" returns `folder "Work"`.
|
|
354
|
-
* Literal slashes in folder names must be escaped as `\/` (e.g., "Travel/Spain\/Portugal").
|
|
355
|
-
*
|
|
356
|
-
* @param folderPath - Folder name or slash-separated path (e.g., "Work/Clients")
|
|
357
|
-
* @returns AppleScript folder reference string
|
|
358
|
-
*/
|
|
359
|
-
export function buildFolderReference(folderPath) {
|
|
360
|
-
validateLength(folderPath, MAX_FOLDER_PATH_LENGTH, "Folder path");
|
|
361
|
-
const parts = splitFolderPath(folderPath);
|
|
362
|
-
if (parts.length > MAX_FOLDER_DEPTH) {
|
|
363
|
-
throw new Error(`Folder path exceeds maximum nesting depth of ${MAX_FOLDER_DEPTH} (got ${parts.length})`);
|
|
364
|
-
}
|
|
365
|
-
if (parts.length === 0) {
|
|
366
|
-
throw new Error("Folder path is empty");
|
|
367
|
-
}
|
|
368
|
-
// Build inside-out: last part is innermost, first part is outermost
|
|
369
|
-
return parts
|
|
370
|
-
.reverse()
|
|
371
|
-
.map((part) => `folder "${escapePlainStringForAppleScript(part)}"`)
|
|
372
|
-
.join(" of ");
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Builds an AppleScript command wrapped in account context.
|
|
376
|
-
*
|
|
377
|
-
* Most Notes.app operations need to be scoped to an account:
|
|
378
|
-
* ```applescript
|
|
379
|
-
* tell application "Notes"
|
|
380
|
-
* tell account "iCloud"
|
|
381
|
-
* -- command here
|
|
382
|
-
* end tell
|
|
383
|
-
* end tell
|
|
384
|
-
* ```
|
|
385
|
-
*
|
|
386
|
-
* This builder generates that wrapper structure.
|
|
387
|
-
*
|
|
388
|
-
* @param scope - Account to target
|
|
389
|
-
* @param command - The AppleScript command to execute
|
|
390
|
-
* @returns Complete AppleScript ready for execution
|
|
391
|
-
*/
|
|
392
|
-
function buildAccountScopedScript(scope, command) {
|
|
393
|
-
const safeAccount = sanitizeAccountName(scope.account);
|
|
394
|
-
return `
|
|
395
|
-
tell application "Notes"
|
|
396
|
-
tell account "${safeAccount}"
|
|
397
|
-
${command}
|
|
398
|
-
end tell
|
|
399
|
-
end tell
|
|
400
|
-
`;
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Builds an AppleScript command at the application level.
|
|
404
|
-
*
|
|
405
|
-
* Some operations (like listing accounts) don't need account scoping:
|
|
406
|
-
* ```applescript
|
|
407
|
-
* tell application "Notes"
|
|
408
|
-
* -- command here
|
|
409
|
-
* end tell
|
|
410
|
-
* ```
|
|
411
|
-
*
|
|
412
|
-
* @param command - The AppleScript command to execute
|
|
413
|
-
* @returns Complete AppleScript ready for execution
|
|
414
|
-
*/
|
|
415
|
-
function buildAppLevelScript(command) {
|
|
416
|
-
return `
|
|
417
|
-
tell application "Notes"
|
|
418
|
-
${command}
|
|
419
|
-
end tell
|
|
420
|
-
`;
|
|
421
|
-
}
|
|
422
|
-
// =============================================================================
|
|
423
|
-
// Result Parsing Utilities
|
|
424
|
-
// =============================================================================
|
|
425
|
-
/**
|
|
426
|
-
* Extracts a CoreData ID from AppleScript output.
|
|
427
|
-
*
|
|
428
|
-
* Notes.app uses CoreData URLs as unique identifiers:
|
|
429
|
-
* "note id x-coredata://ABC123-DEF456/ICNote/p789"
|
|
430
|
-
*
|
|
431
|
-
* This function extracts the ID portion.
|
|
432
|
-
*
|
|
433
|
-
* @param output - AppleScript output containing an ID reference
|
|
434
|
-
* @param prefix - The object type prefix (e.g., "note", "folder")
|
|
435
|
-
* @returns Extracted ID or empty string
|
|
436
|
-
*/
|
|
437
|
-
function extractCoreDataId(output, prefix) {
|
|
438
|
-
const pattern = new RegExp(`${prefix} id ([^\\s]+)`);
|
|
439
|
-
const match = output.match(pattern);
|
|
440
|
-
return match ? match[1] : "";
|
|
441
|
-
}
|
|
442
|
-
// =============================================================================
|
|
443
|
-
// Apple Notes Manager Class
|
|
444
|
-
// =============================================================================
|
|
445
|
-
/**
|
|
446
|
-
* Manages interactions with Apple Notes via AppleScript.
|
|
447
|
-
*
|
|
448
|
-
* This class provides a high-level TypeScript interface for all
|
|
449
|
-
* Notes.app operations. It handles:
|
|
450
|
-
*
|
|
451
|
-
* - Note CRUD operations (create, read, update, delete)
|
|
452
|
-
* - Note organization (folders, moving between folders)
|
|
453
|
-
* - Multi-account support (iCloud, Gmail, Exchange, etc.)
|
|
454
|
-
* - Search functionality (by title or content)
|
|
455
|
-
*
|
|
456
|
-
* All operations are synchronous since they rely on AppleScript
|
|
457
|
-
* execution via osascript. Error handling is consistent: methods
|
|
458
|
-
* return null/false/empty-array on failure rather than throwing.
|
|
459
|
-
*
|
|
460
|
-
* @example
|
|
461
|
-
* ```typescript
|
|
462
|
-
* const notes = new AppleNotesManager();
|
|
463
|
-
*
|
|
464
|
-
* // Create a note in the default (iCloud) account
|
|
465
|
-
* const note = notes.createNote("Shopping List", "Eggs, Milk, Bread");
|
|
466
|
-
*
|
|
467
|
-
* // Search across all notes
|
|
468
|
-
* const results = notes.searchNotes("shopping", true); // searches content
|
|
469
|
-
*
|
|
470
|
-
* // Work with a different account
|
|
471
|
-
* const gmailNotes = notes.listNotes("Gmail");
|
|
472
|
-
* ```
|
|
473
|
-
*/
|
|
474
|
-
export class AppleNotesManager {
|
|
475
|
-
/**
|
|
476
|
-
* Default account used when no account is specified.
|
|
477
|
-
* iCloud is the primary account for most Apple Notes users.
|
|
478
|
-
*/
|
|
479
|
-
defaultAccount = "iCloud";
|
|
480
|
-
/**
|
|
481
|
-
* Resolves the account to use for an operation.
|
|
482
|
-
* Falls back to default if not specified.
|
|
483
|
-
*/
|
|
484
|
-
resolveAccount(account) {
|
|
485
|
-
return account || this.defaultAccount;
|
|
486
|
-
}
|
|
487
|
-
/**
|
|
488
|
-
* Checks if a note is password-protected by its ID.
|
|
489
|
-
*
|
|
490
|
-
* Password-protected notes cannot have their content read or modified
|
|
491
|
-
* via AppleScript when locked. This method allows checking before
|
|
492
|
-
* attempting operations that would fail.
|
|
493
|
-
*
|
|
494
|
-
* @param id - CoreData URL identifier for the note
|
|
495
|
-
* @returns true if the note is password-protected, false otherwise
|
|
496
|
-
*/
|
|
497
|
-
isNotePasswordProtectedById(id) {
|
|
498
|
-
const note = this.getNoteById(id);
|
|
499
|
-
return note?.passwordProtected === true;
|
|
500
|
-
}
|
|
501
|
-
/**
|
|
502
|
-
* Checks if a note is password-protected by its title.
|
|
503
|
-
*
|
|
504
|
-
* @param title - Exact title of the note
|
|
505
|
-
* @param account - Account to search in (defaults to iCloud)
|
|
506
|
-
* @returns true if the note is password-protected, false otherwise
|
|
507
|
-
*/
|
|
508
|
-
isNotePasswordProtected(title, account) {
|
|
509
|
-
const note = this.getNoteDetails(title, account);
|
|
510
|
-
return note?.passwordProtected === true;
|
|
511
|
-
}
|
|
512
|
-
// ===========================================================================
|
|
513
|
-
// Note Operations
|
|
514
|
-
// ===========================================================================
|
|
515
|
-
/**
|
|
516
|
-
* Creates a new note in Apple Notes.
|
|
517
|
-
*
|
|
518
|
-
* The note is created with the specified title and content. If a folder
|
|
519
|
-
* is specified, the note is created in that folder; otherwise it goes
|
|
520
|
-
* to the account's default location.
|
|
521
|
-
*
|
|
522
|
-
* @param title - Display title for the note
|
|
523
|
-
* @param content - Body content (plain text that will be HTML-escaped, or raw HTML when format is "html")
|
|
524
|
-
* @param tags - Optional tags (stored in returned object, not used by Notes.app)
|
|
525
|
-
* @param folder - Optional folder name to create the note in
|
|
526
|
-
* @param account - Account to use (defaults to iCloud)
|
|
527
|
-
* @param format - Content format: "plaintext" escapes and wraps in div tags (default), "html" uses content as-is
|
|
528
|
-
* @returns Created Note object with metadata, or null on failure
|
|
529
|
-
*
|
|
530
|
-
* @example
|
|
531
|
-
* ```typescript
|
|
532
|
-
* // Simple note creation
|
|
533
|
-
* const note = manager.createNote("Meeting Notes", "Discussed Q4 plans");
|
|
534
|
-
*
|
|
535
|
-
* // Create in a specific folder
|
|
536
|
-
* const work = manager.createNote("Task List", "1. Review PR", [], "Work");
|
|
537
|
-
*
|
|
538
|
-
* // Create in a different account
|
|
539
|
-
* const gmail = manager.createNote("Draft", "...", [], undefined, "Gmail");
|
|
540
|
-
*
|
|
541
|
-
* // Create with HTML formatting (no need for <h1> — title is auto-prepended)
|
|
542
|
-
* const html = manager.createNote("Report", "<p>Details here</p>",
|
|
543
|
-
* [], undefined, undefined, "html");
|
|
544
|
-
* ```
|
|
545
|
-
*/
|
|
546
|
-
createNote(title, content, tags = [], folder, account, format = "plaintext") {
|
|
547
|
-
validateLength(title, MAX_TITLE_LENGTH, "Note title");
|
|
548
|
-
validateLength(content, MAX_CONTENT_LENGTH, "Note content");
|
|
549
|
-
const targetAccount = this.resolveAccount(account);
|
|
550
|
-
// Build body HTML: title as <h1>, content follows.
|
|
551
|
-
// We set only 'body' (not 'name') to avoid title duplication —
|
|
552
|
-
// Notes.app auto-uses the first line of body as the note's display title.
|
|
553
|
-
const htmlTitle = title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
554
|
-
const bodyContent = format === "html"
|
|
555
|
-
? content
|
|
556
|
-
: content
|
|
557
|
-
.replace(/&/g, "&")
|
|
558
|
-
.replace(/\\/g, "\")
|
|
559
|
-
.replace(/</g, "<")
|
|
560
|
-
.replace(/>/g, ">")
|
|
561
|
-
.replace(/\n/g, "<br>")
|
|
562
|
-
.replace(/\t/g, "<br>");
|
|
563
|
-
const safeBody = escapeHtmlForAppleScript(`<h1>${htmlTitle}</h1>${bodyContent}`);
|
|
564
|
-
// Build the AppleScript command
|
|
565
|
-
let createCommand;
|
|
566
|
-
if (folder) {
|
|
567
|
-
// Create note in specific folder (supports nested paths like "Work/Clients")
|
|
568
|
-
// Note: We avoid `set newNote` + `return id of newNote` because AppleScript
|
|
569
|
-
// fails to resolve the note reference in deeply nested folder contexts (-1728).
|
|
570
|
-
// The implicit return from `make new note` includes the ID which we parse.
|
|
571
|
-
const folderRef = buildFolderReference(folder);
|
|
572
|
-
createCommand = `make new note at ${folderRef} with properties {body:"${safeBody}"}`;
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
// Create note in default location
|
|
576
|
-
createCommand = `
|
|
577
|
-
set newNote to make new note with properties {body:"${safeBody}"}
|
|
578
|
-
return id of newNote
|
|
579
|
-
`;
|
|
580
|
-
}
|
|
581
|
-
// Execute the script
|
|
582
|
-
const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
|
|
583
|
-
const result = executeAppleScript(script);
|
|
584
|
-
if (!result.success) {
|
|
585
|
-
console.error(`Failed to create note "${title}":`, result.error);
|
|
586
|
-
return null;
|
|
587
|
-
}
|
|
588
|
-
// Extract the CoreData ID from the response.
|
|
589
|
-
// AppleScript's `id of newNote` yields an object specifier of the form
|
|
590
|
-
// "note id x-coredata://<uuid>/ICNote/pN" — with a literal "note id " prefix.
|
|
591
|
-
// Strip that prefix so we return the bare x-coredata:// URL that the id
|
|
592
|
-
// validator and downstream tools (get-note-content, update-note) accept.
|
|
593
|
-
const rawOutput = result.output.trim();
|
|
594
|
-
const noteId = extractCoreDataId(rawOutput, "note") || rawOutput;
|
|
595
|
-
// Return a Note object representing the created note with real ID
|
|
596
|
-
const now = new Date();
|
|
597
|
-
return {
|
|
598
|
-
id: noteId || generateFallbackId(), // Use real ID, fallback to unique temp ID
|
|
599
|
-
title,
|
|
600
|
-
content,
|
|
601
|
-
tags,
|
|
602
|
-
created: now,
|
|
603
|
-
modified: now,
|
|
604
|
-
folder,
|
|
605
|
-
account: targetAccount,
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
/**
|
|
609
|
-
* Searches for notes matching a query.
|
|
610
|
-
*
|
|
611
|
-
* By default, searches note titles. Set searchContent=true to search
|
|
612
|
-
* the body text instead. Optionally filter to a specific folder.
|
|
613
|
-
*
|
|
614
|
-
* @param query - Text to search for
|
|
615
|
-
* @param searchContent - If true, search note bodies; if false, search titles
|
|
616
|
-
* @param account - Account to search in (defaults to iCloud)
|
|
617
|
-
* @param folder - Optional folder to limit search to
|
|
618
|
-
* @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
|
|
619
|
-
* @param limit - Optional maximum number of results to return (default: no limit)
|
|
620
|
-
* @returns Array of matching notes (with minimal metadata)
|
|
621
|
-
*
|
|
622
|
-
* @example
|
|
623
|
-
* ```typescript
|
|
624
|
-
* // Search by title
|
|
625
|
-
* const meetingNotes = manager.searchNotes("meeting");
|
|
626
|
-
*
|
|
627
|
-
* // Search in note content
|
|
628
|
-
* const projectRefs = manager.searchNotes("Project Alpha", true);
|
|
629
|
-
*
|
|
630
|
-
* // Search within a specific folder
|
|
631
|
-
* const workNotes = manager.searchNotes("deadline", false, "iCloud", "Work");
|
|
632
|
-
*
|
|
633
|
-
* // Search only recently modified notes
|
|
634
|
-
* const recentNotes = manager.searchNotes("todo", true, undefined, undefined, "2025-01-01");
|
|
635
|
-
*
|
|
636
|
-
* // Search with a result limit
|
|
637
|
-
* const topResults = manager.searchNotes("project", false, undefined, undefined, undefined, 10);
|
|
638
|
-
* ```
|
|
639
|
-
*/
|
|
640
|
-
searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
|
|
641
|
-
const targetAccount = this.resolveAccount(account);
|
|
642
|
-
const safeQuery = escapePlainStringForAppleScript(query);
|
|
643
|
-
const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
|
|
644
|
-
// Build the where clause based on search type
|
|
645
|
-
// AppleScript uses 'name' for title and 'body' for content
|
|
646
|
-
const whereParts = [];
|
|
647
|
-
if (searchContent) {
|
|
648
|
-
whereParts.push(`body contains "${safeQuery}"`);
|
|
649
|
-
}
|
|
650
|
-
else {
|
|
651
|
-
whereParts.push(`name contains "${safeQuery}"`);
|
|
652
|
-
}
|
|
653
|
-
// Add date filter if specified (uses locale-safe date variable)
|
|
654
|
-
let dateSetup = "";
|
|
655
|
-
if (modifiedSince) {
|
|
656
|
-
const date = new Date(modifiedSince);
|
|
657
|
-
if (!isNaN(date.getTime())) {
|
|
658
|
-
dateSetup = buildAppleScriptDateVar(date) + "\n";
|
|
659
|
-
whereParts.push(`modification date >= thresholdDate`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
const whereClause = whereParts.join(" and ");
|
|
663
|
-
// Build the notes source - either all notes or notes in a specific folder
|
|
664
|
-
const notesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
|
|
665
|
-
// Build the limit logic for the repeat loop
|
|
666
|
-
// Note: The limit only reduces iteration over already-matched results from the whose clause,
|
|
667
|
-
// not the query itself. It controls output size, not AppleScript query performance.
|
|
668
|
-
const limitCheck = safeLimit !== undefined
|
|
669
|
-
? `
|
|
670
|
-
if (count of resultList) >= ${safeLimit} then exit repeat`
|
|
671
|
-
: "";
|
|
672
|
-
// Get names, IDs, and folder for each matching note.
|
|
673
|
-
// Notes.app can return the same CoreData note more than once when asking
|
|
674
|
-
// an account for all notes, so dedupe on note ID before adding results.
|
|
675
|
-
const searchCommand = `
|
|
676
|
-
${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
|
|
677
|
-
set resultList to {}
|
|
678
|
-
set seenIds to {}
|
|
679
|
-
repeat with n in matchingNotes
|
|
680
|
-
try
|
|
681
|
-
set noteName to name of n
|
|
682
|
-
set noteId to id of n
|
|
683
|
-
if seenIds does not contain noteId then
|
|
684
|
-
set end of seenIds to noteId
|
|
685
|
-
try
|
|
686
|
-
set noteFolder to name of container of n
|
|
687
|
-
on error
|
|
688
|
-
set noteFolder to "Notes"
|
|
689
|
-
end try
|
|
690
|
-
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder${limitCheck}
|
|
691
|
-
end if
|
|
692
|
-
end try
|
|
693
|
-
end repeat
|
|
694
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
695
|
-
return resultList as text
|
|
696
|
-
`;
|
|
697
|
-
const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
|
|
698
|
-
const result = executeAppleScript(script);
|
|
699
|
-
if (!result.success) {
|
|
700
|
-
// Surface the failure (#19) — an empty array would look like "no matches".
|
|
701
|
-
throw new Error(`Failed to search notes for "${query}": ${result.error ?? "unknown error"}`);
|
|
702
|
-
}
|
|
703
|
-
// Handle empty results
|
|
704
|
-
if (!result.output.trim()) {
|
|
705
|
-
return [];
|
|
706
|
-
}
|
|
707
|
-
// Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
|
|
708
|
-
const items = result.output.split(RECORD_SEP);
|
|
709
|
-
const notes = [];
|
|
710
|
-
const seenIds = new Set();
|
|
711
|
-
for (const item of items) {
|
|
712
|
-
const [title, id, folder] = item.split(FIELD_SEP);
|
|
713
|
-
if (!title?.trim())
|
|
714
|
-
continue;
|
|
715
|
-
const noteId = id?.trim() || generateFallbackId();
|
|
716
|
-
if (seenIds.has(noteId))
|
|
717
|
-
continue;
|
|
718
|
-
seenIds.add(noteId);
|
|
719
|
-
notes.push({
|
|
720
|
-
id: noteId,
|
|
721
|
-
title: title.trim(),
|
|
722
|
-
content: "", // Not fetched in search
|
|
723
|
-
tags: [],
|
|
724
|
-
created: new Date(),
|
|
725
|
-
modified: new Date(),
|
|
726
|
-
folder: folder?.trim(),
|
|
727
|
-
account: targetAccount,
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
return notes;
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Retrieves the HTML content of a note by its title.
|
|
734
|
-
*
|
|
735
|
-
* Note: Password-protected notes will fail with an AppleScript error.
|
|
736
|
-
* Callers should check for password protection beforehand using
|
|
737
|
-
* getNoteDetails() or isNotePasswordProtected().
|
|
738
|
-
*
|
|
739
|
-
* @param title - Exact title of the note
|
|
740
|
-
* @param account - Account to search in (defaults to iCloud)
|
|
741
|
-
* @returns HTML content of the note, or empty string if not found
|
|
742
|
-
*
|
|
743
|
-
* @example
|
|
744
|
-
* ```typescript
|
|
745
|
-
* const content = manager.getNoteContent("Shopping List");
|
|
746
|
-
* if (content) {
|
|
747
|
-
* console.log("Note found:", content);
|
|
748
|
-
* }
|
|
749
|
-
* ```
|
|
750
|
-
*/
|
|
751
|
-
getNoteContent(title, account) {
|
|
752
|
-
const targetAccount = this.resolveAccount(account);
|
|
753
|
-
const safeTitle = escapePlainStringForAppleScript(title);
|
|
754
|
-
// Retrieve the body property of the note
|
|
755
|
-
const getCommand = `get body of note "${safeTitle}"`;
|
|
756
|
-
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
757
|
-
const result = executeAppleScript(script);
|
|
758
|
-
if (!result.success) {
|
|
759
|
-
console.error(`Failed to get content of note "${title}":`, result.error);
|
|
760
|
-
return "";
|
|
761
|
-
}
|
|
762
|
-
return result.output;
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Retrieves the HTML content of a note by its CoreData ID.
|
|
766
|
-
*
|
|
767
|
-
* This is more reliable than getNoteContent() because IDs are unique
|
|
768
|
-
* across all accounts, while titles can be duplicated.
|
|
769
|
-
*
|
|
770
|
-
* Note: Password-protected notes will fail with an AppleScript error.
|
|
771
|
-
* Callers should check for password protection beforehand using
|
|
772
|
-
* getNoteById() or isNotePasswordProtectedById().
|
|
773
|
-
*
|
|
774
|
-
* @param id - CoreData URL identifier for the note
|
|
775
|
-
* @returns HTML content of the note, or empty string if not found
|
|
776
|
-
*/
|
|
777
|
-
getNoteContentById(id) {
|
|
778
|
-
const safeId = sanitizeId(id);
|
|
779
|
-
// Note IDs work at the application level, not scoped to account
|
|
780
|
-
const getCommand = `get body of note id "${safeId}"`;
|
|
781
|
-
const script = buildAppLevelScript(getCommand);
|
|
782
|
-
const result = executeAppleScript(script);
|
|
783
|
-
if (!result.success) {
|
|
784
|
-
console.error(`Failed to get content of note with ID "${id}":`, result.error);
|
|
785
|
-
return "";
|
|
786
|
-
}
|
|
787
|
-
return result.output;
|
|
788
|
-
}
|
|
789
|
-
/**
|
|
790
|
-
* Retrieves the plain-text content of a note by its exact title.
|
|
791
|
-
*
|
|
792
|
-
* Reads the note's `plaintext` property, which Notes derives from the body
|
|
793
|
-
* with all HTML markup removed. This is the text Notes itself exposes, so it
|
|
794
|
-
* is more faithful than converting the HTML body and skips the markup
|
|
795
|
-
* round-trip entirely.
|
|
796
|
-
*
|
|
797
|
-
* @param title - Exact title of the note
|
|
798
|
-
* @param account - Account to search in (defaults to iCloud)
|
|
799
|
-
* @returns Plain-text content of the note, or empty string if not found
|
|
800
|
-
*/
|
|
801
|
-
getNotePlaintext(title, account) {
|
|
802
|
-
const targetAccount = this.resolveAccount(account);
|
|
803
|
-
const safeTitle = escapePlainStringForAppleScript(title);
|
|
804
|
-
const getCommand = `get plaintext of note "${safeTitle}"`;
|
|
805
|
-
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
806
|
-
const result = executeAppleScript(script);
|
|
807
|
-
if (!result.success) {
|
|
808
|
-
console.error(`Failed to get plaintext of note "${title}":`, result.error);
|
|
809
|
-
return "";
|
|
810
|
-
}
|
|
811
|
-
return result.output;
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Retrieves the plain-text content of a note by its CoreData ID.
|
|
815
|
-
*
|
|
816
|
-
* Reads the read-only `plaintext` property (the body with HTML removed). More
|
|
817
|
-
* reliable than getNotePlaintext() because IDs are unique across accounts.
|
|
818
|
-
*
|
|
819
|
-
* Note: Password-protected notes will fail with an AppleScript error. Callers
|
|
820
|
-
* should check for password protection beforehand using getNoteById().
|
|
821
|
-
*
|
|
822
|
-
* @param id - CoreData URL identifier for the note
|
|
823
|
-
* @returns Plain-text content of the note, or empty string if not found
|
|
824
|
-
*/
|
|
825
|
-
getNotePlaintextById(id) {
|
|
826
|
-
const safeId = sanitizeId(id);
|
|
827
|
-
const getCommand = `get plaintext of note id "${safeId}"`;
|
|
828
|
-
const script = buildAppLevelScript(getCommand);
|
|
829
|
-
const result = executeAppleScript(script);
|
|
830
|
-
if (!result.success) {
|
|
831
|
-
console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
|
|
832
|
-
return "";
|
|
833
|
-
}
|
|
834
|
-
return result.output;
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Retrieves a note by its unique CoreData ID.
|
|
838
|
-
*
|
|
839
|
-
* Each note has a unique ID in the format:
|
|
840
|
-
* "x-coredata://DEVICE-UUID/ICNote/pXXXX"
|
|
841
|
-
*
|
|
842
|
-
* This method fetches the note and its metadata using this ID.
|
|
843
|
-
*
|
|
844
|
-
* @param id - CoreData URL identifier for the note
|
|
845
|
-
* @returns Note object with metadata, or null if not found
|
|
846
|
-
*/
|
|
847
|
-
getNoteById(id) {
|
|
848
|
-
const safeId = sanitizeId(id);
|
|
849
|
-
// Note IDs work at the application level, not scoped to account
|
|
850
|
-
const getCommand = `
|
|
851
|
-
set n to note id "${safeId}"
|
|
852
|
-
set cd to creation date of n
|
|
853
|
-
set md to modification date of n
|
|
854
|
-
set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
|
|
855
|
-
set AppleScript's text item delimiters to ${AS_FIELD_SEP}
|
|
856
|
-
return noteProps as text
|
|
857
|
-
`;
|
|
858
|
-
const script = buildAppLevelScript(getCommand);
|
|
859
|
-
const result = executeAppleScript(script);
|
|
860
|
-
if (!result.success) {
|
|
861
|
-
console.error(`Failed to get note with ID "${id}":`, result.error);
|
|
862
|
-
return null;
|
|
863
|
-
}
|
|
864
|
-
// Parse the AppleScript output using the shared helper
|
|
865
|
-
const parsed = parseNotePropertiesOutput(result.output);
|
|
866
|
-
if (!parsed) {
|
|
867
|
-
return null;
|
|
868
|
-
}
|
|
869
|
-
return {
|
|
870
|
-
id: parsed.id,
|
|
871
|
-
title: parsed.title,
|
|
872
|
-
content: "", // Not fetched to keep response small
|
|
873
|
-
tags: [],
|
|
874
|
-
created: parsed.created,
|
|
875
|
-
modified: parsed.modified,
|
|
876
|
-
shared: parsed.shared,
|
|
877
|
-
passwordProtected: parsed.passwordProtected,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Retrieves detailed metadata for a note by title.
|
|
882
|
-
*
|
|
883
|
-
* Similar to getNoteContent but returns structured metadata
|
|
884
|
-
* including creation date, modification date, and sharing status.
|
|
885
|
-
*
|
|
886
|
-
* @param title - Exact title of the note
|
|
887
|
-
* @param account - Account to search in (defaults to iCloud)
|
|
888
|
-
* @returns Note object with full metadata, or null if not found
|
|
889
|
-
*/
|
|
890
|
-
getNoteDetails(title, account) {
|
|
891
|
-
const targetAccount = this.resolveAccount(account);
|
|
892
|
-
const safeTitle = escapePlainStringForAppleScript(title);
|
|
893
|
-
// Fetch multiple properties at once
|
|
894
|
-
const getCommand = `
|
|
895
|
-
set n to note "${safeTitle}"
|
|
896
|
-
set cd to creation date of n
|
|
897
|
-
set md to modification date of n
|
|
898
|
-
set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
|
|
899
|
-
set AppleScript's text item delimiters to ${AS_FIELD_SEP}
|
|
900
|
-
return noteProps as text
|
|
901
|
-
`;
|
|
902
|
-
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
903
|
-
const result = executeAppleScript(script);
|
|
904
|
-
if (!result.success) {
|
|
905
|
-
console.error(`Failed to get details for note "${title}":`, result.error);
|
|
906
|
-
return null;
|
|
907
|
-
}
|
|
908
|
-
// Parse the AppleScript output using the shared helper
|
|
909
|
-
const parsed = parseNotePropertiesOutput(result.output);
|
|
910
|
-
if (!parsed) {
|
|
911
|
-
return null;
|
|
912
|
-
}
|
|
913
|
-
return {
|
|
914
|
-
id: parsed.id,
|
|
915
|
-
title: parsed.title,
|
|
916
|
-
content: "", // Not fetched
|
|
917
|
-
tags: [],
|
|
918
|
-
created: parsed.created,
|
|
919
|
-
modified: parsed.modified,
|
|
920
|
-
shared: parsed.shared,
|
|
921
|
-
passwordProtected: parsed.passwordProtected,
|
|
922
|
-
account: targetAccount,
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
/**
|
|
926
|
-
* Deletes a note by its title.
|
|
927
|
-
*
|
|
928
|
-
* Note: This permanently deletes the note. It may be recoverable
|
|
929
|
-
* from the "Recently Deleted" folder in Notes.app.
|
|
930
|
-
*
|
|
931
|
-
* @param title - Exact title of the note to delete
|
|
932
|
-
* @param account - Account containing the note (defaults to iCloud)
|
|
933
|
-
* @returns true if deletion succeeded, false otherwise
|
|
934
|
-
*/
|
|
935
|
-
deleteNote(title, account) {
|
|
936
|
-
const targetAccount = this.resolveAccount(account);
|
|
937
|
-
const safeTitle = escapePlainStringForAppleScript(title);
|
|
938
|
-
const deleteCommand = `delete note "${safeTitle}"`;
|
|
939
|
-
const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
|
|
940
|
-
const result = executeAppleScript(script);
|
|
941
|
-
if (!result.success) {
|
|
942
|
-
console.error(`Failed to delete note "${title}":`, result.error);
|
|
943
|
-
return false;
|
|
944
|
-
}
|
|
945
|
-
return true;
|
|
946
|
-
}
|
|
947
|
-
/**
|
|
948
|
-
* Deletes a note by its CoreData ID.
|
|
949
|
-
*
|
|
950
|
-
* This is more reliable than deleteNote() because IDs are unique
|
|
951
|
-
* across all accounts, while titles can be duplicated.
|
|
952
|
-
*
|
|
953
|
-
* @param id - CoreData URL identifier for the note
|
|
954
|
-
* @returns true if deletion succeeded, false otherwise
|
|
955
|
-
*/
|
|
956
|
-
deleteNoteById(id) {
|
|
957
|
-
const safeId = sanitizeId(id);
|
|
958
|
-
const deleteCommand = `delete note id "${safeId}"`;
|
|
959
|
-
const script = buildAppLevelScript(deleteCommand);
|
|
960
|
-
const result = executeAppleScript(script);
|
|
961
|
-
if (!result.success) {
|
|
962
|
-
console.error(`Failed to delete note with ID "${id}":`, result.error);
|
|
963
|
-
return false;
|
|
964
|
-
}
|
|
965
|
-
return true;
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Updates an existing note's content and optionally its title.
|
|
969
|
-
*
|
|
970
|
-
* Apple Notes derives the title from the first line of the body,
|
|
971
|
-
* so updating content also allows title changes. If newTitle is
|
|
972
|
-
* not provided, the original title is preserved.
|
|
973
|
-
*
|
|
974
|
-
* When format is 'html', newTitle is ignored — the caller must include
|
|
975
|
-
* the title in the HTML content.
|
|
976
|
-
*
|
|
977
|
-
* Note: Password-protected notes will fail with an AppleScript error.
|
|
978
|
-
* Callers should check for password protection beforehand using
|
|
979
|
-
* getNoteDetails() or isNotePasswordProtected().
|
|
980
|
-
*
|
|
981
|
-
* @param title - Current title of the note to update
|
|
982
|
-
* @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
|
|
983
|
-
* @param newContent - New content for the note body
|
|
984
|
-
* @param account - Account containing the note (defaults to iCloud)
|
|
985
|
-
* @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
|
|
986
|
-
* @returns true if update succeeded, false otherwise
|
|
987
|
-
*/
|
|
988
|
-
updateNote(title, newTitle, newContent, account, format = "plaintext") {
|
|
989
|
-
if (newTitle)
|
|
990
|
-
validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
|
|
991
|
-
validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
|
|
992
|
-
const targetAccount = this.resolveAccount(account);
|
|
993
|
-
const safeCurrentTitle = escapePlainStringForAppleScript(title);
|
|
994
|
-
let fullBody;
|
|
995
|
-
if (format === "html") {
|
|
996
|
-
// HTML mode: content is the complete body, escaped only for AppleScript string
|
|
997
|
-
fullBody = escapeHtmlForAppleScript(newContent);
|
|
998
|
-
}
|
|
999
|
-
else {
|
|
1000
|
-
// Plaintext mode: wrap title + content in <div> tags (existing behavior)
|
|
1001
|
-
const effectiveTitle = newTitle || title;
|
|
1002
|
-
const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
|
|
1003
|
-
const safeContent = escapeForAppleScript(newContent);
|
|
1004
|
-
fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
|
|
1005
|
-
}
|
|
1006
|
-
const updateCommand = `set body of note "${safeCurrentTitle}" to "${fullBody}"`;
|
|
1007
|
-
const script = buildAccountScopedScript({ account: targetAccount }, updateCommand);
|
|
1008
|
-
const result = executeAppleScript(script);
|
|
1009
|
-
if (!result.success) {
|
|
1010
|
-
console.error(`Failed to update note "${title}":`, result.error);
|
|
1011
|
-
return false;
|
|
1012
|
-
}
|
|
1013
|
-
return true;
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* Updates an existing note by its CoreData ID.
|
|
1017
|
-
*
|
|
1018
|
-
* This is more reliable than updateNote() because IDs are unique,
|
|
1019
|
-
* while titles can be duplicated.
|
|
1020
|
-
*
|
|
1021
|
-
* When format is 'html', newTitle is ignored — the caller must include
|
|
1022
|
-
* the title in the HTML content.
|
|
1023
|
-
*
|
|
1024
|
-
* Note: Password-protected notes will fail with an AppleScript error.
|
|
1025
|
-
* Callers should check for password protection beforehand using
|
|
1026
|
-
* getNoteById() or isNotePasswordProtectedById().
|
|
1027
|
-
*
|
|
1028
|
-
* @param id - CoreData URL identifier for the note
|
|
1029
|
-
* @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
|
|
1030
|
-
* @param newContent - New content for the note body
|
|
1031
|
-
* @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
|
|
1032
|
-
* @returns true if update succeeded, false otherwise
|
|
1033
|
-
*/
|
|
1034
|
-
updateNoteById(id, newTitle, newContent, format = "plaintext") {
|
|
1035
|
-
if (newTitle)
|
|
1036
|
-
validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
|
|
1037
|
-
validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
|
|
1038
|
-
let fullBody;
|
|
1039
|
-
if (format === "html") {
|
|
1040
|
-
// HTML mode: content is the complete body, escaped only for AppleScript string
|
|
1041
|
-
fullBody = escapeHtmlForAppleScript(newContent);
|
|
1042
|
-
}
|
|
1043
|
-
else {
|
|
1044
|
-
// Plaintext mode: wrap title + content in <div> tags (existing behavior)
|
|
1045
|
-
// Get the note to retrieve current title if newTitle not provided
|
|
1046
|
-
let effectiveTitle = newTitle;
|
|
1047
|
-
if (!effectiveTitle) {
|
|
1048
|
-
const note = this.getNoteById(id);
|
|
1049
|
-
if (!note) {
|
|
1050
|
-
console.error(`Cannot update note: note with ID "${id}" not found`);
|
|
1051
|
-
return false;
|
|
1052
|
-
}
|
|
1053
|
-
effectiveTitle = note.title;
|
|
1054
|
-
}
|
|
1055
|
-
const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
|
|
1056
|
-
const safeContent = escapeForAppleScript(newContent);
|
|
1057
|
-
fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
|
|
1058
|
-
}
|
|
1059
|
-
const safeId = sanitizeId(id);
|
|
1060
|
-
const updateCommand = `set body of note id "${safeId}" to "${fullBody}"`;
|
|
1061
|
-
const script = buildAppLevelScript(updateCommand);
|
|
1062
|
-
const result = executeAppleScript(script);
|
|
1063
|
-
if (!result.success) {
|
|
1064
|
-
console.error(`Failed to update note with ID "${id}":`, result.error);
|
|
1065
|
-
return false;
|
|
1066
|
-
}
|
|
1067
|
-
return true;
|
|
1068
|
-
}
|
|
1069
|
-
/**
|
|
1070
|
-
* Lists all notes in an account, optionally filtered by folder, date, and limit.
|
|
1071
|
-
*
|
|
1072
|
-
* @param account - Account to list notes from (defaults to iCloud)
|
|
1073
|
-
* @param folder - Optional folder to filter by
|
|
1074
|
-
* @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
|
|
1075
|
-
* @param limit - Optional maximum number of results to return (default: no limit)
|
|
1076
|
-
* @returns Array of note titles
|
|
1077
|
-
*/
|
|
1078
|
-
listNotes(account, folder, modifiedSince, limit) {
|
|
1079
|
-
const targetAccount = this.resolveAccount(account);
|
|
1080
|
-
const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
|
|
1081
|
-
// When date or limit filters are needed, use a repeat loop for fine-grained control
|
|
1082
|
-
if (modifiedSince || safeLimit !== undefined) {
|
|
1083
|
-
const baseNotesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
|
|
1084
|
-
// Use whose clause for date filtering (locale-safe, no sort order assumption)
|
|
1085
|
-
let dateSetup = "";
|
|
1086
|
-
let notesSource = baseNotesSource;
|
|
1087
|
-
if (modifiedSince) {
|
|
1088
|
-
const date = new Date(modifiedSince);
|
|
1089
|
-
if (!isNaN(date.getTime())) {
|
|
1090
|
-
dateSetup = buildAppleScriptDateVar(date) + "\n";
|
|
1091
|
-
notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
// Build the limit check. Check after appending so deduped results,
|
|
1095
|
-
// rather than duplicate AppleScript references, determine the limit.
|
|
1096
|
-
const limitCheck = safeLimit !== undefined
|
|
1097
|
-
? `
|
|
1098
|
-
if (count of resultList) >= ${safeLimit} then exit repeat`
|
|
1099
|
-
: "";
|
|
1100
|
-
const listCommand = `
|
|
1101
|
-
${dateSetup}set resultList to {}
|
|
1102
|
-
set seenIds to {}
|
|
1103
|
-
repeat with n in ${notesSource}
|
|
1104
|
-
try
|
|
1105
|
-
set noteName to name of n
|
|
1106
|
-
set noteId to id of n
|
|
1107
|
-
if seenIds does not contain noteId then
|
|
1108
|
-
set end of seenIds to noteId
|
|
1109
|
-
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId${limitCheck}
|
|
1110
|
-
end if
|
|
1111
|
-
end try
|
|
1112
|
-
end repeat
|
|
1113
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1114
|
-
return resultList as text
|
|
1115
|
-
`;
|
|
1116
|
-
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1117
|
-
const result = executeAppleScript(script);
|
|
1118
|
-
if (!result.success) {
|
|
1119
|
-
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1120
|
-
}
|
|
1121
|
-
if (!result.output.trim()) {
|
|
1122
|
-
return [];
|
|
1123
|
-
}
|
|
1124
|
-
const seenIds = new Set();
|
|
1125
|
-
const titles = [];
|
|
1126
|
-
for (const item of result.output.split(RECORD_SEP)) {
|
|
1127
|
-
const [title, id] = item.split(FIELD_SEP);
|
|
1128
|
-
if (!title?.trim())
|
|
1129
|
-
continue;
|
|
1130
|
-
const noteId = id?.trim() || generateFallbackId();
|
|
1131
|
-
if (seenIds.has(noteId))
|
|
1132
|
-
continue;
|
|
1133
|
-
seenIds.add(noteId);
|
|
1134
|
-
titles.push(title.trim());
|
|
1135
|
-
}
|
|
1136
|
-
return titles;
|
|
1137
|
-
}
|
|
1138
|
-
// Simple path: no date or limit filters. Use a repeat loop so duplicate
|
|
1139
|
-
// CoreData note references can be deduped by ID before returning titles.
|
|
1140
|
-
const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
|
|
1141
|
-
const listCommand = `
|
|
1142
|
-
set resultList to {}
|
|
1143
|
-
set seenIds to {}
|
|
1144
|
-
repeat with n in ${notesRef}
|
|
1145
|
-
try
|
|
1146
|
-
set noteName to name of n
|
|
1147
|
-
set noteId to id of n
|
|
1148
|
-
if seenIds does not contain noteId then
|
|
1149
|
-
set end of seenIds to noteId
|
|
1150
|
-
set end of resultList to noteName & ${AS_FIELD_SEP} & noteId
|
|
1151
|
-
end if
|
|
1152
|
-
end try
|
|
1153
|
-
end repeat
|
|
1154
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1155
|
-
return resultList as text
|
|
1156
|
-
`;
|
|
1157
|
-
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1158
|
-
const result = executeAppleScript(script);
|
|
1159
|
-
if (!result.success) {
|
|
1160
|
-
throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
|
|
1161
|
-
}
|
|
1162
|
-
if (!result.output.trim())
|
|
1163
|
-
return [];
|
|
1164
|
-
const seenIds = new Set();
|
|
1165
|
-
const titles = [];
|
|
1166
|
-
for (const item of result.output.split(RECORD_SEP)) {
|
|
1167
|
-
const [title, id] = item.split(FIELD_SEP);
|
|
1168
|
-
if (!title?.trim())
|
|
1169
|
-
continue;
|
|
1170
|
-
const noteId = id?.trim() || generateFallbackId();
|
|
1171
|
-
if (seenIds.has(noteId))
|
|
1172
|
-
continue;
|
|
1173
|
-
seenIds.add(noteId);
|
|
1174
|
-
titles.push(title.trim());
|
|
1175
|
-
}
|
|
1176
|
-
return titles;
|
|
1177
|
-
}
|
|
1178
|
-
/**
|
|
1179
|
-
* Lists all shared (collaborative) notes across all accounts.
|
|
1180
|
-
*
|
|
1181
|
-
* Returns notes that are shared with other users. These notes require
|
|
1182
|
-
* extra caution when modifying or deleting as changes affect collaborators.
|
|
1183
|
-
*
|
|
1184
|
-
* @returns Array of Note objects for all shared notes
|
|
1185
|
-
*
|
|
1186
|
-
* @example
|
|
1187
|
-
* ```typescript
|
|
1188
|
-
* const shared = manager.listSharedNotes();
|
|
1189
|
-
* console.log(`You have ${shared.length} shared notes`);
|
|
1190
|
-
* ```
|
|
1191
|
-
*/
|
|
1192
|
-
listSharedNotes() {
|
|
1193
|
-
const sharedNotes = [];
|
|
1194
|
-
// Query each account for shared notes
|
|
1195
|
-
const accounts = this.listAccounts();
|
|
1196
|
-
for (const account of accounts) {
|
|
1197
|
-
// Use delimited output to avoid fragile comma-based parsing.
|
|
1198
|
-
// Format: name|||id|||createdDate|||modifiedDate|||shared|||passwordProtected
|
|
1199
|
-
const script = buildAccountScopedScript({ account: account.name }, `
|
|
1200
|
-
set resultList to {}
|
|
1201
|
-
repeat with n in notes
|
|
1202
|
-
if shared of n is true then
|
|
1203
|
-
set cd to creation date of n
|
|
1204
|
-
set md to modification date of n
|
|
1205
|
-
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)
|
|
1206
|
-
end if
|
|
1207
|
-
end repeat
|
|
1208
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1209
|
-
return resultList as text
|
|
1210
|
-
`);
|
|
1211
|
-
const result = executeAppleScript(script);
|
|
1212
|
-
if (!result.success) {
|
|
1213
|
-
console.error(`Failed to list shared notes for ${account.name}:`, result.error);
|
|
1214
|
-
continue;
|
|
1215
|
-
}
|
|
1216
|
-
const output = result.output.trim();
|
|
1217
|
-
if (!output) {
|
|
1218
|
-
continue;
|
|
1219
|
-
}
|
|
1220
|
-
// Parse control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
|
|
1221
|
-
const items = output.split(RECORD_SEP);
|
|
1222
|
-
for (const item of items) {
|
|
1223
|
-
const parts = item.split(FIELD_SEP);
|
|
1224
|
-
if (parts.length >= 6) {
|
|
1225
|
-
const title = parts[0].trim();
|
|
1226
|
-
const id = parts[1].trim();
|
|
1227
|
-
const createdStr = parts[2].trim();
|
|
1228
|
-
const modifiedStr = parts[3].trim();
|
|
1229
|
-
const shared = parts[4].trim() === "true";
|
|
1230
|
-
const passwordProtected = parts[5].trim() === "true";
|
|
1231
|
-
sharedNotes.push({
|
|
1232
|
-
id,
|
|
1233
|
-
title,
|
|
1234
|
-
content: "",
|
|
1235
|
-
tags: [],
|
|
1236
|
-
created: parseAppleScriptDate(createdStr),
|
|
1237
|
-
modified: parseAppleScriptDate(modifiedStr),
|
|
1238
|
-
account: account.name,
|
|
1239
|
-
shared,
|
|
1240
|
-
passwordProtected,
|
|
1241
|
-
});
|
|
1242
|
-
}
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
return sharedNotes;
|
|
1246
|
-
}
|
|
1247
|
-
// ===========================================================================
|
|
1248
|
-
// Folder Operations
|
|
1249
|
-
// ===========================================================================
|
|
1250
|
-
/**
|
|
1251
|
-
* Lists all folders in an account with full hierarchical paths.
|
|
1252
|
-
*
|
|
1253
|
-
* Each folder's `name` field contains the full path (e.g., "Work/Clients/Omnia")
|
|
1254
|
-
* so that duplicate folder names (e.g., multiple "Archive" folders) are
|
|
1255
|
-
* distinguishable and can be used directly in other operations.
|
|
1256
|
-
*
|
|
1257
|
-
* @param account - Account to list folders from (defaults to iCloud)
|
|
1258
|
-
* @returns Array of Folder objects with path-based names
|
|
1259
|
-
*/
|
|
1260
|
-
listFolders(account) {
|
|
1261
|
-
const targetAccount = this.resolveAccount(account);
|
|
1262
|
-
// Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
|
|
1263
|
-
// Using IDs enables correct tree building even with duplicate folder names.
|
|
1264
|
-
const listCommand = `
|
|
1265
|
-
set folderList to {}
|
|
1266
|
-
set allFolders to every folder
|
|
1267
|
-
repeat with f in allFolders
|
|
1268
|
-
set fRef to contents of f
|
|
1269
|
-
set cRef to container of fRef
|
|
1270
|
-
set parentId to ""
|
|
1271
|
-
if class of cRef is folder then
|
|
1272
|
-
set parentId to id of cRef
|
|
1273
|
-
end if
|
|
1274
|
-
set sharedFlag to shared of fRef as text
|
|
1275
|
-
set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
|
|
1276
|
-
end repeat
|
|
1277
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1278
|
-
return folderList as text
|
|
1279
|
-
`;
|
|
1280
|
-
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1281
|
-
const result = executeAppleScript(script);
|
|
1282
|
-
if (!result.success) {
|
|
1283
|
-
throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
|
|
1284
|
-
}
|
|
1285
|
-
if (!result.output.trim()) {
|
|
1286
|
-
return [];
|
|
1287
|
-
}
|
|
1288
|
-
const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
|
|
1289
|
-
const entries = result.output.split(recordSeparator).map((line) => {
|
|
1290
|
-
const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
|
|
1291
|
-
return {
|
|
1292
|
-
id: (parts[0] || "").trim(),
|
|
1293
|
-
name: (parts[1] || "").trim(),
|
|
1294
|
-
parentId: (parts[2] || "").trim(),
|
|
1295
|
-
shared: (parts[3] || "").trim().toLowerCase() === "true",
|
|
1296
|
-
};
|
|
1297
|
-
});
|
|
1298
|
-
// Build an ID-to-entry map for efficient parent lookups
|
|
1299
|
-
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
1300
|
-
// Build full path by walking up the parent chain using unique IDs
|
|
1301
|
-
// Build full path by walking up the parent chain using unique IDs.
|
|
1302
|
-
// Literal slashes in folder names are escaped as `\/` so they don't
|
|
1303
|
-
// collide with the `/` path separator.
|
|
1304
|
-
const buildPath = (entry) => {
|
|
1305
|
-
const safeName = escapeFolderName(entry.name);
|
|
1306
|
-
if (!entry.parentId)
|
|
1307
|
-
return safeName;
|
|
1308
|
-
const parent = byId.get(entry.parentId);
|
|
1309
|
-
if (parent) {
|
|
1310
|
-
return buildPath(parent) + "/" + safeName;
|
|
1311
|
-
}
|
|
1312
|
-
return safeName;
|
|
1313
|
-
};
|
|
1314
|
-
return entries.map((entry) => ({
|
|
1315
|
-
id: entry.id,
|
|
1316
|
-
name: buildPath(entry),
|
|
1317
|
-
account: targetAccount,
|
|
1318
|
-
shared: entry.shared,
|
|
1319
|
-
}));
|
|
1320
|
-
}
|
|
1321
|
-
/**
|
|
1322
|
-
* Creates a new folder in an account.
|
|
1323
|
-
*
|
|
1324
|
-
* @param name - Name for the new folder
|
|
1325
|
-
* @param account - Account to create folder in (defaults to iCloud)
|
|
1326
|
-
* @returns Created Folder object, or null on failure
|
|
1327
|
-
*/
|
|
1328
|
-
createFolder(name, account) {
|
|
1329
|
-
const targetAccount = this.resolveAccount(account);
|
|
1330
|
-
const parts = splitFolderPath(name);
|
|
1331
|
-
if (parts.length === 0) {
|
|
1332
|
-
console.error(`Invalid folder name: "${name}"`);
|
|
1333
|
-
return null;
|
|
1334
|
-
}
|
|
1335
|
-
// Create each segment of the path, checking existence first to avoid duplicates.
|
|
1336
|
-
// For "A/B/C": ensure "A" exists, then "A/B", then "A/B/C".
|
|
1337
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1338
|
-
const currentPath = parts
|
|
1339
|
-
.slice(0, i + 1)
|
|
1340
|
-
.map((p) => escapeFolderName(p))
|
|
1341
|
-
.join("/");
|
|
1342
|
-
const currentRef = buildFolderReference(currentPath);
|
|
1343
|
-
// Check if this folder already exists
|
|
1344
|
-
const checkScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${currentRef}`);
|
|
1345
|
-
const checkResult = executeAppleScript(checkScript);
|
|
1346
|
-
if (checkResult.success) {
|
|
1347
|
-
// Folder exists, move to next segment
|
|
1348
|
-
continue;
|
|
1349
|
-
}
|
|
1350
|
-
// Folder doesn't exist — create it
|
|
1351
|
-
const segmentName = escapePlainStringForAppleScript(parts[i]);
|
|
1352
|
-
let createCommand;
|
|
1353
|
-
if (i === 0) {
|
|
1354
|
-
createCommand = `make new folder with properties {name:"${segmentName}"}`;
|
|
1355
|
-
}
|
|
1356
|
-
else {
|
|
1357
|
-
const parentPath = parts
|
|
1358
|
-
.slice(0, i)
|
|
1359
|
-
.map((p) => escapeFolderName(p))
|
|
1360
|
-
.join("/");
|
|
1361
|
-
const parentRef = buildFolderReference(parentPath);
|
|
1362
|
-
createCommand = `make new folder at ${parentRef} with properties {name:"${segmentName}"}`;
|
|
1363
|
-
}
|
|
1364
|
-
const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
|
|
1365
|
-
const result = executeAppleScript(script);
|
|
1366
|
-
if (!result.success) {
|
|
1367
|
-
console.error(`Failed to create folder "${name}":`, result.error);
|
|
1368
|
-
return null;
|
|
1369
|
-
}
|
|
1370
|
-
}
|
|
1371
|
-
// Get the ID of the final (deepest) folder
|
|
1372
|
-
const fullRef = buildFolderReference(name);
|
|
1373
|
-
const idScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${fullRef}`);
|
|
1374
|
-
const idResult = executeAppleScript(idScript);
|
|
1375
|
-
const folderId = idResult.success ? extractCoreDataId(idResult.output, "folder") : "";
|
|
1376
|
-
return {
|
|
1377
|
-
id: folderId,
|
|
1378
|
-
name,
|
|
1379
|
-
account: targetAccount,
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
/**
|
|
1383
|
-
* Deletes a folder from an account.
|
|
1384
|
-
*
|
|
1385
|
-
* Note: This may fail if the folder contains notes.
|
|
1386
|
-
*
|
|
1387
|
-
* @param name - Name of the folder to delete
|
|
1388
|
-
* @param account - Account containing the folder (defaults to iCloud)
|
|
1389
|
-
* @returns true if deletion succeeded, false otherwise
|
|
1390
|
-
*/
|
|
1391
|
-
deleteFolder(name, account) {
|
|
1392
|
-
const targetAccount = this.resolveAccount(account);
|
|
1393
|
-
const deleteCommand = `delete ${buildFolderReference(name)}`;
|
|
1394
|
-
const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
|
|
1395
|
-
const result = executeAppleScript(script);
|
|
1396
|
-
if (!result.success) {
|
|
1397
|
-
console.error(`Failed to delete folder "${name}":`, result.error);
|
|
1398
|
-
return false;
|
|
1399
|
-
}
|
|
1400
|
-
return true;
|
|
1401
|
-
}
|
|
1402
|
-
/**
|
|
1403
|
-
* Moves a note to a different folder, looked up by title.
|
|
1404
|
-
*
|
|
1405
|
-
* Uses Notes.app's native `move` command (the same one `batchMoveNotes`
|
|
1406
|
-
* uses), which relocates the note in place — preserving its identity, id,
|
|
1407
|
-
* creation date, AND all embedded attachments (files/images/PDFs/scans/audio).
|
|
1408
|
-
* The previous copy-then-delete implementation rebuilt the note from its body
|
|
1409
|
-
* HTML, which silently dropped attachments and reset the note's identity.
|
|
1410
|
-
*
|
|
1411
|
-
* The note is resolved to its id first (titles can be duplicated), then moved
|
|
1412
|
-
* by id so the title-based and id-based paths share the same native move.
|
|
1413
|
-
*
|
|
1414
|
-
* @param title - Title of the note to move
|
|
1415
|
-
* @param destinationFolder - Name of the folder to move to (must already exist)
|
|
1416
|
-
* @param account - Account containing the note (defaults to iCloud)
|
|
1417
|
-
* @returns true if the move succeeded, false otherwise
|
|
1418
|
-
*/
|
|
1419
|
-
moveNote(title, destinationFolder, account) {
|
|
1420
|
-
const targetAccount = this.resolveAccount(account);
|
|
1421
|
-
// Resolve the note's id first (titles can be duplicated), then delegate to
|
|
1422
|
-
// the id-based native move so both paths preserve attachments + identity.
|
|
1423
|
-
const originalNote = this.getNoteDetails(title, targetAccount);
|
|
1424
|
-
if (!originalNote) {
|
|
1425
|
-
console.error(`Cannot move note "${title}": note not found`);
|
|
1426
|
-
return false;
|
|
1427
|
-
}
|
|
1428
|
-
return this.moveNoteById(originalNote.id, destinationFolder, targetAccount);
|
|
1429
|
-
}
|
|
1430
|
-
/**
|
|
1431
|
-
* Moves a note to a different folder by its CoreData ID.
|
|
1432
|
-
*
|
|
1433
|
-
* Uses Notes.app's native `move <noteRef> to <destFolder>` command — the same
|
|
1434
|
-
* one `batchMoveNotes` uses — which relocates the note in place, preserving its
|
|
1435
|
-
* id, creation date, and all embedded attachments. (The old copy-then-delete
|
|
1436
|
-
* approach rebuilt the note from body HTML and silently lost attachments.)
|
|
1437
|
-
*
|
|
1438
|
-
* @param id - CoreData URL identifier for the note
|
|
1439
|
-
* @param destinationFolder - Name of the folder to move to (must already exist)
|
|
1440
|
-
* @param account - Account containing the destination folder (defaults to iCloud)
|
|
1441
|
-
* @returns true if the move succeeded, false otherwise
|
|
1442
|
-
*/
|
|
1443
|
-
moveNoteById(id, destinationFolder, account) {
|
|
1444
|
-
const targetAccount = this.resolveAccount(account);
|
|
1445
|
-
const safeId = sanitizeId(id);
|
|
1446
|
-
const safeAccount = sanitizeAccountName(targetAccount);
|
|
1447
|
-
// buildFolderReference validates the destination path; a malformed folder is
|
|
1448
|
-
// a precondition error, so let it throw. The destination folder must already
|
|
1449
|
-
// exist — Notes.app's `move` does not create it.
|
|
1450
|
-
const destFolderRef = `${buildFolderReference(destinationFolder)} of account "${safeAccount}"`;
|
|
1451
|
-
const moveCommand = `
|
|
1452
|
-
set destFolder to ${destFolderRef}
|
|
1453
|
-
set noteRef to note id "${safeId}"
|
|
1454
|
-
move noteRef to destFolder
|
|
1455
|
-
`;
|
|
1456
|
-
const script = buildAppLevelScript(moveCommand);
|
|
1457
|
-
const result = executeAppleScript(script);
|
|
1458
|
-
if (!result.success) {
|
|
1459
|
-
console.error(`Cannot move note to "${destinationFolder}" (folder may not exist):`, result.error);
|
|
1460
|
-
return false;
|
|
1461
|
-
}
|
|
1462
|
-
return true;
|
|
1463
|
-
}
|
|
1464
|
-
// ===========================================================================
|
|
1465
|
-
// Account Operations
|
|
1466
|
-
// ===========================================================================
|
|
1467
|
-
/**
|
|
1468
|
-
* Lists all available Notes accounts.
|
|
1469
|
-
*
|
|
1470
|
-
* Common accounts include iCloud, Gmail, Exchange, and other
|
|
1471
|
-
* email providers configured on the Mac.
|
|
1472
|
-
*
|
|
1473
|
-
* @returns Array of Account objects
|
|
1474
|
-
*/
|
|
1475
|
-
listAccounts() {
|
|
1476
|
-
// Coerce account records to text with control-char delimiters so names
|
|
1477
|
-
// containing commas or tabs can't split into phantom accounts (#18).
|
|
1478
|
-
const listCommand = `
|
|
1479
|
-
set resultList to {}
|
|
1480
|
-
repeat with a in accounts
|
|
1481
|
-
set aRef to contents of a
|
|
1482
|
-
set defaultFolderId to ""
|
|
1483
|
-
set defaultFolderName to ""
|
|
1484
|
-
try
|
|
1485
|
-
set fRef to default folder of aRef
|
|
1486
|
-
set defaultFolderId to id of fRef
|
|
1487
|
-
set defaultFolderName to name of fRef
|
|
1488
|
-
end try
|
|
1489
|
-
set upgradedFlag to upgraded of aRef as text
|
|
1490
|
-
set end of resultList to (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & upgradedFlag & ${AS_FIELD_SEP} & defaultFolderId & ${AS_FIELD_SEP} & defaultFolderName
|
|
1491
|
-
end repeat
|
|
1492
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1493
|
-
return resultList as text
|
|
1494
|
-
`;
|
|
1495
|
-
const script = buildAppLevelScript(listCommand);
|
|
1496
|
-
const result = executeAppleScript(script);
|
|
1497
|
-
if (!result.success) {
|
|
1498
|
-
throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
|
|
1499
|
-
}
|
|
1500
|
-
return result.output
|
|
1501
|
-
.split(RECORD_SEP)
|
|
1502
|
-
.map((s) => s.trim())
|
|
1503
|
-
.filter((s) => s.length > 0)
|
|
1504
|
-
.map((item) => {
|
|
1505
|
-
const parts = item.split(FIELD_SEP);
|
|
1506
|
-
if (parts.length === 1) {
|
|
1507
|
-
return { name: parts[0].trim() };
|
|
1508
|
-
}
|
|
1509
|
-
return {
|
|
1510
|
-
id: (parts[0] || "").trim(),
|
|
1511
|
-
name: (parts[1] || "").trim(),
|
|
1512
|
-
upgraded: (parts[2] || "").trim().toLowerCase() === "true",
|
|
1513
|
-
defaultFolderId: (parts[3] || "").trim() || undefined,
|
|
1514
|
-
defaultFolder: (parts[4] || "").trim() || undefined,
|
|
1515
|
-
};
|
|
1516
|
-
});
|
|
1517
|
-
}
|
|
1518
|
-
/**
|
|
1519
|
-
* Gets the default account and folder used by Notes.app for new notes.
|
|
1520
|
-
*
|
|
1521
|
-
* @returns Default account and folder metadata
|
|
1522
|
-
*/
|
|
1523
|
-
getDefaultLocation() {
|
|
1524
|
-
const command = `
|
|
1525
|
-
set aRef to default account
|
|
1526
|
-
set fRef to default folder of aRef
|
|
1527
|
-
return (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & (upgraded of aRef as text) & ${AS_FIELD_SEP} & (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & (shared of fRef as text)
|
|
1528
|
-
`;
|
|
1529
|
-
const result = executeAppleScript(buildAppLevelScript(command));
|
|
1530
|
-
if (!result.success) {
|
|
1531
|
-
throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
|
|
1532
|
-
}
|
|
1533
|
-
const parts = result.output.split(FIELD_SEP);
|
|
1534
|
-
if (parts.length < 6) {
|
|
1535
|
-
throw new Error(`Failed to parse default Notes location: ${result.output}`);
|
|
1536
|
-
}
|
|
1537
|
-
const accountName = (parts[1] || "").trim();
|
|
1538
|
-
return {
|
|
1539
|
-
account: {
|
|
1540
|
-
id: (parts[0] || "").trim(),
|
|
1541
|
-
name: accountName,
|
|
1542
|
-
upgraded: (parts[2] || "").trim().toLowerCase() === "true",
|
|
1543
|
-
defaultFolderId: (parts[3] || "").trim(),
|
|
1544
|
-
defaultFolder: (parts[4] || "").trim(),
|
|
1545
|
-
},
|
|
1546
|
-
folder: {
|
|
1547
|
-
id: (parts[3] || "").trim(),
|
|
1548
|
-
name: (parts[4] || "").trim(),
|
|
1549
|
-
account: accountName,
|
|
1550
|
-
shared: (parts[5] || "").trim().toLowerCase() === "true",
|
|
1551
|
-
},
|
|
1552
|
-
};
|
|
1553
|
-
}
|
|
1554
|
-
/**
|
|
1555
|
-
* Lists the currently selected Notes in the Notes.app UI.
|
|
1556
|
-
*
|
|
1557
|
-
* @returns Array of selected notes, or an empty array when nothing is selected
|
|
1558
|
-
*/
|
|
1559
|
-
getSelectedNotes() {
|
|
1560
|
-
const command = `
|
|
1561
|
-
set selectedNotes to selection
|
|
1562
|
-
set noteList to {}
|
|
1563
|
-
repeat with n in selectedNotes
|
|
1564
|
-
set nRef to contents of n
|
|
1565
|
-
set createdDate to creation date of nRef
|
|
1566
|
-
set modifiedDate to modification date of nRef
|
|
1567
|
-
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
1568
|
-
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
1569
|
-
set folderName to ""
|
|
1570
|
-
set accountName to ""
|
|
1571
|
-
try
|
|
1572
|
-
set fRef to container of nRef
|
|
1573
|
-
set folderName to name of fRef
|
|
1574
|
-
set aRef to container of fRef
|
|
1575
|
-
set accountName to name of aRef
|
|
1576
|
-
end try
|
|
1577
|
-
set end of noteList to (id of nRef) & ${AS_FIELD_SEP} & (name of nRef) & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & (shared of nRef as text) & ${AS_FIELD_SEP} & (password protected of nRef as text) & ${AS_FIELD_SEP} & folderName & ${AS_FIELD_SEP} & accountName
|
|
1578
|
-
end repeat
|
|
1579
|
-
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1580
|
-
return noteList as text
|
|
1581
|
-
`;
|
|
1582
|
-
const result = executeAppleScript(buildAppLevelScript(command));
|
|
1583
|
-
if (!result.success) {
|
|
1584
|
-
throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
|
|
1585
|
-
}
|
|
1586
|
-
if (!result.output.trim()) {
|
|
1587
|
-
return [];
|
|
1588
|
-
}
|
|
1589
|
-
return result.output
|
|
1590
|
-
.split(RECORD_SEP)
|
|
1591
|
-
.filter((s) => s.trim())
|
|
1592
|
-
.map((item) => {
|
|
1593
|
-
const parts = item.split(FIELD_SEP);
|
|
1594
|
-
return {
|
|
1595
|
-
id: (parts[0] || "").trim(),
|
|
1596
|
-
title: (parts[1] || "").trim(),
|
|
1597
|
-
content: "",
|
|
1598
|
-
tags: [],
|
|
1599
|
-
created: parseAppleScriptDate((parts[2] || "").trim()),
|
|
1600
|
-
modified: parseAppleScriptDate((parts[3] || "").trim()),
|
|
1601
|
-
shared: (parts[4] || "").trim().toLowerCase() === "true",
|
|
1602
|
-
passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
|
|
1603
|
-
folder: (parts[6] || "").trim() || undefined,
|
|
1604
|
-
account: (parts[7] || "").trim() || undefined,
|
|
1605
|
-
};
|
|
1606
|
-
});
|
|
1607
|
-
}
|
|
1608
|
-
/**
|
|
1609
|
-
* Reveals a note in the Notes.app UI by ID.
|
|
1610
|
-
*
|
|
1611
|
-
* @param id - CoreData URL identifier for the note
|
|
1612
|
-
* @param separately - Whether to open the note in a separate window
|
|
1613
|
-
* @returns true if Notes.app accepted the show command
|
|
1614
|
-
*/
|
|
1615
|
-
showNoteById(id, separately = false) {
|
|
1616
|
-
const safeId = sanitizeId(id);
|
|
1617
|
-
const separatelyClause = separately ? " separately true" : "";
|
|
1618
|
-
const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
|
|
1619
|
-
if (!result.success) {
|
|
1620
|
-
console.error(`Failed to show note with ID "${id}":`, result.error);
|
|
1621
|
-
return false;
|
|
1622
|
-
}
|
|
1623
|
-
return true;
|
|
1624
|
-
}
|
|
1625
|
-
/**
|
|
1626
|
-
* Reveals a folder in the Notes.app UI by its id.
|
|
1627
|
-
*
|
|
1628
|
-
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1629
|
-
* folders as well as notes. This opens or focuses the Notes UI on the folder.
|
|
1630
|
-
*
|
|
1631
|
-
* @param id - CoreData identifier for the folder (from list-folders)
|
|
1632
|
-
* @param separately - Open in a separate window when supported by Notes.app
|
|
1633
|
-
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1634
|
-
*/
|
|
1635
|
-
showFolderById(id, separately = false) {
|
|
1636
|
-
const safeId = sanitizeId(id);
|
|
1637
|
-
const separatelyClause = separately ? " separately true" : "";
|
|
1638
|
-
const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
|
|
1639
|
-
if (!result.success) {
|
|
1640
|
-
console.error(`Failed to show folder with ID "${id}":`, result.error);
|
|
1641
|
-
return false;
|
|
1642
|
-
}
|
|
1643
|
-
return true;
|
|
1644
|
-
}
|
|
1645
|
-
/**
|
|
1646
|
-
* Reveals an account in the Notes.app UI by its id.
|
|
1647
|
-
*
|
|
1648
|
-
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1649
|
-
* accounts as well as notes. This opens or focuses the Notes UI on the account.
|
|
1650
|
-
*
|
|
1651
|
-
* @param id - CoreData identifier for the account (from list-accounts)
|
|
1652
|
-
* @param separately - Open in a separate window when supported by Notes.app
|
|
1653
|
-
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1654
|
-
*/
|
|
1655
|
-
showAccountById(id, separately = false) {
|
|
1656
|
-
const safeId = sanitizeId(id);
|
|
1657
|
-
const separatelyClause = separately ? " separately true" : "";
|
|
1658
|
-
const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
|
|
1659
|
-
if (!result.success) {
|
|
1660
|
-
console.error(`Failed to show account with ID "${id}":`, result.error);
|
|
1661
|
-
return false;
|
|
1662
|
-
}
|
|
1663
|
-
return true;
|
|
1664
|
-
}
|
|
1665
|
-
/**
|
|
1666
|
-
* Reveals an attachment in the Notes.app UI.
|
|
1667
|
-
*
|
|
1668
|
-
* Attachments are elements of a note, so they cannot be referenced at the
|
|
1669
|
-
* application level by id alone. This resolves the attachment within its note
|
|
1670
|
-
* (the same lookup used by save-attachment) and then runs the Notes `show`
|
|
1671
|
-
* command on it, opening or focusing the Notes UI on the attachment.
|
|
1672
|
-
*
|
|
1673
|
-
* @param noteId - CoreData identifier for the note containing the attachment
|
|
1674
|
-
* @param attachmentId - id of the attachment (from list-attachments)
|
|
1675
|
-
* @param separately - Open in a separate window when supported by Notes.app
|
|
1676
|
-
* @returns true if Notes.app revealed the attachment, false otherwise
|
|
1677
|
-
*/
|
|
1678
|
-
showAttachmentById(noteId, attachmentId, separately = false) {
|
|
1679
|
-
const safeNoteId = sanitizeId(noteId);
|
|
1680
|
-
const safeAttId = escapePlainStringForAppleScript(attachmentId);
|
|
1681
|
-
const separatelyClause = separately ? " separately true" : "";
|
|
1682
|
-
const script = `
|
|
1683
|
-
tell application "Notes"
|
|
1684
|
-
set theNote to note id "${safeNoteId}"
|
|
1685
|
-
set theAttachment to missing value
|
|
1686
|
-
repeat with a in attachments of theNote
|
|
1687
|
-
if (id of a as text) is "${safeAttId}" then
|
|
1688
|
-
set theAttachment to a
|
|
1689
|
-
exit repeat
|
|
1690
|
-
end if
|
|
1691
|
-
end repeat
|
|
1692
|
-
if theAttachment is missing value then
|
|
1693
|
-
return "ERR${AS_FIELD_SEP}attachment not found"
|
|
1694
|
-
end if
|
|
1695
|
-
show theAttachment${separatelyClause}
|
|
1696
|
-
return "OK"
|
|
1697
|
-
end tell
|
|
1698
|
-
`;
|
|
1699
|
-
const result = executeAppleScript(script);
|
|
1700
|
-
if (!result.success) {
|
|
1701
|
-
console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
|
|
1702
|
-
return false;
|
|
1703
|
-
}
|
|
1704
|
-
if ((result.output ?? "").trim().startsWith("ERR")) {
|
|
1705
|
-
console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
|
|
1706
|
-
return false;
|
|
1707
|
-
}
|
|
1708
|
-
return true;
|
|
1709
|
-
}
|
|
1710
|
-
// ===========================================================================
|
|
1711
|
-
// Health Check
|
|
1712
|
-
// ===========================================================================
|
|
1713
|
-
/**
|
|
1714
|
-
* Performs a health check on Notes.app accessibility and functionality.
|
|
1715
|
-
*
|
|
1716
|
-
* This method verifies:
|
|
1717
|
-
* - Notes.app is installed and accessible
|
|
1718
|
-
* - AppleScript automation permissions are granted
|
|
1719
|
-
* - At least one account is available
|
|
1720
|
-
* - Basic list operations work
|
|
1721
|
-
*
|
|
1722
|
-
* Use this to diagnose connection issues or verify setup.
|
|
1723
|
-
*
|
|
1724
|
-
* @returns HealthCheckResult with overall status and individual check details
|
|
1725
|
-
*
|
|
1726
|
-
* @example
|
|
1727
|
-
* ```typescript
|
|
1728
|
-
* const health = manager.healthCheck();
|
|
1729
|
-
* if (!health.healthy) {
|
|
1730
|
-
* console.log("Issues found:");
|
|
1731
|
-
* health.checks.filter(c => !c.passed).forEach(c => console.log(`- ${c.message}`));
|
|
1732
|
-
* }
|
|
1733
|
-
* ```
|
|
1734
|
-
*/
|
|
1735
|
-
healthCheck() {
|
|
1736
|
-
const checks = [];
|
|
1737
|
-
// Check 1: Notes.app is accessible
|
|
1738
|
-
const appCheck = executeAppleScript('tell application "Notes" to return "ok"');
|
|
1739
|
-
if (appCheck.success && appCheck.output === "ok") {
|
|
1740
|
-
checks.push({
|
|
1741
|
-
name: "notes_app",
|
|
1742
|
-
passed: true,
|
|
1743
|
-
message: "Notes.app is accessible",
|
|
1744
|
-
});
|
|
1745
|
-
}
|
|
1746
|
-
else {
|
|
1747
|
-
const errorHint = appCheck.error?.includes("not authorized")
|
|
1748
|
-
? " (check Automation permissions in System Preferences)"
|
|
1749
|
-
: "";
|
|
1750
|
-
checks.push({
|
|
1751
|
-
name: "notes_app",
|
|
1752
|
-
passed: false,
|
|
1753
|
-
message: `Notes.app is not accessible${errorHint}`,
|
|
1754
|
-
});
|
|
1755
|
-
// If Notes.app isn't accessible, skip other checks
|
|
1756
|
-
return { healthy: false, checks };
|
|
1757
|
-
}
|
|
1758
|
-
// Check 2: AppleScript permissions (can we execute commands?)
|
|
1759
|
-
const permCheck = executeAppleScript('tell application "Notes" to get name of account 1');
|
|
1760
|
-
if (permCheck.success) {
|
|
1761
|
-
checks.push({
|
|
1762
|
-
name: "permissions",
|
|
1763
|
-
passed: true,
|
|
1764
|
-
message: "AppleScript automation permissions granted",
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
else {
|
|
1768
|
-
const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
|
|
1769
|
-
checks.push({
|
|
1770
|
-
name: "permissions",
|
|
1771
|
-
passed: !isPermError,
|
|
1772
|
-
message: isPermError
|
|
1773
|
-
? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
|
|
1774
|
-
: `Permission check returned: ${permCheck.error}`,
|
|
1775
|
-
});
|
|
1776
|
-
if (isPermError) {
|
|
1777
|
-
return { healthy: false, checks };
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
// Check 3: At least one account accessible
|
|
1781
|
-
const accounts = this.listAccounts();
|
|
1782
|
-
if (accounts.length > 0) {
|
|
1783
|
-
const accountNames = accounts.map((a) => a.name).join(", ");
|
|
1784
|
-
checks.push({
|
|
1785
|
-
name: "accounts",
|
|
1786
|
-
passed: true,
|
|
1787
|
-
message: `Found ${accounts.length} account(s): ${accountNames}`,
|
|
1788
|
-
});
|
|
1789
|
-
}
|
|
1790
|
-
else {
|
|
1791
|
-
checks.push({
|
|
1792
|
-
name: "accounts",
|
|
1793
|
-
passed: false,
|
|
1794
|
-
message: "No Notes accounts found. Set up an account in Notes.app first.",
|
|
1795
|
-
});
|
|
1796
|
-
return { healthy: false, checks };
|
|
1797
|
-
}
|
|
1798
|
-
// Check 4: Basic operations work (list notes in default account)
|
|
1799
|
-
const defaultAccount = accounts[0]?.name || "iCloud";
|
|
1800
|
-
const notes = this.listNotes(defaultAccount);
|
|
1801
|
-
// Even 0 notes is fine - we just want to verify the operation works
|
|
1802
|
-
checks.push({
|
|
1803
|
-
name: "operations",
|
|
1804
|
-
passed: true,
|
|
1805
|
-
message: `Basic operations working (${notes.length} note(s) in ${defaultAccount})`,
|
|
1806
|
-
});
|
|
1807
|
-
const allPassed = checks.every((c) => c.passed);
|
|
1808
|
-
return { healthy: allPassed, checks };
|
|
1809
|
-
}
|
|
1810
|
-
// ===========================================================================
|
|
1811
|
-
// Statistics
|
|
1812
|
-
// ===========================================================================
|
|
1813
|
-
/**
|
|
1814
|
-
* Gets comprehensive statistics about notes across all accounts.
|
|
1815
|
-
*
|
|
1816
|
-
* Returns total note counts, per-account breakdowns, folder statistics,
|
|
1817
|
-
* and counts of recently modified notes.
|
|
1818
|
-
*
|
|
1819
|
-
* @returns NotesStats object with comprehensive statistics
|
|
1820
|
-
*
|
|
1821
|
-
* @example
|
|
1822
|
-
* ```typescript
|
|
1823
|
-
* const stats = manager.getNotesStats();
|
|
1824
|
-
* console.log(`Total notes: ${stats.totalNotes}`);
|
|
1825
|
-
* console.log(`Modified today: ${stats.recentlyModified.last24h}`);
|
|
1826
|
-
* ```
|
|
1827
|
-
*/
|
|
1828
|
-
getNotesStats() {
|
|
1829
|
-
const accounts = this.listAccounts();
|
|
1830
|
-
const accountStats = [];
|
|
1831
|
-
const warnings = [];
|
|
1832
|
-
let totalNotes = 0;
|
|
1833
|
-
// Collect stats per account with ONE bounded script per account (#20/#26):
|
|
1834
|
-
// count notes server-side per folder instead of fetching every note's name
|
|
1835
|
-
// (unbounded) via a listNotes call per folder (N+1 osascript spawns).
|
|
1836
|
-
//
|
|
1837
|
-
// Per-account failures degrade gracefully (#19): a single unreachable or
|
|
1838
|
-
// locked account is recorded as a coverage warning and skipped, rather than
|
|
1839
|
-
// discarding the stats for every healthy account. Only a total wipeout
|
|
1840
|
-
// (no account readable) is escalated to a thrown error below.
|
|
1841
|
-
for (const account of accounts) {
|
|
1842
|
-
const countScript = buildAccountScopedScript({ account: account.name }, `
|
|
1843
|
-
set out to ""
|
|
1844
|
-
repeat with fldr in folders
|
|
1845
|
-
set out to out & (name of fldr) & ${AS_FIELD_SEP} & (count of notes of fldr) & ${AS_RECORD_SEP}
|
|
1846
|
-
end repeat
|
|
1847
|
-
return out
|
|
1848
|
-
`);
|
|
1849
|
-
const res = executeAppleScript(countScript);
|
|
1850
|
-
if (!res.success) {
|
|
1851
|
-
warnings.push({ scope: account.name, reason: res.error ?? "unknown error" });
|
|
1852
|
-
continue;
|
|
1853
|
-
}
|
|
1854
|
-
const folderStats = [];
|
|
1855
|
-
let accountTotal = 0;
|
|
1856
|
-
for (const rec of res.output.split(RECORD_SEP)) {
|
|
1857
|
-
if (!rec.trim())
|
|
1858
|
-
continue;
|
|
1859
|
-
const [fname, cnt] = rec.split(FIELD_SEP);
|
|
1860
|
-
const noteCount = parseInt((cnt ?? "").trim(), 10) || 0;
|
|
1861
|
-
accountTotal += noteCount;
|
|
1862
|
-
folderStats.push({ name: (fname ?? "").trim(), noteCount });
|
|
1863
|
-
}
|
|
1864
|
-
totalNotes += accountTotal;
|
|
1865
|
-
accountStats.push({
|
|
1866
|
-
name: account.name,
|
|
1867
|
-
totalNotes: accountTotal,
|
|
1868
|
-
folderCount: folderStats.length,
|
|
1869
|
-
folders: folderStats,
|
|
1870
|
-
});
|
|
1871
|
-
}
|
|
1872
|
-
// If every account failed, there is no data to report — surface the error
|
|
1873
|
-
// (#19) rather than returning a deceptively empty stats object.
|
|
1874
|
-
if (accounts.length > 0 && accountStats.length === 0) {
|
|
1875
|
-
throw new Error(`Failed to read folder stats for any of ${accounts.length} account(s): ${warnings
|
|
1876
|
-
.map((w) => `${w.scope} (${w.reason})`)
|
|
1877
|
-
.join("; ")}`);
|
|
1878
|
-
}
|
|
1879
|
-
// Get recently modified notes counts. A failure here is non-fatal — record a
|
|
1880
|
-
// coverage warning and report zeros, flagged as not-covered (#19), instead of
|
|
1881
|
-
// passing off fake zero activity as real.
|
|
1882
|
-
const recent = this.getRecentlyModifiedCounts();
|
|
1883
|
-
if (recent.error) {
|
|
1884
|
-
warnings.push({ scope: "recent-activity", reason: recent.error });
|
|
1885
|
-
}
|
|
1886
|
-
// scopes = each account + the recent-activity scan
|
|
1887
|
-
const scanned = accounts.length + 1;
|
|
1888
|
-
const covered = scanned - warnings.length;
|
|
1889
|
-
return {
|
|
1890
|
-
totalNotes,
|
|
1891
|
-
accounts: accountStats,
|
|
1892
|
-
recentlyModified: recent.counts,
|
|
1893
|
-
coverage: {
|
|
1894
|
-
complete: warnings.length === 0,
|
|
1895
|
-
scanned,
|
|
1896
|
-
covered,
|
|
1897
|
-
warnings,
|
|
1898
|
-
},
|
|
1899
|
-
};
|
|
1900
|
-
}
|
|
1901
|
-
/**
|
|
1902
|
-
* Helper to get counts of recently modified notes.
|
|
1903
|
-
*/
|
|
1904
|
-
getRecentlyModifiedCounts() {
|
|
1905
|
-
// Count server-side with locale-safe date variables (#20/#25): instead of
|
|
1906
|
-
// streaming every note's modification date to JS (unbounded, ENOBUFS-prone,
|
|
1907
|
-
// locale-fragile), let AppleScript count matches via a `whose` filter — three
|
|
1908
|
-
// counts per account, regardless of library size.
|
|
1909
|
-
const now = new Date();
|
|
1910
|
-
const d1 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
1911
|
-
const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
1912
|
-
const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
1913
|
-
const script = `
|
|
1914
|
-
tell application "Notes"
|
|
1915
|
-
${buildAppleScriptDateVar(d1, "d1")}
|
|
1916
|
-
${buildAppleScriptDateVar(d7, "d7")}
|
|
1917
|
-
${buildAppleScriptDateVar(d30, "d30")}
|
|
1918
|
-
set c1 to 0
|
|
1919
|
-
set c7 to 0
|
|
1920
|
-
set c30 to 0
|
|
1921
|
-
repeat with acct in accounts
|
|
1922
|
-
set c1 to c1 + (count of (notes of acct whose modification date >= d1))
|
|
1923
|
-
set c7 to c7 + (count of (notes of acct whose modification date >= d7))
|
|
1924
|
-
set c30 to c30 + (count of (notes of acct whose modification date >= d30))
|
|
1925
|
-
end repeat
|
|
1926
|
-
return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
|
|
1927
|
-
end tell
|
|
1928
|
-
`;
|
|
1929
|
-
const result = executeAppleScript(script);
|
|
1930
|
-
if (!result.success) {
|
|
1931
|
-
// Non-fatal (#19): report the error to the caller so it becomes a coverage
|
|
1932
|
-
// warning, with zeroed counts, instead of throwing away the whole stats
|
|
1933
|
-
// result or passing off fake zero activity as real.
|
|
1934
|
-
return {
|
|
1935
|
-
counts: { last24h: 0, last7d: 0, last30d: 0 },
|
|
1936
|
-
error: result.error ?? "unknown error",
|
|
1937
|
-
};
|
|
1938
|
-
}
|
|
1939
|
-
const parts = result.output.trim().split(FIELD_SEP);
|
|
1940
|
-
const toInt = (s) => {
|
|
1941
|
-
const n = parseInt((s ?? "").trim(), 10);
|
|
1942
|
-
return Number.isFinite(n) ? n : 0;
|
|
1943
|
-
};
|
|
1944
|
-
return {
|
|
1945
|
-
counts: { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) },
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
// ===========================================================================
|
|
1949
|
-
// Attachments
|
|
1950
|
-
// ===========================================================================
|
|
1951
|
-
/**
|
|
1952
|
-
* Lists attachments for a note by its ID.
|
|
1953
|
-
*
|
|
1954
|
-
* Returns metadata about each attachment including name and content type.
|
|
1955
|
-
* Note: The position within the note cannot be determined via AppleScript.
|
|
1956
|
-
*
|
|
1957
|
-
* @param id - CoreData URL identifier for the note
|
|
1958
|
-
* @returns Array of Attachment objects, or empty array if none found
|
|
1959
|
-
*
|
|
1960
|
-
* @example
|
|
1961
|
-
* ```typescript
|
|
1962
|
-
* const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
|
|
1963
|
-
* attachments.forEach(a => console.log(`${a.name}: ${a.contentType}`));
|
|
1964
|
-
* ```
|
|
1965
|
-
*/
|
|
1966
|
-
listAttachmentsById(id) {
|
|
1967
|
-
const safeId = sanitizeId(id);
|
|
1968
|
-
const script = `
|
|
1969
|
-
tell application "Notes"
|
|
1970
|
-
set theNote to note id "${safeId}"
|
|
1971
|
-
set attachmentList to {}
|
|
1972
|
-
repeat with a in attachments of theNote
|
|
1973
|
-
set attachId to id of a
|
|
1974
|
-
set attachName to name of a
|
|
1975
|
-
set attachContentId to content identifier of a
|
|
1976
|
-
set attachUrl to ""
|
|
1977
|
-
try
|
|
1978
|
-
set attachUrl to URL of a as text
|
|
1979
|
-
end try
|
|
1980
|
-
set createdDate to creation date of a
|
|
1981
|
-
set modifiedDate to modification date of a
|
|
1982
|
-
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
1983
|
-
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
1984
|
-
set sharedFlag to shared of a as text
|
|
1985
|
-
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
|
|
1986
|
-
end repeat
|
|
1987
|
-
set output to ""
|
|
1988
|
-
repeat with item in attachmentList
|
|
1989
|
-
set output to output & item & ${AS_RECORD_SEP}
|
|
1990
|
-
end repeat
|
|
1991
|
-
return output
|
|
1992
|
-
end tell
|
|
1993
|
-
`;
|
|
1994
|
-
const result = executeAppleScript(script);
|
|
1995
|
-
if (!result.success || !result.output) {
|
|
1996
|
-
if (result.error) {
|
|
1997
|
-
console.error(`Failed to list attachments for note ID "${id}":`, result.error);
|
|
1998
|
-
}
|
|
1999
|
-
return [];
|
|
2000
|
-
}
|
|
2001
|
-
// Parse the results
|
|
2002
|
-
const attachments = [];
|
|
2003
|
-
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
2004
|
-
for (const item of items) {
|
|
2005
|
-
const parts = item.split(FIELD_SEP);
|
|
2006
|
-
if (parts.length >= 3) {
|
|
2007
|
-
attachments.push({
|
|
2008
|
-
id: parts[0].trim(),
|
|
2009
|
-
name: parts[1].trim(),
|
|
2010
|
-
contentType: parts[2].trim(),
|
|
2011
|
-
contentId: parts[2].trim() || undefined,
|
|
2012
|
-
url: parts[3]?.trim() || undefined,
|
|
2013
|
-
created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
|
|
2014
|
-
modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
|
|
2015
|
-
shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
|
|
2016
|
-
});
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
return attachments;
|
|
2020
|
-
}
|
|
2021
|
-
/**
|
|
2022
|
-
* Lists attachments for a note by its title.
|
|
2023
|
-
*
|
|
2024
|
-
* @param title - Title of the note
|
|
2025
|
-
* @param account - Account containing the note (defaults to iCloud)
|
|
2026
|
-
* @returns Array of Attachment objects, or empty array if none found
|
|
2027
|
-
*/
|
|
2028
|
-
listAttachments(title, account) {
|
|
2029
|
-
const targetAccount = this.resolveAccount(account);
|
|
2030
|
-
const safeAccount = escapePlainStringForAppleScript(targetAccount);
|
|
2031
|
-
const safeTitle = escapePlainStringForAppleScript(title);
|
|
2032
|
-
const script = `
|
|
2033
|
-
tell application "Notes"
|
|
2034
|
-
tell account "${safeAccount}"
|
|
2035
|
-
set theNote to note "${safeTitle}"
|
|
2036
|
-
set attachmentList to {}
|
|
2037
|
-
repeat with a in attachments of theNote
|
|
2038
|
-
set attachId to id of a
|
|
2039
|
-
set attachName to name of a
|
|
2040
|
-
set attachContentId to content identifier of a
|
|
2041
|
-
set attachUrl to ""
|
|
2042
|
-
try
|
|
2043
|
-
set attachUrl to URL of a as text
|
|
2044
|
-
end try
|
|
2045
|
-
set createdDate to creation date of a
|
|
2046
|
-
set modifiedDate to modification date of a
|
|
2047
|
-
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
2048
|
-
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
2049
|
-
set sharedFlag to shared of a as text
|
|
2050
|
-
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
|
|
2051
|
-
end repeat
|
|
2052
|
-
set output to ""
|
|
2053
|
-
repeat with item in attachmentList
|
|
2054
|
-
set output to output & item & ${AS_RECORD_SEP}
|
|
2055
|
-
end repeat
|
|
2056
|
-
return output
|
|
2057
|
-
end tell
|
|
2058
|
-
end tell
|
|
2059
|
-
`;
|
|
2060
|
-
const result = executeAppleScript(script);
|
|
2061
|
-
if (!result.success || !result.output) {
|
|
2062
|
-
if (result.error) {
|
|
2063
|
-
console.error(`Failed to list attachments for note "${title}":`, result.error);
|
|
2064
|
-
}
|
|
2065
|
-
return [];
|
|
2066
|
-
}
|
|
2067
|
-
// Parse the results
|
|
2068
|
-
const attachments = [];
|
|
2069
|
-
const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
|
|
2070
|
-
for (const item of items) {
|
|
2071
|
-
const parts = item.split(FIELD_SEP);
|
|
2072
|
-
if (parts.length >= 3) {
|
|
2073
|
-
attachments.push({
|
|
2074
|
-
id: parts[0].trim(),
|
|
2075
|
-
name: parts[1].trim(),
|
|
2076
|
-
contentType: parts[2].trim(),
|
|
2077
|
-
contentId: parts[2].trim() || undefined,
|
|
2078
|
-
url: parts[3]?.trim() || undefined,
|
|
2079
|
-
created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
|
|
2080
|
-
modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
|
|
2081
|
-
shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
|
|
2082
|
-
});
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
return attachments;
|
|
2086
|
-
}
|
|
2087
|
-
/**
|
|
2088
|
-
* Saves a single attachment of a note (identified by attachment id) to a file
|
|
2089
|
-
* on disk via Notes.app's AppleScript `save` (#27).
|
|
2090
|
-
*
|
|
2091
|
-
* @param noteId - CoreData URL identifier for the note
|
|
2092
|
-
* @param attachmentId - id of the attachment (from list-attachments)
|
|
2093
|
-
* @param savePath - absolute destination file path (within home / temp / /Volumes)
|
|
2094
|
-
* @returns { success, savedPath?, name?, contentType?, error? }
|
|
2095
|
-
*/
|
|
2096
|
-
saveAttachmentById(noteId, attachmentId, savePath) {
|
|
2097
|
-
let abs;
|
|
2098
|
-
try {
|
|
2099
|
-
abs = assertSafeSavePath(savePath);
|
|
2100
|
-
}
|
|
2101
|
-
catch (e) {
|
|
2102
|
-
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
2103
|
-
}
|
|
2104
|
-
const safeNoteId = sanitizeId(noteId);
|
|
2105
|
-
const safeAttId = escapePlainStringForAppleScript(attachmentId);
|
|
2106
|
-
const safePath = escapePlainStringForAppleScript(abs);
|
|
2107
|
-
const script = `
|
|
2108
|
-
tell application "Notes"
|
|
2109
|
-
set theNote to note id "${safeNoteId}"
|
|
2110
|
-
set theAttachment to missing value
|
|
2111
|
-
repeat with a in attachments of theNote
|
|
2112
|
-
if (id of a as text) is "${safeAttId}" then
|
|
2113
|
-
set theAttachment to a
|
|
2114
|
-
exit repeat
|
|
2115
|
-
end if
|
|
2116
|
-
end repeat
|
|
2117
|
-
if theAttachment is missing value then
|
|
2118
|
-
return "ERR${AS_FIELD_SEP}attachment not found"
|
|
2119
|
-
end if
|
|
2120
|
-
save theAttachment in (POSIX file "${safePath}")
|
|
2121
|
-
return "OK${AS_FIELD_SEP}" & (name of theAttachment) & "${AS_FIELD_SEP}" & (content identifier of theAttachment)
|
|
2122
|
-
end tell
|
|
2123
|
-
`;
|
|
2124
|
-
const result = executeAppleScript(script);
|
|
2125
|
-
if (!result.success) {
|
|
2126
|
-
return { success: false, error: result.error ?? "unknown error" };
|
|
2127
|
-
}
|
|
2128
|
-
const parts = (result.output ?? "").trim().split(FIELD_SEP);
|
|
2129
|
-
if (parts[0] !== "OK") {
|
|
2130
|
-
return { success: false, error: parts[1]?.trim() || "attachment not found" };
|
|
2131
|
-
}
|
|
2132
|
-
if (!existsSync(abs) || fileSize(abs) === 0) {
|
|
2133
|
-
return { success: false, error: `Notes reported success but no file was written to ${abs}` };
|
|
2134
|
-
}
|
|
2135
|
-
return {
|
|
2136
|
-
success: true,
|
|
2137
|
-
savedPath: abs,
|
|
2138
|
-
name: parts[1]?.trim(),
|
|
2139
|
-
contentType: parts[2]?.trim(),
|
|
2140
|
-
};
|
|
2141
|
-
}
|
|
2142
|
-
/**
|
|
2143
|
-
* Fetches a note attachment as base64 (#27). Exports to a private temp file,
|
|
2144
|
-
* reads it, then deletes the temp copy.
|
|
2145
|
-
*
|
|
2146
|
-
* @param noteId - CoreData URL identifier for the note
|
|
2147
|
-
* @param attachmentId - id of the attachment
|
|
2148
|
-
* @returns { success, name?, contentType?, base64?, bytes?, error? }
|
|
2149
|
-
*/
|
|
2150
|
-
getAttachmentBase64ById(noteId, attachmentId) {
|
|
2151
|
-
const dir = makeTempDir();
|
|
2152
|
-
try {
|
|
2153
|
-
const dest = `${dir}/attachment.bin`;
|
|
2154
|
-
const saved = this.saveAttachmentById(noteId, attachmentId, dest);
|
|
2155
|
-
if (!saved.success || !saved.savedPath) {
|
|
2156
|
-
return { success: false, error: saved.error };
|
|
2157
|
-
}
|
|
2158
|
-
// readFileBase64Capped checks the file size BEFORE reading and throws if it
|
|
2159
|
-
// exceeds APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES — the throw is caught below
|
|
2160
|
-
// and the temp dir is still cleaned up in `finally`.
|
|
2161
|
-
const base64 = readFileBase64Capped(saved.savedPath);
|
|
2162
|
-
return {
|
|
2163
|
-
success: true,
|
|
2164
|
-
name: saved.name,
|
|
2165
|
-
contentType: saved.contentType,
|
|
2166
|
-
base64,
|
|
2167
|
-
bytes: fileSize(saved.savedPath),
|
|
2168
|
-
};
|
|
2169
|
-
}
|
|
2170
|
-
catch (e) {
|
|
2171
|
-
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
2172
|
-
}
|
|
2173
|
-
finally {
|
|
2174
|
-
cleanupTempDir(dir);
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
// ===========================================================================
|
|
2178
|
-
// Batch Operations
|
|
2179
|
-
// ===========================================================================
|
|
2180
|
-
/**
|
|
2181
|
-
* Result of a batch operation on a single item.
|
|
2182
|
-
*/
|
|
2183
|
-
createBatchResult(id, success, error) {
|
|
2184
|
-
return error ? { id, success, error } : { id, success };
|
|
2185
|
-
}
|
|
2186
|
-
/**
|
|
2187
|
-
* Deletes multiple notes by their IDs.
|
|
2188
|
-
*
|
|
2189
|
-
* Each deletion is attempted independently; failures don't stop other deletions.
|
|
2190
|
-
* Returns results for each note indicating success or failure.
|
|
2191
|
-
*
|
|
2192
|
-
* @param ids - Array of CoreData URL identifiers for notes to delete
|
|
2193
|
-
* @returns Array of results with id, success status, and optional error message
|
|
2194
|
-
*
|
|
2195
|
-
* @example
|
|
2196
|
-
* ```typescript
|
|
2197
|
-
* const results = manager.batchDeleteNotes([
|
|
2198
|
-
* "x-coredata://ABC/ICNote/p1",
|
|
2199
|
-
* "x-coredata://ABC/ICNote/p2"
|
|
2200
|
-
* ]);
|
|
2201
|
-
* results.forEach(r => {
|
|
2202
|
-
* if (r.success) console.log(`Deleted ${r.id}`);
|
|
2203
|
-
* else console.log(`Failed to delete ${r.id}: ${r.error}`);
|
|
2204
|
-
* });
|
|
2205
|
-
* ```
|
|
2206
|
-
*/
|
|
2207
|
-
batchDeleteNotes(ids) {
|
|
2208
|
-
if (ids.length === 0)
|
|
2209
|
-
return [];
|
|
2210
|
-
// Collapse the whole batch into ONE osascript spawn (#26): a single
|
|
2211
|
-
// app-level script loops over every id, with a per-id `try` so one bad note
|
|
2212
|
-
// can't abort the rest. The old path spawned 3 processes per note
|
|
2213
|
-
// (getNoteById + isNotePasswordProtectedById + deleteNoteById) — i.e. 3N
|
|
2214
|
-
// spawns for N notes. This is one spawn total, with the same per-item
|
|
2215
|
-
// isolation and result semantics.
|
|
2216
|
-
const results = new Array(ids.length);
|
|
2217
|
-
const runnable = [];
|
|
2218
|
-
ids.forEach((id, i) => {
|
|
2219
|
-
try {
|
|
2220
|
-
runnable.push({ index: i, safe: sanitizeId(id) });
|
|
2221
|
-
}
|
|
2222
|
-
catch (e) {
|
|
2223
|
-
results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
|
|
2224
|
-
}
|
|
2225
|
-
});
|
|
2226
|
-
if (runnable.length > 0) {
|
|
2227
|
-
const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
|
|
2228
|
-
const script = buildAppLevelScript(`
|
|
2229
|
-
set out to ""
|
|
2230
|
-
repeat with rawId in {${idList}}
|
|
2231
|
-
set theId to (rawId as text)
|
|
2232
|
-
set noteRef to missing value
|
|
2233
|
-
try
|
|
2234
|
-
set noteRef to note id theId
|
|
2235
|
-
end try
|
|
2236
|
-
if noteRef is missing value then
|
|
2237
|
-
set out to out & "missing" & ${AS_RECORD_SEP}
|
|
2238
|
-
else
|
|
2239
|
-
set isPw to false
|
|
2240
|
-
try
|
|
2241
|
-
set isPw to (password protected of noteRef)
|
|
2242
|
-
end try
|
|
2243
|
-
if isPw then
|
|
2244
|
-
set out to out & "pw" & ${AS_RECORD_SEP}
|
|
2245
|
-
else
|
|
2246
|
-
try
|
|
2247
|
-
delete noteRef
|
|
2248
|
-
set out to out & "ok" & ${AS_RECORD_SEP}
|
|
2249
|
-
on error
|
|
2250
|
-
set out to out & "fail" & ${AS_RECORD_SEP}
|
|
2251
|
-
end try
|
|
2252
|
-
end if
|
|
2253
|
-
end if
|
|
2254
|
-
end repeat
|
|
2255
|
-
return out
|
|
2256
|
-
`);
|
|
2257
|
-
const res = executeAppleScript(script);
|
|
2258
|
-
if (!res.success) {
|
|
2259
|
-
// Whole-batch failure (e.g. Notes.app not responding): can't isolate,
|
|
2260
|
-
// so mark every runnable note as failed with the underlying error.
|
|
2261
|
-
for (const r of runnable) {
|
|
2262
|
-
results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch delete failed");
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
else {
|
|
2266
|
-
const statuses = res.output
|
|
2267
|
-
.split(RECORD_SEP)
|
|
2268
|
-
.map((s) => s.trim())
|
|
2269
|
-
.filter((s) => s.length > 0);
|
|
2270
|
-
runnable.forEach((r, k) => {
|
|
2271
|
-
results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
|
|
2272
|
-
});
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
return results;
|
|
2276
|
-
}
|
|
2277
|
-
/**
|
|
2278
|
-
* Maps a per-item status token emitted by a batch AppleScript loop to a
|
|
2279
|
-
* BatchResult, preserving the human-readable error messages of the original
|
|
2280
|
-
* per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
|
|
2281
|
-
*/
|
|
2282
|
-
mapBatchStatus(id, status, op) {
|
|
2283
|
-
switch (status) {
|
|
2284
|
-
case "ok":
|
|
2285
|
-
return this.createBatchResult(id, true);
|
|
2286
|
-
case "pw":
|
|
2287
|
-
return this.createBatchResult(id, false, "Note is password-protected");
|
|
2288
|
-
case "missing":
|
|
2289
|
-
return this.createBatchResult(id, false, "Note not found");
|
|
2290
|
-
case "fail":
|
|
2291
|
-
return this.createBatchResult(id, false, op === "delete" ? "Deletion failed" : "Move failed");
|
|
2292
|
-
default:
|
|
2293
|
-
return this.createBatchResult(id, false, "Unknown error");
|
|
2294
|
-
}
|
|
2295
|
-
}
|
|
2296
|
-
/**
|
|
2297
|
-
* Moves multiple notes to a folder by their IDs.
|
|
2298
|
-
*
|
|
2299
|
-
* Each move is attempted independently; failures don't stop other moves.
|
|
2300
|
-
* Returns results for each note indicating success or failure.
|
|
2301
|
-
*
|
|
2302
|
-
* @param ids - Array of CoreData URL identifiers for notes to move
|
|
2303
|
-
* @param folder - Destination folder name
|
|
2304
|
-
* @param account - Account containing the folder (defaults to iCloud)
|
|
2305
|
-
* @returns Array of results with id, success status, and optional error message
|
|
2306
|
-
*
|
|
2307
|
-
* @example
|
|
2308
|
-
* ```typescript
|
|
2309
|
-
* const results = manager.batchMoveNotes(
|
|
2310
|
-
* ["x-coredata://ABC/ICNote/p1", "x-coredata://ABC/ICNote/p2"],
|
|
2311
|
-
* "Archive"
|
|
2312
|
-
* );
|
|
2313
|
-
* ```
|
|
2314
|
-
*/
|
|
2315
|
-
batchMoveNotes(ids, folder, account) {
|
|
2316
|
-
if (ids.length === 0)
|
|
2317
|
-
return [];
|
|
2318
|
-
// Collapse the whole batch into ONE osascript spawn (#26). The old path
|
|
2319
|
-
// spawned 5+ processes per note (getNoteById + isNotePasswordProtectedById +
|
|
2320
|
-
// moveNoteById's copy-then-delete fan-out). This uses the native `move`
|
|
2321
|
-
// command — which preserves the note's identity and metadata rather than
|
|
2322
|
-
// copy+delete — inside a single app-level loop with per-id `try` isolation.
|
|
2323
|
-
const targetAccount = this.resolveAccount(account);
|
|
2324
|
-
const safeAccount = sanitizeAccountName(targetAccount);
|
|
2325
|
-
// buildFolderReference validates the (single, shared) destination path; a
|
|
2326
|
-
// malformed folder is a precondition error for the whole call, so let it throw.
|
|
2327
|
-
const destFolderRef = `${buildFolderReference(folder)} of account "${safeAccount}"`;
|
|
2328
|
-
const results = new Array(ids.length);
|
|
2329
|
-
const runnable = [];
|
|
2330
|
-
ids.forEach((id, i) => {
|
|
2331
|
-
try {
|
|
2332
|
-
runnable.push({ index: i, safe: sanitizeId(id) });
|
|
2333
|
-
}
|
|
2334
|
-
catch (e) {
|
|
2335
|
-
results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
|
|
2336
|
-
}
|
|
2337
|
-
});
|
|
2338
|
-
if (runnable.length > 0) {
|
|
2339
|
-
const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
|
|
2340
|
-
const script = buildAppLevelScript(`
|
|
2341
|
-
set destFolder to ${destFolderRef}
|
|
2342
|
-
set out to ""
|
|
2343
|
-
repeat with rawId in {${idList}}
|
|
2344
|
-
set theId to (rawId as text)
|
|
2345
|
-
set noteRef to missing value
|
|
2346
|
-
try
|
|
2347
|
-
set noteRef to note id theId
|
|
2348
|
-
end try
|
|
2349
|
-
if noteRef is missing value then
|
|
2350
|
-
set out to out & "missing" & ${AS_RECORD_SEP}
|
|
2351
|
-
else
|
|
2352
|
-
set isPw to false
|
|
2353
|
-
try
|
|
2354
|
-
set isPw to (password protected of noteRef)
|
|
2355
|
-
end try
|
|
2356
|
-
if isPw then
|
|
2357
|
-
set out to out & "pw" & ${AS_RECORD_SEP}
|
|
2358
|
-
else
|
|
2359
|
-
try
|
|
2360
|
-
move noteRef to destFolder
|
|
2361
|
-
set out to out & "ok" & ${AS_RECORD_SEP}
|
|
2362
|
-
on error
|
|
2363
|
-
set out to out & "fail" & ${AS_RECORD_SEP}
|
|
2364
|
-
end try
|
|
2365
|
-
end if
|
|
2366
|
-
end if
|
|
2367
|
-
end repeat
|
|
2368
|
-
return out
|
|
2369
|
-
`);
|
|
2370
|
-
const res = executeAppleScript(script);
|
|
2371
|
-
if (!res.success) {
|
|
2372
|
-
// Whole-batch failure (e.g. destination folder unresolved, Notes not
|
|
2373
|
-
// responding): can't isolate, so fail every runnable note.
|
|
2374
|
-
for (const r of runnable) {
|
|
2375
|
-
results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch move failed");
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
else {
|
|
2379
|
-
const statuses = res.output
|
|
2380
|
-
.split(RECORD_SEP)
|
|
2381
|
-
.map((s) => s.trim())
|
|
2382
|
-
.filter((s) => s.length > 0);
|
|
2383
|
-
runnable.forEach((r, k) => {
|
|
2384
|
-
results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "move");
|
|
2385
|
-
});
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
return results;
|
|
2389
|
-
}
|
|
2390
|
-
// ===========================================================================
|
|
2391
|
-
// Export Operations
|
|
2392
|
-
// ===========================================================================
|
|
2393
|
-
/**
|
|
2394
|
-
* Export structure for a single note.
|
|
2395
|
-
*/
|
|
2396
|
-
exportNote(note, content) {
|
|
2397
|
-
return {
|
|
2398
|
-
id: note.id,
|
|
2399
|
-
title: note.title,
|
|
2400
|
-
content: content,
|
|
2401
|
-
plaintext: this.htmlToPlaintext(content),
|
|
2402
|
-
folder: note.folder || "Notes",
|
|
2403
|
-
account: note.account || "iCloud",
|
|
2404
|
-
created: note.created.toISOString(),
|
|
2405
|
-
modified: note.modified.toISOString(),
|
|
2406
|
-
shared: note.shared || false,
|
|
2407
|
-
passwordProtected: note.passwordProtected || false,
|
|
2408
|
-
};
|
|
2409
|
-
}
|
|
2410
|
-
/**
|
|
2411
|
-
* Simple HTML to plaintext conversion for export.
|
|
2412
|
-
*/
|
|
2413
|
-
htmlToPlaintext(html) {
|
|
2414
|
-
// Convert block/line breaks to newlines first.
|
|
2415
|
-
let text = html
|
|
2416
|
-
.replace(/<br\s*\/?>/gi, "\n")
|
|
2417
|
-
.replace(/<\/div>/gi, "\n")
|
|
2418
|
-
.replace(/<\/p>/gi, "\n");
|
|
2419
|
-
// Strip any remaining tags, looping until the string stabilizes. A single
|
|
2420
|
-
// pass can leave residue when removing one tag re-forms another (e.g.
|
|
2421
|
-
// "<<i>>"), so we repeat until there are no more matches — the recognized
|
|
2422
|
-
// fix for CodeQL js/incomplete-multi-character-sanitization.
|
|
2423
|
-
let prev;
|
|
2424
|
-
do {
|
|
2425
|
-
prev = text;
|
|
2426
|
-
text = text.replace(/<[^>]*>/g, "");
|
|
2427
|
-
} while (text !== prev);
|
|
2428
|
-
return (text
|
|
2429
|
-
.replace(/ /g, " ")
|
|
2430
|
-
.replace(/</g, "<")
|
|
2431
|
-
.replace(/>/g, ">")
|
|
2432
|
-
.replace(/"/g, '"')
|
|
2433
|
-
.replace(/\/g, "\\")
|
|
2434
|
-
// Decode & LAST so an encoded entity like "&lt;" round-trips to the
|
|
2435
|
-
// literal "<" instead of being double-unescaped to "<".
|
|
2436
|
-
.replace(/&/g, "&")
|
|
2437
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
2438
|
-
.trim());
|
|
2439
|
-
}
|
|
2440
|
-
/**
|
|
2441
|
-
* Exports all notes as a JSON structure for backup/migration.
|
|
2442
|
-
*
|
|
2443
|
-
* Exports complete note data including:
|
|
2444
|
-
* - Metadata (id, title, dates, flags)
|
|
2445
|
-
* - Content (HTML and plaintext)
|
|
2446
|
-
* - Organization (folder, account)
|
|
2447
|
-
*
|
|
2448
|
-
* Note: Password-protected notes are included with metadata only (no content).
|
|
2449
|
-
*
|
|
2450
|
-
* @returns JSON-serializable export object
|
|
2451
|
-
*
|
|
2452
|
-
* @example
|
|
2453
|
-
* ```typescript
|
|
2454
|
-
* const snapshot = manager.exportNotesAsJson();
|
|
2455
|
-
* fs.writeFileSync('notes-backup.json', JSON.stringify(snapshot, null, 2));
|
|
2456
|
-
* ```
|
|
2457
|
-
*/
|
|
2458
|
-
exportNotesAsJson() {
|
|
2459
|
-
const accounts = this.listAccounts();
|
|
2460
|
-
const exportData = {
|
|
2461
|
-
exportDate: new Date().toISOString(),
|
|
2462
|
-
version: "1.0",
|
|
2463
|
-
accounts: [],
|
|
2464
|
-
summary: { totalNotes: 0, totalFolders: 0, totalAccounts: accounts.length },
|
|
2465
|
-
};
|
|
2466
|
-
for (const account of accounts) {
|
|
2467
|
-
const folders = this.listFolders(account.name);
|
|
2468
|
-
const accountData = {
|
|
2469
|
-
name: account.name,
|
|
2470
|
-
folders: [],
|
|
2471
|
-
};
|
|
2472
|
-
for (const folder of folders) {
|
|
2473
|
-
const folderData = {
|
|
2474
|
-
name: folder.name,
|
|
2475
|
-
notes: [],
|
|
2476
|
-
};
|
|
2477
|
-
// Get all note titles in this folder
|
|
2478
|
-
const noteTitles = this.listNotes(account.name, folder.name);
|
|
2479
|
-
for (const title of noteTitles) {
|
|
2480
|
-
// Get note details
|
|
2481
|
-
const note = this.getNoteDetails(title, account.name);
|
|
2482
|
-
if (!note)
|
|
2483
|
-
continue;
|
|
2484
|
-
// Skip password-protected notes' content but include metadata
|
|
2485
|
-
let content = "";
|
|
2486
|
-
if (!note.passwordProtected) {
|
|
2487
|
-
content = this.getNoteContent(title, account.name);
|
|
2488
|
-
}
|
|
2489
|
-
folderData.notes.push(this.exportNote(note, content));
|
|
2490
|
-
exportData.summary.totalNotes++;
|
|
2491
|
-
}
|
|
2492
|
-
accountData.folders.push(folderData);
|
|
2493
|
-
exportData.summary.totalFolders++;
|
|
2494
|
-
}
|
|
2495
|
-
exportData.accounts.push(accountData);
|
|
2496
|
-
}
|
|
2497
|
-
return exportData;
|
|
2498
|
-
}
|
|
2499
|
-
// ===========================================================================
|
|
2500
|
-
// Markdown Conversion
|
|
2501
|
-
// ===========================================================================
|
|
2502
|
-
/**
|
|
2503
|
-
* Turndown service instance for HTML to Markdown conversion.
|
|
2504
|
-
* Configured for Apple Notes HTML quirks.
|
|
2505
|
-
* Initialized lazily on first use.
|
|
2506
|
-
*/
|
|
2507
|
-
turndownService;
|
|
2508
|
-
/**
|
|
2509
|
-
* Initialize the Turndown service with Apple Notes-specific rules.
|
|
2510
|
-
*/
|
|
2511
|
-
initTurndownService() {
|
|
2512
|
-
if (this.turndownService)
|
|
2513
|
-
return;
|
|
2514
|
-
this.turndownService = new TurndownService({
|
|
2515
|
-
headingStyle: "atx",
|
|
2516
|
-
codeBlockStyle: "fenced",
|
|
2517
|
-
bulletListMarker: "-",
|
|
2518
|
-
});
|
|
2519
|
-
// Handle Apple Notes-specific HTML patterns
|
|
2520
|
-
// Notes.app uses <div> instead of <p> for paragraphs
|
|
2521
|
-
this.turndownService.addRule("notesDivs", {
|
|
2522
|
-
filter: "div",
|
|
2523
|
-
replacement: (content) => {
|
|
2524
|
-
return content + "\n";
|
|
2525
|
-
},
|
|
2526
|
-
});
|
|
2527
|
-
}
|
|
2528
|
-
/**
|
|
2529
|
-
* Converts HTML content to Markdown.
|
|
2530
|
-
*
|
|
2531
|
-
* @param html - HTML content from Notes.app
|
|
2532
|
-
* @returns Markdown formatted content
|
|
2533
|
-
*/
|
|
2534
|
-
htmlToMarkdown(html) {
|
|
2535
|
-
this.initTurndownService();
|
|
2536
|
-
return this.turndownService.turndown(html).trim();
|
|
2537
|
-
}
|
|
2538
|
-
/**
|
|
2539
|
-
* Enriches markdown with checklist state from the NoteStore database.
|
|
2540
|
-
*
|
|
2541
|
-
* Apple Notes checklists appear as plain list items in the AppleScript HTML
|
|
2542
|
-
* output. This method reads the protobuf data to get done/undone state and
|
|
2543
|
-
* annotates matching list items with [x] or [ ] prefixes.
|
|
2544
|
-
*
|
|
2545
|
-
* Fails silently (returns original markdown) if the database is inaccessible
|
|
2546
|
-
* or the note has no checklists.
|
|
2547
|
-
*
|
|
2548
|
-
* @param markdown - The base markdown content
|
|
2549
|
-
* @param checklistItems - Checklist items with done state
|
|
2550
|
-
* @returns Markdown with checklist annotations
|
|
2551
|
-
*/
|
|
2552
|
-
enrichMarkdownWithChecklists(markdown, checklistItems) {
|
|
2553
|
-
if (checklistItems.length === 0)
|
|
2554
|
-
return markdown;
|
|
2555
|
-
// Build a map of checklist text → done state
|
|
2556
|
-
const checklistMap = new Map();
|
|
2557
|
-
for (const item of checklistItems) {
|
|
2558
|
-
checklistMap.set(item.text.trim(), item.done);
|
|
2559
|
-
}
|
|
2560
|
-
// Replace matching list items with checkbox syntax
|
|
2561
|
-
const lines = markdown.split("\n");
|
|
2562
|
-
const enriched = lines.map((line) => {
|
|
2563
|
-
// Match markdown list items: "- text" or "* text"
|
|
2564
|
-
const listMatch = line.match(/^(\s*[-*])\s+(.+)$/);
|
|
2565
|
-
if (!listMatch)
|
|
2566
|
-
return line;
|
|
2567
|
-
const [, prefix, text] = listMatch;
|
|
2568
|
-
const done = checklistMap.get(text.trim());
|
|
2569
|
-
if (done === undefined)
|
|
2570
|
-
return line;
|
|
2571
|
-
// Remove from map so duplicate text lines aren't all converted
|
|
2572
|
-
checklistMap.delete(text.trim());
|
|
2573
|
-
return `${prefix} ${done ? "[x]" : "[ ]"} ${text}`;
|
|
2574
|
-
});
|
|
2575
|
-
return enriched.join("\n");
|
|
2576
|
-
}
|
|
2577
|
-
/**
|
|
2578
|
-
* Gets note content as Markdown by title.
|
|
2579
|
-
*
|
|
2580
|
-
* If the note contains checklists and the NoteStore database is accessible
|
|
2581
|
-
* (Full Disk Access required), checklist items will be annotated with
|
|
2582
|
-
* [x] (done) or [ ] (undone) prefixes.
|
|
2583
|
-
*
|
|
2584
|
-
* @param title - Exact title of the note
|
|
2585
|
-
* @param account - Account containing the note (defaults to iCloud)
|
|
2586
|
-
* @returns Markdown content, or empty string if not found
|
|
2587
|
-
*
|
|
2588
|
-
* @example
|
|
2589
|
-
* ```typescript
|
|
2590
|
-
* const md = manager.getNoteMarkdown("Shopping List");
|
|
2591
|
-
* console.log(md); // "# Shopping List\n\n- [x] Eggs\n- [ ] Milk"
|
|
2592
|
-
* ```
|
|
2593
|
-
*/
|
|
2594
|
-
getNoteMarkdown(title, account) {
|
|
2595
|
-
const html = this.getNoteContent(title, account);
|
|
2596
|
-
if (!html)
|
|
2597
|
-
return "";
|
|
2598
|
-
let markdown = this.htmlToMarkdown(html);
|
|
2599
|
-
// Try to enrich with checklist state (requires note ID)
|
|
2600
|
-
const note = this.getNoteDetails(title, account);
|
|
2601
|
-
if (note?.id) {
|
|
2602
|
-
const result = getChecklistItems(note.id);
|
|
2603
|
-
if (result.items) {
|
|
2604
|
-
markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
return markdown;
|
|
2608
|
-
}
|
|
2609
|
-
/**
|
|
2610
|
-
* Gets note content as Markdown by ID.
|
|
2611
|
-
*
|
|
2612
|
-
* This is more reliable than getNoteMarkdown() because IDs are unique
|
|
2613
|
-
* across all accounts, while titles can be duplicated.
|
|
2614
|
-
*
|
|
2615
|
-
* If the note contains checklists and the NoteStore database is accessible
|
|
2616
|
-
* (Full Disk Access required), checklist items will be annotated with
|
|
2617
|
-
* [x] (done) or [ ] (undone) prefixes.
|
|
2618
|
-
*
|
|
2619
|
-
* @param id - CoreData URL identifier for the note
|
|
2620
|
-
* @returns Markdown content, or empty string if not found
|
|
2621
|
-
*/
|
|
2622
|
-
getNoteMarkdownById(id) {
|
|
2623
|
-
const html = this.getNoteContentById(id);
|
|
2624
|
-
if (!html)
|
|
2625
|
-
return "";
|
|
2626
|
-
let markdown = this.htmlToMarkdown(html);
|
|
2627
|
-
// Try to enrich with checklist state
|
|
2628
|
-
const result = getChecklistItems(id);
|
|
2629
|
-
if (result.items) {
|
|
2630
|
-
markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
|
|
2631
|
-
}
|
|
2632
|
-
return markdown;
|
|
2633
|
-
}
|
|
2634
|
-
}
|