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