1<?php
2/**
3 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
4 * @author     Esther Brunner <wikidesign@gmail.com>
5 */
6
7/**
8 * Helper part of the tag plugin, allows to query and print tags
9 */
10class helper_plugin_tag extends DokuWiki_Plugin {
11
12    var $namespace  = '';      // namespace tag links point to
13
14    var $sort       = '';      // sort key
15    var $topic_idx  = array();
16
17    /**
18     * Constructor gets default preferences and language strings
19     */
20    function __construct() {
21        global $ID;
22
23        $this->namespace = $this->getConf('namespace');
24        if (!$this->namespace) $this->namespace = getNS($ID);
25        $this->sort = $this->getConf('sortkey');
26    }
27
28    /**
29     * Returns some documentation of the methods provided by this helper part
30     *
31     * @return array Method description
32     */
33    function getMethods() {
34        $result = array();
35        $result[] = array(
36                'name'   => 'th',
37                'desc'   => 'returns the header for the tags column for pagelist',
38                'return' => array('header' => 'string'),
39                );
40        $result[] = array(
41                'name'   => 'td',
42                'desc'   => 'returns the tag links of a given page',
43                'params' => array('id' => 'string'),
44                'return' => array('links' => 'string'),
45                );
46        $result[] = array(
47                'name'   => 'tagLinks',
48                'desc'   => 'generates tag links for given words',
49                'params' => array('tags' => 'array'),
50                'return' => array('links' => 'string'),
51                );
52        $result[] = array(
53                'name'   => 'getTopic',
54                'desc'   => 'returns a list of pages tagged with the given keyword',
55                'params' => array(
56                    'namespace (optional)' => 'string',
57                    'number (not used)' => 'integer',
58                    'tag (required)' => 'string'),
59                'return' => array('pages' => 'array'),
60                );
61        $result[] = array(
62                'name'   => 'tagRefine',
63                'desc'   => 'refines an array of pages with tags',
64                'params' => array(
65                    'pages to refine' => 'array',
66                    'refinement tags' => 'string'),
67                'return' => array('pages' => 'array'),
68                );
69        $result[] = array(
70                'name'   => 'tagOccurrences',
71                'desc'   => 'returns a list of tags with their number of occurrences',
72                'params' => array(
73                    'list of tags to get the occurrences for' => 'array',
74                    'namespaces to which the search shall be restricted' => 'array',
75                    'if all tags shall be returned (then the first parameter is ignored)' => 'boolean',
76                    'if the namespaces shall be searched recursively' => 'boolean'),
77                'return' => array('tags' => 'array'),
78                );
79        return $result;
80    }
81
82    /**
83     * Returns the column header for th Pagelist Plugin
84     */
85    function th() {
86        return $this->getLang('tags');
87    }
88
89    /**
90     * Returns the cell data for the Pagelist Plugin
91     */
92    function td($id) {
93        $subject = $this->_getSubjectMetadata($id);
94        return $this->tagLinks($subject);
95    }
96
97    /**
98     * Returns the links for given tags
99     *
100     * @param array $tags an array of tags
101     * @return string HTML link tags
102     */
103    function tagLinks($tags) {
104        if (empty($tags) || ($tags[0] == '')) return '';
105
106        $links = array();
107        foreach ($tags as $tag) {
108            $links[] = $this->tagLink($tag);
109        }
110        return implode(','.DOKU_LF.DOKU_TAB, $links);
111    }
112
113    /**
114     * Returns the link for one given tag
115     *
116     * @param string $tag the tag the link shall point to
117     * @param string $title the title of the link (optional)
118     * @param bool   $dynamic if the link class shall be changed if no pages with the specified tag exist
119     * @return string The HTML code of the link
120     */
121    function tagLink($tag, $title = '', $dynamic = false) {
122        global $conf;
123        $svtag = $tag;
124        $tag_title = str_replace('_', ' ', noNS($tag));
125        resolve_pageid($this->namespace, $tag, $exists); // resolve shortcuts
126        if ($exists) {
127            $class = 'wikilink1';
128            $url   = wl($tag);
129            if ($conf['useheading']) {
130                // important: set sendond param to false to prevent recursion!
131                $heading = p_get_first_heading($tag, false);
132                if ($heading) $tag_title = $heading;
133            }
134        } else {
135            if ($dynamic) {
136                $pages = $this->getTopic('', 1, $svtag);
137                if (empty($pages)) {
138                    $class = 'wikilink2';
139                } else {
140                    $class = 'wikilink1';
141                }
142            } else {
143                $class = 'wikilink1';
144            }
145            $url   = wl($tag, array('do'=>'showtag', 'tag'=>$svtag));
146        }
147        if (!$title) $title = $tag_title;
148        $link = array(
149            'href' => $url,
150            'class' => $class,
151            'tooltip' => hsc($tag),
152            'title' => hsc($title)
153        );
154        trigger_event('PLUGIN_TAG_LINK', $link);
155        $link = '<a href="'.$link['href'].'" class="'.$link['class'].'" title="'.$link['tooltip'].'" rel="tag">'.$link['title'].'</a>';
156        return $link;
157    }
158
159    /**
160     * Returns a list of pages with a certain tag; very similar to ft_backlinks()
161     *
162     * @param string $ns A namespace to which all pages need to belong, "." for only the root namespace
163     * @param int    $num The maximum number of pages that shall be returned
164     * @param string $tag The tag that shall be searched
165     * @return array The list of pages
166     *
167     * @author  Esther Brunner <wikidesign@gmail.com>
168     */
169    function getTopic($ns = '', $num = NULL, $tag = '') {
170        if (!$tag) $tag = $_REQUEST['tag'];
171        $tag = $this->_parseTagList($tag, true);
172        $result = array();
173
174        // find the pages using topic.idx
175        $pages = $this->_tagIndexLookup($tag);
176        if (!count($pages)) return $result;
177
178        foreach ($pages as $page) {
179            // exclude pages depending on ACL and namespace
180            if($this->_notVisible($page, $ns)) continue;
181            $tags  = $this->_getSubjectMetadata($page);
182            // don't trust index
183            if (!$this->_checkPageTags($tags, $tag)) continue;
184
185            // get metadata
186            $meta = p_get_metadata($page);
187
188            $perm = auth_quickaclcheck($page);
189
190            // skip drafts unless for users with create privilege
191            $draft = ($meta['type'] == 'draft');
192            if ($draft && ($perm < AUTH_CREATE)) continue;
193
194            $title = $meta['title'];
195            $date  = ($this->sort == 'mdate' ? $meta['date']['modified'] : $meta['date']['created'] );
196            $taglinks = $this->tagLinks($tags);
197
198            // determine the sort key
199            if ($this->sort == 'id') $key = $page;
200            elseif ($this->sort == 'ns') {
201                $pos = strrpos($page, ':');
202                if ($pos === false) $key = "\0".$page;
203                else $key = substr_replace($page, "\0\0", $pos, 1);
204                $key = str_replace(':', "\0", $key);
205            } elseif ($this->sort == 'pagename') $key = noNS($page);
206            elseif ($this->sort == 'title') {
207                $key = utf8_strtolower($title);
208                if (empty($key)) $key = str_replace('_', ' ', noNS($page));
209            } else $key = $date;
210            // make sure that the key is unique
211            $key = $this->_uniqueKey($key, $result);
212
213            $result[$key] = array(
214                    'id'     => $page,
215                    'title'  => $title,
216                    'date'   => $date,
217                    'user'   => $meta['creator'],
218                    'desc'   => $meta['description']['abstract'],
219                    'cat'    => $tags[0],
220                    'tags'   => $taglinks,
221                    'perm'   => $perm,
222                    'exists' => true,
223                    'draft'  => $draft, );
224
225            if ($num && count($result) >= $num) break;
226        }
227
228        // finally sort by sort key
229        if ($this->getConf('sortorder') == 'ascending') ksort($result);
230        else krsort($result);
231
232        return $result;
233    }
234
235    /**
236     * Refine found pages with tags (+tag: AND, -tag: (AND) NOT)
237     *
238     * @param array $pages The pages that shall be filtered, each page needs to be an array with a key "id"
239     * @param string $refine The list of tags in the form "tag +tag2 -tag3". The tags will be cleaned.
240     * @return array The filtered list of pages
241     */
242    function tagRefine($pages, $refine) {
243        if (!is_array($pages)) return $pages; // wrong data type
244        $tags = $this->_parseTagList($refine, true);
245        $all_pages = $this->_tagIndexLookup($tags);
246
247        foreach ($pages as $key => $page) {
248            if (!in_array($page['id'], $all_pages)) unset($pages[$key]);
249        }
250
251        return $pages;
252   }
253
254   /**
255    * Get count of occurrences for a list of tags
256    *
257    * @param array $tags array of tags
258    * @param array $namespaces array of namespaces where to count the tags
259    * @param boolean $allTags boolean if all available tags should be counted
260    * @param boolean $recursive boolean if pages in subnamespaces are allowed
261    * @return array
262    */
263   function tagOccurrences($tags, $namespaces = NULL, $allTags = false, $recursive = NULL) {
264        // map with trim here in order to remove newlines from tags
265        if($allTags) $tags = array_map('trim', idx_getIndex('subject', '_w'));
266        $tags = $this->_cleanTagList($tags);
267        $otags = array(); //occurrences
268        if(!$namespaces || $namespaces[0] == '' || !is_array($namespaces)) $namespaces = NULL; // $namespaces not specified
269
270        $indexer = idx_get_indexer();
271        $indexer_pages = $indexer->lookupKey('subject', $tags, array($this, '_tagCompare'));
272
273        $root_allowed = ($namespaces == NULL ? false : in_array('.', $namespaces));
274        if ($recursive === NULL)
275            $recursive = $this->getConf('list_tags_of_subns');
276
277        foreach ($tags as $tag) {
278            if (!isset($indexer_pages[$tag])) continue;
279
280            // just to be sure remove duplicate pages from the list of pages
281            $pages = array_unique($indexer_pages[$tag]);
282
283            // don't count hidden pages or pages the user can't access
284            // for performance reasons this doesn't take drafts into account
285            $pages = array_filter($pages, array($this, '_isVisible'));
286
287            if (empty($pages)) continue;
288
289            if ($namespaces == NULL || ($root_allowed && $recursive)) {
290                // count all pages
291                $otags[$tag] = count($pages);
292            } else if (!$recursive) {
293                // filter by exact namespace
294                $otags[$tag] = 0;
295                foreach ($pages as $page) {
296                    $ns = getNS($page);
297                    if (($ns == false && $root_allowed) || in_array($ns, $namespaces)) $otags[$tag]++;
298                }
299            } else { // recursive, no root
300                $otags[$tag] = 0;
301                foreach ($pages as $page) {
302                    foreach ($namespaces as $ns) {
303                        if(strpos($page, $ns.':') === 0 ) {
304                            $otags[$tag]++ ;
305                            break;
306                        }
307                    }
308                }
309            }
310            // don't return tags without pages
311            if ($otags[$tag] == 0) unset($otags[$tag]);
312        }
313        return $otags;
314    }
315
316    /**
317     * Get the subject metadata cleaning the result
318     *
319     * @param string $id the page id
320     * @return array
321     */
322    function _getSubjectMetadata($id){
323        $tags = p_get_metadata($id, 'subject');
324        if (!is_array($tags)) $tags = explode(' ', $tags);
325        return array_unique($tags);
326    }
327
328    /**
329     * Tag index lookup
330     *
331     * @param array $tags the tags to filter
332     * @return array the matching page ids
333     */
334    function _tagIndexLookup($tags) {
335        $result = array(); // array of page ids
336
337        $clean_tags = array();
338        foreach ($tags as $i => $tag) {
339            if (($tag[0] == '+') || ($tag[0] == '-'))
340                $clean_tags[$i] = substr($tag, 1);
341            else
342                $clean_tags[$i] = $tag;
343        }
344
345        $indexer = idx_get_indexer();
346        $pages = $indexer->lookupKey('subject', $clean_tags, array($this, '_tagCompare'));
347        // use all pages as basis if the first tag isn't an "or"-tag or if there are no tags given
348        if (empty($tags) || $clean_tags[0] != $tags[0]) $result = $indexer->getPages();
349
350        foreach ($tags as $i => $tag) {
351            $t = $clean_tags[$i];
352            if (!is_array($pages[$t])) $pages[$t] = array();
353
354            if ($tag[0] == '+') {       // AND: add only if in both arrays
355                $result = array_intersect($result, $pages[$t]);
356            } elseif ($tag[0] == '-') { // NOT: remove array from docs
357                $result = array_diff($result, $pages[$t]);
358            } else {                   // OR: add array to docs
359                $result = array_unique(array_merge($result, $pages[$t]));
360            }
361        }
362
363        return $result;
364    }
365
366
367    /**
368     * Splits a string into an array of tags
369     */
370    function _parseTagList($tags, $clean = false) {
371
372        // support for "quoted phrase tags"
373        if (preg_match_all('#".*?"#', $tags, $matches)) {
374            foreach ($matches[0] as $match) {
375                $replace = str_replace(' ', '_', substr($match, 1, -1));
376                $tags = str_replace($match, $replace, $tags);
377            }
378        }
379
380        $tags = preg_split('/ /', $tags, -1, PREG_SPLIT_NO_EMPTY);
381
382        if ($clean) {
383            return $this->_cleanTagList($tags);
384        } else {
385            return $tags;
386        }
387    }
388
389    /**
390     * Clean a list (array) of tags using _cleanTag
391     */
392    function _cleanTagList($tags) {
393        return array_unique(array_map(array($this, '_cleanTag'), $tags));
394    }
395
396    /**
397     * Cleans a tag using cleanID while preserving a possible prefix of + or -
398     */
399    function _cleanTag($tag) {
400        $prefix = substr($tag, 0, 1);
401        $tag = $this->_applyMacro($tag);
402        if ($prefix === '-' || $prefix === '+') {
403            return $prefix.cleanID($tag);
404        } else {
405            return cleanID($tag);
406        }
407    }
408
409    /**
410     * Makes user or date dependent topic lists possible
411     */
412    function _applyMacro($id) {
413        /** @var DokuWiki_Auth_Plugin $auth */
414        global $INFO, $auth;
415
416        $user     = $_SERVER['REMOTE_USER'];
417        $group    = '';
418        // .htaccess auth doesn't provide the auth object
419        if($auth) {
420            $userdata = $auth->getUserData($user);
421            $group    = $userdata['grps'][0];
422        }
423
424        $replace = array(
425                '@USER@'  => cleanID($user),
426                '@NAME@'  => cleanID($INFO['userinfo']['name']),
427                '@GROUP@' => cleanID($group),
428                '@YEAR@'  => date('Y'),
429                '@MONTH@' => date('m'),
430                '@DAY@'   => date('d'),
431                );
432        return str_replace(array_keys($replace), array_values($replace), $id);
433    }
434
435    /**
436     * Non-recursive function to check whether an array key is unique
437     *
438     * @author    Esther Brunner <wikidesign@gmail.com>
439     * @author    Ilya S. Lebedev <ilya@lebedev.net>
440     */
441    function _uniqueKey($key, &$result) {
442
443        // increase numeric keys by one
444        if (is_numeric($key)) {
445            while (array_key_exists($key, $result)) $key++;
446            return $key;
447
448            // append a number to literal keys
449        } else {
450            $num     = 0;
451            $testkey = $key;
452            while (array_key_exists($testkey, $result)) {
453                $testkey = $key.$num;
454                $num++;
455            }
456            return $testkey;
457        }
458    }
459
460    /**
461     * Opposite of _notVisible
462     */
463    function _isVisible($id, $ns='') {
464        return !$this->_notVisible($id, $ns);
465    }
466    /**
467     * Check visibility of the page
468     *
469     * @param string $id the page id
470     * @param string $ns the namespace authorized
471     * @return bool if the page is hidden
472     */
473    function _notVisible($id, $ns="") {
474        if (isHiddenPage($id)) return true; // discard hidden pages
475        // discard if user can't read
476        if (auth_quickaclcheck($id) < AUTH_READ) return true;
477        // filter by namespace, root namespace is identified with a dot
478        if($ns == '.') {
479            // root namespace is specified, discard all pages who lay outside the root namespace
480            if(getNS($id) != false) return true;
481        } else {
482            // ("!==0" namespace found at position 0)
483            if ($ns && (strpos(':'.getNS($id).':', ':'.$ns.':') !== 0)) return true;
484        }
485        return !page_exists($id, '', false);
486    }
487
488    /**
489     * Helper function for the indexer in order to avoid interpreting wildcards
490     */
491    function _tagCompare($tag1, $tag2) {
492        return $tag1 === $tag2;
493    }
494
495    /**
496     * Check if the page is a real candidate for the result of the getTopic
497     *
498     * @param array $pagetags tags on the metadata of the page
499     * @param array $tags tags we are looking
500     * @return bool
501     */
502    function _checkPageTags($pagetags, $tags) {
503        $result = false;
504        foreach($tags as $tag) {
505            if ($tag[0] == "+" and !in_array(substr($tag, 1), $pagetags)) $result = false;
506            if ($tag[0] == "-" and in_array(substr($tag, 1), $pagetags)) $result = false;
507            if (in_array($tag, $pagetags)) $result = true;
508        }
509        return $result;
510    }
511
512}
513// vim:ts=4:sw=4:et:
514