xref: /plugin/tagging/helper.php (revision 4a7da0a5bb06936be030da8735839af485bfe649)
1<?php
2/**
3 * Tagging Plugin (hlper component)
4 *
5 * @license GPL 2
6 */
7class helper_plugin_tagging extends DokuWiki_Plugin {
8
9    /**
10     * Gives access to the database
11     *
12     * Initializes the SQLite helper and register the CLEANTAG function
13     *
14     * @return helper_plugin_sqlite|bool false if initialization fails
15     */
16    public function getDB() {
17        static $db = null;
18        if ($db !== null) {
19            return $db;
20        }
21
22        /** @var helper_plugin_sqlite $db */
23        $db = plugin_load('helper', 'sqlite');
24        if ($db === null) {
25            msg('The tagging plugin needs the sqlite plugin', -1);
26
27            return false;
28        }
29        $db->init('tagging', __DIR__ . '/db/');
30        $db->create_function('CLEANTAG', array($this, 'cleanTag'), 1);
31        $db->create_function('GROUP_SORT',
32            function ($group, $newDelimiter) {
33                $ex = explode(',', $group);
34                sort($ex);
35
36                return implode($newDelimiter, $ex);
37            }, 2);
38
39        return $db;
40    }
41
42    /**
43     * Return the user to use for accessing tags
44     *
45     * Handles the singleuser mode by returning 'auto' as user. Returnes false when no user is logged in.
46     *
47     * @return bool|string
48     */
49    public function getUser() {
50        if (!isset($_SERVER['REMOTE_USER'])) {
51            return false;
52        }
53        if ($this->getConf('singleusermode')) {
54            return 'auto';
55        }
56
57        return $_SERVER['REMOTE_USER'];
58    }
59
60    /**
61     * Canonicalizes the tag to its lower case nospace form
62     *
63     * @param $tag
64     *
65     * @return string
66     */
67    public function cleanTag($tag) {
68        $tag = str_replace(array(' ', '-', '_'), '', $tag);
69        $tag = utf8_strtolower($tag);
70
71        return $tag;
72    }
73
74    /**
75     * Canonicalizes the namespace, remove the first colon and add glob
76     *
77     * @param $namespace
78     *
79     * @return string
80     */
81    public function globNamespace($namespace) {
82        return cleanId($namespace) . '*';
83    }
84
85    /**
86     * Create or Update tags of a page
87     *
88     * Uses the translation plugin to store the language of a page (if available)
89     *
90     * @param string $id The page ID
91     * @param string $user
92     * @param array  $tags
93     *
94     * @return bool|SQLiteResult
95     */
96    public function replaceTags($id, $user, $tags) {
97        global $conf;
98        /** @var helper_plugin_translation $trans */
99        $trans = plugin_load('helper', 'translation');
100        if ($trans) {
101            $lang = $trans->realLC($trans->getLangPart($id));
102        } else {
103            $lang = $conf['lang'];
104        }
105
106        $db = $this->getDB();
107        $db->query('BEGIN TRANSACTION');
108        $queries = array(array('DELETE FROM taggings WHERE pid = ? AND tagger = ?', $id, $user));
109        foreach ($tags as $tag) {
110            $queries[] = array('INSERT INTO taggings (pid, tagger, tag, lang) VALUES(?, ?, ?, ?)', $id, $user, $tag, $lang);
111        }
112
113        foreach ($queries as $query) {
114            if (!call_user_func_array(array($db, 'query'), $query)) {
115                $db->query('ROLLBACK TRANSACTION');
116
117                return false;
118            }
119        }
120
121        return $db->query('COMMIT TRANSACTION');
122    }
123
124    /**
125     * Get a list of Tags or Pages matching search criteria
126     *
127     * @param array  $filter What to search for array('field' => 'searchterm')
128     * @param string $type   What field to return 'tag'|'pid'
129     * @param int    $limit  Limit to this many results, 0 for all
130     *
131     * @return array associative array in form of value => count
132     */
133    public function findItems($filter, $type, $limit = 0) {
134
135        global $INPUT;
136
137        /** @var helper_plugin_tagging_querybuilder $queryBuilder */
138        $queryBuilder = new helper_plugin_tagging_querybuilder();
139
140        $queryBuilder->setField($type);
141        $queryBuilder->setLimit($limit);
142        $queryBuilder->setTags($this->getTags($filter));
143        if (isset($filter['ns'])) $queryBuilder->includeNS($filter['ns']);
144        if (isset($filter['notns'])) $queryBuilder->excludeNS($filter['notns']);
145        if (isset($filter['tagger'])) $queryBuilder->setTagger($filter['tagger']);
146        if (isset($filter['pid'])) $queryBuilder->setPid($filter['pid']);
147
148        return $this->queryDb($queryBuilder->getQuery());
149
150    }
151
152    /**
153     * Constructs the URL to search for a tag
154     *
155     * @param string $tag
156     * @param string $ns
157     *
158     * @return string
159     */
160    public function getTagSearchURL($tag, $ns = '') {
161        // wrap tag in quotes if non clean
162        $ctag = utf8_stripspecials($this->cleanTag($tag));
163        if ($ctag != utf8_strtolower($tag)) {
164            $tag = '"' . $tag . '"';
165        }
166
167        $ret = '?do=search&sf=1&id=' . rawurlencode($tag);
168        if ($ns) {
169            $ret .= rawurlencode(' @' . $ns);
170        }
171
172        return $ret;
173    }
174
175    /**
176     * Calculates the size levels for the given list of clouds
177     *
178     * Automatically determines sensible tresholds
179     *
180     * @param array $tags list of tags => count
181     * @param int   $levels
182     *
183     * @return mixed
184     */
185    public function cloudData($tags, $levels = 10) {
186        $min = min($tags);
187        $max = max($tags);
188
189        // calculate tresholds
190        $tresholds = array();
191        for ($i = 0; $i <= $levels; $i++) {
192            $tresholds[$i] = pow($max - $min + 1, $i / $levels) + $min - 1;
193        }
194
195        // assign weights
196        foreach ($tags as $tag => $cnt) {
197            foreach ($tresholds as $tresh => $val) {
198                if ($cnt <= $val) {
199                    $tags[$tag] = $tresh;
200                    break;
201                }
202                $tags[$tag] = $levels;
203            }
204        }
205
206        return $tags;
207    }
208
209    /**
210     * Display a tag cloud
211     *
212     * @param array    $tags   list of tags => count
213     * @param string   $type   'tag'
214     * @param Callable $func   The function to print the link (gets tag and ns)
215     * @param bool     $wrap   wrap cloud in UL tags?
216     * @param bool     $return returnn HTML instead of printing?
217     * @param string   $ns     Add this namespace to search links
218     *
219     * @return string
220     */
221    public function html_cloud($tags, $type, $func, $wrap = true, $return = false, $ns = '') {
222        global $INFO;
223
224        $hidden_str = $this->getConf('hiddenprefix');
225        $hidden_len = strlen($hidden_str);
226
227        $ret = '';
228        if ($wrap) {
229            $ret .= '<ul class="tagging_cloud clearfix">';
230        }
231        if (count($tags) === 0) {
232            // Produce valid XHTML (ul needs a child)
233            $this->setupLocale();
234            $ret .= '<li><div class="li">' . $this->lang['js']['no' . $type . 's'] . '</div></li>';
235        } else {
236            $tags = $this->cloudData($tags);
237            foreach ($tags as $val => $size) {
238                // skip hidden tags for users that can't edit
239                if ($type === 'tag' and
240                    $hidden_len and
241                    substr($val, 0, $hidden_len) == $hidden_str and
242                    !($this->getUser() && $INFO['writable'])
243                ) {
244                    continue;
245                }
246
247                $ret .= '<li class="t' . $size . '"><div class="li">';
248                $ret .= call_user_func($func, $val, $ns);
249                $ret .= '</div></li>';
250            }
251        }
252        if ($wrap) {
253            $ret .= '</ul>';
254        }
255        if ($return) {
256            return $ret;
257        }
258        echo $ret;
259
260        return '';
261    }
262
263    /**
264     * Get the link to a search for the given tag
265     *
266     * @param string $tag search for this tag
267     * @param string $ns  limit search to this namespace
268     *
269     * @return string
270     */
271    protected function linkToSearch($tag, $ns = '') {
272        return '<a href="' . hsc($this->getTagSearchURL($tag, $ns)) . '">' . $tag . '</a>';
273    }
274
275    /**
276     * Display the Tags for the current page and prepare the tag editing form
277     *
278     * @param bool $print Should the HTML be printed or returned?
279     *
280     * @return string
281     */
282    public function tpl_tags($print = true) {
283        global $INFO;
284        global $lang;
285
286        $filter = array('pid' => $INFO['id']);
287        if ($this->getConf('singleusermode')) {
288            $filter['tagger'] = 'auto';
289        }
290
291        $tags = $this->findItems($filter, 'tag');
292
293        $ret = '';
294
295        $ret .= '<div class="plugin_tagging_edit">';
296        $ret .= $this->html_cloud($tags, 'tag', array($this, 'linkToSearch'), true, true);
297
298        if ($this->getUser() && $INFO['writable']) {
299            $lang['btn_tagging_edit'] = $lang['btn_secedit'];
300            $ret .= '<div id="tagging__edit_buttons_group">';
301            $ret .= html_btn('tagging_edit', $INFO['id'], '', array());
302            if (auth_isadmin()) {
303                $ret .= '<label>' . $this->getLang('toggle admin mode') . '<input type="checkbox" id="tagging__edit_toggle_admin" /></label>';
304            }
305            $ret .= '</div>';
306            $form = new dokuwiki\Form\Form();
307            $form->id('tagging__edit');
308            $form->setHiddenField('tagging[id]', $INFO['id']);
309            $form->setHiddenField('call', 'plugin_tagging_save');
310            $tags = $this->findItems(array(
311                'pid'    => $INFO['id'],
312                'tagger' => $this->getUser(),
313            ), 'tag');
314            $form->addTextarea('tagging[tags]')->val(implode(', ', array_keys($tags)))->addClass('edit')->attr('rows', 4);
315            $form->addButton('', $lang['btn_save'])->id('tagging__edit_save');
316            $form->addButton('', $lang['btn_cancel'])->id('tagging__edit_cancel');
317            $ret .= $form->toHTML();
318        }
319        $ret .= '</div>';
320
321        if ($print) {
322            echo $ret;
323        }
324
325        return $ret;
326    }
327
328    /**
329     * @param string $namespace empty for entire wiki
330     *
331     * @return array
332     */
333    public function getAllTags($namespace = '', $order_by = 'tag', $desc = false) {
334        $order_fields = array('pid', 'tid', 'orig', 'taggers', 'count');
335        if (!in_array($order_by, $order_fields)) {
336            msg('cannot sort by ' . $order_by . ' field does not exists', -1);
337            $order_by = 'tag';
338        }
339
340        $db = $this->getDb();
341
342        $query = 'SELECT    "pid",
343                            CLEANTAG("tag") AS "tid",
344                            GROUP_SORT(GROUP_CONCAT("tag"), \', \') AS "orig",
345                            GROUP_SORT(GROUP_CONCAT("tagger"), \', \') AS "taggers",
346                            COUNT(*) AS "count"
347                        FROM "taggings"
348                        WHERE "pid" GLOB ?
349                        GROUP BY "tid"
350                        ORDER BY ' . $order_by;
351        if ($desc) {
352            $query .= ' DESC';
353        }
354
355        $res = $db->query($query, $this->globNamespace($namespace));
356
357        return $db->res2arr($res);
358    }
359
360    /**
361     * Get all pages with tags and their tags
362     *
363     * @return array ['pid' => ['tag1','tag2','tag3']]
364     */
365    public function getAllTagsByPage() {
366        $query = '
367        SELECT pid, GROUP_CONCAT(tag) AS tags
368        FROM taggings
369        GROUP BY pid
370        ';
371        $db = $this->getDb();
372        $res = $db->query($query);
373        return array_map(
374            function ($i) {
375                return explode(',', $i);
376            },
377            array_column($db->res2arr($res), 'tags', 'pid')
378        );
379    }
380
381    /**
382     * Renames a tag
383     *
384     * @param string $formerTagName
385     * @param string $newTagName
386     */
387    public function renameTag($formerTagName, $newTagName) {
388
389        if (empty($formerTagName) || empty($newTagName)) {
390            msg($this->getLang("admin enter tag names"), -1);
391
392            return;
393        }
394
395        $db = $this->getDb();
396
397        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ?', $this->cleanTag($formerTagName));
398        $check = $db->res2arr($res);
399
400        if (empty($check)) {
401            msg($this->getLang("admin tag does not exists"), -1);
402
403            return;
404        }
405
406        $res = $db->query("UPDATE taggings SET tag = ? WHERE CLEANTAG(tag) = ?", $newTagName, $this->cleanTag($formerTagName));
407        $db->res2arr($res);
408
409        msg($this->getLang("admin renamed"), 1);
410
411        return;
412    }
413
414    /**
415     * Rename or delete a tag for all users
416     *
417     * @param string $pid
418     * @param string $formerTagName
419     * @param string $newTagName
420     *
421     * @return array
422     */
423    public function modifyPageTag($pid, $formerTagName, $newTagName) {
424
425        $db = $this->getDb();
426
427        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ? AND pid = ?', $this->cleanTag($formerTagName), $pid);
428        $check = $db->res2arr($res);
429
430        if (empty($check)) {
431            return array(true, $this->getLang('admin tag does not exists'));
432        }
433
434        if (empty($newTagName)) {
435            $res = $db->query('DELETE FROM taggings WHERE pid = ? AND CLEANTAG(tag) = ?', $pid, $this->cleanTag($formerTagName));
436        } else {
437            $res = $db->query('UPDATE taggings SET tag = ? WHERE pid = ? AND CLEANTAG(tag) = ?', $newTagName, $pid, $this->cleanTag($formerTagName));
438        }
439        $db->res2arr($res);
440
441        return array(false, $this->getLang('admin renamed'));
442    }
443
444    /**
445     * Deletes a tag
446     *
447     * @param array  $tags
448     * @param string $namespace current namespace context as in getAllTags()
449     */
450    public function deleteTags($tags, $namespace = '') {
451        if (empty($tags)) {
452            return;
453        }
454
455        $namespace = cleanId($namespace);
456
457        $db = $this->getDB();
458
459        $queryBody = 'FROM taggings WHERE pid GLOB ? AND (' .
460            implode(' OR ', array_fill(0, count($tags), 'CLEANTAG(tag) = ?')) . ')';
461        $args = array_map(array($this, 'cleanTag'), $tags);
462        array_unshift($args, $this->globNamespace($namespace));
463
464
465        $affectedPagesQuery= 'SELECT DISTINCT pid ' . $queryBody;
466        $resAffectedPages = $db->query($affectedPagesQuery, $args);
467        $numAffectedPages = count($resAffectedPages->fetchAll());
468
469        $deleteQuery = 'DELETE ' . $queryBody;
470        $db->query($deleteQuery, $args);
471
472        msg(sprintf($this->getLang("admin deleted"), count($tags), $numAffectedPages), 1);
473    }
474
475    /**
476     * Updates tags with a new page name
477     *
478     * @param string $oldName
479     * @param string $newName
480     */
481    public function renamePage($oldName, $newName) {
482        $db = $this->getDb();
483        $db->query('UPDATE taggings SET pid = ? WHERE pid = ?', $newName, $oldName);
484    }
485
486    /**
487     * Extracts tags from search query
488     *
489     * @param array $parsedQuery
490     * @return array
491     */
492    public function getTags($parsedQuery)
493    {
494        $tags = [];
495        if (isset($parsedQuery['phrases'][0])) {
496            $tags = $parsedQuery['phrases'];
497        } elseif (isset($parsedQuery['and'][0])) {
498            $tags = $parsedQuery['and'];
499        } elseif (isset($parsedQuery['tag'])) {
500            // handle autocomplete call
501            $tags[] = $parsedQuery['tag'];
502        }
503        return $tags;
504    }
505
506    /**
507     * Search for tagged pages
508     *
509     * @return array
510     */
511    public function searchPages()
512    {
513        global $INPUT;
514        global $QUERY;
515        $parsedQuery = ft_queryParser(new Doku_Indexer(), $QUERY);
516
517        /** @var helper_plugin_tagging_querybuilder $queryBuilder */
518        $queryBuilder = new helper_plugin_tagging_querybuilder();
519
520        $queryBuilder->setField('pid');
521        $queryBuilder->setTags($this->getTags($parsedQuery));
522        $queryBuilder->setLogicalAnd($INPUT->str('taggings') === 'and');
523        if (isset($parsedQuery['ns'])) $queryBuilder->includeNS($parsedQuery['ns']);
524        if (isset($parsedQuery['notns'])) $queryBuilder->excludeNS($parsedQuery['notns']);
525        if (isset($parsedQuery['tagger'])) $queryBuilder->setTagger($parsedQuery['tagger']);
526        if (isset($parsedQuery['pid'])) $queryBuilder->setPid($parsedQuery['pid']);
527
528        return $this->queryDb($queryBuilder->getPages());
529    }
530
531    /**
532     * Executes the query and returns the results as array
533     *
534     * @param helper_plugin_tagging_querybuilder $query
535     * @return array
536     */
537    protected function queryDb($query)
538    {
539        $db = $this->getDB();
540        if (!$db) {
541            return [];
542        }
543
544        $res = $db->query($query->getSql(), $query->getParameterValues());
545        $res = $db->res2arr($res);
546
547        $ret = [];
548        foreach ($res as $row) {
549            $ret[$row['item']] = $row['cnt'];
550        }
551        return $ret;
552    }
553}
554