From df0ee0456f3d0dcbb085917be011b0d25097ebcb Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Wed, 14 Jan 2026 23:23:42 +0100 Subject: [PATCH 1/3] FEATURE: Add support for translating repeatable fields Introduces translation support for repeatable properties (e.g. JSON arrays) that contain translatable sub-properties. Changes: - Add TranslatableRepeatablePropertyName class for handling repeatable fields - Extend TranslatablePropertyName with isRepeatable() method - Update TranslatablePropertyNames to support repeatable properties - Enhance TranslatablePropertyNamesFactory to detect repeatable fields - Extend NodeTranslationService to process repeatable field translations - Add configuration options for repeatable fields in Settings.yaml --- .../NodeTranslationService.php | 148 ++++++++++++++++++ .../TranslatablePropertyName.php | 5 + .../TranslatablePropertyNames.php | 30 ++++ .../TranslatablePropertyNamesFactory.php | 30 ++++ .../TranslatableRepeatablePropertyName.php | 35 +++++ Configuration/Settings.yaml | 7 + 6 files changed, 255 insertions(+) create mode 100644 Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php diff --git a/Classes/ContentRepository/NodeTranslationService.php b/Classes/ContentRepository/NodeTranslationService.php index ca0658b..6ebab15 100644 --- a/Classes/ContentRepository/NodeTranslationService.php +++ b/Classes/ContentRepository/NodeTranslationService.php @@ -288,8 +288,25 @@ public function translateNode(NodeInterface $sourceNode, NodeInterface $targetNo /** @phpstan-ignore arguments.count */ $properties = (array)$sourceNode->getProperties(true); $propertiesToTranslate = []; + $repeatablePropertiesToTranslate = []; foreach ($properties as $propertyName => $propertyValue) { + // Check if this is a translatable repeatable property + $repeatableProperty = $translatableProperties->isTranslatableRepeatable($propertyName); + if ($repeatableProperty !== null) { + // Repeatable properties can be: JSON string, array, or object with toArray/jsonSerialize + $repeatableData = $this->normalizeRepeatableValue($propertyValue); + if ($repeatableData !== null) { + $repeatablePropertiesToTranslate[$propertyName] = [ + 'value' => $repeatableData, + 'subProperties' => $repeatableProperty->getTranslatableSubProperties() + ]; + unset($properties[$propertyName]); + continue; + } + } + + // Handle regular string properties if (empty($propertyValue)) { continue; } @@ -318,6 +335,17 @@ public function translateNode(NodeInterface $sourceNode, NodeInterface $targetNo $properties = array_merge($translatedProperties, $properties); } + // Translate repeatable properties + foreach ($repeatablePropertiesToTranslate as $propertyName => $config) { + $translatedValue = $this->translateRepeatableProperty( + $config['value'], + $config['subProperties'], + $targetLanguage, + $sourceLanguage + ); + $properties[$propertyName] = $translatedValue; + } + foreach ($properties as $propertyName => $propertyValue) { // Make sure the uriPathSegment is valid if ($propertyName === 'uriPathSegment' && !preg_match('/^[a-z0-9\-]+$/i', $propertyValue)) { @@ -455,4 +483,124 @@ public function resetContextCache(): void { $this->contextFirstLevelCache = []; } + + /** + * Normalize a repeatable property value to an array format + * + * @param mixed $propertyValue + * @return array|null Returns array if successfully normalized, null otherwise + */ + protected function normalizeRepeatableValue($propertyValue): ?array + { + // Already an array + if (is_array($propertyValue)) { + return $propertyValue; + } + + // JSON string + if (is_string($propertyValue)) { + $decoded = json_decode($propertyValue, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + return null; + } + + // Object with toArray method (like Mireo\RepeatableFields\Model\Repeatable) + if (is_object($propertyValue)) { + if (method_exists($propertyValue, 'toArray')) { + $array = $propertyValue->toArray(); + if (is_array($array)) { + return $array; + } + } + // Try jsonSerialize + if ($propertyValue instanceof \JsonSerializable) { + $serialized = $propertyValue->jsonSerialize(); + if (is_array($serialized)) { + return $serialized; + } + } + } + + return null; + } + + /** + * Translate translatable sub-properties within a repeatable property + * + * @param array|string $repeatableValue + * @param array $translatableSubProperties + * @param string $targetLanguage + * @param string $sourceLanguage + * @return array|string + */ + protected function translateRepeatableProperty( + array $repeatableValue, + array $translatableSubProperties, + string $targetLanguage, + string $sourceLanguage + ): array { + // Handle repeatable structure: can be array of items or byGroup structure + $isByGroupStructure = isset($repeatableValue['byGroup']); + $items = $isByGroupStructure ? $repeatableValue['byGroup'] : $repeatableValue; + + if (!is_array($items)) { + return $repeatableValue; + } + + // Collect all strings to translate (for batch API call) + $stringsToTranslate = []; + $itemKeys = []; // Store the original keys for proper reassignment + + foreach ($items as $itemIndex => $item) { + if (!is_array($item)) { + continue; + } + $itemKeys[] = $itemIndex; + foreach ($translatableSubProperties as $subPropertyName) { + if (isset($item[$subPropertyName]) && is_string($item[$subPropertyName])) { + $value = $item[$subPropertyName]; + $trimmedValue = trim(strip_tags($value)); + if (!empty($trimmedValue)) { + // Use a separator that won't appear in property names + $mappingKey = $itemIndex . '::' . $subPropertyName; + $stringsToTranslate[$mappingKey] = $value; + } + } + } + } + + if (empty($stringsToTranslate)) { + return $repeatableValue; + } + + // Batch translate all strings + $translatedStrings = $this->translationService->translate( + $stringsToTranslate, + $targetLanguage, + $sourceLanguage + ); + + // Apply translations back to the structure + foreach ($translatedStrings as $mappingKey => $translatedValue) { + $parts = explode('::', $mappingKey, 2); + if (count($parts) !== 2) { + continue; + } + [$itemIndex, $subPropertyName] = $parts; + // Handle both numeric and string keys + $key = is_numeric($itemIndex) ? (int)$itemIndex : $itemIndex; + if (isset($items[$key]) && is_array($items[$key])) { + $items[$key][$subPropertyName] = $translatedValue; + } + } + + // Restore byGroup structure if needed + if ($isByGroupStructure) { + return ['byGroup' => $items]; + } + + return $items; + } } diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php index 3da51e8..9642669 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyName.php @@ -40,4 +40,9 @@ public function getTranslationConnector(): ?TranslationConnectorInterface { return $this->translationConnector; } + + public function isRepeatable(): bool + { + return false; + } } diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php index 969ce1f..45eb1f4 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php @@ -44,6 +44,36 @@ public function getTranslationObjectConnector(string $propertyName): ?Translatio return null; } + /** + * Check if a property is a repeatable property with translatable sub-properties + * + * @param string $propertyName + * @return TranslatableRepeatablePropertyName|null + */ + public function isTranslatableRepeatable(string $propertyName): ?TranslatableRepeatablePropertyName + { + foreach ($this->translatableProperties as $property) { + if ($property->getName() === $propertyName && $property->isRepeatable()) { + /** @var TranslatableRepeatablePropertyName $property */ + return $property; + } + } + return null; + } + + /** + * Get all repeatable properties + * + * @return array + */ + public function getRepeatableProperties(): array + { + return array_values(array_filter( + $this->translatableProperties, + fn($prop) => $prop->isRepeatable() + )); + } + /** * @return \ArrayIterator */ diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php index 1c27a60..7dfc75b 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyNamesFactory.php @@ -35,6 +35,12 @@ class TranslatablePropertyNamesFactory */ protected $objectManager; + /** + * @var bool + * @Flow\InjectConfiguration(path="nodeTranslation.translateRepeatableFields") + */ + protected $translateRepeatableFields; + /** * @var array */ @@ -53,6 +59,30 @@ public function createForNodeType(NodeType $nodeType): TranslatablePropertyNames continue; } + // Handle repeatable properties + if ($this->translateRepeatableFields && $type === 'repeatable') { + $subProperties = $propertyDefinition['ui']['inspector']['editorOptions']['properties'] ?? []; + $translatableSubProperties = []; + + foreach ($subProperties as $subPropertyName => $subPropertyDefinition) { + $subPropertyType = $subPropertyDefinition['type'] ?? 'string'; + if ($subPropertyType !== 'string') { + continue; + } + if (isset($subPropertyDefinition['options']['automaticTranslation']) && !$subPropertyDefinition['options']['automaticTranslation']) { + continue; + } + if ($subPropertyDefinition['options']['automaticTranslation'] ?? false) { + $translatableSubProperties[] = $subPropertyName; + } + } + + if (!empty($translatableSubProperties)) { + $translateProperties[] = new TranslatableRepeatablePropertyName($propertyName, $translatableSubProperties); + } + continue; + } + // @deprecated Fallback for renamed setting translateOnAdoption -> automaticTranslation $automaticTranslationIsEnabled = $propertyDefinition[ 'options' ][ 'automaticTranslation' ] ?? ($propertyDefinition[ 'options' ][ 'translateOnAdoption' ] ?? null); diff --git a/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php b/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php new file mode 100644 index 0000000..14e5448 --- /dev/null +++ b/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php @@ -0,0 +1,35 @@ + + */ + protected array $translatableSubProperties; + + public function __construct(string $name, array $translatableSubProperties) + { + parent::__construct($name); + $this->translatableSubProperties = $translatableSubProperties; + } + + /** + * @return array + */ + public function getTranslatableSubProperties(): array + { + return $this->translatableSubProperties; + } + + public function isRepeatable(): bool + { + return true; + } +} diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 01fb769..e13c531 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -70,6 +70,13 @@ Sitegeist: # translateInlineEditables: true + # + # Enable translation of repeatable field sub-properties. + # Sub-properties within repeatable fields must have options.automaticTranslation: true + # to be translated. This feature requires the Mireo.RepeatableFields package. + # + translateRepeatableFields: true + # # The name of the language dimension. Usually needs no modification # From 086e259781944f004c54521b3a4fbc75c158db8f Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Wed, 14 Jan 2026 23:44:29 +0100 Subject: [PATCH 2/3] TASK: Fix PHPStan type annotations for repeatable fields - Add @param PHPDoc for TranslatableRepeatablePropertyName constructor - Add type assertion in getRepeatableProperties() return - Fix array type annotations in normalizeRepeatableValue() - Fix PHPDoc types in translateRepeatableProperty() to match native types --- Classes/ContentRepository/NodeTranslationService.php | 6 +++--- .../TranslatableProperty/TranslatablePropertyNames.php | 4 +++- .../TranslatableRepeatablePropertyName.php | 4 ++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Classes/ContentRepository/NodeTranslationService.php b/Classes/ContentRepository/NodeTranslationService.php index 6ebab15..f5595a2 100644 --- a/Classes/ContentRepository/NodeTranslationService.php +++ b/Classes/ContentRepository/NodeTranslationService.php @@ -488,7 +488,7 @@ public function resetContextCache(): void * Normalize a repeatable property value to an array format * * @param mixed $propertyValue - * @return array|null Returns array if successfully normalized, null otherwise + * @return array|null Returns array if successfully normalized, null otherwise */ protected function normalizeRepeatableValue($propertyValue): ?array { @@ -529,11 +529,11 @@ protected function normalizeRepeatableValue($propertyValue): ?array /** * Translate translatable sub-properties within a repeatable property * - * @param array|string $repeatableValue + * @param array $repeatableValue * @param array $translatableSubProperties * @param string $targetLanguage * @param string $sourceLanguage - * @return array|string + * @return array */ protected function translateRepeatableProperty( array $repeatableValue, diff --git a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php index 45eb1f4..c8d9ae3 100644 --- a/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php +++ b/Classes/Domain/TranslatableProperty/TranslatablePropertyNames.php @@ -68,10 +68,12 @@ public function isTranslatableRepeatable(string $propertyName): ?TranslatableRep */ public function getRepeatableProperties(): array { - return array_values(array_filter( + /** @var array $result */ + $result = array_values(array_filter( $this->translatableProperties, fn($prop) => $prop->isRepeatable() )); + return $result; } /** diff --git a/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php b/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php index 14e5448..c1b61f7 100644 --- a/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php +++ b/Classes/Domain/TranslatableProperty/TranslatableRepeatablePropertyName.php @@ -14,6 +14,10 @@ class TranslatableRepeatablePropertyName extends TranslatablePropertyName */ protected array $translatableSubProperties; + /** + * @param string $name + * @param array $translatableSubProperties + */ public function __construct(string $name, array $translatableSubProperties) { parent::__construct($name); From 9e0bab93ee802a26242794de2feb883696e9d41a Mon Sep 17 00:00:00 2001 From: Felix Gradinaru Date: Thu, 15 Jan 2026 00:03:47 +0100 Subject: [PATCH 3/3] BUGFIX: Apply translated repeatable field values to target node When repeatable properties are translated, they produce an array value. The code was checking for a translation connector for array properties, but if none was found (as is the case for repeatable fields), it would keep the OLD target node value instead of applying the translated array. This fix adds an else clause to use the translated array value directly when no translation connector is configured for the property. --- Classes/ContentRepository/NodeTranslationService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Classes/ContentRepository/NodeTranslationService.php b/Classes/ContentRepository/NodeTranslationService.php index f5595a2..f77ba00 100644 --- a/Classes/ContentRepository/NodeTranslationService.php +++ b/Classes/ContentRepository/NodeTranslationService.php @@ -357,6 +357,10 @@ public function translateNode(NodeInterface $sourceNode, NodeInterface $targetNo if (is_object($targetValue)) { $targetValue = $connector->applyTranslations($targetValue, $propertyValue); } + } else { + // For repeatable fields or other array properties without a connector, + // use the translated array value directly + $targetValue = $propertyValue; } } else { $targetValue = $propertyValue;