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.core.ui.Callable;
034import org.ametys.core.user.CurrentUserProvider;
035import org.ametys.core.user.UserIdentity;
036import org.ametys.core.util.DateUtils;
037import org.ametys.core.util.JSONUtils;
038import org.ametys.plugins.repository.AmetysObjectIterable;
039import org.ametys.plugins.repository.AmetysObjectResolver;
040import org.ametys.plugins.repository.ModifiableAmetysObject;
041import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
042import org.ametys.plugins.repository.RemovableAmetysObject;
043import org.ametys.plugins.repository.RepositoryConstants;
044import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
045import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
046import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
047import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
048import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater;
049import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry;
050import org.ametys.plugins.repository.jcr.ModelAwareJCRAmetysObject;
051import org.ametys.plugins.repository.jcr.NameHelper;
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 and generate the repeater item counts.
147        if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_REPEATERS))
148        {
149            List<Map<String, Object>> repeaters = getRepeatersBackupData(dataHolder);
150            data.put("repeaters", repeaters);
151        }
152        
153        return data;
154    }
155
156    /**
157     * Retrieves the values backup information
158     * @param dataHolder the content backup data holder
159     * @param valuesRepeaterPath the path of the values attribute
160     * @return the values backup information
161     */
162    protected List<Map<String, Object>> getValuesBackupData(ModelAwareDataHolder dataHolder, String valuesRepeaterPath)
163    {
164        List<Map<String, Object>> attributes = new ArrayList<>();
165        
166        ModelAwareRepeater values = dataHolder.getRepeater(valuesRepeaterPath);
167        for (ModelAwareRepeaterEntry entry : values.getEntries())
168        {
169            Map<String, Object> attribute = new HashMap<>();
170            
171            attribute.put("name", entry.getValue(ContentBackupAmetysObject.ATTRIBUTE_NAME, false, StringUtils.EMPTY));
172            
173            String encodedValue = entry.getValue(ContentBackupAmetysObject.ATTRIBUTE_VALUE, false, StringUtils.EMPTY);
174            attribute.put("value", _decodeValue(encodedValue));
175            
176            attributes.add(attribute);
177        }
178        
179        return attributes;
180    }
181    
182    private Object _decodeValue (String value)
183    {
184        if (StringUtils.isBlank(value))
185        {
186            return StringUtils.EMPTY;
187        }
188        
189        try
190        {
191            return _jsonUtils.convertJsonToMap(value);
192        }
193        catch (IllegalArgumentException e)
194        {
195            // Failed to convert into map, continue
196        }
197        
198        try
199        {
200            return _jsonUtils.convertJsonToList(value);
201        }
202        catch (IllegalArgumentException e)
203        {
204            // Failed to convert into list, continue
205        }
206        
207        try
208        {
209            return _jsonUtils.convertJsonToArray(value);
210        }
211        catch (IllegalArgumentException e)
212        {
213            // Failed to convert into array, continue
214        }
215        
216        return value;
217    }
218
219    /**
220     * Retrieves the repeaters backup information
221     * @param dataHolder the content backup data holder
222     * @return the repeaters backup information
223     */
224    protected List<Map<String, Object>> getRepeatersBackupData(ModelAwareDataHolder dataHolder)
225    {
226        List<Map<String, Object>> repeaters = new ArrayList<>();
227        
228        ModelAwareRepeater repeatersData = dataHolder.getRepeater(ContentBackupAmetysObject.AUTOSAVE_REPEATERS);
229        for (ModelAwareRepeaterEntry entry : repeatersData.getEntries())
230        {
231            Map<String, Object> repeater = new HashMap<>();
232            
233            repeater.put("name", entry.getValue(ContentBackupAmetysObject.REPEATER_NAME, false, StringUtils.EMPTY));
234            repeater.put("prefix", entry.getValue(ContentBackupAmetysObject.REPEATER_PREFIX, false, StringUtils.EMPTY));
235            repeater.put("count", entry.getValue(ContentBackupAmetysObject.REPEATER_COUNT, false, "0"));
236            
237            repeaters.add(repeater);
238        }
239        
240        return repeaters;
241    }
242    
243    /**
244     * Delete an automatic backup for a content.
245     * @param contentId The content id
246     * @return A empty map
247     */
248    @Callable
249    public Map<String, Object> deleteContentBackup (String contentId)
250    {
251        if (StringUtils.isNotEmpty(contentId))
252        {
253            if (getLogger().isDebugEnabled())
254            {
255                getLogger().debug(String.format("Delete automatic backup for content '%s'", contentId));
256            }
257            
258            RemovableAmetysObject contentNode = getContentNode(contentId, false);
259            
260            if (contentNode != null)
261            {
262                ModifiableAmetysObject parent = (ModifiableAmetysObject) contentNode.getParent();
263                
264                contentNode.remove();
265                
266                parent.saveChanges();
267            }
268        }
269        
270        return java.util.Collections.EMPTY_MAP;
271    }
272    
273    /**
274     * Store an automatic backup for a content.
275     * @param contentId The content id
276     * @param values The valid values to store
277     * @param invalidValues The invalid values to store
278     * @param repeaters The repeaters to store
279     * @return A empty Map
280     */
281    @Callable
282    public Map<String, Object> setContentBackup (String contentId, Map<String, Object> values, Map<String, Object> invalidValues, Collection<Map<String, String>> repeaters)
283    {
284        if (getLogger().isDebugEnabled())
285        {
286            getLogger().debug(String.format("Start automatic backup for content '%s'", contentId));
287        }
288        
289        UserIdentity currentUser = _currentUserProvider.getUser();
290        
291        ModelAwareJCRAmetysObject contentNode = getContentNode(contentId, true);
292        
293        // Remove all existing metadata.
294        removeAllRepeaterEntries(contentNode);
295        
296        contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_TEMP_DATE, ZonedDateTime.now());
297        contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_CREATOR, currentUser);
298        
299        // Store the valid values.
300        storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_VALUES, values);
301        
302        // Store the invalid values.
303        storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES, invalidValues);
304        
305        // Store the repeater item counts.
306        storeRepeaters(contentNode, repeaters);
307        
308        contentNode.saveChanges();
309        
310        return java.util.Collections.EMPTY_MAP;
311    }
312    
313    /**
314     * Store the attribute values
315     * @param dataHolder the content backup data holder
316     * @param valuesRepeaterPath the path of the values attribute
317     * @param values the values to store, as a Map of values, indexed by name.
318     */
319    protected void storeValues(ModifiableModelAwareDataHolder dataHolder, String valuesRepeaterPath, Map<String, Object> values)
320    {
321        ModifiableModelAwareRepeater valuesData = dataHolder.getRepeater(valuesRepeaterPath, true);
322        for (String name : values.keySet())
323        {
324            ModifiableModelAwareRepeaterEntry entry = valuesData.addEntry();
325            
326            entry.setValue(ContentBackupAmetysObject.ATTRIBUTE_NAME, name);
327
328            // Store value as JSON string
329            Object value = values.get(name);
330            entry.setValue(ContentBackupAmetysObject.ATTRIBUTE_VALUE, _encodeValue(value));
331        }
332    }
333    
334    private String _encodeValue (Object value)
335    {
336        if (value instanceof Map || value instanceof List)
337        {
338            return _jsonUtils.convertObjectToJson(value);
339        }
340        else
341        {
342            return value != null ? value.toString() : "";
343        }
344    }
345    
346    /**
347     * Store the repeater item counts to be able to re-initialize them
348     * @param dataHolder the content backup data holder
349     * @param repeaters the repeaters data
350     */
351    protected void storeRepeaters(ModifiableModelAwareDataHolder dataHolder, Collection<Map<String, String>> repeaters)
352    {
353        ModifiableModelAwareRepeater repeatersData = dataHolder.getRepeater(ContentBackupAmetysObject.AUTOSAVE_REPEATERS, true);
354        for (Map<String, String> repeaterData : repeaters)
355        {
356            ModifiableModelAwareRepeaterEntry entry = repeatersData.addEntry();
357            entry.setValue(ContentBackupAmetysObject.REPEATER_NAME, repeaterData.get("name"));
358            entry.setValue(ContentBackupAmetysObject.REPEATER_PREFIX, repeaterData.get("prefix"));
359            entry.setValue(ContentBackupAmetysObject.REPEATER_COUNT, repeaterData.get("count"));
360        }
361    }
362    
363    /**
364     * Remove all the repeater entries of a given data holder.
365     * @param dataHolder the data holder to clear.
366     */
367    protected void removeAllRepeaterEntries(ModifiableModelAwareDataHolder dataHolder)
368    {
369        for (String dataName : dataHolder.getDataNames())
370        {
371            if (org.ametys.plugins.repository.data.type.ModelItemTypeConstants.REPEATER_TYPE_ID.equals(dataHolder.getType(dataName).getId()))
372            {
373                dataHolder.removeValue(dataName);
374            }
375        }
376    }
377
378    /**
379     * Get the storage node for a content in the automatic backup space, or create it if it doesn't exist.
380     * @param contentId the content ID.
381     * @param createNew <code>true</code> to create automatically the node when missing.
382     * @return the content backup storage node.
383     */
384    protected ModelAwareJCRAmetysObject getContentNode(String contentId, boolean createNew)
385    {
386        ModelAwareJCRAmetysObject contentNode = null;
387        
388        // Query the content node by path and contentId property.
389        Expression expression = new StringExpression(ContentBackupAmetysObject.AUTOSAVE_CONTENT_ID, Operator.EQ, contentId);
390        String query = QueryHelper.getXPathQuery(null, __AUTOSAVE_NODETYPE, expression);
391        
392        AmetysObjectIterable<ModelAwareJCRAmetysObject> autoSaveObjects = _resolver.query(query);
393        Iterator<ModelAwareJCRAmetysObject> it = autoSaveObjects.iterator();
394        
395        // Get or create the content node.
396        if (it.hasNext())
397        {
398            contentNode = it.next();
399        }
400        else if (createNew)
401        {
402            ModifiableTraversableAmetysObject tempRoot = getOrCreateTempRoot();
403            
404            String objectName = NameHelper.filterName(contentId);
405            contentNode = (ModelAwareJCRAmetysObject) tempRoot.createChild(objectName, __AUTOSAVE_NODETYPE);
406            contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_CONTENT_ID, contentId);
407            
408            tempRoot.saveChanges();
409        }
410        
411        return contentNode;
412    }
413    
414    /**
415     * Get the temporary backup root, or create it if it doesn't exist.
416     * @return the temporary backup root.
417     */
418    protected ModifiableTraversableAmetysObject getOrCreateTempRoot()
419    {
420        ModifiableTraversableAmetysObject pluginsRoot = _resolver.resolveByPath("/ametys:plugins");
421        
422        ModifiableTraversableAmetysObject cmsNode = null;
423        if (pluginsRoot.hasChild("cms"))
424        {
425            cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.getChild("cms");
426        }
427        else
428        {
429            cmsNode = (ModifiableTraversableAmetysObject) pluginsRoot.createChild("cms", "ametys:unstructured");
430        }
431        
432        ModifiableTraversableAmetysObject editionNode = null;
433        if (cmsNode.hasChild("edition"))
434        {
435            editionNode = (ModifiableTraversableAmetysObject) cmsNode.getChild("edition");
436        }
437        else
438        {
439            editionNode = (ModifiableTraversableAmetysObject) cmsNode.createChild("edition", "ametys:unstructured");
440        }
441        
442        ModifiableTraversableAmetysObject tempNode = null;
443        if (editionNode.hasChild("temp"))
444        {
445            tempNode = (ModifiableTraversableAmetysObject) editionNode.getChild("temp");
446        }
447        else
448        {
449            tempNode = (ModifiableTraversableAmetysObject) editionNode.createChild("temp", "ametys:unstructured");
450        }
451        
452        if (pluginsRoot.needsSave())
453        {
454            pluginsRoot.saveChanges();
455        }
456        
457        return tempNode;
458    }
459}