Translate entire websites in AEM automatically with OpenAI

Jeremy Lanssiers
14 min readNov 13, 2023

--

We set up a Translation Workflow in AEM that can translate entire websites in 5 clicks. The workflow can be finetuned to translate only single pages and only specific fields in specific components.

The Translation Workflow in action.

The source code used in this blogpost can be found at https://github.com/jlanssie/wknd or its branch https://github.com/jlanssie/wknd/tree/feature/ai-trainslations

Setup

First, we are going to create a Workflow Step for the Translation Workflow. Afterwards, we will create the Workflow Model. This will allow you to see a Workflow in the Granite UI.

Secondly, we will create the Workflow Process and associated Java Classes. And we will also set the necessary OSGI config. This will allow you to see the Services coming up in the Felix console.

Finally, we will create a token at OpenAI. This will allow you to start a translation workflow via the Granite UI, read the OSGI logs and eventually see the pages getting translated.

Process Architecture

In order not to block the main AEM thread and to minimize the calls to our OpenAI translation endpoint, our translation workflow will create translation jobs for each page that needs to be translated and the Sling Job Manager will execute those jobs asynchronously.

A rudimentary overview of the Translation Workflow setup.

Translation Workflow Step

We will create a My Translation Workflow Process as a step for our upcoming My Translation Workflow Model.

Create a node config file at ui.content/src/main/content/jcr_root/conf/wknd/settings/workflow/models/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:Folder"/>

Create a node config file at ui.content/src/main/content/jcr_root/conf/wknd/settings/workflow/models/mytranslationworkflow/.content.xml

<jcr:root
xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
xmlns:cq="http://www.day.com/jcr/cq/1.0"
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
jcr:primaryType="cq:Page">
<jcr:content
cq:designPath="/libs/settings/wcm/designs/default"
cq:template="/libs/cq/workflow/templates/model"
jcr:primaryType="cq:PageContent"
jcr:title="My Translation Workflow Configuration"
sling:resourceType="cq/workflow/components/pages/model"
transient="{Boolean}true">
<flow
jcr:primaryType="nt:unstructured"
sling:resourceType="foundation/components/parsys">
<process
jcr:primaryType="nt:unstructured"
jcr:title="My Translation Workflow Process"
jcr:description="Translates pages."
sling:resourceType="cq/workflow/components/model/process">
<metaData
jcr:primaryType="nt:unstructured"
PROCESS="com.adobe.aem.guides.wknd.core.workflows.MyTranslationWorkflowProcess"
PROCESS_AUTO_ADVANCE="true"/>
</process>
</flow>
</jcr:content>
</jcr:root>

Translation Workflow Model

We will use the newly created My Translation Workflow Process in a Workflow Model, so Authors can find and select My Translation Workflow from the Granite UI Actions Bar, via Create > Workflow.

Create a node config file at ui.content/src/main/content/jcr_root/var/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:Folder"/>

Create a node config file at ui.content/src/main/content/jcr_root/var/workflow/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:Folder"/>

Create a node config file at ui.content/src/main/content/jcr_root/var/workflow/models/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:Folder"/>

Create a node config file at ui.content/src/main/content/jcr_root/var/workflow/models/wknd/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:Folder"/>

Create a node config file at ui.content/src/main/content/jcr_root/var/workflow/models/wknd/mytranslationworkflow/.content.xml

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root
xmlns:jcr="http://www.jcp.org/jcr/1.0"
xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:cq="http://www.day.com/jcr/cq/1.0"
jcr:primaryType="cq:WorkflowModel"
jcr:isCheckedOut="{Boolean}false"
sling:resourceType="cq/workflow/components/model"
title="My Translation Workflow"
description="Translates pages.">
<metaData
cq:generatingPage="/conf/wknd/settings/workflow/models/mytranslationworkflow/jcr:content"
jcr:primaryType="nt:unstructured"
transient="{Boolean}true"/>
<nodes jcr:primaryType="nt:unstructured">
<node0
jcr:primaryType="cq:WorkflowNode"
title="Start"
type="START">
<metaData jcr:primaryType="nt:unstructured"/>
</node0>
<node1
jcr:primaryType="cq:WorkflowNode"
title="My Translation Workflow Process"
type="PROCESS">
<metaData
jcr:primaryType="nt:unstructured"
PROCESS="com.adobe.aem.guides.wknd.core.workflows.MyTranslationWorkflowProcess"
PROCESS_ARGS="Sample Argument"
PROCESS_AUTO_ADVANCE="true"/>
</node1>
<node2
jcr:primaryType="cq:WorkflowNode"
title="End"
type="END">
<metaData jcr:primaryType="nt:unstructured"/>
</node2>
</nodes>
<transitions jcr:primaryType="nt:unstructured">
<node0_x0023_node1
jcr:primaryType="cq:WorkflowTransition"
from="node0"
rule=""
to="node1">
<metaData jcr:primaryType="nt:unstructured"/>
</node0_x0023_node1>
<node1_x0023_node2
jcr:primaryType="cq:WorkflowTransition"
from="node1"
to="node2">
<metaData jcr:primaryType="nt:unstructured"/>
</node1_x0023_node2>
</transitions>
</jcr:root>

