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}