001/*
002 *  Copyright 2014 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.cms.tag.jcr;
017
018import java.io.File;
019import java.io.FileInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.util.Collections;
023import java.util.HashMap;
024import java.util.HashSet;
025import java.util.Map;
026import java.util.Set;
027
028import javax.xml.parsers.SAXParserFactory;
029
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
033import org.apache.avalon.framework.parameters.Parameters;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.cocoon.environment.ObjectModelHelper;
037import org.apache.cocoon.environment.Redirector;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.environment.SourceResolver;
040import org.apache.cocoon.servlet.multipart.Part;
041import org.apache.cocoon.servlet.multipart.PartOnDisk;
042import org.apache.cocoon.servlet.multipart.RejectedPart;
043import org.apache.commons.io.FilenameUtils;
044import org.apache.commons.lang3.StringUtils;
045import org.xml.sax.XMLReader;
046
047import org.ametys.cms.ObservationConstants;
048import org.ametys.cms.tag.CMSTag;
049import org.ametys.cms.tag.CMSTag.TagVisibility;
050import org.ametys.cms.tag.Tag;
051import org.ametys.cms.tag.TagProvider;
052import org.ametys.cms.tag.TagProviderExtensionPoint;
053import org.ametys.cms.tag.TagTargetType;
054import org.ametys.cms.tag.TagTargetTypeExtensionPoint;
055import org.ametys.core.cocoon.JSonReader;
056import org.ametys.core.observation.AbstractNotifierAction;
057import org.ametys.core.observation.Event;
058import org.ametys.core.util.JSONUtils;
059import org.ametys.plugins.repository.AmetysObjectResolver;
060import org.ametys.plugins.repository.TraversableAmetysObject;
061import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject;
062import org.ametys.runtime.i18n.I18nizableText;
063
064/**
065 * Import subscribers from a CSV or text file.
066 */
067public class ImportTagsAction extends AbstractNotifierAction
068{
069    private static final String[] _ALLOWED_EXTENSIONS = new String[] {"xml"};
070    
071    /** The tag provider extension point */
072    protected TagProviderExtensionPoint _tagProviderExtPt;
073    /** The Ametys object resolver */
074    protected AmetysObjectResolver _resolver;
075    /** Target types */
076    protected TagTargetTypeExtensionPoint _targetTypeEP;
077    /** The JSon Utils Component */
078    protected JSONUtils _jsonUtils;
079    
080    /** The count of new tags **/
081    private int _createdTagsCount;
082    /** The count of updated tags **/
083    private int _updatedTagsCount;
084    /** The count of error **/
085    private int _errorCount;
086
087    @Override
088    public void service(ServiceManager smanager) throws ServiceException
089    {
090        super.service(smanager);
091        _tagProviderExtPt = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
092        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
093        _targetTypeEP = (TagTargetTypeExtensionPoint) smanager.lookup(TagTargetTypeExtensionPoint.ROLE);
094        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
095    }
096    
097    @Override
098    public Map act(Redirector redirector, SourceResolver resolver, Map objectModel, String source, Parameters parameters) throws Exception
099    {
100        Map<String, Object> result = new HashMap<>();
101
102        Request request = ObjectModelHelper.getRequest(objectModel);
103
104        String parentId = request.getParameter("tagId");
105        String forceProvider = request.getParameter("tagProvider");
106        if (StringUtils.isEmpty(forceProvider))
107        {
108            forceProvider = CMSJCRTagProvider.class.getName();
109        }
110        
111        Part part = (Part) request.get("importFile");
112        if (part instanceof RejectedPart)
113        {
114            return Collections.singletonMap("error", "rejected-file");
115        }
116        
117        PartOnDisk uploadedFilePart = (PartOnDisk) part;
118        File uploadedFile = (uploadedFilePart != null) ? uploadedFilePart.getFile() : null;
119        String filename = (uploadedFilePart != null) ? uploadedFilePart.getFileName().toLowerCase() : null;
120        
121        if (!FilenameUtils.isExtension(filename, _ALLOWED_EXTENSIONS))
122        {
123            result.put("success", false);
124            result.put("error", "invalid-extension");
125        }
126        else if (uploadedFile == null)
127        {
128            result.put("success", false);
129            result.put("error", "no-file");
130        }
131        else
132        {
133            String contextualParametersAsStr = request.getParameter("contextualParameters");
134            Map<String, Object> contextualParameters = _jsonUtils.convertJsonToMap(contextualParametersAsStr);
135            
136            Configuration configuration = initializeTags(uploadedFile.getPath());
137            Map<String, CMSTag> tags = configureTags(configuration, null, "plugin.cms");
138            
139            _createdTagsCount = 0;
140            _updatedTagsCount = 0;
141            _errorCount = 0;
142            
143            DefaultTraversableAmetysObject<?> parent = _resolver.resolveById(parentId);
144            createOrUpdateTags(parent, tags, contextualParameters);
145            
146            CMSJCRTagProvider provider = (CMSJCRTagProvider) _tagProviderExtPt.getExtension(forceProvider);
147            TraversableAmetysObject rootNode = provider.getRootNode(contextualParameters);
148
149            result.put("success", true);
150            result.put("rootId", rootNode.getId());
151            result.put("createdCount", _createdTagsCount);
152            result.put("updatedCount", _updatedTagsCount);
153            result.put("errorCount", _errorCount);
154        }
155        
156        request.setAttribute(JSonReader.OBJECT_TO_READ, result);
157        return EMPTY_MAP;
158    }
159    
160    /**
161     * Save or update a tag
162     * @param parent the JCR parent tag or provider
163     * @param tags the list of tags to add or update
164     * @param contextualParameters Contextual parameters transmitted by the environment.
165     */
166    protected void createOrUpdateTags(DefaultTraversableAmetysObject<?> parent, Map<String, CMSTag> tags, Map<String, Object> contextualParameters)
167    {
168        boolean update = false;
169        for (CMSTag cmsTag : tags.values())
170        {
171            CMSJCRTag jcrTag = null;
172            
173            try
174            {
175                if (parent.hasChild(cmsTag.getName()))
176                {
177                    // Update existing tag
178                    jcrTag = parent.getChild(cmsTag.getName());
179                    update = true;
180                }
181                else
182                {
183                    // Create new tag
184                    String name = _findUniqueName (cmsTag.getId(), contextualParameters);
185                    jcrTag = parent.createChild(name, CMSTagFactory.TAG_NODETYPE);
186                }
187                
188                jcrTag.setTitle(cmsTag.getTitle().getLabel());
189                jcrTag.setDescription(cmsTag.getDescription().getLabel());
190                jcrTag.setVisibility(cmsTag.getVisibility());
191                jcrTag.setTargetType(cmsTag.getTarget());
192                
193                // recursive call to save/update
194                createOrUpdateTags(jcrTag, cmsTag.getTags(), contextualParameters);
195                
196                jcrTag.saveChanges();
197                
198                Map<String, Object> eventParams = new HashMap<>();
199                eventParams.put(ObservationConstants.ARGS_TAG_ID, jcrTag.getId());
200                eventParams.put(ObservationConstants.ARGS_TAG_NAME, jcrTag.getName());
201                
202                if (update)
203                {
204                    _observationManager.notify(new Event(ObservationConstants.EVENT_TAG_UPDATED, _getCurrentUser(), eventParams));
205                    _updatedTagsCount++;
206                }
207                else
208                {
209                    // Notify observers that the tag has been added.
210                    _observationManager.notify(new Event(ObservationConstants.EVENT_TAG_ADDED, _getCurrentUser(), eventParams));
211                    _createdTagsCount++;
212                }
213            }
214            catch  (Exception e)
215            {
216                getLogger().error("Unable to add tag " + cmsTag.getName() + " to JCR tag category of id '" + parent.getId() + "'", e);
217                _errorCount++;
218            }
219        }
220    }
221    
222    /**
223     * Initialize a configuration tags from the tags file.
224     * @param path the path of the file to initialize tags.
225     * @return The configuration
226     * @throws Exception if an error occurs.
227     */
228    protected Configuration initializeTags(String path) throws Exception
229    {
230        try (InputStream is = new FileInputStream(path))
231        {
232            SAXParserFactory factory = SAXParserFactory.newInstance();
233            
234            XMLReader reader = factory.newSAXParser().getXMLReader();
235
236            DefaultConfigurationBuilder confBuilder = new DefaultConfigurationBuilder(reader);
237
238            return confBuilder.build(is);
239        }
240        catch (IOException e)
241        {
242            return null;
243        }
244    }
245    
246    /**
247     * Configure tag from the passed configuration
248     * @param configuration The configuration
249     * @param parent The parent tag if any
250     * @param defaultCatalogue The default catalogue for i18n
251     * @return a Set of {@link CMSTag}
252     * @throws ConfigurationException If an error occurred
253     */
254    protected Map<String, CMSTag> configureTags (Configuration configuration, CMSTag parent, String defaultCatalogue)  throws ConfigurationException
255    {
256        Map<String, CMSTag> tags = new HashMap<>();
257        
258        Configuration[] tagsConfiguration = configuration.getChildren("tag");
259        for (Configuration tagConfiguration : tagsConfiguration)
260        {
261            String id = tagConfiguration.getAttribute("id");
262            if (!Tag.NAME_PATTERN.matcher(id).matches())
263            {
264                throw new ConfigurationException("Invalid tag ID '" + id + "': it must match the pattern " + Tag.NAME_PATTERN.toString(), configuration);
265            }
266            
267            TagVisibility visibility = TagVisibility.PUBLIC;
268            if (tagConfiguration.getAttribute("private", "").equals("true"))
269            {
270                visibility = TagVisibility.PRIVATE;
271            }
272            
273            String typeName = tagConfiguration.getAttribute("target", "CONTENT");
274            TagTargetType targetType = _targetTypeEP.getTagTargetType(typeName);
275            
276            I18nizableText label = configureLabel (tagConfiguration, defaultCatalogue);
277            I18nizableText description = configureDescription (tagConfiguration, defaultCatalogue);
278            CMSTag tag = new CMSTag(id, id, parent, label, description, visibility, targetType);
279            tags.put(id, tag);
280            
281            // Recursive configuration
282            Map<String, CMSTag> childTags = configureTags(tagConfiguration, tag, defaultCatalogue);
283            tag.setTags(childTags);
284        }
285        
286        return tags;
287    }
288    
289    /**
290     * Configure label from the passed configuration
291     * @param configuration The configuration
292     * @param defaultCatalogue The default catalogue
293     * @return The label
294     * @throws ConfigurationException If an error occurred
295     */
296    protected I18nizableText configureLabel (Configuration configuration, String defaultCatalogue) throws ConfigurationException
297    {
298        Configuration labelConfiguration = configuration.getChild("label");
299        
300        if (labelConfiguration.getAttributeAsBoolean("i18n", false))
301        {
302            return new I18nizableText(defaultCatalogue, labelConfiguration.getValue(""));
303        }
304        else
305        {
306            return new I18nizableText(labelConfiguration.getValue(""));
307        }
308    }
309    
310    /**
311     * Configure description from the passed configuration
312     * @param configuration The configuration
313     * @param defaultCatalogue The default catalogue
314     * @return The description
315     * @throws ConfigurationException If an error occurred
316     */
317    protected I18nizableText configureDescription (Configuration configuration, String defaultCatalogue) throws ConfigurationException
318    {
319        Configuration descConfiguration = configuration.getChild("description");
320        
321        if (descConfiguration.getAttributeAsBoolean("i18n", false))
322        {
323            return new I18nizableText(defaultCatalogue, descConfiguration.getValue(""));
324        }
325        else
326        {
327            return new I18nizableText(descConfiguration.getValue(""));
328        }
329    }
330
331    private String _findUniqueName(String originalName, Map<String, Object> contextualParameters)
332    {
333        Set<TagProvider<CMSTag>> providers = _getTagProviders();
334        
335        // Find an unique name
336        int index = 2;
337        String name = originalName;
338        while (_hasTag(providers, name, contextualParameters))
339        {
340            name = originalName + "_" + (index++);
341        }
342        
343        return name;
344    }
345    
346    private boolean _hasTag(Set<TagProvider<CMSTag>> providers, String name, Map<String, Object> contextualParameters)
347    {
348        for (TagProvider<CMSTag> provider : providers)
349        {
350            if (provider.hasTag(name, contextualParameters))
351            {
352                return true;
353            }
354        }
355        return false;
356    }
357    
358    private Set<TagProvider<CMSTag>> _getTagProviders ()
359    {
360        Set<TagProvider<CMSTag>> providers = new HashSet<>();
361        
362        Set<String> ids = _tagProviderExtPt.getExtensionsIds();
363        for (String id : ids)
364        {
365            TagProvider<CMSTag> provider = _tagProviderExtPt.getExtension(id);
366            providers.add(provider);
367        }
368        
369        return providers;
370    }
371}