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.plugins.contentio.in;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.Iterator;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028
029import org.apache.avalon.framework.configuration.Configurable;
030import org.apache.avalon.framework.configuration.Configuration;
031import org.apache.avalon.framework.configuration.ConfigurationException;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.commons.io.FilenameUtils;
036import org.apache.commons.lang3.StringUtils;
037
038import org.ametys.cms.repository.Content;
039import org.ametys.cms.repository.ContentQueryHelper;
040import org.ametys.cms.repository.WorkflowAwareContent;
041import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
042import org.ametys.cms.workflow.ContentWorkflowHelper;
043import org.ametys.cms.workflow.EditContentFunction;
044import org.ametys.plugins.repository.AmetysObjectIterable;
045import org.ametys.plugins.repository.AmetysObjectResolver;
046import org.ametys.plugins.repository.jcr.NameHelper;
047import org.ametys.plugins.repository.query.expression.AndExpression;
048import org.ametys.plugins.repository.query.expression.Expression.Operator;
049import org.ametys.plugins.repository.query.expression.StringExpression;
050import org.ametys.plugins.workflow.AbstractWorkflowComponent;
051import org.ametys.runtime.plugin.component.AbstractLogEnabled;
052
053import com.opensymphony.workflow.WorkflowException;
054
055/**
056 * Abstract {@link ContentImporter} class which provides base importer configuration and logic.<br>
057 * Configuration options:
058 * <ul>
059 *   <li>Importer priority</li>
060 *   <li>Allowed extensions, without leading dot and comma-separated</li>
061 *   <li>Content types and mixins of the created contents</li>
062 *   <li>Language of the created contents</li>
063 *   <li>Content workflow name and creation action ID</li>
064 * </ul><br>
065 * Example configuration handled by the configure method:
066 * <pre>
067 * <extension point="org.ametys.plugins.contentio.ContentImporterExtensionPoint"
068 *               id="my.content.importer"
069 *               class="...">
070 *     <priority>500</priority>
071 *     <extensions>ext,ext2</extensions>
072 *     <content-creation>
073 *         <content-types>My.ContentType.1,My.ContentType.2</content-types>
074 *         <mixins>My.Mixin.1,My.Mixin.2</mixins>
075 *         <language>en</language>
076 *         <workflow name="content" createActionId="1" editActionId="2"/>
077 *     </content-creation>
078 * </extension>
079 * </pre>
080 */
081public abstract class AbstractContentImporter extends AbstractLogEnabled implements ContentImporter, Serviceable, Configurable
082{
083    
084    /** The default importer priority. */
085    protected static final int DEFAULT_PRIORITY = 5000;
086    
087    /** Map used to store the mapping from "local" ID to content ID, when actually imported. */
088    protected static final String _CONTENT_ID_MAP_KEY = AbstractContentImporter.class.getName() + "$contentIdMap";
089    
090    /** Map used to store the content references, indexed by content and metadata path. */
091    protected static final String _CONTENT_LINK_MAP_KEY = AbstractContentImporter.class.getName() + "$contentLinkMap";
092    
093    /** Map used to store the content repeater sizes. */
094    protected static final String _CONTENT_REPEATER_SIZE_MAP = AbstractContentImporter.class.getName() + "$contentRepeaterSizeMap";
095    
096    /** The AmetysObject resolver. */
097    protected AmetysObjectResolver _resolver;
098    
099    /** The content workflow helper. */
100    protected ContentWorkflowHelper _contentWorkflowHelper;
101    
102    /** The importer priority. */
103    protected int _priority = DEFAULT_PRIORITY;
104    
105    /** The allowed extensions. */
106    protected Set<String> _extensions;
107    
108    /** The imported contents' types. */
109    protected String[] _contentTypes;
110    
111    /** The imported contents' mixins. */
112    protected String[] _mixins;
113    
114    /** The importer contents' language. */
115    protected String _language;
116    
117    /** The importer contents' workflow name. */
118    protected String _workflowName;
119    
120    /** The importer contents' initial action ID. */
121    protected int _initialActionId;
122    
123    /** The importer contents' edition action ID. */
124    protected int _editActionId;
125    
126    @Override
127    public void service(ServiceManager manager) throws ServiceException
128    {
129        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
130        _contentWorkflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE);
131    }
132    
133    @Override
134    public void configure(Configuration configuration) throws ConfigurationException
135    {
136        _priority = configuration.getChild("priority").getValueAsInteger(DEFAULT_PRIORITY);
137        
138        configureExtensions(configuration.getChild("extensions"));
139        
140        configureContentCreation(configuration.getChild("content-creation"));
141    }
142    
143    /**
144     * Configure the allowed extensions.
145     * @param configuration the extension configuration.
146     * @throws ConfigurationException if an error occurs.
147     */
148    protected void configureExtensions(Configuration configuration) throws ConfigurationException
149    {
150        _extensions = new HashSet<>();
151        
152        String extensionsStr = configuration.getValue("");
153        
154        if (StringUtils.isBlank(extensionsStr))
155        {
156            _extensions.addAll(getDefaultExtensions());
157        }
158        else
159        {
160            for (String ext : StringUtils.split(extensionsStr, ", "))
161            {
162                String extension = ext.trim();
163                if (extension.startsWith("."))
164                {
165                    extension = extension.substring(1);
166                }
167                
168                _extensions.add(extension);
169            }
170        }
171    }
172    
173    /**
174     * Configure the content creation parameters.
175     * @param configuration the content creation configuration.
176     * @throws ConfigurationException if an error occurs.
177     */
178    protected void configureContentCreation(Configuration configuration) throws ConfigurationException
179    {
180        String typesStr = configuration.getChild("content-types").getValue();
181        _contentTypes = StringUtils.split(typesStr, ", ");
182        
183        String mixins = configuration.getChild("mixins").getValue("");  // mixins can be empty
184        _mixins = StringUtils.split(mixins, ", ");
185        
186        _language = configuration.getChild("language").getValue();
187        
188        configureWorkflow(configuration);
189    }
190    
191    /**
192     * Configure the content workflow.
193     * @param configuration the content creation configuration.
194     * @throws ConfigurationException if an error occurs.
195     */
196    protected void configureWorkflow(Configuration configuration) throws ConfigurationException
197    {
198        Configuration wfConf = configuration.getChild("workflow");
199        
200        _workflowName = wfConf.getAttribute("name");
201        
202        _initialActionId = wfConf.getAttributeAsInteger("createActionId");
203        _editActionId = wfConf.getAttributeAsInteger("editActionId");
204    }
205    
206    @Override
207    public int getPriority()
208    {
209        return _priority;
210    }
211    
212    /**
213     * Get the default allowed extensions.
214     * @return the default allowed extensions, without leading dots. Cannot be null.
215     */
216    protected Collection<String> getDefaultExtensions()
217    {
218        return Collections.emptySet();
219    }
220    
221    /**
222     * Test if the given filename has a supported extension.
223     * @param name the name, can't be null.
224     * @return true if the extension is supported, false otherwise.
225     * @throws IOException if an error occurs.
226     */
227    protected boolean isExtensionValid(String name) throws IOException
228    {
229        return _extensions.isEmpty() || _extensions.contains(FilenameUtils.getExtension(name));
230    }
231    
232    /**
233     * The content types of a created content.
234     * @param params the import parameters.
235     * @return the content types of a created content.
236     */
237    protected String[] getContentTypes(Map<String, Object> params)
238    {
239        return _contentTypes;
240    }
241    
242    /**
243     * The mixins of a created content.
244     * @param params the import parameters.
245     * @return The mixins of a created content.
246     */
247    protected String[] getMixins(Map<String, Object> params)
248    {
249        return _mixins;
250    }
251    
252    /**
253     * The language of a created content.
254     * @param params the import parameters.
255     * @return The language of a created content.
256     */
257    protected String getLanguage(Map<String, Object> params)
258    {
259        return _language;
260    }
261    
262    /**
263     * The workflow name of a created content.
264     * @param params the import parameters.
265     * @return The workflow name of a created content.
266     */
267    protected String getWorkflowName(Map<String, Object> params)
268    {
269        return _workflowName;
270    }
271    
272    /**
273     * The workflow creation action ID of a created content.
274     * @param params the import parameters.
275     * @return The workflow creation action ID of a created content.
276     */
277    protected int getInitialActionId(Map<String, Object> params)
278    {
279        return _initialActionId;
280    }
281    
282    /**
283     * The workflow action ID used to edit a content.
284     * @param params the import parameters.
285     * @return The workflow action ID used to edit a content.
286     */
287    protected int getEditActionId(Map<String, Object> params)
288    {
289        return _editActionId;
290    }
291    
292    /**
293     * Get the map used to store the mapping from "local" ID (defined in the import file)
294     * to the AmetysObject ID of the contents, when actually imported.
295     * @param params the import parameters.
296     * @return the content "local to repository" ID map.
297     */
298    protected Map<String, String> getContentIdMap(Map<String, Object> params)
299    {
300        // Get or create the map in the global parameters.
301        @SuppressWarnings("unchecked")
302        Map<String, String> contentIdMap = (Map<String, String>) params.get(_CONTENT_ID_MAP_KEY);
303        if (contentIdMap == null)
304        {
305            contentIdMap = new HashMap<>();
306            params.put(_CONTENT_ID_MAP_KEY, contentIdMap);
307        }
308        
309        return contentIdMap;
310    }
311    
312    /**
313     * Get the map used to store the content references.
314     * The Map is shaped like: referencing content -&gt; local metadata path -&gt; content references.
315     * @param params the import parameters.
316     * @return the content reference map.
317     */
318    protected Map<Content, Map<String, Object>> getContentRefMap(Map<String, Object> params)
319    {
320        // Get or create the map in the global parameters.
321        @SuppressWarnings("unchecked")
322        Map<Content, Map<String, Object>> contentRefMap = (Map<Content, Map<String, Object>>) params.get(_CONTENT_LINK_MAP_KEY);
323        if (contentRefMap == null)
324        {
325            contentRefMap = new HashMap<>();
326            params.put(_CONTENT_LINK_MAP_KEY, contentRefMap);
327        }
328        
329        return contentRefMap;
330    }
331    
332    /**
333     * Add a content reference to the map.
334     * @param content The referencing content.
335     * @param metadataPath The path of the metadata which holds the content references.
336     * @param reference The content reference.
337     * @param params The import parameters.
338     */
339    protected void addContentReference(Content content, String metadataPath, ContentReference reference, Map<String, Object> params)
340    {
341        addContentReference(getContentRefMap(params), content, metadataPath, reference);
342    }
343    
344    /**
345     * Add a content reference to the map.
346     * @param contentRefMap The content reference map.
347     * @param content The referencing content.
348     * @param metadataPath The path of the metadata which holds the content references.
349     * @param reference The content reference.
350     */
351    protected void addContentReference(Map<Content, Map<String, Object>> contentRefMap, Content content, String metadataPath, ContentReference reference)
352    {
353        Map<String, Object> contentReferences;
354        if (contentRefMap.containsKey(content))
355        {
356            contentReferences = contentRefMap.get(content);
357        }
358        else
359        {
360            contentReferences = new HashMap<>();
361            contentRefMap.put(content, contentReferences);
362        }
363        
364        contentReferences.put(metadataPath, reference);
365    }
366    
367    /**
368     * Add content references to the map.
369     * @param contentRefMap The content reference map.
370     * @param content The referencing content.
371     * @param metadataPath The path of the metadata which holds the content references.
372     * @param references the content reference list.
373     */
374    protected void addContentReferences(Map<Content, Map<String, Object>> contentRefMap, Content content, String metadataPath, List<ContentReference> references)
375    {
376        Map<String, Object> contentReferences;
377        if (contentRefMap.containsKey(content))
378        {
379            contentReferences = contentRefMap.get(content);
380        }
381        else
382        {
383            contentReferences = new HashMap<>();
384            contentRefMap.put(content, contentReferences);
385        }
386        
387        contentReferences.put(metadataPath, references);
388    }
389    
390    /**
391     * Get the map used to store the repeater sizes.
392     * The Map is shaped like: referencing content -&gt; local metadata path -&gt; content references.
393     * @param params the import parameters.
394     * @return the content reference map.
395     */
396    protected Map<Content, Map<String, Integer>> getContentRepeaterSizeMap(Map<String, Object> params)
397    {
398        // Get or create the map in the global parameters.
399        @SuppressWarnings("unchecked")
400        Map<Content, Map<String, Integer>> contentRepeaterSizeMap = (Map<Content, Map<String, Integer>>) params.get(_CONTENT_REPEATER_SIZE_MAP);
401        if (contentRepeaterSizeMap == null)
402        {
403            contentRepeaterSizeMap = new HashMap<>();
404            params.put(_CONTENT_REPEATER_SIZE_MAP, contentRepeaterSizeMap);
405        }
406        
407        return contentRepeaterSizeMap;
408    }
409    
410    /**
411     * Set a repeater size in the map (needed to execute the edit content function).
412     * @param content The content containing the repeater.
413     * @param metadataPath The repeater metadata path.
414     * @param repeaterSize The repeater size.
415     * @param params The import parameters.
416     */
417    protected void setRepeaterSize(Content content, String metadataPath, int repeaterSize, Map<String, Object> params)
418    {
419        Map<Content, Map<String, Integer>> contentRepeaterSizeMap = getContentRepeaterSizeMap(params);
420        
421        Map<String, Integer> repeaters;
422        if (contentRepeaterSizeMap.containsKey(content))
423        {
424            repeaters = contentRepeaterSizeMap.get(content);
425        }
426        else
427        {
428            repeaters = new HashMap<>();
429            contentRepeaterSizeMap.put(content, repeaters);
430        }
431        
432        repeaters.put(metadataPath, repeaterSize);
433    }
434    
435    /**
436     * Create a content.
437     * @param title the content title.
438     * @param params the import parameters.
439     * @return the created content.
440     * @throws WorkflowException if an error occurs.
441     */
442    protected Content createContent(String title, Map<String, Object> params) throws WorkflowException
443    {
444        String[] contentTypes = getContentTypes(params);
445        String[] mixins = getMixins(params);
446        String language = getLanguage(params);
447        String workflowName = getWorkflowName(params);
448        int initialActionId = getInitialActionId(params);
449        
450        return createContent(title, contentTypes, mixins, language, workflowName, initialActionId, params);
451    }
452    
453    /**
454     * Create a content.
455     * @param title the content title.
456     * @param contentTypes the content types.
457     * @param mixins the content mixins.
458     * @param language the content language.
459     * @param params the import parameters.
460     * @return the created content.
461     * @throws WorkflowException if an error occurs.
462     */
463    protected Content createContent(String title, String[] contentTypes, String[] mixins, String language, Map<String, Object> params) throws WorkflowException
464    {
465        String workflowName = getWorkflowName(params);
466        int initialActionId = getInitialActionId(params);
467        
468        return createContent(title, contentTypes, mixins, language, workflowName, initialActionId, params);
469    }
470    
471    /**
472     * Create a content.
473     * @param title the content title.
474     * @param contentTypes the content types.
475     * @param mixins the content mixins.
476     * @param language the content language.
477     * @param workflowName the content workflow name.
478     * @param initialActionId the content create action ID.
479     * @param params the import parameters.
480     * @return the created content.
481     * @throws WorkflowException if an error occurs.
482     */
483    protected Content createContent(String title, String[] contentTypes, String[] mixins, String language, String workflowName, int initialActionId, Map<String, Object> params) throws WorkflowException
484    {
485        String name;
486        try
487        {
488            name = NameHelper.filterName(title);
489        }
490        catch (Exception e)
491        {
492            // Ignore the exception, just provide a valid start.
493            name = "content-" + title;
494        }
495        
496        Map<String, Object> result = _contentWorkflowHelper.createContent(workflowName, initialActionId, name, title, contentTypes, mixins, language);
497        
498        return (Content) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
499    }
500    
501    /**
502     * Restore content references.
503     * @param params The import parameters.
504     */
505    protected void restoreContentReferences(Map<String, Object> params)
506    {
507        Map<Content, Map<String, Object>> contentRefMap = getContentRefMap(params);
508        Map<Content, Map<String, Integer>> contentRepeaterSizeMap = getContentRepeaterSizeMap(params);
509        int editActionId = getEditActionId(params);
510        
511        for (Content content : contentRefMap.keySet())
512        {
513            if (content instanceof WorkflowAwareContent)
514            {
515                Map<String, Object> contentReferences = contentRefMap.get(content);
516                Map<String, Integer> repeaters = contentRepeaterSizeMap.get(content);
517                
518                Map<String, Object> values = new HashMap<>();
519                
520                // Fill the value map with the content references.
521                setReferenceMetadatas(contentReferences, values, repeaters, params);
522                
523                try
524                {
525                    if (!values.isEmpty())
526                    {
527                        Map<String, Object> contextParameters = new HashMap<>();
528                        contextParameters.put(EditContentFunction.QUIT, true);
529                        contextParameters.put(EditContentFunction.FORM_RAW_VALUES, values);
530                        
531                        Map<String, Object> inputs = new HashMap<>();
532                        inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
533                        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
534                        
535                        _contentWorkflowHelper.doAction((WorkflowAwareContent) content, editActionId, inputs);
536                    }
537                }
538                catch (WorkflowException e)
539                {
540                    // TODO Throw exception?
541                    getLogger().warn("An error occurred restoring content references in content {}", content, e);
542                }
543            }
544        }
545    }
546    
547    /**
548     * Fill the value map with the content references.
549     * @param contentReferences The list of content references indexed by metadata path.
550     * @param values The value map passed to the EditContentFunction class.
551     * @param repeaters The repeater sizes for this content.
552     * @param params The import parameters.
553     */
554    protected void setReferenceMetadatas(Map<String, Object> contentReferences, Map<String, Object> values, Map<String, Integer> repeaters, Map<String, Object> params)
555    {
556        for (String metadataPath : contentReferences.keySet())
557        {
558            Object value = contentReferences.get(metadataPath);
559            String metaKey = EditContentFunction.FORM_ELEMENTS_PREFIX + metadataPath;
560            
561            if (value instanceof List<?>)
562            {
563                // Multiple value
564                @SuppressWarnings("unchecked")
565                List<ContentReference> references = (List<ContentReference>) value;
566                List<String> contentIds = new ArrayList<>(references.size());
567                for (ContentReference reference : references)
568                {
569                    String refContentId = getReferencedContentId(reference, params);
570                    if (refContentId != null)
571                    {
572                        contentIds.add(refContentId);
573                    }
574                }
575                
576                if (!contentIds.isEmpty())
577                {
578                    values.put(metaKey, contentIds);
579                }
580            }
581            else if (value instanceof ContentReference)
582            {
583                // Single value.
584                String refContentId = getReferencedContentId((ContentReference) value, params);
585                if (refContentId != null)
586                {
587                    values.put(metaKey, refContentId);
588                }
589            }
590        }
591        
592        if (repeaters != null)
593        {
594            for (String repeaterPath : repeaters.keySet())
595            {
596                Integer size = repeaters.get(repeaterPath);
597                if (size > 0)
598                {
599                    String sizeKey = EditContentFunction.INTERNAL_FORM_ELEMENTS_PREFIX + repeaterPath + "/size";
600                    values.put(sizeKey, repeaters.get(repeaterPath).toString());
601                }
602            }
603        }
604    }
605    
606    /**
607     * Get the content ID from a content reference.
608     * @param contentRef The content reference.
609     * @param params The import parameters.
610     * @return the content ID if it was found, or null otherwise.
611     */
612    protected String getReferencedContentId(ContentReference contentRef, Map<String, Object> params)
613    {
614        int refType = contentRef.getType();
615        if (refType == ContentReference.TYPE_LOCAL_ID)
616        {
617            String localId = (String) contentRef.getValue();
618            String contentId = getContentIdMap(params).get(localId);
619            if (StringUtils.isNotEmpty(contentId) && _resolver.hasAmetysObjectForId(contentId))
620            {
621                return contentId;
622            }
623        }
624        else if (refType == ContentReference.TYPE_CONTENT_ID)
625        {
626            String contentId = (String) contentRef.getValue();
627            if (StringUtils.isNotEmpty(contentId) && _resolver.hasAmetysObjectForId(contentId))
628            {
629                return contentId;
630            }
631        }
632        else if (refType == ContentReference.TYPE_CONTENT_VALUES)
633        {
634            @SuppressWarnings("unchecked")
635            Map<String, String> values = (Map<String, String>) contentRef.getValue();
636            Content content = getContentFromProperties(values);
637            if (content != null)
638            {
639                return content.getId();
640            }
641        }
642        
643        return null;
644    }
645    
646    /**
647     * Search a content from a map of its metadata values.
648     * @param propertyValues The metadata values.
649     * @return The Content if found, null otherwise.
650     */
651    protected Content getContentFromProperties(Map<String, String> propertyValues)
652    {
653        Content content = null;
654        
655        AndExpression expression = new AndExpression();
656        for (String property : propertyValues.keySet())
657        {
658            String value = propertyValues.get(property);
659            expression.add(new StringExpression(property, Operator.EQ, value));
660        }
661        
662        String query = ContentQueryHelper.getContentXPathQuery(expression);
663        
664        AmetysObjectIterable<Content> contents = _resolver.query(query);
665        Iterator<Content> it = contents.iterator();
666        
667        if (it.hasNext())
668        {
669            content = it.next();
670            
671            if (it.hasNext())
672            {
673                content = null;
674            }
675        }
676        
677        return content;
678    }
679    
680    /**
681     * Class representing a reference to a content in an import file.
682     */
683    public class ContentReference
684    {
685        /**
686         * The referenced content doesn't exist in the repository, it's in the import file.
687         * The reference value is the content ID in the import file.
688         */
689        public static final int TYPE_LOCAL_ID = 1;
690        
691        /**
692         * The referenced content exits in the repository and its ID is known.
693         * The reference value is the content ID in the repository (AmetysObject ID).
694         */
695        public static final int TYPE_CONTENT_ID = 2;
696        
697        /**
698         * The referenced content exits in the repository. Its ID is not known,
699         * but it can be identified by one or several of its metadata.
700         * The reference value is a Map of metadata name -&gt; value.
701         */
702        public static final int TYPE_CONTENT_VALUES = 3;
703        
704        /** The reference type. */
705        private int _type;
706        
707        /** The reference value, depends on the reference type. */
708        private Object _value;
709        
710        /**
711         * Build a content reference.
712         * @param type the reference type.
713         * @param value the reference value.
714         */
715        public ContentReference(int type, Object value)
716        {
717            this._type = type;
718            this._value = value;
719        }
720
721        /**
722         * Get the type.
723         * @return the type
724         */
725        public int getType()
726        {
727            return _type;
728        }
729
730        /**
731         * Set the type.
732         * @param type the type to set
733         */
734        public void setType(int type)
735        {
736            this._type = type;
737        }
738
739        /**
740         * Get the value.
741         * @return the value
742         */
743        public Object getValue()
744        {
745            return _value;
746        }
747
748        /**
749         * Set the value.
750         * @param value the value to set
751         */
752        public void setValue(Object value)
753        {
754            this._value = value;
755        }
756    }
757}