001/*
002 *  Copyright 2012 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.repository;
017
018import java.time.ZonedDateTime;
019import java.util.Calendar;
020import java.util.List;
021import java.util.Locale;
022import java.util.Map;
023
024import javax.jcr.Node;
025import javax.jcr.RepositoryException;
026
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.lang3.StringUtils;
032import org.apache.commons.lang3.tuple.Pair;
033
034import org.ametys.cms.content.references.OutgoingReferences;
035import org.ametys.cms.data.type.ModelItemTypeConstants;
036import org.ametys.cms.repository.comment.AbstractComment;
037import org.ametys.core.user.UserIdentity;
038import org.ametys.core.util.DateUtils;
039import org.ametys.plugins.repository.AmetysObjectResolver;
040import org.ametys.plugins.repository.AmetysRepositoryException;
041import org.ametys.plugins.repository.RepositoryConstants;
042import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
043import org.ametys.plugins.repository.data.repositorydata.ModifiableRepositoryData;
044import org.ametys.plugins.repository.data.repositorydata.impl.JCRRepositoryData;
045import org.ametys.plugins.repository.lock.LockableAmetysObject;
046import org.ametys.runtime.model.ElementDefinition;
047import org.ametys.runtime.model.ModelItem;
048import org.ametys.runtime.plugin.component.AbstractLogEnabled;
049
050
051/**
052 * Provides helper methods to use the {@link ModifiableContent} API on {@link DefaultContent}s.
053 */
054public class ModifiableContentHelper extends AbstractLogEnabled implements Component, Serviceable
055{
056    /** The Avalon role */
057    public static final String ROLE = ModifiableContentHelper.class.getName();
058    
059    /** Constants for the root of comments */
060    public static final String METADATA_COMMENTS = "comments";
061    /** Constants for the root of contributor comments */
062    public static final String METADATA_CONTRIBUTOR_COMMENTS = "contributor-comments";
063
064    /** The Ametys object resolver */
065    protected AmetysObjectResolver _resolver;
066    
067    public void service(ServiceManager manager) throws ServiceException
068    {
069        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
070    }
071    
072    /**
073     * Set a {@link DefaultContent} title for the given locale.
074     * @param content the {@link DefaultContent} to set.
075     * @param title the title to set.
076     * @param locale The locale. Can be null if the content is not a multilingual content.
077     * @throws AmetysRepositoryException if an error occurs.
078     */
079    public void setTitle(DefaultContent content, String title, Locale locale) throws AmetysRepositoryException
080    {
081        ModelItem titleDefinition = content.getDefinition(Content.ATTRIBUTE_TITLE);
082        ModifiableRepositoryData repositoryData = new JCRRepositoryData(content.getNode());
083        if (titleDefinition instanceof ElementDefinition elementDef && ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(elementDef.getType().getId()))
084        {
085            if (locale == null)
086            {
087                throw new IllegalArgumentException("Cannot set a title with null locale on a multilingual title");
088            }
089            
090            _addLockToken(content);
091            ModifiableRepositoryData titleRepositoryData;
092            if (repositoryData.hasValue(Content.ATTRIBUTE_TITLE))
093            {
094                titleRepositoryData = repositoryData.getRepositoryData(Content.ATTRIBUTE_TITLE);
095            }
096            else
097            {
098                titleRepositoryData = repositoryData.addRepositoryData(Content.ATTRIBUTE_TITLE, RepositoryConstants.MULTILINGUAL_STRING_METADATA_NODETYPE);
099            }
100            titleRepositoryData.setValue(locale.toString(), title);
101        }
102        else
103        {
104            _addLockToken(content);
105            repositoryData.setValue(Content.ATTRIBUTE_TITLE, title);
106        }
107    }
108    
109    /**
110     * Set the title of non-multilingual {@link DefaultContent}.
111     * Be careful! Use only if content's title is not a multilingual string. If not sure use {@link #setTitle(DefaultContent, String, Locale)} instead.
112     * @param content the {@link DefaultContent} to set.
113     * @param title the title to set.
114     * @throws AmetysRepositoryException if an error occurs.
115     */
116    public void setTitle(DefaultContent content, String title) throws AmetysRepositoryException
117    {
118        setTitle(content, title, null);
119    }
120    
121    /**
122     * Copy the title of the source content to the target content
123     * @param srcContent The source content
124     * @param targetContent The target content
125     * @throws AmetysRepositoryException if an error occurs.
126     */
127    public void copyTitle(Content srcContent, ModifiableContent targetContent) throws AmetysRepositoryException
128    {
129        targetContent.setValue(Content.ATTRIBUTE_TITLE, srcContent.getValue(Content.ATTRIBUTE_TITLE));
130    }
131    
132    /**
133     * Set a {@link DefaultContent} user.
134     * @param content the {@link DefaultContent} to set.
135     * @param user the user to set.
136     * @throws AmetysRepositoryException if an error occurs.
137     */
138    public void setCreator(DefaultContent content, UserIdentity user) throws AmetysRepositoryException
139    {
140        _storeUserMetadata(content, DefaultContent.METADATA_CREATOR, user);
141    }
142
143    /**
144     * Set a {@link DefaultContent} creation date.
145     * @param content the {@link DefaultContent} to set.
146     * @param creationDate the creation date to set.
147     * @throws AmetysRepositoryException if an error occurs.
148     */
149    public void setCreationDate(DefaultContent content, ZonedDateTime creationDate) throws AmetysRepositoryException
150    {
151        _storeDatetimeMetadata(content, DefaultContent.METADATA_CREATION, creationDate);
152    }
153    
154    /**
155     * Set a {@link DefaultContent} contributor.
156     * @param content the {@link DefaultContent} to set.
157     * @param user the contributor to set.
158     * @throws AmetysRepositoryException if an error occurs.
159     */
160    public void setLastContributor(DefaultContent content, UserIdentity user) throws AmetysRepositoryException
161    {
162        _storeUserMetadata(content, DefaultContent.METADATA_CONTRIBUTOR, user);
163    }
164    
165    /**
166     * Set a {@link DefaultContent} last modification date.
167     * @param content the {@link DefaultContent} to set.
168     * @param lastModified the last modification date to set.
169     * @throws AmetysRepositoryException if an error occurs.
170     */
171    public void setLastModified(DefaultContent content, ZonedDateTime lastModified) throws AmetysRepositoryException
172    {
173        _storeDatetimeMetadata(content, DefaultContent.METADATA_MODIFIED, lastModified);
174    }
175    
176    /**
177     * Set a {@link DefaultContent} first validator.
178     * @param content the {@link DefaultContent} to set.
179     * @param user the validator to set.
180     * @throws AmetysRepositoryException if an error occurs.
181     */
182    public void setFirstValidator(DefaultContent content, UserIdentity user) throws AmetysRepositoryException
183    {
184        _storeUserMetadata(content, DefaultContent.METADATA_FIRST_VALIDATOR, user);
185    }
186    
187    /**
188     * Set a {@link DefaultContent} first validation date.
189     * @param content the {@link DefaultContent} to set.
190     * @param validationDate the first validation date.
191     * @throws AmetysRepositoryException if an error occurs.
192     */
193    public void setFirstValidationDate(DefaultContent content, ZonedDateTime validationDate) throws AmetysRepositoryException
194    {
195        _storeDatetimeMetadata(content, DefaultContent.METADATA_FIRST_VALIDATION, validationDate);
196    }
197    
198    /**
199     * Set a {@link DefaultContent} last validator.
200     * @param content the {@link DefaultContent} to set.
201     * @param user the validator to set.
202     * @throws AmetysRepositoryException if an error occurs.
203     */
204    public void setLastValidator(DefaultContent content, UserIdentity user) throws AmetysRepositoryException
205    {
206        _storeUserMetadata(content, DefaultContent.METADATA_LAST_VALIDATOR, user);
207    }
208    
209    /**
210     * Set a {@link DefaultContent} last validation date.
211     * @param content the {@link DefaultContent} to set.
212     * @param validationDate the last validation date.
213     * @throws AmetysRepositoryException if an error occurs.
214     */
215    public void setLastValidationDate(DefaultContent content, ZonedDateTime validationDate) throws AmetysRepositoryException
216    {
217        _storeDatetimeMetadata(content, DefaultContent.METADATA_LAST_VALIDATION, validationDate);
218    }
219    
220    /**
221     * Set a {@link DefaultContent} last major validator.
222     * @param content the {@link DefaultContent} to set.
223     * @param user the validator to set.
224     * @throws AmetysRepositoryException if an error occurs.
225     */
226    public void setLastMajorValidator(DefaultContent content, UserIdentity user) throws AmetysRepositoryException
227    {
228        _storeUserMetadata(content, DefaultContent.METADATA_LAST_MAJOR_VALIDATOR, user);
229    }
230    
231    /**
232     * Set a {@link DefaultContent} last major validation date.
233     * @param content the {@link DefaultContent} to set.
234     * @param validationDate the last major validation date.
235     * @throws AmetysRepositoryException if an error occurs.
236     */
237    public void setLastMajorValidationDate(DefaultContent content, ZonedDateTime validationDate) throws AmetysRepositoryException
238    {
239        _storeDatetimeMetadata(content, DefaultContent.METADATA_LAST_MAJORVALIDATION, validationDate);
240    }
241    
242    /**
243     * Store the outgoing references on the content.
244     * @param content The content concerned by these outgoing references.
245     * @param references A non null map of outgoing references grouped by metadata (key are metadata path)
246     * @throws AmetysRepositoryException if an error occurs.
247     */
248    public void setOutgoingReferences(DefaultContent content, Map<String, OutgoingReferences> references) throws AmetysRepositoryException
249    {
250        try
251        {
252            Node contentNode = content.getNode();
253            if (contentNode.hasNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_ROOT_OUTGOING_REFERENCES))
254            {
255                contentNode.getNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_ROOT_OUTGOING_REFERENCES).remove();
256            }
257            
258            if (!references.isEmpty())
259            {
260                Node rootOutgoingRefsNode = contentNode.addNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_ROOT_OUTGOING_REFERENCES);
261                for (String path : references.keySet())
262                {
263                    // Reference nodes per type
264                    OutgoingReferences outgoingReferences = references.get(path);
265                    
266                    Node outgoingRefsNode = null;
267                    
268                    for (String type : outgoingReferences.keySet())
269                    {
270                        List<String> referenceValues = outgoingReferences.get(type);
271                        if (referenceValues != null && !referenceValues.isEmpty())
272                        {
273                            if (outgoingRefsNode == null)
274                            {
275                                // Outgoing references node creation (for the given path)
276                                outgoingRefsNode = rootOutgoingRefsNode.addNode(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES);
277                                outgoingRefsNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES_PATH_PROPERTY, path);
278                            }
279                            
280                            Node outgoingReferenceNode = outgoingRefsNode.addNode(type, RepositoryConstants.NAMESPACE_PREFIX + ':' + DefaultContent.METADATA_OUTGOING_REFERENCE_NODETYPE);
281                            outgoingReferenceNode.setProperty(RepositoryConstants.NAMESPACE_PREFIX + ':' + DefaultContent.METADATA_OUTGOING_REFERENCE_PROPERTY, referenceValues.toArray(new String[referenceValues.size()]));
282                        }
283                    }
284                }
285            }
286        }
287        catch (RepositoryException e)
288        {
289            throw new AmetysRepositoryException(e);
290        }
291    }
292    
293    /**
294     * Store a metadata of type datetime in the content
295     * @param content the content described by the metadata
296     * @param metadataName the name of the metadata
297     * @param date the value to set
298     * @throws AmetysRepositoryException when an error occurred
299     */
300    protected void _storeDatetimeMetadata(DefaultContent content, String metadataName, ZonedDateTime date)
301    {
302        _addLockToken(content);
303        
304        ModifiableRepositoryData repositoryData = new JCRRepositoryData(content.getNode());
305        Calendar calendar = DateUtils.asCalendar(date);
306        repositoryData.setValue(metadataName, calendar);
307    }
308
309    /**
310     * Store a metadata of type user in the content
311     * @param content the content described by the metadata
312     * @param metadataName the name of the metadata
313     * @param user the value to set
314     * @throws AmetysRepositoryException when an error occurred
315     */
316    protected void _storeUserMetadata(DefaultContent content, String metadataName, UserIdentity user) throws AmetysRepositoryException
317    {
318        try
319        {
320            _addLockToken(content);
321            
322            // TODO CMS-9336 All the metatadata here should be stored using types
323            ModifiableRepositoryData repositoryData = new JCRRepositoryData(content.getNode());
324            ModifiableRepositoryData creatorRepositoryData;
325            if (repositoryData.hasValue(metadataName))
326            {
327                creatorRepositoryData = repositoryData.getRepositoryData(metadataName);
328            }
329            else
330            {
331                creatorRepositoryData = repositoryData.addRepositoryData(metadataName, RepositoryConstants.USER_NODETYPE);
332            }
333            creatorRepositoryData.setValue("login", user.getLogin());
334            creatorRepositoryData.setValue("population", user.getPopulationId());
335        }
336        catch (AmetysRepositoryException e)
337        {
338            throw new AmetysRepositoryException("Error setting the metadata '" + metadataName + "' for content '" + content.getId() + "' with value '" + UserIdentity.userIdentityToString(user) + "'.", e);
339        }
340    }
341    
342    private void _addLockToken(DefaultContent content)
343    {
344        if (content instanceof LockableAmetysObject lockableContent)
345        {
346            lockableContent.setLockInfoOnCurrentContext();
347        }
348    }
349    
350    /**
351     * Retrieves the data holder for the given content's comments
352     * @param content the content
353     * @param createNew <code>true</code> to create the comments' data holder if it does not already exist, <code>false</code> otherwise
354     * @return the data holder for content's comments, or <code>null</code> if the data holder does not already exist and createNew is set to <code>false</code>
355     */
356    public ModifiableModelLessDataHolder getCommentsDataHolder(DefaultContent content, boolean createNew)
357    {
358        return content.getUnversionedDataHolder().getComposite(METADATA_COMMENTS, createNew);
359    }
360    
361    /**
362     * Retrieves the data holder for the given content's contributor comments
363     * @param content the content
364     * @param createNew <code>true</code> to create the contributor comments' data holder if it does not already exist, <code>false</code> otherwise
365     * @return the data holder for content's contributor comments, or <code>null</code> if the data holder does not already exist and createNew is set to <code>false</code>
366     */
367    public ModifiableModelLessDataHolder getContributorCommentsDataHolder(DefaultContent content, boolean createNew)
368    {
369        return content.getUnversionedDataHolder().getComposite(METADATA_CONTRIBUTOR_COMMENTS, createNew);
370    }
371    
372    /**
373     * Get the content and the comment identifier from a comment node.
374     * This supports both comment from contributor and visitor
375     * @param commentNode the comment node
376     * @return the content and the comment id or null if it wasn't possible to retrieve the content
377     */
378    public Pair<DefaultContent, String> getContentAndCommentId(Node commentNode)
379    {
380        String commentNodePath = null;
381        try
382        {
383            commentNodePath = commentNode.getPath();
384            
385            // Retrieve the comment id by going up the hierarchy
386            // …/ametys-internal:unversioned/ametys:[contributor-]comments/ametys:comment-x/ametys:comments/ametys:comment-y
387            // => comment-x_comment-y
388            
389            // We start at the comment node
390            StringBuilder commentId = new StringBuilder(StringUtils.removeStart(commentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"));
391            
392            // Reconstructing the ID of comment
393            // we go up twice for as long has we don't find the unversioned node
394            Node parentNode = commentNode.getParent().getParent();
395            while (parentNode != null && !StringUtils.equals(parentNode.getName(), "ametys-internal:unversioned"))
396            {
397                commentId
398                    .insert(0, AbstractComment.ID_SEPARATOR)
399                    .insert(0, StringUtils.removeStart(parentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"));
400                
401                parentNode = parentNode.getParent().getParent(); // continue iterating
402            }
403            
404            if (parentNode == null)
405            {
406                getLogger().error("Parent content not found from comment node '{}'", commentNodePath);
407                return null;
408            }
409            
410            return Pair.of(_resolver.resolve(parentNode.getParent(), false), commentId.toString());
411        }
412        catch (RepositoryException | AmetysRepositoryException e)
413        {
414            getLogger().error("Failed to retrieve content and comment from comment node '{}'", commentNodePath,  e);
415            return null;
416        }
417    }
418}