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.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Collection;
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.lang3.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.ModifiableTraversableAmetysObject;
043import org.ametys.plugins.repository.RemovableAmetysObject;
044import org.ametys.plugins.repository.RepositoryConstants;
045import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
046import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
047import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeater;
048import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeaterEntry;
049import org.ametys.plugins.repository.data.holder.group.impl.ModifiableModelAwareRepeater;
050import org.ametys.plugins.repository.data.holder.group.impl.ModifiableModelAwareRepeaterEntry;
051import org.ametys.plugins.repository.jcr.ModelAwareJCRAmetysObject;
052import org.ametys.plugins.repository.query.QueryHelper;
053import org.ametys.plugins.repository.query.expression.Expression;
054import org.ametys.plugins.repository.query.expression.Expression.Operator;
055import org.ametys.plugins.repository.query.expression.StringExpression;
056import org.ametys.runtime.config.Config;
057
058/**
059 * Component for manipulating auto-backup on contents
060 *
061 */
062public class ContentBackupClientInteraction extends AbstractLogEnabled implements Serviceable, Component
063{
064    private static final String __AUTOSAVE_NODETYPE = RepositoryConstants.NAMESPACE_PREFIX + ":autoSave";
065    
066    private AmetysObjectResolver _resolver;
067    private CurrentUserProvider _currentUserProvider;
068    private JSONUtils _jsonUtils;
069    
070    @Override
071    public void service(ServiceManager manager) throws ServiceException
072    {
073        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
074        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
075        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
076    }
077    
078    /**
079     * Get the content backup information
080     * @param contentId The content id
081     * @return The saved data
082     */
083    @Callable
084    public Map<String, Object> getContentBackup (String contentId)
085    {
086        if (getLogger().isDebugEnabled())
087        {
088            getLogger().debug(String.format("Get automatic data backup for content '%s'", contentId));
089        }
090        
091        Map<String, Object> result = new HashMap<>();
092        
093        boolean autoSaveEnabled = Config.getInstance().getValue("automatic.save.enabled");
094        Long autoSaveFrequency = Config.getInstance().getValue("automatic.save.frequency");
095        
096        result.put("enabled", autoSaveEnabled);
097        result.put("frequency", autoSaveFrequency);
098        
099        ModelAwareDataHolder contentNode = getContentNode(contentId, false);
100        
101        if (contentNode != null)
102        {
103            Map<String, Object> autoBackup = new HashMap<>();
104            
105            UserIdentity creator = contentNode.getValue(ContentBackupAmetysObject.AUTOSAVE_CREATOR);
106            autoBackup.put("creator", creator != null ? creator.toString() : StringUtils.EMPTY);
107            autoBackup.put("contentId", contentId);
108            
109            ZonedDateTime backupDate = contentNode.getValue(ContentBackupAmetysObject.AUTOSAVE_TEMP_DATE);
110            if (backupDate != null)
111            {
112                autoBackup.put("date", DateUtils.zonedDateTimeToString(backupDate));
113            }
114            
115            autoBackup.put("data", getBackupData(contentNode));
116            
117            result.put("auto-backup", autoBackup);
118        }
119        
120        return result;
121    }
122    
123    /**
124     * Retrieves the content backup information
125     * @param dataHolder the content backup data holder
126     * @return the content backup information
127     */
128    protected Map<String, Object> getBackupData (ModelAwareDataHolder dataHolder)
129    {
130        Map<String, Object> data = new HashMap<>();
131        
132        // Get and generate the valid attributes values.
133        if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_VALUES))
134        {
135            List<Map<String, Object>> values = getValuesBackupData(dataHolder, ContentBackupAmetysObject.AUTOSAVE_VALUES);
136            data.put("attributes", values);
137        }
138        
139        // Get and generate the invalid attributes values.
140        if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES))
141        {
142            List<Map<String, Object>> invalidValues = getValuesBackupData(dataHolder, ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES);
143            data.put("invalid-attributes", invalidValues);
144        }
145        
146        // Get the field comments
147        data.put("comments", dataHolder.getValue(ContentBackupAmetysObject.AUTOSAVE_COMMENTS, false, "{}"));
148        
149        // Get and generate the repeater item counts.
150        if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_REPEATERS))
151        {
152            List<Map<String, Object>> repeaters = getRepeatersBackupData(dataHolder);
153            data.put("repeaters", repeaters);
154        }
155        
156        return data;
157    }
158
159    /**
160     * Retrieves the values backup information
161     * @param dataHolder the content backup data holder
162     * @param valuesRepeaterPath the path of the values attribute
163     * @return the values backup information
164     */
165    protected List<Map<String, Object>> getValuesBackupData(ModelAwareDataHolder dataHolder, String valuesRepeaterPath)
166    {
167        List<Map<String, Object>> attributes = new ArrayList<>();
168        
169        ModelAwareRepeater values = dataHolder.getRepeater(valuesRepeaterPath);
170        for (ModelAwareRepeaterEntry entry : values.getEntries())
171        {
172            Map<String, Object> attribute = new HashMap<>();
173            
174            attribute.put("name", entry.getValue(ContentBackupAmetysObject.ATTRIBUTE_NAME, false, StringUtils.EMPTY));
175            
176            String encodedValue = entry.getValue(ContentBackupAmetysObject.ATTRIBUTE_VALUE, false, StringUtils.EMPTY);
177            attribute.put("value", _decodeValue(encodedValue));
178            
179            attributes.add(attribute);
180        }
181        
182        return attributes;
183    }
184    
185    private Object _decodeValue (String value)
186    {
187        if (StringUtils.isBlank(value))
188        {
189            return StringUtils.EMPTY;
190        }
191        
192        try
193        {
194            return _jsonUtils.convertJsonToMap(value);
195        }
196        catch (IllegalArgumentException e)
197        {
198            // Failed to convert into map, continue
199        }
200        
201        try
202        {
203            return _jsonUtils.convertJsonToList(value);
204        }
205        catch (IllegalArgumentException e)
206        {
207            // Failed to convert into list, continue
208        }
209        
210        try
211        {
212            return _jsonUtils.convertJsonToArray(value);
213        }
214        catch (IllegalArgumentException e)
215        {
216            // Failed to convert into array, continue
217        }
218        
219        return value;
220    }
221
222    /**
223     * Retrieves the repeaters backup information
224     * @param dataHolder the content backup data holder
225     * @return the repeaters backup information
226     */
227    protected List<Map<String, Object>> getRepeatersBackupData(ModelAwareDataHolder dataHolder)
228    {
229        List<Map<String, Object>> repeaters = new ArrayList<>();
230        
231        ModelAwareRepeater repeatersData = dataHolder.getRepeater(ContentBackupAmetysObject.AUTOSAVE_REPEATERS);
232        for (ModelAwareRepeaterEntry entry : repeatersData.getEntries())
233        {
234            Map<String, Object> repeater = new HashMap<>();
235            
236            repeater.put("name", entry.getValue(ContentBackupAmetysObject.REPEATER_NAME, false, StringUtils.EMPTY));
237            repeater.put("prefix", entry.getValue(ContentBackupAmetysObject.REPEATER_PREFIX, false, StringUtils.EMPTY));
238            repeater.put("count", entry.getValue(ContentBackupAmetysObject.REPEATER_COUNT, false, "0"));
239            
240            repeaters.add(repeater);
241        }
242        
243        return repeaters;
244    }
245    
246    /**
247     * Delete an automatic backup for a content.
248     * @param contentId The content id
249     * @return A empty map
250     */
251    @Callable
252    public Map<String, Object> deleteContentBackup (String contentId)
253    {
254        if (StringUtils.isNotEmpty(contentId))
255        {
256            if (getLogger().isDebugEnabled())
257            {
258                getLogger().debug(String.format("Delete automatic backup for content '%s'", contentId));
259            }
260            
261            RemovableAmetysObject contentNode = getContentNode(contentId, false);
262            
263            if (contentNode != null)
264            {
265                ModifiableAmetysObject parent = (ModifiableAmetysObject) contentNode.getParent();
266                
267                contentNode.remove();
268                
269                parent.saveChanges();
270            }
271        }
272        
273        return java.util.Collections.EMPTY_MAP; 
274    }
275    
276    /**
277     * Store an automatic backup for a content.
278     * @param contentId The content id
279     * @param values The valid values to store
280     * @param invalidValues The invalid values to store
281     * @param comments The comments as JSON string
282     * @param repeaters The repeaters to store
283     * @return A empty Map
284     */
285    @Callable
286    public Map<String, Object> setContentBackup (String contentId, Map<String, Object> values, Map<String, Object> invalidValues, String comments, Collection<Map<String, String>> repeaters)
287    {
288        if (getLogger().isDebugEnabled())
289        {
290            getLogger().debug(String.format("Start automatic backup for content '%s'", contentId));
291        }
292        
293        UserIdentity currentUser = _currentUserProvider.getUser();
294        
295        ModelAwareJCRAmetysObject contentNode = getContentNode(contentId, true);
296        
297        // Remove all existing metadata.
298        removeAllRepeaterEntries(contentNode);
299        
300        contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_TEMP_DATE, ZonedDateTime.now());
301        contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_CREATOR, currentUser);
302        
303        // Store the valid values.
304        storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_VALUES, values);
305        
306        // Store the invalid values.
307        storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES, invalidValues);
308        
309        // Store the comments.
310        contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_COMMENTS, comments);
311        
312        // Store the repeater item counts.
313        storeRepeaters(contentNode, repeaters);
314        
315        contentNode.saveChanges();
316        
317        return java.util.Collections.EMPTY_MAP; 
318    }
319    
320    /**
321     * Store the attribute values
322     * @param dataHolder the content backup data holder
323     * @param valuesRepeaterPath the path of the values attribute
324     * @param values the values to store, as a Map of values, indexed by name.
325     */
326    protected void storeValues(ModifiableModelAwareDataHolder dataHolder, String valuesRepeaterPath, Map<String, Object> values)
327    {
328        ModifiableModelAwareRepeater valuesData = dataHolder.getRepeater(valuesRepeaterPath, true);
329        for (String name : values.keySet())
330        {
331            ModifiableModelAwareRepeaterEntry entry = valuesData.addEntry();
332            
333            entry.setValue(ContentBackupAmetysObject.ATTRIBUTE_NAME, name);
334
335            // Store value as JSON string
336            Object value = values.get(name);
337            entry.setValue(ContentBackupAmetysObject.ATTRIBUTE_VALUE, _encodeValue(value));
338        }
339    }
340    
341    private String _encodeValue (Object value)
342    {
343        if (value instanceof Map || value instanceof List)
344        {
345            return _jsonUtils.convertObjectToJson(value);
346        }
347        else
348        {
349            return value != null ? value.toString() : "";
350        }
351    }
352    
353    /**
354     * Store the repeater item counts to be able to re-initialize them
355     * @param dataHolder the content backup data holder
356     * @param repeaters the repeaters data
357     */
358    protected void storeRepeaters(ModifiableModelAwareDataHolder dataHolder, Collection<Map<String, String>> repeaters)
359    {
360        ModifiableModelAwareRepeater repeatersData = dataHolder.getRepeater(ContentBackupAmetysObject.AUTOSAVE_REPEATERS, true);
361        for (Map<String, String> repeaterData : repeaters)
362        {
363            ModifiableModelAwareRepeaterEntry entry = repeatersData.addEntry();
364            entry.setValue(ContentBackupAmetysObject.REPEATER_NAME, repeaterData.get("name"));
365            entry.setValue(ContentBackupAmetysObject.REPEATER_PREFIX, repeaterData.get("prefix"));
366            entry.setValue(ContentBackupAmetysObject.REPEATER_COUNT, repeaterData.get("count"));
367        }
368    }
369    
370    /**
371     * Remove all the repeater entries of a given data holder.
372     * @param dataHolder the data holder to clear.
373     */
374    protected void removeAllRepeaterEntries(ModifiableModelAwareDataHolder dataHolder)
375    {
376        for (String dataName : dataHolder.getDataNames())
377        {
378            if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(dataHolder.getType(dataName).getId()))
379            {
380                dataHolder.removeValue(dataName);
381            }
382        }
383    }
384
385    /**
386     * Get the storage node for a content in the automatic backup space, or create it if it doesn't exist.
387     * @param contentId the content ID.
388     * @param createNew <code>true</code> to create automatically the node when missing.
389     * @return the content backup storage node.
390     */
391    protected ModelAwareJCRAmetysObject getContentNode(String contentId, boolean createNew)
392    {
393        ModelAwareJCRAmetysObject contentNode = null;
394        
395        // Query the content node by path and contentId property.
396        Expression expression = new StringExpression(ContentBackupAmetysObject.AUTOSAVE_CONTENT_ID, Operator.EQ, contentId);
397        String query = QueryHelper.getXPathQuery(null, __AUTOSAVE_NODETYPE, expression);
398        
399        AmetysObjectIterable<ModelAwareJCRAmetysObject> autoSaveObjects = _resolver.query(query);
400        Iterator<ModelAwareJCRAmetysObject> it = autoSaveObjects.iterator();
401        
402        // Get or create the content node.
403        if (it.hasNext())
404        {
405            contentNode = it.next();
406        }
407        else if (createNew)
408        {
409            ModifiableTraversableAmetysObject tempRoot = getOrCreateTempRoot();
410            
411            String objectName = FilterNameHelper.filterName(contentId);
412            contentNode = (ModelAwareJCRAmetysObject) tempRoot.createChild(objectName, __AUTOSAVE_NODETYPE);
413            contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_CONTENT_ID, contentId);
414            
415            tempRoot.saveChanges();
416        }
417        
418        return contentNode;
419    }
420    
421    /**
422     * Get the temporary backup root, or create it if it doesn't exist.
423     * @return the temporary backup root.
424     */
425    protected ModifiableTraversableAmetysObject getOrCreateTempRoot()
426    {
427        ModifiableTraversableAmetysObject pluginsRoot = _resolver.resolveByPath("/ametys:plugins");
428        
429        ModifiableTraversableAmetysObject cmsNode = null;
430        if (pluginsRoot.hasChild("cms"))
431        {
432            cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.getChild("cms");
433        }
434        else
435        {
436            cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.createChild("cms", "ametys:unstructured");
437        }
438        
439        ModifiableTraversableAmetysObject editionNode = null;
440        if (cmsNode.hasChild("edition"))
441        {
442            editionNode = (ModifiableTraversableAmetysObject) cmsNode.getChild("edition");
443        }
444        else
445        {
446            editionNode = (ModifiableTraversableAmetysObject) cmsNode.createChild("edition", "ametys:unstructured");
447        }
448        
449        ModifiableTraversableAmetysObject tempNode = null;
450        if (editionNode.hasChild("temp"))
451        {
452            tempNode = (ModifiableTraversableAmetysObject) editionNode.getChild("temp");
453        }
454        else
455        {
456            tempNode = (ModifiableTraversableAmetysObject) editionNode.createChild("temp", "ametys:unstructured");
457        }
458        
459        if (pluginsRoot.needsSave())
460        {
461            pluginsRoot.saveChanges();
462        }
463        
464        return tempNode;
465    }
466}