Adapt the vault filters at ui.content/src/main/content/META-INF/vault/filter.xml and add

    <filter root="/conf/wknd/settings/workflow" mode="update"/>
<filter root="/var" mode="update"/>
You can find My Translation Workflow among your Workflow Models
You can see the My Translation Workflow Process as a step of the My Translation Workflow
You can run the workflow via the Granite UI Actions Bar. Create > Workflow.

My Translation Workflow Process

We referenced a process com.adobe.aem.guides.wknd.core.workflows.MyTranslationWorkflowProcess inside our My Translation Workflow Process. We need to create this Java Class.

Create a Java Class at core/src/main/java/com/adobe/aem/guides/wknd/core/workflows/MyTranslationWorkflowProcess.java

package com.adobe.aem.guides.wknd.core.workflows;

import com.adobe.aem.guides.wknd.core.jobs.TranslationJob;
import com.adobe.aem.guides.wknd.core.services.MyTranslationService;
import com.adobe.aem.guides.wknd.core.utils.ServiceUtils;
import com.adobe.aem.guides.wknd.core.utils.SlingUtils;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.WorkflowProcess;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.PageManager;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.event.jobs.JobManager;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;

@Component(
service = {WorkflowProcess.class},
property = {
"process.label=My Translation Workflow Process",
"process.description=Translates pages."
}
)
public class MyTranslationWorkflowProcess implements WorkflowProcess {

private final Logger logger = LoggerFactory.getLogger(getClass());
private String processArgs;
private boolean translateChildPages;

@Reference
private JobManager jobManager;

@Reference
private ResourceResolverFactory resourceResolverFactory;

@Reference
private MyTranslationService myTranslationService;


public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap metaDataMap) {
logger.debug(ServiceUtils.PROCESS_STARTED);

final String PAYLOAD = workItem.getWorkflowData().getPayload().toString();
final String USERID = workItem.getWorkflowData().getMetaDataMap().get("userId").toString();

if (metaDataMap.containsKey("PROCESS_ARGS")){
processArgs = metaDataMap.get("PROCESS_ARGS",String.class);
} else {
processArgs = StringUtils.EMPTY;
}

logger.debug("Payload: {} | UserId: {} | Process Args: {}", PAYLOAD, USERID, processArgs);

PageManager pageManager = SlingUtils.getPageManager(resourceResolverFactory, ServiceUtils.SERVICE_USER);
if (pageManager == null) {
logger.debug(ServiceUtils.PROCESS_STOPPED);
return;
}

Page page = pageManager.getPage(PAYLOAD);

if (myTranslationService != null) {
translateChildPages = myTranslationService.translateChildPages();
} else {
translateChildPages = false;
}

if (translateChildPages) {
logger.debug("Starting translation jobs for {} and its child pages.", page.getPath());
translateTree(page);
} else {
logger.debug("Starting translation job for {}.", page.getPath());
translatePage(page);
}

logger.debug(ServiceUtils.PROCESS_FINISHED);
}

private void translateTree(Page page) {
translatePage(page);

page.listChildren().forEachRemaining(this::translateTree);
}

private void translatePage(Page page) {
Map<String, Object> map = new HashMap<>();
map.put(TranslationJob.DataTypes.PATH.getDataType(), page.getPath());
map.put(TranslationJob.DataTypes.PROCESS_ARGS.getDataType(), processArgs);

jobManager.addJob(TranslationJob.topic, map);
}
}

