001/* 002 * Copyright 2012 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 */ 016 017package org.ametys.core.util; 018 019import java.io.IOException; 020import java.util.Arrays; 021import java.util.Collection; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Locale; 025import java.util.Map; 026import java.util.stream.Collectors; 027 028import org.apache.avalon.framework.activity.Initializable; 029import org.apache.avalon.framework.component.Component; 030import org.apache.avalon.framework.component.ComponentException; 031import org.apache.avalon.framework.context.Context; 032import org.apache.avalon.framework.context.ContextException; 033import org.apache.avalon.framework.context.Contextualizable; 034import org.apache.avalon.framework.logger.AbstractLogEnabled; 035import org.apache.avalon.framework.service.ServiceException; 036import org.apache.avalon.framework.service.ServiceManager; 037import org.apache.avalon.framework.service.Serviceable; 038import org.apache.cocoon.components.ContextHelper; 039import org.apache.cocoon.i18n.Bundle; 040import org.apache.cocoon.i18n.BundleFactory; 041import org.apache.cocoon.xml.ParamSaxBuffer; 042import org.apache.cocoon.xml.SaxBuffer; 043import org.apache.cocoon.xml.SaxBuffer.Characters; 044import org.apache.commons.lang3.LocaleUtils; 045import org.apache.excalibur.source.Source; 046import org.apache.excalibur.source.SourceResolver; 047import org.apache.excalibur.source.TraversableSource; 048import org.xml.sax.SAXException; 049import org.xml.sax.helpers.DefaultHandler; 050 051import org.ametys.core.DevMode; 052import org.ametys.core.DevMode.DEVMODE; 053import org.ametys.core.cache.AbstractCacheManager; 054import org.ametys.core.cache.Cache; 055import org.ametys.core.cocoon.XMLResourceBundle; 056import org.ametys.core.util.language.LocaleHelper; 057import org.ametys.plugins.core.impl.cache.AbstractCacheKey; 058import org.ametys.runtime.i18n.FormatableI18nizable; 059import org.ametys.runtime.i18n.I18nizable; 060import org.ametys.runtime.i18n.I18nizableText; 061import org.ametys.runtime.i18n.I18nizableTextParameter; 062import org.ametys.runtime.plugin.PluginsManager; 063import org.ametys.runtime.workspace.WorkspaceManager; 064 065/** 066 * Utils for i18n 067 */ 068public class I18nUtils extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable 069{ 070 /** The avalon role */ 071 public static final String ROLE = I18nUtils.class.getName(); 072 073 /** i18n cache id */ 074 public static final String I18N_CACHE = I18nUtils.class.getName() + "$i18n"; 075 076 /** Base filename for application */ 077 public static final String APPLICATION = "application"; 078 079 /** Base filename for all other types except application (like plugin, workspace, etc.) */ 080 public static final String MESSAGES = "messages"; 081 082 /** I18n catalogues */ 083 protected Map<String, Location> _locations; 084 085 /** The avalon context */ 086 protected Context _context; 087 088 /** Source Resolver */ 089 protected SourceResolver _resolver; 090 091 private BundleFactory _bundleFactory; 092 093 private AbstractCacheManager _cacheManager; 094 private LocaleHelper _localeHelper; 095 096 @Override 097 public void contextualize(Context context) throws ContextException 098 { 099 _context = context; 100 } 101 102 @Override 103 public void service(ServiceManager manager) throws ServiceException 104 { 105 _bundleFactory = (BundleFactory) manager.lookup(BundleFactory.ROLE); 106 _cacheManager = (AbstractCacheManager) manager.lookup(AbstractCacheManager.ROLE); 107 _resolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 108 _localeHelper = (LocaleHelper) manager.lookup(LocaleHelper.ROLE); 109 } 110 111 @Override 112 public void initialize() throws Exception 113 { 114 _createCache(); 115 116 _configure(); 117 } 118 119 /** 120 * Create the i18n cache 121 */ 122 protected void _createCache() 123 { 124 _cacheManager.createMemoryCache(I18N_CACHE, 125 new I18nizableText("plugin.core", "PLUGINS_CORE_I18N_CACHE_LABEL"), 126 new I18nizableText("plugin.core", "PLUGINS_CORE_I18N_CACHE_DESCRIPTION"), 127 true, 128 null); 129 } 130 131 /** 132 * Configure the i18n catalogue 133 */ 134 protected void _configure () 135 { 136 _locations = new HashMap<>(); 137 138 // initializes locations 139 140 _locations.put("application", new Location(APPLICATION, new String[]{getApplicationCatalogLocation()})); 141 142 // WEB-INF/param/*/i18n/ 143 for (String name : getParamsFoldersWithI18n()) 144 { 145 _locations.put("param." + name, new Location(MESSAGES, new String[]{getParamCatalogLocation(name)})); 146 } 147 148 String type = "plugin"; 149 PluginsManager pm = PluginsManager.getInstance(); 150 for (String pluginName : pm.getPluginNames()) 151 { 152 _locations.put(type + "." + pluginName, new Location(MESSAGES, new String[]{getOverridableCatalogLocation(type, pluginName), getDefaultCatalogLocation(type, pluginName)})); 153 } 154 155 type = "workspace"; 156 WorkspaceManager wm = WorkspaceManager.getInstance(); 157 for (String workspace : wm.getWorkspaceNames()) 158 { 159 _locations.put(type + "." + workspace, new Location(MESSAGES, new String[]{getOverridableCatalogLocation(type, workspace), getDefaultCatalogLocation(type, workspace)})); 160 } 161 } 162 163 /** 164 * Get the catalog location for application. 165 * context://WEB-INF/i18n 166 * @return the location 167 */ 168 public String getApplicationCatalogLocation() 169 { 170 return "context://WEB-INF/i18n"; 171 } 172 173 /** 174 * Get the catalog location form param. 175 * context://WEB-INF/param/[name]/i18n 176 * @param name the param name 177 * @return the location 178 */ 179 public String getParamCatalogLocation(String name) 180 { 181 return "context://WEB-INF/param/" + name + "/i18n"; 182 } 183 184 /** 185 * Get the default catalog location for most typed cases (plugin, workspace at least). 186 * [type]:[name]://i18n 187 * @param type the type 188 * @param name the name of the element (can be a plugin, workspace, etc.) 189 * @return the location 190 */ 191 public String getDefaultCatalogLocation(String type, String name) 192 { 193 return type + ":" + name + "://i18n"; 194 } 195 196 /** 197 * Get the overridable catalog location for most typed cases (plugin, workspace at least). 198 * context://WEB-INF/i18n/[type]s/[name] 199 * @param type the type 200 * @param name the name of the element (can be a plugin, workspace, etc.) 201 * @return the location 202 */ 203 public String getOverridableCatalogLocation(String type, String name) 204 { 205 // Transform plugin to plugins and workspace to workspaces 206 return "context://WEB-INF/i18n/" + type + "s/" + name; 207 } 208 209 /** 210 * Get the name of folders into WEB-INF/param which contains i18n catalogues 211 * @return the name of folders into WEB-INF/param which contains i18n catalogues 212 */ 213 public List<String> getParamsFoldersWithI18n() 214 { 215 try 216 { 217 Source paramsRootFolder = _resolver.resolveURI("context://WEB-INF/param"); 218 if (paramsRootFolder.exists() && paramsRootFolder instanceof TraversableSource) 219 { 220 Collection<Source> children = ((TraversableSource) paramsRootFolder).getChildren(); 221 222 return children.stream() 223 .filter(TraversableSource.class::isInstance) 224 .map(TraversableSource.class::cast) 225 .filter(s -> s.isCollection()) 226 .filter(LambdaUtils.wrapPredicate(s -> s.getChild("i18n").exists())) 227 .map(s -> s.getName()) 228 .collect(Collectors.toList()); 229 } 230 } 231 catch (IOException e) 232 { 233 getLogger().error("Error while fetching i18n folders in WEB-INF/param/*/i18n", e); 234 } 235 236 return List.of(); 237 } 238 239 /** 240 * Reload the i18n catalogues and clear cache. 241 * This method should be called as soon as the list of i18n catalogue was changed, when adding a new catalogue for example. 242 */ 243 public void reloadCatalogues () 244 { 245 clearCache(); 246 _configure(); 247 } 248 249 /** 250 * Get the translation of the key in the first locale found in this order : user's language, browser language, language config parameter and then server's language. 251 * @param text The i18n key to translate 252 * @return The translation or null if there's no available translation 253 * @throws IllegalStateException if an error occured 254 */ 255 public String translate(I18nizable text) 256 { 257 return translate(text, null); 258 } 259 260 /** 261 * Get the translation of the key. 262 * @param text The i18n key to translate 263 * @param language The language code to use for translation. Can be null. Defaults to the first locale found in this order : user's language, browser language, language config parameter and then server's language. 264 * @return The translation or null if there's no available translation 265 * @throws IllegalStateException if an error occurred 266 */ 267 public String translate(I18nizable text, String language) throws IllegalStateException 268 { 269 return translate(text, language, false); 270 } 271 272 /** 273 * Get the translation of the key. 274 * @param i18nizable The {@link I18nizable} to translate 275 * @param language The language code to use for translation. Can be null. Defaults to the first locale found in this order : user's language, browser language, language config parameter and then server's language. 276 * @param rawValue Set true to get the value corresponding strictly to the specified Locale, without escalading to parent Locale if not found. Note that there is no cache for strict values. 277 * @return The translation or null if there's no available translation 278 * @throws IllegalStateException if an error occurred 279 */ 280 public String translate(I18nizable i18nizable, String language, boolean rawValue) throws IllegalStateException 281 { 282 // Check language 283 final String langCode; 284 if (language != null) 285 { 286 langCode = language; 287 } 288 else 289 { 290 Map objectModel = ContextHelper.getObjectModel(_context); 291 Locale locale = _localeHelper.findLocale(objectModel); 292 langCode = locale.toString(); 293 } 294 295 if (i18nizable instanceof FormatableI18nizable) 296 { 297 return ((FormatableI18nizable) i18nizable).format(LocaleUtils.toLocale(langCode)); 298 } 299 300 I18nizableText text = (I18nizableText) i18nizable; 301 String value = null; 302 303 if (rawValue) 304 { 305 // No cache for strict values 306 value = _translate(text, langCode, true); 307 } 308 else if (DevMode.getDeveloperMode() != DEVMODE.PRODUCTION) 309 { 310 // don't use cache in dev mode, so that i18n catalogues could by modified at any time 311 value = _translate(text, langCode, false); 312 } 313 else 314 { 315 value = _getI18NCache().get(I18nKey.of(langCode, text), __ -> _translate(text, langCode, false)); 316 } 317 318 return value; 319 } 320 321 /** 322 * Clear the i18n cache. 323 */ 324 public void clearCache() 325 { 326 _getI18NCache().invalidateAll(); 327 } 328 329 /** 330 * Get the translation of the key. 331 * Only use in very specific cases (send mail for example) 332 * @param text The i18n key to translate 333 * @param language The language code to use for translation. Can be null. 334 * @param rawValue Set true to get the value corresponding strictly to the specified Locale, without escalading to parent Locale if not found 335 * @return The translation or null if there's no available translation 336 * @throws IllegalStateException if an error occured 337 */ 338 protected String _translate(I18nizableText text, String language, boolean rawValue) throws IllegalStateException 339 { 340 if (!text.isI18n()) 341 { 342 return text.getLabel(); 343 } 344 345 Location location = null; 346 if (text.getLocation() != null) 347 { 348 location = new Location(text.getBundleName(), new String[]{text.getLocation()}); 349 } 350 else 351 { 352 String catalogue = text.getCatalogue(); 353 location = _locations.get(catalogue); 354 } 355 356 if (location == null) 357 { 358 return null; 359 } 360 361 try 362 { 363 Bundle bundle = _bundleFactory.select(location.getLocations(), location.getName(), org.apache.cocoon.i18n.I18nUtils.parseLocale(language)); 364 365 // translated message 366 ParamSaxBuffer buffer = rawValue ? (ParamSaxBuffer) ((XMLResourceBundle) bundle).getRawObject(text.getKey()) : (ParamSaxBuffer) bundle.getObject(text.getKey()); 367 368 if (buffer == null) 369 { 370 return null; 371 } 372 373 // message parameters 374 Map<String, SaxBuffer> params = new HashMap<>(); 375 376 if (text.getParameters() != null) 377 { 378 int p = 0; 379 for (String param : text.getParameters()) 380 { 381 Characters characters = new Characters(param.toCharArray(), 0, param.length()); 382 params.put(String.valueOf(p++), new SaxBuffer(Arrays.asList(characters))); 383 } 384 } 385 386 if (text.getParameterMap() != null) 387 { 388 for (String name : text.getParameterMap().keySet()) 389 { 390 I18nizableTextParameter i18nizable = text.getParameterMap().get(name); 391 392 if (i18nizable instanceof FormatableI18nizable) 393 { 394 String param = ((FormatableI18nizable) i18nizable).format(LocaleUtils.toLocale(language)); 395 Characters characters = new Characters(param.toCharArray(), 0, param.length()); 396 params.put(name, new SaxBuffer(Arrays.asList(characters))); 397 } 398 else 399 { 400 // named parameters are themselves I18nizableText, so translate them recursively 401 String param = translate((I18nizableText) i18nizable, language, rawValue); 402 if (param == null) 403 { 404 param = ""; 405 } 406 Characters characters = new Characters(param.toCharArray(), 0, param.length()); 407 params.put(name, new SaxBuffer(Arrays.asList(characters))); 408 } 409 } 410 } 411 412 StringBuilder result = new StringBuilder(); 413 buffer.toSAX(new BufferHandler(result), params); 414 415 return result.toString(); 416 } 417 catch (SAXException e) 418 { 419 throw new RuntimeException("Unable to get i18n translation", e); 420 } 421 catch (ComponentException e) 422 { 423 throw new RuntimeException("Unable to get i18n catalogue", e); 424 } 425 } 426 427 private class BufferHandler extends DefaultHandler 428 { 429 StringBuilder _builder; 430 431 public BufferHandler(StringBuilder builder) 432 { 433 _builder = builder; 434 } 435 436 @Override 437 public void characters(char[] ch, int start, int length) throws SAXException 438 { 439 _builder.append(ch, start, length); 440 } 441 } 442 443 /** 444 * Class representing an i18n location 445 */ 446 protected static class Location 447 { 448 String[] _loc; 449 String _name; 450 451 /** 452 * Constructor. 453 * @param name the catalogue name 454 * @param locations the files locations. 455 */ 456 public Location(String name, String[] locations) 457 { 458 _name = name; 459 _loc = locations; 460 } 461 462 /** 463 * Get the name 464 * @return the name 465 */ 466 public String getName() 467 { 468 return _name; 469 } 470 471 /** 472 * Get the files location 473 * @return the files location 474 */ 475 public String[] getLocations() 476 { 477 return _loc; 478 } 479 } 480 481 /** 482 * get the i18n cache (link language and I18nizable text to a translated value) 483 * @return the i18n cache 484 */ 485 protected Cache<I18nKey, String> _getI18NCache() 486 { 487 return _cacheManager.get(I18N_CACHE); 488 } 489 490 static final class I18nKey extends AbstractCacheKey 491 { 492 private I18nKey(String language, I18nizableText text) 493 { 494 super(language, text); 495 } 496 497 static I18nKey of(String language, I18nizableText text) 498 { 499 return new I18nKey(language, text); 500 } 501 } 502}