001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.ai;
029
030import org.opencms.ade.configuration.CmsADEConfigData;
031import org.opencms.ade.contenteditor.I_CmsContentTranslator;
032import org.opencms.ai.CmsTranslationUtil.FoundOrCreatedValue;
033import org.opencms.configuration.CmsConfigurationException;
034import org.opencms.configuration.CmsParameterConfiguration;
035import org.opencms.file.CmsFile;
036import org.opencms.file.CmsObject;
037import org.opencms.gwt.shared.CmsGwtConstants;
038import org.opencms.i18n.CmsLocaleManager;
039import org.opencms.main.CmsLog;
040import org.opencms.main.OpenCms;
041import org.opencms.util.CmsStringUtil;
042import org.opencms.xml.content.CmsXmlContent;
043import org.opencms.xml.content.I_CmsXmlContentAugmentation;
044import org.opencms.xml.types.CmsXmlHtmlValue;
045import org.opencms.xml.types.I_CmsXmlContentValue;
046
047import java.nio.charset.StandardCharsets;
048import java.util.ArrayList;
049import java.util.Arrays;
050import java.util.Collection;
051import java.util.HashMap;
052import java.util.List;
053import java.util.Locale;
054import java.util.Map;
055import java.util.stream.Collectors;
056
057import org.apache.commons.lang3.LocaleUtils;
058import org.apache.commons.logging.Log;
059
060import com.deepl.api.DeepLClient;
061import com.deepl.api.DeepLClientOptions;
062import com.deepl.api.Language;
063import com.deepl.api.TextResult;
064import com.deepl.api.TextTranslationOptions;
065import com.google.common.collect.ArrayListMultimap;
066import com.google.common.collect.Multimap;
067
068public class CmsDeeplTranslation implements I_CmsContentTranslator {
069
070    /** Key used for looking up the API key in the secret store. */
071    public static final String SECRET_API_KEY = "contenteditor.translation.deepl.apiKey";
072
073    /** The logger instance for this class. */
074    private static final Log LOG = CmsLog.getLog(CmsDeeplTranslation.class);
075
076    /** Configuration parameter for determining a target locale mapping. */
077    protected static final Object PARAM_TARGET_LOCALE_MAPPING = "targetLocaleMapping";
078
079    /** Cached available target languages. */
080    private volatile List<Language> m_targetLanguages;
081
082    /** Cached available source languages. */
083    private volatile List<Language> m_sourceLanguages;
084
085    /** The parameters from the configuration. */
086    private CmsParameterConfiguration m_params = new CmsParameterConfiguration();
087
088    /** The parsed target locale mapping. */
089    private volatile Map<Locale, Locale> m_targetLocaleMapping;
090
091    /**
092     * Tries to find a suitable Language object from a list of DeepL-supported languages that matches a given locale.
093     *
094     * @param locale the locale
095     * @param languages the languages
096     * @return the matching language
097     */
098    public static Language getMatchingLanguage(Locale locale, List<Language> languages) {
099
100        for (Language language : languages) {
101            if (Locale.forLanguageTag(language.getCode()).equals(locale)) {
102                return language;
103            }
104        }
105        // if not found, fall back to just using the language portion
106        for (Language language : languages) {
107            Locale l1 = new Locale(Locale.forLanguageTag(language.getCode()).getLanguage());
108            Locale l2 = new Locale(locale.getLanguage());
109            if (l1.equals(l2)) {
110                return language;
111            }
112        }
113        return null;
114    }
115
116    /**
117     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#addConfigurationParameter(java.lang.String, java.lang.String)
118     */
119    @Override
120    public void addConfigurationParameter(String paramName, String paramValue) {
121
122        m_params.add(paramName, paramValue);
123
124    }
125
126    /**
127     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#getConfiguration()
128     */
129    @Override
130    public CmsParameterConfiguration getConfiguration() {
131
132        return m_params;
133    }
134
135    /**
136     * @see org.opencms.ade.contenteditor.I_CmsContentTranslator#getContentAugmentation()
137     */
138    @Override
139    public I_CmsXmlContentAugmentation getContentAugmentation() {
140
141        return new I_CmsXmlContentAugmentation() {
142
143            @Override
144            public void augmentContent(Context context) throws Exception {
145
146                CmsObject cms = context.getCmsObject();
147                final Locale wpLocale = OpenCms.getWorkplaceManager().getWorkplaceLocale(cms);
148                CmsXmlContent content = context.getContent();
149                Locale sourceLocale = context.getLocale();
150                String targetLocaleParam = context.getParameter(CmsGwtConstants.PARAM_TARGET_LOCALE);
151                String apiKey = OpenCms.getSecretStore().getSecret(SECRET_API_KEY);
152                apiKey = apiKey.trim();
153                Locale targetLocale = CmsLocaleManager.getLocale(targetLocaleParam);
154
155                DeepLClientOptions options = new DeepLClientOptions();
156                DeepLClient client = new DeepLClient(apiKey, options);
157
158                String html = CmsTranslationUtil.getWaitMessage(wpLocale);
159                context.progress(html);
160
161                List<Language> sourceLanguages = getSourceLanguages(client);
162                List<Language> targetLanguages = getTargetLanguages(client);
163                Language srcLang = getMatchingLanguage(sourceLocale, sourceLanguages);
164                Language targetLang = getMatchingLanguage(
165                    getTargetLocaleMapping().getOrDefault(targetLocale, targetLocale),
166                    targetLanguages);
167
168                List<I_CmsXmlContentValue> values = CmsTranslationUtil.getValuesToTranslate(
169                    cms,
170                    content,
171                    sourceLocale,
172                    targetLocale);
173
174                // DeepL can translate multiple strings in one API call. But the total request size is limited,
175                // and also all strings in that API call are translated with the same translation options.
176                // So we first group the values to be translated by whether they are HTML values or not
177                // (This is because when we use HTML mode for non-HTML values, it may replace <, > with entities.).
178                // Then we split each of these two groups into smaller batches which can be translated in one API call each.
179
180                Multimap<Boolean, I_CmsXmlContentValue> valuesByType = ArrayListMultimap.create();
181                for (I_CmsXmlContentValue value : values) {
182                    valuesByType.put(value instanceof CmsXmlHtmlValue, value);
183                }
184
185                // Collect *all* translation results in this map
186                Map<String, String> valuesToSet = new HashMap<>();
187
188                for (Boolean isHtml : Arrays.asList(Boolean.FALSE, Boolean.TRUE)) {
189                    TextTranslationOptions translationOptions = new TextTranslationOptions();
190                    if (isHtml) {
191                        translationOptions.setTagHandlingVersion("v2");
192                        translationOptions.setTagHandling("html");
193                    }
194                    Collection<I_CmsXmlContentValue> valuesForCurrentMode = valuesByType.get(isHtml);
195                    if (valuesForCurrentMode.isEmpty()) {
196                        continue;
197                    }
198                    List<List<I_CmsXmlContentValue>> batches = new ArrayList<>();
199
200                    // Max request size for the API is 128K, but this also includes JSON punctuation and additional parameters, so we leave a wide safety margin
201                    final int limit = 15;
202                    int currentBatchSize = 0;
203                    List<I_CmsXmlContentValue> currentBatch = new ArrayList<>();
204                    batches.add(currentBatch);
205                    for (I_CmsXmlContentValue val : valuesForCurrentMode) {
206                        String strValue = getTranslationValue(cms, val);
207                        long byteSize = strValue.getBytes(StandardCharsets.UTF_8).length;
208                        if ((byteSize + currentBatchSize) > limit) {
209                            currentBatch = new ArrayList<>();
210                            batches.add(currentBatch);
211                            currentBatchSize = 0;
212                        }
213                        currentBatch.add(val);
214                        currentBatchSize += byteSize;
215                    }
216
217                    for (List<I_CmsXmlContentValue> batch : batches) {
218                        if (batch.size() == 0) {
219                            continue;
220                        }
221
222                        List<String> inputs = batch.stream().map(val -> getTranslationValue(cms, val)).collect(
223                            Collectors.toList());
224                        List<TextResult> outputs = client.translateText(
225                            inputs,
226                            srcLang,
227                            targetLang,
228                            translationOptions);
229                        for (int i = 0; i < batch.size(); i++) {
230                            valuesToSet.put(batch.get(i).getPath(), outputs.get(i).getText());
231                        }
232                    }
233                }
234                int numTranslatedFields = 0;
235
236                if (!content.hasLocale(targetLocale)) {
237                    content.copyLocale(sourceLocale, targetLocale);
238                    for (Map.Entry<String, String> entry : valuesToSet.entrySet()) {
239                        I_CmsXmlContentValue targetValue = content.getValue(entry.getKey(), targetLocale);
240                        setTranslationValue(cms, targetValue, entry.getValue());
241                        numTranslatedFields += 1;
242                    }
243                } else {
244                    for (Map.Entry<String, String> entry : valuesToSet.entrySet()) {
245                        I_CmsXmlContentValue origValue = content.getValue(entry.getKey(), sourceLocale);
246                        try {
247                            FoundOrCreatedValue val = CmsTranslationUtil.findOrCreateValue(
248                                cms,
249                                content,
250                                targetLocale,
251                                origValue.getPath());
252                            // If the value already existed, we only want to write to it if it's empty.
253                            // But if it was just created, it might have a default value, which we need to overwrite.
254                            if (val.wasCreated()
255                                || CmsStringUtil.isEmptyOrWhitespaceOnly(val.getValue().getStringValue(cms))) {
256                                if (val.getValue() instanceof CmsXmlHtmlValue) {
257                                    // for HTML values, we need to copy the old value first so the link table is filled
258                                    val.getValue().setStringValue(cms, origValue.getStringValue(cms));
259                                }
260                                setTranslationValue(cms, val.getValue(), entry.getValue());
261                                numTranslatedFields += 1;
262                            }
263                        } catch (Exception e) {
264                            LOG.debug(e.getLocalizedMessage(), e);
265                        }
266                    }
267                }
268
269                if (numTranslatedFields > 0) {
270                    context.setResult(content);
271                    context.setHtmlMessage(buildFeedbackHtml(cms, sourceLocale, targetLocale, numTranslatedFields));
272                    context.setNextLocale(targetLocale);
273                } else {
274                    String nothingTranslated = Messages.get().getBundle(wpLocale).key(
275                        Messages.GUI_TRANSLATION_NOTHING_TRANSLATED_0);
276                    context.setHtmlMessage("<p>" + nothingTranslated + "</p>");
277                }
278
279            }
280
281        };
282
283    }
284
285    /**
286     * @see org.opencms.configuration.I_CmsConfigurationParameterHandler#initConfiguration()
287     */
288    @Override
289    public void initConfiguration() throws CmsConfigurationException {
290
291    }
292
293    @Override
294    public void initialize(CmsObject cms) {
295
296    }
297
298    /**
299     * @see org.opencms.ade.contenteditor.I_CmsContentTranslator#isEnabled(org.opencms.file.CmsObject, org.opencms.ade.configuration.CmsADEConfigData, org.opencms.file.CmsFile)
300     */
301    @Override
302    public boolean isEnabled(CmsObject cms, CmsADEConfigData config, CmsFile file) {
303
304        return OpenCms.getSecretStore().getSecret(SECRET_API_KEY) != null;
305
306    }
307
308    /**
309     * Gets (and lazily creates if necessary) the target locale mapping.
310     *
311     * <p>DeepL API only lists en-US / en-GB  for English, and pt-BR / pt-PT for Portuguese as target languages.
312     * Using just en or pt might work, but it's probably a good idea to be specific,
313     * So if just en or pt is specified, we map it to the variant that's more widely used by default.
314     *
315     * <p>This can be overriden by the configuration parameter targetLocaleMapping, which has the form l1:r1|l2:r2|..., where the li is
316     * the locale to map, and the ri is the locale to map to.
317     * @return
318     */
319    protected Map<Locale, Locale> getTargetLocaleMapping() {
320
321        if (m_targetLocaleMapping == null) {
322            Map<Locale, Locale> targetLocaleMapping = new HashMap<>();
323            targetLocaleMapping.put(Locale.ENGLISH, Locale.US);
324            targetLocaleMapping.put(new Locale("pt"), Locale.forLanguageTag("pt-PT"));
325            String localeMappingStr = m_params.get(PARAM_TARGET_LOCALE_MAPPING);
326            if (!CmsStringUtil.isEmptyOrWhitespaceOnly(localeMappingStr)) {
327                Map<String, String> mappings = CmsStringUtil.splitAsMap(localeMappingStr.trim(), "|", ":");
328                for (Map.Entry<String, String> entry : mappings.entrySet()) {
329                    targetLocaleMapping.put(
330                        LocaleUtils.toLocale(entry.getKey()),
331                        LocaleUtils.toLocale(entry.getValue()));
332                }
333            }
334            m_targetLocaleMapping = targetLocaleMapping;
335        }
336
337        return m_targetLocaleMapping;
338    }
339
340    /**
341     * Builds the HTML for the feedback screen.
342     *
343     * @param cms the CMS context
344     * @param sourceLocale the translation source locale
345     * @param targetLocale the translation target locale
346     * @param numSuccessfulFieldUpdates the number of translated fields
347     * @param conflictFields the list of fields with conflicts
348     * @return
349     */
350    private String buildFeedbackHtml(
351        CmsObject cms,
352        Locale sourceLocale,
353        Locale targetLocale,
354        int numSuccessfulFieldUpdates) {
355
356        StringBuilder buffer = new StringBuilder();
357        Locale wpLocale = OpenCms.getWorkplaceManager().getWorkplaceLocale(cms);
358        buffer.append("<p>");
359        buffer.append(
360            Messages.get().getBundle(wpLocale).key(
361                Messages.GUI_TRANSLATION_FEEDBACK_3,
362                numSuccessfulFieldUpdates,
363                sourceLocale.getDisplayName(wpLocale),
364                targetLocale.getDisplayName(wpLocale)));
365        buffer.append("</p>");
366        return buffer.toString();
367    }
368
369    /**
370     * Gets the available source languages from the client, but caches them for later calls.
371     *
372     * @param client the DeepL client
373     * @return the available source languages
374     */
375    private List<Language> getSourceLanguages(DeepLClient client) {
376
377        if (m_sourceLanguages == null) {
378            try {
379                m_sourceLanguages = client.getSourceLanguages();
380            } catch (Exception e) {
381                throw new RuntimeException(e);
382            }
383        }
384        return m_sourceLanguages;
385    }
386
387    /**
388     * Gets the available target languages from the DeepL client, but caches them for later calls
389     *
390     * @param client the DeepL client
391     * @return the available target languages
392     */
393    private List<Language> getTargetLanguages(DeepLClient client) {
394
395        if (m_targetLanguages == null) {
396            try {
397                m_targetLanguages = client.getTargetLanguages();
398            } catch (Exception e) {
399                throw new RuntimeException(e);
400            }
401        }
402        return m_targetLanguages;
403
404    }
405
406    /**
407     * Gets the value to translate, which in the case of HTML values is the value with unexpanded macros, and the normal string value otherwise.
408     *
409     * @param cms the CMS context
410     * @param value an XML content value
411     *
412     * @return the value to translate
413     */
414    private String getTranslationValue(CmsObject cms, I_CmsXmlContentValue value) {
415
416        if (value instanceof CmsXmlHtmlValue) {
417            return ((CmsXmlHtmlValue)value).getRawContent();
418        } else {
419            return value.getStringValue(cms);
420        }
421    }
422
423    /**
424     * Sets the translated value, which in the case of HTML values sets just the content node of the value (with unexpanded macros), and sets the value normally otherwise.
425     *
426     * @param cms the CMS context
427     * @param value the value to modify
428     * @param newValue
429     * @param newValue
430     */
431    private void setTranslationValue(CmsObject cms, I_CmsXmlContentValue value, String newValue) {
432
433        if (value instanceof CmsXmlHtmlValue) {
434            ((CmsXmlHtmlValue)value).setRawContent(newValue);
435        } else {
436            value.setStringValue(cms, newValue);
437        }
438
439    }
440
441}