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.file.CmsObject;
031import org.opencms.main.CmsLog;
032import org.opencms.util.CmsStringUtil;
033import org.opencms.xml.CmsXmlUtils;
034import org.opencms.xml.I_CmsXmlDocument;
035import org.opencms.xml.content.CmsSynchronizationSpec;
036import org.opencms.xml.content.CmsXmlContent;
037import org.opencms.xml.content.CmsXmlContentFactory;
038import org.opencms.xml.content.I_CmsXmlContentHandler;
039import org.opencms.xml.types.CmsXmlNestedContentDefinition;
040import org.opencms.xml.types.I_CmsXmlContentValue;
041
042import java.util.ArrayList;
043import java.util.Collections;
044import java.util.List;
045import java.util.Locale;
046import java.util.concurrent.atomic.AtomicBoolean;
047import java.util.stream.Collectors;
048
049import org.apache.commons.logging.Log;
050
051public class CmsTranslationUtil {
052
053    public static class FoundOrCreatedValue {
054
055        private boolean m_created;
056        private I_CmsXmlContentValue m_value;
057
058        public FoundOrCreatedValue(I_CmsXmlContentValue value, boolean created) {
059
060            m_value = value;
061            m_created = created;
062        }
063
064        public I_CmsXmlContentValue getValue() {
065
066            return m_value;
067        }
068
069        public boolean wasCreated() {
070
071            return m_created;
072        }
073    }
074
075    /** The XML content types that are considered translatable. */
076    public static final List<String> translatedTypes = new ArrayList<String>(
077        List.of(
078            org.opencms.xml.types.CmsXmlStringValue.TYPE_NAME,
079            org.opencms.xml.types.CmsXmlPlainTextStringValue.TYPE_NAME,
080            org.opencms.xml.types.CmsXmlHtmlValue.TYPE_NAME));
081
082    private static final Log LOG = CmsLog.getLog(CmsTranslationUtil.class);
083
084    /** Metadata tag to disable translation for a content field. */
085    public static final String AGENT_TAG_NOTRANSLATE = "translate=false";
086
087    /**
088     * Helper method to either find an existing value for a given xpath or to try and create it, along with its parent values if necessary.
089     *
090     * <p>The result object contains both the value and an indication whether it was created or found.
091     *
092     * <p>Note that this method will fail with an exception when the value can neither found nor created. This can happen if its creation
093     * would conflict with pre-existing values in the content, e.g. choice values with different choices than that indicated by the given path.
094     *
095     * @param cms the CMS context
096     * @param content the XML content
097     * @param locale the locale in which to find/create the value
098     * @param path the xpath of the value to find/create
099     * @return the result, containing both the value and a flag which indicates whether a pre-existing value was found or whether it had to be created
100     *
101     * @throws Exception if something goes wrong
102     */
103    public static FoundOrCreatedValue findOrCreateValue(
104        CmsObject cms,
105        CmsXmlContent content,
106        Locale locale,
107        String path)
108    throws Exception {
109
110        I_CmsXmlContentValue value = content.getValue(path, locale);
111        if (value != null) {
112            return new FoundOrCreatedValue(value, false);
113        }
114        I_CmsXmlContentValue parent = null;
115        boolean isMultipleChoice = false;
116        if (CmsXmlUtils.isDeepXpath(path)) {
117
118            String parentPath = CmsXmlUtils.removeLastXpathElement(path);
119            parent = findOrCreateValue(cms, content, locale, parentPath).getValue();
120            isMultipleChoice = ((CmsXmlNestedContentDefinition)parent).getChoiceMaxOccurs() > 1;
121
122            // Maybe it got created by creating a parent value?
123            value = content.getValue(path, locale);
124            if (value != null) {
125                return new FoundOrCreatedValue(value, true);
126            }
127        }
128
129        int index = CmsXmlUtils.getXpathIndexInt(path); // 1-based
130        int numValues = 0;
131        if (isMultipleChoice) {
132            while ((numValues = content.getValues(path, locale).size()) < index) {
133                content.addValue(cms, path, locale, content.getSubValues(parent.getPath(), locale).size());
134            }
135        } else {
136            while ((numValues = content.getValues(path, locale).size()) < index) {
137                content.addValue(cms, path, locale, numValues);
138            }
139        }
140        value = content.getValue(path, locale);
141        if (value != null) {
142            return new FoundOrCreatedValue(value, true);
143        } else {
144            throw new RuntimeException("failed to find/create value " + path + " for unknown reasons.");
145        }
146    }
147
148    /**
149     * Collects the translatable XML values for the given locale.<p>
150     *
151     * @param xmlContent the XML content
152     * @param locale the locale to read
153     *
154     * @return the translatable values
155     */
156    public static List<I_CmsXmlContentValue> getValuesToTranslate(
157        CmsObject cms,
158        CmsXmlContent m_xmlContent,
159        Locale locale,
160        Locale targetLocale) {
161
162        List<I_CmsXmlContentValue> result = new ArrayList<I_CmsXmlContentValue>();
163
164        if (m_xmlContent != null) {
165            List<I_CmsXmlContentValue> valuesInOrder = m_xmlContent.getValuesInDocumentOrder(locale).stream().filter(
166                val -> translatedTypes.contains(val.getTypeName())).filter(
167                    val -> !CmsStringUtil.isEmptyOrWhitespaceOnly(val.getStringValue(cms))).filter(
168                        val -> !isTranslationDisabledInSchema(val)).collect(Collectors.toList());
169
170            // Now find those values which we could actually write to if we translated them
171            if ((targetLocale == null) || !m_xmlContent.hasLocale(targetLocale)) {
172                // if the content doesn't already have the locale, translation involves copying the source to the target locale first,
173                // which means we can write all the values from the source locale
174                result = valuesInOrder;
175            } else {
176                // if the content does have the target locale, we can just try creating the values corresponding to the xpaths from the
177                // source locale. It doesn't matter that the values we are setting are not the actual translations, we are just checking
178                // if they can be created / written to.
179                CmsXmlContent copy = null;
180                try {
181                    copy = CmsXmlContentFactory.unmarshal(cms, m_xmlContent.getFile());
182                } catch (Exception e) {
183                    LOG.error(e.getLocalizedMessage(), e);
184                    return Collections.emptyList();
185                }
186                for (I_CmsXmlContentValue value : valuesInOrder) {
187                    try {
188                        FoundOrCreatedValue valueInTargetLocaleInCopy = findOrCreateValue(
189                            cms,
190                            copy,
191                            targetLocale,
192                            value.getPath());
193                        if (valueInTargetLocaleInCopy.wasCreated()
194                            || CmsStringUtil.isEmptyOrWhitespaceOnly(
195                                valueInTargetLocaleInCopy.getValue().getStringValue(cms))) {
196                            result.add(value);
197                        } else {
198                            LOG.debug(
199                                "Excluding "
200                                    + value.getPath()
201                                    + " from translation because it would cause a conflict in the target locale."
202                                    + targetLocale);
203                        }
204                    } catch (Exception e) {
205                        LOG.info(e.getLocalizedMessage(), e);
206                    }
207                }
208            }
209        }
210        return result;
211    }
212
213    /**
214     * Gets the message to display to the user, while content is being translated (in HTML format).
215     *
216     * @param locale the workplace locale
217     * @return the message
218     */
219    public static String getWaitMessage(final Locale locale) {
220
221        String msg = Messages.get().getBundle(locale).key(Messages.GUI_TRANSLATION_PROGRESS_0);
222        String s = "<div class=\"oc-translation-message\">\n"
223            + "<div class=\"oc-translation-message-dots\">\n"
224            + "<span></span>\n"
225            + "<span></span>\n"
226            + "<span></span>\n"
227            + "</div>\n"
228            + "<div>"
229            + msg
230            + "</div>\n"
231            + "</div>\n"
232            + "";
233        return s;
234    }
235
236    /**
237     * Checks if translation is disabled by schema settings for a specific content value.
238     *
239     * @param val the content value
240     * @return true if translation is disabled
241     */
242    public static boolean isTranslationDisabledInSchema(I_CmsXmlContentValue val) {
243
244        // We just use AtomicBoolean here because it's a convenient way of having a boolean mutable from a closure, not because of concurrency
245        AtomicBoolean result = new AtomicBoolean(false);
246        String path = CmsXmlUtils.removeAllXpathIndices(val.getPath());
247        I_CmsXmlDocument content = val.getDocument();
248        I_CmsXmlContentHandler rootHandler = content.getContentDefinition().getContentHandler();
249        CmsSynchronizationSpec syncSpec = rootHandler.getSynchronizations(true);
250        if (syncSpec.getSynchronizationPaths().contains(path)) {
251            result.set(true);
252            LOG.debug("Excluding " + val.getPath() + " from translation because it's synchronized");
253        }
254        if (!result.get()) {
255            content.getContentDefinition().findSchemaTypesForPath(path, (schemaType, remainingPath) -> {
256                remainingPath = CmsXmlUtils.concatXpath(schemaType.getName(), remainingPath);
257                I_CmsXmlContentHandler handler = schemaType.getContentDefinition().getContentHandler();
258                if (handler.getAgentTags(remainingPath).contains(AGENT_TAG_NOTRANSLATE)) {
259                    if (result.compareAndSet(false, true)) {
260                        LOG.debug(
261                            "Excluding "
262                                + val.getPath()
263                                + " from translation because it's marked with translate=false");
264                    }
265                }
266            });
267        }
268        return result.get();
269    }
270
271}