My-Weekly-ToDo-List/scripts/icloud-reminders.py
mARTin 7f76774258 fix: Google OAuth redirect, Tasks Account creation, Apple Reminders CalDAV removal
- Fix Google OAuth callback to redirect using NEXTAUTH_URL (prevents session loss behind reverse proxy)
- Create/update Account record during Google OAuth callback so Google Tasks API has access tokens
- Remove legacy CalDAV fallback from Apple Reminders - only use CloudKit (iOS 13+ Reminders app)
- Add debug command to icloud-reminders.py for diagnosing CloudKit connectivity
- Improve profile save error messages to show details
- Add better error logging throughout CloudKit reminders flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 17:26:43 +01:00

596 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Python bridge for Apple iCloud Reminders using pyicloud.
Called from Node.js via subprocess.
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders.
NO legacy CalDAV fallback — only real Reminders app data.
Usage:
python3 icloud-reminders.py init <email> <password> [--session-dir <dir>]
python3 icloud-reminders.py verify <email> <password> <code> [--session-dir <dir>]
python3 icloud-reminders.py lists <email> <password> [--session-dir <dir>]
python3 icloud-reminders.py reminders <email> <password> [--collection-guid <guid>] [--session-dir <dir>]
python3 icloud-reminders.py debug <email> <password> [--session-dir <dir>]
Output: JSON to stdout
"""
import sys
import json
import os
import argparse
from urllib.parse import urlencode
from datetime import datetime
def get_session_dir(args_session_dir=None):
return args_session_dir or os.environ.get(
'ICLOUD_SESSION_DIR',
os.path.join(os.getcwd(), 'data', 'icloud-sessions')
)
def get_api(email, password, session_dir):
"""Create a PyiCloudService instance with session persistence."""
from pyicloud import PyiCloudService
os.makedirs(session_dir, exist_ok=True)
api = PyiCloudService(
apple_id=email,
password=password,
cookie_directory=session_dir,
)
return api
# ---------------------------------------------------------------------------
# CloudKit helpers
# ---------------------------------------------------------------------------
def get_ck_base(api):
"""Get the CloudKit base URL for the reminders container."""
ck_url = api.data["webservices"]["ckdatabasews"]["url"]
return f"{ck_url}/database/1/com.apple.reminders/production/private"
def ck_request(api, path, body=None):
"""Make a CloudKit request using the authenticated session."""
base = get_ck_base(api)
params = dict(api.params)
url = f"{base}{path}?{urlencode(params)}"
data = json.dumps(body) if body else "{}"
resp = api.session.post(url, data=data, headers={"Content-type": "text/plain"})
result = resp.json()
# Check for CloudKit errors
if "error" in result:
err = result["error"]
raise Exception(f"CloudKit error: {err.get('reason', err.get('serverErrorCode', str(err)))}")
return result
def ck_list_zones(api):
"""List all zones in the reminders container."""
return ck_request(api, "/zones/list")
def ck_query_records(api, record_type, zone_id, filter_by=None, results_limit=200):
"""Query records by type in a specific zone."""
body = {
"query": {"recordType": record_type},
"zoneID": zone_id,
"resultsLimit": results_limit,
}
if filter_by:
body["query"]["filterBy"] = filter_by
return ck_request(api, "/records/query", body)
def ck_get_zone_changes(api, zone_id, sync_token=None):
"""Get all changes in a zone (used to discover all record types)."""
body = {"zoneID": zone_id}
if sync_token:
body["syncToken"] = sync_token
return ck_request(api, "/changes/zone", body)
# ---------------------------------------------------------------------------
# CloudKit-based list/reminder fetching
# ---------------------------------------------------------------------------
# Common record type names to try for reminder lists
LIST_RECORD_TYPES = ["Checklist", "List", "REMCDList", "Collection", "ReminderList"]
# Common record type names to try for individual reminders
TASK_RECORD_TYPES = ["Task", "Reminder", "REMCDReminder", "Item"]
def discover_schema(api, zone_id):
"""Discover record types by fetching zone changes."""
try:
data = ck_get_zone_changes(api, zone_id)
records = data.get("records", [])
record_types = {}
for r in records:
rt = r.get("recordType", "unknown")
if rt not in record_types:
record_types[rt] = {
"count": 0,
"fields": list(r.get("fields", {}).keys()) if r.get("fields") else [],
"sample": r,
}
record_types[rt]["count"] += 1
return record_types, records
except Exception as e:
sys.stderr.write(f"[REMINDERS] discover_schema error: {e}\n")
return {}, []
def find_list_record_type(record_types):
"""Find the record type used for reminder lists."""
for rt in LIST_RECORD_TYPES:
if rt in record_types:
return rt
# Heuristic: look for record types with a 'title' field but not 'parentList'
for rt, info in record_types.items():
fields = info.get("fields", [])
if "title" in fields and "parentList" not in fields:
return rt
return None
def find_task_record_type(record_types):
"""Find the record type used for individual reminders."""
for rt in TASK_RECORD_TYPES:
if rt in record_types:
return rt
# Heuristic: look for record types with 'title' AND 'parentList'
for rt, info in record_types.items():
fields = info.get("fields", [])
if "title" in fields and ("parentList" in fields or "parent" in fields):
return rt
return None
def extract_field_value(field):
"""Extract the value from a CloudKit field dict."""
if isinstance(field, dict):
return field.get("value")
return field
def ck_timestamp_to_iso(ts):
"""Convert CloudKit timestamp (ms since epoch) to ISO string."""
if ts is None:
return None
try:
if isinstance(ts, (int, float)):
return datetime.utcfromtimestamp(ts / 1000).isoformat()
if isinstance(ts, dict):
return datetime.utcfromtimestamp(ts.get("value", 0) / 1000).isoformat()
except Exception:
pass
return None
def fetch_lists_via_cloudkit(api):
"""Fetch reminder lists using the CloudKit API. Returns (lists, error)."""
try:
# Step 1: Discover zones
zones_data = ck_list_zones(api)
zones = zones_data.get("zones", [])
if not zones:
return [], "No zones found in reminders container"
all_lists = []
for zone in zones:
zone_id = zone.get("zoneID", {})
zone_name = zone_id.get("zoneName", "unknown")
sys.stderr.write(f"[REMINDERS] Scanning zone: {zone_name}\n")
# Step 2: Discover schema via zone changes
record_types, all_records = discover_schema(api, zone_id)
sys.stderr.write(f"[REMINDERS] Zone '{zone_name}' has record types: {list(record_types.keys())}\n")
if not record_types:
# Try querying common record types directly
for rt in LIST_RECORD_TYPES:
try:
result = ck_query_records(api, rt, zone_id)
records = result.get("records", [])
if records:
record_types[rt] = {
"count": len(records),
"fields": list(records[0].get("fields", {}).keys()),
}
sys.stderr.write(f"[REMINDERS] Found {len(records)} records of type '{rt}'\n")
break
except Exception as e:
sys.stderr.write(f"[REMINDERS] Query for '{rt}' failed: {e}\n")
continue
list_type = find_list_record_type(record_types)
sys.stderr.write(f"[REMINDERS] List record type: {list_type}\n")
if list_type:
# Query for lists using the discovered record type
result = ck_query_records(api, list_type, zone_id)
records = result.get("records", [])
for r in records:
fields = r.get("fields", {})
title = extract_field_value(fields.get("title"))
if not title:
title = extract_field_value(fields.get("name"))
if not title:
title = "Untitled"
all_lists.append({
"guid": r.get("recordName", ""),
"title": title,
"zone": zone_name,
"color": extract_field_value(fields.get("color")),
})
else:
# Fallback: extract lists from zone changes data
for r in all_records:
fields = r.get("fields", {})
title = extract_field_value(fields.get("title"))
if title and "parentList" not in fields and "parent" not in fields:
rt = r.get("recordType", "")
if rt.lower() not in ("task", "reminder", "item"):
all_lists.append({
"guid": r.get("recordName", ""),
"title": title,
"zone": zone_name,
})
return all_lists, None
except Exception as e:
return [], str(e)
def fetch_reminders_via_cloudkit(api, collection_guid=None):
"""Fetch reminders using the CloudKit API. Returns (reminders, error)."""
try:
zones_data = ck_list_zones(api)
zones = zones_data.get("zones", [])
if not zones:
return [], "No zones found in reminders container"
all_reminders = []
for zone in zones:
zone_id = zone.get("zoneID", {})
# Discover schema
record_types, all_records = discover_schema(api, zone_id)
task_type = find_task_record_type(record_types)
if task_type:
# Build filter if collection_guid specified
filter_by = None
if collection_guid:
filter_by = [{
"fieldName": "parentList",
"comparator": "EQUALS",
"fieldValue": {
"value": {
"recordName": collection_guid,
"action": "NONE",
},
"type": "REFERENCE",
},
}]
result = ck_query_records(api, task_type, zone_id, filter_by=filter_by)
records = result.get("records", [])
else:
# Use zone changes data and filter for task-like records
records = []
for r in all_records:
fields = r.get("fields", {})
if "parentList" in fields or "parent" in fields:
if collection_guid:
parent_ref = extract_field_value(fields.get("parentList") or fields.get("parent"))
if isinstance(parent_ref, dict):
parent_name = parent_ref.get("recordName", "")
else:
parent_name = str(parent_ref) if parent_ref else ""
if parent_name != collection_guid:
continue
records.append(r)
for r in records:
fields = r.get("fields", {})
title = extract_field_value(fields.get("title"))
if not title:
continue
# Get parent list reference
parent_ref = extract_field_value(fields.get("parentList") or fields.get("parent"))
p_guid = ""
if isinstance(parent_ref, dict):
p_guid = parent_ref.get("recordName", "")
elif parent_ref:
p_guid = str(parent_ref)
# Parse completion status
is_completed = False
completed_val = extract_field_value(fields.get("isCompleted") or fields.get("completed"))
if completed_val is not None:
is_completed = bool(completed_val)
# Parse dates
due_date = None
due_val = extract_field_value(fields.get("dueDate") or fields.get("due"))
if due_val:
due_date = ck_timestamp_to_iso(due_val)
completed_date = None
cd_val = extract_field_value(fields.get("completionDate") or fields.get("completedDate"))
if cd_val:
completed_date = ck_timestamp_to_iso(cd_val)
all_reminders.append({
"guid": r.get("recordName", ""),
"pGuid": p_guid,
"title": title,
"description": extract_field_value(fields.get("notes") or fields.get("description")) or "",
"priority": extract_field_value(fields.get("priority")) or 0,
"isCompleted": is_completed,
"dueDate": due_date,
"completedDate": completed_date,
})
return all_reminders, None
except Exception as e:
return [], str(e)
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_init(args):
"""Initialize iCloud session. Returns whether 2FA is needed."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if api.requires_2fa:
print(json.dumps({
"needs2FA": True,
"error": None
}))
elif api.requires_2sa:
print(json.dumps({
"needs2FA": True,
"error": None
}))
else:
print(json.dumps({
"needs2FA": False,
"error": None
}))
except Exception as e:
print(json.dumps({
"needs2FA": False,
"error": str(e)
}))
def cmd_verify(args):
"""Submit 2FA verification code."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if not api.requires_2fa and not api.requires_2sa:
print(json.dumps({
"success": True,
"error": None,
"message": "Already verified"
}))
return
result = api.validate_2fa_code(args.code)
if result:
if not api.is_trusted_session:
api.trust_session()
print(json.dumps({
"success": True,
"error": None
}))
else:
print(json.dumps({
"success": False,
"error": "Invalid verification code"
}))
except Exception as e:
print(json.dumps({
"success": False,
"error": str(e)
}))
def cmd_lists(args):
"""Fetch all reminder lists via CloudKit only (iOS 13+ Reminders app)."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if api.requires_2fa or api.requires_2sa:
print(json.dumps({
"error": "2FA required. Please reconnect Apple Reminders.",
"needs2FA": True,
"lists": []
}))
return
# CloudKit only — no legacy CalDAV fallback
lists, ck_error = fetch_lists_via_cloudkit(api)
if lists:
print(json.dumps({
"error": None,
"lists": lists,
"source": "cloudkit"
}))
elif ck_error:
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
print(json.dumps({
"error": f"Could not fetch Reminders via iCloud (CloudKit): {ck_error}. "
"This may require Apple Developer CloudKit access or a different authentication method.",
"lists": [],
"source": "cloudkit"
}))
else:
print(json.dumps({
"error": "No reminder lists found via CloudKit. Your account may not have iCloud Reminders enabled.",
"lists": [],
"source": "cloudkit"
}))
except Exception as e:
print(json.dumps({
"error": str(e),
"lists": []
}))
def cmd_reminders(args):
"""Fetch reminders via CloudKit only (iOS 13+ Reminders app)."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if api.requires_2fa or api.requires_2sa:
print(json.dumps({
"error": "2FA required. Please reconnect Apple Reminders.",
"reminders": []
}))
return
# CloudKit only — no legacy CalDAV fallback
reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid)
if reminders or ck_error is None:
print(json.dumps({
"error": None,
"reminders": reminders,
"source": "cloudkit"
}))
else:
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
print(json.dumps({
"error": f"Could not fetch reminders via CloudKit: {ck_error}",
"reminders": [],
"source": "cloudkit"
}))
except Exception as e:
print(json.dumps({
"error": str(e),
"reminders": []
}))
def cmd_debug(args):
"""Debug: show available webservices and CloudKit info."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if api.requires_2fa or api.requires_2sa:
print(json.dumps({
"error": "2FA required",
"services": []
}))
return
services = list(api.data.get("webservices", {}).keys())
ck_url = api.data.get("webservices", {}).get("ckdatabasews", {}).get("url", "N/A")
# Try listing zones
zones_info = []
try:
zones_data = ck_list_zones(api)
for zone in zones_data.get("zones", []):
zone_id = zone.get("zoneID", {})
zone_name = zone_id.get("zoneName", "unknown")
# Try discovering record types
record_types, records = discover_schema(api, zone_id)
zones_info.append({
"name": zone_name,
"recordTypes": {k: {"count": v["count"], "fields": v["fields"]} for k, v in record_types.items()},
"totalRecords": len(records),
})
except Exception as e:
zones_info = [{"error": str(e)}]
print(json.dumps({
"services": services,
"ckdatabasews_url": ck_url,
"zones": zones_info,
}, indent=2))
except Exception as e:
print(json.dumps({"error": str(e)}))
def main():
parser = argparse.ArgumentParser(description='iCloud Reminders bridge')
parser.add_argument('--session-dir', type=str, default=None)
subparsers = parser.add_subparsers(dest='command')
# init
p_init = subparsers.add_parser('init')
p_init.add_argument('email')
p_init.add_argument('password')
# verify
p_verify = subparsers.add_parser('verify')
p_verify.add_argument('email')
p_verify.add_argument('password')
p_verify.add_argument('code')
# lists
p_lists = subparsers.add_parser('lists')
p_lists.add_argument('email')
p_lists.add_argument('password')
# reminders
p_rem = subparsers.add_parser('reminders')
p_rem.add_argument('email')
p_rem.add_argument('password')
p_rem.add_argument('--collection-guid', type=str, default=None)
# debug
p_debug = subparsers.add_parser('debug')
p_debug.add_argument('email')
p_debug.add_argument('password')
args = parser.parse_args()
if args.command == 'init':
cmd_init(args)
elif args.command == 'verify':
cmd_verify(args)
elif args.command == 'lists':
cmd_lists(args)
elif args.command == 'reminders':
cmd_reminders(args)
elif args.command == 'debug':
cmd_debug(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()