LocalAIze: Automatic AI Translation in MODX

Another month, another write-up of a dirty hack of a potentially cool AI feature.

I have worked on several mutli-language projects before using contexts and I do like the way this is handled in modx (at least compared to a lot of other CMS offerings). There are also some cool extras like Babel which also helps teams manage the localization of a site - although this can sometimes be cumbersome. The actual translations will often be handled in a different product by a different team, and creating the connections between different language versions of a resource is often a challenge.

I wanted a way to click a button and modx would not just translate the content, but also create the resource in the correct context and link it back to the original resource.

So strap in, and away we go.

The first step is obviously to set up the contexts (and routing) for the languages you want to use - there are planty of great tutorials about this, so I don’t go into it in depth here. For my case, I wanted to offer the product in English, German, Spanish and Swedish and set up the Contexts accordingly.

The next step was to create the Template Variables that we will need. The goal is to be able to select a target language you want to translate into and (if the translation does not yet exist) then the system should do everything for you when you save the resources.

You need to create 2 TVs for each Context and attach them to all the relevant templates:

localizeDE (checkbox)
localizeDEID (text)
localizeES
localizeESID
localizeSV
localizeSVID

The checkbox TV is the toggle to determine if you want the page translated when you save, and the text TV will hold the ID of the newly created resource. It should look something like this:

So far so good. We are now set up, let’s start writing our plugin.

First create a new plugin and set the trigger event to OnDocFormSave (which makes sense, as you want it to run when you dave a doc).

First we start by connecting the translation API and to tell it what we want to translate. In our case I will use the OpenAI API. I have stored the API key as a system setting - so make sure you do the same. I also just want to translate the basic Resource fields for now:

//------------------------------------------------------
// 1. Load configuration
//------------------------------------------------------
$openAIApiKey = $modx->getOption('openai_api_key', null, '');
if (empty($openAIApiKey)) {
    $modx->log(MODX_LOG_LEVEL_ERROR, '[LocalAIze] Missing openai_api_key. Please set system setting "openai_api_key".');
    return;
}

// Map context => language code
$contextToLanguage = [
    'web' => 'en',  // main context is English
    'de'  => 'de',  // Deutsch
    'es'  => 'es',  // Español
    'sv'  => 'sv',  // Svenska
];

// The contexts you want to handle
$allContexts = ['web', 'de', 'es', 'sv'];

// Fields to translate
$fieldsToTranslate = [
    'pagetitle',
    'longtitle',
    'description',
    'introtext',
    'content',
];

// Should newly created translations be published?
$publishTranslations = false;

// Identify current context
$currentContextKey = $resource->get('context_key');
if (!in_array($currentContextKey, $allContexts, true)) {
    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Skipping: context '{$currentContextKey}' not in ".implode(', ', $allContexts));
    return;
}

Let’s assume that English (i.e. web) is our source language - the plugin now has to check to see which checkboxes you have selected to translate into, as well as checking to see if the ID field is empty for the corresponding language. We obvioudly don’t want to overwrite an existing translation - so it will only run if the ID field is empty:

//------------------------------------------------------
// 2. Determine which translations to create (based on Switch + ID TVs)
//    For example, "localizeDE" => yes/no, "localizeDEID" => ID storage
//------------------------------------------------------
$tvConfig = [
    'de' => [
        'switchTv' => 'localizeDE',
        'idTv'     => 'localizeDEID',
    ],
    'es' => [
        'switchTv' => 'localizeES',
        'idTv'     => 'localizeESID',
    ],
    'sv' => [
        'switchTv' => 'localizeSV',
        'idTv'     => 'localizeSVID',
    ],
    
];

$targetContexts = array_diff($allContexts, [$currentContextKey]);

// Gather content to translate from the Resource
$contentToTranslate = [];
foreach ($fieldsToTranslate as $field) {
    $val = $resource->get($field);
    if (!empty($val)) {
        $contentToTranslate[$field] = $val;
    }
}
if (empty($contentToTranslate)) {
    $modx->log(MODX_LOG_LEVEL_INFO, '[LocalAIze] No fields to translate for this Resource.');
    return;
}

This is where things started to get a bit complicated. It’s all fine to create and translate the resource - but what about keeping the site structure? What about the non-translatable TV values in the original resource? This is what ended up working for me:

