001/* 002 * Copyright 2017 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.compare; 017 018import java.io.IOException; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.List; 022 023import org.apache.avalon.framework.component.Component; 024import org.apache.avalon.framework.service.ServiceException; 025import org.apache.avalon.framework.service.ServiceManager; 026import org.apache.avalon.framework.service.Serviceable; 027import org.apache.commons.lang3.StringUtils; 028 029import org.ametys.cms.contenttype.ContentTypesHelper; 030import org.ametys.cms.repository.Content; 031import org.ametys.plugins.repository.AmetysRepositoryException; 032import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeater; 033import org.ametys.plugins.repository.data.type.ModelItemTypeConstants; 034import org.ametys.runtime.i18n.I18nizableText; 035import org.ametys.runtime.model.ElementDefinition; 036import org.ametys.runtime.model.Model; 037import org.ametys.runtime.model.ModelItem; 038import org.ametys.runtime.model.ModelViewItem; 039import org.ametys.runtime.model.ModelViewItemGroup; 040import org.ametys.runtime.model.SimpleViewItemGroup; 041import org.ametys.runtime.model.View; 042import org.ametys.runtime.model.ViewItem; 043import org.ametys.runtime.model.ViewItemContainer; 044import org.ametys.runtime.model.compare.DataChangeType; 045import org.ametys.runtime.plugin.component.AbstractLogEnabled; 046 047/** 048 * Object used to compare two contents 049 * 050 */ 051public class ContentComparator extends AbstractLogEnabled implements Component, Serviceable 052{ 053 /** The Avalon role */ 054 public static final String ROLE = ContentComparator.class.getName(); 055 056 private ContentTypesHelper _contentTypesHelper; 057 058 public void service(ServiceManager manager) throws ServiceException 059 { 060 this._contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 061 } 062 063 /** 064 * Compare 2 contents, for all their common attributes (contents with different contentTypes are rejected) 065 * @param content1 1st content 066 * @param content2 new content 067 * @return {@link ContentComparatorResult} 068 * @throws AmetysRepositoryException repository exception 069 * @throws IOException IO exception 070 */ 071 public ContentComparatorResult compare(Content content1, Content content2) throws AmetysRepositoryException, IOException 072 { 073 return compare(content1, content2, null); 074 } 075 076 /** 077 * Compare 2 contents, for all their common attributes 078 * @param content1 1st content 079 * @param content2 new content 080 * @param strictCompare true to reject contents with different content types 081 * @return {@link ContentComparatorResult} 082 * @throws AmetysRepositoryException repository exception 083 * @throws IOException IO exception 084 */ 085 public ContentComparatorResult compare(Content content1, Content content2, boolean strictCompare) throws AmetysRepositoryException, IOException 086 { 087 return compare(content1, content2, null, strictCompare); 088 } 089 090 /** 091 * Compare 2 contents, filtering with a view (contents with different contentTypes are rejected) 092 * @param content1 1st content 093 * @param content2 new content 094 * @param viewName name of the view 095 * @return {@link ContentComparatorResult} 096 * @throws AmetysRepositoryException repository exception 097 * @throws IOException IO exception 098 */ 099 public ContentComparatorResult compare(Content content1, Content content2, String viewName) throws AmetysRepositoryException, IOException 100 { 101 return compare(content1, content2, viewName, true); 102 } 103 104 /** 105 * Compare 2 contents, filtering with a view 106 * @param content1 1st content 107 * @param content2 new content 108 * @param viewName name of the view 109 * @param strictCompare true to reject contents with different content types 110 * @return {@link ContentComparatorResult} 111 * @throws AmetysRepositoryException repository exception 112 * @throws IOException IO exception 113 */ 114 public ContentComparatorResult compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException 115 { 116 return _compare(content1, content2, viewName, strictCompare); 117 } 118 119 private ContentComparatorResult _compare(Content content1, Content content2, String viewName, boolean strictCompare) throws AmetysRepositoryException, IOException 120 { 121 //if strictCompare, we check if the types are strictly equals 122 List<String> types1 = Arrays.asList(content1.getTypes()); 123 List<String> types2 = Arrays.asList(content2.getTypes()); 124 if (strictCompare && (types1.size() != types2.size() || !types1.containsAll(types2) || !types2.containsAll(types1))) 125 { 126 ContentComparatorResult result = new ContentComparatorResult(content1, content2); 127 result.setNotEquals(); 128 return result; 129 } 130 else 131 { 132 View view = _generateView(viewName, content1); 133 ContentComparatorResult result = new ContentComparatorResult(content1, content2, viewName, view); 134 if (view != null) 135 { 136 for (ViewItem viewItem : view.getViewItems()) 137 { 138 _compareAttributes(result, content1, content2, viewItem, StringUtils.EMPTY); 139 } 140 } 141 else 142 { 143 result.setNotEquals(); 144 } 145 146 return result; 147 } 148 } 149 150 /** 151 * Generate a view using a name (can be null) and a content 152 * @param viewName name of the view. If null, a __generated__ view will be created using all content's attributes 153 * @param content content where the view should be retrieved 154 * @return {@link View} from the name, or generated 155 */ 156 private View _generateView(String viewName, Content content) 157 { 158 if (viewName != null) 159 { 160 return _contentTypesHelper.getView(viewName, content.getTypes(), content.getMixinTypes()); 161 } 162 else 163 { 164 @SuppressWarnings("unchecked") 165 View view = View.of((Collection<Model>) content.getModel()); 166 view.setName("__generated__"); 167 view.setLabel(new I18nizableText("Live edition metadataset")); 168 view.setDescription(new I18nizableText("Live edition metadataset")); 169 view.setSmallIcon(null); 170 view.setMediumIcon(null); 171 view.setLargeIcon(null); 172 view.setIsInternal(true); 173 174 return view; 175 } 176 } 177 178 /** 179 * Compare 2 attributes and add the differences in result 180 * @param result global result where differences will be stored 181 * @param content1 1st content 182 * @param content2 2nd content 183 * @param viewItem view item that will be compared 184 * @param parentDataPath absolute data path of the container of the current view item 185 * @throws AmetysRepositoryException repository exception 186 * @throws IOException IO Exception (comparison of InputStream mainly) 187 */ 188 private void _compareAttributes(ContentComparatorResult result, Content content1, Content content2, ViewItem viewItem, String parentDataPath) throws AmetysRepositoryException, IOException 189 { 190 // If the view item is a reference to a model item, compare the model items and their values 191 if (viewItem instanceof ModelViewItem) 192 { 193 ModelItem modelItemFromView = ((ModelViewItem) viewItem).getDefinition(); 194 String modelItemPath = modelItemFromView.getPath(); 195 196 ModelItem modelItem1 = null; 197 if (content1.hasDefinition(modelItemPath)) 198 { 199 modelItem1 = content1.getDefinition(modelItemPath); 200 } 201 202 ModelItem modelItem2 = null; 203 if (content2.hasDefinition(modelItemPath)) 204 { 205 modelItem2 = content2.getDefinition(modelItemPath); 206 } 207 208 if (modelItem1 != null && modelItem2 != null && modelItem1.getModel().equals(modelItem2.getModel())) 209 { 210 String modelItemName = ((ModelViewItem) viewItem).getDefinition().getName(); 211 String currentDataPath = _concatAttributeDataPathSegments(parentDataPath, modelItemName); 212 if (_checkAttributePresence(result, content1, content2, currentDataPath, true)) 213 { 214 if (ModelItemTypeConstants.COMPOSITE_TYPE_ID.equals(modelItemFromView.getType().getId())) 215 { 216 _compareComposite(result, (ModelViewItemGroup) viewItem, content1, content2, modelItemFromView, currentDataPath); 217 } 218 else if (ModelItemTypeConstants.REPEATER_TYPE_ID.equals(modelItemFromView.getType().getId())) 219 { 220 _compareRepeater(result, (ModelViewItemGroup) viewItem, content1, content2, modelItemFromView, currentDataPath); 221 } 222 else 223 { 224 // If the model item is not a group, it has to be an ElementDefinition 225 _compareAttribute(result, content1, content2, (ElementDefinition) modelItemFromView, currentDataPath); 226 } 227 } 228 } 229 else 230 { 231 // TODO CMS-9978 Ignore or add the difference? What happen if an item is defined by a content but not by the other one? 232 // TODO CMS-9978 Shouldn't we compare the definitions? (cardinality, ...)? 233 } 234 } 235 236 // If the view item contains other view items, compare the children 237 if (viewItem instanceof SimpleViewItemGroup) 238 { 239 for (ViewItem child : ((ViewItemContainer) viewItem).getViewItems()) 240 { 241 _compareAttributes(result, content1, content2, child, parentDataPath); 242 } 243 } 244 } 245 246 /** 247 * Check if an attribute is present in both contents and add a change if requested 248 * @param result The result 249 * @param content1 1st content 250 * @param content2 2nd content 251 * @param attributeDataPath absolute path of the data to check (also used to log) 252 * @param logChange true to add a change in the list (if one has it and the other doesn't) 253 * @return true if both contents have the node 254 */ 255 private boolean _checkAttributePresence(ContentComparatorResult result, Content content1, Content content2, String attributeDataPath, boolean logChange) 256 { 257 if (content1.hasValue(attributeDataPath) && content2.hasValue(attributeDataPath)) 258 { 259 return true; 260 } 261 else if (logChange && content1.hasValue(attributeDataPath) && !content2.hasValue(attributeDataPath)) 262 { 263 ModelItem definition = content1.getDefinition(attributeDataPath); 264 result.addChange(new ContentComparatorChange(attributeDataPath, StringUtils.EMPTY, definition, DataChangeType.REMOVED)); 265 } 266 else if (logChange && !content1.hasValue(attributeDataPath) && content2.hasValue(attributeDataPath)) 267 { 268 ModelItem definition = content2.getDefinition(attributeDataPath); 269 result.addChange(new ContentComparatorChange(attributeDataPath, StringUtils.EMPTY, definition, DataChangeType.ADDED)); 270 } 271 return false; 272 } 273 274 /** 275 * Compare 2 composites 276 * @param result global result where differences will be stored 277 * @param viewItemGroup view item of the current composite 278 * @param content1 1st content 279 * @param content2 2nd content 280 * @param definition definition of the composite 281 * @param compositeDataPath absolute data path of the current composite 282 * @throws AmetysRepositoryException repository exception 283 * @throws IOException IO Exception 284 */ 285 private void _compareComposite(ContentComparatorResult result, ModelViewItemGroup viewItemGroup, Content content1, Content content2, ModelItem definition, String compositeDataPath) throws AmetysRepositoryException, IOException 286 { 287 for (ViewItem viewItem : viewItemGroup.getViewItems()) 288 { 289 _compareAttributes(result, content1, content2, viewItem, compositeDataPath); 290 } 291 } 292 293 /** 294 * Compare 2 repeaters 295 * @param result global result where differences will be stored 296 * @param viewItemGroup view item of the current repeater 297 * @param content1 1st content 298 * @param content2 2nd content 299 * @param definition definition of the repeater 300 * @param repeaterDataPath absolute data path of the current repeater 301 * @throws AmetysRepositoryException repository exception 302 * @throws IOException IO Exception 303 */ 304 private void _compareRepeater(ContentComparatorResult result, ModelViewItemGroup viewItemGroup, Content content1, Content content2, ModelItem definition, String repeaterDataPath) throws AmetysRepositoryException, IOException 305 { 306 ModelAwareRepeater repeater1 = content1.getRepeater(repeaterDataPath); 307 ModelAwareRepeater repeater2 = content2.getRepeater(repeaterDataPath); 308 309 if (repeater1.getSize() > repeater2.getSize()) 310 { 311 for (int i = repeater2.getSize() + 1; i <= repeater1.getSize(); i++) 312 { 313 result.addChange(new ContentComparatorChange(repeaterDataPath + "[" + Integer.toString(i) + "]", StringUtils.EMPTY, definition, DataChangeType.REMOVED)); 314 } 315 } 316 else if (repeater1.getSize() < repeater2.getSize()) 317 { 318 for (int i = repeater1.getSize() + 1; i <= repeater2.getSize(); i++) 319 { 320 result.addChange(new ContentComparatorChange(repeaterDataPath + "[" + Integer.toString(i) + "]", StringUtils.EMPTY, definition, DataChangeType.ADDED)); 321 } 322 } 323 324 // Compare common entries 325 int numberOfCommonEntries = Math.min(repeater1.getSize(), repeater2.getSize()); 326 for (int i = 1; i <= numberOfCommonEntries; i++) 327 { 328 String repeaterEntryDataPath = repeaterDataPath + "[" + Integer.toString(i) + "]"; 329 if (_checkAttributePresence(result, content1, content2, repeaterEntryDataPath, true)) 330 { 331 // Compare repeater's children for each entry 332 for (ViewItem viewItem : viewItemGroup.getViewItems()) 333 { 334 _compareAttributes(result, content1, content2, viewItem, repeaterEntryDataPath); 335 } 336 } 337 } 338 } 339 340 /** 341 * Compare 2 attributes 342 * @param result global result where differences will be stored 343 * @param content1 1st content 344 * @param content2 2nd content 345 * @param definition definition of the attribute 346 * @param attributeDataPath absolute data path of the current attribute 347 * @throws AmetysRepositoryException repository exception 348 * @throws IOException IO Exception 349 */ 350 private void _compareAttribute(ContentComparatorResult result, Content content1, Content content2, ElementDefinition<?> definition, String attributeDataPath) throws AmetysRepositoryException, IOException 351 { 352 definition.getType() 353 .compareValues(content1.getValue(attributeDataPath), content2.getValue(attributeDataPath)) 354 .map(change -> 355 { 356 String detailPath = change.getRight(); 357 return new ContentComparatorChange(attributeDataPath, detailPath, definition, change.getLeft(), change.getMiddle()); 358 }) 359 .forEach(result::addChange); 360 } 361 362 private String _concatAttributeDataPathSegments(String... segments) 363 { 364 StringBuilder result = new StringBuilder(); 365 366 for (String segment : segments) 367 { 368 if (StringUtils.isNotEmpty(result) && StringUtils.isNotEmpty(segment)) 369 { 370 result.append(ModelItem.ITEM_PATH_SEPARATOR); 371 } 372 result.append(segment); 373 } 374 375 return result.toString(); 376 } 377}