1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik\Plugins\Actions;
10
11use PDOStatement;
12use Piwik\Config;
13use Piwik\DataTable\Row\DataTableSummaryRow;
14use Piwik\DataTable;
15use Piwik\DataTable\Row;
16use Piwik\Metrics as PiwikMetrics;
17use Piwik\Piwik;
18use Piwik\RankingQuery;
19use Piwik\Tracker\Action;
20use Piwik\Tracker\PageUrl;
21use Zend_Db_Statement;
22
23/**
24 * This static class provides:
25 * - logic to parse/cleanup Action names,
26 * - logic to efficiently process aggregate the array data during Archiving
27 *
28 */
29class ArchivingHelper
30{
31    const OTHERS_ROW_KEY = '';
32
33    /**
34     * Ideally this should use the DataArray object instead of custom data structure
35     *
36     * @param Zend_Db_Statement|PDOStatement $query
37     * @param string|bool $fieldQueried
38     * @param array $actionsTablesByType
39     * @return int
40     */
41    public static function updateActionsTableWithRowQuery($query, $fieldQueried, & $actionsTablesByType, $metricsConfig)
42    {
43        $rowsProcessed = 0;
44        while ($row = $query->fetch()) {
45            if (empty($row['idaction'])) {
46                $row['type'] = ($fieldQueried == 'idaction_url' ? Action::TYPE_PAGE_URL : Action::TYPE_PAGE_TITLE);
47                // This will be replaced with 'X not defined' later
48                $row['name'] = '';
49                // Yes, this is kind of a hack, so we don't mix 'page url not defined' with 'page title not defined' etc.
50                $row['idaction'] = -$row['type'];
51            }
52
53            if ($row['type'] != Action::TYPE_SITE_SEARCH) {
54                unset($row[PiwikMetrics::INDEX_SITE_SEARCH_HAS_NO_RESULT]);
55            }
56
57            if (in_array($row['type'], array(Action::TYPE_CONTENT, Action::TYPE_EVENT, Action::TYPE_EVENT_NAME, Action::TYPE_CONTENT_PIECE, Action::TYPE_CONTENT_TARGET))) {
58                continue;
59            }
60
61            $hasRowName = !empty($row['name']) && $row['name'] != RankingQuery::LABEL_SUMMARY_ROW;
62
63            // This will appear as <url /> in the API, which is actually very important to keep
64            // eg. When there's at least one row in a report that does not have a URL, not having this <url/> would break HTML/PDF reports.
65            $url = '';
66            $pageTitlePath = null;
67            if ($row['type'] == Action::TYPE_SITE_SEARCH
68                || $row['type'] == Action::TYPE_PAGE_TITLE
69            ) {
70                $url = null;
71                if ($hasRowName) {
72                    $pageTitlePath = $row['name'];
73                }
74            } elseif ($hasRowName) {
75                $url = PageUrl::reconstructNormalizedUrl((string)$row['name'], $row['url_prefix']);
76            }
77
78            if (isset($row['name'])
79                && isset($row['type'])
80            ) {
81                $actionName = $row['name'];
82                $actionType = $row['type'];
83                $urlPrefix = $row['url_prefix'];
84                $idaction = $row['idaction'];
85
86                // in some unknown case, the type field is NULL, as reported in #1082 - we ignore this page view
87                if (empty($actionType)) {
88                    if ($idaction != DataTable::LABEL_SUMMARY_ROW) {
89                        self::setCachedActionRow($idaction, $actionType, false);
90                    }
91                    continue;
92                }
93
94                $actionRow = self::getActionRow($actionName, $actionType, $urlPrefix, $actionsTablesByType);
95
96                self::setCachedActionRow($idaction, $actionType, $actionRow);
97            } else {
98                $actionRow = self::getCachedActionRow($row['idaction'], $row['type']);
99
100                // Action processed as "to skip" for some reasons
101                if ($actionRow === false) {
102                    continue;
103                }
104            }
105
106            if (is_null($actionRow)) {
107                continue;
108            }
109
110            // Here we do ensure that, the Metadata URL set for a given row, is the one from the Pageview with the most hits.
111            // This is to ensure that when, different URLs are loaded with the same page name.
112            // For example http://piwik.org and http://id.piwik.org are reported in Piwik > Actions > Pages with /index
113            // But, we must make sure http://piwik.org is used to link & for transitions
114            // Note: this code is partly duplicated from Row->sumRowMetadata()
115            if (!is_null($url)
116                && !$actionRow->isSummaryRow()
117            ) {
118                if (($existingUrl = $actionRow->getMetadata('url')) !== false) {
119                    if (!empty($row[PiwikMetrics::INDEX_PAGE_NB_HITS])
120                        && $row[PiwikMetrics::INDEX_PAGE_NB_HITS] > $actionRow->maxVisitsSummed
121                    ) {
122                        $actionRow->setMetadata('url', $url);
123                        $actionRow->maxVisitsSummed = $row[PiwikMetrics::INDEX_PAGE_NB_HITS];
124                    }
125                } else {
126                    $actionRow->setMetadata('url', $url);
127                    $actionRow->maxVisitsSummed = !empty($row[PiwikMetrics::INDEX_PAGE_NB_HITS]) ? $row[PiwikMetrics::INDEX_PAGE_NB_HITS] : 0;
128                }
129            }
130
131            if ($pageTitlePath !== null
132                && !$actionRow->isSummaryRow()
133            ) {
134                $actionRow->setMetadata('page_title_path', $pageTitlePath);
135            }
136
137            if ($row['type'] != Action::TYPE_PAGE_URL
138                && $row['type'] != Action::TYPE_PAGE_TITLE
139            ) {
140                // only keep performance metrics when they're used (i.e. for URLs and page titles)
141                if (array_key_exists(PiwikMetrics::INDEX_PAGE_SUM_TIME_GENERATION, $row)) {
142                    unset($row[PiwikMetrics::INDEX_PAGE_SUM_TIME_GENERATION]);
143                }
144                if (array_key_exists(PiwikMetrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION, $row)) {
145                    unset($row[PiwikMetrics::INDEX_PAGE_NB_HITS_WITH_TIME_GENERATION]);
146                }
147                if (array_key_exists(PiwikMetrics::INDEX_PAGE_MIN_TIME_GENERATION, $row)) {
148                    unset($row[PiwikMetrics::INDEX_PAGE_MIN_TIME_GENERATION]);
149                }
150                if (array_key_exists(PiwikMetrics::INDEX_PAGE_MAX_TIME_GENERATION, $row)) {
151                    unset($row[PiwikMetrics::INDEX_PAGE_MAX_TIME_GENERATION]);
152                }
153            }
154
155            unset($row['name']);
156            unset($row['type']);
157            unset($row['idaction']);
158            unset($row['url_prefix']);
159
160            foreach ($row as $name => $value) {
161                // in some edge cases, we have twice the same action name with 2 different idaction
162                // - this happens when 2 visitors visit the same new page at the same time, and 2 actions get recorded for the same name
163                // - this could also happen when 2 URLs end up having the same label (eg. 2 subdomains get aggregated to the "/index" page name)
164                if (($alreadyValue = $actionRow->getColumn($name)) !== false) {
165                    $newValue = self::getColumnValuesMerged($name, $alreadyValue, $value, $metricsConfig);
166                    $actionRow->setColumn($name, $newValue);
167                } else {
168                    $actionRow->addColumn($name, $value);
169                }
170            }
171
172            // if the exit_action was not recorded properly in the log_link_visit_action
173            // there would be an error message when getting the nb_hits column
174            // we must fake the record and add the columns
175            if ($actionRow->getColumn(PiwikMetrics::INDEX_PAGE_NB_HITS) === false) {
176                // to test this code: delete the entries in log_link_action_visit for
177                //  a given exit_idaction_url
178                foreach (self::getDefaultRow()->getColumns() as $name => $value) {
179                    $actionRow->addColumn($name, $value);
180                }
181            }
182            $rowsProcessed++;
183        }
184
185        // just to make sure php copies the last $actionRow in the $parentTable array
186        $actionRow =& $actionsTablesByType;
187        return $rowsProcessed;
188    }
189
190    public static function removeEmptyColumns($dataTable)
191    {
192        // Delete all columns that have a value of zero
193        $dataTable->filter('ColumnDelete', array(
194                                                $columnsToRemove = array(PiwikMetrics::INDEX_PAGE_IS_FOLLOWING_SITE_SEARCH_NB_HITS),
195                                                $columnsToKeep = array(),
196                                                $deleteIfZeroOnly = true
197                                           ));
198    }
199
200    /**
201     * For rows which have subtables (eg. directories with sub pages),
202     * deletes columns which don't make sense when all values of sub pages are summed.
203     *
204     * @param $dataTable DataTable
205     */
206    public static function deleteInvalidSummedColumnsFromDataTable($dataTable)
207    {
208        foreach ($dataTable->getRows() as $id => $row) {
209            if (($idSubtable = $row->getIdSubDataTable()) !== null
210                || $id === DataTable::ID_SUMMARY_ROW
211            ) {
212                $subTable = $row->getSubtable();
213                if ($subTable) {
214                    self::deleteInvalidSummedColumnsFromDataTable($subTable);
215                }
216
217                if ($row instanceof DataTableSummaryRow) {
218                    $row->recalculate();
219                }
220
221                foreach (Metrics::$columnsToDeleteAfterAggregation as $name) {
222                    $row->deleteColumn($name);
223                }
224            }
225        }
226
227        // And this as well
228        ArchivingHelper::removeEmptyColumns($dataTable);
229    }
230
231    /**
232     * Returns the limit to use with RankingQuery for this plugin.
233     *
234     * @return int
235     */
236    public static function getRankingQueryLimit()
237    {
238        $configGeneral = Config::getInstance()->General;
239        $configLimit = $configGeneral['archiving_ranking_query_row_limit'];
240        $limit = $configLimit == 0 ? 0 : max(
241            $configLimit,
242            $configGeneral['datatable_archiving_maximum_rows_actions'],
243            $configGeneral['datatable_archiving_maximum_rows_subtable_actions']
244        );
245
246        // FIXME: This is a quick fix for #3482. The actual cause of the bug is that
247        // the site search & performance metrics additions to
248        // ArchivingHelper::updateActionsTableWithRowQuery expect every
249        // row to have 'type' data, but not all of the SQL queries that are run w/o
250        // ranking query join on the log_action table and thus do not select the
251        // log_action.type column.
252        //
253        // NOTES: Archiving logic can be generalized as follows:
254        // 0) Do SQL query over log_link_visit_action & join on log_action to select
255        //    some metrics (like visits, hits, etc.)
256        // 1) For each row, cache the action row & metrics. (This is done by
257        //    updateActionsTableWithRowQuery for result set rows that have
258        //    name & type columns.)
259        // 2) Do other SQL queries for metrics we can't put in the first query (like
260        //    entry visits, exit vists, etc.) w/o joining log_action.
261        // 3) For each row, find the cached row by idaction & add the new metrics to
262        //    it. (This is done by updateActionsTableWithRowQuery for result set rows
263        //    that DO NOT have name & type columns.)
264        //
265        // The site search & performance metrics additions expect a 'type' all the time
266        // which breaks the original pre-rankingquery logic. Ranking query requires a
267        // join, so the bug is only seen when ranking query is disabled.
268        if ($limit === 0) {
269            $limit = 100000;
270        }
271        return $limit;
272
273    }
274
275    /**
276     * @param $columnName
277     * @param $alreadyValue
278     * @param $value
279     * @return mixed
280     */
281    private static function getColumnValuesMerged($columnName, $alreadyValue, $value, $metricsConfig)
282    {
283        if (array_key_exists($columnName, $metricsConfig)) {
284            $config = $metricsConfig[$columnName];
285
286            if (!empty($config['aggregation'])) {
287
288                if ($config['aggregation'] == 'min') {
289                    if (empty($alreadyValue)) {
290                        $newValue = $value;
291                    } else if (empty($value)) {
292                        $newValue = $alreadyValue;
293                    } else {
294                        $newValue = min($alreadyValue, $value);
295                    }
296                    return $newValue;
297                }
298                if ($config['aggregation'] == 'max') {
299                    $newValue = max($alreadyValue, $value);
300                    return $newValue;
301                }
302            }
303        }
304
305        $newValue = $alreadyValue + $value;
306        return $newValue;
307    }
308
309    public static $maximumRowsInDataTableLevelZero;
310    public static $maximumRowsInSubDataTable;
311    public static $maximumRowsInDataTableSiteSearch;
312    public static $columnToSortByBeforeTruncation;
313
314    protected static $actionUrlCategoryDelimiter = null;
315    protected static $actionTitleCategoryDelimiter = null;
316    protected static $defaultActionName = null;
317    protected static $defaultActionNameWhenNotDefined = null;
318    protected static $defaultActionUrlWhenNotDefined = null;
319
320    public static function reloadConfig()
321    {
322        // for BC, we read the old style delimiter first (see #1067)
323        $actionDelimiter = @Config::getInstance()->General['action_category_delimiter'];
324        if (empty($actionDelimiter)) {
325            self::$actionUrlCategoryDelimiter = Config::getInstance()->General['action_url_category_delimiter'];
326            self::$actionTitleCategoryDelimiter = Config::getInstance()->General['action_title_category_delimiter'];
327        } else {
328            self::$actionUrlCategoryDelimiter = self::$actionTitleCategoryDelimiter = $actionDelimiter;
329        }
330
331        self::$defaultActionName = Config::getInstance()->General['action_default_name'];
332        self::$columnToSortByBeforeTruncation = PiwikMetrics::INDEX_NB_VISITS;
333        self::$maximumRowsInDataTableLevelZero = Config::getInstance()->General['datatable_archiving_maximum_rows_actions'];
334        self::$maximumRowsInSubDataTable = Config::getInstance()->General['datatable_archiving_maximum_rows_subtable_actions'];
335        self::$maximumRowsInDataTableSiteSearch = Config::getInstance()->General['datatable_archiving_maximum_rows_site_search'];
336
337        DataTable::setMaximumDepthLevelAllowedAtLeast(self::getSubCategoryLevelLimit() + 1);
338    }
339
340    /**
341     * The default row is used when archiving, if data is inconsistent in the DB,
342     * there could be pages that have exit/entry hits, but don't yet
343     * have a record in the table (or the record was truncated).
344     *
345     * @return Row
346     */
347    private static function getDefaultRow()
348    {
349        static $row = false;
350        if ($row === false) {
351            // This row is used in the case where an action is know as an exit_action
352            // but this action was not properly recorded when it was hit in the first place
353            // so we add this fake row information to make sure there is a nb_hits, etc. column for every action
354            $row = new Row(array(
355                                Row::COLUMNS => array(
356                                    PiwikMetrics::INDEX_NB_VISITS        => 1,
357                                    PiwikMetrics::INDEX_NB_UNIQ_VISITORS => 1,
358                                    PiwikMetrics::INDEX_PAGE_NB_HITS     => 1,
359                                )));
360        }
361        return $row;
362    }
363
364    /**
365     * Given a page name and type, builds a recursive datatable where
366     * each level of the tree is a category, based on the page name split by a delimiter (slash / by default)
367     *
368     * @param string $actionName
369     * @param int $actionType
370     * @param int $urlPrefix
371     * @param array $actionsTablesByType
372     * @return DataTable\Row
373     */
374    public static function getActionRow($actionName, $actionType, $urlPrefix, &$actionsTablesByType)
375    {
376        // we work on the root table of the given TYPE (either ACTION_URL or DOWNLOAD or OUTLINK etc.)
377        /* @var DataTable $currentTable */
378        $currentTable =& $actionsTablesByType[$actionType];
379
380        if (is_null($currentTable)) {
381            throw new \Exception("Action table for type '$actionType' was not found during Actions archiving.");
382        }
383
384        // check for ranking query cut-off
385        if ($actionName == RankingQuery::LABEL_SUMMARY_ROW) {
386            $summaryRow = $currentTable->getRowFromId(DataTable::ID_SUMMARY_ROW);
387            if ($summaryRow === false) {
388                $summaryRow = $currentTable->addSummaryRow(self::createSummaryRow());
389            }
390            return $summaryRow;
391        }
392
393        // go to the level of the subcategory
394        $actionExplodedNames = self::getActionExplodedNames($actionName, $actionType, $urlPrefix);
395        list($row, $level) = $currentTable->walkPath(
396            $actionExplodedNames, self::getDefaultRowColumns(), self::$maximumRowsInSubDataTable);
397
398        return $row;
399    }
400
401    /**
402     * Returns the configured sub-category level limit.
403     *
404     * @return int
405     */
406    public static function getSubCategoryLevelLimit()
407    {
408        return Config::getInstance()->General['action_category_level_limit'];
409    }
410
411    /**
412     * Returns default label for the action type
413     *
414     * @param $type
415     * @return string
416     */
417    public static function getUnknownActionName($type)
418    {
419        if (empty(self::$defaultActionNameWhenNotDefined)) {
420            self::$defaultActionNameWhenNotDefined = Piwik::translate('General_NotDefined', Piwik::translate('Actions_ColumnPageName'));
421            self::$defaultActionUrlWhenNotDefined = Piwik::translate('General_NotDefined', Piwik::translate('Actions_ColumnPageURL'));
422        }
423        if ($type == Action::TYPE_PAGE_TITLE) {
424            return self::$defaultActionNameWhenNotDefined;
425        }
426        return self::$defaultActionUrlWhenNotDefined;
427    }
428
429    /**
430     * Explodes action name into an array of elements.
431     *
432     * NOTE: before calling this function make sure ArchivingHelper::reloadConfig(); is called
433     *
434     * for downloads:
435     *  we explode link http://piwik.org/some/path/piwik.zip into an array( 'piwik.org', '/some/path/piwik.zip' );
436     *
437     * for outlinks:
438     *  we explode link http://dev.piwik.org/some/path into an array( 'dev.piwik.org', '/some/path' );
439     *
440     * for action urls:
441     *  we explode link http://piwik.org/some/path into an array( 'some', 'path' );
442     *
443     * for action names:
444     *   we explode name 'Piwik / Category 1 / Category 2' into an array('Matomo', 'Category 1', 'Category 2');
445     *
446     * @param string $name action name
447     * @param int $type action type
448     * @param int $urlPrefix url prefix (only used for TYPE_PAGE_URL)
449     * @return array of exploded elements from $name
450     */
451    public static function getActionExplodedNames($name, $type, $urlPrefix = null)
452    {
453        // Site Search does not split Search keywords
454        if ($type == Action::TYPE_SITE_SEARCH) {
455            return array($name);
456        }
457
458        $name = str_replace("\n", "", $name);
459
460        if ($type == Action::TYPE_PAGE_TITLE && self::$actionTitleCategoryDelimiter === '') {
461            if ($name === '' || $name === false || $name === null || trim($name) === '') {
462                $name = self::getUnknownActionName($type);
463            }
464            return array(' ' . trim($name));
465        }
466
467        $name = self::parseNameFromPageUrl($name, $type, $urlPrefix);
468
469        // outlinks and downloads
470        if (is_array($name)) {
471            return $name;
472        }
473
474        $split = self::splitNameByDelimiter($name, $type);
475
476        if (empty($split)) {
477            $defaultName = self::getUnknownActionName($type);
478            return array(trim($defaultName));
479        }
480
481        $lastPageName = end($split);
482        // we are careful to prefix the page URL / name with some value
483        // so that if a page has the same name as a category
484        // we don't merge both entries
485        if ($type != Action::TYPE_PAGE_TITLE) {
486            $lastPageName = '/' . $lastPageName;
487        } else {
488            $lastPageName = ' ' . $lastPageName;
489        }
490        $split[count($split) - 1] = $lastPageName;
491        return array_values($split);
492    }
493
494    /**
495     * Gets the key for the cache of action rows from an action ID and type.
496     *
497     * @param int $idAction
498     * @param int $actionType
499     * @return string|int
500     */
501    private static function getCachedActionRowKey($idAction, $actionType)
502    {
503        return $idAction == RankingQuery::LABEL_SUMMARY_ROW
504            ? $actionType . '_others'
505            : $idAction;
506    }
507
508    /**
509     * Static cache to store Rows during processing
510     */
511    protected static $cacheParsedAction = array();
512
513    public static function clearActionsCache()
514    {
515        self::$cacheParsedAction = array();
516    }
517
518    /**
519     * Get cached action row by id & type. If $idAction is set to -1, the 'Others' row
520     * for the specific action type will be returned.
521     *
522     * @param int $idAction
523     * @param int $actionType
524     * @return Row|false
525     */
526    private static function getCachedActionRow($idAction, $actionType)
527    {
528        $cacheLabel = self::getCachedActionRowKey($idAction, $actionType);
529
530        if (!isset(self::$cacheParsedAction[$cacheLabel])) {
531            // This can happen when
532            // - We select an entry page ID that was only seen yesterday, so wasn't selected in the first query
533            // - We count time spent on a page, when this page was only seen yesterday
534            return false;
535        }
536
537        return self::$cacheParsedAction[$cacheLabel];
538    }
539
540    /**
541     * Set cached action row for an id & type.
542     *
543     * @param int $idAction
544     * @param int $actionType
545     * @param \Piwik\DataTable\Row
546     */
547    private static function setCachedActionRow($idAction, $actionType, $actionRow)
548    {
549        $cacheLabel = self::getCachedActionRowKey($idAction, $actionType);
550        self::$cacheParsedAction[$cacheLabel] = $actionRow;
551    }
552
553    /**
554     * Returns the default columns for a row in an Actions DataTable.
555     *
556     * @return array
557     */
558    private static function getDefaultRowColumns()
559    {
560        return array(PiwikMetrics::INDEX_NB_VISITS           => 0,
561                     PiwikMetrics::INDEX_NB_UNIQ_VISITORS    => 0,
562                     PiwikMetrics::INDEX_PAGE_NB_HITS        => 0,
563                     PiwikMetrics::INDEX_PAGE_SUM_TIME_SPENT => 0);
564    }
565
566    /**
567     * Creates a summary row for an Actions DataTable.
568     *
569     * @return Row
570     */
571    private static function createSummaryRow()
572    {
573        $summaryRow = new Row(array(
574                            Row::COLUMNS =>
575                                array('label' => DataTable::LABEL_SUMMARY_ROW) + self::getDefaultRowColumns()
576                       ));
577        $summaryRow->setIsSummaryRow(); // this should be set in DataTable::addSummaryRow(), but we set it here as well to be safe
578        return $summaryRow;
579    }
580
581    private static function splitNameByDelimiter($name, $type)
582    {
583        if(is_array($name)) {
584            return $name;
585        }
586        if ($type == Action::TYPE_PAGE_TITLE) {
587            $categoryDelimiter = self::$actionTitleCategoryDelimiter;
588        } else {
589            $categoryDelimiter = self::$actionUrlCategoryDelimiter;
590        }
591
592        if (empty($categoryDelimiter)) {
593            return array(trim($name));
594        }
595
596        $split = explode($categoryDelimiter, $name, self::getSubCategoryLevelLimit());
597
598        // trim every category and remove empty categories
599        $split = array_map('trim', $split);
600        $split = array_filter($split, 'strlen');
601
602        // forces array key to start at 0
603        $split = array_values($split);
604
605        return $split;
606    }
607
608    private static function parseNameFromPageUrl($name, $type, $urlPrefix)
609    {
610        $urlRegexAfterDomain = '([^/]+)[/]?([^#]*)[#]?(.*)';
611        if ($urlPrefix === null) {
612            // match url with protocol (used for outlinks / downloads)
613            $urlRegex = '@^http[s]?://' . $urlRegexAfterDomain . '$@i';
614        } else {
615            // the name is a url that does not contain protocol and www anymore
616            // we know that normalization has been done on db level because $urlPrefix is set
617            $urlRegex = '@^' . $urlRegexAfterDomain . '$@i';
618        }
619
620        $matches = array();
621        preg_match($urlRegex, $name, $matches);
622        if (!count($matches)) {
623            return $name;
624        }
625        $urlHost = $matches[1];
626        $urlPath = $matches[2];
627        $urlFragment = $matches[3];
628
629        if (in_array($type, array(Action::TYPE_DOWNLOAD, Action::TYPE_OUTLINK))) {
630            $path = '/' . trim($urlPath);
631            if (!empty($urlFragment)) {
632                $path .= '#' . $urlFragment;
633            }
634
635            return array(trim($urlHost), $path);
636        }
637
638        $name = $urlPath;
639        if ($name === '' || substr($name, -1) == '/') {
640            $name .= self::$defaultActionName;
641        }
642
643        $urlFragment = PageUrl::processUrlFragment($urlFragment);
644        if (!empty($urlFragment)) {
645            $name .= '#' . $urlFragment;
646        }
647
648        return $name;
649    }
650
651    public static function setFolderPathMetadata(DataTable $dataTable, $isUrl, $prefix = '')
652    {
653        $configGeneral = Config::getInstance()->General;
654        $separator = $isUrl ? '/' : $configGeneral['action_title_category_delimiter'];
655        $metadataName = $isUrl ? 'folder_url_start' : 'page_title_path';
656
657        foreach ($dataTable->getRows() as $row) {
658            $subtable = $row->getSubtable();
659            if (!$subtable) {
660                continue;
661            }
662
663            $metadataValue = $prefix . $row->getColumn('label');
664            $row->setMetadata($metadataName, $metadataValue);
665
666            self::setFolderPathMetadata($subtable, $isUrl, $metadataValue . $separator);
667        }
668    }
669}
670