src/Core/System/Snippet/SnippetService.php line 306

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Core\System\Snippet;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Framework\Context;
  5. use Shopware\Core\Framework\DataAbstractionLayer\Entity;
  6. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Bucket\TermsResult;
  9. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  10. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  11. use Shopware\Core\Framework\Log\Package;
  12. use Shopware\Core\Framework\Uuid\Uuid;
  13. use Shopware\Core\System\SalesChannel\Aggregate\SalesChannelDomain\SalesChannelDomainEntity;
  14. use Shopware\Core\System\Snippet\Aggregate\SnippetSet\SnippetSetEntity;
  15. use Shopware\Core\System\Snippet\Files\AbstractSnippetFile;
  16. use Shopware\Core\System\Snippet\Files\SnippetFileCollection;
  17. use Shopware\Core\System\Snippet\Filter\SnippetFilterFactory;
  18. use Shopware\Storefront\Theme\SalesChannelThemeLoader;
  19. use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfiguration;
  20. use Shopware\Storefront\Theme\StorefrontPluginRegistry;
  21. use Symfony\Component\DependencyInjection\ContainerInterface;
  22. use Symfony\Component\Translation\MessageCatalogueInterface;
  23. #[Package('system-settings')]
  24. class SnippetService
  25. {
  26.     /**
  27.      * @internal
  28.      */
  29.     public function __construct(
  30.         private readonly Connection $connection,
  31.         private readonly SnippetFileCollection $snippetFileCollection,
  32.         private readonly EntityRepository $snippetRepository,
  33.         private readonly EntityRepository $snippetSetRepository,
  34.         private readonly EntityRepository $salesChannelDomain,
  35.         private readonly SnippetFilterFactory $snippetFilterFactory,
  36.         /**
  37.          * The "kernel" service is synthetic, it needs to be set at boot time before it can be used.
  38.          * We need to get StorefrontPluginRegistry service from service_container lazily because it depends on kernel service.
  39.          */
  40.         private readonly ContainerInterface $container,
  41.         private readonly ?SalesChannelThemeLoader $salesChannelThemeLoader null
  42.     ) {
  43.     }
  44.     /**
  45.      * filters: [
  46.      *      'isCustom' => bool,
  47.      *      'isEmpty' => bool,
  48.      *      'term' => string,
  49.      *      'namespaces' => array,
  50.      *      'authors' => array,
  51.      *      'translationKeys' => array,
  52.      * ]
  53.      *
  54.      * sort: [
  55.      *      'column' => NULL || the string -> 'translationKey' || setId
  56.      *      'direction' => 'ASC' || 'DESC'
  57.      * ]
  58.      *
  59.      * @param int<1, max> $limit
  60.      * @param array<string, bool|string|array<int, string>> $requestFilters
  61.      * @param array<string, string> $sort
  62.      *
  63.      * @return array{total:int, data: array<string, array<int, array<string, string|null>>>}
  64.      */
  65.     public function getList(int $pageint $limitContext $context, array $requestFilters, array $sort): array
  66.     {
  67.         --$page;
  68.         /** @var array<string, array{iso: string, id: string}> $metaData */
  69.         $metaData $this->getSetMetaData($context);
  70.         $isoList $this->createIsoList($metaData);
  71.         $languageFiles $this->getSnippetFilesByIso($isoList);
  72.         $fileSnippets $this->getFileSnippets($languageFiles$isoList);
  73.         $dbSnippets $this->databaseSnippetsToArray($this->findSnippetInDatabase(new Criteria(), $context), $fileSnippets);
  74.         $snippets array_replace_recursive($fileSnippets$dbSnippets);
  75.         $snippets $this->fillBlankSnippets($snippets$isoList);
  76.         foreach ($requestFilters as $requestFilterName => $requestFilterValue) {
  77.             $snippets $this->snippetFilterFactory->getFilter($requestFilterName)->filter($snippets$requestFilterValue);
  78.         }
  79.         $snippets $this->sortSnippets($sort$snippets);
  80.         $total 0;
  81.         foreach ($snippets as &$set) {
  82.             $total $total $total \count($set['snippets']);
  83.             $set['snippets'] = array_chunk($set['snippets'], $limittrue)[$page] ?? [];
  84.         }
  85.         return [
  86.             'total' => $total,
  87.             'data' => $this->mergeSnippetsComparison($snippets),
  88.         ];
  89.     }
  90.     /**
  91.      * @return array<string, string>
  92.      */
  93.     public function getStorefrontSnippets(MessageCatalogueInterface $catalogstring $snippetSetId, ?string $fallbackLocale null, ?string $salesChannelId null): array
  94.     {
  95.         $locale $this->getLocaleBySnippetSetId($snippetSetId);
  96.         $snippets = [];
  97.         $snippetFileCollection = clone $this->snippetFileCollection;
  98.         $usingThemes $this->getUsedThemes($salesChannelId);
  99.         $unusedThemes $this->getUnusedThemes($usingThemes);
  100.         $snippetCollection $snippetFileCollection->filter(fn (AbstractSnippetFile $snippetFile) => !\in_array($snippetFile->getTechnicalName(), $unusedThemestrue));
  101.         $fallbackSnippets = [];
  102.         if ($fallbackLocale !== null) {
  103.             // fallback has to be the base
  104.             $snippets $fallbackSnippets $this->getSnippetsByLocale($snippetCollection$fallbackLocale);
  105.         }
  106.         // now override fallback with defaults in catalog
  107.         $snippets array_replace_recursive(
  108.             $snippets,
  109.             $catalog->all('messages')
  110.         );
  111.         // after fallback and default catalog merged, overwrite them with current locale snippets
  112.         $snippets array_replace_recursive(
  113.             $snippets,
  114.             $locale === $fallbackLocale $fallbackSnippets $this->getSnippetsByLocale($snippetCollection$locale)
  115.         );
  116.         // at least overwrite the snippets with the database customer overwrites
  117.         return array_replace_recursive(
  118.             $snippets,
  119.             $this->fetchSnippetsFromDatabase($snippetSetId$unusedThemes)
  120.         );
  121.     }
  122.     /**
  123.      * @return array<int, string>
  124.      */
  125.     public function getRegionFilterItems(Context $context): array
  126.     {
  127.         /** @var array<string, array{iso: string, id: string}> $metaData */
  128.         $metaData $this->getSetMetaData($context);
  129.         $isoList $this->createIsoList($metaData);
  130.         $snippetFiles $this->getSnippetFilesByIso($isoList);
  131.         $criteria = new Criteria();
  132.         $dbSnippets $this->findSnippetInDatabase($criteria$context);
  133.         $result = [];
  134.         foreach ($snippetFiles as $files) {
  135.             foreach ($this->getSnippetsFromFiles($files'') as $namespace => $_value) {
  136.                 $region explode('.'$namespace)[0];
  137.                 if (\in_array($region$resulttrue)) {
  138.                     continue;
  139.                 }
  140.                 $result[] = $region;
  141.             }
  142.         }
  143.         /** @var SnippetEntity $snippet */
  144.         foreach ($dbSnippets as $snippet) {
  145.             $region explode('.'$snippet->getTranslationKey())[0];
  146.             if (\in_array($region$resulttrue)) {
  147.                 continue;
  148.             }
  149.             $result[] = $region;
  150.         }
  151.         sort($result);
  152.         return $result;
  153.     }
  154.     /**
  155.      * @return array<int, int|string>
  156.      */
  157.     public function getAuthors(Context $context): array
  158.     {
  159.         $files $this->snippetFileCollection->toArray();
  160.         $criteria = new Criteria();
  161.         $criteria->addAggregation(new TermsAggregation('distinct_author''author'));
  162.         /** @var TermsResult|null $aggregation */
  163.         $aggregation $this->snippetRepository->aggregate($criteria$context)
  164.                 ->get('distinct_author');
  165.         if (!$aggregation || empty($aggregation->getBuckets())) {
  166.             $result = [];
  167.         } else {
  168.             $result $aggregation->getKeys();
  169.         }
  170.         $authors array_flip($result);
  171.         foreach ($files as $file) {
  172.             $authors[$file['author']] = true;
  173.         }
  174.         $result array_keys($authors);
  175.         sort($result);
  176.         return $result;
  177.     }
  178.     public function getSnippetSet(string $salesChannelIdstring $languageIdstring $localeContext $context): ?SnippetSetEntity
  179.     {
  180.         $criteria = new Criteria();
  181.         $criteria->addFilter(
  182.             new EqualsFilter('salesChannelId'$salesChannelId),
  183.             new EqualsFilter('languageId'$languageId)
  184.         );
  185.         $criteria->addAssociation('snippetSet');
  186.         /** @var SalesChannelDomainEntity|null $salesChannelDomain */
  187.         $salesChannelDomain $this->salesChannelDomain->search($criteria$context)->first();
  188.         if ($salesChannelDomain === null) {
  189.             $criteria = new Criteria();
  190.             $criteria->addFilter(new EqualsFilter('iso'$locale));
  191.             $snippetSet $this->snippetSetRepository->search($criteria$context)->first();
  192.         } else {
  193.             $snippetSet $salesChannelDomain->getSnippetSet();
  194.         }
  195.         return $snippetSet;
  196.     }
  197.     /**
  198.      * @param list<string> $usingThemes
  199.      *
  200.      * @return list<string>
  201.      */
  202.     protected function getUnusedThemes(array $usingThemes = []): array
  203.     {
  204.         if (!$this->container->has(StorefrontPluginRegistry::class)) {
  205.             return [];
  206.         }
  207.         $themeRegistry $this->container->get(StorefrontPluginRegistry::class);
  208.         $unusedThemes $themeRegistry->getConfigurations()->getThemes()->filter(fn (StorefrontPluginConfiguration $theme) => !\in_array($theme->getTechnicalName(), $usingThemestrue))->map(fn (StorefrontPluginConfiguration $theme) => $theme->getTechnicalName());
  209.         return array_values($unusedThemes);
  210.     }
  211.     /**
  212.      * Second parameter $unusedThemes is used for external dependencies
  213.      *
  214.      * @param list<string> $unusedThemes
  215.      *
  216.      * @return array<string, string>
  217.      */
  218.     protected function fetchSnippetsFromDatabase(string $snippetSetId, array $unusedThemes = []): array
  219.     {
  220.         /** @var array<string, string> $snippets */
  221.         $snippets $this->connection->fetchAllKeyValue('SELECT translation_key, value FROM snippet WHERE snippet_set_id = :snippetSetId', [
  222.             'snippetSetId' => Uuid::fromHexToBytes($snippetSetId),
  223.         ]);
  224.         return $snippets;
  225.     }
  226.     /**
  227.      * @return array<string, string>
  228.      */
  229.     private function getSnippetsByLocale(SnippetFileCollection $snippetFileCollectionstring $locale): array
  230.     {
  231.         $files $snippetFileCollection->getSnippetFilesByIso($locale);
  232.         $snippets = [];
  233.         foreach ($files as $file) {
  234.             $json json_decode(file_get_contents($file->getPath()) ?: ''true);
  235.             $jsonError json_last_error();
  236.             if ($jsonError !== 0) {
  237.                 throw new \RuntimeException(sprintf('Invalid JSON in snippet file at path \'%s\' with code \'%d\''$file->getPath(), $jsonError));
  238.             }
  239.             $flattenSnippetFileSnippets $this->flatten($json);
  240.             $snippets array_replace_recursive(
  241.                 $snippets,
  242.                 $flattenSnippetFileSnippets
  243.             );
  244.         }
  245.         return $snippets;
  246.     }
  247.     /**
  248.      * @return list<string>
  249.      */
  250.     private function getUsedThemes(?string $salesChannelId null): array
  251.     {
  252.         if (!$salesChannelId || $this->salesChannelThemeLoader === null) {
  253.             return [StorefrontPluginRegistry::BASE_THEME_NAME];
  254.         }
  255.         $saleChannelThemes $this->salesChannelThemeLoader->load($salesChannelId);
  256.         $usedThemes array_filter([
  257.             $saleChannelThemes['themeName'] ?? null,
  258.             $saleChannelThemes['parentThemeName'] ?? null,
  259.         ]);
  260.         /** @var list<string> */
  261.         return array_values(array_unique([
  262.             ...$usedThemes,
  263.             StorefrontPluginRegistry::BASE_THEME_NAME// Storefront snippets should always be loaded
  264.         ]));
  265.     }
  266.     /**
  267.      * @param array<string, string> $isoList
  268.      *
  269.      * @return array<string, array<int, AbstractSnippetFile>>
  270.      */
  271.     private function getSnippetFilesByIso(array $isoList): array
  272.     {
  273.         $result = [];
  274.         foreach ($isoList as $iso) {
  275.             $result[$iso] = $this->snippetFileCollection->getSnippetFilesByIso($iso);
  276.         }
  277.         return $result;
  278.     }
  279.     /**
  280.      * @param array<int, AbstractSnippetFile> $languageFiles
  281.      *
  282.      * @return array<string, array<string, string|null>>
  283.      */
  284.     private function getSnippetsFromFiles(array $languageFilesstring $setId): array
  285.     {
  286.         $result = [];
  287.         foreach ($languageFiles as $snippetFile) {
  288.             $json json_decode((string) file_get_contents($snippetFile->getPath()), true);
  289.             $jsonError json_last_error();
  290.             if ($jsonError !== 0) {
  291.                 throw new \RuntimeException(sprintf('Invalid JSON in snippet file at path \'%s\' with code \'%d\''$snippetFile->getPath(), $jsonError));
  292.             }
  293.             $flattenSnippetFileSnippets $this->flatten(
  294.                 $json,
  295.                 '',
  296.                 ['author' => $snippetFile->getAuthor(), 'id' => null'setId' => $setId]
  297.             );
  298.             $result array_replace_recursive(
  299.                 $result,
  300.                 $flattenSnippetFileSnippets
  301.             );
  302.         }
  303.         return $result;
  304.     }
  305.     /**
  306.      * @param array<string, array<string, array<string, array<string, string|null>>>> $sets
  307.      *
  308.      * @return array<string, array<int, array<string, string|null>>>
  309.      */
  310.     private function mergeSnippetsComparison(array $sets): array
  311.     {
  312.         $result = [];
  313.         foreach ($sets as $snippetSet) {
  314.             foreach ($snippetSet['snippets'] as $translationKey => $snippet) {
  315.                 $result[$translationKey][] = $snippet;
  316.             }
  317.         }
  318.         return $result;
  319.     }
  320.     private function getLocaleBySnippetSetId(string $snippetSetId): string
  321.     {
  322.         $locale $this->connection->fetchOne('SELECT iso FROM snippet_set WHERE id = :snippetSetId', [
  323.             'snippetSetId' => Uuid::fromHexToBytes($snippetSetId),
  324.         ]);
  325.         if ($locale === false) {
  326.             throw new \InvalidArgumentException(sprintf('No snippetSet with id "%s" found'$snippetSetId));
  327.         }
  328.         return (string) $locale;
  329.     }
  330.     /**
  331.      * @param array<string, array<string, array<string, array<string, string|null>>>> $fileSnippets
  332.      * @param array<string, string> $isoList
  333.      *
  334.      * @return array<string, array<string, array<string, array<string, string|null>>>>
  335.      */
  336.     private function fillBlankSnippets(array $fileSnippets, array $isoList): array
  337.     {
  338.         foreach ($isoList as $setId => $_iso) {
  339.             foreach ($isoList as $currentSetId => $_currentIso) {
  340.                 if ($setId === $currentSetId) {
  341.                     continue;
  342.                 }
  343.                 foreach ($fileSnippets[$setId]['snippets'] as $index => $_snippet) {
  344.                     if (!isset($fileSnippets[$currentSetId]['snippets'][$index])) {
  345.                         $fileSnippets[$currentSetId]['snippets'][$index] = [
  346.                             'value' => '',
  347.                             'translationKey' => $index,
  348.                             'author' => '',
  349.                             'origin' => '',
  350.                             'resetTo' => '',
  351.                             'setId' => $currentSetId,
  352.                             'id' => null,
  353.                         ];
  354.                     }
  355.                 }
  356.                 ksort($fileSnippets[$currentSetId]['snippets']);
  357.             }
  358.         }
  359.         return $fileSnippets;
  360.     }
  361.     /**
  362.      * @param array<string, array<int, AbstractSnippetFile>> $languageFiles
  363.      * @param array<string, string> $isoList
  364.      *
  365.      * @return array<string, array<string, array<string, array<string, string|null>>>>
  366.      */
  367.     private function getFileSnippets(array $languageFiles, array $isoList): array
  368.     {
  369.         $fileSnippets = [];
  370.         foreach ($isoList as $setId => $iso) {
  371.             $fileSnippets[$setId]['snippets'] = $this->getSnippetsFromFiles($languageFiles[$iso], $setId);
  372.         }
  373.         return $fileSnippets;
  374.     }
  375.     /**
  376.      * @param array<string, array{iso: string, id: string}> $metaData
  377.      *
  378.      * @return array<string, string>
  379.      */
  380.     private function createIsoList(array $metaData): array
  381.     {
  382.         $isoList = [];
  383.         foreach ($metaData as $set) {
  384.             $isoList[$set['id']] = $set['iso'];
  385.         }
  386.         return $isoList;
  387.     }
  388.     /**
  389.      * @return array<string, array<mixed>>
  390.      */
  391.     private function getSetMetaData(Context $context): array
  392.     {
  393.         $queryResult $this->findSnippetSetInDatabase(new Criteria(), $context);
  394.         /** @var array<string, array{iso: string, id: string}> $result */
  395.         $result = [];
  396.         /** @var SnippetSetEntity $value */
  397.         foreach ($queryResult as $key => $value) {
  398.             $result[$key] = $value->jsonSerialize();
  399.         }
  400.         return $result;
  401.     }
  402.     /**
  403.      * @param array<string, Entity> $queryResult
  404.      * @param array<string, array<string, array<string, array<string, string|null>>>> $fileSnippets
  405.      *
  406.      * @return array<string, array<string, array<string, array<string, string|null>>>>
  407.      */
  408.     private function databaseSnippetsToArray(array $queryResult, array $fileSnippets): array
  409.     {
  410.         $result = [];
  411.         /** @var SnippetEntity $snippet */
  412.         foreach ($queryResult as $snippet) {
  413.             $currentSnippet array_intersect_key(
  414.                 $snippet->jsonSerialize(),
  415.                 array_flip([
  416.                     'author',
  417.                     'id',
  418.                     'setId',
  419.                     'translationKey',
  420.                     'value',
  421.                 ])
  422.             );
  423.             $currentSnippet['origin'] = '';
  424.             $currentSnippet['resetTo'] = $fileSnippets[$snippet->getSetId()]['snippets'][$snippet->getTranslationKey()]['origin'] ?? $snippet->getValue();
  425.             $result[$snippet->getSetId()]['snippets'][$snippet->getTranslationKey()] = $currentSnippet;
  426.         }
  427.         return $result;
  428.     }
  429.     /**
  430.      * @return array<string, Entity>
  431.      */
  432.     private function findSnippetInDatabase(Criteria $criteriaContext $context): array
  433.     {
  434.         return $this->snippetRepository->search($criteria$context)->getEntities()->getElements();
  435.     }
  436.     /**
  437.      * @return array<string, Entity>
  438.      */
  439.     private function findSnippetSetInDatabase(Criteria $criteriaContext $context): array
  440.     {
  441.         return $this->snippetSetRepository->search($criteria$context)->getEntities()->getElements();
  442.     }
  443.     /**
  444.      * @param array<string, string> $sort
  445.      * @param array<string, array<string, array<string, array<string, string|null>>>> $snippets
  446.      *
  447.      * @return array<string, array<string, array<string, array<string, string|null>>>>
  448.      */
  449.     private function sortSnippets(array $sort, array $snippets): array
  450.     {
  451.         if (!isset($sort['sortBy'], $sort['sortDirection'])) {
  452.             return $snippets;
  453.         }
  454.         if ($sort['sortBy'] === 'translationKey' || $sort['sortBy'] === 'id') {
  455.             foreach ($snippets as &$set) {
  456.                 if ($sort['sortDirection'] === 'ASC') {
  457.                     ksort($set['snippets']);
  458.                 } elseif ($sort['sortDirection'] === 'DESC') {
  459.                     krsort($set['snippets']);
  460.                 }
  461.             }
  462.             return $snippets;
  463.         }
  464.         if (!isset($snippets[$sort['sortBy']])) {
  465.             return $snippets;
  466.         }
  467.         $mainSet $snippets[$sort['sortBy']];
  468.         unset($snippets[$sort['sortBy']]);
  469.         uasort($mainSet['snippets'], static function ($a$b) use ($sort) {
  470.             $a mb_strtolower((string) $a['value']);
  471.             $b mb_strtolower((string) $b['value']);
  472.             return $sort['sortDirection'] !== 'DESC' $a <=> $b $b <=> $a;
  473.         });
  474.         $result = [$sort['sortBy'] => $mainSet];
  475.         foreach ($snippets as $setId => $set) {
  476.             foreach ($mainSet['snippets'] as $currentKey => $_value) {
  477.                 $result[$setId]['snippets'][$currentKey] = $set['snippets'][$currentKey];
  478.             }
  479.         }
  480.         return $result;
  481.     }
  482.     /**
  483.      * @param array<string, string|array<string, mixed>> $array
  484.      * @param array<string, string|null>|null $additionalParameters
  485.      *
  486.      * @return array<string, string|array<string, mixed>>
  487.      */
  488.     private function flatten(array $arraystring $prefix '', ?array $additionalParameters null): array
  489.     {
  490.         $result = [];
  491.         foreach ($array as $index => $value) {
  492.             $newIndex $prefix . (empty($prefix) ? '' '.') . $index;
  493.             if (\is_array($value)) {
  494.                 $result = [...$result, ...$this->flatten($value$newIndex$additionalParameters)];
  495.             } else {
  496.                 if (!empty($additionalParameters)) {
  497.                     $result[$newIndex] = array_merge([
  498.                         'value' => $value,
  499.                         'origin' => $value,
  500.                         'resetTo' => $value,
  501.                         'translationKey' => $newIndex,
  502.                     ], $additionalParameters);
  503.                     continue;
  504.                 }
  505.                 $result[$newIndex] = $value;
  506.             }
  507.         }
  508.         return $result;
  509.     }
  510. }