From 60a38d7472f6b659284a17f9fa3cb46be6277afc Mon Sep 17 00:00:00 2001 From: abhinaykhalatkar Date: Wed, 20 May 2026 07:58:33 +0200 Subject: [PATCH] feat: add support for translating Category elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - new findTargetCategory() in TranslateService with structure-preserving propagation (uses propagateElement, never duplicateElement) - explicit Category branch in ElementHelper::query() — removes a latent Entry-fallback footgun for any future supported element type - type-aware debug-log propagationMethod via match(true) (also unmasks a latent Asset bug under debug=true that produced wrong values) - save Category targets with propagate=false (Craft's category propagation pipeline otherwise silently rejects per-site rows with diverged translated values) - BulkTranslateJob: per-element try/catch around UnsupportedSiteException and InvalidConfigException so one unsupported target site no longer fails the whole bulk job - README: new "Supported element types" section + Category slug listener recipe via afterElementTranslation - CHANGELOG: Unreleased entry documenting the additions + fixes --- CHANGELOG.md | 12 ++++++ README.md | 43 ++++++++++++++++++- src/MultiTranslator.php | 2 + src/helpers/ElementHelper.php | 6 +++ src/jobs/BulkTranslateJob.php | 21 ++++++++-- src/services/TranslateService.php | 68 ++++++++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff117c..055fcad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Release Notes for Multi Translator +## Unreleased + +### Added + +- add support for translating `craft\elements\Category` elements — sidebar button, bulk action, review screen, and all four lifecycle events +- `BulkTranslateJob` now catches `UnsupportedSiteException` / `InvalidConfigException` per element so one unsupported target site no longer fails the whole job + +### Fixed + +- debug-log `propagationMethod` field is now type-aware: it emits the Section propagation enum for Entries, `group:` for Categories, and `null` for Assets/Products/Variants (previously could throw under `debug=true` on any element without a `->section`) +- `ElementHelper::query()` now has an explicit `craft\elements\Category` branch — the previous `else` fallback to `Entry::find()` would have silently misrouted Category lookups + ## 2.27.0 - 2026-04-24 ### Added diff --git a/README.md b/README.md index 865e48e..ee725ce 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Multi Translator -Translate your site content using Deepl, Google Translate or ChatGPT. +Translate your Entry, Category, Asset, and Commerce Product/Variant content using DeepL, Google Translate, or ChatGPT. ## Requirements @@ -83,6 +83,15 @@ For non-admin users, enable permissions under the 'Multi Translator' section: - 'Translate Content in bulk (element action)' - enables the bulk actions to [translate one-by-one](#translate-in-bulk) +## Supported element types + +- `craft\elements\Entry` +- `craft\elements\Category` +- `craft\elements\Asset` (title and translatable `alt` text) +- `craft\commerce\elements\Product` (and its `Variant`s — variants are recursively translated when their parent product is translated) + +For Categories: translation works on category groups whose `propagationMethod` lets per-site values diverge (i.e. any setting other than "all sites identical"). The Translate button only acts on sites the source's category group is enabled for; bulk translation to an unsupported site logs the failure and continues to the next target. + ## Supported field types - craft\fields\PlainText @@ -177,6 +186,38 @@ Event::on( ); ``` +#### Translate category slugs (Category-specific recipe) + +Category URIs commonly use templates like `{parent.uri}/{slug}`, so a translated tree wants translated slugs to produce a fully localised URL path. The same `afterElementTranslation` event works — and because Craft rebuilds `uri` on save, no plugin code touches URIs directly: + +```php +use craft\elements\Category; +use digitalpulsebe\craftmultitranslator\events\ElementTranslationEvent; +use digitalpulsebe\craftmultitranslator\services\TranslateService; +use digitalpulsebe\craftmultitranslator\MultiTranslator; + +Event::on( + TranslateService::class, + TranslateService::EVENT_AFTER_ELEMENT_TRANSLATION, + function (ElementTranslationEvent $event) { + if (!$event->targetElement instanceof Category) { + return; + } + $translated = MultiTranslator::getInstance()->translate->translateText( + $event->sourceSite->language, + $event->targetSite->language, + $event->sourceElement->slug + ); + if ($translated) { + // Craft's slug helper normalises spacing/casing for URLs + $event->targetElement->slug = \craft\helpers\StringHelper::slugify($translated); + } + } +); +``` + +When bulk-translating a category tree, Craft's element index returns categories in structure order (parents first), so `BulkTranslateJob` propagates parents before children — the URI template `{parent.uri}/{slug}` resolves correctly on each save. + ### The `beforeFieldTranslation` event Example: diff --git a/src/MultiTranslator.php b/src/MultiTranslator.php index 5437461..a0e7029 100644 --- a/src/MultiTranslator.php +++ b/src/MultiTranslator.php @@ -7,6 +7,7 @@ use craft\base\Model; use craft\base\Plugin; use craft\elements\Asset; +use craft\elements\Category; use craft\elements\Entry; use craft\events\DefineHtmlEvent; use craft\events\RegisterElementActionsEvent; @@ -261,6 +262,7 @@ public static function getSupportedElementClasses(): array { $supportedElementClasses = [ Entry::class, + Category::class, Asset::class, 'craft\commerce\elements\Product', 'craft\commerce\elements\Variant', diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index f175b79..1ffd9eb 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -8,6 +8,7 @@ use craft\commerce\elements\Product; use craft\commerce\elements\Variant; use craft\elements\Asset; +use craft\elements\Category; use craft\elements\db\ElementQuery; use craft\elements\Entry; use digitalpulsebe\craftmultitranslator\MultiTranslator; @@ -25,12 +26,17 @@ class ElementHelper */ public static function query(string $elementType, int|array $elementIds, int $siteId): ElementQuery { + // NOTE: any element class added to MultiTranslator::getSupportedElementClasses() MUST + // also have a branch here. The else-fallback to Entry::find() is a latent footgun — + // unsupported types silently get misrouted as Entry queries. if ($elementType == 'craft\commerce\elements\Product') { return Product::find()->drafts(null)->status(null)->id($elementIds)->siteId($siteId); } elseif ($elementType == 'craft\commerce\elements\Variant') { return Variant::find()->status(null)->id($elementIds)->siteId($siteId); } elseif ($elementType == Asset::class) { return Asset::find()->status(null)->id($elementIds)->siteId($siteId); + } elseif ($elementType == Category::class) { + return Category::find()->drafts(null)->status(null)->id($elementIds)->siteId($siteId); } else { return Entry::find()->drafts(null)->status(null)->id($elementIds)->siteId($siteId); } diff --git a/src/jobs/BulkTranslateJob.php b/src/jobs/BulkTranslateJob.php index 2b093c2..bb7db6a 100644 --- a/src/jobs/BulkTranslateJob.php +++ b/src/jobs/BulkTranslateJob.php @@ -3,10 +3,12 @@ namespace digitalpulsebe\craftmultitranslator\jobs; use \Craft; +use craft\errors\UnsupportedSiteException; use craft\queue\BaseJob; use digitalpulsebe\craftmultitranslator\helpers\ElementHelper; use digitalpulsebe\craftmultitranslator\MultiTranslator; use Exception; +use yii\base\InvalidConfigException; class BulkTranslateJob extends BaseJob { @@ -36,10 +38,23 @@ public function execute($queue): void $this->setProgress($queue, $i/$elementCount, "Translating element $iHuman/$elementCount to $targetSite->name"); - $translatedElement = MultiTranslator::getInstance()->translate->translateElement($element, $sourceSite, $targetSite); + try { + $translatedElement = MultiTranslator::getInstance()->translate->translateElement($element, $sourceSite, $targetSite); - if (!empty($translatedElement->errors)) { - $errors[$translatedElement->id] = $translatedElement->errors; + if (!empty($translatedElement?->errors)) { + $errors[$translatedElement->id] = $translatedElement->errors; + } + } catch (UnsupportedSiteException | InvalidConfigException $e) { + // Surfaces when a Category's group is not enabled for the target site, + // or any other "this element can't live on that site" condition. Per-element + // recovery — log and continue so one bad target doesn't tank the whole job. + MultiTranslator::error([ + 'message' => 'BulkTranslateJob: skipping unsupported site for element', + 'elementId' => $element->id, + 'siteHandle' => $this->targetSiteHandle, + 'exception' => $e->getMessage(), + ]); + $errors[$element->id] = ['unsupportedSite' => $e->getMessage()]; } } diff --git a/src/services/TranslateService.php b/src/services/TranslateService.php index 262dc08..58c71b4 100644 --- a/src/services/TranslateService.php +++ b/src/services/TranslateService.php @@ -9,6 +9,7 @@ use craft\commerce\elements\Product; use craft\commerce\elements\Variant; use craft\elements\Asset; +use craft\elements\Category; use craft\elements\ContentBlock; use craft\elements\Entry; use craft\enums\PropagationMethod; @@ -107,6 +108,15 @@ public function translateElement(Element $source, Site $sourceSite, Site $target $targetElement = \Craft::$app->drafts->createDraft($targetElement, null, $draftName, $revisionNotes); $this->setElementTranslation($source, $targetElement, $translatedValues); \Craft::$app->elements->saveElement($targetElement); + } elseif ($targetElement instanceof Category) { + // Category: the per-site row already exists (findTargetCategory ensured it). + // saveElement($element, true, true) — propagate=true — silently returns false + // when re-saving a per-site Category row with diverged translated values + // (Craft's category propagation pipeline reconciles cross-site rows differently + // than Entry's, and rejects the update with no error surfacing). Saving with + // propagate=false updates just this site's row, which is exactly what we want. + $targetElement->setRevisionNotes($revisionNotes); + \Craft::$app->elements->saveElement($targetElement, true, false); } else { $targetElement->setRevisionNotes($revisionNotes); \Craft::$app->elements->saveElement($targetElement); @@ -135,7 +145,13 @@ public function translateElement(Element $source, Site $sourceSite, Site $target }, $source->fieldLayout->getCustomFields()), 'sourceSiteLanguage' => $sourceSite->language, 'targetSiteLanguage' => $targetSite->language, - 'propagationMethod' => $source?->section->propagationMethod ?? null, + // type-aware: Section propagation enum for Entry, group handle for Category, + // null for element types without a propagation concept (Asset, Product, Variant). + 'propagationMethod' => match (true) { + $source instanceof Entry => $source?->section?->propagationMethod ?? null, + $source instanceof Category => 'group:' . ($source->getGroup()?->handle ?? 'unknown'), + default => null, + }, 'sourceEntry' => ['id' => $source->id, 'siteId' => $source->siteId, 'draft' => $source->getIsDraft(), 'customFields' => $source->getSerializedFieldValues()], 'targetElement' => ['id' => $targetElement->id, 'siteId' => $targetElement->siteId, 'draft' => $targetElement->getIsDraft()], 'serialized' => $originalHtmls, @@ -296,6 +312,8 @@ public function findTargetElement(Element $source, int $targetSiteId): Element return ElementHelper::one(Asset::class, $source->id, $targetSiteId); } elseif ($source instanceof Variant) { return Variant::find()->status(null)->id($source->id)->siteId($targetSiteId)->one(); + } elseif ($source instanceof Category) { + return $this->findTargetCategory($source, $targetSiteId); } else { return $this->findTargetEntry($source, $targetSiteId); } @@ -352,6 +370,54 @@ public function findTargetEntry(Entry $source, int $targetSiteId): Entry return $targetEntry; } + /** + * Find the target Category in the target site for a source Category. + * + * Categories don't have a propagationMethod enum (unlike Entry's Section); a category + * group is either enabled or not enabled for a given site. If the row already exists + * in the target site (the common case — Craft auto-propagates on save), we just return + * it. If it doesn't exist but the group is enabled there, we propagate the source row. + * + * Never falls back to duplicateElement(): categories live in a structure, and + * duplication reshuffles lft/rgt/level and breaks the parent/child topology that's + * identical-across-sites by design. + * + * @throws UnsupportedSiteException when the source's category group is not enabled + * for the target site. + */ + public function findTargetCategory(Category $source, int $targetSiteId): Category + { + $target = ElementHelper::one(Category::class, $source->id, $targetSiteId); + if ($target) { + return $target; + } + + $group = $source->getGroup(); + $siteSettings = $group->getSiteSettings(); + if (!isset($siteSettings[$targetSiteId])) { + throw new UnsupportedSiteException( + $source, + $targetSiteId, + sprintf('Category group "%s" is not enabled for the target site (id=%d).', + $group->handle, + $targetSiteId + ) + ); + } + + Craft::$app->elements->propagateElement($source, $targetSiteId, false); + + $target = ElementHelper::one(Category::class, $source->id, $targetSiteId); + if (!$target) { + throw new Exception(sprintf( + 'Could not locate target Category for source id %d on site id %d after propagation.', + $source->id, + $targetSiteId + )); + } + return $target; + } + /** * Translate text (or HTML) using the configured provider * @param string|null $sourceLocale