$val, 'tzid' => $tzid, 'raw_prop' => $prop]; } if (empty($e['SUMMARY']) || empty($e['UID'])) continue; $uid = $e['UID']['val']; $summary = calDecode($e['SUMMARY']['val']); $desc = isset($e['DESCRIPTION']) ? calDecode($e['DESCRIPTION']['val']) : ''; $loc = isset($e['LOCATION']) ? calDecode($e['LOCATION']['val']) : ''; $startRaw = $e['DTSTART']['val'] ?? null; $endRaw = $e['DTEND']['val'] ?? ($e['DURATION']['val'] ?? null); if (!$startRaw) continue; $startDt = parseCalDate($startRaw, $e['DTSTART']['tzid'] ?? null); $endDt = $endRaw ? parseCalDate($endRaw, $e['DTEND']['tzid'] ?? null) : null; $allDay = (strlen($startRaw) === 8); // YYYYMMDD format = all-day if (!$startDt) continue; // Skip events more than 1 year in the past if (strtotime($startDt) < time() - 365 * 86400) continue; $events[] = compact('uid','summary','desc','loc','startDt','endDt','allDay'); } return $events; } function parseCalDate(string $raw, ?string $tzid): ?string { $raw = preg_replace('/[^0-9T Z]/i', '', $raw); // All-day: YYYYMMDD if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', $raw, $m)) return "{$m[1]}-{$m[2]}-{$m[3]} 00:00:00"; // Datetime: YYYYMMDDTHHMMSS[Z] if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/', $raw, $m)) { $ts = gmmktime((int)$m[4],(int)$m[5],(int)$m[6],(int)$m[2],(int)$m[3],(int)$m[1]); // Convert to local (server) timezone return date('Y-m-d H:i:s', $ts - date('Z')); } return null; } function calDecode(string $s): string { // Decode quoted-printable and backslash escapes $s = str_replace(['\\n','\\N','\\,','\;'], ["\n"," ",",",";"], $s); return trim($s); } // ── CalDAV Client ───────────────────────────────────────────────────────────── function caldavRequest(string $method, string $url, string $user, string $pass, string $body = '', array $extraHeaders = []): array { $ch = curl_init($url); $headers = array_merge([ 'Content-Type: text/xml; charset=utf-8', 'Prefer: return-minimal', ], $extraHeaders); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_USERPWD => "$user:$pass", CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $body, CURLOPT_TIMEOUT => 20, CURLOPT_CONNECTTIMEOUT => 8, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); return ['code' => $code, 'body' => $resp ?: '', 'error' => $err]; } function xmlVal(string $xml, string $tag): ?string { if (preg_match('~<(?:[^:]+:)?' . preg_quote($tag, '~') . '[^>]*>(.*?)~s', $xml, $m)) return trim(html_entity_decode(strip_tags($m[1]))); return null; } function fetchICloudCalendars(string $user, string $pass): array { $results = []; // Step 1: Discover principal $propfind = ''; $r = caldavRequest('PROPFIND', 'https://caldav.icloud.com/', $user, $pass, $propfind, ['Depth: 0']); $principal = xmlVal($r['body'], 'current-user-principal'); if (!$principal) $principal = xmlVal($r['body'], 'href'); if (!$principal) return ['error' => 'Cannot discover principal: HTTP ' . $r['code']]; // Make absolute URL if (!str_starts_with($principal, 'http')) $principal = 'https://caldav.icloud.com' . $principal; // Step 2: Get calendar home $propfind2 = ''; $r2 = caldavRequest('PROPFIND', $principal, $user, $pass, $propfind2, ['Depth: 0']); $calHome = xmlVal($r2['body'], 'calendar-home-set'); if (!$calHome) $calHome = xmlVal($r2['body'], 'href'); if (!$calHome) return ['error' => 'Cannot find calendar home']; if (!str_starts_with($calHome, 'http')) $calHome = 'https://caldav.icloud.com' . $calHome; // Step 3: List calendars $propfind3 = ''; $r3 = caldavRequest('PROPFIND', $calHome, $user, $pass, $propfind3, ['Depth: 1']); // Parse calendar URLs from multistatus response preg_match_all('~(.*?)~s', $r3['body'], $responses); foreach ($responses[1] as $resp) { $href = xmlVal($resp, 'href'); $name = xmlVal($resp, 'displayname'); // Only include actual calendars (not the home itself) if ($href && $href !== parse_url($calHome, PHP_URL_PATH) && strpos($resp, 'VEVENT') !== false) { $calUrl = str_starts_with($href, 'http') ? $href : 'https://caldav.icloud.com' . $href; $results[] = ['url' => $calUrl, 'name' => $name ?: 'iCloud Calendar']; } } return ['calendars' => $results, 'home' => $calHome]; } function fetchCalDAVEvents(string $calUrl, string $user, string $pass): array { // Fetch events from 1 year ago to 2 years ahead $from = gmdate('Ymd\THis\Z', strtotime('-1 year')); $to = gmdate('Ymd\THis\Z', strtotime('+2 years')); $report = << XML; $r = caldavRequest('REPORT', $calUrl, $user, $pass, $report, ['Depth: 1', 'Content-Type: text/xml']); if ($r['code'] !== 207) return ['error' => 'CalDAV REPORT failed: HTTP ' . $r['code']]; // Extract calendar-data from response $allICS = ''; preg_match_all('~<(?:[^:]+:)?calendar-data[^>]*>(.*?)~s', $r['body'], $m); foreach ($m[1] as $icsBlock) { $allICS .= html_entity_decode($icsBlock) . "\n"; } return ['events' => parseICS("BEGIN:VCALENDAR\n" . $allICS . "\nEND:VCALENDAR")]; } // ── Sync events to DB ───────────────────────────────────────────────────────── function syncEvents(array $events, string $source, string $feedName): array { $inserted = $updated = $skipped = 0; foreach ($events as $ev) { $uid = md5($source . ':' . $ev['uid']); // deterministic per source $existing = JarvisDB::single('SELECT id, start_at, title FROM appointments WHERE external_uid=? AND external_source=?', [$uid, $source]); $cat = 'personal'; if (preg_match('/\b(work|meeting|office|client|project|call|conference|interview|standup|sprint)\b/i', $ev['summary'])) $cat = 'work'; if (preg_match('/\b(doctor|medical|dentist|pharmacy|hospital|clinic|appointment)\b/i', $ev['summary'])) $cat = 'medical'; if ($existing) { // Update if start time changed if ($existing['start_at'] !== $ev['startDt']) { JarvisDB::execute( 'UPDATE appointments SET title=?, description=?, location=?, start_at=?, end_at=?, all_day=?, category=?, last_synced=NOW() WHERE id=?', [$ev['summary'], $ev['desc'], $ev['loc'], $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $cat, $existing['id']] ); $updated++; } else { $skipped++; } } else { JarvisDB::execute( 'INSERT INTO appointments (title, description, location, category, start_at, end_at, all_day, external_uid, external_source, last_synced) VALUES (?,?,?,?,?,?,?,?,?,NOW())', [$ev['summary'], $ev['desc'], $ev['loc'], $cat, $ev['startDt'], $ev['endDt'], $ev['allDay'] ? 1 : 0, $uid, $source] ); $inserted++; } } // Remove events that no longer exist in the feed (deleted from calendar) $syncedUids = array_map(fn($ev) => md5($source . ':' . $ev['uid']), $events); if ($syncedUids) { $placeholders = implode(',', array_fill(0, count($syncedUids), '?')); JarvisDB::execute( "DELETE FROM appointments WHERE external_source=? AND external_uid NOT IN ($placeholders) AND external_uid IS NOT NULL", array_merge([$source], $syncedUids) ); } return compact('inserted', 'updated', 'skipped'); } // ── Main sync runner ────────────────────────────────────────────────────────── function runSync(): array { $results = []; // ── iCloud CalDAV ────────────────────────────────────────────────────── if (defined('ICLOUD_USER') && ICLOUD_USER && defined('ICLOUD_PASS') && ICLOUD_PASS) { $calInfo = fetchICloudCalendars(ICLOUD_USER, ICLOUD_PASS); if (isset($calInfo['error'])) { $results['icloud'] = 'Error: ' . $calInfo['error']; } else { $totalEvents = []; foreach ($calInfo['calendars'] as $cal) { $evData = fetchCalDAVEvents($cal['url'], ICLOUD_USER, ICLOUD_PASS); if (!empty($evData['events'])) $totalEvents = array_merge($totalEvents, $evData['events']); } $r = syncEvents($totalEvents, 'icloud', 'iCloud'); JarvisDB::execute("UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE source='icloud'", [count($totalEvents)]); $results['icloud'] = "OK: {$r['inserted']} new, {$r['updated']} updated, {$r['skipped']} unchanged (" . count($totalEvents) . " events total)"; } } // ── Google Calendar ICS ──────────────────────────────────────────────── $feeds = JarvisDB::query("SELECT * FROM calendar_feeds WHERE source IN ('google','ics') AND active=1") ?? []; foreach ($feeds as $feed) { if (!$feed['ics_url']) continue; $ch = curl_init($feed['ics_url']); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>20, CURLOPT_FOLLOWLOCATION=>true, CURLOPT_SSL_VERIFYPEER=>true]); if ($feed['username']) curl_setopt($ch, CURLOPT_USERPWD, $feed['username'] . ':' . $feed['password']); $ics = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code !== 200 || !$ics) { $results[$feed['name']] = "Error fetching ICS (HTTP $code)"; continue; } $events = parseICS($ics); $r = syncEvents($events, $feed['source'] . '_' . $feed['id'], $feed['name']); JarvisDB::execute('UPDATE calendar_feeds SET last_sync=NOW(), last_count=? WHERE id=?', [count($events), $feed['id']]); $results[$feed['name']] = "OK: {$r['inserted']} new, {$r['updated']} updated (" . count($events) . " events)"; } return $results; } // ── Entry point ─────────────────────────────────────────────────────────────── if ($isCLI) { echo date('Y-m-d H:i:s') . " Calendar sync started\n"; $r = runSync(); foreach ($r as $src => $msg) echo " $src: $msg\n"; echo date('Y-m-d H:i:s') . " Done\n"; } else { // HTTP endpoint $r = runSync(); echo json_encode(['status' => 'ok', 'results' => $r, 'timestamp' => date('c')]); }