//------------------------------------------------------
// 3. Create/Translate a Resource per target context
//------------------------------------------------------
foreach ($targetContexts as $targetContextKey) {
    // Check if we have config for that target context
    if (!isset($tvConfig[$targetContextKey])) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] No TV config found for '{$targetContextKey}', skipping.");
        continue;
    }

    // Grab the Switch TV + ID TV
    $switchTvName = $tvConfig[$targetContextKey]['switchTv'];
    $idTvName     = $tvConfig[$targetContextKey]['idTv'];

    // Does the user want a translation for this context? (Check Switch TV)
    $switchValue = $resource->getTVValue($switchTvName);
    if (strtolower(trim($switchValue)) !== 'yes') {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] TV {$switchTvName} = '{$switchValue}' => skip {$targetContextKey}.");
        continue;
    }

    // If an ID is already stored, skip
    $existingId = $resource->getTVValue($idTvName);
    if (!empty($existingId)) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Found existing ID (#{$existingId}) in TV {$idTvName}, skipping.");
        continue;
    }

    // Check if same parent/pagetitle combo exists in target context
    $exists = $modx->getObject(modResource::class, [
        'context_key' => $targetContextKey,
        'parent'      => $resource->get('parent'),
        'pagetitle'   => $resource->get('pagetitle'),
    ]);
    if ($exists) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] A resource with same pagetitle/parent already exists in '{$targetContextKey}'. Skipping.");
        continue;
    }

    //------------------------------------------------------
    // 3.1 Determine new parent ID (if parent's localized)
    //------------------------------------------------------
    $originalParentId = (int) $resource->get('parent');
    $newParentId = 0;

    if ($originalParentId > 0) {
        // Load the original parent resource
        $originalParent = $modx->getObject(modResource::class, $originalParentId);
        if ($originalParent) {
            // If parent is also localized
            if (isset($tvConfig[$targetContextKey])) {
                $parentIdTvName = $tvConfig[$targetContextKey]['idTv'];
                $parentTranslationId = $originalParent->getTVValue($parentIdTvName);

                if (!empty($parentTranslationId)) {
                    $newParentId = (int) $parentTranslationId;
                    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Found parent translation (#{$newParentId}) for context '{$targetContextKey}'.");
                } else {
                    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Parent not localized in {$targetContextKey}, child => root.");
                }
            }
        }
    }

    //------------------------------------------------------
    // 3.2 Create the new resource
    //------------------------------------------------------
    $newResource = $modx->newObject(modResource::class);
    if (!$newResource) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Failed creating resource object for '{$targetContextKey}'.");
        continue;
    }

    // Copy all core fields from the original, removing the ID, alias, etc.
    $data = $resource->toArray();
    unset($data['id'], $data['alias'], $data['uri'], $data['uri_override'], 
          $data['createdon'], $data['editedon']);

    $newResource->fromArray($data, '', false, true);

    // Force unique context, alias, published, etc.
    $newResource->set('context_key', $targetContextKey);
    $newResource->set('parent', $newParentId);
    $newResource->set('alias', null);
    $newResource->set('uri', null);
    $newResource->set('published', $publishTranslations);

    // Translate the core content fields
    foreach ($contentToTranslate as $field => $originalText) {
        $translatedText = localAIzeTranslate(
            $originalText,
            $currentContextKey,
            $targetContextKey,
            $contextToLanguage,
            $openAIApiKey
        );
        $newResource->set($field, $translatedText);
    }

    //------------------------------------------------------
    // 3.3 Save the new resource (so we have an ID) 
    //------------------------------------------------------
    if (!$newResource->save()) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Failed to save new resource in '{$targetContextKey}'.");
        continue;
    }

    $newId = $newResource->get('id');
    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Created Resource #{$newId} in context '{$targetContextKey}'.");

    //------------------------------------------------------
    // 3.4 Copy ALL TVs from original to new resource
    //------------------------------------------------------
    /** @var modTemplateVar[] $originalTVs */
    $originalTVs = $resource->getTemplateVars(); 
    if (is_array($originalTVs)) {
        foreach ($originalTVs as $tvObj) {
            $tvName = $tvObj->get('name');
            $tvValue = $resource->getTVValue($tvName); // the original resource's TV value

            // If you want to skip certain TVs (like localizeDEID, etc.), do a check here
            // For now, let's copy everything exactly:
            $newResource->setTVValue($tvName, $tvValue);
        }
        // Save again to persist all copied TV values
        $newResource->save();
    }

    //------------------------------------------------------
    // 3.5 Overwrite localizeXXID on the ORIGINAL resource 
    //     to store the newly created resource's ID
    //------------------------------------------------------
    $resource->setTVValue($idTvName, $newId);
    $resource->save();

    // Optionally, if you want the *new Resource* to store a link back
    // to the original resource ID in a TV like localizeENID, do that here:
     $newResource->setTVValue('localizeENID', $resource->get('id'));
     $newResource->save();
}

