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}