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