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.rights.ContentRightAssignmentContext; 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.ModelAwareRepeater; 048import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 049import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater; 050import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry; 051import org.ametys.plugins.repository.jcr.ModelAwareJCRAmetysObject; 052import org.ametys.plugins.repository.jcr.NameHelper; 053import org.ametys.plugins.repository.query.QueryHelper; 054import org.ametys.plugins.repository.query.expression.Expression; 055import org.ametys.plugins.repository.query.expression.Expression.Operator; 056import org.ametys.plugins.repository.query.expression.StringExpression; 057import org.ametys.runtime.config.Config; 058 059/** 060 * Component for manipulating auto-backup on contents 061 * 062 */ 063public class ContentBackupClientInteraction extends AbstractLogEnabled implements Serviceable, Component 064{ 065 private static final String __AUTOSAVE_NODETYPE = RepositoryConstants.NAMESPACE_PREFIX + ":autoSave"; 066 067 private AmetysObjectResolver _resolver; 068 private CurrentUserProvider _currentUserProvider; 069 private JSONUtils _jsonUtils; 070 071 @Override 072 public void service(ServiceManager manager) throws ServiceException 073 { 074 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 075 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 076 _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE); 077 } 078 079 /** 080 * Get the content backup information 081 * @param contentId The content id 082 * @return The saved data 083 */ 084 // It would be more appropriate to check the right to edit the content 085 // but such right doesn't really exist. It depends on the workflow action and other such things 086 @Callable(rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID) 087 public Map<String, Object> getContentBackup (String contentId) 088 { 089 if (getLogger().isDebugEnabled()) 090 { 091 getLogger().debug(String.format("Get automatic data backup for content '%s'", contentId)); 092 } 093 094 Map<String, Object> result = new HashMap<>(); 095 096 boolean autoSaveEnabled = Config.getInstance().getValue("automatic.save.enabled"); 097 Long autoSaveFrequency = Config.getInstance().getValue("automatic.save.frequency"); 098 099 result.put("enabled", autoSaveEnabled); 100 result.put("frequency", autoSaveFrequency); 101 102 ModelAwareDataHolder contentNode = getContentNode(contentId, false); 103 104 if (contentNode != null) 105 { 106 Map<String, Object> autoBackup = new HashMap<>(); 107 108 UserIdentity creator = contentNode.getValue(ContentBackupAmetysObject.AUTOSAVE_CREATOR); 109 autoBackup.put("creator", creator != null ? creator.toString() : StringUtils.EMPTY); 110 autoBackup.put("contentId", contentId); 111 112 ZonedDateTime backupDate = contentNode.getValue(ContentBackupAmetysObject.AUTOSAVE_TEMP_DATE); 113 if (backupDate != null) 114 { 115 autoBackup.put("date", DateUtils.zonedDateTimeToString(backupDate)); 116 } 117 118 autoBackup.put("data", getBackupData(contentNode)); 119 120 result.put("auto-backup", autoBackup); 121 } 122 123 return result; 124 } 125 126 /** 127 * Retrieves the content backup information 128 * @param dataHolder the content backup data holder 129 * @return the content backup information 130 */ 131 protected Map<String, Object> getBackupData (ModelAwareDataHolder dataHolder) 132 { 133 Map<String, Object> data = new HashMap<>(); 134 135 // Get and generate the valid attributes values. 136 if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_VALUES)) 137 { 138 List<Map<String, Object>> values = getValuesBackupData(dataHolder, ContentBackupAmetysObject.AUTOSAVE_VALUES); 139 data.put("attributes", values); 140 } 141 142 // Get and generate the invalid attributes values. 143 if (dataHolder.hasValue(ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES)) 144 { 145 List<Map<String, Object>> invalidValues = getValuesBackupData(dataHolder, ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES); 146 data.put("invalid-attributes", invalidValues); 147 } 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 // It would be more appropriate to check the right to edit the content 252 // but such right doesn't really exist. It depends on the workflow action and other such things 253 @Callable(rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID) 254 public Map<String, Object> deleteContentBackup (String contentId) 255 { 256 if (StringUtils.isNotEmpty(contentId)) 257 { 258 if (getLogger().isDebugEnabled()) 259 { 260 getLogger().debug(String.format("Delete automatic backup for content '%s'", contentId)); 261 } 262 263 RemovableAmetysObject contentNode = getContentNode(contentId, false); 264 265 if (contentNode != null) 266 { 267 ModifiableAmetysObject parent = (ModifiableAmetysObject) contentNode.getParent(); 268 269 contentNode.remove(); 270 271 parent.saveChanges(); 272 } 273 } 274 275 return java.util.Collections.EMPTY_MAP; 276 } 277 278 /** 279 * Store an automatic backup for a content. 280 * @param contentId The content id 281 * @param values The valid values to store 282 * @param invalidValues The invalid values to store 283 * @param repeaters The repeaters to store 284 * @return A empty Map 285 */ 286 // It would be more appropriate to check the right to edit the content 287 // but such right doesn't really exist. It depends on the workflow action and other such things 288 @Callable(rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID) 289 public Map<String, Object> setContentBackup (String contentId, Map<String, Object> values, Map<String, Object> invalidValues, Collection<Map<String, String>> repeaters) 290 { 291 if (getLogger().isDebugEnabled()) 292 { 293 getLogger().debug(String.format("Start automatic backup for content '%s'", contentId)); 294 } 295 296 UserIdentity currentUser = _currentUserProvider.getUser(); 297 298 ModelAwareJCRAmetysObject contentNode = getContentNode(contentId, true); 299 300 // Remove all existing metadata. 301 removeAllRepeaterEntries(contentNode); 302 303 contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_TEMP_DATE, ZonedDateTime.now()); 304 contentNode.setValue(ContentBackupAmetysObject.AUTOSAVE_CREATOR, currentUser); 305 306 // Store the valid values. 307 storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_VALUES, values); 308 309 // Store the invalid values. 310 storeValues(contentNode, ContentBackupAmetysObject.AUTOSAVE_INVALID_VALUES, invalidValues); 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 = NameHelper.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}