macos-apps-mcp 0.6.0__py3-none-any.whl
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.
- macos_apps_mcp/__init__.py +5 -0
- macos_apps_mcp/__main__.py +5 -0
- macos_apps_mcp/adapters/__init__.py +7 -0
- macos_apps_mcp/adapters/calendar.py +401 -0
- macos_apps_mcp/adapters/contacts.py +196 -0
- macos_apps_mcp/adapters/mail.py +479 -0
- macos_apps_mcp/adapters/messages.py +345 -0
- macos_apps_mcp/adapters/notes.py +544 -0
- macos_apps_mcp/adapters/photos.py +50 -0
- macos_apps_mcp/adapters/reminders.py +347 -0
- macos_apps_mcp/adapters/safari.py +82 -0
- macos_apps_mcp/adapters/shortcuts.py +205 -0
- macos_apps_mcp/contracts.py +323 -0
- macos_apps_mcp/doctor.py +237 -0
- macos_apps_mcp/runtime.py +920 -0
- macos_apps_mcp/server.py +602 -0
- macos_apps_mcp-0.6.0.dist-info/METADATA +171 -0
- macos_apps_mcp-0.6.0.dist-info/RECORD +21 -0
- macos_apps_mcp-0.6.0.dist-info/WHEEL +4 -0
- macos_apps_mcp-0.6.0.dist-info/entry_points.txt +2 -0
- macos_apps_mcp-0.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Per-app adapters — one module per macOS app.
|
|
2
|
+
|
|
3
|
+
Each module owns its access method (EventKit / AppleScript / osxphotos) behind the
|
|
4
|
+
``contracts.PointerSource`` Protocol (reads) plus its own typed write methods. An
|
|
5
|
+
adapter must not reach into another adapter. Adding an app = add a module here +
|
|
6
|
+
mount its tools in ``server.py``.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Calendar adapter — EventKit via PyObjC.
|
|
2
|
+
|
|
3
|
+
Reads return Pointers; writes take ``CalendarEventData``. All EventKit access goes
|
|
4
|
+
through ``runtime.run_native``; the store is owned by runtime (shared, not
|
|
5
|
+
reached-into).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
import EventKit as EK
|
|
13
|
+
|
|
14
|
+
from ..contracts import CalendarEventData, Pointer, parse_datetime
|
|
15
|
+
from ..runtime import (
|
|
16
|
+
SpanRequired,
|
|
17
|
+
VerificationFailed,
|
|
18
|
+
WriteRefused,
|
|
19
|
+
clean_summary,
|
|
20
|
+
epoch_nsdate,
|
|
21
|
+
from_nsdate,
|
|
22
|
+
norm_text,
|
|
23
|
+
persisted_recurrence_signature,
|
|
24
|
+
recurrence_signature,
|
|
25
|
+
resolve_container,
|
|
26
|
+
run_native,
|
|
27
|
+
store,
|
|
28
|
+
to_nsdate,
|
|
29
|
+
to_recurrence_rule,
|
|
30
|
+
verify_persisted,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _range(query: str) -> tuple[datetime, datetime]:
|
|
35
|
+
q = query.strip().lower()
|
|
36
|
+
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
37
|
+
if q == "today":
|
|
38
|
+
return today, today + timedelta(days=1)
|
|
39
|
+
if q == "week":
|
|
40
|
+
return today, today + timedelta(days=7)
|
|
41
|
+
day = parse_datetime(query.strip()).replace(
|
|
42
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
43
|
+
)
|
|
44
|
+
return day, day + timedelta(days=1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _event_summary(item) -> str:
|
|
48
|
+
start = from_nsdate(item.startDate())
|
|
49
|
+
if item.isAllDay():
|
|
50
|
+
return f"{item.title()} (all day {start:%Y-%m-%d})"
|
|
51
|
+
end = from_nsdate(item.endDate())
|
|
52
|
+
return f"{item.title()} {start:%H:%M}–{end:%H:%M}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _event_deeplink(item) -> str:
|
|
56
|
+
# calshow:<seconds-since-2001> opens Calendar to the event's day/time. macOS has no
|
|
57
|
+
# public scheme to open a *specific* event by id (x-apple-calevent:// is rejected;
|
|
58
|
+
# eventIdentifier isn't URL-addressable, and is occurrence-shared). See Apple Dev
|
|
59
|
+
# Forums #759266. Co-starting events thus share a deeplink; the Pointer summary +
|
|
60
|
+
# occurrence-precise id (see _event_id) disambiguate, not the link.
|
|
61
|
+
secs = int(item.startDate().timeIntervalSinceReferenceDate())
|
|
62
|
+
return f"calshow:{secs}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Recurring events share ONE calendarItemIdentifier across every occurrence, so the
|
|
66
|
+
# bare id can't name a single occurrence. Carry the occurrence start (epoch seconds) in
|
|
67
|
+
# the pointer id and re-fetch the concrete EKEvent on write (see _resolve_event), so
|
|
68
|
+
# EKSpanThisEvent targets THAT occurrence.
|
|
69
|
+
_OCC_SEP = "|"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _event_id(item) -> str:
|
|
73
|
+
base = item.calendarItemIdentifier()
|
|
74
|
+
epoch = int(item.startDate().timeIntervalSince1970())
|
|
75
|
+
return f"{base}{_OCC_SEP}{epoch}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _event_pointer(item) -> Pointer:
|
|
79
|
+
return Pointer(
|
|
80
|
+
id=_event_id(item),
|
|
81
|
+
summary=clean_summary(_event_summary(item)),
|
|
82
|
+
deeplink=_event_deeplink(item),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _calendar_pointer(cal) -> Pointer:
|
|
87
|
+
# A calendar (container) has no public per-calendar URL scheme; id + name (summary)
|
|
88
|
+
# are what a write resolves against — a write may target EITHER (#55). The title is
|
|
89
|
+
# kept RAW (NOT routed through clean_summary, unlike event summaries): the resolver
|
|
90
|
+
# still matches `c.title() == name` exactly for the name path, so the summary IS a
|
|
91
|
+
# write key — sanitizing it would desync the displayed name from the resolvable one
|
|
92
|
+
# and make the calendar name-untargetable (#52 review). deeplink empty by design.
|
|
93
|
+
return Pointer(id=cal.calendarIdentifier(), summary=cal.title(), deeplink="")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_calendar(s, name: str | None):
|
|
97
|
+
# Disambiguation rule (#55): accept a Pointer.id OR an exact name; an id is used
|
|
98
|
+
# directly, an ambiguous name is refused loudly (never auto-picked). The shared
|
|
99
|
+
# logic — including listing candidate ids — lives in runtime.resolve_container.
|
|
100
|
+
if name is None:
|
|
101
|
+
return s.defaultCalendarForNewEvents()
|
|
102
|
+
items = [
|
|
103
|
+
(c.calendarIdentifier(), c.title(), c)
|
|
104
|
+
for c in s.calendarsForEntityType_(EK.EKEntityTypeEvent)
|
|
105
|
+
]
|
|
106
|
+
return resolve_container(items, name, noun="calendar")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _all_day_bounds(start: datetime, end: datetime) -> tuple[datetime, datetime]:
|
|
110
|
+
"""Snap an all-day event's bounds to date-only midnight so a stored time can't drift
|
|
111
|
+
on CalDAV roundtrips. EventKit's all-day end date is INCLUSIVE (verified on-device:
|
|
112
|
+
the event covers start's day through end's day), so a same-day event keeps
|
|
113
|
+
``end == start`` as one day; only a reversed span clamps back to a single day.
|
|
114
|
+
|
|
115
|
+
An all-day event is a calendar date, not an instant — so tzinfo is dropped too:
|
|
116
|
+
that keeps date-only math well-defined and stops a mixed naive/aware (start, end)
|
|
117
|
+
pair from the tool boundary raising on the comparison below."""
|
|
118
|
+
floor = {"hour": 0, "minute": 0, "second": 0, "microsecond": 0, "tzinfo": None}
|
|
119
|
+
s, e = start.replace(**floor), end.replace(**floor)
|
|
120
|
+
if e < s: # reversed input only: clamp to a single day (end inclusive == start)
|
|
121
|
+
e = s
|
|
122
|
+
return s, e
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _apply_event(s, e, data: CalendarEventData) -> None:
|
|
126
|
+
e.setTitle_(data.title)
|
|
127
|
+
e.setAllDay_(data.all_day)
|
|
128
|
+
start, end = data.start, data.end
|
|
129
|
+
if data.all_day: # date-only bounds — EventKit/CalDAV must not see a stray time
|
|
130
|
+
start, end = _all_day_bounds(start, end)
|
|
131
|
+
e.setStartDate_(to_nsdate(start))
|
|
132
|
+
e.setEndDate_(to_nsdate(end))
|
|
133
|
+
e.setLocation_(data.location) # full-replace: None clears
|
|
134
|
+
e.setNotes_(data.notes) # full-replace: None clears
|
|
135
|
+
# Recurrence is the exception to full-replace: only SET it when provided. Clearing a
|
|
136
|
+
# series needs EKSpanFutureEvents (see _resolve_span), but an omitted recurrence
|
|
137
|
+
# means "edit this occurrence" (EKSpanThisEvent) — so clearing-on-None would detach
|
|
138
|
+
# one occurrence and leave the series recurring. Leave the rule untouched.
|
|
139
|
+
if data.recurrence is not None:
|
|
140
|
+
e.setRecurrenceRules_([to_recurrence_rule(data.recurrence)])
|
|
141
|
+
e.setCalendar_(_resolve_calendar(s, data.calendar))
|
|
142
|
+
# ponytail: all-day alarm 1440-gotcha (#51) — EKAlarm relativeOffset for an all-day
|
|
143
|
+
# event is measured from MIDNIGHT, so "9am the day before" is -1440+540 = -900 min,
|
|
144
|
+
# NOT -900 from an implicit 9am. We set no alarms yet (no alarm field on
|
|
145
|
+
# CalendarEventData), so nothing can go wrong; wire the -1440 base in with the alarm
|
|
146
|
+
# field, and test it then.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# EKSpanThisEvent == 0 (falsy!), EKSpanFutureEvents == 1 — so map via membership, never
|
|
150
|
+
# a truthy test.
|
|
151
|
+
_SPANS = {
|
|
152
|
+
"this-event": EK.EKSpanThisEvent,
|
|
153
|
+
"future-events": EK.EKSpanFutureEvents,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_span(e, span: str | None, *, adds_recurrence: bool = False) -> int:
|
|
158
|
+
"""The EKSpan for an update/delete — requiring an explicit choice for a recurring
|
|
159
|
+
target (#51).
|
|
160
|
+
|
|
161
|
+
Editing/deleting one occurrence vs rewriting the whole series is destructive to get
|
|
162
|
+
wrong (mcp-ical silently rewrote users' series with a hardcoded EKSpanFutureEvents),
|
|
163
|
+
so a **recurring** event demands an explicit ``span``; a **single** event ignores it
|
|
164
|
+
(span is moot — EventKit has no other occurrences to span). Adding a rule to a
|
|
165
|
+
single event is inherently series-defining, so it saves future-events.
|
|
166
|
+
"""
|
|
167
|
+
if not e.recurrenceRules(): # single event — span is moot
|
|
168
|
+
return EK.EKSpanFutureEvents if adds_recurrence else EK.EKSpanThisEvent
|
|
169
|
+
if span is None:
|
|
170
|
+
raise SpanRequired(
|
|
171
|
+
"This is a recurring event — specify span='this-event' (only this "
|
|
172
|
+
"occurrence) or span='future-events' (this and all later), then retry. "
|
|
173
|
+
"No change was made."
|
|
174
|
+
)
|
|
175
|
+
if span not in _SPANS:
|
|
176
|
+
raise SpanRequired(
|
|
177
|
+
"span must be 'this-event' or 'future-events' for a recurring event; got "
|
|
178
|
+
f"{span!r}. No change was made."
|
|
179
|
+
)
|
|
180
|
+
if adds_recurrence and _SPANS[span] == EK.EKSpanThisEvent:
|
|
181
|
+
raise SpanRequired(
|
|
182
|
+
"a recurrence change rewrites the series, so span='this-event' cannot "
|
|
183
|
+
"apply it — use span='future-events', or omit recurrence to edit only "
|
|
184
|
+
"this occurrence. No change was made."
|
|
185
|
+
)
|
|
186
|
+
return _SPANS[span]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_event(s, ident: str):
|
|
190
|
+
"""Resolve a pointer id to the concrete EKEvent (specific occurrence if recurring).
|
|
191
|
+
|
|
192
|
+
Pointer ids are ``<calendarItemIdentifier>|<occurrence-start-epoch>``.
|
|
193
|
+
``calendarItemWithIdentifier_`` returns the series *master* (shared across
|
|
194
|
+
occurrences), so editing/deleting it with EKSpanThisEvent hits the wrong occurrence.
|
|
195
|
+
Re-fetch via a tight date-range predicate and match on (calendarItemIdentifier,
|
|
196
|
+
start) so the write targets exactly the cited occurrence.
|
|
197
|
+
"""
|
|
198
|
+
base, sep, occ = ident.rpartition(_OCC_SEP)
|
|
199
|
+
if (
|
|
200
|
+
not sep
|
|
201
|
+
): # legacy/plain id (no occurrence suffix) — fall back to the master lookup
|
|
202
|
+
e = s.calendarItemWithIdentifier_(ident)
|
|
203
|
+
if e is None:
|
|
204
|
+
raise ValueError(f"no event with id {ident!r}")
|
|
205
|
+
return e
|
|
206
|
+
occ_epoch = int(occ)
|
|
207
|
+
# ±1s window built straight from the epoch: datetime±timedelta resets the PEP-495
|
|
208
|
+
# fold, shifting DST-repeated-hour instants by 1h — epoch_nsdate is fold-proof.
|
|
209
|
+
pred = s.predicateForEventsWithStartDate_endDate_calendars_(
|
|
210
|
+
epoch_nsdate(occ_epoch - 1),
|
|
211
|
+
epoch_nsdate(occ_epoch + 1),
|
|
212
|
+
None,
|
|
213
|
+
)
|
|
214
|
+
for e in s.eventsMatchingPredicate_(pred) or []:
|
|
215
|
+
if (
|
|
216
|
+
e.calendarItemIdentifier() == base
|
|
217
|
+
and int(e.startDate().timeIntervalSince1970()) == occ_epoch
|
|
218
|
+
):
|
|
219
|
+
return e
|
|
220
|
+
raise ValueError(f"no event occurrence for id {ident!r}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _verify_event(fresh, ident: str, data: CalendarEventData, cal_id: str) -> None:
|
|
224
|
+
"""Re-fetch-by-id verify (#49): fail loudly if the saved event didn't persist as
|
|
225
|
+
requested. `fresh` is a fresh re-resolve of the occurrence we're about to return
|
|
226
|
+
(from _refetch_event — never None; a missed re-fetch raised there); `cal_id` is the
|
|
227
|
+
requested calendar's identifier (of the calendar _apply_event set).
|
|
228
|
+
|
|
229
|
+
The calendar is verified by IDENTIFIER, not title (#55 review): with id-targeting a
|
|
230
|
+
write can name a SPECIFIC one of several same-named calendars, so a title compare
|
|
231
|
+
would falsely pass if the store re-homed the event to a different calendar that
|
|
232
|
+
happens to share the name — exactly the re-home this #49 guard exists to catch."""
|
|
233
|
+
# free text through norm_text: NFC/NFD + CRLF folds are the store normalizing, not
|
|
234
|
+
# a dropped field; "" and None both mean "unset" (norm_text folds "" → None).
|
|
235
|
+
expected = {
|
|
236
|
+
"title": norm_text(data.title),
|
|
237
|
+
"all_day": data.all_day,
|
|
238
|
+
"location": norm_text(data.location),
|
|
239
|
+
"notes": norm_text(data.notes),
|
|
240
|
+
"calendar": cal_id, # opaque UUID handle — compared raw, not norm_text
|
|
241
|
+
}
|
|
242
|
+
actual = {
|
|
243
|
+
"title": norm_text(fresh.title()),
|
|
244
|
+
"all_day": bool(fresh.isAllDay()),
|
|
245
|
+
"location": norm_text(fresh.location()),
|
|
246
|
+
"notes": norm_text(fresh.notes()),
|
|
247
|
+
"calendar": fresh.calendar().calendarIdentifier(),
|
|
248
|
+
}
|
|
249
|
+
fresh_start = from_nsdate(fresh.startDate())
|
|
250
|
+
if data.all_day:
|
|
251
|
+
# EventKit's internal all-day END representation is ambiguous (inclusive vs
|
|
252
|
+
# stored-as-next-midnight) — an end-epoch check would false-fail every all-day
|
|
253
|
+
# event, so verify the start *date* + the flag only. End stays on-device
|
|
254
|
+
# calibration (integration), like the deeplinks.
|
|
255
|
+
s_exp, _ = _all_day_bounds(data.start, data.end)
|
|
256
|
+
expected["start_date"] = (s_exp.year, s_exp.month, s_exp.day)
|
|
257
|
+
actual["start_date"] = (fresh_start.year, fresh_start.month, fresh_start.day)
|
|
258
|
+
else:
|
|
259
|
+
expected["start"] = int(data.start.timestamp())
|
|
260
|
+
expected["end"] = int(data.end.timestamp())
|
|
261
|
+
actual["start"] = int(fresh_start.timestamp())
|
|
262
|
+
actual["end"] = int(from_nsdate(fresh.endDate()).timestamp())
|
|
263
|
+
if data.recurrence is not None: # None = "leave series untouched" (_apply_event)
|
|
264
|
+
# verify the exact cadence, not just presence — a wrong-frequency series is a
|
|
265
|
+
# changed field #49 must name (UNTIL deferred; see recurrence_signature).
|
|
266
|
+
expected["recurs"] = recurrence_signature(data.recurrence)
|
|
267
|
+
actual["recurs"] = persisted_recurrence_signature(fresh.recurrenceRules())
|
|
268
|
+
verify_persisted("event", expected, actual)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _refetch_event(s, ident: str):
|
|
272
|
+
"""Re-resolve the occurrence by id after a write; a not-found is a persistence
|
|
273
|
+
failure (fabricated id / rollback), not a plain lookup miss."""
|
|
274
|
+
try:
|
|
275
|
+
return _resolve_event(s, ident)
|
|
276
|
+
except ValueError as e:
|
|
277
|
+
raise VerificationFailed(
|
|
278
|
+
f"event {ident!r} could not be re-fetched after save — the write did not "
|
|
279
|
+
"persist (a fabricated id or an iCloud rollback). Do not trust the id."
|
|
280
|
+
) from e
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CalendarAdapter:
|
|
284
|
+
def get_pointers(self, query: str) -> list[Pointer]:
|
|
285
|
+
"""query: 'today' | 'week' | 'YYYY-MM-DD'."""
|
|
286
|
+
|
|
287
|
+
def work():
|
|
288
|
+
s = store()
|
|
289
|
+
start, end = _range(query)
|
|
290
|
+
pred = s.predicateForEventsWithStartDate_endDate_calendars_(
|
|
291
|
+
to_nsdate(start), to_nsdate(end), None
|
|
292
|
+
)
|
|
293
|
+
return [_event_pointer(e) for e in (s.eventsMatchingPredicate_(pred) or [])]
|
|
294
|
+
|
|
295
|
+
return run_native(work)
|
|
296
|
+
|
|
297
|
+
def get_calendars(self) -> list[Pointer]:
|
|
298
|
+
"""Enumerate calendars as Pointers (id + name) for resolving write targets."""
|
|
299
|
+
|
|
300
|
+
def work():
|
|
301
|
+
s = store()
|
|
302
|
+
return [
|
|
303
|
+
_calendar_pointer(c)
|
|
304
|
+
for c in s.calendarsForEntityType_(EK.EKEntityTypeEvent)
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
return run_native(work)
|
|
308
|
+
|
|
309
|
+
def create_event(self, data: CalendarEventData) -> Pointer:
|
|
310
|
+
def work():
|
|
311
|
+
s = store()
|
|
312
|
+
e = EK.EKEvent.eventWithEventStore_(s)
|
|
313
|
+
# A fresh event has no rules → single-event path; a create defining a series
|
|
314
|
+
# saves future-events. No span param — create is never an ambiguous edit.
|
|
315
|
+
span = _resolve_span(e, None, adds_recurrence=data.recurrence is not None)
|
|
316
|
+
_apply_event(s, e, data)
|
|
317
|
+
# read before save — the commit may re-home the object, and post-save it
|
|
318
|
+
# would tautologically equal the actual. cal may be nil (no writable
|
|
319
|
+
# calendar account); let the save below surface the WriteRefused. Capture
|
|
320
|
+
# the IDENTIFIER (not title) — that's what verify keys on (#55 review).
|
|
321
|
+
cal = e.calendar()
|
|
322
|
+
cal_id = cal.calendarIdentifier() if cal is not None else None
|
|
323
|
+
ok, err = s.saveEvent_span_commit_error_(e, span, True, None)
|
|
324
|
+
if not ok:
|
|
325
|
+
raise WriteRefused(
|
|
326
|
+
f"the event write was refused by the store: {err}. The target "
|
|
327
|
+
"calendar may be read-only (a subscribed calendar) or the account "
|
|
328
|
+
"rejected the change — do not retry the same target; tell the "
|
|
329
|
+
"user."
|
|
330
|
+
)
|
|
331
|
+
# Re-resolve by the occurrence id we'll return — never trust the in-memory
|
|
332
|
+
# event (#49): prove the id resolves and the fields persisted.
|
|
333
|
+
ident = _event_id(e)
|
|
334
|
+
fresh = _refetch_event(s, ident)
|
|
335
|
+
_verify_event(fresh, ident, data, cal_id)
|
|
336
|
+
return _event_pointer(fresh)
|
|
337
|
+
|
|
338
|
+
return run_native(work)
|
|
339
|
+
|
|
340
|
+
def update_event(
|
|
341
|
+
self, ident: str, data: CalendarEventData, span: str | None = None
|
|
342
|
+
) -> Pointer:
|
|
343
|
+
def work():
|
|
344
|
+
s = store()
|
|
345
|
+
e = _resolve_event(s, ident)
|
|
346
|
+
# Resolve span BEFORE saving: a recurring target with no span raises here,
|
|
347
|
+
# so no write happens (#51). Checks the ORIGINAL event's rules, so a
|
|
348
|
+
# single→recurring conversion is series-defining, not ambiguous.
|
|
349
|
+
ek_span = _resolve_span(
|
|
350
|
+
e, span, adds_recurrence=data.recurrence is not None
|
|
351
|
+
)
|
|
352
|
+
_apply_event(s, e, data)
|
|
353
|
+
# read before save — the commit may re-home the object, and post-save it
|
|
354
|
+
# would tautologically equal the actual. cal may be nil (no writable
|
|
355
|
+
# calendar account); let the save below surface the WriteRefused. Capture
|
|
356
|
+
# the IDENTIFIER (not title) — that's what verify keys on (#55 review).
|
|
357
|
+
cal = e.calendar()
|
|
358
|
+
cal_id = cal.calendarIdentifier() if cal is not None else None
|
|
359
|
+
ok, err = s.saveEvent_span_commit_error_(e, ek_span, True, None)
|
|
360
|
+
if not ok:
|
|
361
|
+
raise WriteRefused(
|
|
362
|
+
f"the event write was refused by the store: {err}. The target "
|
|
363
|
+
"calendar may be read-only (a subscribed calendar) or the account "
|
|
364
|
+
"rejected the change — do not retry the same target; tell the "
|
|
365
|
+
"user."
|
|
366
|
+
)
|
|
367
|
+
# Re-key from the post-apply event: if start changed, _event_id(e) carries
|
|
368
|
+
# the new occurrence epoch, so we re-resolve (and cite) it as persisted.
|
|
369
|
+
ident_after = _event_id(e)
|
|
370
|
+
fresh = _refetch_event(s, ident_after)
|
|
371
|
+
_verify_event(fresh, ident_after, data, cal_id)
|
|
372
|
+
return _event_pointer(fresh)
|
|
373
|
+
|
|
374
|
+
return run_native(work)
|
|
375
|
+
|
|
376
|
+
def delete_event(
|
|
377
|
+
self, ident: str, span: str | None = None, dry_run: bool = False
|
|
378
|
+
) -> Pointer | None:
|
|
379
|
+
"""Delete an event by id. ``dry_run=True`` resolves the target — and its span,
|
|
380
|
+
so a recurring event still surfaces ``SpanRequired`` exactly as the real delete
|
|
381
|
+
would — then returns the pointer that WOULD be deleted, no mutation (#54)."""
|
|
382
|
+
|
|
383
|
+
def work():
|
|
384
|
+
s = store()
|
|
385
|
+
e = _resolve_event(s, ident)
|
|
386
|
+
ek_span = _resolve_span(
|
|
387
|
+
e, span
|
|
388
|
+
) # recurring + no span → SpanRequired, no write
|
|
389
|
+
if dry_run:
|
|
390
|
+
return _event_pointer(e) # preview only — nothing is removed
|
|
391
|
+
ok, err = s.removeEvent_span_commit_error_(e, ek_span, True, None)
|
|
392
|
+
if not ok:
|
|
393
|
+
raise WriteRefused(
|
|
394
|
+
f"the event delete was refused by the store: {err}. The target "
|
|
395
|
+
"calendar may be read-only (a subscribed calendar) or the account "
|
|
396
|
+
"rejected the change — do not retry the same target; tell the "
|
|
397
|
+
"user."
|
|
398
|
+
)
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
return run_native(work)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Contacts adapter — Contacts.app via AppleScript/osascript (Automation TCC).
|
|
2
|
+
|
|
3
|
+
Reads return Pointers; writes take ``ContactData``. Uses the osascript escape hatch
|
|
4
|
+
(``runtime.run_osascript``), NOT the Contacts framework: a non-bundled server can't get
|
|
5
|
+
``CNContactStore`` TCC (no usage-description bundle), but Automation TCC — scripting
|
|
6
|
+
Contacts.app — works, attributed to the responsible host app. See issue #15.
|
|
7
|
+
|
|
8
|
+
User input is passed as ``osascript`` argv (``on run argv``), not interpolated into
|
|
9
|
+
the script, so a name or id can't break out of the AppleScript.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..contracts import ContactData, Pointer
|
|
15
|
+
from ..runtime import (
|
|
16
|
+
VerificationFailed,
|
|
17
|
+
clean_summary,
|
|
18
|
+
norm_text,
|
|
19
|
+
run_osascript,
|
|
20
|
+
verify_persisted,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
MAX_CONTACTS = 50 # cap a broad name match
|
|
24
|
+
|
|
25
|
+
# Field/record delimiters for the osascript payload: control chars instead of
|
|
26
|
+
# tab/newline, so a tab or newline *inside* a field can't split or spoof a row. _parse
|
|
27
|
+
# splits on these; the AppleScript emits them via `character id`.
|
|
28
|
+
# ponytail: US/RS are effectively absent from your own Contacts (trusted input), so this
|
|
29
|
+
# is treated as an invariant, not sanitized. A field carrying a literal 0x1f/0x1e (e.g.
|
|
30
|
+
# an adversarial vCard import) would still mis-split — strip them if seen in the wild.
|
|
31
|
+
_FIELD = "\x1f" # ASCII Unit Separator — between fields
|
|
32
|
+
_RECORD = "\x1e" # ASCII Record Separator — between people
|
|
33
|
+
|
|
34
|
+
# Each matching person -> "id|name|org|phone|email" (US-delimited, RS-terminated). Unset
|
|
35
|
+
# fields (`missing value` / no phones / no emails) normalize to "". Phone/email are the
|
|
36
|
+
# first of each — a citable handle to *reach* the person, not the full card.
|
|
37
|
+
# maxN bounds the work IN AppleScript: stop after maxN matches so a broad query ("a")
|
|
38
|
+
# doesn't fetch phone/email for thousands of people just to have Python drop all but the
|
|
39
|
+
# first MAX_CONTACTS. (The `whose name contains` scan itself is one cheap Apple Events
|
|
40
|
+
# call; the per-person property fetches below are the expensive part this `exit repeat`
|
|
41
|
+
# caps.) The cap is passed via argv, not interpolated.
|
|
42
|
+
_SEARCH = """on run argv
|
|
43
|
+
set q to item 1 of argv
|
|
44
|
+
set maxN to (item 2 of argv) as integer
|
|
45
|
+
set uSep to character id 31
|
|
46
|
+
set rSep to character id 30
|
|
47
|
+
set out to ""
|
|
48
|
+
set n to 0
|
|
49
|
+
with timeout of 120 seconds
|
|
50
|
+
tell application "Contacts"
|
|
51
|
+
repeat with p in (people whose name contains q)
|
|
52
|
+
set theOrg to organization of p
|
|
53
|
+
if theOrg is missing value then set theOrg to ""
|
|
54
|
+
set thePhone to ""
|
|
55
|
+
if (count of phones of p) > 0 then set thePhone to value of item 1 of phones of p
|
|
56
|
+
set theEmail to ""
|
|
57
|
+
if (count of emails of p) > 0 then set theEmail to value of item 1 of emails of p
|
|
58
|
+
set out to out & (id of p) & uSep & (name of p) & uSep & theOrg
|
|
59
|
+
set out to out & uSep & thePhone & uSep & theEmail & rSep
|
|
60
|
+
set n to n + 1
|
|
61
|
+
if n >= maxN then exit repeat
|
|
62
|
+
end repeat
|
|
63
|
+
end tell
|
|
64
|
+
end timeout
|
|
65
|
+
return out
|
|
66
|
+
end run"""
|
|
67
|
+
|
|
68
|
+
_CREATE = """on run argv
|
|
69
|
+
set fn to item 1 of argv
|
|
70
|
+
set ln to item 2 of argv
|
|
71
|
+
set org to item 3 of argv
|
|
72
|
+
with timeout of 120 seconds
|
|
73
|
+
tell application "Contacts"
|
|
74
|
+
set p to make new person with properties {first name:fn}
|
|
75
|
+
if ln is not "" then set last name of p to ln
|
|
76
|
+
if org is not "" then set organization of p to org
|
|
77
|
+
save
|
|
78
|
+
return id of p
|
|
79
|
+
end tell
|
|
80
|
+
end timeout
|
|
81
|
+
end run"""
|
|
82
|
+
|
|
83
|
+
# Re-read a person by the id we're about to return (#49) — proves the create persisted
|
|
84
|
+
# and didn't silently drop a field. `person id` is the direct O(1) specifier (the form
|
|
85
|
+
# notes.py already uses); a whose-filter would scan the whole address book. Emits
|
|
86
|
+
# "firstname|lastname|org" (US-delimited); an empty return means the id resolves to
|
|
87
|
+
# nothing (fabricated / not saved). id via argv.
|
|
88
|
+
_VERIFY = """on run argv
|
|
89
|
+
set pid to item 1 of argv
|
|
90
|
+
set uSep to character id 31
|
|
91
|
+
with timeout of 120 seconds
|
|
92
|
+
tell application "Contacts"
|
|
93
|
+
try
|
|
94
|
+
set p to person id pid
|
|
95
|
+
on error
|
|
96
|
+
return ""
|
|
97
|
+
end try
|
|
98
|
+
set fn to first name of p
|
|
99
|
+
if fn is missing value then set fn to ""
|
|
100
|
+
set ln to last name of p
|
|
101
|
+
if ln is missing value then set ln to ""
|
|
102
|
+
set org to organization of p
|
|
103
|
+
if org is missing value then set org to ""
|
|
104
|
+
return fn & uSep & ln & uSep & org
|
|
105
|
+
end tell
|
|
106
|
+
end timeout
|
|
107
|
+
end run"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _verify_contact(raw: str, ident: str, data: ContactData) -> None:
|
|
111
|
+
"""Re-query verify (#49): fail loudly if the created contact can't be re-read or a
|
|
112
|
+
requested field didn't persist. `raw` is the ``_VERIFY`` payload for ``ident``."""
|
|
113
|
+
# run_osascript already strips osascript's single terminating newline, so `raw` is
|
|
114
|
+
# the payload as-is — never str.strip() it: the \x1f/\x1e separators are Unicode
|
|
115
|
+
# whitespace, so strip() would eat a leading empty given_name's delimiter and shift
|
|
116
|
+
# every field left. _VERIFY returns "" (empty) for a not-found id.
|
|
117
|
+
if not raw:
|
|
118
|
+
raise VerificationFailed(
|
|
119
|
+
f"contact {ident!r} could not be re-read after save — the write did not "
|
|
120
|
+
"persist (a fabricated id or an unsaved record). Do not trust the id."
|
|
121
|
+
)
|
|
122
|
+
parts = raw.split(_FIELD)
|
|
123
|
+
fn = parts[0] if parts else ""
|
|
124
|
+
ln = parts[1] if len(parts) > 1 else ""
|
|
125
|
+
org = parts[2] if len(parts) > 2 else ""
|
|
126
|
+
expected = {
|
|
127
|
+
# norm_text folds "" ↔ None on BOTH sides (an org-only contact has an empty
|
|
128
|
+
# given_name) and NFC/LF-normalizes, so a correct save can't false-fail (#49).
|
|
129
|
+
"given_name": norm_text(data.given_name),
|
|
130
|
+
"family_name": norm_text(data.family_name),
|
|
131
|
+
"organization": norm_text(data.organization),
|
|
132
|
+
}
|
|
133
|
+
actual = {
|
|
134
|
+
"given_name": norm_text(fn),
|
|
135
|
+
"family_name": norm_text(ln),
|
|
136
|
+
"organization": norm_text(org),
|
|
137
|
+
}
|
|
138
|
+
verify_persisted("contact", expected, actual)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _summary(name: str, org: str, phone: str = "", email: str = "") -> str:
|
|
142
|
+
name, org = name.strip(), org.strip()
|
|
143
|
+
head = f"{name} — {org}" if (name and org) else (name or org)
|
|
144
|
+
contact = " · ".join(x for x in (phone.strip(), email.strip()) if x)
|
|
145
|
+
if head and contact:
|
|
146
|
+
return f"{head} · {contact}"
|
|
147
|
+
return head or contact or "(unnamed contact)"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _deeplink(ident: str) -> str:
|
|
151
|
+
# addressbook://<id> opens Contacts to the card (best-effort; verify on-device).
|
|
152
|
+
return f"addressbook://{ident}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse(raw: str) -> list[Pointer]:
|
|
156
|
+
out = []
|
|
157
|
+
for rec in raw.split(_RECORD):
|
|
158
|
+
if not rec.strip():
|
|
159
|
+
continue
|
|
160
|
+
parts = rec.split(_FIELD)
|
|
161
|
+
ident = parts[0]
|
|
162
|
+
name = parts[1] if len(parts) > 1 else ""
|
|
163
|
+
org = parts[2] if len(parts) > 2 else ""
|
|
164
|
+
phone = parts[3] if len(parts) > 3 else ""
|
|
165
|
+
email = parts[4] if len(parts) > 4 else ""
|
|
166
|
+
out.append(
|
|
167
|
+
Pointer(
|
|
168
|
+
id=ident,
|
|
169
|
+
summary=clean_summary(_summary(name, org, phone, email)),
|
|
170
|
+
deeplink=_deeplink(ident),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ContactsAdapter:
|
|
177
|
+
def get_pointers(self, query: str) -> list[Pointer]:
|
|
178
|
+
"""query: a name (substring) to match, e.g. 'jane' or 'Jane Doe'."""
|
|
179
|
+
name = query.strip()
|
|
180
|
+
if not name:
|
|
181
|
+
raise ValueError("contacts read needs a name to match (got an empty query)")
|
|
182
|
+
# AppleScript caps at MAX_CONTACTS; the slice is a cheap backstop on its output.
|
|
183
|
+
return _parse(run_osascript(_SEARCH, name, str(MAX_CONTACTS)))[:MAX_CONTACTS]
|
|
184
|
+
|
|
185
|
+
def create_contact(self, data: ContactData) -> Pointer:
|
|
186
|
+
ident = run_osascript(
|
|
187
|
+
_CREATE, data.given_name, data.family_name or "", data.organization or ""
|
|
188
|
+
).strip()
|
|
189
|
+
# Re-read by the id we're about to return — never trust the create's echo (#49).
|
|
190
|
+
_verify_contact(run_osascript(_VERIFY, ident), ident, data)
|
|
191
|
+
full = f"{data.given_name} {data.family_name or ''}"
|
|
192
|
+
return Pointer(
|
|
193
|
+
id=ident,
|
|
194
|
+
summary=clean_summary(_summary(full, data.organization or "")),
|
|
195
|
+
deeplink=_deeplink(ident),
|
|
196
|
+
)
|