From fe6ef490cf8916515dd2bc4232e7adf4785cd1cf Mon Sep 17 00:00:00 2001 From: Omar Pakker Date: Thu, 28 May 2026 23:14:46 +0200 Subject: [PATCH] Add ICS Calendars plugin --- config/services.yaml | 6 + src/Command/SyncICSCalendars.php | 60 ++++++++ src/Controller/DAVController.php | 12 +- src/Entity/CalendarInstance.php | 2 +- src/Plugins/ICSCalendarsPlugin.php | 64 +++++++++ src/Services/ICSCalendarsService.php | 207 +++++++++++++++++++++++++++ 6 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 src/Command/SyncICSCalendars.php create mode 100644 src/Plugins/ICSCalendarsPlugin.php create mode 100644 src/Services/ICSCalendarsService.php diff --git a/config/services.yaml b/config/services.yaml index 25662b1..ceda1d1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,6 +13,8 @@ parameters: default_birthday_reminder_offset: "PT9H" caldav_enabled: "%env(bool:CALDAV_ENABLED)%" carddav_enabled: "%env(bool:CARDDAV_ENABLED)%" + icscalendars_enabled: "%env(default:default_icscalendars_enabled:bool:ICSCALENDARS_ENABLED)%" + default_icscalendars_enabled: "0" services: # default configuration for services in *this* file @@ -78,6 +80,10 @@ services: arguments: $birthdayReminderOffset: "%birthday_reminder_offset%" + App\Services\ICSCalendarsService: + arguments: + $enabled: "%icscalendars_enabled%" + App\Security\ApiKeyAuthenticator: arguments: $apiKey: "%env(API_KEY)%" diff --git a/src/Command/SyncICSCalendars.php b/src/Command/SyncICSCalendars.php new file mode 100644 index 0000000..2bd3b21 --- /dev/null +++ b/src/Command/SyncICSCalendars.php @@ -0,0 +1,60 @@ +getManager(); + $pdo = $em->getConnection()->getNativeConnection(); + $this->icsService->setBackend(new CalendarBackend($pdo)); + } + + protected function configure(): void + { + $this + ->setName('dav:sync-ics-calendars') + ->setDescription('Synchronizes the ICS calendars'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if (!$this->icsService->isEnabled()) { + $output->writeln('ICS calendars are disabled.'); + $output->writeln('Enable ICS calendars by setting ICSCALENDARS_ENABLED=true.'); + return self::SUCCESS; + } + + $output->writeln('Start ICS calendars sync ...'); + $p = new ProgressBar($output); + $p->start(); + + $subscriptions = $this->doctrine->getRepository(CalendarSubscription::class)->findAll(); + + foreach ($subscriptions as $subscription) { + $p->advance(); + $this->icsService->sync($subscription); + } + + $p->finish(); + $output->writeln(''); + + return self::SUCCESS; + } +} diff --git a/src/Controller/DAVController.php b/src/Controller/DAVController.php index abb1e5d..cc7a3cd 100644 --- a/src/Controller/DAVController.php +++ b/src/Controller/DAVController.php @@ -7,12 +7,13 @@ use App\Plugins\BirthdayCalendarPlugin; use App\Plugins\DavisIMipPlugin; use App\Plugins\PublicAwareDAVACLPlugin; +use App\Plugins\ICSCalendarsPlugin; use App\Services\BasicAuth; use App\Services\BirthdayService; +use App\Services\ICSCalendarsService; use App\Services\IMAPAuth; use App\Services\LDAPAuth; use Doctrine\ORM\EntityManagerInterface; -use PDO; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -107,6 +108,11 @@ class DAVController extends AbstractController */ protected $birthdayService; + /** + * @var ICSCalendarsService + */ + protected $icsCalendarsService; + /** * Base URI of the server. * @@ -149,7 +155,7 @@ class DAVController extends AbstractController */ protected $server; - public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, BirthdayService $birthdayService, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null) + public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, BirthdayService $birthdayService, ICSCalendarsService $icsCalendarsService, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null) { $this->publicDir = $publicDir; @@ -167,6 +173,7 @@ public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend $this->logger = $logger; $this->mailer = $mailer; $this->birthdayService = $birthdayService; + $this->icsCalendarsService = $icsCalendarsService; $this->baseUri = $router->generate('dav', ['path' => '']); $this->basicAuthBackend = $basicAuthBackend; @@ -273,6 +280,7 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL if ($this->inviteAddress) { $this->server->addPlugin(new DavisIMipPlugin($this->mailer, $this->inviteAddress, $this->publicDir)); } + $this->server->addPlugin(new ICSCalendarsPlugin($this->icsCalendarsService, $calendarBackend)); } // CardDAV plugins diff --git a/src/Entity/CalendarInstance.php b/src/Entity/CalendarInstance.php index 1929272..4082cea 100644 --- a/src/Entity/CalendarInstance.php +++ b/src/Entity/CalendarInstance.php @@ -140,7 +140,7 @@ public function isPublic(): bool public function isAutomaticallyGenerated(): bool { - return in_array($this->uri, [Constants::BIRTHDAY_CALENDAR_URI]); + return $this->uri === Constants::BIRTHDAY_CALENDAR_URI || str_ends_with($this->uri, '.ics'); } public function getDisplayName(): ?string diff --git a/src/Plugins/ICSCalendarsPlugin.php b/src/Plugins/ICSCalendarsPlugin.php new file mode 100644 index 0000000..4cd8715 --- /dev/null +++ b/src/Plugins/ICSCalendarsPlugin.php @@ -0,0 +1,64 @@ +icsService = $icsService; + $this->icsService->setBackend($calendarBackend); + } + + public function initialize(Server $server) + { + $this->server = $server; + + // Hook into creation + $server->on('afterBind', [$this, 'afterSubscriptionCreate']); + + // Hook into deletion + // Note: The node no longer exists after unbind so we hook in before + $server->on('beforeUnbind', [$this, 'beforeSubscriptionDelete']); + } + + public function afterSubscriptionCreate(string $path): void + { + $node = $this->server->tree->getNodeForPath($path); + if (!$node instanceof ISubscription) { + return; + } + + $this->icsService->onSubscriptionCreate($node->getProperties(['id'])['id']); + } + + public function beforeSubscriptionDelete(string $path): void + { + $node = $this->server->tree->getNodeForPath($path); + if (!$node instanceof ISubscription) { + return; + } + + $this->icsService->onSubscriptionDelete($node->getProperties(['id'])['id']); + } + + public function getPluginInfo() + { + return [ + 'name' => $this->getPluginName(), + 'description' => 'Creates calendars for Subscriptions.', + 'link' => 'https://github.com/tchapi/davis', + ]; + } +} diff --git a/src/Services/ICSCalendarsService.php b/src/Services/ICSCalendarsService.php new file mode 100644 index 0000000..90688aa --- /dev/null +++ b/src/Services/ICSCalendarsService.php @@ -0,0 +1,207 @@ +enabled; + } + + public function setBackend(CalendarBackend $calendarBackend): void + { + $this->calendarBackend = $calendarBackend; + } + + public function sync(CalendarSubscription $subscription): void + { + if (!$this->isEnabled()) { + return; + } + + $vcalendar = $this->retrieve($subscription->getSource()); + if ($vcalendar === null) { + return; + } + + $principalUri = $subscription->getPrincipalUri(); + $calendarUri = sha1($subscription->getSource()).'.ics'; + $calendarInstance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => $calendarUri]); + + if ($calendarInstance === null) { + $em = $this->doctrine->getManager(); + + $calendarInstance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['uri' => $calendarUri]); + if ($calendarInstance === null) { + $calendarComponents = [Calendar::COMPONENT_EVENTS, Calendar::COMPONENT_NOTES]; + if ($subscription->getStripTodos() === null) { + $calendarComponents[] = Calendar::COMPONENT_TODOS; + } + + $calendar = new Calendar(); + $calendar->setComponents(implode(',', $calendarComponents)); + $em->persist($calendar); + } else { + $calendar = $calendarInstance->getCalendar(); + } + + $calendarInstance = (new CalendarInstance()) + ->setPrincipalUri($principalUri) + ->setDisplayName($subscription->getDisplayName()) + ->setDescription('Calendar mirror for subscription '.$subscription->getDisplayName()) + ->setAccess(SharingPlugin::ACCESS_READ) + ->setCalendarOrder($subscription->getCalendarOrder()) + ->setCalendarColor($subscription->getCalendarColor()) + ->setCalendar($calendar) + ->setShareInviteStatus(SharingPlugin::INVITE_ACCEPTED) + ->setUri($calendarUri); + $em->persist($calendarInstance); + $em->flush(); + } else { + $calendar = $calendarInstance->getCalendar(); + } + + $existingUris = []; + foreach ($calendar->getObjects() as $object) { + $existingUris[] = $object->getUri(); + } + + $elements = ['VEVENT', 'VJOURNAL']; + if ($subscription->getStripTodos() === null) { + $elements[] = 'VTODO'; + } + + $seenUris = []; + $backendId = [$calendar->getId(), $calendarInstance->getId()]; + foreach ($elements as $element) { + foreach ($vcalendar->select($element) as $event) { + $uid = $event->UID->getValue(); + if ($uid === null) { + continue; + } + + // Stable URI derived from UID + $objectUri = sha1($uid).'.ics'; + $seenUris[] = $objectUri; + + if ($subscription->getStripAlarms() !== null) { + $event->remove('VALARM'); + } + + if ($subscription->getStripAttachments() !== null) { + $event->remove('ATTACH'); + } + + $eventCalendar = new VCalendar(); + $eventCalendar->add($event); + + if (in_array($objectUri, $existingUris, true)) { + $this->calendarBackend->updateCalendarObject( + $backendId, + $objectUri, + $eventCalendar->serialize() + ); + } else { + $this->calendarBackend->createCalendarObject( + $backendId, + $objectUri, + $eventCalendar->serialize() + ); + } + } + } + + foreach ($existingUris as $uri) { + if (!in_array($uri, $seenUris, true)) { + $this->calendarBackend->deleteCalendarObject( + $backendId, + $uri + ); + } + } + } + + public function onSubscriptionCreate(int $subscriptionId): void + { + if (!$this->isEnabled()) { + return; + } + + $subscription = $this->doctrine->getRepository(CalendarSubscription::class)->findOneById($subscriptionId); + $this->sync($subscription); + } + + public function onSubscriptionDelete(int $subscriptionId): void + { + if (!$this->isEnabled()) { + return; + } + + $subscription = $this->doctrine->getRepository(CalendarSubscription::class)->findOneById($subscriptionId); + + $principalUri = $subscription->getPrincipalUri(); + $calendarUri = sha1($subscription->getSource()).'.ics'; + $calendarInstance = $this->doctrine->getRepository(CalendarInstance::class)->findOneBy(['principalUri' => $principalUri, 'uri' => $calendarUri]); + if ($calendarInstance === null) { + return; + } + + $em = $this->doctrine->getManager(); + + $em->remove($calendarInstance); + + $calendar = $calendarInstance->getCalendar(); + $calendar->getInstances()->removeElement($calendarInstance); + if ($calendar->getInstances()->isEmpty()) { + foreach ($calendar->getObjects() as $object) { + $em->remove($object); + } + foreach ($calendar->getChanges() as $change) { + $em->remove($change); + } + + $em->remove($calendar); + } + + $em->flush(); + } + + private function retrieve(string $url): ?VCalendar + { + $response = $this->client->request('GET', $url); + if ($response->getStatusCode() !== 200) { + return null; + } + + try { + $vcal = Reader::read($response->getContent()); + if ($vcal instanceof VCalendar) { + return $vcal; + } + } catch (\Exception $e) { } + + return null; + } +}