mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
@@ -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')]);
|
||||
}
|
||||
@@ -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)}
|
||||
<div class="nav-section">PLANNER</div>
|
||||
<div class="nav-item" data-tab="tasks" onclick="nav(this)">📋 TASKS</div>
|
||||
<div class="nav-item" data-tab="appointments" onclick="nav(this)">📅 APPOINTMENTS</div>
|
||||
<div class="nav-item" data-tab="calendar" onclick="nav(this)">🗓 CALENDAR SYNC</div>
|
||||
<div class="nav-section">INFO</div>
|
||||
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
|
||||
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
|
||||
@@ -871,6 +906,22 @@ select.filter-sel:focus{border-color:var(--cyan)}
|
||||
<div class="tbl-wrap" id="appts-tbl"></div>
|
||||
</div>
|
||||
|
||||
<!-- CALENDAR SYNC -->
|
||||
<div class="tab" id="tab-calendar">
|
||||
<div class="page-title">CALENDAR SYNC
|
||||
<div class="actions">
|
||||
<button class="btn btn-sm btn-green" onclick="calFeedModal()">+ ADD FEED</button>
|
||||
<button class="btn btn-sm" onclick="syncCalNow()" id="calSyncBtn">⟳ SYNC NOW</button>
|
||||
<button class="btn btn-sm" onclick="loadCalFeeds()">REFRESH</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:0.65rem;color:var(--dim);margin-bottom:10px">
|
||||
iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below.
|
||||
<span id="cal-sync-status" style="margin-left:12px;color:var(--cyan)"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap" id="cal-feeds-tbl"></div>
|
||||
</div>
|
||||
|
||||
<!-- USERS -->
|
||||
<div class="tab" id="tab-users">
|
||||
<div class="page-title">USERS</div>
|
||||
@@ -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 = '<div class="loading">No calendar feeds configured. iCloud syncs via config.php credentials.</div>';
|
||||
return;
|
||||
}
|
||||
const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'};
|
||||
el.innerHTML = `<table class="data-tbl"><thead><tr>
|
||||
<th>NAME</th><th>SOURCE</th><th>ICS URL</th><th>LAST SYNC</th><th>COUNT</th><th>STATUS</th><th>ACTIONS</th>
|
||||
</tr></thead><tbody>${feeds.map(f=>`<tr>
|
||||
<td>${esc(f.name)}</td>
|
||||
<td><span class="badge badge-dim">${srcLabel[f.source]||f.source}</span></td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.6rem">${esc(f.ics_url||'—')}</td>
|
||||
<td>${ts(f.last_sync)}</td>
|
||||
<td>${f.last_count||0}</td>
|
||||
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
|
||||
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
|
||||
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function calFeedModal(f={}) {
|
||||
const id = f.id||0;
|
||||
openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', `
|
||||
<div class="form-row"><label>NAME</label><input id="cf-name" class="inp" value="${esc(f.name||'')}"></div>
|
||||
<div class="form-row"><label>SOURCE</label>
|
||||
<select id="cf-source" class="inp">
|
||||
<option value="google"${f.source==='google'?' selected':''}>Google Calendar (ICS)</option>
|
||||
<option value="ics"${f.source==='ics'||!f.source?' selected':''}>ICS URL</option>
|
||||
<option value="caldav"${f.source==='caldav'?' selected':''}>CalDAV</option>
|
||||
<option value="outlook"${f.source==='outlook'?' selected':''}>Outlook (ICS)</option>
|
||||
</select></div>
|
||||
<div class="form-row"><label>ICS URL</label><input id="cf-ics" class="inp" value="${esc(f.ics_url||'')}" placeholder="https://..."></div>
|
||||
<div class="form-row"><label>USERNAME (optional)</label><input id="cf-user" class="inp" value="${esc(f.username||'')}"></div>
|
||||
<div class="form-row"><label>PASSWORD (optional)</label><input id="cf-pass" class="inp" type="password" placeholder="leave blank to keep"></div>
|
||||
<div class="form-row"><label>ACTIVE</label>
|
||||
<select id="cf-active" class="inp"><option value="1"${f.active!==0?' selected':''}>Yes</option><option value="0"${f.active===0?' selected':''}>No</option></select></div>
|
||||
`, () => 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';
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+64
-9
@@ -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{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLANNER MINI PANEL -->
|
||||
<div id="plannerMiniPanel" class="panel" style="flex:0 0 auto;max-height:220px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div class="panel-title" style="display:flex;align-items:center;gap:8px">
|
||||
PLANNER
|
||||
<span id="planner-badge" style="font-size:0.55rem;color:var(--cyan);margin-left:auto"></span>
|
||||
<a href="/admin" target="_blank" style="font-size:0.55rem;color:var(--text-dim);text-decoration:none;letter-spacing:1px">ADMIN ↗</a>
|
||||
</div>
|
||||
<div id="planner-tasks" style="overflow-y:auto;flex:1;font-size:0.62rem;line-height:1.7"></div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Arc Reactor + Chat -->
|
||||
<div id="centerPanel">
|
||||
<div id="arcReactor">
|
||||
@@ -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 = '<div style="color:var(--text-dim);font-size:0.6rem;padding:4px 0">No tasks or appointments today.</div>';
|
||||
} else {
|
||||
if (appts.length) {
|
||||
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin-bottom:3px">TODAY'S SCHEDULE</div>';
|
||||
html += appts.map(a => `<div class="appt-row"><span class="appt-time">${fmtTime(a.start_at)}</span><span>${a.title}</span>${a.location?'<span style="color:var(--text-dim);font-size:0.55rem"> · '+a.location+'</span>':''}</div>`).join('');
|
||||
}
|
||||
if (tasks.length) {
|
||||
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin:5px 0 3px">TASKS DUE</div>';
|
||||
html += tasks.map(t => `<div class="task-item"><span class="pri-dot ${priClass[t.priority]||'pri-normal'}"></span><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.title}</span>${t._overdue?'<span style="color:#f66;font-size:0.55rem">OVERDUE</span>':''}</div>`).join('');
|
||||
}
|
||||
if (d.pending_count > tasks.length) {
|
||||
html += `<div style="color:var(--text-dim);font-size:0.55rem;padding:3px 0">${d.pending_count} pending total</div>`;
|
||||
}
|
||||
}
|
||||
pEl.innerHTML = html;
|
||||
const total = tasks.length + appts.length;
|
||||
if (badge) badge.textContent = total ? total + ' TODAY' : '';
|
||||
}
|
||||
|
||||
// ── ALERTS ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user