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