1<?php
2
3use dokuwiki\Extension\Plugin;
4use dokuwiki\Utf8\PhpString;
5use dokuwiki\Form\Form;
6use dokuwiki\Search\Indexer;
7
8/**
9 * Tagging Plugin (hlper component)
10 *
11 * @license GPL 2
12 */
13class helper_plugin_tagging extends Plugin
14{
15    /**
16     * Gives access to the database
17     *
18     * Initializes the SQLite helper and register the CLEANTAG function
19     *
20     * @return helper_plugin_sqlite|bool false if initialization fails
21     */
22    public function getDB()
23    {
24        static $db = null;
25        if ($db !== null) {
26            return $db;
27        }
28
29        /** @var helper_plugin_sqlite $db */
30        $db = plugin_load('helper', 'sqlite');
31        if ($db === null) {
32            msg('The tagging plugin needs the sqlite plugin', -1);
33
34            return false;
35        }
36        $db->init('tagging', __DIR__ . '/db/');
37        $db->create_function('CLEANTAG', [$this, 'cleanTag'], 1);
38        $db->create_function(
39            'GROUP_SORT',
40            function ($group, $newDelimiter) {
41                $ex = array_filter(explode(',', $group));
42                sort($ex);
43
44                return implode($newDelimiter, $ex);
45            },
46            2
47        );
48        $db->create_function('GET_NS', 'getNS', 1);
49
50        return $db;
51    }
52
53    /**
54     * Return the user to use for accessing tags
55     *
56     * Handles the singleuser mode by returning 'auto' as user. Returnes false when no user is logged in.
57     *
58     * @return bool|string
59     */
60    public function getUser()
61    {
62        if (!isset($_SERVER['REMOTE_USER'])) {
63            return false;
64        }
65        if ($this->getConf('singleusermode')) {
66            return 'auto';
67        }
68
69        return $_SERVER['REMOTE_USER'];
70    }
71
72    /**
73     * If plugin elasticsearch is installed, inform it that we have just made changes
74     * to some data relevant to a page. The page should be re-indexed.
75     *
76     * @param string $id
77     */
78    public function updateElasticState($id)
79    {
80        /** @var \helper_plugin_elasticsearch_plugins $elasticHelper */
81        $elasticHelper = plugin_load('helper', 'elasticsearch_plugins');
82        if ($elasticHelper) {
83            $elasticHelper->updateRefreshState($id);
84        }
85    }
86
87    /**
88     * Canonicalizes the tag to its lower case nospace form
89     *
90     * @param $tag
91     *
92     * @return string
93     */
94    public function cleanTag($tag)
95    {
96        $tag = str_replace([' ', '-', '_', '#'], '', $tag);
97        return PhpString::strtolower($tag);
98    }
99
100    /**
101     * Canonicalizes the namespace, remove the first colon and add glob
102     *
103     * @param $namespace
104     *
105     * @return string
106     */
107    public function globNamespace($namespace)
108    {
109        return cleanId($namespace) . '*';
110    }
111
112    /**
113     * Create or Update tags of a page
114     *
115     * Uses the translation plugin to store the language of a page (if available)
116     *
117     * @param string $id The page ID
118     * @param string $user
119     * @param array  $tags
120     *
121     * @return bool|SQLiteResult
122     */
123    public function replaceTags($id, $user, $tags)
124    {
125        global $conf;
126        /** @var helper_plugin_translation $trans */
127        $trans = plugin_load('helper', 'translation');
128        if ($trans) {
129            $lang = $trans->realLC($trans->getLangPart($id));
130        } else {
131            $lang = $conf['lang'];
132        }
133
134        $db = $this->getDB();
135        $db->query('BEGIN TRANSACTION');
136
137        $queries = [['DELETE FROM taggings WHERE pid = ? AND tagger = ?', $id, $user]];
138        foreach ($tags as $tag) {
139            $queries[] = ['INSERT INTO taggings (pid, tagger, tag, lang) VALUES(?, ?, ?, ?)', $id, $user, $tag, $lang];
140        }
141
142        foreach ($queries as $query) {
143            if (!call_user_func_array([$db, 'query'], $query)) {
144                $db->query('ROLLBACK TRANSACTION');
145
146                return false;
147            }
148        }
149
150        return $db->query('COMMIT TRANSACTION');
151    }
152
153    /**
154     * Get a list of Tags or Pages matching search criteria
155     *
156     * @param array  $filter What to search for array('field' => 'searchterm')
157     * @param string $type   What field to return 'tag'|'pid'
158     * @param int    $limit  Limit to this many results, 0 for all
159     *
160     * @return array associative array in form of value => count
161     */
162    public function findItems($filter, $type, $limit = 0)
163    {
164        $queryBuilder = new helper_plugin_tagging_querybuilder();
165
166        $queryBuilder->setField($type);
167        $queryBuilder->setLimit($limit);
168        $queryBuilder->setTags($this->extractFromQuery($filter));
169        if (isset($filter['ns'])) $queryBuilder->includeNS($filter['ns']);
170        if (isset($filter['notns'])) $queryBuilder->excludeNS($filter['notns']);
171        if (isset($filter['tagger'])) $queryBuilder->setTagger($filter['tagger']);
172        if (isset($filter['pid'])) $queryBuilder->setPid($filter['pid']);
173
174        return $this->queryDb($queryBuilder->getQuery());
175    }
176
177    /**
178     * Constructs the URL to search for a tag
179     *
180     * @param string $tag
181     * @param string $ns
182     *
183     * @return string
184     */
185    public function getTagSearchURL($tag, $ns = '')
186    {
187        $ret = '?do=search&sf=1&q=' . rawurlencode('#' . $this->cleanTag($tag));
188        if ($ns) {
189            $ret .= rawurlencode(' @' . $ns);
190        }
191
192        return $ret;
193    }
194
195    /**
196     * Calculates the size levels for the given list of clouds
197     *
198     * Automatically determines sensible tresholds
199     *
200     * @param array $tags list of tags => count
201     * @param int   $levels
202     *
203     * @return array
204     */
205    public function cloudData($tags, $levels = 10)
206    {
207        $min = min($tags);
208        $max = max($tags);
209
210        // calculate tresholds
211        $tresholds = [];
212        for ($i = 0; $i <= $levels; $i++) {
213            $tresholds[$i] = ($max - $min + 1) ** ($i / $levels) + $min - 1;
214        }
215
216        // assign weights
217        foreach ($tags as $tag => $cnt) {
218            foreach ($tresholds as $tresh => $val) {
219                if ($cnt <= $val) {
220                    $tags[$tag] = $tresh;
221                    break;
222                }
223                $tags[$tag] = $levels;
224            }
225        }
226
227        return $tags;
228    }
229
230    /**
231     * Display a tag cloud
232     *
233     * @param array    $tags   list of tags => count
234     * @param string   $type   'tag'
235     * @param Callable $func   The function to print the link (gets tag and ns)
236     * @param bool     $wrap   wrap cloud in UL tags?
237     * @param bool     $return returnn HTML instead of printing?
238     * @param string   $ns     Add this namespace to search links
239     *
240     * @return string
241     */
242    public function html_cloud($tags, $type, $func, $wrap = true, $return = false, $ns = '')
243    {
244        global $INFO;
245
246        $hidden_str = $this->getConf('hiddenprefix');
247        $hidden_len = strlen($hidden_str);
248
249        $ret = '';
250        if ($wrap) {
251            $ret .= '<ul class="tagging_cloud clearfix">';
252        }
253        if (count($tags) === 0) {
254            // Produce valid XHTML (ul needs a child)
255            $this->setupLocale();
256            $ret .= '<li><div class="li">' . $this->lang['js']['no' . $type . 's'] . '</div></li>';
257        } else {
258            $tags = $this->cloudData($tags);
259            foreach ($tags as $val => $size) {
260                // skip hidden tags for users that can't edit
261                if (
262                    $type === 'tag' &&
263                    $hidden_len &&
264                    substr($val, 0, $hidden_len) == $hidden_str &&
265                    !($this->getUser() && $INFO['writable'])
266                ) {
267                    continue;
268                }
269
270                $ret .= '<li class="t' . $size . '"><div class="li">';
271                $ret .= call_user_func($func, $val, $ns);
272                $ret .= '</div></li>';
273            }
274        }
275        if ($wrap) {
276            $ret .= '</ul>';
277        }
278        if ($return) {
279            return $ret;
280        }
281        echo $ret;
282
283        return '';
284    }
285
286    /**
287     * Display a List of Page Links
288     *
289     * @param array    $pids   list of pids => count
290     * @return string
291     */
292    public function html_page_list($pids)
293    {
294        $ret = '<div class="search_quickresult">';
295        $ret .= '<ul class="search_quickhits">';
296
297        if (count($pids) === 0) {
298            // Produce valid XHTML (ul needs a child)
299            $ret .= '<li><div class="li">' . $this->lang['js']['nopages'] . '</div></li>';
300        } else {
301            foreach (array_keys($pids) as $val) {
302                $ret .= '<li><div class="li">';
303                $ret .= html_wikilink(":$val");
304                $ret .= '</div></li>';
305            }
306        }
307
308        $ret .= '</ul>';
309        $ret .= '</div>';
310        $ret .= '<div class="clearer"></div>';
311
312        return $ret;
313    }
314
315    /**
316     * Get the link to a search for the given tag
317     *
318     * @param string $tag search for this tag
319     * @param string $ns  limit search to this namespace
320     *
321     * @return string
322     */
323    protected function linkToSearch($tag, $ns = '')
324    {
325        return '<a href="' . hsc($this->getTagSearchURL($tag, $ns)) . '">' . $tag . '</a>';
326    }
327
328    /**
329     * Display the Tags for the current page and prepare the tag editing form
330     *
331     * @param bool $print Should the HTML be printed or returned?
332     *
333     * @return string
334     */
335    public function tpl_tags($print = true)
336    {
337        global $INFO;
338        global $lang;
339
340        $filter = ['pid' => $INFO['id']];
341        if ($this->getConf('singleusermode')) {
342            $filter['tagger'] = 'auto';
343        }
344
345        $tags = $this->findItems($filter, 'tag');
346
347        $ret = '<div class="plugin_tagging_edit">';
348        $ret .= $this->html_cloud($tags, 'tag', [$this, 'linkToSearch'], true, true);
349
350        if ($this->getUser() && $INFO['writable']) {
351            $lang['btn_tagging_edit'] = $lang['btn_secedit'];
352            $ret .= '<div id="tagging__edit_buttons_group">';
353            $ret .= html_btn('tagging_edit', $INFO['id'], '', []);
354            if (auth_isadmin()) {
355                $ret .= '<label>'
356                    . $this->getLang('toggle admin mode')
357                    . '<input type="checkbox" id="tagging__edit_toggle_admin" /></label>';
358            }
359            $ret .= '</div>';
360            $form = new Form();
361            $form->id('tagging__edit');
362            $form->setHiddenField('tagging[id]', $INFO['id']);
363            $form->setHiddenField('call', 'plugin_tagging_save');
364            $tags = $this->findItems(['pid'    => $INFO['id'], 'tagger' => $this->getUser()], 'tag');
365            $form->addTextarea('tagging[tags]')
366                ->val(implode(', ', array_keys($tags)))
367                ->addClass('edit')
368                ->attr('rows', 4);
369            $form->addButton('', $lang['btn_save'])->id('tagging__edit_save');
370            $form->addButton('', $lang['btn_cancel'])->id('tagging__edit_cancel');
371            $ret .= $form->toHTML();
372        }
373        $ret .= '</div>';
374
375        if ($print) {
376            echo $ret;
377        }
378
379        return $ret;
380    }
381
382    /**
383     * @param string $namespace empty for entire wiki
384     *
385     * @param string $order_by
386     * @param bool $desc
387     * @param array $filters
388     * @return array
389     */
390    public function getAllTags($namespace = '', $order_by = 'tid', $desc = false, $filters = [])
391    {
392        $order_fields = ['pid', 'tid', 'taggers', 'ns', 'count'];
393        if (!in_array($order_by, $order_fields)) {
394            msg('cannot sort by ' . $order_by . ' field does not exists', -1);
395            $order_by = 'tag';
396        }
397
398        [$having, $params] = $this->getFilterSql($filters);
399
400        $db = $this->getDB();
401
402        $query = 'SELECT    "pid",
403                            CLEANTAG("tag") AS "tid",
404                            GROUP_SORT(GROUP_CONCAT("tagger"), \', \') AS "taggers",
405                            GROUP_SORT(GROUP_CONCAT(GET_NS("pid")), \', \') AS "ns",
406                            GROUP_SORT(GROUP_CONCAT("pid"), \', \') AS "pids",
407                            COUNT(*) AS "count"
408                        FROM "taggings"
409                        WHERE "pid" GLOB ? AND GETACCESSLEVEL(pid) >= ' . AUTH_READ
410                        . ' GROUP BY "tid"';
411        $query .= $having;
412        $query .=      'ORDER BY ' . $order_by;
413        if ($desc) {
414            $query .= ' DESC';
415        }
416
417        array_unshift($params, $this->globNamespace($namespace));
418        $res = $db->query($query, $params);
419
420        return $db->res2arr($res);
421    }
422
423    /**
424     * Get all pages with tags and their tags
425     *
426     * @return array ['pid' => ['tag1','tag2','tag3']]
427     */
428    public function getAllTagsByPage()
429    {
430        $query = '
431        SELECT pid, GROUP_CONCAT(tag) AS tags
432        FROM taggings
433        GROUP BY pid
434        ';
435        $db = $this->getDb();
436        $res = $db->query($query);
437        return array_map(
438            fn($i) => explode(',', $i),
439            array_column($db->res2arr($res), 'tags', 'pid')
440        );
441    }
442
443    /**
444     * Renames a tag
445     *
446     * @param string $formerTagName
447     * @param string $newTagNames
448     */
449    public function renameTag($formerTagName, $newTagNames)
450    {
451
452        if (empty($formerTagName) || empty($newTagNames)) {
453            msg($this->getLang("admin enter tag names"), -1);
454            return;
455        }
456
457        $keepFormerTag = false;
458
459        // enable splitting tags on rename
460        $newTagNames = array_map(fn($tag) => $this->cleanTag($tag), explode(',', $newTagNames));
461
462        $db = $this->getDB();
463
464        // non-admins can rename only their own tags
465        if (!auth_isadmin()) {
466            $queryTagger = ' AND tagger = ?';
467            $tagger = $this->getUser();
468        } else {
469            $queryTagger = '';
470            $tagger = '';
471        }
472
473        $insertQuery = 'INSERT INTO taggings ';
474        $insertQuery .= 'SELECT pid, ?, tagger, lang FROM taggings';
475        $where = ' WHERE CLEANTAG(tag) = ?';
476        $where .= ' AND GETACCESSLEVEL(pid) >= ' . AUTH_EDIT;
477        $where .= $queryTagger;
478
479        $db->query('BEGIN TRANSACTION');
480
481        // insert new tags first
482        foreach ($newTagNames as $newTag) {
483            if ($newTag === $this->cleanTag($formerTagName)) {
484                $keepFormerTag = true;
485                continue;
486            }
487            $params = [$newTag, $this->cleanTag($formerTagName)];
488            if ($tagger) $params[] = $tagger;
489            $res = $db->query($insertQuery . $where, $params);
490            if ($res === false) {
491                $db->query('ROLLBACK TRANSACTION');
492                return;
493            }
494            $db->res_close($res);
495        }
496
497        // finally delete the renamed tags
498        if (!$keepFormerTag) {
499            $deleteQuery = 'DELETE FROM taggings';
500            $params = [$this->cleanTag($formerTagName)];
501            if ($tagger) $params[] = $tagger;
502            if ($db->query($deleteQuery . $where, $params) === false) {
503                $db->query('ROLLBACK TRANSACTION');
504                return;
505            }
506        }
507
508        $db->query('COMMIT TRANSACTION');
509
510        msg($this->getLang("admin renamed"), 1);
511    }
512
513    /**
514     * Rename or delete a tag for all users
515     *
516     * @param string $pid
517     * @param string $formerTagName
518     * @param string $newTagName
519     *
520     * @return array
521     */
522    public function modifyPageTag($pid, $formerTagName, $newTagName)
523    {
524
525        $db = $this->getDb();
526
527        $res = $db->query(
528            'SELECT pid FROM taggings WHERE CLEANTAG(tag) = ? AND pid = ?',
529            $this->cleanTag($formerTagName),
530            $pid
531        );
532        $check = $db->res2arr($res);
533
534        if (empty($check)) {
535            return [true, $this->getLang('admin tag does not exists')];
536        }
537
538        if (empty($newTagName)) {
539            $res = $db->query(
540                'DELETE FROM taggings WHERE pid = ? AND CLEANTAG(tag) = ?',
541                $pid,
542                $this->cleanTag($formerTagName)
543            );
544        } else {
545            $res = $db->query(
546                'UPDATE taggings SET tag = ? WHERE pid = ? AND CLEANTAG(tag) = ?',
547                $newTagName,
548                $pid,
549                $this->cleanTag($formerTagName)
550            );
551        }
552        $db->res2arr($res);
553
554        return [false, $this->getLang('admin renamed')];
555    }
556
557    /**
558     * Deletes a tag
559     *
560     * @param array  $tags
561     * @param string $namespace current namespace context as in getAllTags()
562     */
563    public function deleteTags($tags, $namespace = '')
564    {
565        if (empty($tags)) {
566            return;
567        }
568
569        $namespace = cleanId($namespace);
570
571        $db = $this->getDB();
572
573        $queryBody = 'FROM taggings WHERE pid GLOB ? AND (' .
574            implode(' OR ', array_fill(0, count($tags), 'CLEANTAG(tag) = ?')) . ')';
575        $args = array_map([$this, 'cleanTag'], $tags);
576        array_unshift($args, $this->globNamespace($namespace));
577
578        // non-admins can delete only their own tags
579        if (!auth_isadmin()) {
580            $queryBody .= ' AND tagger = ?';
581            $args[] = $this->getUser();
582        }
583
584        $affectedPagesQuery = 'SELECT DISTINCT pid ' . $queryBody;
585        $resAffectedPages = $db->query($affectedPagesQuery, $args);
586        $numAffectedPages = count($resAffectedPages->fetchAll());
587
588        $deleteQuery = 'DELETE ' . $queryBody;
589        $db->query($deleteQuery, $args);
590
591        msg(sprintf($this->getLang("admin deleted"), count($tags), $numAffectedPages), 1);
592    }
593
594    /**
595     * Delete taggings of nonexistent pages
596     */
597    public function deleteInvalidTaggings()
598    {
599        $db = $this->getDB();
600        $query = 'DELETE    FROM "taggings"
601                            WHERE NOT PAGEEXISTS(pid)
602                 ';
603        $res = $db->query($query);
604        $db->res_close($res);
605    }
606
607    /**
608     * Updates tags with a new page name
609     *
610     * @param string $oldName
611     * @param string $newName
612     */
613    public function renamePage($oldName, $newName)
614    {
615        $db = $this->getDB();
616        $db->query('UPDATE taggings SET pid = ? WHERE pid = ?', $newName, $oldName);
617    }
618
619    /**
620     * Extracts tags from search query
621     *
622     * @param array $parsedQuery
623     * @return array
624     */
625    public function extractFromQuery($parsedQuery)
626    {
627        $tags = [];
628        if (isset($parsedQuery['phrases'][0])) {
629            $tags = $parsedQuery['phrases'];
630        } elseif (isset($parsedQuery['and'][0])) {
631            $tags = $parsedQuery['and'];
632        } elseif (isset($parsedQuery['tag'])) {
633            // handle autocomplete call
634            $tags[] = $parsedQuery['tag'];
635        }
636        return $tags;
637    }
638
639    /**
640     * Search for tagged pages
641     *
642     * @param array $tagFiler
643     * @return array
644     */
645    public function searchPages($tagFiler)
646    {
647        global $INPUT;
648        global $QUERY;
649        $parsedQuery = ft_queryParser(new Indexer(), $QUERY);
650
651        $queryBuilder = new helper_plugin_tagging_querybuilder();
652
653        $queryBuilder->setField('pid');
654        $queryBuilder->setTags($tagFiler);
655        $queryBuilder->setLogicalAnd($INPUT->str('tagging-logic') === 'and');
656        if (isset($parsedQuery['ns'])) $queryBuilder->includeNS($parsedQuery['ns']);
657        if (isset($parsedQuery['notns'])) $queryBuilder->excludeNS($parsedQuery['notns']);
658        if (isset($parsedQuery['tagger'])) $queryBuilder->setTagger($parsedQuery['tagger']);
659        if (isset($parsedQuery['pid'])) $queryBuilder->setPid($parsedQuery['pid']);
660
661        return $this->queryDb($queryBuilder->getPages());
662    }
663
664    /**
665     * Syntax to allow users to manage tags on regular pages, respects ACLs
666     * @param string $ns
667     * @return string
668     */
669    public function manageTags($ns)
670    {
671        global $INPUT;
672
673        $this->setDefaultSort();
674
675        // initially set namespace filter to what is defined in syntax
676        if ($ns && !$INPUT->has('tagging__filters')) {
677            $INPUT->set('tagging__filters', ['ns' => $ns]);
678        }
679
680        return $this->html_table();
681    }
682
683    /**
684     * HTML list of tagged pages
685     *
686     * @param string $tid
687     * @return string
688     */
689    public function getPagesHtml($tid)
690    {
691        $html = '';
692
693        $db = $this->getDB();
694        $sql = 'SELECT pid from taggings where CLEANTAG(tag) = CLEANTAG(?)';
695        $res =  $db->query($sql, $tid);
696        $pages = $db->res2arr($res);
697
698        if ($pages) {
699            $html .= '<ul>';
700            foreach ($pages as $page) {
701                $pid = $page['pid'];
702                $html .= '<li><a href="' . wl($pid) . '" target="_blank">' . $pid . '</li>';
703            }
704            $html .= '</ul>';
705        }
706
707        return $html;
708    }
709
710    /**
711     * Display tag management table
712     */
713    public function html_table()
714    {
715        global $ID, $INPUT;
716
717        $headers = [
718            ['value' => $this->getLang('admin tag'), 'sort_by' => 'tid'],
719            ['value' => $this->getLang('admin occurrence'), 'sort_by' => 'count']
720        ];
721
722        if (!$this->conf['hidens']) {
723            $headers[] = ['value' => $this->getLang('admin namespaces'), 'sort_by' => 'ns'];
724        }
725        $headers[] = ['value' => $this->getLang('admin taggers'), 'sort_by' => 'taggers'];
726        $headers[] = ['value' => $this->getLang('admin actions'), 'sort_by' => false];
727
728        $sort = explode(',', $this->getParam('sort'));
729        $order_by = $sort[0];
730        $desc = false;
731        if (isset($sort[1]) && $sort[1] === 'desc') {
732            $desc = true;
733        }
734        $filters = $INPUT->arr('tagging__filters');
735
736        $tags = $this->getAllTags($INPUT->str('filter'), $order_by, $desc, $filters);
737
738        $form = new Form();
739        // required in admin mode
740        $form->setHiddenField('page', 'tagging');
741        $form->setHiddenField('id', $ID);
742        $form->setHiddenField('[tagging]sort', $this->getParam('sort'));
743
744        /**
745         * Actions dialog
746         */
747        $form->addTagOpen('div')->id('tagging__action-dialog')->attr('style', "display:none;");
748        $form->addTagClose('div');
749
750        /**
751         * Tag pages dialog
752         */
753        $form->addTagOpen('div')->id('tagging__taggedpages-dialog')->attr('style', "display:none;");
754        $form->addTagClose('div');
755
756        /**
757         * Tag management table
758         */
759        $form->addTagOpen('table')->addClass('inline plugin_tagging');
760
761        $nscol = $this->conf['hidens'] ? '' : '<col class="wide-col" />';
762        $form->addHTML(
763            '<colgroup>
764                <col />
765                <col class="narrow-col" />'
766                . $nscol .
767                '<col />
768                <col class="narrow-col" />
769            </colgroup>'
770        );
771
772        /**
773         * Table headers
774         */
775        $form->addTagOpen('tr');
776        foreach ($headers as $header) {
777            $form->addTagOpen('th');
778            if ($header['sort_by'] !== false) {
779                $param = $header['sort_by'];
780                $icon = 'arrow-both';
781                $title = $this->getLang('admin sort ascending');
782                if ($header['sort_by'] === $order_by) {
783                    if ($desc === false) {
784                        $icon = 'arrow-up';
785                        $title = $this->getLang('admin sort descending');
786                        $param .= ',desc';
787                    } else {
788                        $icon = 'arrow-down';
789                    }
790                }
791                $form->addButtonHTML(
792                    "tagging[sort]",
793                    $header['value'] . ' ' . inlineSVG(__DIR__ . "/images/$icon.svg")
794                )
795                    ->addClass('plugin_tagging sort_button')
796                    ->attr('title', $title)
797                    ->val($param);
798            } else {
799                $form->addHTML($header['value']);
800            }
801            $form->addTagClose('th');
802        }
803        $form->addTagClose('tr');
804
805        /**
806         * Table filters for all sortable columns
807         */
808        $form->addTagOpen('tr');
809        foreach ($headers as $header) {
810            $form->addTagOpen('th');
811            if ($header['sort_by'] !== false) {
812                $field = $header['sort_by'];
813                $input = $form->addTextInput("tagging__filters[$field]");
814                $input->addClass('full-col');
815            }
816            $form->addTagClose('th');
817        }
818        $form->addTagClose('tr');
819
820
821        foreach ($tags as $taginfo) {
822            $tagname = $taginfo['tid'];
823            $taggers = $taginfo['taggers'];
824            $ns = $taginfo['ns'];
825            $pids = explode(',', $taginfo['pids']);
826
827            $form->addTagOpen('tr');
828            $form->addHTML('<td>');
829            $form->addHTML('<a class="tagslist" href="#" data-tid="' . $taginfo['tid'] . '">');
830            $form->addHTML(hsc($tagname) . '</a>');
831            $form->addHTML('</td>');
832            $form->addHTML('<td>' . $taginfo['count'] . '</td>');
833            if (!$this->conf['hidens']) {
834                $form->addHTML('<td>' . hsc($ns) . '</td>');
835            }
836            $form->addHTML('<td>' . hsc($taggers) . '</td>');
837
838            /**
839             * action buttons
840             */
841            $form->addHTML('<td>');
842
843            // check ACLs
844            $userEdit = false;
845            foreach ($pids as $pid) {
846                if (auth_quickaclcheck($pid) >= AUTH_EDIT) {
847                    $userEdit = true;
848                    break;
849                }
850            }
851
852            if ($userEdit) {
853                $form->addButtonHTML(
854                    'tagging[actions][rename][' . $taginfo['tid'] . ']',
855                    inlineSVG(__DIR__ . '/images/edit.svg')
856                )
857                    ->addClass('plugin_tagging action_button')
858                    ->attr('data-action', 'rename')
859                    ->attr('data-tid', $taginfo['tid']);
860                $form->addButtonHTML(
861                    'tagging[actions][delete][' . $taginfo['tid'] . ']',
862                    inlineSVG(__DIR__ . '/images/delete.svg')
863                )
864                    ->addClass('plugin_tagging action_button')
865                    ->attr('data-action', 'delete')
866                    ->attr('data-tid', $taginfo['tid']);
867            }
868
869            $form->addHTML('</td>');
870            $form->addTagClose('tr');
871        }
872
873        $form->addTagClose('table');
874        return '<div class="table">' . $form->toHTML() . '</div>';
875    }
876
877    /**
878     * Display tag cleaner
879     *
880     * @return string
881     */
882    public function html_clean()
883    {
884        $invalid = $this->getInvalidTaggings();
885
886        if (!$invalid) {
887            return '<p><strong>' . $this->getLang('admin no invalid') . '</strong></p>';
888        }
889
890        $form = new Form();
891        $form->setHiddenField('do', 'admin');
892        $form->setHiddenField('page', $this->getPluginName());
893        $form->addButton('cmd[clean]', $this->getLang('admin clean'));
894
895        $html = $form->toHTML();
896
897        $html .= '<div class="table"><table class="inline plugin_tagging">';
898        $html .= '<thead><tr><th>' .
899            $this->getLang('admin nonexistent page') .
900            '</th><th>' .
901            $this->getLang('admin tags') .
902            '</th></tr></thead><tbody>';
903
904        foreach ($invalid as $row) {
905            $html .= '<tr><td>' . $row['pid'] . '</td><td>' . $row['tags'] . '</td></tr>';
906        }
907
908        $html .= '</tbody></table></div>';
909
910        return $html;
911    }
912
913    /**
914     * Returns all tagging parameters from the query string
915     *
916     * @return mixed
917     */
918    public function getParams()
919    {
920        global $INPUT;
921        return $INPUT->param('tagging', []);
922    }
923
924    /**
925     * Get a tagging parameter, empty string if not set
926     *
927     * @param string $name
928     * @return string
929     */
930    public function getParam($name)
931    {
932        $params = $this->getParams();
933        if ($params) {
934            return $params[$name] ?: '';
935        }
936        return '';
937    }
938
939    /**
940     * Sets a tagging parameter
941     *
942     * @param string $name
943     * @param string|array $value
944     */
945    public function setParam($name, $value)
946    {
947        global $INPUT;
948        $params = $this->getParams();
949        $params = array_merge($params, [$name => $value]);
950
951        $INPUT->set('tagging', $params);
952    }
953
954    /**
955     * Default sorting by tag id
956     */
957    public function setDefaultSort()
958    {
959        if (!$this->getParam('sort')) {
960            $this->setParam('sort', 'tid');
961        }
962    }
963
964    /**
965     * Executes the query and returns the results as array
966     *
967     * @param array $query
968     * @return array
969     */
970    protected function queryDb($query)
971    {
972        $db = $this->getDB();
973        if (!$db) {
974            return [];
975        }
976
977        $res = $db->query($query[0], $query[1]);
978        $res = $db->res2arr($res);
979
980        $ret = [];
981        foreach ($res as $row) {
982            $ret[$row['item']] = $row['cnt'];
983        }
984        return $ret;
985    }
986
987    /**
988     * Construct the HAVING part of the search query
989     *
990     * @param array $filters
991     * @return array
992     */
993    protected function getFilterSql($filters)
994    {
995        $having = '';
996        $parts = [];
997        $params = [];
998        $filters = array_filter($filters);
999        if ($filters !== []) {
1000            $having = ' HAVING ';
1001            foreach ($filters as $filter => $value) {
1002                $parts[] = " $filter LIKE ? ";
1003                $params[] = "%$value%";
1004            }
1005            $having .= implode(' AND ', $parts);
1006        }
1007        return [$having, $params];
1008    }
1009
1010    /**
1011     * Returns taggings of nonexistent pages
1012     *
1013     * @return array
1014     */
1015    protected function getInvalidTaggings()
1016    {
1017        $db = $this->getDB();
1018        $query = 'SELECT    "pid",
1019                            GROUP_CONCAT(CLEANTAG("tag")) AS "tags"
1020                            FROM "taggings"
1021                            WHERE NOT PAGEEXISTS(pid)
1022                            GROUP BY pid
1023                 ';
1024        $res = $db->query($query);
1025        return $db->res2arr($res);
1026    }
1027}
1028