// Done
return;

It’s pretty messy, but essentially, it grabs the ID of the newly created resource and writes it to the localizeXXID of the original document (as well as the other translated versions) so that you always have a connection between them. It also copies all the TV values into the new resource. Defining which TVs to translate and doing that automatically is a feature for v2 :).

It also checks to see what the equiavlent localized ID of the parent is and tries to put the new resource in that parent, so the site structure stays the same. Note that I have only tested with this one level so far so - caveat emptor, and all that.

Finally, you have a function which sends everything to the OpenAI API and gets the response:

//------------------------------------------------------
// 4. localAIzeTranslate() - The OpenAI Helper Function
//------------------------------------------------------
function localAIzeTranslate($text, $sourceContextKey, $targetContextKey, array $contextToLanguage, $openAIApiKey)
{
    global $modx;
    
    $sourceLang = $contextToLanguage[$sourceContextKey];
    $targetLang = $contextToLanguage[$targetContextKey];

    if ($sourceLang === $targetLang) {
        return $text;
    }

    $messages = [
        [
            'role'    => 'system',
            'content' => "You are a helpful translator. Translate from {$sourceLang} to {$targetLang}, returning only the translation."
        ],
        [
            'role'    => 'user',
            'content' => $text
        ],
    ];

    $payload = [
        'model'       => 'gpt-3.5-turbo',
        'messages'    => $messages,
        'temperature' => 0.2,
    ];

    $ch = curl_init('https://api.openai.com/v1/chat/completions');
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        "Authorization: Bearer {$openAIApiKey}",
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    $response   = curl_exec($ch);
    $curlError  = curl_error($ch);
    $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($curlError) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] cURL Error: {$curlError}");
        return $text; // fallback
    }
    if ($httpStatus !== 200) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] OpenAI API error: HTTP {$httpStatus} - {$response}");
        return $text; // fallback
    }

    $data = json_decode($response, true);
    if (empty($data['choices'][0]['message']['content'])) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Invalid translation response: {$response}");
        return $text;
    }

    return trim($data['choices'][0]['message']['content']);
}

Hey presto! You should now have a plugin that automatically translates your resrouces for you, if you check the box, and puts them in the right place for you.

As mentioned, this is a working (but perhaps not amazingly efficient) plugin. There are plenty of improvements that I would like to make, such as:

  • Select the API you want to use dynamically (DeepL, Gemini, OpenAI GPT3.5, 4, 1o - whatever).
  • Make the TV selection thing a bit more ergonomic and nicer e.g. link the checkbox and text using MGIX
  • Enable different translation styles (casual, formal etc.) using demons.
  • Translate the relevant TVs (e.g. Text, Textarea) automatically as well.
  • Automatically recognize which contexts you have set up without having to change this manually in the code.

Have a play around with it and let me know how you get on. Would love to hear feeedback and ideas from the community.

Until next time.

COMPLETE PLUGIN CODE


<?php
/**
 * LocalAIze Plugin for MODX 3.x 
 * Refactored to:
 *   - Duplicate ALL TVs from the original Resource to the new Resource.
 *   - Translate core fields (including 'summary'), 
 *   - Check if parent is localized, 
 *   - Store cross-links in TVs if desired.
 *
 * Attach to: OnDocFormSave
 */

use MODX\Revolution\modResource;

if ($modx->event->name !== 'OnDocFormSave') {
    return;
}

if (!$resource instanceof modResource) {
    return;
}

// Only proceed if newly created or updated
if ($mode !== 'new' && $mode !== 'upd') {
    $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze DEBUG] Exiting: mode={$mode} is neither 'new' nor 'upd'.");
    return;
}

//------------------------------------------------------
// 1. Load configuration
//------------------------------------------------------
$openAIApiKey = $modx->getOption('openai_api_key', null, '');
if (empty($openAIApiKey)) {
    $modx->log(MODX_LOG_LEVEL_ERROR, '[LocalAIze] Missing openai_api_key. Please set system setting "openai_api_key".');
    return;
}

