xref: /plugin/tagging/helper.php (revision 853810cfb88ccfa088b7d873a42656f788a2d5dc)
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        $db = $this->getDB();
135        if (!$db) {
136            return array();
137        }
138
139        $sql = $this->getWikiSearchSql($filter, $type, $limit);
140
141        // run query and turn into associative array
142        $data = [];
143        foreach ($filter as $item) {
144            $data = array_merge($data, explode(',', $item));
145        }
146        $res = $db->query($sql, $data);
147        $res = $db->res2arr($res);
148
149        $ret = array();
150        foreach ($res as $row) {
151            $ret[$row['item']] = $row['cnt'];
152        }
153
154        return $ret;
155    }
156
157    /**
158     * Check if the given string is a LIKE statement
159     *
160     * @param string $value
161     *
162     * @return bool
163     */
164    private function useLike($value) {
165        return strpos($value, '*') === 0 || strrpos($value, '*') === strlen($value) - 1;
166    }
167
168    /**
169     * Constructs the URL to search for a tag
170     *
171     * @param string $tag
172     * @param string $ns
173     *
174     * @return string
175     */
176    public function getTagSearchURL($tag, $ns = '') {
177        // wrap tag in quotes if non clean
178        $ctag = utf8_stripspecials($this->cleanTag($tag));
179        if ($ctag != utf8_strtolower($tag)) {
180            $tag = '"' . $tag . '"';
181        }
182
183        $ret = '?do=search&sf=1&id=' . rawurlencode($tag);
184        if ($ns) {
185            $ret .= rawurlencode(' @' . $ns);
186        }
187
188        return $ret;
189    }
190
191    /**
192     * Calculates the size levels for the given list of clouds
193     *
194     * Automatically determines sensible tresholds
195     *
196     * @param array $tags list of tags => count
197     * @param int   $levels
198     *
199     * @return mixed
200     */
201    public function cloudData($tags, $levels = 10) {
202        $min = min($tags);
203        $max = max($tags);
204
205        // calculate tresholds
206        $tresholds = array();
207        for ($i = 0; $i <= $levels; $i++) {
208            $tresholds[$i] = pow($max - $min + 1, $i / $levels) + $min - 1;
209        }
210
211        // assign weights
212        foreach ($tags as $tag => $cnt) {
213            foreach ($tresholds as $tresh => $val) {
214                if ($cnt <= $val) {
215                    $tags[$tag] = $tresh;
216                    break;
217                }
218                $tags[$tag] = $levels;
219            }
220        }
221
222        return $tags;
223    }
224
225    /**
226     * Display a tag cloud
227     *
228     * @param array    $tags   list of tags => count
229     * @param string   $type   'tag'
230     * @param Callable $func   The function to print the link (gets tag and ns)
231     * @param bool     $wrap   wrap cloud in UL tags?
232     * @param bool     $return returnn HTML instead of printing?
233     * @param string   $ns     Add this namespace to search links
234     *
235     * @return string
236     */
237    public function html_cloud($tags, $type, $func, $wrap = true, $return = false, $ns = '') {
238        global $INFO;
239
240        $hidden_str = $this->getConf('hiddenprefix');
241        $hidden_len = strlen($hidden_str);
242
243        $ret = '';
244        if ($wrap) {
245            $ret .= '<ul class="tagging_cloud clearfix">';
246        }
247        if (count($tags) === 0) {
248            // Produce valid XHTML (ul needs a child)
249            $this->setupLocale();
250            $ret .= '<li><div class="li">' . $this->lang['js']['no' . $type . 's'] . '</div></li>';
251        } else {
252            $tags = $this->cloudData($tags);
253            foreach ($tags as $val => $size) {
254                // skip hidden tags for users that can't edit
255                if ($type === 'tag' and
256                    $hidden_len and
257                    substr($val, 0, $hidden_len) == $hidden_str and
258                    !($this->getUser() && $INFO['writable'])
259                ) {
260                    continue;
261                }
262
263                $ret .= '<li class="t' . $size . '"><div class="li">';
264                $ret .= call_user_func($func, $val, $ns);
265                $ret .= '</div></li>';
266            }
267        }
268        if ($wrap) {
269            $ret .= '</ul>';
270        }
271        if ($return) {
272            return $ret;
273        }
274        echo $ret;
275
276        return '';
277    }
278
279    /**
280     * Get the link to a search for the given tag
281     *
282     * @param string $tag search for this tag
283     * @param string $ns  limit search to this namespace
284     *
285     * @return string
286     */
287    protected function linkToSearch($tag, $ns = '') {
288        return '<a href="' . hsc($this->getTagSearchURL($tag, $ns)) . '">' . $tag . '</a>';
289    }
290
291    /**
292     * Display the Tags for the current page and prepare the tag editing form
293     *
294     * @param bool $print Should the HTML be printed or returned?
295     *
296     * @return string
297     */
298    public function tpl_tags($print = true) {
299        global $INFO;
300        global $lang;
301
302        $filter = array('pid' => $INFO['id']);
303        if ($this->getConf('singleusermode')) {
304            $filter['tagger'] = 'auto';
305        }
306
307        $tags = $this->findItems($filter, 'tag');
308
309        $ret = '';
310
311        $ret .= '<div class="plugin_tagging_edit">';
312        $ret .= $this->html_cloud($tags, 'tag', array($this, 'linkToSearch'), true, true);
313
314        if ($this->getUser() && $INFO['writable']) {
315            $lang['btn_tagging_edit'] = $lang['btn_secedit'];
316            $ret .= '<div id="tagging__edit_buttons_group">';
317            $ret .= html_btn('tagging_edit', $INFO['id'], '', array());
318            if (auth_isadmin()) {
319                $ret .= '<label>' . $this->getLang('toggle admin mode') . '<input type="checkbox" id="tagging__edit_toggle_admin" /></label>';
320            }
321            $ret .= '</div>';
322            $form = new dokuwiki\Form\Form();
323            $form->id('tagging__edit');
324            $form->setHiddenField('tagging[id]', $INFO['id']);
325            $form->setHiddenField('call', 'plugin_tagging_save');
326            $tags = $this->findItems(array(
327                'pid'    => $INFO['id'],
328                'tagger' => $this->getUser(),
329            ), 'tag');
330            $form->addTextarea('tagging[tags]')->val(implode(', ', array_keys($tags)))->addClass('edit')->attr('rows', 4);
331            $form->addButton('', $lang['btn_save'])->id('tagging__edit_save');
332            $form->addButton('', $lang['btn_cancel'])->id('tagging__edit_cancel');
333            $ret .= $form->toHTML();
334        }
335        $ret .= '</div>';
336
337        if ($print) {
338            echo $ret;
339        }
340
341        return $ret;
342    }
343
344    /**
345     * @param string $namespace empty for entire wiki
346     *
347     * @return array
348     */
349    public function getAllTags($namespace = '', $order_by = 'tag', $desc = false) {
350        $order_fields = array('pid', 'tid', 'orig', 'taggers', 'count');
351        if (!in_array($order_by, $order_fields)) {
352            msg('cannot sort by ' . $order_by . ' field does not exists', -1);
353            $order_by = 'tag';
354        }
355
356        $db = $this->getDb();
357
358        $query = 'SELECT    "pid",
359                            CLEANTAG("tag") AS "tid",
360                            GROUP_SORT(GROUP_CONCAT("tag"), \', \') AS "orig",
361                            GROUP_SORT(GROUP_CONCAT("tagger"), \', \') AS "taggers",
362                            COUNT(*) AS "count"
363                        FROM "taggings"
364                        WHERE "pid" GLOB ?
365                        GROUP BY "tid"
366                        ORDER BY ' . $order_by;
367        if ($desc) {
368            $query .= ' DESC';
369        }
370
371        $res = $db->query($query, $this->globNamespace($namespace));
372
373        return $db->res2arr($res);
374    }
375
376    /**
377     * Get all pages with tags and their tags
378     *
379     * @return array ['pid' => ['tag1','tag2','tag3']]
380     */
381    public function getAllTagsByPage() {
382        $query = '
383        SELECT pid, GROUP_CONCAT(tag) AS tags
384        FROM taggings
385        GROUP BY pid
386        ';
387        $db = $this->getDb();
388        $res = $db->query($query);
389        return array_map(
390            function ($i) {
391                return explode(',', $i);
392            },
393            array_column($db->res2arr($res), 'tags', 'pid')
394        );
395    }
396
397    /**
398     * Renames a tag
399     *
400     * @param string $formerTagName
401     * @param string $newTagName
402     */
403    public function renameTag($formerTagName, $newTagName) {
404
405        if (empty($formerTagName) || empty($newTagName)) {
406            msg($this->getLang("admin enter tag names"), -1);
407
408            return;
409        }
410
411        $db = $this->getDb();
412
413        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ?', $this->cleanTag($formerTagName));
414        $check = $db->res2arr($res);
415
416        if (empty($check)) {
417            msg($this->getLang("admin tag does not exists"), -1);
418
419            return;
420        }
421
422        $res = $db->query("UPDATE taggings SET tag = ? WHERE CLEANTAG(tag) = ?", $newTagName, $this->cleanTag($formerTagName));
423        $db->res2arr($res);
424
425        msg($this->getLang("admin renamed"), 1);
426
427        return;
428    }
429
430    /**
431     * Rename or delete a tag for all users
432     *
433     * @param string $pid
434     * @param string $formerTagName
435     * @param string $newTagName
436     *
437     * @return array
438     */
439    public function modifyPageTag($pid, $formerTagName, $newTagName) {
440
441        $db = $this->getDb();
442
443        $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ? AND pid = ?', $this->cleanTag($formerTagName), $pid);
444        $check = $db->res2arr($res);
445
446        if (empty($check)) {
447            return array(true, $this->getLang('admin tag does not exists'));
448        }
449
450        if (empty($newTagName)) {
451            $res = $db->query('DELETE FROM taggings WHERE pid = ? AND CLEANTAG(tag) = ?', $pid, $this->cleanTag($formerTagName));
452        } else {
453            $res = $db->query('UPDATE taggings SET tag = ? WHERE pid = ? AND CLEANTAG(tag) = ?', $newTagName, $pid, $this->cleanTag($formerTagName));
454        }
455        $db->res2arr($res);
456
457        return array(false, $this->getLang('admin renamed'));
458    }
459
460    /**
461     * Deletes a tag
462     *
463     * @param array  $tags
464     * @param string $namespace current namespace context as in getAllTags()
465     */
466    public function deleteTags($tags, $namespace = '') {
467        if (empty($tags)) {
468            return;
469        }
470
471        $namespace = cleanId($namespace);
472
473        $db = $this->getDB();
474
475        $queryBody = 'FROM taggings WHERE pid GLOB ? AND (' .
476            implode(' OR ', array_fill(0, count($tags), 'CLEANTAG(tag) = ?')) . ')';
477        $args = array_map(array($this, 'cleanTag'), $tags);
478        array_unshift($args, $this->globNamespace($namespace));
479
480
481        $affectedPagesQuery= 'SELECT DISTINCT pid ' . $queryBody;
482        $resAffectedPages = $db->query($affectedPagesQuery, $args);
483        $numAffectedPages = count($resAffectedPages->fetchAll());
484
485        $deleteQuery = 'DELETE ' . $queryBody;
486        $db->query($deleteQuery, $args);
487
488        msg(sprintf($this->getLang("admin deleted"), count($tags), $numAffectedPages), 1);
489    }
490
491    /**
492     * Updates tags with a new page name
493     *
494     * @param string $oldName
495     * @param string $newName
496     */
497    public function renamePage($oldName, $newName) {
498        $db = $this->getDb();
499        $db->query('UPDATE taggings SET pid = ? WHERE pid = ?', $newName, $oldName);
500    }
501
502    /**
503     * Create query SQL from search filter
504     *
505     * @param array $filter
506     * @param string $type
507     * @param int $limit
508     * @return string
509     */
510    public function getWikiSearchSql(array $filter, $type, $limit)
511    {
512        // create WHERE clause
513        $where = '1=1';
514
515        foreach ($filter as $field => $value) {
516            // needed for an AND search
517            $subvalsCount = count(explode(',', $value));
518            $having = ($field === 'andtag') ? 'HAVING cnt = ' . $subvalsCount : '';
519
520            // compare clean tags only
521            if ($field === 'ortag' || $field === 'andtag') {
522                $field = 'CLEANTAG(tag)';
523                // multiple tags in one filter
524                $q = implode(', ', array_fill(0, $subvalsCount, 'CLEANTAG(?)'));
525            } else {
526                $q = '?';
527            }
528
529            if (substr($field, 0, 6) === 'notpid') {
530                $field = 'pid';
531
532                // detect LIKE filters
533                if ($this->useLike($value)) {
534                    $where .= " AND $field NOT GLOB $q";
535                } else {
536                    $where .= " AND $field NOT IN ( $q )";
537                }
538            } else {
539                // detect LIKE filters
540                if ($this->useLike($value)) {
541                    $where .= " AND $field GLOB $q";
542                } else {
543                    $where .= " AND $field IN ( $q )";
544                }
545            }
546        }
547        $where .= ' AND GETACCESSLEVEL(pid) >= ' . AUTH_READ;
548
549        // group and order
550        if ($type === 'tag') {
551            $groupby = 'CLEANTAG(tag)';
552            $orderby = 'CLEANTAG(tag)';
553        } else {
554            $groupby = $type;
555            $orderby = "cnt DESC, $type";
556        }
557
558        // limit results
559        if ($limit) {
560            $limit = " LIMIT $limit";
561        } else {
562            $limit = '';
563        }
564
565        // create SQL
566        return "SELECT $type AS item, COUNT(*) AS cnt
567                  FROM taggings
568                 WHERE $where
569              GROUP BY $groupby
570              $having
571              ORDER BY $orderby
572                $limit
573              ";
574    }
575}
576