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