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:
mARTin 2026-02-21 09:04:49 +01:00
parent dd8d471a1d
commit ccb34d12d5
20 changed files with 394 additions and 1823 deletions

View 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;

View File

@ -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 {

View File

@ -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()

View File

@ -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);

View File

@ -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,

View File

@ -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,

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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({

View File

@ -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

View File

@ -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);

View File

@ -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(

View File

@ -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({

View File

@ -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>
)}
</>
);
}

View File

@ -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;
}
};

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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