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