@mgcrea/mcp-apple-maps 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.
@@ -0,0 +1,2080 @@
1
+ import { AppleAutomationError, AppleAutomationError as AppleMapsError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, SchemaDriftError, SchemaDriftError as SchemaDriftError$1, columnsOf, compact, describeStore, detectEpoch, escapeLike, fail, fingerprintSchema, inspectFile, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, trimmed, wrap, wrapResult } from "@mgcrea/mcp-apple-core";
2
+ import { readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execFileSync } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { z } from "zod";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ //#region src/build-info.ts
11
+ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
12
+ name: "@mgcrea/mcp-apple-maps",
13
+ version: "0.0.0"
14
+ });
15
+ const BUILD_INFO = {
16
+ name: pkg.name,
17
+ version: pkg.version,
18
+ gitCommit: "1779320",
19
+ gitCommitDate: "2026-08-30T12:29:09+02:00"
20
+ };
21
+ //#endregion
22
+ //#region src/client/dates.ts
23
+ /**
24
+ * Date handling for the Maps tools.
25
+ *
26
+ * Deliberately much smaller than `packages/safari/src/client/dates.ts`. That
27
+ * surface needed a whole input grammar because history is a range query; Maps'
28
+ * entities are lists a person curates, and v1 ships no tool taking a date
29
+ * argument. So this module solves only the OUTPUT half — turning a stored
30
+ * number into an instant. Inventing a parser nothing calls would be a fourth
31
+ * copy of a grammar `packages/safari` has already noted should be hoisted into
32
+ * core rather than duplicated again.
33
+ *
34
+ * ## The epoch is DETECTED, never assumed
35
+ *
36
+ * `docs/maps.md` records the measurement: `ZCREATETIME` resolves to
37
+ * **apple-seconds** (Core Data, 2001 anchor). The same value read as unix
38
+ * seconds lands in **1995** — well-formed, plausible at a glance, and wrong by
39
+ * 31 years. `pnpm probe:maps` prints both readings side by side precisely
40
+ * because the wrong one does not announce itself.
41
+ *
42
+ * So the offset is resolved from the store's own newest timestamp at open time,
43
+ * and when it cannot be resolved every date renders `null` rather than being
44
+ * guessed. An absent timestamp is a visible gap somebody can report; a
45
+ * confidently wrong one is not. This is the discipline `packages/safari`
46
+ * arrived at after a probe misread its own column — adopted here rather than
47
+ * re-derived.
48
+ */
49
+ /** What `detectEpoch` says when it has matched nothing. Same shape as Safari's. */
50
+ const GAVE_UP = /^(no dated rows|neither epoch)/;
51
+ const resolveEpoch = (maxTimestamp, now = Date.now()) => {
52
+ const { offset, reason } = detectEpoch(maxTimestamp, now);
53
+ return {
54
+ offset,
55
+ reason,
56
+ confident: !GAVE_UP.test(reason)
57
+ };
58
+ };
59
+ /** The expectation docs/maps.md carries, used only where no store is open. */
60
+ const APPLE_SECONDS = {
61
+ offset: CORE_DATA_EPOCH_OFFSET,
62
+ reason: "assumed apple-seconds; no store was opened to measure against",
63
+ confident: false
64
+ };
65
+ /** A stored timestamp to a JS Date, or null when it cannot be placed. */
66
+ const fromStoreTime = (value, epoch) => {
67
+ if (value === null || !Number.isFinite(value) || value === 0) return null;
68
+ if (!epoch.confident) return null;
69
+ const date = /* @__PURE__ */ new Date((value + epoch.offset) * 1e3);
70
+ return Number.isNaN(date.getTime()) ? null : date;
71
+ };
72
+ /** ISO-8601, or null. What every date field on a result carries. */
73
+ const renderInstant = (value, epoch) => fromStoreTime(value, epoch)?.toISOString() ?? null;
74
+ //#endregion
75
+ //#region src/client/errors.ts
76
+ /**
77
+ * Maps' error surface. The taxonomy lives in `@mgcrea/mcp-apple-core`; what
78
+ * belongs here is the identity those messages are written against, plus the
79
+ * failures no other surface has.
80
+ */
81
+ const MAPS_SURFACE = {
82
+ appName: "Maps",
83
+ envPrefix: "APPLE_MAPS"
84
+ };
85
+ /**
86
+ * Maps is one of the few Apple apps whose bundle id matches its display name —
87
+ * unlike `com.apple.iCal`, `com.apple.AddressBook` and `com.apple.MobileSMS`.
88
+ * Stated rather than assumed, because this repo has been caught by the opposite
89
+ * three times.
90
+ *
91
+ * It is recorded here and used by almost nothing. Maps ships **no scripting
92
+ * dictionary** — `/System/Applications/Maps.app` contains no `.sdef`, checked
93
+ * directly rather than inferred from `NSAppleScriptEnabled` — so there is no
94
+ * Apple Events lane to address, and this server never sends one.
95
+ */
96
+ const MAPS_BUNDLE_ID = "com.apple.Maps";
97
+ /**
98
+ * The store could not be read.
99
+ *
100
+ * This surface has **one lane and no fallback**, which puts it with Messages
101
+ * rather than with Safari: without Full Disk Access there is no degraded Maps
102
+ * server, there is no Maps server. So every read throws this rather than
103
+ * returning an empty list — an empty `favorites` reads exactly like a person
104
+ * who has saved no places, and that is the failure this error exists to
105
+ * prevent.
106
+ *
107
+ * The hint names the trap that actually catches people: the store lives at a
108
+ * path with **no file extension**, inside the one directory in Maps' container
109
+ * that Full Disk Access gates. A sweep for `*.db` finds nothing and concludes
110
+ * the data is not on disk. It is.
111
+ */
112
+ var MapsStoreUnavailableError = class extends AppleAutomationError {
113
+ name = "MapsStoreUnavailableError";
114
+ constructor(reason) {
115
+ super(`${reason} Maps has no second lane — it ships no scripting dictionary, so there is no Apple Events fallback and nothing at all can be read without the grant.`, {});
116
+ }
117
+ };
118
+ /** A ref no longer resolves — the place was removed, or the store was re-synced. */
119
+ var PlaceNotFoundError = class extends AppleAutomationError {
120
+ name = "PlaceNotFoundError";
121
+ constructor(ref) {
122
+ super(`No place for ref "${ref}". It may have been removed in Maps, or iCloud may have re-synced the store and renumbered it. Re-run the listing to get a current ref.`, { ref });
123
+ }
124
+ };
125
+ /**
126
+ * The epoch could not be identified from the data.
127
+ *
128
+ * The same discipline `packages/safari` applies, for the same measured reason:
129
+ * a wrong epoch produces dates that are well-formed and wrong by 31 years.
130
+ * `docs/maps.md` records that this store's `ZCREATETIME` read as apple-seconds
131
+ * and that a unix-seconds reading of the same value lands in 1995 while still
132
+ * looking entirely plausible. So the offset is detected from the store at open
133
+ * time and dates are WITHHELD when it cannot be.
134
+ */
135
+ var UndatableStoreError = class extends AppleAutomationError {
136
+ name = "UndatableStoreError";
137
+ constructor(reason) {
138
+ super(`Maps' timestamps could not be placed on a known epoch (${reason}). Dates are withheld rather than guessed — a timestamp wrong by decades reads exactly like a correct one. Everything that does not depend on a date still works.`, {});
139
+ }
140
+ };
141
+ //#endregion
142
+ //#region src/client/locate.ts
143
+ /**
144
+ * Find Maps' store.
145
+ *
146
+ * ## The path, and the three ways it was missed
147
+ *
148
+ * `~/Library/Containers/com.apple.Maps/Data/Maps/MapsSync_0.0.1` — 3.3 MB of
149
+ * Core Data on the probed machine. `docs/maps.md` records that this surface was
150
+ * declared "no file lane" three separate times before the file was found, and
151
+ * each failure is a rule this locator is shaped by:
152
+ *
153
+ * 1. **The file has no extension.** A sweep for `*.db` / `*.sqlite*` finds
154
+ * nothing here. Nothing in this file matches on a suffix.
155
+ * 2. **`Data/Maps/` is gated.** It was the ONE unreadable directory in the
156
+ * container, so a listing taken without Full Disk Access omits it, and the
157
+ * omission reads as absence. `inspectFile` splits exists from readable
158
+ * precisely so those two never collapse into one answer.
159
+ * 3. **`group.com.apple.Maps` is a decoy.** It exists, it is EPERM without the
160
+ * grant, and it holds three files that are not the store. A probe that
161
+ * could not descend into it reported it as empty.
162
+ *
163
+ * The rule underneath all three is the one `docs/surfaces.md` states: "'Absent'
164
+ * and 'EPERM' are different findings." Keeping them apart, and saying which one
165
+ * happened, is most of this module's job.
166
+ *
167
+ * ## The device-local cache is located and never read
168
+ *
169
+ * `MapsSync_0.0.1_deviceLocalCache.db` sits beside the store with the same 33
170
+ * entities and, on the probed machine, **zero rows in every one of them**. It is
171
+ * reported by diagnostics so anyone comparing the directory against this
172
+ * server's output can see it was considered, and it is never opened.
173
+ */
174
+ /** The container path. `Data/Maps` is the gated directory, not `Data/Library`. */
175
+ const MAPS_DIR = [
176
+ "Library",
177
+ "Containers",
178
+ "com.apple.Maps",
179
+ "Data",
180
+ "Maps"
181
+ ];
182
+ /**
183
+ * No extension, and a version in the name.
184
+ *
185
+ * The `0.0.1` was stable on every machine this project has measured, but it is a
186
+ * version string and it will move. `locateStore` falls back to scanning for a
187
+ * `MapsSync_*` sibling rather than reporting a rename as "Maps has never run".
188
+ */
189
+ const STORE_FILENAME = "MapsSync_0.0.1";
190
+ const LOCAL_CACHE_SUFFIX = "_deviceLocalCache.db";
191
+ const FDA_HINT = "Grant Full Disk Access to the app running this server (System Settings > Privacy & Security > Full Disk Access) and restart it. Granting it to Maps.app does nothing — the reader needs the permission, not the app.";
192
+ const defaultDirectory = (home = homedir()) => join(home, ...MAPS_DIR);
193
+ const defaultStorePath = (home = homedir()) => join(defaultDirectory(home), STORE_FILENAME);
194
+ const locateFile = (path) => ({
195
+ ...inspectFile(path),
196
+ path
197
+ });
198
+ /**
199
+ * Look for a `MapsSync_*` that is neither the local cache nor a SQLite sidecar.
200
+ *
201
+ * Only reached when the constant path is absent, and it needs the directory to
202
+ * be listable — the same grant the store itself needs, so this asks for no new
203
+ * permission and simply survives a version bump.
204
+ */
205
+ const scanForStore = (directory, readdir) => {
206
+ let names;
207
+ try {
208
+ names = readdir(directory);
209
+ } catch {
210
+ return null;
211
+ }
212
+ const candidate = names.filter((n) => n.startsWith("MapsSync_") && !n.endsWith("_deviceLocalCache.db") && !n.endsWith("-wal") && !n.endsWith("-shm")).toSorted().at(-1);
213
+ return candidate ? join(directory, candidate) : null;
214
+ };
215
+ const locateStore = (opts = {}) => {
216
+ const directory = defaultDirectory(opts.home);
217
+ const readdir = opts.readdir ?? ((p) => readdirSync(p));
218
+ const explicit = opts.storePath;
219
+ const constant = defaultStorePath(opts.home);
220
+ let storePath = explicit ?? constant;
221
+ let resolvedByScan = false;
222
+ if (!explicit && !describeStore(constant).exists) {
223
+ const scanned = scanForStore(directory, readdir);
224
+ if (scanned) {
225
+ storePath = scanned;
226
+ resolvedByScan = true;
227
+ }
228
+ }
229
+ const facts = describeStore(storePath);
230
+ const reason = facts.readable ? null : facts.exists ? `Found Maps' store at ${storePath} but cannot read it. ${FDA_HINT}` : explicit ? `No file at ${explicit}. APPLE_MAPS_STORE points at nothing.` : `No Maps store at ${storePath}, and no MapsSync_* file in ${directory}. Either Maps has never been used on this account, or the directory itself is not readable — those look identical from outside and only the grant tells them apart. ${FDA_HINT}`;
231
+ return {
232
+ ...facts,
233
+ directory,
234
+ storePath: facts.exists || !explicit ? storePath : null,
235
+ localCache: locateFile(`${constant}${LOCAL_CACHE_SUFFIX}`),
236
+ resolvedByScan,
237
+ reason
238
+ };
239
+ };
240
+ //#endregion
241
+ //#region src/client/ref.ts
242
+ /**
243
+ * Refs for places and collections.
244
+ *
245
+ * ## Why the ref is not `ZMUID`
246
+ *
247
+ * `ZMUID` is Apple's own place identifier and looks like the better key — it is
248
+ * the same number for the same restaurant across every device. It is not used,
249
+ * for two measured reasons recorded in `docs/maps.md`:
250
+ *
251
+ * * It is populated **20 of 23** favourites. The three without it are the
252
+ * rows that have no linked place at all, but a ref scheme that cannot
253
+ * address three rows in twenty-three is not a ref scheme.
254
+ * * It identifies a PLACE, not an entry. The same café saved as a favourite
255
+ * AND filed in a collection carries one `ZMUID` across both, so a ref built
256
+ * on it could not say which of the two a caller meant.
257
+ *
258
+ * The keys below address the ENTRY, which is what every tool here returns. The
259
+ * kind is carried in the prefix so a favourite ref and a collection-item ref
260
+ * can never be confused for one another.
261
+ *
262
+ * ## What the ref carries: a uuid when the store has one, a row id when it does not
263
+ *
264
+ * `ZIDENTIFIER` — a 16-byte Core Data UUID — is set on every favourite,
265
+ * collection, collection item and recent on a real store, and is distinct on
266
+ * every one. It addresses the ENTRY, survives a delete elsewhere in the table,
267
+ * and survives an iCloud re-sync renumbering rows. It is the ref whenever
268
+ * `store.ts` can confirm total coverage.
269
+ *
270
+ * It was found by watching Maps write, not by reading the schema — see
271
+ * `docs/maps.md`. The read probe never reported it, because a probe can only
272
+ * report columns it thought to look for.
273
+ *
274
+ * When a store has no usable identifier the ref falls back to the Core Data row
275
+ * id, and then the old caveat applies in full: `Z_PK` is reused after a delete
276
+ * and renumbered by a re-sync, so such a ref is only good for the session.
277
+ * `packages/messages` rejected row ids outright for that reason; here they are
278
+ * the degraded mode rather than the design.
279
+ *
280
+ * The two are told apart BY SHAPE — a uuid is 32 hex characters, a row id is
281
+ * decimal — and the resolver refuses to try a uuid against a store with no
282
+ * identifier column rather than reinterpreting it as a number. Silently
283
+ * resolving one key space in the other would find a real but wrong place, which
284
+ * is worse than finding nothing.
285
+ *
286
+ * ## Why `p1:` and `pc1:`
287
+ *
288
+ * `c1:` is Calendar's, `r1:` Reminders', `k1:` Contacts', `n1:` Notes',
289
+ * `m1:`/`mc1:` Messages', `s1:`/`sb1:` Safari's. A ref that decodes under two
290
+ * surfaces is worse than one that decodes under none, so each prefix is claimed
291
+ * once and the version digit keeps a future change additive rather than a
292
+ * silent reinterpretation of refs already sitting in a conversation.
293
+ */
294
+ const PLACE_REF_VERSION = "p1";
295
+ const COLLECTION_REF_VERSION = "pc1";
296
+ const KIND_CODE = {
297
+ favorite: "f",
298
+ "collection-item": "c",
299
+ history: "h"
300
+ };
301
+ const CODE_KIND = {
302
+ f: "favorite",
303
+ c: "collection-item",
304
+ h: "history"
305
+ };
306
+ const PLACE_PATTERN = /^p1:([fch]):([0-9a-f]{32}|\d+)$/;
307
+ const COLLECTION_PATTERN = /^pc1:([0-9a-f]{32}|\d+)$/;
308
+ /** 32 hex characters is a uuid; anything else that matched the pattern is a row id. */
309
+ const toKey = (raw) => /^[0-9a-f]{32}$/.test(raw) ? { uuid: `${raw.slice(0, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}-${raw.slice(16, 20)}-${raw.slice(20)}` } : { rowId: Number(raw) };
310
+ /** A uuid goes into the ref undashed, so the ref stays one opaque token. */
311
+ const fromKey = (key) => "uuid" in key ? key.uuid.replaceAll("-", "").toLowerCase() : String(key.rowId);
312
+ const otherSurface = (raw) => {
313
+ if (raw.startsWith("c1:")) return " That one is a Calendar event ref.";
314
+ if (raw.startsWith("r1:")) return " That one is a Reminders ref.";
315
+ if (raw.startsWith("k1:")) return " That one is a Contacts ref.";
316
+ if (raw.startsWith("n1:")) return " That one is a Notes ref.";
317
+ if (raw.startsWith("mc1:")) return " That one is a Messages chat ref.";
318
+ if (raw.startsWith("m1:")) return " That one is a Messages message ref.";
319
+ if (raw.startsWith("sb1:")) return " That one is a Safari bookmark ref.";
320
+ if (raw.startsWith("s1:")) return " That one is a Safari history ref.";
321
+ if (raw.startsWith("pc1:")) return " That one is a COLLECTION ref — this wants a place ref.";
322
+ if (raw.startsWith("p1:")) return " That one is a PLACE ref — this wants a collection ref.";
323
+ return "";
324
+ };
325
+ var InvalidMapsRefError = class extends AppleAutomationError {
326
+ name = "InvalidMapsRefError";
327
+ constructor(raw, want) {
328
+ super(`"${raw}" is not a ${want} ref. Refs come from apple_maps_* results and look like ${want === "place" ? "\"p1:<kind>:<id>\"" : "\"pc1:<id>\""} — they are opaque and must not be constructed by hand.${otherSurface(raw)}`, { ref: raw });
329
+ }
330
+ };
331
+ const encodePlaceRef = (kind, key) => `p1:${KIND_CODE[kind]}:${fromKey(key)}`;
332
+ const encodeCollectionRef = (key) => `pc1:${fromKey(key)}`;
333
+ const decodePlaceRef = (raw) => {
334
+ const m = PLACE_PATTERN.exec(raw.trim());
335
+ const kind = m?.[1] ? CODE_KIND[m[1]] : void 0;
336
+ if (!m?.[2] || !kind) throw new InvalidMapsRefError(raw, "place");
337
+ return {
338
+ kind,
339
+ key: toKey(m[2])
340
+ };
341
+ };
342
+ const decodeCollectionRef = (raw) => {
343
+ const m = COLLECTION_PATTERN.exec(raw.trim());
344
+ if (!m?.[1]) throw new InvalidMapsRefError(raw, "collection");
345
+ return toKey(m[1]);
346
+ };
347
+ //#endregion
348
+ //#region src/client/store.ts
349
+ /**
350
+ * Maps' file lane. There is no other lane.
351
+ *
352
+ * ## What is measured, and what is NOT
353
+ *
354
+ * MEASURED by `pnpm probe:maps` on macOS 26.6 — 146 schema objects, fingerprint
355
+ * `2bbc03143125`, Core Data, `ZCREATETIME` on apple-seconds:
356
+ *
357
+ * ZFAVORITEITEM 24 rows ZCOLLECTION 10 rows
358
+ * ZCOLLECTIONITEM 30 rows ZHISTORYITEM 34 rows
359
+ * ZMIXINMAPITEM 71 rows ZREVIEWEDPLACE 24 rows
360
+ *
361
+ * and the id bridge, verified by running the join rather than by reading column
362
+ * names: `ZMIXINMAPITEM`'s inverse relationships partition exactly —
363
+ * 30 collection items + 21 favourites + 20 history rows = 71 = every row.
364
+ *
365
+ * NOT measured: this file has never been run against the real store by its
366
+ * author, who has no Full Disk Access. Everything below is written against a
367
+ * probe REPORT, which is one step further from the data than
368
+ * `packages/safari/src/client/store.ts` was, and that file already treats every
369
+ * column as optional. This one goes further in two ways.
370
+ *
371
+ * ## Columns are resolved BY COVERAGE, not by first name match
372
+ *
373
+ * The probe found `ZHISTORYITEM` carrying both `ZLATITUDE` (1 of 33 rows) and
374
+ * `ZLATITUDE1` (19 of 33). A resolver that took the first candidate it
375
+ * recognised would pick the column that is null 97% of the time and report that
376
+ * Maps holds almost no coordinates. So each logical field names several
377
+ * candidates and the one with the **most non-null values** wins, counted at
378
+ * open time. That is a real query per candidate, run once, on tables of tens of
379
+ * rows.
380
+ *
381
+ * ## Collection membership is a JOIN TABLE, and it is VALIDATED not guessed
382
+ *
383
+ * MEASURED: membership is `Z_6PLACES(Z_6COLLECTIONS, Z_7PLACES)`, a Core Data
384
+ * many-to-many. `Z_PRIMARYKEY` decodes ordinal 6 as Collection and 7 as
385
+ * CollectionItem, so the relationship is `Collection.places`.
386
+ *
387
+ * Four column names were guessed here before it was found — ZCOLLECTION,
388
+ * ZCOLLECTION1, ZPARENTCOLLECTION, ZOWNINGCOLLECTION — and the store has none
389
+ * of them, so every guide listed empty. A many-to-many leaves NO COLUMN on
390
+ * either entity, so no list of column names could have contained the answer.
391
+ * The near-miss is what makes this worth stating: `ZCOLLECTIONITEM.ZMAPITEM`
392
+ * joins `ZCOLLECTION` for 3 of 10 collections, and a resolver picking the
393
+ * best-covered joinable column would have chosen it and been confidently wrong.
394
+ *
395
+ * So membership is resolved BY RUNNING THE JOIN and scoring it against an
396
+ * oracle the store hands over for free: `ZCOLLECTION.ZPLACESCOUNT`, Maps' own
397
+ * count per guide. A candidate is accepted only when it reproduces all ten
398
+ * numbers exactly with no key pointing at a missing collection. That survives
399
+ * Apple renaming the relationship, which a hard-coded `Z_6PLACES` would not.
400
+ *
401
+ * The oracle is not independent evidence, and it is stronger for it. Core Data
402
+ * maintains `ZPLACESCOUNT` with a trigger that reads:
403
+ *
404
+ * UPDATE ZCOLLECTION SET ZPLACESCOUNT =
405
+ * (SELECT IFNULL(COUNT(Z_6COLLECTIONS), 0)
406
+ * FROM Z_6PLACES WHERE Z_6COLLECTIONS = NEW.Z_PK)
407
+ *
408
+ * so Apple's own schema states the relationship this resolver re-derives. The
409
+ * count match is therefore guaranteed for the true mechanism rather than lucky,
410
+ * and coincidental for anything else. The triggers are visible only in the
411
+ * captured fixture — `pnpm probe:maps --write` omits them from the replay
412
+ * because they call Core Data's private SQLite functions, which is recorded in
413
+ * `writeFixture`.
414
+ *
415
+ * Because it is many-to-many, one place can sit in several guides, and the
416
+ * membership query uses `IN (SELECT ...)` rather than a JOIN so a place in two
417
+ * guides is not returned twice from one of them.
418
+ *
419
+ * MEASURED: 30 item rows, 18 of them in a guide. The other 12 belong to no
420
+ * collection at all; all 30 link to a `ZMIXINMAPITEM`, so they are intact
421
+ * places rather than broken rows.
422
+ *
423
+ * `SchemaDriftError` fires for exactly one condition — none of the three
424
+ * place-bearing tables exists — because without them there is no surface.
425
+ * Everything else is a capability downgrade that is reported, never a throw.
426
+ */
427
+ const FAVORITES_TABLE = "ZFAVORITEITEM";
428
+ const COLLECTIONS_TABLE = "ZCOLLECTION";
429
+ const COLLECTION_ITEMS_TABLE = "ZCOLLECTIONITEM";
430
+ const HISTORY_TABLE = "ZHISTORYITEM";
431
+ const MAP_ITEMS_TABLE = "ZMIXINMAPITEM";
432
+ /**
433
+ * Candidates per logical field, best-known first — but order only breaks ties.
434
+ * Coverage decides. See the header.
435
+ */
436
+ const NAME_CANDIDATES = [
437
+ "ZMAPITEMNAME",
438
+ "ZCUSTOMNAME",
439
+ "ZLOCATIONDISPLAY",
440
+ "ZTITLE"
441
+ ];
442
+ const LAT_CANDIDATES = ["ZLATITUDE", "ZLATITUDE1"];
443
+ const LON_CANDIDATES = ["ZLONGITUDE", "ZLONGITUDE1"];
444
+ const ADDRESS_CANDIDATES = ["ZMAPITEMADDRESS", "ZORIGINATINGADDRESSSTRING"];
445
+ const MUID_CANDIDATES = ["ZMUID"];
446
+ const MAP_ITEM_FK_CANDIDATES = ["ZMAPITEM"];
447
+ const CREATED_CANDIDATES = ["ZCREATETIME"];
448
+ const MODIFIED_CANDIDATES = ["ZMODIFICATIONTIME"];
449
+ const COLLECTION_TITLE_CANDIDATES = [
450
+ "ZTITLE",
451
+ "ZNAME",
452
+ "ZCUSTOMNAME"
453
+ ];
454
+ /**
455
+ * The stable per-entry identifier, if this store has one.
456
+ *
457
+ * FOUND BY WATCHING MAPS WRITE, not by reading the schema: every row Maps
458
+ * created during `pnpm probe:maps-write` carried `ZIDENTIFIER`, a 16-byte blob
459
+ * — a UUID. It is not in the read probe's report because that probe only looked
460
+ * for columns it already had candidates for, which is a good argument for
461
+ * diffing a live write even when the read surface already works.
462
+ *
463
+ * `ZORIGINALIDENTIFIER` is deliberately NOT a candidate. It exists on
464
+ * `ZCOLLECTIONITEM` and is populated on 3 rows of 30 — it records where an entry
465
+ * was copied FROM, so it is neither complete nor unique to the entry.
466
+ */
467
+ const IDENTIFIER_CANDIDATES = ["ZIDENTIFIER"];
468
+ /**
469
+ * Maps' own count of the places in a guide, and the oracle every membership
470
+ * candidate is scored against. See the header.
471
+ */
472
+ const COLLECTION_COUNT_COLUMN = "ZPLACESCOUNT";
473
+ /** Core Data's own tables, which are never a relationship. */
474
+ const RESERVED_JOIN_TABLES = /* @__PURE__ */ new Set([
475
+ "Z_METADATA",
476
+ "Z_MODELCACHE",
477
+ "Z_PRIMARYKEY"
478
+ ]);
479
+ const isRealTable = (name) => !name.startsWith("sqlite_");
480
+ /**
481
+ * `HEX()` of a 16-byte blob to a canonical UUID.
482
+ *
483
+ * Returns null on anything that is not exactly 32 hex characters. Core Data
484
+ * stores a UUID attribute as 16 raw bytes, so a different length means the
485
+ * column is not what this code thinks it is — and a malformed ref that still
486
+ * looks ref-shaped would be resolved against the wrong row rather than rejected.
487
+ */
488
+ const toUuid = (hex) => {
489
+ if (hex === null || hex === void 0) return null;
490
+ const h = String(hex).toLowerCase();
491
+ if (!/^[0-9a-f]{32}$/.test(h)) return null;
492
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
493
+ };
494
+ /** A `NULL AS alias` column reads back as null; every other value is coerced. */
495
+ const num = (v) => v === null || v === void 0 ? null : Number(v);
496
+ const str = (v) => v === null || v === void 0 ? null : String(v);
497
+ /**
498
+ * Pick the candidate column with the most non-null values.
499
+ *
500
+ * Returns null when none of the candidates exists, and also when they all exist
501
+ * and are all empty — an all-null column is not a usable field, and reporting
502
+ * one as resolved would produce a result whose every value is null with no
503
+ * explanation.
504
+ */
505
+ const resolveField = (db, table, columns, candidates, rows) => {
506
+ const present = candidates.filter((c) => columns.has(c));
507
+ if (present.length === 0) return null;
508
+ if (rows === 0) return present[0] ?? null;
509
+ let best = null;
510
+ for (const name of present) {
511
+ let count = 0;
512
+ try {
513
+ count = Number(db.prepare(`SELECT COUNT("${name}") AS c FROM "${table}"`).get().c);
514
+ } catch {
515
+ continue;
516
+ }
517
+ if (!best || count > best.count) best = {
518
+ name,
519
+ count
520
+ };
521
+ }
522
+ return best && best.count > 0 ? best.name : present[0] ?? null;
523
+ };
524
+ /**
525
+ * The identifier column, but ONLY if it can actually carry every ref.
526
+ *
527
+ * Coverage is not enough here, and this is the difference between a fix and a
528
+ * trap. A column populated on the rows Maps has written lately and null on the
529
+ * ones that predate it would work perfectly for every place the user saves from
530
+ * now on and fail silently for the places they already had — the failure mode
531
+ * hardest to notice and worst to hit, since the older entries are the ones a
532
+ * person is most likely to ask about.
533
+ *
534
+ * So the bar is TOTAL: set on every row, and distinct across every row. Anything
535
+ * less returns null and the store falls back to `Z_PK`, which is worse but
536
+ * uniformly worse. Measured on a real store: 24/24 favourites, 30/30 collection
537
+ * items, 10/10 collections, 33/33 recents, all distinct.
538
+ */
539
+ const resolveIdentifier = (db, table, columns, rows) => {
540
+ const present = IDENTIFIER_CANDIDATES.filter((c) => columns.has(c));
541
+ if (present.length === 0 || rows === 0) return null;
542
+ for (const name of present) try {
543
+ const r = db.prepare(`SELECT COUNT("${name}") AS nset, COUNT(DISTINCT "${name}") AS ndistinct FROM "${table}"`).get();
544
+ if (Number(r.nset) === rows && Number(r.ndistinct) === rows) return name;
545
+ } catch {
546
+ continue;
547
+ }
548
+ return null;
549
+ };
550
+ const countRows = (db, table) => {
551
+ try {
552
+ return Number(db.prepare(`SELECT COUNT(*) AS c FROM "${table}"`).get().c);
553
+ } catch {
554
+ return 0;
555
+ }
556
+ };
557
+ const describeEntity = (db, table, tables, nameCandidates = NAME_CANDIDATES) => {
558
+ const present = tables.includes(table);
559
+ if (!present) return {
560
+ table,
561
+ present: false,
562
+ rows: 0,
563
+ columns: /* @__PURE__ */ new Set(),
564
+ fields: {
565
+ identifier: null,
566
+ name: null,
567
+ customName: null,
568
+ latitude: null,
569
+ longitude: null,
570
+ address: null,
571
+ muid: null,
572
+ mapItem: null,
573
+ created: null,
574
+ modified: null
575
+ }
576
+ };
577
+ const columns = new Set(columnsOf(db, table));
578
+ const rows = countRows(db, table);
579
+ const pick = (cands) => resolveField(db, table, columns, cands, rows);
580
+ return {
581
+ table,
582
+ present,
583
+ rows,
584
+ columns,
585
+ fields: {
586
+ identifier: resolveIdentifier(db, table, columns, rows),
587
+ name: pick(nameCandidates),
588
+ customName: columns.has("ZCUSTOMNAME") ? "ZCUSTOMNAME" : null,
589
+ latitude: pick(LAT_CANDIDATES),
590
+ longitude: pick(LON_CANDIDATES),
591
+ address: pick(ADDRESS_CANDIDATES),
592
+ muid: pick(MUID_CANDIDATES),
593
+ mapItem: pick(MAP_ITEM_FK_CANDIDATES),
594
+ created: pick(CREATED_CANDIDATES),
595
+ modified: pick(MODIFIED_CANDIDATES)
596
+ }
597
+ };
598
+ };
599
+ /**
600
+ * Maps' own places-per-guide, keyed by collection row id.
601
+ *
602
+ * Null when the store has no `ZPLACESCOUNT`, which changes what the resolver
603
+ * below is allowed to accept — see `resolveMembership`.
604
+ */
605
+ const declaredCounts = (db, collections) => {
606
+ if (!collections.present || !collections.columns.has(COLLECTION_COUNT_COLUMN)) return null;
607
+ try {
608
+ const rows = db.prepare(`SELECT "Z_PK" AS pk, "${COLLECTION_COUNT_COLUMN}" AS n FROM "${collections.table}"`).all();
609
+ return new Map(rows.map((r) => [Number(r.pk), Number(r.n ?? 0)]));
610
+ } catch {
611
+ return null;
612
+ }
613
+ };
614
+ /**
615
+ * Group a candidate's keys and count the items under each.
616
+ *
617
+ * Keys are read as TEXT because `ZMUID` is an INTEGER column holding 64-bit
618
+ * place ids, and `node:sqlite` THROWS on those rather than truncating — reading
619
+ * raw would drop the candidate into a catch instead of rejecting it on the
620
+ * evidence. See `PlaceRow.muid`.
621
+ */
622
+ const tally = (db, sql) => {
623
+ try {
624
+ const rows = db.prepare(sql).all();
625
+ return new Map(rows.filter((r) => r.pk !== null).map((r) => [Number(r.pk), Number(r.n)]));
626
+ } catch {
627
+ return /* @__PURE__ */ new Map();
628
+ }
629
+ };
630
+ /**
631
+ * Does this candidate reproduce Maps' own counts, exactly, for every guide?
632
+ *
633
+ * Exactness is the whole test. `ZCOLLECTIONITEM.ZMAPITEM` matches 3 of 10
634
+ * collections by coincidence, so "mostly right" is precisely the answer that
635
+ * must be rejected. A key pointing at no collection at all (`unknown`) is
636
+ * disqualifying for the same reason.
637
+ */
638
+ const reproducesCounts = (declared, tallies) => {
639
+ for (const pk of tallies.keys()) if (!declared.has(pk)) return false;
640
+ for (const [pk, n] of declared) if ((tallies.get(pk) ?? 0) !== n) return false;
641
+ return true;
642
+ };
643
+ /**
644
+ * Find how an item belongs to a collection, by running each candidate join.
645
+ *
646
+ * Scalar columns are tried before join tables only so the cheaper query runs
647
+ * first; the verdict does not depend on order, because a candidate is accepted
648
+ * only when it reproduces every count. When several would qualify the first is
649
+ * taken, and that ambiguity cannot arise on a store whose counts are distinct.
650
+ *
651
+ * WITHOUT the oracle the bar changes rather than disappearing: a single join
652
+ * table whose every key resolves on both sides is accepted, and anything
653
+ * ambiguous is refused. That is weaker evidence, and it is the reason the
654
+ * result is reported to callers rather than assumed.
655
+ */
656
+ const resolveMembership = (db, collectionItems, collections, tables) => {
657
+ if (!collectionItems.present || !collections.present) return null;
658
+ const declared = declaredCounts(db, collections);
659
+ if (declared) for (const column of collectionItems.columns) {
660
+ if ([
661
+ "Z_PK",
662
+ "Z_ENT",
663
+ "Z_OPT"
664
+ ].includes(column)) continue;
665
+ const tallies = tally(db, `SELECT CAST(t."${column}" AS TEXT) AS pk, COUNT(*) AS n FROM "${collectionItems.table}" t
666
+ WHERE t."${column}" IS NOT NULL GROUP BY t."${column}"`);
667
+ if (tallies.size > 0 && reproducesCounts(declared, tallies)) return {
668
+ kind: "column",
669
+ column
670
+ };
671
+ }
672
+ const joinTables = tables.filter((t) => t.startsWith("Z_") && !RESERVED_JOIN_TABLES.has(t));
673
+ const accepted = [];
674
+ for (const table of joinTables) {
675
+ const columns = columnsOf(db, table);
676
+ for (const collectionColumn of columns) for (const itemColumn of columns) {
677
+ if (collectionColumn === itemColumn) continue;
678
+ const tallies = tally(db, `SELECT CAST(j."${collectionColumn}" AS TEXT) AS pk, COUNT(*) AS n FROM "${table}" j
679
+ JOIN "${collectionItems.table}" t ON t."Z_PK" = j."${itemColumn}"
680
+ GROUP BY j."${collectionColumn}"`);
681
+ if (tallies.size === 0) continue;
682
+ if (declared) {
683
+ if (reproducesCounts(declared, tallies)) return {
684
+ kind: "joinTable",
685
+ table,
686
+ collectionColumn,
687
+ itemColumn
688
+ };
689
+ continue;
690
+ }
691
+ if (tally(db, `SELECT CAST(j."${collectionColumn}" AS TEXT) AS pk, COUNT(*) AS n FROM "${table}" j
692
+ LEFT JOIN "${collections.table}" c ON c."Z_PK" = j."${collectionColumn}"
693
+ WHERE c."Z_PK" IS NULL GROUP BY j."${collectionColumn}"`).size === 0) accepted.push({
694
+ kind: "joinTable",
695
+ table,
696
+ collectionColumn,
697
+ itemColumn
698
+ });
699
+ }
700
+ }
701
+ return accepted.length === 1 ? accepted[0] ?? null : null;
702
+ };
703
+ /** The mechanism as one short string, for diagnostics. */
704
+ const describeMembership = (m) => {
705
+ if (!m) return null;
706
+ return m.kind === "column" ? m.column : `${m.table}(${m.collectionColumn}, ${m.itemColumn})`;
707
+ };
708
+ const introspect = (db, now = Date.now()) => {
709
+ const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`).all().map((r) => r.name).filter(isRealTable);
710
+ const favorites = describeEntity(db, FAVORITES_TABLE, tables);
711
+ const collectionItems = describeEntity(db, COLLECTION_ITEMS_TABLE, tables);
712
+ const history = describeEntity(db, HISTORY_TABLE, tables);
713
+ if (!favorites.present && !collectionItems.present && !history.present) throw new SchemaDriftError(`Maps' store has none of "${FAVORITES_TABLE}", "${COLLECTION_ITEMS_TABLE}" or "${HISTORY_TABLE}" (found: ${tables.join(", ") || "nothing"}). This is either not a MapsSync store or Apple has restructured it. Nothing can be read from it.`);
714
+ const collections = describeEntity(db, COLLECTIONS_TABLE, tables, COLLECTION_TITLE_CANDIDATES);
715
+ const mapItems = describeEntity(db, MAP_ITEMS_TABLE, tables);
716
+ const membership = resolveMembership(db, collectionItems, collections, tables);
717
+ const dated = [
718
+ favorites,
719
+ collectionItems,
720
+ history,
721
+ mapItems
722
+ ].filter((e) => e.present && e.rows > 0 && e.fields.created).toSorted((a, b) => b.rows - a.rows)[0];
723
+ let maxCreated = null;
724
+ if (dated?.fields.created) try {
725
+ maxCreated = db.prepare(`SELECT MAX(CAST("${dated.fields.created}" AS REAL)) AS m FROM "${dated.table}"`).get().m;
726
+ } catch {
727
+ maxCreated = null;
728
+ }
729
+ return {
730
+ fingerprint: fingerprintSchema(db),
731
+ tables,
732
+ favorites,
733
+ collections,
734
+ collectionItems,
735
+ history,
736
+ mapItems,
737
+ membership,
738
+ collectionFk: describeMembership(membership),
739
+ epoch: resolveEpoch(maxCreated, now)
740
+ };
741
+ };
742
+ var MapsStore = class {
743
+ db;
744
+ caps;
745
+ path;
746
+ mode;
747
+ constructor(opts) {
748
+ this.db = opts.db;
749
+ this.caps = opts.caps;
750
+ this.path = opts.path;
751
+ this.mode = opts.mode;
752
+ }
753
+ /**
754
+ * One column, or `NULL` under the same alias when it could not be resolved.
755
+ *
756
+ * The same guard `packages/safari` uses, and needed more here: this schema was
757
+ * read from a probe report rather than from the database, so a wrong
758
+ * expectation should cost one field instead of the whole lane.
759
+ */
760
+ #col(column, alias, table = "t") {
761
+ return column ? `${table}."${column}" AS ${alias}` : `NULL AS ${alias}`;
762
+ }
763
+ /**
764
+ * The same, for a column whose value may not fit in a JS double.
765
+ *
766
+ * SQLite holds 64-bit integers; a JS number holds 53 bits of them, and
767
+ * `node:sqlite` refuses rather than silently losing precision. Casting in SQL
768
+ * keeps the exact value and moves the decision about what to do with it out
769
+ * of the driver.
770
+ */
771
+ #colText(column, alias, table = "t") {
772
+ return column ? `CAST(${table}."${column}" AS TEXT) AS ${alias}` : `NULL AS ${alias}`;
773
+ }
774
+ #placeSelect(e) {
775
+ const f = e.fields;
776
+ return [
777
+ `t."Z_PK" AS id`,
778
+ f.identifier ? `HEX(t."${f.identifier}") AS uuid` : `NULL AS uuid`,
779
+ this.#col(f.name, "name"),
780
+ this.#col(f.customName, "customName"),
781
+ this.#col(f.latitude, "latitude"),
782
+ this.#col(f.longitude, "longitude"),
783
+ this.#col(f.address, "address"),
784
+ this.#colText(f.muid, "muid"),
785
+ this.#col(f.created, "createdRaw"),
786
+ this.#col(f.modified, "modifiedRaw"),
787
+ f.mapItem ? `t."${f.mapItem}" AS mapItem` : `NULL AS mapItem`
788
+ ].join(", ");
789
+ }
790
+ #toPlace(r) {
791
+ return {
792
+ id: Number(r.id),
793
+ uuid: toUuid(r.uuid),
794
+ name: str(r.name),
795
+ customName: str(r.customName),
796
+ latitude: num(r.latitude),
797
+ longitude: num(r.longitude),
798
+ address: str(r.address),
799
+ muid: r.muid === null || r.muid === void 0 || String(r.muid) === "0" ? null : String(r.muid),
800
+ createdRaw: num(r.createdRaw),
801
+ modifiedRaw: num(r.modifiedRaw),
802
+ linked: r.mapItem !== null && r.mapItem !== void 0
803
+ };
804
+ }
805
+ #entityFor(kind) {
806
+ if (kind === "favorite") return this.caps.favorites;
807
+ if (kind === "collection-item") return this.caps.collectionItems;
808
+ return this.caps.history;
809
+ }
810
+ /**
811
+ * Items belonging to no collection at all, or null when unanswerable.
812
+ *
813
+ * MEASURED: 30 collection items, 18 filed in a guide, 12 in none — and Core
814
+ * Data has deleted nothing (`Z_PRIMARYKEY.Z_MAX` equals the live count for
815
+ * both `Collection` and `CollectionItem`), so these are not the debris of
816
+ * removed guides. 7 of the 12 appear nowhere else in the store: not as a
817
+ * favourite, not in another guide, not in recents. They are places the user
818
+ * saved that no other tool can reach.
819
+ *
820
+ * `NOT IN` needs the null guard. A subquery yielding a single NULL makes
821
+ * `NOT IN` false for EVERY row, so the filter would silently return nothing
822
+ * and read as "you have no unfiled places" — the failure this whole surface
823
+ * keeps having to design against.
824
+ */
825
+ #unfiledClause() {
826
+ const m = this.caps.membership;
827
+ if (!m) return null;
828
+ if (m.kind === "column") return `t."${m.column}" IS NULL`;
829
+ return `t."Z_PK" NOT IN (SELECT j."${m.itemColumn}" FROM "${m.table}" j WHERE j."${m.itemColumn}" IS NOT NULL)`;
830
+ }
831
+ /** How many collection items are filed in no collection; null when unknown. */
832
+ unfiledCount() {
833
+ const e = this.caps.collectionItems;
834
+ const clause = this.#unfiledClause();
835
+ if (!e.present || !clause) return null;
836
+ try {
837
+ return Number(this.db.prepare(`SELECT COUNT(*) AS c FROM "${e.table}" t WHERE ${clause}`).get().c);
838
+ } catch {
839
+ return null;
840
+ }
841
+ }
842
+ /** One entity's places, newest first when a date is available. */
843
+ places(kind, opts) {
844
+ const e = this.#entityFor(kind);
845
+ if (!e.present || e.rows === 0) return {
846
+ rows: [],
847
+ truncated: false
848
+ };
849
+ const where = [];
850
+ const params = [];
851
+ if (opts.collectionId !== void 0) {
852
+ const m = this.caps.membership;
853
+ if (!m) return {
854
+ rows: [],
855
+ truncated: false
856
+ };
857
+ if (m.kind === "column") where.push(`t."${m.column}" = ?`);
858
+ else where.push(`t."Z_PK" IN (SELECT j."${m.itemColumn}" FROM "${m.table}" j WHERE j."${m.collectionColumn}" = ?)`);
859
+ params.push(opts.collectionId);
860
+ }
861
+ if (opts.unfiled) {
862
+ const clause = this.#unfiledClause();
863
+ if (!clause) return {
864
+ rows: [],
865
+ truncated: false
866
+ };
867
+ where.push(clause);
868
+ }
869
+ if (opts.query) {
870
+ const pattern = `%${escapeLike(opts.query)}%`;
871
+ const clauses = [];
872
+ for (const col of [
873
+ e.fields.name,
874
+ e.fields.customName,
875
+ e.fields.address
876
+ ]) {
877
+ if (!col) continue;
878
+ clauses.push(`t."${col}" LIKE ? ESCAPE '\\'`);
879
+ params.push(pattern);
880
+ }
881
+ if (clauses.length === 0) return {
882
+ rows: [],
883
+ truncated: false
884
+ };
885
+ where.push(`(${clauses.join(" OR ")})`);
886
+ }
887
+ const limit = Math.max(1, opts.limit);
888
+ params.push(limit + 1);
889
+ const order = e.fields.modified ? `t."${e.fields.modified}" DESC` : e.fields.created ? `t."${e.fields.created}" DESC` : `t."Z_PK" DESC`;
890
+ const sql = `SELECT ${this.#placeSelect(e)} FROM "${e.table}" t ` + (where.length ? `WHERE ${where.join(" AND ")} ` : "") + `ORDER BY ${order} LIMIT ?`;
891
+ const raw = this.db.prepare(sql).all(...params);
892
+ return {
893
+ rows: raw.slice(0, limit).map((r) => this.#toPlace(r)),
894
+ truncated: raw.length > limit
895
+ };
896
+ }
897
+ /**
898
+ * The WHERE clause and parameter for one key, or null when the key cannot be
899
+ * honoured against this entity.
900
+ *
901
+ * A uuid key against a store with no resolved identifier column returns null
902
+ * rather than falling back to the row id. The two number spaces are unrelated,
903
+ * so a fallback would resolve to a real but WRONG place — the one outcome
904
+ * worse than not finding it.
905
+ *
906
+ * `HEX()` on both sides rather than `X'..'` literal: it keeps the comparison
907
+ * in one form, and these tables are tens of rows, so the lost index is free.
908
+ */
909
+ #keyClause(e, key) {
910
+ if ("uuid" in key) {
911
+ if (!e.fields.identifier) return null;
912
+ return {
913
+ sql: `HEX(t."${e.fields.identifier}") = ?`,
914
+ param: key.uuid.replaceAll("-", "").toUpperCase()
915
+ };
916
+ }
917
+ return {
918
+ sql: `t."Z_PK" = ?`,
919
+ param: key.rowId
920
+ };
921
+ }
922
+ /** One place by entity and key. */
923
+ place(kind, key) {
924
+ const e = this.#entityFor(kind);
925
+ if (!e.present) return null;
926
+ const clause = this.#keyClause(e, key);
927
+ if (!clause) return null;
928
+ const sql = `SELECT ${this.#placeSelect(e)} FROM "${e.table}" t WHERE ${clause.sql} LIMIT 1`;
929
+ const r = this.db.prepare(sql).get(clause.param);
930
+ return r ? this.#toPlace(r) : null;
931
+ }
932
+ /**
933
+ * A collection key to the row id its items point at.
934
+ *
935
+ * Collection membership is a Core Data foreign key, so it holds `Z_PK` values
936
+ * whatever the ref carries. A uuid ref has to be translated before it can
937
+ * filter items, and this is the one place that happens.
938
+ */
939
+ collectionRowId(key) {
940
+ if (!("uuid" in key)) return key.rowId;
941
+ const e = this.caps.collections;
942
+ const clause = this.#keyClause(e, key);
943
+ if (!e.present || !clause) return null;
944
+ const r = this.db.prepare(`SELECT t."Z_PK" AS id FROM "${e.table}" t WHERE ${clause.sql} LIMIT 1`).get(clause.param);
945
+ return r ? Number(r.id) : null;
946
+ }
947
+ collections(opts) {
948
+ const e = this.caps.collections;
949
+ if (!e.present || e.rows === 0) return {
950
+ rows: [],
951
+ truncated: false
952
+ };
953
+ const limit = Math.max(1, opts.limit);
954
+ const hasCount = e.columns.has("ZPLACESCOUNT");
955
+ const order = e.fields.modified ? `t."${e.fields.modified}" DESC` : `t."Z_PK" DESC`;
956
+ const sql = `SELECT t."Z_PK" AS id, ${e.fields.identifier ? `HEX(t."${e.fields.identifier}") AS uuid` : `NULL AS uuid`}, ${this.#col(e.fields.name, "title")}, ${hasCount ? `t."ZPLACESCOUNT" AS placesCount` : `NULL AS placesCount`}, ${this.#col(e.fields.created, "createdRaw")}, ${this.#col(e.fields.modified, "modifiedRaw")} FROM "${e.table}" t ORDER BY ${order} LIMIT ?`;
957
+ const raw = this.db.prepare(sql).all(limit + 1);
958
+ return {
959
+ rows: raw.slice(0, limit).map((r) => ({
960
+ id: Number(r.id),
961
+ uuid: toUuid(r.uuid),
962
+ title: r.title === null || r.title === void 0 ? null : String(r.title),
963
+ placesCount: r.placesCount === null ? null : Number(r.placesCount),
964
+ createdRaw: r.createdRaw === null ? null : Number(r.createdRaw),
965
+ modifiedRaw: r.modifiedRaw === null ? null : Number(r.modifiedRaw)
966
+ })),
967
+ truncated: raw.length > limit
968
+ };
969
+ }
970
+ close() {
971
+ try {
972
+ this.db.close();
973
+ } catch {}
974
+ }
975
+ };
976
+ const openStore = (opts) => {
977
+ const opened = openReadOnly(opts.path, opts.mode ?? "auto", {
978
+ envVar: "APPLE_MAPS_INDEX_MODE",
979
+ label: "Maps' place store",
980
+ ...opts.hint ? { hint: opts.hint } : {},
981
+ validate: (db) => introspect(db, opts.now),
982
+ fatal: (err) => err instanceof SchemaDriftError,
983
+ onFallback: () => opts.logger?.warn?.("maps: opened immutable; places saved since the last checkpoint may be missing")
984
+ });
985
+ return new MapsStore({
986
+ db: opened.db,
987
+ caps: opened.validated,
988
+ path: opts.path,
989
+ mode: opened.mode
990
+ });
991
+ };
992
+ //#endregion
993
+ //#region src/client/write.ts
994
+ /**
995
+ * Writing to Maps' store.
996
+ *
997
+ * ## Why this is SQL and not the app's own API
998
+ *
999
+ * Maps ships no scripting dictionary and registers no App Intents on macOS, so
1000
+ * there is no lane where the app performs the write on our behalf. Every other
1001
+ * surface in this repo writes through an Apple Event; this one cannot, and the
1002
+ * alternatives were measured rather than assumed — see `docs/maps.md`.
1003
+ *
1004
+ * ## The one thing that cannot be synthesised, and how it is obtained anyway
1005
+ *
1006
+ * A place is only real to Maps if it has a `ZMAPITEMSTORAGE` — a GEO protobuf of
1007
+ * one to four kilobytes that this repo has never decoded and cannot generate.
1008
+ *
1009
+ * It does not have to. **Opening a place makes Maps write one.**
1010
+ * `maps://?q=<name>&ll=<lat>,<lon>` goes through LaunchServices — not Apple
1011
+ * Events, not Accessibility — and Maps resolves the place and records it in
1012
+ * Recents with a full record attached. That record is then copied into the new
1013
+ * favourite. The blob is always minted by Maps, which is the only thing that can
1014
+ * mint one correctly.
1015
+ *
1016
+ * MEASURED consequences of that design, all of them in `docs/maps.md`:
1017
+ *
1018
+ * * The insert needs THREE tables — the favourite, its map item, and the two
1019
+ * `Z_PRIMARYKEY` counters. Not the eight the app touches.
1020
+ * * No persistent history and no `NSCK*` metadata is written. Core Data
1021
+ * reconciles unregistered objects on the app's next save and mirrors them to
1022
+ * iCloud by itself; a favourite written this way reached another device.
1023
+ * * It works with Maps RUNNING, and survives a subsequent save by the app.
1024
+ * There is no need to quit anything.
1025
+ *
1026
+ * ## The cost the caller must be told about
1027
+ *
1028
+ * Seeding leaves the place in the user's **Recents**, whether or not the
1029
+ * favourite is kept. That is unavoidable — it is the mechanism — so every tool
1030
+ * built on this says so in its description rather than springing it on somebody.
1031
+ *
1032
+ * ## Why writes are dangerous here in a way the other surfaces are not
1033
+ *
1034
+ * The store is mirrored by `NSPersistentCloudKitContainer`, and mirroring does
1035
+ * not wait to be told. A malformed row is not a local mistake: it reaches every
1036
+ * device on the account as soon as Maps next runs. There is no such thing as a
1037
+ * local-only insert here — that was believed for a while and measured false.
1038
+ * Hence: never fabricate a place record, only ever copy one Maps wrote.
1039
+ */
1040
+ const STORAGE_POLL_MS = 250;
1041
+ /**
1042
+ * Progress, on stderr.
1043
+ *
1044
+ * Adding a favourite can take tens of seconds — Maps has to resolve a place over
1045
+ * the network before there is anything to copy — and the first version said
1046
+ * NOTHING for the whole of it. That is indistinguishable from a hang, and was
1047
+ * reported as one three times while three different theories were tried. A long
1048
+ * operation that reports nothing cannot be diagnosed, only guessed at.
1049
+ *
1050
+ * stderr because this is an MCP stdio server: stdout carries the protocol, and
1051
+ * the server already writes its banner here.
1052
+ */
1053
+ const progress = (message) => {
1054
+ process.stderr.write(`[apple-maps-mcp] ${message}\n`);
1055
+ };
1056
+ var MapsWriteError = class extends AppleAutomationError {
1057
+ name = "MapsWriteError";
1058
+ };
1059
+ const defaultOpenUrl = (url) => {
1060
+ execFileSync("/usr/bin/open", ["-g", url], {
1061
+ timeout: 1e4,
1062
+ stdio: "ignore"
1063
+ });
1064
+ };
1065
+ const uuidBytes = () => Uint8Array.from(Buffer.from(randomUUID().replaceAll("-", ""), "hex"));
1066
+ const uuidOf = (bytes) => {
1067
+ const h = Buffer.from(bytes).toString("hex");
1068
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
1069
+ };
1070
+ /**
1071
+ * Coordinates are floats and never compare exactly across sources, so identity
1072
+ * is "within about a metre" rather than equality. 1e-5 degrees is ~1.1 m of
1073
+ * latitude — close enough that two rows are the same doorway, far enough apart
1074
+ * that neighbouring shops are not merged.
1075
+ */
1076
+ const NEAR = 1e-5;
1077
+ /** Same doorway, within about a metre. */
1078
+ const near = (aLat, aLon, bLat, bLon) => Math.abs(aLat - bLat) <= SEED_TOLERANCE && Math.abs(aLon - bLon) <= SEED_TOLERANCE;
1079
+ /**
1080
+ * How far a seeded place may sit from the coordinate the caller gave.
1081
+ *
1082
+ * Far looser than `NEAR`: the caller's coordinate and Apple's for the same place
1083
+ * routinely differ by a building's width, and a search resolves to the entrance
1084
+ * rather than the pin. ~250 m accepts that and still rejects a different town.
1085
+ */
1086
+ const SEED_TOLERANCE = .0025;
1087
+ const toRad = (deg) => deg * Math.PI / 180;
1088
+ /** Metres between two coordinates, for the error message only. */
1089
+ const haversineMetres = (aLat, aLon, bLat, bLon) => {
1090
+ const dLat = toRad(bLat - aLat);
1091
+ const dLon = toRad(bLon - aLon);
1092
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
1093
+ return 12742e3 * Math.asin(Math.sqrt(h));
1094
+ };
1095
+ var MapsWriter = class {
1096
+ #path;
1097
+ #openUrl;
1098
+ #seedTimeoutMs;
1099
+ constructor(opts) {
1100
+ this.#path = opts.storePath;
1101
+ this.#openUrl = opts.openUrl ?? defaultOpenUrl;
1102
+ this.#seedTimeoutMs = opts.seedTimeoutMs ?? 3e4;
1103
+ }
1104
+ /**
1105
+ * A read-write handle.
1106
+ *
1107
+ * `busy_timeout` is not optional: Maps and `mapssyncd` hold this store open,
1108
+ * and without it a write during ordinary use is refused outright rather than
1109
+ * waiting for the lock the way any other SQLite client would.
1110
+ */
1111
+ #open() {
1112
+ const db = new DatabaseSync(this.#path);
1113
+ db.exec("PRAGMA busy_timeout = 5000");
1114
+ return db;
1115
+ }
1116
+ #entity(db, name) {
1117
+ const row = db.prepare(`SELECT Z_ENT AS e FROM Z_PRIMARYKEY WHERE Z_NAME = ?`).get(name);
1118
+ if (!row) throw new MapsWriteError(`This store has no "${name}" entity, so it cannot be written.`);
1119
+ return Number(row.e);
1120
+ }
1121
+ /**
1122
+ * The next primary key: one past whichever is HIGHER, the counter or the
1123
+ * highest row actually present.
1124
+ *
1125
+ * `Z_PRIMARYKEY.Z_MAX` is the counter Core Data maintains, and trusting it
1126
+ * alone is what a first version did. It is not always current — Maps can be
1127
+ * mid-write, a crash can leave it behind, and a foreign writer that forgot to
1128
+ * bump it (a mistake this very file guards against making) leaves it stale
1129
+ * forever. Taking `MAX(Z_PK)` as well costs one indexed lookup and makes a
1130
+ * collision impossible rather than unlikely.
1131
+ *
1132
+ * Found by a test whose stand-in for Maps inserted a row without bumping the
1133
+ * counter. The stand-in was unfaithful; the bug it exposed was not.
1134
+ */
1135
+ #nextPk(db, name, table) {
1136
+ const counter = db.prepare(`SELECT Z_MAX AS m FROM Z_PRIMARYKEY WHERE Z_NAME = ?`).get(name);
1137
+ const highest = db.prepare(`SELECT MAX(Z_PK) AS m FROM "${table}"`).get();
1138
+ return Math.max(Number(counter?.m ?? 0), Number(highest?.m ?? 0)) + 1;
1139
+ }
1140
+ /**
1141
+ * An existing favourite for this place.
1142
+ *
1143
+ * Two ways to match, because a coordinate alone is not enough in either
1144
+ * direction. TIGHT coordinate (~1 m) catches the canonical case, where both
1145
+ * numbers came from Maps. NAME plus a LOOSE coordinate (~250 m) catches the
1146
+ * case that matters to a caller: it passed its own coordinate, which differs
1147
+ * from Apple's by a building's width — measured at 6.9e-5 for Sagrada Família.
1148
+ *
1149
+ * Without the second, every repeated call re-seeded before discovering the
1150
+ * duplicate, leaving a Recents entry each time for a favourite it then did not
1151
+ * create. Loosening the coordinate ALONE was the wrong fix: two different shops
1152
+ * 200 m apart would merge. Requiring the name as well makes the loose radius
1153
+ * safe, because it only ever merges rows the caller already calls the same
1154
+ * thing.
1155
+ */
1156
+ #existingFavorite(db, lat, lon, name) {
1157
+ const tight = db.prepare(`SELECT Z_PK AS pk, HEX(ZIDENTIFIER) AS hex, ZMAPITEMNAME AS name,
1158
+ ZLATITUDE AS lat, ZLONGITUDE AS lon
1159
+ FROM ZFAVORITEITEM
1160
+ WHERE ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
1161
+ LIMIT 1`).get(lat - NEAR, lat + NEAR, lon - NEAR, lon + NEAR);
1162
+ if (tight || !name) return tight;
1163
+ return db.prepare(`SELECT Z_PK AS pk, HEX(ZIDENTIFIER) AS hex, ZMAPITEMNAME AS name,
1164
+ ZLATITUDE AS lat, ZLONGITUDE AS lon
1165
+ FROM ZFAVORITEITEM
1166
+ WHERE ZMAPITEMNAME = ?
1167
+ AND ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
1168
+ LIMIT 1`).get(name, lat - SEED_TOLERANCE, lat + SEED_TOLERANCE, lon - SEED_TOLERANCE, lon + SEED_TOLERANCE);
1169
+ }
1170
+ /**
1171
+ * A place record already in the store for this coordinate.
1172
+ *
1173
+ * Checked before seeding, so asking for somewhere the user has already looked
1174
+ * at costs no Recents entry and no network round trip. Recents, collection
1175
+ * items and other favourites are all valid sources — the record is the same
1176
+ * object whichever row points at it.
1177
+ */
1178
+ #donorNear(db, lat, lon) {
1179
+ return db.prepare(`SELECT m."Z_PK" AS mapPk, m."ZLATITUDE" AS lat, m."ZLONGITUDE" AS lon,
1180
+ m."ZMAPITEMSTORAGE" AS storage,
1181
+ COALESCE(f."ZMAPITEMNAME", ci."ZMAPITEMNAME") AS name,
1182
+ COALESCE(f."ZMAPITEMADDRESS", ci."ZMAPITEMADDRESS") AS address,
1183
+ COALESCE(f."ZMAPITEMCATEGORY", ci."ZMAPITEMCATEGORY") AS category,
1184
+ CAST(COALESCE(f."ZMUID", ci."ZMUID") AS TEXT) AS muid
1185
+ FROM "ZMIXINMAPITEM" m
1186
+ LEFT JOIN "ZFAVORITEITEM" f ON f."ZMAPITEM" = m."Z_PK"
1187
+ LEFT JOIN "ZCOLLECTIONITEM" ci ON ci."ZMAPITEM" = m."Z_PK"
1188
+ WHERE m."ZMAPITEMSTORAGE" IS NOT NULL
1189
+ AND m."ZLATITUDE" BETWEEN ? AND ? AND m."ZLONGITUDE" BETWEEN ? AND ?
1190
+ LIMIT 1`).get(lat - NEAR, lat + NEAR, lon - NEAR, lon + NEAR) ?? null;
1191
+ }
1192
+ /** The newest recent carrying a place record, above a watermark. */
1193
+ #seededDonor(db, sinceHistoryPk) {
1194
+ return db.prepare(`SELECT m."Z_PK" AS mapPk, m."ZLATITUDE" AS lat, m."ZLONGITUDE" AS lon,
1195
+ m."ZMAPITEMSTORAGE" AS storage,
1196
+ h."ZCUSTOMNAME" AS name, NULL AS address, NULL AS category,
1197
+ CAST(h."ZMUID" AS TEXT) AS muid
1198
+ FROM "ZHISTORYITEM" h
1199
+ JOIN "ZMIXINMAPITEM" m ON m."Z_PK" = h."ZMAPITEM"
1200
+ WHERE h."Z_PK" > ? AND m."ZMAPITEMSTORAGE" IS NOT NULL
1201
+ ORDER BY h."Z_PK" DESC LIMIT 1`).get(sinceHistoryPk) ?? null;
1202
+ }
1203
+ #maxHistory(db) {
1204
+ const row = db.prepare(`SELECT MAX(Z_PK) AS m FROM ZHISTORYITEM`).get();
1205
+ return Number(row?.m ?? 0);
1206
+ }
1207
+ /**
1208
+ * Add a favourite, in three phases with a connection open for as little of it
1209
+ * as possible.
1210
+ *
1211
+ * THE PHASES ARE THE POINT. A first version opened one read-write handle at the
1212
+ * top and held it across the whole seed — including the wait for Maps to
1213
+ * resolve a place, which can run to tens of seconds. That hung: Maps is being
1214
+ * asked to WRITE the very record being waited for, into a store this process is
1215
+ * holding open for writing. Whatever the precise interaction, the shape was a
1216
+ * departure from the sequence that had been proven by hand, and the proven
1217
+ * sequence never holds a write handle while waiting on the app.
1218
+ *
1219
+ * 1. READ — look for an existing favourite and an existing place record.
1220
+ * 2. SEED — ask Maps to mint a record, holding NO connection at all, and
1221
+ * poll with a short-lived read-only handle each time.
1222
+ * 3. WRITE — open read-write, insert, close.
1223
+ */
1224
+ addFavorite(input) {
1225
+ const hasCoords = input.latitude !== void 0 && input.longitude !== void 0;
1226
+ const lat = input.latitude ?? 0;
1227
+ const lon = input.longitude ?? 0;
1228
+ progress(`add_favorite ${JSON.stringify(input.query)}: reading the store`);
1229
+ const ro = new DatabaseSync(this.#path, { readOnly: true });
1230
+ let existing = null;
1231
+ let donor = null;
1232
+ let watermark = 0;
1233
+ try {
1234
+ if (hasCoords) {
1235
+ existing = this.#existingFavorite(ro, lat, lon, input.name ?? input.query) ?? null;
1236
+ donor = this.#donorNear(ro, lat, lon);
1237
+ }
1238
+ watermark = this.#maxHistory(ro);
1239
+ } finally {
1240
+ ro.close();
1241
+ }
1242
+ if (existing) return {
1243
+ rowId: Number(existing.pk),
1244
+ uuid: uuidOf(Buffer.from(existing.hex, "hex")),
1245
+ name: existing.name,
1246
+ latitude: Number(existing.lat),
1247
+ longitude: Number(existing.lon),
1248
+ created: false,
1249
+ seeded: false
1250
+ };
1251
+ let seeded = false;
1252
+ if (!donor) {
1253
+ const url = `maps://?q=${encodeURIComponent(input.query)}`;
1254
+ progress(`asking Maps to resolve ${JSON.stringify(input.query)}…`);
1255
+ this.#openUrl(url);
1256
+ seeded = true;
1257
+ progress(`opened, waiting up to ${Math.round(this.#seedTimeoutMs / 1e3)}s for a record`);
1258
+ const deadline = Date.now() + this.#seedTimeoutMs;
1259
+ let polls = 0;
1260
+ while (Date.now() < deadline && !donor) {
1261
+ const probe = new DatabaseSync(this.#path, { readOnly: true });
1262
+ try {
1263
+ donor = this.#seededDonor(probe, watermark);
1264
+ } finally {
1265
+ probe.close();
1266
+ }
1267
+ if (!donor) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STORAGE_POLL_MS);
1268
+ polls += 1;
1269
+ if (polls % 8 === 0) progress(`still waiting (${polls * (STORAGE_POLL_MS / 1e3)}s)…`);
1270
+ }
1271
+ if (donor && hasCoords && !near(donor.lat, donor.lon, lat, lon)) throw new MapsWriteError(`Maps resolved ${JSON.stringify(input.query)} to a place about ${Math.round(haversineMetres(donor.lat, donor.lon, lat, lon))}m from the coordinate given, so it is probably not the place meant. Nothing was saved — try a more specific name.`, { query: input.query });
1272
+ if (donor) progress(`got a place record (${donor.storage?.length ?? 0} bytes) after ${polls} polls`);
1273
+ if (!donor) throw new MapsWriteError(`Maps did not produce a place record for "${input.query}" within ${Math.round(this.#seedTimeoutMs / 1e3)}s. Maps must be RUNNING for this to work — it is the only thing that can create a place record. It may also have resolved the query to a search rather than a place, since searches get no record. Try a more specific name, or pass latitude and longitude.`, { query: input.query });
1274
+ }
1275
+ const canonical = new DatabaseSync(this.#path, { readOnly: true });
1276
+ let duplicate = null;
1277
+ try {
1278
+ duplicate = this.#existingFavorite(canonical, donor.lat, donor.lon) ?? null;
1279
+ } finally {
1280
+ canonical.close();
1281
+ }
1282
+ if (duplicate) {
1283
+ progress("already a favourite; nothing written");
1284
+ return {
1285
+ rowId: Number(duplicate.pk),
1286
+ uuid: uuidOf(Buffer.from(duplicate.hex, "hex")),
1287
+ name: duplicate.name,
1288
+ latitude: Number(duplicate.lat),
1289
+ longitude: Number(duplicate.lon),
1290
+ created: false,
1291
+ seeded
1292
+ };
1293
+ }
1294
+ progress("writing the favourite");
1295
+ const db = this.#open();
1296
+ try {
1297
+ const favEnt = this.#entity(db, "FavoriteItem");
1298
+ const mixEnt = this.#entity(db, "MixinMapItem");
1299
+ const favPk = this.#nextPk(db, "FavoriteItem", "ZFAVORITEITEM");
1300
+ const mixPk = this.#nextPk(db, "MixinMapItem", "ZMIXINMAPITEM");
1301
+ const now = Date.now() / 1e3 - CORE_DATA_EPOCH_OFFSET;
1302
+ const id = uuidBytes();
1303
+ const label = input.name ?? donor.name ?? input.query;
1304
+ const position = Number(db.prepare(`SELECT COUNT(*) AS c FROM ZFAVORITEITEM`).get().c);
1305
+ db.exec("BEGIN IMMEDIATE");
1306
+ try {
1307
+ db.prepare(`INSERT INTO "ZFAVORITEITEM"
1308
+ (Z_PK, Z_ENT, Z_OPT, ZHIDDEN, ZPOSITIONINDEX, ZSOURCE, ZTYPE, ZVERSION,
1309
+ ZMAPITEM, ZMUID, ZCREATETIME, ZMODIFICATIONTIME, ZMAPITEMLASTREFRESHED,
1310
+ ZLATITUDE, ZLONGITUDE, ZMAPITEMNAME, ZMAPITEMADDRESS, ZMAPITEMCATEGORY,
1311
+ ZIDENTIFIER)
1312
+ VALUES (?, ?, 1, 0, ?, 0, 1, 2, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(favPk, favEnt, position, mixPk, donor.muid === null ? null : Number(donor.muid), now, now, now, donor.lat, donor.lon, label, donor.address, donor.category, id);
1313
+ db.prepare(`INSERT INTO "ZMIXINMAPITEM"
1314
+ (Z_PK, Z_ENT, Z_OPT, ZFAVORITEITEM, ZCREATETIME, ZMODIFICATIONTIME,
1315
+ ZLATITUDE, ZLONGITUDE, ZMAPITEMSTORAGE)
1316
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`).run(mixPk, mixEnt, favPk, now, now, donor.lat, donor.lon, donor.storage);
1317
+ db.prepare(`UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'FavoriteItem'`).run(favPk);
1318
+ db.prepare(`UPDATE Z_PRIMARYKEY SET Z_MAX = ? WHERE Z_NAME = 'MixinMapItem'`).run(mixPk);
1319
+ db.exec("COMMIT");
1320
+ } catch (err) {
1321
+ try {
1322
+ db.exec("ROLLBACK");
1323
+ } catch {}
1324
+ throw err;
1325
+ }
1326
+ return {
1327
+ rowId: favPk,
1328
+ uuid: uuidOf(id),
1329
+ name: label,
1330
+ latitude: Number(donor.lat),
1331
+ longitude: Number(donor.lon),
1332
+ created: true,
1333
+ seeded
1334
+ };
1335
+ } finally {
1336
+ db.close();
1337
+ }
1338
+ }
1339
+ /**
1340
+ * Remove a favourite and the place record it owns.
1341
+ *
1342
+ * `Z_MAX` is deliberately NOT rolled back. Core Data never reuses a primary
1343
+ * key, and decrementing the counter would hand the next insert one that is
1344
+ * already spoken for by a row still referenced elsewhere.
1345
+ */
1346
+ removeFavorite(key) {
1347
+ const db = this.#open();
1348
+ try {
1349
+ const where = "uuid" in key ? `HEX(ZIDENTIFIER) = ?` : `Z_PK = ?`;
1350
+ const param = "uuid" in key ? key.uuid.replaceAll("-", "").toUpperCase() : key.rowId;
1351
+ const row = db.prepare(`SELECT Z_PK AS pk, ZMAPITEM AS mapPk FROM ZFAVORITEITEM WHERE ${where} LIMIT 1`).get(param);
1352
+ if (!row) return false;
1353
+ db.exec("BEGIN IMMEDIATE");
1354
+ try {
1355
+ if (row.mapPk !== null) db.prepare(`DELETE FROM ZMIXINMAPITEM WHERE Z_PK = ?`).run(row.mapPk);
1356
+ db.prepare(`DELETE FROM ZFAVORITEITEM WHERE Z_PK = ?`).run(row.pk);
1357
+ db.exec("COMMIT");
1358
+ } catch (err) {
1359
+ try {
1360
+ db.exec("ROLLBACK");
1361
+ } catch {}
1362
+ throw err;
1363
+ }
1364
+ return true;
1365
+ } finally {
1366
+ db.close();
1367
+ }
1368
+ }
1369
+ };
1370
+ //#endregion
1371
+ //#region src/client/maps.ts
1372
+ /**
1373
+ * What diagnostics says about one entity: how many rows, which column won for
1374
+ * each field, and which fields resolved to nothing. `resolved` is the part that
1375
+ * matters when a result looks wrong — it names the column actually being read.
1376
+ */
1377
+ const summariseEntity = (e) => ({
1378
+ rows: e.rows,
1379
+ resolved: Object.fromEntries(Object.entries(e.fields).filter(([, v]) => v !== null)),
1380
+ unresolved: Object.entries(e.fields).filter(([, v]) => v === null).map(([k]) => k)
1381
+ });
1382
+ var AppleMapsClient = class {
1383
+ #config;
1384
+ #logger;
1385
+ #home;
1386
+ #located = null;
1387
+ #store = null;
1388
+ #storeError = null;
1389
+ constructor(opts) {
1390
+ this.#config = opts.config;
1391
+ this.#logger = opts.logger;
1392
+ this.#home = opts.home;
1393
+ }
1394
+ get config() {
1395
+ return this.#config;
1396
+ }
1397
+ located() {
1398
+ this.#located ??= locateStore({
1399
+ storePath: this.#config.storePath,
1400
+ ...this.#home ? { home: this.#home } : {}
1401
+ });
1402
+ return this.#located;
1403
+ }
1404
+ /**
1405
+ * A writer bound to the located store, or an error explaining why not.
1406
+ *
1407
+ * Writes go through their own read-write connection rather than the shared
1408
+ * read-only one: `store()` opens with `query_only` and an `immutable` fallback
1409
+ * precisely so a read can never mutate by accident, and widening it would
1410
+ * throw that guarantee away for every read on the surface.
1411
+ */
1412
+ writer(openUrl) {
1413
+ const located = this.located();
1414
+ if (!located.readable || !located.storePath) throw new MapsStoreUnavailableError(located.reason ?? "Maps' store could not be opened.");
1415
+ return new MapsWriter({
1416
+ storePath: located.storePath,
1417
+ ...openUrl ? { openUrl } : {}
1418
+ });
1419
+ }
1420
+ /** Open the store, once, lazily. Every read goes through here. */
1421
+ store() {
1422
+ if (this.#store) return this.#store;
1423
+ if (this.#storeError !== null) throw new MapsStoreUnavailableError(this.#storeError);
1424
+ const located = this.located();
1425
+ if (!located.readable || !located.storePath) {
1426
+ this.#storeError = located.reason ?? "Maps' store could not be opened.";
1427
+ throw new MapsStoreUnavailableError(this.#storeError);
1428
+ }
1429
+ try {
1430
+ this.#store = openStore({
1431
+ path: located.storePath,
1432
+ mode: this.#config.indexMode,
1433
+ ...this.#logger ? { logger: this.#logger } : {}
1434
+ });
1435
+ return this.#store;
1436
+ } catch (err) {
1437
+ this.#storeError = err instanceof Error ? err.message : String(err);
1438
+ throw err;
1439
+ }
1440
+ }
1441
+ get epoch() {
1442
+ return this.store().caps.epoch;
1443
+ }
1444
+ #render(kind, row, epoch) {
1445
+ return {
1446
+ ref: encodePlaceRef(kind, row.uuid ? { uuid: row.uuid } : { rowId: row.id }),
1447
+ kind,
1448
+ name: row.customName ?? row.name,
1449
+ placeName: row.name,
1450
+ address: row.address,
1451
+ latitude: row.latitude,
1452
+ longitude: row.longitude,
1453
+ muid: row.muid,
1454
+ created: renderInstant(row.createdRaw, epoch),
1455
+ modified: renderInstant(row.modifiedRaw, epoch),
1456
+ linked: row.linked
1457
+ };
1458
+ }
1459
+ #renderCollection(row, epoch) {
1460
+ return {
1461
+ ref: encodeCollectionRef(row.uuid ? { uuid: row.uuid } : { rowId: row.id }),
1462
+ title: row.title,
1463
+ placesCount: row.placesCount,
1464
+ created: renderInstant(row.createdRaw, epoch),
1465
+ modified: renderInstant(row.modifiedRaw, epoch)
1466
+ };
1467
+ }
1468
+ places(kind, opts) {
1469
+ const store = this.store();
1470
+ const { rows, truncated } = store.places(kind, opts);
1471
+ return {
1472
+ places: rows.map((r) => this.#render(kind, r, store.caps.epoch)),
1473
+ truncated,
1474
+ datesAvailable: store.caps.epoch.confident
1475
+ };
1476
+ }
1477
+ place(kind, key) {
1478
+ const store = this.store();
1479
+ const row = store.place(kind, key);
1480
+ return row ? this.#render(kind, row, store.caps.epoch) : null;
1481
+ }
1482
+ /**
1483
+ * A collection ref to the row id its items point at, or null when the ref
1484
+ * addresses nothing in this store.
1485
+ *
1486
+ * Collection membership is a Core Data foreign key and always holds `Z_PK`,
1487
+ * so a uuid ref has to be translated before it can filter items.
1488
+ */
1489
+ collectionRowId(key) {
1490
+ return this.store().collectionRowId(key);
1491
+ }
1492
+ collections(opts) {
1493
+ const store = this.store();
1494
+ const { rows, truncated } = store.collections(opts);
1495
+ return {
1496
+ collections: rows.map((r) => this.#renderCollection(r, store.caps.epoch)),
1497
+ truncated,
1498
+ itemsEnumerable: store.caps.membership !== null,
1499
+ unfiled: store.unfiledCount()
1500
+ };
1501
+ }
1502
+ /**
1503
+ * Search every place-bearing entity at once.
1504
+ *
1505
+ * Three separate queries rather than a UNION, because the three tables have
1506
+ * different resolved columns and a UNION would have to flatten them to the
1507
+ * narrowest — which on this store means losing history's coordinates, since
1508
+ * they live in a differently named column from the other two.
1509
+ */
1510
+ search(opts) {
1511
+ const store = this.store();
1512
+ const kinds = [
1513
+ "favorite",
1514
+ "collection-item",
1515
+ "history"
1516
+ ];
1517
+ const places = [];
1518
+ let truncated = false;
1519
+ for (const kind of kinds) {
1520
+ const { rows, truncated: t } = store.places(kind, {
1521
+ limit: opts.limit,
1522
+ query: opts.query
1523
+ });
1524
+ truncated ||= t;
1525
+ for (const r of rows) places.push(this.#render(kind, r, store.caps.epoch));
1526
+ }
1527
+ return {
1528
+ places: places.slice(0, opts.limit),
1529
+ truncated: truncated || places.length > opts.limit,
1530
+ datesAvailable: store.caps.epoch.confident
1531
+ };
1532
+ }
1533
+ /** Everything diagnostics needs, with no failure allowed to fail the call. */
1534
+ status() {
1535
+ const located = this.located();
1536
+ let store = null;
1537
+ let reason = located.reason;
1538
+ try {
1539
+ store = this.store();
1540
+ } catch (err) {
1541
+ reason = err instanceof Error ? err.message : String(err);
1542
+ }
1543
+ return {
1544
+ located,
1545
+ store: {
1546
+ opened: store !== null,
1547
+ mode: store?.mode ?? null,
1548
+ reason
1549
+ },
1550
+ capabilities: store ? {
1551
+ fingerprint: store.caps.fingerprint,
1552
+ tableCount: store.caps.tables.length,
1553
+ epoch: store.caps.epoch,
1554
+ collectionFk: store.caps.collectionFk,
1555
+ collectionMembership: store.caps.membership,
1556
+ entities: {
1557
+ favorites: summariseEntity(store.caps.favorites),
1558
+ collections: summariseEntity(store.caps.collections),
1559
+ collectionItems: summariseEntity(store.caps.collectionItems),
1560
+ history: summariseEntity(store.caps.history),
1561
+ mapItems: summariseEntity(store.caps.mapItems)
1562
+ }
1563
+ } : null
1564
+ };
1565
+ }
1566
+ close() {
1567
+ this.#store?.close();
1568
+ this.#store = null;
1569
+ }
1570
+ };
1571
+ //#endregion
1572
+ //#region src/config.ts
1573
+ /**
1574
+ * Configuration is environment-only — this server holds no secret at all. Its
1575
+ * access is the macOS permission the user granted, which is the whole point.
1576
+ *
1577
+ * `allowWrites` gates two mutating tools, and gates them harder than elsewhere.
1578
+ * Maps has no scripting dictionary and no registered App Intents, so a write is
1579
+ * SQL straight into a Core Data store that `NSPersistentCloudKitContainer`
1580
+ * mirrors — which means it reaches every device on the account, not just this
1581
+ * Mac. That was measured, along with the rule that keeps it safe: never
1582
+ * fabricate a place record, only ever copy one Maps wrote itself. See
1583
+ * `docs/maps.md` and `client/write.ts`.
1584
+ */
1585
+ const ConfigSchema = BaseConfigSchema.extend({
1586
+ /** Explicit store path. Bypasses discovery — for tests and forensic copies. */
1587
+ storePath: z.string().optional(),
1588
+ indexMode: z.enum([
1589
+ "auto",
1590
+ "ro",
1591
+ "immutable",
1592
+ "off"
1593
+ ]).default("auto")
1594
+ }).strict();
1595
+ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
1596
+ allowWrites: parseBool(env.APPLE_MAPS_ALLOW_WRITES),
1597
+ exposePrompts: parseBool(env.APPLE_MAPS_EXPOSE_PROMPTS),
1598
+ debug: parseBool(env.APPLE_MAPS_DEBUG),
1599
+ storePath: trimmed(env.APPLE_MAPS_STORE),
1600
+ indexMode: trimmed(env.APPLE_MAPS_INDEX_MODE),
1601
+ osascriptPath: trimmed(env.APPLE_MAPS_OSASCRIPT_PATH),
1602
+ osascriptTimeoutMs: parseIntOpt(env.APPLE_MAPS_OSASCRIPT_TIMEOUT_MS),
1603
+ maxResults: parseIntOpt(env.APPLE_MAPS_MAX_RESULTS)
1604
+ });
1605
+ //#endregion
1606
+ //#region src/guide.ts
1607
+ /**
1608
+ * The Maps operating manual, served as `cupertino://maps/guide` and embedded
1609
+ * ahead of every Maps prompt. Static by design — see the note in the Mail guide.
1610
+ */
1611
+ const MAPS_GUIDE = `# Maps — how to drive this server
1612
+
1613
+ ## What this surface is, and is not
1614
+
1615
+ It reads the places **saved on this Mac**: favourites, collections (Guides) and
1616
+ recents. It does **not** search Apple's map of the world, geocode an address,
1617
+ give directions, or compute a travel time. If asked for any of those, say so
1618
+ plainly rather than searching the saved places and presenting a near-miss —
1619
+ "the nearest coffee shop" is not a question this server can answer, and a
1620
+ favourite called "Coffee" is not the answer to it.
1621
+
1622
+ ## One lane, and no fallback
1623
+
1624
+ Maps ships **no scripting dictionary** — there is no \`.sdef\` in the app bundle
1625
+ — so unlike every other surface here there is no Apple Events lane to fall back
1626
+ to. Everything comes from a Core Data store under **Full Disk Access**.
1627
+
1628
+ The consequence matters: without the grant this server returns an **error**, not
1629
+ an empty list. If a tool ever does return an empty list, that means the user has
1630
+ genuinely saved nothing of that kind. Check
1631
+ \`cupertino://maps/diagnostics\` before concluding either way.
1632
+
1633
+ ## Which tool answers which question
1634
+
1635
+ | Question | Tool |
1636
+ | --- | --- |
1637
+ | What places has the user saved? | \`apple_maps_list_favorites\` |
1638
+ | What Guides do they keep? | \`apple_maps_list_collections\` |
1639
+ | What is filed in one Guide? | \`apple_maps_list_collection_places\` |
1640
+ | Where have they looked recently? | \`apple_maps_list_recents\` |
1641
+ | Find a saved place by name or address | \`apple_maps_search_places\` |
1642
+ | One place, in full | \`apple_maps_get_place\` |
1643
+
1644
+ ## Four things not to say
1645
+
1646
+ **\`linked: false\` is not a broken row.** Some favourites have no place
1647
+ attached, no name and no coordinate. They are Maps' unconfigured Home / Work /
1648
+ School slots. Report them as unset, not as places.
1649
+
1650
+ **A null date is unknown, not old.** Timestamps are placed on an epoch detected
1651
+ from the store. When detection fails every date reads \`null\` rather than being
1652
+ guessed, because a date wrong by 31 years reads exactly like a correct one.
1653
+
1654
+ **A collection with no listable places may still have places.** How an item
1655
+ belongs to a collection is not exposed by every version of this store. When the
1656
+ key is missing, \`placesCount\` is still Maps' own accurate number and the
1657
+ places simply cannot be enumerated. Say that, rather than reporting an empty
1658
+ Guide.
1659
+
1660
+ **Refs expire.** They address a row in a store that iCloud re-syncs, and Core
1661
+ Data reuses row ids after a delete. A ref is good for this conversation. Do not
1662
+ store one or hand it back later.
1663
+
1664
+ ## Privacy, worth holding in mind
1665
+
1666
+ Saved places are a home address, a doctor, a school, a partner's flat. This is
1667
+ among the most sensitive data any surface here reads. Answer what was asked;
1668
+ do not enumerate everything because a listing tool is available.
1669
+ `;
1670
+ //#endregion
1671
+ //#region src/prompts.ts
1672
+ const CTX = {
1673
+ surface: "maps",
1674
+ guide: MAPS_GUIDE
1675
+ };
1676
+ /**
1677
+ * Maps' one workflow prompt.
1678
+ *
1679
+ * It exists because "where was that place" spans three lists that hold the same
1680
+ * kind of thing for different reasons — a favourite is deliberate, a collection
1681
+ * entry is filed, a recent is incidental — and searching only one produces a
1682
+ * confident answer to a question nobody asked. The prompt also carries the
1683
+ * refusal: this server does not know about places the user has never saved, and
1684
+ * the most likely way to be wrong here is to answer anyway.
1685
+ */
1686
+ const registerPrompts = (server) => {
1687
+ registerWorkflowPrompt(server, CTX, {
1688
+ name: "apple_maps_where_was_that_place",
1689
+ title: "Find a place I saved",
1690
+ description: "Track down a saved place across favourites, Guides and recents — three lists that hold places for different reasons. Read-only.",
1691
+ argsSchema: { about: promptArg("What the place was, e.g. \"the ramen place near the office\".") },
1692
+ build: ({ about }) => `Find the saved place ${about ? `matching: ${about}` : "I am after"}.
1693
+
1694
+ Start with \`apple_maps_search_places\` — it covers favourites, Guide entries and
1695
+ recents in one call, and tells you which kind each match came from. That
1696
+ distinction is the answer, not a detail: a favourite is somewhere I chose to
1697
+ keep, a recent is somewhere I merely looked at once.
1698
+
1699
+ If that finds nothing, list the three directly before concluding it is not
1700
+ there — a place I saved under my own label ("Mum's") will not match a search for
1701
+ what it actually is.
1702
+
1703
+ Then report what you found, with the address and how it was saved.
1704
+
1705
+ Three things to get right:
1706
+ - If nothing matches, say I have not saved it. Do **not** fall back to guessing
1707
+ a place from general knowledge — this server only knows what is on this Mac,
1708
+ and inventing an address is the worst possible failure here.
1709
+ - An entry with \`linked: false\` is an unconfigured Home/Work/School slot, not
1710
+ a place. Do not report it as one.
1711
+ - If a date reads null, the date is unknown. Do not describe it as old.
1712
+
1713
+ If every list comes back empty, check \`cupertino://maps/diagnostics\` before
1714
+ concluding I have saved nothing — this surface has no second lane, so a missing
1715
+ Full Disk Access grant takes all of it at once.`
1716
+ });
1717
+ };
1718
+ //#endregion
1719
+ //#region src/tools/util.ts
1720
+ const placeRefArg = z.string().min(1).describe("An opaque place ref from a listing (looks like \"p1:f:12\"). Do not construct one by hand. Refs address a row in the local store, so they are only valid for this session — an iCloud re-sync can renumber them.");
1721
+ const collectionRefArg = z.string().min(1).describe("An opaque collection ref from apple_maps_list_collections (looks like \"pc1:3\").");
1722
+ const queryArg = z.string().min(1).describe("Text to look for in a place's name, the label you gave it, or its address. Wildcards are escaped, so searching for \"100%\" finds that literal string.");
1723
+ //#endregion
1724
+ //#region src/tools/diagnostics.ts
1725
+ /**
1726
+ * Build the report.
1727
+ *
1728
+ * Split out of the tool registration so the `cupertino://maps/diagnostics`
1729
+ * resource can serve the same bytes. Two renderings of one probe: duplicated,
1730
+ * the resource and the tool would drift, and the disagreement would surface as
1731
+ * "the diagnostics lied" — the one thing this file must never do.
1732
+ */
1733
+ const buildDiagnostics = async (client) => {
1734
+ const status = client.status();
1735
+ const located = status.located;
1736
+ return {
1737
+ server: {
1738
+ name: BUILD_INFO.name,
1739
+ version: BUILD_INFO.version
1740
+ },
1741
+ settings: { exposePrompts: client.config.exposePrompts },
1742
+ lanes: {
1743
+ summary: "Maps has ONE lane. Maps.app ships no scripting dictionary — there is no .sdef in the bundle, checked directly — so there is no Apple Events fallback. Without Full Disk Access this server cannot read anything at all, and it says so rather than returning empty lists.",
1744
+ fileLane: {
1745
+ needs: "Full Disk Access",
1746
+ answers: "favourites, collections (guides), recents",
1747
+ working: status.store.opened
1748
+ },
1749
+ appleEvents: "none — Maps is not scriptable",
1750
+ writes: "none — this server registers no mutating tool. The store is mirrored to iCloud by NSPersistentCloudKitContainer, so a write is an edit to one replica of a synchronising graph underneath a running app. That was never probed."
1751
+ },
1752
+ store: {
1753
+ path: located.storePath,
1754
+ exists: located.exists,
1755
+ readable: located.readable,
1756
+ opened: status.store.opened,
1757
+ mode: status.store.mode,
1758
+ reason: status.store.reason,
1759
+ resolvedByScan: located.resolvedByScan,
1760
+ ...status.capabilities
1761
+ },
1762
+ files: {
1763
+ directory: located.directory,
1764
+ localCache: {
1765
+ path: located.localCache.path,
1766
+ exists: located.localCache.exists,
1767
+ note: "The device-local cache. Same entities as the sync store and, on the probed machine, zero rows in every one of them. Located so it is visibly considered, never opened."
1768
+ }
1769
+ },
1770
+ caveats: [
1771
+ "This store was missed three times before it was found, and the reasons are worth knowing if it ever looks absent: the file has NO EXTENSION (MapsSync_0.0.1), it lives in the one directory of Maps' container that Full Disk Access gates, and `group.com.apple.Maps` is a decoy that is EPERM rather than empty.",
1772
+ "Columns are resolved BY COVERAGE, not by name. ZHISTORYITEM carries both ZLATITUDE (1 row of 33) and ZLATITUDE1 (19 of 33); picking the first recognised name would report that Maps holds almost no coordinates. `entities.*.resolved` above shows which column actually won for each field.",
1773
+ "Some favourites have no linked place: 3 of 23 on the probed machine, with no name and no coordinate. Almost certainly the unconfigured Home / Work / School slots. They are returned with `linked: false` rather than dropped, because silently omitting rows reads as a deletion.",
1774
+ "Collection membership is a many-to-many join table, Z_6PLACES(Z_6COLLECTIONS, Z_7PLACES) on the probed machine, NOT a column on ZCOLLECTIONITEM — which is why four guessed column names all missed it. It is re-proved at open time by reproducing ZPLACESCOUNT exactly, never by matching a name, because ZCOLLECTIONITEM.ZMAPITEM joins 3 of 10 collections by coincidence. When nothing reproduces those counts, `collectionMembership` is null, collections list without their places and every result says so.",
1775
+ "Refs address a local row id. Core Data reuses those after a delete and this store is mirrored from CloudKit, so a re-sync can renumber rows. A ref is good for the current session and should not be stored.",
1776
+ "Timestamps are placed on an epoch DETECTED from the store, never assumed. The same value read as unix seconds instead of Core Data seconds lands in 1995 and looks entirely plausible. When detection fails every date reads null rather than guessed."
1777
+ ]
1778
+ };
1779
+ };
1780
+ const registerDiagnosticsTools = (server, client) => {
1781
+ server.registerTool("apple_maps_diagnostics", {
1782
+ description: "Report whether Maps' store can be read, which columns were resolved for each entity, and what this server deliberately cannot do. Start here when a read returns nothing — this surface has no second lane, so a missing grant means no data at all rather than slower data.",
1783
+ inputSchema: {},
1784
+ annotations: { readOnlyHint: true }
1785
+ }, async () => wrap(() => buildDiagnostics(client)));
1786
+ };
1787
+ //#endregion
1788
+ //#region src/tools/places.ts
1789
+ /**
1790
+ * The place tools.
1791
+ *
1792
+ * Every description states the permission fact plainly. On this surface it is
1793
+ * not the usual "slower without the grant" — there is no second lane, so
1794
+ * without Full Disk Access these tools have nothing to read. Saying so in the
1795
+ * description is what stops a model concluding, from an error it half-read,
1796
+ * that the person simply has not saved any places.
1797
+ */
1798
+ const registerPlaceTools = (server, client) => {
1799
+ server.registerTool("apple_maps_list_favorites", {
1800
+ description: "List the places saved as favourites in Maps, with coordinates, address and the label the user gave them. Needs Full Disk Access — Maps is not scriptable, so without the grant this returns an error rather than an empty list. Some entries have no linked place (`linked: false`); those are the unconfigured Home/Work/School slots, not broken rows. This is everything SAVED: Maps' own Pinned panel applies display rules that are not in the store and can show fewer.",
1801
+ inputSchema: { limit: limitArg },
1802
+ annotations: {
1803
+ readOnlyHint: true,
1804
+ idempotentHint: true
1805
+ }
1806
+ }, async ({ limit }) => wrapResult(async () => {
1807
+ const result = client.places("favorite", { limit: limit ?? client.config.maxResults });
1808
+ const unlinked = result.places.filter((p) => !p.linked).length;
1809
+ return ok(compact({
1810
+ favorites: result.places,
1811
+ count: result.places.length,
1812
+ unlinked: unlinked ? `${unlinked} entr${unlinked === 1 ? "y has" : "ies have"} no linked place — these are Maps' unconfigured Home/Work/School slots. They are returned rather than dropped, because silently omitting rows reads as a deletion.` : void 0,
1813
+ /**
1814
+ * MEASURED, and the reason the note above no longer claims this count
1815
+ * matches the app: on a real store Maps' Pinned panel showed 17 while
1816
+ * this tool returned 24. Three extras were the unlinked slots; four
1817
+ * were ordinary favourites with names, addresses and coordinates, one
1818
+ * an exact duplicate of a shown entry.
1819
+ *
1820
+ * No column in ZFAVORITEITEM separates them — ZHIDDEN, ZSOURCE, ZTYPE
1821
+ * and ZVERSION were all cross-tabulated and none yields a group of
1822
+ * four. `ZVERSION = 2` has exactly 17 rows and is a COINCIDENCE OF
1823
+ * TOTALS: 16 of them are linked while all 17 shown entries are. So
1824
+ * Maps applies display rules this server cannot see — de-duplication
1825
+ * is the visible one — and no filter written here could reproduce
1826
+ * them. Saying so beats guessing, and beats the old note, which
1827
+ * asserted the opposite of what was measured. See docs/maps.md.
1828
+ */
1829
+ mayExceedApp: "Maps' own Pinned panel can show FEWER places than this. It applies display rules — de-duplication at least — that are not recorded in the store, so this is everything saved, not everything shown.",
1830
+ truncated: result.truncated ? `More favourites exist beyond limit=${limit ?? client.config.maxResults}.` : void 0,
1831
+ datesUnavailable: result.datesAvailable ? void 0 : "Timestamps could not be placed on a known epoch, so every date reads null. They are withheld rather than guessed — see apple_maps_diagnostics."
1832
+ }));
1833
+ }));
1834
+ server.registerTool("apple_maps_list_collections", {
1835
+ description: "List the collections (Guides) in Maps. Each carries Maps' own count of the places in it. Use apple_maps_list_collection_places to enumerate one. Needs Full Disk Access.",
1836
+ inputSchema: { limit: limitArg },
1837
+ annotations: {
1838
+ readOnlyHint: true,
1839
+ idempotentHint: true
1840
+ }
1841
+ }, async ({ limit }) => wrapResult(async () => {
1842
+ const result = client.collections({ limit: limit ?? client.config.maxResults });
1843
+ return ok(compact({
1844
+ collections: result.collections,
1845
+ count: result.collections.length,
1846
+ unfiled: result.unfiled && result.unfiled > 0 ? `${result.unfiled} saved place(s) are in no collection. List them with apple_maps_list_unfiled_places.` : void 0,
1847
+ itemsUnavailable: result.itemsEnumerable ? void 0 : "This store does not expose how an item belongs to a collection, so the places inside a collection cannot be listed. `placesCount` is Maps' own number and is still accurate; apple_maps_list_collection_places will return nothing.",
1848
+ truncated: result.truncated ? `More collections exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
1849
+ }));
1850
+ }));
1851
+ server.registerTool("apple_maps_list_collection_places", {
1852
+ description: "List the places filed in one collection, by its ref. Needs Full Disk Access. Returns nothing when this store does not expose collection membership — check apple_maps_diagnostics, which reports whether the key was resolved.",
1853
+ inputSchema: {
1854
+ ref: collectionRefArg,
1855
+ limit: limitArg
1856
+ },
1857
+ annotations: {
1858
+ readOnlyHint: true,
1859
+ idempotentHint: true
1860
+ }
1861
+ }, async ({ ref, limit }) => wrapResult(async () => {
1862
+ const collectionId = client.collectionRowId(decodeCollectionRef(ref));
1863
+ const result = client.places("collection-item", {
1864
+ limit: limit ?? client.config.maxResults,
1865
+ collectionId: collectionId ?? void 0
1866
+ });
1867
+ if (!client.collections({ limit: 1e3 }).itemsEnumerable) return fail("This store does not expose which collection an item belongs to, so its places cannot be listed. The collection itself and its place count are still readable through apple_maps_list_collections.");
1868
+ return ok(compact({
1869
+ places: result.places,
1870
+ count: result.places.length,
1871
+ truncated: result.truncated ? `More places exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
1872
+ }));
1873
+ }));
1874
+ server.registerTool("apple_maps_list_unfiled_places", {
1875
+ description: "List saved places that are in no collection. Maps files a saved place into a Guide through a join table, and a place can exist with no row there — 12 of 30 on the probed machine, 7 of which appear nowhere else in the store: not as a favourite, not in another Guide, not in recents. Those are reachable through no other tool. Needs Full Disk Access. Returns nothing when collection membership could not be resolved, which apple_maps_diagnostics reports.",
1876
+ inputSchema: { limit: limitArg },
1877
+ annotations: {
1878
+ readOnlyHint: true,
1879
+ idempotentHint: true
1880
+ }
1881
+ }, async ({ limit }) => wrapResult(async () => {
1882
+ if (!client.collections({ limit: 1 }).itemsEnumerable) return fail("This store does not expose which collection an item belongs to, so places in no collection cannot be told apart from places in one. apple_maps_list_collections still works.");
1883
+ const result = client.places("collection-item", {
1884
+ limit: limit ?? client.config.maxResults,
1885
+ unfiled: true
1886
+ });
1887
+ return ok(compact({
1888
+ places: result.places,
1889
+ count: result.places.length,
1890
+ truncated: result.truncated ? `More places exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
1891
+ }));
1892
+ }));
1893
+ server.registerTool("apple_maps_list_recents", {
1894
+ description: "List the places recently looked at in Maps, newest first. This is Maps' Recents list, not a search history: it holds places, directions and searches the user actually opened. Needs Full Disk Access.",
1895
+ inputSchema: { limit: limitArg },
1896
+ annotations: {
1897
+ readOnlyHint: true,
1898
+ idempotentHint: true
1899
+ }
1900
+ }, async ({ limit }) => wrapResult(async () => {
1901
+ const result = client.places("history", { limit: limit ?? client.config.maxResults });
1902
+ const named = result.places.filter((p) => p.name).length;
1903
+ return ok(compact({
1904
+ recents: result.places,
1905
+ count: result.places.length,
1906
+ namesUnavailable: result.places.length > 0 && named * 4 < result.places.length ? `Only ${named} of ${result.places.length} entries carry a name. Maps keeps a recent's place name in an encoded record this server does not decode, so most rows have coordinates and dates but no label. This is the store's shape, not missing data.` : void 0,
1907
+ truncated: result.truncated ? `More entries exist beyond limit=${limit ?? client.config.maxResults}.` : void 0,
1908
+ datesUnavailable: result.datesAvailable ? void 0 : "Timestamps could not be placed on a known epoch, so every date reads null."
1909
+ }));
1910
+ }));
1911
+ server.registerTool("apple_maps_search_places", {
1912
+ description: "Search every saved place — favourites, collection entries and recents — by name, by the label the user gave it, or by address. Returns each match with the kind of entry it came from. Needs Full Disk Access. This searches what is SAVED on this Mac; it does not search Apple's map of the world.",
1913
+ inputSchema: {
1914
+ query: queryArg,
1915
+ limit: limitArg
1916
+ },
1917
+ annotations: {
1918
+ readOnlyHint: true,
1919
+ idempotentHint: true
1920
+ }
1921
+ }, async ({ query, limit }) => wrapResult(async () => {
1922
+ const result = client.search({
1923
+ query,
1924
+ limit: limit ?? client.config.maxResults
1925
+ });
1926
+ return ok(compact({
1927
+ places: result.places,
1928
+ count: result.places.length,
1929
+ truncated: result.truncated ? `More matches exist beyond limit=${limit ?? client.config.maxResults}.` : void 0
1930
+ }));
1931
+ }));
1932
+ server.registerTool("apple_maps_get_place", {
1933
+ description: "Get one saved place by its ref, with coordinates, address and dates. Needs Full Disk Access.",
1934
+ inputSchema: { ref: placeRefArg },
1935
+ annotations: {
1936
+ readOnlyHint: true,
1937
+ idempotentHint: true
1938
+ }
1939
+ }, async ({ ref }) => wrapResult(async () => {
1940
+ const { kind, key } = decodePlaceRef(ref);
1941
+ const place = client.place(kind, key);
1942
+ if (!place) return fail("uuid" in key ? "No place for that ref. It was removed in Maps, or it belongs to a different store than the one being read." : "No place for that ref. It may have been removed in Maps, or iCloud may have re-synced the store and renumbered the rows since the listing ran — this ref carries a row id, which only this store's current numbering can resolve. Re-run the listing for a current ref.");
1943
+ return ok({ place });
1944
+ }));
1945
+ };
1946
+ //#endregion
1947
+ //#region src/tools/writes.ts
1948
+ /**
1949
+ * The mutating tools.
1950
+ *
1951
+ * Registered only when `APPLE_MAPS_ALLOW_WRITES` is true, so a host that has not
1952
+ * opted in is never told they exist — the same gate every other surface uses.
1953
+ *
1954
+ * ## Two things these descriptions must say, because they are surprising
1955
+ *
1956
+ * **Adding a favourite leaves an entry in Recents.** A place is only real to
1957
+ * Maps once it has a GEO record, and the only thing that can produce one is Maps
1958
+ * itself: the place is opened through the `maps://` URL scheme, Maps resolves it
1959
+ * and files it in Recents, and that record is copied into the favourite. There
1960
+ * is no way to have the first without the second, so the caller is told rather
1961
+ * than surprised. When the place is already known to the store, no seeding
1962
+ * happens and no Recents entry appears.
1963
+ *
1964
+ * **It reaches the user's other devices.** The store is mirrored by
1965
+ * `NSPersistentCloudKitContainer`, which reconciles on the app's next save
1966
+ * whether or not it was told anything. A favourite added here arrived on an
1967
+ * iPhone. That makes this the one write in the bundle whose blast radius is
1968
+ * larger than the machine it runs on, and the descriptions say so.
1969
+ */
1970
+ const registerWriteTools = (server, client) => {
1971
+ server.registerTool("apple_maps_add_favorite", {
1972
+ description: "Save a place to Maps' favourites (the Pinned list). Give the place NAME as `query` — a bare coordinate does not identify a place to Maps — and latitude/longitude when known, which makes the match exact and skips the lookup. SIDE EFFECT: unless the place is already in the store, Maps is asked to resolve it and the place also appears in the user's Recents; there is no way to add a favourite without that. The favourite syncs to the user's other Apple devices through iCloud. Idempotent: asking twice for the same place returns the existing favourite rather than creating a second. Needs Full Disk Access.",
1973
+ inputSchema: {
1974
+ query: z.string().min(1).describe("The place's name, as you would type it into Maps' search field."),
1975
+ latitude: z.number().min(-90).max(90).optional().describe("Latitude, when known."),
1976
+ longitude: z.number().min(-180).max(180).optional().describe("Longitude, when known."),
1977
+ name: z.string().min(1).optional().describe("Label to save it under. Defaults to `query`.")
1978
+ },
1979
+ annotations: {
1980
+ readOnlyHint: false,
1981
+ idempotentHint: true
1982
+ }
1983
+ }, async ({ query, latitude, longitude, name }) => wrapResult(async () => {
1984
+ const result = client.writer().addFavorite({
1985
+ query,
1986
+ latitude,
1987
+ longitude,
1988
+ name
1989
+ });
1990
+ return ok(compact({
1991
+ favorite: {
1992
+ ref: `p1:f:${result.uuid.replaceAll("-", "")}`,
1993
+ name: result.name,
1994
+ latitude: result.latitude,
1995
+ longitude: result.longitude
1996
+ },
1997
+ created: result.created,
1998
+ alreadyExisted: result.created ? void 0 : "A favourite for this place already existed and was returned unchanged.",
1999
+ recentsNote: result.seeded ? "Maps was asked to resolve this place, so it now also appears in the user's Recents. That is how the place record is obtained and cannot be avoided." : void 0,
2000
+ syncNote: "This favourite will reach the user's other Apple devices through iCloud once Maps next runs."
2001
+ }));
2002
+ }));
2003
+ server.registerTool("apple_maps_remove_favorite", {
2004
+ description: "Remove a place from Maps' favourites, by a ref from apple_maps_list_favorites. This deletes the favourite on the user's other Apple devices too, through iCloud. It does not affect Guides, Recents, or anything else that references the same place. Needs Full Disk Access.",
2005
+ inputSchema: { ref: placeRefArg },
2006
+ annotations: {
2007
+ readOnlyHint: false,
2008
+ idempotentHint: true,
2009
+ destructiveHint: true
2010
+ }
2011
+ }, async ({ ref }) => wrapResult(async () => {
2012
+ const { kind, key } = decodePlaceRef(ref);
2013
+ if (kind !== "favorite") return fail(`That ref points at a ${kind === "history" ? "recent" : "collection entry"}, not a favourite. Only favourites can be removed; pass a ref from apple_maps_list_favorites.`);
2014
+ if (!client.writer().removeFavorite(key)) return fail("No favourite matched that ref. It may already have been removed, or the listing it came from may predate a change. Re-run apple_maps_list_favorites.");
2015
+ return ok({
2016
+ removed: true,
2017
+ syncNote: "The removal will reach the user's other Apple devices through iCloud once Maps next runs."
2018
+ });
2019
+ }));
2020
+ };
2021
+ //#endregion
2022
+ //#region src/tools/index.ts
2023
+ /**
2024
+ * Register the Apple Maps tools.
2025
+ *
2026
+ * The registered set does NOT vary with whether the store opened. That is a
2027
+ * runtime condition and MCP clients cache the tool list, so a list that shrank
2028
+ * without Full Disk Access would stay shrunk after the grant was given. Every
2029
+ * tool is registered on a machine with no grant at all; each fails with an
2030
+ * error that says which permission is missing.
2031
+ */
2032
+ const registerTools = (server, client, ctx) => {
2033
+ registerDiagnosticsTools(server, client);
2034
+ registerPlaceTools(server, client);
2035
+ if (!ctx.allowWrites) return;
2036
+ registerWriteTools(server, client);
2037
+ };
2038
+ //#endregion
2039
+ //#region src/server.ts
2040
+ const SERVER_NAME = BUILD_INFO.name;
2041
+ const SERVER_VERSION = BUILD_INFO.version;
2042
+ /**
2043
+ * Build the server. Side-effect free: it opens no database and reads no file,
2044
+ * so a test can construct it freely and every external dependency arrives
2045
+ * through an option.
2046
+ *
2047
+ * There is no `osascript` seam here, unlike every other surface. This server
2048
+ * never spawns one — Maps is not scriptable, so there is no Apple Events lane
2049
+ * to inject a fake for. Its absence is the point.
2050
+ */
2051
+ const createServer = (opts) => {
2052
+ const { config } = opts;
2053
+ const server = new McpServer({
2054
+ name: SERVER_NAME,
2055
+ version: SERVER_VERSION
2056
+ });
2057
+ const client = new AppleMapsClient({
2058
+ config,
2059
+ ...opts.logger ? { logger: opts.logger } : {},
2060
+ ...opts.home ? { home: opts.home } : {}
2061
+ });
2062
+ registerTools(server, client, { allowWrites: config.allowWrites });
2063
+ if (config.exposePrompts) {
2064
+ registerPrompts(server);
2065
+ registerSurfaceResources(server, {
2066
+ surface: "maps",
2067
+ displayName: "Maps",
2068
+ guide: MAPS_GUIDE,
2069
+ diagnostics: () => buildDiagnostics(client)
2070
+ });
2071
+ }
2072
+ return {
2073
+ server,
2074
+ client
2075
+ };
2076
+ };
2077
+ //#endregion
2078
+ export { fromStoreTime as A, MAPS_SURFACE as C, UndatableStoreError as D, SchemaDriftError$1 as E, resolveEpoch as M, BUILD_INFO as N, APPLE_SECONDS as O, MAPS_BUNDLE_ID as S, PlaceNotFoundError as T, defaultDirectory as _, loadConfig as a, AppleMapsError as b, introspect as c, InvalidMapsRefError as d, PLACE_REF_VERSION as f, encodePlaceRef as g, encodeCollectionRef as h, registerTools as i, renderInstant as j, CORE_DATA_EPOCH_OFFSET as k, openStore as l, decodePlaceRef as m, SERVER_VERSION as n, AppleMapsClient as o, decodeCollectionRef as p, createServer as r, MapsStore as s, SERVER_NAME as t, COLLECTION_REF_VERSION as u, defaultStorePath as v, MapsStoreUnavailableError as w, IndexUnavailableError as x, locateStore as y };
2079
+
2080
+ //# sourceMappingURL=server-DgIy0w0S.js.map