Skip to content

Commit 3a1ae4e

Browse files
committed
minor #2352 [Icons] Fetch icons in batch in Import command (smnandre)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Icons] Fetch icons in batch in Import command | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Issues | Fix #... | License | MIT First usage of #2351 with the import command --- **Update:** - add icons counts (imported / total) - handle same-prefix icons per batch - improve visual feedback <img width="878" alt="Capture d’écran 2024-11-09 à 19 26 16" src="https://github.com/user-attachments/assets/fc6c377e-ba40-43f3-b62a-0070ba14125b"> --- Next ones: * lock * warm-up Commits ------- 88d24e0 [Icons] Fetch icons in batch in Import command
2 parents f329468 + 88d24e0 commit 3a1ae4e

File tree

4 files changed

+202
-38
lines changed

4 files changed

+202
-38
lines changed

src/Icons/src/Command/ImportIconCommand.php

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
use Symfony\Component\Console\Input\InputInterface;
1919
use Symfony\Component\Console\Output\OutputInterface;
2020
use Symfony\Component\Console\Style\SymfonyStyle;
21-
use Symfony\UX\Icons\Exception\IconNotFoundException;
2221
use Symfony\UX\Icons\Iconify;
2322
use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
2423

@@ -41,11 +40,7 @@ public function __construct(private Iconify $iconify, private LocalSvgIconRegist
4140
protected function configure(): void
4241
{
4342
$this
44-
->addArgument(
45-
'names',
46-
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
47-
'Icon name from ux.symfony.com/icons (e.g. "mdi:home")',
48-
)
43+
->addArgument('names', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Icon name from ux.symfony.com/icons (e.g. "mdi:home")')
4944
;
5045
}
5146

@@ -54,7 +49,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5449
$io = new SymfonyStyle($input, $output);
5550
$names = $input->getArgument('names');
5651
$result = Command::SUCCESS;
52+
$importedIcons = 0;
5753

54+
$prefixIcons = [];
5855
foreach ($names as $name) {
5956
if (!preg_match('#^([\w-]+):([\w-]+)$#', $name, $matches)) {
6057
$io->error(\sprintf('Invalid icon name "%s".', $name));
@@ -63,35 +60,63 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6360
continue;
6461
}
6562

66-
[$fullName, $prefix, $name] = $matches;
67-
68-
$io->comment(\sprintf('Importing %s...', $fullName));
63+
[, $prefix, $name] = $matches;
64+
$prefixIcons[$prefix] ??= [];
65+
$prefixIcons[$prefix][$name] = $name;
66+
}
6967

70-
try {
71-
$iconSvg = $this->iconify->fetchIcon($prefix, $name)->toHtml();
72-
} catch (IconNotFoundException $e) {
73-
$io->error($e->getMessage());
68+
foreach ($prefixIcons as $prefix => $icons) {
69+
if (!$this->iconify->hasIconSet($prefix)) {
70+
$io->error(\sprintf('Icon set "%s" not found.', $prefix));
7471
$result = Command::FAILURE;
7572

7673
continue;
7774
}
7875

79-
$cursor = new Cursor($output);
80-
$cursor->moveUp(2);
81-
82-
$this->registry->add(\sprintf('%s/%s', $prefix, $name), $iconSvg);
83-
84-
$license = $this->iconify->metadataFor($prefix)['license'];
85-
86-
$io->text(\sprintf(
87-
" <fg=bright-green;options=bold>✓</> Imported <fg=bright-white;bg=black>%s:</><fg=bright-magenta;bg=black;options>%s</> (License: <href=%s>%s</>). Render with: <comment>{{ ux_icon('%s') }}</comment>",
88-
$prefix,
89-
$name,
90-
$license['url'] ?? '#',
91-
$license['title'],
92-
$fullName,
93-
));
76+
$metadata = $this->iconify->metadataFor($prefix);
9477
$io->newLine();
78+
$io->writeln(\sprintf(' Icon set: %s (License: %s)', $metadata['name'], $metadata['license']['title']));
79+
80+
foreach ($this->iconify->chunk($prefix, array_keys($icons)) as $iconNames) {
81+
$cursor = new Cursor($output);
82+
foreach ($iconNames as $name) {
83+
$io->writeln(\sprintf(' Importing %s:%s ...', $prefix, $name));
84+
}
85+
$cursor->moveUp(\count($iconNames));
86+
87+
try {
88+
$batchResults = $this->iconify->fetchIcons($prefix, $iconNames);
89+
} catch (\InvalidArgumentException $e) {
90+
// At this point no exception should be thrown
91+
$io->error($e->getMessage());
92+
93+
return Command::FAILURE;
94+
}
95+
96+
foreach ($iconNames as $name) {
97+
$cursor->clearLineAfter();
98+
99+
// If the icon is not found, the value will be null
100+
if (null === $icon = $batchResults[$name] ?? null) {
101+
$io->writeln(\sprintf(' <fg=red;options=bold>✗</> Not Found <fg=bright-white;bg=black>%s:</><fg=bright-red;bg=black>%s</>', $prefix, $name));
102+
103+
continue;
104+
}
105+
106+
++$importedIcons;
107+
$this->registry->add(\sprintf('%s/%s', $prefix, $name), (string) $icon);
108+
$io->writeln(\sprintf(' <fg=bright-green;options=bold>✓</> Imported <fg=bright-white;bg=black>%s:</><fg=bright-magenta;bg=black;options>%s</>', $prefix, $name));
109+
}
110+
}
111+
}
112+
113+
if ($importedIcons === $totalIcons = \count($names)) {
114+
$io->success(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons));
115+
} elseif ($importedIcons > 0) {
116+
$io->warning(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons));
117+
} else {
118+
$io->error(\sprintf('Imported %d/%d icons.', $importedIcons, $totalIcons));
119+
$result = Command::FAILURE;
95120
}
96121

97122
return $result;

src/Icons/src/Iconify.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,27 @@ final class Iconify
2727
{
2828
public const API_ENDPOINT = 'https://api.iconify.design';
2929

30+
// URL must be 500 chars max (iconify limit)
31+
// -39 chars: https://api.iconify.design/XXX.json?icons=
32+
// -safe margin
33+
private const MAX_ICONS_QUERY_LENGTH = 400;
34+
3035
private HttpClientInterface $http;
3136
private \ArrayObject $sets;
37+
private int $maxIconsQueryLength;
3238

3339
public function __construct(
3440
private CacheInterface $cache,
3541
string $endpoint = self::API_ENDPOINT,
3642
?HttpClientInterface $http = null,
43+
?int $maxIconsQueryLength = null,
3744
) {
3845
if (!class_exists(HttpClient::class)) {
3946
throw new \LogicException('You must install "symfony/http-client" to use Iconify. Try running "composer require symfony/http-client".');
4047
}
4148

4249
$this->http = ScopingHttpClient::forBaseUri($http ?? HttpClient::create(), $endpoint);
50+
$this->maxIconsQueryLength = min(self::MAX_ICONS_QUERY_LENGTH, $maxIconsQueryLength ?? self::MAX_ICONS_QUERY_LENGTH);
4351
}
4452

4553
public function metadataFor(string $prefix): array
@@ -95,13 +103,10 @@ public function fetchIcons(string $prefix, array $names): array
95103
sort($names);
96104
$queryString = implode(',', $names);
97105
if (!preg_match('#^[a-z0-9-,]+$#', $queryString)) {
98-
throw new \InvalidArgumentException('Invalid icon names.');
106+
throw new \InvalidArgumentException('Invalid icon names.'.$queryString);
99107
}
100108

101-
// URL must be 500 chars max (iconify limit)
102-
// -39 chars: https://api.iconify.design/XXX.json?icons=
103-
// -safe margin
104-
if (450 < \strlen($prefix.$queryString)) {
109+
if (self::MAX_ICONS_QUERY_LENGTH < \strlen($prefix.$queryString)) {
105110
throw new \InvalidArgumentException('The query string is too long.');
106111
}
107112

@@ -155,6 +160,40 @@ public function searchIcons(string $prefix, string $query)
155160
return new \ArrayObject($response->toArray());
156161
}
157162

163+
/**
164+
* @return iterable<string[]>
165+
*/
166+
public function chunk(string $prefix, array $names): iterable
167+
{
168+
if (100 < ($prefixLength = \strlen($prefix))) {
169+
throw new \InvalidArgumentException(\sprintf('The icon prefix "%s" is too long.', $prefix));
170+
}
171+
172+
$maxLength = $this->maxIconsQueryLength - $prefixLength;
173+
174+
$curBatch = [];
175+
$curLength = 0;
176+
foreach ($names as $name) {
177+
if (100 < ($nameLength = \strlen($name))) {
178+
throw new \InvalidArgumentException(\sprintf('The icon name "%s" is too long.', $name));
179+
}
180+
if ($curLength && ($maxLength < ($curLength + $nameLength + 1))) {
181+
yield $curBatch;
182+
183+
$curBatch = [];
184+
$curLength = 0;
185+
}
186+
$curLength += $nameLength + 1;
187+
$curBatch[] = $name;
188+
}
189+
190+
if ($curLength) {
191+
yield $curBatch;
192+
}
193+
194+
yield from [];
195+
}
196+
158197
private function sets(): \ArrayObject
159198
{
160199
return $this->sets ??= $this->cache->get('ux-iconify-sets', function () {

src/Icons/tests/Integration/Command/ImportIconCommandTest.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ final class ImportIconCommandTest extends KernelTestCase
2323
use InteractsWithConsole;
2424

2525
private const ICON_DIR = __DIR__.'/../../Fixtures/icons';
26-
private const ICONS = ['uiw/dashboard.svg'];
26+
private const ICONS = ['uiw/dashboard.svg', 'lucide/circle.svg'];
2727

2828
/**
2929
* @before
@@ -45,8 +45,8 @@ public function testCanImportIcon(): void
4545

4646
$this->executeConsoleCommand('ux:icons:import uiw:dashboard')
4747
->assertSuccessful()
48+
->assertOutputContains('Icon set: uiw icons (License: MIT)')
4849
->assertOutputContains('Importing uiw:dashboard')
49-
->assertOutputContains("Imported uiw:dashboard (License: MIT). Render with: {{ ux_icon('uiw:dashboard') }}")
5050
;
5151

5252
$this->assertFileExists($expectedFile);
@@ -60,13 +60,34 @@ public function testImportInvalidIconName(): void
6060
;
6161
}
6262

63-
public function testImportNonExistentIcon(): void
63+
public function testImportNonExistentIconSet(): void
6464
{
6565
$this->executeConsoleCommand('ux:icons:import something:invalid')
6666
->assertStatusCode(1)
67-
->assertOutputContains('[ERROR] The icon "something:invalid" does not exist on iconify.design.')
67+
->assertOutputContains('[ERROR] Icon set "something" not found.')
68+
;
69+
}
70+
71+
public function testImportNonExistentIcon(): void
72+
{
73+
$this->executeConsoleCommand('ux:icons:import lucide:not-existing-icon')
74+
->assertStatusCode(1)
75+
->assertOutputContains('Not Found lucide:not-existing-icon')
76+
->assertOutputContains('[ERROR] Imported 0/1 icons.')
77+
;
78+
79+
$this->assertFileDoesNotExist(self::ICON_DIR.'/not-existing-icon.svg');
80+
}
81+
82+
public function testImportNonExistentIconWithExistentOne(): void
83+
{
84+
$this->executeConsoleCommand('ux:icons:import lucide:circle lucide:not-existing-icon')
85+
->assertStatusCode(0)
86+
->assertOutputContains('Imported lucide:circle')
87+
->assertOutputContains('Not Found lucide:not-existing-icon')
88+
->assertOutputContains('[WARNING] Imported 1/2 icons.')
6889
;
6990

70-
$this->assertFileDoesNotExist(self::ICON_DIR.'/invalid.svg');
91+
$this->assertFileDoesNotExist(self::ICON_DIR.'/not-existing-icon.svg');
7192
}
7293
}

src/Icons/tests/Unit/IconifyTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,85 @@ public function testGetMetadata(): void
238238
$this->assertSame('Font Awesome Solid', $metadata['name']);
239239
}
240240

241+
/**
242+
* @dataProvider provideChunkCases
243+
*/
244+
public function testChunk(int $maxQueryLength, string $prefix, array $names, array $chunks): void
245+
{
246+
$iconify = new Iconify(
247+
new NullAdapter(),
248+
'https://example.com',
249+
new MockHttpClient([]),
250+
$maxQueryLength,
251+
);
252+
253+
$this->assertSame($chunks, iterator_to_array($iconify->chunk($prefix, $names)));
254+
}
255+
256+
public static function provideChunkCases(): iterable
257+
{
258+
yield 'no icon should make no chunk' => [
259+
10,
260+
'ppppp',
261+
[],
262+
[],
263+
];
264+
265+
yield 'one icon should make one chunk' => [
266+
10,
267+
'ppppp',
268+
['aaaa1'],
269+
[['aaaa1']],
270+
];
271+
272+
yield 'two icons that should make two chunck' => [
273+
10,
274+
'ppppp',
275+
['aa1', 'aa2'],
276+
[['aa1'], ['aa2']],
277+
];
278+
279+
yield 'three icons that should make two chunck' => [
280+
15,
281+
'ppppp',
282+
['aaa1', 'aaa2', 'aaa3'],
283+
[['aaa1', 'aaa2'], ['aaa3']],
284+
];
285+
286+
yield 'four icons that should make two chunck' => [
287+
15,
288+
'ppppp',
289+
['aaaaaaaa1', 'a2', 'a3', 'a4'],
290+
[['aaaaaaaa1'], ['a2', 'a3', 'a4']],
291+
];
292+
}
293+
294+
public function testChunkThrowWithIconPrefixTooLong(): void
295+
{
296+
$iconify = new Iconify(new NullAdapter(), 'https://example.com', new MockHttpClient([]));
297+
298+
$prefix = str_pad('p', 101, 'p');
299+
$name = 'icon';
300+
301+
$this->expectExceptionMessage(\sprintf('The icon prefix "%s" is too long.', $prefix));
302+
303+
// We need to iterate over the iterator to trigger the exception
304+
$result = iterator_to_array($iconify->chunk($prefix, [$name]));
305+
}
306+
307+
public function testChunkThrowWithIconNameTooLong(): void
308+
{
309+
$iconify = new Iconify(new NullAdapter(), 'https://example.com', new MockHttpClient([]));
310+
311+
$prefix = 'prefix';
312+
$name = str_pad('n', 101, 'n');
313+
314+
$this->expectExceptionMessage(\sprintf('The icon name "%s" is too long.', $name));
315+
316+
// We need to iterate over the iterator to trigger the exception
317+
$result = iterator_to_array($iconify->chunk($prefix, [$name]));
318+
}
319+
241320
private function createHttpClient(mixed $data, int $code = 200): MockHttpClient
242321
{
243322
$mockResponse = new JsonMockResponse($data, ['http_code' => $code]);

0 commit comments

Comments
 (0)