diff --git a/.env b/.env index 915066f30..4e929106f 100644 --- a/.env +++ b/.env @@ -114,3 +114,11 @@ HTTP_CLIENT_LOG_LEVEL=error TRACK_SCREEN_INFO=false TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS=300 + +###> NemDeling integration ### +NEMDELING_HTTP_BASIC_USER= +NEMDELING_HTTP_BASIC_PASS= +NEMDELING_TENANT_KEY= +NEMDELING_EVENT_TEMPLATE_TITLE=Event +NEMDELING_EVENT_LIST_TEMPLATE_TITLE="Event List" +###< NemDeling integration ### diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d16b2314..9382e1ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Added NemDeling webhook integration for syncing KK events and event lists. + - Added `POST /api/v1/nemdeling/events` and `POST /api/v1/nemdeling/event-lists` endpoints. + - Ported NemDeling XML parsing, event data mapping, and playlist/slide sync from integration-service. + - Syncs slides to `event_{screen}` and `event_list_{screen}` playlists via Doctrine repositories. + - Sets slide `externalId` from NemDeling `nid` for idempotent updates. + - Added HTTP Basic authentication, sync concurrency lock (503), and `NEMDELING_*` configuration. + - Added unit tests for `NemDelingXmlParser`. + ## [2.8.0] - 2026-06-23 - [#495](https://github.com/os2display/display-api-service/pull/495) diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 89ad82cb3..c8bc4c4ee 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -69,3 +69,5 @@ api_platform: App\Exceptions\NotAcceptableException: 406 App\Exceptions\ConflictException: 409 App\Exceptions\TooManyRequestsException: 429 + App\NemDeling\Exception\NemDelingSyncInProgressException: 503 + App\NemDeling\Exception\NemDelingConfigurationException: 500 diff --git a/config/packages/security.yaml b/config/packages/security.yaml index e9310ab13..ee3d4e9a5 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -49,6 +49,11 @@ security: password_path: password success_handler: Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface failure_handler: Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface + nemdeling: + pattern: ^/api/v1/nemdeling/ + stateless: true + custom_authenticators: + - App\Security\NemDelingBasicAuthenticator activation_code: pattern: ^/v2/user-activation-codes/activate stateless: true @@ -73,6 +78,7 @@ security: access_control: - { path: ^/v2/authentication, roles: PUBLIC_ACCESS } - { path: ^/v2/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI + - { path: ^/api/v1/nemdeling, roles: PUBLIC_ACCESS } - { path: ^/v2, roles: IS_AUTHENTICATED_FULLY } role_hierarchy: diff --git a/config/routes/nemdeling.yaml b/config/routes/nemdeling.yaml new file mode 100644 index 000000000..682ddb782 --- /dev/null +++ b/config/routes/nemdeling.yaml @@ -0,0 +1,3 @@ +nemdeling_controllers: + resource: ../../src/NemDeling/Controller/ + type: attribute diff --git a/config/services.yaml b/config/services.yaml index bcebd5580..e7d543752 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -64,6 +64,14 @@ services: arguments: $cacheExpire: '%env(int:EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS)%' + App\NemDeling\NemDelingConfig: + arguments: + $basicAuthUser: '%env(default::NEMDELING_HTTP_BASIC_USER)%' + $basicAuthPass: '%env(default::NEMDELING_HTTP_BASIC_PASS)%' + $tenantKey: '%env(default::NEMDELING_TENANT_KEY)%' + $eventTemplateTitle: '%env(default::NEMDELING_EVENT_TEMPLATE_TITLE)%' + $eventListTemplateTitle: '%env(default::NEMDELING_EVENT_LIST_TEMPLATE_TITLE)%' + App\Feed\CalendarApiFeedType: arguments: $locationEndpoint: '%env(string:CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT)%' diff --git a/src/Command/Utils/ConvertEnvTo3xCommand.php b/src/Command/Utils/ConvertEnvTo3xCommand.php index 059f3469e..dd8218989 100644 --- a/src/Command/Utils/ConvertEnvTo3xCommand.php +++ b/src/Command/Utils/ConvertEnvTo3xCommand.php @@ -89,6 +89,12 @@ class ConvertEnvTo3xCommand extends Command // Screen info tracking 'TRACK_SCREEN_INFO' => 'TRACK_SCREEN_INFO', 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS' => 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS', + // NemDeling integration + 'NEMDELING_HTTP_BASIC_USER' => 'NEMDELING_HTTP_BASIC_USER', + 'NEMDELING_HTTP_BASIC_PASS' => 'NEMDELING_HTTP_BASIC_PASS', + 'NEMDELING_TENANT_KEY' => 'NEMDELING_TENANT_KEY', + 'NEMDELING_EVENT_TEMPLATE_TITLE' => 'NEMDELING_EVENT_TEMPLATE_TITLE', + 'NEMDELING_EVENT_LIST_TEMPLATE_TITLE' => 'NEMDELING_EVENT_LIST_TEMPLATE_TITLE', ]; /** diff --git a/src/NemDeling/Controller/NemDelingEventListsController.php b/src/NemDeling/Controller/NemDelingEventListsController.php new file mode 100644 index 000000000..0df097352 --- /dev/null +++ b/src/NemDeling/Controller/NemDelingEventListsController.php @@ -0,0 +1,70 @@ +logger->debug('NemDeling event-lists webhook received.'); + + $this->syncLock->acquireEventLists(); + + try { + $body = $this->xmlParser->parse($request->getContent()); + $mapped = $this->eventDataMapper->map( + $body, + $this->config->getEventListTemplateTitle(), + 'eventList' + ); + $results = $this->nemDelingService->syncEventLists($mapped); + + return $this->createResponse($results); + } finally { + $this->syncLock->releaseEventLists(); + } + } + + /** + * @param NemDelingResult[] $results + */ + private function createResponse(array $results): Response + { + $payload = array_map( + static fn (NemDelingResult $result): array => [ + 'name' => $result->name, + 'status' => $result->status, + ], + $results + ); + + $this->logger->info('NemDeling event lists result: '.json_encode($payload, JSON_THROW_ON_ERROR)); + + return new Response('OK: '.json_encode($payload, JSON_THROW_ON_ERROR), Response::HTTP_CREATED); + } +} diff --git a/src/NemDeling/Controller/NemDelingEventsController.php b/src/NemDeling/Controller/NemDelingEventsController.php new file mode 100644 index 000000000..85f0160de --- /dev/null +++ b/src/NemDeling/Controller/NemDelingEventsController.php @@ -0,0 +1,70 @@ +logger->debug('NemDeling events webhook received.'); + + $this->syncLock->acquireEvents(); + + try { + $body = $this->xmlParser->parse($request->getContent()); + $mapped = $this->eventDataMapper->map( + $body, + $this->config->getEventTemplateTitle(), + 'event' + ); + $results = $this->nemDelingService->syncEvents($mapped); + + return $this->createResponse($results); + } finally { + $this->syncLock->releaseEvents(); + } + } + + /** + * @param NemDelingResult[] $results + */ + private function createResponse(array $results): Response + { + $payload = array_map( + static fn (NemDelingResult $result): array => [ + 'name' => $result->name, + 'status' => $result->status, + ], + $results + ); + + $this->logger->info('NemDeling events result: '.json_encode($payload, JSON_THROW_ON_ERROR)); + + return new Response('OK: '.json_encode($payload, JSON_THROW_ON_ERROR), Response::HTTP_CREATED); + } +} diff --git a/src/NemDeling/EventSubscriber/NemDelingExceptionSubscriber.php b/src/NemDeling/EventSubscriber/NemDelingExceptionSubscriber.php new file mode 100644 index 000000000..134bbe112 --- /dev/null +++ b/src/NemDeling/EventSubscriber/NemDelingExceptionSubscriber.php @@ -0,0 +1,34 @@ +getRequest()->getPathInfo(), '/api/v1/nemdeling/')) { + return; + } + + $throwable = $event->getThrowable(); + if ($throwable instanceof NemDelingSyncInProgressException) { + $event->setResponse(new Response($throwable->getMessage(), Response::HTTP_SERVICE_UNAVAILABLE)); + + return; + } + + if ($throwable instanceof NemDelingConfigurationException) { + $event->setResponse(new Response($throwable->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR)); + } + } +} diff --git a/src/NemDeling/Exception/NemDelingConfigurationException.php b/src/NemDeling/Exception/NemDelingConfigurationException.php new file mode 100644 index 000000000..6812efa07 --- /dev/null +++ b/src/NemDeling/Exception/NemDelingConfigurationException.php @@ -0,0 +1,9 @@ + '#000000', + 'kk_blaa' => '#000c2e', + 'blaa' => '#002CFC', + 'marine_blaa' => '#260EB5', + 'stoev_blaa' => '#025FCC', + 'moerk_stoev_blaa' => '#00519C', + 'graa_blaa' => '#1271A6', + 'roed' => '#C10023', + 'rust_roed' => '#BD3615', + 'moerk_rosa' => '#CD274F', + 'bordeaux' => '#900009', + 'lilla' => '#8332EB', + 'groen' => '#047C6E', + 'blaa_groen' => '#00777E', + 'brun' => '#5E4347', + 'bronze' => '#926B1F', + 'moerke_graa' => '#665E62', + 'prismen' => '#428515', + 'gmc' => '#0c807e', + 'blaagaarden' => '#116B91', + 'huset' => '#c7e2df', + 'kiby' => '#153d44', + ]; + + private const array COLOR_PALETTE_MAP = [ + 'farvepar1' => 'farvepar1', + 'farvepar2' => 'farvepar2', + 'farvepar3' => 'farvepar3', + ]; + + public function __construct( + private readonly NemDelingConfig $config, + private readonly TenantRepository $tenantRepository, + private readonly TemplateRepository $templateRepository, + private readonly ScreenRepository $screenRepository, + ) {} + + /** + * @param array $body + * + * @return array{result: array, notFound: string[]} + */ + public function map(array $body, string $templateTitle, string $slideType): array + { + $tenant = $this->resolveTenant(); + $template = $this->templateRepository->findOneBy(['title' => $templateTitle]); + if (!$template instanceof Template) { + throw new \RuntimeException(sprintf('No template found for title "%s".', $templateTitle)); + } + + $templateId = (string) $template->getId(); + $result = []; + $notFound = []; + + /** @var Screen[] $screens */ + $screens = $this->screenRepository->findBy(['tenant' => $tenant]); + foreach ($screens as $screen) { + $title = $screen->getTitle(); + if ('' !== $title) { + $result[$title] = []; + } + } + + $items = $body['result']['item'] ?? []; + if (!is_array($items)) { + $items = [$items]; + } + + usort($items, function (array $a, array $b): int { + $startA = $this->firstValue($a['startdate'] ?? null); + $startB = $this->firstValue($b['startdate'] ?? null); + + return $this->dateSortKey($startA) <=> $this->dateSortKey($startB); + }); + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $screenNames = $this->screenNames($item['screen'] ?? null); + if ([] === $screenNames) { + continue; + } + + $timeValue = $this->firstValue($item['time'] ?? null); + [$startTime, $endTime] = $this->splitTimeRange($timeValue); + + $startDate = $this->formatEventDate( + $this->firstValue($item['startdate'] ?? null), + "EEEE 'd'. d. MMMM" + ); + $endDate = $this->formatEventDate( + $this->firstValue($item['enddate'] ?? null), + "EEEE 'd'. d. MMMM" + ); + + foreach ($screenNames as $screenName) { + if (!array_key_exists($screenName, $result)) { + $notFound[] = $screenName; + continue; + } + + $backgroundColor = ''; + $colorKey = $this->firstValue($item['color'] ?? null); + if ('' !== $colorKey && array_key_exists($colorKey, self::COLOR_MAP)) { + $backgroundColor = self::COLOR_MAP[$colorKey]; + } + + $title = $this->firstValue($item['title'] ?? null); + $alternativeTitle = $this->firstValue($item['alternativ_titel'] ?? null); + if ('' !== $alternativeTitle) { + $title = $alternativeTitle; + } + + $colorPalette = ''; + $paletteKey = $this->firstValue($item['farvepar'] ?? null); + if ('' !== $paletteKey && array_key_exists($paletteKey, self::COLOR_PALETTE_MAP)) { + $colorPalette = self::COLOR_PALETTE_MAP[$paletteKey]; + } + + $result[$screenName][] = new NemDelingSlide( + templateId: $templateId, + content: [ + 'externalId' => $this->firstValue($item['nid'] ?? $item['Nid'] ?? null), + 'title' => $title, + 'subTitle' => $this->firstValue($item['field_teaser'] ?? null), + 'host' => $this->firstValue($item['host'] ?? null), + 'startDate' => '' !== $startDate ? sprintf('%s kl. %s', $startDate, $startTime) : '', + 'endDate' => $startDate !== $endDate ? sprintf('%s kl. %s', $endDate, $endTime) : '', + 'image' => $this->imageUrl($item['billede'] ?? null), + 'bgColor' => $backgroundColor, + 'colorPalette' => $colorPalette, + ], + type: $slideType, + ); + } + } + + return [ + 'result' => $result, + 'notFound' => array_values(array_unique($notFound)), + ]; + } + + /** + * @param mixed $value + */ + private function firstValue(mixed $value): string + { + if (null === $value) { + return ''; + } + + if (is_string($value) || is_numeric($value)) { + return (string) $value; + } + + if (!is_array($value)) { + return ''; + } + + if (array_key_exists('item', $value)) { + return $this->firstValue($value['item']); + } + + if (array_is_list($value)) { + return $this->firstValue($value[0] ?? null); + } + + return ''; + } + + /** + * @return string[] + */ + private function screenNames(mixed $screen): array + { + if (!is_array($screen)) { + return []; + } + + $items = $screen['item'] ?? $screen; + if (!is_array($items)) { + return [] === $items ? [] : [(string) $items]; + } + + if (!array_is_list($items)) { + $items = [$items]; + } + + return array_values(array_filter(array_map( + fn (mixed $item): string => is_scalar($item) ? (string) $item : $this->firstValue($item), + $items + ))); + } + + /** + * @return array{0: string, 1: string} + */ + private function splitTimeRange(string $timeValue): array + { + if ('' === $timeValue || !str_contains($timeValue, ' til ')) { + return ['', '']; + } + + $parts = explode(' til ', $timeValue, 2); + + return [$parts[0], $parts[1] ?? '']; + } + + private function dateSortKey(string $date): int + { + if ('' === $date) { + return 0; + } + + $parts = explode('.', $date); + if (3 !== count($parts)) { + return 0; + } + + return (int) sprintf('%s%s%s', $parts[2], $parts[1], $parts[0]); + } + + /** + * @param mixed $billede + */ + private function imageUrl(mixed $billede): ?string + { + if (!is_array($billede)) { + return null; + } + + $items = $billede['item'] ?? $billede; + if (!is_array($items)) { + return null; + } + + if (!array_is_list($items)) { + $items = [$items]; + } + + $firstItem = $items[0] ?? null; + if (!is_array($firstItem)) { + return null; + } + + $img = $firstItem['img'] ?? null; + if (!is_array($img)) { + return null; + } + + $imgNode = $img[0] ?? $img; + if (!is_array($imgNode)) { + return null; + } + + $attributes = $imgNode['$'] ?? null; + if (!is_array($attributes)) { + return null; + } + + $src = $attributes['src'] ?? null; + + return is_string($src) && '' !== $src ? $src : null; + } + + private function formatEventDate(string $dateString, string $pattern): string + { + if ('' === $dateString) { + return ''; + } + + $parts = explode('.', $dateString); + if (3 !== count($parts)) { + return ''; + } + + [$day, $month, $year] = $parts; + $date = new \DateTimeImmutable( + sprintf('%s-%s-%s 00:00:00', $year, $month, $day), + new \DateTimeZone('UTC') + ); + + $formatter = new \IntlDateFormatter( + 'da_DK', + \IntlDateFormatter::NONE, + \IntlDateFormatter::NONE, + 'UTC', + null, + $pattern + ); + + $formatted = $formatter->format($date); + if (!is_string($formatted) || '' === $formatted) { + return ''; + } + + return mb_strtoupper(mb_substr($formatted, 0, 1)).mb_substr($formatted, 1); + } + + private function resolveTenant(): Tenant + { + $tenantKey = $this->config->getTenantKey(); + if ('' === $tenantKey) { + throw new NemDelingConfigurationException('NEMDELING_TENANT_KEY is not configured.'); + } + + $tenant = $this->tenantRepository->findOneBy(['tenantKey' => $tenantKey]); + if (!$tenant instanceof Tenant) { + throw new NemDelingConfigurationException(sprintf('Tenant "%s" was not found.', $tenantKey)); + } + + return $tenant; + } +} diff --git a/src/NemDeling/Model/NemDelingResult.php b/src/NemDeling/Model/NemDelingResult.php new file mode 100644 index 000000000..e3b52b458 --- /dev/null +++ b/src/NemDeling/Model/NemDelingResult.php @@ -0,0 +1,13 @@ +basicAuthUser && '' !== $this->basicAuthPass; + } + + public function getBasicAuthUser(): string + { + return $this->basicAuthUser; + } + + public function getBasicAuthPass(): string + { + return $this->basicAuthPass; + } + + public function getTenantKey(): string + { + return $this->tenantKey; + } + + public function getEventTemplateTitle(): string + { + return $this->eventTemplateTitle; + } + + public function getEventListTemplateTitle(): string + { + return $this->eventListTemplateTitle; + } +} diff --git a/src/NemDeling/NemDelingService.php b/src/NemDeling/NemDelingService.php new file mode 100644 index 000000000..77596d865 --- /dev/null +++ b/src/NemDeling/NemDelingService.php @@ -0,0 +1,303 @@ +getPlaylistByName(sprintf('event_%s', $screenName), $tenant); + } + + public function getEventListPlaylistFromScreenName(string $screenName, Tenant $tenant): ?Playlist + { + return $this->getPlaylistByName(sprintf('event_list_%s', $screenName), $tenant); + } + + /** + * @param array{result: array, notFound: string[]} $mappedData + * + * @return NemDelingResult[] + */ + public function syncEvents(array $mappedData): array + { + $tenant = $this->resolveTenant(); + $results = []; + + foreach ($mappedData['result'] as $screenName => $slides) { + $playlist = $this->getEventPlaylistFromScreenName($screenName, $tenant); + if (!$playlist instanceof Playlist) { + continue; + } + + $success = $this->syncPlaylist($playlist, $slides, $tenant); + $results[] = new NemDelingResult($screenName, $success ? 'success' : 'error'); + } + + foreach ($mappedData['notFound'] as $screenName) { + $results[] = new NemDelingResult($screenName, 'not_found'); + } + + return $results; + } + + /** + * @param array{result: array, notFound: string[]} $mappedData + * + * @return NemDelingResult[] + */ + public function syncEventLists(array $mappedData): array + { + $tenant = $this->resolveTenant(); + $template = $this->resolveTemplate($this->config->getEventListTemplateTitle()); + $results = []; + + foreach ($mappedData['result'] as $screenName => $slides) { + $playlist = $this->getEventListPlaylistFromScreenName($screenName, $tenant); + if (!$playlist instanceof Playlist) { + continue; + } + + if ([] === $slides) { + $success = $this->syncPlaylist($playlist, [], $tenant); + } else { + $aggregateSlide = new NemDelingSlide( + templateId: (string) $template->getId(), + content: [ + 'jsonData' => json_encode(array_map( + static fn (NemDelingSlide $slide): array => $slide->content, + $slides + ), JSON_THROW_ON_ERROR), + ], + type: 'eventList', + ); + $success = $this->syncPlaylist($playlist, [$aggregateSlide], $tenant); + } + + $results[] = new NemDelingResult($screenName, $success ? 'success' : 'error'); + } + + foreach ($mappedData['notFound'] as $screenName) { + $results[] = new NemDelingResult($screenName, 'not_found'); + } + + return $results; + } + + /** + * @param NemDelingSlide[] $slides + */ + public function syncPlaylist(Playlist $playlist, array $slides, Tenant $tenant): bool + { + $playlistId = $playlist->getId(); + if (!$playlistId instanceof Ulid) { + return false; + } + + try { + /** @var PlaylistSlide[] $playlistSlides */ + $playlistSlides = $this->playlistSlideRepository + ->getPlaylistSlideRelationsFromPlaylistId($playlistId) + ->getQuery() + ->getResult(); + + /** @var list $newRelations */ + $newRelations = []; + foreach ($slides as $index => $slideDefinition) { + $newRelations[] = (object) [ + 'slide' => $this->ensureSlideOnPlaylist($slideDefinition, $index, $playlistSlides, $tenant), + 'weight' => $index, + ]; + } + + $newSlideIds = array_map( + static fn (object $relation): string => (string) $relation->slide, + $newRelations + ); + + foreach ($playlistSlides as $oldRelation) { + $oldSlide = $oldRelation->getSlide(); + $oldSlideId = (string) $oldSlide->getId(); + if (!in_array($oldSlideId, $newSlideIds, true)) { + $this->slideRepository->remove($oldSlide, true); + } + } + + /** @var ArrayCollection $relationCollection */ + $relationCollection = new ArrayCollection($newRelations); + $this->playlistSlideRepository->updatePlaylistSlideRelations( + $playlistId, + $relationCollection, + $tenant + ); + + return true; + } catch (\Throwable $exception) { + $this->logger->error( + sprintf('Error updating playlist "%s"', (string) $playlistId), + ['exception' => $exception] + ); + + return false; + } + } + + public function resolveTenant(): Tenant + { + $tenantKey = $this->config->getTenantKey(); + if ('' === $tenantKey) { + throw new NemDelingConfigurationException('NEMDELING_TENANT_KEY is not configured.'); + } + + $tenant = $this->tenantRepository->findOneBy(['tenantKey' => $tenantKey]); + if (!$tenant instanceof Tenant) { + throw new NemDelingConfigurationException(sprintf('Tenant "%s" was not found.', $tenantKey)); + } + + return $tenant; + } + + public function resolveTemplate(string $title): Template + { + $template = $this->templateRepository->findOneBy(['title' => $title]); + if (!$template instanceof Template) { + throw new NemDelingConfigurationException(sprintf('Template "%s" was not found.', $title)); + } + + return $template; + } + + private function getPlaylistByName(string $name, Tenant $tenant): ?Playlist + { + return $this->playlistRepository->findOneBy([ + 'title' => $name, + 'tenant' => $tenant, + ]); + } + + /** + * @param PlaylistSlide[] $playlistSlides + */ + private function ensureSlideOnPlaylist( + NemDelingSlide $slideDefinition, + int $weight, + array $playlistSlides, + Tenant $tenant, + ): Ulid { + $existingSlide = $this->findExistingSlide($slideDefinition, $playlistSlides); + $title = $this->buildSlideTitle($slideDefinition, $weight); + + if ($existingSlide instanceof Slide) { + if ($this->contentMatches($slideDefinition->content, $existingSlide->getContent())) { + return $this->requireSlideId($existingSlide); + } + + $existingSlide->setTitle($title); + $existingSlide->setContent($slideDefinition->content); + $existingSlide->setTemplate($this->resolveTemplateById($slideDefinition->templateId)); + $this->slideRepository->save($existingSlide, true); + + return $this->requireSlideId($existingSlide); + } + + $slide = new Slide(); + $slide->setTenant($tenant); + $slide->setTitle($title); + $slide->setContent($slideDefinition->content); + $slide->setTemplate($this->resolveTemplateById($slideDefinition->templateId)); + + $this->slideRepository->save($slide, true); + + return $this->requireSlideId($slide); + } + + /** + * @param PlaylistSlide[] $playlistSlides + */ + private function findExistingSlide(NemDelingSlide $slideDefinition, array $playlistSlides): ?Slide + { + $externalId = $slideDefinition->content['externalId'] ?? ''; + if ('' !== $externalId) { + foreach ($playlistSlides as $playlistSlide) { + $slide = $playlistSlide->getSlide(); + if (($slide->getContent()['externalId'] ?? '') === $externalId) { + return $slide; + } + } + } + + foreach ($playlistSlides as $playlistSlide) { + $slide = $playlistSlide->getSlide(); + if ($this->contentMatches($slideDefinition->content, $slide->getContent())) { + return $slide; + } + } + + return null; + } + + /** + * @param array $left + * @param array $right + */ + private function contentMatches(array $left, array $right): bool + { + return json_encode($left, JSON_THROW_ON_ERROR) === json_encode($right, JSON_THROW_ON_ERROR); + } + + private function buildSlideTitle(NemDelingSlide $slideDefinition, int $weight): string + { + $title = $slideDefinition->content['title'] ?? 'Event'; + + return sprintf('NemDeling Slide - %s - %d', $title, $weight + 1); + } + + private function resolveTemplateById(string $templateId): Template + { + $template = $this->templateRepository->find($templateId); + if (!$template instanceof Template) { + throw new NemDelingConfigurationException(sprintf('Template "%s" was not found.', $templateId)); + } + + return $template; + } + + private function requireSlideId(Slide $slide): Ulid + { + $id = $slide->getId(); + if (!$id instanceof Ulid) { + throw new \RuntimeException('Slide id missing after persist.'); + } + + return $id; + } +} diff --git a/src/NemDeling/NemDelingSyncLock.php b/src/NemDeling/NemDelingSyncLock.php new file mode 100644 index 000000000..9623f1515 --- /dev/null +++ b/src/NemDeling/NemDelingSyncLock.php @@ -0,0 +1,47 @@ +acquire($this->eventsAreSyncing, 'Events are already being synced.'); + } + + public function releaseEvents(): void + { + $this->eventsAreSyncing = 0; + } + + public function acquireEventLists(): void + { + $this->acquire($this->eventListsAreSyncing, 'Event lists are already being synced.'); + } + + public function releaseEventLists(): void + { + $this->eventListsAreSyncing = 0; + } + + private function acquire(int &$counter, string $message): void + { + if ($counter > 0 && $counter < 5) { + ++$counter; + throw new NemDelingSyncInProgressException($message); + } + + $counter = 1; + } +} diff --git a/src/NemDeling/Xml/NemDelingXmlParser.php b/src/NemDeling/Xml/NemDelingXmlParser.php new file mode 100644 index 000000000..f78362258 --- /dev/null +++ b/src/NemDeling/Xml/NemDelingXmlParser.php @@ -0,0 +1,106 @@ +getName(); + $parsed = $this->convertNode($document); + + return [$rootName => $parsed]; + } + + /** + * @return mixed + */ + private function convertNode(\SimpleXMLElement $node) + { + $attributes = $node->attributes(); + $isArray = null !== $attributes && isset($attributes['is_array']) && 'true' === (string) $attributes['is_array']; + + $children = $node->children(); + if (null === $children || 0 === count($children)) { + $value = trim((string) $node); + + $nodeAttributes = $node->attributes(); + if (null !== $nodeAttributes && count($nodeAttributes) > 0) { + $attributeMap = ['$' => $this->convertAttributes($node)]; + if ('' !== $value) { + $attributeMap['_'] = $value; + } + + return [$attributeMap]; + } + + return $value; + } + + $grouped = []; + foreach ($children as $child) { + $name = $child->getName(); + $converted = $this->convertNode($child); + + if (!array_key_exists($name, $grouped)) { + $grouped[$name] = $converted; + continue; + } + + if (!is_array($grouped[$name]) || !array_is_list($grouped[$name])) { + $grouped[$name] = [$grouped[$name]]; + } + + $grouped[$name][] = $converted; + } + + if ($isArray) { + if (!array_key_exists('item', $grouped)) { + return [[]]; + } + + $items = $grouped['item']; + if (!is_array($items) || !array_is_list($items)) { + $items = [$items]; + } + + return ['item' => $items]; + } + + return $grouped; + } + + /** + * @return array + */ + private function convertAttributes(\SimpleXMLElement $node): array + { + $attributes = []; + $nodeAttributes = $node->attributes(); + if (null === $nodeAttributes) { + return $attributes; + } + + foreach ($nodeAttributes as $name => $value) { + if ('is_array' === $name) { + continue; + } + $attributes[$name] = (string) $value; + } + + return $attributes; + } +} diff --git a/src/Security/NemDelingBasicAuthenticator.php b/src/Security/NemDelingBasicAuthenticator.php new file mode 100644 index 000000000..3d025b8ea --- /dev/null +++ b/src/Security/NemDelingBasicAuthenticator.php @@ -0,0 +1,62 @@ +getPathInfo(), '/api/v1/nemdeling/'); + } + + public function authenticate(Request $request): Passport + { + if (!$this->config->isBasicAuthEnabled()) { + return new SelfValidatingPassport(new UserBadge('nemdeling')); + } + + $username = $request->headers->get('PHP_AUTH_USER'); + $password = $request->headers->get('PHP_AUTH_PW'); + + if ( + !is_string($username) + || !is_string($password) + || $username !== $this->config->getBasicAuthUser() + || $password !== $this->config->getBasicAuthPass() + ) { + throw new CustomUserMessageAuthenticationException('Invalid NemDeling credentials.'); + } + + return new SelfValidatingPassport(new UserBadge($username)); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + $response = new Response('Unauthorized', Response::HTTP_UNAUTHORIZED); + $response->headers->set('WWW-Authenticate', 'Basic realm="NemDeling"'); + + return $response; + } +} diff --git a/tests/NemDeling/NemDelingXmlParserTest.php b/tests/NemDeling/NemDelingXmlParserTest.php new file mode 100644 index 000000000..77a6c95f8 --- /dev/null +++ b/tests/NemDeling/NemDelingXmlParserTest.php @@ -0,0 +1,61 @@ +parser = new NemDelingXmlParser(); + } + + public function testParsesEventPayload(): void + { + $xml = <<<'XML' + + + + 21.10.2022 + + + 21.10.2022 + + + 140 + + + Example + + + Example event + Teaser text + + copenhagen_test + + Example host + kk_blaa + + +XML; + + $parsed = $this->parser->parse($xml); + + self::assertArrayHasKey('result', $parsed); + self::assertCount(1, $parsed['result']['item']); + self::assertSame('Example event', $parsed['result']['item'][0]['title']); + self::assertSame('140', $parsed['result']['item'][0]['Nid']); + self::assertSame( + 'https://example.com/image.jpg', + $parsed['result']['item'][0]['billede']['item'][0]['img'][0]['$']['src'] + ); + } +}