001/* 002 * Copyright 2016 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 */ 016package org.ametys.core.resources; 017 018import java.io.BufferedWriter; 019import java.io.IOException; 020import java.io.InputStream; 021import java.io.OutputStream; 022import java.io.OutputStreamWriter; 023import java.io.Serializable; 024import java.util.Map; 025import java.util.regex.Matcher; 026import java.util.regex.Pattern; 027 028import org.apache.avalon.framework.context.Context; 029import org.apache.avalon.framework.context.ContextException; 030import org.apache.avalon.framework.context.Contextualizable; 031import org.apache.avalon.framework.parameters.Parameters; 032import org.apache.avalon.framework.service.ServiceException; 033import org.apache.avalon.framework.service.ServiceManager; 034import org.apache.cocoon.ProcessingException; 035import org.apache.cocoon.ResourceNotFoundException; 036import org.apache.cocoon.components.ContextHelper; 037import org.apache.commons.io.IOUtils; 038import org.apache.excalibur.source.Source; 039import org.apache.excalibur.source.SourceException; 040 041import org.ametys.core.util.I18nUtils; 042import org.ametys.core.util.language.LocaleHelper; 043import org.ametys.runtime.i18n.I18nizableText; 044 045/** 046 * This class generates a translated version of an input file. 047 * It is designed to handle the following notation : {{i18n x}} <br> 048 * When encountering this pattern, we instantiate an {@link I18nizableText} 049 * with x and try to translate it. <br> 050 * Unknown translations are logged and do not prevent the generation process from continuing. 051 */ 052public class I18nTextResourceHandler extends SimpleResourceHandler implements Contextualizable 053{ 054 /** 055 * This configuration parameter specifies the id of the catalogue to be used as 056 * default catalogue, allowing to redefine the default catalogue on the pipeline 057 * level. 058 */ 059 private static final String __I18N_DEFAULT_CATALOGUE_ID = "default-catalogue-id"; 060 061 /** The beginning of a valid declaration for an internationalizable text as characters */ 062 private static final char[] __I18N_BEGINNING_CHARS = {'{', '{', 'i', '1', '8', 'n'}; 063 064 private static final Pattern __LOCALE_PATTERN = Pattern.compile("^(.*resources/.*)\\.([^/.]+)\\.([^/.]+)$"); 065 066 /** The application context */ 067 protected Context _context; 068 069 /** Avalon component gathering utility methods concerning {@link I18nizableText}, allowing their translation in several languages */ 070 private I18nUtils _i18nUtils; 071 /** The user language provider */ 072 private LocaleHelper _localeHelper; 073 074 /** Is the last analyzed i18n declaration valid ? */ 075 private boolean _isDeclarationValid; 076 077 private String _locale; 078 079 public void contextualize(Context context) throws ContextException 080 { 081 _context = context; 082 } 083 084 @Override 085 public void service(ServiceManager serviceManager) throws ServiceException 086 { 087 super.service(serviceManager); 088 _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE); 089 _localeHelper = (LocaleHelper) serviceManager.lookup(LocaleHelper.ROLE); 090 } 091 092 @Override 093 public Source setup(String location, Map objectModel, Parameters parameters, boolean readForDownload) throws ProcessingException, IOException 094 { 095 _parameters = parameters; 096 097 Source source = null; 098 try 099 { 100 source = _resolver.resolveURI(location); 101 } 102 catch (SourceException e) 103 { 104 // Nothing 105 } 106 107 if (source == null || !source.exists()) 108 { 109 _resolver.release(source); 110 111 // Compute real source uri 112 Matcher matcher = __LOCALE_PATTERN.matcher(location); 113 if (matcher.matches()) 114 { 115 String realSrc = matcher.group(1) + "." + matcher.group(3); 116 _locale = matcher.group(2); 117 118 source = _resolver.resolveURI(realSrc); 119 120 if (!source.exists()) 121 { 122 _resolver.release(source); 123 throw new ResourceNotFoundException("Resource not found for URI : '" + location + "'."); 124 } 125 } 126 else 127 { 128 throw new ResourceNotFoundException("Resource not found for URI : '" + location + "'."); 129 } 130 } 131 132 _source = source; 133 return _source; 134 } 135 136 /** 137 * Retrieve the locale from the parameters 138 * @return The locale, or null 139 */ 140 protected String getLocale() 141 { 142 if (_locale == null) 143 { 144 // Default locale 145 Map objectModel = ContextHelper.getObjectModel(_context); 146 _locale = _localeHelper.findLocale(objectModel).getLanguage(); 147 } 148 149 return _locale; 150 } 151 152 @Override 153 public void generate(OutputStream out) throws IOException, ProcessingException 154 { 155 if (!_source.exists()) 156 { 157 throw new ResourceNotFoundException("Resource not found for URI : " + _source.getURI()); 158 } 159 160 BufferedWriter outWriter = null; 161 try (InputStream is = _source.getInputStream()) 162 { 163 outWriter = new BufferedWriter(new OutputStreamWriter(out, "UTF-8")); 164 165 int beginLength = __I18N_BEGINNING_CHARS.length; 166 int endLength = 2; // "}}" 167 int minI18nDeclarationLength = beginLength + 1 + 1 + endLength; // 1 mandatory backspace and at least 1 character for the key 168 169 char[] srcChars = IOUtils.toCharArray(is, "UTF-8"); 170 171 int srcLength = srcChars.length; 172 173 int skip = 0; // Avoid checkstyle warning : "Control variable 'i' is modified" 174 int offset = 0; // Amount of characters to be copied between two valid i18n declarations 175 for (int i = 0; i < srcLength; i = i + skip) 176 { 177 skip = 1; 178 char c = srcChars[i]; 179 180 // Do not bother analyzing when there is no room for a valid declaration 181 if (c == '{' && i + minI18nDeclarationLength < srcLength) 182 { 183 offset++; 184 185 if (_testI18nDeclarationPrefix(srcChars, i)) 186 { 187 // Keep the analyzed characters to put them in the output stream 188 // in case we know the character sequence won't make a viable candidate 189 char backspaceCandidate = srcChars[i + beginLength]; 190 if (backspaceCandidate != ' ') 191 { 192 getLogger().warn("Invalid i18n declaration in the file '{}': '{{i18n' must be followed by a backspace.", _source.getURI()); 193 194 // Update the amount of skipped characters for the next iteration 195 skip += beginLength; 196 197 // Update the offset 198 offset += beginLength; 199 200 continue; 201 } 202 203 // Valid candidate so far, check the end of the notation 204 skip = _analyzeI18nDeclaration(srcChars, i, outWriter, offset); 205 206 // Reset the offset only when the declaration is valid (i.e. when a string from the input has been replaced by another) 207 offset = _isDeclarationValid ? 0 : offset + skip - 1; 208 } 209 } 210 // Escape '{' when its preceding a valid i18n declaration 211 else if (c == '\\' && i + 1 + beginLength < srcLength && _testI18nDeclarationPrefix(srcChars, i + 1)) 212 { 213 outWriter.write(srcChars, i - offset, offset); 214 outWriter.write('{'); 215 216 offset = 0; 217 skip++; 218 } 219 else 220 { 221 offset++; 222 } 223 } 224 225 if (offset == srcLength) 226 { 227 // No i18n declarations to be found ! Simply copy srcChars to the output stream 228 outWriter.write(srcChars, 0, srcChars.length); 229 } 230 else if (offset > 0) 231 { 232 // Copy the last characters 233 outWriter.write(srcChars, srcLength - offset, offset); 234 } 235 236 outWriter.flush(); 237 } 238 } 239 240 /** 241 * Test if the given character is the start of an i18n declaration 242 * @param srcChars the input file as characters 243 * @param start the index of the given character 244 * @return true if this is a start of an i18n declaration, false otherwise 245 */ 246 private boolean _testI18nDeclarationPrefix(char[] srcChars, int start) 247 { 248 return srcChars[start] == '{' && srcChars[start + 1] == '{' && srcChars[start + 2] == 'i' && srcChars[start + 3] == '1' && srcChars[start + 4] == '8' && srcChars[start + 5] == 'n'; 249 } 250 251 /** 252 * Analyze characters from the key beginning index to the possible closure sequence '}}', 253 * and write the appropriate replacement in the output string builder 254 * @param srcChars the input file as characters 255 * @param candidateBeginIdx the index at which we started analyzing a viable i18n declaration 256 * @param outWriter the buffered writer where we store the output string 257 * @param initialOffset the initial offset 258 * @return the amount of analyzed characters 259 * @throws IOException if an error occurs while writing the output 260 */ 261 private int _analyzeI18nDeclaration(char[] srcChars, int candidateBeginIdx, BufferedWriter outWriter, int initialOffset) throws IOException 262 { 263 _isDeclarationValid = false; 264 265 int beginLength = __I18N_BEGINNING_CHARS.length; // "{{i18n" 266 int keyBeginningIndex = candidateBeginIdx + beginLength + 1; // "...........{{i18n " 267 int srcLength = srcChars.length; 268 269 boolean invalid = false; 270 boolean valid = false; 271 272 int j = keyBeginningIndex; 273 while (j < srcLength && !invalid && !valid) 274 { 275 char c = srcChars[j]; 276 switch (c) 277 { 278 case '{': 279 if (j + 1 != srcLength && srcChars[j + 1] == '{') 280 { 281 getLogger().warn("Invalid i18n declaration in the file '{}': '{{' within an i18n declaration is forbidden.", _source.getURI()); 282 invalid = true; 283 } 284 break; 285 286 case '}': 287 if (j + 1 != srcLength && srcChars[j + 1] == '}') 288 { 289 if (j == keyBeginningIndex) 290 { 291 getLogger().warn("Invalid i18n declaration in the file '{}': a key must be specified.", _source.getURI()); 292 invalid = true; 293 break; 294 } 295 else 296 { 297 _isDeclarationValid = true; 298 valid = true; 299 } 300 } 301 break; 302 303 case '\n': 304 305 getLogger().warn("Invalid i18n declaration in the file '{}': '\\n' within an i18n declaration is forbidden. Make sure all i18n declarations are closed with the sequence '}}'.", _source.getURI()); 306 invalid = true; 307 break; 308 309 default: 310 break; 311 } 312 313 j++; 314 } 315 316 if (!valid && !invalid) 317 { 318 // We've reached the end of the file without encountering the closing sequence '}}', and the declaration has not been found valid 319 // nor invalid yet 320 getLogger().warn("Invalid i18n declaration in the file '{}': Reached end of the file without finding the closing sequence of an i18n declaration.", _source.getURI()); 321 return j - candidateBeginIdx; 322 } 323 324 if (valid) 325 { 326 // try to replace the key with its translation 327 _translateKey(srcChars, outWriter, candidateBeginIdx, j, initialOffset); 328 } 329 330 return j - candidateBeginIdx + 1; 331 } 332 333 /** 334 * Try to translate the key and write the output stream with its translation if found, the key itself if not 335 * @param srcChars the input source as characters 336 * @param outWriter the string builder where to write 337 * @param candidateBeginIdx the index at which the i18n declaration started 338 * @param lastIdx the last index analyzed 339 * @param initialOffset the amount of characters that we have to write before the i18n declaration 340 * @throws IOException if an error occurs while writing the output 341 */ 342 private void _translateKey(char[] srcChars, BufferedWriter outWriter, int candidateBeginIdx, int lastIdx, int initialOffset) throws IOException 343 { 344 int keyBeginningIndex = candidateBeginIdx + __I18N_BEGINNING_CHARS.length + 1; // "...........{{i18n " 345 346 // Proper i18n declaration, write the 'offset' characters that are just copied 347 outWriter.write(srcChars, candidateBeginIdx - initialOffset + 1, initialOffset - 1); 348 349 // Extract the key and the catalogue 350 int keyLength = lastIdx - 1 - keyBeginningIndex; 351 String key = String.valueOf(srcChars, keyBeginningIndex, keyLength); 352 353 int indexOfSemiColon = key.indexOf(':'); 354 String catalogue = null; 355 if (indexOfSemiColon != -1) 356 { 357 catalogue = key.substring(0, key.indexOf(':')); 358 key = key.substring(indexOfSemiColon + 1, key.length()); 359 } 360 361 if (catalogue == null) 362 { 363 // Default catalog 364 catalogue = _parameters.getParameter(__I18N_DEFAULT_CATALOGUE_ID, null); 365 } 366 367 // Attempt to translate 368 String translation = _i18nUtils.translate(new I18nizableText(catalogue, key.trim()), getLocale()); 369 if (translation == null) 370 { 371 getLogger().warn("Translation not found for key '{}' in catalogue '{}' with locale {}.", key, catalogue, getLocale()); 372 373 char[] rawI18nDeclaration = new char[7 + keyLength + 2]; // "{{i18n " + "KEY" + "}}" 374 System.arraycopy(srcChars, keyBeginningIndex - 7, rawI18nDeclaration, 0, 7 + keyLength + 2); 375 translation = String.valueOf(rawI18nDeclaration); 376 } 377 378 // replace the i18n declaration with its translation (can be the key itself if no translation found) 379 outWriter.write(translation); 380 } 381 382 @Override 383 public Serializable getKey() 384 { 385 return _source.getURI() + "*" + getLocale(); 386 } 387}