@mgcrea/mcp-apple-notes 0.0.0-bootstrap
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/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +433 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-HLS_dM7r.js +1550 -0
- package/dist/server-HLS_dM7r.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,1550 @@
|
|
|
1
|
+
import { AppBusyError as NotesBusyError, AppNotRunningError as NotesNotRunningError, AppleAutomationError, AppleAutomationError as AppleNotesError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, okText, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, readPackageIdentity, trimmed, withBusyRetry, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { gunzipSync } from "node:zlib";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
//#region src/build-info.ts
|
|
9
|
+
const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
|
|
10
|
+
name: "@mgcrea/mcp-apple-notes",
|
|
11
|
+
version: "0.0.0"
|
|
12
|
+
});
|
|
13
|
+
const BUILD_INFO = {
|
|
14
|
+
name: pkg.name,
|
|
15
|
+
version: pkg.version,
|
|
16
|
+
gitCommit: "df06e7f",
|
|
17
|
+
gitCommitDate: "2026-08-22T16:20:23+02:00"
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/client/errors.ts
|
|
21
|
+
/**
|
|
22
|
+
* Notes' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
|
|
23
|
+
* belongs here is the identity those messages are written against, and the
|
|
24
|
+
* errors that are genuinely about notes.
|
|
25
|
+
*/
|
|
26
|
+
/** Named in every user-facing error and in the env vars they mention. */
|
|
27
|
+
const NOTES_SURFACE = {
|
|
28
|
+
appName: "Notes",
|
|
29
|
+
envPrefix: "APPLE_NOTES"
|
|
30
|
+
};
|
|
31
|
+
/** A NoteRef no longer resolves — the note was deleted, or the index is stale. */
|
|
32
|
+
var NoteNotFoundError = class extends AppleAutomationError {
|
|
33
|
+
name = "NoteNotFoundError";
|
|
34
|
+
constructor(ref) {
|
|
35
|
+
super(`No note for ref "${ref}". It was probably deleted since the search ran. Re-run the search to get a current ref.`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The note is password-protected and Notes will not hand over its text.
|
|
40
|
+
*
|
|
41
|
+
* The file lane cannot help either: locked notes are AES ciphertext at rest,
|
|
42
|
+
* which is why `ZICNOTEDATA` carries `ZCRYPTOINITIALIZATIONVECTOR` and
|
|
43
|
+
* `ZCRYPTOTAG` columns. Saying so is more useful than an empty body.
|
|
44
|
+
*/
|
|
45
|
+
var NoteLockedError = class extends AppleAutomationError {
|
|
46
|
+
name = "NoteLockedError";
|
|
47
|
+
constructor(ref) {
|
|
48
|
+
super(`Note "${ref}" is password-protected. Unlock it in Notes and retry — its text is encrypted at rest, so no amount of permission makes it readable from here.`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/client/locate.ts
|
|
53
|
+
/**
|
|
54
|
+
* Find Notes' store.
|
|
55
|
+
*
|
|
56
|
+
* Simpler than Mail's, and measurably so: Mail's root is version-numbered
|
|
57
|
+
* (`~/Library/Mail/V10`) *and* TCC-hidden, so it needs a four-branch strategy
|
|
58
|
+
* ladder that asks Mail to name its own directory. Notes has one constant path,
|
|
59
|
+
* confirmed on macOS 26.6.
|
|
60
|
+
*
|
|
61
|
+
* The path is TCC-protected, so `statSync` succeeds on it while `open` and
|
|
62
|
+
* `access` are denied — existence and readability are different questions, and
|
|
63
|
+
* only readability answers whether Full Disk Access is granted.
|
|
64
|
+
*/
|
|
65
|
+
/** `~/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite`. */
|
|
66
|
+
const GROUP_CONTAINER = "group.com.apple.notes";
|
|
67
|
+
const defaultStorePath = (home = homedir()) => join(home, "Library", "Group Containers", GROUP_CONTAINER, "NoteStore.sqlite");
|
|
68
|
+
const locateStore = (opts = {}) => {
|
|
69
|
+
const storePath = opts.storePath ?? defaultStorePath(opts.home);
|
|
70
|
+
const facts = describeStore(storePath);
|
|
71
|
+
const reason = facts.exists ? facts.readable ? null : "NoteStore.sqlite exists but cannot be read. Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Notes.app does nothing — the reader needs the permission." : `No NoteStore.sqlite at ${storePath}. Has Notes ever been set up on this account?`;
|
|
72
|
+
return {
|
|
73
|
+
...facts,
|
|
74
|
+
storePath,
|
|
75
|
+
mediaRoot: join(dirname(storePath), "Accounts"),
|
|
76
|
+
reason
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/client/jxa/core.ts
|
|
81
|
+
/**
|
|
82
|
+
* JXA script fragments.
|
|
83
|
+
*
|
|
84
|
+
* Every script here is a static constant. None of them may contain a template
|
|
85
|
+
* interpolation — `assertStaticScript` rejects any script containing `${`, and
|
|
86
|
+
* that includes JavaScript template literals written *inside* the JXA source.
|
|
87
|
+
* Use string concatenation in JXA code.
|
|
88
|
+
*
|
|
89
|
+
* Every script follows the same contract:
|
|
90
|
+
* - it reads its parameters from `JSON.parse(argv[0])`
|
|
91
|
+
* - it returns `JSON.stringify({ok: true, data})` on success
|
|
92
|
+
* - it returns `JSON.stringify({ok: false, error: {code, message}})` on an
|
|
93
|
+
* application-level failure, still exiting 0
|
|
94
|
+
* so that a non-zero exit always means infrastructure rather than "no such note".
|
|
95
|
+
*
|
|
96
|
+
* ## Two rules the measurements impose
|
|
97
|
+
*
|
|
98
|
+
* 1. **Never read a property per note in a loop.** One note's `plaintext` costs
|
|
99
|
+
* ~116ms, so a 921-note library would take ~107 seconds. Every read here is a
|
|
100
|
+
* bulk array fetch (`N.notes.plaintext()`), which is one Apple Event whatever
|
|
101
|
+
* the size.
|
|
102
|
+
* 2. **Never use `whose` for text search.** `notes.whose({plaintext: ...})` took
|
|
103
|
+
* 671ms against 97ms for pulling everything and filtering in JS — 6.9x slower,
|
|
104
|
+
* because the specifier evaluates per note across the Apple Event bridge while
|
|
105
|
+
* the bulk form is a single round trip.
|
|
106
|
+
*/
|
|
107
|
+
/**
|
|
108
|
+
* Shared prelude.
|
|
109
|
+
*
|
|
110
|
+
* The liveness gate matters more than it looks. `Application("Notes").notes`
|
|
111
|
+
* *launches* Notes if it isn't running — which steals focus as a side effect of
|
|
112
|
+
* a read. `NSRunningApplication` answers the same question without launching
|
|
113
|
+
* anything and without a second TCC prompt.
|
|
114
|
+
*
|
|
115
|
+
* Deliberately NOT shared with Mail's prelude: 45% of that file is a UI-scripting
|
|
116
|
+
* workaround for Mail's forced blockquote citation, which every script there
|
|
117
|
+
* carries whether it composes mail or lists accounts. Notes needs none of it.
|
|
118
|
+
*/
|
|
119
|
+
const PRELUDE = `
|
|
120
|
+
ObjC.import("AppKit");
|
|
121
|
+
|
|
122
|
+
function isNotesRunning() {
|
|
123
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.Notes");
|
|
124
|
+
return apps.count > 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ok(data) { return JSON.stringify({ ok: true, data: data }); }
|
|
128
|
+
function err(code, message) { return JSON.stringify({ ok: false, error: { code: code, message: String(message) } }); }
|
|
129
|
+
|
|
130
|
+
function iso(d) {
|
|
131
|
+
try { return d ? d.toISOString() : null; } catch (e) { return null; }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Read one property defensively: Notes throws on properties it cannot supply. */
|
|
135
|
+
function prop(fn, fallback) {
|
|
136
|
+
try {
|
|
137
|
+
var v = fn();
|
|
138
|
+
return v === undefined ? fallback : v;
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Zip parallel bulk arrays into records.
|
|
146
|
+
*
|
|
147
|
+
* Bulk fetches are the only affordable read shape, and they come back as
|
|
148
|
+
* separate arrays that line up by index. Rebuilding records here keeps that
|
|
149
|
+
* assumption in one place.
|
|
150
|
+
*/
|
|
151
|
+
function zip(keys, columns, count) {
|
|
152
|
+
var out = [];
|
|
153
|
+
for (var i = 0; i < count; i++) {
|
|
154
|
+
var row = {};
|
|
155
|
+
for (var k = 0; k < keys.length; k++) {
|
|
156
|
+
var col = columns[k];
|
|
157
|
+
row[keys[k]] = col && i < col.length ? col[i] : null;
|
|
158
|
+
}
|
|
159
|
+
out.push(row);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Walk the folder tree. Folders nest in the dictionary, so this recurses. */
|
|
165
|
+
function folderTree(container, accountId, depth, out) {
|
|
166
|
+
if (depth > 8) return out;
|
|
167
|
+
var subs = [];
|
|
168
|
+
try { subs = container.folders(); } catch (e) { return out; }
|
|
169
|
+
for (var i = 0; i < subs.length; i++) {
|
|
170
|
+
var f = subs[i];
|
|
171
|
+
var id = prop(function () { return String(f.id()); }, null);
|
|
172
|
+
out.push({
|
|
173
|
+
id: id,
|
|
174
|
+
name: prop(function () { return String(f.name()); }, null),
|
|
175
|
+
accountId: accountId,
|
|
176
|
+
depth: depth,
|
|
177
|
+
shared: prop(function () { return f.shared(); }, false),
|
|
178
|
+
noteCount: prop(function () { return f.notes().length; }, null)
|
|
179
|
+
});
|
|
180
|
+
folderTree(f, accountId, depth + 1, out);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
`;
|
|
185
|
+
/**
|
|
186
|
+
* Wrap a script body in the prelude and the liveness gate.
|
|
187
|
+
*
|
|
188
|
+
* `allowLaunch` is opt-in per script, and only tools whose whole purpose is to
|
|
189
|
+
* make Notes do something set it. A read that silently launches Notes is a read
|
|
190
|
+
* with a side effect.
|
|
191
|
+
*/
|
|
192
|
+
const script = (body, opts = {}) => `
|
|
193
|
+
${PRELUDE}
|
|
194
|
+
function run(argv) {
|
|
195
|
+
var p = JSON.parse(argv[0] || "{}");
|
|
196
|
+
if (!isNotesRunning() && !${opts.allowLaunch ? "true" : "false"}) {
|
|
197
|
+
return err("APP_NOT_RUNNING", "Notes is not running.");
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
var N = Application("Notes");
|
|
201
|
+
${body}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
var msg = String(e && e.message ? e.message : e);
|
|
204
|
+
if (msg.indexOf("-1743") !== -1) return err("NOT_AUTHORIZED", msg);
|
|
205
|
+
return err("SCRIPT_ERROR", msg);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/client/jxa/read.ts
|
|
211
|
+
/** Accounts, with the folder count each one holds. */
|
|
212
|
+
const LIST_ACCOUNTS = script(`
|
|
213
|
+
var accounts = N.accounts();
|
|
214
|
+
var out = [];
|
|
215
|
+
for (var i = 0; i < accounts.length; i++) {
|
|
216
|
+
var a = accounts[i];
|
|
217
|
+
out.push({
|
|
218
|
+
id: prop(function () { return String(a.id()); }, null),
|
|
219
|
+
name: prop(function () { return String(a.name()); }, null),
|
|
220
|
+
defaultFolder: prop(function () { return String(a.defaultFolder.name()); }, null),
|
|
221
|
+
folderCount: prop(function () { return a.folders().length; }, 0),
|
|
222
|
+
noteCount: prop(function () { return a.notes().length; }, 0)
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return ok(out);
|
|
226
|
+
`);
|
|
227
|
+
/** The folder tree. Folders nest, so this is depth-first rather than a flat list. */
|
|
228
|
+
const LIST_FOLDERS = script(`
|
|
229
|
+
var accounts = N.accounts();
|
|
230
|
+
var out = [];
|
|
231
|
+
for (var i = 0; i < accounts.length; i++) {
|
|
232
|
+
var a = accounts[i];
|
|
233
|
+
var accountId = prop(function () { return String(a.id()); }, null);
|
|
234
|
+
var accountName = prop(function () { return String(a.name()); }, null);
|
|
235
|
+
var folders = folderTree(a, accountId, 1, []);
|
|
236
|
+
for (var j = 0; j < folders.length; j++) {
|
|
237
|
+
folders[j].accountName = accountName;
|
|
238
|
+
out.push(folders[j]);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return ok(out);
|
|
242
|
+
`);
|
|
243
|
+
/**
|
|
244
|
+
* Every note's metadata, as bulk arrays.
|
|
245
|
+
*
|
|
246
|
+
* One Apple Event per property for the whole library — ~0.1ms per note — versus
|
|
247
|
+
* ~116ms per note for reading properties one note at a time. Folder membership
|
|
248
|
+
* is resolved with one bulk call per *folder*, which is a handful of round trips
|
|
249
|
+
* rather than one per note.
|
|
250
|
+
*/
|
|
251
|
+
const BULK_NOTES = script(`
|
|
252
|
+
var ids = N.notes.id();
|
|
253
|
+
var count = ids.length;
|
|
254
|
+
var rows = zip(
|
|
255
|
+
["id", "name", "modified", "created", "locked", "shared"],
|
|
256
|
+
[
|
|
257
|
+
ids,
|
|
258
|
+
prop(function () { return N.notes.name(); }, []),
|
|
259
|
+
prop(function () { return N.notes.modificationDate(); }, []),
|
|
260
|
+
prop(function () { return N.notes.creationDate(); }, []),
|
|
261
|
+
prop(function () { return N.notes.passwordProtected(); }, []),
|
|
262
|
+
prop(function () { return N.notes.shared(); }, [])
|
|
263
|
+
],
|
|
264
|
+
count
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Note -> folder, one bulk call per folder rather than one per note.
|
|
268
|
+
var folderOf = {};
|
|
269
|
+
var accounts = N.accounts();
|
|
270
|
+
for (var i = 0; i < accounts.length; i++) {
|
|
271
|
+
var a = accounts[i];
|
|
272
|
+
var accountId = prop(function () { return String(a.id()); }, null);
|
|
273
|
+
var accountName = prop(function () { return String(a.name()); }, null);
|
|
274
|
+
var folders = folderTree(a, accountId, 1, []);
|
|
275
|
+
for (var j = 0; j < folders.length; j++) {
|
|
276
|
+
var f = folders[j];
|
|
277
|
+
var noteIds = [];
|
|
278
|
+
try {
|
|
279
|
+
var handle = N.folders.byId(f.id);
|
|
280
|
+
noteIds = handle.notes.id();
|
|
281
|
+
} catch (e) { noteIds = []; }
|
|
282
|
+
for (var k = 0; k < noteIds.length; k++) {
|
|
283
|
+
folderOf[String(noteIds[k])] = { folderId: f.id, folder: f.name, accountId: accountId, account: accountName };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (var r = 0; r < rows.length; r++) {
|
|
289
|
+
var row = rows[r];
|
|
290
|
+
row.id = String(row.id);
|
|
291
|
+
row.modified = iso(row.modified);
|
|
292
|
+
row.created = iso(row.created);
|
|
293
|
+
var where = folderOf[row.id];
|
|
294
|
+
row.folder = where ? where.folder : null;
|
|
295
|
+
row.folderId = where ? where.folderId : null;
|
|
296
|
+
row.account = where ? where.account : null;
|
|
297
|
+
row.accountId = where ? where.accountId : null;
|
|
298
|
+
}
|
|
299
|
+
return ok({ count: count, notes: rows });
|
|
300
|
+
`);
|
|
301
|
+
/**
|
|
302
|
+
* Every note's id and plaintext, in one round trip.
|
|
303
|
+
*
|
|
304
|
+
* This is the search lane when the index is unavailable: pull everything, filter
|
|
305
|
+
* in JS. Measured at 97ms over 921 notes and 250KB — against 671ms for the
|
|
306
|
+
* equivalent `whose` specifier.
|
|
307
|
+
*/
|
|
308
|
+
const BULK_PLAINTEXT = script(`
|
|
309
|
+
return ok({ ids: N.notes.id(), texts: N.notes.plaintext() });
|
|
310
|
+
`);
|
|
311
|
+
/** Bodies for a specific set of notes, by id. Used after the index narrows things down. */
|
|
312
|
+
const GET_NOTE_BODIES = script(`
|
|
313
|
+
var out = [];
|
|
314
|
+
for (var i = 0; i < p.ids.length; i++) {
|
|
315
|
+
var id = String(p.ids[i]);
|
|
316
|
+
var note = null;
|
|
317
|
+
try { note = N.notes.byId(id); } catch (e) { note = null; }
|
|
318
|
+
if (!note) { out.push({ id: id, found: false }); continue; }
|
|
319
|
+
var locked = prop(function () { return note.passwordProtected(); }, false);
|
|
320
|
+
out.push({
|
|
321
|
+
id: id,
|
|
322
|
+
found: true,
|
|
323
|
+
locked: locked,
|
|
324
|
+
name: prop(function () { return String(note.name()); }, null),
|
|
325
|
+
plaintext: locked ? null : prop(function () { return String(note.plaintext()); }, null),
|
|
326
|
+
body: locked ? null : prop(function () { return String(note.body()); }, null),
|
|
327
|
+
modified: iso(prop(function () { return note.modificationDate(); }, null)),
|
|
328
|
+
created: iso(prop(function () { return note.creationDate(); }, null))
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return ok(out);
|
|
332
|
+
`);
|
|
333
|
+
/** Attachment metadata for one note. There is no filesystem path in the dictionary. */
|
|
334
|
+
const LIST_ATTACHMENTS = script(`
|
|
335
|
+
var note = null;
|
|
336
|
+
try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
|
|
337
|
+
if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
|
|
338
|
+
var atts = prop(function () { return note.attachments(); }, []);
|
|
339
|
+
var out = [];
|
|
340
|
+
for (var i = 0; i < atts.length; i++) {
|
|
341
|
+
var a = atts[i];
|
|
342
|
+
out.push({
|
|
343
|
+
id: prop(function () { return String(a.id()); }, null),
|
|
344
|
+
name: prop(function () { return String(a.name()); }, null),
|
|
345
|
+
contentIdentifier: prop(function () { return String(a.contentIdentifier()); }, null),
|
|
346
|
+
url: prop(function () { return String(a.URL()); }, null),
|
|
347
|
+
created: iso(prop(function () { return a.creationDate(); }, null)),
|
|
348
|
+
modified: iso(prop(function () { return a.modificationDate(); }, null))
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return ok(out);
|
|
352
|
+
`);
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/client/jxa/repair.ts
|
|
355
|
+
/**
|
|
356
|
+
* Repair for Notes' lossy `body` round trip.
|
|
357
|
+
*
|
|
358
|
+
* `UPDATE_NOTE`'s append mode reads `note.body()` and assigns the result back,
|
|
359
|
+
* so whatever the getter mangles is written back mangled — onto content the
|
|
360
|
+
* caller never touched. Two independent losses, both reproduced against Notes
|
|
361
|
+
* on macOS 15:
|
|
362
|
+
*
|
|
363
|
+
* 1. **Entities come back unterminated.** The text `ps."projectId";` serialises
|
|
364
|
+
* to `ps."projectId"` — `"` with no semicolon. Fed back to the
|
|
365
|
+
* setter, the trailing `"` fuses with the literal `;` that followed it
|
|
366
|
+
* into a single `"`, and the semicolon is gone. The getter never terminates
|
|
367
|
+
* an entity, so a `"` in its output is always entity-plus-literal-`;`,
|
|
368
|
+
* never a well-formed `"`. That is what makes the repair unconditional
|
|
369
|
+
* rather than a `(?!;)` lookahead — a lookahead would skip exactly the case
|
|
370
|
+
* that loses a character.
|
|
371
|
+
* 2. **Space runs come back literal**, and HTML collapses them on re-parse, so
|
|
372
|
+
* every indented line loses its indentation and every aligned block loses
|
|
373
|
+
* its alignment.
|
|
374
|
+
*
|
|
375
|
+
* Order matters: entities first, then spaces. The space pass emits ` `
|
|
376
|
+
* with a terminating semicolon, and running the entity pass afterwards would
|
|
377
|
+
* rewrite those into ` ;`.
|
|
378
|
+
*
|
|
379
|
+
* Exported as source rather than a function because it runs inside the JXA
|
|
380
|
+
* script, in osascript's JavaScriptCore. `test/repair.test.ts` evaluates this
|
|
381
|
+
* same string, so the tested code is the shipped code.
|
|
382
|
+
*/
|
|
383
|
+
const REPAIR_BODY_SRC = `
|
|
384
|
+
var repairBody = function (html) {
|
|
385
|
+
var entities = html
|
|
386
|
+
.replace(/&(amp|quot|apos|lt|gt|nbsp)/g, "&$1;")
|
|
387
|
+
.replace(/&#(x?[0-9a-fA-F]+)/g, "&#$1;");
|
|
388
|
+
return entities.replace(/>([^<]*)</g, function (_m, text) {
|
|
389
|
+
return ">" + text
|
|
390
|
+
.replace(/ {2,}/g, function (run) { return new Array(run.length + 1).join(" "); })
|
|
391
|
+
.replace(/^ /, " ") + "<";
|
|
392
|
+
});
|
|
393
|
+
};
|
|
394
|
+
`;
|
|
395
|
+
//#endregion
|
|
396
|
+
//#region src/client/jxa/write.ts
|
|
397
|
+
/**
|
|
398
|
+
* Mutations may launch Notes.
|
|
399
|
+
*
|
|
400
|
+
* A read that silently launches an app is a read with a side effect, which is
|
|
401
|
+
* why the read scripts refuse. A tool whose whole purpose is to create or change
|
|
402
|
+
* a note has no such problem — the user asked for the change.
|
|
403
|
+
*/
|
|
404
|
+
const MUTATION = { allowLaunch: true };
|
|
405
|
+
/**
|
|
406
|
+
* Create a note.
|
|
407
|
+
*
|
|
408
|
+
* Notes derives a note's `name` from the first line of its body rather than from
|
|
409
|
+
* a settable property, so the title is prepended as a heading instead of
|
|
410
|
+
* assigned. Setting `name` directly is silently ignored.
|
|
411
|
+
*/
|
|
412
|
+
const CREATE_NOTE = script(`
|
|
413
|
+
var folder = null;
|
|
414
|
+
if (p.folderId) {
|
|
415
|
+
try { folder = N.folders.byId(String(p.folderId)); } catch (e) { folder = null; }
|
|
416
|
+
if (!folder) return err("FOLDER_NOT_FOUND", "No folder with id " + p.folderId);
|
|
417
|
+
} else {
|
|
418
|
+
var accounts = N.accounts();
|
|
419
|
+
if (!accounts.length) return err("NO_ACCOUNT", "Notes reports no accounts.");
|
|
420
|
+
folder = accounts[0].defaultFolder;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
var html = "";
|
|
424
|
+
if (p.title) html += "<h1>" + p.title + "</h1>";
|
|
425
|
+
if (p.body) html += p.body;
|
|
426
|
+
if (!html) return err("EMPTY_NOTE", "A note needs a title or a body.");
|
|
427
|
+
|
|
428
|
+
var note = N.Note({ body: html });
|
|
429
|
+
folder.notes.push(note);
|
|
430
|
+
return ok({
|
|
431
|
+
id: prop(function () { return String(note.id()); }, null),
|
|
432
|
+
name: prop(function () { return String(note.name()); }, null),
|
|
433
|
+
folder: prop(function () { return String(folder.name()); }, null)
|
|
434
|
+
});
|
|
435
|
+
`, MUTATION);
|
|
436
|
+
/**
|
|
437
|
+
* Replace a note's body. The title follows the first line, as it does on create.
|
|
438
|
+
*
|
|
439
|
+
* Append re-serialises the *existing* body through Notes' HTML parser, which is
|
|
440
|
+
* lossy in both directions — see `repair.ts`. `repairBody` undoes the getter's
|
|
441
|
+
* damage before the concatenation, so an append leaves untouched content byte
|
|
442
|
+
* identical instead of silently flattening indentation and eating characters.
|
|
443
|
+
*/
|
|
444
|
+
const UPDATE_NOTE = script(REPAIR_BODY_SRC + `
|
|
445
|
+
var note = null;
|
|
446
|
+
try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
|
|
447
|
+
if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
|
|
448
|
+
if (prop(function () { return note.passwordProtected(); }, false)) {
|
|
449
|
+
return err("NOTE_LOCKED", "That note is password-protected; unlock it in Notes first.");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
var current = prop(function () { return String(note.body()); }, "");
|
|
453
|
+
var next = p.mode === "append" ? repairBody(current) + (p.body || "") : (p.body || "");
|
|
454
|
+
note.body = next;
|
|
455
|
+
return ok({
|
|
456
|
+
id: String(p.id),
|
|
457
|
+
name: prop(function () { return String(note.name()); }, null),
|
|
458
|
+
bodyLength: next.length,
|
|
459
|
+
mode: p.mode || "replace"
|
|
460
|
+
});
|
|
461
|
+
`, MUTATION);
|
|
462
|
+
/** Move a note to another folder. */
|
|
463
|
+
const MOVE_NOTE = script(`
|
|
464
|
+
var note = null;
|
|
465
|
+
try { note = N.notes.byId(String(p.id)); } catch (e) { note = null; }
|
|
466
|
+
if (!note) return err("NOTE_NOT_FOUND", "No note with id " + p.id);
|
|
467
|
+
var folder = null;
|
|
468
|
+
try { folder = N.folders.byId(String(p.folderId)); } catch (e) { folder = null; }
|
|
469
|
+
if (!folder) return err("FOLDER_NOT_FOUND", "No folder with id " + p.folderId);
|
|
470
|
+
|
|
471
|
+
N.move(note, { to: folder });
|
|
472
|
+
return ok({
|
|
473
|
+
id: String(p.id),
|
|
474
|
+
folder: prop(function () { return String(folder.name()); }, null)
|
|
475
|
+
});
|
|
476
|
+
`, MUTATION);
|
|
477
|
+
/**
|
|
478
|
+
* Delete notes.
|
|
479
|
+
*
|
|
480
|
+
* Notes moves them to Recently Deleted rather than destroying them, which is
|
|
481
|
+
* what makes this safe enough to expose at all — but it is still gated behind an
|
|
482
|
+
* explicit confirm at the tool layer.
|
|
483
|
+
*/
|
|
484
|
+
const DELETE_NOTES = script(`
|
|
485
|
+
var deleted = [];
|
|
486
|
+
var missing = [];
|
|
487
|
+
for (var i = 0; i < p.ids.length; i++) {
|
|
488
|
+
var id = String(p.ids[i]);
|
|
489
|
+
var note = null;
|
|
490
|
+
try { note = N.notes.byId(id); } catch (e) { note = null; }
|
|
491
|
+
if (!note) { missing.push(id); continue; }
|
|
492
|
+
try { N.delete(note); deleted.push(id); } catch (e) { missing.push(id); }
|
|
493
|
+
}
|
|
494
|
+
return ok({ deleted: deleted, missing: missing, note: "Deleted notes go to Recently Deleted." });
|
|
495
|
+
`, MUTATION);
|
|
496
|
+
//#endregion
|
|
497
|
+
//#region src/client/ref.ts
|
|
498
|
+
/**
|
|
499
|
+
* NoteRef — the one identifier any tool accepts or returns.
|
|
500
|
+
*
|
|
501
|
+
* Notes' own id is already globally unique and resolvable without a container,
|
|
502
|
+
* unlike Mail's message ids: `x-coredata://<store>/ICNote/p<N>` addresses a note
|
|
503
|
+
* outright, so this carries no folder and needs no lookup ladder.
|
|
504
|
+
*
|
|
505
|
+
* Wire format: n1:<coredata-id>
|
|
506
|
+
*
|
|
507
|
+
* The `n1:` prefix is deliberate. If the id scheme ever changes, a versioned
|
|
508
|
+
* prefix makes that an additive change instead of a silent reinterpretation of
|
|
509
|
+
* every ref already in a conversation.
|
|
510
|
+
*
|
|
511
|
+
* Deliberately NOT shared with Mail's `ref.ts`: that codec's `#(\d+)$` and
|
|
512
|
+
* `Number.isSafeInteger` both assume an integer id, which this is not.
|
|
513
|
+
*/
|
|
514
|
+
const REF_VERSION = "n1";
|
|
515
|
+
/** `x-coredata://<uuid>/ICNote/p123` — the primary key is the trailing p-number. */
|
|
516
|
+
const CORE_DATA_ID = /^x-coredata:\/\/[^/]+\/ICNote\/p(\d+)$/;
|
|
517
|
+
const encodeRef = (id) => `n1:${id}`;
|
|
518
|
+
const decodeRef = (raw) => {
|
|
519
|
+
const m = /^([a-z0-9]+):(.+)$/.exec(raw);
|
|
520
|
+
if (!m) throw new PreconditionError(`Malformed note ref "${raw}". Refs come from the search and list tools — construct them from those results rather than by hand.`, { expected: `n1:x-coredata://<store>/ICNote/p<N>` });
|
|
521
|
+
const [, version, id] = m;
|
|
522
|
+
if (version !== "n1") throw new PreconditionError(`Unknown note ref version "${version}". This server issues "n1:" refs.`);
|
|
523
|
+
const pk = CORE_DATA_ID.exec(id ?? "")?.[1];
|
|
524
|
+
if (!pk) throw new PreconditionError(`Note ref "${raw}" does not carry a Core Data note id.`);
|
|
525
|
+
return {
|
|
526
|
+
id,
|
|
527
|
+
primaryKey: Number(pk)
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
/** Build a ref from an index row, which only ever knows the primary key. */
|
|
531
|
+
const refFromPrimaryKey = (storeUuid, primaryKey) => encodeRef(`x-coredata://${storeUuid}/ICNote/p${primaryKey}`);
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/client/protobuf.ts
|
|
534
|
+
/**
|
|
535
|
+
* Minimal protobuf wire-format reader, enough to pull the body text out of a
|
|
536
|
+
* `ZICNOTEDATA.ZDATA` blob.
|
|
537
|
+
*
|
|
538
|
+
* ## Why it does not hardcode Apple's schema
|
|
539
|
+
*
|
|
540
|
+
* The usual approach compiles Apple's unpublished `.proto` and reads
|
|
541
|
+
* `document.note.note_text` by field number. That breaks the first time Apple
|
|
542
|
+
* renumbers a field, and this project already budgets for the schema drifting
|
|
543
|
+
* on every macOS release.
|
|
544
|
+
*
|
|
545
|
+
* So this walks the wire format structurally, and the path is *measured* rather
|
|
546
|
+
* than assumed — see `NOTE_TEXT_PATH`.
|
|
547
|
+
*
|
|
548
|
+
* Wire format, for reference:
|
|
549
|
+
* tag = varint; field = tag >> 3; wire type = tag & 7
|
|
550
|
+
* 0 varint · 1 fixed64 · 2 length-delimited · 5 fixed32 · 3/4 groups (legacy)
|
|
551
|
+
*
|
|
552
|
+
* Pure and I/O-free: callers gunzip, callers decide what to do with the result.
|
|
553
|
+
*/
|
|
554
|
+
/** Depth cap. Notes nests a handful deep; anything past this is a misparse. */
|
|
555
|
+
const MAX_DEPTH = 8;
|
|
556
|
+
/** Below this, a "string" is more likely coincidence than text. */
|
|
557
|
+
const MIN_TEXT_LENGTH = 2;
|
|
558
|
+
/** Above this share of control characters, the bytes are not text. */
|
|
559
|
+
const MAX_CONTROL_RATIO = .05;
|
|
560
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
561
|
+
/** Tab, newline and carriage return are text; the rest of C0 and DEL are not. */
|
|
562
|
+
const isControl = (code) => code < 32 && code !== 9 && code !== 10 && code !== 13 || code === 127;
|
|
563
|
+
/** Read a base-128 varint. Returns null rather than throwing on a truncated one. */
|
|
564
|
+
const readVarint = (buf, pos) => {
|
|
565
|
+
let value = 0;
|
|
566
|
+
let shift = 0;
|
|
567
|
+
let p = pos;
|
|
568
|
+
while (p < buf.length) {
|
|
569
|
+
const byte = buf[p];
|
|
570
|
+
p += 1;
|
|
571
|
+
if (shift > 45) return null;
|
|
572
|
+
value += (byte & 127) * 2 ** shift;
|
|
573
|
+
if ((byte & 128) === 0) return {
|
|
574
|
+
value,
|
|
575
|
+
next: p
|
|
576
|
+
};
|
|
577
|
+
shift += 7;
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
};
|
|
581
|
+
/**
|
|
582
|
+
* Split a buffer into wire-format fields. Returns null if the bytes are not a
|
|
583
|
+
* well-formed message ending exactly on the boundary — that "exactly" is what
|
|
584
|
+
* makes this usable as a test for whether a blob is a nested message.
|
|
585
|
+
*/
|
|
586
|
+
const parseFields = (buf, start = 0, end = buf.length) => {
|
|
587
|
+
const fields = [];
|
|
588
|
+
let p = start;
|
|
589
|
+
while (p < end) {
|
|
590
|
+
const tag = readVarint(buf, p);
|
|
591
|
+
if (!tag) return null;
|
|
592
|
+
const field = Math.floor(tag.value / 8);
|
|
593
|
+
const wire = tag.value % 8;
|
|
594
|
+
if (field === 0) return null;
|
|
595
|
+
p = tag.next;
|
|
596
|
+
if (wire === 0) {
|
|
597
|
+
const v = readVarint(buf, p);
|
|
598
|
+
if (!v) return null;
|
|
599
|
+
fields.push({
|
|
600
|
+
field,
|
|
601
|
+
wire,
|
|
602
|
+
value: v.value
|
|
603
|
+
});
|
|
604
|
+
p = v.next;
|
|
605
|
+
} else if (wire === 2) {
|
|
606
|
+
const len = readVarint(buf, p);
|
|
607
|
+
if (!len) return null;
|
|
608
|
+
const from = len.next;
|
|
609
|
+
const to = from + len.value;
|
|
610
|
+
if (to > end) return null;
|
|
611
|
+
fields.push({
|
|
612
|
+
field,
|
|
613
|
+
wire,
|
|
614
|
+
from,
|
|
615
|
+
to
|
|
616
|
+
});
|
|
617
|
+
p = to;
|
|
618
|
+
} else if (wire === 1 || wire === 5) {
|
|
619
|
+
const width = wire === 1 ? 8 : 4;
|
|
620
|
+
if (p + width > end) return null;
|
|
621
|
+
fields.push({
|
|
622
|
+
field,
|
|
623
|
+
wire
|
|
624
|
+
});
|
|
625
|
+
p += width;
|
|
626
|
+
} else return null;
|
|
627
|
+
}
|
|
628
|
+
return p === end ? fields : null;
|
|
629
|
+
};
|
|
630
|
+
/** Valid UTF-8 that is mostly not control characters. */
|
|
631
|
+
const asText = (bytes) => {
|
|
632
|
+
let text;
|
|
633
|
+
try {
|
|
634
|
+
text = utf8.decode(bytes);
|
|
635
|
+
} catch {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
if (text.length < MIN_TEXT_LENGTH) return null;
|
|
639
|
+
let controls = 0;
|
|
640
|
+
for (let i = 0; i < text.length; i += 1) if (isControl(text.charCodeAt(i))) controls += 1;
|
|
641
|
+
return controls / text.length > MAX_CONTROL_RATIO ? null : text;
|
|
642
|
+
};
|
|
643
|
+
/**
|
|
644
|
+
* Every string in the blob, with the field path that reached it.
|
|
645
|
+
*
|
|
646
|
+
* A length-delimited field can be both a valid message and valid text, so both
|
|
647
|
+
* readings are followed. That ambiguity is the normal case, not an error.
|
|
648
|
+
*/
|
|
649
|
+
const collectStrings = (buf, maxDepth = MAX_DEPTH) => {
|
|
650
|
+
const out = [];
|
|
651
|
+
const walk = (start, end, path, depth) => {
|
|
652
|
+
if (depth > maxDepth) return;
|
|
653
|
+
const fields = parseFields(buf, start, end);
|
|
654
|
+
if (!fields) return;
|
|
655
|
+
for (const f of fields) {
|
|
656
|
+
if (f.wire !== 2 || f.from === void 0 || f.to === void 0) continue;
|
|
657
|
+
const here = [...path, f.field];
|
|
658
|
+
const text = asText(buf.subarray(f.from, f.to));
|
|
659
|
+
if (text) out.push({
|
|
660
|
+
path: here.join("."),
|
|
661
|
+
text,
|
|
662
|
+
length: text.length
|
|
663
|
+
});
|
|
664
|
+
walk(f.from, f.to, here, depth + 1);
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
walk(0, buf.length, [], 0);
|
|
668
|
+
return out;
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* Where the body lives: `document(2) -> note(3) -> note_text(2)`.
|
|
672
|
+
*
|
|
673
|
+
* Measured, not assumed. Running the structural walk over 53 real notes and
|
|
674
|
+
* checking each result against the same note's Apple Events plaintext, every
|
|
675
|
+
* note read from this path matched exactly (53/53), and picking the longest
|
|
676
|
+
* string instead matched only 27/53 — on the other 26 an attribute-run field
|
|
677
|
+
* outran the body by ~60 characters. That failure is silent: it returns
|
|
678
|
+
* plausible, wrong text rather than an error.
|
|
679
|
+
*/
|
|
680
|
+
const NOTE_TEXT_PATH = "2.3.2";
|
|
681
|
+
/**
|
|
682
|
+
* The note body.
|
|
683
|
+
*
|
|
684
|
+
* Reads the pinned path first and falls back to the longest string only if that
|
|
685
|
+
* path is absent, reporting which route it took so drift is detectable rather
|
|
686
|
+
* than silently wrong.
|
|
687
|
+
*/
|
|
688
|
+
const extractNoteText = (buf, preferPath = NOTE_TEXT_PATH) => {
|
|
689
|
+
const candidates = collectStrings(buf);
|
|
690
|
+
const pinned = preferPath ? candidates.find((c) => c.path === preferPath) ?? null : null;
|
|
691
|
+
const longest = candidates.toSorted((a, b) => b.length - a.length)[0] ?? null;
|
|
692
|
+
const best = pinned ?? longest;
|
|
693
|
+
return {
|
|
694
|
+
text: best?.text ?? null,
|
|
695
|
+
path: best?.path ?? null,
|
|
696
|
+
via: best === null ? null : pinned ? "pinned" : "longest",
|
|
697
|
+
candidateCount: candidates.length
|
|
698
|
+
};
|
|
699
|
+
};
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/client/store.ts
|
|
702
|
+
/**
|
|
703
|
+
* Read-only access to `NoteStore.sqlite`.
|
|
704
|
+
*
|
|
705
|
+
* Everything here is measured against a real store — see `docs/notes.md`. Three
|
|
706
|
+
* facts do most of the work:
|
|
707
|
+
*
|
|
708
|
+
* 1. **`Z_ENT` alone is not "a note the user sees".** On the probed library it
|
|
709
|
+
* matched 1191 rows against 921 notes reported by Apple Events, and the two
|
|
710
|
+
* deletion columns explained none of the difference. `ZTITLE1 IS NOT NULL`
|
|
711
|
+
* reconciles them **exactly** — verified by comparing `Z_PK` sets, not counts.
|
|
712
|
+
* 2. **No column holds the body.** `ZTITLE1` is the title (100% coverage) and
|
|
713
|
+
* `ZWIDGETSNIPPET` a ~86-character preview; the text lives in a gzipped
|
|
714
|
+
* protobuf in `ZICNOTEDATA.ZDATA`.
|
|
715
|
+
* 3. **Dates are Core Data seconds**, offset 978307200.
|
|
716
|
+
*/
|
|
717
|
+
/** Every column this module reads, so a missing one is named rather than thrown at. */
|
|
718
|
+
const REQUIRED_COLUMNS = [
|
|
719
|
+
"Z_PK",
|
|
720
|
+
"Z_ENT",
|
|
721
|
+
"ZTITLE1",
|
|
722
|
+
"ZMODIFICATIONDATE1",
|
|
723
|
+
"ZCREATIONDATE1"
|
|
724
|
+
];
|
|
725
|
+
const OPTIONAL = {
|
|
726
|
+
snippet: "ZSNIPPET",
|
|
727
|
+
widgetSnippet: "ZWIDGETSNIPPET",
|
|
728
|
+
folder: "ZFOLDER",
|
|
729
|
+
locked: "ZISPASSWORDPROTECTED"
|
|
730
|
+
};
|
|
731
|
+
const introspect = (db) => {
|
|
732
|
+
const cols = columnsOf(db, "ZICCLOUDSYNCINGOBJECT");
|
|
733
|
+
const missing = REQUIRED_COLUMNS.filter((c) => !cols.includes(c));
|
|
734
|
+
let noteEnt = null;
|
|
735
|
+
try {
|
|
736
|
+
noteEnt = db.prepare("SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = 'ICNote'").get()?.Z_ENT ?? null;
|
|
737
|
+
} catch {
|
|
738
|
+
noteEnt = null;
|
|
739
|
+
}
|
|
740
|
+
if (noteEnt === null) missing.push("Z_PRIMARYKEY row for ICNote");
|
|
741
|
+
let storeUuid = null;
|
|
742
|
+
try {
|
|
743
|
+
storeUuid = db.prepare("SELECT Z_UUID FROM Z_METADATA LIMIT 1").get()?.Z_UUID ?? null;
|
|
744
|
+
} catch {
|
|
745
|
+
storeUuid = null;
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
fingerprint: fingerprintSchema(db),
|
|
749
|
+
storeUuid,
|
|
750
|
+
noteEnt,
|
|
751
|
+
has: {
|
|
752
|
+
title: cols.includes("ZTITLE1"),
|
|
753
|
+
snippet: cols.includes(OPTIONAL.snippet),
|
|
754
|
+
widgetSnippet: cols.includes(OPTIONAL.widgetSnippet),
|
|
755
|
+
folder: cols.includes(OPTIONAL.folder),
|
|
756
|
+
locked: cols.includes(OPTIONAL.locked)
|
|
757
|
+
},
|
|
758
|
+
deletionColumns: cols.filter((c) => /^Z(ISRECOVERINGFROMTRASH|MARKEDFORDELETION)$/.test(c)),
|
|
759
|
+
missing
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
const assertUsable = (caps) => {
|
|
763
|
+
if (caps.missing.length) throw new SchemaDriftError(`NoteStore.sqlite is not the schema this server knows how to read (fingerprint ${caps.fingerprint}). Missing: ${caps.missing.join(", ")}. The index lane is unavailable; the Apple Events lane still works.`);
|
|
764
|
+
};
|
|
765
|
+
const toIso = (coreDataSeconds) => {
|
|
766
|
+
if (coreDataSeconds === null || !Number.isFinite(coreDataSeconds)) return null;
|
|
767
|
+
return (/* @__PURE__ */ new Date((coreDataSeconds + CORE_DATA_EPOCH_OFFSET) * 1e3)).toISOString();
|
|
768
|
+
};
|
|
769
|
+
var NoteStore = class {
|
|
770
|
+
#db;
|
|
771
|
+
caps;
|
|
772
|
+
mode;
|
|
773
|
+
constructor(db, caps, mode) {
|
|
774
|
+
this.#db = db;
|
|
775
|
+
this.caps = caps;
|
|
776
|
+
this.mode = mode;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The predicate for "a note the user sees".
|
|
780
|
+
*
|
|
781
|
+
* `Z_ENT` picks note rows; `ZTITLE1 IS NOT NULL` drops the title-less rows
|
|
782
|
+
* that Apple Events does not report. Verified by set equality against the
|
|
783
|
+
* 921 ids Apple Events returned — a matching count would not have been
|
|
784
|
+
* evidence, as the first ZDATA decoder demonstrated.
|
|
785
|
+
*/
|
|
786
|
+
#where() {
|
|
787
|
+
return `o.Z_ENT = ? AND o.ZTITLE1 IS NOT NULL${this.caps.deletionColumns.map((c) => ` AND (o."${c}" IS NULL OR o."${c}" = 0)`).join("")}`;
|
|
788
|
+
}
|
|
789
|
+
#select() {
|
|
790
|
+
return `SELECT o.Z_PK AS pk, o.ZTITLE1 AS title, ${this.caps.has.widgetSnippet ? "o.ZWIDGETSNIPPET" : this.caps.has.snippet ? "o.ZSNIPPET" : "NULL"} AS snippet,
|
|
791
|
+
o.ZMODIFICATIONDATE1 AS modified, o.ZCREATIONDATE1 AS created,
|
|
792
|
+
${this.caps.has.folder ? "o.ZFOLDER" : "NULL"} AS folderPk, ${this.caps.has.locked ? "o.ZISPASSWORDPROTECTED" : "0"} AS locked
|
|
793
|
+
FROM ZICCLOUDSYNCINGOBJECT o`;
|
|
794
|
+
}
|
|
795
|
+
#toRow(r) {
|
|
796
|
+
return {
|
|
797
|
+
primaryKey: r.pk,
|
|
798
|
+
title: r.title ?? null,
|
|
799
|
+
snippet: r.snippet ?? null,
|
|
800
|
+
modified: toIso(r.modified ?? null),
|
|
801
|
+
created: toIso(r.created ?? null),
|
|
802
|
+
folderPk: r.folderPk ?? null,
|
|
803
|
+
locked: Boolean(r.locked)
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
count() {
|
|
807
|
+
return this.#db.prepare(`SELECT COUNT(*) AS c FROM ZICCLOUDSYNCINGOBJECT o WHERE ${this.#where()}`).get(this.caps.noteEnt).c;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Title and snippet search.
|
|
811
|
+
*
|
|
812
|
+
* Deliberately not full text: no indexed column carries the body. Callers that
|
|
813
|
+
* need body matching combine this with `bodyOf` or fall back to the Apple
|
|
814
|
+
* Events bulk scan.
|
|
815
|
+
*/
|
|
816
|
+
search(filters) {
|
|
817
|
+
const clauses = [this.#where()];
|
|
818
|
+
const params = [this.caps.noteEnt];
|
|
819
|
+
if (filters.query) {
|
|
820
|
+
const like = `%${escapeLike(filters.query)}%`;
|
|
821
|
+
const snippetCol = this.caps.has.widgetSnippet ? "o.ZWIDGETSNIPPET" : this.caps.has.snippet ? "o.ZSNIPPET" : null;
|
|
822
|
+
clauses.push(snippetCol ? `(o.ZTITLE1 LIKE ? ESCAPE '\\' OR ${snippetCol} LIKE ? ESCAPE '\\')` : `o.ZTITLE1 LIKE ? ESCAPE '\\'`);
|
|
823
|
+
params.push(like);
|
|
824
|
+
if (snippetCol) params.push(like);
|
|
825
|
+
}
|
|
826
|
+
if (filters.folderPk !== void 0 && this.caps.has.folder) {
|
|
827
|
+
clauses.push("o.ZFOLDER = ?");
|
|
828
|
+
params.push(filters.folderPk);
|
|
829
|
+
}
|
|
830
|
+
if (filters.modifiedAfter) {
|
|
831
|
+
clauses.push("o.ZMODIFICATIONDATE1 >= ?");
|
|
832
|
+
params.push(Date.parse(filters.modifiedAfter) / 1e3 - CORE_DATA_EPOCH_OFFSET);
|
|
833
|
+
}
|
|
834
|
+
if (filters.modifiedBefore) {
|
|
835
|
+
clauses.push("o.ZMODIFICATIONDATE1 <= ?");
|
|
836
|
+
params.push(Date.parse(filters.modifiedBefore) / 1e3 - CORE_DATA_EPOCH_OFFSET);
|
|
837
|
+
}
|
|
838
|
+
const sql = `${this.#select()} WHERE ${clauses.join(" AND ")}
|
|
839
|
+
ORDER BY o.ZMODIFICATIONDATE1 DESC LIMIT ? OFFSET ?`;
|
|
840
|
+
params.push(filters.limit, filters.offset);
|
|
841
|
+
return this.#db.prepare(sql).all(...params).map((r) => this.#toRow(r));
|
|
842
|
+
}
|
|
843
|
+
byPrimaryKey(pk) {
|
|
844
|
+
const row = this.#db.prepare(`${this.#select()} WHERE ${this.#where()} AND o.Z_PK = ?`).get(this.caps.noteEnt, pk);
|
|
845
|
+
return row ? this.#toRow(row) : null;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The note body, decoded from the gzipped protobuf.
|
|
849
|
+
*
|
|
850
|
+
* Returns null for password-protected notes: their `ZDATA` is AES ciphertext,
|
|
851
|
+
* flagged by a non-null `ZCRYPTOTAG`. No permission makes that readable.
|
|
852
|
+
*/
|
|
853
|
+
bodyOf(pk) {
|
|
854
|
+
const row = this.#db.prepare("SELECT ZDATA AS data, ZCRYPTOTAG AS tag FROM ZICNOTEDATA WHERE ZNOTE = ?").get(pk);
|
|
855
|
+
if (!row?.data) return {
|
|
856
|
+
text: null,
|
|
857
|
+
via: null,
|
|
858
|
+
encrypted: false
|
|
859
|
+
};
|
|
860
|
+
if (row.tag) return {
|
|
861
|
+
text: null,
|
|
862
|
+
via: null,
|
|
863
|
+
encrypted: true
|
|
864
|
+
};
|
|
865
|
+
const blob = Buffer.from(row.data);
|
|
866
|
+
if (blob[0] !== 31 || blob[1] !== 139) return {
|
|
867
|
+
text: null,
|
|
868
|
+
via: null,
|
|
869
|
+
encrypted: false
|
|
870
|
+
};
|
|
871
|
+
let inflated;
|
|
872
|
+
try {
|
|
873
|
+
inflated = gunzipSync(blob);
|
|
874
|
+
} catch {
|
|
875
|
+
return {
|
|
876
|
+
text: null,
|
|
877
|
+
via: null,
|
|
878
|
+
encrypted: false
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
const decoded = extractNoteText(inflated);
|
|
882
|
+
return {
|
|
883
|
+
text: decoded.text,
|
|
884
|
+
via: decoded.via,
|
|
885
|
+
encrypted: false
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
close() {
|
|
889
|
+
this.#db.close();
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
const openStore = (path, mode, logger) => {
|
|
893
|
+
const { db, mode: opened, validated: caps } = openReadOnly(path, mode, {
|
|
894
|
+
envVar: "APPLE_NOTES_INDEX_MODE",
|
|
895
|
+
label: "Notes' store",
|
|
896
|
+
hint: "If this is a permission error, grant Full Disk Access to the app running this server.",
|
|
897
|
+
validate: (conn) => {
|
|
898
|
+
const found = introspect(conn);
|
|
899
|
+
assertUsable(found);
|
|
900
|
+
return found;
|
|
901
|
+
},
|
|
902
|
+
fatal: (err) => err instanceof SchemaDriftError,
|
|
903
|
+
onFallback: () => logger?.warn?.("opened the store with immutable=1; results may omit notes still in the -wal")
|
|
904
|
+
});
|
|
905
|
+
return new NoteStore(db, caps, opened);
|
|
906
|
+
};
|
|
907
|
+
//#endregion
|
|
908
|
+
//#region src/client/notes.ts
|
|
909
|
+
/**
|
|
910
|
+
* The Notes client.
|
|
911
|
+
*
|
|
912
|
+
* Two lanes, and which one answers depends on what was granted:
|
|
913
|
+
*
|
|
914
|
+
* - **Apple Events** — accounts, folders, every mutation, and bodies. Always the
|
|
915
|
+
* authority: after a write, the result is what Notes re-read.
|
|
916
|
+
* - **Index** — `NoteStore.sqlite`, for search and metadata at a scale Apple
|
|
917
|
+
* Events cannot reach.
|
|
918
|
+
*
|
|
919
|
+
* The discipline the measurements impose is in `jxa/core.ts`: bulk array fetches
|
|
920
|
+
* only, never `whose`, never a property read per note.
|
|
921
|
+
*/
|
|
922
|
+
var AppleNotesClient = class {
|
|
923
|
+
config;
|
|
924
|
+
runner;
|
|
925
|
+
#logger;
|
|
926
|
+
#store = null;
|
|
927
|
+
#storeTried = false;
|
|
928
|
+
#bodies = null;
|
|
929
|
+
constructor(opts) {
|
|
930
|
+
this.config = opts.config;
|
|
931
|
+
this.#logger = opts.logger;
|
|
932
|
+
this.runner = opts.osascript ?? createOsascriptRunner({
|
|
933
|
+
osascriptPath: opts.config.osascriptPath,
|
|
934
|
+
timeoutMs: opts.config.osascriptTimeoutMs,
|
|
935
|
+
surface: NOTES_SURFACE,
|
|
936
|
+
logger: opts.logger
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
locate() {
|
|
940
|
+
return locateStore({ storePath: this.config.storePath });
|
|
941
|
+
}
|
|
942
|
+
/** Open the index once and remember the outcome, including the failure. */
|
|
943
|
+
index() {
|
|
944
|
+
if (this.#storeTried) return this.#store;
|
|
945
|
+
this.#storeTried = true;
|
|
946
|
+
if (this.config.indexMode === "off") return null;
|
|
947
|
+
try {
|
|
948
|
+
this.#store = openStore(this.locate().storePath, this.config.indexMode, this.#logger);
|
|
949
|
+
} catch (err) {
|
|
950
|
+
this.#logger?.debug?.("index lane unavailable", err);
|
|
951
|
+
this.#store = null;
|
|
952
|
+
}
|
|
953
|
+
return this.#store;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Probe both lanes.
|
|
957
|
+
*
|
|
958
|
+
* Apple Events is probed twice with a gap: the first event is what triggers
|
|
959
|
+
* the Automation prompt, and it can return failure while the user is still
|
|
960
|
+
* deciding — reporting "denied" then would be wrong.
|
|
961
|
+
*/
|
|
962
|
+
async lanes() {
|
|
963
|
+
let applescript = "unavailable";
|
|
964
|
+
for (let attempt = 0; attempt < 2; attempt += 1) try {
|
|
965
|
+
await this.runner.run(LIST_ACCOUNTS);
|
|
966
|
+
applescript = "live";
|
|
967
|
+
break;
|
|
968
|
+
} catch (err) {
|
|
969
|
+
this.#logger?.debug?.("applescript probe failed", err);
|
|
970
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, 400));
|
|
971
|
+
}
|
|
972
|
+
const located = this.locate();
|
|
973
|
+
const store = this.index();
|
|
974
|
+
return {
|
|
975
|
+
applescript,
|
|
976
|
+
index: this.config.indexMode === "off" ? "disabled" : store ? "live" : "unavailable",
|
|
977
|
+
indexMode: store?.mode ?? null,
|
|
978
|
+
storeFingerprint: store?.caps.fingerprint ?? null,
|
|
979
|
+
reason: store ? null : located.reason
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
#allowed(accountName) {
|
|
983
|
+
const list = this.config.accounts;
|
|
984
|
+
if (!list.length) return true;
|
|
985
|
+
return Boolean(accountName && list.some((a) => a.toLowerCase() === accountName.toLowerCase()));
|
|
986
|
+
}
|
|
987
|
+
async accounts() {
|
|
988
|
+
return (await withBusyRetry(() => this.runner.run(LIST_ACCOUNTS))).filter((a) => this.#allowed(a.name));
|
|
989
|
+
}
|
|
990
|
+
async folders() {
|
|
991
|
+
return (await withBusyRetry(() => this.runner.run(LIST_FOLDERS))).filter((f) => this.#allowed(f.accountName));
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Note metadata.
|
|
995
|
+
*
|
|
996
|
+
* Prefers the index — it holds the same fields, costs no Apple Event, and
|
|
997
|
+
* keeps working as a library grows past the point where a bulk fetch stops
|
|
998
|
+
* being instant.
|
|
999
|
+
*/
|
|
1000
|
+
async listNotes(opts) {
|
|
1001
|
+
const store = this.index();
|
|
1002
|
+
if (store?.caps.storeUuid) {
|
|
1003
|
+
const folderPk = opts.folder ? await this.#folderPk(opts.folder) : void 0;
|
|
1004
|
+
return store.search({
|
|
1005
|
+
...folderPk === void 0 ? {} : { folderPk },
|
|
1006
|
+
limit: opts.limit,
|
|
1007
|
+
offset: 0
|
|
1008
|
+
}).map((r) => this.#fromIndex(r, store.caps.storeUuid));
|
|
1009
|
+
}
|
|
1010
|
+
return this.#bulkNotes(opts);
|
|
1011
|
+
}
|
|
1012
|
+
async #folderPk(_name) {}
|
|
1013
|
+
#fromIndex(row, storeUuid) {
|
|
1014
|
+
return {
|
|
1015
|
+
ref: refFromPrimaryKey(storeUuid, row.primaryKey),
|
|
1016
|
+
title: row.title,
|
|
1017
|
+
snippet: row.snippet,
|
|
1018
|
+
folder: null,
|
|
1019
|
+
account: null,
|
|
1020
|
+
modified: row.modified,
|
|
1021
|
+
created: row.created,
|
|
1022
|
+
locked: row.locked,
|
|
1023
|
+
source: "index"
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
async #bulkNotes(opts) {
|
|
1027
|
+
return (await withBusyRetry(() => this.runner.run(BULK_NOTES))).notes.filter((n) => this.#allowed(n.account)).filter((n) => !opts.folder || n.folder === opts.folder).slice(0, Math.min(opts.limit, this.config.degradedMaxNotes)).map((n) => ({
|
|
1028
|
+
ref: encodeRef(n.id),
|
|
1029
|
+
title: n.name,
|
|
1030
|
+
snippet: null,
|
|
1031
|
+
folder: n.folder,
|
|
1032
|
+
account: n.account,
|
|
1033
|
+
modified: n.modified,
|
|
1034
|
+
created: n.created,
|
|
1035
|
+
locked: n.locked,
|
|
1036
|
+
source: "apple-events"
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Body text for every note, cached.
|
|
1041
|
+
*
|
|
1042
|
+
* A TTL rather than invalidation, deliberately: checking whether the cache is
|
|
1043
|
+
* stale costs *more* over Apple Events (fetching ids and modification dates,
|
|
1044
|
+
* 128ms) than simply redoing the scan (97ms). There is no cheap freshness
|
|
1045
|
+
* check to be had, so bounded staleness is the honest trade.
|
|
1046
|
+
*/
|
|
1047
|
+
async #allBodies() {
|
|
1048
|
+
const now = Date.now();
|
|
1049
|
+
if (this.#bodies && now - this.#bodies.at < this.config.searchCacheTtlMs) return this.#bodies;
|
|
1050
|
+
const store = this.index();
|
|
1051
|
+
const texts = /* @__PURE__ */ new Map();
|
|
1052
|
+
if (store?.caps.storeUuid) {
|
|
1053
|
+
const uuid = store.caps.storeUuid;
|
|
1054
|
+
for (const row of store.search({
|
|
1055
|
+
limit: 1e5,
|
|
1056
|
+
offset: 0
|
|
1057
|
+
})) {
|
|
1058
|
+
const body = store.bodyOf(row.primaryKey);
|
|
1059
|
+
if (body.text) texts.set(refFromPrimaryKey(uuid, row.primaryKey), body.text);
|
|
1060
|
+
}
|
|
1061
|
+
this.#bodies = {
|
|
1062
|
+
at: now,
|
|
1063
|
+
source: "index",
|
|
1064
|
+
texts
|
|
1065
|
+
};
|
|
1066
|
+
return this.#bodies;
|
|
1067
|
+
}
|
|
1068
|
+
const bulk = await withBusyRetry(() => this.runner.run(BULK_PLAINTEXT));
|
|
1069
|
+
bulk.ids.forEach((id, i) => {
|
|
1070
|
+
const text = bulk.texts[i];
|
|
1071
|
+
if (text) texts.set(encodeRef(String(id)), String(text));
|
|
1072
|
+
});
|
|
1073
|
+
this.#bodies = {
|
|
1074
|
+
at: now,
|
|
1075
|
+
source: "apple-events",
|
|
1076
|
+
texts
|
|
1077
|
+
};
|
|
1078
|
+
return this.#bodies;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Search.
|
|
1082
|
+
*
|
|
1083
|
+
* `title` uses the index's title and snippet columns, which is a SQL query.
|
|
1084
|
+
* `full` needs the body, which no indexed column carries — so it decodes the
|
|
1085
|
+
* gzipped protobuf when the index is available and falls back to the Apple
|
|
1086
|
+
* Events bulk scan when it is not. Either way the match happens in JS over a
|
|
1087
|
+
* cached corpus, because `whose` is 6.9x slower than fetching and filtering.
|
|
1088
|
+
*/
|
|
1089
|
+
async searchNotes(opts) {
|
|
1090
|
+
const store = this.index();
|
|
1091
|
+
if (opts.scope === "title" && store?.caps.storeUuid) return {
|
|
1092
|
+
notes: store.search({
|
|
1093
|
+
query: opts.query,
|
|
1094
|
+
limit: opts.limit,
|
|
1095
|
+
offset: opts.offset
|
|
1096
|
+
}).map((r) => this.#fromIndex(r, store.caps.storeUuid)),
|
|
1097
|
+
source: "index",
|
|
1098
|
+
scope: "title+snippet"
|
|
1099
|
+
};
|
|
1100
|
+
const corpus = await this.#allBodies();
|
|
1101
|
+
const needle = opts.query.toLowerCase();
|
|
1102
|
+
const hits = [];
|
|
1103
|
+
for (const [ref, text] of corpus.texts) if (text.toLowerCase().includes(needle)) hits.push(ref);
|
|
1104
|
+
const page = hits.slice(opts.offset, opts.offset + opts.limit);
|
|
1105
|
+
return {
|
|
1106
|
+
notes: (await Promise.all(page.map((ref) => this.getNote(ref, { body: false })))).map((s) => s.summary),
|
|
1107
|
+
source: corpus.source,
|
|
1108
|
+
scope: "full-text"
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
async getNote(ref, opts = {}) {
|
|
1112
|
+
const decoded = decodeRef(ref);
|
|
1113
|
+
const store = this.index();
|
|
1114
|
+
if (store?.caps.storeUuid) {
|
|
1115
|
+
const row = store.byPrimaryKey(decoded.primaryKey);
|
|
1116
|
+
if (row) {
|
|
1117
|
+
const summary = this.#fromIndex(row, store.caps.storeUuid);
|
|
1118
|
+
if (opts.body === false) return {
|
|
1119
|
+
summary,
|
|
1120
|
+
body: null,
|
|
1121
|
+
bodySource: null
|
|
1122
|
+
};
|
|
1123
|
+
if (row.locked) throw new NoteLockedError(ref);
|
|
1124
|
+
const body = store.bodyOf(decoded.primaryKey);
|
|
1125
|
+
if (body.encrypted) throw new NoteLockedError(ref);
|
|
1126
|
+
if (body.text !== null) return {
|
|
1127
|
+
summary,
|
|
1128
|
+
body: this.#truncate(body.text),
|
|
1129
|
+
bodySource: `index:${body.via}`
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const [found] = await withBusyRetry(() => this.runner.run(GET_NOTE_BODIES, { ids: [decoded.id] }));
|
|
1134
|
+
if (!found?.found) throw new NoteNotFoundError(ref);
|
|
1135
|
+
if (found.locked) throw new NoteLockedError(ref);
|
|
1136
|
+
return {
|
|
1137
|
+
summary: {
|
|
1138
|
+
ref,
|
|
1139
|
+
title: found.name,
|
|
1140
|
+
snippet: null,
|
|
1141
|
+
folder: null,
|
|
1142
|
+
account: null,
|
|
1143
|
+
modified: found.modified,
|
|
1144
|
+
created: found.created,
|
|
1145
|
+
locked: false,
|
|
1146
|
+
source: "apple-events"
|
|
1147
|
+
},
|
|
1148
|
+
body: found.plaintext === null ? null : this.#truncate(found.plaintext),
|
|
1149
|
+
bodySource: "apple-events"
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
#truncate(text) {
|
|
1153
|
+
const max = this.config.bodyMaxBytes;
|
|
1154
|
+
if (Buffer.byteLength(text, "utf8") <= max) return text;
|
|
1155
|
+
return `${text.slice(0, max)}\n\n[truncated at ${max} bytes; raise APPLE_NOTES_BODY_MAX_BYTES]`;
|
|
1156
|
+
}
|
|
1157
|
+
async attachments(ref) {
|
|
1158
|
+
const decoded = decodeRef(ref);
|
|
1159
|
+
return withBusyRetry(() => this.runner.run(LIST_ATTACHMENTS, { id: decoded.id }));
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Save an attachment's bytes.
|
|
1163
|
+
*
|
|
1164
|
+
* The scripting dictionary exposes `name`, `id`, `URL` and `content
|
|
1165
|
+
* identifier` but **no filesystem path**, so the bytes can only come from the
|
|
1166
|
+
* media directory — which is Full Disk Access territory. Without it this
|
|
1167
|
+
* reports what is missing rather than failing obscurely.
|
|
1168
|
+
*/
|
|
1169
|
+
async saveAttachment(ref, attachmentId, targetDir, opts = {}) {
|
|
1170
|
+
const located = this.locate();
|
|
1171
|
+
if (!located.readable) throw new PreconditionError("Attachment bytes need Full Disk Access: the scripting dictionary carries no file path for attachments, so they can only be read from the Notes media directory. Call apple_notes_diagnostics for the exact pane to open.");
|
|
1172
|
+
const meta = (await this.attachments(ref)).find((a) => a.id === attachmentId);
|
|
1173
|
+
if (!meta) throw new PreconditionError(`No attachment ${attachmentId} on note ${ref}.`);
|
|
1174
|
+
const source = this.#findMedia(located.mediaRoot, attachmentId, meta.name);
|
|
1175
|
+
if (!source) throw new PreconditionError(`Found the attachment's metadata but no file for it under ${located.mediaRoot}.`);
|
|
1176
|
+
const root = resolve(this.config.attachmentDir);
|
|
1177
|
+
const dir = targetDir ? resolve(root, targetDir) : root;
|
|
1178
|
+
if (dir !== root && !dir.startsWith(root + sep)) throw new PreconditionError(`Refusing to write outside ${root}. Set APPLE_NOTES_ATTACHMENT_DIR to change the destination.`);
|
|
1179
|
+
const name = basename(meta.name ?? basename(source));
|
|
1180
|
+
const target = resolve(join(dir, name));
|
|
1181
|
+
if (target !== join(dir, name) || !target.startsWith(dir + sep)) throw new PreconditionError(`Refusing to write outside ${dir}.`);
|
|
1182
|
+
if (existsSync(target) && !opts.overwrite) throw new PreconditionError(`${target} already exists; refusing to overwrite it.`);
|
|
1183
|
+
const bytes = readFileSync(source);
|
|
1184
|
+
mkdirSync(dir, { recursive: true });
|
|
1185
|
+
writeFileSync(target, bytes, { mode: 384 });
|
|
1186
|
+
return {
|
|
1187
|
+
path: target,
|
|
1188
|
+
bytes: bytes.length
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
/** Bounded walk for a file whose directory is named after the attachment id. */
|
|
1192
|
+
#findMedia(root, attachmentId, name, depth = 0) {
|
|
1193
|
+
if (depth > 5) return null;
|
|
1194
|
+
let entries;
|
|
1195
|
+
try {
|
|
1196
|
+
entries = readdirSync(root);
|
|
1197
|
+
} catch {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
for (const entry of entries) {
|
|
1201
|
+
const full = join(root, entry);
|
|
1202
|
+
let isDir = false;
|
|
1203
|
+
try {
|
|
1204
|
+
isDir = statSync(full).isDirectory();
|
|
1205
|
+
} catch {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
if (isDir) {
|
|
1209
|
+
if (entry === attachmentId) {
|
|
1210
|
+
const inner = (() => {
|
|
1211
|
+
try {
|
|
1212
|
+
return readdirSync(full);
|
|
1213
|
+
} catch {
|
|
1214
|
+
return [];
|
|
1215
|
+
}
|
|
1216
|
+
})();
|
|
1217
|
+
const pick = name ? inner.find((f) => f === name) ?? inner[0] : inner[0];
|
|
1218
|
+
if (pick) return join(full, pick);
|
|
1219
|
+
}
|
|
1220
|
+
const found = this.#findMedia(full, attachmentId, name, depth + 1);
|
|
1221
|
+
if (found) return found;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
async createNote(opts) {
|
|
1227
|
+
this.#bodies = null;
|
|
1228
|
+
return withBusyRetry(() => this.runner.run(CREATE_NOTE, opts));
|
|
1229
|
+
}
|
|
1230
|
+
async updateNote(ref, body, mode) {
|
|
1231
|
+
this.#bodies = null;
|
|
1232
|
+
const decoded = decodeRef(ref);
|
|
1233
|
+
return withBusyRetry(() => this.runner.run(UPDATE_NOTE, {
|
|
1234
|
+
id: decoded.id,
|
|
1235
|
+
body,
|
|
1236
|
+
mode
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
async moveNote(ref, folderId) {
|
|
1240
|
+
this.#bodies = null;
|
|
1241
|
+
const decoded = decodeRef(ref);
|
|
1242
|
+
return withBusyRetry(() => this.runner.run(MOVE_NOTE, {
|
|
1243
|
+
id: decoded.id,
|
|
1244
|
+
folderId
|
|
1245
|
+
}));
|
|
1246
|
+
}
|
|
1247
|
+
async deleteNotes(refs) {
|
|
1248
|
+
this.#bodies = null;
|
|
1249
|
+
const ids = refs.map((r) => decodeRef(r).id);
|
|
1250
|
+
return withBusyRetry(() => this.runner.run(DELETE_NOTES, { ids }));
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region src/config.ts
|
|
1255
|
+
/**
|
|
1256
|
+
* Configuration is environment-only — this server holds no secret at all, its
|
|
1257
|
+
* access is the macOS permission the user granted.
|
|
1258
|
+
*
|
|
1259
|
+
* `allowWrites`, `debug`, `osascriptPath`, `osascriptTimeoutMs` and `maxResults`
|
|
1260
|
+
* come from `BaseConfigSchema`.
|
|
1261
|
+
*/
|
|
1262
|
+
const ConfigSchema = BaseConfigSchema.extend({
|
|
1263
|
+
/**
|
|
1264
|
+
* Account allowlist (names or ids). Empty means every account.
|
|
1265
|
+
*
|
|
1266
|
+
* `allowWrites` gates mutation, but the larger blast radius is *reading* a
|
|
1267
|
+
* person's whole notebook, so this is the read-side control.
|
|
1268
|
+
*/
|
|
1269
|
+
accounts: z.array(z.string().min(1)).default([]),
|
|
1270
|
+
/** Explicit NoteStore.sqlite path. Bypasses discovery — for tests and forensic copies. */
|
|
1271
|
+
storePath: z.string().optional(),
|
|
1272
|
+
indexMode: z.enum([
|
|
1273
|
+
"auto",
|
|
1274
|
+
"ro",
|
|
1275
|
+
"immutable",
|
|
1276
|
+
"off"
|
|
1277
|
+
]).default("auto"),
|
|
1278
|
+
/**
|
|
1279
|
+
* Cap for the Apple Events listing lane. Generous compared to Mail's because
|
|
1280
|
+
* a bulk property fetch here is one round trip at ~0.1ms per note, not 42ms.
|
|
1281
|
+
*/
|
|
1282
|
+
degradedMaxNotes: z.number().int().min(1).max(1e3).default(200),
|
|
1283
|
+
bodyMaxBytes: z.number().int().min(1024).max(1e7).default(262144),
|
|
1284
|
+
/**
|
|
1285
|
+
* The confinement boundary for save_attachment, not merely its default: the
|
|
1286
|
+
* tool's `directory` argument may select a subdirectory of this, never escape
|
|
1287
|
+
* it. Enforced lexically with `resolve()` + `basename()`, not `realpath()` —
|
|
1288
|
+
* the destination may not exist yet.
|
|
1289
|
+
*/
|
|
1290
|
+
attachmentDir: z.string().default(join(homedir(), "Downloads")),
|
|
1291
|
+
/**
|
|
1292
|
+
* How long the Apple Events plaintext cache stays warm.
|
|
1293
|
+
*
|
|
1294
|
+
* Measured: fetching ids + modification dates to check freshness costs *more*
|
|
1295
|
+
* (128ms) than simply re-scanning every body (97ms), so there is no cheap
|
|
1296
|
+
* invalidation to be had. A TTL with bounded staleness is the only sensible
|
|
1297
|
+
* trade when the index lane is unavailable.
|
|
1298
|
+
*/
|
|
1299
|
+
searchCacheTtlMs: z.number().int().min(0).max(36e5).default(6e4)
|
|
1300
|
+
}).strict();
|
|
1301
|
+
/**
|
|
1302
|
+
* `env` is a parameter with a default so tests are hermetic — they pass their
|
|
1303
|
+
* own object rather than mutating (and having to restore) process.env.
|
|
1304
|
+
*/
|
|
1305
|
+
const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
|
|
1306
|
+
allowWrites: parseBool(env.APPLE_NOTES_ALLOW_WRITES),
|
|
1307
|
+
debug: parseBool(env.APPLE_NOTES_DEBUG),
|
|
1308
|
+
accounts: parseList(env.APPLE_NOTES_ACCOUNTS),
|
|
1309
|
+
storePath: trimmed(env.APPLE_NOTES_STORE),
|
|
1310
|
+
indexMode: trimmed(env.APPLE_NOTES_INDEX_MODE),
|
|
1311
|
+
osascriptPath: trimmed(env.APPLE_NOTES_OSASCRIPT_PATH),
|
|
1312
|
+
osascriptTimeoutMs: parseIntOpt(env.APPLE_NOTES_OSASCRIPT_TIMEOUT_MS),
|
|
1313
|
+
maxResults: parseIntOpt(env.APPLE_NOTES_MAX_RESULTS),
|
|
1314
|
+
degradedMaxNotes: parseIntOpt(env.APPLE_NOTES_DEGRADED_MAX_NOTES),
|
|
1315
|
+
bodyMaxBytes: parseIntOpt(env.APPLE_NOTES_BODY_MAX_BYTES),
|
|
1316
|
+
attachmentDir: trimmed(env.APPLE_NOTES_ATTACHMENT_DIR),
|
|
1317
|
+
searchCacheTtlMs: parseIntOpt(env.APPLE_NOTES_SEARCH_CACHE_TTL_MS)
|
|
1318
|
+
});
|
|
1319
|
+
//#endregion
|
|
1320
|
+
//#region src/tools/util.ts
|
|
1321
|
+
const noteRefArg = z.string().min(1).describe("An opaque note ref from a search or list result (looks like \"n1:x-coredata://…/ICNote/p123\"). Do not construct one by hand.");
|
|
1322
|
+
const noteRefsArg = z.array(z.string().min(1)).min(1).max(200).describe("Note refs from a search or list result.");
|
|
1323
|
+
const folderArg = z.string().optional().describe("Folder name, e.g. \"Notes\" or \"Projects\". Folders nest; give the leaf name.");
|
|
1324
|
+
const folderIdArg = z.string().min(1).describe("A folder id from apple_notes_list_folders.");
|
|
1325
|
+
//#endregion
|
|
1326
|
+
//#region src/tools/accounts.ts
|
|
1327
|
+
const registerAccountTools = (server, client) => {
|
|
1328
|
+
server.registerTool("apple_notes_list_accounts", {
|
|
1329
|
+
description: "List the Notes accounts, with how many folders and notes each holds. Start here to discover account names for the other tools.",
|
|
1330
|
+
inputSchema: {},
|
|
1331
|
+
annotations: { readOnlyHint: true }
|
|
1332
|
+
}, async () => wrap(() => client.accounts()));
|
|
1333
|
+
server.registerTool("apple_notes_list_folders", {
|
|
1334
|
+
description: "List folders across every account. Folders nest, so each entry carries its depth and the account it belongs to. Use the returned id with the move and create tools.",
|
|
1335
|
+
inputSchema: {},
|
|
1336
|
+
annotations: { readOnlyHint: true }
|
|
1337
|
+
}, async () => wrap(() => client.folders()));
|
|
1338
|
+
};
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/tools/actions.ts
|
|
1341
|
+
/**
|
|
1342
|
+
* The mutating tools.
|
|
1343
|
+
*
|
|
1344
|
+
* Registered only when writes are enabled, so with the flag off they are
|
|
1345
|
+
* invisible to the model rather than merely refused.
|
|
1346
|
+
*/
|
|
1347
|
+
const registerActionTools = (server, client) => {
|
|
1348
|
+
server.registerTool("apple_notes_create_note", {
|
|
1349
|
+
description: "Create a note. Notes takes its title from the first line of the body, so `title` is prepended as a heading rather than set as a property.",
|
|
1350
|
+
inputSchema: {
|
|
1351
|
+
title: z.string().optional().describe("Becomes the first line, and therefore the title."),
|
|
1352
|
+
body: z.string().optional().describe("Body content. Simple HTML is accepted."),
|
|
1353
|
+
folderId: z.string().optional().describe("Target folder id. Defaults to the first account's default folder.")
|
|
1354
|
+
}
|
|
1355
|
+
}, async ({ title, body, folderId }) => wrap(() => client.createNote({
|
|
1356
|
+
title,
|
|
1357
|
+
body,
|
|
1358
|
+
folderId
|
|
1359
|
+
})));
|
|
1360
|
+
server.registerTool("apple_notes_update_note", {
|
|
1361
|
+
description: "Replace or append to a note's body. Editing the first line changes the title, because that is where Notes takes it from.",
|
|
1362
|
+
inputSchema: {
|
|
1363
|
+
ref: noteRefArg,
|
|
1364
|
+
body: z.string().min(1).describe("The new content. Simple HTML is accepted."),
|
|
1365
|
+
mode: z.enum(["replace", "append"]).optional().describe("replace overwrites the body (default). append adds to the end.")
|
|
1366
|
+
}
|
|
1367
|
+
}, async ({ ref, body, mode }) => wrap(() => client.updateNote(ref, body, mode ?? "replace")));
|
|
1368
|
+
server.registerTool("apple_notes_move_note", {
|
|
1369
|
+
description: "Move a note to another folder.",
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
ref: noteRefArg,
|
|
1372
|
+
folderId: folderIdArg
|
|
1373
|
+
}
|
|
1374
|
+
}, async ({ ref, folderId }) => wrap(() => client.moveNote(ref, folderId)));
|
|
1375
|
+
server.registerTool("apple_notes_delete_notes", {
|
|
1376
|
+
description: "Delete notes. They go to Recently Deleted rather than being destroyed, but this still requires an explicit confirm.",
|
|
1377
|
+
inputSchema: {
|
|
1378
|
+
refs: noteRefsArg,
|
|
1379
|
+
confirm: confirmArg
|
|
1380
|
+
}
|
|
1381
|
+
}, async ({ refs }) => wrap(() => client.deleteNotes(refs)));
|
|
1382
|
+
};
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/tools/diagnostics.ts
|
|
1385
|
+
/**
|
|
1386
|
+
* One tool that answers "why is this not working".
|
|
1387
|
+
*
|
|
1388
|
+
* Lanes are probed before anything else is read, because the first Apple Event
|
|
1389
|
+
* is what triggers the Automation prompt: reading accounts first and probing
|
|
1390
|
+
* afterwards makes the two disagree whenever that first event loses the race.
|
|
1391
|
+
*/
|
|
1392
|
+
const registerDiagnosticsTools = (server, client, ctx) => {
|
|
1393
|
+
server.registerTool("apple_notes_diagnostics", {
|
|
1394
|
+
description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool returns degraded or fails — it names the exact System Settings pane to open.",
|
|
1395
|
+
inputSchema: {},
|
|
1396
|
+
annotations: { readOnlyHint: true }
|
|
1397
|
+
}, async () => wrap(async () => {
|
|
1398
|
+
const lanes = await client.lanes();
|
|
1399
|
+
const located = client.locate();
|
|
1400
|
+
const accounts = lanes.applescript === "live" ? await client.accounts() : [];
|
|
1401
|
+
const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied" : "unknown (no store found)";
|
|
1402
|
+
return {
|
|
1403
|
+
server: { lanes },
|
|
1404
|
+
permissions: {
|
|
1405
|
+
automation: lanes.applescript === "live" ? "granted" : "denied-or-notes-not-running",
|
|
1406
|
+
fullDiskAccess,
|
|
1407
|
+
...located.readable ? {} : { howToGrant: [
|
|
1408
|
+
"System Settings > Privacy & Security > Full Disk Access",
|
|
1409
|
+
"Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
|
|
1410
|
+
"Granting it to Notes.app does nothing — the reader needs the permission, not Notes."
|
|
1411
|
+
] }
|
|
1412
|
+
},
|
|
1413
|
+
store: {
|
|
1414
|
+
path: located.storePath,
|
|
1415
|
+
exists: located.exists,
|
|
1416
|
+
readable: located.readable,
|
|
1417
|
+
sizeBytes: located.size,
|
|
1418
|
+
walPresent: located.walPresent,
|
|
1419
|
+
walSizeBytes: located.walSizeBytes,
|
|
1420
|
+
fingerprint: lanes.storeFingerprint,
|
|
1421
|
+
reason: located.reason
|
|
1422
|
+
},
|
|
1423
|
+
accounts,
|
|
1424
|
+
settings: {
|
|
1425
|
+
allowWrites: ctx.allowWrites,
|
|
1426
|
+
accountAllowlist: client.config.accounts,
|
|
1427
|
+
indexMode: client.config.indexMode,
|
|
1428
|
+
maxResults: client.config.maxResults,
|
|
1429
|
+
searchCacheTtlMs: client.config.searchCacheTtlMs,
|
|
1430
|
+
attachmentDir: client.config.attachmentDir
|
|
1431
|
+
},
|
|
1432
|
+
caveats: [
|
|
1433
|
+
"Without Full Disk Access the index lane is unavailable: search falls back to an Apple Events bulk scan, which is fine on a small library and degrades linearly.",
|
|
1434
|
+
"Password-protected notes are encrypted at rest; no permission makes them readable.",
|
|
1435
|
+
"Attachment bytes need Full Disk Access — the scripting dictionary carries no file path."
|
|
1436
|
+
]
|
|
1437
|
+
};
|
|
1438
|
+
}).then((r) => r ?? ok({})));
|
|
1439
|
+
};
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/tools/notes.ts
|
|
1442
|
+
const registerNoteTools = (server, client, allowWrites) => {
|
|
1443
|
+
server.registerTool("apple_notes_list_notes", {
|
|
1444
|
+
description: "List notes, newest first. Returns a `ref` per note for the read and action tools. Prefers Notes' own index, which stays fast as a library grows.",
|
|
1445
|
+
inputSchema: {
|
|
1446
|
+
folder: folderArg,
|
|
1447
|
+
limit: limitArg
|
|
1448
|
+
},
|
|
1449
|
+
annotations: { readOnlyHint: true }
|
|
1450
|
+
}, async ({ folder, limit }) => wrap(() => client.listNotes({
|
|
1451
|
+
folder,
|
|
1452
|
+
limit: limit ?? Math.min(25, client.config.maxResults)
|
|
1453
|
+
})));
|
|
1454
|
+
server.registerTool("apple_notes_search_notes", {
|
|
1455
|
+
description: "Search notes. `scope: \"full\"` (the default) matches the body text; `scope: \"title\"` matches only titles and previews and is the cheaper query. Returns a `ref` per hit.",
|
|
1456
|
+
inputSchema: {
|
|
1457
|
+
query: z.string().min(1).describe("Text to look for. Case-insensitive substring match."),
|
|
1458
|
+
scope: z.enum(["full", "title"]).optional().describe("full = search body text (default). title = titles and previews only, which is a single indexed query and cheaper on a large library."),
|
|
1459
|
+
limit: limitArg,
|
|
1460
|
+
offset: z.number().int().min(0).optional().describe("Skip this many results, for paging.")
|
|
1461
|
+
},
|
|
1462
|
+
annotations: { readOnlyHint: true }
|
|
1463
|
+
}, async ({ query, scope, limit, offset }) => wrap(() => client.searchNotes({
|
|
1464
|
+
query,
|
|
1465
|
+
scope: scope ?? "full",
|
|
1466
|
+
limit: limit ?? Math.min(25, client.config.maxResults),
|
|
1467
|
+
offset: offset ?? 0
|
|
1468
|
+
})));
|
|
1469
|
+
server.registerTool("apple_notes_get_note", {
|
|
1470
|
+
description: "Read one note's full text by ref. Password-protected notes report as locked rather than returning an empty body — their text is encrypted at rest.",
|
|
1471
|
+
inputSchema: {
|
|
1472
|
+
ref: noteRefArg,
|
|
1473
|
+
format: z.enum(["text", "json"]).optional().describe("text returns the body as-is (default). json wraps it with the metadata.")
|
|
1474
|
+
},
|
|
1475
|
+
annotations: { readOnlyHint: true }
|
|
1476
|
+
}, async ({ ref, format }) => wrapResult(async () => {
|
|
1477
|
+
const result = await client.getNote(ref);
|
|
1478
|
+
if ((format ?? "text") === "text" && result.body !== null) return okText(result.body);
|
|
1479
|
+
return ok(result);
|
|
1480
|
+
}));
|
|
1481
|
+
server.registerTool("apple_notes_list_attachments", {
|
|
1482
|
+
description: "List a note's attachments. Metadata only — retrieving the bytes needs Full Disk Access, because the scripting dictionary carries no file path.",
|
|
1483
|
+
inputSchema: { ref: noteRefArg },
|
|
1484
|
+
annotations: { readOnlyHint: true }
|
|
1485
|
+
}, async ({ ref }) => wrap(() => client.attachments(ref)));
|
|
1486
|
+
if (!allowWrites) return;
|
|
1487
|
+
server.registerTool("apple_notes_save_attachment", {
|
|
1488
|
+
description: "Save one attachment to disk. Needs Full Disk Access. It can only write into APPLE_NOTES_ATTACHMENT_DIR (default ~/Downloads) and will not overwrite an existing file unless you ask it to. Write-gated because it puts a file on the user's disk, even though it changes nothing in Notes.",
|
|
1489
|
+
inputSchema: {
|
|
1490
|
+
ref: noteRefArg,
|
|
1491
|
+
attachmentId: z.string().min(1).describe("An id from apple_notes_list_attachments."),
|
|
1492
|
+
directory: z.string().optional().describe("Override the target directory. Must still resolve inside the configured one."),
|
|
1493
|
+
overwrite: z.boolean().optional().describe("Replace an existing file. Default false.")
|
|
1494
|
+
},
|
|
1495
|
+
annotations: {
|
|
1496
|
+
readOnlyHint: false,
|
|
1497
|
+
destructiveHint: false,
|
|
1498
|
+
idempotentHint: false
|
|
1499
|
+
}
|
|
1500
|
+
}, async ({ ref, attachmentId, directory, overwrite }) => wrap(() => client.saveAttachment(ref, attachmentId, directory, overwrite ? { overwrite } : {})));
|
|
1501
|
+
};
|
|
1502
|
+
//#endregion
|
|
1503
|
+
//#region src/tools/index.ts
|
|
1504
|
+
/**
|
|
1505
|
+
* Register the Apple Notes tools.
|
|
1506
|
+
*
|
|
1507
|
+
* The registered set is a pure function of `allowWrites` and nothing else. In
|
|
1508
|
+
* particular it does NOT vary with whether Full Disk Access is granted: that is
|
|
1509
|
+
* a runtime condition which can change while the process lives, and MCP clients
|
|
1510
|
+
* cache the tool list, so a tool that appears and disappears would leave clients
|
|
1511
|
+
* calling names the server no longer has. Tools that need the index instead
|
|
1512
|
+
* report their source, or explain what is missing.
|
|
1513
|
+
*/
|
|
1514
|
+
const registerTools = (server, client, ctx) => {
|
|
1515
|
+
registerDiagnosticsTools(server, client, ctx);
|
|
1516
|
+
registerAccountTools(server, client);
|
|
1517
|
+
registerNoteTools(server, client, ctx.allowWrites);
|
|
1518
|
+
if (!ctx.allowWrites) return;
|
|
1519
|
+
registerActionTools(server, client);
|
|
1520
|
+
};
|
|
1521
|
+
//#endregion
|
|
1522
|
+
//#region src/server.ts
|
|
1523
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
1524
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
1525
|
+
/**
|
|
1526
|
+
* Build the server. Side-effect free: it opens no connection, spawns no
|
|
1527
|
+
* process and reads no file, so a test can construct it freely and every
|
|
1528
|
+
* external dependency arrives through an option.
|
|
1529
|
+
*/
|
|
1530
|
+
const createServer = (opts) => {
|
|
1531
|
+
const { config } = opts;
|
|
1532
|
+
const server = new McpServer({
|
|
1533
|
+
name: SERVER_NAME,
|
|
1534
|
+
version: SERVER_VERSION
|
|
1535
|
+
});
|
|
1536
|
+
const client = new AppleNotesClient({
|
|
1537
|
+
config,
|
|
1538
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
1539
|
+
...opts.osascript ? { osascript: opts.osascript } : {}
|
|
1540
|
+
});
|
|
1541
|
+
registerTools(server, client, { allowWrites: config.allowWrites });
|
|
1542
|
+
return {
|
|
1543
|
+
server,
|
|
1544
|
+
client
|
|
1545
|
+
};
|
|
1546
|
+
};
|
|
1547
|
+
//#endregion
|
|
1548
|
+
export { NoteLockedError as C, BUILD_INFO as D, NotesNotRunningError as E, NOTES_SURFACE as S, NotesBusyError as T, encodeRef as _, loadConfig as a, locateStore as b, introspect as c, collectStrings as d, extractNoteText as f, decodeRef as g, REF_VERSION as h, registerTools as i, openStore as l, readVarint as m, SERVER_VERSION as n, AppleNotesClient as o, parseFields as p, createServer as r, NoteStore as s, SERVER_NAME as t, NOTE_TEXT_PATH as u, refFromPrimaryKey as v, NoteNotFoundError as w, AppleNotesError as x, defaultStorePath as y };
|
|
1549
|
+
|
|
1550
|
+
//# sourceMappingURL=server-HLS_dM7r.js.map
|