Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:<handle>` 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
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/MultiTranslator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -261,6 +262,7 @@ public static function getSupportedElementClasses(): array
{
$supportedElementClasses = [
Entry::class,
Category::class,
Asset::class,
'craft\commerce\elements\Product',
'craft\commerce\elements\Variant',
Expand Down
6 changes: 6 additions & 0 deletions src/helpers/ElementHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
21 changes: 18 additions & 3 deletions src/jobs/BulkTranslateJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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()];
}
}

Expand Down
68 changes: 67 additions & 1 deletion src/services/TranslateService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down