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