001/*
002 *  Copyright 2023 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.web.statistics;
017
018import java.util.ArrayList;
019import java.util.Comparator;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.stream.Collectors;
024
025import org.apache.avalon.framework.service.ServiceException;
026import org.apache.avalon.framework.service.ServiceManager;
027import org.apache.avalon.framework.service.Serviceable;
028import org.apache.commons.lang3.StringUtils;
029
030import org.ametys.cms.repository.Content;
031import org.ametys.core.util.I18nUtils;
032import org.ametys.plugins.explorer.resources.Resource;
033import org.ametys.plugins.explorer.resources.ResourceCollection;
034import org.ametys.plugins.repository.AmetysObject;
035import org.ametys.plugins.repository.AmetysObjectIterable;
036import org.ametys.plugins.repository.AmetysObjectResolver;
037import org.ametys.plugins.repository.TraversableAmetysObject;
038import org.ametys.runtime.i18n.I18nizableText;
039import org.ametys.runtime.plugin.component.PluginAware;
040import org.ametys.runtime.plugins.admin.statistics.Statistics;
041import org.ametys.runtime.plugins.admin.statistics.StatisticsNode;
042import org.ametys.runtime.plugins.admin.statistics.StatisticsProvider;
043import org.ametys.runtime.plugins.admin.statistics.StatisticsValue;
044import org.ametys.web.cache.PageHelper;
045import org.ametys.web.repository.content.SharedContent;
046import org.ametys.web.repository.content.WebContent;
047import org.ametys.web.repository.page.Page;
048import org.ametys.web.repository.page.SitemapElement;
049import org.ametys.web.repository.page.Zone;
050import org.ametys.web.repository.page.ZoneItem;
051import org.ametys.web.repository.site.Site;
052import org.ametys.web.repository.site.SiteManager;
053import org.ametys.web.repository.sitemap.Sitemap;
054import org.ametys.web.service.Service;
055import org.ametys.web.service.ServiceExtensionPoint;
056import org.ametys.web.skin.SkinsManager;
057
058/**
059 * Web statistics
060 */
061public class WebStatisticsProvider implements StatisticsProvider, Serviceable, PluginAware
062{
063    private String _id;
064    private SiteManager _siteManager;
065    private PageHelper _pageHelper;
066    private ServiceExtensionPoint _serviceEP;
067    private I18nUtils _i18nUtils;
068    private AmetysObjectResolver _ametysResolver;
069    private SkinsManager _skinsManager;
070
071    public void service(ServiceManager manager) throws ServiceException
072    {
073        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
074        _pageHelper = (PageHelper) manager.lookup(PageHelper.ROLE);
075        _serviceEP = (ServiceExtensionPoint) manager.lookup(ServiceExtensionPoint.ROLE);
076        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
077        _ametysResolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
078        _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE);
079    }
080    
081    public void setPluginInfo(String pluginName, String featureName, String id)
082    {
083        _id = id;
084    }
085    
086    public Statistics getStatistics()
087    {
088        List<List<Map<String, Long>>> countPages = _countPages();
089        int siteCount = countPages.size();
090        int sitemapCount = countPages.stream().map(List<Map<String, Long>>::size).reduce(0, Integer::sum);
091        List<Long> nbPagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBPAGES")).sorted().toList();
092        long pagesCount = nbPagesPerSitemap.stream().reduce(0L, Long::sum);
093        List<Long> nbContentsRefPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("CONTENTS")).sorted().toList();
094        long contentsRefsCount = nbContentsRefPerSitemap.stream().reduce(0L, Long::sum);
095        List<Long> nbServicesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("SERVICES")).sorted().toList();
096        long servicesCount = nbServicesPerSitemap.stream().reduce(0L, Long::sum);
097        List<Long> nbCacheablePagesPerSitemap = countPages.stream().flatMap(List<Map<String, Long>>::stream).map(m -> m.get("NBCACHEABLE")).sorted().toList();
098        long cacheablePagesCount = nbCacheablePagesPerSitemap.stream().reduce(0L, Long::sum);
099        
100        Map<String, Long> contentsForSites = _processContentsForSites();
101        
102        return new StatisticsNode(
103            _id,
104            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_LABEL"),
105            "ametysicon-code-html-link",
106            null,
107            List.of(
108                new StatisticsNode(
109                    "sites",
110                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_LABEL"),
111                    "ametysicon-world-earth-black",
112                    siteCount,
113                    List.of(
114                        new StatisticsNode(
115                            "sitemaps",
116                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_LABEL"),
117                            "ametysicon-world-flag",
118                            sitemapCount,
119                            List.of(
120                                new StatisticsNode(
121                                    "pages",
122                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_LABEL"),
123                                    "ametysicon-website38",
124                                    pagesCount,
125                                    List.of(
126                                        new StatisticsValue(
127                                            "max",
128                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MAX_LABEL"),
129                                            "ametysicon-sort51",
130                                            nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() - 1) : 0
131                                        ),
132                                        new StatisticsValue(
133                                            "median",
134                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_MEDIAN_LABEL"),
135                                            "ametysicon-maths-window-symbol-x",
136                                            nbPagesPerSitemap.size() > 0 ? nbPagesPerSitemap.get(nbPagesPerSitemap.size() / 2) : 0
137                                        ),
138                                        new StatisticsValue(
139                                            "cacheable",
140                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_PAGES_CACHEABLE_LABEL"),
141                                            "ametysicon-desktop-archive",
142                                            cacheablePagesCount 
143                                        )                                             
144                                    ),
145                                    false
146                                ),
147                                new StatisticsNode(
148                                    "content-references",
149                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_LABEL"),
150                                    "ametysicon-text70",
151                                    contentsRefsCount,
152                                    List.of(
153                                        new StatisticsValue(
154                                            "max",
155                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MAX_LABEL"),
156                                            "ametysicon-sort51",
157                                            nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() - 1) : 0
158                                        ),
159                                        new StatisticsValue(
160                                            "median",
161                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_CONTENTREFERENCES_MEDIAN_LABEL"),
162                                            "ametysicon-maths-window-symbol-x",
163                                            nbContentsRefPerSitemap.size() > 0 ? nbContentsRefPerSitemap.get(nbContentsRefPerSitemap.size() / 2) : 0
164                                        )                                             
165                                    ),
166                                    false
167                                ),
168                                new StatisticsNode(
169                                    "content-references",
170                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_LABEL"),
171                                    "ametysicon-text70",
172                                    servicesCount, 
173                                    List.of(
174                                        new StatisticsValue(
175                                            "max",
176                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MAX_LABEL"),
177                                            "ametysicon-sort51",
178                                            nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() - 1) : 0
179                                        ),
180                                        new StatisticsValue(
181                                            "median",
182                                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SITEMAP_SERVICES_MEDIAN_LABEL"),
183                                            "ametysicon-maths-window-symbol-x",
184                                            nbServicesPerSitemap.size() > 0 ? nbServicesPerSitemap.get(nbServicesPerSitemap.size() / 2) : 0
185                                        )                                             
186                                    ),
187                                    false
188                                )
189                            ),
190                            false
191                        ),
192                        new StatisticsNode(
193                            "contents",
194                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_LABEL"),
195                            "ametysicon-text70",
196                            contentsForSites.get("COUNT"),
197                            List.of(
198                                new StatisticsValue(
199                                    "orphans",
200                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_ORPHANS_LABEL"),
201                                    "ametysicon-sort51",
202                                    contentsForSites.get("ORPHANS")
203                                ),
204                                new StatisticsValue(
205                                    "shared",
206                                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_CONTENTS_EXTERNAL_LABEL"),
207                                    "ametysicon-maths-window-symbol-x",
208                                    contentsForSites.get("EXTERNAL")
209                                )                                       
210                            ),
211                            false
212                        ),
213                        new StatisticsNode(
214                            "services",
215                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_LABEL"),
216                            "ametysicon-system-monitoring",
217                            null,
218                            _getServices(),
219                            false
220                        ),
221                        _processResources(),
222                        new StatisticsValue(
223                            "skins",
224                            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SKINS_LABEL"),
225                            "ametysicon-puzzle-piece1",
226                            _skinsManager.getResourceSkins()
227                        ) 
228                    ),
229                    true
230                )
231            ),
232            true
233        );
234    }
235    
236    private List<List<Map<String, Long>>> _countPages()
237    {
238        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
239        {
240            List<List<Map<String, Long>>> sitesValues = new ArrayList<>();
241            
242            for (Site site : sites)
243            {
244                sitesValues.add(_countPages(site));
245            }
246            
247            return sitesValues;
248        }
249    }
250    
251    private List<Map<String, Long>> _countPages(Site site)
252    {
253        List<Map<String, Long>> siteValues = new ArrayList<>();
254
255        for (Sitemap sitemap : site.getSitemaps())
256        {
257            siteValues.add(_countPages(sitemap));
258        }
259        
260        return siteValues;
261    }   
262    
263    private Map<String, Long> _countPages(SitemapElement pages)
264    {
265        Map<String, Long> sitemapElementValues = _newCountMap();
266        
267        AmetysObjectIterable<? extends Page> childPages = pages.getChildrenPages();
268        
269        sitemapElementValues.put("NBPAGES", sitemapElementValues.get("NBPAGES") + childPages.getSize());
270        
271        int cacheableCount = 0;
272        
273        for (Page page : childPages)
274        {
275            boolean isCacheable = _pageHelper.isCacheable(page);
276            for (Zone zone : page.getZones())
277            {
278                AmetysObjectIterable<? extends ZoneItem> zoneItems = zone.getZoneItems();
279                for (ZoneItem item : zoneItems)
280                {
281                    switch (item.getType())
282                    {
283                        case CONTENT:
284                            sitemapElementValues.put("CONTENTS", sitemapElementValues.get("CONTENTS") + 1);
285                            break;
286                        case SERVICE:
287                            sitemapElementValues.put("SERVICES", sitemapElementValues.get("SERVICES") + 1);
288                            break;
289                        default:
290                            break;
291                    }
292                }
293            }
294            
295            if (isCacheable)
296            {
297                cacheableCount++;
298            }
299            
300            _sumMaps(sitemapElementValues, _countPages(page));
301        }
302        
303        sitemapElementValues.put("NBCACHEABLE", sitemapElementValues.get("NBCACHEABLE") + cacheableCount);
304        
305        return sitemapElementValues;
306    }
307    
308    
309    private Map<String, Long> _newCountMap()
310    {
311        Map<String, Long> countMap = new HashMap<>();
312        countMap.put("NBPAGES", 0L); // Number of pages
313        countMap.put("NBCACHEABLE", 0L); // Number of cacheable pages
314        countMap.put("SERVICES", 0L); // Number of services
315        countMap.put("CONTENTS", 0L); // Number of contents
316        return countMap;
317    }
318    
319    private void _sumMaps(Map<String, Long> mainMap, Map<String, Long> mapToAdd)
320    {
321        for (String k : mainMap.keySet())
322        {
323            mainMap.put(k, mainMap.get(k) + mapToAdd.get(k));
324        }
325    }
326    
327    private List<Statistics> _getServices()
328    {
329        List<Statistics> stats = new ArrayList<>();
330        
331        for (String serviceId : _serviceEP.getExtensionsIds())
332        {
333            Service service = _serviceEP.getExtension(serviceId);
334            
335            stats.add(new StatisticsValue(
336                serviceId,
337                new I18nizableText(_i18nUtils.translate(service.getLabel())), // Translate now for sorting
338                StringUtils.defaultIfBlank(service.getIconGlyph(), "ametysicon-system-monitoring"),
339                _countAllServices(serviceId)
340            ));
341        }
342        
343        stats.sort(new Comparator<>() {
344            public int compare(Statistics o1, Statistics o2)
345            {
346                return o1.getLabel().getLabel().compareTo(o2.getLabel().getLabel());
347            }
348        });
349        
350        String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'"
351                + _serviceEP.getExtensionsIds().stream().map(id -> " and not(@ametys-internal:service='" + id + "')").collect(Collectors.joining())
352                + "]";        
353        try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query))
354        {
355            stats.add(0, new StatisticsValue(
356                "unknown",
357                new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_SERVICES_UNKNOWN_LABEL"),
358                "ametysicon-file-extension-generic-unknown",
359                serviceZoneItems.getSize()
360            ));
361        }
362        
363        return stats;
364    }
365
366    private long _countAllServices(String serviceId)
367    {
368        String query = "//element(ametys:zoneItem, ametys:zoneItem)[@ametys-internal:type='SERVICE'" 
369                + (serviceId != null ? " and @ametys-internal:service='" + serviceId + "'" : "")
370                + "]";
371        try (AmetysObjectIterable<ZoneItem> serviceZoneItems = _ametysResolver.query(query))
372        {
373            return serviceZoneItems.getSize();
374        }
375    }    
376    
377    private Map<String, Long> _processContentsForSites()
378    {
379        Map<String, Long> values = new HashMap<>();
380        values.put("COUNT", 0L);
381        values.put("ORPHANS", 0L);
382        values.put("EXTERNAL", 0L);
383        
384        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
385        {
386            for (Site site : sites)
387            {
388                _processContents (site, values);
389            }
390        }   
391        catch (Exception e)
392        {
393            throw new IllegalStateException(e);
394        }
395        
396        return values;
397    }
398    
399    private void _processContents (Site site, Map<String, Long> values)
400    {
401        // Number of contents
402        AmetysObjectIterable<Content> contents = site.getContents();
403        values.put("COUNT", values.get("COUNT") + contents.getSize());
404        
405        int orphans = 0;
406        int external = 0;
407        for (Content content : contents)
408        {
409            if (content instanceof WebContent webContent 
410                && webContent.getReferencingPages().size() == 0)
411            {
412                orphans++;
413            }
414            if (content instanceof SharedContent)
415            {
416                external++;
417            }
418        }
419        
420        // Number of orphan contents
421        values.put("ORPHANS", values.get("ORPHANS") + orphans);
422        
423        // Number of shared contents
424        values.put("EXTERNAL", values.get("EXTERNAL") + external);
425    }
426    
427    private Statistics _processResources()
428    {
429        HashMap<String, Long> values = new HashMap<>();
430        values.put("FOLDERCOUNT", 0L);
431        values.put("RESOURCECOUNT", 0L);
432        values.put("TOTALSIZE", 0L);
433        
434        try (AmetysObjectIterable<Site> sites = _siteManager.getSites())
435        {
436            for (Site site : sites)
437            {
438                _processResources (site.getRootResources(), values);
439            }
440        }
441        
442        return new StatisticsNode(
443            "explorer",
444            new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_LABEL"),
445            "ametysicon-folder249",
446            values.get("TOTALSIZE"),
447            List.of(
448                new StatisticsValue(
449                    "folders",
450                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FOLDER_LABEL"),
451                    "ametysicon-folder250",
452                    values.get("FOLDERCOUNT")
453                ),
454                new StatisticsValue(
455                    "files",
456                    new I18nizableText("plugin.web", "PLUGINS_WEB_STATISTICS_SITES_EXPLORER_FILES_LABEL"),
457                    "ametysicon-document77",
458                    values.get("RESOURCECOUNT")
459                )                
460            ),
461            false
462        );
463    }
464    
465    private void _processResources(TraversableAmetysObject resourceContainer, HashMap<String, Long> values)
466    {
467        AmetysObjectIterable<? extends AmetysObject> objects = resourceContainer.getChildren();
468        
469        for (AmetysObject object : objects)
470        {
471            // Traverse the child nodes if depth < 0 (generate all) or depth > 0 (we're not in the last level).
472            if (object instanceof ResourceCollection)
473            {
474                values.put("FOLDERCOUNT", values.get("FOLDERCOUNT") + 1);
475                
476                _processResources((ResourceCollection) object, values);
477            }
478            else if (object instanceof Resource)
479            {
480                Resource resource = (Resource) object;
481                
482                values.put("RESOURCECOUNT", values.get("RESOURCECOUNT") + 1);
483                values.put("TOTALSIZE", values.get("TOTALSIZE") + resource.getLength());
484            }
485        }
486    }
487}