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