subscription = $subscription; } public function handle(): void { // normalize the source a bit so comparisons/logging are consistent $source = rtrim(trim((string) $this->subscription->source), '/'); // 1) download the remote feed try { $resp = Http::retry(3, 5000) ->timeout(30) ->withHeaders([ 'User-Agent' => 'Kithkin CalDAV Bot', 'Accept' => 'text/calendar, text/plain;q=0.9, */*;q=0.8', ]) ->get($source); $resp->throw(); $body = $resp->body(); $contentType = (string) $resp->header('Content-Type'); } catch (ConnectionException | RequestException | \Throwable $e) { $this->markSyncFailed('fetch_failed', $e->getMessage()); Log::warning('Feed fetch failed', [ 'sub' => $this->subscription->id, 'url' => $source, 'msg' => $e->getMessage(), ]); $this->fail($e); return; } // 2) ensure we actually got an ICS payload, not HTML or other content if (! $this->looksLikeIcs($body)) { $this->markSyncFailed('not_ical', 'remote feed did not return vcALENDAR data'); Log::warning('Feed did not return iCalendar data', [ 'sub' => $this->subscription->id, 'url' => $source, 'status' => $resp->status(), 'content_type' => $contentType ?? null, 'starts_with' => substr(ltrim($body), 0, 200), ]); $this->fail(new \RuntimeException('Remote feed did not return VCALENDAR data.')); return; } // 3) get or create mirror calendar $meta = $this->subscription->meta()->first() ?? $this->subscription->meta()->create(); if (! $meta->calendar_id) { $meta->calendar_id = $this->createMirrorCalendar($meta, $source); $meta->save(); } $calendarId = (int) $meta->calendar_id; // 4) parse the VCALENDAR try { $vcalendar = Reader::read($body); } catch (ParseException | \Throwable $e) { $this->markSyncFailed('parse_failed', $e->getMessage()); Log::warning('ICS parse failed', [ 'sub' => $this->subscription->id, 'url' => $source, 'msg' => $e->getMessage(), 'starts_with' => substr(ltrim($body), 0, 200), ]); $this->fail($e); return; } Log::info('Syncing subscription', [ 'sub' => $this->subscription->id, 'url' => $source, ]); // 5) upsert events foreach (($vcalendar->VEVENT ?? []) as $vevent) { $uid = isset($vevent->UID) ? (string) $vevent->UID : null; $dtStart = $vevent->DTSTART ?? null; // skip malformed events (rare, but it happens) if (! $uid || ! $dtStart) { continue; } $now = now()->timestamp; $blob = (string) $vevent->serialize(); $object = Event::updateOrCreate( ['uid' => $uid, 'calendarid' => $calendarId], [ 'uri' => Str::uuid().'.ics', 'lastmodified' => $now, 'etag' => md5($blob), 'size' => strlen($blob), 'componenttype' => 'VEVENT', 'calendardata' => $blob, ] ); // sabre gives DateTime objects here; Carbon::instance is safest $start = Carbon::instance($dtStart->getDateTime())->utc(); $end = isset($vevent->DTEND) ? Carbon::instance($vevent->DTEND->getDateTime())->utc() : $start; EventMeta::upsertForEvent($object->id, [ 'title' => (string) ($vevent->SUMMARY ?? 'Untitled'), 'description' => (string) ($vevent->DESCRIPTION ?? ''), 'location' => (string) ($vevent->LOCATION ?? ''), 'all_day' => $dtStart->isFloating(), 'start_at' => $start, 'end_at' => $end, ]); } // sync is ok $this->markSyncOk(); Log::info('Syncing subscription complete', [ 'sub' => $this->subscription->id, ]); } private function looksLikeIcs(string $body): bool { // cheap + effective: an .ics must include BEGIN:VCALENDAR // allow leading whitespace/bom $haystack = ltrim($body); return str_contains($haystack, 'BEGIN:VCALENDAR'); } private function mirrorDescription(string $source): string { return 'Remote feed: '.$source; } /** * Lazily builds the shadow calendar + instance when missing. */ private function createMirrorCalendar($meta, string $source): int { if ($meta->calendar_id) { return (int) $meta->calendar_id; } $desc = $this->mirrorDescription($source); $existing = CalendarInstance::where('principaluri', $this->subscription->principaluri) ->where('description', $desc) ->first(); if ($existing) { return (int) $existing->calendarid; } $calendar = Calendar::create([ 'synctoken' => 1, 'components' => 'VEVENT', ]); CalendarInstance::create([ 'calendarid' => $calendar->id, 'principaluri' => $this->subscription->principaluri, 'uri' => (string) Str::uuid(), 'displayname' => $this->subscription->displayname, 'description' => $desc, 'calendarcolor' => $meta->color ?? '#1a1a1a', 'timezone' => config('app.timezone', 'UTC'), ]); return (int) $calendar->id; } /** * sync helpers */ private function markSyncOk(): void { $this->subscription->forceFill([ 'last_sync_status' => 'ok', 'last_sync_error' => null, 'last_sync_at' => now(), 'lastmodified' => now()->timestamp, // sabre uses int timestamps ])->save(); } private function markSyncFailed(string $status, string $message): void { $this->subscription->forceFill([ 'last_sync_status' => $status, 'last_sync_error' => mb_substr($message, 0, 5000), 'last_sync_at' => now(), 'lastmodified' => now()->timestamp, ])->save(); } }