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\DataTable; 10 11use Exception; 12use Piwik\Columns\Dimension; 13use Piwik\Common; 14use Piwik\DataTable; 15use Piwik\Metrics; 16use Piwik\Piwik; 17use Piwik\BaseFactory; 18 19/** 20 * A DataTable Renderer can produce an output given a DataTable object. 21 * All new Renderers must be copied in DataTable/Renderer and added to the factory() method. 22 * To use a renderer, simply do: 23 * $render = new Xml(); 24 * $render->setTable($dataTable); 25 * echo $render; 26 */ 27abstract class Renderer extends BaseFactory 28{ 29 protected $table; 30 31 /** 32 * @var Exception 33 */ 34 protected $exception; 35 protected $renderSubTables = false; 36 protected $hideIdSubDatatable = false; 37 38 /** 39 * Whether to translate column names (i.e. metric names) or not 40 * @var bool 41 */ 42 public $translateColumnNames = false; 43 44 /** 45 * Column translations 46 * @var array 47 */ 48 private $columnTranslations = false; 49 50 /** 51 * The API method that has returned the data that should be rendered 52 * @var string 53 */ 54 public $apiMethod = false; 55 56 /** 57 * API metadata for the current report 58 * @var array 59 */ 60 private $apiMetaData = null; 61 62 /** 63 * The current idSite 64 * @var int 65 */ 66 public $idSite = 'all'; 67 68 public function __construct() 69 { 70 } 71 72 /** 73 * Sets whether to render subtables or not 74 * 75 * @param bool $enableRenderSubTable 76 */ 77 public function setRenderSubTables($enableRenderSubTable) 78 { 79 $this->renderSubTables = (bool)$enableRenderSubTable; 80 } 81 82 /** 83 * @param bool $bool 84 */ 85 public function setHideIdSubDatableFromResponse($bool) 86 { 87 $this->hideIdSubDatatable = (bool)$bool; 88 } 89 90 /** 91 * Returns whether to render subtables or not 92 * 93 * @return bool 94 */ 95 protected function isRenderSubtables() 96 { 97 return $this->renderSubTables; 98 } 99 100 /** 101 * Output HTTP Content-Type header 102 */ 103 protected function renderHeader() 104 { 105 Common::sendHeader('Content-Type: text/plain; charset=utf-8'); 106 } 107 108 /** 109 * Computes the dataTable output and returns the string/binary 110 * 111 * @return mixed 112 */ 113 abstract public function render(); 114 115 /** 116 * @see render() 117 * @return string 118 */ 119 public function __toString() 120 { 121 return $this->render(); 122 } 123 124 /** 125 * Set the DataTable to be rendered 126 * 127 * @param DataTableInterface $table table to be rendered 128 * @throws Exception 129 */ 130 public function setTable($table) 131 { 132 if (!is_array($table) 133 && !($table instanceof DataTableInterface) 134 ) { 135 throw new Exception("DataTable renderers renderer accepts only DataTable, Simple and Map instances, and arrays."); 136 } 137 $this->table = $table; 138 } 139 140 /** 141 * @var array 142 */ 143 protected static $availableRenderers = array('xml', 144 'json', 145 'csv', 146 'tsv', 147 'html' 148 ); 149 150 /** 151 * Returns available renderers 152 * 153 * @return array 154 */ 155 public static function getRenderers() 156 { 157 return self::$availableRenderers; 158 } 159 160 protected static function getClassNameFromClassId($id) 161 { 162 $className = ucfirst(strtolower($id)); 163 $className = 'Piwik\DataTable\Renderer\\' . $className; 164 165 return $className; 166 } 167 168 protected static function getInvalidClassIdExceptionMessage($id) 169 { 170 $availableRenderers = implode(', ', self::getRenderers()); 171 $klassName = self::getClassNameFromClassId($id); 172 173 return Piwik::translate('General_ExceptionInvalidRendererFormat', array($klassName, $availableRenderers)); 174 } 175 176 /** 177 * Format a value to xml 178 * 179 * @param string|number|bool $value value to format 180 * @return int|string 181 */ 182 public static function formatValueXml($value) 183 { 184 if (is_string($value) 185 && !is_numeric($value) 186 ) { 187 $value = html_entity_decode($value, ENT_QUOTES, 'UTF-8'); 188 // make sure non-UTF-8 chars don't cause htmlspecialchars to choke 189 if (function_exists('mb_convert_encoding')) { 190 $value = @mb_convert_encoding($value, 'UTF-8', 'UTF-8'); 191 } 192 $value = htmlspecialchars($value, ENT_COMPAT, 'UTF-8'); 193 194 $htmlentities = array(" ", "¡", "¢", "£", "¤", "¥", "¦", "§", "¨", "©", "ª", "«", "¬", "­", "®", "¯", "°", "±", "²", "³", "´", "µ", "¶", "·", "¸", "¹", "º", "»", "¼", "½", "¾", "¿", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "Ð", "Ñ", "Ò", "Ó", "Ô", "Õ", "Ö", "×", "Ø", "Ù", "Ú", "Û", "Ü", "Ý", "Þ", "ß", "à", "á", "â", "ã", "ä", "å", "æ", "ç", "è", "é", "ê", "ë", "ì", "í", "î", "ï", "ð", "ñ", "ò", "ó", "ô", "õ", "ö", "÷", "ø", "ù", "ú", "û", "ü", "ý", "þ", "ÿ", "€"); 195 $xmlentities = array("¢", "£", "¤", "¥", "¦", "§", "¨", "©", "ª", "«", "¬", "­", "®", "¯", "°", "±", "²", "³", "´", "µ", "¶", "·", "¸", "¹", "º", "»", "¼", "½", "¾", "¿", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "Ð", "Ñ", "Ò", "Ó", "Ô", "Õ", "Ö", "×", "Ø", "Ù", "Ú", "Û", "Ü", "Ý", "Þ", "ß", "à", "á", "â", "ã", "ä", "å", "æ", "ç", "è", "é", "ê", "ë", "ì", "í", "î", "ï", "ð", "ñ", "ò", "ó", "ô", "õ", "ö", "÷", "ø", "ù", "ú", "û", "ü", "ý", "þ", "ÿ", "€"); 196 $value = str_replace($htmlentities, $xmlentities, $value); 197 } elseif ($value === false) { 198 $value = 0; 199 } 200 201 return $value; 202 } 203 204 /** 205 * Translate column names to the current language. 206 * Used in subclasses. 207 * 208 * @param array $names 209 * @return array 210 */ 211 protected function translateColumnNames($names) 212 { 213 if (!$this->apiMethod) { 214 return $names; 215 } 216 217 // load the translations only once 218 // when multiple dates are requested (date=...,...&period=day), the meta data would 219 // be loaded lots of times otherwise 220 if ($this->columnTranslations === false) { 221 $meta = $this->getApiMetaData(); 222 if ($meta === false) { 223 return $names; 224 } 225 226 $t = Metrics::getDefaultMetricTranslations(); 227 foreach (array('metrics', 'processedMetrics', 'metricsGoal', 'processedMetricsGoal') as $index) { 228 if (isset($meta[$index]) && is_array($meta[$index])) { 229 $t = array_merge($t, $meta[$index]); 230 } 231 } 232 233 foreach (Dimension::getAllDimensions() as $dimension) { 234 $dimensionId = str_replace('.', '_', $dimension->getId()); 235 $dimensionName = $dimension->getName(); 236 237 if (!empty($dimensionId) && !empty($dimensionName)) { 238 $t[$dimensionId] = $dimensionName; 239 } 240 } 241 242 $this->columnTranslations = & $t; 243 } 244 245 foreach ($names as &$name) { 246 if (isset($this->columnTranslations[$name])) { 247 $name = $this->columnTranslations[$name]; 248 } 249 } 250 251 return $names; 252 } 253 254 /** 255 * @return array|null 256 */ 257 protected function getApiMetaData() 258 { 259 if ($this->apiMetaData === null) { 260 list($apiModule, $apiAction) = explode('.', $this->apiMethod); 261 262 if (!$apiModule || !$apiAction) { 263 $this->apiMetaData = false; 264 } 265 266 $api = \Piwik\Plugins\API\API::getInstance(); 267 $meta = $api->getMetadata($this->idSite, $apiModule, $apiAction); 268 if (isset($meta[0]) && is_array($meta[0])) { 269 $meta = $meta[0]; 270 } 271 272 $this->apiMetaData = & $meta; 273 } 274 275 return $this->apiMetaData; 276 } 277 278 /** 279 * Translates the given column name 280 * 281 * @param string $column 282 * @return mixed 283 */ 284 protected function translateColumnName($column) 285 { 286 $columns = array($column); 287 $columns = $this->translateColumnNames($columns); 288 return $columns[0]; 289 } 290 291 /** 292 * Enables column translating 293 * 294 * @param bool $bool 295 */ 296 public function setTranslateColumnNames($bool) 297 { 298 $this->translateColumnNames = $bool; 299 } 300 301 /** 302 * Sets the api method 303 * 304 * @param $method 305 */ 306 public function setApiMethod($method) 307 { 308 $this->apiMethod = $method; 309 } 310 311 /** 312 * Sets the site id 313 * 314 * @param int $idSite 315 */ 316 public function setIdSite($idSite) 317 { 318 $this->idSite = $idSite; 319 } 320 321 /** 322 * Returns true if an array should be wrapped before rendering. This is used to 323 * mimic quirks in the old rendering logic (for backwards compatibility). The 324 * specific meaning of 'wrap' is left up to the Renderer. For XML, this means a 325 * new <row> node. For JSON, this means wrapping in an array. 326 * 327 * In the old code, arrays were added to new DataTable instances, and then rendered. 328 * This transformation wrapped associative arrays except under certain circumstances, 329 * including: 330 * - single element (ie, array('nb_visits' => 0)) (not wrapped for some renderers) 331 * - empty array (ie, array()) 332 * - array w/ arrays/DataTable instances as values (ie, 333 * array('name' => 'myreport', 334 * 'reportData' => new DataTable()) 335 * OR array('name' => 'myreport', 336 * 'reportData' => array(...)) ) 337 * 338 * @param array $array 339 * @param bool $wrapSingleValues Whether to wrap array('key' => 'value') arrays. Some 340 * renderers wrap them and some don't. 341 * @param bool|null $isAssociativeArray Whether the array is associative or not. 342 * If null, it is determined. 343 * @return bool 344 */ 345 protected static function shouldWrapArrayBeforeRendering( 346 $array, $wrapSingleValues = true, $isAssociativeArray = null) 347 { 348 if (empty($array)) { 349 return false; 350 } 351 352 if ($isAssociativeArray === null) { 353 $isAssociativeArray = Piwik::isAssociativeArray($array); 354 } 355 356 $wrap = true; 357 if ($isAssociativeArray) { 358 // we don't wrap if the array has one element that is a value 359 $firstValue = reset($array); 360 if (!$wrapSingleValues 361 && count($array) === 1 362 && (!is_array($firstValue) 363 && !is_object($firstValue)) 364 ) { 365 $wrap = false; 366 } else { 367 foreach ($array as $value) { 368 if (is_array($value) 369 || is_object($value) 370 ) { 371 $wrap = false; 372 break; 373 } 374 } 375 } 376 } else { 377 $wrap = false; 378 } 379 380 return $wrap; 381 } 382 383 /** 384 * Produces a flat php array from the DataTable, putting "columns" and "metadata" on the same level. 385 * 386 * For example, when a originalRender() would be 387 * array( 'columns' => array( 'col1_name' => value1, 'col2_name' => value2 ), 388 * 'metadata' => array( 'metadata1_name' => value_metadata) ) 389 * 390 * a flatRender() is 391 * array( 'col1_name' => value1, 392 * 'col2_name' => value2, 393 * 'metadata1_name' => value_metadata ) 394 * 395 * @param null|DataTable|DataTable\Map|Simple $dataTable 396 * @return array Php array representing the 'flat' version of the datatable 397 */ 398 protected function convertDataTableToArray($dataTable = null) 399 { 400 if (is_null($dataTable)) { 401 $dataTable = $this->table; 402 } 403 404 if (is_array($dataTable)) { 405 $flatArray = $dataTable; 406 if (self::shouldWrapArrayBeforeRendering($flatArray)) { 407 $flatArray = array($flatArray); 408 } 409 } elseif ($dataTable instanceof DataTable\Map) { 410 $flatArray = array(); 411 foreach ($dataTable->getDataTables() as $keyName => $table) { 412 $flatArray[$keyName] = $this->convertDataTableToArray($table); 413 } 414 } elseif ($dataTable instanceof Simple) { 415 $flatArray = $this->convertSimpleTable($dataTable); 416 417 reset($flatArray); 418 $firstKey = key($flatArray); 419 420 // if we return only one numeric value then we print out the result in a simple <result> tag 421 // keep it simple! 422 if (count($flatArray) == 1 423 && $firstKey !== DataTable\Row::COMPARISONS_METADATA_NAME 424 ) { 425 $flatArray = current($flatArray); 426 } 427 } // A normal DataTable needs to be handled specifically 428 else { 429 $array = $this->convertTable($dataTable); 430 $flatArray = $this->flattenArray($array); 431 } 432 433 return $flatArray; 434 } 435 436 /** 437 * Converts the given data table to an array 438 * 439 * @param DataTable $table 440 * @return array 441 */ 442 protected function convertTable($table) 443 { 444 $array = []; 445 446 foreach ($table->getRows() as $id => $row) { 447 $newRow = array( 448 'columns' => $row->getColumns(), 449 'metadata' => $row->getMetadata(), 450 'idsubdatatable' => $row->getIdSubDataTable(), 451 ); 452 453 if ($id == DataTable::ID_SUMMARY_ROW) { 454 $newRow['issummaryrow'] = true; 455 } 456 457 if (isset($newRow['metadata'][DataTable\Row::COMPARISONS_METADATA_NAME])) { 458 $newRow['metadata'][DataTable\Row::COMPARISONS_METADATA_NAME] = $row->getComparisons(); 459 } 460 461 $subTable = $row->getSubtable(); 462 if ($this->isRenderSubtables() 463 && $subTable 464 ) { 465 $subTable = $this->convertTable($subTable); 466 $newRow['subtable'] = $subTable; 467 if ($this->hideIdSubDatatable === false 468 && isset($newRow['metadata']['idsubdatatable_in_db']) 469 ) { 470 $newRow['columns']['idsubdatatable'] = $newRow['metadata']['idsubdatatable_in_db']; 471 } 472 unset($newRow['metadata']['idsubdatatable_in_db']); 473 } 474 if ($this->hideIdSubDatatable !== false) { 475 unset($newRow['idsubdatatable']); 476 } 477 478 $array[] = $newRow; 479 } 480 return $array; 481 } 482 483 /** 484 * Converts the simple data table to an array 485 * 486 * @param Simple $table 487 * @return array 488 */ 489 protected function convertSimpleTable($table) 490 { 491 $array = []; 492 493 $row = $table->getFirstRow(); 494 if ($row === false) { 495 return $array; 496 } 497 foreach ($row->getColumns() as $columnName => $columnValue) { 498 $array[$columnName] = $columnValue; 499 } 500 501 $comparisons = $row->getComparisons(); 502 if (!empty($comparisons)) { 503 $array[DataTable\Row::COMPARISONS_METADATA_NAME] = $comparisons; 504 } 505 506 return $array; 507 } 508 509 /** 510 * 511 * @param array $array 512 * @return array 513 */ 514 protected function flattenArray($array) 515 { 516 $flatArray = []; 517 foreach ($array as $row) { 518 $newRow = $row['columns'] + $row['metadata']; 519 if (isset($row['idsubdatatable']) 520 && $this->hideIdSubDatatable === false 521 ) { 522 $newRow += array('idsubdatatable' => $row['idsubdatatable']); 523 } 524 if (isset($row['subtable'])) { 525 $newRow += array('subtable' => $this->flattenArray($row['subtable'])); 526 } 527 $flatArray[] = $newRow; 528 } 529 return $flatArray; 530 } 531} 532