Skip to content

Commit 7ca858a

Browse files
committed
Merge branch 'develop'
2 parents b3e036e + a4bec38 commit 7ca858a

13 files changed

+439
-11
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [2.0.5] - 2024-05-21
8+
9+
- [#206](https://github.com/os2display/display-api-service/pull/206)
10+
- Added support for Notified (Instagram) feed as replacement for SparkleIOFeedType.
11+
- Deprecated SparkleIOFeedType. (getsparkle.io has shut down)
12+
713
## [2.0.4] - 2024-04-25
814

915
- [#204](https://github.com/os2display/display-api-service/pull/204)

fixtures/feed.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ App\Entity\Tenant\Feed:
77
createdAt (unique): '<dateTimeBetween("-2 years", "-2 days")>'
88
modifiedAt: '<dateTimeBetween($createdAt, "-1 days")>'
99
id: '<ulid($createdAt)>'
10+
feed_abc_notified:
11+
feedSource: '@feed_source_abc_notified'
12+
slide: '@slide_abc_notified'
13+
tenant: '@tenant_abc'
14+
createdAt (unique): '<dateTimeBetween("-2 years", "-2 days")>'
15+
modifiedAt: '<dateTimeBetween($createdAt, "-1 days")>'
16+
id: '<ulid($createdAt)>'
17+
configuration:
18+
feeds: [12345]

fixtures/feed_source.yaml

+8-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ App\Entity\Tenant\FeedSource:
33
feed (template):
44
description: <text()>
55
feedType: "App\\Feed\\RssFeedType"
6-
secrets: []
6+
secrets: [ ]
77
supportedFeedOutputType: 'rss'
88
createdAt (unique): '<dateTimeBetween("-2 years", "-2 days")>'
99
modifiedAt: '<dateTimeBetween($createdAt, "-1 days")>'
@@ -14,3 +14,10 @@ App\Entity\Tenant\FeedSource:
1414
feed_source_xyz_2 (extends feed):
1515
title: 'feed_source_xyz_2'
1616
tenant: '@tenant_xyz'
17+
feed_source_abc_notified (extends feed):
18+
title: 'feed_source_abc_notified'
19+
feedType: "App\\Feed\\RssFeedType"
20+
secrets:
21+
token: '1234567890'
22+
supportedFeedOutputType: 'instagram'
23+
tenant: '@tenant_abc'

fixtures/slide.yaml

+8-2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ App\Entity\Tenant\Slide:
1313
theme: '@theme_abc_1'
1414
feed: '@feed_abc_1'
1515
tenant: '@tenant_abc'
16-
media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*']
16+
media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*' ]
1717
slide_abc_{2..60} (extends slide):
1818
title: 'slide_abc_<current()>'
1919
theme: '@theme_abc_*'
2020
tenant: '@tenant_abc'
21-
media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*']
21+
media: [ '@media_abc_*', '@media_abc_*', '@media_abc_*' ]
2222
slide_def_shared_to_abc (extends slide):
2323
title: 'slide_def_shared_to_abc'
2424
theme: '@theme_def'
@@ -28,3 +28,9 @@ App\Entity\Tenant\Slide:
2828
title: 'slide_xyz_<current()>'
2929
theme: '@theme_xyz'
3030
tenant: '@tenant_xyz'
31+
slide_abc_notified (extends slide):
32+
title: 'slide_abc_notified'
33+
template: '@template_notified'
34+
content:
35+
maxEntries: 6
36+
tenant: '@tenant_abc'

fixtures/template.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ App\Entity\Template:
77
createdAt (unique): '<dateTimeBetween("-2 years", "-2 days")>'
88
modifiedAt: '<dateTimeBetween($createdAt, "-1 days")>'
99
id: '<ulid($createdAt)>'
10+
template_notified:
11+
title: 'template_notified'
12+
description: A template with different that serves notified data
13+
resources: <templateResources()>
14+
createdAt (unique): '<dateTimeBetween("-2 years", "-2 days")>'
15+
modifiedAt: '<dateTimeBetween($createdAt, "-1 days")>'
16+
id: '<ulid($createdAt)>'

src/Feed/NotifiedFeedType.php

+236
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Feed;
6+
7+
use App\Entity\Tenant\Feed;
8+
use App\Entity\Tenant\FeedSource;
9+
use App\Service\FeedService;
10+
use Psr\Log\LoggerInterface;
11+
use Symfony\Component\HttpFoundation\Request;
12+
use Symfony\Component\Uid\Ulid;
13+
use Symfony\Contracts\HttpClient\HttpClientInterface;
14+
15+
/**
16+
* @see https://api.listen.notified.com/docs/index.html
17+
*/
18+
class NotifiedFeedType implements FeedTypeInterface
19+
{
20+
final public const string SUPPORTED_FEED_TYPE = 'instagram';
21+
final public const int REQUEST_TIMEOUT = 10;
22+
23+
private const string BASE_URL = 'https://api.listen.notified.com';
24+
25+
public function __construct(
26+
private readonly FeedService $feedService,
27+
private readonly HttpClientInterface $client,
28+
private readonly LoggerInterface $logger
29+
) {}
30+
31+
public function getData(Feed $feed): array
32+
{
33+
try {
34+
$secrets = $feed->getFeedSource()?->getSecrets();
35+
if (!isset($secrets['token'])) {
36+
return [];
37+
}
38+
39+
$configuration = $feed->getConfiguration();
40+
if (!isset($configuration['feeds']) || 0 === count($configuration['feeds'])) {
41+
return [];
42+
}
43+
44+
$slide = $feed->getSlide();
45+
$slideContent = $slide?->getContent();
46+
47+
$pageSize = $slideContent['maxEntries'] ?? 10;
48+
49+
$token = $secrets['token'];
50+
51+
$data = $this->getMentions($token, $pageSize, $configuration['feeds']);
52+
53+
return array_map(fn (array $item) => $this->getFeedItemObject($item), $data);
54+
} catch (\Throwable $throwable) {
55+
$this->logger->error('{code}: {message}', [
56+
'code' => $throwable->getCode(),
57+
'message' => $throwable->getMessage(),
58+
]);
59+
}
60+
61+
return [];
62+
}
63+
64+
/**
65+
* {@inheritDoc}
66+
*/
67+
public function getAdminFormOptions(FeedSource $feedSource): array
68+
{
69+
$endpoint = $this->feedService->getFeedSourceConfigUrl($feedSource, 'feeds');
70+
71+
// @TODO: Translation.
72+
return [
73+
[
74+
'key' => 'notified-selector',
75+
'input' => 'multiselect-from-endpoint',
76+
'endpoint' => $endpoint,
77+
'name' => 'feeds',
78+
'label' => 'Vælg feed',
79+
'helpText' => 'Her vælger du hvilket feed der skal hentes indgange fra.',
80+
'formGroupClasses' => 'col-md-6 mb-3',
81+
],
82+
];
83+
}
84+
85+
/**
86+
* {@inheritDoc}
87+
*/
88+
public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array
89+
{
90+
try {
91+
if ('feeds' === $name) {
92+
$secrets = $feedSource->getSecrets();
93+
94+
if (!isset($secrets['token'])) {
95+
return [];
96+
}
97+
98+
$token = $secrets['token'];
99+
100+
$data = $this->getSearchProfiles($token);
101+
102+
return array_map(fn (array $item) => [
103+
'id' => Ulid::generate(),
104+
'title' => $item['name'] ?? '',
105+
'value' => $item['id'] ?? '',
106+
], $data);
107+
}
108+
} catch (\Throwable $throwable) {
109+
$this->logger->error('{code}: {message}', [
110+
'code' => $throwable->getCode(),
111+
'message' => $throwable->getMessage(),
112+
]);
113+
}
114+
115+
return null;
116+
}
117+
118+
public function getMentions(string $token, int $pageSize = 10, array $searchProfileIds = []): array
119+
{
120+
$body = [
121+
'pageSize' => $pageSize,
122+
'page' => 1,
123+
'searchProfileIds' => $searchProfileIds,
124+
];
125+
126+
$res = $this->client->request(
127+
'POST',
128+
self::BASE_URL.'/api/listen/mentions',
129+
[
130+
'timeout' => self::REQUEST_TIMEOUT,
131+
'headers' => [
132+
'Accept' => 'application/json',
133+
'Content-Type' => 'application/json',
134+
'Notified-Custom-Token' => $token,
135+
],
136+
'body' => json_encode($body),
137+
]
138+
);
139+
140+
return $res->toArray();
141+
}
142+
143+
public function getSearchProfiles(string $token): array
144+
{
145+
$response = $this->client->request(
146+
'GET',
147+
self::BASE_URL.'/api/listen/searchprofiles',
148+
[
149+
'timeout' => self::REQUEST_TIMEOUT,
150+
'headers' => [
151+
'Accept' => 'application/json',
152+
'Content-Type' => 'application/json',
153+
'Notified-Custom-Token' => $token,
154+
],
155+
]
156+
);
157+
158+
return $response->toArray();
159+
}
160+
161+
/**
162+
* {@inheritDoc}
163+
*/
164+
public function getRequiredSecrets(): array
165+
{
166+
return ['token'];
167+
}
168+
169+
/**
170+
* {@inheritDoc}
171+
*/
172+
public function getRequiredConfiguration(): array
173+
{
174+
return ['feeds'];
175+
}
176+
177+
/**
178+
* {@inheritDoc}
179+
*/
180+
public function getSupportedFeedOutputType(): string
181+
{
182+
return self::SUPPORTED_FEED_TYPE;
183+
}
184+
185+
/**
186+
* Parse feed item into object.
187+
*/
188+
private function getFeedItemObject(array $item): array
189+
{
190+
$description = $item['description'] ?? null;
191+
192+
return [
193+
'text' => $description,
194+
'textMarkup' => null !== $description ? $this->wrapTags($description) : null,
195+
'mediaUrl' => $item['mediaUrl'] ?? null,
196+
// Video is not supported by the Notified Listen API.
197+
'videoUrl' => null,
198+
'username' => $item['sourceName'] ?? null,
199+
'createdTime' => $item['published'] ?? null,
200+
];
201+
}
202+
203+
private function wrapTags(string $input): string
204+
{
205+
$text = trim($input);
206+
207+
// Strip unicode zero-width-space.
208+
$text = str_replace("\xE2\x80\x8B", '', $text);
209+
210+
// Collects trailing tags one by one.
211+
$trailingTags = [];
212+
$pattern = "/\s*#(?<tag>[^\s#]+)\n?$/u";
213+
while (preg_match($pattern, (string) $text, $matches)) {
214+
// We're getting tags in reverse order.
215+
array_unshift($trailingTags, $matches['tag']);
216+
$text = preg_replace($pattern, '', (string) $text);
217+
}
218+
219+
// Wrap sections in p tags.
220+
$text = preg_replace("/(.+)\n?/u", '<p>\1</p>', (string) $text);
221+
222+
// Wrap inline tags.
223+
$pattern = '/(#(?<tag>[^\s#]+))/';
224+
225+
return implode('', [
226+
'<div class="text">',
227+
preg_replace($pattern, '<span class="tag">\1</span>', (string) $text),
228+
'</div>',
229+
'<div class="tags">',
230+
implode(' ',
231+
array_map(fn ($tag) => '<span class="tag">#'.$tag.'</span>', $trailingTags)
232+
),
233+
'</div>',
234+
]);
235+
}
236+
}

src/Feed/SparkleIOFeedType.php

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
2020
use Symfony\Contracts\HttpClient\HttpClientInterface;
2121

22+
/** @deprecated The SparkleIO service is discontinued. */
2223
class SparkleIOFeedType implements FeedTypeInterface
2324
{
2425
final public const SUPPORTED_FEED_TYPE = 'instagram';

tests/Api/FeedSourceTest.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ public function testGetCollection(): void
2020
'@context' => '/contexts/FeedSource',
2121
'@id' => '/v2/feed-sources',
2222
'@type' => 'hydra:Collection',
23-
'hydra:totalItems' => 1,
23+
'hydra:totalItems' => 2,
2424
'hydra:view' => [
2525
'@id' => '/v2/feed-sources?itemsPerPage=10',
2626
'@type' => 'hydra:PartialCollectionView',
2727
],
2828
]);
2929

30-
$this->assertCount(1, $response->toArray()['hydra:member']);
30+
$this->assertCount(2, $response->toArray()['hydra:member']);
3131
}
3232

