<?php
declare(strict_types=1);
namespace FourtwosixReferenceGallery;
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Seo\Event\SeoUrlUpdateEvent;
use Shopware\Core\Content\Seo\SeoUrlPersister;
use Shopware\Core\Defaults;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\MultiInsertQueryQueue;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableTransaction;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\IdSearchResult;
use Shopware\Core\Framework\Plugin;
use Shopware\Core\Framework\Plugin\Context\ActivateContext;
use Shopware\Core\Framework\Plugin\Context\DeactivateContext;
use Shopware\Core\Framework\Plugin\Context\InstallContext;
use Shopware\Core\Framework\Plugin\Context\UninstallContext;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\CustomField\CustomFieldTypes;
class FourtwosixReferenceGallery extends Plugin
{
private const TEXT_COMPONENT_NAME = 'sw-text-field';
private const EDITOR_COMPONENT_NAME = 'sw-text-editor';
private const SELECT_COMPONENT_NAME = 'sw-single-select';
private const ENTITY_COMPONENT_NAME = 'sw-entity-single-select';
public const ATTRIBUTE_SET_NAME = "fts_media_information";
public const ATTRIBUTE_SET_LABEL_EN = "Media Information (426)";
public const ATTRIBUTE_SET_LABEL_DE = "Media Informazionen (426)";
public const REFERENCE_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_reference";
public const REFERENCE_ATTRIBUTE_LABEL_EN = "Reference (*required)";
public const REFERENCE_ATTRIBUTE_LABEL_DE = "Referenz (*erforderlich)";
public const TITLE_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_title";
public const TITLE_ATTRIBUTE_LABEL_EN = "Title (*required)";
public const TITLE_ATTRIBUTE_LABEL_DE = "Titel (*erforderlich)";
public const DESCRIPTION_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_description";
public const DESCRIPTION_ATTRIBUTE_LABEL_EN = "Description";
public const DESCRIPTION_ATTRIBUTE_LABEL_DE = "Beschreibung";
public const ALT_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_alt";
public const ALT_ATTRIBUTE_LABEL_EN = "Alt";
public const ALT_ATTRIBUTE_LABEL_DE = "Alt";
public const PRODUCT_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_product";
public const PRODUCT_ATTRIBUTE_LABEL_EN = "Product";
public const PRODUCT_ATTRIBUTE_LABEL_DE = "Produkt";
public const POSITIONING_ATTRIBUTE_NAME = self::ATTRIBUTE_SET_NAME . "_positioning";
public const POSITIONING_ATTRIBUTE_LABEL_EN = "Positioning";
public const POSITIONING_ATTRIBUTE_LABEL_DE = "Posizionierung";
public const LIMIT = 12;
public const GALLERY_CATEGORY_LABELS = [
"de" => [
"categories" => [
"private" => "privat",
"hotel" => "hotel",
"company" => "unternehmen"
],
"locale" => "de-DE",
"path" => "/geschaeft/referenzen/referenzen-projekte/",
],
"en" => [
"categories" => [
"private" => "private",
"hotel" => "hotel",
"company" => "company",
],
"locale" => "en-GB",
"path" => "/shop/references/references-projects/",
],
"it" => [
"categories" => [
"private" => "privati",
"hotel" => "albergo",
"company" => "azienda",
],
"locale" => "it-IT",
"path" => "/negozio/riferimento/riferimento-progetti/",
],
];
public const GALLERY_CONTROLLER_ROUTE = 'frontend.reference.gallery';
private $connection;
public function deactivate(DeactivateContext $deactivateContext): void
{
$this->removeGallerySeoURLs();
}
public function activate(ActivateContext $activateContext): void
{
$this->createGallerySeoURLs($activateContext->getContext());
}
public function install(InstallContext $installContext): void
{
$this->createCustomFields($installContext->getContext());
}
public function uninstall(UninstallContext $uninstallContext): void
{
if ($uninstallContext->keepUserData()) {
return;
}
$this->deleteCustomFields($uninstallContext->getContext());
}
private function createCustomFields(Context $context): void
{
$customFieldSetRepository = $this->container->get('custom_field_set.repository');
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('name', self::ATTRIBUTE_SET_NAME));
if ($customFieldSetRepository->search($criteria, $context)->first()) {
return;
}
$customFieldData = $this->getCustomFieldData();
$customFieldSetRepository->upsert([$customFieldData], $context);
}
private function deleteCustomFields(Context $context): void
{
$customFieldSetRepository = $this->container->get('custom_field_set.repository');
if (!$customFields = $this->getCustomFields($customFieldSetRepository, $context)) {
return;
}
$customFieldSetRepository->delete(array_values($customFields->getData()), $context);
}
private function getCustomFields(EntityRepository &$customFieldSetRepository, Context &$context): ?IdSearchResult
{
$criteria = new Criteria();
$criteria->addFilter(new EqualsAnyFilter('name', [self::ATTRIBUTE_SET_NAME]));
$customFields = $customFieldSetRepository->searchIds($criteria, $context);
return $customFields->getTotal() > 0 ? $customFields : null;
}
private function getCustomFieldData(): array
{
return [
'name' => self::ATTRIBUTE_SET_NAME,
'config' => [
'label' => [
'en-GB' => self::ATTRIBUTE_SET_LABEL_EN,
'de-DE' => self::ATTRIBUTE_SET_LABEL_DE,
],
],
'customFields' => [
$this->getReferenceCustomField(),
$this->getTitleCustomField(),
$this->getDescriptionCustomField(),
$this->getAltCustomField(),
$this->getProductCustomField(),
$this->getPositioningCustomField(),
],
'relations' => [
[
'entityName' => 'media',
],
],
];
}
private function getReferenceCustomField(): array
{
return $this->getCustomField(
self::REFERENCE_ATTRIBUTE_NAME,
CustomFieldTypes::SELECT,
[
'en-GB' => self::REFERENCE_ATTRIBUTE_LABEL_EN,
'de-DE' => self::REFERENCE_ATTRIBUTE_LABEL_DE,
],
self::SELECT_COMPONENT_NAME,
1,
[
'options' => [
[
'label' => [
'en-GB' => 'Private',
'de-DE' => 'Privat',
],
'value' => 'private',
],
[
'label' => [
'en-GB' => 'Hotel',
'de-DE' => 'Hotel',
],
'value' => 'hotel',
],
[
'label' => [
'en-GB' => 'Company',
'de-DE' => 'Unternehmen',
],
'value' => 'company',
],
],
]
);
}
private function getTitleCustomField(): array
{
return $this->getCustomField(
self::TITLE_ATTRIBUTE_NAME,
CustomFieldTypes::TEXT,
[
'en-GB' => self::TITLE_ATTRIBUTE_LABEL_EN,
'de-DE' => self::TITLE_ATTRIBUTE_LABEL_DE,
],
self::TEXT_COMPONENT_NAME,
2
);
}
private function getDescriptionCustomField(): array
{
return $this->getCustomField(
self::DESCRIPTION_ATTRIBUTE_NAME,
CustomFieldTypes::TEXT,
[
'en-GB' => self::DESCRIPTION_ATTRIBUTE_LABEL_EN,
'de-DE' => self::DESCRIPTION_ATTRIBUTE_LABEL_DE,
],
self::EDITOR_COMPONENT_NAME,
3
);
}
private function getAltCustomField(): array
{
return $this->getCustomField(
self::ALT_ATTRIBUTE_NAME,
CustomFieldTypes::TEXT,
[
'en-GB' => self::ALT_ATTRIBUTE_LABEL_EN,
'de-DE' => self::ALT_ATTRIBUTE_LABEL_DE,
],
self::TEXT_COMPONENT_NAME,
4
);
}
private function getProductCustomField(): array
{
return $this->getCustomField(
self::PRODUCT_ATTRIBUTE_NAME,
CustomFieldTypes::ENTITY,
[
'en-GB' => self::PRODUCT_ATTRIBUTE_LABEL_EN,
'de-DE' => self::PRODUCT_ATTRIBUTE_LABEL_DE,
],
self::ENTITY_COMPONENT_NAME,
5,
[
'entity' => 'product'
]
);
}
private function getPositioningCustomField(): array
{
return $this->getCustomField(
self::POSITIONING_ATTRIBUTE_NAME,
CustomFieldTypes::SELECT,
[
'en-GB' => self::POSITIONING_ATTRIBUTE_LABEL_EN,
'de-DE' => self::POSITIONING_ATTRIBUTE_LABEL_DE,
],
self::SELECT_COMPONENT_NAME,
6,
[
'options' => [
[
'label' => [
'en-GB' => 'Center',
'de-DE' => 'Mitte',
],
'value' => 'center',
],
[
'label' => [
'en-GB' => 'Top',
'de-DE' => 'Oben',
],
'value' => 'top',
],
[
'label' => [
'en-GB' => 'Right',
'de-DE' => 'Rechts',
],
'value' => 'right',
],
[
'label' => [
'en-GB' => 'Left',
'de-DE' => 'Links',
],
'value' => 'left',
],
[
'label' => [
'en-GB' => 'Bottom',
'de-DE' => 'Unten',
],
'value' => 'bottom',
],
],
]
);
}
/**
* @param string $name
* @param string $type
* @param array<string> $label
* @param string $component
* @param int $position
* @param ?array<array<string>> $optional
*/
private function getCustomField(
string $name,
string $type,
array $label,
string $component,
int $position,
?array $optional = null
): array {
$data = [
'name' => $name,
'type' => $type,
'config' => [
'label' => $label,
"componentName" => $component,
"customFieldType" => $type,
'customFieldPosition' => $position,
],
];
if ($optional) {
$data['config'] = array_merge($data['config'], $optional);
}
return $data;
}
/********************************
* CREATE / DEACTIVATE SEO URLS
********************************/
private function createGallerySeoURLs(Context $context)
{
$urls = [];
$salesChannelId = $this->getStorefrontSalesChannelId($context);
if (!$salesChannelId) {
// Might want to throw an error here
return;
}
$connection = \Shopware\Core\Kernel::getConnection();
$connection->executeStatement(
'
DELETE FROM `seo_url` WHERE `route_name` = :galleryRoute',
['galleryRoute' => self::GALLERY_CONTROLLER_ROUTE]
);
foreach (self::GALLERY_CATEGORY_LABELS as $entry) {
$foreignKeys = [];
$urls = [];
$languageId = $connection->fetchOne('
SELECT LOWER(HEX(`language`.id)) as id
FROM `language`
INNER JOIN locale
ON `language`.`locale_id` = `locale`.`id`
AND locale.code = :locale
', ['locale' => $entry['locale']]);
if ($languageId) {
foreach ($entry['categories'] as $category => $categoryTranslated) {
$foreignKey = Uuid::randomHex();
$foreignKeys[] = $foreignKey;
$urls[] = [
'salesChannelId' => $salesChannelId,
'foreignKey' => $foreignKey,
'languageId' => $languageId,
// The name of the route in the respective controller
'routeName' => self::GALLERY_CONTROLLER_ROUTE,
// The technical path of your custom route, using a given parameter
'pathInfo' => "/referenceListing/$category",
'isCanonical' => 1,
// The SEO URL that you want to use here, in this case just the name
'seoPathInfo' => $entry['path'] . $categoryTranslated,
];
}
$languageContext = new Context(
$context->getSource(),
$context->getRuleIds(),
$context->getCurrencyId(),
[$languageId],
$context->getVersionId(),
$context->getCurrencyFactor(),
false,
$context->getTaxState(),
$context->getRounding()
);
$this->updateSeoUrls(
$languageContext,
self::GALLERY_CONTROLLER_ROUTE,
array_values($foreignKeys),
$urls,
$connection
);
}
}
}
private function removeGallerySeoURLs()
{
$connection = \Shopware\Core\Kernel::getConnection();
$connection->executeStatement(
'
UPDATE `seo_url` SET `is_deleted` = 1 WHERE `route_name` = :galleryRoute',
['galleryRoute' => self::GALLERY_CONTROLLER_ROUTE]
);
}
private function getStorefrontSalesChannelId(Context $context): ?string
{
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('typeId', Defaults::SALES_CHANNEL_TYPE_STOREFRONT));
$criteria->addFilter(new EqualsFilter('active', 1));
$salesChannelRepository = $this->container->get('sales_channel.repository');
return $salesChannelRepository->searchIds($criteria, $context)->firstId();
}
private function updateSeoUrls(
Context $context,
string $routeName,
array $foreignKeys,
iterable $seoUrls,
Connection $connection
): void {
$languageId = $context->getLanguageId();
$canonicals = $this->findCanonicalPaths($routeName, $languageId, $foreignKeys, $connection);
$dateTime = (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT);
$insertQuery = new MultiInsertQueryQueue($connection, 250, false, true);
$updatedFks = [];
$obsoleted = [];
$processed = [];
// should be provided
$salesChannelId = null;
$updates = [];
foreach ($seoUrls as $seoUrl) {
if ($seoUrl instanceof \JsonSerializable) {
$seoUrl = $seoUrl->jsonSerialize();
}
$updates[] = $seoUrl;
$fk = $seoUrl['foreignKey'];
/** @var string|null $salesChannelId */
$salesChannelId = $seoUrl['salesChannelId'] = $seoUrl['salesChannelId'] ?? null;
// skip duplicates
if (isset($processed[$fk][$salesChannelId])) {
continue;
}
if (!isset($processed[$fk])) {
$processed[$fk] = [];
}
$processed[$fk][$salesChannelId] = true;
$updatedFks[] = $fk;
if (isset($seoUrl['error'])) {
continue;
}
$existing = $canonicals[$fk][$salesChannelId] ?? null;
if ($existing) {
// entity has override or does not change
/** @var array{isModified: bool, seoPathInfo: string, salesChannelId: string} $seoUrl */
if ($this->skipUpdate($existing, $seoUrl)) {
continue;
}
$obsoleted[] = $existing['id'];
}
$insert = [];
$insert['id'] = Uuid::randomBytes();
if ($salesChannelId) {
$insert['sales_channel_id'] = Uuid::fromHexToBytes($salesChannelId);
}
$insert['language_id'] = Uuid::fromHexToBytes($languageId);
$insert['foreign_key'] = Uuid::fromHexToBytes($fk);
$insert['path_info'] = $seoUrl['pathInfo'];
$insert['seo_path_info'] = ltrim($seoUrl['seoPathInfo'], '/');
$insert['route_name'] = $routeName;
$insert['is_canonical'] = ($seoUrl['isCanonical'] ?? true) ? 1 : null;
$insert['is_modified'] = ($seoUrl['isModified'] ?? false) ? 1 : 0;
$insert['is_deleted'] = ($seoUrl['isDeleted'] ?? true) ? 1 : 0;
$insert['created_at'] = $dateTime;
$seoUrlRepository = $this->container->get('seo_url.repository');
$insertQuery->addInsert($seoUrlRepository->getDefinition()->getEntityName(), $insert);
}
$this->connection = $connection;
RetryableTransaction::retryable(
$connection,
function () use ($obsoleted, $dateTime, $insertQuery, $foreignKeys, $updatedFks, $salesChannelId): void {
$this->obsoleteIds($obsoleted, $salesChannelId, $this->connection);
$insertQuery->execute();
$deletedIds = array_diff($foreignKeys, $updatedFks);
$notDeletedIds = array_unique(array_intersect($foreignKeys, $updatedFks));
$this->markAsDeleted(true, $deletedIds, $dateTime, $salesChannelId, $this->connection);
$this->markAsDeleted(false, $notDeletedIds, $dateTime, $salesChannelId, $this->connection);
}
);
$this->container->get('event_dispatcher')->dispatch(new SeoUrlUpdateEvent($updates));
}
/**
* @param list<string> $foreignKeys
*
* @return array<string, mixed>
*/
private function findCanonicalPaths(
string $routeName,
string $languageId,
array $foreignKeys,
Connection $connection
): array {
$fks = Uuid::fromHexToBytesList($foreignKeys);
$languageId = Uuid::fromHexToBytes($languageId);
$query = $connection->createQueryBuilder();
$query->select([
'LOWER(HEX(seo_url.id)) as id',
'LOWER(HEX(seo_url.foreign_key)) foreignKey',
'LOWER(HEX(seo_url.sales_channel_id)) salesChannelId',
'seo_url.is_modified as isModified',
'seo_url.seo_path_info seoPathInfo',
]);
$query->from('seo_url', 'seo_url');
$query->andWhere('seo_url.route_name = :routeName');
$query->andWhere('seo_url.language_id = :language_id');
$query->andWhere('seo_url.is_canonical = 1');
$query->andWhere('seo_url.foreign_key IN (:foreign_keys)');
$query->setParameter('routeName', $routeName);
$query->setParameter('language_id', $languageId);
$query->setParameter('foreign_keys', $fks, Connection::PARAM_STR_ARRAY);
$rows = $query->executeQuery()->fetchAllAssociative();
$canonicals = [];
foreach ($rows as $row) {
$row['isModified'] = (bool) $row['isModified'];
$foreignKey = (string) $row['foreignKey'];
if (!isset($canonicals[$foreignKey])) {
$canonicals[$foreignKey] = [$row['salesChannelId'] => $row];
continue;
}
$canonicals[$foreignKey][$row['salesChannelId']] = $row;
}
return $canonicals;
}
/**
* @param array{isModified: bool, seoPathInfo: string, salesChannelId: string} $existing
* @param array{isModified: bool, seoPathInfo: string, salesChannelId: string} $seoUrl
*/
private function skipUpdate(array $existing, array $seoUrl): bool
{
if ($existing['isModified'] && !($seoUrl['isModified'] ?? false) && trim($seoUrl['seoPathInfo']) !== '') {
return true;
}
return $seoUrl['seoPathInfo'] === $existing['seoPathInfo']
&& $seoUrl['salesChannelId'] === $existing['salesChannelId'];
}
/**
* @internal (flag:FEATURE_NEXT_13410) Parameter $salesChannelId will be required
*
* @param list<string> $ids
*/
private function obsoleteIds(array $ids, ?string $salesChannelId, Connection $connection): void
{
if (empty($ids)) {
return;
}
$ids = Uuid::fromHexToBytesList($ids);
$query = $connection->createQueryBuilder()
->update('seo_url')
->set('is_canonical', 'NULL')
->where('id IN (:ids)')
->setParameter('ids', $ids, Connection::PARAM_STR_ARRAY);
if ($salesChannelId) {
$query->andWhere('sales_channel_id = :salesChannelId');
$query->setParameter('salesChannelId', Uuid::fromHexToBytes($salesChannelId));
}
RetryableQuery::retryable($connection, function () use ($query): void {
$query->execute();
});
}
/**
* @internal (flag:FEATURE_NEXT_13410) Parameter $salesChannelId will be required
*
* @param list<string> $ids
*/
private function markAsDeleted(
bool $deleted,
array $ids,
string $dateTime,
?string $salesChannelId,
Connection $connection
): void {
if (empty($ids)) {
return;
}
$ids = Uuid::fromHexToBytesList($ids);
$query = $connection->createQueryBuilder()
->update('seo_url')
->set('is_deleted', $deleted ? '1' : '0')
->where('foreign_key IN (:fks)')
->setParameter('fks', $ids, Connection::PARAM_STR_ARRAY);
if ($salesChannelId) {
$query->andWhere('sales_channel_id = :salesChannelId');
$query->setParameter('salesChannelId', Uuid::fromHexToBytes($salesChannelId));
}
$query->execute();
}
}