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 cms the CMS context
152     * @param m_xmlContent the XML content
153     * @param locale the locale to read
154     *
155     * @return the translatable values
156     */
157    public static List<I_CmsXmlContentValue> getValuesToTranslate(
158        CmsObject cms,
159        CmsXmlContent m_xmlContent,
160        Locale locale,
161        Locale targetLocale) {
162
163        List<I_CmsXmlContentValue> result = new ArrayList<I_CmsXmlContentValue>();
164
165        if (m_xmlContent != null) {
166            List<I_CmsXmlContentValue> valuesInOrder = m_xmlContent.getValuesInDocumentOrder(locale).stream().filter(
167                val -> translatedTypes.contains(val.getTypeName())).filter(
168                    val -> !CmsStringUtil.isEmptyOrWhitespaceOnly(val.getStringValue(cms))).filter(
169                        val -> !isTranslationDisabledInSchema(val)).collect(Collectors.toList());
170
171            // Now find those values which we could actually write to if we translated them
172            if ((targetLocale == null) || !m_xmlContent.hasLocale(targetLocale)) {
173                // if the content doesn't already have the locale, translation involves copying the source to the target locale first,
174                // which means we can write all the values from the source locale
175                result = valuesInOrder;
176            } else {
177                // if the content does have the target locale, we can just try creating the values corresponding to the xpaths from the
178                // source locale. It doesn't matter that the values we are setting are not the actual translations, we are just checking
179                // if they can be created / written to.
180                CmsXmlContent copy = null;
181                try {
182                    copy = CmsXmlContentFactory.unmarshal(cms, m_xmlContent.getFile());
183                } catch (Exception e) {
184                    LOG.error(e.getLocalizedMessage(), e);
185                    return Collections.emptyList();
186                }
187                for (I_CmsXmlContentValue value : valuesInOrder) {
188                    try {
189                        FoundOrCreatedValue valueInTargetLocaleInCopy = findOrCreateValue(
190                            cms,
191                            copy,
192                            targetLocale,
193                            value.getPath());
194                        if (valueInTargetLocaleInCopy.wasCreated()
195                            || CmsStringUtil.isEmptyOrWhitespaceOnly(
196                                valueInTargetLocaleInCopy.getValue().getStringValue(cms))) {
197                            result.add(value);
198                        } else {
199                            LOG.debug(
200                                "Excluding "
201                                    + value.getPath()
202                                    + " from translation because it would cause a conflict in the target locale."
203                                    + targetLocale);
204                        }
205                    } catch (Exception e) {
206                        LOG.info(e.getLocalizedMessage(), e);
207                    }
208                }
209            }
210        }
211        return result;
212    }
213
214    /**
215     * Gets the message to display to the user, while content is being translated (in HTML format).
216     *
217     * @param locale the workplace locale
218     * @return the message
219     */
220    public static String getWaitMessage(final Locale locale) {
221
222        String msg = Messages.get().getBundle(locale).key(Messages.GUI_TRANSLATION_PROGRESS_0);
223        String s = "<div class=\"oc-translation-message\">\n"
224            + "<div class=\"oc-translation-message-dots\">\n"
225            + "<span></span>\n"
226            + "<span></span>\n"
227            + "<span></span>\n"
228            + "</div>\n"
229            + "<div>"
230            + msg
231            + "</div>\n"
232            + "</div>\n"
233            + "";
234        return s;
235    }
236
237    /**
238     * Checks if translation is disabled by schema settings for a specific content value.
239     *
240     * @param val the content value
241     * @return true if translation is disabled
242     */
243    public static boolean isTranslationDisabledInSchema(I_CmsXmlContentValue val) {
244
245        // We just use AtomicBoolean here because it's a convenient way of having a boolean mutable from a closure, not because of concurrency
246        AtomicBoolean result = new AtomicBoolean(false);
247        String path = CmsXmlUtils.removeAllXpathIndices(val.getPath());
248        I_CmsXmlDocument content = val.getDocument();
249        I_CmsXmlContentHandler rootHandler = content.getContentDefinition().getContentHandler();
250        CmsSynchronizationSpec syncSpec = rootHandler.getSynchronizations(true);
251        if (syncSpec.getSynchronizationPaths().contains(path)) {
252            result.set(true);
253            LOG.debug("Excluding " + val.getPath() + " from translation because it's synchronized");
254        }
255        if (!result.get()) {
256            content.getContentDefinition().findSchemaTypesForPath(path, (schemaType, remainingPath) -> {
257                remainingPath = CmsXmlUtils.concatXpath(schemaType.getName(), remainingPath);
258                I_CmsXmlContentHandler handler = schemaType.getContentDefinition().getContentHandler();
259                if (handler.getAgentTags(remainingPath).contains(AGENT_TAG_NOTRANSLATE)) {
260                    if (result.compareAndSet(false, true)) {
261                        LOG.debug(
262                            "Excluding "
263                                + val.getPath()
264                                + " from translation because it's marked with translate=false");
265                    }
266                }
267            });
268        }
269        return result.get();
270    }
271
272}