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;