// Map context => language code
$contextToLanguage = [
    'web' => 'en',  // main context is English
    'de'  => 'de',  // Deutsch
    'es'  => 'es',  // Español
    'sv'  => 'sv',  // Svenska
];

// The contexts you want to handle
$allContexts = ['web', 'de', 'es', 'sv'];

// Fields to translate (including "summary" if used)
$fieldsToTranslate = [
    'pagetitle',
    'longtitle',
    'description',
    'introtext',
    'content',
];

// Should newly created translations be published?
$publishTranslations = false;

// Identify current context
$currentContextKey = $resource->get('context_key');
if (!in_array($currentContextKey, $allContexts, true)) {
    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Skipping: context '{$currentContextKey}' not in ".implode(', ', $allContexts));
    return;
}

//------------------------------------------------------
// 2. Determine which translations to create (based on Switch + ID TVs)
//    For example, "localizeDE" => yes/no, "localizeDEID" => ID storage
//------------------------------------------------------
$tvConfig = [
    'de' => [
        'switchTv' => 'localizeDE',
        'idTv'     => 'localizeDEID',
    ],
    'es' => [
        'switchTv' => 'localizeES',
        'idTv'     => 'localizeESID',
    ],
    'sv' => [
        'switchTv' => 'localizeSV',
        'idTv'     => 'localizeSVID',
    ],
    
];

$targetContexts = array_diff($allContexts, [$currentContextKey]);

// Gather content to translate from the Resource
$contentToTranslate = [];
foreach ($fieldsToTranslate as $field) {
    $val = $resource->get($field);
    if (!empty($val)) {
        $contentToTranslate[$field] = $val;
    }
}
if (empty($contentToTranslate)) {
    $modx->log(MODX_LOG_LEVEL_INFO, '[LocalAIze] No fields to translate for this Resource.');
    return;
}

