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