0, 'pulled' => 0, 'deleted' => 0, 'conflicts' => 0]; public function __construct(PDO $db) { $this->db = $db; } // ── Encryption helpers ── private function getEncKey(): string { $vaultKey = $this->db->query("SELECT value FROM settings WHERE key = 'vault_key'")->fetchColumn(); if ($vaultKey) return substr(hash('sha256', $vaultKey), 0, 32); // Fallback: old method (password hash) for migration $hash = $this->db->query("SELECT value FROM settings WHERE key = 'password_hash'")->fetchColumn(); return substr(hash('sha256', $hash ?: 'hyprcrd-default-key'), 0, 32); } private function encrypt(string $plain): string { $key = $this->getEncKey(); $iv = random_bytes(16); $cipher = openssl_encrypt($plain, 'aes-256-cbc', $key, 0, $iv); return base64_encode($iv . base64_decode($cipher)); } private function decrypt(string $encrypted): string { $key = $this->getEncKey(); $raw = base64_decode($encrypted); $iv = substr($raw, 0, 16); $cipher = base64_encode(substr($raw, 16)); return openssl_decrypt($cipher, 'aes-256-cbc', $key, 0, $iv) ?: ''; } // ── Settings ── public function getSetting(string $key): ?string { return $this->db->query("SELECT value FROM settings WHERE key = 'caldav_{$key}'")->fetchColumn() ?: null; } public function setSetting(string $key, ?string $value): void { $this->db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (:k, :v)") ->execute(['k' => "caldav_{$key}", 'v' => $value ?? '']); } public function getSettings(): array { $url = $this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : ''; $user = $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''; return [ 'enabled' => $this->getSetting('enabled') === '1', 'url' => $url, 'username' => $user, 'has_password' => !empty($this->getSetting('password')), 'calendar' => $this->getSetting('calendar') ?? '', 'last_sync' => $this->getSetting('last_sync') ?? '', ]; } public function saveSettings(array $input): void { if (!empty($input['url'])) $this->setSetting('url', $this->encrypt($input['url'])); if (!empty($input['username'])) $this->setSetting('username', $this->encrypt($input['username'])); if (!empty($input['password'])) $this->setSetting('password', $this->encrypt($input['password'])); if (isset($input['calendar'])) $this->setSetting('calendar', $input['calendar']); if (isset($input['enabled'])) $this->setSetting('enabled', $input['enabled'] ? '1' : '0'); } // ── Client ── private function getClient(?string $url = null, ?string $user = null, ?string $pass = null): CalDavClient { if (!$this->client) { $this->client = new CalDavClient( $url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : ''), $user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''), $pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : '') ); } return $this->client; } public function testConnection(?string $url = null, ?string $user = null, ?string $pass = null): array { $log = []; try { $resolvedUrl = $url ?? ($this->getSetting('url') ? $this->decrypt($this->getSetting('url')) : ''); $resolvedUser = $user ?? ($this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''); $resolvedPass = $pass ?? ($this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : ''); $log[] = 'URL: ' . preg_replace('#(https?://)[^@/]+:[^@/]+@#', '$1***:***@', $resolvedUrl); $log[] = 'User: ' . substr($resolvedUser, 0, 3) . '***'; if (!$resolvedUrl) { $log[] = 'ERROR: no URL configured'; return ['ok' => false, 'error' => 'no URL', 'log' => $log]; } if (!$resolvedUser) { $log[] = 'ERROR: no username'; return ['ok' => false, 'error' => 'no username', 'log' => $log]; } if (!$resolvedPass) { $log[] = 'ERROR: no password'; return ['ok' => false, 'error' => 'no password', 'log' => $log]; } $client = new CalDavClient($resolvedUrl, $resolvedUser, $resolvedPass); $log[] = 'PROPFIND depth 0...'; $res = $client->request('PROPFIND', $resolvedUrl, '', ['Depth: 0']); $log[] = "HTTP {$res['status']}" . ($res['error'] ? " — {$res['error']}" : ''); if ($res['status'] === 0) { $log[] = 'ERROR: could not connect (check URL, network, SSL)'; return ['ok' => false, 'error' => $res['error'] ?: 'connection failed', 'log' => $log]; } if ($res['status'] === 401) { $log[] = 'ERROR: authentication failed (check username/password)'; return ['ok' => false, 'error' => '401 unauthorized', 'log' => $log]; } if ($res['status'] === 404) { $log[] = 'ERROR: calendar not found (check URL path)'; return ['ok' => false, 'error' => '404 not found', 'log' => $log]; } if ($res['status'] !== 207) { $log[] = 'ERROR: unexpected status ' . $res['status']; $log[] = substr($res['body'], 0, 300); return ['ok' => false, 'error' => "HTTP {$res['status']}", 'log' => $log]; } $log[] = 'OK — listing calendars (PROPFIND depth 1)...'; $calendars = $client->listCalendars(); $log[] = count($calendars) . ' calendar(s) found'; foreach ($calendars as $c) $log[] = " - {$c['name']} ({$c['href']})"; if (!$calendars) { // Debug: show raw PROPFIND response $debugRes = $client->request('PROPFIND', $resolvedUrl, '', ['Depth: 1']); $log[] = 'DEBUG depth 1 status: ' . $debugRes['status']; // Count responses in XML $responseCount = substr_count($debugRes['body'], ' true, 'calendars' => $calendars, 'log' => $log]; } catch (Exception $e) { $log[] = 'EXCEPTION: ' . $e->getMessage(); return ['ok' => false, 'error' => $e->getMessage(), 'log' => $log]; } } // ── Field mapping ── public function cardToVEvent(array $card): string { $title = $card['title'] ?? ''; $body = $card['body'] ?? ''; // Parse title: "YYYY-MM-DD — Subject" or "YYYY-MM-DD" preg_match('/^(\d{4}-\d{2}-\d{2})\s*(?:—|-)\s*(.*)$/', $title, $tm); $date = $tm[1] ?? substr($title, 0, 10); $summary = trim($tm[2] ?? $title); // Parse body fields $time = ''; $timeEnd = ''; if (preg_match('/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})(?:\s*-\s*(\d{1,2}:\d{2}))?/i', $body, $zm)) { $time = $zm[1]; $timeEnd = $zm[2] ?? ''; } $location = ''; if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]); $description = ''; if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?=\n#[a-z]|\z)/i', $body, $nm)) $description = trim($nm[1]); // Build DTSTART/DTEND $dateCompact = str_replace('-', '', $date); if ($time) { $dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00'; if ($timeEnd) { $dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00'; } else { // Default: +1 hour $ts = strtotime($date . ' ' . $time); $dtend = date('Ymd\THis', $ts + 3600); } } else { $dtstart = $dateCompact; // All-day: DTEND = next day $dtend = date('Ymd', strtotime($date . ' +1 day')); } $uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd'; // Detect birthday → add RRULE:FREQ=YEARLY $tags = array_map('trim', explode(',', $card['tags'] ?? '')); $isBirthday = in_array('geburtstag', $tags); $rrule = $card['caldav_rrule'] ?? ($isBirthday ? 'FREQ=YEARLY' : null); return CalDavClient::buildVEvent([ 'uid' => $uid, 'dtstart' => $dtstart, 'dtend' => $dtend, 'summary' => $summary, 'location' => $location, 'rrule' => $rrule, 'description' => $description, 'color' => $card['color'] ?? '#fb923c', 'sequence' => 0, ]); } public function vEventToCard(array $vevent, ?array $existing = null, ?string $calendarName = null): array { $dt = $vevent['dtstart'] ?? ''; if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) { $date = "{$dm[1]}-{$dm[2]}-{$dm[3]}"; } else { $date = date('Y-m-d'); } $time = ''; $timeEnd = ''; if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) { $time = "{$hm[1]}:{$hm[2]}"; } if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) { $timeEnd = "{$hm2[1]}:{$hm2[2]}"; } $summary = $vevent['summary'] ?? 'Termin'; $title = $summary ? "{$date} — {$summary}" : $date; // Determine tag from calendar name $isBirthdayCal = $calendarName && (stripos($calendarName, 'geburtstag') !== false || stripos($calendarName, 'birthday') !== false); $tag = $isBirthdayCal ? '#geburtstag' : '#termin'; if ($calendarName && !$isBirthdayCal) { // Add calendar name as additional tag $calTag = strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', $calendarName)); if ($calTag) $tag .= " #{$calTag}"; } $zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : ''; $body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}"; // Color: birthdays pink, others orange $defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c'; $card = [ 'title' => $title, 'body' => $body, 'color' => $existing['color'] ?? $defaultColor, 'bucket_id' => $existing['bucket_id'] ?? null, 'parent_id' => $existing['parent_id'] ?? null, 'position' => $existing['position'] ?? -1, ]; return $card; } private function eventDate(array $vevent): ?string { $dt = $vevent['dtstart'] ?? ''; if (preg_match('/^(\d{4})(\d{2})(\d{2})/', $dt, $dm)) { return "{$dm[1]}-{$dm[2]}-{$dm[3]}"; } return null; } private function resolveCalendarUrls(CalDavClient $client): array { $parsed = parse_url($client->getBaseUrl()); $base = $parsed['scheme'] . '://' . $parsed['host'] . (isset($parsed['port']) ? ':' . $parsed['port'] : ''); $calendars = $client->listCalendars(); if (empty($calendars)) { return ['all' => [$client->getBaseUrl()], 'push' => $client->getBaseUrl(), 'push_birthday' => $client->getBaseUrl()]; } $allUrls = []; $pushUrl = null; $birthdayUrl = null; $preferred = $this->getSetting('calendar'); foreach ($calendars as $cal) { $url = $base . $cal['href']; $allUrls[] = $url; $this->addLog('sync', null, null, 'init', 'ok', "found calendar: {$cal['name']}"); if ($preferred && ($cal['name'] === $preferred || str_contains($cal['href'], $preferred))) { $pushUrl = $url; } if (stripos($cal['name'], 'geburtstag') !== false || stripos($cal['name'], 'birthday') !== false) { $birthdayUrl = $url; } } // Default push: first non-birthday/non-contact calendar if (!$pushUrl) { foreach ($calendars as $cal) { if (stripos($cal['name'], 'geburtstag') === false && stripos($cal['name'], 'birthday') === false && stripos($cal['name'], 'contact') === false) { $pushUrl = $base . $cal['href']; break; } } if (!$pushUrl) $pushUrl = $allUrls[0]; } if (!$birthdayUrl) $birthdayUrl = $pushUrl; $this->addLog('sync', null, null, 'init', 'ok', "push termine: {$pushUrl}"); $this->addLog('sync', null, null, 'init', 'ok', "push geburtstage: {$birthdayUrl}"); return ['all' => $allUrls, 'push' => $pushUrl, 'push_birthday' => $birthdayUrl]; } private function makeClient(string $url): CalDavClient { $user = $this->getSetting('username') ? $this->decrypt($this->getSetting('username')) : ''; $pass = $this->getSetting('password') ? $this->decrypt($this->getSetting('password')) : ''; return new CalDavClient($url, $user, $pass); } private function isBirthday(array $card): bool { $tags = array_map('trim', explode(',', $card['tags'] ?? '')); return in_array('geburtstag', $tags); } private function getPushClient(array $card, CalDavClient $pushClient, CalDavClient $birthdayClient, array $allUrls): CalDavClient { if ($this->isBirthday($card)) return $birthdayClient; // Match calendar tag to URL slug $tags = array_map('trim', explode(',', $card['tags'] ?? '')); foreach ($tags as $tag) { if ($tag && $tag !== 'termin' && $tag !== 'geburtstag') { foreach ($allUrls as $url) { $slug = basename(rtrim($url, '/')); if ($slug === $tag) { $this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', "tag #{$tag} → {$slug}"); return $this->makeClient($url); } } $this->addLog('sync', $card['id'] ?? null, null, 'route', 'error', "tag #{$tag} no match in: " . implode(', ', array_map(fn($u) => basename(rtrim($u, '/')), $allUrls))); } } $this->addLog('sync', $card['id'] ?? null, null, 'route', 'ok', 'using default push calendar'); return $pushClient; } // ── Sync ── private function addLog(string $dir, ?int $cardId, ?string $uid, string $action, string $status, string $detail = ''): void { $this->log[] = compact('dir', 'cardId', 'uid', 'action', 'status', 'detail'); $this->db->prepare('INSERT INTO caldav_sync_log (direction, card_id, caldav_uid, action, status, detail) VALUES (?, ?, ?, ?, ?, ?)') ->execute([$dir, $cardId, $uid, $action, $status, $detail]); } public function sync(): array { // Skip if no credentials configured $url = $this->getSetting('url'); $pass = $this->getSetting('password'); if (!$url || !$pass) { return ['ok' => true, 'log' => [], 'stats' => $this->stats]; } $baseClient = $this->getClient(); // 0. Resolve all calendars $urls = $this->resolveCalendarUrls($baseClient); if (empty($urls['all'])) { $this->addLog('sync', null, null, 'init', 'error', 'no calendar found — check URL'); return ['ok' => false, 'error' => 'no calendar found', 'log' => $this->log, 'stats' => $this->stats]; } $pushClient = $this->makeClient($urls['push']); $birthdayClient = $this->makeClient($urls['push_birthday']); // 1-2. Fetch remote events from ALL calendars $remoteMap = []; // uid -> event data (gets modified during reconciliation) $remoteAll = []; // uid -> event data (unmodified copy for tombstone lookups) $remoteEvents = []; foreach ($urls['all'] as $calUrl) { $calSlug = basename(rtrim($calUrl, '/')); $cal = $this->makeClient($calUrl); $res = $cal->request('REPORT', $calUrl, '', ['Depth: 1']); $this->addLog('sync', null, null, 'fetch', $res['status'] == 207 ? 'ok' : 'error', "REPORT {$calSlug} HTTP {$res['status']}"); $events = $cal->listEvents(); $this->addLog('sync', null, null, 'fetch', 'ok', count($events) . " events from {$calSlug}"); $parseFail = 0; foreach ($events as $ev) { $parsed = CalDavClient::parseVEvent($ev['ical']); if ($parsed && $parsed['uid']) { $data = array_merge($parsed, ['href' => $ev['href'], 'etag' => $ev['etag'], 'ical' => $ev['ical'], 'cal_url' => $calUrl]); $remoteMap[$parsed['uid']] = $data; $remoteAll[$parsed['uid']] = $data; $remoteEvents[] = $ev; } else { $parseFail++; } } if ($parseFail) $this->addLog('sync', null, null, 'fetch', 'error', "{$parseFail} events failed to parse from {$calSlug}"); } $this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events"); // 3. Fetch local appointment cards — only #termin tagged or already synced $stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL"); $localCards = $stmt->fetchAll(); $localMap = []; // caldav_uid -> card $localNew = []; // cards without caldav_uid foreach ($localCards as $c) { if ($c['caldav_uid']) { $localMap[$c['caldav_uid']] = $c; } else { $localNew[] = $c; } } // 4. Tombstones $tombstones = []; foreach ($this->db->query("SELECT * FROM caldav_tombstones")->fetchAll() as $t) { $tombstones[$t['caldav_uid']] = $t; } // 5. Reconciliation // A: Local cards with UID — update or detect remote deletion foreach ($localMap as $uid => $card) { if (isset($remoteMap[$uid])) { $remote = $remoteMap[$uid]; $remoteClient = $this->makeClient($remote['cal_url']); // Compare timestamps with 2s tolerance to avoid false positives from sync-triggered updates $localTime = strtotime($card['updated_at'] ?? '2000-01-01'); $syncTime = strtotime($card['caldav_synced_at'] ?? '2000-01-01'); $localChanged = !$card['caldav_synced_at'] || ($localTime - $syncTime) > 2; $remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag']; // Always backfill rrule if missing locally but present remotely $remoteRRule = $remote['rrule'] ?? null; if ($remoteRRule && $card['caldav_rrule'] !== $remoteRRule) { $this->db->prepare('UPDATE cards SET caldav_rrule = ? WHERE id = ?') ->execute([$remoteRRule, $card['id']]); $this->addLog('pull', $card['id'], $uid, 'rrule-fix', 'ok', $remoteRRule); } if ($localChanged && $remoteChanged) { $remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0; if ($localTime >= $remoteTime) { $this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']); } else { $this->pullEvent($remote, $card); } $this->stats['conflicts']++; } elseif ($localChanged) { $this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']); } elseif ($remoteChanged) { $this->pullEvent($remote, $card); } unset($remoteMap[$uid]); } else { // Event not on server — was it deleted remotely or never pushed? $localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at']; $wasSynced = !empty($card['caldav_synced_at']); if ($localChanged) { // Local edit or never synced → re-push $ical = $this->cardToVEvent($card); $targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']); $res = $targetClient->createEvent($card['caldav_uid'], $ical); $filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $card['caldav_uid']); if ($res['status'] >= 200 && $res['status'] < 300) { $this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $filename . '.ics'); $this->addLog('push', $card['id'], $uid, 'recreate', 'ok', $wasSynced ? 'was deleted, local edit wins' : 'first push'); $this->stats['pushed']++; } else { $this->addLog('push', $card['id'], $uid, 'recreate', 'error', "HTTP {$res['status']}"); } } else { // No local changes + was synced before → remote intentionally deleted $title = $card['title'] ?? ''; $this->db->prepare('DELETE FROM cards WHERE id = ?')->execute([$card['id']]); $this->addLog('pull', $card['id'], $uid, 'delete', 'ok', "remote deleted: {$title}"); $this->stats['deleted']++; } } } // B: New local cards → push to appropriate calendar foreach ($localNew as $card) { $uid = 'card-' . $card['id'] . '@hyprcrd'; $filename = preg_replace('/[^a-zA-Z0-9._-]/', '-', $uid); $ical = $this->cardToVEvent(array_merge($card, ['caldav_uid' => $uid])); $targetClient = $this->getPushClient($card, $pushClient, $birthdayClient, $urls['all']); $targetUrl = $targetClient->getBaseUrl(); $targetSlug = basename(rtrim($targetUrl, '/')); $this->addLog('push', $card['id'], $uid, 'create', 'ok', "→ {$targetSlug} ({$targetUrl}) tags={$card['tags']}"); $res = $targetClient->createEvent($uid, $ical); if ($res['status'] >= 200 && $res['status'] < 300) { $this->updateCardSync($card['id'], $uid, $res['etag'], $filename . '.ics'); $this->addLog('push', $card['id'], $uid, 'create', 'ok', "done in {$targetSlug}"); $this->stats['pushed']++; } else { $putUrl = rtrim($targetUrl, '/') . '/' . $filename . '.ics'; $this->addLog('push', $card['id'], $uid, 'create', 'error', "HTTP {$res['status']} PUT {$putUrl}" . ($res['error'] ? " — {$res['error']}" : '')); } } // C: New remote events from ALL calendars → pull (skip past non-recurring) $today = date('Y-m-d'); $skippedPast = 0; foreach ($remoteMap as $uid => $remote) { if (isset($tombstones[$uid])) continue; // Skip past events (unless recurring) $evDate = $this->eventDate($remote); $isRecurring = !empty($remote['rrule']); if ($evDate && $evDate < $today && !$isRecurring) { $skippedPast++; continue; } $calName = basename(rtrim($remote['cal_url'] ?? '', '/')); $cardData = $this->vEventToCard($remote, null, $calName); $tags = $this->extractTags($cardData['body']); $stmt = $this->db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position, tags, caldav_uid, caldav_etag, caldav_href, caldav_rrule, caldav_synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime("now"))'); $stmt->execute([$cardData['title'], $cardData['body'], $cardData['color'], $cardData['bucket_id'], $cardData['parent_id'], $cardData['position'], $tags, $uid, $remote['etag'], $remote['href'], $remote['rrule'] ?? null]); $this->addLog('pull', (int) $this->db->lastInsertId(), $uid, 'create', 'ok', "from {$calName}"); $this->stats['pulled']++; } if ($skippedPast) $this->addLog('sync', null, null, 'skip', 'ok', "{$skippedPast} past events skipped"); // D: Tombstones → delete from remote calendar foreach ($tombstones as $uid => $tomb) { if (isset($remoteAll[$uid])) { $remote = $remoteAll[$uid]; $delClient = $this->makeClient($remote['cal_url']); $res = $delClient->deleteEvent($remote['href'], $remote['etag']); if ($res['status'] >= 200 && $res['status'] < 300) { $this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', basename(rtrim($remote['cal_url'], '/'))); $this->stats['deleted']++; } else { $this->addLog('push', $tomb['card_id'], $uid, 'delete', 'error', "HTTP {$res['status']}"); } } else { $this->addLog('push', $tomb['card_id'], $uid, 'delete', 'ok', 'already gone from server'); } $this->db->prepare('DELETE FROM caldav_tombstones WHERE caldav_uid = ?')->execute([$uid]); } // 6. Update sync metadata $this->setSetting('last_sync', date('Y-m-d H:i:s')); return ['ok' => true, 'log' => $this->log, 'stats' => $this->stats]; } private function pushCard(CalDavClient $client, array $card, string $href, string $etag): void { $ical = $this->cardToVEvent($card); $res = $client->updateEvent($href, $ical, $etag); if ($res['status'] >= 200 && $res['status'] < 300) { $this->updateCardSync($card['id'], $card['caldav_uid'], $res['etag'], $href); $this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'ok'); $this->stats['pushed']++; } else { $this->addLog('push', $card['id'], $card['caldav_uid'], 'update', 'error', "HTTP {$res['status']}"); } } private function pullEvent(array $remote, array $card): void { $calName = basename(rtrim($remote['cal_url'] ?? '', '/')); $cardData = $this->vEventToCard($remote, $card, $calName); $tags = $this->extractTags($cardData['body']); $this->db->prepare('UPDATE cards SET title=?, body=?, tags=?, caldav_etag=?, caldav_href=?, caldav_rrule=?, caldav_synced_at=datetime("now"), updated_at=datetime("now") WHERE id=?') ->execute([$cardData['title'], $cardData['body'], $tags, $remote['etag'], $remote['href'], $remote['rrule'] ?? null, $card['id']]); $this->addLog('pull', $card['id'], $remote['uid'], 'update', 'ok'); $this->stats['pulled']++; } private function updateCardSync(int $cardId, string $uid, ?string $etag, string $href): void { $this->db->prepare('UPDATE cards SET caldav_uid=?, caldav_etag=?, caldav_href=?, caldav_synced_at=datetime("now") WHERE id=?') ->execute([$uid, $etag, $href, $cardId]); } private function extractTags(string $body): string { preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $body, $matches); return implode(',', array_unique(array_map('strtolower', $matches[1] ?? []))); } private function findEventByUid(array $events, string $uid): ?array { foreach ($events as $ev) { if (str_contains($ev['ical'], 'UID:' . $uid)) return $ev; } return null; } }