diff --git a/splitsh.json b/splitsh.json index 6cec132cefc..013dd9e3580 100644 --- a/splitsh.json +++ b/splitsh.json @@ -2,6 +2,7 @@ "subtrees": { "stimulus-bundle": "src/StimulusBundle", "ux-autocomplete": "src/Autocomplete", + "ux-calendar-link": "src/CalendarLink", "ux-dropzone": "src/Dropzone", "ux-chartjs": "src/Chartjs", "ux-cropperjs": "src/Cropperjs", diff --git a/src/CalendarLink/.gitattributes b/src/CalendarLink/.gitattributes new file mode 100644 index 00000000000..db1844b9e9f --- /dev/null +++ b/src/CalendarLink/.gitattributes @@ -0,0 +1,5 @@ +/.git* export-ignore +/.symfony.bundle.yaml export-ignore +/doc export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore diff --git a/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md b/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..887284ecea1 --- /dev/null +++ b/src/CalendarLink/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Please do not submit any Pull Requests here. They will be closed. + +Please submit your PR here instead: +https://github.com/symfony/ux + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/CalendarLink/.github/workflows/close-pull-request.yml b/src/CalendarLink/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000000..8150a6a6a7e --- /dev/null +++ b/src/CalendarLink/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/ux + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/CalendarLink/.gitignore b/src/CalendarLink/.gitignore new file mode 100644 index 00000000000..43da50110b8 --- /dev/null +++ b/src/CalendarLink/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/composer.lock +/phpunit.xml +/.phpunit.result.cache + +/var \ No newline at end of file diff --git a/src/CalendarLink/.symfony.bundle.yaml b/src/CalendarLink/.symfony.bundle.yaml new file mode 100644 index 00000000000..9e3566394b9 --- /dev/null +++ b/src/CalendarLink/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ['3.x'] +maintained_branches: ['3.x'] +doc_dir: 'doc' diff --git a/src/CalendarLink/CHANGELOG.md b/src/CalendarLink/CHANGELOG.md new file mode 100644 index 00000000000..b0411ae89a7 --- /dev/null +++ b/src/CalendarLink/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 3.1 + +- Add component diff --git a/src/CalendarLink/LICENSE b/src/CalendarLink/LICENSE new file mode 100644 index 00000000000..94b768f8ae8 --- /dev/null +++ b/src/CalendarLink/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/CalendarLink/README.md b/src/CalendarLink/README.md new file mode 100644 index 00000000000..091b049ae56 --- /dev/null +++ b/src/CalendarLink/README.md @@ -0,0 +1,18 @@ +# Symfony UX Calendar Link + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Calendar Link generates "Add to calendar" links for Google +Calendar, Outlook.com, Office 365 and iCalendar (`.ics`), the format +consumed by Apple Calendar, Outlook desktop, Thunderbird and every native +calendar client. It is part of [the Symfony UX initiative](https://symfony.com/ux). + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](doc/index.rst) +- Report issues and send Pull Requests in the + [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/CalendarLink/composer.json b/src/CalendarLink/composer.json new file mode 100644 index 00000000000..384966a2006 --- /dev/null +++ b/src/CalendarLink/composer.json @@ -0,0 +1,54 @@ +{ + "name": "symfony/ux-calendar-link", + "type": "symfony-bundle", + "description": "Generates 'Add to calendar' links for Google, Outlook, Office 365 and iCalendar (.ics) in Twig templates.", + "keywords": [ + "symfony-ux", + "calendar", + "ics" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Imad ZAIRIG", + "email": "imadzairig@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\CalendarLink\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\CalendarLink\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.4", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0" + }, + "config": { + "sort-packages": true + }, + "conflict": { + "symfony/flex": "<1.13" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/CalendarLink/config/services.php b/src/CalendarLink/config/services.php new file mode 100644 index 00000000000..0b06b738b1b --- /dev/null +++ b/src/CalendarLink/config/services.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; +use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator; + +use Symfony\UX\CalendarLink\Ics\IcsBuilder; +use Symfony\UX\CalendarLink\Provider\GoogleCalendarLinkProvider; +use Symfony\UX\CalendarLink\Provider\IcsCalendarLinkProvider; +use Symfony\UX\CalendarLink\Provider\Office365CalendarLinkProvider; +use Symfony\UX\CalendarLink\Provider\OutlookCalendarLinkProvider; +use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry; +use Symfony\UX\CalendarLink\Twig\UXCalendarLinkExtension; +use Symfony\UX\CalendarLink\Twig\UXCalendarLinkRuntime; + +return static function (ContainerConfigurator $container): void { + $services = $container->services(); + + $services->set('ux_calendar_link.ics.builder', IcsBuilder::class); + + $services->set('ux_calendar_link.provider.google', GoogleCalendarLinkProvider::class) + ->tag('ux_calendar_link.provider'); + + $services->set('ux_calendar_link.provider.outlook', OutlookCalendarLinkProvider::class) + ->tag('ux_calendar_link.provider'); + + $services->set('ux_calendar_link.provider.office365', Office365CalendarLinkProvider::class) + ->tag('ux_calendar_link.provider'); + + $services->set('ux_calendar_link.provider.ics', IcsCalendarLinkProvider::class) + ->args([service('ux_calendar_link.ics.builder')]) + ->tag('ux_calendar_link.provider'); + + $services->set('ux_calendar_link.registry', CalendarLinkProviderRegistry::class) + ->args([tagged_iterator('ux_calendar_link.provider')]); + + $services->alias(CalendarLinkProviderRegistry::class, 'ux_calendar_link.registry'); + + $services->set('ux_calendar_link.twig.runtime', UXCalendarLinkRuntime::class) + ->args([service('ux_calendar_link.registry')]) + ->tag('twig.runtime'); + + $services->set('ux_calendar_link.twig.extension', UXCalendarLinkExtension::class) + ->tag('twig.extension'); +}; diff --git a/src/CalendarLink/doc/index.rst b/src/CalendarLink/doc/index.rst new file mode 100644 index 00000000000..385fdd2626e --- /dev/null +++ b/src/CalendarLink/doc/index.rst @@ -0,0 +1,141 @@ +Symfony UX Calendar Link +======================== + +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + +Symfony UX CalendarLink is a Symfony bundle that allows generation of "Add to calendar" links for Google +Calendar, Outlook.com, Office 365 and iCalendar (``.ics``), the format +consumed by Apple Calendar, Outlook desktop, Thunderbird and every native +calendar client. + +Installation +------------ + +Install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-calendar-link + +Usage +----- + +Start by creating a ``CalendarEvent`` object in your controller. Once populated with your event details, pass it to Twig to render the calendar links:: + + // src/Controller/EventController.php + namespace App\Controller; + + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Attribute\Route; + use Symfony\UX\CalendarLink\CalendarEvent; + + final class EventController extends AbstractController + { + #[Route('/event/{id}', name: 'app_event_show')] + public function show(): Response + { + // Build from your database, API, etc. + $event = new CalendarEvent( + title: 'Symfony Live Paris', + start: new \DateTimeImmutable('2026-05-14 09:00'), + end: new \DateTimeImmutable('2026-05-15 18:00'), + location: 'Cité Universitaire Paris', + description: 'Annual Symfony conference in France', + ); + + return $this->render('event/show.html.twig', ['event' => $event]); + } + } + +Then use the two Twig functions to render links: + +* ``ux_calendar_link(event, provider)``: generates a link for one provider +* ``ux_calendar_links(event)``: generates links for every registered provider + +.. code-block:: html+twig + + {# templates/event/show.html.twig #} + Add to Google Calendar + + + +Supported providers +------------------- + +========================= =========== ========== ============= ========= +Field Google Outlook Office 365 ICS +========================= =========== ========== ============= ========= +title yes yes yes yes +start / end yes yes yes yes +description yes yes yes yes +location yes yes yes yes +all-day yes yes yes yes +reminders (VALARM) — — — yes +recurrence (RRULE) yes — — yes +========================= =========== ========== ============= ========= + +Fields marked "—" are silently ignored when generating a link for that +provider, because the provider's URL scheme does not support them. For example, +reminders are honoured only in the ``.ics`` output since Google and Outlook +deeplink URLs cannot carry VALARM data. + +Reminders and recurrence +------------------------ + +Both recurrence rules and reminders can be attached when constructing the event:: + + use Symfony\UX\CalendarLink\CalendarEvent; + use Symfony\UX\CalendarLink\CalendarRecurrence; + use Symfony\UX\CalendarLink\CalendarReminder; + + $event = new CalendarEvent( + title: 'Weekly sync', + start: new \DateTimeImmutable('2026-05-14 10:00'), + end: new \DateTimeImmutable('2026-05-14 10:30'), + recurrence: CalendarRecurrence::weekly(count: 10), + reminders: [new CalendarReminder(minutesBefore: 15)], + ); + +``CalendarRecurrence`` exposes one static factory per RFC 5545 frequency: +``minutely()``, ``daily()``, ``weekly()``, ``monthly()``, ``yearly()``, each +accepting the usual ``interval``, ``count`` and ``until`` named arguments. For example:: + + // every other day + CalendarRecurrence::daily(interval: 2); + + // six monthly occurrences + CalendarRecurrence::monthly(count: 6); + + // yearly until 2030 + CalendarRecurrence::yearly(until: new \DateTimeImmutable('2030-01-01')); + +.. note:: + + ``CalendarRecurrence::minutely()`` produces a valid RFC 5545 RRULE, but + Google Calendar and Outlook deeplink URLs do not support ``FREQ=MINUTELY`` + and will silently ignore it. Use ``minutely()`` only when targeting the + ``ics`` provider. + +All-day events +-------------- + +Pass ``allDay: true`` to create an event with no specific start or end time:: + + $holiday = new CalendarEvent( + title: 'Bastille Day', + start: new \DateTimeImmutable('2026-07-14'), + end: new \DateTimeImmutable('2026-07-14'), + allDay: true, + ); + +Backward compatibility promise +------------------------------ + +This bundle follows the same backward-compatibility promise as +`Symfony itself `_. diff --git a/src/CalendarLink/phpunit.xml.dist b/src/CalendarLink/phpunit.xml.dist new file mode 100644 index 00000000000..95007776710 --- /dev/null +++ b/src/CalendarLink/phpunit.xml.dist @@ -0,0 +1,36 @@ + + + + + + + + + + + ./tests + + + + + src + + + trigger_deprecation + + + diff --git a/src/CalendarLink/src/CalendarEvent.php b/src/CalendarLink/src/CalendarEvent.php new file mode 100644 index 00000000000..8bbf35d8278 --- /dev/null +++ b/src/CalendarLink/src/CalendarEvent.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink; + +use Symfony\UX\CalendarLink\Exception\InvalidArgumentException; + +/** + * @author Imad ZAIRIG + */ +final class CalendarEvent +{ + public readonly \DateTimeImmutable $start; + public readonly \DateTimeImmutable $end; + + /** + * @param list $reminders + */ + public function __construct( + public readonly string $title, + \DateTimeInterface $start, + \DateTimeInterface $end, + public readonly ?string $description = null, + public readonly ?string $location = null, + public readonly bool $allDay = false, + public readonly ?string $url = null, + public readonly ?CalendarRecurrence $recurrence = null, + public readonly array $reminders = [], + ) { + if ('' === trim($title)) { + throw new InvalidArgumentException('Event title must not be empty.'); + } + + $this->start = \DateTimeImmutable::createFromInterface($start); + $this->end = \DateTimeImmutable::createFromInterface($end); + + if ($this->end < $this->start) { + throw new InvalidArgumentException(\sprintf('Event end "%s" must be on or after start "%s".', $this->end->format('c'), $this->start->format('c'))); + } + } +} diff --git a/src/CalendarLink/src/CalendarLink.php b/src/CalendarLink/src/CalendarLink.php new file mode 100644 index 00000000000..93f6bbe2db6 --- /dev/null +++ b/src/CalendarLink/src/CalendarLink.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink; + +/** + * @author Imad ZAIRIG + */ +final class CalendarLink implements \Stringable +{ + public function __construct( + public readonly string $provider, + public readonly string $label, + public readonly string $url, + ) { + } + + public function __toString(): string + { + return $this->url; + } +} diff --git a/src/CalendarLink/src/CalendarRecurrence.php b/src/CalendarLink/src/CalendarRecurrence.php new file mode 100644 index 00000000000..640410c3c1a --- /dev/null +++ b/src/CalendarLink/src/CalendarRecurrence.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink; + +use Symfony\UX\CalendarLink\Exception\InvalidArgumentException; + +/** + * @author Imad ZAIRIG + */ +final class CalendarRecurrence +{ + private function __construct( + public readonly string $rrule, + ) { + } + + public static function minutely(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self + { + return self::fromParts('MINUTELY', $interval, $count, $until); + } + + public static function daily(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self + { + return self::fromParts('DAILY', $interval, $count, $until); + } + + public static function weekly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self + { + return self::fromParts('WEEKLY', $interval, $count, $until); + } + + public static function monthly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self + { + return self::fromParts('MONTHLY', $interval, $count, $until); + } + + public static function yearly(int $interval = 1, ?int $count = null, ?\DateTimeInterface $until = null): self + { + return self::fromParts('YEARLY', $interval, $count, $until); + } + + private static function fromParts(string $frequency, int $interval, ?int $count, ?\DateTimeInterface $until): self + { + if ($interval < 1) { + throw new InvalidArgumentException(\sprintf('Recurrence "interval" must be >= 1, got %d.', $interval)); + } + + if (null !== $count && null !== $until) { + throw new InvalidArgumentException('Recurrence "count" and "until" are mutually exclusive.'); + } + + if (null !== $count && $count < 1) { + throw new InvalidArgumentException(\sprintf('Recurrence "count" must be >= 1, got %d.', $count)); + } + + $parts = ['FREQ='.$frequency]; + + if (1 !== $interval) { + $parts[] = 'INTERVAL='.$interval; + } + + if (null !== $count) { + $parts[] = 'COUNT='.$count; + } + + if (null !== $until) { + $parts[] = 'UNTIL='.\DateTimeImmutable::createFromInterface($until) + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Ymd\THis\Z'); + } + + return new self(implode(';', $parts)); + } +} diff --git a/src/CalendarLink/src/CalendarReminder.php b/src/CalendarLink/src/CalendarReminder.php new file mode 100644 index 00000000000..91c058272c7 --- /dev/null +++ b/src/CalendarLink/src/CalendarReminder.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink; + +use Symfony\UX\CalendarLink\Exception\InvalidArgumentException; + +/** + * @author Imad ZAIRIG + */ +final class CalendarReminder +{ + public function __construct( + public readonly int $minutesBefore, + public readonly string $description = '', + ) { + if ($minutesBefore < 0) { + throw new InvalidArgumentException(\sprintf('Reminder "minutesBefore" must be >= 0, got %d.', $minutesBefore)); + } + } +} diff --git a/src/CalendarLink/src/Exception/ExceptionInterface.php b/src/CalendarLink/src/Exception/ExceptionInterface.php new file mode 100644 index 00000000000..764fb0a3f8f --- /dev/null +++ b/src/CalendarLink/src/Exception/ExceptionInterface.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Exception; + +/** + * @author Imad ZAIRIG + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/CalendarLink/src/Exception/InvalidArgumentException.php b/src/CalendarLink/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000000..f4688eada2a --- /dev/null +++ b/src/CalendarLink/src/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Exception; + +/** + * @author Imad ZAIRIG + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/CalendarLink/src/Exception/UnknownProviderException.php b/src/CalendarLink/src/Exception/UnknownProviderException.php new file mode 100644 index 00000000000..31362101a95 --- /dev/null +++ b/src/CalendarLink/src/Exception/UnknownProviderException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Exception; + +/** + * @author Imad ZAIRIG + */ +class UnknownProviderException extends InvalidArgumentException +{ + /** + * @param list $available + */ + public function __construct(string $name, array $available) + { + parent::__construct(\sprintf( + 'Unknown calendar link provider "%s". Available providers: %s.', + $name, + $available ? '"'.implode('", "', $available).'"' : '(none registered)', + )); + } +} diff --git a/src/CalendarLink/src/Ics/IcsBuilder.php b/src/CalendarLink/src/Ics/IcsBuilder.php new file mode 100644 index 00000000000..b20d146b482 --- /dev/null +++ b/src/CalendarLink/src/Ics/IcsBuilder.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Ics; + +use Symfony\Component\Uid\Factory\UuidFactory; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarReminder; + +/** + * @author Imad ZAIRIG + */ +final class IcsBuilder +{ + private const CRLF = "\r\n"; + private const PRODID = '-//Symfony//UX Calendar Link//EN'; + + private readonly UuidFactory $uuidFactory; + + public function __construct(?UuidFactory $uuidFactory = null) + { + $this->uuidFactory = $uuidFactory ?? new UuidFactory(); + } + + public function build(CalendarEvent $event): string + { + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:'.self::PRODID, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + 'UID:'.$this->uuidFactory->create()->toRfc4122(), + 'DTSTAMP:'.$this->formatUtc(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))), + ...$this->formatDateLines($event), + 'SUMMARY:'.$this->escape($event->title), + ]; + + if (null !== $event->description && '' !== $event->description) { + $lines[] = 'DESCRIPTION:'.$this->escape($event->description); + } + + if (null !== $event->location && '' !== $event->location) { + $lines[] = 'LOCATION:'.$this->escape($event->location); + } + + if (null !== $event->url && '' !== $event->url) { + $lines[] = 'URL:'.$event->url; + } + + if (null !== $event->recurrence) { + $lines[] = 'RRULE:'.$event->recurrence->rrule; + } + + foreach ($event->reminders as $reminder) { + array_push($lines, ...$this->formatAlarm($reminder)); + } + + $lines[] = 'END:VEVENT'; + $lines[] = 'END:VCALENDAR'; + + $folded = array_map([$this, 'fold'], $lines); + + return implode(self::CRLF, $folded).self::CRLF; + } + + /** + * @return list + */ + private function formatDateLines(CalendarEvent $event): array + { + if ($event->allDay) { + // DTEND is exclusive for all-day events: the user-visible last day + // must be incremented by one. + $start = $event->start->setTime(0, 0, 0); + $end = $event->end->setTime(0, 0, 0)->modify('+1 day'); + + return [ + 'DTSTART;VALUE=DATE:'.$start->format('Ymd'), + 'DTEND;VALUE=DATE:'.$end->format('Ymd'), + ]; + } + + return [ + 'DTSTART:'.$this->formatUtc($event->start), + 'DTEND:'.$this->formatUtc($event->end), + ]; + } + + /** + * @return list + */ + private function formatAlarm(CalendarReminder $reminder): array + { + return [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'TRIGGER:-PT'.$reminder->minutesBefore.'M', + 'DESCRIPTION:'.$this->escape('' !== $reminder->description ? $reminder->description : 'Reminder'), + 'END:VALARM', + ]; + } + + private function formatUtc(\DateTimeInterface $dt): string + { + return \DateTimeImmutable::createFromInterface($dt) + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Ymd\THis\Z'); + } + + private function escape(string $text): string + { + $text = str_replace(['\\', ',', ';'], ['\\\\', '\\,', '\\;'], $text); + + return str_replace(["\r\n", "\r", "\n"], '\\n', $text); + } + + private function fold(string $line): string + { + if (\strlen($line) <= 75) { + return $line; + } + + $result = mb_strcut($line, 0, 75, 'UTF-8'); + $offset = \strlen($result); + + while ($offset < \strlen($line)) { + $chunk = mb_strcut($line, $offset, 74, 'UTF-8'); + $result .= self::CRLF.' '.$chunk; + $offset += \strlen($chunk); + } + + return $result; + } +} diff --git a/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php b/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php new file mode 100644 index 00000000000..4be95120e8b --- /dev/null +++ b/src/CalendarLink/src/Provider/AbstractOutlookCalendarLinkProvider.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; + +/** + * @author Imad ZAIRIG + */ +abstract class AbstractOutlookCalendarLinkProvider implements CalendarLinkProviderInterface +{ + abstract protected function getBaseUrl(): string; + + public function generate(CalendarEvent $event): CalendarLink + { + $params = [ + 'path' => '/calendar/action/compose', + 'rru' => 'addevent', + 'subject' => $event->title, + 'startdt' => $this->formatDate($event->start, $event->allDay), + 'enddt' => $this->formatDate($event->end, $event->allDay), + ]; + + if ($event->allDay) { + $params['allday'] = 'true'; + } + + if (null !== $event->description && '' !== $event->description) { + $params['body'] = $event->description; + } + + if (null !== $event->location && '' !== $event->location) { + $params['location'] = $event->location; + } + + $url = $this->getBaseUrl().'?'.http_build_query($params, '', '&', \PHP_QUERY_RFC3986); + + return new CalendarLink($this->getName(), $this->getLabel(), $url); + } + + private function formatDate(\DateTimeImmutable $dt, bool $allDay): string + { + if ($allDay) { + return $dt->format('Y-m-d'); + } + + return $dt->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'); + } +} diff --git a/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php b/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php new file mode 100644 index 00000000000..af1141c7b6f --- /dev/null +++ b/src/CalendarLink/src/Provider/CalendarLinkProviderInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; + +/** + * Produces an "Add to calendar" {@see CalendarLink} for a given target (Google Calendar, Outlook, .ics file, ...). + * + * Implementations are registered as services tagged `ux_calendar_link.provider` and picked up by the + * {@see \Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry}. + * + * @author Imad ZAIRIG + */ +interface CalendarLinkProviderInterface +{ + /** + * Returns the unique machine name identifying this provider (e.g. "google", "outlook", "ics"). + * + * The name is the key used to select a provider in Twig and in the registry. + */ + public function getName(): string; + + /** + * Returns a human-readable label suitable for display in UI (e.g. "Google Calendar"). + */ + public function getLabel(): string; + + /** + * Builds a {@see CalendarLink} for the given event according to this provider's URL scheme. + */ + public function generate(CalendarEvent $event): CalendarLink; +} diff --git a/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php b/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php new file mode 100644 index 00000000000..fd2196a8509 --- /dev/null +++ b/src/CalendarLink/src/Provider/GoogleCalendarLinkProvider.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; + +/** + * @author Imad ZAIRIG + */ +final class GoogleCalendarLinkProvider implements CalendarLinkProviderInterface +{ + private const BASE_URL = 'https://calendar.google.com/calendar/render'; + + public function getName(): string + { + return 'google'; + } + + public function getLabel(): string + { + return 'Google Calendar'; + } + + public function generate(CalendarEvent $event): CalendarLink + { + $params = [ + 'action' => 'TEMPLATE', + 'text' => $event->title, + 'dates' => $this->formatDates($event), + ]; + + if (null !== $event->description && '' !== $event->description) { + $params['details'] = $event->description; + } + + if (null !== $event->location && '' !== $event->location) { + $params['location'] = $event->location; + } + + if (null !== $event->recurrence) { + // Google expects the literal "RRULE:" prefix. + $params['recur'] = 'RRULE:'.$event->recurrence->rrule; + } + + $url = self::BASE_URL.'?'.http_build_query($params, '', '&', \PHP_QUERY_RFC3986); + + return new CalendarLink($this->getName(), $this->getLabel(), $url); + } + + private function formatDates(CalendarEvent $event): string + { + if ($event->allDay) { + // Google all-day format: YYYYMMDD/YYYYMMDD (end exclusive). + $start = $event->start->format('Ymd'); + $end = $event->end->modify('+1 day')->format('Ymd'); + + return $start.'/'.$end; + } + + $start = $event->start->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis\Z'); + $end = $event->end->setTimezone(new \DateTimeZone('UTC'))->format('Ymd\THis\Z'); + + return $start.'/'.$end; + } +} diff --git a/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php b/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php new file mode 100644 index 00000000000..92dbf817fa0 --- /dev/null +++ b/src/CalendarLink/src/Provider/IcsCalendarLinkProvider.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; +use Symfony\UX\CalendarLink\Ics\IcsBuilder; + +/** + * @author Imad ZAIRIG + */ +final class IcsCalendarLinkProvider implements CalendarLinkProviderInterface +{ + public function __construct( + private readonly IcsBuilder $icsBuilder, + ) { + } + + public function getName(): string + { + return 'ics'; + } + + public function getLabel(): string + { + return 'iCalendar (.ics)'; + } + + public function generate(CalendarEvent $event): CalendarLink + { + $ics = $this->icsBuilder->build($event); + + return new CalendarLink( + $this->getName(), + $this->getLabel(), + 'data:text/calendar;charset=utf-8;base64,'.base64_encode($ics), + ); + } +} diff --git a/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php b/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php new file mode 100644 index 00000000000..8c5ab652c52 --- /dev/null +++ b/src/CalendarLink/src/Provider/Office365CalendarLinkProvider.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +/** + * @author Imad ZAIRIG + */ +final class Office365CalendarLinkProvider extends AbstractOutlookCalendarLinkProvider +{ + public function getName(): string + { + return 'office365'; + } + + public function getLabel(): string + { + return 'Office 365'; + } + + protected function getBaseUrl(): string + { + return 'https://outlook.office.com/calendar/0/deeplink/compose'; + } +} diff --git a/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php b/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php new file mode 100644 index 00000000000..f813e65045f --- /dev/null +++ b/src/CalendarLink/src/Provider/OutlookCalendarLinkProvider.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Provider; + +/** + * @author Imad ZAIRIG + */ +final class OutlookCalendarLinkProvider extends AbstractOutlookCalendarLinkProvider +{ + public function getName(): string + { + return 'outlook'; + } + + public function getLabel(): string + { + return 'Outlook.com'; + } + + protected function getBaseUrl(): string + { + return 'https://outlook.live.com/calendar/0/deeplink/compose'; + } +} diff --git a/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php b/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php new file mode 100644 index 00000000000..0a506ebfffe --- /dev/null +++ b/src/CalendarLink/src/Registry/CalendarLinkProviderRegistry.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Registry; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; +use Symfony\UX\CalendarLink\Exception\UnknownProviderException; +use Symfony\UX\CalendarLink\Provider\CalendarLinkProviderInterface; + +/** + * @author Imad ZAIRIG + */ +final class CalendarLinkProviderRegistry +{ + /** @var array */ + private array $providers = []; + + /** + * @param iterable $providers + */ + public function __construct(iterable $providers) + { + foreach ($providers as $provider) { + $this->providers[$provider->getName()] = $provider; + } + } + + public function has(string $name): bool + { + return isset($this->providers[$name]); + } + + public function get(string $name): CalendarLinkProviderInterface + { + if (!isset($this->providers[$name])) { + throw new UnknownProviderException($name, array_keys($this->providers)); + } + + return $this->providers[$name]; + } + + /** + * @return array + */ + public function all(): array + { + return $this->providers; + } + + public function generate(CalendarEvent $event, string $provider): CalendarLink + { + return $this->get($provider)->generate($event); + } + + /** + * @return array + */ + public function generateAll(CalendarEvent $event): array + { + $links = []; + foreach ($this->providers as $name => $provider) { + $links[$name] = $provider->generate($event); + } + + return $links; + } +} diff --git a/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php b/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php new file mode 100644 index 00000000000..e78df060455 --- /dev/null +++ b/src/CalendarLink/src/Twig/UXCalendarLinkExtension.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Twig; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Imad ZAIRIG + */ +final class UXCalendarLinkExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('ux_calendar_link', [UXCalendarLinkRuntime::class, 'link']), + new TwigFunction('ux_calendar_links', [UXCalendarLinkRuntime::class, 'links']), + ]; + } +} diff --git a/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php b/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php new file mode 100644 index 00000000000..c34be414648 --- /dev/null +++ b/src/CalendarLink/src/Twig/UXCalendarLinkRuntime.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Twig; + +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarLink; +use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry; +use Twig\Extension\RuntimeExtensionInterface; + +/** + * @author Imad ZAIRIG + */ +final class UXCalendarLinkRuntime implements RuntimeExtensionInterface +{ + public function __construct( + private readonly CalendarLinkProviderRegistry $registry, + ) { + } + + public function link(CalendarEvent $event, string $provider): CalendarLink + { + return $this->registry->generate($event, $provider); + } + + /** + * @return array + */ + public function links(CalendarEvent $event): array + { + return $this->registry->generateAll($event); + } +} diff --git a/src/CalendarLink/src/UXCalendarLinkBundle.php b/src/CalendarLink/src/UXCalendarLinkBundle.php new file mode 100644 index 00000000000..e07b1083ef1 --- /dev/null +++ b/src/CalendarLink/src/UXCalendarLinkBundle.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; + +/** + * @author Imad ZAIRIG + */ +final class UXCalendarLinkBundle extends AbstractBundle +{ + protected string $extensionAlias = 'ux_calendar_link'; + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('../config/services.php'); + } + + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/CalendarLink/tests/CalendarEventTest.php b/src/CalendarLink/tests/CalendarEventTest.php new file mode 100644 index 00000000000..12d8e139de2 --- /dev/null +++ b/src/CalendarLink/tests/CalendarEventTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Exception\InvalidArgumentException; + +final class CalendarEventTest extends TestCase +{ + public function testRejectsEmptyTitle() + { + $this->expectException(InvalidArgumentException::class); + + new CalendarEvent( + title: ' ', + start: new \DateTimeImmutable('2026-05-14 09:00'), + end: new \DateTimeImmutable('2026-05-14 10:00'), + ); + } + + public function testRejectsEndBeforeStart() + { + $this->expectException(InvalidArgumentException::class); + + new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 10:00'), + end: new \DateTimeImmutable('2026-05-14 09:00'), + ); + } +} diff --git a/src/CalendarLink/tests/CalendarRecurrenceTest.php b/src/CalendarLink/tests/CalendarRecurrenceTest.php new file mode 100644 index 00000000000..293bb4da44b --- /dev/null +++ b/src/CalendarLink/tests/CalendarRecurrenceTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarRecurrence; +use Symfony\UX\CalendarLink\Exception\InvalidArgumentException; + +final class CalendarRecurrenceTest extends TestCase +{ + /** + * @return iterable + */ + public static function factoryProvider(): iterable + { + yield 'minutely default' => [CalendarRecurrence::minutely(), 'FREQ=MINUTELY']; + yield 'daily default' => [CalendarRecurrence::daily(), 'FREQ=DAILY']; + yield 'weekly default' => [CalendarRecurrence::weekly(), 'FREQ=WEEKLY']; + yield 'monthly default' => [CalendarRecurrence::monthly(), 'FREQ=MONTHLY']; + yield 'yearly default' => [CalendarRecurrence::yearly(), 'FREQ=YEARLY']; + yield 'weekly count' => [CalendarRecurrence::weekly(count: 10), 'FREQ=WEEKLY;COUNT=10']; + yield 'daily interval' => [CalendarRecurrence::daily(interval: 2), 'FREQ=DAILY;INTERVAL=2']; + yield 'monthly interval + count' => [CalendarRecurrence::monthly(interval: 3, count: 4), 'FREQ=MONTHLY;INTERVAL=3;COUNT=4']; + } + + #[DataProvider('factoryProvider')] + public function testFactoriesProduceExpectedRrule(CalendarRecurrence $recurrence, string $expected) + { + $this->assertSame($expected, $recurrence->rrule); + } + + public function testUntilIsFormattedAsUtc() + { + $until = new \DateTimeImmutable('2026-12-31 09:00:00', new \DateTimeZone('Europe/Paris')); + + $this->assertSame('FREQ=WEEKLY;UNTIL=20261231T080000Z', CalendarRecurrence::weekly(until: $until)->rrule); + } + + /** + * @return iterable + */ + public static function invalidArgumentsProvider(): iterable + { + yield 'count and until together' => [static fn () => CalendarRecurrence::weekly(count: 5, until: new \DateTimeImmutable('2026-12-31'))]; + yield 'non-positive interval' => [static fn () => CalendarRecurrence::daily(interval: 0)]; + yield 'non-positive count' => [static fn () => CalendarRecurrence::daily(count: 0)]; + } + + #[DataProvider('invalidArgumentsProvider')] + public function testRejectsInvalidArguments(callable $build) + { + $this->expectException(InvalidArgumentException::class); + + $build(); + } +} diff --git a/src/CalendarLink/tests/Fixtures/TestKernel.php b/src/CalendarLink/tests/Fixtures/TestKernel.php new file mode 100644 index 00000000000..4c035edaadc --- /dev/null +++ b/src/CalendarLink/tests/Fixtures/TestKernel.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Fixtures; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; +use Symfony\UX\CalendarLink\UXCalendarLinkBundle; + +final class TestKernel extends Kernel +{ + use MicroKernelTrait; + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new TwigBundle(); + yield new UXCalendarLinkBundle(); + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', [ + 'test' => true, + 'secret' => 'test', + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'router' => ['utf8' => true], + ]); + + $container->extension('twig', [ + 'default_path' => __DIR__.'/templates', + ]); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + } + + public function getCacheDir(): string + { + return sys_get_temp_dir().'/ux-calendar-link/'.spl_object_hash($this).'/cache'; + } + + public function getLogDir(): string + { + return sys_get_temp_dir().'/ux-calendar-link/'.spl_object_hash($this).'/log'; + } +} diff --git a/src/CalendarLink/tests/Ics/IcsBuilderTest.php b/src/CalendarLink/tests/Ics/IcsBuilderTest.php new file mode 100644 index 00000000000..9655c885a90 --- /dev/null +++ b/src/CalendarLink/tests/Ics/IcsBuilderTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Ics; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\Factory\MockUuidFactory; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\CalendarRecurrence; +use Symfony\UX\CalendarLink\CalendarReminder; +use Symfony\UX\CalendarLink\Ics\IcsBuilder; + +final class IcsBuilderTest extends TestCase +{ + private IcsBuilder $builder; + + protected function setUp(): void + { + $this->builder = new IcsBuilder(new MockUuidFactory(['0192a5d2-7c6f-7000-8000-000000000000'])); + } + + public function testUidIsUniquePerBuild() + { + $builder = new IcsBuilder(); + + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + ); + + preg_match('/^UID:(.+)$/m', $builder->build($event), $first); + preg_match('/^UID:(.+)$/m', $builder->build($event), $second); + + $this->assertNotSame($first[1], $second[1]); + } + + public function testMinimalTimedEventStructure() + { + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString( + "DTSTART:20260514T090000Z\r\n" + ."DTEND:20260514T100000Z\r\n" + ."SUMMARY:Demo\r\n" + ."END:VEVENT\r\n" + ."END:VCALENDAR\r\n", + $ics, + ); + } + + public function testAllDayEventUsesValueDateAndIncrementsEnd() + { + $event = new CalendarEvent( + title: 'Conf', + start: new \DateTimeImmutable('2026-05-14', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-15', new \DateTimeZone('UTC')), + allDay: true, + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("DTSTART;VALUE=DATE:20260514\r\n", $ics); + $this->assertStringContainsString("DTEND;VALUE=DATE:20260516\r\n", $ics); + } + + public function testTextEscaping() + { + $event = new CalendarEvent( + title: 'Symfony, UX; test', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + description: "Line 1\nLine 2", + location: 'A\\B', + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("SUMMARY:Symfony\\, UX\\; test\r\n", $ics); + $this->assertStringContainsString('DESCRIPTION:Line 1\nLine 2', $ics); + $this->assertStringContainsString('LOCATION:A\\\\B', $ics); + } + + public function testLineFoldingAtSeventyFiveOctets() + { + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + description: str_repeat('a', 300), + ); + + $ics = $this->builder->build($event); + + foreach (explode("\r\n", $ics) as $line) { + $this->assertLessThanOrEqual(75, \strlen($line), \sprintf('Line "%s" exceeds 75 octets.', $line)); + } + } + + public function testValarmBlockFromReminders() + { + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + reminders: [new CalendarReminder(15, 'Stand-up')], + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("BEGIN:VALARM\r\n", $ics); + $this->assertStringContainsString("TRIGGER:-PT15M\r\n", $ics); + $this->assertStringContainsString("END:VALARM\r\n", $ics); + } + + public function testRrulePassthrough() + { + $event = new CalendarEvent( + title: 'Weekly', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + recurrence: CalendarRecurrence::weekly(count: 10), + ); + + $ics = $this->builder->build($event); + + $this->assertStringContainsString("RRULE:FREQ=WEEKLY;COUNT=10\r\n", $ics); + } +} diff --git a/src/CalendarLink/tests/Integration/BundleIntegrationTest.php b/src/CalendarLink/tests/Integration/BundleIntegrationTest.php new file mode 100644 index 00000000000..24ff372e824 --- /dev/null +++ b/src/CalendarLink/tests/Integration/BundleIntegrationTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Integration; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Provider\CalendarLinkProviderInterface; +use Symfony\UX\CalendarLink\Registry\CalendarLinkProviderRegistry; + +final class BundleIntegrationTest extends KernelTestCase +{ + public function testContainerCompilesAndRegistersAllProviders() + { + $container = self::getContainer(); + + /** @var CalendarLinkProviderRegistry $registry */ + $registry = $container->get(CalendarLinkProviderRegistry::class); + + $this->assertSame(['google', 'outlook', 'office365', 'ics'], array_keys($registry->all())); + + foreach ($registry->all() as $provider) { + $this->assertInstanceOf(CalendarLinkProviderInterface::class, $provider); + } + } + + public function testTwigRendersCalendarLinks() + { + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + ); + + $rendered = self::getContainer()->get('twig') + ->createTemplate('{{- ux_calendar_link(event, \'google\').url }}') + ->render(['event' => $event]); + + $this->assertStringStartsWith('https://calendar.google.com/calendar/render?', $rendered); + $this->assertStringContainsString('text=Demo', $rendered); + } +} diff --git a/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php new file mode 100644 index 00000000000..bbf64c068a8 --- /dev/null +++ b/src/CalendarLink/tests/Provider/GoogleCalendarLinkProviderTest.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Provider\GoogleCalendarLinkProvider; + +final class GoogleCalendarLinkProviderTest extends TestCase +{ + public function testTimedEvent() + { + $provider = new GoogleCalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Symfony Live', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 18:00', new \DateTimeZone('UTC')), + description: 'Annual conference', + location: 'Paris', + ); + + $link = $provider->generate($event); + + $this->assertSame('google', $link->provider); + $this->assertStringStartsWith('https://calendar.google.com/calendar/render?', $link->url); + + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('Symfony Live', $params['text']); + $this->assertSame('20260514T090000Z/20260514T180000Z', $params['dates']); + $this->assertSame('Paris', $params['location']); + } + + public function testAllDayEventEndIsExclusive() + { + $provider = new GoogleCalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Holiday', + start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + allDay: true, + ); + + $link = $provider->generate($event); + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('20260714/20260715', $params['dates']); + } +} diff --git a/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php new file mode 100644 index 00000000000..feeead10faa --- /dev/null +++ b/src/CalendarLink/tests/Provider/IcsCalendarLinkProviderTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Ics\IcsBuilder; +use Symfony\UX\CalendarLink\Provider\IcsCalendarLinkProvider; + +final class IcsCalendarLinkProviderTest extends TestCase +{ + public function testReturnsDataUri() + { + $provider = new IcsCalendarLinkProvider(new IcsBuilder()); + $event = new CalendarEvent( + title: 'Demo', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 10:00', new \DateTimeZone('UTC')), + ); + + $link = $provider->generate($event); + + $this->assertSame('ics', $link->provider); + $this->assertStringStartsWith('data:text/calendar;charset=utf-8;base64,', $link->url); + + $payload = base64_decode(substr($link->url, \strlen('data:text/calendar;charset=utf-8;base64,')), true); + $this->assertIsString($payload); + $this->assertStringContainsString('BEGIN:VCALENDAR', $payload); + $this->assertStringContainsString('SUMMARY:Demo', $payload); + } +} diff --git a/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php new file mode 100644 index 00000000000..ce448202a44 --- /dev/null +++ b/src/CalendarLink/tests/Provider/Office365CalendarLinkProviderTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Provider\Office365CalendarLinkProvider; + +final class Office365CalendarLinkProviderTest extends TestCase +{ + public function testOffice365TimedEvent() + { + $provider = new Office365CalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Standup', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 09:15', new \DateTimeZone('UTC')), + location: 'Paris', + ); + + $link = $provider->generate($event); + + $this->assertSame('office365', $link->provider); + $this->assertStringStartsWith('https://outlook.office.com/calendar/0/deeplink/compose?', $link->url); + + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('Standup', $params['subject']); + $this->assertSame('2026-05-14T09:00:00Z', $params['startdt']); + $this->assertSame('2026-05-14T09:15:00Z', $params['enddt']); + $this->assertSame('Paris', $params['location']); + } + + public function testAllDayEvent() + { + $provider = new Office365CalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Bastille Day', + start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + allDay: true, + ); + + $link = $provider->generate($event); + + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('true', $params['allday']); + $this->assertSame('2026-07-14', $params['startdt']); + $this->assertSame('2026-07-14', $params['enddt']); + } +} diff --git a/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php b/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php new file mode 100644 index 00000000000..beb6b3ba5f8 --- /dev/null +++ b/src/CalendarLink/tests/Provider/OutlookCalendarLinkProviderTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\CalendarLink\Tests\Provider; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\CalendarLink\CalendarEvent; +use Symfony\UX\CalendarLink\Provider\OutlookCalendarLinkProvider; + +final class OutlookCalendarLinkProviderTest extends TestCase +{ + public function testOutlookComTimedEvent() + { + $provider = new OutlookCalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Symfony Live', + start: new \DateTimeImmutable('2026-05-14 09:00', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-05-14 18:00', new \DateTimeZone('UTC')), + location: 'Paris', + ); + + $link = $provider->generate($event); + + $this->assertSame('outlook', $link->provider); + $this->assertStringStartsWith('https://outlook.live.com/calendar/0/deeplink/compose?', $link->url); + + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('Symfony Live', $params['subject']); + $this->assertSame('2026-05-14T09:00:00Z', $params['startdt']); + $this->assertSame('2026-05-14T18:00:00Z', $params['enddt']); + } + + public function testAllDayEvent() + { + $provider = new OutlookCalendarLinkProvider(); + $event = new CalendarEvent( + title: 'Bastille Day', + start: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + end: new \DateTimeImmutable('2026-07-14', new \DateTimeZone('UTC')), + allDay: true, + ); + + $link = $provider->generate($event); + + parse_str(parse_url($link->url, \PHP_URL_QUERY), $params); + + $this->assertSame('true', $params['allday']); + $this->assertSame('2026-07-14', $params['startdt']); + $this->assertSame('2026-07-14', $params['enddt']); + } +} diff --git a/src/CalendarLink/tests/bootstrap.php b/src/CalendarLink/tests/bootstrap.php new file mode 100644 index 00000000000..89a23684510 --- /dev/null +++ b/src/CalendarLink/tests/bootstrap.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Component\ErrorHandler\ErrorHandler; + +require __DIR__.'/../vendor/autoload.php'; + +// @see https://github.com/symfony/symfony/issues/53812 +ErrorHandler::register(null, false);