diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b2789af..fb333b100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,15 @@ Changelog ## 6.0.0 (unreleased) +Make sure to check the [upgrade notes](https://github.com/bolt/core/blob/main/UPGRADING.md) for instructions when upgrading your installation! + - `master` branch has been renamed to the now more common `main`. + +### ⚙️ Dependency updates + - Bump `nesbot/carbon` from version 2 to version 3.8+. (macintoshplus, [#3551](https://github.com/bolt/core/issues/3551)) - Replaced `tightenco/collect` with `illuminate/collections`. The namespace has changed from `Tightenco\Collect\Support\*` to `Illuminate\Support\*`. (macintoshplus, [#3555](https://github.com/bolt/core/issues/3555)) +- The `knplabs/doctrine-behaviors` package has been removed from the Bolt core, but its functionality has been integrated. (macintoshplus, [#3561](https://github.com/bolt/core/issues/3561)) ## 5.2.2 diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 000000000..b8d7c6679 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,32 @@ +# From Bolt 5.2 to 6.0 + +## Replaced `tightenco/collect` with `illuminate/collections` + +If you were using classes from `Tightenco\Collect\Support`, you should replace them with `Illuminate\Support\` or install the deprecated `tightenco/collect` library yourself. + +## Dropped `knplabs/doctrine-behaviors` dependency + +The `knplabs/doctrine-behaviors` package has been removed from the Bolt core, but the translation behavior that was used by Bolt core has been integrated. + +The following classes have replaced: + +| 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 | + +Check if this line has been removed from your `config/bundles.php` file: + +```php +Knp\DoctrineBehaviors\DoctrineBehaviorsBundle::class => ['all' => true], +``` diff --git a/composer.json b/composer.json index b7322e962..6c976c334 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,6 @@ "fakerphp/faker": "^1.16", "illuminate/collections": "^10.48", "jasny/twig-extensions": "^1.3", - "knplabs/doctrine-behaviors": "^2.1", "knplabs/knp-menu-bundle": "^3.1", "league/glide-symfony": "^2.0", "miljar/php-exif": "^0.6.4", diff --git a/config/bundles.php b/config/bundles.php index 7f952e1c5..4f4f4c5b2 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..bc161b5d1 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 @@ -78,6 +80,15 @@ services: Bolt\Event\Listener\UserAvatarLoadListener: tags: - { name: doctrine.event_listener, event: postLoad } + + Bolt\Event\Listener\TranslatableListener: + arguments: + $translatableFetchMode: '%bolt.doctrine_behaviors_translatable_fetch_mode%' + $translationFetchMode: '%bolt.doctrine_behaviors_translation_fetch_mode%' + 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 045b51c22..55b8e549d 100644 --- a/src/DataFixtures/ContentFixtures.php +++ b/src/DataFixtures/ContentFixtures.php @@ -49,8 +49,13 @@ 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'); @@ -180,8 +185,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 +221,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 +258,11 @@ 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); } } diff --git a/src/Entity/Field.php b/src/Entity/Field.php index b92edc386..9514d0017 100644 --- a/src/Entity/Field.php +++ b/src/Entity/Field.php @@ -9,12 +9,12 @@ 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 Illuminate\Support\Collection; -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 Twig\Environment; @@ -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..03b03a76b --- /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(mb_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..e541eab97 100644 --- a/src/Entity/TranslationInterface.php +++ b/src/Entity/TranslationInterface.php @@ -4,14 +4,17 @@ 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..17c568607 --- /dev/null +++ b/src/Event/Listener/TranslatableListener.php @@ -0,0 +1,175 @@ +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 7d477b71d..f51208204 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" }, @@ -212,9 +209,6 @@ "kevinlebrun/colors.php": { "version": "0.4.1" }, - "knplabs/doctrine-behaviors": { - "version": "v2.0.1" - }, "knplabs/knp-menu": { "version": "v3.1.0" }, @@ -266,9 +260,6 @@ "nesbot/carbon": { "version": "2.30.0" }, - "nette/utils": { - "version": "v3.1.1" - }, "nikic/php-parser": { "version": "v4.3.0" }, @@ -430,12 +421,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" }, diff --git a/yaml-migrations/m_2025-07-23-service.yaml b/yaml-migrations/m_2025-07-23-service.yaml new file mode 100644 index 000000000..5fb51dd0b --- /dev/null +++ b/yaml-migrations/m_2025-07-23-service.yaml @@ -0,0 +1,16 @@ +file: services.yaml +since: 5.2.2 + +add: + parameters: + bolt.doctrine_behaviors_translatable_fetch_mode: LAZY + bolt.doctrine_behaviors_translation_fetch_mode: LAZY + services: + Bolt\Event\Listener\TranslatableListener: + arguments: + $translatableFetchMode: '%bolt.doctrine_behaviors_translatable_fetch_mode%' + $translationFetchMode: '%bolt.doctrine_behaviors_translation_fetch_mode%' + tags: + - { name: doctrine.event_listener, event: loadClassMetadata } + - { name: doctrine.event_listener, event: postLoad } + - { name: doctrine.event_listener, event: prePersist }