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