diff --git a/.env b/.env index 114aa3363..697751b0a 100644 --- a/.env +++ b/.env @@ -93,7 +93,7 @@ REDIS_CACHE_DSN=redis://redis:6379/0 ###< redis ### ###> Calendar Api Feed Source ### -# See docs/calendar-api-feed.md for variable explainations. +# See docs/feed/calendar-api-feed.md for variable explainations. CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT= CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT= CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT= diff --git a/CHANGELOG.md b/CHANGELOG.md index e6badc10d..e328e4528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#226](https://github.com/os2display/display-api-service/pull/226) + - Added Colibo feed type. - [#225](https://github.com/os2display/display-api-service/pull/225) - Added ADRs. - [#215](https://github.com/os2display/display-api-service/pull/215) diff --git a/composer.json b/composer.json index f2425bda7..e4ff34207 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "rlanvin/php-rrule": "^2.2", "symfony/asset": "~6.4.0", "symfony/console": "~6.4.0", + "symfony/dom-crawler": "~6.4.0", "symfony/dotenv": "~6.4.0", "symfony/expression-language": "~6.4.0", "symfony/flex": "^2.0", diff --git a/composer.lock b/composer.lock index fa7f11b5c..ac9344e10 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": "bed3fa646c40854e6982154f552fcae6", + "content-hash": "67233a09d452101515a4003cb8eee218", "packages": [ { "name": "api-platform/core", @@ -3611,6 +3611,73 @@ }, "time": "2024-09-04T12:55:26+00:00" }, + { + "name": "masterminds/html5", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" + }, + "time": "2024-03-31T07:05:07+00:00" + }, { "name": "monolog/monolog", "version": "3.7.0", @@ -6316,6 +6383,73 @@ ], "time": "2024-09-08T12:31:10+00:00" }, + { + "name": "symfony/dom-crawler", + "version": "v6.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "ae074dffb018c37a57071990d16e6152728dd972" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/ae074dffb018c37a57071990d16e6152728dd972", + "reference": "ae074dffb018c37a57071990d16e6152728dd972", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-25T15:07:50+00:00" + }, { "name": "symfony/dotenv", "version": "v6.4.12", @@ -11666,73 +11800,6 @@ ], "time": "2020-07-06T04:49:32+00:00" }, - { - "name": "masterminds/html5", - "version": "2.9.0", - "source": { - "type": "git", - "url": "https://github.com/Masterminds/html5-php.git", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Masterminds\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Matt Butcher", - "email": "technosophos@gmail.com" - }, - { - "name": "Matt Farina", - "email": "matt@mattfarina.com" - }, - { - "name": "Asmir Mustafic", - "email": "goetas@gmail.com" - } - ], - "description": "An HTML5 parser and serializer.", - "homepage": "http://masterminds.github.io/html5-php", - "keywords": [ - "HTML5", - "dom", - "html", - "parser", - "querypath", - "serializer", - "xml" - ], - "support": { - "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.9.0" - }, - "time": "2024-03-31T07:05:07+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.12.0", @@ -13889,73 +13956,6 @@ ], "time": "2024-05-31T14:49:08+00:00" }, - { - "name": "symfony/dom-crawler", - "version": "v6.4.12", - "source": { - "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/9d307ecbcb917001692be333cdc58f474fdb37f0", - "reference": "9d307ecbcb917001692be333cdc58f474fdb37f0", - "shasum": "" - }, - "require": { - "masterminds/html5": "^2.6", - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "symfony/css-selector": "^5.4|^6.0|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.12" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-15T06:35:36+00:00" - }, { "name": "symfony/maker-bundle", "version": "v1.61.0", diff --git a/config/services.yaml b/config/services.yaml index 6f1a0b8c7..975641c29 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -51,7 +51,7 @@ services: Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationFailureHandler' Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface: '@Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Authentication\AuthenticationSuccessHandler' - App\Feed\CalendarApiFeedType: + App\Feed\SourceType\CalendarApi\CalendarApiFeedType: arguments: $locationEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT)%' $resourceEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT)%' diff --git a/docs/calender-api-feed.md b/docs/feed/calender-api-feed.md similarity index 100% rename from docs/calender-api-feed.md rename to docs/feed/calender-api-feed.md diff --git a/docs/feed/feed-overview.md b/docs/feed/feed-overview.md new file mode 100644 index 000000000..adb2c0af5 --- /dev/null +++ b/docs/feed/feed-overview.md @@ -0,0 +1,27 @@ +# Feed Overview + +"Feeds" in OS2display are external data sources that can provide up-to-data to slides. The idea is that if you can set +up slide based on a feed and publish it. The Screen Client will then fetch new data from the feed whenever the Slide is +shown on screen. + +The simplest example is a classic RSS news feed. You can set up a slide based on the RSS slide template, configure the +RSS source URL, and whenever the slide is on screen it will show the latest entries from the RSS feed. + +This means that administrators can set up slides and playlists that stays up to date automatically. + +## Architecture + +The "Feed" architecture is designed to enable both generic and custom feed types. To enable this all feed based screen +templates are designed to support a given "feed output model". These are normalized data sets from a given feed type. + +Each feed implementation defines which output model it supports. Thereby multiple feed implementations can support the +same output model. This is done to enable decoupling of the screen templates from the feed implementation. + +For example: + +* If you have a news source that is not a RSS feed you can implement a "FeedSource" that fetches data from your source + then normalizes the data and outputs it as the RSS output model. When setting up RSS slides this feed source can then + be selected as the source for the slide. +* OS2display has calendar templates that can show bookings or meetings. To show data from your specific calendar or + booking system you can implement a "FeedSource" that fetches booking data from your source and normalizes it to match + the calendar output model. diff --git a/migrations/Version20241125085559.php b/migrations/Version20241125085559.php new file mode 100644 index 000000000..f2efc7a99 --- /dev/null +++ b/migrations/Version20241125085559.php @@ -0,0 +1,41 @@ +addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\CalendarApi\\\\CalendarApiFeedType" WHERE feed_type = "App\\\\Feed\\\\CalendarApiFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\CalendarKoba\\\\KobaFeedType" WHERE feed_type = "App\\\\Feed\\\\KobaFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\NewsColibo\\\\ColiboFeedType" WHERE feed_type = "App\\\\Feed\\\\ColiboFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\NewsRss\\\\RssFeedType" WHERE feed_type = "App\\\\Feed\\\\RssFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\PosterEventDatabase\\\\EventDatabaseApiFeedType" WHERE feed_type = "App\\\\Feed\\\\EventDatabaseApiFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\StoryNotified\\\\NotifiedFeedType" WHERE feed_type = "App\\\\Feed\\\\NotifiedFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SourceType\\\\StorySparkleIO\\\\SparkleIOFeedType" WHERE feed_type = "App\\\\Feed\\\\SparkleIOFeedType"'); + } + + public function down(Schema $schema): void + { + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\CalendarApiFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\CalendarApi\\\\CalendarApiFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\KobaFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\CalendarKoba\\\\KobaFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\ColiboFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\NewsColibo\\\\ColiboFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\RssFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\NewsRss\\\\RssFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\EventDatabaseApiFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\PosterEventDatabase\\\\EventDatabaseApiFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\NotifiedFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\StoryNotified\\\\NotifiedFeedType"'); + $this->addSql('UPDATE feed_source SET feed_type = "App\\\\Feed\\\\SparkleIOFeedType" WHERE feed_type = "App\\\\Feed\\\\SourceType\\\\StorySparkleIO\\\\SparkleIOFeedType"'); + } +} diff --git a/migrations/Version20241125085560.php b/migrations/Version20241125085560.php new file mode 100644 index 000000000..869af5391 --- /dev/null +++ b/migrations/Version20241125085560.php @@ -0,0 +1,31 @@ +addSql('UPDATE feed_source SET supported_feed_output_type = "news" WHERE supported_feed_output_type = "rss"'); + $this->addSql('UPDATE feed_source SET supported_feed_output_type = "story" WHERE supported_feed_output_type = "instagram"'); + } + + public function down(Schema $schema): void + { + $this->addSql('UPDATE feed_source SET supported_feed_output_type = "rss" WHERE supported_feed_output_type = "news"'); + $this->addSql('UPDATE feed_source SET supported_feed_output_type = "instagram" WHERE supported_feed_output_type = "story"'); + } +} diff --git a/psalm-baseline.xml b/psalm-baseline.xml index ef8d120f0..e2174f7bf 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -285,33 +285,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Feed/FeedException.php b/src/Feed/FeedException.php new file mode 100644 index 000000000..e164db7b5 --- /dev/null +++ b/src/Feed/FeedException.php @@ -0,0 +1,9 @@ +events; + } +} diff --git a/src/Feed/OutputModel/Calendar/Event.php b/src/Feed/OutputModel/Calendar/Event.php new file mode 100644 index 000000000..8d8fe527d --- /dev/null +++ b/src/Feed/OutputModel/Calendar/Event.php @@ -0,0 +1,19 @@ +news; + } +} diff --git a/src/Feed/OutputModel/OutputInterface.php b/src/Feed/OutputModel/OutputInterface.php new file mode 100644 index 000000000..47e348892 --- /dev/null +++ b/src/Feed/OutputModel/OutputInterface.php @@ -0,0 +1,8 @@ +posters; + } +} diff --git a/src/Feed/OutputModel/Story/Story.php b/src/Feed/OutputModel/Story/Story.php new file mode 100644 index 000000000..af9119525 --- /dev/null +++ b/src/Feed/OutputModel/Story/Story.php @@ -0,0 +1,15 @@ +stories; + } +} diff --git a/src/Feed/CalendarApiFeedType.php b/src/Feed/SourceType/CalendarApi/CalendarApiFeedType.php similarity index 91% rename from src/Feed/CalendarApiFeedType.php rename to src/Feed/SourceType/CalendarApi/CalendarApiFeedType.php index 37210cf59..151644f3f 100644 --- a/src/Feed/CalendarApiFeedType.php +++ b/src/Feed/SourceType/CalendarApi/CalendarApiFeedType.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\CalendarApi; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; -use App\Model\CalendarEvent; -use App\Model\CalendarLocation; -use App\Model\CalendarResource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\Calendar\CalendarOutput; +use App\Feed\OutputModel\Calendar\Event; +use App\Feed\OutputModel\Calendar\Location; +use App\Feed\OutputModel\Calendar\Resource; use App\Service\FeedService; -use Faker\Core\DateTime; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -29,7 +31,7 @@ */ class CalendarApiFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::CALENDAR_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::CALENDAR_OUTPUT; final public const string EXCLUDE_IF_TITLE_NOT_CONTAINS = 'EXCLUDE_IF_TITLE_NOT_CONTAINS'; final public const string REPLACE_TITLE_IF_CONTAINS = 'REPLACE_TITLE_IF_CONTAINS'; @@ -79,7 +81,7 @@ public function getData(Feed $feed): array foreach ($resources as $resource) { $events = $this->getResourceEvents($resource); - /** @var CalendarEvent $event */ + /** @var Event $event */ foreach ($events as $event) { $title = $event->title; @@ -117,21 +119,21 @@ public function getData(Feed $feed): array $title = trim($title); - $results[] = [ - 'id' => Ulid::generate(), - 'title' => $title, - 'startTime' => $event->startTimeTimestamp, - 'endTime' => $event->endTimeTimestamp, - 'resourceTitle' => $event->resourceDisplayName, - 'resourceId' => $event->resourceId, - ]; + $results[] = new Event( + Ulid::generate(), + $title, + $event->startTime, + $event->endTime, + $event->resourceTitle, + $event->resourceId, + ); } } // Sort bookings by start time. - usort($results, fn (array $a, array $b) => $a['startTime'] > $b['startTime'] ? 1 : -1); + usort($results, fn (Event $a, Event $b) => $a->startTime > $b->startTime ? 1 : -1); - return $results; + return (new CalendarOutput($results))->toArray(); } catch (\Throwable $throwable) { $this->logger->error('{code}: {message}', [ 'code' => $throwable->getCode(), @@ -204,7 +206,7 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin $resources = array_merge($resources, $locationResources); } - $resourceOptions = array_map(fn (CalendarResource $resource) => [ + $resourceOptions = array_map(fn (Resource $resource) => [ 'id' => Ulid::generate(), 'title' => $resource->displayName, 'value' => $resource->id, @@ -215,7 +217,7 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin return $resourceOptions; } elseif ('locations' === $name) { - $locationOptions = array_map(fn (CalendarLocation $location) => [ + $locationOptions = array_map(fn (Location $location) => [ 'id' => Ulid::generate(), 'title' => $location->displayName, 'value' => $location->id, @@ -260,7 +262,7 @@ private function getLocationOptions(): array { $locations = $this->loadLocations(); - return array_reduce($locations, function (array $carry, CalendarLocation $location) { + return array_reduce($locations, function (array $carry, Location $location) { $carry[] = $location->id; return $carry; @@ -274,7 +276,7 @@ private function getResourceEvents(string $resourceId): array if (!$cacheItem->isHit()) { $allEvents = $this->loadEvents(); - $items = array_filter($allEvents, fn (CalendarEvent $item) => $item->resourceId === $resourceId); + $items = array_filter($allEvents, fn (Event $item) => $item->resourceId === $resourceId); $cacheItem->set($items); $cacheItem->expiresAfter($this->cacheExpireSeconds); @@ -291,7 +293,7 @@ private function getLocationResources(string $locationId): array if (!$cacheItem->isHit()) { $allResources = $this->loadResources(); - $items = array_filter($allResources, fn (CalendarResource $item) => $item->locationId === $locationId); + $items = array_filter($allResources, fn (Resource $item) => $item->locationId === $locationId); $cacheItem->set($items); $cacheItem->expiresAfter($this->cacheExpireSeconds); @@ -311,7 +313,7 @@ private function loadLocations(): array $LocationEntries = $response->toArray(); - $locations = array_map(fn (array $entry) => new CalendarLocation( + $locations = array_map(fn (array $entry) => new Location( $entry[$this->getMapping('locationId')], $entry[$this->getMapping('locationDisplayName')], ), $LocationEntries); @@ -346,7 +348,7 @@ private function loadResources(): array // Only include resources that are included in events endpoint. if ($includeValue) { - $resource = new CalendarResource( + $resource = new Resource( $resourceEntry[$this->getMapping('resourceId')], $resourceEntry[$this->getMapping('resourceLocationId')], $resourceEntry[$this->getMapping('resourceDisplayName')], @@ -377,7 +379,7 @@ private function loadEvents(): array $eventEntries = $response->toArray(); $events = array_reduce($eventEntries, function (array $carry, array $entry) { - $newEntry = new CalendarEvent( + $newEntry = new Event( Ulid::generate(), $entry[$this->getMapping('eventTitle')], $this->stringToUnixTimestamp($entry[$this->getMapping('eventStartTime')]), @@ -388,10 +390,10 @@ private function loadEvents(): array // Filter out entries if they do not supply required data. if ( - !empty($newEntry->startTimeTimestamp) - && !empty($newEntry->endTimeTimestamp) + !empty($newEntry->startTime) + && !empty($newEntry->endTime) && !empty($newEntry->resourceId) - && !empty($newEntry->resourceDisplayName) + && !empty($newEntry->resourceTitle) ) { $carry[] = $newEntry; } diff --git a/src/Feed/KobaFeedType.php b/src/Feed/SourceType/CalendarKoba/KobaFeedType.php similarity index 90% rename from src/Feed/KobaFeedType.php rename to src/Feed/SourceType/CalendarKoba/KobaFeedType.php index 2bf319b6b..54f62f4d0 100644 --- a/src/Feed/KobaFeedType.php +++ b/src/Feed/SourceType/CalendarKoba/KobaFeedType.php @@ -2,10 +2,14 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\CalendarKoba; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\Calendar\CalendarOutput; +use App\Feed\OutputModel\Calendar\Event; use App\Service\FeedService; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -15,7 +19,7 @@ /** @deprecated */ class KobaFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::CALENDAR_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::CALENDAR_OUTPUT; public function __construct( private readonly FeedService $feedService, @@ -85,7 +89,7 @@ public function getData(Feed $feed): array } // Apply list filter. If enabled it removes all events that do not have (liste) in title. - if ($filterList) { + if (true === $filterList) { if (!str_contains($title, '(liste)')) { continue; } else { @@ -94,28 +98,27 @@ public function getData(Feed $feed): array } // Apply booked title override. If enabled it changes the title to Optaget if it contains (optaget). - if ($rewriteBookedTitles) { + if (true === $rewriteBookedTitles) { if (str_contains($title, '(optaget)')) { $title = 'Optaget'; } } - $results[] = [ - 'id' => Ulid::generate(), - 'title' => $title, - 'description' => $booking['event_description'] ?? '', - 'startTime' => $booking['start_time'] ?? '', - 'endTime' => $booking['end_time'] ?? '', - 'resourceTitle' => $booking['resource_alias'] ?? '', - 'resourceId' => $booking['resource_id'] ?? '', - ]; + $results[] = new Event( + Ulid::generate(), + $title, + $booking['start_time'] ?? '', + $booking['end_time'] ?? '', + $booking['resource_alias'] ?? '', + $booking['resource_id'] ?? '', + ); } } // Sort bookings by start time. - usort($results, fn ($a, $b) => strcmp((string) $a['startTime'], (string) $b['startTime'])); + usort($results, fn (Event $a, Event $b) => $a->startTime > $b->startTime ? 1 : -1); - return $results; + return (new CalendarOutput($results))->toArray(); } catch (\Throwable $throwable) { $this->logger->error('{code}: {message}', [ 'code' => $throwable->getCode(), diff --git a/src/Feed/SourceType/NewsColibo/ApiClient.php b/src/Feed/SourceType/NewsColibo/ApiClient.php new file mode 100644 index 000000000..fb67bdd97 --- /dev/null +++ b/src/Feed/SourceType/NewsColibo/ApiClient.php @@ -0,0 +1,230 @@ + */ + private array $apiClients = []; + + public function __construct( + private readonly CacheItemPoolInterface $feedsCache, + private readonly LoggerInterface $logger, + ) {} + + /** + * Get Feed News Entries for a given FeedSource. + * + * @param FeedSource $feedSource + * The FeedSource to scope by + * @param array $recipients + * An array of recipient ID's to filter by + * @param array $publishers + * An array of publisher ID's to filter by + * @param int $pageSize + * Number of elements to retrieve + * + * @return mixed + */ + public function getFeedEntriesNews(FeedSource $feedSource, array $recipients = [], array $publishers = [], int $pageSize = 10): mixed + { + try { + $client = $this->getApiClient($feedSource); + + $options = [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'query' => [ + 'recipients' => array_map(fn ($recipient) => (object) [ + 'Id' => $recipient, + 'Type' => 'Group', + ], $recipients), + 'publishers' => array_map(fn ($publisher) => (object) [ + 'Id' => $publisher, + 'Type' => 'Group', + ], $publishers), + 'pageSize' => $pageSize, + ], + ]; + + $response = $client->request('GET', '/api/feedentries/news', $options); + + return json_decode($response->getContent(), false, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * Retrieve search groups based on the given feed source and type. + * + * @param FeedSource $feedSource + * @param string $type + * + * @return array + */ + public function getSearchGroups(FeedSource $feedSource, string $type = 'WorkGroup'): array + { + try { + $responseData = $this->getSearchGroupsPage($feedSource, $type)->toArray(); + + $groups = $responseData['results']; + + $total = $responseData['total']; + $pages = (int) ceil($total / self::BATCH_SIZE); + + /** @var ResponseInterface[] $responses */ + $responses = []; + for ($page = 1; $page < $pages; ++$page) { + $responses[] = $this->getSearchGroupsPage($feedSource, $type, $page); + } + + foreach ($responses as $response) { + $responseData = $response->toArray(); + $groups = array_merge($groups, $responseData['results']); + } + + return $groups; + } catch (\Throwable $throwable) { + $this->logger->error('{code}: {message}', [ + 'code' => $throwable->getCode(), + 'message' => $throwable->getMessage(), + ]); + + return []; + } + } + + /** + * @param FeedSource $feedSource + * @param string $type + * @param int $pageIndex + * @param int $pageSize + * + * @return ResponseInterface + * + * @throws ColiboException + */ + private function getSearchGroupsPage(FeedSource $feedSource, string $type, int $pageIndex = 0, int $pageSize = self::BATCH_SIZE): ResponseInterface + { + try { + $client = $this->getApiClient($feedSource); + + return $client->request('GET', '/api/search/groups', [ + 'query' => [ + 'groupSearchQuery.groupTypes' => $type, + 'groupSearchQuery.pageIndex' => $pageIndex, + 'groupSearchQuery.pageSize' => $pageSize, + ], + ]); + } catch (ColiboException $exception) { + throw $exception; + } catch (\Throwable $throwable) { + throw new ColiboException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + /** + * Get an authenticated scoped API client for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return HttpClientInterface + * + * @throws ColiboException + */ + private function getApiClient(FeedSource $feedSource): HttpClientInterface + { + $id = ColiboFeedType::getIdKey($feedSource); + + if (array_key_exists($id, $this->apiClients)) { + return $this->apiClients[$id]; + } + + $secrets = new SecretsDTO($feedSource); + $this->apiClients[$id] = HttpClient::createForBaseUri($secrets->apiBaseUri)->withOptions([ + 'headers' => [ + 'Authorization' => 'Bearer '.$this->fetchToken($feedSource), + 'Accept' => 'application/json', + ], + ]); + + return $this->apiClients[$id]; + } + + /** + * Get the auth token for the given FeedSource. + * + * @param FeedSource $feedSource + * + * @return string + * + * @throws ColiboException + */ + private function fetchToken(FeedSource $feedSource): string + { + $id = ColiboFeedType::getIdKey($feedSource); + + /** @var CacheItemInterface $cacheItem */ + $cacheItem = $this->feedsCache->getItem('colibo_token_'.$id); + + if ($cacheItem->isHit()) { + /** @var string $token */ + $token = $cacheItem->get(); + } else { + try { + $secrets = new SecretsDTO($feedSource); + $client = HttpClient::createForBaseUri($secrets->apiBaseUri); + + $response = $client->request('POST', '/auth/oauth2/connect/token', [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ], + 'body' => [ + 'grant_type' => self::GRANT_TYPE, + 'scope' => self::SCOPE, + 'client_id' => $secrets->clientId, + 'client_secret' => $secrets->clientSecret, + ], + ]); + + $content = $response->getContent(); + $contentDecoded = json_decode($content, false, 512, JSON_THROW_ON_ERROR); + + $token = $contentDecoded->access_token; + + // Expire cache 5 min before token expire + $expireSeconds = intval($contentDecoded->expires_in - 300); + + $cacheItem->set($token); + $cacheItem->expiresAfter($expireSeconds); + $this->feedsCache->save($cacheItem); + } catch (\Throwable $throwable) { + throw new ColiboException($throwable->getMessage(), (int) $throwable->getCode(), $throwable); + } + } + + return $token; + } +} diff --git a/src/Feed/SourceType/NewsColibo/ColiboException.php b/src/Feed/SourceType/NewsColibo/ColiboException.php new file mode 100644 index 000000000..1d1551579 --- /dev/null +++ b/src/Feed/SourceType/NewsColibo/ColiboException.php @@ -0,0 +1,11 @@ +feedService->getFeedSourceConfigUrl($feedSource, 'allowed-recipients'); + + return [ + [ + 'key' => 'colibo-feed-type-recipient-selector', + 'input' => 'multiselect-from-endpoint', + 'endpoint' => $feedEntryRecipients, + 'name' => 'recipients', + 'label' => 'Grupper', + 'helpText' => 'Vælg hvilke grupper, der skal hentes nyheder fra.', + 'formGroupClasses' => 'mb-3', + ], + [ + 'key' => 'colibo-feed-type-page-size', + 'input' => 'input', + 'type' => 'number', + 'name' => 'page_size', + 'label' => 'Antal nyheder', + 'defaultValue' => '5', + 'helpText' => 'Vælg hvor mange nyheder der maksimalt skal hentes.', + 'formGroupClasses' => 'mb-3', + ], + ]; + } + + public function getData(Feed $feed): array + { + $configuration = $feed->getConfiguration(); + $secrets = $feed->getFeedSource()?->getSecrets() ?? []; + + $baseUri = $secrets['api_base_uri']; + $recipients = $configuration['recipients'] ?? []; + $publishers = $configuration['publishers'] ?? []; + $pageSize = isset($configuration['page_size']) ? (int) $configuration['page_size'] : 10; + + if (empty($baseUri) || 0 === count($recipients)) { + return []; + } + + $feedSource = $feed->getFeedSource(); + + if (null === $feedSource) { + return []; + } + + $results = []; + + $entries = $this->apiClient->getFeedEntriesNews($feedSource, $recipients, $publishers, $pageSize); + + foreach ($entries as $entry) { + $categories = array_map(fn($recipient) => $recipient->name, $entry->recipients); + $title = $entry->fields->title; + + $crawler = new Crawler($entry->fields->description); + $summary = ''; + foreach ($crawler as $domElement) { + $summary .= $domElement->textContent; + } + + $link = sprintf('%s/feedentry/%s', $baseUri, $entry->id); + + if (null !== $entry->fields->body) { + $crawler = new Crawler($entry->fields->body); + $content = ''; + foreach ($crawler as $domElement) { + $content .= $domElement->textContent; + } + } else { + $content = $summary; + } + + $updated = $entry->updated ?? $entry->publishDate; + $lastModified = new \DateTime($updated); + + $author = $entry->author->firstName . ' ' . $entry->author->lastName; + + $imageUrl = null; + if (null !== $entry->fields->galleryItems) { + try { + $galleryItems = json_decode($entry->fields->galleryItems, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $galleryItems = []; + } + + $imageUrl = count($galleryItems) > 0 ? sprintf('%s/api/files/%s/thumbnail/large', $baseUri, $galleryItems[0]['id']) : null; + } + + $publisher = $entry->publisher->name ?? null; + + $results[] = new News( + $categories, + $title, + $content, + $summary, + $imageUrl, + $author, + $lastModified->format('c'), + $publisher, + $link, + ); + } + + return (new NewsOutput($results))->toArray(); + } + + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array + { + switch ($name) { + case 'allowed-recipients': + $allowedIds = $feedSource->getSecrets()['allowed_recipients'] ?? []; + $allGroupOptions = $this->getConfigOptions($request, $feedSource, 'recipients'); + + if (null === $allGroupOptions) { + return []; + } + + return array_values(array_filter($allGroupOptions, fn (ConfigOption $group) => in_array($group->value, $allowedIds))); + case 'recipients': + $id = self::getIdKey($feedSource); + + $cacheItem = $this->feedsCache->getItem('colibo_feed_entry_groups_'.$id); + + if ($cacheItem->isHit()) { + $groups = $cacheItem->get(); + } else { + $groups = $this->apiClient->getSearchGroups($feedSource); + + $groups = array_map(fn (array $item) => new ConfigOption( + Ulid::generate(), + sprintf('%s (%d)', $item['model']['title'], $item['model']['id']), + (string) $item['model']['id'] + ), $groups); + + usort($groups, fn ($a, $b) => strcmp($a->title, $b->title)); + + $cacheItem->set($groups); + $cacheItem->expiresAfter(self::CACHE_TTL); + $this->feedsCache->save($cacheItem->set($groups)); + } + + return $groups; + default: + return null; + } + } + + public function getRequiredSecrets(): array + { + return [ + 'api_base_uri' => [ + 'type' => 'string', + 'exposeValue' => true, + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'string_array', + 'exposeValue' => true, + ], + ]; + } + + public function getRequiredConfiguration(): array + { + return ['recipients', 'page_size']; + } + + public function getSupportedFeedOutputType(): string + { + return self::SUPPORTED_FEED_TYPE; + } + + public function getSchema(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'type' => 'object', + 'properties' => [ + 'api_base_uri' => [ + 'type' => 'string', + 'format' => 'uri', + ], + 'client_id' => [ + 'type' => 'string', + ], + 'client_secret' => [ + 'type' => 'string', + ], + 'allowed_recipients' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + ], + ], + 'required' => ['api_base_uri', 'client_id', 'client_secret'], + ]; + } + + public static function getIdKey(FeedSource $feedSource): string + { + $ulid = $feedSource->getId(); + assert(null !== $ulid); + + return $ulid->toBase32(); + } +} diff --git a/src/Feed/SourceType/NewsColibo/SecretsDTO.php b/src/Feed/SourceType/NewsColibo/SecretsDTO.php new file mode 100644 index 000000000..6b0f2f49d --- /dev/null +++ b/src/Feed/SourceType/NewsColibo/SecretsDTO.php @@ -0,0 +1,35 @@ +getSecrets(); + + if (null === $secrets) { + throw new \RuntimeException('No secrets found for feed source.'); + } + + if (!isset($secrets['api_base_uri'], $secrets['client_id'], $secrets['client_secret'])) { + throw new \RuntimeException('Missing required secrets for feed source.'); + } + + if (false === filter_var($secrets['api_base_uri'], FILTER_VALIDATE_URL)) { + throw new \RuntimeException('Invalid api_endpoint.'); + } + + $this->apiBaseUri = rtrim((string) $secrets['api_base_uri'], '/'); + $this->clientId = $secrets['client_id']; + $this->clientSecret = $secrets['client_secret']; + } +} diff --git a/src/Feed/RssFeedType.php b/src/Feed/SourceType/NewsRss/RssFeedType.php similarity index 73% rename from src/Feed/RssFeedType.php rename to src/Feed/SourceType/NewsRss/RssFeedType.php index 4e395da7d..2e1cedb2c 100644 --- a/src/Feed/RssFeedType.php +++ b/src/Feed/SourceType/NewsRss/RssFeedType.php @@ -2,12 +2,17 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\NewsRss; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\News\News; +use App\Feed\OutputModel\News\NewsOutput; use FeedIo\Adapter\Http\Client; use FeedIo\Feed\Item; +use FeedIo\Feed\Node\CategoryInterface; use FeedIo\FeedIo; use Psr\Log\LoggerInterface; use Symfony\Component\HttpClient\HttplugClient; @@ -15,13 +20,14 @@ class RssFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::RSS_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::NEWS_OUTPUT; private readonly FeedIo $feedIo; public function __construct( private readonly LoggerInterface $logger, - ) { + ) + { $client = new Client(new HttplugClient()); $this->feedIo = new FeedIo($client, $this->logger); } @@ -46,24 +52,33 @@ public function getData(Feed $feed): array return []; } + $results = []; $feedResult = $this->feedIo->read($url); - $result = [ - 'title' => $feedResult->getFeed()->getTitle(), - 'entries' => [], - ]; /** @var Item $item */ foreach ($feedResult->getFeed() as $item) { - $result['entries'][] = $item->toArray(); - - if (!is_null($numberOfEntries) && count($result['entries']) >= $numberOfEntries) { + $medias = $item->getMedias(); + + $results[] = new News( + array_map(fn (CategoryInterface $category) => $category->getLabel(), iterator_to_array($item->getCategories())), + $item->getTitle(), + strip_tags($item->getContent() ?? ''), + $item->getSummary(), + count($medias) > 0 ? $medias[0]->getUrl() : null, + $item->getAuthor()?->getName(), + $item->getLastModified()->format('c'), + $feedResult->getFeed()->getTitle(), + $item->getLink(), + ); + + if (!is_null($numberOfEntries) && count($results) >= $numberOfEntries) { break; } } - return $result; + return (new NewsOutput($results))->toArray(); } catch (\Throwable $throwable) { - $this->logger->error($throwable->getCode().': '.$throwable->getMessage()); + $this->logger->error($throwable->getCode() . ': ' . $throwable->getMessage()); } return []; diff --git a/src/Feed/EventDatabaseApiFeedType.php b/src/Feed/SourceType/PosterEventDatabaseApi/EventDatabaseApiFeedType.php similarity index 78% rename from src/Feed/EventDatabaseApiFeedType.php rename to src/Feed/SourceType/PosterEventDatabaseApi/EventDatabaseApiFeedType.php index fb660ef7c..a74ba3dac 100644 --- a/src/Feed/EventDatabaseApiFeedType.php +++ b/src/Feed/SourceType/PosterEventDatabaseApi/EventDatabaseApiFeedType.php @@ -2,10 +2,15 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\PosterEventDatabaseApi; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\Poster\Place; +use App\Feed\OutputModel\Poster\Poster; +use App\Feed\OutputModel\Poster\PosterOutput; use App\Service\FeedService; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -19,7 +24,7 @@ */ class EventDatabaseApiFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::POSTER_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::POSTER_OUTPUT; final public const int REQUEST_TIMEOUT = 10; public function __construct( @@ -50,12 +55,21 @@ public function getData(Feed $feed): array $tags = $configuration['subscriptionTagValue'] ?? null; $numberOfItems = $configuration['subscriptionNumberValue'] ?? 5; - $queryParams = array_filter([ + $queryParams = [ 'items_per_page' => $numberOfItems, - 'occurrences.place.id' => array_map(static fn ($place) => str_replace('/api/places/', '', (string) $place['value']), $places), - 'organizer.id' => array_map(static fn ($organizer) => str_replace('/api/organizers/', '', (string) $organizer['value']), $organizers), - 'tags' => array_map(static fn ($tag) => str_replace('/api/tags/', '', (string) $tag['value']), $tags), - ]); + ]; + + if (null !== $places) { + $queryParams['occurrences.place.id'] = array_map(static fn (array $place) => str_replace('/api/places/', '', (string) $place['value']), $places); + } + + if (null !== $organizers) { + $queryParams['organizer.id'] = array_map(static fn (array $organizer) => str_replace('/api/organizers/', '', (string) $organizer['value']), $organizers); + } + + if (null !== $tags) { + $queryParams['tags'] = array_map(static fn (array $tag) => str_replace('/api/tags/', '', (string) $tag['value']), $tags); + } $response = $this->client->request( 'GET', @@ -87,33 +101,36 @@ public function getData(Feed $feed): array $baseUrl = parse_url((string) $decoded->event->{'url'}, PHP_URL_HOST); - $eventOccurrence = (object) [ - 'eventId' => $decoded->event->{'@id'}, - 'occurrenceId' => $decoded->{'@id'}, - 'ticketPurchaseUrl' => $decoded->event->{'ticketPurchaseUrl'}, - 'excerpt' => $decoded->event->{'excerpt'}, - 'name' => $decoded->event->{'name'}, - 'url' => $decoded->event->{'url'}, - 'baseUrl' => $baseUrl, - 'image' => $decoded->event->{'image'}, - 'startDate' => $decoded->{'startDate'}, - 'endDate' => $decoded->{'endDate'}, - 'ticketPriceRange' => $decoded->{'ticketPriceRange'}, - 'eventStatusText' => $decoded->{'eventStatusText'}, - ]; + $place = null; if (isset($decoded->place)) { - $eventOccurrence->place = (object) [ - 'name' => $decoded->place->name, - 'streetAddress' => $decoded->place->streetAddress, - 'addressLocality' => $decoded->place->addressLocality, - 'postalCode' => $decoded->place->postalCode, - 'image' => $decoded->place->image, - 'telephone' => $decoded->place->telephone, - ]; + $place = new Place( + $decoded->place->name, + $decoded->place->streetAddress, + $decoded->place->addressLocality, + $decoded->place->postalCode, + $decoded->place->image, + $decoded->place->telephone, + ); } - return [$eventOccurrence]; + $poster = new Poster( + $decoded->event->{'@id'}, + $decoded->{'@id'}, + $decoded->event->{'ticketPurchaseUrl'}, + $decoded->event->{'excerpt'}, + $decoded->event->{'name'}, + $decoded->event->{'url'}, + $baseUrl, + $decoded->event->{'image'}, + $decoded->{'startDate'}, + $decoded->{'endDate'}, + $decoded->{'ticketPriceRange'}, + $decoded->{'eventStatusText'}, + $place, + ); + + return (new PosterOutput([$poster]))->toArray(); } } } @@ -122,12 +139,13 @@ public function getData(Feed $feed): array if ($throwable instanceof ClientException && Response::HTTP_NOT_FOUND == $throwable->getCode()) { try { // Slide publishedTo is set to now. This will make the slide unpublished from this point on. - $feed->getSlide()->setPublishedTo(new \DateTime('now', new \DateTimeZone('UTC'))); + $slide = $feed->getSlide()?->setPublishedTo(new \DateTime('now', new \DateTimeZone('UTC'))); + $this->entityManager->flush(); $this->logger->info('Feed with id: {feedId} depends on an item that does not exist in Event Database. Unpublished slide with id: {slideId}', [ 'feedId' => $feed->getId(), - 'slideId' => $feed->getSlide()->getId(), + 'slideId' => $slide?->getId(), ]); } catch (\Exception $exception) { $this->logger->error('{code}: {message}', [ diff --git a/src/Feed/NotifiedFeedType.php b/src/Feed/SourceType/StoryNotified/NotifiedFeedType.php similarity index 86% rename from src/Feed/NotifiedFeedType.php rename to src/Feed/SourceType/StoryNotified/NotifiedFeedType.php index 75ac8b9c1..089531764 100644 --- a/src/Feed/NotifiedFeedType.php +++ b/src/Feed/SourceType/StoryNotified/NotifiedFeedType.php @@ -2,10 +2,15 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\StoryNotified; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\ConfigOption; +use App\Feed\OutputModel\Story\Story; +use App\Feed\OutputModel\Story\StoryOutput; use App\Service\FeedService; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; @@ -17,7 +22,7 @@ */ class NotifiedFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::INSTAGRAM_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::STORY_OUTPUT; final public const int REQUEST_TIMEOUT = 10; private const string BASE_URL = 'https://api.listen.notified.com'; @@ -52,19 +57,19 @@ public function getData(Feed $feed): array $feedItems = array_map(fn (array $item) => $this->getFeedItemObject($item), $data); - $result = []; + $results = []; // Check that image is accessible, otherwise leave out the feed element. foreach ($feedItems as $feedItem) { - $response = $this->client->request(Request::METHOD_HEAD, $feedItem['mediaUrl']); + // Only add item if the media can be retrieved. + $response = $this->client->request(Request::METHOD_HEAD, $feedItem->mediaUrl); $statusCode = $response->getStatusCode(); - if (200 == $statusCode) { - $result[] = $feedItem; + $results[] = $feedItem; } } - return $result; + return (new StoryOutput($results))->toArray(); } catch (\Throwable $throwable) { $this->logger->error('{code}: {message}', [ 'code' => $throwable->getCode(), @@ -113,11 +118,11 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin $data = $this->getSearchProfiles($token); - return array_map(fn (array $item) => [ - 'id' => Ulid::generate(), - 'title' => $item['name'] ?? '', - 'value' => $item['id'] ?? '', - ], $data); + return array_map(fn (array $item) => new ConfigOption( + Ulid::generate(), + $item['name'] ?? '', + $item['id'] ?? '', + ), $data); } } catch (\Throwable $throwable) { $this->logger->error('{code}: {message}', [ @@ -203,19 +208,18 @@ public function getSupportedFeedOutputType(): string /** * Parse feed item into object. */ - private function getFeedItemObject(array $item): array + private function getFeedItemObject(array $item): Story { $description = $item['description'] ?? null; - return [ - 'text' => $description, - 'textMarkup' => null !== $description ? $this->wrapTags($description) : null, - 'mediaUrl' => $item['mediaUrl'] ?? null, - // Video is not supported by the Notified Listen API. - 'videoUrl' => null, - 'username' => $item['sourceName'] ?? null, - 'createdTime' => $item['published'] ?? null, - ]; + return new Story( + $description, + null !== $description ? $this->wrapTags($description) : null, + $item['mediaUrl'] ?? null, + null, + $item['sourceName'] ?? null, + $item['published'] ?? null + ); } private function wrapTags(string $input): string diff --git a/src/Feed/SparkleIOFeedType.php b/src/Feed/SourceType/StorySparkleIO/SparkleIOFeedType.php similarity index 91% rename from src/Feed/SparkleIOFeedType.php rename to src/Feed/SourceType/StorySparkleIO/SparkleIOFeedType.php index 64daed7bf..7d77fa51f 100644 --- a/src/Feed/SparkleIOFeedType.php +++ b/src/Feed/SourceType/StorySparkleIO/SparkleIOFeedType.php @@ -2,17 +2,21 @@ declare(strict_types=1); -namespace App\Feed; +namespace App\Feed\SourceType\StorySparkleIO; use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; +use App\Feed\FeedOutputModels; +use App\Feed\FeedTypeInterface; +use App\Feed\OutputModel\ConfigOption; +use App\Feed\OutputModel\Story\Story; use App\Service\FeedService; use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Uid\Ulid; -use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; @@ -22,14 +26,14 @@ /** @deprecated The SparkleIO service is discontinued. */ class SparkleIOFeedType implements FeedTypeInterface { - final public const string SUPPORTED_FEED_TYPE = SupportedFeedOutputs::INSTAGRAM_OUTPUT; + final public const string SUPPORTED_FEED_TYPE = FeedOutputModels::STORY_OUTPUT; final public const int REQUEST_TIMEOUT = 10; public function __construct( private readonly FeedService $feedService, private readonly HttpClientInterface $client, - private readonly CacheInterface $feedsCache, + private readonly CacheItemPoolInterface $feedsCache, private readonly LoggerInterface $logger, ) {} @@ -144,11 +148,11 @@ public function getConfigOptions(Request $request, FeedSource $feedSource, strin $feeds = []; foreach ($items as $item) { - $feeds[] = [ - 'id' => Ulid::generate(), - 'title' => $item->name ?? '', - 'value' => $item->id ?? '', - ]; + $feeds[] = new ConfigOption( + Ulid::generate(), + $item->name ?? '', + $item->id ?? '', + ); } return $feeds; @@ -252,16 +256,16 @@ private function getToken(string $baseUrl, string $clientId, string $clientSecre * * @return object */ - private function getFeedItemObject(object $item): object + private function getFeedItemObject(object $item): Story { - return (object) [ - 'text' => $item->text, - 'textMarkup' => null !== $item->text ? $this->wrapTags($item->text) : null, - 'mediaUrl' => $item->mediaUrl, - 'videoUrl' => $item->videoUrl, - 'username' => $item->username, - 'createdTime' => $item->createdTime, - ]; + return new Story( + $item->text, + null !== $item->text ? $this->wrapTags($item->text) : null, + $item->mediaUrl, + $item->videoUrl, + $item->username, + $item->createdTime, + ); } /** diff --git a/src/Model/CalendarEvent.php b/src/Model/CalendarEvent.php deleted file mode 100644 index d8aeea9c3..000000000 --- a/src/Model/CalendarEvent.php +++ /dev/null @@ -1,17 +0,0 @@ -feedsCache->getItem($feedId); - if ($cacheItem->isHit()) { + if (false && $cacheItem->isHit()) { /** @var array $data */ $data = $cacheItem->get(); } else { diff --git a/tests/Api/FeedSourceTest.php b/tests/Api/FeedSourceTest.php index f72e32618..23fc527cf 100644 --- a/tests/Api/FeedSourceTest.php +++ b/tests/Api/FeedSourceTest.php @@ -60,7 +60,7 @@ public function testCreateFeedSource(): void 'json' => [ 'title' => 'Test feed source', 'description' => 'This is a test feed source', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', ], @@ -77,7 +77,7 @@ public function testCreateFeedSource(): void '@type' => 'FeedSource', 'title' => 'Test feed source', 'description' => 'This is a test feed source', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', ], @@ -146,7 +146,7 @@ public function testCreateFeedSourceWithEventDatabaseFeedTypeWithoutRequiredSecr 'json' => [ 'title' => 'Test feed source', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType::class, 'secrets' => [ 'test secret', ], @@ -171,7 +171,7 @@ public function testUpdateFeedSource(): void 'title' => 'Updated title', 'description' => 'Updated description', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType::class, 'secrets' => [ ], ], @@ -198,7 +198,7 @@ public function testDeleteFeedSource(): void 'title' => 'Test feed source', 'description' => 'This is a test feed source', 'outputType' => 'This is a test output type', - 'feedType' => \App\Feed\EventDatabaseApiFeedType::class, + 'feedType' => \App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType::class, 'secrets' => [ 'host' => 'https://www.test.dk', ], diff --git a/tests/Feed/NotifiedFeedTypeTest.php b/tests/Feed/NotifiedFeedTypeTest.php index 5a7ef76c5..e76c8d5b0 100644 --- a/tests/Feed/NotifiedFeedTypeTest.php +++ b/tests/Feed/NotifiedFeedTypeTest.php @@ -4,7 +4,7 @@ namespace App\Tests\Feed; -use App\Feed\NotifiedFeedType; +use App\Feed\SourceType\StoryNotified\NotifiedFeedType; use App\Repository\FeedSourceRepository; use App\Repository\SlideRepository; use App\Service\FeedService; diff --git a/tests/Service/FeedServiceTest.php b/tests/Service/FeedServiceTest.php index 9cfd24b27..6694b4315 100644 --- a/tests/Service/FeedServiceTest.php +++ b/tests/Service/FeedServiceTest.php @@ -6,13 +6,13 @@ use App\Entity\Tenant\Feed; use App\Entity\Tenant\FeedSource; -use App\Feed\CalendarApiFeedType; -use App\Feed\EventDatabaseApiFeedType; use App\Feed\FeedTypeInterface; -use App\Feed\KobaFeedType; -use App\Feed\NotifiedFeedType; -use App\Feed\RssFeedType; -use App\Feed\SparkleIOFeedType; +use App\Feed\SourceType\CalendarApi\CalendarApiFeedType; +use App\Feed\SourceType\CalendarKoba\KobaFeedType; +use App\Feed\SourceType\NewsRss\RssFeedType; +use App\Feed\SourceType\PosterEventDatabaseApi\EventDatabaseApiFeedType; +use App\Feed\SourceType\StoryNotified\NotifiedFeedType; +use App\Feed\SourceType\StorySparkleIO\SparkleIOFeedType; use App\Service\FeedService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;