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