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}