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