001/* 002 * Copyright 2023 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.workflow.support; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.io.OutputStream; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.LinkedHashMap; 024import java.util.Map; 025import java.util.Map.Entry; 026import java.util.Optional; 027import java.util.Properties; 028 029import javax.xml.parsers.ParserConfigurationException; 030import javax.xml.parsers.SAXParserFactory; 031import javax.xml.transform.OutputKeys; 032import javax.xml.transform.TransformerFactory; 033import javax.xml.transform.sax.SAXTransformerFactory; 034import javax.xml.transform.sax.TransformerHandler; 035import javax.xml.transform.stream.StreamResult; 036 037import org.apache.avalon.framework.component.Component; 038import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 039import org.apache.avalon.framework.service.ServiceException; 040import org.apache.avalon.framework.service.ServiceManager; 041import org.apache.avalon.framework.service.Serviceable; 042import org.apache.cocoon.xml.AttributesImpl; 043import org.apache.cocoon.xml.XMLUtils; 044import org.apache.commons.lang3.ArrayUtils; 045import org.apache.commons.lang3.StringUtils; 046import org.apache.excalibur.source.ModifiableSource; 047import org.apache.excalibur.source.Source; 048import org.apache.excalibur.source.SourceNotFoundException; 049import org.apache.excalibur.source.SourceResolver; 050import org.apache.xml.serializer.OutputPropertiesFactory; 051import org.xml.sax.SAXException; 052 053import org.ametys.core.observation.Event; 054import org.ametys.core.observation.ObservationManager; 055import org.ametys.core.user.CurrentUserProvider; 056import org.ametys.core.util.I18nUtils; 057import org.ametys.plugins.workflow.component.WorkflowLanguageManager; 058import org.ametys.runtime.i18n.I18nizableText; 059import org.ametys.runtime.plugin.component.AbstractLogEnabled; 060 061/** 062 * Helper for saxing i18n catalogs 063 */ 064public class I18nHelper extends AbstractLogEnabled implements Component, Serviceable 065{ 066 /** The helper role */ 067 public static final String ROLE = I18nHelper.class.getName(); 068 069 /** I18n Utils */ 070 protected I18nUtils _i18nUtils; 071 072 /** The workflow helper */ 073 protected WorkflowHelper _workflowHelper; 074 075 /** The workflow session helper */ 076 protected WorkflowSessionHelper _workflowSessionHelper; 077 078 /** The workflow language manager */ 079 protected WorkflowLanguageManager _workflowLanguageManager; 080 081 /** The source resolver */ 082 protected SourceResolver _sourceResolver; 083 084 /** The observation manager */ 085 protected ObservationManager _observationManager; 086 087 /** The current user provider */ 088 protected CurrentUserProvider _currentUserProvider; 089 090 public void service(ServiceManager manager) throws ServiceException 091 { 092 _sourceResolver = (SourceResolver) manager.lookup(org.apache.excalibur.source.SourceResolver.ROLE); 093 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 094 _workflowSessionHelper = (WorkflowSessionHelper) manager.lookup(WorkflowSessionHelper.ROLE); 095 _workflowLanguageManager = (WorkflowLanguageManager) manager.lookup(WorkflowLanguageManager.ROLE); 096 _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE); 097 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 098 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 099 } 100 101 /** 102 * Get the default language to use for the i18n translation 103 * @return a language code 104 */ 105 public String getI18nDefaultLanguage() 106 { 107 Optional<String> fileLanguage = Optional.empty(); 108 try 109 { 110 // Parse default catalog application to know the default language 111 fileLanguage = _getFileLanguage("application"); 112 } 113 catch (Exception e) 114 { 115 getLogger().warn("An exception occured while getting current catalog language", e); 116 } 117 return fileLanguage.orElseGet(_workflowLanguageManager::getCurrentLanguage); 118 } 119 120 /** 121 * Get language used by XML file 122 * @param catalogName the catalog name 123 * @return the language code 124 * @throws Exception while resolving and getting file configuration 125 */ 126 private Optional<String> _getFileLanguage(String catalogName) throws Exception 127 { 128 String defaultLanguage = null; 129 String defaultI18nCatalogPath = getPrefixCatalogLocation(catalogName) + ".xml"; 130 Source defaultI18nCatalogSource = null; 131 try 132 { 133 defaultI18nCatalogSource = _sourceResolver.resolveURI(defaultI18nCatalogPath); 134 if (defaultI18nCatalogSource.exists()) 135 { 136 try (InputStream is = defaultI18nCatalogSource.getInputStream()) 137 { 138 defaultLanguage = new DefaultConfigurationBuilder(true) 139 .build(is) 140 .getAttribute("xml:lang", null); 141 } 142 } 143 144 } 145 catch (SourceNotFoundException e) 146 { 147 getLogger().warn("Couldn't find file at path: {} a new file will be created", defaultI18nCatalogPath, e); 148 } 149 finally 150 { 151 _sourceResolver.release(defaultI18nCatalogSource); 152 } 153 154 return Optional.ofNullable(defaultLanguage).filter(StringUtils::isNotEmpty); 155 } 156 157 /** 158 * Translate i18n label for workflow element, return a default name if translation is not found 159 * @param workflowName the workflow's unique name 160 * @param i18nKey an i18n key pointing to workflow element's label 161 * @param defaultKey a default i18n key for workflow element 162 * @return a translated label 163 */ 164 public String translateKey(String workflowName, I18nizableText i18nKey, I18nizableText defaultKey) 165 { 166 return Optional.of(workflowName) 167 .map(_workflowSessionHelper::getTranslations) 168 .map(translations -> translations.get(i18nKey)) 169 // if current language doesn't have translations jump to _translateKey() 170 .map(translation -> translation.get(_workflowLanguageManager.getCurrentLanguage())) 171 .filter(StringUtils::isNotEmpty) 172 .orElseGet(() -> _translateKey(i18nKey, defaultKey)); 173 } 174 175 private String _translateKey(I18nizableText i18nKey, I18nizableText defaultKey) 176 { 177 return Optional.ofNullable(_i18nUtils.translate(i18nKey)) 178 .filter(StringUtils::isNotBlank) 179 .orElseGet(() -> _i18nUtils.translate(defaultKey)); 180 } 181 182 /** 183 * Transform workflow name in unique I18n key 184 * @param workflowName the workflow's unique name 185 * @return the workflow label I18n Key 186 */ 187 public I18nizableText getWorkflowLabelKey(String workflowName) 188 { 189 return ArrayUtils.contains(_workflowHelper.getWorkflowNames(), workflowName) 190 ? _workflowHelper.getWorkflowLabel(workflowName) 191 : new I18nizableText("application", buildI18nWorkflowKey(workflowName)); 192 } 193 194 /** 195 * Generate unique key for workflow element label 196 * @param workflowName the workflow's unique name 197 * @param type the workflow's element type 198 * @param workflowElementId the element's id 199 * @return a unique i18n key 200 */ 201 public I18nizableText generateI18nKey(String workflowName, String type, int workflowElementId) 202 { 203 String key = buildI18nWorkflowKey(workflowName) + "_" + type.toUpperCase() + "_" + workflowElementId; 204 String workflowCatalog = _workflowHelper.getWorkflowCatalog(workflowName); 205 I18nizableText i18nKey = new I18nizableText(workflowCatalog, key); 206 return i18nKey; 207 } 208 209 /** 210 * Sax new messages into i18N catalogs 211 * @param newI18nMessages catalog of the new i18N messages, key is language, value is map of i18nKeys, translation 212 * @param currentCatalog the current catalog 213 * @throws Exception exception while reading file 214 */ 215 public void saveCatalogs(Map<String, Map<I18nizableText, String>> newI18nMessages, String currentCatalog) throws Exception 216 { 217 String prefixCatalogPath = getPrefixCatalogLocation(currentCatalog); 218 219 // Determine the default catalog language 220 String defaultLanguage = getI18nDefaultLanguage(); 221 Map<String, Map<I18nizableText, String>> i18nCatalogs = new HashMap<>(); 222 for (Entry<String, Map<I18nizableText, String>> i18nMessageTranslation : newI18nMessages.entrySet()) 223 { 224 Map<I18nizableText, String> i18nMessages = new HashMap<>(); 225 String language = i18nMessageTranslation.getKey(); 226 String catalogPath = prefixCatalogPath + (currentCatalog.equals("application") && language.equals(defaultLanguage) ? "" : ("_" + language)) + ".xml"; 227 i18nMessages = readI18nCatalog(i18nCatalogs, catalogPath, currentCatalog); 228 _updateI18nMessages(i18nMessages, i18nMessageTranslation.getValue()); 229 _saveI18nCatalog(i18nMessages, catalogPath, language); 230 } 231 } 232 233 /** 234 * Clear the i18n caches (ametys and cocoon) 235 */ 236 public void clearCaches() 237 { 238 _i18nUtils.reloadCatalogues(); 239 240 _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_CACHE_RESET, 241 _currentUserProvider.getUser(), 242 Collections.singletonMap(org.ametys.core.ObservationConstants.ARGS_CACHE_ID, I18nUtils.I18N_CACHE))); 243 } 244 245 /** 246 * Generate i18n catalog from workflow's new i18n translations 247 * @param translationsToConvert the workflow to save's translation list: keys are future i18n key, values are maps of language codes and translations 248 * @return a map of i18n catalogs : keys are languages, values are pair of i18nkey, translation 249 */ 250 public Map<String, Map<I18nizableText, String>> createNewI18nCatalogs(Map<I18nizableText, Map<String, String>> translationsToConvert) 251 { 252 Map<String, Map<I18nizableText, String>> i18nMessageTranslations = new HashMap<>(); 253 for (Entry<I18nizableText, Map<String, String>> newI18n : translationsToConvert.entrySet()) 254 { 255 _updateI18nMessageTranslations(i18nMessageTranslations, newI18n.getKey(), newI18n.getValue()); 256 } 257 return i18nMessageTranslations; 258 } 259 260 /** 261 * Add translations to catalogs 262 * @param i18nMessageTranslations the map of i18n catalogs, keys are languages, values are pair of i18nkey, translation 263 * @param key an i18nKey to add 264 * @param translations a map of translations to transform: key is language and value is translation 265 */ 266 private void _updateI18nMessageTranslations(Map<String, Map<I18nizableText, String>> i18nMessageTranslations, I18nizableText key, Map<String, String> translations) 267 { 268 for (Entry<String, String> translation : translations.entrySet()) 269 { 270 String language = translation.getKey(); 271 Map<I18nizableText, String> map = i18nMessageTranslations.computeIfAbsent(language, __ -> new HashMap<>()); 272 map.put(key, translation.getValue()); 273 } 274 } 275 276 /** 277 * Read existing i18n catalog 278 * @param i18nCatalogs map of already read catalogs 279 * @param path the path to the file to read 280 * @param currentCatalog the catalog of the current workflow 281 * @return the messages in the catalog as map 282 */ 283 public Map<I18nizableText, String> readI18nCatalog(Map<String, Map<I18nizableText, String>> i18nCatalogs, String path, String currentCatalog) 284 { 285 if (i18nCatalogs.containsKey(path)) 286 { 287 return i18nCatalogs.get(path); 288 } 289 290 Map<I18nizableText, String> i18nMessages = new LinkedHashMap<>(); 291 Source i18nCatalogSource = null; 292 293 try 294 { 295 i18nCatalogSource = _sourceResolver.resolveURI(path); 296 if (i18nCatalogSource.exists()) 297 { 298 try (InputStream is = i18nCatalogSource.getInputStream()) 299 { 300 SAXParserFactory.newInstance().newSAXParser().parse(is, new I18nMessageHandler(i18nMessages, currentCatalog)); 301 i18nCatalogs.put(path, i18nMessages); 302 } 303 } 304 } 305 catch (IOException | SAXException | ParserConfigurationException e) 306 { 307 getLogger().error("An error occured while reading existing i18n catalog", e); 308 } 309 finally 310 { 311 _sourceResolver.release(i18nCatalogSource); 312 } 313 314 return i18nMessages; 315 } 316 317 /** 318 * Update current i18n catalog with new values 319 * @param i18nMessages the current i18n catalog 320 * @param newI18nMessages the map of new messages 321 */ 322 private void _updateI18nMessages(Map<I18nizableText, String> i18nMessages, Map<I18nizableText, String> newI18nMessages) 323 { 324 for (Entry<I18nizableText, String> newI18nMessage : newI18nMessages.entrySet()) 325 { 326 i18nMessages.put(newI18nMessage.getKey(), newI18nMessage.getValue()); 327 } 328 } 329 330 /** 331 * Save the i18n catalog with new values 332 * @param i18nMessages the messages to save 333 * @param catalogPath the path to the catalog 334 * @param language the language of the translations 335 */ 336 protected void _saveI18nCatalog(Map<I18nizableText, String> i18nMessages, String catalogPath, String language) 337 { 338 ModifiableSource defaultI18nCatalogSource = null; 339 try 340 { 341 defaultI18nCatalogSource = (ModifiableSource) _sourceResolver.resolveURI(catalogPath); 342 try (OutputStream os = defaultI18nCatalogSource.getOutputStream()) 343 { 344 // create a transformer for saving sax into a file 345 TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler(); 346 347 StreamResult result = new StreamResult(os); 348 th.setResult(result); 349 350 // create the format of result 351 Properties format = new Properties(); 352 format.put(OutputKeys.METHOD, "xml"); 353 format.put(OutputKeys.INDENT, "yes"); 354 format.put(OutputKeys.ENCODING, "UTF-8"); 355 format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "2"); 356 th.getTransformer().setOutputProperties(format); 357 358 // sax the config into the transformer 359 _saxI18nCatalog(th, i18nMessages, language); 360 361 } 362 catch (Exception e) 363 { 364 getLogger().error("An error occured while saving the catalog values.", e); 365 } 366 } 367 catch (IOException e) 368 { 369 getLogger().error("An error occured while getting i18n catalog", e); 370 } 371 finally 372 { 373 _sourceResolver.release(defaultI18nCatalogSource); 374 } 375 376 } 377 378 private void _saxI18nCatalog(TransformerHandler handler, Map<I18nizableText, String> i18nMessages, String language) throws SAXException 379 { 380 handler.startDocument(); 381 AttributesImpl attribute = new AttributesImpl(); 382 attribute.addCDATAAttribute("xml:lang", language); 383 XMLUtils.startElement(handler, "catalogue", attribute); 384 for (Entry<I18nizableText, String> i18Message : i18nMessages.entrySet()) 385 { 386 attribute = new AttributesImpl(); 387 attribute.addCDATAAttribute("key", i18Message.getKey().getKey()); 388 XMLUtils.createElement(handler, "message", attribute, i18Message.getValue()); 389 } 390 XMLUtils.endElement(handler, "catalogue"); 391 handler.endDocument(); 392 } 393 394 /** 395 * Get the overridable catalog location for a plugin, workspace or application. 396 * @param catalogName The catalog name 397 * @return the location 398 */ 399 public String getPrefixCatalogLocation(String catalogName) 400 { 401 if (catalogName.equals("application")) 402 { 403 return _i18nUtils.getApplicationCatalogLocation() + "/" + I18nUtils.APPLICATION; 404 } 405 406 String[] catalogParts = catalogName.split("\\.", 2); 407 if (catalogParts.length != 2) 408 { 409 throw new IllegalArgumentException("The catalog name should be composed of two parts (like plugin.cms): " + catalogName); 410 } 411 412 // Transform plugin to plugins and workspace to workspaces 413 return _i18nUtils.getOverridableCatalogLocation(catalogParts[0], catalogParts[1]) + "/" + I18nUtils.MESSAGES; 414 } 415 416 /** 417 * Build an i18n key for workflows like "WORKFLOW_[WORKFLOW_NAME]" 418 * @param workflowName the workflow name 419 * @return the built key 420 */ 421 422 public static String buildI18nWorkflowKey(String workflowName) 423 { 424 return "WORKFLOW_" + workflowName.replace("-", "_").toUpperCase(); 425 } 426}