diff --git a/api/endpoints/calendar_sync.php b/api/endpoints/calendar_sync.php new file mode 100644 index 0000000..41eb3ed --- /dev/null +++ b/api/endpoints/calendar_sync.php @@ -0,0 +1,283 @@ + $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')]); +} diff --git a/public_html/admin/index.php b/public_html/admin/index.php index 7b7a97b..bd538d3 100644 --- a/public_html/admin/index.php +++ b/public_html/admin/index.php @@ -425,6 +425,40 @@ if ($action) { JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]); j(['ok'=>true]); + + case 'cal_feeds_list': + j(JarvisDB::query("SELECT * FROM calendar_feeds ORDER BY source,name") ?? []); + + case 'cal_feed_save': + $id = (int)($_POST['id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $source = $_POST['source'] ?? 'ics'; + $ics = trim($_POST['ics_url'] ?? ''); + $user = trim($_POST['username'] ?? ''); + $pass = trim($_POST['password'] ?? ''); + $active = (int)($_POST['active'] ?? 1); + if (!$name) bad('Name required'); + if ($id) { + JarvisDB::execute("UPDATE calendar_feeds SET name=?,source=?,ics_url=?,username=?,password=?,active=? WHERE id=?", + [$name,$source,$ics,$user,$pass,$active,$id]); + j(['ok'=>true,'id'=>$id]); + } else { + $nid = JarvisDB::insert("INSERT INTO calendar_feeds(name,source,ics_url,username,password,active) VALUES(?,?,?,?,?,?)", + [$name,$source,$ics,$user,$pass,$active]); + j(['ok'=>true,'id'=>$nid]); + } + + case 'cal_feed_delete': + $id = (int)($_POST['id'] ?? 0); if (!$id) bad('No id'); + JarvisDB::execute("DELETE FROM calendar_feeds WHERE id=?", [$id]); + j(['ok'=>true]); + + case 'cal_sync_now': + if (!class_exists('JarvisDB')) require_once __DIR__ . '/../../api/lib/db.php'; + require_once __DIR__ . '/../../api/endpoints/calendar_sync.php'; + $r = runSync(); + j(['ok'=>true,'results'=>$r]); + case 'users_list': j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username')); @@ -660,6 +694,7 @@ select.filter-sel:focus{border-color:var(--cyan)} + @@ -871,6 +906,22 @@ select.filter-sel:focus{border-color:var(--cyan)}
+ +
+
CALENDAR SYNC +
+ + + +
+
+
+ iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below. + +
+
+
+
USERS
@@ -990,6 +1041,7 @@ function loadTab(tab) { email: ()=>{ loadEmailInbox(); loadEmailActionItems(); }, tasks: loadTasks, appointments: loadAppts, + calendar: loadCalFeeds, })[tab]?.(); } @@ -1925,6 +1977,77 @@ function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); } +// ── CALENDAR FEEDS ──────────────────────────────────────────────────────────── +async function loadCalFeeds() { + const feeds = await api('cal_feeds_list'); + const el = document.getElementById('cal-feeds-tbl'); + if (!feeds || !feeds.length) { + el.innerHTML = '
No calendar feeds configured. iCloud syncs via config.php credentials.
'; + return; + } + const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'}; + el.innerHTML = ` + + ${feeds.map(f=>` + + + + + + + + `).join('')}
NAMESOURCEICS URLLAST SYNCCOUNTSTATUSACTIONS
${esc(f.name)}${srcLabel[f.source]||f.source}${esc(f.ics_url||'—')}${ts(f.last_sync)}${f.last_count||0}${f.active?'ACTIVE':'PAUSED'} +
`; +} + +function calFeedModal(f={}) { + const id = f.id||0; + openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', ` +
+
+
+
+
+
+
+
+ `, () => calFeedSave(id)); +} + +async function calFeedSave(id) { + await apiPost('cal_feed_save', { + id, name: document.getElementById('cf-name').value, + source: document.getElementById('cf-source').value, + ics_url: document.getElementById('cf-ics').value, + username: document.getElementById('cf-user').value, + password: document.getElementById('cf-pass').value, + active: document.getElementById('cf-active').value + }, () => { toast('Saved','ok'); closeModal(); loadCalFeeds(); }); +} + +function calFeedDel(id) { + if (!confirm('Delete this calendar feed?')) return; + apiPost('cal_feed_delete', {id}, () => { toast('Deleted','ok'); loadCalFeeds(); }); +} + +async function syncCalNow() { + const btn = document.getElementById('calSyncBtn'); + const status = document.getElementById('cal-sync-status'); + btn.disabled = true; btn.textContent = '⟳ SYNCING...'; + status.textContent = 'Syncing...'; + apiPost('cal_sync_now', {}, d => { + status.textContent = d.ok ? Object.entries(d.results||{}).map(([k,v])=>k+': '+v).join(' | ') || 'Sync complete' : 'Error'; + toast('Calendar sync complete','ok'); + loadCalFeeds(); + btn.disabled = false; btn.textContent = '⟳ SYNC NOW'; + }); +} + diff --git a/public_html/api.php b/public_html/api.php index 564848e..2143f85 100644 --- a/public_html/api.php +++ b/public_html/api.php @@ -99,6 +99,9 @@ switch ($endpoint) { case "planner": require __DIR__ . '/../api/endpoints/planner.php'; break; + case "calendar": + require __DIR__ . '/../api/endpoints/calendar_sync.php'; + break; default: http_response_code(404); echo json_encode(['error' => 'Unknown endpoint: ' . $endpoint]); diff --git a/public_html/index.html b/public_html/index.html index 89d4526..c1b42db 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -388,6 +388,16 @@ body::after{ } #contextClear:hover{color:var(--red)} +/* Planner mini panel */ +#plannerMiniPanel .task-item{display:flex;align-items:center;gap:6px;padding:2px 0;border-bottom:1px solid rgba(0,212,255,0.06);cursor:default} +#plannerMiniPanel .task-item:last-child{border-bottom:none} +#plannerMiniPanel .pri-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0} +#plannerMiniPanel .pri-urgent{background:#f44} +#plannerMiniPanel .pri-high{background:#fa0} +#plannerMiniPanel .pri-normal{background:var(--cyan)} +#plannerMiniPanel .pri-low{background:var(--text-dim)} +#plannerMiniPanel .appt-row{color:var(--text-dim);font-size:0.58rem;padding:2px 0;display:flex;gap:6px;border-bottom:1px solid rgba(0,212,255,0.04)} +#plannerMiniPanel .appt-time{color:var(--cyan);min-width:42px} /* Clickable panel items */ .vm-card{cursor:pointer;transition:background 0.15s,border-color 0.15s} .vm-card:hover{background:rgba(0,212,255,0.07);border-color:rgba(0,212,255,0.4)} @@ -718,6 +728,16 @@ body::after{
+ +
+
+ PLANNER + + ADMIN ↗ +
+
+
+
@@ -1543,15 +1563,50 @@ async function loadPlannerSummary() { const d = await api('planner/today'); const el = document.getElementById('tb-planner'); const tx = document.getElementById('tb-planner-text'); - if (!el || !tx) return; - const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length; - const appts = (d.appts_today || []).length; - if (!tasksDue && !appts) { el.style.display = 'none'; return; } - const parts = []; - if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : '')); - if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : '')); - tx.textContent = parts.join(' · '); - el.style.display = ''; + if (el && tx) { + const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length; + const appts = (d.appts_today || []).length; + if (!tasksDue && !appts) { el.style.display = 'none'; } + else { + const parts = []; + if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : '')); + if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : '')); + tx.textContent = parts.join(' · '); + el.style.display = ''; + } + } + + // Render planner mini panel + const pEl = document.getElementById('planner-tasks'); + const badge = document.getElementById('planner-badge'); + if (!pEl) return; + + const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'}; + const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); }; + const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); }; + + const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])]; + const appts = d.appts_today || []; + + let html = ''; + if (!tasks.length && !appts.length) { + html = '
No tasks or appointments today.
'; + } else { + if (appts.length) { + html += '
TODAY'S SCHEDULE
'; + html += appts.map(a => `
${fmtTime(a.start_at)}${a.title}${a.location?' · '+a.location+'':''}
`).join(''); + } + if (tasks.length) { + html += '
TASKS DUE
'; + html += tasks.map(t => `
${t.title}${t._overdue?'OVERDUE':''}
`).join(''); + } + if (d.pending_count > tasks.length) { + html += `
${d.pending_count} pending total
`; + } + } + pEl.innerHTML = html; + const total = tasks.length + appts.length; + if (badge) badge.textContent = total ? total + ' TODAY' : ''; } // ── ALERTS ────────────────────────────────────────────────────────────