diff --git a/Classes/ContentRepository/NodeTranslationService.php b/Classes/ContentRepository/NodeTranslationService.php index 94a131d..ca0658b 100644 --- a/Classes/ContentRepository/NodeTranslationService.php +++ b/Classes/ContentRepository/NodeTranslationService.php @@ -15,6 +15,7 @@ use Neos\Neos\Utility\NodeUriPathSegmentGenerator; use Sitegeist\LostInTranslation\Domain\TranslatableProperty\TranslatablePropertyNamesFactory; use Sitegeist\LostInTranslation\Domain\TranslationServiceInterface; +use Sitegeist\LostInTranslation\Utility\ArrayFlatteningUtility; /** * @Flow\Scope("singleton") @@ -287,22 +288,33 @@ public function translateNode(NodeInterface $sourceNode, NodeInterface $targetNo /** @phpstan-ignore arguments.count */ $properties = (array)$sourceNode->getProperties(true); $propertiesToTranslate = []; + foreach ($properties as $propertyName => $propertyValue) { - if (empty($propertyValue) || !is_string($propertyValue)) { + if (empty($propertyValue)) { continue; } + assert($propertyName !== ''); + assert($propertyValue !== ''); if (!$translatableProperties->isTranslatable($propertyName)) { continue; } - if ((trim(strip_tags($propertyValue))) == "") { + if (is_string($propertyValue) && trim(strip_tags($propertyValue)) === "") { continue; } - $propertiesToTranslate[$propertyName] = $propertyValue; - unset($properties[$propertyName]); + if ($connector = $translatableProperties->getTranslationObjectConnector($propertyName)) { + $propertiesToTranslate[$propertyName] = $connector->extractTranslations($propertyValue); + unset($properties[$propertyName]); + } else { + $propertiesToTranslate[$propertyName] = $propertyValue; + unset($properties[$propertyName]); + } } if (count($propertiesToTranslate) > 0) { - $translatedProperties = $this->translationService->translate($propertiesToTranslate, $targetLanguage, $sourceLanguage); + $propertiesToTranslateDeflated = ArrayFlatteningUtility::deflate($propertiesToTranslate); + /** @var array $translatedPropertiesDeflated */ + $translatedPropertiesDeflated = $this->translationService->translate($propertiesToTranslateDeflated, $targetLanguage, $sourceLanguage); + $translatedProperties = ArrayFlatteningUtility::enflate($translatedPropertiesDeflated); $properties = array_merge($translatedProperties, $properties); } @@ -311,9 +323,18 @@ public function translateNode(NodeInterface $sourceNode, NodeInterface $targetNo if ($propertyName === 'uriPathSegment' && !preg_match('/^[a-z0-9\-]+$/i', $propertyValue)) { $propertyValue = $this->nodeUriPathSegmentGenerator->generateUriPathSegment(null, $propertyValue); } - - if ($targetNode->getProperty($propertyName) !== $propertyValue) { - $targetNode->setProperty($propertyName, $propertyValue); + if (is_array($propertyValue)) { + $targetValue = $targetNode->getProperty($propertyName); + if ($connector = $translatableProperties->getTranslationObjectConnector($propertyName)) { + if (is_object($targetValue)) { + $targetValue = $connector->applyTranslations($targetValue, $propertyValue); + } + } + } else { + $targetValue = $propertyValue; + } + if ($targetNode->getProperty($propertyName) !== $targetValue) { + $targetNode->setProperty($propertyName, $targetValue); } } } diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php index 6cb453d..3da51e8 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php @@ -4,6 +4,8 @@ namespace Sitegeist\LostInTranslation\Domain\TranslatableProperty; +use Sitegeist\LostInTranslation\Domain\TranslationConnectorInterface; + class TranslatablePropertyName { /** @@ -11,13 +13,31 @@ class TranslatablePropertyName */ protected $name; - public function __construct(string $name) + /** + * @var TranslationConnectorInterface|null + */ + protected $translationConnector; + + /** + * @param string $name + * @param TranslationConnectorInterface|null $translationConnector + */ + public function __construct(string $name, ?TranslationConnectorInterface $translationConnector = null) { $this->name = $name; + $this->translationConnector = $translationConnector; } public function getName(): string { return $this->name; } + + /** + * @return TranslationConnectorInterface|null + */ + public function getTranslationConnector(): ?TranslationConnectorInterface + { + return $this->translationConnector; + } } diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php index 78769a2..969ce1f 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php @@ -4,6 +4,8 @@ namespace Sitegeist\LostInTranslation\Domain\TranslatableProperty; +use Sitegeist\LostInTranslation\Domain\TranslationConnectorInterface; + /** * @implements \IteratorAggregate */ @@ -28,6 +30,20 @@ public function isTranslatable(string $propertyName): bool return false; } + /** + * @param string $propertyName + * @return TranslationConnectorInterface|null + */ + public function getTranslationObjectConnector(string $propertyName): ?TranslationConnectorInterface + { + foreach ($this->translatableProperties as $translatableProperty) { + if ($translatableProperty->getName() == $propertyName) { + return $translatableProperty->getTranslationConnector(); + } + } + return null; + } + /** * @return \ArrayIterator */ diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php index a7ac22e..45dc483 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php @@ -6,6 +6,8 @@ use Neos\Flow\Annotations as Flow; use Neos\ContentRepository\Domain\Model\NodeType; +use Neos\Flow\ObjectManagement\ObjectManagerInterface; +use Sitegeist\LostInTranslation\Domain\TranslationConnectorInterface; class TranslatablePropertyNamesFactory { @@ -15,6 +17,24 @@ class TranslatablePropertyNamesFactory */ protected $translateInlineEditables; + /** + * @var bool + * @Flow\InjectConfiguration(path="nodeTranslation.translateTypesWithConnectors") + */ + protected $translateTypesWithConnectors; + + /** + * @Flow\InjectConfiguration(path="nodeTranslation.translationConnectors") + * @var array + */ + protected $translationConnectors; + + /** + * @Flow\Inject + * @var ObjectManagerInterface + */ + protected $objectManager; + /** * @var array */ @@ -28,20 +48,28 @@ public function createForNodeType(NodeType $nodeType): TranslatablePropertyNames $propertyDefinitions = $nodeType->getProperties(); $translateProperties = []; foreach ($propertyDefinitions as $propertyName => $propertyDefinition) { - if (array_key_exists('type', $propertyDefinition) && $propertyDefinition['type'] !== 'string') { + $type = $propertyDefinition['type']; + + // @deprecated Fallback for renamed setting translateOnAdoption -> automaticTranslation + $automaticTranslationIsEnabled = $propertyDefinition[ 'options' ][ 'automaticTranslation' ] + ?? ($propertyDefinition[ 'options' ][ 'translateOnAdoption' ] ?? null); + $isInlineEditable = $propertyDefinition['ui']['inlineEditable'] + ?? false; + $translationConnector = $this->translationConnectors[$type] + ?? null; + + if ($automaticTranslationIsEnabled === false) { continue; } - if (isset($propertyDefinition['options']['automaticTranslation']) && !$propertyDefinition['options']['automaticTranslation']) { - continue; // do not translate (inline-editable) properties explicitly set to: 'automaticTranslation: false' - } - if ($this->translateInlineEditables && ($propertyDefinitions[$propertyName]['ui']['inlineEditable'] ?? false)) { + + if ($type === "string" && $this->translateInlineEditables && $isInlineEditable) { $translateProperties[] = new TranslatablePropertyName($propertyName); - continue; - } - // @deprecated Fallback for renamed setting translateOnAdoption -> automaticTranslation - if ($propertyDefinition['options']['automaticTranslation'] ?? ($propertyDefinition['options']['translateOnAdoption'] ?? false)) { + } elseif ($type === "string" && $automaticTranslationIsEnabled === true) { $translateProperties[] = new TranslatablePropertyName($propertyName); - continue; + } elseif ($translationConnector && ($this->translateTypesWithConnectors || $automaticTranslationIsEnabled)) { + $translationConnectorInstance = $this->objectManager->get($translationConnector); + assert($translationConnectorInstance instanceof TranslationConnectorInterface); + $translateProperties[] = new TranslatablePropertyName($propertyName, $translationConnectorInstance); } } $this->firstLevelCache[$nodeType->getName()] = new TranslatablePropertyNames(...$translateProperties); diff --git a/Classes/Domain/TranslationConnectorInterface.php b/Classes/Domain/TranslationConnectorInterface.php new file mode 100644 index 0000000..baed932 --- /dev/null +++ b/Classes/Domain/TranslationConnectorInterface.php @@ -0,0 +1,24 @@ + + */ + public function extractTranslations(object $object): array; + + /** + * @param T $object + * @param array $translations + * @return T + */ + public function applyTranslations(object $object, array $translations): object; +} diff --git a/Classes/Utility/ArrayFlatteningUtility.php b/Classes/Utility/ArrayFlatteningUtility.php new file mode 100644 index 0000000..32d4f96 --- /dev/null +++ b/Classes/Utility/ArrayFlatteningUtility.php @@ -0,0 +1,59 @@ +> $array to deflate + * @return array + */ + public static function deflate(array $array): array + { + $result = []; + foreach ($array as $key => $value) { + assert($key !== ''); + if (is_string($value)) { + $result[$key] = $value; + } elseif (is_array($value)) { + foreach ($value as $subkey => $subvalue) { + $result[$key . self::SEPERATOR . $subkey] = $subvalue; + } + } + } + return $result; + } + + /** + * @param array $array to enflate + * @return array> + */ + public static function enflate(array $array): array + { + $result = []; + foreach ($array as $key => $value) { + assert($key !== ''); + if (str_contains($key, self::SEPERATOR)) { + list($mainKey, $subKey) = explode(self::SEPERATOR, $key, 2); + assert($mainKey !== ''); + assert($subKey !== ''); + if (array_key_exists($mainKey, $result) && is_array($result[$mainKey])) { + $result[$mainKey][$subKey] = $value; + } else { + $result[$mainKey] = [$subKey => $value]; + } + } else { + $result[$key] = $value; + } + } + return $result; + } +} diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index c70c596..01fb769 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -83,3 +83,17 @@ Sitegeist: skipAuthorizationChecks: false excludedNodePaths: [] + + # + # Translate all object properties that have a translationConnector configured + # if this is set to false each property must be enabled via options.automaticTranslation + # + translateTypesWithConnectors: true + + # + # Connectors to translate value object properties + # + # for each value object type a clas implementing the TranslationConnectorInterface + # can be configured to extract and apply translations + # + translationConnectors: [] diff --git a/Tests/Unit/Domain/TranslatablePropertyNamesFactoryTest.php b/Tests/Unit/Domain/TranslatablePropertyNamesFactoryTest.php new file mode 100644 index 0000000..5889fe2 --- /dev/null +++ b/Tests/Unit/Domain/TranslatablePropertyNamesFactoryTest.php @@ -0,0 +1,164 @@ +translatablePropertyNamesFactory = new TranslatablePropertyNamesFactory(); + $this->inject($this->translatablePropertyNamesFactory, 'translateInlineEditables', true); + + } + + public function exampleProvider(): \Generator + { + yield 'empty' => [ + new NodeType('Example', [], []), + new TranslatablePropertyNames(), + ]; + + yield 'ignored' => [ + new NodeType('Example', [], [ + 'properties' => [ + 'stringProperty' => [ + 'type' => 'string', + ] + ] + ]), + new TranslatablePropertyNames(), + ]; + + yield 'inline editable' => [ + new NodeType('Example', [], [ + 'properties' => [ + 'inlineEditableTextProperty' => [ + 'type' => 'string', + 'ui' => [ + 'inlineEditable' => true, + ] + ] + ] + ]), + new TranslatablePropertyNames( + new TranslatablePropertyName('inlineEditableTextProperty'), + ), + ]; + + yield 'automaticTranslation' => [ + new NodeType('Example', [], [ + 'properties' => [ + 'textPropertyWithOptions' => [ + 'type' => 'string', + 'options' => [ + 'automaticTranslation' => true, + ] + ] + ] + ]), + new TranslatablePropertyNames( + new TranslatablePropertyName('textPropertyWithOptions'), + ), + ]; + } + + /** + * @dataProvider exampleProvider + */ + public function testDetectionOfTranslatableProperties(NodeType $nodeType, TranslatablePropertyNames $expectedPropertyNames): void { + $this->assertEquals($expectedPropertyNames, $this->translatablePropertyNamesFactory->createForNodeType($nodeType) ); + } + + + public function testPropertiesWithConfiguredConnector(): void + { + $mockTranslationConnector = $this->createMock(TranslationConnectorInterface::class); + + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); + $mockObjectManager + ->expects(self::once()) + ->method('get') + ->with('Example\TranslationConnector') + ->willReturn($mockTranslationConnector); + + $this->inject($this->translatablePropertyNamesFactory, 'objectManager', $mockObjectManager); + $this->inject($this->translatablePropertyNamesFactory, 'translateTypesWithConnectors', true); + $this->inject($this->translatablePropertyNamesFactory, 'translationConnectors', ['Example\Class' => 'Example\TranslationConnector']); + + $nodeType = new NodeType('Example', [], [ + 'properties' => [ + 'object' => [ + 'type' => 'Example\Class', + ], + 'objectWithoutConnector' => [ + 'type' => 'Example\Other\Class', + ], + 'objectWithConnectorButDisabled' => [ + 'type' => 'Example\Class', + 'options' => [ + 'automaticTranslation' => false, + ] + ], + ] + ]); + + $expectedPropertyNames = new TranslatablePropertyNames( + new TranslatablePropertyName('object', $mockTranslationConnector) + ); + + $this->assertEquals($expectedPropertyNames, $this->translatablePropertyNamesFactory->createForNodeType($nodeType) ); + } + + public function testPropertiesWithConfiguredConnectorOptIn(): void + { + $mockTranslationConnector = $this->createMock(TranslationConnectorInterface::class); + + $mockObjectManager = $this->createMock(ObjectManagerInterface::class); + $mockObjectManager + ->expects(self::once()) + ->method('get') + ->with('Example\TranslationConnector') + ->willReturn($mockTranslationConnector); + + $this->inject($this->translatablePropertyNamesFactory, 'objectManager', $mockObjectManager); + $this->inject($this->translatablePropertyNamesFactory, 'translateTypesWithConnectors', false); + $this->inject($this->translatablePropertyNamesFactory, 'translationConnectors', ['Example\Class' => 'Example\TranslationConnector']); + + $nodeType = new NodeType('Example', [], [ + 'properties' => [ + 'object' => [ + 'type' => 'Example\Class', + 'options' => [ + 'automaticTranslation' => true, + ] + ], + 'objectWithoutConnector' => [ + 'type' => 'Example\Other\Class', + ], + 'objectWithoutOptIn' => [ + 'type' => 'Example\Class', + ], + ] + ]); + + $expectedPropertyNames = new TranslatablePropertyNames( + new TranslatablePropertyName('object', $mockTranslationConnector) + ); + + $this->assertEquals($expectedPropertyNames, $this->translatablePropertyNamesFactory->createForNodeType($nodeType) ); + } + +} diff --git a/Tests/Unit/Utility/ArrayFlatteningUtilityTest.php b/Tests/Unit/Utility/ArrayFlatteningUtilityTest.php new file mode 100644 index 0000000..7f1f298 --- /dev/null +++ b/Tests/Unit/Utility/ArrayFlatteningUtilityTest.php @@ -0,0 +1,67 @@ + [ + [], + [] + ]; + + yield 'simple array' => [ + ['foo' => 'bar', 'bar' => 'baz'], + ['foo' => 'bar', 'bar' => 'baz'] + ]; + + yield 'nested array' => [ + ['foo' => ['bar' => 'baz', 'baz' => 'bam'], 'bar' => ['baz' => 'bam']], + ['foo.bar' => 'baz', 'foo.baz' => 'bam', 'bar.baz' => 'bam'] + ]; + + yield 'mixed array' => [ + ['foo' => ['bar' => 'baz', 'baz' => 'bam'], 'bar' => ['baz' => 'bam'], 'baz' => 'bam'], + ['foo.bar' => 'baz', 'foo.baz' => 'bam', 'bar.baz' => 'bam', 'baz' => 'bam'] + ]; + + yield 'nested mixed array' => [ + ['foo' => ['bar' => 'baz', 'baz' => 'bam'], 'bar' => ['baz' => 'bam'], 'baz' => 'bam'], + ['foo.bar' => 'baz', 'foo.baz' => 'bam', 'bar.baz' => 'bam', 'baz' => 'bam'] + ]; + + yield 'nested with . in subkeys' => [ + ['foo' => ['bar.baz' => "bam", 'bar.bam' => 'blah'], 'bar' => ['baz.bam' => 'blah'], 'baz' => 'bam'], + ['foo.bar.baz' => 'bam', 'foo.bar.bam' => 'blah', 'bar.baz.bam' => 'blah', 'baz' => 'bam'] + ]; + } + + /** + * @dataProvider provideExamples + * @param array> $enflated + * @param array $deflated + * @param string $seperator + * @return void + */ + public function testArrayDeflation(array $enflated, array $deflated): void + { + $this->assertEquals($deflated, ArrayFlatteningUtility::deflate($enflated)); + } + + /** + * @dataProvider provideExamples + * @param array> $enflated + * @param array $deflated + * @param string $seperator + * @return void + */ + public function testArrayEnflation(array $enflated, array $deflated): void + { + $this->assertEquals($enflated, ArrayFlatteningUtility::enflate($deflated)); + } +}