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