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}