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