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