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.plugins.contentio.synchronize;
017
018import java.time.ZonedDateTime;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Set;
029import java.util.stream.Collectors;
030import java.util.stream.Stream;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.commons.lang3.LocaleUtils;
037import org.apache.commons.lang3.StringUtils;
038
039import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
040import org.ametys.cms.repository.Content;
041import org.ametys.cms.repository.ModifiableContent;
042import org.ametys.core.user.CurrentUserProvider;
043import org.ametys.plugins.repository.AmetysObjectResolver;
044import org.ametys.plugins.repository.AmetysRepositoryException;
045import org.ametys.plugins.repository.data.holder.ModelLessDataHolder;
046import org.ametys.plugins.repository.query.expression.Expression;
047import org.ametys.plugins.repository.query.expression.Expression.Operator;
048import org.ametys.plugins.repository.query.expression.ExpressionContext;
049import org.ametys.plugins.repository.query.expression.StringExpression;
050import org.ametys.runtime.model.type.ModelItemTypeConstants;
051import org.ametys.runtime.plugin.component.AbstractLogEnabled;
052
053import com.google.common.collect.ImmutableList;
054import com.google.common.collect.ImmutableMap;
055
056/**
057 * Helper for Synchronizable Contents Collections.
058 */
059public class SynchronizableContentsCollectionHelper extends AbstractLogEnabled implements Serviceable, Component
060{
061    /** The Avalon Role */
062    public static final String ROLE = SynchronizableContentsCollectionHelper.class.getName();
063    
064    /** SCC DAO */
065    protected SynchronizableContentsCollectionDAO _sccDAO;
066    /** The content type extension point */
067    protected ContentTypeExtensionPoint _contentTypeEP;
068    /** The current user provider */
069    protected CurrentUserProvider _currentUserProvider;
070    /** The Ametys resolver */
071    protected AmetysObjectResolver _resolver;
072    
073    @Override
074    public void service(ServiceManager smanager) throws ServiceException
075    {
076        _sccDAO = (SynchronizableContentsCollectionDAO) smanager.lookup(SynchronizableContentsCollectionDAO.ROLE);
077        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
078        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
079        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
080    }
081    
082    /**
083     * Get the first {@link SynchronizableContentsCollection} found for the given SCC model id.
084     * @param modelId Id of the SCC model
085     * @return The first SCC found or null
086     */
087    public SynchronizableContentsCollection getSCCFromModelId(String modelId)
088    {
089        SynchronizableContentsCollection collection = null;
090
091        // Get the first collection corresponding to the SCC model
092        for (SynchronizableContentsCollection scc : _sccDAO.getSynchronizableContentsCollections())
093        {
094            if (scc.getSynchronizeCollectionModelId().equals(modelId))
095            {
096                collection = scc;
097                break;
098            }
099        }
100        
101        return collection;
102    }
103
104    /**
105     * Transform results to be organized by content attribute, and remove the null values.
106     * @param searchResult Remote values from a search by content and column or attribute
107     * @param mapping Mapping between content attribute and columns/attributes
108     * @return A {@link Map} of possible attribute values organized by content synchronization key and attribute name
109     */
110    public Map<String, Map<String, List<Object>>> organizeRemoteValuesByAttribute(Map<String, Map<String, Object>> searchResult, Map<String, List<String>> mapping)
111    {
112        Map<String, Map<String, List<Object>>> result = new LinkedHashMap<>();
113        
114        // For each searchResult line (1 line = 1 content)
115        for (String resultKey : searchResult.keySet())
116        {
117            Map<String, Object> searchItem = searchResult.get(resultKey);
118            Map<String, List<Object>> contentResult = new HashMap<>();
119            
120            // For each attribute in the mapping
121            for (String attributeName : mapping.keySet())
122            {
123                List<String> columns = mapping.get(attributeName); // Get the columns for the current attribute
124                List<Object> values = columns.stream() // For each column corresponding to the attribute
125                        .map(searchItem::get) // Map the values
126                        .flatMap(o ->
127                        {
128                            if (o instanceof Collection<?>)
129                            {
130                                return ((Collection<?>) o).stream();
131                            }
132                            return Stream.of(o);
133                        }) // If it's a list of objects, get a flat stream
134                        .filter(Objects::nonNull) // Remove null values
135                        .collect(Collectors.toList()); // Collect it into a List
136                
137                contentResult.put(attributeName, values); // Add the retrieved attribute values list to the contentResult
138            }
139            
140            result.put(resultKey, contentResult);
141        }
142        
143        return result;
144    }
145    
146    /**
147     * Add the given synchronizable collection id to the existing ones
148     * @param content The synchronized content
149     * @param collectionId The ID of the collection to add
150     */
151    public void updateSCCProperty(Content content, String collectionId)
152    {
153        Set<String> collectionIds = getSynchronizableCollectionIds(content);
154        if (collectionIds.add(collectionId))
155        {
156            content.getInternalDataHolder().setValue(SynchronizableContentsCollection.COLLECTION_ID_DATA_NAME, collectionIds.toArray(new String[collectionIds.size()]));
157        }
158    }
159    
160    /**
161     * Remove the synchronizable collection id from the SCC property
162     * @param content The synchronized content
163     * @param collectionId The ID of the collection to remove
164     */
165    public void removeSCCProperty(Content content, String collectionId)
166    {
167        Set<String> collectionIds = getSynchronizableCollectionIds(content);
168        if (collectionIds.remove(collectionId))
169        {
170            content.getInternalDataHolder().setValue(SynchronizableContentsCollection.COLLECTION_ID_DATA_NAME, collectionIds.toArray(new String[collectionIds.size()]));
171        }
172    }
173    
174    /**
175     * Update the given content's synchronization properties
176     * @param content the synchronized content
177     */
178    public void updateLastSynchronizationProperties(Content content)
179    {
180        content.getInternalDataHolder().setValue(SynchronizableContentsCollection.LAST_SYNCHRONIZATION_DATA_NAME, ZonedDateTime.now(), ModelItemTypeConstants.DATETIME_TYPE_ID);
181        content.getInternalDataHolder().setValue(SynchronizableContentsCollection.LAST_SYNCHRONIZATION_USER_DATA_NAME, _currentUserProvider.getUser(), org.ametys.cms.data.type.ModelItemTypeConstants.USER_ELEMENT_TYPE_ID);
182    }
183    
184    /**
185     * Retrieves the synchronizable collection identifiers
186     * @param content the content
187     * @return the synchronizable collection identifiers
188     * @throws AmetysRepositoryException if an error occurs while reading SCC info on the given content
189     */
190    public Set<String> getSynchronizableCollectionIds(Content content) throws AmetysRepositoryException
191    {
192        ModelLessDataHolder internalDataHolder = content.getInternalDataHolder();
193        
194        Set<String> collectionIds = new HashSet<>();
195        if (internalDataHolder.hasValue(SynchronizableContentsCollection.COLLECTION_ID_DATA_NAME))
196        {
197            String[] existingCollectionIds = internalDataHolder.getValue(SynchronizableContentsCollection.COLLECTION_ID_DATA_NAME);
198            collectionIds = Arrays.stream(existingCollectionIds)
199                                  .collect(Collectors.toSet());
200        }
201        
202        return collectionIds;
203    }
204    
205    /**
206     * Retrieves a query expression testing the collection
207     * @param collectionId the identifier of the collection to test
208     * @return the query expression
209     */
210    public Expression getCollectionExpression(String collectionId)
211    {
212        ExpressionContext context = ExpressionContext.newInstance()
213                                                     .withInternal(true);
214        
215        return new StringExpression(SynchronizableContentsCollection.COLLECTION_ID_DATA_NAME, Operator.EQ, collectionId, context);
216    }
217    
218    /**
219     * Import the content specified by the id in the specified collection.
220     * @param collectionId Collection ID
221     * @param id Synchronization ID of the content
222     * @param additionalParameters Additional parameters
223     * @return Imported contents
224     */
225    public Map<String, Object> importContent(String collectionId, String id, Map<String, Object> additionalParameters)
226    {
227        if (StringUtils.isBlank(id))
228        {
229            getLogger().warn("The synchronization code cannot be empty.");
230            return Map.of("error", "noSyncCode");
231        }
232        
233        Map<String, Object> result = new HashMap<>();
234        
235        try
236        {
237            Locale defaultLocale = additionalParameters.containsKey("language") ? LocaleUtils.toLocale((String) additionalParameters.get("language")) : null;
238            Set<Map<String, String>> contentsList = new HashSet<>();
239            
240            SynchronizableContentsCollection collection = _sccDAO.getSynchronizableContentsCollection(collectionId);
241            Content existingContent = collection.getContent(null, id, true);
242            if (existingContent == null)
243            {
244                List<ModifiableContent> contents = collection.importContent(id, additionalParameters, getLogger());
245                for (ModifiableContent content : contents)
246                {
247                    Map<String, String> contentMap = new HashMap<>();
248                    contentMap.put("id", content.getId());
249                    contentMap.put("title", content.getTitle(defaultLocale));
250                    contentMap.put("lang", content.getLanguage());
251                    contentsList.add(contentMap);
252                }
253                result.put("contents", contentsList);
254                result.put("total", contents.size());
255            }
256            else
257            {
258                result.put("contents", ImmutableList.of(ImmutableMap.of("id", existingContent.getId(), "title", existingContent.getTitle(defaultLocale), "lang", existingContent.getLanguage())));
259                result.put("error", "alreadyImported");
260            }
261        }
262        catch (Exception e)
263        {
264            String errorMessage = "An exception occured during import of the content '" + id + "' on SCC '" + collectionId + "'";
265            getLogger().error(errorMessage, e);
266            throw new IllegalStateException(errorMessage);
267        }
268        
269        return result;
270    }
271    
272    /**
273     * Synchronize the content on the given collection with the given synchronization code.
274     * @param collectionId Collection ID
275     * @param contentId Content ID
276     * @param syncCode Synchronization code
277     * @return true if an error occurred
278     */
279    public boolean synchronizeContent(String collectionId, String contentId, String syncCode)
280    {
281        ModifiableContent content = _resolver.resolveById(contentId);
282        boolean hasErrors = false;
283        
284        try
285        {
286            SynchronizableContentsCollection collection = _sccDAO.getSynchronizableContentsCollection(collectionId);
287            
288            // First, add, update or remove synchronization informations
289            collection.updateSyncInformations(content, syncCode, getLogger());
290            
291            // If the synchronization code is empty, the process ends here
292            if (StringUtils.isBlank(syncCode))
293            {
294                return false;
295            }
296            
297            Map<String, Object> searchParameters = new HashMap<>();
298            searchParameters.put(collection.getIdField(), syncCode);
299            
300            if (collection.getTotalCount(searchParameters, getLogger()) > 0)
301            {
302                collection.synchronizeContent(content, getLogger());
303            }
304            else
305            {
306                getLogger().warn("In the collection '{}', there is not content matching with the synchronization code '{}'.", collectionId, syncCode);
307                hasErrors = true;
308            }
309        }
310        catch (Exception e)
311        {
312            getLogger().error("An error occured while synchronizing the content '{}' with the synchronization code '{}' from the '{}' collection.", contentId, syncCode, collectionId, e);
313            hasErrors = true;
314        }
315        
316        return hasErrors;
317    }
318    
319    /**
320     * Get the value of the synchronization field.
321     * @param collectionId Collection ID
322     * @param contentId Content ID
323     * @return The value of the synchronization field
324     */
325    public String getSyncCode(String contentId, String collectionId)
326    {
327        SynchronizableContentsCollection collection = _sccDAO.getSynchronizableContentsCollection(collectionId);
328        Content content = _resolver.resolveById(contentId);
329        
330        String syncCode = null;
331        if (content.hasValue(collection.getIdField()))
332        {
333            syncCode = content.getValue(collection.getIdField());
334        }
335        return syncCode;
336    }
337}