Passed
Push — trunk ( 94905f...91596f )
by Christian
09:50 queued 14s
created

ThemeLifecycleService::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 1
eloc 0
nc 1
nop 12
dl 0
loc 14
rs 10
c 1
b 1
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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\Aggregate\MediaFolder\MediaFolderEntity;
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\Content\Media\MediaCollection;
12
use Shopware\Core\Content\Media\MediaException;
13
use Shopware\Core\Defaults;
14
use Shopware\Core\Framework\Context;
15
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
16
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
17
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
18
use Shopware\Core\Framework\DataAbstractionLayer\Write\Validation\RestrictDeleteViolationException;
19
use Shopware\Core\Framework\Log\Package;
20
use Shopware\Core\Framework\Uuid\Uuid;
21
use Shopware\Core\System\Language\LanguageEntity;
22
use Shopware\Core\System\Locale\LocaleEntity;
23
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\AbstractStorefrontPluginConfigurationFactory;
24
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfiguration;
25
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfigurationCollection;
26
27
#[Package('storefront')]
28
class ThemeLifecycleService
29
{
30
    /**
31
     * @param EntityRepository<MediaCollection> $mediaRepository
32
     *
33
     * @internal
34
     *
35
     * @decrecated tag:v6.6.0 argument $pluginConfigurationFactory will be mandatory
36
     */
37
    public function __construct(
38
        private readonly StorefrontPluginRegistryInterface $pluginRegistry,
39
        private readonly EntityRepository $themeRepository,
40
        private readonly EntityRepository $mediaRepository,
41
        private readonly EntityRepository $mediaFolderRepository,
42
        private readonly EntityRepository $themeMediaRepository,
43
        private readonly FileSaver $fileSaver,
44
        private readonly FileNameProvider $fileNameProvider,
45
        private readonly ThemeFileImporterInterface $themeFileImporter,
46
        private readonly EntityRepository $languageRepository,
47
        private readonly EntityRepository $themeChildRepository,
48
        private readonly Connection $connection,
49
        private readonly ?AbstractStorefrontPluginConfigurationFactory $pluginConfigurationFactory
50
    ) {
51
    }
52
53
    public function refreshThemes(
54
        Context $context,
55
        ?StorefrontPluginConfigurationCollection $configurationCollection = null
56
    ): void {
57
        if ($configurationCollection === null) {
58
            $configurationCollection = $this->pluginRegistry->getConfigurations()->getThemes();
59
        }
60
61
        // iterate over all theme configs in the filesystem (plugins/bundles)
62
        foreach ($configurationCollection as $config) {
63
            $this->refreshTheme($config, $context);
64
        }
65
    }
66
67
    public function refreshTheme(StorefrontPluginConfiguration $configuration, Context $context): void
68
    {
69
        $themeData = [];
70
        $themeData['name'] = $configuration->getName();
71
        $themeData['technicalName'] = $configuration->getTechnicalName();
72
        $themeData['author'] = $configuration->getAuthor();
73
        $themeData['themeJson'] = $configuration->getThemeJson();
74
75
        // refresh theme after deleting media
76
        $theme = $this->getThemeByTechnicalName($configuration->getTechnicalName(), $context);
77
78
        // check if theme config already exists in the database
79
        if ($theme) {
80
            $themeData['id'] = $theme->getId();
81
        } else {
82
            $themeData['active'] = true;
83
        }
84
85
        $themeData['translations'] = $this->getTranslationsConfiguration($configuration, $context);
86
87
        $updatedData = $this->updateMediaInConfiguration($theme, $configuration, $context);
88
89
        $themeData = array_merge($themeData, $updatedData);
90
91
        if (!empty($configuration->getConfigInheritance())) {
92
            $themeData = $this->addParentTheme($configuration, $themeData, $context);
93
        }
94
95
        $writtenEvent = $this->themeRepository->upsert([$themeData], $context);
96
97
        if (empty($themeData['id'])) {
98
            $themeData['id'] = current($writtenEvent->getPrimaryKeys(ThemeDefinition::ENTITY_NAME));
99
        }
100
101
        $this->themeRepository->upsert([$themeData], $context);
102
103
        if (!empty($themeData['toDeleteMedia'])) {
104
            $this->themeMediaRepository->delete($themeData['toDeleteMedia'], $context);
105
        }
106
107
        $parentThemes = $this->getParentThemes($configuration, $themeData['id']);
108
        $parentCriteria = new Criteria();
109
        $parentCriteria->addFilter(new EqualsFilter('childId', $themeData['id']));
110
        /** @var list<array<string, string>> $toDeleteIds */
111
        $toDeleteIds = $this->themeChildRepository->searchIds($parentCriteria, $context)->getIds();
112
        $this->themeChildRepository->delete($toDeleteIds, $context);
113
        $this->themeChildRepository->upsert($parentThemes, $context);
114
    }
115
116
    public function removeTheme(string $technicalName, Context $context): void
117
    {
118
        $criteria = new Criteria();
119
        $criteria->addAssociation('dependentThemes');
120
        $criteria->addFilter(new EqualsFilter('technicalName', $technicalName));
121
122
        /** @var ThemeEntity|null $theme */
123
        $theme = $this->themeRepository->search($criteria, $context)->first();
124
125
        if ($theme === null) {
126
            return;
127
        }
128
129
        $dependentThemes = $theme->getDependentThemes() ?? new ThemeCollection();
130
        $ids = [...array_values($dependentThemes->getIds()), ...[$theme->getId()]];
131
132
        $this->removeOldMedia($technicalName, $context);
133
        $this->themeRepository->delete(array_map(fn (string $id) => ['id' => $id], $ids), $context);
134
    }
135
136
    private function getThemeByTechnicalName(string $technicalName, Context $context): ?ThemeEntity
137
    {
138
        $criteria = new Criteria();
139
        $criteria->addFilter(new EqualsFilter('technicalName', $technicalName));
140
        $criteria->addAssociation('previewMedia');
141
142
        $theme = $this->themeRepository->search($criteria, $context)->first();
143
144
        return $theme instanceof ThemeEntity ? $theme : null;
145
    }
146
147
    /**
148
     * @return array<string, mixed>|null
149
     */
150
    private function createMediaStruct(string $path, string $mediaId, ?string $themeFolderId): ?array
151
    {
152
        $path = $this->themeFileImporter->getRealPath($path);
153
154
        if (!$this->themeFileImporter->fileExists($path)) {
155
            return null;
156
        }
157
158
        $pathinfo = pathinfo($path);
159
160
        return [
161
            'basename' => $pathinfo['filename'],
162
            'media' => ['id' => $mediaId, 'mediaFolderId' => $themeFolderId],
163
            'mediaFile' => new MediaFile(
164
                $path,
165
                (string) MimeType::fromFilename($pathinfo['basename']),
166
                $pathinfo['extension'] ?? '',
167
                (int) filesize($path)
168
            ),
169
        ];
170
    }
171
172
    private function getMediaDefaultFolderId(Context $context): ?string
173
    {
174
        $criteria = new Criteria();
175
        $criteria->addFilter(new EqualsFilter('media_folder.defaultFolder.entity', 'theme'));
176
        $criteria->addAssociation('defaultFolder');
177
        $criteria->setLimit(1);
178
        $defaultFolder = $this->mediaFolderRepository->search($criteria, $context);
179
        $defaultFolderId = null;
180
        if ($defaultFolder->count() === 1) {
181
            $defaultFolder = $defaultFolder->first();
182
183
            if ($defaultFolder instanceof MediaFolderEntity) {
184
                $defaultFolderId = $defaultFolder->getId();
185
            }
186
        }
187
188
        return $defaultFolderId;
189
    }
190
191
    /**
192
     * @return array<string, array<string, mixed>>
193
     */
194
    private function getTranslationsConfiguration(StorefrontPluginConfiguration $configuration, Context $context): array
195
    {
196
        $systemLanguageLocale = $this->getSystemLanguageLocale($context);
197
198
        $themeConfig = $configuration->getThemeConfig();
199
        if (!$themeConfig) {
200
            return [];
201
        }
202
203
        $labelTranslations = $this->getLabelsFromConfig($themeConfig);
204
        $translations = $this->mapTranslations($labelTranslations, 'labels', $systemLanguageLocale);
205
206
        $helpTextTranslations = $this->getHelpTextsFromConfig($themeConfig);
207
208
        return array_merge_recursive(
209
            $translations,
210
            $this->mapTranslations($helpTextTranslations, 'helpTexts', $systemLanguageLocale)
211
        );
212
    }
213
214
    /**
215
     * @param array<string, mixed> $config
216
     *
217
     * @return array<string, array<string, mixed>>
218
     */
219
    private function getLabelsFromConfig(array $config): array
220
    {
221
        $translations = [];
222
        if (\array_key_exists('blocks', $config)) {
223
            $translations = array_merge_recursive($translations, $this->extractLabels('blocks', $config['blocks']));
224
        }
225
226
        if (\array_key_exists('sections', $config)) {
227
            $translations = array_merge_recursive($translations, $this->extractLabels('sections', $config['sections']));
228
        }
229
230
        if (\array_key_exists('tabs', $config)) {
231
            $translations = array_merge_recursive($translations, $this->extractLabels('tabs', $config['tabs']));
232
        }
233
234
        if (\array_key_exists('fields', $config)) {
235
            $translations = array_merge_recursive($translations, $this->extractLabels('fields', $config['fields']));
236
        }
237
238
        return $translations;
239
    }
240
241
    /**
242
     * @param array<string, mixed> $data
243
     *
244
     * @return array<string, array<string, mixed>>
245
     */
246
    private function extractLabels(string $prefix, array $data): array
247
    {
248
        $labels = [];
249
        foreach ($data as $key => $item) {
250
            if (\array_key_exists('label', $item)) {
251
                /**
252
                 * @var string $locale
253
                 * @var string $label
254
                 */
255
                foreach ($item['label'] as $locale => $label) {
256
                    $labels[$locale][$prefix . '.' . $key] = $label;
257
                }
258
            }
259
        }
260
261
        return $labels;
262
    }
263
264
    /**
265
     * @param array<string, mixed> $config
266
     *
267
     * @return array<string, array<string, mixed>>
268
     */
269
    private function getHelpTextsFromConfig(array $config): array
270
    {
271
        $translations = [];
272
273
        if (\array_key_exists('fields', $config)) {
274
            $translations = array_merge_recursive($translations, $this->extractHelpTexts('fields', $config['fields']));
275
        }
276
277
        return $translations;
278
    }
279
280
    /**
281
     * @param array<string, mixed> $data
282
     *
283
     * @return array<string, array<string, mixed>>
284
     */
285
    private function extractHelpTexts(string $prefix, array $data): array
286
    {
287
        $helpTexts = [];
288
        foreach ($data as $key => $item) {
289
            if (!isset($item['helpText'])) {
290
                continue;
291
            }
292
293
            /**
294
             * @var string $locale
295
             * @var string $label
296
             */
297
            foreach ($item['helpText'] as $locale => $label) {
298
                $helpTexts[$locale][$prefix . '.' . $key] = $label;
299
            }
300
        }
301
302
        return $helpTexts;
303
    }
304
305
    private function removeOldMedia(string $technicalName, Context $context): void
306
    {
307
        $theme = $this->getThemeByTechnicalName($technicalName, $context);
308
309
        if (!$theme) {
310
            return;
311
        }
312
313
        // find all assigned media files
314
        $criteria = new Criteria();
315
        $criteria->addFilter(new EqualsFilter('media.themeMedia.id', $theme->getId()));
316
        $result = $this->mediaRepository->searchIds($criteria, $context);
317
318
        // delete theme media association
319
        $themeMediaData = [];
320
        foreach ($result->getIds() as $id) {
321
            $themeMediaData[] = ['themeId' => $theme->getId(), 'mediaId' => $id];
322
        }
323
324
        if (empty($themeMediaData)) {
325
            return;
326
        }
327
328
        // remove associations between theme and media first
329
        $this->themeMediaRepository->delete($themeMediaData, $context);
330
331
        // delete media associated with theme
332
        foreach ($themeMediaData as $item) {
333
            try {
334
                $this->mediaRepository->delete([['id' => $item['mediaId']]], $context);
335
            } catch (RestrictDeleteViolationException) {
336
                // don't delete files that are associated with other entities.
337
                // This files will be recreated using the file name strategy for duplicated filenames.
338
            }
339
        }
340
    }
341
342
    /**
343
     * @return array<string, mixed>
344
     */
345
    private function updateMediaInConfiguration(
346
        ?ThemeEntity $theme,
347
        StorefrontPluginConfiguration $pluginConfiguration,
348
        Context $context
349
    ): array {
350
        $media = [];
351
        $themeData = [];
352
        $themeFolderId = $this->getMediaDefaultFolderId($context);
353
354
        $installedConfiguration = null;
355
        if ($theme && \is_array($theme->getThemeJson()) && $this->pluginConfigurationFactory) {
356
            $installedConfiguration = $this->pluginConfigurationFactory->createFromThemeJson(
357
                $theme->getTechnicalName() ?? 'childTheme',
358
                $theme->getThemeJson(),
359
                $pluginConfiguration->getBasePath(),
360
                false
361
            );
362
        }
363
364
        if (
365
            $pluginConfiguration->getPreviewMedia()
366
            && $pluginConfiguration->getPreviewMedia() !== $installedConfiguration?->getPreviewMedia()
367
            && (
368
                $theme === null
369
                || $theme->getPreviewMedia() === null
370
                || basename($installedConfiguration?->getPreviewMedia() ?? '') !== $theme->getPreviewMedia()->getFileNameIncludingExtension()
371
            )
372
        ) {
373
            $mediaId = Uuid::randomHex();
374
375
            $path = $pluginConfiguration->getPreviewMedia();
376
377
            $mediaItem = $this->createMediaStruct($path, $mediaId, $themeFolderId);
378
379
            if ($mediaItem) {
380
                $themeData['previewMediaId'] = $mediaId;
381
                $media[$path] = $mediaItem;
382
            }
383
        }
384
385
        $baseConfig = $pluginConfiguration->getThemeConfig() ?? [];
386
        $installedBaseConfig = $installedConfiguration?->getThemeConfig() ?? [];
387
388
        $currentThemeMedia = null;
389
        $currentMediaIds = null;
390
        $toDeleteIds = null;
391
        // get existing MediaFiles
392
        if ($theme !== null && \array_key_exists('fields', $theme->getBaseConfig() ?? [])) {
393
            foreach ($theme->getBaseConfig()['fields'] as $key => $field) {
394
                if (!\array_key_exists('type', $field) || $field['type'] !== 'media' || !Uuid::isValid($field['value'])) {
395
                    continue;
396
                }
397
                $currentMediaIds[$key] = $field['value'];
398
            }
399
400
            if (!empty($currentMediaIds)) {
401
                $currentThemeMedia = $this->mediaRepository->search(new Criteria($currentMediaIds), $context);
402
            }
403
        }
404
405
        if (\array_key_exists('fields', $baseConfig)) {
406
            foreach ($baseConfig['fields'] as $key => $field) {
407
                if (!\array_key_exists('type', $field) || $field['type'] !== 'media') {
408
                    continue;
409
                }
410
411
                if (
412
                    isset($installedBaseConfig['fields'][$key]['value'])
413
                    && $field['value'] === $installedBaseConfig['fields'][$key]['value']
414
                ) {
415
                    continue;
416
                }
417
418
                $path = $pluginConfiguration->getBasePath() . \DIRECTORY_SEPARATOR . $field['value'];
419
420
                if (!\array_key_exists($path, $media)) {
421
                    if (
422
                        $currentThemeMedia !== null
423
                        && !empty($currentMediaIds)
424
                        && isset($currentMediaIds[$key])
425
                        && $currentThemeMedia->getEntities()->get($currentMediaIds[$key])?->getFileNameIncludingExtension() === basename($path)) {
426
                        continue;
427
                    }
428
429
                    $criteriaMedia = new Criteria();
430
                    $criteriaMedia->addFilter(new EqualsFilter('fileName', basename($path)));
431
                    if ($this->mediaRepository->searchIds($criteriaMedia, $context)->getTotal() > 0) {
432
                        continue;
433
                    }
434
435
                    $mediaId = Uuid::randomHex();
436
                    $mediaItem = $this->createMediaStruct($path, $mediaId, $themeFolderId);
437
438
                    if (!$mediaItem) {
439
                        continue;
440
                    }
441
442
                    $media[$path] = $mediaItem;
443
444
                    // replace media path with media ids
445
                    $baseConfig['fields'][$key]['value'] = $mediaId;
446
                } else {
447
                    $baseConfig['fields'][$key]['value'] = $media[$path]['media']['id'];
448
                }
449
                if ($theme && isset($currentMediaIds[$key])) {
450
                    $toDeleteIds[] = $currentMediaIds[$key];
451
                }
452
            }
453
            $themeData['baseConfig'] = $baseConfig;
454
        }
455
456
        $mediaIds = [];
457
458
        if (!empty($media)) {
459
            $mediaIds = array_column($media, 'media');
460
461
            $this->mediaRepository->create($mediaIds, $context);
462
463
            foreach ($media as $item) {
464
                if (!$item['mediaFile'] instanceof MediaFile) {
465
                    throw MediaException::missingFile($item['media']['id']);
466
                }
467
468
                try {
469
                    $this->fileSaver->persistFileToMedia($item['mediaFile'], $item['basename'], $item['media']['id'], $context);
470
                } catch (MediaException $e) {
471
                    if ($e->getErrorCode() !== MediaException::MEDIA_DUPLICATED_FILE_NAME) {
472
                        throw $e;
473
                    }
474
475
                    $newFileName = $this->fileNameProvider->provide(
476
                        $item['basename'],
477
                        $item['mediaFile']->getFileExtension(),
478
                        null,
479
                        $context
480
                    );
481
                    $this->fileSaver->persistFileToMedia($item['mediaFile'], $newFileName, $item['media']['id'], $context);
482
                }
483
            }
484
        }
485
486
        $themeData['media'] = $mediaIds;
487
488
        if ($theme && \is_array($toDeleteIds)) {
489
            $toDeleteIds = array_unique($toDeleteIds);
490
            foreach ($toDeleteIds as $id) {
491
                if (Uuid::isValid($id)) {
492
                    $themeData['toDeleteMedia'][] = [
493
                        'mediaId' => $id,
494
                        'themeId' => $theme->getId(),
495
                    ];
496
                }
497
            }
498
        }
499
500
        return $themeData;
501
    }
502
503
    private function getSystemLanguageLocale(Context $context): string
504
    {
505
        $criteria = new Criteria();
506
        $criteria->addAssociation('translationCode');
507
        $criteria->addFilter(new EqualsFilter('id', Defaults::LANGUAGE_SYSTEM));
508
509
        /** @var LanguageEntity $language */
510
        $language = $this->languageRepository->search($criteria, $context)->first();
511
        /** @var LocaleEntity $locale */
512
        $locale = $language->getTranslationCode();
513
514
        return $locale->getCode();
515
    }
516
517
    /**
518
     * @param array<string, mixed> $translations
519
     *
520
     * @return array<string, array<string, mixed>>
521
     */
522
    private function mapTranslations(array $translations, string $property, string $systemLanguageLocale): array
523
    {
524
        $result = [];
525
        $containsSystemLanguage = false;
526
        foreach ($translations as $locale => $translation) {
527
            if ($locale === $systemLanguageLocale) {
528
                $containsSystemLanguage = true;
529
            }
530
            $result[$locale] = [$property => $translation];
531
        }
532
533
        if (!$containsSystemLanguage && \count($translations) > 0) {
534
            $translation = array_shift($translations);
535
            if (\array_key_exists('en-GB', $translations)) {
536
                $translation = $translations['en-GB'];
537
            }
538
            $result[$systemLanguageLocale] = [$property => $translation];
539
        }
540
541
        return $result;
542
    }
543
544
    /**
545
     * @param array<string, mixed> $themeData
546
     *
547
     * @return array<string, mixed>
548
     */
549
    private function addParentTheme(StorefrontPluginConfiguration $configuration, array $themeData, Context $context): array
550
    {
551
        $lastNotSameTheme = null;
552
        foreach (array_reverse($configuration->getConfigInheritance()) as $themeName) {
553
            if (
554
                $themeName === '@' . StorefrontPluginRegistry::BASE_THEME_NAME
555
                || $themeName === '@' . $themeData['technicalName']
556
            ) {
557
                continue;
558
            }
559
            /** @var string $lastNotSameTheme */
560
            $lastNotSameTheme = str_replace('@', '', (string) $themeName);
561
        }
562
563
        if ($lastNotSameTheme !== null) {
564
            $criteria = new Criteria();
565
            $criteria->addFilter(new EqualsFilter('technicalName', $lastNotSameTheme));
566
            /** @var ThemeEntity|null $parentTheme */
567
            $parentTheme = $this->themeRepository->search($criteria, $context)->first();
568
            if ($parentTheme) {
569
                $themeData['parentThemeId'] = $parentTheme->getId();
570
            }
571
        }
572
573
        return $themeData;
574
    }
575
576
    /**
577
     * @return list<array{parentId: string, childId: string}>
578
     */
579
    private function getParentThemes(StorefrontPluginConfiguration $config, string $id): array
580
    {
581
        $allThemeConfigs = $this->pluginRegistry->getConfigurations()->getThemes();
582
583
        $allThemes = $this->getAllThemesPlain();
584
585
        $parentThemeConfigs = $allThemeConfigs->filter(
586
            fn (StorefrontPluginConfiguration $parentConfig) => $this->isDependentTheme($parentConfig, $config)
587
        );
588
589
        $technicalNames = $parentThemeConfigs->map(
590
            fn (StorefrontPluginConfiguration $theme) => $theme->getTechnicalName()
591
        );
592
593
        $parentThemes = array_filter(
594
            $allThemes,
595
            fn (array $theme) => \in_array($theme['technicalName'], $technicalNames, true)
596
        );
597
598
        $updateParents = [];
599
        foreach ($parentThemes as $parentTheme) {
600
            $updateParents[] = [
601
                'parentId' => $parentTheme['parentThemeId'],
602
                'childId' => $id,
603
            ];
604
        }
605
606
        return $updateParents;
607
    }
608
609
    /**
610
     * @return list<array{technicalName: string, parentThemeId: string}>
611
     */
612
    private function getAllThemesPlain(): array
613
    {
614
        /** @var list<array{technicalName: string, parentThemeId: string}> $result */
615
        $result = $this->connection->fetchAllAssociative(
616
            'SELECT theme.technical_name as technicalName, LOWER(HEX(theme.id)) as parentThemeId FROM theme'
617
        );
618
619
        return $result;
620
    }
621
622
    private function isDependentTheme(
623
        StorefrontPluginConfiguration $parentConfig,
624
        StorefrontPluginConfiguration $currentThemeConfig
625
    ): bool {
626
        return $currentThemeConfig->getTechnicalName() !== $parentConfig->getTechnicalName()
627
            && \in_array('@' . $parentConfig->getTechnicalName(), $currentThemeConfig->getStyleFiles()->getFilepaths(), true)
628
        ;
629
    }
630
}
631