Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 253
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Matomo
0.00% covered (danger)
0.00%
0 / 253
0.00% covered (danger)
0.00%
0 / 24
6162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 __invoke
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 trackSearch
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 trackCombinedSearch
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 trackRecordPage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 trackPageView
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getSearchResults
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 getCombinedSearchResults
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getRecordDriver
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getSearchCustomData
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getRecordPageCustomData
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 getLightboxCustomData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getGenericCustomData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getOpeningTrackingCode
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getClosingTrackingCode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getPageUrl
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 getCustomVarsCode
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getCustomDimensionsCode
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getTrackSearchCode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getTrackCombinedSearchCode
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getTrackPageViewCode
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
56
 getTrackerUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTrackerJsUrl
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInitFunctionName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Matomo web analytics view helper for Matomo versions >= 4
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2014-2021.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  View_Helpers
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Site
28 */
29
30namespace VuFind\View\Helper\Root;
31
32use VuFind\RecordDriver\AbstractBase as RecordDriverBase;
33use VuFind\Search\Base\Results;
34
35use function intval;
36use function is_array;
37
38/**
39 * Matomo web analytics view helper for Matomo versions >= 4
40 *
41 * @category VuFind
42 * @package  View_Helpers
43 * @author   Ere Maijala <ere.maijala@helsinki.fi>
44 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
45 * @link     https://vufind.org Main Site
46 */
47class Matomo extends \Laminas\View\Helper\AbstractHelper
48{
49    /**
50     * Matomo URL (empty if disabled)
51     *
52     * @var string
53     */
54    protected $url;
55
56    /**
57     * Matomo Site ID
58     *
59     * @var int
60     */
61    protected $siteId;
62
63    /**
64     * Search prefix (see config.ini for details)
65     *
66     * @var string
67     */
68    protected $searchPrefix;
69
70    /**
71     * Whether to disable cookies (see config.ini for details)
72     *
73     * @var bool
74     */
75    protected $disableCookies;
76
77    /**
78     * Whether to use custom variables to track additional information
79     *
80     * @var bool
81     */
82    protected $customVars;
83
84    /**
85     * Mappings from data fields to custom dimensions for tracking additional
86     * information
87     *
88     * @var array
89     */
90    protected $customDimensions;
91
92    /**
93     * Request object
94     *
95     * @var \Laminas\Http\PhpEnvironment\Request
96     */
97    protected $request;
98
99    /**
100     * Router object
101     *
102     * @var \Laminas\Router\Http\RouteMatch
103     */
104    protected $router;
105
106    /**
107     * A timestamp used to identify the init function to avoid name clashes when
108     * opening lightboxes.
109     *
110     * @var int
111     */
112    protected $timestamp;
113
114    /**
115     * Tracker initialization context ('', 'lightbox', 'accordion' or 'tabs')
116     *
117     * @var string
118     */
119    protected $context = '';
120
121    /**
122     * Additional parameters
123     *
124     * @var array
125     */
126    protected $params = [];
127
128    /**
129     * Constructor
130     *
131     * @param \Laminas\Config\Config               $config  VuFind configuration
132     * @param \Laminas\Router\Http\TreeRouteStack  $router  Router
133     * @param \Laminas\Http\PhpEnvironment\Request $request Request
134     */
135    public function __construct(
136        \Laminas\Config\Config $config,
137        \Laminas\Router\Http\TreeRouteStack $router,
138        \Laminas\Http\PhpEnvironment\Request $request
139    ) {
140        $this->url = $config->Matomo->url ?? '';
141        if ($this->url && !str_ends_with($this->url, '/')) {
142            $this->url .= '/';
143        }
144        $this->siteId = $config->Matomo->site_id ?? 1;
145        $this->searchPrefix = $config->Matomo->searchPrefix ?? '';
146        $this->disableCookies = $config->Matomo->disableCookies ?? false;
147        $this->customVars = $config->Matomo->custom_variables ?? false;
148        $this->customDimensions = $config->Matomo->custom_dimensions ?? [];
149        $this->router = $router;
150        $this->request = $request;
151        $this->timestamp = round(microtime(true) * 1000);
152    }
153
154    /**
155     * Returns Matomo code (if active) or empty string if not.
156     *
157     * @param array $params Parameters
158     *
159     * @return string
160     */
161    public function __invoke(array $params = []): string
162    {
163        if (!$this->url) {
164            return '';
165        }
166
167        $this->params = $params;
168        $this->context = $this->params['context'] ?? '';
169
170        $results = $this->getSearchResults();
171        if ($results && ($combinedResults = $this->getCombinedSearchResults())) {
172            $code = $this->trackCombinedSearch($results, $combinedResults);
173        } elseif ($results) {
174            $code = $this->trackSearch($results);
175        } elseif ($recordDriver = $this->getRecordDriver()) {
176            $code = $this->trackRecordPage($recordDriver);
177        } else {
178            $code = $this->trackPageView();
179        }
180
181        $inlineScript = $this->getView()->plugin('inlinescript');
182        return $inlineScript(\Laminas\View\Helper\HeadScript::SCRIPT, $code, 'SET');
183    }
184
185    /**
186     * Track a Search
187     *
188     * @param Results $results Search Results
189     *
190     * @return string Tracking Code
191     */
192    protected function trackSearch(Results $results): string
193    {
194        $customData = 'lightbox' === $this->context
195            ? $this->getLightboxCustomData()
196            : $this->getSearchCustomData($results);
197
198        $code = $this->getOpeningTrackingCode();
199        $code .= $this->getCustomVarsCode($customData);
200        $code .= $this->getTrackSearchCode($results, $customData);
201        $code .= $this->getClosingTrackingCode();
202
203        return $code;
204    }
205
206    /**
207     * Track a Combined Search
208     *
209     * @param Results $results         Search Results
210     * @param array   $combinedResults Combined Search Results
211     *
212     * @return string Tracking Code
213     */
214    protected function trackCombinedSearch(
215        Results $results,
216        array $combinedResults
217    ): string {
218        $customData = 'lightbox' === $this->context
219            ? $this->getLightboxCustomData()
220            : $this->getSearchCustomData($results);
221
222        $code = $this->getOpeningTrackingCode();
223        $code .= $this->getCustomVarsCode($customData);
224        $code .= $this->getTrackCombinedSearchCode(
225            $results,
226            $combinedResults,
227            $customData
228        );
229        $code .= $this->getClosingTrackingCode();
230
231        return $code;
232    }
233
234    /**
235     * Track a Record View
236     *
237     * @param RecordDriverBase $recordDriver Record Driver
238     *
239     * @return string Tracking Code
240     */
241    protected function trackRecordPage(RecordDriverBase $recordDriver): string
242    {
243        $customData = 'lightbox' === $this->context
244            ? $this->getLightboxCustomData()
245            : $this->getRecordPageCustomData($recordDriver);
246
247        $code = $this->getOpeningTrackingCode();
248        $code .= $this->getCustomVarsCode($customData);
249        $code .= $this->getTrackPageViewCode($customData);
250        $code .= $this->getClosingTrackingCode();
251
252        return $code;
253    }
254
255    /**
256     * Track a Generic Page View
257     *
258     * @return string Tracking Code
259     */
260    protected function trackPageView(): string
261    {
262        $customData = 'lightbox' === $this->context
263            ? $this->getLightboxCustomData()
264            : $this->getGenericCustomData();
265
266        $code = $this->getOpeningTrackingCode();
267        $code .= $this->getCustomVarsCode($customData);
268        $code .= $this->getTrackPageViewCode($customData);
269        $code .= $this->getClosingTrackingCode();
270
271        return $code;
272    }
273
274    /**
275     * Get Search Results if on a Results Page
276     *
277     * @return ?Results Search results or null if not on a search page
278     */
279    protected function getSearchResults(): ?Results
280    {
281        $viewModel = $this->getView()->plugin('view_model');
282        $current = $viewModel->getCurrent();
283        if (null === $current || 'layout/lightbox' === $current->getTemplate()) {
284            return null;
285        }
286        $children = $current->getChildren();
287        if (isset($children[0])) {
288            $template = $children[0]->getTemplate();
289            if (!strstr($template, '/home') && !strstr($template, 'facet-list')) {
290                $results = $children[0]->getVariable('results');
291                if ($results instanceof Results) {
292                    return $results;
293                }
294            }
295        }
296        return null;
297    }
298
299    /**
300     * Get Combined Search Results if on a Results Page
301     *
302     * @return ?array Array of search results or null if not on a combined search
303     * page
304     */
305    protected function getCombinedSearchResults(): ?array
306    {
307        $viewModel = $this->getView()->plugin('view_model');
308        $current = $viewModel->getCurrent();
309        if (null === $current) {
310            return null;
311        }
312        $children = $current->getChildren();
313        if (isset($children[0])) {
314            $results = $children[0]->getVariable('combinedResults');
315            if (is_array($results)) {
316                return $results;
317            }
318        }
319        return null;
320    }
321
322    /**
323     * Get Record Driver if on a Record Page
324     *
325     * @return ?RecordDriverBase Record driver or null if not on a record page
326     */
327    protected function getRecordDriver(): ?RecordDriverBase
328    {
329        $view = $this->getView();
330        $viewModel = $view->plugin('view_model');
331        $current = $viewModel->getCurrent();
332        if (null === $current) {
333            $driver = $view->vars('driver');
334            if ($driver instanceof RecordDriverBase) {
335                return $driver;
336            }
337            return null;
338        }
339        $children = $current->getChildren();
340        if (isset($children[0])) {
341            $driver = $children[0]->getVariable('driver');
342            if ($driver instanceof RecordDriverBase) {
343                return $driver;
344            }
345        }
346        return null;
347    }
348
349    /**
350     * Get custom data for search results
351     *
352     * @param Results $results Search results
353     *
354     * @return array Associative array of custom data
355     */
356    protected function getSearchCustomData(Results $results): array
357    {
358        $facets = [];
359        $facetTypes = [];
360        $params = $results->getParams();
361        foreach ($params->getFilterList() as $filterType => $filters) {
362            $facetTypes[] = $filterType;
363            foreach ($filters as $filter) {
364                $facets[] = $filter['field'] . '|' . $filter['value'];
365            }
366        }
367        $facets = implode("\t", $facets);
368        $facetTypes = implode("\t", $facetTypes);
369
370        return [
371            'Facets' => $facets,
372            'FacetTypes' => $facetTypes,
373            'SearchType' => $params->getSearchType(),
374            'SearchBackend' => $params->getSearchClassId(),
375            'Sort' => $params->getSort(),
376            'Page' => $params->getPage(),
377            'Limit' => $params->getLimit(),
378            'View' => $params->getView(),
379            'Context' => $this->context ?: 'page',
380        ];
381    }
382
383    /**
384     * Get custom data for record page
385     *
386     * @param RecordDriverBase $recordDriver Record driver
387     *
388     * @return array Associative array of custom data
389     */
390    protected function getRecordPageCustomData(RecordDriverBase $recordDriver): array
391    {
392        $id = $recordDriver->getUniqueID();
393        $formats = $recordDriver->tryMethod('getFormats');
394        if (is_array($formats)) {
395            $formats = implode(',', $formats);
396        }
397        $formats = $formats;
398        $author = $recordDriver->tryMethod('getPrimaryAuthor');
399        if (empty($author)) {
400            $author = '-';
401        }
402        // Use breadcrumb for title since it's guaranteed to return something
403        $title = $recordDriver->tryMethod('getBreadcrumb');
404        if (empty($title)) {
405            $title = '-';
406        }
407        $institutions = $recordDriver->tryMethod('getInstitutions');
408        if (is_array($institutions)) {
409            $institutions = implode(',', $institutions);
410        }
411        $institutions = $institutions;
412
413        return [
414            'Context' => $this->context ?: 'page',
415            'RecordFormat' => $formats,
416            'RecordData' => "$id|$author|$title",
417            'RecordInstitution' => $institutions,
418        ];
419    }
420
421    /**
422     * Get custom data for lightbox actions
423     *
424     * @return array Associative array of custom data
425     */
426    protected function getLightboxCustomData(): array
427    {
428        return [
429            'Context' => $this->context ?: 'page',
430        ];
431    }
432
433    /**
434     * Get custom data for a generic page view
435     *
436     * @return array Associative array of custom data
437     */
438    protected function getGenericCustomData(): array
439    {
440        return [
441            'Context' => $this->context ?: 'page',
442        ];
443    }
444
445    /**
446     * Get the Initialization Part of the Tracking Code
447     *
448     * @return string JavaScript Code Fragment
449     */
450    protected function getOpeningTrackingCode(): string
451    {
452        $escape = $this->getView()->plugin('escapejs');
453        $cookieConsent = $this->getView()->plugin('cookieConsent');
454        $pageUrl = $escape($this->getPageUrl());
455        $code = <<<EOT
456            var _paq = window._paq = window._paq || [];
457            _paq.push(['enableLinkTracking']);
458            _paq.push(['setCustomUrl', '$pageUrl']);
459
460            EOT;
461        if ($this->disableCookies) {
462            $code .= "_paq.push(['disableCookies']);\n";
463        } elseif ($cookieConsent->isEnabled()) {
464            $code .= "_paq.push(['requireCookieConsent']);\n";
465        }
466
467        return $code;
468    }
469
470    /**
471     * Get the Finalization Part of the Tracking Code
472     *
473     * @return string JavaScript Code Fragment
474     */
475    protected function getClosingTrackingCode(): string
476    {
477        $escape = $this->getView()->plugin('escapejs');
478        $trackerUrl = $escape($this->getTrackerUrl());
479        $url = $escape($this->getTrackerJsUrl());
480        return <<<EOT
481            (function() {
482              var d=document;
483              if (!d.getElementById('_matomo_js_script')) {
484                _paq.push(['setTrackerUrl', '$trackerUrl']);
485                _paq.push(['setSiteId', {$this->siteId}]);
486                var g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
487                g.async=true; g.src='$url';
488                g.id = '_matomo_js_script';
489                s.parentNode.insertBefore(g,s);
490              }
491            })();
492
493            EOT;
494    }
495
496    /**
497     * Get the URL for the current page
498     *
499     * @return string
500     */
501    protected function getPageUrl(): string
502    {
503        $path = $this->request->getUri()->toString();
504        // Replace 'AjaxTab' with tab name in record page URLs:
505        $routeMatch = $this->router->match($this->request);
506        if (
507            $routeMatch
508            && ($tab = $this->request->getPost('tab'))
509            && str_ends_with($routeMatch->getMatchedRouteName(), '-ajaxtab')
510            && null !== ($pos = strrpos($path, '/AjaxTab'))
511        ) {
512            $path = substr_replace($path, $tab, $pos + 1, 7);
513        }
514        return $path;
515    }
516
517    /**
518     * Convert a custom data array to JavaScript code
519     *
520     * @param array $customData Custom data
521     *
522     * @return string JavaScript Code Fragment
523     */
524    protected function getCustomVarsCode(array $customData): string
525    {
526        // Don't output custom vars if disabled:
527        if (!$this->customVars) {
528            return '';
529        }
530
531        $escape = $this->getView()->plugin('escapejs');
532        $code = <<<EOT
533            _paq.push(['deleteCustomVariables','page']);
534
535            EOT;
536        $i = 0;
537        foreach ($customData as $key => $value) {
538            ++$i;
539            // We're committed to tracking a maximum of 10 custom variables at a
540            // time:
541            if ($i > 10) {
542                break;
543            }
544            $value = $escape($value);
545            $code .= <<<EOT
546                _paq.push(['setCustomVariable',$i,'$key','$value','page']);
547
548                EOT;
549        }
550        return $code;
551    }
552
553    /**
554     * Convert a custom data array to JavaScript dimensions code
555     *
556     * @param array $customData Custom data
557     *
558     * @return string JavaScript Code Fragment
559     */
560    protected function getCustomDimensionsCode(array $customData): string
561    {
562        // Return immediately if custom dimensions are not configured:
563        if (!$this->customDimensions) {
564            return '{}';
565        }
566
567        $dimensions = [];
568        foreach ($customData as $key => $value) {
569            if (!empty($this->customDimensions[$key])) {
570                $dimensionId = 'dimension' . intval($this->customDimensions[$key]);
571                $dimensions[$dimensionId] = $value;
572            }
573        }
574        return json_encode($dimensions);
575    }
576
577    /**
578     * Get Site Search Tracking Code
579     *
580     * @param Results $results    Search results
581     * @param array   $customData Custom data
582     *
583     * @return string JavaScript Code Fragment
584     */
585    protected function getTrackSearchCode(
586        Results $results,
587        array $customData
588    ): string {
589        $escape = $this->getView()->plugin('escapejs');
590        $params = $results->getParams();
591        $searchTerms = $escape($params->getDisplayQuery());
592        $searchType = $escape($params->getSearchType());
593        $resultCount = $results->getResultTotal();
594        $backendId = $results->getOptions()->getSearchClassId();
595        $dimensions = $this->getCustomDimensionsCode($customData);
596
597        // Use trackSiteSearch *instead* of trackPageView in searches
598        return "_paq.push(['trackSiteSearch', '{$this->searchPrefix}$backendId|"
599            . "$searchTerms', '$searchType', $resultCount$dimensions]);\n";
600    }
601
602    /**
603     * Get site search tracking code for combined search
604     *
605     * @param Results $results         Search results
606     * @param array   $combinedResults Combined search results
607     * @param array   $customData      Custom data
608     *
609     * @return string JavaScript Code Fragment
610     */
611    protected function getTrackCombinedSearchCode(
612        Results $results,
613        array $combinedResults,
614        array $customData
615    ): string {
616        $escape = $this->getView()->plugin('escapejs');
617        $params = $results->getParams();
618        $searchTerms = $escape($params->getDisplayQuery());
619        $searchType = $escape($params->getSearchType());
620        $resultCount = 0;
621        foreach ($combinedResults as $currentSearch) {
622            if (!empty($currentSearch['ajax'])) {
623                // Some results fetched via ajax, so report that we don't know the
624                // result count.
625                $resultCount = 'false';
626                break;
627            }
628            $resultCount += $currentSearch['view']->results
629                ->getResultTotal();
630        }
631        $dimensions = $this->getCustomDimensionsCode($customData);
632
633        // Use trackSiteSearch *instead* of trackPageView in searches
634        return "_paq.push(['trackSiteSearch', '{$this->searchPrefix}Combined|"
635            . "$searchTerms', '$searchType', $resultCount$dimensions]);\n";
636    }
637
638    /**
639     * Get Page View Tracking Code
640     *
641     * @param array $customData Custom data
642     *
643     * @return string JavaScript Code Fragment
644     */
645    protected function getTrackPageViewCode(array $customData): string
646    {
647        $titleJs = 'var title = null;';
648        $dimensions = $this->getCustomDimensionsCode($customData);
649        switch ($this->context) {
650            case 'accordion':
651                $translate = $this->getView()->plugin('translate');
652                $escape = $this->getView()->plugin('escapejs');
653                $title = $translate('ajaxview_label_information');
654                if ($driver = $this->getRecordDriver()) {
655                    $title .= ': ' . $driver->getBreadcrumb();
656                }
657                $titleJs = "var title = '" . $escape($title) . "';";
658                break;
659            case 'tabs':
660                $escape = $this->getView()->plugin('escapejs');
661                $headTitle = $this->getView()->plugin('headTitle');
662                if ($title = $headTitle->renderTitle()) {
663                    $title = $escape($title);
664                    $titleJs = "var title = '$title';";
665                } elseif ($driver = $this->getRecordDriver()) {
666                    $title = $escape($driver->getBreadcrumb());
667                    $titleJs = "var title = '$title';";
668                    $titleJs .= <<<EOT
669                        var a = document.querySelector('.record-tabs ul.nav-tabs li.active a');
670                        if (a) { title = a.innerText + (title ? ': ' + title : ''); }
671
672                        EOT;
673                }
674                break;
675            case 'lightbox':
676                $titleJs .= <<<EOT
677                    var h = document.getElementsByClassName('lightbox-header');
678                    if (h[0]) title = h[0].innerText;
679
680                    EOT;
681                break;
682        }
683
684        return <<<EOT
685            $titleJs
686            _paq.push(['trackPageView', title, $dimensions]);
687
688            EOT;
689    }
690
691    /**
692     * Get Matomo tracker URL
693     *
694     * @return string
695     */
696    protected function getTrackerUrl(): string
697    {
698        return $this->url . 'matomo.php';
699    }
700
701    /**
702     * Get Matomo tracker JS URL
703     *
704     * @return string
705     */
706    protected function getTrackerJsUrl(): string
707    {
708        return $this->url . 'matomo.js';
709    }
710
711    /**
712     * Get name of JS init function
713     *
714     * @return string
715     */
716    protected function getInitFunctionName(): string
717    {
718        return 'initMatomoTracker' . $this->timestamp;
719    }
720}