diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 000000000..4d40e1c4f --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,24 @@ +# From Bolt 5.2 + +## Drop knplabs/doctrine-behaviors dependency + +The `knplabs/doctrine-behaviors` package has been removed from the Bolt core. + +The translation behavior has been integrated. + +Namespace changes: + +| Old class | New class | +|----------------------------------------------------------------------|------------------------------------------------------| +| Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface | Bolt\Entity\TranslatableInterface | +| Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface | Bolt\Entity\TranslationInterface | +| Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait | Use two trait below | +| Knp\DoctrineBehaviors\Model\Translatable\TranslatableMethodsTrait | Bolt\Entity\Translatable\TranslatableMethodsTrait | +| Knp\DoctrineBehaviors\Model\Translatable\TranslatablePropertiesTrait | Bolt\Entity\Translatable\TranslatablePropertiesTrait | +| Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait | Use two trait below | +| Knp\DoctrineBehaviors\Model\Translatable\TranslationMethodsTrait | Bolt\Entity\Translatable\TranslationMethodsTrait | +| Knp\DoctrineBehaviors\Model\Translatable\TranslationPropertiesTrait | Bolt\Entity\Translatable\TranslationPropertiesTrait | +| Knp\DoctrineBehaviors\EventSubscriber\TranslatableEventSubscriber | Bolt\Event\Listener\TranslatableListener | +| Knp\DoctrineBehaviors\Exception\TranslatableException | Bolt\Exception\TranslatableException | +| Knp\DoctrineBehaviors\Provider\LocaleProvider | Bolt\Locale\LocaleProvider | +| Knp\DoctrineBehaviors\Contract\Provider\LocaleProviderInterface | Bolt\Locale\LocaleProviderInterface | diff --git a/composer.json b/composer.json index cf0edab74..e85a57e4a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,6 @@ "erusev/parsedown-extra": "^0.8.1", "fakerphp/faker": "^1.16", "jasny/twig-extensions": "^1.3", - "knplabs/doctrine-behaviors": "^2.1", "knplabs/knp-menu-bundle": "^3.1", "league/glide-symfony": "^1.0.4", "miljar/php-exif": "^0.6.4", diff --git a/config/bundles.php b/config/bundles.php index 8194a8122..890afa343 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -9,7 +9,6 @@ Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Http\HttplugBundle\HttplugBundle::class => ['dev' => true, 'local' => true], Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], - Knp\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true], Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['all' => true], diff --git a/config/services.yaml b/config/services.yaml index 25def9489..a393a2a12 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -13,6 +13,8 @@ parameters: # foo_manager: foo_ bolt.backend_url: /bolt bolt.remember_lifetime: 2592000 # 30 days in seconds + bolt.doctrine_behaviors_translatable_fetch_mode: LAZY + bolt.doctrine_behaviors_translation_fetch_mode: LAZY services: # default configuration for services in *this* file @@ -31,6 +33,8 @@ services: $tablePrefix: '%bolt.table_prefix%' $backendUrl: '%bolt.backend_url%' $rememberLifetime: '%bolt.remember_lifetime%' + $translatableFetchMode: '%bolt.doctrine_behaviors_translatable_fetch_mode%' + $translationFetchMode: '%bolt.doctrine_behaviors_translation_fetch_mode%' _instanceof: Bolt\Menu\ExtensionBackendMenuInterface: @@ -78,6 +82,12 @@ services: Bolt\Event\Listener\UserAvatarLoadListener: tags: - { name: doctrine.event_listener, event: postLoad } + + Bolt\Event\Listener\TranslatableListener: + tags: + - { name: doctrine.event_listener, event: loadClassMetadata } + - { name: doctrine.event_listener, event: postLoad } + - { name: doctrine.event_listener, event: prePersist } Bolt\Extension\RoutesLoader: tags: [routing.loader] diff --git a/phpstan.neon b/phpstan.neon index 92f7f00bb..415c1b9ba 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -16,11 +16,6 @@ parameters: message: '#Unreachable statement - code above always terminates#' path: %currentWorkingDirectory%/src/* - # false positive: `TranslationInterface does not know about FieldTranslation::getValue().` Skip this error. - - - message: '#Call to an undefined method Knp\\DoctrineBehaviors\\Contract\\Entity\\TranslationInterface#' - path: %currentWorkingDirectory%/src/* - # false positive: Parameters in Storage\Directive\OrderDirective::orderByNumericField() aren't seen as integers - message: '#of method Doctrine\\ORM\\Query\\Expr::substring\(\) expects int#' diff --git a/src/DataFixtures/ContentFixtures.php b/src/DataFixtures/ContentFixtures.php index 859b8a86f..59be62710 100644 --- a/src/DataFixtures/ContentFixtures.php +++ b/src/DataFixtures/ContentFixtures.php @@ -11,6 +11,7 @@ use Bolt\Entity\Content; use Bolt\Entity\Field; use Bolt\Entity\Field\SelectField; +use Bolt\Entity\FieldTranslation; use Bolt\Enum\Statuses; use Bolt\Repository\FieldRepository; use Bolt\Twig\ContentExtension; @@ -49,12 +50,17 @@ class ContentFixtures extends BaseFixture implements DependentFixtureInterface, /** @var ContentExtension */ private $contentExtension; - public function __construct(Config $config, FileLocations $fileLocations, TagAwareCacheInterface $cache, string $defaultLocale, ContentExtension $contentExtension) - { + public function __construct( + Config $config, + FileLocations $fileLocations, + TagAwareCacheInterface $cache, + string $defaultLocale, + ContentExtension $contentExtension + ) { $this->config = $config; $this->faker = Factory::create(); $seed = $this->config->get('general/fixtures_seed'); - if (! empty($seed)) { + if (!empty($seed)) { $this->faker->seed($seed); } @@ -103,7 +109,7 @@ private function loadContent(ObjectManager $manager): void continue; } - $amount = $contentType['singleton'] ? 1 : (int) ($contentType['listing_records'] * 3); + $amount = $contentType['singleton'] ? 1 : (int)($contentType['listing_records'] * 3); for ($i = 1; $i <= $amount; $i++) { if ($i === 1) { @@ -124,7 +130,7 @@ private function loadContent(ObjectManager $manager): void $preset = $this->getPreset($contentType['slug']); - if ($i === 1 || ! empty($preset)) { + if ($i === 1 || !empty($preset)) { $content->setStatus($preset['status'] ?? Statuses::PUBLISHED); } else { $content->setStatus($this->getRandomStatus()); @@ -180,8 +186,13 @@ private function loadContent(ObjectManager $manager): void } } - private function loadCollectionField(Content $content, Field $field, $fieldType, ContentType $contentType, array $preset): Field - { + private function loadCollectionField( + Content $content, + Field $field, + $fieldType, + ContentType $contentType, + array $preset + ): Field { $collectionItems = $field->getDefinition()->get('fields'); $i = 0; @@ -211,8 +222,14 @@ private function loadSetField(Content $content, Field $set, ContentType $content return $set; } - private function loadField(Content $content, string $name, $fieldType, ContentType $contentType, array $preset, bool $addToContent = true): Field - { + private function loadField( + Content $content, + string $name, + $fieldType, + ContentType $contentType, + array $preset, + bool $addToContent = true + ): Field { $sortorder = 1; $field = FieldRepository::factory($fieldType, $name); @@ -242,7 +259,8 @@ private function loadField(Content $content, string $name, $fieldType, ContentTy $locales = $contentType['locales']->toArray(); foreach ($locales as $locale) { if ($locale !== $this->defaultLocale && array_search($locale, $locales, true) !== count($locales) - 1) { - $value = $preset[$name] ?? $this->getValuesforFieldType($fieldType, $contentType['singleton'], $content); + $value = $preset[$name] ?? $this->getValuesforFieldType($fieldType, $contentType['singleton'], + $content); $field->translate($locale, false)->setValue($value); } } @@ -273,19 +291,19 @@ private function getFixtureFormatValues(string $format): array { return [ preg_replace_callback( - '/{([\w]+)}/i', - function ($match) { - $match = $match[1]; + '/{([\w]+)}/i', + function ($match) { + $match = $match[1]; - try { - return $this->faker->{$match}; - } finally { - } + try { + return $this->faker->{$match}; + } finally { + } - return '(unknown)'; - }, - $format - ), + return '(unknown)'; + }, + $format + ), ]; } @@ -361,7 +379,7 @@ private function getFieldTypeValue(DeepCollection $field, bool $singleton, Conte break; case 'number': - $data = [(string) $this->faker->numberBetween(-100, 1000)]; + $data = [(string)$this->faker->numberBetween(-100, 1000)]; break; case 'checkbox': @@ -411,7 +429,7 @@ private function getFieldTypeValue(DeepCollection $field, bool $singleton, Conte $data = ['selected' => 'latlong', 'zoom' => '7', 'search' => '']; $coordinates = $this->faker->localCoordinates(); $data['lat'] = $coordinates['latitude']; - $data['long'] =$coordinates['longitude']; + $data['long'] = $coordinates['longitude']; $data = [json_encode($data)]; @@ -534,7 +552,7 @@ private function getPresetRecords(): array private function getPreset(string $slug): array { - if (isset($this->presetRecords[$slug]) && ! empty($this->presetRecords[$slug]) && ! $this->getOption('--append')) { + if (isset($this->presetRecords[$slug]) && !empty($this->presetRecords[$slug]) && !$this->getOption('--append')) { $preset = array_shift($this->presetRecords[$slug]); } else { $preset = []; @@ -552,14 +570,14 @@ private function setSelectFieldsMappedWithContent(ObjectManager $manager): void $contentDefaultLocale = $content->getDefaultLocale(); $contentLocales = $content->getLocales(); foreach ($content->getFields() as $field) { - if (! $this->isSelectFieldAndMappedWithContent($field)) { + if (!$this->isSelectFieldAndMappedWithContent($field)) { continue; } /** @var SelectField $field */ $contentType = $field->getContentType(); - if (! \is_string($contentType)) { + if (!\is_string($contentType)) { continue; } @@ -590,14 +608,14 @@ private function setSelectFieldsMappedWithContent(ObjectManager $manager): void private function isSelectFieldAndMappedWithContent(Field $field): bool { - if (! $field instanceof SelectField) { - return FALSE; + if (!$field instanceof SelectField) { + return false; } - if (! $field->isContentSelect()) { - return FALSE; + if (!$field->isContentSelect()) { + return false; } - return TRUE; + return true; } } diff --git a/src/Entity/Field.php b/src/Entity/Field.php index d6f99797a..f7167c8f2 100644 --- a/src/Entity/Field.php +++ b/src/Entity/Field.php @@ -9,11 +9,11 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter; use Bolt\Common\Arr; use Bolt\Configuration\Content\FieldType; +use Bolt\Entity\Translatable\TranslatableMethodsTrait; +use Bolt\Entity\Translatable\TranslatablePropertiesTrait; use Bolt\Event\Listener\FieldFillListener; use Bolt\Utils\Sanitiser; use Doctrine\ORM\Mapping as ORM; -use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface; -use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\SerializedName; use Tightenco\Collect\Support\Collection; @@ -53,10 +53,13 @@ * @ORM\InheritanceType("SINGLE_TABLE") * @ORM\DiscriminatorColumn(name="type", type="string", length=191) * @ORM\DiscriminatorMap({"generic" = "Field"}) + * + * @method FieldTranslation translate(?string $locale = null, bool $fallbackToDefault = true) */ class Field implements FieldInterface, TranslatableInterface { - use TranslatableTrait; + use TranslatablePropertiesTrait; + use TranslatableMethodsTrait; public const TYPE = 'generic'; diff --git a/src/Entity/FieldTranslation.php b/src/Entity/FieldTranslation.php index 5e1a2442c..8fcae37da 100644 --- a/src/Entity/FieldTranslation.php +++ b/src/Entity/FieldTranslation.php @@ -4,15 +4,17 @@ namespace Bolt\Entity; +use Bolt\Entity\Translatable\TranslationMethodsTrait; +use Bolt\Entity\Translatable\TranslationPropertiesTrait; use Doctrine\ORM\Mapping as ORM; -use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait; /** * @ORM\Entity */ class FieldTranslation implements TranslationInterface { - use TranslationTrait; + use TranslationPropertiesTrait; + use TranslationMethodsTrait; /** * @ORM\Id() @@ -59,6 +61,6 @@ public function set(string $key, $value): self */ public static function getTranslatableEntityClass(): string { - return 'Field'; + return Field::class; } } diff --git a/src/Entity/Translatable/TranslatableMethodsTrait.php b/src/Entity/Translatable/TranslatableMethodsTrait.php new file mode 100644 index 000000000..e537d537d --- /dev/null +++ b/src/Entity/Translatable/TranslatableMethodsTrait.php @@ -0,0 +1,244 @@ + + */ + public function getTranslations(): Collection + { + // initialize collection, usually in ctor + if ($this->translations === null) { + $this->translations = new ArrayCollection(); + } + + return $this->translations; + } + + /** + * @param Collection $translations + * @phpstan-param iterable $translations + */ + public function setTranslations(iterable $translations): void + { + $this->ensureIsIterableOrCollection($translations); + + foreach ($translations as $translation) { + $this->addTranslation($translation); + } + } + + /** + * @return Collection + */ + public function getNewTranslations(): Collection + { + // initialize collection, usually in ctor + if ($this->newTranslations === null) { + $this->newTranslations = new ArrayCollection(); + } + + return $this->newTranslations; + } + + public function addTranslation(TranslationInterface $translation): void + { + $this->getTranslations() + ->set($translation->getLocale(), $translation); + $translation->setTranslatable($this); + } + + public function removeTranslation(TranslationInterface $translation): void + { + $this->getTranslations() + ->removeElement($translation); + } + + /** + * Returns translation for specific locale (creates new one if doesn't exists). If requested translation doesn't + * exist, it will first try to fallback default locale If any translation doesn't exist, it will be added to + * newTranslations collection. In order to persist new translations, call mergeNewTranslations method, before flush + * + * @param string $locale The locale (en, ru, fr) | null If null, will try with current locale + */ + public function translate(?string $locale = null, bool $fallbackToDefault = true): TranslationInterface + { + return $this->doTranslate($locale, $fallbackToDefault); + } + + /** + * Merges newly created translations into persisted translations. + */ + public function mergeNewTranslations(): void + { + foreach ($this->getNewTranslations() as $newTranslation) { + if (! $this->getTranslations()->contains($newTranslation) && ! $newTranslation->isEmpty()) { + $this->addTranslation($newTranslation); + $this->getNewTranslations() + ->removeElement($newTranslation); + } + } + + foreach ($this->getTranslations() as $translation) { + if (! $translation->isEmpty()) { + continue; + } + + $this->removeTranslation($translation); + } + } + + public function setCurrentLocale(string $locale): void + { + $this->currentLocale = $locale; + } + + public function getCurrentLocale(): string + { + return $this->currentLocale ?: $this->getDefaultLocale(); + } + + public function setDefaultLocale(string $locale): void + { + $this->defaultLocale = $locale; + } + + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + public static function getTranslationEntityClass(): string + { + return static::class . 'Translation'; + } + + /** + * Returns translation for specific locale (creates new one if doesn't exists). If requested translation doesn't + * exist, it will first try to fallback default locale If any translation doesn't exist, it will be added to + * newTranslations collection. In order to persist new translations, call mergeNewTranslations method, before flush + * + * @param string $locale The locale (en, ru, fr) | null If null, will try with current locale + */ + protected function doTranslate(?string $locale = null, bool $fallbackToDefault = true): TranslationInterface + { + if ($locale === null) { + $locale = $this->getCurrentLocale(); + } + + $foundTranslation = $this->findTranslationByLocale($locale); + if ($foundTranslation && ! $foundTranslation->isEmpty()) { + return $foundTranslation; + } + + if ($fallbackToDefault) { + $fallbackTranslation = $this->resolveFallbackTranslation($locale); + if ($fallbackTranslation !== null) { + return $fallbackTranslation; + } + } + + if ($foundTranslation) { + return $foundTranslation; + } + + $translationEntityClass = static::getTranslationEntityClass(); + + /** @var TranslationInterface $translation */ + $translation = new $translationEntityClass(); + $translation->setLocale($locale); + + $this->getNewTranslations() + ->set($translation->getLocale(), $translation); + $translation->setTranslatable($this); + + return $translation; + } + + /** + * An extra feature allows you to proxy translated fields of a translatable entity. + * + * @return mixed The translated value of the field for current locale + */ + protected function proxyCurrentLocaleTranslation(string $method, array $arguments = []): mixed + { + // allow $entity->name call $entity->getName() in templates + if (! method_exists(self::getTranslationEntityClass(), $method)) { + $method = 'get' . ucfirst($method); + } + + $translation = $this->translate($this->getCurrentLocale()); + + return call_user_func_array([$translation, $method], $arguments); + } + + /** + * Finds specific translation in collection by its locale. + */ + protected function findTranslationByLocale(string $locale, bool $withNewTranslations = true): ?TranslationInterface + { + $translation = $this->getTranslations() + ->get($locale); + + if ($translation) { + return $translation; + } + + if ($withNewTranslations) { + return $this->getNewTranslations() + ->get($locale); + } + + return null; + } + + protected function computeFallbackLocale(string $locale): ?string + { + if (mb_strrchr($locale, '_') !== false) { + return mb_substr($locale, 0, -mb_strlen(mb_strrchr($locale, '_'))); + } + + return null; + } + + /** + * @param Collection|mixed $translations + */ + private function ensureIsIterableOrCollection($translations): void + { + if ($translations instanceof Collection) { + return; + } + + if (is_iterable($translations)) { + return; + } + + throw new TranslatableException( + sprintf('$translations parameter must be iterable or %s', Collection::class) + ); + } + + private function resolveFallbackTranslation(string $locale): ?TranslationInterface + { + $fallbackLocale = $this->computeFallbackLocale($locale); + + if ($fallbackLocale !== null) { + $translation = $this->findTranslationByLocale($fallbackLocale); + if ($translation && ! $translation->isEmpty()) { + return $translation; + } + } + + return $this->findTranslationByLocale($this->getDefaultLocale(), false); + } +} diff --git a/src/Entity/Translatable/TranslatablePropertiesTrait.php b/src/Entity/Translatable/TranslatablePropertiesTrait.php new file mode 100644 index 000000000..6d19948b8 --- /dev/null +++ b/src/Entity/Translatable/TranslatablePropertiesTrait.php @@ -0,0 +1,30 @@ + */ + protected $translations; + + /** + * @see mergeNewTranslations + * @var Collection + */ + protected $newTranslations; + + /** + * currentLocale is a non persisted field configured during postLoad event + * + * @var string|null + */ + protected $currentLocale; + + /** @var string */ + protected $defaultLocale = 'en'; +} diff --git a/src/Entity/Translatable/TranslationMethodsTrait.php b/src/Entity/Translatable/TranslationMethodsTrait.php new file mode 100644 index 000000000..121b0c636 --- /dev/null +++ b/src/Entity/Translatable/TranslationMethodsTrait.php @@ -0,0 +1,61 @@ +translatable = $translatable; + } + + /** + * Returns entity, that this translation is mapped to. + */ + public function getTranslatable(): TranslatableInterface + { + return $this->translatable; + } + + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + public function getLocale(): string + { + return $this->locale; + } + + public function isEmpty(): bool + { + foreach (get_object_vars($this) as $var => $value) { + if (in_array($var, ['id', 'translatable', 'locale'], true)) { + continue; + } + + if (is_string($value) && mb_strlen(trim($value)) > 0) { + return false; + } + + if (! empty($value)) { + return false; + } + } + + return true; + } +} diff --git a/src/Entity/Translatable/TranslationPropertiesTrait.php b/src/Entity/Translatable/TranslationPropertiesTrait.php new file mode 100644 index 000000000..23b223aea --- /dev/null +++ b/src/Entity/Translatable/TranslationPropertiesTrait.php @@ -0,0 +1,20 @@ + + */ + public function getTranslations(): Collection; + + /** + * @return Collection + */ + public function getNewTranslations(): Collection; + + public function addTranslation(TranslationInterface $translation): void; + + public function removeTranslation(TranslationInterface $translation): void; + + /** + * Returns translation for specific locale (creates new one if doesn't exists). If requested translation doesn't + * exist, it will first try to fallback default locale If any translation doesn't exist, it will be added to + * newTranslations collection. In order to persist new translations, call mergeNewTranslations method, before flush + * + * @param string $locale The locale (en, ru, fr) | null If null, will try with current locale + */ + public function translate(?string $locale = null, bool $fallbackToDefault = true): TranslationInterface; + + /** + * Merges newly created translations into persisted translations. + */ + public function mergeNewTranslations(): void; + + public function setCurrentLocale(string $locale): void; + + public function getCurrentLocale(): string; + + public function setDefaultLocale(string $locale): void; + + public function getDefaultLocale(): string; + + public static function getTranslationEntityClass(): string; +} diff --git a/src/Entity/TranslationInterface.php b/src/Entity/TranslationInterface.php index 695d1a1fe..5fbd96661 100644 --- a/src/Entity/TranslationInterface.php +++ b/src/Entity/TranslationInterface.php @@ -4,14 +4,18 @@ namespace Bolt\Entity; -use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface as KnpTranslationInterface; -interface TranslationInterface extends KnpTranslationInterface +interface TranslationInterface { -} + public static function getTranslatableEntityClass(): string; + + public function setTranslatable(TranslatableInterface $translatable): void; + + public function getTranslatable(): TranslatableInterface; -/* - * The following prevents a 'Class "Knp\DoctrineBehaviors\Model\Translatable\TranslationInterface" does not exist'-Exception - * See screenshot: https://github.com/bolt/core/pull/2496#issuecomment-808725120 - */ -class_alias(TranslationInterface::class, 'Knp\DoctrineBehaviors\Model\Translatable\TranslationInterface'); + public function setLocale(string $locale): void; + + public function getLocale(): string; + + public function isEmpty(): bool; +} diff --git a/src/Event/Listener/TranslatableListener.php b/src/Event/Listener/TranslatableListener.php new file mode 100644 index 000000000..dca78590c --- /dev/null +++ b/src/Event/Listener/TranslatableListener.php @@ -0,0 +1,177 @@ +translatableFetchMode = $this->convertFetchString($translatableFetchMode); + $this->translationFetchMode = $this->convertFetchString($translationFetchMode); + } + + /** + * Adds mapping to the translatable and translations. + */ + public function loadClassMetadata(LoadClassMetadataEventArgs $loadClassMetadataEventArgs): void + { + $classMetadata = $loadClassMetadataEventArgs->getClassMetadata(); + if (!$classMetadata->reflClass instanceof ReflectionClass) { + // Class has not yet been fully built, ignore this event + return; + } + + if ($classMetadata->isMappedSuperclass) { + return; + } + + if (is_a($classMetadata->reflClass->getName(), TranslatableInterface::class, true)) { + $this->mapTranslatable($classMetadata); + } + + if (is_a($classMetadata->reflClass->getName(), TranslationInterface::class, true)) { + $this->mapTranslation($classMetadata, $loadClassMetadataEventArgs->getObjectManager()); + } + } + + public function postLoad(PostLoadEventArgs $lifecycleEventArgs): void + { + $this->setLocales($lifecycleEventArgs->getObject()); + } + + public function prePersist(PrePersistEventArgs $lifecycleEventArgs): void + { + $this->setLocales($lifecycleEventArgs->getObject()); + } + + /** + * Convert string FETCH mode to required string + */ + private function convertFetchString(string|int $fetchMode): int + { + if (is_int($fetchMode)) { + return $fetchMode; + } + + if ($fetchMode === 'EAGER') { + return ClassMetadataInfo::FETCH_EAGER; + } + + if ($fetchMode === 'EXTRA_LAZY') { + return ClassMetadataInfo::FETCH_EXTRA_LAZY; + } + + return ClassMetadataInfo::FETCH_LAZY; + } + + private function mapTranslatable(ClassMetadataInfo $classMetadataInfo): void + { + if ($classMetadataInfo->hasAssociation('translations')) { + return; + } + + $classMetadataInfo->mapOneToMany([ + 'fieldName' => 'translations', + 'mappedBy' => 'translatable', + 'indexBy' => self::LOCALE, + 'cascade' => ['persist', 'merge', 'remove'], + 'fetch' => $this->translatableFetchMode, + 'targetEntity' => $classMetadataInfo->getReflectionClass() + ->getMethod('getTranslationEntityClass') + ->invoke(null), + 'orphanRemoval' => true, + ]); + } + + private function mapTranslation(ClassMetadataInfo $classMetadataInfo, ObjectManager $objectManager): void + { + if (!$classMetadataInfo->hasAssociation('translatable')) { + $targetEntity = $classMetadataInfo->getReflectionClass() + ->getMethod('getTranslatableEntityClass') + ->invoke(null); + + /** @var ClassMetadataInfo $classMetadata */ + $classMetadata = $objectManager->getClassMetadata($targetEntity); + + $singleIdentifierFieldName = $classMetadata->getSingleIdentifierFieldName(); + + $classMetadataInfo->mapManyToOne([ + 'fieldName' => 'translatable', + 'inversedBy' => 'translations', + 'cascade' => ['persist', 'merge'], + 'fetch' => $this->translationFetchMode, + 'joinColumns' => [ + [ + 'name' => 'translatable_id', + 'referencedColumnName' => $singleIdentifierFieldName, + 'onDelete' => 'CASCADE', + ] + ], + 'targetEntity' => $targetEntity, + ]); + } + + $name = $classMetadataInfo->getTableName() . '_unique_translation'; + if (!$this->hasUniqueTranslationConstraint($classMetadataInfo, $name) && + $classMetadataInfo->getName() === $classMetadataInfo->rootEntityName) { + $classMetadataInfo->table['uniqueConstraints'][$name] = [ + 'columns' => ['translatable_id', self::LOCALE], + ]; + } + + if (!$classMetadataInfo->hasField(self::LOCALE) && !$classMetadataInfo->hasAssociation(self::LOCALE)) { + $classMetadataInfo->mapField([ + 'fieldName' => self::LOCALE, + 'type' => 'string', + 'length' => 5, + ]); + } + } + + private function setLocales(object $entity): void + { + if (!$entity instanceof TranslatableInterface) { + return; + } + + $currentLocale = $this->localeProvider->provideCurrentLocale(); + if ($currentLocale) { + $entity->setCurrentLocale($currentLocale); + } + + $fallbackLocale = $this->localeProvider->provideFallbackLocale(); + if ($fallbackLocale) { + $entity->setDefaultLocale($fallbackLocale); + } + } + + private function hasUniqueTranslationConstraint(ClassMetadataInfo $classMetadataInfo, string $name): bool + { + return isset($classMetadataInfo->table['uniqueConstraints'][$name]); + } +} diff --git a/src/Exception/TranslatableException.php b/src/Exception/TranslatableException.php new file mode 100644 index 000000000..465ea60b2 --- /dev/null +++ b/src/Exception/TranslatableException.php @@ -0,0 +1,11 @@ +requestStack->getCurrentRequest(); + if (! $currentRequest instanceof Request) { + return null; + } + + $currentLocale = $currentRequest->getLocale(); + if ($currentLocale !== '') { + return $currentLocale; + } + + if ($this->translator !== null) { + return $this->translator->getLocale(); + } + + return null; + } + + public function provideFallbackLocale(): ?string + { + $currentRequest = $this->requestStack->getCurrentRequest(); + if ($currentRequest !== null) { + return $currentRequest->getDefaultLocale(); + } + + try { + if ($this->parameterBag->has('locale')) { + return (string) $this->parameterBag->get('locale'); + } + + return (string) $this->parameterBag->get('kernel.default_locale'); + } catch (ParameterNotFoundException | InvalidArgumentException) { + return null; + } + } +} diff --git a/src/Locale/LocaleProviderInterface.php b/src/Locale/LocaleProviderInterface.php new file mode 100644 index 000000000..b0d5650c2 --- /dev/null +++ b/src/Locale/LocaleProviderInterface.php @@ -0,0 +1,12 @@ +getTranslations()->toArray(); diff --git a/src/Utils/TranslationsManager.php b/src/Utils/TranslationsManager.php index 693a206d4..00e323dea 100644 --- a/src/Utils/TranslationsManager.php +++ b/src/Utils/TranslationsManager.php @@ -7,8 +7,8 @@ use Bolt\Entity\Field; use Bolt\Entity\Field\CollectionField; use Bolt\Entity\FieldParentInterface; +use Bolt\Entity\TranslationInterface; use Doctrine\Common\Collections\Collection; -use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface; class TranslationsManager { diff --git a/symfony.lock b/symfony.lock index 2201b0391..131a968af 100644 --- a/symfony.lock +++ b/symfony.lock @@ -37,9 +37,6 @@ "bolt/newswidget": { "version": "1.2.0" }, - "brick/math": { - "version": "0.8.14" - }, "clue/stream-filter": { "version": "v1.4.1" }, @@ -215,9 +212,6 @@ "kevinlebrun/colors.php": { "version": "0.4.1" }, - "knplabs/doctrine-behaviors": { - "version": "v2.0.1" - }, "knplabs/knp-menu": { "version": "v3.1.0" }, @@ -269,9 +263,6 @@ "nesbot/carbon": { "version": "2.30.0" }, - "nette/utils": { - "version": "v3.1.1" - }, "nikic/php-parser": { "version": "v4.3.0" }, @@ -457,12 +448,6 @@ "ralouphie/getallheaders": { "version": "3.0.3" }, - "ramsey/collection": { - "version": "1.0.1" - }, - "ramsey/uuid": { - "version": "3.9.3" - }, "react/promise": { "version": "v2.8.0" },