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.xml;
017
018import java.io.ByteArrayInputStream;
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Set;
026import java.util.regex.Pattern;
027
028import javax.xml.transform.TransformerException;
029
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.commons.lang3.StringUtils;
035import org.w3c.dom.Document;
036import org.w3c.dom.Node;
037import org.w3c.dom.NodeList;
038
039import org.ametys.cms.contenttype.ContentTypesHelper;
040import org.ametys.cms.contenttype.MetadataDefinition;
041import org.ametys.cms.contenttype.RepeaterDefinition;
042import org.ametys.cms.repository.Content;
043import org.ametys.cms.repository.ModifiableContent;
044import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
045import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
046import org.ametys.plugins.repository.version.VersionableAmetysObject;
047
048import com.opensymphony.workflow.WorkflowException;
049
050/**
051 * Default implementation of an XML content importer.
052 */
053public class DefaultXmlContentImporter extends AbstractXmlContentImporter
054{
055    
056    /** Default content types key in the import map. */
057    protected static final String _DEFAULT_CONTENT_TYPES_KEY = DefaultXmlContentImporter.class.getName() + "$defaultContentTypes";
058    /** Default content mixins key in the import map. */
059    protected static final String _DEFAULT_CONTENT_MIXINS_KEY = DefaultXmlContentImporter.class.getName() + "$defaultMixins";
060    /** Default content language key in the import map. */
061    protected static final String _DEFAULT_CONTENT_LANG_KEY = DefaultXmlContentImporter.class.getName() + "$defaultLanguage";
062    
063    /** The content type helper. */
064    protected ContentTypesHelper _cTypeHelper;
065    
066    /** The XPath expression to match. */
067    protected String _matchPath;
068    
069    /** The XPath value to match. */
070    protected String _matchValue;
071    
072    /** The XPath value to match. */
073    protected Pattern _matchRegex;
074    
075    @Override
076    public void service(ServiceManager serviceManager) throws ServiceException
077    {
078        super.service(serviceManager);
079        _cTypeHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE);
080    }
081    
082    @Override
083    public void configure(Configuration configuration) throws ConfigurationException
084    {
085        super.configure(configuration);
086        
087        configureXmlMatch(configuration.getChild("xml").getChild("match"));
088    }
089    
090    /**
091     * Configure the matching value.
092     * @param configuration the matching configuration.
093     * @throws ConfigurationException if an error occurs.
094     */
095    protected void configureXmlMatch(Configuration configuration) throws ConfigurationException
096    {
097        _matchPath = configuration.getAttribute("path");
098        _matchValue = configuration.getAttribute("value", null);
099        String regex = configuration.getAttribute("regex", null);
100        if (StringUtils.isNotBlank(regex))
101        {
102            _matchRegex = Pattern.compile(regex);
103        }
104    }
105    
106    @Override
107    public boolean supports(Document document) throws IOException
108    {
109        Node node = _xPathProcessor.selectSingleNode(document, _matchPath, getPrefixResolver());
110        
111        if (_matchValue == null && _matchRegex == null)
112        {
113            return node != null;
114        }
115        else if (_matchRegex != null)
116        {
117            String text = getTextContent(node, null, true);
118            return text != null ? _matchRegex.matcher(text).matches() : false;
119        }
120        else
121        {
122            return _matchValue.equals(getTextContent(node, null, true));
123        }
124    }
125    
126    @Override
127    protected Set<String> importContents(Document node, Map<String, Object> params) throws IOException
128    {
129        Set<String> contentIds = new HashSet<>();
130        
131        Node root = node.getFirstChild();
132        
133        if (root != null)
134        {
135            String[] defaultTypes = StringUtils.split(getAttributeValue(root, "default-types", ""), ", ");
136            String[] defaultMixins = StringUtils.split(getAttributeValue(root, "default-mixins", ""), ", ");
137            String defaultLang = getAttributeValue(root, "default-language", getLanguage(params));
138            if (defaultTypes.length == 0)
139            {
140                defaultTypes = getContentTypes(params);
141            }
142            if (defaultMixins.length == 0)
143            {
144                defaultMixins = getMixins(params);
145            }
146            
147            params.put(_DEFAULT_CONTENT_TYPES_KEY, defaultTypes);
148            params.put(_DEFAULT_CONTENT_MIXINS_KEY, defaultMixins);
149            params.put(_DEFAULT_CONTENT_LANG_KEY, defaultLang);
150            
151            // Import all the contents.
152            NodeList contents = _xPathProcessor.selectNodeList(root, "content", getPrefixResolver());
153            
154            for (int i = 0; i < contents.getLength(); i++)
155            {
156                Node contentNode = contents.item(i);
157                
158                try
159                {
160                    Content content = importContent(contentNode, defaultTypes, defaultMixins, defaultLang, params);
161                    
162                    if (content != null)
163                    {
164                        contentIds.add(content.getId());
165                    }
166                }
167                catch (WorkflowException e)
168                {
169                    getLogger().error("Error importing a content.", e);
170                }
171            }
172            
173            // Second pass: restore all the content links.
174            restoreContentReferences(params);
175            
176            params.remove(_DEFAULT_CONTENT_TYPES_KEY);
177            params.remove(_DEFAULT_CONTENT_MIXINS_KEY);
178            params.remove(_DEFAULT_CONTENT_LANG_KEY);
179        }
180        
181        return contentIds;
182    }
183    
184    /**
185     * Import a content from a XML node.
186     * @param contentNode the content XML node.
187     * @param defaultTypes the default content types.
188     * @param defaultMixins the default mixins.
189     * @param defaultLang the default content language.
190     * @param params the import parameters.
191     * @return the Content or null if not created.
192     * @throws IOException if an error occurs during the import.
193     * @throws WorkflowException if an error occurs creating the Content.
194     */
195    protected Content importContent(Node contentNode, String[] defaultTypes, String[] defaultMixins, String defaultLang, Map<String, Object> params) throws IOException, WorkflowException
196    {
197        String localId = getAttributeValue(contentNode, "id", "");
198        
199        String cTypesStr = getAttributeValue(contentNode, "types", "");
200        String mixinsStr = getAttributeValue(contentNode, "mixins", "");
201        
202        String[] contentTypes = StringUtils.isEmpty(cTypesStr) ? defaultTypes : StringUtils.split(cTypesStr, ", ");
203        String[] contentMixins = StringUtils.isEmpty(mixinsStr) ? defaultMixins : StringUtils.split(mixinsStr, ", ");
204        String language = getAttributeValue(contentNode, "language", defaultLang);
205        
206        String title = _xPathProcessor.evaluateAsString(contentNode, "metadata/title", getPrefixResolver());
207        
208        Content content = createContent(title, contentTypes, contentMixins, language, params);
209        
210        if (content instanceof ModifiableContent)
211        {
212            importMetadata((ModifiableContent) content, contentNode, "", params);
213            
214            ((ModifiableContent) content).saveChanges();
215            
216            if (content instanceof VersionableAmetysObject)
217            {
218                ((VersionableAmetysObject) content).checkpoint();
219            }
220        }
221        
222        // If the content contains a "local" ID (local to the import file), remember it.
223        if (StringUtils.isNotBlank(localId))
224        {
225            Map<String, String> contentIdMap = getContentIdMap(params);
226            contentIdMap.put(localId, content.getId());
227        }
228        
229        return content;
230    }
231    
232    /**
233     * Import metadata from a content node.
234     * @param content the content to populate.
235     * @param contentNode the content DOM node.
236     * @param prefix the metadata prefix.
237     * @param params the import parameters.
238     * @throws IOException if an error occurs.
239     */
240    protected void importMetadata(ModifiableContent content, Node contentNode, String prefix, Map<String, Object> params) throws IOException
241    {
242        try
243        {
244            Map<String, MetadataDefinition> metadataDefinitions = _cTypeHelper.getMetadataDefinitions(content.getTypes());
245            
246            ModifiableCompositeMetadata meta = content.getMetadataHolder();
247            
248            Node metadataRootNode = _xPathProcessor.selectSingleNode(contentNode, "metadata", getPrefixResolver());
249            
250            if (metadataRootNode != null)
251            {
252                NodeList metaNodes = metadataRootNode.getChildNodes();
253                for (int i = 0; i < metaNodes.getLength(); i++)
254                {
255                    Node metaNode = metaNodes.item(i);
256                    if (metaNode.getNodeType() == Node.ELEMENT_NODE)
257                    {
258                        String metaName = metaNode.getLocalName();
259                        MetadataDefinition metaDef = metadataDefinitions.get(metaName);
260                        
261                        String subMetadataPath = prefix + (StringUtils.isEmpty(prefix) ? "" : "/") + metaName;
262                        
263                        importMetadata(content, meta, metaNode, metaDef, subMetadataPath, params);
264                    }
265                }
266            }
267        }
268        catch (ConfigurationException e)
269        {
270            throw new IOException("Error retrieving metadata definitions.", e);
271        }
272    }
273    
274    /**
275     * Import metadata from a DOM node.
276     * @param content The content being imported.
277     * @param meta the metadata holder to populate.
278     * @param metaNode the metadata DOM node.
279     * @param metaDef the metadata definition.
280     * @param metadataPath the metadata path.
281     * @param params the import parameters.
282     * @throws IOException if an error occurs.
283     */
284    protected void importMetadata(ModifiableContent content, ModifiableCompositeMetadata meta, Node metaNode, MetadataDefinition metaDef, String metadataPath, Map<String, Object> params) throws IOException
285    {
286        String name = metaNode.getLocalName();
287        NodeList valueNodes = _xPathProcessor.selectNodeList(metaNode, "value", getPrefixResolver());
288        String[] values = new String[valueNodes.getLength()];
289        for (int i = 0; i < valueNodes.getLength(); i++)
290        {
291            values[i] = valueNodes.item(i).getTextContent();
292        }
293        
294        try
295        {
296            if (metaDef != null)
297            {
298                if (metaDef instanceof RepeaterDefinition)
299                {
300                    setRepeater(content, meta, metaNode, (RepeaterDefinition) metaDef, name, metadataPath, params);
301                }
302                else
303                {
304                    setMetadata(content, meta, metaNode, metaDef, name, values, metadataPath, params);
305                }
306            }
307        }
308        catch (Exception e)
309        {
310            String message = "The values for metadata '" + name + "' are invalid and will be ignored: " + StringUtils.join(values, ", ");
311            getLogger().warn(message, e);
312        }
313    }
314    
315    /**
316     * Set the values of a metadata.
317     * @param content The content being imported.
318     * @param meta the metadata holder.
319     * @param metaNode the metadata DOM node.
320     * @param metaDef the metadata definition.
321     * @param name the metadata name.
322     * @param values the metadata values.
323     * @param metadataPath the metadata path.
324     * @param params the import parameters.
325     * @throws IOException if an error occurs.
326     */
327    protected void setMetadata(ModifiableContent content, ModifiableCompositeMetadata meta, Node metaNode, MetadataDefinition metaDef, String name, String[] values, String metadataPath, Map<String, Object> params) throws IOException
328    {
329        switch (metaDef.getType())
330        {
331            case STRING:
332                setStringMetadata(meta, name, metaDef, values);
333                break;
334            case BOOLEAN:
335                setBooleanMetadata(meta, name, metaDef, values);
336                break;
337            case LONG:
338                setLongMetadata(meta, name, metaDef, values);
339                break;
340            case DOUBLE:
341                setDoubleMetadata(meta, name, metaDef, values);
342                break;
343            case DATE:
344            case DATETIME:
345                setDateMetadata(meta, name, metaDef, values);
346                break;
347            case GEOCODE:
348                String latitude = getAttributeValue(metaNode, "latitude", null);
349                String longitude = getAttributeValue(metaNode, "longitude", null);
350                if (latitude != null && longitude != null)
351                {
352                    setGeocodeMetadata(meta, name, metaDef, latitude, longitude);
353                }
354                break;
355            case RICH_TEXT:
356                setRichText(meta, metaNode, name);
357                break;
358            case COMPOSITE:
359                setComposite(content, meta, metaNode, metaDef, name, metadataPath, params);
360                break;
361            case BINARY:
362            case FILE:
363                if (values.length > 0)
364                {
365                    setBinaryMetadata(meta, name, metaDef, values[0]);
366                }
367                break;
368            case CONTENT:
369                setContentReferences(content, meta, metaNode, name, metaDef, values, metadataPath, params);
370                break;
371            case SUB_CONTENT:
372                setSubContents(meta, metaNode, name, content.getId(), metadataPath, params);                
373                break;
374            case USER:
375                break;
376            case REFERENCE:
377                break;
378            default:
379                break;
380        }
381    }
382    
383    /**
384     * Set a composite metadata.
385     * @param content The content being imported.
386     * @param meta the metadata holder.
387     * @param metaNode the metadata DOM node.
388     * @param metaDef the metadata definition.
389     * @param name the metadata name.
390     * @param metadataPath the metadata path.
391     * @param params the import parameters.
392     * @throws IOException if an error occurs.
393     */
394    protected void setComposite(ModifiableContent content, ModifiableCompositeMetadata meta, Node metaNode, MetadataDefinition metaDef, String name, String metadataPath, Map<String, Object> params) throws IOException
395    {
396        NodeList subMetaNodes = metaNode.getChildNodes();
397        if (subMetaNodes.getLength() > 0)
398        {
399            ModifiableCompositeMetadata composite = meta.getCompositeMetadata(name, true);
400            for (int i = 0; i < subMetaNodes.getLength(); i++)
401            {
402                Node subMetaNode = subMetaNodes.item(i);
403                if (subMetaNode.getNodeType() == Node.ELEMENT_NODE)
404                {
405                    String subMetaName = subMetaNode.getLocalName();
406                    MetadataDefinition childDef = metaDef.getMetadataDefinition(subMetaName);
407                    String subMetaPath = metadataPath + "/" + subMetaName;
408                    
409                    importMetadata(content, composite, subMetaNode, childDef, subMetaPath, params);
410                }
411            }
412        }
413    }
414    
415    /**
416     * Set a RichText metadata.
417     * @param meta the metadata holder.
418     * @param metaNode the metadata node.
419     * @param name the metadata name.
420     * @throws IOException if an error occurs.
421     */
422    protected void setRichText(ModifiableCompositeMetadata meta, Node metaNode, String name) throws IOException
423    {
424        NodeList docbookNodes = metaNode.getChildNodes();
425        for (int i = 0; i < docbookNodes.getLength(); i++)
426        {
427            Node docbookNode = docbookNodes.item(i);
428            if (docbookNode.getNodeType() == Node.ELEMENT_NODE && "article".equals(docbookNode.getLocalName()))
429            {
430                try
431                {
432                    String docbook = serializeNode(docbookNode);
433                    setRichText(meta, name, new ByteArrayInputStream(docbook.getBytes("UTF-8")));
434                }
435                catch (TransformerException e)
436                {
437                    throw new IOException("Error serializing a docbook node.", e);
438                }
439            }
440        }
441    }
442    
443    /**
444     * Set a repeater metadata.
445     * @param content The content being imported.
446     * @param meta the metadata holder.
447     * @param metaNode the metadata DOM node.
448     * @param repeaterDef the repeater definition.
449     * @param name the metadata name.
450     * @param metadataPath the metadata path.
451     * @param params the import parameters.
452     * @throws IOException if an error occurs.
453     */
454    protected void setRepeater(ModifiableContent content, ModifiableCompositeMetadata meta, Node metaNode, RepeaterDefinition repeaterDef, String name, String metadataPath, Map<String, Object> params) throws IOException
455    {
456        ModifiableCompositeMetadata repeaterMeta = meta.getCompositeMetadata(name, true);
457        
458        NodeList entryNodes = _xPathProcessor.selectNodeList(metaNode, "entry");
459        int repeaterSize = entryNodes.getLength();
460        setRepeaterSize(content, metadataPath, repeaterSize, params);
461        
462        for (int i = 0; i < repeaterSize; i++)
463        {
464            Node entryNode = entryNodes.item(i);
465            String entryName = Integer.toString(i + 1);
466            
467            ModifiableCompositeMetadata entryMeta = repeaterMeta.getCompositeMetadata(entryName, true);
468            NodeList subMetaNodes = entryNode.getChildNodes();
469            for (int j = 0; j < subMetaNodes.getLength(); j++)
470            {
471                Node subMetaNode = subMetaNodes.item(j);
472                if (subMetaNode.getNodeType() == Node.ELEMENT_NODE)
473                {
474                    String subMetaName = subMetaNode.getLocalName();
475                    MetadataDefinition childDef = repeaterDef.getMetadataDefinition(subMetaName);
476                    String subMetaPath = metadataPath + "/" + entryName + "/" + subMetaName;
477                    
478                    importMetadata(content, entryMeta, subMetaNode, childDef, subMetaPath, params);
479                }
480            }
481        }
482    }
483    
484    /**
485     * Set a content metadata.
486     * @param content The content being imported.
487     * @param meta the metadata holder.
488     * @param metaNode the metadata DOM node.
489     * @param name the metadata name.
490     * @param metaDef the metadata definition
491     * @param values the values array 
492     * @param metadataPath the metadata path.
493     * @param params the import parameters.
494     * @throws IOException if an error occurs.
495     */
496    protected void setContentReferences(ModifiableContent content, ModifiableCompositeMetadata meta, Node metaNode, String name, MetadataDefinition metaDef, String[] values, String metadataPath, Map<String, Object> params) throws IOException
497    {
498        if (values != null)
499        {
500            Map<Content, Map<String, Object>> contentRefMap = getContentRefMap(params);
501            
502            List<ContentReference> references = new ArrayList<>();
503            
504            NodeList valueNodes = _xPathProcessor.selectNodeList(metaNode, "value", getPrefixResolver());
505            for (int i = 0; i < valueNodes.getLength(); i++)
506            {
507                Node valueNode = valueNodes.item(i);
508                
509                String refType = getAttributeValue(valueNode, "type", "local");
510                String textContent = valueNode.getTextContent();
511                
512                if (refType.equals("local") && StringUtils.isNotBlank(textContent))
513                {
514                    // The reference is local to the file.
515                    references.add(new ContentReference(ContentReference.TYPE_LOCAL_ID, textContent.trim()));
516                }
517                else if (refType.equals("repository") && StringUtils.isNotBlank(textContent))
518                {
519                    // The reference point to a content in the repository.
520                    references.add(new ContentReference(ContentReference.TYPE_CONTENT_ID, textContent.trim()));
521                }
522                else if (refType.equals("properties"))
523                {
524                    // No text content, parse the child nodes.
525                    Map<String, String> refValues = new HashMap<>();
526                    NodeList refNodes = _xPathProcessor.selectNodeList(valueNode, "meta", getPrefixResolver());
527                    for (int j = 0; j < refNodes.getLength(); j++)
528                    {
529                        Node refNode = refNodes.item(j);
530                        String metaName = getAttributeValue(refNode, "name", null);
531                        String value = getAttributeValue(refNode, "value", null);
532                        
533                        if (StringUtils.isNotBlank(metaName) && value != null)
534                        {
535                            refValues.put(metaName, value);
536                        }
537                    }
538                    
539                    references.add(new ContentReference(ContentReference.TYPE_CONTENT_VALUES, refValues));
540                }
541            }
542            
543            if (!references.isEmpty())
544            {
545                if (metaDef.isMultiple())
546                {
547                    addContentReferences(contentRefMap, content, metadataPath, references);
548                }
549                else
550                {
551                    addContentReference(contentRefMap, content, metadataPath, references.get(0));
552                }
553            }
554        }
555    }
556    
557    /**
558     * Set a sub-content metadata.
559     * @param meta the metadata holder.
560     * @param metaNode the metadata DOM node.
561     * @param name the metadata name.
562     * @param parentContentId the parent content ID.
563     * @param metadataPath the metadata path.
564     * @param params the import parameters.
565     * @throws IOException if an error occurs.
566     */
567    protected void setSubContents(ModifiableCompositeMetadata meta, Node metaNode, String name, String parentContentId, String metadataPath, Map<String, Object> params) throws IOException
568    {
569        NodeList contentNodes = _xPathProcessor.selectNodeList(metaNode, "value/content");
570        if (contentNodes.getLength() > 0)
571        {
572            ModifiableTraversableAmetysObject subContentMeta = meta.getObjectCollection(name, true);
573            
574            for (int i = 0; i < contentNodes.getLength(); i++)
575            {
576                Node contentNode = contentNodes.item(i);
577                importSubContent(subContentMeta, contentNode, parentContentId, metadataPath, params);
578            }
579        }
580    }
581    
582    /**
583     * Import a sub-content.
584     * @param subContentMeta the content collection metadata.
585     * @param contentNode the DOM node representing the sub-content.
586     * @param parentContentId the parent content ID.
587     * @param metadataPath the content collection metadata path.
588     * @param params the import parameters.
589     * @return the created Content.
590     * @throws IOException if an error occurs.
591     */
592    protected Content importSubContent(ModifiableTraversableAmetysObject subContentMeta, Node contentNode, String parentContentId, String metadataPath, Map<String, Object> params) throws IOException
593    {
594        String[] defaultTypes = (String[]) params.get(_DEFAULT_CONTENT_TYPES_KEY);
595        String[] defaultMixins = (String[]) params.get(_DEFAULT_CONTENT_MIXINS_KEY);
596        String defaultLanguage = (String) params.get(_DEFAULT_CONTENT_LANG_KEY);
597        
598        String cTypesStr = getAttributeValue(contentNode, "types", "");
599        String mixinsStr = getAttributeValue(contentNode, "mixins", "");
600        
601        String[] contentTypes = StringUtils.isEmpty(cTypesStr) ? defaultTypes : StringUtils.split(cTypesStr, ", ");
602        String[] mixins = StringUtils.isEmpty(mixinsStr) ? defaultMixins : StringUtils.split(mixinsStr, ", ");
603        String language = getAttributeValue(contentNode, "language", defaultLanguage);
604        
605        String title = _xPathProcessor.evaluateAsString(contentNode, "metadata/title");
606        
607        try
608        {
609            Content content = createContent(title, contentTypes, mixins, language, parentContentId, metadataPath, params);
610            
611            if (content instanceof ModifiableContent)
612            {
613                importMetadata((ModifiableContent) content, contentNode, metadataPath, params);
614                
615                ((ModifiableContent) content).saveChanges();
616            }
617            
618            return content;
619        }
620        catch (WorkflowException e)
621        {
622            getLogger().error("Error creating sub-content.", e);
623            throw new IOException("Error creating sub-content.", e);
624        }
625    }
626    
627}