feat: complete calendar integration + planner widget + 298 new KB intents

- 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
This commit is contained in:
2026-05-31 22:47:35 +00:00
parent 1c7a42f68b
commit 5d5eb2fdac
4 changed files with 473 additions and 9 deletions
+283
View File
@@ -0,0 +1,283 @@
<?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')]);
}