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