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