kithkin/app/Jobs/SyncSubscription.php

161 lines
5.3 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\Calendar;
use App\Models\CalendarInstance;
use App\Models\Event;
use App\Models\EventMeta;
use App\Models\Subscription;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Sabre\VObject\Reader;
/**
* Mirrors a remote iCalendar (ICS) feed into the local Sabre tables.
*
* • Runs in the background (ShouldQueue).
* • Ensures a hidden “mirror” calendar exists once per subscription.
* • Upserts every VEVENT into calendarobjects + event_meta.
*/
class SyncSubscription implements ShouldQueue
{
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
/** @var \App\Models\Subscription */
public Subscription $subscription;
/**
* @param Subscription $subscription The feed to sync.
*/
public function __construct(Subscription $subscription)
{
$this->subscription = $subscription;
}
/**
* Main entry-point executed by the queue worker.
*/
public function handle(): void
{
/**
* download the remote .ics file with retry and a long timeout */
try {
$ics = Http::retry(3, 5000)->timeout(30)
->withHeaders(['User-Agent' => 'Kithkin CalDAV Bot'])
->get($this->subscription->source)
->throw() // throws if not 2xx
->body();
} catch (ConnectionException | \Throwable $e) {
Log::warning('Feed fetch failed', [
'sub' => $this->subscription->id,
'msg' => $e->getMessage(),
]);
/* mark the job as failed and let Horizon / queue retry logic handle it */
$this->fail($e);
return;
}
/**
* get the mirror calendar, or lazy create it */
$meta = $this->subscription->meta ?? $this->subscription->meta()->create();
if (! $meta->calendar_id) {
$meta->calendar_id = $this->createMirrorCalendar($meta);
$meta->save();
}
$calendarId = $meta->calendar_id;
/**
* parse and upsert vevents */
$vcalendar = Reader::read($ics);
Log::info('Syncing subscription '.$this->subscription->id);
foreach ($vcalendar->VEVENT as $vevent) {
$uid = (string) $vevent->UID;
$now = now()->timestamp;
$blob = (string) $vevent->serialize();
/** @var Event $object */
$object = Event::updateOrCreate(
['uid' => $uid, 'calendarid' => $calendarId],
[
'uri' => Str::uuid().'.ics',
'lastmodified' => $now,
'etag' => md5($blob),
'size' => strlen($blob),
'componenttype' => 'VEVENT',
'calendardata' => $blob,
]
);
$startUtc = Carbon::parse($vevent->DTSTART->getDateTime());
$endUtc = isset($vevent->DTEND)
? Carbon::parse($vevent->DTEND->getDateTime())
: $startUtc;
EventMeta::upsertForEvent($object->id, [
'title' => (string) ($vevent->SUMMARY ?? 'Untitled'),
'description' => (string) ($vevent->DESCRIPTION ?? ''),
'location' => (string) ($vevent->LOCATION ?? ''),
'all_day' => $vevent->DTSTART->isFloating(),
'start_at' => $startUtc->utc(),
'end_at' => $endUtc->utc(),
]);
}
Log::info('Syncing subscription post foreach '.$this->subscription->id);
}
/**
* Lazily builds the shadow calendar + instance when missing
* (for legacy subscriptions added before we moved creation to the controller).
*/
private function createMirrorCalendar($meta): int
{
// check if controller created one already and return it if so
if ($meta->calendar_id) {
return $meta->calendar_id;
}
// check if a mirror calendar already exists, and return that if so
$existing = CalendarInstance::where('principaluri', $this->subscription->principaluri)
->where('description', 'Remote feed: '.$this->subscription->source)
->first();
if ($existing) {
return $existing->calendarid;
}
// otherwise create the new master calendar in `calendars`
$calendar = Calendar::create([
'synctoken' => 1,
'components' => 'VEVENT',
]);
// attach an instance for this user
CalendarInstance::create([
'calendarid' => $calendar->id,
'principaluri' => $this->subscription->principaluri,
'uri' => Str::uuid(),
'displayname' => $this->subscription->displayname,
'description' => 'Remote feed: '.$this->subscription->source,
'calendarcolor' => $meta->color ?? '#1a1a1a',
'timezone' => config('app.timezone', 'UTC'),
]);
return $calendar->id;
}
}