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("&nbsp;", "&iexcl;", "&cent;", "&pound;", "&curren;", "&yen;", "&brvbar;", "&sect;", "&uml;", "&copy;", "&ordf;", "&laquo;", "&not;", "&shy;", "&reg;", "&macr;", "&deg;", "&plusmn;", "&sup2;", "&sup3;", "&acute;", "&micro;", "&para;", "&middot;", "&cedil;", "&sup1;", "&ordm;", "&raquo;", "&frac14;", "&frac12;", "&frac34;", "&iquest;", "&Agrave;", "&Aacute;", "&Acirc;", "&Atilde;", "&Auml;", "&Aring;", "&AElig;", "&Ccedil;", "&Egrave;", "&Eacute;", "&Ecirc;", "&Euml;", "&Igrave;", "&Iacute;", "&Icirc;", "&Iuml;", "&ETH;", "&Ntilde;", "&Ograve;", "&Oacute;", "&Ocirc;", "&Otilde;", "&Ouml;", "&times;", "&Oslash;", "&Ugrave;", "&Uacute;", "&Ucirc;", "&Uuml;", "&Yacute;", "&THORN;", "&szlig;", "&agrave;", "&aacute;", "&acirc;", "&atilde;", "&auml;", "&aring;", "&aelig;", "&ccedil;", "&egrave;", "&eacute;", "&ecirc;", "&euml;", "&igrave;", "&iacute;", "&icirc;", "&iuml;", "&eth;", "&ntilde;", "&ograve;", "&oacute;", "&ocirc;", "&otilde;", "&ouml;", "&divide;", "&oslash;", "&ugrave;", "&uacute;", "&ucirc;", "&uuml;", "&yacute;", "&thorn;", "&yuml;", "&euro;");
195            $xmlentities  = array("&#162;", "&#163;", "&#164;", "&#165;", "&#166;", "&#167;", "&#168;", "&#169;", "&#170;", "&#171;", "&#172;", "&#173;", "&#174;", "&#175;", "&#176;", "&#177;", "&#178;", "&#179;", "&#180;", "&#181;", "&#182;", "&#183;", "&#184;", "&#185;", "&#186;", "&#187;", "&#188;", "&#189;", "&#190;", "&#191;", "&#192;", "&#193;", "&#194;", "&#195;", "&#196;", "&#197;", "&#198;", "&#199;", "&#200;", "&#201;", "&#202;", "&#203;", "&#204;", "&#205;", "&#206;", "&#207;", "&#208;", "&#209;", "&#210;", "&#211;", "&#212;", "&#213;", "&#214;", "&#215;", "&#216;", "&#217;", "&#218;", "&#219;", "&#220;", "&#221;", "&#222;", "&#223;", "&#224;", "&#225;", "&#226;", "&#227;", "&#228;", "&#229;", "&#230;", "&#231;", "&#232;", "&#233;", "&#234;", "&#235;", "&#236;", "&#237;", "&#238;", "&#239;", "&#240;", "&#241;", "&#242;", "&#243;", "&#244;", "&#245;", "&#246;", "&#247;", "&#248;", "&#249;", "&#250;", "&#251;", "&#252;", "&#253;", "&#254;", "&#255;", "&#8364;");
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