xref: /plugin/tagging/helper.php (revision 6bb5774fd94f1577a5f6949b1d96175b40847352)
1<?php
2
3if (!defined('DOKU_INC')) {
4    die();
5}
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 (!is_null($db)) {
19            return $db;
20        }
21
22        /** @var helper_plugin_sqlite $db */
23        $db = plugin_load('helper', 'sqlite');
24        if (is_null($db)) {
25            msg('The tagging plugin needs the sqlite plugin', -1);
26
27            return false;
28        }
29        $db->init('tagging', dirname(__FILE__) . '/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(' ', '', $tag);
69        $tag = str_replace('-', '', $tag);
70        $tag = str_replace('_', '', $tag);
71        $tag = utf8_strtolower($tag);
72
73        return $tag;
74    }
75
76    /**
77     * Canonicalizes the namespace, remove the first colon and add glob
78     *
79     * @param $namespace
80     *
81     * @return string
82     */
83    public function globNamespace($namespace) {
84        return cleanId($namespace) . '%';
85    }
86
87    /**
88     * Create or Update tags of a page
89     *
90     * Uses the translation plugin to store the language of a page (if available)
91     *
92     * @param string $id The page ID
93     * @param string $user
94     * @param array  $tags
95     *
96     * @return bool|SQLiteResult
97     */
98    public function replaceTags($id, $user, $tags) {
99        global $conf;
100        /** @var helper_plugin_translation $trans */
101        $trans = plugin_load('helper', 'translation');
102        if ($trans) {
103            $lang = $trans->realLC($trans->getLangPart($id));
104        } else {
105            $lang = $conf['lang'];
106        }
107
108        $db = $this->getDB();
109        $db->query('BEGIN TRANSACTION');
110        $queries = array(array('DELETE FROM taggings WHERE pid = ? AND tagger = ?', $id, $user));
111        foreach ($tags as $tag) {
112            $queries[] = array('INSERT INTO taggings (pid, tagger, tag, lang) VALUES(?, ?, ?, ?)', $id, $user, $tag, $lang);
113        }
114
115        foreach ($queries as $query) {
116            if (!call_user_func_array(array($db, 'query'), $query)) {
117                $db->query('ROLLBACK TRANSACTION');
118
119                return false;
120            }
121        }
122
123        return $db->query('COMMIT TRANSACTION');
124    }
125
126    /**
127     * Get a list of Tags or Pages matching search criteria
128     *
129     * @param array  $filter What to search for array('field' => 'searchterm')
130     * @param string $type   What field to return 'tag'|'pid'
131     * @param int    $limit  Limit to this many results, 0 for all
132     *
133     * @return array associative array in form of value => count
134     */
135    public function findItems($filter, $type, $limit = 0) {
136        $db = $this->getDB();
137        if (!$db) {
138            return array();
139        }
140
141        // create WHERE clause
142        $where = '1=1';
143        foreach ($filter as $field => $value) {
144            // compare clean tags only
145            if ($field === 'tag') {
146                $field = 'CLEANTAG(tag)';
147                $q = 'CLEANTAG(?)';
148            } else {
149                $q = '?';
150            }
151
152            if (substr($field, 0, 6) === 'notpid') {
153                $field = 'pid';
154
155                // detect LIKE filters
156                if ($this->useLike($value)) {
157                    $where .= " AND $field NOT LIKE $q";
158                } else {
159                    $where .= " AND $field != $q";
160                }
161            } else {
162                // detect LIKE filters
163                if ($this->useLike($value)) {
164                    $where .= " AND $field LIKE $q";
165                } else {
166                    $where .= " AND $field = $q";
167                }
168            }
169        }
170        $where .= ' AND GETACCESSLEVEL(pid) >= ' . AUTH_READ;
171
172        // group and order
173        if ($type == 'tag') {
174            $groupby = 'CLEANTAG(tag)';
175            $orderby = 'CLEANTAG(tag)';
176        } else {
177            $groupby = $type;
178            $orderby = "cnt DESC, $type";
179        }
180
181        // limit results
182        if ($limit) {
183            $limit = " LIMIT $limit";
184        } else {
185            $limit = '';
186        }
187
188        // create SQL
189        $sql = "SELECT $type AS item, COUNT(*) AS cnt
190                  FROM taggings
191                 WHERE $where
192              GROUP BY $groupby
193              ORDER BY $orderby
194                $limit
195              ";
196
197        // run query and turn into associative array
198        $res = $db->query($sql, array_values($filter));
199        $res = $db->res2arr($res);
200
201        $ret = array();
202        foreach ($res as $row) {
203            $ret[$row['item']] = $row['cnt'];
204        }
205
206        return $ret;
207    }
208
209    /**
210     * Check if the given string is a LIKE statement
211     *
212     * @param string $value
213     *
214     * @return bool
215     */
216    private function useLike($value) {
217        return strpos($value, '%') === 0 || strrpos($value, '%') === strlen($value) - 1;
218    }
219
220    /**
221     * Constructs the URL to search for a tag
222     *
223     * @param string $tag
224     * @param string $ns
225     *
226     * @return string
227     */
228    public function getTagSearchURL($tag, $ns = '') {
229        // wrap tag in quotes if non clean
230        $ctag = utf8_stripspecials($this->cleanTag($tag));
231        if ($ctag != utf8_strtolower($tag)) {
232            $tag = '"' . $tag . '"';
233        }
234
235        $ret = '?do=search&id=' . rawurlencode($tag);
236        if ($ns) {
237            $ret .= rawurlencode(' @' . $ns);
238        }
239
240        return $ret;
241    }
242
243    /**
244     * Calculates the size levels for the given list of clouds
245     *
246     * Automatically determines sensible tresholds
247     *
248     * @param array $tags list of tags => count
249     * @param int   $levels
250     *
251     * @return mixed
252     */
253    public function cloudData($tags, $levels = 10) {
254        $min = min($tags);
255        $max = max($tags);
256
257        // calculate tresholds
258        $tresholds = array();
259        for ($i = 0; $i <= $levels; $i++) {
260            $tresholds[$i] = pow($max - $min + 1, $i / $levels) + $min - 1;
261        }
262
263        // assign weights
264        foreach ($tags as $tag => $cnt) {
265            foreach ($tresholds as $tresh => $val) {
266                if ($cnt <= $val) {
267                    $tags[$tag] = $tresh;
268                    break;
269                }
270                $tags[$tag] = $levels;
271            }
272        }
273
274        return $tags;
275    }
276
277    /**
278     * Display a tag cloud
279     *
280     * @param array    $tags   list of tags => count
281     * @param string   $type   'tag'
282     * @param Callable $func   The function to print the link (gets tag and ns)
283     * @param bool     $wrap   wrap cloud in UL tags?
284     * @param bool     $return returnn HTML instead of printing?
285     * @param string   $ns     Add this namespace to search links
286     *
287     * @return string
288     */
289    public function html_cloud($tags, $type, $func, $wrap = true, $return = false, $ns = '') {
290        global $INFO;
291
292        $hidden_str = $this->getConf('hiddenprefix');
293        $hidden_len = strlen($hidden_str);
294
295        $ret = '';
296        if ($wrap) {
297            $ret .= '<ul class="tagging_cloud clearfix">';
298        }
299        if (count($tags) === 0) {
300            // Produce valid XHTML (ul needs a child)
301            $this->setupLocale();
302            $ret .= '<li><div class="li">' . $this->lang['js']['no' . $type . 's'] . '</div></li>';
303        } else {
304            $tags = $this->cloudData($tags);
305            foreach ($tags as $val => $size) {
306                // skip hidden tags for users that can't edit
307                if ($type == 'tag' and
308                    $hidden_len and
309                    substr($val, 0, $hidden_len) == $hidden_str and
310                    !($this->getUser() && $INFO['writable'])
311                ) {
312                    continue;
313                }
314
315                $ret .= '<li class="t' . $size . '"><div class="li">';
316                $ret .= call_user_func($func, $val, $ns);
317                $ret .= '</div></li>';
318            }
319        }
320        if ($wrap) {
321            $ret .= '</ul>';
322        }
323        if ($return) {
324            return $ret;
325        }
326        echo $ret;
327
328        return '';
329    }
330
331    /**
332     * Get the link to a search for the given tag
333     *
334     * @param string $tag search for this tag
335     * @param string $ns  limit search to this namespace
336     *
337     * @return string
338     */
339    protected function linkToSearch($tag, $ns = '') {
340        return '<a href="' . hsc($this->getTagSearchURL($tag, $ns)) . '">' . $tag . '</a>';
341    }
342
343    /**
344     * Display the Tags for the current page and prepare the tag editing form
345     *
346     * @param bool $print Should the HTML be printed or returned?
347     *
348     * @return string
349     */
350    public function tpl_tags($print = true) {
351        global $INFO;
352        global $lang;
353
354        $filter = array('pid' => $INFO['id']);
355        if ($this->getConf('singleusermode')) {
356            $filter['tagger'] = 'auto';
357        }
358
359        $tags = $this->findItems($filter, 'tag');
360
361        $ret = '';
362
363        $ret .= '<div class="plugin_tagging_edit">';
364        $ret .= $this->html_cloud($tags, 'tag', array($this, 'linkToSearch'), true, true);
365
366        if ($this->getUser() && $INFO['writable']) {
367            $lang['btn_tagging_edit'] = $lang['btn_secedit'];
368            $ret .= '<div id="tagging__edit_buttons_group">';
369            $ret .= html_btn('tagging_edit', $INFO['id'], '', array());
370            if (auth_isadmin()) {
371                $ret .= '<label>' . $this->getLang('toggle admin mode') . '<input type="checkbox" id="tagging__edit_toggle_admin" /></label>';
372            }
373            $ret .= '</div>';
374            $form = new dokuwiki\Form\Form();
375            $form->id('tagging__edit');
376            $form->setHiddenField('tagging[id]', $INFO['id']);
377            $form->setHiddenField('call', 'plugin_tagging_save');
378            $tags = $this->findItems(array(
379                'pid'    => $INFO['id'],
380                'tagger' => $this->getUser(),
381            ), 'tag');
382            $form->addTextarea('tagging[tags]')->val(implode(', ', array_keys($tags)))->addClass('edit');
383            $form->addButton('', $lang['btn_save'])->id('tagging__edit_save');
384            $form->addButton('', $lang['btn_cancel'])->id('tagging__edit_cancel');
385            $ret .= $form->toHTML();
386        }
387        $ret .= '</div>';
388
389        if ($print) {
390            echo $ret;
391        }
392
393        return $ret;
394    }
395
396    /**
397     * @param string $namespace empty for entire wiki
398     *
399     * @return array
400     */
401    public function getAllTags($namespace = '', $order_by = 'tag', $desc = false) {
402        $order_fields = array('pid', 'tid', 'orig', 'taggers', 'count');
403        if (!in_array($order_by, $order_fields)) {
404            msg('cannot sort by ' . $order_by . ' field does not exists', -1);
405            $order_by = 'tag';
406        }
407
408        $db = $this->getDb();
409
410        $query = 'SELECT    "pid",
411                            CLEANTAG("tag") AS "tid",
412                            GROUP_SORT(GROUP_CONCAT("tag"), \', \') AS "orig",
413                            GROUP_SORT(GROUP_CONCAT("tagger"), \', \') AS "taggers",
414                            COUNT(*) AS "count"
415                        FROM "taggings"
416                        WHERE "pid" LIKE ?
417                        GROUP BY "tid"
418                        ORDER BY ' . $order_by;
419        if ($desc) {
420            $query .= ' DESC';
421        }
422
423        $res = $db->query($query, $this->globNamespace($namespace));
424
425        return $db->res2arr($res);
426    }
427
428    /**
429     * Renames a tag
430     *
431     * @param string $formerTagName
432     * @param string $newTagName
433     */
434    public function renameTag($formerTagName, $newTagName) {
435
436        if (empty($formerTagName) || empty($newTagName)) {
437            msg($this->getLang("admin enter tag names"), -1);
438
439            return;
440        }
441
442        $db = $this->getDb();
443
444        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ?', $this->cleanTag($formerTagName));
445        $check = $db->res2arr($res);
446
447        if (empty($check)) {
448            msg($this->getLang("admin tag does not exists"), -1);
449
450            return;
451        }
452
453        $res = $db->query("UPDATE taggings SET tag = ? WHERE CLEANTAG(tag) = ?", $newTagName, $this->cleanTag($formerTagName));
454        $db->res2arr($res);
455
456        msg($this->getLang("admin renamed"), 1);
457
458        return;
459    }
460
461    /**
462     * Rename or delete a tag for all users
463     *
464     * @param string $pid
465     * @param string $formerTagName
466     * @param string $newTagName
467     *
468     * @return array
469     */
470    public function modifyPageTag($pid, $formerTagName, $newTagName) {
471
472        $db = $this->getDb();
473
474        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ? AND pid = ?', $this->cleanTag($formerTagName), $pid);
475        $check = $db->res2arr($res);
476
477        if (empty($check)) {
478            return array(true, $this->getLang('admin tag does not exists'));
479        }
480
481        if (empty($newTagName)) {
482            $res = $db->query('DELETE FROM taggings WHERE pid = ? AND CLEANTAG(tag) = ?', $pid, $this->cleanTag($formerTagName));
483        } else {
484            $res = $db->query('UPDATE taggings SET tag = ? WHERE pid = ? AND CLEANTAG(tag) = ?', $newTagName, $pid, $this->cleanTag($formerTagName));
485        }
486        $db->res2arr($res);
487
488        return array(false, $this->getLang('admin renamed'));
489    }
490
491    /**
492     * Deletes a tag
493     *
494     * @param array  $tags
495     * @param string $namespace current namespace context as in getAllTags()
496     */
497    public function deleteTags($tags, $namespace = '') {
498        if (empty($tags)) {
499            return;
500        }
501
502        $namespace = cleanId($namespace);
503
504        $db = $this->getDb();
505
506        $query = 'DELETE FROM taggings WHERE pid LIKE ? AND (' .
507            implode(' OR ', array_fill(0, count($tags), 'CLEANTAG(tag) = ?')) . ')';
508
509        $args = array_map(array($this, 'cleanTag'), $tags);
510        array_unshift($args, $this->globNamespace($namespace));
511        $res = $db->query($query, $args);
512
513        msg(sprintf($this->getLang("admin deleted"), count($tags), $res->rowCount()), 1);
514
515        return;
516    }
517}
518