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}