1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5use dokuwiki\Extension\Event;
6
7/**
8 * Creates the table aggregation output
9 *
10 * @package dokuwiki\plugin\struct\meta
11 */
12class AggregationTable extends Aggregation
13{
14    /** @var array for summing up columns */
15    protected $sums;
16
17    public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig)
18    {
19        parent::__construct($id, $mode, $renderer, $searchConfig);
20    }
21
22    /** @inheritdoc */
23    public function render($showNotFound = false)
24    {
25        if (in_array($this->mode, \helper_plugin_struct::BLACKLIST_RENDERER)) return;
26
27        // abort early if there are no results at all (not filtered)
28        if ($this->searchConfig->getCount() <= 0 && !$this->isDynamicallyFiltered() && $showNotFound) {
29            $this->renderer->cdata($this->helper->getLang('none'));
30            return;
31        }
32
33        $this->renderActiveFilters();
34
35        $rendercontext = [
36            'table' => $this,
37            'renderer' => $this->renderer,
38            'format' => $this->mode,
39            'search' => $this->searchConfig,
40            'columns' => $this->columns,
41            'data' => $this->searchConfig->getRows()
42        ];
43
44        $event = new Event(
45            'PLUGIN_STRUCT_RENDER_AGGREGATION_TABLE',
46            $rendercontext
47        );
48        $event->trigger([$this, 'renderTable']);
49
50        // export handle
51        $this->renderExportControls();
52    }
53
54    /**
55     * Render the default aggregation table
56     */
57    public function renderTable($rendercontext)
58    {
59        $this->renderer->table_open();
60
61        // header
62        $this->renderer->tablethead_open();
63        $this->renderColumnHeaders();
64        $this->renderDynamicFilters();
65        $this->renderer->tablethead_close();
66
67        if ($this->searchConfig->getCount()) {
68            // actual data
69            $this->renderer->tabletbody_open();
70            $this->renderResult();
71            $this->renderer->tabletbody_close();
72
73            // footer (tfoot is develonly currently)
74            if (method_exists($this->renderer, 'tabletfoot_open')) $this->renderer->tabletfoot_open();
75            $this->renderSums();
76            $this->renderPagingControls();
77            if (method_exists($this->renderer, 'tabletfoot_close')) $this->renderer->tabletfoot_close();
78        } else {
79            // nothing found
80            $this->renderEmptyResult();
81        }
82
83        // table close
84        $this->renderer->table_close();
85    }
86
87    /**
88     * Adds additional info to document and renderer in XHTML mode
89     *
90     * @see finishScope()
91     */
92    public function startScope()
93    {
94        // unique identifier for this aggregation
95        $this->renderer->info['struct_table_hash'] = md5(var_export($this->data, true));
96
97        parent::startScope();
98    }
99
100    /**
101     * Closes the table and anything opened in startScope()
102     *
103     * @see startScope()
104     */
105    public function finishScope()
106    {
107        // remove identifier from renderer again
108        if (isset($this->renderer->info['struct_table_hash'])) {
109            unset($this->renderer->info['struct_table_hash']);
110        }
111
112        parent::finishScope();
113    }
114
115    /**
116     * Displays info about the currently applied filters
117     */
118    protected function renderActiveFilters()
119    {
120        if ($this->mode != 'xhtml') return;
121        $dynamic = $this->searchConfig->getDynamicParameters();
122        $filters = $dynamic->getFilters();
123        if (!$filters) return;
124
125        $fltrs = [];
126        foreach ($filters as $column => $filter) {
127            [$comp, $value] = $filter;
128
129            // display the filters in a human readable format
130            foreach ($this->columns as $col) {
131                if ($column === $col->getFullQualifiedLabel()) {
132                    $column = $col->getTranslatedLabel();
133                }
134            }
135            $fltrs[] = sprintf('"%s" %s "%s"', $column, $this->helper->getLang("comparator $comp"), $value);
136        }
137
138        $this->renderer->doc .= '<div class="filter">';
139        $this->renderer->doc .= '<h4>' .
140            sprintf(
141                $this->helper->getLang('tablefilteredby'),
142                hsc(implode(' & ', $fltrs))
143            ) .
144            '</h4>';
145        $this->renderer->doc .= '<div class="resetfilter">';
146        $this->renderer->internallink($this->id, $this->helper->getLang('tableresetfilter'));
147        $this->renderer->doc .= '</div>';
148        $this->renderer->doc .= '</div>';
149    }
150
151    /**
152     * Shows the column headers with links to sort by column
153     */
154    protected function renderColumnHeaders()
155    {
156        $this->renderer->tablerow_open();
157
158        // additional column for row numbers
159        if (!empty($this->data['rownumbers'])) {
160            $this->renderer->tableheader_open();
161            $this->renderer->cdata('#');
162            $this->renderer->tableheader_close();
163        }
164
165        // show all headers
166        foreach ($this->columns as $num => $column) {
167            $header = '';
168            if (isset($this->data['headers'][$num])) {
169                $header = $this->data['headers'][$num];
170            }
171
172            // use field label if no header was set
173            if (blank($header)) {
174                if (is_a($column, 'dokuwiki\plugin\struct\meta\Column')) {
175                    $header = $column->getTranslatedLabel();
176                } else {
177                    $header = 'column ' . $num; // this should never happen
178                }
179            }
180
181            // simple mode first
182            if ($this->mode != 'xhtml') {
183                $this->renderer->tableheader_open();
184                $this->renderer->cdata($header);
185                $this->renderer->tableheader_close();
186                continue;
187            }
188
189            // still here? create custom header for more flexibility
190
191            // width setting, widths are prevalidated, no escape needed
192            $width = '';
193            if (isset($this->data['widths'][$num]) && $this->data['widths'][$num] != '-') {
194                $width = ' style="min-width: ' . $this->data['widths'][$num] . ';' .
195                    'max-width: ' . $this->data['widths'][$num] . ';"';
196            }
197
198            // prepare data attribute for inline edits
199            if (
200                !is_a($column, '\dokuwiki\plugin\struct\meta\PageColumn') &&
201                !is_a($column, '\dokuwiki\plugin\struct\meta\RevisionColumn')
202            ) {
203                $data = 'data-field="' . hsc($column->getFullQualifiedLabel()) . '"';
204            } else {
205                $data = '';
206            }
207
208            // sort indicator and link
209            $sortclass = '';
210            $sorts = $this->searchConfig->getSorts();
211            $dynamic = $this->searchConfig->getDynamicParameters();
212            $dynamic->setSort($column, true);
213            if (isset($sorts[$column->getFullQualifiedLabel()])) {
214                [/*colname*/, $currentSort] = $sorts[$column->getFullQualifiedLabel()];
215                if ($currentSort) {
216                    $sortclass = 'sort-down';
217                    $dynamic->setSort($column, false);
218                } else {
219                    $sortclass = 'sort-up';
220                }
221            }
222            $link = wl($this->id, $dynamic->getURLParameters());
223
224            // output XHTML header
225            $this->renderer->doc .= "<th $width $data>";
226
227            if (is_a($this->renderer, 'renderer_plugin_dw2pdf')) {
228                $this->renderer->doc .= hsc($header);
229            } else {
230                $this->renderer->doc .= '<a href="' . $link . '" class="' . $sortclass . '" ' .
231                    'title="' . $this->helper->getLang('sort') . '">' . hsc($header) . '</a>';
232            }
233
234            $this->renderer->doc .= '</th>';
235        }
236
237        $this->renderer->tablerow_close();
238    }
239
240    /**
241     * Is the result set currently dynamically filtered?
242     * @return bool
243     */
244    protected function isDynamicallyFiltered()
245    {
246        if ($this->mode != 'xhtml') return false;
247        if (!$this->data['dynfilters']) return false;
248
249        $dynamic = $this->searchConfig->getDynamicParameters();
250        return (bool)$dynamic->getFilters();
251    }
252
253    /**
254     * Add input fields for dynamic filtering
255     */
256    protected function renderDynamicFilters()
257    {
258        if ($this->mode != 'xhtml') return;
259        if (empty($this->data['dynfilters'])) return;
260        if (is_a($this->renderer, 'renderer_plugin_dw2pdf')) {
261            return;
262        }
263        global $conf;
264
265        $this->renderer->doc .= '<tr class="dataflt">';
266
267        // add extra column for row numbers
268        if ($this->data['rownumbers']) {
269            $this->renderer->doc .= '<th></th>';
270        }
271
272        // each column gets a form
273        foreach ($this->columns as $column) {
274            $this->renderer->doc .= '<th>';
275
276            // BEGIN FORM
277            $form = new \Doku_Form(
278                [
279                    'method' => 'GET',
280                    'action' => wl($this->id, $this->renderer->info['struct_table_hash'], false, '#')
281                ]
282            );
283            unset($form->_hidden['sectok']); // we don't need it here
284            if (!$conf['userewrite']) $form->addHidden('id', $this->id);
285
286            // current value
287            $dynamic = $this->searchConfig->getDynamicParameters();
288            $filters = $dynamic->getFilters();
289            if (isset($filters[$column->getFullQualifiedLabel()])) {
290                [, $current] = $filters[$column->getFullQualifiedLabel()];
291                $dynamic->removeFilter($column);
292            } else {
293                $current = '';
294            }
295
296            // Add current request params
297            $params = $dynamic->getURLParameters();
298            foreach ($params as $key => $val) {
299                $form->addHidden($key, $val);
300            }
301
302            // add input field
303            $key = $column->getFullQualifiedLabel() . $column->getType()->getDefaultComparator();
304            $form->addElement(
305                form_makeField('text', SearchConfigParameters::$PARAM_FILTER . '[' . $key . ']', $current, '')
306            );
307            $this->renderer->doc .= $form->getForm();
308            // END FORM
309
310            $this->renderer->doc .= '</th>';
311        }
312        $this->renderer->doc .= '</tr>';
313    }
314
315    /**
316     * Display the actual table data
317     */
318    protected function renderResult()
319    {
320        foreach ($this->searchConfig->getRows() as $rownum => $row) {
321            $data = [
322                'id' => $this->id,
323                'mode' => $this->mode,
324                'renderer' => $this->renderer,
325                'searchConfig' => $this->searchConfig,
326                'data' => $this->data,
327                'rownum' => &$rownum,
328                'row' => &$row
329            ];
330            $evt = new Event('PLUGIN_STRUCT_AGGREGATIONTABLE_RENDERRESULTROW', $data);
331            if ($evt->advise_before()) {
332                $this->renderResultRow($rownum, $row);
333            }
334            $evt->advise_after();
335        }
336    }
337
338    /**
339     * Render a single result row
340     *
341     * @param int $rownum
342     * @param array $row
343     */
344    protected function renderResultRow($rownum, $row)
345    {
346        $this->renderer->tablerow_open();
347
348        // add data attribute for inline edit
349        if ($this->mode == 'xhtml') {
350            $pid = $this->searchConfig->getPids()[$rownum];
351            $rid = $this->searchConfig->getRids()[$rownum];
352            $rev = $this->searchConfig->getRevs()[$rownum];
353            $this->renderer->doc = substr(rtrim($this->renderer->doc), 0, -1); // remove closing '>'
354            $this->renderer->doc .= ' data-pid="' . hsc($pid) . '" data-rev="' . $rev . '" data-rid="' . $rid . '">';
355        }
356
357        // row number column
358        if (!empty($this->data['rownumbers'])) {
359            $this->renderer->tablecell_open();
360            $this->renderer->cdata($rownum + $this->searchConfig->getOffset() + 1);
361            $this->renderer->tablecell_close();
362        }
363
364        /** @var Value $value */
365        foreach ($row as $colnum => $value) {
366            $align = $this->data['align'][$colnum] ?? null;
367            $this->renderer->tablecell_open(1, $align);
368            $value->render($this->renderer, $this->mode);
369            $this->renderer->tablecell_close();
370
371            // summarize
372            if (!empty($this->data['summarize']) && is_numeric($value->getValue())) {
373                if (!isset($this->sums[$colnum])) {
374                    $this->sums[$colnum] = 0;
375                }
376                $this->sums[$colnum] += $value->getValue();
377            }
378        }
379        $this->renderer->tablerow_close();
380    }
381
382    /**
383     * Renders an information row for when no results were found
384     */
385    protected function renderEmptyResult()
386    {
387        $this->renderer->tablerow_open();
388        $this->renderer->tablecell_open(count($this->columns) + $this->data['rownumbers'], 'center');
389        $this->renderer->cdata($this->helper->getLang('none'));
390        $this->renderer->tablecell_close();
391        $this->renderer->tablerow_close();
392    }
393
394    /**
395     * Add sums if wanted
396     */
397    protected function renderSums()
398    {
399        if (empty($this->data['summarize'])) return;
400
401        $this->renderer->info['struct_table_meta'] = true;
402        if ($this->mode == 'xhtml') {
403            $this->renderer->tablerow_open('summarize');
404        } else {
405            $this->renderer->tablerow_open();
406        }
407
408        if ($this->data['rownumbers']) {
409            $this->renderer->tableheader_open();
410            $this->renderer->tableheader_close();
411        }
412
413        $len = count($this->columns);
414        for ($i = 0; $i < $len; $i++) {
415            $this->renderer->tableheader_open(1, $this->data['align'][$i]);
416            if (!empty($this->sums[$i])) {
417                $this->renderer->cdata('∑ ');
418                $this->columns[$i]->getType()->renderValue($this->sums[$i], $this->renderer, $this->mode);
419            } elseif ($this->mode == 'xhtml') {
420                $this->renderer->doc .= '&nbsp;';
421            }
422            $this->renderer->tableheader_close();
423        }
424        $this->renderer->tablerow_close();
425        $this->renderer->info['struct_table_meta'] = false;
426    }
427
428    /**
429     * Adds paging controls to the table
430     */
431    protected function renderPagingControls()
432    {
433        if ($this->mode != 'xhtml') return;
434
435        $limit = $this->searchConfig->getLimit();
436        if (!$limit) return;
437        $offset = $this->searchConfig->getOffset();
438
439        $this->renderer->info['struct_table_meta'] = true;
440        $this->renderer->tablerow_open();
441        $this->renderer->tableheader_open((count($this->columns) + ($this->data['rownumbers'] ? 1 : 0)));
442
443
444        // prev link
445        if ($offset) {
446            $prev = $offset - $limit;
447            if ($prev < 0) {
448                $prev = 0;
449            }
450
451            $dynamic = $this->searchConfig->getDynamicParameters();
452            $dynamic->setOffset($prev);
453            $link = wl($this->id, $dynamic->getURLParameters());
454            $this->renderer->doc .= '<a href="' . $link . '" class="prev">' . $this->helper->getLang('prev') . '</a>';
455        }
456
457        // next link
458        if ($this->searchConfig->getCount() > $offset + $limit) {
459            $next = $offset + $limit;
460            $dynamic = $this->searchConfig->getDynamicParameters();
461            $dynamic->setOffset($next);
462            $link = wl($this->id, $dynamic->getURLParameters());
463            $this->renderer->doc .= '<a href="' . $link . '" class="next">' . $this->helper->getLang('next') . '</a>';
464        }
465
466        $this->renderer->tableheader_close();
467        $this->renderer->tablerow_close();
468        $this->renderer->info['struct_table_meta'] = true;
469    }
470
471    /**
472     * Adds CSV export controls
473     */
474    protected function renderExportControls()
475    {
476        if ($this->mode != 'xhtml') return;
477        if (empty($this->data['csv'])) return;
478        if (!$this->searchConfig->getCount()) return;
479
480        $dynamic = $this->searchConfig->getDynamicParameters();
481        $params = $dynamic->getURLParameters();
482        $params['hash'] = $this->renderer->info['struct_table_hash'];
483
484        // FIXME apply dynamic filters
485        $link = exportlink($this->id, 'struct_csv', $params);
486
487        $this->renderer->doc .= '<a href="' . $link . '" class="export mediafile mf_csv">' .
488            $this->helper->getLang('csvexport') . '</a>';
489    }
490}
491