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, String language) 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, language);
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     * @param language The language to use
139     * @return the body
140     * @throws IOException if failed to build HTML email body for report
141     */
142    protected String _createBody(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap, Map<Content, DuplicateContentsManager.Status> tooComplexMap, boolean hasErrors, String language) throws IOException
143    {
144        MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
145            .withTitle(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_TITLE"))
146            .withLanguage(language);
147        
148        // Intro
149        bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_INTRO"));
150        
151        // Duplicate and near duplicate lists
152        if (duplicatesMap.isEmpty() && nearDuplicatesMap.isEmpty() && tooComplexMap.isEmpty())
153        {
154            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_NO_DUPLICATE_CONTENTS"));
155        }
156        else
157        {
158            Map<String, I18nizableTextParameter> introI18nParameters = new HashMap<>();
159            introI18nParameters.put("duplicateCount", new I18nizableText(String.valueOf(duplicatesMap.size())));
160            introI18nParameters.put("nearDuplicateCount", new I18nizableText(String.valueOf(nearDuplicatesMap.size())));
161            
162            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_DETECTED", introI18nParameters));
163            
164            bodyBuilder.addMessage(_displayContents(duplicatesMap, nearDuplicatesMap, tooComplexMap, language));
165            
166        }
167        if (hasErrors)
168        {
169            bodyBuilder.addMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_FOOTER_ERROR_OR_WARN"));
170        }
171        
172        return bodyBuilder.build();
173    }
174
175    /**
176     * Create StringBuilder used to display contents
177     * @param duplicatesMap map linking a content to his duplicates
178     * @param nearDuplicatesMap map linking a content to his near duplicates
179     * @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)
180     * @param language The language to use
181     * @return the StringBuilder used to display contents
182     */
183    protected String _displayContents(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap,
184            Map<Content, DuplicateContentsManager.Status> tooComplexMap, String language)
185    {
186        Set<String> contentTypes = _duplicatesManager.getContentTypeIds();
187
188        StringBuilder body = new StringBuilder();
189        body.append("<ul>");
190        for (String contentTypeId : contentTypes)
191        {
192            Set<Content> tooComplexMapKeySet = tooComplexMap.entrySet()
193                    .stream()
194                    .filter(entry -> entry.getValue().equals(Status.TOO_COMPLEX))
195                    .map(Entry::getKey)
196                    .collect(Collectors.toSet());
197            
198            Set<Content> allMapKeySet = Stream.of(duplicatesMap.keySet(), nearDuplicatesMap.keySet(), tooComplexMapKeySet)
199                    .flatMap(Collection::stream)
200                    .filter(content -> _contentTypesHelper.isInstanceOf(content, contentTypeId))
201                    .sorted(Comparator.comparing(Content::getTitle, String.CASE_INSENSITIVE_ORDER))
202                    .collect(Collectors.toCollection(LinkedHashSet::new));
203            if (!allMapKeySet.isEmpty())
204            {
205                body.append("<li>");
206                ContentType contentType = _cTypeEP.getExtension(contentTypeId);
207                body.append(_i18nUtils.translate(contentType.getLabel(), language));
208                body.append("<ul>");
209                // Duplicate list
210                for (Content content : allMapKeySet)
211                {
212                    body.append(_displayDuplicateContent(duplicatesMap, nearDuplicatesMap, tooComplexMap, language, content));
213                    
214                    // Remove contents from maps, in case of contents with multiple content types appear twice
215                    duplicatesMap.remove(content);
216                    nearDuplicatesMap.remove(content);
217                    tooComplexMap.remove(content);
218                }
219                body.append("</ul>");
220                body.append("</li>");
221            }
222            
223        }
224        body.append("</ul>");
225        return body.toString();
226    }
227
228    /**
229     * Create StringBuilder used to display a content
230     * @param duplicatesMap map linking a content to his duplicates
231     * @param nearDuplicatesMap map linking a content to his near duplicates
232     * @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)
233     * @param language The language to use
234     * @param content the content
235     * @return the StringBuilder used to display a content
236     */
237    protected StringBuilder _displayDuplicateContent(Map<Content, List<Content>> duplicatesMap, Map<Content, List<Content>> nearDuplicatesMap,
238            Map<Content, DuplicateContentsManager.Status> tooComplexMap, String language, Content content)
239    {
240        StringBuilder body = new StringBuilder();
241        body.append("<li>");
242        body.append(_renderContentTitle(content));
243        
244        body.append("<ul>");
245        List<Content> duplicates = duplicatesMap.get(content);
246        if (duplicates != null)
247        {
248            body.append("<li>");
249            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_LIST_DUPLICATE_CONTENTS"), language));
250            body.append("<ul>");
251            for (Content duplicate : duplicates)
252            {
253                body.append("<li>");
254                body.append(_renderContentTitle(duplicate));
255                body.append("</li>");
256            }
257            body.append("</ul>");
258            body.append("</li>");
259        }
260        
261        List<Content> nearDuplicates = nearDuplicatesMap.get(content);
262        if (nearDuplicates != null)
263        {
264            body.append("<li>");
265            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_LIST_NEAR_DUPLICATE_CONTENTS"), language));
266            body.append("<ul>");
267            for (Content nearDuplicate : nearDuplicates)
268            {
269                body.append("<li>");
270                body.append(_renderContentTitle(nearDuplicate));
271                body.append("</li>");
272            }
273            body.append("</ul>");
274            body.append("</li>");
275        }
276        
277        if (tooComplexMap.get(content) == Status.TOO_COMPLEX)
278        {
279            body.append("<li>");
280            body.append(_i18nUtils.translate(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_TOO_COMPLEX"), language));
281            body.append("</li>");
282        }
283        
284        body.append("</ul>");
285        body.append("</li>");
286        return body;
287    }
288
289    /**
290     * Render the content title
291     * @param content The content
292     * @return the link
293     */
294    protected String _renderContentTitle(Content content)
295    {
296        return _renderCmsToolLink(content);
297    }
298    
299    /**
300     * Render the cms tool link
301     * @param content The content
302     * @return the link
303     */
304    protected String _renderCmsToolLink(Content content)
305    {
306        String contentBOUrl = _contentHelper.getContentBOUrl(content, Map.of());
307        return "<a href=\"" + contentBOUrl + "\">" + content.getTitle() + "</a>";
308    }
309
310    @Override
311    protected I18nizableText _getErrorMailSubject(JobExecutionContext context) throws Exception
312    {
313        return new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_SUBJECT");
314    }
315    
316    @Override
317    protected String _getErrorMailBody(JobExecutionContext context, String language, Throwable throwable) throws Exception
318    {
319        MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
320                .withTitle(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_BODY_TITLE"))
321                .withLanguage(language);
322        
323        if ((boolean) context.get(DuplicateContentsManager.NO_DUPLICATE_CONTENTS_CONTENT_TYPE_KEY))
324        {
325            bodyBuilder.withMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_MAIL_BODY_NO_CONTENT_TYPE"));
326        }
327        else
328        {
329            bodyBuilder.withMessage(new I18nizableText("plugin.cms", "PLUGINS_CMS_DUPLICATE_CONTENTS_GLOBAL_DETECTION_ERROR_MAIL_BODY"));
330        }
331        
332        return bodyBuilder.build();
333    }
334}