diff --git a/.env b/.env
index 114aa336..697751b0 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 e6badc10..e328e452 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 f2425bda..e4ff3420 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 fa7f11b5..ac9344e1 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/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 00000000..adb2c0af
--- /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/psalm-baseline.xml b/psalm-baseline.xml
index ef8d120f..e2174f7b 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -285,33 +285,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Feed/CalendarApiFeedType.php b/src/Feed/CalendarApiFeedType.php
index 37210cf5..7f4c820d 100644
--- a/src/Feed/CalendarApiFeedType.php
+++ b/src/Feed/CalendarApiFeedType.php
@@ -6,11 +6,10 @@
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\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 +28,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 +78,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;
@@ -204,7 +203,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 +214,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 +259,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 +273,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 +290,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 +310,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 +345,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 +376,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')]),
diff --git a/src/Feed/ColiboFeedType.php b/src/Feed/ColiboFeedType.php
new file mode 100644
index 00000000..65526ab6
--- /dev/null
+++ b/src/Feed/ColiboFeedType.php
@@ -0,0 +1,265 @@
+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() ?? [];
+
+ $result = [
+ 'title' => 'Intranet',
+ 'entries' => [],
+ ];
+
+ $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 $result;
+ }
+
+ $feedSource = $feed->getFeedSource();
+
+ if (null === $feedSource) {
+ return $result;
+ }
+
+ $entries = $this->apiClient->getFeedEntriesNews($feedSource, $recipients, $publishers, $pageSize);
+
+ foreach ($entries as $entry) {
+ $item = new Item();
+ $item->setTitle($entry->fields->title);
+
+ $crawler = new Crawler($entry->fields->description);
+ $summary = '';
+ foreach ($crawler as $domElement) {
+ $summary .= $domElement->textContent;
+ }
+ $item->setSummary($summary);
+
+ $item->setPublicId((string) $entry->id);
+
+ $link = sprintf('%s/feedentry/%s', $baseUri, $entry->id);
+ $item->setLink($link);
+
+ if (null !== $entry->fields->body) {
+ $crawler = new Crawler($entry->fields->body);
+ $content = '';
+ foreach ($crawler as $domElement) {
+ $content .= $domElement->textContent;
+ }
+ } else {
+ $content = $item->getSummary();
+ }
+ $item->setContent($content);
+
+ $updated = $entry->updated ?? $entry->publishDate;
+ $item->setLastModified(new \DateTime($updated));
+
+ $author = new Item\Author();
+ $author->setName($entry->publisher->name);
+ $item->setAuthor($author);
+
+ if (null !== $entry->fields->galleryItems) {
+ try {
+ $galleryItems = json_decode($entry->fields->galleryItems, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException) {
+ $galleryItems = [];
+ }
+
+ foreach ($galleryItems as $galleryItem) {
+ $media = new Item\Media();
+
+ $large = sprintf('%s/api/files/%s/thumbnail/large', $baseUri, $galleryItem['id']);
+ $media->setUrl($large);
+
+ $small = sprintf('%s/api/files/%s/thumbnail/small', $baseUri, $galleryItem['id']);
+ $media->setThumbnail($small);
+
+ $item->addMedia($media);
+ }
+ }
+
+ foreach ($entry->recipients as $recipient) {
+ $category = new Category();
+ $category->setLabel($recipient->name);
+
+ $item->addCategory($category);
+ }
+
+ $result['entries'][] = $item->toArray();
+ }
+
+ return $result;
+ }
+
+ 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);
+
+ /** @var CacheItemInterface $cacheItem */
+ $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/EventDatabaseApiFeedType.php b/src/Feed/EventDatabaseApiFeedType.php
index fb660ef7..250c2e40 100644
--- a/src/Feed/EventDatabaseApiFeedType.php
+++ b/src/Feed/EventDatabaseApiFeedType.php
@@ -19,7 +19,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 +50,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',
@@ -122,12 +131,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/FeedException.php b/src/Feed/FeedException.php
new file mode 100644
index 00000000..e164db7b
--- /dev/null
+++ b/src/Feed/FeedException.php
@@ -0,0 +1,9 @@
+getFeed() as $item) {
- $result['entries'][] = $item->toArray();
+ $entry = $item->toArray();
+
+ if (empty($entry['author'])) {
+ $entry['author'] = [
+ 'name' => $feedResult->getFeed()->getTitle(),
+ ];
+ }
+
+ $result['entries'][] = $entry;
if (!is_null($numberOfEntries) && count($result['entries']) >= $numberOfEntries) {
break;
diff --git a/src/Feed/SourceType/Colibo/ApiClient.php b/src/Feed/SourceType/Colibo/ApiClient.php
new file mode 100644
index 00000000..ae98dab1
--- /dev/null
+++ b/src/Feed/SourceType/Colibo/ApiClient.php
@@ -0,0 +1,231 @@
+ */
+ 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/Colibo/ColiboException.php b/src/Feed/SourceType/Colibo/ColiboException.php
new file mode 100644
index 00000000..6ca7f2c5
--- /dev/null
+++ b/src/Feed/SourceType/Colibo/ColiboException.php
@@ -0,0 +1,11 @@
+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/SparkleIOFeedType.php b/src/Feed/SparkleIOFeedType.php
index 64daed7b..67787a84 100644
--- a/src/Feed/SparkleIOFeedType.php
+++ b/src/Feed/SparkleIOFeedType.php
@@ -8,11 +8,11 @@
use App\Entity\Tenant\FeedSource;
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 +22,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::INSTAGRAM_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,
) {}