diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..498599b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +tests/fixtures/mergephp.ical eol=crlf diff --git a/composer.json b/composer.json index 9b31ad9..6e936b3 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "ext-mbstring": "*", "ext-readline": "*", "ext-simplexml": "*", + "eluceo/ical": "^2.15", "league/commonmark": "^2.3", "nette/php-generator": "^4.0.0", "psr/log": "^3.0.0", diff --git a/composer.lock b/composer.lock index 9c399a7..37d6057 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1ec222f64774a4befe980582feaa8022", + "content-hash": "d33d218d32b95dedb4e3f40905ecdeb3", "packages": [ { "name": "dflydev/dot-access-data", @@ -81,6 +81,70 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "eluceo/ical", + "version": "2.15.0", + "source": { + "type": "git", + "url": "https://github.com/markuspoerschke/iCal.git", + "reference": "1324190463b502f9aaad64333b737071c0107ab1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/markuspoerschke/iCal/zipball/1324190463b502f9aaad64333b737071c0107ab1", + "reference": "1324190463b502f9aaad64333b737071c0107ab1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "~8.3.0 || ~8.4.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0" + }, + "conflict": { + "php": "7.4.6" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.23.1", + "friendsofphp/php-cs-fixer": "^3.75", + "infection/infection": "^0.27 || ^0.29", + "phpmd/phpmd": "^2.13", + "phpunit/phpunit": "^10.5", + "vimeo/psalm": "^5.0 || ^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Eluceo\\iCal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Markus Poerschke", + "email": "markus@poerschke.nrw", + "role": "Developer" + } + ], + "description": "The eluceo/iCal package offers an abstraction layer for creating iCalendars. You can easily create iCal files by using PHP objects instead of typing your *.ics file by hand. The output will follow RFC 5545 as best as possible.", + "homepage": "https://github.com/markuspoerschke/iCal", + "keywords": [ + "calendar", + "iCalendar", + "ical", + "ics", + "php calendar" + ], + "support": { + "docs": "https://ical.poerschke.nrw", + "forum": "https://github.com/markuspoerschke/iCal/discussions", + "issues": "https://github.com/markuspoerschke/iCal/issues", + "source": "https://github.com/markuspoerschke/iCal" + }, + "time": "2025-08-15T18:19:43+00:00" + }, { "name": "lcobucci/clock", "version": "3.3.1", diff --git a/src/Builder/MeetupCollection.php b/src/Builder/MeetupCollection.php index 1c3716e..b2c151c 100644 --- a/src/Builder/MeetupCollection.php +++ b/src/Builder/MeetupCollection.php @@ -4,12 +4,14 @@ namespace MergePHP\Website\Builder; +use ArrayAccess; use Countable; use DateTimeImmutable; use DateTimeZone; use Iterator; +use MergePHP\Website\Exception\NotImplementedException; -class MeetupCollection implements Iterator, Countable +class MeetupCollection implements Iterator, Countable, ArrayAccess { /** @var MeetupEntry[] */ private array $array = []; @@ -87,4 +89,24 @@ public function withOnlyFuture(): array return $meetupEntry->instance->getDateTime() > $NOW; })); } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->array); + } + + public function offsetGet(mixed $offset): ?MeetupEntry + { + return $this->array[$offset] ?? null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new NotImplementedException('Setting values directly is not allowed'); + } + + public function offsetUnset(mixed $offset): void + { + throw new NotImplementedException('Unsetting values directly is not allowed'); + } } diff --git a/src/Builder/Processor/ICalProcessor.php b/src/Builder/Processor/ICalProcessor.php new file mode 100644 index 0000000..e7f2f34 --- /dev/null +++ b/src/Builder/Processor/ICalProcessor.php @@ -0,0 +1,113 @@ +outputDirectory); + } + + public function run(): void + { + $this->logger->info('Building iCal feed'); + $converter = new CommonMarkConverter(); + + $calendar = new Calendar($this->generateEvents($converter)); + + $this->meetups->sort(); + $oldestTimestamp = $this->meetups[0]->instance->getDateTime(); + $newestTimestamp = $this->meetups[count($this->meetups) - 1]->instance->getDateTime(); + $calendar->addTimeZone( + TimeZone::createFromPhpDateTimeZone( + new DateTimeZone('America/New_York'), + $oldestTimestamp, + $newestTimestamp, + ) + ); + + $componentFactory = new CalendarFactory(); + $calendar = $componentFactory->createCalendar($calendar); + + $outputFilename = "$this->outputDirectory/mergephp.ical"; + $bytes = file_put_contents($outputFilename, (string)$calendar); + + if ($bytes === false) { + throw new RuntimeException("Could not write $outputFilename"); + } + $this->logger->info("Saved $bytes bytes to $outputFilename"); + } + + protected function generateEvents(CommonMarkConverter $converter): Generator + { + foreach ($this->meetups as $meetup) { + $this->logger->debug("Generating iCal event for {$meetup->instance->getTitle()}"); + + $permalink = 'https://www.mergephp.com' . $meetup->instance->getPermalink(); + $description = $converter->convert($meetup->instance->getDescription()); + $description = trim(strip_tags((string)$description)); + + yield (new Event( + new UniqueIdentifier($permalink), + )) + ->setSummary( + $meetup->instance->getTitle(), + ) + ->setDescription( + $description, + ) + ->setOccurrence( + new TimeSpan( + new DateTime($meetup->instance->getDateTime(), true), + new DateTime($meetup->instance->getDateTime()->add(new DateInterval('PT1H')), true), + ), + ) + ->setLocation( + new Location($meetup->instance->getYouTubeLink() ?: 'https://www.mergephp.com/'), + ) + ->setOrganizer( + new Organizer(new EmailAddress('nobody@mergephp.com'), $meetup->instance->getSpeakerName()), + ) + ->setUrl( + new Uri($permalink), + ) + ->setLastModified( + new Timestamp($meetup->modifiedTimestamp), + ) + ->touch( + new Timestamp($meetup->modifiedTimestamp), + ) + ->setStatus( + (new EventStatus())->CONFIRMED(), + ) + ; + } + } +} diff --git a/src/Builder/SiteBuilderService.php b/src/Builder/SiteBuilderService.php index ed01529..362f663 100644 --- a/src/Builder/SiteBuilderService.php +++ b/src/Builder/SiteBuilderService.php @@ -8,6 +8,7 @@ use FilesystemIterator; use Lcobucci\Clock\SystemClock; use MergePHP\Website\Builder\Processor\ArchiveProcessor; +use MergePHP\Website\Builder\Processor\ICalProcessor; use MergePHP\Website\Builder\Processor\MeetupProcessor; use MergePHP\Website\Builder\Processor\HomepageProcessor; use MergePHP\Website\Builder\Processor\MissingLinkProcessor; @@ -61,6 +62,7 @@ public function build(): void (new ArchiveProcessor($this->logger, $this->outputDirectory, $collection, $this->twig, $twigData))->run(); (new SitemapProcessor($this->logger, $this->outputDirectory, $clock))->run(); (new RSSFeedProcessor($this->logger, $this->outputDirectory, $collection))->run(); + (new ICalProcessor($this->logger, $this->outputDirectory, $collection))->run(); (new MissingLinkProcessor($this->logger, $this->outputDirectory, $collection))->run(); $this->logger->info('Finished successfully'); } diff --git a/src/Exception/NotImplementedException.php b/src/Exception/NotImplementedException.php new file mode 100644 index 0000000..d747b61 --- /dev/null +++ b/src/Exception/NotImplementedException.php @@ -0,0 +1,9 @@ + + diff --git a/tests/Builder/Processor/ICalProcessorTest.php b/tests/Builder/Processor/ICalProcessorTest.php new file mode 100644 index 0000000..a0647cf --- /dev/null +++ b/tests/Builder/Processor/ICalProcessorTest.php @@ -0,0 +1,69 @@ +directory = vfsStream::setup(); + parent::setUp(); + } + + public function testItGeneratesAnIcalFeed(): void + { + $collection = new MeetupCollection(); + $collection->append( + new MeetupEntry( + new class extends AbstractMeetup + { + public function getTitle(): string + { + return 'Meetup Title'; + } + + public function getDescription(): string + { + return <<run(); + + $this->assertFileEquals(__DIR__ . '/../../fixtures/mergephp.ical', 'vfs://root/mergephp.ical'); + } +} diff --git a/tests/fixtures/mergephp.ical b/tests/fixtures/mergephp.ical new file mode 100644 index 0000000..0a4228a --- /dev/null +++ b/tests/fixtures/mergephp.ical @@ -0,0 +1,28 @@ +BEGIN:VCALENDAR +PRODID:-//eluceo/ical//2.0/EN +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +UID:https://www.mergephp.com/meetups/2000/01/01/meetup-title.html +DTSTAMP:20010101T060101Z +LAST-MODIFIED:20010101T060101Z +SUMMARY:Meetup Title +DESCRIPTION:This description spans multiple lines and uses the heredoc synt + ax\nThis description spans multiple lines and uses the heredoc syntax +URL:https://www.mergephp.com/meetups/2000/01/01/meetup-title.html +DTSTART;TZID=America/New_York:20000101T000000 +DTEND;TZID=America/New_York:20000101T010000 +LOCATION:https://www.mergephp.com/ +ORGANIZER;CN=Speaker Name:mailto:nobody@mergephp.com +STATUS:CONFIRMED +END:VEVENT +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:STANDARD +DTSTART:20000101T000000 +TZNAME:EST +TZOFFSETTO:-0500 +TZOFFSETFROM:-0500 +END:STANDARD +END:VTIMEZONE +END:VCALENDAR