3333
public function testGetItem(): void

tests/Api/SlidesTest.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ public function testGetCollection(): void
2222
'@context' => '/contexts/Slide',
2323
'@id' => '/v2/slides',
2424
'@type' => 'hydra:Collection',
25-
'hydra:totalItems' => 60,
25+
'hydra:totalItems' => 61,
2626
'hydra:view' => [
2727
'@id' => '/v2/slides?itemsPerPage=10&page=1',
2828
'@type' => 'hydra:PartialCollectionView',
2929
'hydra:first' => '/v2/slides?itemsPerPage=10&page=1',
30-
'hydra:last' => '/v2/slides?itemsPerPage=10&page=6',
30+
'hydra:last' => '/v2/slides?itemsPerPage=10&page=7',
3131
],
3232
]);
3333

tests/Api/TemplatesTest.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ public function testGetCollection(): void
1919
'@context' => '/contexts/Template',
2020
'@id' => '/v2/templates',
2121
'@type' => 'hydra:Collection',
22-
'hydra:totalItems' => 1,
22+
'hydra:totalItems' => 2,
2323
'hydra:view' => [
2424
'@id' => '/v2/templates?itemsPerPage=5',
2525
'@type' => 'hydra:PartialCollectionView',
2626
],
2727
]);
2828

29-
$this->assertCount(1, $response->toArray()['hydra:member']);
29+
$this->assertCount(2, $response->toArray()['hydra:member']);
3030

3131
// @TODO: resources: Object value found, but an array is required. In JSON it's an object but in the entity
3232
// it's an key array? So this test will fail.

0 commit comments

Comments
 (0)