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