Passed
Push — trunk ( 05c644...7dd39b )
by Christian
10:24 queued 14s
created

ThemeLifecycleService::refreshThemes()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 11
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 4
nop 2
dl 0
loc 11
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Theme;
4
5
use Doctrine\DBAL\Connection;
6
use GuzzleHttp\Psr7\MimeType;
7
use Shopware\Core\Content\Media\Exception\DuplicatedMediaFileNameException;
8
use Shopware\Core\Content\Media\File\FileNameProvider;
9
use Shopware\Core\Content\Media\File\FileSaver;
10
use Shopware\Core\Content\Media\File\MediaFile;
11
use Shopware\Core\Defaults;
12
use Shopware\Core\Framework\Context;
13
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
14
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
15
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
16
use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\RestrictDeleteViolationException;
17
use Shopware\Core\Framework\Log\Package;
18
use Shopware\Core\Framework\Uuid\Uuid;
19
use Shopware\Core\System\Language\LanguageEntity;
0 ignored issues
show
Bug introduced by
The type Shopware\Core\System\Language\LanguageEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
use Shopware\Core\System\Locale\LocaleEntity;
0 ignored issues
show
Bug introduced by
The type Shopware\Core\System\Locale\LocaleEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfiguration;
22
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfigurationCollection;
23
24
#[Package('storefront')]
25
class ThemeLifecycleService
26
{
27
    /**
28
     * @internal
29
     */
30
    public function __construct(
31
        private readonly StorefrontPluginRegistryInterface $pluginRegistry,
32
        private readonly EntityRepository $themeRepository,
33
        private readonly EntityRepository $mediaRepository,
34
        private readonly EntityRepository $mediaFolderRepository,
35
        private readonly EntityRepository $themeMediaRepository,
36
        private readonly FileSaver $fileSaver,
37
        private readonly FileNameProvider $fileNameProvider,
38
        private readonly ThemeFileImporterInterface $themeFileImporter,
39
        private readonly EntityRepository $languageRepository,
40
        private readonly EntityRepository $themeChildRepository,
41
        private readonly Connection $connection
42
    ) {
43
    }
44
45
    public function refreshThemes(
46
        Context $context,
47
        ?StorefrontPluginConfigurationCollection $configurationCollection = null
48
    ): void {
49
        if ($configurationCollection === null) {
50
            $configurationCollection = $this->pluginRegistry->getConfigurations()->getThemes();
51
        }
52
53
        // iterate over all theme configs in the filesystem (plugins/bundles)
54
        foreach ($configurationCollection as $config) {
55
            $this->refreshTheme($config, $context);
0 ignored issues
show
Bug introduced by
$config of type array is incompatible with the type Shopware\Storefront\Them...rontPluginConfiguration expected by parameter $configuration of Shopware\Storefront\Them...Service::refreshTheme(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

55
            $this->refreshTheme(/** @scrutinizer ignore-type */ $config, $context);
Loading history...
56
        }
57
    }
58
59
    public function refreshTheme(StorefrontPluginConfiguration $configuration, Context $context): void
60
    {
61
        $themeData = [];
62
        $themeData['name'] = $configuration->getName();
63
        $themeData['technicalName'] = $configuration->getTechnicalName();
64
        $themeData['author'] = $configuration->getAuthor();
65
66
        // refresh theme after deleting media
67
        $theme = $this->getThemeByTechnicalName($configuration->getTechnicalName(), $context);
68
69
        // check if theme config already exists in the database
70
        if ($theme) {
71
            $themeData['id'] = $theme->getId();
72
        } else {
73
            $themeData['active'] = true;
74
        }
75
76
        $themeData['translations'] = $this->getTranslationsConfiguration($configuration, $context);
77
78
        $updatedData = $this->updateMediaInConfiguration($theme, $configuration, $context);
79
80
        $themeData = array_merge($themeData, $updatedData);
81
82
        if (!empty($configuration->getConfigInheritance())) {
83
            $themeData = $this->addParentTheme($configuration, $themeData, $context);
84
        }
85
86
        $writtenEvent = $this->themeRepository->upsert([$themeData], $context);
87
88
        if (!isset($themeData['id']) || empty($themeData['id'])) {
89
            $themeData['id'] = current($writtenEvent->getPrimaryKeys(ThemeDefinition::ENTITY_NAME));
90
        }
91
92
        $this->themeRepository->upsert([$themeData], $context);
93
94
        $parentThemes = $this->getParentThemes($configuration, $themeData['id']);
95
        $parentCriteria = new Criteria();
96
        $parentCriteria->addFilter(new EqualsFilter('childId', $themeData['id']));
97
        /** @var list<array<string, string>> $toDeleteIds */
98
        $toDeleteIds = $this->themeChildRepository->searchIds($parentCriteria, $context)->getIds();
99
        $this->themeChildRepository->delete($toDeleteIds, $context);
0 ignored issues
show
Bug introduced by
$toDeleteIds of type Shopware\Storefront\Theme\list is incompatible with the type array expected by parameter $ids of Shopware\Core\Framework\...ityRepository::delete(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

99
        $this->themeChildRepository->delete(/** @scrutinizer ignore-type */ $toDeleteIds, $context);
Loading history...
100
        $this->themeChildRepository->upsert($parentThemes, $context);
101
    }
102
103
    public function removeTheme(string $technicalName, Context $context): void
104
    {
105
        $criteria = new Criteria();
106
        $criteria->addAssociation('dependentThemes');
107
        $criteria->addFilter(new EqualsFilter('technicalName', $technicalName));
108
109
        /** @var ThemeEntity|null $theme */
110
        $theme = $this->themeRepository->search($criteria, $context)->first();
111
112
        if ($theme === null) {
113
            return;
114
        }
115
116
        $dependentThemes = $theme->getDependentThemes() ?? new ThemeCollection();
117
        $ids = [...array_values($dependentThemes->getIds()), ...[$theme->getId()]];
118
119
        $this->removeOldMedia($technicalName, $context);
120
        $this->themeRepository->delete(array_map(fn (string $id) => ['id' => $id], $ids), $context);
121
    }
122
123
    private function getThemeByTechnicalName(string $technicalName, Context $context): ?ThemeEntity
0 ignored issues
show
Bug introduced by
The type Shopware\Storefront\Theme\ThemeEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
124
    {
125
        $criteria = new Criteria();
126
        $criteria->addFilter(new EqualsFilter('technicalName', $technicalName));
127
128
        return $this->themeRepository->search($criteria, $context)->first();
129
    }
130
131
    /**
132
     * @return array<string, mixed>|null
133
     */
134
    private function createMediaStruct(string $path, string $mediaId, ?string $themeFolderId): ?array
135
    {
136
        $path = $this->themeFileImporter->getRealPath($path);
137
138
        if (!$this->themeFileImporter->fileExists($path)) {
139
            return null;
140
        }
141
142
        $pathinfo = pathinfo($path);
143
144
        return [
145
            'basename' => $pathinfo['filename'],
146
            'media' => ['id' => $mediaId, 'mediaFolderId' => $themeFolderId],
147
            'mediaFile' => new MediaFile(
148
                $path,
149
                (string) MimeType::fromFilename($pathinfo['basename']),
150
                $pathinfo['extension'] ?? '',
151
                (int) filesize($path)
152
            ),
153
        ];
154
    }
155
156
    private function getMediaDefaultFolderId(Context $context): ?string
157
    {
158
        $criteria = new Criteria();
159
        $criteria->addFilter(new EqualsFilter('media_folder.defaultFolder.entity', 'theme'));
160
        $criteria->addAssociation('defaultFolder');
161
        $criteria->setLimit(1);
162
        $defaultFolder = $this->mediaFolderRepository->search($criteria, $context);
163
        $defaultFolderId = null;
164
        if ($defaultFolder->count() === 1) {
165
            $defaultFolderId = $defaultFolder->first()->getId();
166
        }
167
168
        return $defaultFolderId;
169
    }
170
171
    /**
172
     * @return array<string, array<string, mixed>>
173
     */
174
    private function getTranslationsConfiguration(StorefrontPluginConfiguration $configuration, Context $context): array
175
    {
176
        $systemLanguageLocale = $this->getSystemLanguageLocale($context);
177
178
        $themeConfig = $configuration->getThemeConfig();
179
        if (!$themeConfig) {
180
            return [];
181
        }
182
183
        $labelTranslations = $this->getLabelsFromConfig($themeConfig);
184
        $translations = $this->mapTranslations($labelTranslations, 'labels', $systemLanguageLocale);
185
186
        $helpTextTranslations = $this->getHelpTextsFromConfig($themeConfig);
187
188
        return array_merge_recursive(
189
            $translations,
190
            $this->mapTranslations($helpTextTranslations, 'helpTexts', $systemLanguageLocale)
191
        );
192
    }
193
194
    /**
195
     * @param array<string, mixed> $config
196
     *
197
     * @return array<string, array<string, mixed>>
198
     */
199
    private function getLabelsFromConfig(array $config): array
200
    {
201
        $translations = [];
202
        if (\array_key_exists('blocks', $config)) {
203
            $translations = array_merge_recursive($translations, $this->extractLabels('blocks', $config['blocks']));
204
        }
205
206
        if (\array_key_exists('sections', $config)) {
207
            $translations = array_merge_recursive($translations, $this->extractLabels('sections', $config['sections']));
208
        }
209
210
        if (\array_key_exists('tabs', $config)) {
211
            $translations = array_merge_recursive($translations, $this->extractLabels('tabs', $config['tabs']));
212
        }
213
214
        if (\array_key_exists('fields', $config)) {
215
            $translations = array_merge_recursive($translations, $this->extractLabels('fields', $config['fields']));
216
        }
217
218
        return $translations;
219
    }
220
221
    /**
222
     * @param array<string, mixed> $data
223
     *
224
     * @return array<string, array<string, mixed>>
225
     */
226
    private function extractLabels(string $prefix, array $data): array
227
    {
228
        $labels = [];
229
        foreach ($data as $key => $item) {
230
            if (\array_key_exists('label', $item)) {
231
                /**
232
                 * @var string $locale
233
                 * @var string $label
234
                 */
235
                foreach ($item['label'] as $locale => $label) {
236
                    $labels[$locale][$prefix . '.' . $key] = $label;
237
                }
238
            }
239
        }
240
241
        return $labels;
242
    }
243
244
    /**
245
     * @param array<string, mixed> $config
246
     *
247
     * @return array<string, array<string, mixed>>
248
     */
249
    private function getHelpTextsFromConfig(array $config): array
250
    {
251
        $translations = [];
252
253
        if (\array_key_exists('fields', $config)) {
254
            $translations = array_merge_recursive($translations, $this->extractHelpTexts('fields', $config['fields']));
255
        }
256
257
        return $translations;
258
    }
259
260
    /**
261
     * @param array<string, mixed> $data
262
     *
263
     * @return array<string, array<string, mixed>>
264
     */
265
    private function extractHelpTexts(string $prefix, array $data): array
266
    {
267
        $helpTexts = [];
268
        foreach ($data as $key => $item) {
269
            if (!isset($item['helpText'])) {
270
                continue;
271
            }
272
273
            /**
274
             * @var string $locale
275
             * @var string $label
276
             */
277
            foreach ($item['helpText'] as $locale => $label) {
278
                $helpTexts[$locale][$prefix . '.' . $key] = $label;
279
            }
280
        }
281
282
        return $helpTexts;
283
    }
284
285
    private function removeOldMedia(string $technicalName, Context $context): void
286
    {
287
        $theme = $this->getThemeByTechnicalName($technicalName, $context);
288
289
        if (!$theme) {
290
            return;
291
        }
292
293
        // find all assigned media files
294
        $criteria = new Criteria();
295
        $criteria->addFilter(new EqualsFilter('media.themeMedia.id', $theme->getId()));
296
        $result = $this->mediaRepository->searchIds($criteria, $context);
297
298
        // delete theme media association
299
        $themeMediaData = [];
300
        foreach ($result->getIds() as $id) {
301
            $themeMediaData[] = ['themeId' => $theme->getId(), 'mediaId' => $id];
302
        }
303
304
        if (empty($themeMediaData)) {
305
            return;
306
        }
307
308
        // remove associations between theme and media first
309
        $this->themeMediaRepository->delete($themeMediaData, $context);
310
311
        // delete media associated with theme
312
        foreach ($themeMediaData as $item) {
313
            try {
314
                $this->mediaRepository->delete([['id' => $item['mediaId']]], $context);
315
            } catch (RestrictDeleteViolationException) {
316
                // don't delete files that are associated with other entities.
317
                // This files will be recreated using the file name strategy for duplicated filenames.
318
            }
319
        }
320
    }
321
322
    /**
323
     * @return array<string, mixed>
324
     */
325
    private function updateMediaInConfiguration(
326
        ?ThemeEntity $theme,
327
        StorefrontPluginConfiguration $pluginConfiguration,
328
        Context $context
329
    ): array {
330
        $media = [];
331
        $themeData = [];
332
        $themeFolderId = $this->getMediaDefaultFolderId($context);
333
334
        if ($pluginConfiguration->getPreviewMedia()) {
335
            $mediaId = Uuid::randomHex();
336
            $path = $pluginConfiguration->getPreviewMedia();
337
338
            $mediaItem = $this->createMediaStruct($path, $mediaId, $themeFolderId);
339
340
            if ($mediaItem) {
341
                $themeData['previewMediaId'] = $mediaId;
342
                $media[$path] = $mediaItem;
343
            }
344
345
            // if preview was not deleted because it is not created from theme use current preview id
346
            if ($theme && $theme->getPreviewMediaId() !== null) {
347
                $themeData['previewMediaId'] = $theme->getPreviewMediaId();
348
            }
349
        }
350
351
        $baseConfig = $pluginConfiguration->getThemeConfig() ?? [];
352
353
        if (\array_key_exists('fields', $baseConfig)) {
354
            foreach ($baseConfig['fields'] as $key => $field) {
355
                if (!\array_key_exists('type', $field) || $field['type'] !== 'media') {
356
                    continue;
357
                }
358
359
                $path = $pluginConfiguration->getBasePath() . \DIRECTORY_SEPARATOR . $field['value'];
360
361
                if (!\array_key_exists($path, $media)) {
362
                    $mediaId = Uuid::randomHex();
363
                    $mediaItem = $this->createMediaStruct($path, $mediaId, $themeFolderId);
364
365
                    if (!$mediaItem) {
366
                        continue;
367
                    }
368
369
                    $media[$path] = $mediaItem;
370
371
                    // replace media path with media ids
372
                    $baseConfig['fields'][$key]['value'] = $mediaId;
373
                } else {
374
                    $baseConfig['fields'][$key]['value'] = $media[$path]['media']['id'];
375
                }
376
            }
377
            $themeData['baseConfig'] = $baseConfig;
378
        }
379
380
        $mediaIds = [];
381
382
        if (!empty($media)) {
383
            $mediaIds = array_column($media, 'media');
384
385
            $this->mediaRepository->create($mediaIds, $context);
386
387
            foreach ($media as $item) {
388
                try {
389
                    $this->fileSaver->persistFileToMedia($item['mediaFile'], $item['basename'], $item['media']['id'], $context);
390
                } catch (DuplicatedMediaFileNameException) {
391
                    $newFileName = $this->fileNameProvider->provide(
392
                        $item['basename'],
393
                        $item['mediaFile']->getFileExtension(),
394
                        null,
395
                        $context
396
                    );
397
                    $this->fileSaver->persistFileToMedia($item['mediaFile'], $newFileName, $item['media']['id'], $context);
398
                }
399
            }
400
        }
401
402
        $themeData['media'] = $mediaIds;
403
404
        return $themeData;
405
    }
406
407
    private function getSystemLanguageLocale(Context $context): string
408
    {
409
        $criteria = new Criteria();
410
        $criteria->addAssociation('translationCode');
411
        $criteria->addFilter(new EqualsFilter('id', Defaults::LANGUAGE_SYSTEM));
412
413
        /** @var LanguageEntity $language */
414
        $language = $this->languageRepository->search($criteria, $context)->first();
415
        /** @var LocaleEntity $locale */
416
        $locale = $language->getTranslationCode();
417
418
        return $locale->getCode();
419
    }
420
421
    /**
422
     * @param array<string, mixed> $translations
423
     *
424
     * @return array<string, array<string, mixed>>
425
     */
426
    private function mapTranslations(array $translations, string $property, string $systemLanguageLocale): array
427
    {
428
        $result = [];
429
        $containsSystemLanguage = false;
430
        foreach ($translations as $locale => $translation) {
431
            if ($locale === $systemLanguageLocale) {
432
                $containsSystemLanguage = true;
433
            }
434
            $result[$locale] = [$property => $translation];
435
        }
436
437
        if (!$containsSystemLanguage && \count($translations) > 0) {
438
            $translation = array_shift($translations);
439
            if (\array_key_exists('en-GB', $translations)) {
440
                $translation = $translations['en-GB'];
441
            }
442
            $result[$systemLanguageLocale] = [$property => $translation];
443
        }
444
445
        return $result;
446
    }
447
448
    /**
449
     * @param array<string, mixed> $themeData
450
     *
451
     * @return array<string, mixed>
452
     */
453
    private function addParentTheme(StorefrontPluginConfiguration $configuration, array $themeData, Context $context): array
454
    {
455
        $lastNotSameTheme = null;
456
        foreach (array_reverse($configuration->getConfigInheritance()) as $themeName) {
457
            if (
458
                $themeName === '@' . StorefrontPluginRegistry::BASE_THEME_NAME
459
                || $themeName === '@' . $themeData['technicalName']
460
            ) {
461
                continue;
462
            }
463
            /** @var string $lastNotSameTheme */
464
            $lastNotSameTheme = str_replace('@', '', (string) $themeName);
465
        }
466
467
        if ($lastNotSameTheme !== null) {
468
            $criteria = new Criteria();
469
            $criteria->addFilter(new EqualsFilter('technicalName', $lastNotSameTheme));
470
            /** @var ThemeEntity|null $parentTheme */
471
            $parentTheme = $this->themeRepository->search($criteria, $context)->first();
472
            if ($parentTheme) {
473
                $themeData['parentThemeId'] = $parentTheme->getId();
474
            }
475
        }
476
477
        return $themeData;
478
    }
479
480
    /**
481
     * @return list<array{parentId: string, childId: string}>
0 ignored issues
show
Bug introduced by
The type Shopware\Storefront\Theme\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
482
     */
483
    private function getParentThemes(StorefrontPluginConfiguration $config, string $id): array
484
    {
485
        $allThemeConfigs = $this->pluginRegistry->getConfigurations()->getThemes();
486
487
        $allThemes = $this->getAllThemesPlain();
488
489
        $parentThemeConfigs = $allThemeConfigs->filter(
490
            fn (StorefrontPluginConfiguration $parentConfig) => $this->isDependentTheme($parentConfig, $config)
491
        );
492
493
        $technicalNames = $parentThemeConfigs->map(
494
            fn (StorefrontPluginConfiguration $theme) => $theme->getTechnicalName()
495
        );
496
497
        $parentThemes = array_filter(
498
            $allThemes,
499
            fn (array $theme) => \in_array($theme['technicalName'], $technicalNames, true)
500
        );
501
502
        $updateParents = [];
503
        foreach ($parentThemes as $parentTheme) {
504
            $updateParents[] = [
505
                'parentId' => $parentTheme['parentThemeId'],
506
                'childId' => $id,
507
            ];
508
        }
509
510
        return $updateParents;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $updateParents returns the type array|array<mixed,array<string,mixed|string>> which is incompatible with the documented return type Shopware\Storefront\Theme\list.
Loading history...
511
    }
512
513
    /**
514
     * @return list<array{technicalName: string, parentThemeId: string}>
515
     */
516
    private function getAllThemesPlain(): array
517
    {
518
        /** @var list<array{technicalName: string, parentThemeId: string}> $result */
519
        $result = $this->connection->fetchAllAssociative(
520
            'SELECT theme.technical_name as technicalName, LOWER(HEX(theme.id)) as parentThemeId FROM theme'
521
        );
522
523
        return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result returns the type Shopware\Storefront\Theme\list which is incompatible with the type-hinted return array.
Loading history...
524
    }
525
526
    private function isDependentTheme(
527
        StorefrontPluginConfiguration $parentConfig,
528
        StorefrontPluginConfiguration $currentThemeConfig
529
    ): bool {
530
        return $currentThemeConfig->getTechnicalName() !== $parentConfig->getTechnicalName()
531
            && \in_array('@' . $parentConfig->getTechnicalName(), $currentThemeConfig->getStyleFiles()->getFilepaths(), true)
532
        ;
533
    }
534
}
535