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;
 | 
						|
    }
 | 
						|
}
 |