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