//------------------------------------------------------
// 3. Create/Translate a Resource per target context
//------------------------------------------------------
foreach ($targetContexts as $targetContextKey) {
    // Check if we have config for that target context
    if (!isset($tvConfig[$targetContextKey])) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] No TV config found for '{$targetContextKey}', skipping.");
        continue;
    }

    // Grab the Switch TV + ID TV
    $switchTvName = $tvConfig[$targetContextKey]['switchTv'];
    $idTvName     = $tvConfig[$targetContextKey]['idTv'];

    // Does the user want a translation for this context? (Check Switch TV)
    $switchValue = $resource->getTVValue($switchTvName);
    if (strtolower(trim($switchValue)) !== 'yes') {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] TV {$switchTvName} = '{$switchValue}' => skip {$targetContextKey}.");
        continue;
    }

    // If an ID is already stored, skip
    $existingId = $resource->getTVValue($idTvName);
    if (!empty($existingId)) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Found existing ID (#{$existingId}) in TV {$idTvName}, skipping.");
        continue;
    }

    // Check if same parent/pagetitle combo exists in target context
    $exists = $modx->getObject(modResource::class, [
        'context_key' => $targetContextKey,
        'parent'      => $resource->get('parent'),
        'pagetitle'   => $resource->get('pagetitle'),
    ]);
    if ($exists) {
        $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] A resource with same pagetitle/parent already exists in '{$targetContextKey}'. Skipping.");
        continue;
    }

    //------------------------------------------------------
    // 3.1 Determine new parent ID (if parent's localized)
    //------------------------------------------------------
    $originalParentId = (int) $resource->get('parent');
    $newParentId = 0;

    if ($originalParentId > 0) {
        // Load the original parent resource
        $originalParent = $modx->getObject(modResource::class, $originalParentId);
        if ($originalParent) {
            // If parent is also localized
            if (isset($tvConfig[$targetContextKey])) {
                $parentIdTvName = $tvConfig[$targetContextKey]['idTv'];
                $parentTranslationId = $originalParent->getTVValue($parentIdTvName);

                if (!empty($parentTranslationId)) {
                    $newParentId = (int) $parentTranslationId;
                    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Found parent translation (#{$newParentId}) for context '{$targetContextKey}'.");
                } else {
                    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Parent not localized in {$targetContextKey}, child => root.");
                }
            }
        }
    }

    //------------------------------------------------------
    // 3.2 Create the new resource
    //------------------------------------------------------
    $newResource = $modx->newObject(modResource::class);
    if (!$newResource) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Failed creating resource object for '{$targetContextKey}'.");
        continue;
    }

    // Copy all core fields from the original, removing the ID, alias, etc.
    $data = $resource->toArray();
    unset($data['id'], $data['alias'], $data['uri'], $data['uri_override'], 
          $data['createdon'], $data['editedon']);

    $newResource->fromArray($data, '', false, true);

    // Force unique context, alias, published, etc.
    $newResource->set('context_key', $targetContextKey);
    $newResource->set('parent', $newParentId);
    $newResource->set('alias', null);
    $newResource->set('uri', null);
    $newResource->set('published', $publishTranslations);

    // Translate the core content fields
    foreach ($contentToTranslate as $field => $originalText) {
        $translatedText = localAIzeTranslate(
            $originalText,
            $currentContextKey,
            $targetContextKey,
            $contextToLanguage,
            $openAIApiKey
        );
        $newResource->set($field, $translatedText);
    }

    //------------------------------------------------------
    // 3.3 Save the new resource (so we have an ID) 
    //------------------------------------------------------
    if (!$newResource->save()) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Failed to save new resource in '{$targetContextKey}'.");
        continue;
    }

    $newId = $newResource->get('id');
    $modx->log(MODX_LOG_LEVEL_INFO, "[LocalAIze] Created Resource #{$newId} in context '{$targetContextKey}'.");

    //------------------------------------------------------
    // 3.4 Copy ALL TVs from original to new resource
    //------------------------------------------------------
    /** @var modTemplateVar[] $originalTVs */
    $originalTVs = $resource->getTemplateVars(); 
    if (is_array($originalTVs)) {
        foreach ($originalTVs as $tvObj) {
            $tvName = $tvObj->get('name');
            $tvValue = $resource->getTVValue($tvName); // the original resource's TV value

            // If you want to skip certain TVs (like localizeDEID, etc.), do a check here
            // For now, let's copy everything exactly:
            $newResource->setTVValue($tvName, $tvValue);
        }
        // Save again to persist all copied TV values
        $newResource->save();
    }

    //------------------------------------------------------
    // 3.5 Overwrite localizeXXID on the ORIGINAL resource 
    //     to store the newly created resource's ID
    //------------------------------------------------------
    $resource->setTVValue($idTvName, $newId);
    $resource->save();

    // Optionally, if you want the *new Resource* to store a link back
    // to the original resource ID in a TV like localizeENID, do that here:
     $newResource->setTVValue('localizeENID', $resource->get('id'));
     $newResource->save();
}

// Done
return;


//------------------------------------------------------
// 4. localAIzeTranslate() - The OpenAI Helper Function
//------------------------------------------------------
function localAIzeTranslate($text, $sourceContextKey, $targetContextKey, array $contextToLanguage, $openAIApiKey)
{
    global $modx;
    
    $sourceLang = $contextToLanguage[$sourceContextKey];
    $targetLang = $contextToLanguage[$targetContextKey];

    if ($sourceLang === $targetLang) {
        return $text;
    }

    $messages = [
        [
            'role'    => 'system',
            'content' => "You are a helpful translator. Translate from {$sourceLang} to {$targetLang}, returning only the translation."
        ],
        [
            'role'    => 'user',
            'content' => $text
        ],
    ];

    $payload = [
        'model'       => 'gpt-3.5-turbo',
        'messages'    => $messages,
        'temperature' => 0.2,
    ];

    $ch = curl_init('https://api.openai.com/v1/chat/completions');
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        "Authorization: Bearer {$openAIApiKey}",
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

    $response   = curl_exec($ch);
    $curlError  = curl_error($ch);
    $httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($curlError) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] cURL Error: {$curlError}");
        return $text; // fallback
    }
    if ($httpStatus !== 200) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] OpenAI API error: HTTP {$httpStatus} - {$response}");
        return $text; // fallback
    }

    $data = json_decode($response, true);
    if (empty($data['choices'][0]['message']['content'])) {
        $modx->log(MODX_LOG_LEVEL_ERROR, "[LocalAIze] Invalid translation response: {$response}");
        return $text;
    }

    return trim($data['choices'][0]['message']['content']);
}
4 Likes