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}