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