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