In this Java Class, we create a Workflow Process which takes the payload, but which also reads the user who is executing the workflow. A future extension might be to to allow only certain users or user groups to execute this workflow.

We are also calling the MyTranslationService to get a config, we will set a boolean on this service to determine if our worfklow needs to translate a page tree or on a page-by-page basis. A future extension might be to allow that setting via the Workflow or create two Workflows:one for a page-by-page translation and one for a page tree translation.

We are also using two utility classes.

SlingUtils

Create a Java Class at core/src/main/java/com/adobe/aem/guides/wknd/core/utils/SlingUtils.java

package com.adobe.aem.guides.wknd.core.utils;

import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.PageManager;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Locale;
import java.util.Map;
import java.util.Optional;

import static com.adobe.aem.guides.wknd.core.utils.ServiceUtils.getAuthInfo;

public class SlingUtils {

private static final Logger logger = LoggerFactory.getLogger(SlingUtils.class);

public static ResourceResolver getResourceResolver(ResourceResolverFactory resourceResolverFactory, String serviceUser) {
final Map<String, Object> authInfo = getAuthInfo(serviceUser);

ResourceResolver resourceResolver;
try {
resourceResolver = resourceResolverFactory.getServiceResourceResolver(authInfo);
logger.debug("{} running ResourceResolver", serviceUser);
} catch ( LoginException e) {
logger.error("Login failed. ResourceResolver could not be retrieved.", e);
return null;
}

return resourceResolver;
}

public static PageManager getPageManager(ResourceResolverFactory resourceResolverFactory, String serviceUser) {
ResourceResolver resourceResolver = getResourceResolver(resourceResolverFactory, serviceUser);;

PageManager pageManager = Optional.ofNullable(resourceResolver).map(rr -> rr.adaptTo(PageManager.class)).orElse(null);
if (pageManager == null) {
logger.error("PageManager could not be retrieved.");
return null;
}

return pageManager;
}

public static Locale getLanguage(ResourceResolverFactory resourceResolverFactory, String serviceUser, String path) {
PageManager pageManager = getPageManager(resourceResolverFactory, serviceUser);;
if (pageManager != null){

Page page = pageManager.getPage(path);
if (page != null) {
return page.getLanguage();
}
}

return new Locale ("en", "US");
}
}

In this Java Class, we set up 3 utility methods to easily get Resource Resolvers, Page Managers and a Page’s language, which will be used for automatic translations.

ServiceUtils

Create a Java Class at core/src/main/java/com/adobe/aem/guides/wknd/core/utils/ServiceUtils.java

package com.adobe.aem.guides.wknd.core.utils;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.ResourceResolverFactory;

import java.util.Collections;
import java.util.Map;

public class ServiceUtils {

public static final String PROCESS_STARTED = "Process started.";
public static final String PROCESS_STOPPED = "Process stopped.";
public static final String PROCESS_FINISHED = "Process finished.";
public static final String SERVICE_ACTIVATED = "Service activated.";
public static final String SERVICE_MODIFIED = "Service modified.";
public static final String SERVICE_DEACTIVATED = "Service deactivated.";
public static final String SERVICE_DISABLED = "Service disabled.";
public static final String SERVICE_USER = "wkndservice";

public static Map<String, Object> getAuthInfo(String serviceUser) {
if (StringUtils.isBlank(serviceUser)) { serviceUser = StringUtils.EMPTY; }
return Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, serviceUser);
}
}

In this Java Class, we set up a method to get an authentication Map, which is used for authenticating our Services. We also set up some resuable logging constants.

Translation Job

Create a Java Class at core/src/main/java/com/adobe/aem/guides/wknd/core/jobs/TranslationJob.java

package com.adobe.aem.guides.wknd.core.jobs;

