001/*
002 *  Copyright 2020 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.workflow;
017
018import java.io.IOException;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.commons.lang3.StringUtils;
029
030import org.ametys.cms.content.compare.ContentComparator;
031import org.ametys.cms.content.compare.ContentComparatorChange;
032import org.ametys.cms.data.ContentValue;
033import org.ametys.cms.data.type.ModelItemTypeConstants;
034import org.ametys.cms.repository.Content;
035import org.ametys.cms.repository.ModifiableWorkflowAwareContent;
036import org.ametys.cms.repository.WorkflowAwareContent;
037import org.ametys.cms.workflow.history.VersionInformation;
038import org.ametys.core.user.UserIdentity;
039import org.ametys.core.util.I18nUtils;
040import org.ametys.plugins.repository.AmetysObjectResolver;
041import org.ametys.plugins.repository.data.external.ExternalizableDataProviderExtensionPoint;
042import org.ametys.plugins.repository.lock.LockHelper;
043import org.ametys.plugins.repository.lock.LockableAmetysObject;
044import org.ametys.plugins.repository.model.RepositoryDataContext;
045import org.ametys.plugins.repository.version.VersionableAmetysObject;
046import org.ametys.runtime.i18n.I18nizableText;
047import org.ametys.runtime.i18n.I18nizableTextParameter;
048import org.ametys.runtime.model.ElementDefinition;
049import org.ametys.runtime.model.ModelHelper;
050import org.ametys.runtime.model.View;
051import org.ametys.runtime.model.ViewItemContainer;
052import org.ametys.runtime.model.type.DataContext;
053
054import com.opensymphony.module.propertyset.PropertySet;
055import com.opensymphony.workflow.WorkflowException;
056
057/**
058 * OSWorkflow function to restore an old revision of a content.
059 * Builds a Map with the old content's attributes values, and passes it to the
060 * {@link EditContentFunction}, which does the real job.
061 */
062public class RestoreRevisionFunction extends AbstractContentFunction
063{
064    private AmetysObjectResolver _resolver;
065    private ExternalizableDataProviderExtensionPoint _externalizableDataProviderEP;
066    private ContentComparator _contentComparator;
067    private I18nUtils _i18nUtils;
068    
069    @Override
070    public void service(ServiceManager manager) throws ServiceException
071    {
072        super.service(manager);
073        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
074        _externalizableDataProviderEP = (ExternalizableDataProviderExtensionPoint) manager.lookup(ExternalizableDataProviderExtensionPoint.ROLE);
075        _contentComparator = (ContentComparator) manager.lookup(ContentComparator.ROLE);
076        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
077    }
078    
079    @Override
080    public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException
081    {
082        WorkflowAwareContent content = getContent(transientVars);
083        UserIdentity user = getUser(transientVars);
084        
085        if (!(content instanceof ModifiableWorkflowAwareContent))
086        {
087            throw new IllegalArgumentException("The provided content " + content.getId() + " is not a ModifiableWorkflowAwareContent.");
088        }
089        
090        ModifiableWorkflowAwareContent modifiableContent = (ModifiableWorkflowAwareContent) content;
091        
092        if (content instanceof LockableAmetysObject)
093        {
094            LockableAmetysObject lockableContent = (LockableAmetysObject) content;
095            if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
096            {
097                throw new WorkflowException ("The user '" + user + "' try to restore the content '" + content.getId() + "', but this content is locked by the user '" + user + "'");
098            }
099            else if (lockableContent.isLocked())
100            {
101                lockableContent.unlock();
102            }
103        }
104        
105        String contentVersion = (String) getContextParameters(transientVars).get("contentVersion");
106        
107        Content oldContent = _resolver.resolveById(content.getId());
108        if (oldContent instanceof VersionableAmetysObject versionAware)
109        {
110            versionAware.switchToRevision(contentVersion);
111        }
112        
113        Map<String, Object> results = getResultsMap(transientVars);
114        Map<String, Object> brokenReferences = new HashMap<>();
115        results.put("brokenReferences", brokenReferences);
116        
117        Map<String, Object> parameters = getContextParameters(transientVars);
118        Set<String> externalizableData = _externalizableDataProviderEP.getExternalizableDataPaths(content);
119        
120        // Get values from version to restore
121        DataContext context = RepositoryDataContext.newInstance()
122            .withExternalizableData(externalizableData)
123            .withEmptyValues(true);
124        Map<String, Object> newValues = oldContent.dataToMap(context);
125        
126        String[] changingAttributes;
127        // CMS-11242 Only keep values with differences
128        try
129        {
130            changingAttributes = _contentComparator.compare(content, oldContent)
131                .getChanges()
132                .stream()
133                .map(ContentComparatorChange::getAttributeDataPath)
134                .map(ModelHelper::getDefinitionPathFromDataPath)
135                .distinct()
136                .toArray(String[]::new);
137        }
138        catch (IOException e)
139        {
140            throw new WorkflowException("An exception occured during content version comparison", e);
141        }
142        
143        String language = oldContent.getLanguage();
144        
145        Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
146        i18nParams.put("versionNumber", new I18nizableText(VersionInformation.getVersionNameFromRawVersion(contentVersion)));
147        String versionNumberComment = _i18nUtils.translate(new I18nizableText("plugin.cms", "CONTENT_RESTORE_COMMENT_VERSION_NUMBER", i18nParams), language);
148        
149        StringBuilder sb = new StringBuilder();
150        sb.append(versionNumberComment);
151        
152        String comment = (String) transientVars.get("comment");
153        if (StringUtils.isNotEmpty(comment))
154        {
155            sb.append("\n");
156            sb.append(comment);
157        }
158        
159        transientVars.put("comment", sb.toString());
160        
161        View view = View.of(content.getModel(), changingAttributes);
162        
163        // Exclude invalid links
164        newValues = _processContents(view, newValues, "", brokenReferences);
165        
166        parameters.put(EditContentFunction.VIEW, view);
167        parameters.put(EditContentFunction.VALUES_KEY, newValues);
168        parameters.put(EditContentFunction.GLOBAL_VALIDATION, false);
169        parameters.put(EditContentFunction.QUIT, Boolean.TRUE);
170        
171        modifiableContent.setLastContributor(user);
172        modifiableContent.setLastModified(ZonedDateTime.now());
173        
174        // Remove the proposal date.
175        modifiableContent.setProposalDate(null);
176        
177        // Commit changes
178        modifiableContent.saveChanges();
179    }
180    
181    private ContentValue _processContentValue(ContentValue value, String dataPath, ElementDefinition definition, Map<String, Object> brokenReferences)
182    {
183        if (_resolver.hasAmetysObjectForId(value.getContentId()))
184        {
185            return value;
186        }
187        else if (!brokenReferences.containsKey(dataPath))
188        {
189            brokenReferences.put(dataPath, definition.getLabel());
190        }
191        
192        return null;
193    }
194    
195    @SuppressWarnings("unchecked")
196    private Map<String, Object> _processContents(ViewItemContainer viewItemContainer, Map<String, Object> values, String dataPath, Map<String, Object> brokenReferences)
197    {
198        if (values == null)
199        {
200            return null;
201        }
202        
203        Map<String, Object> result = new HashMap<>();
204        
205        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer,
206            (element, definition) -> {
207                // simple element
208                String name = definition.getName();
209                Object value = values.get(name);
210                
211                if (values.containsKey(name))
212                {
213                    if (definition.getType().getId().equals(ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID))
214                    {
215                        Object newValue = null;
216                        if (value instanceof ContentValue[])
217                        {
218                            List<ContentValue> validContents = new ArrayList<>();
219                            
220                            for (ContentValue contentValue : (ContentValue[]) value)
221                            {
222                                ContentValue newContentValue = _processContentValue(contentValue, dataPath + name, definition, brokenReferences);
223                                if (newContentValue != null)
224                                {
225                                    validContents.add(newContentValue);
226                                }
227                            }
228                            
229                            newValue = validContents;
230                        }
231                        else if (value instanceof ContentValue)
232                        {
233                            ContentValue newContentValue = _processContentValue((ContentValue) value, dataPath + name, definition, brokenReferences);
234                            newValue = newContentValue;
235                        }
236                        
237                        result.put(name, newValue);
238                    }
239                    else
240                    {
241                        result.put(name, value);
242                    }
243                }
244            },
245            (group, definition) -> {
246                // composite
247                String name = definition.getName();
248                if (values.containsKey(name))
249                {
250                    result.put(name, _processContents(group, (Map<String, Object>) values.get(name), dataPath + name + "/" , brokenReferences));
251                }
252            },
253            (group, definition) -> {
254                // repeater
255                String name = definition.getName();
256                if (values.containsKey(name))
257                {
258                    List<Map<String, Object>> entries = (List<Map<String, Object>>) values.get(name);
259                    
260                    List<Map<String, Object>> newEntries = new ArrayList<>();
261                    for (int i = 0; i < entries.size(); i++)
262                    {
263                        newEntries.add(_processContents(group, entries.get(i), dataPath + name + "[" + (i + 1) + "]/" , brokenReferences));
264                    }
265                
266                    result.put(name, newEntries);
267                }
268            },
269            group -> result.putAll(_processContents(group, values, dataPath, brokenReferences)));
270        
271        return result;
272    }
273    
274    @Override
275    public FunctionType getFunctionExecType()
276    {
277        return FunctionType.PRE;
278    }
279    
280    @Override
281    public I18nizableText getLabel()
282    {
283        return new I18nizableText("plugin.cms", "PLUGINS_CMS_RESTORE_REVISION_FUNCTION_LABEL");
284    }
285}