mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
5d5eb2fdac
- Add calendar sync route to api.php (/api/calendar → calendar_sync.php) - Add CALENDAR SYNC tab to admin with feed CRUD (add/edit/delete Google/ICS feeds) - Add cal_sync_now action to admin for on-demand iCloud/Google sync - Add cron: calendar_sync.php every 15 min (iCloud CalDAV + ICS feeds) - Add PLANNER mini panel to index.html (left panel, shows today tasks + appointments) - Update loadPlannerSummary() to render tasks/appts with priority dots and times - Seed 298 new KB intents across 37 categories: science, history, tech, geography, math, health, food, space, philosophy, psychology, sports, music, film, travel, language, literature, finance, productivity, nature, facts, home automation, architecture, geopolitics, and more — 543 total intents
284 lines
14 KiB
PHP
284 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* JARVIS Calendar Sync — iCloud CalDAV + Google/ICS feeds
|
|
* Fetches events, parses them, syncs into appointments table.
|
|
* Runs via cron (/usr/local/lsws/lsphp85/bin/lsphp /path/to/calendar_sync.php)
|
|
* or via HTTP: GET /api/calendar/sync
|
|
*/
|
|
|
|
$isCLI = php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed';
|
|
if (!class_exists('JarvisDB')) {
|
|
require_once __DIR__ . '/../config.php';
|
|
require_once __DIR__ . '/../lib/db.php';
|
|
}
|
|
|
|
// ── ICS Parser ───────────────────────────────────────────────────────────────
|
|
function parseICS(string $ics): array {
|
|
$events = [];
|
|
// Unfold lines (RFC 5545: lines folded with CRLF + whitespace)
|
|
$ics = preg_replace('/\r\n[ \t]/', '', $ics);
|
|
$ics = preg_replace('/\n[ \t]/', '', $ics);
|
|
|
|
preg_match_all('/BEGIN:VEVENT(.+?)END:VEVENT/s', $ics, $blocks);
|
|
foreach ($blocks[1] as $block) {
|
|
$e = [];
|
|
// Parse each property line
|
|
foreach (explode("\n", $block) as $line) {
|
|
$line = trim($line);
|
|
if (!$line) continue;
|
|
// Split on first colon not inside quotes
|
|
$pos = strpos($line, ':');
|
|
if ($pos === false) continue;
|
|
$prop = strtoupper(trim(substr($line, 0, $pos)));
|
|
$val = trim(substr($line, $pos + 1));
|
|
// Extract param-stripped property name (e.g. DTSTART;TZID=... → DTSTART)
|
|
$propBase = explode(';', $prop)[0];
|
|
// Extract TZID param if present
|
|
$tzid = null;
|
|
if (preg_match('/TZID=([^;:]+)/i', $prop, $tzm)) $tzid = $tzm[1];
|
|
$e[$propBase] = ['val' => $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, '~') . '[^>]*>(.*?)</(?:[^:]+:)?' . 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 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d: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 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><c:calendar-home-set/></d:prop></d:propfind>';
|
|
$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 = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:displayname/><c:supported-calendar-component-set/></d:prop></d:propfind>';
|
|
$r3 = caldavRequest('PROPFIND', $calHome, $user, $pass, $propfind3, ['Depth: 1']);
|
|
|
|
// Parse calendar URLs from multistatus response
|
|
preg_match_all('~<d:response>(.*?)</d:response>~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
|
|
<?xml version="1.0" encoding="utf-8" ?>
|
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:prop><d:getetag/><c:calendar-data/></d:prop>
|
|
<c:filter>
|
|
<c:comp-filter name="VCALENDAR">
|
|
<c:comp-filter name="VEVENT">
|
|
<c:time-range start="$from" end="$to"/>
|
|
</c:comp-filter>
|
|
</c:comp-filter>
|
|
</c:filter>
|
|
</c:calendar-query>
|
|
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[^>]*>(.*?)</(?:[^:]+:)?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')]);
|
|
}
|