Initial commit — Apple Apps MCP server with native macOS config app
Python MCP server (FastMCP) providing Claude Code/CoWork access to Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps via AppleScript. Includes a native SwiftUI config/installer app and a compiled EventKit helper for fast reminder queries on large databases (8,000+ items). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""Apple Calendar tools."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from helpers import run_applescript, safe_applescript_string, parse_applescript_date, today_str
|
||||
|
||||
|
||||
def list_calendars() -> list[dict]:
|
||||
"""List all calendars."""
|
||||
script = '''
|
||||
tell application "Calendar"
|
||||
set output to ""
|
||||
repeat with c in calendars
|
||||
set cName to name of c
|
||||
set cDesc to ""
|
||||
try
|
||||
set cDesc to description of c
|
||||
end try
|
||||
set output to output & cName & "|||" & cDesc & linefeed
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
results.append({
|
||||
"name": parts[0].strip(),
|
||||
"description": parts[1].strip() if len(parts) > 1 else "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_events(start_date: str | None = None, end_date: str | None = None,
|
||||
calendar_name: str | None = None, days: int = 7) -> list[dict]:
|
||||
"""Get calendar events in a date range.
|
||||
|
||||
Args:
|
||||
start_date: ISO date (YYYY-MM-DD). Defaults to today.
|
||||
end_date: ISO date (YYYY-MM-DD). Defaults to start_date + days.
|
||||
calendar_name: Filter to a specific calendar.
|
||||
days: Number of days to look ahead (used if end_date not provided).
|
||||
"""
|
||||
if not start_date:
|
||||
start_date = today_str()
|
||||
if not end_date:
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = start_dt + timedelta(days=days)
|
||||
end_date = end_dt.strftime("%Y-%m-%d")
|
||||
|
||||
cal_filter = ""
|
||||
if calendar_name:
|
||||
safe_cal = safe_applescript_string(calendar_name)
|
||||
cal_filter = f'set cals to {{calendar "{safe_cal}"}}'
|
||||
else:
|
||||
cal_filter = "set cals to calendars"
|
||||
|
||||
script = f'''
|
||||
tell application "Calendar"
|
||||
{cal_filter}
|
||||
set startD to current date
|
||||
set year of startD to {start_date[:4]}
|
||||
set month of startD to {int(start_date[5:7])}
|
||||
set day of startD to {int(start_date[8:10])}
|
||||
set hours of startD to 0
|
||||
set minutes of startD to 0
|
||||
set seconds of startD to 0
|
||||
|
||||
set endD to current date
|
||||
set year of endD to {end_date[:4]}
|
||||
set month of endD to {int(end_date[5:7])}
|
||||
set day of endD to {int(end_date[8:10])}
|
||||
set hours of endD to 23
|
||||
set minutes of endD to 59
|
||||
set seconds of endD to 59
|
||||
|
||||
set output to ""
|
||||
repeat with c in cals
|
||||
set calName to name of c
|
||||
set evts to (every event of c whose start date >= startD and start date <= endD)
|
||||
repeat with e in evts
|
||||
set eSummary to summary of e
|
||||
set eStart to (start date of e) as string
|
||||
set eEnd to (end date of e) as string
|
||||
set eLoc to ""
|
||||
try
|
||||
set eLoc to location of e
|
||||
end try
|
||||
set eAllDay to allday event of e as string
|
||||
set eNotes to ""
|
||||
try
|
||||
set eNotes to description of e
|
||||
on error
|
||||
set eNotes to ""
|
||||
end try
|
||||
set eId to uid of e
|
||||
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eLoc & "|||" & eAllDay & "|||" & eNotes & "|||" & eId & linefeed
|
||||
end repeat
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=60)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
name_part = parts[0]
|
||||
cal = None
|
||||
if ">>>" in name_part:
|
||||
cal, name_part = name_part.split(">>>", 1)
|
||||
results.append({
|
||||
"summary": name_part.strip(),
|
||||
"calendar": cal.strip() if cal else None,
|
||||
"start": parse_applescript_date(parts[1].strip()),
|
||||
"end": parse_applescript_date(parts[2].strip()),
|
||||
"location": parts[3].strip() or None,
|
||||
"all_day": parts[4].strip() == "true",
|
||||
"notes": parts[5].strip() or None,
|
||||
"id": parts[6].strip(),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def search_events(query: str, days_back: int = 30, days_forward: int = 30) -> list[dict]:
|
||||
"""Search for events by summary text."""
|
||||
start_dt = datetime.now() - timedelta(days=days_back)
|
||||
end_dt = datetime.now() + timedelta(days=days_forward)
|
||||
start_date = start_dt.strftime("%Y-%m-%d")
|
||||
end_date = end_dt.strftime("%Y-%m-%d")
|
||||
|
||||
safe_query = safe_applescript_string(query.lower())
|
||||
script = f'''
|
||||
tell application "Calendar"
|
||||
set startD to current date
|
||||
set year of startD to {start_date[:4]}
|
||||
set month of startD to {int(start_date[5:7])}
|
||||
set day of startD to {int(start_date[8:10])}
|
||||
set hours of startD to 0
|
||||
set minutes of startD to 0
|
||||
set seconds of startD to 0
|
||||
|
||||
set endD to current date
|
||||
set year of endD to {end_date[:4]}
|
||||
set month of endD to {int(end_date[5:7])}
|
||||
set day of endD to {int(end_date[8:10])}
|
||||
set hours of endD to 23
|
||||
set minutes of endD to 59
|
||||
set seconds of endD to 59
|
||||
|
||||
set output to ""
|
||||
repeat with c in calendars
|
||||
set calName to name of c
|
||||
set evts to (every event of c whose start date >= startD and start date <= endD)
|
||||
repeat with e in evts
|
||||
set eSummary to summary of e
|
||||
if eSummary contains "{safe_query}" then
|
||||
set eStart to (start date of e) as string
|
||||
set eEnd to (end date of e) as string
|
||||
set eId to uid of e
|
||||
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eId & linefeed
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=60)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name_part = parts[0]
|
||||
cal = None
|
||||
if ">>>" in name_part:
|
||||
cal, name_part = name_part.split(">>>", 1)
|
||||
results.append({
|
||||
"summary": name_part.strip(),
|
||||
"calendar": cal.strip() if cal else None,
|
||||
"start": parse_applescript_date(parts[1].strip()),
|
||||
"end": parse_applescript_date(parts[2].strip()),
|
||||
"id": parts[3].strip(),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def create_event(summary: str, start_date: str, end_date: str | None = None,
|
||||
calendar_name: str | None = None, location: str | None = None,
|
||||
notes: str | None = None, all_day: bool = False) -> dict:
|
||||
"""Create a calendar event.
|
||||
|
||||
Args:
|
||||
summary: Event title.
|
||||
start_date: ISO datetime (e.g., 2026-04-15T10:00:00).
|
||||
end_date: ISO datetime. Defaults to 1 hour after start.
|
||||
calendar_name: Which calendar. Defaults to first calendar.
|
||||
location: Event location.
|
||||
notes: Event notes/description.
|
||||
all_day: Whether this is an all-day event.
|
||||
"""
|
||||
if not end_date:
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = start_dt + timedelta(hours=1)
|
||||
end_date = end_dt.isoformat()
|
||||
|
||||
start_dt = datetime.fromisoformat(start_date)
|
||||
end_dt = datetime.fromisoformat(end_date)
|
||||
|
||||
safe_summary = safe_applescript_string(summary)
|
||||
cal_line = f'set theCal to calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "set theCal to first calendar"
|
||||
|
||||
loc_line = ""
|
||||
if location:
|
||||
loc_line = f'set location of newEvent to "{safe_applescript_string(location)}"'
|
||||
|
||||
notes_line = ""
|
||||
if notes:
|
||||
notes_line = f'set description of newEvent to "{safe_applescript_string(notes)}"'
|
||||
|
||||
script = f'''
|
||||
tell application "Calendar"
|
||||
{cal_line}
|
||||
set startD to current date
|
||||
set year of startD to {start_dt.year}
|
||||
set month of startD to {start_dt.month}
|
||||
set day of startD to {start_dt.day}
|
||||
set hours of startD to {start_dt.hour}
|
||||
set minutes of startD to {start_dt.minute}
|
||||
set seconds of startD to 0
|
||||
|
||||
set endD to current date
|
||||
set year of endD to {end_dt.year}
|
||||
set month of endD to {end_dt.month}
|
||||
set day of endD to {end_dt.day}
|
||||
set hours of endD to {end_dt.hour}
|
||||
set minutes of endD to {end_dt.minute}
|
||||
set seconds of endD to 0
|
||||
|
||||
set newEvent to make new event at end of events of theCal with properties {{summary:"{safe_summary}", start date:startD, end date:endD, allday event:{str(all_day).lower()}}}
|
||||
{loc_line}
|
||||
{notes_line}
|
||||
return uid of newEvent
|
||||
end tell'''
|
||||
event_id = run_applescript(script)
|
||||
return {"id": event_id.strip(), "summary": summary, "start": start_date, "end": end_date, "created": True}
|
||||
|
||||
|
||||
def delete_event(event_summary: str, event_date: str | None = None, calendar_name: str | None = None) -> dict:
|
||||
"""Delete a calendar event by summary (and optionally date)."""
|
||||
safe_summary = safe_applescript_string(event_summary)
|
||||
cal_source = f'calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "first calendar"
|
||||
|
||||
if event_date:
|
||||
dt = datetime.fromisoformat(event_date)
|
||||
date_filter = f'''
|
||||
set targetD to current date
|
||||
set year of targetD to {dt.year}
|
||||
set month of targetD to {dt.month}
|
||||
set day of targetD to {dt.day}
|
||||
set hours of targetD to 0
|
||||
set minutes of targetD to 0
|
||||
set seconds of targetD to 0
|
||||
set nextD to targetD + (1 * days)
|
||||
set evts to (every event of theCal whose summary is "{safe_summary}" and start date >= targetD and start date < nextD)'''
|
||||
else:
|
||||
date_filter = f'set evts to (every event of theCal whose summary is "{safe_summary}")'
|
||||
|
||||
script = f'''
|
||||
tell application "Calendar"
|
||||
set theCal to {cal_source}
|
||||
{date_filter}
|
||||
if (count of evts) > 0 then
|
||||
delete item 1 of evts
|
||||
return "deleted"
|
||||
else
|
||||
return "not_found"
|
||||
end if
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "deleted":
|
||||
return {"summary": event_summary, "deleted": True}
|
||||
raise RuntimeError(f"Event '{event_summary}' not found")
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Apple Contacts tools."""
|
||||
|
||||
from helpers import run_applescript, safe_applescript_string
|
||||
|
||||
|
||||
def search_contacts(query: str) -> list[dict]:
|
||||
"""Search contacts by name, email, or phone."""
|
||||
safe_query = safe_applescript_string(query)
|
||||
script = f'''
|
||||
tell application "Contacts"
|
||||
set output to ""
|
||||
set results to (every person whose name contains "{safe_query}")
|
||||
repeat with p in results
|
||||
set pName to name of p
|
||||
set pId to id of p
|
||||
set pEmail to ""
|
||||
try
|
||||
set pEmail to value of first email of p
|
||||
end try
|
||||
set pPhone to ""
|
||||
try
|
||||
set pPhone to value of first phone of p
|
||||
end try
|
||||
set pOrg to ""
|
||||
try
|
||||
set pOrg to organization of p
|
||||
end try
|
||||
set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed
|
||||
end repeat
|
||||
|
||||
-- Also search by email
|
||||
set emailResults to (every person whose value of emails contains "{safe_query}")
|
||||
repeat with p in emailResults
|
||||
set pName to name of p
|
||||
set pId to id of p
|
||||
if output does not contain pId then
|
||||
set pEmail to ""
|
||||
try
|
||||
set pEmail to value of first email of p
|
||||
end try
|
||||
set pPhone to ""
|
||||
try
|
||||
set pPhone to value of first phone of p
|
||||
end try
|
||||
set pOrg to ""
|
||||
try
|
||||
set pOrg to organization of p
|
||||
end try
|
||||
set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed
|
||||
end if
|
||||
end repeat
|
||||
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=30)
|
||||
results = []
|
||||
seen_ids = set()
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
cid = parts[4].strip()
|
||||
if cid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(cid)
|
||||
results.append({
|
||||
"name": parts[0].strip(),
|
||||
"email": parts[1].strip() or None,
|
||||
"phone": parts[2].strip() or None,
|
||||
"organization": parts[3].strip() or None,
|
||||
"id": cid,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_contact(name: str) -> dict:
|
||||
"""Get detailed info for a contact by name."""
|
||||
safe_name = safe_applescript_string(name)
|
||||
script = f'''
|
||||
tell application "Contacts"
|
||||
set results to (every person whose name is "{safe_name}")
|
||||
if (count of results) = 0 then
|
||||
set results to (every person whose name contains "{safe_name}")
|
||||
end if
|
||||
if (count of results) = 0 then
|
||||
return "NOT_FOUND"
|
||||
end if
|
||||
set p to item 1 of results
|
||||
|
||||
set pName to name of p
|
||||
set pFirst to first name of p
|
||||
set pLast to last name of p
|
||||
set pId to id of p
|
||||
|
||||
set pOrg to ""
|
||||
try
|
||||
set pOrg to organization of p
|
||||
end try
|
||||
|
||||
set pTitle to ""
|
||||
try
|
||||
set pTitle to job title of p
|
||||
end try
|
||||
|
||||
set pNote to ""
|
||||
try
|
||||
set pNote to note of p
|
||||
end try
|
||||
|
||||
-- Emails
|
||||
set emailList to ""
|
||||
repeat with e in emails of p
|
||||
set emailList to emailList & (label of e) & ":" & (value of e) & ","
|
||||
end repeat
|
||||
|
||||
-- Phones
|
||||
set phoneList to ""
|
||||
repeat with ph in phones of p
|
||||
set phoneList to phoneList & (label of ph) & ":" & (value of ph) & ","
|
||||
end repeat
|
||||
|
||||
-- Addresses
|
||||
set addrList to ""
|
||||
repeat with a in addresses of p
|
||||
set addrList to addrList & (label of a) & ":" & (formatted address of a) & ";;;"
|
||||
end repeat
|
||||
|
||||
return pName & "|||" & pFirst & "|||" & pLast & "|||" & pOrg & "|||" & pTitle & "|||" & pNote & "|||" & emailList & "|||" & phoneList & "|||" & addrList & "|||" & pId
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=30)
|
||||
if raw == "NOT_FOUND":
|
||||
raise RuntimeError(f"Contact '{name}' not found")
|
||||
|
||||
parts = raw.split("|||")
|
||||
if len(parts) < 10:
|
||||
raise RuntimeError("Failed to parse contact details")
|
||||
|
||||
emails = []
|
||||
for e in parts[6].strip().rstrip(",").split(","):
|
||||
if ":" in e:
|
||||
label, value = e.split(":", 1)
|
||||
emails.append({"label": label.strip(), "value": value.strip()})
|
||||
|
||||
phones = []
|
||||
for ph in parts[7].strip().rstrip(",").split(","):
|
||||
if ":" in ph:
|
||||
label, value = ph.split(":", 1)
|
||||
phones.append({"label": label.strip(), "value": value.strip()})
|
||||
|
||||
addresses = []
|
||||
for a in parts[8].strip().split(";;;"):
|
||||
if ":" in a:
|
||||
label, value = a.split(":", 1)
|
||||
addresses.append({"label": label.strip(), "address": value.strip()})
|
||||
|
||||
return {
|
||||
"name": parts[0].strip(),
|
||||
"first_name": parts[1].strip() or None,
|
||||
"last_name": parts[2].strip() or None,
|
||||
"organization": parts[3].strip() or None,
|
||||
"job_title": parts[4].strip() or None,
|
||||
"note": parts[5].strip() or None,
|
||||
"emails": emails,
|
||||
"phones": phones,
|
||||
"addresses": addresses,
|
||||
"id": parts[9].strip(),
|
||||
}
|
||||
|
||||
|
||||
def list_groups() -> list[dict]:
|
||||
"""List all contact groups."""
|
||||
script = '''
|
||||
tell application "Contacts"
|
||||
set output to ""
|
||||
repeat with g in groups
|
||||
set gName to name of g
|
||||
set gId to id of g
|
||||
set gCount to count of people of g
|
||||
set output to output & gName & "|||" & gId & "|||" & (gCount as string) & linefeed
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) >= 3:
|
||||
results.append({
|
||||
"name": parts[0].strip(),
|
||||
"id": parts[1].strip(),
|
||||
"member_count": int(parts[2].strip()) if parts[2].strip().isdigit() else 0,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def create_contact(first_name: str, last_name: str | None = None,
|
||||
email: str | None = None, phone: str | None = None,
|
||||
organization: str | None = None, job_title: str | None = None,
|
||||
note: str | None = None) -> dict:
|
||||
"""Create a new contact."""
|
||||
safe_first = safe_applescript_string(first_name)
|
||||
props = [f'first name:"{safe_first}"']
|
||||
|
||||
if last_name:
|
||||
props.append(f'last name:"{safe_applescript_string(last_name)}"')
|
||||
if organization:
|
||||
props.append(f'organization:"{safe_applescript_string(organization)}"')
|
||||
if job_title:
|
||||
props.append(f'job title:"{safe_applescript_string(job_title)}"')
|
||||
if note:
|
||||
props.append(f'note:"{safe_applescript_string(note)}"')
|
||||
|
||||
props_str = ", ".join(props)
|
||||
|
||||
email_line = ""
|
||||
if email:
|
||||
email_line = f'make new email at end of emails of newPerson with properties {{label:"work", value:"{safe_applescript_string(email)}"}}'
|
||||
|
||||
phone_line = ""
|
||||
if phone:
|
||||
phone_line = f'make new phone at end of phones of newPerson with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}'
|
||||
|
||||
script = f'''
|
||||
tell application "Contacts"
|
||||
set newPerson to make new person with properties {{{props_str}}}
|
||||
{email_line}
|
||||
{phone_line}
|
||||
save
|
||||
return id of newPerson
|
||||
end tell'''
|
||||
contact_id = run_applescript(script)
|
||||
return {
|
||||
"id": contact_id.strip(),
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"created": True,
|
||||
}
|
||||
|
||||
|
||||
def update_contact(name: str, email: str | None = None, phone: str | None = None,
|
||||
organization: str | None = None, job_title: str | None = None,
|
||||
note: str | None = None) -> dict:
|
||||
"""Update an existing contact by name."""
|
||||
safe_name = safe_applescript_string(name)
|
||||
updates = []
|
||||
if organization is not None:
|
||||
updates.append(f'set organization of p to "{safe_applescript_string(organization)}"')
|
||||
if job_title is not None:
|
||||
updates.append(f'set job title of p to "{safe_applescript_string(job_title)}"')
|
||||
if note is not None:
|
||||
updates.append(f'set note of p to "{safe_applescript_string(note)}"')
|
||||
if email:
|
||||
updates.append(f'make new email at end of emails of p with properties {{label:"work", value:"{safe_applescript_string(email)}"}}')
|
||||
if phone:
|
||||
updates.append(f'make new phone at end of phones of p with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}')
|
||||
|
||||
if not updates:
|
||||
raise RuntimeError("No updates specified")
|
||||
|
||||
updates_str = "\n ".join(updates)
|
||||
|
||||
script = f'''
|
||||
tell application "Contacts"
|
||||
set results to (every person whose name contains "{safe_name}")
|
||||
if (count of results) = 0 then
|
||||
return "NOT_FOUND"
|
||||
end if
|
||||
set p to item 1 of results
|
||||
{updates_str}
|
||||
save
|
||||
return "updated"
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "NOT_FOUND":
|
||||
raise RuntimeError(f"Contact '{name}' not found")
|
||||
return {"name": name, "updated": True}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Apple Find My tools — reads cached location data."""
|
||||
|
||||
import json
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
FINDMY_CACHE = Path.home() / "Library" / "Caches" / "com.apple.findmy.fmipcore"
|
||||
ITEMS_FILE = FINDMY_CACHE / "Items.data"
|
||||
DEVICES_FILE = FINDMY_CACHE / "Devices.data"
|
||||
|
||||
|
||||
def _load_cache_file(path: Path) -> list[dict]:
|
||||
"""Load a Find My cache file (plist format)."""
|
||||
if not path.exists():
|
||||
raise RuntimeError(
|
||||
f"Find My cache not found at {path}. "
|
||||
"Make sure the Find My app has been opened at least once."
|
||||
)
|
||||
try:
|
||||
f = open(path, "rb")
|
||||
except PermissionError:
|
||||
raise RuntimeError(
|
||||
f"Permission denied reading {path}. "
|
||||
"Grant Full Disk Access to the terminal/app running the MCP server: "
|
||||
"System Settings > Privacy & Security > Full Disk Access."
|
||||
)
|
||||
with f:
|
||||
try:
|
||||
data = plistlib.load(f)
|
||||
except Exception:
|
||||
# Some versions use JSON
|
||||
f.seek(0)
|
||||
try:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
raise RuntimeError(f"Could not parse Find My cache at {path}")
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def _format_location(item: dict) -> dict | None:
|
||||
"""Extract location info from a Find My item/device."""
|
||||
loc = item.get("location")
|
||||
if not loc:
|
||||
return None
|
||||
result = {
|
||||
"latitude": loc.get("latitude"),
|
||||
"longitude": loc.get("longitude"),
|
||||
"altitude": loc.get("altitude"),
|
||||
"horizontal_accuracy": loc.get("horizontalAccuracy"),
|
||||
"is_old": loc.get("isOld", False),
|
||||
"floor_level": loc.get("floorLevel"),
|
||||
}
|
||||
timestamp = loc.get("timeStamp")
|
||||
if timestamp:
|
||||
try:
|
||||
if isinstance(timestamp, (int, float)):
|
||||
# Apple epoch (2001-01-01) or Unix epoch
|
||||
if timestamp > 1e15: # nanoseconds
|
||||
timestamp = timestamp / 1e9
|
||||
if timestamp < 1e9: # Apple epoch
|
||||
timestamp += 978307200 # seconds between 1970 and 2001
|
||||
result["timestamp"] = datetime.fromtimestamp(timestamp).isoformat()
|
||||
else:
|
||||
result["timestamp"] = str(timestamp)
|
||||
except Exception:
|
||||
result["timestamp"] = str(timestamp)
|
||||
return result
|
||||
|
||||
|
||||
def list_devices() -> list[dict]:
|
||||
"""List all Apple devices in Find My."""
|
||||
try:
|
||||
devices = _load_cache_file(DEVICES_FILE)
|
||||
except RuntimeError as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
for dev in devices:
|
||||
location = _format_location(dev)
|
||||
results.append({
|
||||
"name": dev.get("name", "Unknown"),
|
||||
"device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")),
|
||||
"battery_level": dev.get("batteryLevel"),
|
||||
"battery_status": dev.get("batteryStatus"),
|
||||
"location": location,
|
||||
"id": dev.get("baUUID", dev.get("id", "")),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_device_location(device_name: str) -> dict:
|
||||
"""Get location of a specific Apple device."""
|
||||
try:
|
||||
devices = _load_cache_file(DEVICES_FILE)
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(str(e))
|
||||
|
||||
device_name_lower = device_name.lower()
|
||||
for dev in devices:
|
||||
name = dev.get("name", "")
|
||||
if device_name_lower in name.lower():
|
||||
location = _format_location(dev)
|
||||
return {
|
||||
"name": name,
|
||||
"device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")),
|
||||
"battery_level": dev.get("batteryLevel"),
|
||||
"location": location,
|
||||
}
|
||||
raise RuntimeError(f"Device '{device_name}' not found in Find My")
|
||||
|
||||
|
||||
def list_items() -> list[dict]:
|
||||
"""List all Find My items (AirTags, third-party trackers)."""
|
||||
try:
|
||||
items = _load_cache_file(ITEMS_FILE)
|
||||
except RuntimeError as e:
|
||||
return [{"error": str(e)}]
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
location = _format_location(item)
|
||||
results.append({
|
||||
"name": item.get("name", "Unknown"),
|
||||
"product_type": item.get("productType", {}).get("type", "Unknown") if isinstance(item.get("productType"), dict) else item.get("productType", "Unknown"),
|
||||
"serial_number": item.get("serialNumber"),
|
||||
"battery_status": item.get("batteryStatus"),
|
||||
"location": location,
|
||||
"id": item.get("identifier", ""),
|
||||
})
|
||||
return results
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
"""Apple Mail tools."""
|
||||
|
||||
from helpers import run_applescript, safe_applescript_string, parse_applescript_date
|
||||
|
||||
|
||||
def list_mailboxes() -> list[dict]:
|
||||
"""List all mail accounts and their mailboxes."""
|
||||
script = '''
|
||||
tell application "Mail"
|
||||
set output to ""
|
||||
repeat with acct in accounts
|
||||
set acctName to name of acct
|
||||
repeat with mb in mailboxes of acct
|
||||
set mbName to name of mb
|
||||
set msgCount to count of messages of mb
|
||||
set output to output & acctName & ">>>" & mbName & "|||" & (msgCount as string) & linefeed
|
||||
end repeat
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=30)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name_part = parts[0]
|
||||
account = None
|
||||
if ">>>" in name_part:
|
||||
account, name_part = name_part.split(">>>", 1)
|
||||
results.append({
|
||||
"account": account.strip() if account else None,
|
||||
"mailbox": name_part.strip(),
|
||||
"message_count": int(parts[1].strip()) if parts[1].strip().isdigit() else 0,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_messages(mailbox_name: str = "INBOX", account_name: str | None = None,
|
||||
count: int = 20) -> list[dict]:
|
||||
"""Get recent messages from a mailbox.
|
||||
|
||||
Args:
|
||||
mailbox_name: Mailbox name (default INBOX).
|
||||
account_name: Specific account. If None, uses first account.
|
||||
count: Number of recent messages to fetch.
|
||||
"""
|
||||
safe_mb = safe_applescript_string(mailbox_name)
|
||||
if account_name:
|
||||
safe_acct = safe_applescript_string(account_name)
|
||||
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
|
||||
else:
|
||||
mb_ref = f'inbox'
|
||||
if mailbox_name != "INBOX":
|
||||
mb_ref = f'mailbox "{safe_mb}" of first account'
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set output to ""
|
||||
set theMB to {mb_ref}
|
||||
set msgList to messages 1 through {count} of theMB
|
||||
repeat with m in msgList
|
||||
set mSubject to subject of m
|
||||
set mFrom to sender of m
|
||||
set mDate to (date received of m) as string
|
||||
set mRead to read status of m as string
|
||||
set mId to message id of m
|
||||
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & "|||" & mId & linefeed
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=30)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
results.append({
|
||||
"subject": parts[0].strip(),
|
||||
"from": parts[1].strip(),
|
||||
"date": parse_applescript_date(parts[2].strip()),
|
||||
"read": parts[3].strip() == "true",
|
||||
"message_id": parts[4].strip(),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_message_content(subject: str, mailbox_name: str = "INBOX",
|
||||
account_name: str | None = None) -> dict:
|
||||
"""Get full content of a specific message by subject."""
|
||||
safe_subject = safe_applescript_string(subject)
|
||||
if account_name:
|
||||
safe_acct = safe_applescript_string(account_name)
|
||||
safe_mb = safe_applescript_string(mailbox_name)
|
||||
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
|
||||
else:
|
||||
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set theMB to {mb_ref}
|
||||
set msgs to (messages of theMB whose subject contains "{safe_subject}")
|
||||
if (count of msgs) > 0 then
|
||||
set m to item 1 of msgs
|
||||
set mSubject to subject of m
|
||||
set mFrom to sender of m
|
||||
set mTo to ""
|
||||
try
|
||||
set recipList to to recipients of m
|
||||
set mTo to ""
|
||||
repeat with r in recipList
|
||||
set mTo to mTo & (address of r) & ", "
|
||||
end repeat
|
||||
end try
|
||||
set mDate to (date received of m) as string
|
||||
set mContent to content of m
|
||||
set mRead to read status of m as string
|
||||
return mSubject & "|||" & mFrom & "|||" & mTo & "|||" & mDate & "|||" & mRead & "|||" & mContent
|
||||
else
|
||||
return "NOT_FOUND"
|
||||
end if
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=30)
|
||||
if raw == "NOT_FOUND":
|
||||
raise RuntimeError(f"Message with subject containing '{subject}' not found")
|
||||
parts = raw.split("|||", 5)
|
||||
if len(parts) < 6:
|
||||
raise RuntimeError("Failed to parse message")
|
||||
return {
|
||||
"subject": parts[0].strip(),
|
||||
"from": parts[1].strip(),
|
||||
"to": parts[2].strip().rstrip(", ") or None,
|
||||
"date": parse_applescript_date(parts[3].strip()),
|
||||
"read": parts[4].strip() == "true",
|
||||
"content": parts[5].strip(),
|
||||
}
|
||||
|
||||
|
||||
def search_mail(query: str, mailbox_name: str = "INBOX", count: int = 20) -> list[dict]:
|
||||
"""Search mail by subject or sender."""
|
||||
safe_query = safe_applescript_string(query)
|
||||
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set output to ""
|
||||
set theMB to {mb_ref}
|
||||
set msgs to (messages of theMB whose subject contains "{safe_query}" or sender contains "{safe_query}")
|
||||
set maxCount to {count}
|
||||
set i to 0
|
||||
repeat with m in msgs
|
||||
if i >= maxCount then exit repeat
|
||||
set mSubject to subject of m
|
||||
set mFrom to sender of m
|
||||
set mDate to (date received of m) as string
|
||||
set mRead to read status of m as string
|
||||
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & linefeed
|
||||
set i to i + 1
|
||||
end repeat
|
||||
return output
|
||||
end tell'''
|
||||
raw = run_applescript(script, timeout=60)
|
||||
results = []
|
||||
for line in raw.strip().split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("|||")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
results.append({
|
||||
"subject": parts[0].strip(),
|
||||
"from": parts[1].strip(),
|
||||
"date": parse_applescript_date(parts[2].strip()),
|
||||
"read": parts[3].strip() == "true",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def send_mail(to: str, subject: str, body: str, cc: str | None = None,
|
||||
from_account: str | None = None) -> dict:
|
||||
"""Send an email."""
|
||||
safe_to = safe_applescript_string(to)
|
||||
safe_subject = safe_applescript_string(subject)
|
||||
safe_body = safe_applescript_string(body)
|
||||
|
||||
cc_line = ""
|
||||
if cc:
|
||||
cc_addrs = [a.strip() for a in cc.split(",")]
|
||||
cc_lines = "\n".join([f'make new cc recipient at end of cc recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in cc_addrs])
|
||||
cc_line = cc_lines
|
||||
|
||||
account_line = ""
|
||||
if from_account:
|
||||
account_line = f', sender:"{safe_applescript_string(from_account)}"'
|
||||
|
||||
to_addrs = [a.strip() for a in to.split(",")]
|
||||
to_lines = "\n".join([f'make new to recipient at end of to recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in to_addrs])
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set newMsg to make new outgoing message with properties {{subject:"{safe_subject}", content:"{safe_body}", visible:false{account_line}}}
|
||||
tell newMsg
|
||||
{to_lines}
|
||||
{cc_line}
|
||||
end tell
|
||||
send newMsg
|
||||
return "sent"
|
||||
end tell'''
|
||||
run_applescript(script, timeout=30)
|
||||
return {"to": to, "subject": subject, "sent": True}
|
||||
|
||||
|
||||
def mark_read(subject: str, mailbox_name: str = "INBOX", read: bool = True) -> dict:
|
||||
"""Mark a message as read or unread."""
|
||||
safe_subject = safe_applescript_string(subject)
|
||||
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
|
||||
read_val = "true" if read else "false"
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set theMB to {mb_ref}
|
||||
set msgs to (messages of theMB whose subject contains "{safe_subject}")
|
||||
if (count of msgs) > 0 then
|
||||
set read status of item 1 of msgs to {read_val}
|
||||
return "done"
|
||||
else
|
||||
return "not_found"
|
||||
end if
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "done":
|
||||
return {"subject": subject, "read": read}
|
||||
raise RuntimeError(f"Message '{subject}' not found")
|
||||
|
||||
|
||||
def move_message(subject: str, from_mailbox: str, to_mailbox: str,
|
||||
account_name: str | None = None) -> dict:
|
||||
"""Move a message to a different mailbox."""
|
||||
safe_subject = safe_applescript_string(subject)
|
||||
safe_from = safe_applescript_string(from_mailbox)
|
||||
safe_to = safe_applescript_string(to_mailbox)
|
||||
|
||||
if account_name:
|
||||
safe_acct = safe_applescript_string(account_name)
|
||||
from_ref = f'mailbox "{safe_from}" of account "{safe_acct}"'
|
||||
to_ref = f'mailbox "{safe_to}" of account "{safe_acct}"'
|
||||
else:
|
||||
from_ref = "inbox" if from_mailbox == "INBOX" else f'mailbox "{safe_from}" of first account'
|
||||
to_ref = f'mailbox "{safe_to}" of first account'
|
||||
|
||||
script = f'''
|
||||
tell application "Mail"
|
||||
set fromMB to {from_ref}
|
||||
set toMB to {to_ref}
|
||||
set msgs to (messages of fromMB whose subject contains "{safe_subject}")
|
||||
if (count of msgs) > 0 then
|
||||
move item 1 of msgs to toMB
|
||||
return "moved"
|
||||
else
|
||||
return "not_found"
|
||||
end if
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "moved":
|
||||
return {"subject": subject, "from": from_mailbox, "to": to_mailbox, "moved": True}
|
||||
raise RuntimeError(f"Message '{subject}' not found in {from_mailbox}")
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""Apple Maps tools."""
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from helpers import run_applescript, run_jxa, safe_applescript_string
|
||||
|
||||
|
||||
def search_locations(query: str) -> list[dict]:
|
||||
"""Search for locations using Maps."""
|
||||
safe_query = safe_applescript_string(query)
|
||||
|
||||
# Use JXA with MapKit-like search via Maps app
|
||||
script = f'''
|
||||
var Maps = Application("Maps");
|
||||
Maps.activate();
|
||||
|
||||
// Use URL scheme to search
|
||||
var app = Application.currentApplication();
|
||||
app.includeStandardAdditions = true;
|
||||
app.openLocation("maps://?q=" + encodeURIComponent("{safe_query}"));
|
||||
|
||||
// Return the query - Maps will show results in the app
|
||||
"{safe_query}";
|
||||
'''
|
||||
# Maps doesn't expose search results via AppleScript, so we open the search
|
||||
# and also try to geocode using CoreLocation via JXA
|
||||
geocode_script = f'''
|
||||
ObjC.import("CoreLocation");
|
||||
ObjC.import("Foundation");
|
||||
|
||||
var geocoder = $.CLGeocoder.alloc.init;
|
||||
var query = "{safe_query}";
|
||||
var results = [];
|
||||
var done = false;
|
||||
|
||||
geocoder.geocodeAddressStringCompletionHandler(query, function(placemarks, error) {{
|
||||
if (placemarks && placemarks.count > 0) {{
|
||||
for (var i = 0; i < Math.min(placemarks.count, 5); i++) {{
|
||||
var pm = placemarks.objectAtIndex(i);
|
||||
var loc = pm.location;
|
||||
var name = pm.name ? pm.name.js : query;
|
||||
var locality = pm.locality ? pm.locality.js : "";
|
||||
var admin = pm.administrativeArea ? pm.administrativeArea.js : "";
|
||||
var country = pm.country ? pm.country.js : "";
|
||||
var postal = pm.postalCode ? pm.postalCode.js : "";
|
||||
results.push({{
|
||||
name: name,
|
||||
latitude: loc.coordinate.latitude,
|
||||
longitude: loc.coordinate.longitude,
|
||||
locality: locality,
|
||||
state: admin,
|
||||
country: country,
|
||||
postal_code: postal
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
done = true;
|
||||
}});
|
||||
|
||||
// Wait for geocoding (up to 10 seconds)
|
||||
var startTime = new Date().getTime();
|
||||
while (!done && (new Date().getTime() - startTime) < 10000) {{
|
||||
$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1));
|
||||
}}
|
||||
|
||||
JSON.stringify(results);
|
||||
'''
|
||||
try:
|
||||
raw = run_jxa(geocode_script, timeout=15)
|
||||
import json
|
||||
locations = json.loads(raw)
|
||||
return locations
|
||||
except Exception:
|
||||
# Fallback: just open in Maps
|
||||
encoded = urllib.parse.quote(query)
|
||||
subprocess.run(["open", f"maps://?q={encoded}"], capture_output=True)
|
||||
return [{"query": query, "opened_in_maps": True, "note": "Search opened in Maps app. CoreLocation geocoding unavailable."}]
|
||||
|
||||
|
||||
def get_directions(from_address: str | None = None, to_address: str = "",
|
||||
mode: str = "driving") -> dict:
|
||||
"""Get directions between two locations.
|
||||
|
||||
Args:
|
||||
from_address: Starting address. None = current location.
|
||||
to_address: Destination address.
|
||||
mode: Travel mode - "driving", "walking", "transit".
|
||||
"""
|
||||
mode_map = {"driving": "d", "walking": "w", "transit": "r"}
|
||||
mode_char = mode_map.get(mode, "d")
|
||||
|
||||
params = {"daddr": to_address, "dirflg": mode_char}
|
||||
if from_address:
|
||||
params["saddr"] = from_address
|
||||
|
||||
url = "maps://?" + urllib.parse.urlencode(params)
|
||||
subprocess.run(["open", url], capture_output=True)
|
||||
|
||||
return {
|
||||
"from": from_address or "Current Location",
|
||||
"to": to_address,
|
||||
"mode": mode,
|
||||
"opened_in_maps": True,
|
||||
}
|
||||
|
||||
|
||||
def open_location(address: str | None = None, latitude: float | None = None,
|
||||
longitude: float | None = None, label: str | None = None) -> dict:
|
||||
"""Open a location in Apple Maps.
|
||||
|
||||
Provide either an address string or lat/lon coordinates.
|
||||
"""
|
||||
if latitude is not None and longitude is not None:
|
||||
params = {"ll": f"{latitude},{longitude}"}
|
||||
if label:
|
||||
params["q"] = label
|
||||
url = "maps://?" + urllib.parse.urlencode(params)
|
||||
elif address:
|
||||
params = {"address": address}
|
||||
if label:
|
||||
params["q"] = label
|
||||
url = "maps://?" + urllib.parse.urlencode(params)
|
||||
else:
|
||||
raise RuntimeError("Provide either address or latitude/longitude")
|
||||
|
||||
subprocess.run(["open", url], capture_output=True)
|
||||
return {
|
||||
"address": address,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"label": label,
|
||||
"opened_in_maps": True,
|
||||
}
|
||||
|
||||
|
||||
def drop_pin(latitude: float, longitude: float, label: str = "Pin") -> dict:
|
||||
"""Drop a pin at specific coordinates in Maps."""
|
||||
params = {"ll": f"{latitude},{longitude}", "q": label}
|
||||
url = "maps://?" + urllib.parse.urlencode(params)
|
||||
subprocess.run(["open", url], capture_output=True)
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"label": label,
|
||||
"dropped_pin": True,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Apple Reminders tools — uses compiled EventKit helper for speed."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from helpers import run_applescript, safe_applescript_string
|
||||
|
||||
HELPER_BIN = Path(__file__).parent.parent / "helpers" / "reminders_helper"
|
||||
|
||||
|
||||
def _run_helper(*args: str, timeout: int = 15) -> dict | list:
|
||||
"""Run the compiled EventKit reminders helper and return parsed JSON."""
|
||||
result = subprocess.run(
|
||||
[str(HELPER_BIN)] + list(args),
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
try:
|
||||
err = json.loads(result.stdout)
|
||||
raise RuntimeError(err.get("error", stderr))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise RuntimeError(f"Reminders helper error: {stderr or result.stdout}")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def list_lists() -> list[dict]:
|
||||
"""List all reminder lists (e.g., Reminders, Shopping, Work)."""
|
||||
return _run_helper("lists")
|
||||
|
||||
|
||||
def get_reminders(list_name: str | None = None, include_completed: bool = False) -> dict:
|
||||
"""Get reminders, optionally filtered by list name.
|
||||
|
||||
Args:
|
||||
list_name: Filter to a specific list. None = all lists.
|
||||
include_completed: Include completed reminders.
|
||||
"""
|
||||
args = ["get"]
|
||||
if list_name:
|
||||
args += ["--list", list_name]
|
||||
if not include_completed:
|
||||
pass # Default is incomplete only
|
||||
return _run_helper(*args, timeout=30)
|
||||
|
||||
|
||||
def search_reminders(query: str) -> dict:
|
||||
"""Search reminders by name across all lists."""
|
||||
return _run_helper("search", query, timeout=30)
|
||||
|
||||
|
||||
def get_overdue_reminders() -> dict:
|
||||
"""Get all overdue (past due) incomplete reminders."""
|
||||
return _run_helper("get", "--overdue")
|
||||
|
||||
|
||||
def get_due_today() -> dict:
|
||||
"""Get reminders due today."""
|
||||
return _run_helper("get", "--due-today")
|
||||
|
||||
|
||||
def get_due_this_week() -> dict:
|
||||
"""Get reminders due in the next 7 days."""
|
||||
return _run_helper("get", "--due-this-week")
|
||||
|
||||
|
||||
def create_reminder(name: str, list_name: str = "Reminders", due_date: str | None = None,
|
||||
body: str | None = None, priority: int = 0) -> dict:
|
||||
"""Create a new reminder."""
|
||||
safe_name = safe_applescript_string(name)
|
||||
safe_list = safe_applescript_string(list_name)
|
||||
|
||||
props = [f'name:"{safe_name}"']
|
||||
if body:
|
||||
props.append(f'body:"{safe_applescript_string(body)}"')
|
||||
if priority > 0:
|
||||
props.append(f'priority:{priority}')
|
||||
|
||||
props_str = ", ".join(props)
|
||||
|
||||
due_line = ""
|
||||
if due_date:
|
||||
due_line = f'''
|
||||
set due date of newReminder to date "{due_date}"'''
|
||||
|
||||
script = f'''
|
||||
tell application "Reminders"
|
||||
set theList to list "{safe_list}"
|
||||
set newReminder to make new reminder at end of theList with properties {{{props_str}}}
|
||||
{due_line}
|
||||
return id of newReminder
|
||||
end tell'''
|
||||
reminder_id = run_applescript(script)
|
||||
return {"id": reminder_id.strip(), "name": name, "list": list_name, "created": True}
|
||||
|
||||
|
||||
def complete_reminder(name: str, list_name: str | None = None) -> dict:
|
||||
"""Mark a reminder as completed by name."""
|
||||
safe_name = safe_applescript_string(name)
|
||||
if list_name:
|
||||
safe_list = safe_applescript_string(list_name)
|
||||
script = f'''
|
||||
tell application "Reminders"
|
||||
set theList to list "{safe_list}"
|
||||
set theReminders to (every reminder of theList whose name is "{safe_name}" and completed is false)
|
||||
if (count of theReminders) > 0 then
|
||||
set completed of item 1 of theReminders to true
|
||||
return "completed"
|
||||
else
|
||||
return "not_found"
|
||||
end if
|
||||
end tell'''
|
||||
else:
|
||||
script = f'''
|
||||
tell application "Reminders"
|
||||
repeat with L in lists
|
||||
set theReminders to (every reminder of L whose name is "{safe_name}" and completed is false)
|
||||
if (count of theReminders) > 0 then
|
||||
set completed of item 1 of theReminders to true
|
||||
return "completed"
|
||||
end if
|
||||
end repeat
|
||||
return "not_found"
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "completed":
|
||||
return {"name": name, "completed": True}
|
||||
raise RuntimeError(f"Reminder '{name}' not found or already completed")
|
||||
|
||||
|
||||
def delete_reminder(name: str, list_name: str | None = None) -> dict:
|
||||
"""Delete a reminder by name."""
|
||||
safe_name = safe_applescript_string(name)
|
||||
if list_name:
|
||||
safe_list = safe_applescript_string(list_name)
|
||||
script = f'''
|
||||
tell application "Reminders"
|
||||
set theList to list "{safe_list}"
|
||||
set theReminders to (every reminder of theList whose name is "{safe_name}")
|
||||
if (count of theReminders) > 0 then
|
||||
delete item 1 of theReminders
|
||||
return "deleted"
|
||||
else
|
||||
return "not_found"
|
||||
end if
|
||||
end tell'''
|
||||
else:
|
||||
script = f'''
|
||||
tell application "Reminders"
|
||||
repeat with L in lists
|
||||
set theReminders to (every reminder of L whose name is "{safe_name}")
|
||||
if (count of theReminders) > 0 then
|
||||
delete item 1 of theReminders
|
||||
return "deleted"
|
||||
end if
|
||||
end repeat
|
||||
return "not_found"
|
||||
end tell'''
|
||||
result = run_applescript(script)
|
||||
if result == "deleted":
|
||||
return {"name": name, "deleted": True}
|
||||
raise RuntimeError(f"Reminder '{name}' not found")
|
||||
Reference in New Issue
Block a user