import com.adobe.aem.guides.wknd.core.services.MyTranslationService;
import com.adobe.aem.guides.wknd.core.utils.ServiceUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component(
service = {JobConsumer.class, TranslationJob.class},
immediate = true,
property = {JobConsumer.PROPERTY_TOPICS + "=" + TranslationJob.topic}
)
public class TranslationJob implements JobConsumer {

private final Logger logger = LoggerFactory.getLogger(getClass());
public static final String topic = "translationJob";

@Reference
private MyTranslationService myTranslationService;

public enum DataTypes {
PATH("path"), PROCESS_ARGS("processArgs");

private final String dataType;

DataTypes(String dataType) {
this.dataType = dataType;
}

public String getDataType() {
return dataType;
}
}

@Override
public JobResult process(Job job) {
logger.error(ServiceUtils.PROCESS_STARTED);

String path = job.getProperty(DataTypes.PATH.getDataType(), StringUtils.EMPTY);
String processArgs = job.getProperty(DataTypes.PROCESS_ARGS.getDataType(), StringUtils.EMPTY);

final boolean SUCCESS = myTranslationService.translate(path, processArgs);

if (!SUCCESS) {
logger.error("MyTranslationService returns a failure.");
logger.error(ServiceUtils.PROCESS_STOPPED);
return JobResult.FAILED;
}

logger.error(ServiceUtils.PROCESS_FINISHED);
return JobResult.OK;
}
}

In this Java class, we call the MyTranslationService to translate a single page. The Page’s path is definined within the Job it gets from the Sling Job Manager.

My Translation Service

Create a Java Interface for the My Translation Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/MyTranslationService.java

package com.adobe.aem.guides.wknd.core.services;

public interface MyTranslationService {
boolean translate(String path, String additionalArgs);

boolean translateChildPages();
}

Create a Java Configuration for the My Translation Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/conf/MyTranslationServiceConf.java


package com.adobe.aem.guides.wknd.core.services.conf;

import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.AttributeType;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;

@ObjectClassDefinition(
name = "My Translation Service",
description = "Translates pages."
)
public @interface MyTranslationServiceConf {
@AttributeDefinition(
name = "Enabled",
description = "Enable the service's functionalities.",
type = AttributeType.BOOLEAN)
boolean enabled() default true;

@AttributeDefinition(
name = "Translate Child pages",
description = "Enables the MyTranslationWorkflowProcess to create translation jobs for child pages of any payload.",
type = AttributeType.BOOLEAN)
boolean translate_child_pages() default true;

@AttributeDefinition(
name = "Node Properties",
description = "Defines which node properties should be translated.",
type = AttributeType.STRING)
String[] node_properties() default {};

@AttributeDefinition(
name = "API Endpoint",
description = "Defines to which API endpoint translation requests are sent.",
type = AttributeType.STRING)
String llm_endpoint() default "";

@AttributeDefinition(
name = "Model",
description = "Defines the LLM's model.",
type = AttributeType.STRING)
String llm_model() default "";

@AttributeDefinition(
name = "Content Type",
description = "Defines the POST request's contentType header.",
type = AttributeType.STRING)
String llm_content_type() default "application/json";

@AttributeDefinition(
name = "Model",
description = "Defines the POST request's Authorization header.",
type = AttributeType.STRING)
String llm_authorization() default "";
}

Create an OSGI config file for the My Translation Service at ui.config/src/main/content/jcr_root/apps/wknd/osgiconfig/config/com.adobe.aem.guides.wknd.core.services.impl.MyTranslationServiceImpl.cfg.json

{
"enabled": true,
"translate.child.pages": true,
"node.properties": ["jcr:title","jcr:description","title","text"],
"llm.endpoint": "https://api.openai.com/v1/chat/completions",
"llm.model": "gpt-3.5-turbo",
"llm.content.type": "application/json",
"llm.authorization": "Bearer yourTokenHere"
}

Note. We will be translating jcr:title, jcr:description, title, text properties on JCR nodes.

Create a Service User wkndservice config file for the My Translation Service at ui.config/src/main/content/jcr_root/apps/wknd/osgiconfig/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-wkndservice.config

{
"user.mapping": [
"wknd.core:wkndservice=wkndservice"
]
}

Create Service User permissions for the wkndservice user config file at ui.config/src/main/content/jcr_root/apps/wknd/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-wkndservice.cfg.json

