161 lines
5.3 KiB
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;
|
|
}
|
|
}
|