001/*
002 *  Copyright 2013 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.content.autosave;
017
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Date;
021import java.util.HashMap;
022import java.util.Iterator;
023import java.util.List;
024import java.util.Map;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.logger.AbstractLogEnabled;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.lang.StringUtils;
032
033import org.ametys.cms.FilterNameHelper;
034import org.ametys.core.ui.Callable;
035import org.ametys.core.user.CurrentUserProvider;
036import org.ametys.core.user.UserIdentity;
037import org.ametys.core.util.DateUtils;
038import org.ametys.core.util.JSONUtils;
039import org.ametys.plugins.repository.AmetysObjectIterable;
040import org.ametys.plugins.repository.AmetysObjectResolver;
041import org.ametys.plugins.repository.ModifiableAmetysObject;
042import org.ametys.plugins.repository.RepositoryConstants;
043import org.ametys.plugins.repository.jcr.DefaultTraversableAmetysObject;
044import org.ametys.plugins.repository.metadata.CompositeMetadata;
045import org.ametys.plugins.repository.metadata.CompositeMetadata.MetadataType;
046import org.ametys.plugins.repository.metadata.MetadataAwareAmetysObject;
047import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
048import org.ametys.plugins.repository.metadata.UnknownMetadataException;
049import org.ametys.plugins.repository.query.QueryHelper;
050import org.ametys.plugins.repository.query.expression.Expression;
051import org.ametys.plugins.repository.query.expression.Expression.Operator;
052import org.ametys.plugins.repository.query.expression.StringExpression;
053import org.ametys.runtime.config.Config;
054
055/**
056 * Component for manipulating auto-backup on contents
057 *
058 */
059public class ContentBackupClientInteraction extends AbstractLogEnabled implements Serviceable, Component
060{
061    private static final String __AUTOSAVE_NODETYPE = RepositoryConstants.NAMESPACE_PREFIX + ":autoSave";
062    
063    private static final String __AUTOSAVE_CREATOR = "creator";
064    private static final String __AUTOSAVE_TEMP_DATE = "tempContentDate";
065    private static final String __AUTOSAVE_COMMENTS = "jsonComments";
066    private static final String __AUTOSAVE_VALUES = "values";
067    private static final String __AUTOSAVE_INVALID_VALUES = "invalid";
068    private static final String __AUTOSAVE_REPEATERS = "repeaters";
069    
070    private static final String __METADATA_NAME = "name";
071    private static final String __METADATA_VALUE = "value";
072    
073    private static final String __REPEATER_NAME = "name";
074    private static final String __REPEATER_COUNT = "count";
075    private static final String __REPEATER_PREFIX = "prefix";
076    
077    private AmetysObjectResolver _resolver;
078    private CurrentUserProvider _currentUserProvider;
079    private JSONUtils _jsonUtils;
080    
081    @Override
082    public void service(ServiceManager manager) throws ServiceException
083    {
084        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
085        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
086        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
087    }
088    
089    /**
090     * Get the content backup information
091     * @param contentId The content id
092     * @return The saved data
093     */
094    @Callable
095    public Map<String, Object> getContentBackup (String contentId)
096    {
097        if (getLogger().isDebugEnabled())
098        {
099            getLogger().debug(String.format("Get automatic data backup for content '%s'", contentId));
100        }
101        
102        Map<String, Object> result = new HashMap<>();
103        
104        boolean autoSaveEnabled = Config.getInstance().getValue("automatic.save.enabled");
105        Long autoSaveFrequency = Config.getInstance().getValue("automatic.save.frequency");
106        
107        result.put("enabled", autoSaveEnabled);
108        result.put("frequency", autoSaveFrequency);
109        
110        MetadataAwareAmetysObject contentNode = getContentNode(contentId, false);
111        
112        if (contentNode != null)
113        {
114            Map<String, Object> autoBackup = new HashMap<>();
115            
116            CompositeMetadata metadataHolder = contentNode.getMetadataHolder();
117            
118            autoBackup.put("creator", metadataHolder.getString(__AUTOSAVE_CREATOR, ""));
119            autoBackup.put("contentId", contentId);
120            
121            Date backupDate = metadataHolder.getDate(__AUTOSAVE_TEMP_DATE, null);
122            if (backupDate != null)
123            {
124                autoBackup.put("date", DateUtils.dateToString(backupDate));
125            }
126            
127            autoBackup.put("data", _getBackupData(metadataHolder));
128            
129            result.put("auto-backup", autoBackup);
130        }
131        
132        return result;
133    }
134    
135    private Map<String, Object> _getBackupData (CompositeMetadata metadataHolder)
136    {
137        Map<String, Object> data = new HashMap<>();
138        
139        // Get and generate the repeater item counts.
140        try
141        {
142            List<Map<String, Object>> repeaters = new ArrayList<>();
143            
144            CompositeMetadata repeatersMeta = metadataHolder.getCompositeMetadata(__AUTOSAVE_REPEATERS);
145            for (String name : repeatersMeta.getMetadataNames())
146            {
147                if (repeatersMeta.getType(name).equals(MetadataType.COMPOSITE))
148                {
149                    CompositeMetadata composite = repeatersMeta.getCompositeMetadata(name);
150                    
151                    Map<String, Object> repeater = new HashMap<>();
152                    
153                    repeater.put("name", composite.getString(__REPEATER_NAME, ""));
154                    repeater.put("prefix", composite.getString(__REPEATER_PREFIX, ""));
155                    repeater.put("count", composite.getString(__REPEATER_COUNT, "0"));
156                    
157                    repeaters.add(repeater);
158                }
159            }
160            
161            data.put("repeaters", repeaters);
162        }
163        catch (UnknownMetadataException e)
164        {
165            // Ignore
166        }
167        
168        // Get and generate the valid metadata values.
169        try
170        {
171            List<Map<String, Object>> metadatas = new ArrayList<>();
172            
173            CompositeMetadata values = metadataHolder.getCompositeMetadata(__AUTOSAVE_VALUES);
174            for (String name : values.getMetadataNames())
175            {
176                if (values.getType(name).equals(MetadataType.COMPOSITE))
177                {
178                    Map<String, Object> metadata = new HashMap<>();
179                    
180                    CompositeMetadata composite = values.getCompositeMetadata(name);
181                    
182                    metadata.put("name", composite.getString(__METADATA_NAME, ""));
183                    
184                    String encodedValue = composite.getString(__METADATA_VALUE, "");
185                    metadata.put("value", _decodeValue(encodedValue));
186                    metadatas.add(metadata);
187                }
188            }
189            
190            data.put("metadatas", metadatas);
191        }
192        catch (UnknownMetadataException e)
193        {
194            // Ignore
195        }
196        
197        // Get and generate the invalid metadata values.
198        try
199        {
200            List<Map<String, Object>> invalidMetadatas = new ArrayList<>();
201            
202            CompositeMetadata invalidValues = metadataHolder.getCompositeMetadata(__AUTOSAVE_INVALID_VALUES);
203            for (String name : invalidValues.getMetadataNames())
204            {
205                if (invalidValues.getType(name).equals(MetadataType.COMPOSITE))
206                {
207                    Map<String, Object> invalidMetadata = new HashMap<>();
208                    
209                    CompositeMetadata composite = invalidValues.getCompositeMetadata(name);
210                    
211                    invalidMetadata.put("name", composite.getString(__METADATA_NAME, ""));
212                    String encodedValue = composite.getString(__METADATA_VALUE, "");
213                    invalidMetadata.put("value", _decodeValue(encodedValue));
214                    
215                    invalidMetadatas.add(invalidMetadata);
216                }
217            }
218            
219            data.put("invalid-metadatas", invalidMetadatas);
220        }
221        catch (UnknownMetadataException e)
222        {
223            // Ignore
224        }
225        
226        // Get the field comments
227        data.put("comments", metadataHolder.getString(__AUTOSAVE_COMMENTS, "{}"));
228        
229        return data;
230    }
231    
232    /**
233     * Delete an automatic backup for a content.
234     * @param contentId The content id
235     * @return A empty map
236     */
237    @Callable
238    public Map<String, Object> deleteContentBackup (String contentId)
239    {
240        if (StringUtils.isNotEmpty(contentId))
241        {
242            if (getLogger().isDebugEnabled())
243            {
244                getLogger().debug(String.format("Delete automatic backup for content '%s'", contentId));
245            }
246            
247            // Query the content node by path and contentId property.
248            Expression expression = new StringExpression("contentId", Operator.EQ, contentId);
249            String query = QueryHelper.getXPathQuery(null, __AUTOSAVE_NODETYPE, expression);
250            
251            AmetysObjectIterable<DefaultTraversableAmetysObject> contentObjects = _resolver.query(query);
252            Iterator<DefaultTraversableAmetysObject> it = contentObjects.iterator();
253            
254            // Get or create the content node.
255            if (it.hasNext())
256            {
257                DefaultTraversableAmetysObject object = it.next();
258                
259                ModifiableAmetysObject parent = (ModifiableAmetysObject) object.getParent();
260                
261                object.remove();
262                
263                parent.saveChanges();
264            }
265        }
266        
267        return java.util.Collections.EMPTY_MAP; 
268    }
269    
270    /**
271     * Store an automatic backup for a content.
272     * @param contentId The content id
273     * @param values The valid values to store
274     * @param invalidValues The invalid values to store
275     * @param comments The comments as JSON string
276     * @param repeaters The repeaters to store
277     * @return A empty Map
278     */
279    @Callable
280    public Map<String, Object> setContentBackup (String contentId, Map<String, Object> values, Map<String, Object> invalidValues, String comments, Collection<Map<String, String>> repeaters)
281    {
282        if (getLogger().isDebugEnabled())
283        {
284            getLogger().debug(String.format("Start automatic backup for content '%s'", contentId));
285        }
286        
287        UserIdentity currentUser = _currentUserProvider.getUser();
288        
289        DefaultTraversableAmetysObject contentNode = getContentNode(contentId, true);
290        ModifiableCompositeMetadata meta = contentNode.getMetadataHolder();
291        
292        // Remove all existing metadata.
293        removeAllComposites(meta);
294        
295        meta.setMetadata(__AUTOSAVE_TEMP_DATE, new Date());
296        
297        ModifiableCompositeMetadata creatorMetadata = meta.getCompositeMetadata(__AUTOSAVE_CREATOR, true);
298        creatorMetadata.setMetadata("login", currentUser.getLogin());
299        creatorMetadata.setMetadata("populationId", currentUser.getPopulationId());
300        
301        // Get the four sub-parts: valid values, invalid values, comments and repeater item counts.
302        ModifiableCompositeMetadata valuesMeta = meta.getCompositeMetadata(__AUTOSAVE_VALUES, true);
303        ModifiableCompositeMetadata invalidValuesMeta = meta.getCompositeMetadata(__AUTOSAVE_INVALID_VALUES, true);
304        ModifiableCompositeMetadata repeatersMeta = meta.getCompositeMetadata(__AUTOSAVE_REPEATERS, true);
305        
306        // Store the valid values.
307        storeValues(values, valuesMeta);
308        
309        // Store the invalid values.
310        storeValues(invalidValues, invalidValuesMeta);
311        
312        // Store the comments.
313        meta.setMetadata(__AUTOSAVE_COMMENTS, comments);
314        
315        // Store the repeater item counts.
316        storeRepeaters(repeaters, repeatersMeta);
317        
318        contentNode.saveChanges();
319        
320        return java.util.Collections.EMPTY_MAP; 
321    }
322    
323    /**
324     * Store the metadata values (in composites named 1, 2, 3...)
325     * @param values the meta values to store, as a Map of values, indexed by name.
326     * @param valuesMeta the composite metadata to store the values in.
327     */
328    protected void storeValues(Map<String, Object> values, ModifiableCompositeMetadata valuesMeta)
329    {
330        int metaIndex = 1;
331        for (String name : values.keySet())
332        {
333            ModifiableCompositeMetadata composite = valuesMeta.getCompositeMetadata(Integer.toString(metaIndex), true);
334            
335            Object value = values.get(name);
336            
337            composite.setMetadata(__METADATA_NAME, name);
338            // Store value as JSON string
339            composite.setMetadata(__METADATA_VALUE, _encodeValue(value));
340            
341            metaIndex++;
342        }
343    }
344    
345    private String _encodeValue (Object value)
346    {
347        if (value instanceof Map || value instanceof List)
348        {
349            return _jsonUtils.convertObjectToJson(value);
350        }
351        else
352        {
353            return value != null ? value.toString() : "";
354        }
355    }
356    
357    private Object _decodeValue (String value)
358    {
359        if (StringUtils.isBlank(value))
360        {
361            return "";
362        }
363        
364        try
365        {
366            return _jsonUtils.convertJsonToMap(value);
367        }
368        catch (IllegalArgumentException e)
369        {
370            // Failed to convert into map, continue
371        }
372        
373        try
374        {
375            return _jsonUtils.convertJsonToList(value);
376        }
377        catch (IllegalArgumentException e)
378        {
379            // Failed to convert into list, continue
380        }
381        
382        try
383        {
384            return _jsonUtils.convertJsonToArray(value);
385        }
386        catch (IllegalArgumentException e)
387        {
388            // Failed to convert into array, continue
389        }
390        
391        return value;
392    }
393    
394    /**
395     * Store the repeater item counts to be able to re-initialize them.
396     * @param repeaters the repeaters data.
397     * @param repeatersMeta the composite metadata to store the repeaters in.
398     */
399    protected void storeRepeaters(Collection<Map<String, String>> repeaters, ModifiableCompositeMetadata repeatersMeta)
400    {
401        int repeaterIndex = 1;
402        for (Map<String, String> repeaterData : repeaters)
403        {
404            ModifiableCompositeMetadata composite = repeatersMeta.getCompositeMetadata(Integer.toString(repeaterIndex), true);
405            composite.setMetadata(__REPEATER_NAME, repeaterData.get("name"));
406            composite.setMetadata(__REPEATER_PREFIX, repeaterData.get("prefix"));
407            composite.setMetadata(__REPEATER_COUNT, repeaterData.get("count"));
408            
409            repeaterIndex++;
410        }
411    }
412    
413    /**
414     * Remove all the composite metadatas of a given composite.
415     * @param meta the metadata to clear.
416     */
417    protected void removeAllComposites(ModifiableCompositeMetadata meta)
418    {
419        for (String metaName : meta.getMetadataNames())
420        {
421            if (meta.getType(metaName).equals(MetadataType.COMPOSITE))
422            {
423                meta.removeMetadata(metaName);
424            }
425        }
426    }
427
428    /**
429     * Get the storage node for a content in the automatic backup space, or create it if it doesn't exist.
430     * @param contentId the content ID.
431     * @param createNew <code>true</code> to create automatically the node when missing.
432     * @return the content backup storage node.
433     */
434    protected DefaultTraversableAmetysObject getContentNode(String contentId, boolean createNew)
435    {
436        DefaultTraversableAmetysObject contentNode = null;
437        
438        // Query the content node by path and contentId property.
439        Expression expression = new StringExpression("contentId", Operator.EQ, contentId);
440        String query = QueryHelper.getXPathQuery(null, __AUTOSAVE_NODETYPE, expression);
441        
442        AmetysObjectIterable<DefaultTraversableAmetysObject> autoSaveObjects = _resolver.query(query);
443        Iterator<DefaultTraversableAmetysObject> it = autoSaveObjects.iterator();
444        
445        // Get or create the content node.
446        if (it.hasNext())
447        {
448            contentNode = it.next();
449        }
450        else if (createNew)
451        {
452            DefaultTraversableAmetysObject tempRoot = getOrCreateTempRoot();
453            
454            String objectName = FilterNameHelper.filterName(contentId);
455            contentNode = (DefaultTraversableAmetysObject) tempRoot.createChild(objectName, __AUTOSAVE_NODETYPE);
456            contentNode.getMetadataHolder().setMetadata("contentId", contentId);
457            
458            tempRoot.saveChanges();
459        }
460        
461        return contentNode;
462    }
463    
464    /**
465     * Get the temporary backup root, or create it if it doesn't exist.
466     * @return the temporary backup root.
467     */
468    protected DefaultTraversableAmetysObject getOrCreateTempRoot()
469    {
470        DefaultTraversableAmetysObject pluginsRoot = _resolver.resolveByPath("/ametys:plugins");
471        
472        DefaultTraversableAmetysObject cmsNode = null;
473        if (pluginsRoot.hasChild("cms"))
474        {
475            cmsNode = (DefaultTraversableAmetysObject) pluginsRoot.getChild("cms");
476        }
477        else
478        {
479            cmsNode = (DefaultTraversableAmetysObject) pluginsRoot.createChild("cms", "ametys:unstructured");
480        }
481        
482        DefaultTraversableAmetysObject editionNode = null;
483        if (cmsNode.hasChild("edition"))
484        {
485            editionNode = (DefaultTraversableAmetysObject) cmsNode.getChild("edition");
486        }
487        else
488        {
489            editionNode = (DefaultTraversableAmetysObject) cmsNode.createChild("edition", "ametys:unstructured");
490        }
491        
492        DefaultTraversableAmetysObject tempNode = null;
493        if (editionNode.hasChild("temp"))
494        {
495            tempNode = (DefaultTraversableAmetysObject) editionNode.getChild("temp");
496        }
497        else
498        {
499            tempNode = (DefaultTraversableAmetysObject) editionNode.createChild("temp", "ametys:unstructured");
500        }
501        
502        if (pluginsRoot.needsSave())
503        {
504            pluginsRoot.saveChanges();
505        }
506        
507        return tempNode;
508    }
509}