feat: Add soft delete, remove Apple Reminders, improve Google Tasks sync
Add soft-delete support for tasks with deletedAt field and migration. Remove Apple Reminders iCloud integration entirely (API routes, lib, UI modal, and Python script) in favor of CalDAV approach. Add periodic pull-sync from Google Tasks every 2 minutes with deletion detection. Fix orphaned someday task rescue and cross-list task toggle/edit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dd8d471a1d
commit
ccb34d12d5
10
prisma/migrations/20260220_add_soft_delete/migration.sql
Normal file
10
prisma/migrations/20260220_add_soft_delete/migration.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- AlterTable: Add soft delete column to Task
|
||||
ALTER TABLE "Task" ADD COLUMN "deletedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex: Index for efficient filtering of non-deleted tasks
|
||||
CREATE INDEX "Task_userId_deletedAt_idx" ON "Task"("userId", "deletedAt");
|
||||
|
||||
-- AlterTable: Change onDelete behavior for somedayList relation
|
||||
-- Drop old foreign key and add new one with SET NULL
|
||||
ALTER TABLE "Task" DROP CONSTRAINT IF EXISTS "Task_somedayListId_fkey";
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_somedayListId_fkey" FOREIGN KEY ("somedayListId") REFERENCES "SomedayList"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -138,13 +138,14 @@ model Task {
|
||||
recurrenceUnit String? // "days" or "weeks"
|
||||
recurrenceTime String? // e.g. "09:00" - time for the recurring task
|
||||
recurrenceEndDate DateTime? // Optional end date for recurrence
|
||||
deletedAt DateTime? // Soft delete - null means active, set means trashed
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
somedayList SomedayList? @relation(fields: [somedayListId], references: [id])
|
||||
|
||||
|
||||
somedayList SomedayList? @relation(fields: [somedayListId], references: [id], onDelete: SetNull)
|
||||
|
||||
// External Integration
|
||||
externalId String?
|
||||
externalProvider String? // "google" | "apple"
|
||||
@ -155,6 +156,7 @@ model Task {
|
||||
@@index([userId, scheduledDate])
|
||||
@@index([userId, somedayListId])
|
||||
@@index([userId, externalId])
|
||||
@@index([userId, deletedAt])
|
||||
}
|
||||
|
||||
model SomedayList {
|
||||
|
||||
@ -1,595 +0,0 @@
|
||||
#!/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()
|
||||
@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { clearSession } from '@/lib/apple-reminders';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -32,13 +31,26 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Return calendar connections (without sensitive tokens)
|
||||
const connections = user.calendarConnections.map(conn => ({
|
||||
id: conn.id,
|
||||
provider: conn.provider,
|
||||
calendars: conn.calendars, // Include calendar list
|
||||
createdAt: conn.createdAt,
|
||||
expiresAt: conn.expiresAt,
|
||||
}));
|
||||
const connections = user.calendarConnections.map(conn => {
|
||||
let calendars = conn.calendars as any[] | null;
|
||||
|
||||
// For Apple connections, filter out VTODO/Reminders collections
|
||||
// that may have been stored before the VEVENT-only filter was added.
|
||||
// Reminder list URLs contain '/reminders/' in the path.
|
||||
if (conn.provider === 'apple' && Array.isArray(calendars)) {
|
||||
calendars = calendars.filter((cal: any) =>
|
||||
!cal.id?.includes('/reminders/')
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: conn.id,
|
||||
provider: conn.provider,
|
||||
calendars,
|
||||
createdAt: conn.createdAt,
|
||||
expiresAt: conn.expiresAt,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ connections });
|
||||
} catch (error) {
|
||||
@ -143,15 +155,6 @@ export async function DELETE(request: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clear Apple iCloud session file so reconnect starts fresh
|
||||
if ((connection?.provider === 'apple' || connection?.provider === 'apple-reminders') && connection.accessToken) {
|
||||
const colonIdx = connection.accessToken.indexOf(':');
|
||||
if (colonIdx !== -1) {
|
||||
const email = connection.accessToken.slice(0, colonIdx);
|
||||
clearSession(email);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting calendar connection:', error);
|
||||
|
||||
@ -21,7 +21,7 @@ async function findConnectionForCalendar(userId: string, calendarId: string) {
|
||||
if (calendars.some(c => c.id === calendarId)) {
|
||||
return {
|
||||
id: conn.id,
|
||||
provider: conn.provider as 'google' | 'apple' | 'apple-reminders' | 'outlook',
|
||||
provider: conn.provider as 'google' | 'apple' | 'outlook',
|
||||
accessToken: conn.accessToken,
|
||||
refreshToken: conn.refreshToken || undefined,
|
||||
expiresAt: conn.expiresAt || undefined,
|
||||
|
||||
@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
|
||||
// Map to CalendarConnection interface
|
||||
const calendarConnections: CalendarConnection[] = connections.map(conn => ({
|
||||
id: conn.id,
|
||||
provider: conn.provider as 'google' | 'apple' | 'apple-reminders' | 'outlook',
|
||||
provider: conn.provider as 'google' | 'apple' | 'outlook',
|
||||
accessToken: conn.accessToken,
|
||||
refreshToken: conn.refreshToken || undefined,
|
||||
expiresAt: conn.expiresAt || undefined,
|
||||
|
||||
@ -1,80 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { initICloudSession } from '@/lib/apple-reminders';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Apple ID and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('[APPLE REMINDERS CONNECT] Initializing iCloud session for:', email);
|
||||
|
||||
const result = await initICloudSession(email, password);
|
||||
|
||||
if (result.error) {
|
||||
console.error('[APPLE REMINDERS CONNECT] Session init failed:', result.error);
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
if (result.needs2FA) {
|
||||
console.log('[APPLE REMINDERS CONNECT] 2FA required for:', email);
|
||||
return NextResponse.json({ needs2FA: true });
|
||||
}
|
||||
|
||||
// No 2FA needed — save the connection immediately
|
||||
console.log('[APPLE REMINDERS CONNECT] No 2FA needed, saving connection');
|
||||
const accessToken = `${email}:${password}`;
|
||||
|
||||
const existingConnection = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple-reminders' }
|
||||
});
|
||||
|
||||
if (existingConnection) {
|
||||
await prisma.calendarConnection.update({
|
||||
where: { id: existingConnection.id },
|
||||
data: { accessToken, updatedAt: new Date() }
|
||||
});
|
||||
} else {
|
||||
await prisma.calendarConnection.create({
|
||||
data: { userId: user.id, provider: 'apple-reminders', accessToken, calendars: [] }
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
needs2FA: false,
|
||||
message: 'Apple Reminders connected successfully!'
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[APPLE REMINDERS CONNECT] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to connect Apple Reminders' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { submitSecurityCode } from '@/lib/apple-reminders';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { email, code, password } = body;
|
||||
|
||||
if (!email || !code || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email, security code, and password are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[REMINDERS VERIFY] Submitting 2FA code for:', email);
|
||||
|
||||
const result = await submitSecurityCode(email, code, password);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Invalid security code' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[REMINDERS VERIFY] 2FA verification successful');
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Save connection as apple-reminders provider
|
||||
const accessToken = `${email}:${password}`;
|
||||
|
||||
const existingConnection = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple-reminders' }
|
||||
});
|
||||
|
||||
if (existingConnection) {
|
||||
await prisma.calendarConnection.update({
|
||||
where: { id: existingConnection.id },
|
||||
data: { accessToken, updatedAt: new Date() }
|
||||
});
|
||||
} else {
|
||||
await prisma.calendarConnection.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: 'apple-reminders',
|
||||
accessToken,
|
||||
calendars: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Apple Reminders verified and connected successfully!'
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[REMINDERS VERIFY] Error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Verification failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
tasks: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
@ -132,8 +133,10 @@ export async function DELETE(request: NextRequest) {
|
||||
// So I should clean up tasks manually or update schema.
|
||||
// For now, let's delete tasks in the list.
|
||||
|
||||
await prisma.task.deleteMany({
|
||||
where: { somedayListId: id }
|
||||
// Soft-delete tasks in this list (they can be recovered from trash)
|
||||
await prisma.task.updateMany({
|
||||
where: { somedayListId: id },
|
||||
data: { deletedAt: new Date(), somedayListId: null }
|
||||
});
|
||||
|
||||
await prisma.somedayList.delete({
|
||||
|
||||
@ -4,7 +4,6 @@ import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createGoogleClient, fetchGoogleTasks, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||
import { fetchTasks as fetchAppleTasks } from '@/lib/apple-calendar';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -33,7 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
const { provider, sourceLists } = body;
|
||||
|
||||
if (!provider || !['google', 'apple'].includes(provider)) {
|
||||
if (!provider || provider !== 'google') {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
@ -89,45 +88,6 @@ export async function POST(req: NextRequest) {
|
||||
})));
|
||||
}
|
||||
|
||||
} else if (provider === 'apple') {
|
||||
// Prefer 'apple' (CalDAV) connection, fall back to 'apple-reminders'
|
||||
const appleConn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple' }
|
||||
});
|
||||
const remindersConn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple-reminders' }
|
||||
});
|
||||
|
||||
const connection = appleConn || remindersConn;
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: 'Apple Calendar not connected. Please connect Apple Calendar in Settings first.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const colonIdx = connection.accessToken.indexOf(':');
|
||||
const email = connection.accessToken.slice(0, colonIdx);
|
||||
const password = connection.accessToken.slice(colonIdx + 1);
|
||||
console.log('[IMPORT] Apple credentials found, email:', email);
|
||||
console.log('[IMPORT] Target lists:', lists.map(l => l.title));
|
||||
|
||||
for (const sourceList of lists) {
|
||||
try {
|
||||
// sourceList.id is a CalDAV URL (from getAppleReminderLists)
|
||||
console.log(`[IMPORT] Fetching reminders from list: ${sourceList.title} (url: ${sourceList.id})`);
|
||||
const tasks = await fetchAppleTasks(email, password, sourceList.id);
|
||||
console.log(`[IMPORT] Fetched ${tasks.length} tasks from "${sourceList.title}"`);
|
||||
importedTasks.push(...tasks.map(t => ({
|
||||
title: t.title,
|
||||
description: t.description || '',
|
||||
externalId: t.id,
|
||||
externalListId: sourceList.id,
|
||||
dueDate: t.endDate ? new Date(t.endDate) : null,
|
||||
status: 'NEEDS-ACTION', // fetchTasks already filters out completed
|
||||
sourceListTitle: sourceList.title,
|
||||
})));
|
||||
} catch (e) {
|
||||
console.error(`[IMPORT] Failed to fetch reminders from "${sourceList.title}"`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group tasks by source list title
|
||||
|
||||
@ -3,7 +3,6 @@ import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createGoogleClient, fetchGoogleTaskLists } from '@/lib/google-tasks';
|
||||
import { getAppleReminderLists } from '@/lib/apple-calendar';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -17,7 +16,7 @@ export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const provider = searchParams.get('provider');
|
||||
|
||||
if (!provider || !['google', 'apple'].includes(provider)) {
|
||||
if (!provider || provider !== 'google') {
|
||||
return NextResponse.json({ error: 'Invalid provider' }, { status: 400 });
|
||||
}
|
||||
|
||||
@ -29,52 +28,18 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (provider === 'google') {
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: user.id, provider: 'google' }
|
||||
});
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: user.id, provider: 'google' }
|
||||
});
|
||||
|
||||
if (!account || !account.access_token) {
|
||||
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
const lists = await fetchGoogleTaskLists(client);
|
||||
|
||||
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
|
||||
|
||||
} else if (provider === 'apple') {
|
||||
// Try CalDAV connections: prefer 'apple' (CalDAV with app-specific password),
|
||||
// fall back to 'apple-reminders' credentials
|
||||
const appleConn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple' }
|
||||
});
|
||||
const remindersConn = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: user.id, provider: 'apple-reminders' }
|
||||
});
|
||||
|
||||
const connection = appleConn || remindersConn;
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: 'Apple Calendar not connected. Please connect Apple Calendar in Settings first (requires app-specific password).' }, { status: 400 });
|
||||
}
|
||||
|
||||
const colonIdx = connection.accessToken.indexOf(':');
|
||||
const email = connection.accessToken.slice(0, colonIdx);
|
||||
const password = connection.accessToken.slice(colonIdx + 1);
|
||||
|
||||
try {
|
||||
const reminderLists = await getAppleReminderLists(email, password);
|
||||
return NextResponse.json({ lists: reminderLists.map(l => ({ id: l.id, title: l.title })) });
|
||||
} catch (error: any) {
|
||||
console.error('[TASKS/LISTS] Failed to fetch reminder lists via CalDAV:', error.message);
|
||||
return NextResponse.json({
|
||||
error: error.message || 'Failed to fetch Apple Reminder lists via CalDAV.',
|
||||
needsReconnect: error.message?.includes('auth') || error.message?.includes('credentials') || error.message?.includes('401')
|
||||
}, { status: 401 });
|
||||
}
|
||||
if (!account || !account.access_token) {
|
||||
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ lists: [] });
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
const lists = await fetchGoogleTaskLists(client);
|
||||
|
||||
return NextResponse.json({ lists: lists.map(l => ({ id: l.id, title: l.title })) });
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Fetch lists error:', error);
|
||||
|
||||
@ -132,9 +132,13 @@ export async function GET(request: NextRequest) {
|
||||
const start = searchParams.get('start');
|
||||
const end = searchParams.get('end');
|
||||
|
||||
// Fetch REAL tasks
|
||||
// Fetch REAL tasks (exclude soft-deleted)
|
||||
const includeDeleted = searchParams.get('includeDeleted') === 'true';
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { userId },
|
||||
where: {
|
||||
userId,
|
||||
...(includeDeleted ? {} : { deletedAt: null }),
|
||||
},
|
||||
orderBy: [
|
||||
{ dayOfWeek: 'asc' },
|
||||
{ order: 'asc' },
|
||||
@ -254,7 +258,7 @@ export async function PATCH(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
|
||||
const { id } = body;
|
||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate } = body;
|
||||
const { title, description, completed, dayOfWeek, order, markdownContent, scheduledDate, startTime, somedayListId, isRecurring, recurrenceInterval, recurrenceUnit, recurrenceTime, recurrenceEndDate, restore } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@ -336,7 +340,8 @@ export async function PATCH(request: NextRequest) {
|
||||
...(recurrenceInterval !== undefined && { recurrenceInterval: recurrenceInterval ? parseInt(recurrenceInterval) : null }),
|
||||
...(recurrenceUnit !== undefined && { recurrenceUnit }),
|
||||
...(recurrenceTime !== undefined && { recurrenceTime }),
|
||||
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null })
|
||||
...(recurrenceEndDate !== undefined && { recurrenceEndDate: recurrenceEndDate ? new Date(recurrenceEndDate) : null }),
|
||||
...(restore === true && { deletedAt: null })
|
||||
},
|
||||
});
|
||||
|
||||
@ -425,11 +430,23 @@ export async function DELETE(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.task.delete({
|
||||
// Check if permanent delete is requested (for emptying trash)
|
||||
const permanent = searchParams.get('permanent') === 'true';
|
||||
|
||||
if (permanent) {
|
||||
await prisma.task.delete({
|
||||
where: { id },
|
||||
});
|
||||
return NextResponse.json({ message: 'Task permanently deleted' });
|
||||
}
|
||||
|
||||
// Soft delete - mark as deleted but keep in DB for recovery
|
||||
await prisma.task.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Task deleted successfully' });
|
||||
return NextResponse.json({ message: 'Task moved to trash' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -2,11 +2,125 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask } from '@/lib/google-tasks';
|
||||
import { updateTaskStatus } from '@/lib/apple-calendar';
|
||||
import { createGoogleClient, updateGoogleTaskStatus, updateGoogleTask, deleteGoogleTask, fetchGoogleTasksForSync } from '@/lib/google-tasks';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET - Pull changes from Google Tasks into local DB
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email }
|
||||
});
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const account = await prisma.account.findFirst({
|
||||
where: { userId: user.id, provider: 'google' }
|
||||
});
|
||||
|
||||
if (!account?.access_token) {
|
||||
return NextResponse.json({ error: 'Google account not connected' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = createGoogleClient(account.access_token, account.refresh_token || undefined);
|
||||
|
||||
// Find all local tasks linked to Google
|
||||
const localTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalProvider: 'google',
|
||||
externalId: { not: null },
|
||||
deletedAt: null,
|
||||
}
|
||||
});
|
||||
|
||||
// Group by externalListId
|
||||
const tasksByList = new Map<string, typeof localTasks>();
|
||||
for (const task of localTasks) {
|
||||
if (!task.externalListId) continue;
|
||||
if (!tasksByList.has(task.externalListId)) {
|
||||
tasksByList.set(task.externalListId, []);
|
||||
}
|
||||
tasksByList.get(task.externalListId)!.push(task);
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const [listId, tasks] of tasksByList) {
|
||||
try {
|
||||
const remoteTasks = await fetchGoogleTasksForSync(client, listId);
|
||||
const remoteMap = new Map(remoteTasks.map(t => [t.id, t]));
|
||||
|
||||
for (const localTask of tasks) {
|
||||
const remote = remoteMap.get(localTask.externalId!);
|
||||
|
||||
if (!remote) {
|
||||
// Task was deleted in Google - soft delete locally
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: { deletedAt: new Date() }
|
||||
});
|
||||
deleted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if remote is newer
|
||||
const remoteUpdated = new Date(remote.updated);
|
||||
const localUpdated = localTask.lastSyncedAt || localTask.updatedAt;
|
||||
if (remoteUpdated <= localUpdated) continue;
|
||||
|
||||
// Apply remote changes
|
||||
const updateData: any = { lastSyncedAt: new Date() };
|
||||
|
||||
const remoteCompleted = remote.status === 'completed';
|
||||
if (remoteCompleted !== localTask.completed) {
|
||||
updateData.completed = remoteCompleted;
|
||||
}
|
||||
|
||||
if (remote.title && remote.title !== localTask.title) {
|
||||
updateData.title = remote.title;
|
||||
}
|
||||
|
||||
if (remote.notes !== undefined && remote.notes !== (localTask.description || undefined)) {
|
||||
updateData.description = remote.notes || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 1) { // more than just lastSyncedAt
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: updateData
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
// Still update lastSyncedAt
|
||||
await prisma.task.update({
|
||||
where: { id: localTask.id },
|
||||
data: { lastSyncedAt: new Date() }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (listError) {
|
||||
console.error(`Error syncing list ${listId}:`, listError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, updated, deleted });
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Pull sync error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Pull sync failed';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
@ -62,32 +176,6 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (task.externalProvider === 'apple' && task.externalListId) {
|
||||
// Prefer 'apple' (CalDAV) connection, fall back to 'apple-reminders'
|
||||
const connection = await prisma.calendarConnection.findFirst({
|
||||
where: { userId: task.userId, provider: 'apple' }
|
||||
}) || await prisma.calendarConnection.findFirst({
|
||||
where: { userId: task.userId, provider: 'apple-reminders' }
|
||||
});
|
||||
|
||||
if (connection) {
|
||||
const colonIdx = connection.accessToken.indexOf(':');
|
||||
const email = connection.accessToken.slice(0, colonIdx);
|
||||
const password = connection.accessToken.slice(colonIdx + 1);
|
||||
|
||||
if (completed !== undefined) {
|
||||
await updateTaskStatus(
|
||||
email,
|
||||
password,
|
||||
task.externalListId,
|
||||
task.externalId,
|
||||
completed
|
||||
);
|
||||
}
|
||||
// Note: Apple Reminders title update and delete via CloudKit
|
||||
// is not currently supported due to API limitations
|
||||
}
|
||||
}
|
||||
|
||||
// Update lastSyncedAt
|
||||
await prisma.task.update({
|
||||
|
||||
@ -153,7 +153,6 @@ const translations: Record<string, any> = {
|
||||
connectMore: 'Connect More',
|
||||
connectGoogle: 'Connect Google Calendar',
|
||||
connectApple: 'Connect Apple Calendar',
|
||||
connectAppleReminders: 'Connect Apple Reminders',
|
||||
noCalendars: 'No calendars connected yet.',
|
||||
dataPrivacy: 'Data & Privacy',
|
||||
downloadData: 'Download My Data',
|
||||
@ -208,7 +207,6 @@ const translations: Record<string, any> = {
|
||||
connectMore: 'Mehr verbinden',
|
||||
connectGoogle: 'Google Kalender verbinden',
|
||||
connectApple: 'Apple Kalender verbinden',
|
||||
connectAppleReminders: 'Apple Reminders verbinden',
|
||||
noCalendars: 'Keine Kalender verbunden.',
|
||||
dataPrivacy: 'Daten & Datenschutz',
|
||||
downloadData: 'Meine Daten herunterladen',
|
||||
@ -797,11 +795,31 @@ export default function WeeklyView() {
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchTasks();
|
||||
fetchConnections(); // Fetch connections
|
||||
fetchConnections();
|
||||
fetchCalendarEvents();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Periodic pull-sync from Google Tasks (every 2 minutes)
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/tasks/sync');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.updated > 0 || data.deleted > 0) {
|
||||
console.log(`[SYNC] Pulled ${data.updated} updates, ${data.deleted} deletions from Google Tasks`);
|
||||
fetchTasks(); // Reload to reflect changes
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail for background sync
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [session]);
|
||||
|
||||
async function fetchConnections() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -1102,21 +1120,40 @@ export default function WeeklyView() {
|
||||
updatedAt: new Date(t.updatedAt),
|
||||
}));
|
||||
|
||||
const dayTasks = fetchedTasks.filter((t: Task) => t.dayOfWeek !== null && !t.somedayListId);
|
||||
// Calendar tasks: anything NOT in a someday list (includes tasks with scheduledDate OR dayOfWeek)
|
||||
const dayTasks = fetchedTasks.filter((t: Task) => !t.somedayListId);
|
||||
const somedayTasks = fetchedTasks.filter((t: Task) => t.somedayListId);
|
||||
|
||||
setTasks(dayTasks);
|
||||
|
||||
// Populate lists with tasks
|
||||
const listIds = new Set(fetchedLists.map((l: SomedayList) => l.id));
|
||||
const orphanedSomedayTasks = somedayTasks.filter((t: Task) => !listIds.has(t.somedayListId || ''));
|
||||
|
||||
const populatedLists = fetchedLists.map(list => ({
|
||||
...list,
|
||||
tasks: somedayTasks.filter((t: Task) => t.somedayListId === list.id)
|
||||
}));
|
||||
|
||||
// Fallback: If there are someday tasks with IDs that don't match any list (orphans),
|
||||
// or if we rely on the old "default" list for legacy data.
|
||||
// The old code had a default list.
|
||||
// Let's ensure we use the fetched lists.
|
||||
// Rescue orphaned someday tasks: if their list was deleted, move them to calendar
|
||||
if (orphanedSomedayTasks.length > 0) {
|
||||
console.warn(`[RESCUE] Found ${orphanedSomedayTasks.length} orphaned someday tasks, recovering to calendar`);
|
||||
const rescuedTasks = orphanedSomedayTasks.map((t: Task) => ({
|
||||
...t,
|
||||
somedayListId: null,
|
||||
scheduledDate: t.scheduledDate || new Date().toISOString(),
|
||||
}));
|
||||
setTasks(prev => [...prev, ...rescuedTasks]);
|
||||
// Persist the rescue to DB
|
||||
for (const t of orphanedSomedayTasks) {
|
||||
fetch('/api/tasks', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: t.id, somedayListId: null, scheduledDate: new Date().toISOString() }),
|
||||
}).catch(e => console.error('Failed to rescue orphaned task:', e));
|
||||
}
|
||||
}
|
||||
|
||||
setSomedayLists(populatedLists);
|
||||
|
||||
// Roll overdue tasks
|
||||
@ -1376,7 +1413,7 @@ export default function WeeklyView() {
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
setTasks(updatedTasks.filter(t => t.dayOfWeek !== null && !t.somedayListId));
|
||||
setTasks(updatedTasks.filter(t => !t.somedayListId));
|
||||
}
|
||||
}, [profile.autoRolling, cellDuration, endHour, getEventsForDate]);
|
||||
|
||||
@ -1550,17 +1587,36 @@ export default function WeeklyView() {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to find a task in both calendar tasks and someday lists
|
||||
const findTaskAnywhere = (taskId: string): Task | undefined => {
|
||||
const calTask = tasks.find(t => t.id === taskId);
|
||||
if (calTask) return calTask;
|
||||
for (const list of somedayLists) {
|
||||
const found = list.tasks.find(t => t.id === taskId);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const toggleTask = async (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
const task = findTaskAnywhere(taskId);
|
||||
if (!task) return;
|
||||
|
||||
const updatedCompleted = !task.completed;
|
||||
const isSomeday = !!task.somedayListId;
|
||||
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||||
: t
|
||||
));
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, completed: updatedCompleted, updatedAt: new Date() } : t)
|
||||
})));
|
||||
} else {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, completed: updatedCompleted, updatedAt: new Date() }
|
||||
: t
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/tasks', {
|
||||
@ -1569,11 +1625,7 @@ export default function WeeklyView() {
|
||||
body: JSON.stringify({ id: taskId, completed: updatedCompleted }),
|
||||
});
|
||||
|
||||
// Trigger external sync if applicable
|
||||
if (task.externalId && task.externalProvider) {
|
||||
// Don't await this strictly or handle error silently?
|
||||
// Better to fire and forget for UI responsiveness, or await if we want to ensure consistency?
|
||||
// Let's await but catch separately
|
||||
fetch('/api/tasks/sync', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -1592,11 +1644,21 @@ export default function WeeklyView() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||||
: t
|
||||
));
|
||||
const task = findTaskAnywhere(taskId);
|
||||
const isSomeday = !!task?.somedayListId;
|
||||
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, title: newTitle.trim(), updatedAt: new Date() } : t)
|
||||
})));
|
||||
} else {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, title: newTitle.trim(), updatedAt: new Date() }
|
||||
: t
|
||||
));
|
||||
}
|
||||
setEditingTaskId(null);
|
||||
|
||||
try {
|
||||
@ -1606,8 +1668,6 @@ export default function WeeklyView() {
|
||||
body: JSON.stringify({ id: taskId, title: newTitle.trim() }),
|
||||
});
|
||||
|
||||
// Sync title change to external provider if applicable
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task?.externalId && task?.externalProvider) {
|
||||
fetch('/api/tasks/sync', {
|
||||
method: 'PATCH',
|
||||
@ -1677,9 +1737,19 @@ export default function WeeklyView() {
|
||||
};
|
||||
|
||||
const updateTaskNotes = async (taskId: string, notes: string) => {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t
|
||||
));
|
||||
const task = findTaskAnywhere(taskId);
|
||||
const isSomeday = !!task?.somedayListId;
|
||||
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t)
|
||||
})));
|
||||
} else {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, markdownContent: notes, updatedAt: new Date() } : t
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/tasks', {
|
||||
@ -1688,8 +1758,6 @@ export default function WeeklyView() {
|
||||
body: JSON.stringify({ id: taskId, markdownContent: notes }),
|
||||
});
|
||||
|
||||
// Sync notes to external provider
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task?.externalId && task?.externalProvider) {
|
||||
fetch('/api/tasks/sync', {
|
||||
method: 'PATCH',
|
||||
@ -1703,14 +1771,22 @@ export default function WeeklyView() {
|
||||
};
|
||||
|
||||
const toggleTaskRolling = async (taskId: string) => {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
const task = findTaskAnywhere(taskId);
|
||||
if (!task) return;
|
||||
|
||||
const newRollingState = !task.isRolling;
|
||||
const isSomeday = !!task.somedayListId;
|
||||
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t
|
||||
));
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t)
|
||||
})));
|
||||
} else {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, isRolling: newRollingState, updatedAt: new Date() } : t
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/tasks', {
|
||||
@ -1720,10 +1796,16 @@ export default function WeeklyView() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating task rolling state:', error);
|
||||
// Revert on error
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t
|
||||
));
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.map(t => t.id === taskId ? { ...t, isRolling: !newRollingState } : t)
|
||||
})));
|
||||
} else {
|
||||
setTasks(tasks.map(t =>
|
||||
t.id === taskId ? { ...t, isRolling: !newRollingState } : t
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1757,7 +1839,8 @@ export default function WeeklyView() {
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find(t => t.id === taskId);
|
||||
const taskToDelete = findTaskAnywhere(taskId);
|
||||
const isSomeday = !!taskToDelete?.somedayListId;
|
||||
const isVirtual = taskId.startsWith('virtual-');
|
||||
|
||||
let originalId = taskId;
|
||||
@ -1772,26 +1855,19 @@ export default function WeeklyView() {
|
||||
const isSeries = isVirtual || (taskToDelete && taskToDelete.isRecurring);
|
||||
|
||||
if (isSeries) {
|
||||
// Confirm deletion type
|
||||
const deleteSeries = window.confirm("This is a recurring task.\n\nPress OK to delete the ENTIRE SERIES (stop recurrence and remove all future tasks).\nPress Cancel to delete ONLY THIS OCCURRENCE.");
|
||||
|
||||
if (deleteSeries) {
|
||||
// DELETE SERIES
|
||||
// Remove all tasks related to this series from the UI immediately
|
||||
setTasks(prev => prev.filter(t => {
|
||||
// Check if t is the original task
|
||||
if (t.id === originalId) return false;
|
||||
// Check if t is a virtual task of this series
|
||||
if (t.id.startsWith(`virtual-${originalId}-`)) return false;
|
||||
// Check if t is the specific task being clicked (if logic above didn't catch it)
|
||||
if (t.id === taskId) return false;
|
||||
return true;
|
||||
}));
|
||||
setEditingTaskId(null);
|
||||
|
||||
try {
|
||||
// Sync delete to external provider if applicable
|
||||
const origTask = tasks.find(t => t.id === originalId);
|
||||
const origTask = findTaskAnywhere(originalId);
|
||||
if (origTask?.externalId && origTask?.externalProvider) {
|
||||
fetch('/api/tasks/sync', {
|
||||
method: 'PATCH',
|
||||
@ -1800,7 +1876,6 @@ export default function WeeklyView() {
|
||||
}).catch(e => console.error('Sync delete error:', e));
|
||||
}
|
||||
|
||||
// Deleting the original ID stops the series
|
||||
await fetch(`/api/tasks?id=${originalId}`, { method: 'DELETE' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting series:', error);
|
||||
@ -1810,11 +1885,17 @@ export default function WeeklyView() {
|
||||
}
|
||||
|
||||
// NORMAL DELETE (Single instance)
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
if (isSomeday) {
|
||||
setSomedayLists(prev => prev.map(l => ({
|
||||
...l,
|
||||
tasks: l.tasks.filter(t => t.id !== taskId)
|
||||
})));
|
||||
} else {
|
||||
setTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}
|
||||
setEditingTaskId(null);
|
||||
|
||||
try {
|
||||
// Sync delete to external provider if applicable
|
||||
if (taskToDelete?.externalId && taskToDelete?.externalProvider) {
|
||||
fetch('/api/tasks/sync', {
|
||||
method: 'PATCH',
|
||||
@ -2045,12 +2126,13 @@ export default function WeeklyView() {
|
||||
const handleSync = async () => {
|
||||
setSyncStatus('syncing');
|
||||
try {
|
||||
await fetchCalendarEvents();
|
||||
// Pull changes from Google Tasks, then reload everything
|
||||
await fetch('/api/tasks/sync').catch(e => console.error('Task pull sync error:', e));
|
||||
await Promise.all([fetchCalendarEvents(), fetchTasks()]);
|
||||
setSyncStatus('synced');
|
||||
// Reset status after 3 seconds
|
||||
setTimeout(() => setSyncStatus('idle'), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error syncing calendar:', error);
|
||||
console.error('Error syncing:', error);
|
||||
setSyncStatus('idle');
|
||||
}
|
||||
};
|
||||
@ -2890,7 +2972,12 @@ export default function WeeklyView() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
onDragOver={(e) => handleDragOver(e, date.getDay())}
|
||||
onDrop={(e) => handleDrop(e, date.getDay())}
|
||||
onDragLeave={handleDragLeave}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{/* Calendar Events */}
|
||||
{getEventsForDate(date).map(event => {
|
||||
const eventColor = event.calendarColor || '#009a9a';
|
||||
@ -2941,7 +3028,7 @@ export default function WeeklyView() {
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3148,8 +3235,12 @@ export default function WeeklyView() {
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Only drag if clicking the handle
|
||||
const target = e.target as HTMLElement;
|
||||
// Allow task items to be dragged freely
|
||||
if (target.closest('.weekly-task-item')) {
|
||||
return; // Let the TaskItem handle its own drag
|
||||
}
|
||||
// Only allow list drag if clicking the handle
|
||||
if (!target.closest('.someday-drag-handle')) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
@ -4250,49 +4341,10 @@ function SettingsSidebar({
|
||||
const [isConnectingAppleCal, setIsConnectingAppleCal] = useState(false);
|
||||
const [appleCalError, setAppleCalError] = useState('');
|
||||
|
||||
// Apple Reminders (iCloud) State
|
||||
const [showAppleModal, setShowAppleModal] = useState(false);
|
||||
const [appleEmail, setAppleEmail] = useState('');
|
||||
const [applePassword, setApplePassword] = useState('');
|
||||
const [isConnectingApple, setIsConnectingApple] = useState(false);
|
||||
const [appleError, setAppleError] = useState('');
|
||||
const [needs2FA, setNeeds2FA] = useState(false);
|
||||
const [securityCode, setSecurityCode] = useState('');
|
||||
|
||||
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
|
||||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<string | null>(null);
|
||||
const [connMsg, setConnMsg] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
// Apple Reminders inline state
|
||||
const [reminderLists, setReminderLists] = useState<{ id: string, title: string }[]>([]);
|
||||
const [isFetchingReminderLists, setIsFetchingReminderLists] = useState(false);
|
||||
const [selectedReminderIds, setSelectedReminderIds] = useState<string[]>([]);
|
||||
const [reminderListsLoaded, setReminderListsLoaded] = useState(false);
|
||||
const [reminderListsError, setReminderListsError] = useState<string | null>(null);
|
||||
const [needsReconnect, setNeedsReconnect] = useState(false);
|
||||
|
||||
const loadReminderLists = async () => {
|
||||
setIsFetchingReminderLists(true);
|
||||
setReminderListsError(null);
|
||||
try {
|
||||
const res = await fetch('/api/tasks/lists?provider=apple');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setReminderLists(data.lists || []);
|
||||
setSelectedReminderIds((data.lists || []).map((l: any) => l.id));
|
||||
setReminderListsLoaded(true);
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
setReminderListsError(errData.error || 'Failed to load reminder lists.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load reminder lists:', e);
|
||||
setReminderListsError('Failed to load reminder lists.');
|
||||
} finally {
|
||||
setIsFetchingReminderLists(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showConnMsg = (type: 'success' | 'error', text: string) => {
|
||||
setConnMsg({ type, text });
|
||||
setTimeout(() => setConnMsg(null), 5000);
|
||||
@ -4529,89 +4581,6 @@ function SettingsSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
// --- Apple Reminders (iCloud) handlers ---
|
||||
const handleAppleConnect = () => {
|
||||
setShowAppleModal(true);
|
||||
setAppleError('');
|
||||
setAppleEmail('');
|
||||
setApplePassword('');
|
||||
setNeeds2FA(false);
|
||||
setSecurityCode('');
|
||||
};
|
||||
|
||||
const submitAppleConnection = async () => {
|
||||
if (!appleEmail || !applePassword) {
|
||||
setAppleError('Please enter both email and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnectingApple(true);
|
||||
setAppleError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/reminders/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: appleEmail, password: applePassword }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to connect Apple Reminders');
|
||||
}
|
||||
|
||||
if (data.needs2FA) {
|
||||
setNeeds2FA(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowAppleModal(false);
|
||||
setNeeds2FA(false);
|
||||
setNeedsReconnect(false);
|
||||
showConnMsg('success', data.message || 'Apple Reminders connected!');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} catch (err: any) {
|
||||
setAppleError(err.message || 'Connection failed');
|
||||
} finally {
|
||||
setIsConnectingApple(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitSecurityCode = async () => {
|
||||
if (!securityCode || securityCode.length < 4) {
|
||||
setAppleError('Please enter a valid security code.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnectingApple(true);
|
||||
setAppleError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/reminders/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: appleEmail, code: securityCode, password: applePassword }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Verification failed');
|
||||
}
|
||||
|
||||
setShowAppleModal(false);
|
||||
setNeeds2FA(false);
|
||||
setNeedsReconnect(false);
|
||||
showConnMsg('success', data.message || 'Apple Reminders verified and connected!');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
} catch (err: any) {
|
||||
setAppleError(err.message || 'Verification failed');
|
||||
} finally {
|
||||
setIsConnectingApple(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutlookConnect = () => {
|
||||
window.location.href = '/api/calendar/outlook/start';
|
||||
};
|
||||
@ -5498,8 +5467,8 @@ function SettingsSidebar({
|
||||
<li key={conn.id} style={{ padding: '1rem 0', borderBottom: '1px solid var(--weekly-border)' }}>
|
||||
<div style={{ marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{conn.provider === 'google' ? '📅' : conn.provider === 'apple' ? '🍎' : conn.provider === 'apple-reminders' ? '🔔' : '📧'}</span>
|
||||
{conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : conn.provider === 'apple-reminders' ? 'Apple Reminders' : 'Outlook Calendar'}
|
||||
<span>{conn.provider === 'google' ? '📅' : conn.provider === 'apple' ? '🍎' : '📧'}</span>
|
||||
{conn.provider === 'google' ? 'Google Calendar' : conn.provider === 'apple' ? 'Apple Calendar' : 'Outlook Calendar'}
|
||||
</div>
|
||||
{confirmDisconnectId === conn.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
@ -5550,9 +5519,8 @@ function SettingsSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar Event Selection List (not for apple-reminders) */}
|
||||
{conn.provider !== 'apple-reminders' && (
|
||||
conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
||||
{/* Calendar Event Selection List */}
|
||||
{conn.calendars && Array.isArray(conn.calendars) && conn.calendars.length > 0 ? (
|
||||
<ul style={{ paddingLeft: '24px', listStyle: 'none' }}>
|
||||
{conn.calendars.map((cal: any) => {
|
||||
const isShared = /⚠/.test(cal.title);
|
||||
@ -5596,78 +5564,7 @@ function SettingsSidebar({
|
||||
? 'No calendars loaded. Please disconnect and reconnect Apple Calendar to load your calendars.'
|
||||
: 'Selection available after connect.'}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Apple Reminders inline import section */}
|
||||
{conn.provider === 'apple-reminders' && (
|
||||
<div style={{ marginTop: '0.75rem', paddingLeft: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--weekly-text)' }}>Reminders</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{!reminderListsLoaded && (
|
||||
<button
|
||||
onClick={loadReminderLists}
|
||||
disabled={isFetchingReminderLists}
|
||||
style={{ fontSize: '0.78rem', padding: '2px 8px', background: 'none', border: '1px solid #aaa', borderRadius: '4px', cursor: isFetchingReminderLists ? 'not-allowed' : 'pointer', color: 'var(--weekly-text)' }}
|
||||
>
|
||||
{isFetchingReminderLists ? 'Loading…' : 'Load Lists'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{reminderListsError && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#dc2626', marginTop: '4px', lineHeight: 1.4 }}>
|
||||
{reminderListsError}
|
||||
</div>
|
||||
)}
|
||||
{reminderListsLoaded && reminderLists.length > 0 && (
|
||||
<>
|
||||
<ul style={{ listStyle: 'none', padding: 0, marginBottom: '0.5rem' }}>
|
||||
{reminderLists.map(list => {
|
||||
const isShared = /⚠/.test(list.title);
|
||||
const cleanTitle = list.title.replace(/\s*⚠️?\s*/g, '').trim();
|
||||
return (
|
||||
<li key={list.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReminderIds.includes(list.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedReminderIds(prev => [...prev, list.id]);
|
||||
else setSelectedReminderIds(prev => prev.filter(id => id !== list.id));
|
||||
}}
|
||||
style={{ cursor: 'pointer', width: '13px', height: '13px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--weekly-text)' }}>
|
||||
{cleanTitle}
|
||||
{isShared && <span title="Shared list" style={{ marginLeft: '5px', fontSize: '0.75rem', opacity: 0.5 }}>🔗</span>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => onImportLists(reminderLists.filter(l => selectedReminderIds.includes(l.id)))}
|
||||
disabled={selectedReminderIds.length === 0 || importingTasksState}
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
padding: '4px 10px',
|
||||
background: selectedReminderIds.length === 0 || importingTasksState ? '#9ca3af' : '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: selectedReminderIds.length === 0 || importingTasksState ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{importingTasksState ? 'Importing…' : `Import ${selectedReminderIds.length} list(s)`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{reminderListsLoaded && reminderLists.length === 0 && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>No reminder lists found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -5685,14 +5582,11 @@ function SettingsSidebar({
|
||||
<button onClick={handleOutlookConnect} className="calendar-connect-btn">
|
||||
<span>📧</span> Connect Outlook
|
||||
</button>
|
||||
<button onClick={handleAppleConnect} className="calendar-connect-btn">
|
||||
<span>🔔</span> {t.connectAppleReminders}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '1rem', fontSize: '1rem', fontWeight: 600, marginTop: '2rem' }}>Import Tasks</h3>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--weekly-text-light)', marginBottom: '1rem' }}>
|
||||
Import tasks from Google Tasks into a Someday list. For Apple Reminders, use the Reminders section above.
|
||||
Import tasks from Google Tasks into a Someday list.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
@ -5930,101 +5824,6 @@ function SettingsSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apple Reminders (iCloud) Connection Modal */}
|
||||
{showAppleModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[2000]">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||
<h3 className="text-xl font-bold mb-4">
|
||||
{needs2FA ? 'Two-Factor Authentication' : 'Connect Apple Reminders'}
|
||||
</h3>
|
||||
|
||||
{!needs2FA && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||||
Sign in with your Apple ID to import your Reminders lists via iCloud.
|
||||
Two-factor authentication will be required.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needs2FA && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4 text-sm text-blue-800">
|
||||
A verification code was sent to your trusted devices. Enter it below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appleError && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
|
||||
{appleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!needs2FA ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID (Email)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={appleEmail}
|
||||
onChange={(e) => setAppleEmail(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="name@icloud.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Apple ID Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={applePassword}
|
||||
onChange={(e) => setApplePassword(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Your Apple ID password"
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitAppleConnection()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={securityCode}
|
||||
onChange={(e) => setSecurityCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none text-center text-xl tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitSecurityCode()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowAppleModal(false); setNeeds2FA(false); setSecurityCode(''); setAppleError(''); }}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
disabled={isConnectingApple}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={needs2FA ? submitSecurityCode : submitAppleConnection}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-blue-300 flex items-center"
|
||||
disabled={isConnectingApple}
|
||||
>
|
||||
{isConnectingApple ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{needs2FA ? 'Verifying...' : 'Connecting...'}
|
||||
</>
|
||||
) : (needs2FA ? 'Verify' : 'Sign In')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -44,18 +44,25 @@ export const validateCredentials = async (email: string, appSpecificPassword: st
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
// Fetch calendars logic would go here
|
||||
// Since tsdav creates a complex object graph, we'll wrap this in a try/catch
|
||||
const calendars = await client.fetchCalendars();
|
||||
|
||||
const mappedCalendars = calendars.map(cal => ({
|
||||
// Filter to VEVENT-only calendars — exclude VTODO (Reminders) collections
|
||||
const eventCalendars = calendars.filter(cal => {
|
||||
const components: string[] = (cal as any).components || [];
|
||||
// Keep if components include VEVENT, or if components is empty/undefined
|
||||
// (some calendars don't advertise components). Exclude if VTODO-only.
|
||||
if (components.length === 0) return true;
|
||||
return components.includes('VEVENT');
|
||||
});
|
||||
|
||||
const mappedCalendars = eventCalendars.map(cal => ({
|
||||
id: cal.url, // Using URL as ID for CalDAV
|
||||
title: (cal.displayName as string) || 'Untitled Calendar',
|
||||
color: cal.calendarColor,
|
||||
isPrimary: false, // Hard to determine primary in generic CalDAV
|
||||
isPrimary: false,
|
||||
}));
|
||||
|
||||
console.log('[APPLE CALENDAR] Found calendars:', mappedCalendars.map(c => ({ title: c.title, color: c.color })));
|
||||
console.log('[APPLE CALENDAR] Found calendars:', mappedCalendars.length, '(filtered from', calendars.length, 'total)');
|
||||
return mappedCalendars;
|
||||
} catch (error) {
|
||||
console.error('Apple Calendar validation failed:', error);
|
||||
@ -68,190 +75,6 @@ export const validateCredentials = async (email: string, appSpecificPassword: st
|
||||
*/
|
||||
export const getUserCalendars = validateCredentials;
|
||||
|
||||
/**
|
||||
* Get Apple Reminder lists (VTODO calendars) via raw PROPFIND.
|
||||
*
|
||||
* tsdav's fetchCalendars() silently drops any collection that lacks a recognised
|
||||
* `supported-calendar-component-set` — which is exactly what iCloud returns for
|
||||
* Reminders app lists. We bypass that filter by doing the PROPFIND ourselves
|
||||
* through davRequest(), which handles auth automatically but does no result filtering.
|
||||
*/
|
||||
export const getAppleReminderLists = async (email: string, appSpecificPassword: string): Promise<AppleCalendar[]> => {
|
||||
try {
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
const account = (client as any).account;
|
||||
const homeUrl: string = account?.homeUrl;
|
||||
|
||||
console.log('[APPLE REMINDERS] Calendar home URL:', homeUrl);
|
||||
|
||||
if (!homeUrl) {
|
||||
throw new Error('Unable to determine calendar home URL from account discovery');
|
||||
}
|
||||
|
||||
// Collect URLs of known VEVENT calendars so we can exclude them below.
|
||||
const veventCalendars = await client.fetchCalendars();
|
||||
const veventUrls = new Set<string>(
|
||||
veventCalendars.map((c: any) => {
|
||||
const u: string = c.url ?? '';
|
||||
return u.endsWith('/') ? u : u + '/';
|
||||
})
|
||||
);
|
||||
console.log('[APPLE REMINDERS] Known VEVENT calendars:', veventCalendars.length);
|
||||
|
||||
// Normalise a href to a trailing-slash absolute URL for reliable set lookups.
|
||||
const normalizeUrl = (href: string, base: string): string => {
|
||||
try {
|
||||
const abs = new URL(href, base).href;
|
||||
return abs.endsWith('/') ? abs : abs + '/';
|
||||
} catch {
|
||||
return href.endsWith('/') ? href : href + '/';
|
||||
}
|
||||
};
|
||||
|
||||
const propfindXml =
|
||||
`<?xml version="1.0" encoding="UTF-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:ical="http://apple.com/ns/ical/">` +
|
||||
`<D:prop><D:displayname/><D:resourcetype/><C:supported-calendar-component-set/><ical:calendar-color/></D:prop>` +
|
||||
`</D:propfind>`;
|
||||
|
||||
/**
|
||||
* Issue a Depth:1 PROPFIND on `targetUrl` and return reminder-list entries.
|
||||
* We include a collection when:
|
||||
* - resourcetype contains "calendar"
|
||||
* - it is NOT a known VEVENT calendar (already found by fetchCalendars)
|
||||
* - either no supported-calendar-component-set is advertised (iCloud Reminders
|
||||
* often omits this), OR the set explicitly lists VTODO
|
||||
*/
|
||||
const doRawPropfind = async (targetUrl: string): Promise<AppleCalendar[]> => {
|
||||
// convertIncoming: false → send raw XML string as-is (don't try to js2xml it)
|
||||
// parseOutgoing: true → parse the XML multistatus response into DAVResponse[]
|
||||
const rawResponses: any[] = await (client as any).davRequest({
|
||||
url: targetUrl,
|
||||
init: {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=UTF-8',
|
||||
'Depth': '1',
|
||||
},
|
||||
body: propfindXml,
|
||||
},
|
||||
convertIncoming: false,
|
||||
parseOutgoing: true,
|
||||
});
|
||||
|
||||
console.log('[APPLE REMINDERS] PROPFIND returned', rawResponses?.length, 'entries from', targetUrl);
|
||||
if (rawResponses?.length > 0) {
|
||||
// Log first entry to understand the actual property structure.
|
||||
console.log('[APPLE REMINDERS] Entry[0] sample:', JSON.stringify(rawResponses[0], null, 2));
|
||||
}
|
||||
|
||||
const results: AppleCalendar[] = [];
|
||||
const normalTarget = normalizeUrl(targetUrl, targetUrl);
|
||||
|
||||
for (const r of rawResponses || []) {
|
||||
const props = r.props ?? {};
|
||||
const href: string = r.href ?? '';
|
||||
|
||||
// Must be a calendar collection.
|
||||
const resourceType = props.resourcetype ?? {};
|
||||
if (!('calendar' in resourceType)) continue;
|
||||
|
||||
const normalUrl = normalizeUrl(href, targetUrl);
|
||||
|
||||
// Skip the collection root itself.
|
||||
if (normalUrl === normalTarget) continue;
|
||||
|
||||
// Skip calendars already known as VEVENT collections.
|
||||
if (veventUrls.has(normalUrl)) {
|
||||
console.log('[APPLE REMINDERS] Skipping known VEVENT:', normalUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check supported-calendar-component-set (may be missing for iCloud Reminders).
|
||||
const compSet = props.supportedCalendarComponentSet;
|
||||
if (compSet) {
|
||||
const compRaw = compSet.comp;
|
||||
const compArray: any[] = Array.isArray(compRaw) ? compRaw : (compRaw ? [compRaw] : []);
|
||||
const comps: string[] = compArray
|
||||
.map((c: any) => c?._attributes?.name ?? c?.name ?? String(c))
|
||||
.filter(Boolean);
|
||||
if (comps.length > 0 && !comps.includes('VTODO')) {
|
||||
console.log('[APPLE REMINDERS] Skipping non-VTODO collection:', href, 'comps:', comps);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract display name — tsdav XML parsing may use _cdata, _text, or a plain string.
|
||||
const rawName = props.displayname;
|
||||
const displayName: string = (
|
||||
typeof rawName === 'object'
|
||||
? (rawName?._cdata ?? rawName?._text ?? rawName?._ ?? '')
|
||||
: (rawName ?? '')
|
||||
).toString().trim();
|
||||
|
||||
if (!displayName) {
|
||||
console.log('[APPLE REMINDERS] Skipping unnamed collection:', href);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract colour (optional).
|
||||
const rawColor = props.calendarColor ?? props['calendar-color'];
|
||||
const color: string | undefined = rawColor
|
||||
? (typeof rawColor === 'object'
|
||||
? (rawColor?._cdata ?? rawColor?._text ?? rawColor?._ ?? '')
|
||||
: rawColor
|
||||
).toString() || undefined
|
||||
: undefined;
|
||||
|
||||
const absoluteUrl = new URL(href, targetUrl).href;
|
||||
console.log(`[APPLE REMINDERS] Found list: "${displayName}" → ${absoluteUrl}`);
|
||||
results.push({ id: absoluteUrl, title: displayName, color, isPrimary: false });
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Derive the /reminders/ home URL.
|
||||
// Apple iCloud keeps VTODO reminder collections at a /reminders/ sibling next to /calendars/.
|
||||
// Prefer deriving this from an actual known calendar URL (e.g. .../calendars/Home/ → .../reminders/)
|
||||
// rather than the homeUrl itself, since homeUrl ends in /calendars/ only on some accounts.
|
||||
const firstCalUrl: string = veventCalendars[0]?.url ?? homeUrl;
|
||||
const remindersUrl = firstCalUrl.replace(/\/calendars\/.*$/, '/reminders/');
|
||||
const remindersUrlDiffers = remindersUrl !== firstCalUrl && remindersUrl !== homeUrl;
|
||||
|
||||
// Search 1: /reminders/ path (primary — this is where Apple puts VTODO collections).
|
||||
let reminderLists: AppleCalendar[] = [];
|
||||
if (remindersUrlDiffers) {
|
||||
console.log('[APPLE REMINDERS] Trying /reminders/ path:', remindersUrl);
|
||||
try {
|
||||
reminderLists = await doRawPropfind(remindersUrl);
|
||||
} catch (e) {
|
||||
console.warn('[APPLE REMINDERS] /reminders/ PROPFIND failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Search 2: calendar-home-set URL (catches accounts where VTODO is co-located with VEVENT).
|
||||
if (reminderLists.length === 0) {
|
||||
console.log('[APPLE REMINDERS] Trying calendar homeUrl:', homeUrl);
|
||||
try {
|
||||
const fromHome = await doRawPropfind(homeUrl);
|
||||
reminderLists = fromHome;
|
||||
} catch (e) {
|
||||
console.warn('[APPLE REMINDERS] homeUrl PROPFIND failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[APPLE REMINDERS] Total reminder lists found: ${reminderLists.length}`);
|
||||
return reminderLists;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Apple Reminders fetch failed:', error);
|
||||
throw new Error('Unable to fetch Apple Reminder lists.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get upcoming events for a specified time period from a specific calendar
|
||||
*/
|
||||
@ -774,169 +597,3 @@ export const deleteEvent = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch VTODO tasks from a specific calendar
|
||||
*/
|
||||
export const fetchTasks = async (
|
||||
email: string,
|
||||
appSpecificPassword: string,
|
||||
calendarUrl: string
|
||||
): Promise<AppleCalendarEvent[]> => {
|
||||
try {
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
// Use the URL directly without re-discovery — needed for /reminders/ path
|
||||
// which is not in the calendar-home-set returned by fetchCalendars().
|
||||
console.log('[APPLE TASKS] Fetching objects from:', calendarUrl);
|
||||
const targetCalendar = { url: calendarUrl } as any;
|
||||
|
||||
// Explicitly request VTODO components — tsdav defaults to VEVENT which returns nothing from reminder lists
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar: targetCalendar,
|
||||
filters: [
|
||||
{
|
||||
'comp-filter': {
|
||||
_attributes: { name: 'VCALENDAR' },
|
||||
'comp-filter': {
|
||||
_attributes: { name: 'VTODO' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as any,
|
||||
});
|
||||
console.log(`[APPLE TASKS] Fetched ${objects.length} objects from calendar`);
|
||||
|
||||
const parsedTasks: AppleCalendarEvent[] = [];
|
||||
|
||||
objects.forEach(obj => {
|
||||
if (!obj.data) return;
|
||||
|
||||
try {
|
||||
const jcal = ICAL.parse(obj.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
const vtodo = comp.getFirstSubcomponent('vtodo');
|
||||
|
||||
if (vtodo) {
|
||||
const todo = new ICAL.Event(vtodo);
|
||||
|
||||
const status = vtodo.getFirstPropertyValue('status');
|
||||
const completed = status === 'COMPLETED' || status === 'CANCELLED';
|
||||
|
||||
if (!completed) {
|
||||
// Use helper to safely get due date if available
|
||||
// ICAL.js Event wrapper usually handles start/end for VEVENT.
|
||||
// For VTODO, 'due' is the end property eq.
|
||||
// safely access property
|
||||
let dueDate = null;
|
||||
const dueProp = vtodo.getFirstProperty('due');
|
||||
if (dueProp) {
|
||||
dueDate = dueProp.getFirstValue();
|
||||
}
|
||||
|
||||
parsedTasks.push({
|
||||
id: todo.uid || obj.url,
|
||||
title: todo.summary || 'Untitled Task',
|
||||
startDate: todo.startDate ? todo.startDate.toJSDate().toISOString() : '',
|
||||
endDate: (dueDate && (dueDate as any).toJSDate) ? (dueDate as any).toJSDate().toISOString() : '',
|
||||
description: todo.description || '',
|
||||
location: todo.location || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[APPLE TASKS] Error parsing object:', e);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[APPLE TASKS] Parsed ${parsedTasks.length} tasks (excluding completed)`);
|
||||
return parsedTasks;
|
||||
|
||||
} catch (error) {
|
||||
console.error('[APPLE TASKS] Error fetching tasks:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a VTODO task status
|
||||
*/
|
||||
export const updateTaskStatus = async (
|
||||
email: string,
|
||||
appSpecificPassword: string,
|
||||
calendarUrl: string,
|
||||
taskId: string,
|
||||
completed: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const client = createClient(email, appSpecificPassword);
|
||||
await client.login();
|
||||
|
||||
const calendars = await client.fetchCalendars();
|
||||
const targetCalendar = calendars.find(c => c.url === calendarUrl);
|
||||
|
||||
if (!targetCalendar) {
|
||||
throw new Error(`Calendar not found: ${calendarUrl}`);
|
||||
}
|
||||
|
||||
// Task ID might be UID or URL. Try to find the object.
|
||||
const uid = taskId.split('-')[0]; // Simple heuristic if we composite ID
|
||||
|
||||
const allObjects = await client.fetchCalendarObjects({
|
||||
calendar: targetCalendar,
|
||||
});
|
||||
|
||||
const targetObject = allObjects.find(obj => {
|
||||
if (obj.data) {
|
||||
return obj.data.includes(`UID:${uid}`);
|
||||
}
|
||||
return obj.url.includes(uid);
|
||||
});
|
||||
|
||||
if (!targetObject) {
|
||||
throw new Error('Task not found on server');
|
||||
}
|
||||
|
||||
const jcal = ICAL.parse(targetObject.data);
|
||||
const comp = new ICAL.Component(jcal);
|
||||
const vtodo = comp.getFirstSubcomponent('vtodo');
|
||||
|
||||
if (!vtodo) {
|
||||
throw new Error('No VTODO found in calendar object');
|
||||
}
|
||||
|
||||
const todo = new ICAL.Event(vtodo);
|
||||
|
||||
// Update status
|
||||
if (completed) {
|
||||
vtodo.updatePropertyWithValue('status', 'COMPLETED');
|
||||
// Set completed date because Apple Reminders needs it to consider it done
|
||||
// VTODO standard says COMPLETED property (date-time)
|
||||
vtodo.updatePropertyWithValue('completed', ICAL.Time.now());
|
||||
vtodo.updatePropertyWithValue('percent-complete', 100);
|
||||
} else {
|
||||
vtodo.updatePropertyWithValue('status', 'NEEDS-ACTION');
|
||||
vtodo.removeProperty('completed');
|
||||
vtodo.updatePropertyWithValue('percent-complete', 0);
|
||||
}
|
||||
|
||||
// Bump sequence
|
||||
if (todo.sequence !== null && todo.sequence !== undefined) {
|
||||
vtodo.updatePropertyWithValue('sequence', todo.sequence + 1);
|
||||
}
|
||||
vtodo.updatePropertyWithValue('dtstamp', ICAL.Time.now());
|
||||
|
||||
const updatedIcalString = comp.toString();
|
||||
console.log('[APPLE CALENDAR] Updating active task status:', completed ? 'COMPLETED' : 'NEEDS-ACTION');
|
||||
|
||||
await client.updateObject({
|
||||
url: targetObject.url,
|
||||
data: updatedIcalString,
|
||||
etag: targetObject.etag
|
||||
} as any);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[APPLE TASKS] Error updating task status:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Apple Reminders integration via pyicloud (Python).
|
||||
* Uses Apple's iCloud API to access ALL reminder lists (iOS 13+ format).
|
||||
* Requires Apple ID + password + 2FA.
|
||||
*
|
||||
* Calls a Python subprocess (pyicloud) for the actual iCloud communication,
|
||||
* since the Node.js apple-icloud library is broken/outdated.
|
||||
*/
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// Path to the Python virtual environment and bridge script
|
||||
const VENV_PYTHON = path.join(process.cwd(), '.venv', 'bin', 'python3');
|
||||
const BRIDGE_SCRIPT = path.join(process.cwd(), 'scripts', 'icloud-reminders.py');
|
||||
|
||||
// Session directory for storing iCloud sessions
|
||||
const SESSION_DIR = process.env.ICLOUD_SESSION_DIR
|
||||
|| path.join(process.cwd(), 'data', 'icloud-sessions');
|
||||
|
||||
// Ensure session directory exists
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Python bridge script with given arguments.
|
||||
* Returns parsed JSON output.
|
||||
*/
|
||||
function runPyBridge(args: string[], timeoutMs = 60000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fullArgs = ['--session-dir', SESSION_DIR, ...args];
|
||||
|
||||
console.log(`[APPLE REMINDERS] Running: ${VENV_PYTHON} ${BRIDGE_SCRIPT} ${fullArgs.join(' ').replace(/--session-dir \S+/, '--session-dir <dir>')}`);
|
||||
|
||||
execFile(VENV_PYTHON, [BRIDGE_SCRIPT, ...fullArgs], {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
}, (error, stdout, stderr) => {
|
||||
if (stderr) {
|
||||
console.error('[APPLE REMINDERS] Python stderr:', stderr.substring(0, 500));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error('[APPLE REMINDERS] Python error:', error.message);
|
||||
reject(new Error(`iCloud bridge failed: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(stdout.trim());
|
||||
resolve(result);
|
||||
} catch (parseError) {
|
||||
console.error('[APPLE REMINDERS] Failed to parse Python output:', stdout.substring(0, 500));
|
||||
reject(new Error('Failed to parse iCloud bridge response'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an iCloud session. Returns whether 2FA is needed.
|
||||
*/
|
||||
export async function initICloudSession(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ needs2FA: boolean; error: string | null }> {
|
||||
try {
|
||||
const result = await runPyBridge(['init', email, password]);
|
||||
return {
|
||||
needs2FA: result.needs2FA === true,
|
||||
error: result.error || null
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
needs2FA: false,
|
||||
error: error.message || 'Failed to initialize iCloud session'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit 2FA security code for an active iCloud session.
|
||||
*/
|
||||
export async function submitSecurityCode(
|
||||
email: string,
|
||||
code: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; error: string | null }> {
|
||||
try {
|
||||
const result = await runPyBridge(['verify', email, password, code]);
|
||||
return {
|
||||
success: result.success === true,
|
||||
error: result.error || null
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Verification failed'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an Apple Reminder List (collection)
|
||||
*/
|
||||
export interface ReminderList {
|
||||
guid: string;
|
||||
title: string;
|
||||
color?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an Apple Reminder (task)
|
||||
*/
|
||||
export interface Reminder {
|
||||
guid: string;
|
||||
pGuid: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
isCompleted: boolean;
|
||||
dueDate?: Date | null;
|
||||
completedDate?: Date | null;
|
||||
createdDate?: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all reminder lists (collections) from Apple Reminders.
|
||||
*/
|
||||
export async function fetchReminderLists(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<ReminderList[]> {
|
||||
const result = await runPyBridge(['lists', email, password]);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return (result.lists || []).map((l: any) => ({
|
||||
guid: l.guid,
|
||||
title: l.title || 'Untitled',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reminders (tasks) from a specific list or all lists.
|
||||
*/
|
||||
export async function fetchReminders(
|
||||
email: string,
|
||||
password: string,
|
||||
collectionGuid?: string
|
||||
): Promise<Reminder[]> {
|
||||
const args = ['reminders', email, password];
|
||||
if (collectionGuid) {
|
||||
args.push('--collection-guid', collectionGuid);
|
||||
}
|
||||
|
||||
const result = await runPyBridge(args);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return (result.reminders || []).map((r: any) => ({
|
||||
guid: r.guid,
|
||||
pGuid: r.pGuid,
|
||||
title: r.title || 'Untitled',
|
||||
description: r.description || '',
|
||||
priority: r.priority,
|
||||
isCompleted: r.isCompleted || false,
|
||||
dueDate: r.dueDate ? new Date(r.dueDate) : null,
|
||||
completedDate: r.completedDate ? new Date(r.completedDate) : null,
|
||||
createdDate: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a valid iCloud session exists for the given email
|
||||
*/
|
||||
export function hasValidSession(email: string): boolean {
|
||||
// pyicloud stores session cookies in the session directory
|
||||
const sanitized = email.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const cookieFile = path.join(SESSION_DIR, `${sanitized}`);
|
||||
return fs.existsSync(cookieFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the iCloud session for the given email
|
||||
*/
|
||||
export function clearSession(email: string): void {
|
||||
// pyicloud stores cookies as files named after the apple ID
|
||||
try {
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
for (const file of files) {
|
||||
if (file.includes(email.replace('@', '_').replace('.', '_')) || file.includes(email)) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('[APPLE REMINDERS] Cleared session file:', filePath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[APPLE REMINDERS] Error clearing session:', e);
|
||||
}
|
||||
}
|
||||
@ -46,7 +46,7 @@ function getGoogleEventColor(colorId: string): string {
|
||||
|
||||
export interface CalendarConnection {
|
||||
id: string;
|
||||
provider: 'google' | 'apple' | 'apple-reminders' | 'outlook';
|
||||
provider: 'google' | 'apple' | 'outlook';
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
|
||||
@ -4,7 +4,7 @@ import { Task } from '@/types/task';
|
||||
|
||||
export interface CalendarConnection {
|
||||
id: string;
|
||||
provider: 'google' | 'apple' | 'apple-reminders' | 'outlook';
|
||||
provider: 'google' | 'apple' | 'outlook';
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
|
||||
@ -123,6 +123,38 @@ export const deleteGoogleTask = async (client: OAuth2Client, taskListId: string,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch tasks from a specific list including completed ones (for sync)
|
||||
*/
|
||||
export const fetchGoogleTasksForSync = async (client: OAuth2Client, taskListId: string, updatedMin?: string): Promise<GoogleTask[]> => {
|
||||
const service = google.tasks({ version: 'v1', auth: client });
|
||||
try {
|
||||
const params: any = {
|
||||
tasklist: taskListId,
|
||||
showCompleted: true,
|
||||
showHidden: true,
|
||||
maxResults: 100,
|
||||
};
|
||||
if (updatedMin) {
|
||||
params.updatedMin = updatedMin;
|
||||
}
|
||||
|
||||
const response = await service.tasks.list(params);
|
||||
|
||||
return (response.data.items || []).map(item => ({
|
||||
id: item.id!,
|
||||
title: item.title!,
|
||||
notes: item.notes || undefined,
|
||||
status: item.status!,
|
||||
due: item.due || undefined,
|
||||
updated: item.updated!
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Google Tasks for sync from list ${taskListId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateGoogleTaskStatus = async (client: OAuth2Client, taskListId: string, taskId: string, status: 'needsAction' | 'completed'): Promise<GoogleTask> => {
|
||||
const service = google.tasks({ version: 'v1', auth: client });
|
||||
try {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user