001/*
002 *  Copyright 2021 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.duplicate.contents;
017
018import java.io.IOException;
019import java.util.Collection;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.LinkedHashSet;
023import java.util.List;
024import java.util.Map;
025import java.util.Map.Entry;
026import java.util.Set;
027import java.util.stream.Collectors;
028import java.util.stream.Stream;
029
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.quartz.JobExecutionContext;
033
034import org.ametys.cms.content.ContentHelper;
035import org.ametys.cms.contenttype.ContentType;
036import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
037import org.ametys.cms.contenttype.ContentTypesHelper;
038import org.ametys.cms.duplicate.contents.DuplicateContentsManager.Status;
039import org.ametys.cms.repository.Content;
040import org.ametys.cms.schedule.AbstractSendingMailSchedulable;
041import org.ametys.core.schedule.Schedulable;
042import org.ametys.core.schedule.progression.ContainerProgressionTracker;
043import org.ametys.core.ui.mail.StandardMailBodyHelper;
044import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
045import org.ametys.runtime.i18n.I18nizableText;
046import org.ametys.runtime.i18n.I18nizableTextParameter;
047
048/**
049 * A {@link Schedulable} job for detecting duplicates.
050 */
051public class DuplicateContentsDetectionSchedulable extends AbstractSendingMailSchedulable
052{
053
054    private static final Object _HAS_CONFIGURATION_ERRORS = "hasConfigurationErrors";
055    
056    /** The duplicates manager */
057    protected DuplicateContentsManager _duplicatesManager;
058
059    /** The content types helper */
060    protected ContentTypesHelper _contentTypesHelper;
061    
062    /** The content type extension point */
063    protected ContentTypeExtensionPoint _cTypeEP;
064    
065    /** The content helper */
066    protected ContentHelper _contentHelper;
067    
068    @Override
069    public void service(ServiceManager manager) throws ServiceException
070    {
071        super.service(manager);
072        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
073        _duplicatesManager = (DuplicateContentsManager) manager.lookup(DuplicateContentsManager.ROLE);
074        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
075        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
076    }
077
078    @Override
079    protected void _doExecute(JobExecutionContext context, ContainerProgressionTracker progressionTracker) throws Exception
080    {
081        Map<String, Object> duplicatesInfo = _duplicatesManager.searchDuplicates();
082        _duplicatesManager.logConfigurationErrors(getLogger());
083
084        context.put(DuplicateContentsManager.NO_DUPLICATE_CONTENTS_CONTENT_TYPE_KEY, duplicatesInfo.get(DuplicateContentsManager.NO_DUPLICATE_CONTENTS_CONTENT_TYPE_KEY));
085        context.put(DuplicateContentsManager.DUPLICATE_CONTENTS_KEY, duplicatesInfo.get(DuplicateContentsManager.DUPLICATE_CONTENTS_KEY));
086        context.put(DuplicateContentsManager.NEAR_DUPLICATE_CONTENTS_KEY, duplicatesInfo.get(DuplicateContentsManager.NEAR_DUPLICATE_CONTENTS_KEY));
087        context.put(DuplicateContentsManager.STATUS_KEY, duplicatesInfo.get(DuplicateContentsManager.STATUS_KEY));
088        context.put(_HAS_CONFIGURATION_ERRORS, _duplicatesManager.hasConfigurationErrors());
089
090        if ((boolean) duplicatesInfo.get(DuplicateContentsManager.NO_DUPLICATE_CONTENTS_CONTENT_TYPE_KEY))
091        {
092            throw new IllegalArgumentException("Missing or badly configured duplicate-contents.xml file inside WEB-INF/param folder");
093        }
094    }
095    
096    @Override
097    protected I18nizableText _getSuccessMailSubject(JobExecutionContext context) throws Exception
098    {
099        @SuppressWarnings("unchecked")
100        Map<Content, List<Content>> duplicatesMap = (Map<Content, List<Content>>) context.get(DuplicateContentsManager.DUPLICATE_CONTENTS_KEY);
101        @SuppressWarnings("unchecked")
102        Map<Content, List<Content>> nearDuplicatesMap = (Map<Content, List<Content>>) context.get(DuplicateContentsManager.NEAR_DUPLICATE_CONTENTS_KEY);
103        int resultsCount = duplicatesMap.size() + nearDuplicatesMap.size();
104        Map<String, I18nizableTextParameter> i18nParameters = new HashMap<>();
105        i18nParameters.put("resultsCount", new I18nizableText(String.valueOf(resultsCount)));
106
107        return new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_SUBJECT", i18nParameters);
108    }
109    
110    @Override
111    protected boolean _isMailBodyInHTML(JobExecutionContext context)
112    {
113        return true;
114    }
115    
116    @Override
117    protected String _getSuccessMailBody(JobExecutionContext context) throws Exception
118    {
119        @SuppressWarnings("unchecked")
120        Map<Content, List<Content>> duplicatesMap = (Map<Content, List<Content>>) context.get(DuplicateContentsManager.DUPLICATE_CONTENTS_KEY);
121        @SuppressWarnings("unchecked")
122        Map<Content, List<Content>> nearDuplicatesMap = (Map<Content, List<Content>>) context.get(DuplicateContentsManager.NEAR_DUPLICATE_CONTENTS_KEY);
123        @SuppressWarnings("unchecked")
124        Map<Content, DuplicateContentsManager.Status> tooComplexMap = (Map<Content, DuplicateContentsManager.Status>) context.get(DuplicateContentsManager.STATUS_KEY);
125
126        boolean hasConfigurationErrors = (boolean) context.get(_HAS_CONFIGURATION_ERRORS);
127        
128        return _createBody(duplicatesMap, nearDuplicatesMap, tooComplexMap, hasConfigurationErrors);        
129        
130    }
131
132    /**
133     * Create the body of the email
134     * @param duplicatesMap map linking a content to his duplicates
135     * @param nearDuplicatesMap map linking a content to his near duplicates
136     * @param tooComplexMap map linking a content to his search status (used to know if a query failed because the fuzzy query was too complex for example)
137     * @param hasErrors true if the configuration have some errors
138     * @return the body
139     * @throws IOException if failed to build HTML email body for report
140     */
141    protected String _createBody(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap, Map<Content, DuplicateContentsManager.Status> tooComplexMap, boolean hasErrors) throws IOException
142    {
143        MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
144            .withTitle(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_TITLE"));
145        
146        // Intro
147        bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_INTRO"));
148        
149        // Duplicate and near duplicate lists
150        if (duplicatesMap.isEmpty() && nearDuplicatesMap.isEmpty() && tooComplexMap.isEmpty())
151        {
152            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_NO_DUPLICATE_CONTENTS"));
153        }
154        else
155        {
156            Map<String, I18nizableTextParameter> introI18nParameters = new HashMap<>();
157            introI18nParameters.put("duplicateCount", new I18nizableText(String.valueOf(duplicatesMap.size())));
158            introI18nParameters.put("nearDuplicateCount", new I18nizableText(String.valueOf(nearDuplicatesMap.size())));
159            
160            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_DETECTED", introI18nParameters));
161            
162            bodyBuilder.addMessage(_displayContents(duplicatesMap, nearDuplicatesMap, tooComplexMap));
163            
164        }
165        if (hasErrors)
166        {
167            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_FOOTER_ERROR_OR_WARN"));
168        }
169        
170        return bodyBuilder.build();
171    }
172
173    /**
174     * Create StringBuilder used to display contents
175     * @param duplicatesMap map linking a content to his duplicates
176     * @param nearDuplicatesMap map linking a content to his near duplicates
177     * @param tooComplexMap map linking a content to his search status (used to know if a query failed because the fuzzy query was too complex for example)
178     * @return the StringBuilder used to display contents
179     */
180    protected String _displayContents(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap,
181            Map<Content, DuplicateContentsManager.Status> tooComplexMap)
182    {
183        Set<String> contentTypes = _duplicatesManager.getContentTypeIds();
184
185        StringBuilder body = new StringBuilder();
186        body.append("<ul>");
187        for (String contentTypeId : contentTypes)
188        {
189            Set<Content> tooComplexMapKeySet = tooComplexMap.entrySet()
190                    .stream()
191                    .filter(entry -> entry.getValue().equals(Status.TOO_COMPLEX))
192                    .map(Entry::getKey)
193                    .collect(Collectors.toSet());
194            
195            Set<Content> allMapKeySet = Stream.of(duplicatesMap.keySet(), nearDuplicatesMap.keySet(), tooComplexMapKeySet)
196                    .flatMap(Collection::stream)
197                    .filter(content -> _contentTypesHelper.isInstanceOf(content, contentTypeId))
198                    .sorted(Comparator.comparing(Content::getTitle, String.CASE_INSENSITIVE_ORDER))
199                    .collect(Collectors.toCollection(LinkedHashSet::new));
200            if (!allMapKeySet.isEmpty())
201            {
202                body.append("<li>");
203                ContentType contentType = _cTypeEP.getExtension(contentTypeId);
204                body.append(_i18nUtils.translate(contentType.getLabel()));
205                body.append("<ul>");
206                // Duplicate list
207                for (Content content : allMapKeySet)
208                {
209                    body.append(_displayDuplicateContent(duplicatesMap, nearDuplicatesMap, tooComplexMap, content));
210                    
211                    // Remove contents from maps, in case of contents with multiple content types appear twice
212                    duplicatesMap.remove(content);
213                    nearDuplicatesMap.remove(content);
214                    tooComplexMap.remove(content);
215                }
216                body.append("</ul>");
217                body.append("</li>");
218            }
219            
220        }
221        body.append("</ul>");
222        return body.toString();
223    }
224
225    /**
226     * Create StringBuilder used to display a content
227     * @param duplicatesMap map linking a content to his duplicates
228     * @param nearDuplicatesMap map linking a content to his near duplicates
229     * @param tooComplexMap map linking a content to his search status (used to know if a query failed because the fuzzy query was too complex for example)
230     * @param content the content
231     * @return the StringBuilder used to display a content
232     */
233    protected StringBuilder _displayDuplicateContent(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap,
234            Map<Content, DuplicateContentsManager.Status> tooComplexMap, Content content)
235    {
236        StringBuilder body = new StringBuilder();
237        body.append("<li>");
238        body.append(_renderContentTitle(content));
239        
240        body.append("<ul>");
241        List<Content> duplicates = duplicatesMap.get(content);
242        if (duplicates != null)
243        {
244            body.append("<li>");
245            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_LIST_DUPLICATE_CONTENTS")));
246            body.append("<ul>");
247            for (Content duplicate : duplicates)
248            {
249                body.append("<li>");
250                body.append(_renderContentTitle(duplicate));
251                body.append("</li>");
252            }
253            body.append("</ul>");
254            body.append("</li>");
255        }
256        
257        List<Content> nearDuplicates = nearDuplicatesMap.get(content);
258        if (nearDuplicates != null)
259        {
260            body.append("<li>");
261            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_LIST_NEAR_DUPLICATE_CONTENTS")));
262            body.append("<ul>");
263            for (Content nearDuplicate : nearDuplicates)
264            {
265                body.append("<li>");
266                body.append(_renderContentTitle(nearDuplicate));
267                body.append("</li>");
268            }
269            body.append("</ul>");
270            body.append("</li>");
271        }
272        
273        if (tooComplexMap.get(content) == Status.TOO_COMPLEX)
274        {
275            body.append("<li>");
276            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_TOO_COMPLEX")));
277            body.append("</li>");
278        }
279        
280        body.append("</ul>");
281        body.append("</li>");
282        return body;
283    }
284
285    /**
286     * Render the content title
287     * @param content The content
288     * @return the link
289     */
290    protected String _renderContentTitle(Content content)
291    {
292        return _renderCmsToolLink(content);
293    }
294    
295    /**
296     * Render the cms tool link
297     * @param content The content
298     * @return the link
299     */
300    protected String _renderCmsToolLink(Content content)
301    {
302        String contentBOUrl = _contentHelper.getContentBOUrl(content, Map.of());
303        return "<a href=\"" + contentBOUrl + "\">" + content.getTitle() + "</a>";
304    }
305
306    @Override
307    protected I18nizableText _getErrorMailSubject(JobExecutionContext context) throws Exception
308    {
309        return new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_SUBJECT");
310    }
311    
312    @Override
313    protected String _getErrorMailBody(JobExecutionContext context, Throwable throwable) throws Exception
314    {
315        MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
316                .withTitle(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_BODY_TITLE"));
317        
318        if ((boolean) context.get(DuplicateContentsManager.NO_DUPLICATE_CONTENTS_CONTENT_TYPE_KEY))
319        {
320            bodyBuilder.withMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_NO_CONTENT_TYPE"));
321        }
322        else
323        {
324            bodyBuilder.withMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_BODY"));
325        }
326        
327        return bodyBuilder.build();
328    }
329}