scripts=["
create service user wkndservice with forced path system/cq:services/wkndservice
set principal ACL for wkndservice
allow jcr:read on /content
end
"]

Create a Java Class that implements the My Translation Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/impl/MyTranslationServiceImpl.java

package com.adobe.aem.guides.wknd.core.services.impl;

import com.adobe.aem.guides.wknd.core.services.HttpClientService;
import com.adobe.aem.guides.wknd.core.services.MyTranslationService;
import com.adobe.aem.guides.wknd.core.services.conf.MyTranslationServiceConf;
import com.adobe.aem.guides.wknd.core.utils.HttpRequestUtils;
import com.adobe.aem.guides.wknd.core.utils.ServiceUtils;
import com.adobe.aem.guides.wknd.core.utils.SlingUtils;
import com.day.cq.commons.jcr.JcrConstants;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.sling.api.resource.ModifiableValueMap;
import org.apache.sling.api.resource.PersistenceException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ValueMap;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

@Component(
service = MyTranslationService.class,
configurationPolicy = ConfigurationPolicy.OPTIONAL,
immediate = true)
@Designate(ocd = MyTranslationServiceConf.class)
public class MyTranslationServiceImpl implements MyTranslationService {

private final Logger logger = LoggerFactory.getLogger(getClass());
private MyTranslationServiceConf configuration;
private List<String> properties;

@Reference
private HttpClientService httpClientService;

@Reference
private ResourceResolverFactory resourceResolverFactory;

@Activate
public void activate(final MyTranslationServiceConf configuration) {
logger.info(ServiceUtils.SERVICE_ACTIVATED);
this.configuration = configuration;
this.properties = Arrays.asList(configuration.node_properties());
}

@Modified
public void modified(final MyTranslationServiceConf configuration) {
logger.info(ServiceUtils.SERVICE_MODIFIED);
this.configuration = configuration;
this.properties = Arrays.asList(configuration.node_properties());
}

@Deactivate
public void deactivate(final MyTranslationServiceConf configuration) {
logger.info(ServiceUtils.SERVICE_DEACTIVATED);
this.configuration = configuration;
}

@Override
public boolean translate(String path, String additionalArgs) {
if (configuration.enabled()) {
logger.debug(ServiceUtils.PROCESS_STARTED + " at path {}", path);

JsonObject translationObject = getTranslatableObject(path);

if (translationObject != null) {
logger.debug("Translatable Object: {}", translationObject);

String targetLanguage = SlingUtils.getLanguage(resourceResolverFactory, ServiceUtils.SERVICE_USER, path).toString();

JsonObject translatedObject;

try {
JsonObject responsePayload = requestTranslation(translationObject, targetLanguage);
JsonArray choices = responsePayload.getAsJsonArray("choices");
JsonObject choice = choices.get(0).getAsJsonObject();
JsonObject message = choice.getAsJsonObject("message");
String content = message.get("content").getAsString();

translatedObject = JsonParser.parseString(content).getAsJsonObject();
} catch (UnsupportedEncodingException e) {
logger.error("Unsupported Encoding Exception", e);
translatedObject = null;
}

if (translatedObject != null) {
logger.debug("Translated Object: {}.", translatedObject);
applyTranslatedObject(translatedObject, path);

logger.debug(ServiceUtils.PROCESS_FINISHED);
return true;
} else {
logger.debug("Translated Object is null");
}
} else {
logger.debug("Translation Object is null.");
}
logger.debug(ServiceUtils.PROCESS_STOPPED);
} else {
logger.debug(ServiceUtils.SERVICE_DISABLED);
}

return false;
}

@Override
public boolean translateChildPages() {
return configuration.translate_child_pages();
}

private JsonObject getTranslatableObject(String path) {
ResourceResolver resourceResolver = SlingUtils.getResourceResolver(resourceResolverFactory, ServiceUtils.SERVICE_USER);
if (resourceResolver == null) {
return null;
}

Resource resource = resourceResolver.getResource(path + "/" + JcrConstants.JCR_CONTENT);
if (resource == null) {
return null;
}

JsonObject translationObject = new JsonObject();

translateResources(resource, translationObject);

return translationObject;
}

private void translateResources(Resource resource, JsonObject translationObject) {
translateResource(resource, translationObject);
resource.listChildren().forEachRemaining(r -> translateResources(r, translationObject));
}

private void translateResource(Resource resource, JsonObject translationObject) {
logger.debug("Searching properties to translate at path {}", resource.getPath());

JsonObject propertyObj = new JsonObject();

ValueMap valueMap = resource.getValueMap();
for (Map.Entry<String, Object> entry : valueMap.entrySet()) {
String key = entry.getKey();

if (properties.contains(key)) {
String value = entry.getValue().toString();
propertyObj.addProperty(key, value);

logger.debug("Key: {} | Value: {}", key, value);
}
}

if (!propertyObj.entrySet().isEmpty()) {
translationObject.add(resource.getPath(), propertyObj);
}
}

private JsonObject requestTranslation(JsonObject translationObject, String targetLanguage) throws UnsupportedEncodingException {
HttpPost httpPost = new HttpPost(configuration.llm_endpoint());
httpPost.addHeader("Content-Type", configuration.llm_content_type());

// Consider changing plain text token with AEM Crypto?
httpPost.addHeader("Authorization", configuration.llm_authorization());

JsonObject payload = new JsonObject();
payload.addProperty("model", configuration.llm_model());
payload.add("messages", getMessages(translationObject, targetLanguage));

httpPost.setEntity(new StringEntity(payload.toString()));

logger.debug("Payload: {}", payload);
logger.debug("Http Post: {}", httpPost);

CloseableHttpClient closeableHttpClient = httpClientService.getHttpClient();

return HttpRequestUtils.makePostRequestJSONtoJSON(closeableHttpClient, httpPost);
}

private JsonArray getMessages(JsonObject translationObject, String targetLanguage) {
JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", String.format("Translate the values in the following JSON to %s and reply with a valid JSON object. %s", targetLanguage, translationObject.toString()));

JsonArray messages = new JsonArray();
messages.add(message);

return messages;
}

private void applyTranslatedObject(JsonObject jsonObject, String jcrNodePath) {
for (java.util.Map.Entry<String, JsonElement> entry : jsonObject.entrySet()) {
String key = entry.getKey();
JsonElement value = entry.getValue();

logger.debug("JCR node path is: {}", jcrNodePath);

if (value.isJsonObject()) {
logger.debug("Key is a node path: {}", key);
logger.debug("Value is a JsonObject: {}", value);

applyTranslatedObject(value.getAsJsonObject(), key);
}

if (value.isJsonPrimitive()) {
logger.debug("Key is a property: {}", key);
logger.debug("Value is a JsonPrimitive: {}", value);

applyTranslation(jcrNodePath, key, value.getAsString());
}
}
}

private void applyTranslation(String jcrNodePath, String key, String value) {
ResourceResolver resourceResolver = SlingUtils.getResourceResolver(resourceResolverFactory, ServiceUtils.SERVICE_USER);
if (resourceResolver == null) {
return;
}

Resource resource = resourceResolver.getResource(jcrNodePath);
if (resource == null) {
return;
}

try {
ModifiableValueMap modifiableValueMap = resource.adaptTo(ModifiableValueMap.class);
if (modifiableValueMap != null) {
logger.debug("Path: {}. Property: {}. Value: {}.", jcrNodePath, key, value);

modifiableValueMap.put(key, value);
resource.getResourceResolver().commit();
} else {
logger.error("Modifiable ValueMap is null.");
}
} catch (PersistenceException e) {
logger.error("Could not commit translation changes.");
}
}
}

In this Java Class, we translate a Page.

We loop over the Page’s node tree in JCR and populate a translationObject with node paths, properties and their respective values to be translated. e.g.

{
"/content/wknd/us/es/sample-page/jcr:content":{
"jcr:title":"Sample page"
},
"/content/wknd/us/es/sample-page/jcr:content/root/container/container/text":{
"text":"<p>This is test content.</p>\r\n"
},
"/content/wknd/us/es/sample-page/jcr:content/root/container/container/custom":{
"text":"text",
"title":"title"
},
"/content/wknd/us/es/sample-page/jcr:content/root/container/container/extendedcorecomponen":{
"text":"<p>text</p>\r\n"
}
}

Afterwards, we make an HTTP post call to the OpenAI endpoint, e.g.

{
"model":"gpt-3.5-turbo",
"messages":[
{
"role":"user",
"content":"Translate the values in the following JSON to es and reply with a valid JSON object. {\"/content/wknd/us/es/sample-page2/jcr:content\":{\"jcr:title\":\"Sample page\"},\"/content/wknd/us/es/sample-page2/jcr:content/root/container/container/text\":{\"text\":\"<p>This is test content.</p>\\r\\n\"}}"
}
]
}

We get a response from the OpenAI endpoint, parse that response for a translatedObject and then apply that object to our Page, effectively replacing the original content by translated content.

A future extension might be to make the translationService compatible with multiple LLM APIs and adapt the request payload. Currently, the service generates a payload tailored to OpenAI endpoint. The token is also stored within the OSGI config, but it might be safer to store this token in the AEM Crypto Service.

HTTP Client Service

Creating an httpClient opens ports on your machine. As we want to use as few ports as possible, we reuse the same httpClient. In order to achieve this, we create an HTTP Client Service that will have 1 reusable instance of an httpClient.

Create a Java Interface for the HTTP Client Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/HttpClientService.java

package com.adobe.aem.guides.wknd.core.services;

import org.apache.http.impl.client.CloseableHttpClient;

public interface HttpClientService {
CloseableHttpClient getHttpClient();
}

Create an Java Configuration for the HTTP Client Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/conf/HttpClientServiceConf.java

package com.adobe.aem.guides.wknd.core.services.conf;

import org.osgi.service.metatype.annotations.AttributeDefinition;
import org.osgi.service.metatype.annotations.AttributeType;
import org.osgi.service.metatype.annotations.ObjectClassDefinition;

@ObjectClassDefinition(
name = "My Http Client Service",
description = "Create HTTP Clients."
)
public @interface HttpClientServiceConf {
@AttributeDefinition(
name = "Time To Live",
description = "Time to live in ms.", type = AttributeType.INTEGER)
int time_to_live() default 50000;

@AttributeDefinition(
name = "Connection timeout",
description = "Time before timeout in ms.", type = AttributeType.INTEGER)
int connection_timeout() default 50000;

@AttributeDefinition(
name = "Max per route",
description = "Maximum connections per route.", type = AttributeType.INTEGER)
int max_per_route() default 20;

@AttributeDefinition(
name = "Max total",
description = "Maximum connections in total.", type = AttributeType.INTEGER)
int max_total() default 20;

@AttributeDefinition(
name = "Keep-Alive",
description = "Keep connections alive with Keep-Alive header or time to live.",
type = AttributeType.BOOLEAN)
boolean keep_alive() default true;
}

Note. The time to live and timeout values may be too short for long texts, you can adapt them and see what suits your use case best.

Create a Java Class that implements the HTTP Client Service at core/src/main/java/com/adobe/aem/guides/wknd/core/services/impl/HttpClientServiceImpl.java

package com.adobe.aem.guides.wknd.core.services.impl;

import com.adobe.aem.guides.wknd.core.services.HttpClientService;
import com.adobe.aem.guides.wknd.core.services.conf.HttpClientServiceConf;
import com.adobe.aem.guides.wknd.core.utils.ServiceUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HeaderElement;
import org.apache.http.HeaderElementIterator;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeaderElementIterator;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Modified;
import org.osgi.service.metatype.annotations.Designate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

@Component(
immediate = true,
service = HttpClientService.class,
configurationPolicy = ConfigurationPolicy.OPTIONAL,
configurationPid = "com.dxp.aem.core.services.client.HttpClientServiceImpl"
)
@Designate(ocd = HttpClientServiceConf.class)
public class HttpClientServiceImpl implements HttpClientService {

private final Logger logger = LoggerFactory.getLogger(getClass());
private HttpClientServiceConf configuration;
private HttpClientConnectionManager httpClientConnectionManager;
private CloseableHttpClient httpClient;

@Activate
protected void activate(final HttpClientServiceConf configuration) {
this.configuration = configuration;
createHttpClientConnectionManager();
createHttpClient();
}

@Modified
public void modified(final HttpClientServiceConf configuration) {
logger.info(ServiceUtils.SERVICE_MODIFIED);
this.configuration = configuration;
createHttpClientConnectionManager();
createHttpClient();
}

@Deactivate
public void deactivate(final HttpClientServiceConf configuration) {
logger.info(ServiceUtils.SERVICE_DEACTIVATED);
this.configuration = configuration;
shutdownClient();
}

public CloseableHttpClient getHttpClient() {
return httpClient;
}

private void createHttpClientConnectionManager() {
logger.debug("Creating HTTP client connection manager.");
shutdownClient();

PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(configuration.time_to_live(), TimeUnit.MILLISECONDS);
poolingHttpClientConnectionManager.setMaxTotal(configuration.max_total());
poolingHttpClientConnectionManager.setDefaultMaxPerRoute(configuration.max_per_route());
httpClientConnectionManager = poolingHttpClientConnectionManager;
}

private void createHttpClient() {
logger.debug("Creating HTTP client.");

HttpClientBuilder builder = HttpClientBuilder.create();
builder.setDefaultRequestConfig(generateRequestConfiguration());

if (httpClientConnectionManager != null) {
logger.debug("Setting connection manager");
builder.setConnectionManager(httpClientConnectionManager);
}

if (configuration.keep_alive()) {
logger.debug("Setting Keep-Alive header strategy.");
builder.setKeepAliveStrategy(generateKeepAliveStrategy());
}

httpClient = builder.build();
}

private RequestConfig generateRequestConfiguration() {
RequestConfig.Builder requestConfigBuilder = RequestConfig.copy(RequestConfig.DEFAULT);

requestConfigBuilder.setCookieSpec(CookieSpecs.IGNORE_COOKIES);

requestConfigBuilder
.setConnectTimeout(configuration.connection_timeout())
.setConnectionRequestTimeout(configuration.connection_timeout());

return requestConfigBuilder.build();
}

private ConnectionKeepAliveStrategy generateKeepAliveStrategy() {
return (response, context) -> {
HeaderElementIterator headerElementIterator = new BasicHeaderElementIterator(
response.headerIterator("Keep-Alive"));
while (headerElementIterator.hasNext()) {
HeaderElement headerElement = headerElementIterator.nextElement();
String param = headerElement.getName();
String value = headerElement.getValue();
if ("timeout".equalsIgnoreCase(param) && StringUtils.isNotEmpty(value)) {
try {
return Long.parseLong(value) * 1000;
} catch (NumberFormatException e) {
logger.warn("Invalid Keep-Alive header timeout on request {}. Defaulting to {} ms",headerElement, configuration.time_to_live());
return configuration.time_to_live();
}
}
}
return configuration.time_to_live();
};
}

private void shutdownClient() {
if (httpClientConnectionManager != null) {
httpClientConnectionManager.shutdown();
}

if (httpClient != null) {
try {
httpClient.close();
} catch (IOException e) {
logger.error("Could not close HTTP Client.", e);
}
}
}
}

HttpRequestUtils

Create a Java Class at core/src/main/java/com/adobe/aem/guides/wknd/core/utils/HttpRequestUtils.java

package com.adobe.aem.guides.wknd.core.utils;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.impl.client.CloseableHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;

public class HttpRequestUtils {

private static final Logger logger = LoggerFactory.getLogger(HttpRequestUtils.class);

private HttpRequestUtils() {
}

public static JsonObject makePostRequestJSONtoJSON(
CloseableHttpClient closeableHttpClient,
HttpRequestBase httpRequestBase) {

Reader reader = null;

try (CloseableHttpResponse closeableHttpResponse = closeableHttpClient.execute(httpRequestBase)) {
reader = new InputStreamReader(closeableHttpResponse.getEntity().getContent(), StandardCharsets.UTF_8);

int responseStatus = closeableHttpResponse.getStatusLine().getStatusCode();
JsonObject responsePayload = new Gson().fromJson(reader, JsonObject.class);

logger.debug("Response Status: {}.", responseStatus);
logger.debug("Response Payload: {}.", responsePayload);

if (responseStatus == 200) {
return responsePayload;
}
} catch (Exception e) {
logger.error("Exception.", e);
} finally {
httpRequestBase.releaseConnection();
logger.debug("Connection released.");

closeStream(reader);
}

return null;
}

private static void closeStream(Reader reader) {
if (reader != null) {
try {
reader.close();
logger.debug("Stream closed.");
} catch (IOException e) {
logger.error("IOException. Cannot close reader.", e);
}
}
}
}

The My Translation Workflow is now be able to be executed from the Granite UI.

Left shows the original state before running the workflow. Right shows the translated page titles after running the workflow.
Left shows the original state before running the workflow. Right shows the translated page after running the workflow.
Left shows the original state before running the workflow. Right shows the translated page after running the workflow.

--

--

Jeremy Lanssiers

Full-stack developer with an interest in Operating Systems, Backend development, Frontend development, Networking, Cloud and AI.