*/
use dokuwiki\Extension\Event;
use dokuwiki\Utf8\PhpString;
/**
* Helper part of the tag plugin, allows to query and print tags
*/
class helper_plugin_tag extends DokuWiki_Plugin {
/**
* @deprecated 2022-10-02 Use the helper_plugin_tag::getNamespace() function instead!
* @var string namespace tag links point to
*/
public $namespace;
/**
* @var string sort key: 'cdate', 'mdate', 'pagename', 'id', 'ns', 'title'
*/
protected $sort;
/**
* @var string sort order 'ascending' or 'descending'
*/
protected $sortorder;
/**
* @var array
* @deprecated 2022-08-31 Not used/filled any more by tag plugin
*/
var $topic_idx = [];
/**
* Constructor gets default preferences and language strings
*/
public function __construct() {
global $ID;
$this->namespace = $this->getConf('namespace');
if (!$this->namespace) {
$this->namespace = getNS($ID);
}
$this->sort = $this->getConf('sortkey');
$this->sortorder = $this->getConf('sortorder');
}
/**
* Returns some documentation of the methods provided by this helper part
*
* @return array Method description
*/
public function getMethods() {
$result = [];
$result[] = [
'name' => 'overrideSortFlags',
'desc' => 'takes an array of sortflags and overrides predefined value',
'params' => [
'name' => 'string'
]
];
$result[] = [
'name' => 'th',
'desc' => 'returns the header for the tags column for pagelist',
'return' => ['header' => 'string'],
];
$result[] = [
'name' => 'td',
'desc' => 'returns the tag links of a given page',
'params' => ['id' => 'string'],
'return' => ['links' => 'string'],
];
$result[] = [
'name' => 'tagLinks',
'desc' => 'generates tag links for given words',
'params' => ['tags' => 'array'],
'return' => ['links' => 'string'],
];
$result[] = [
'name' => 'getTopic',
'desc' => 'returns a list of pages tagged with the given keyword',
'params' => [
'namespace (optional)' => 'string',
'number (not used)' => 'integer',
'tag (required)' => 'string'
],
'return' => ['pages' => 'array'],
];
$result[] = [
'name' => 'tagRefine',
'desc' => 'refines an array of pages with tags',
'params' => [
'pages to refine' => 'array',
'refinement tags' => 'string'
],
'return' => ['pages' => 'array'],
];
$result[] = [
'name' => 'tagOccurrences',
'desc' => 'returns a list of tags with their number of occurrences',
'params' => [
'list of tags to get the occurrences for' => 'array',
'namespaces to which the search shall be restricted' => 'array',
'if all tags shall be returned (then the first parameter is ignored)' => 'boolean',
'if the namespaces shall be searched recursively' => 'boolean'
],
'return' => ['tags' => 'array'],
];
return $result;
}
/**
* Takes an array of sortflags and overrides predefined value
*
* @param array $newflags recognizes:
* 'sortkey' => string,
* 'sortorder' => string
* @return void
*/
public function overrideSortFlags($newflags = []) {
if(isset($newflags['sortkey'])) {
$this->sort = trim($newflags['sortkey']);
}
if(isset($newflags['sortorder'])) {
$this->sortorder = trim($newflags['sortorder']);
}
}
/**
* Returns the column header for the Pagelist Plugin
*/
public function th() {
return $this->getLang('tags');
}
/**
* Returns the cell data for the Pagelist Plugin
*
* @param string $id page id
* @return string html content for cell of table
*/
public function td($id) {
$subject = $this->getTagsFromPageMetadata($id);
return $this->tagLinks($subject);
}
/**
*
* @return string|false
*/
public function getNamespace() {
return $this->namespace;
}
/**
* Returns the links for given tags
*
* @param array $tags an array of tags
* @return string HTML link tags
*/
public function tagLinks($tags) {
if (empty($tags) || ($tags[0] == '')) {
return '';
}
$links = array();
foreach ($tags as $tag) {
$links[] = $this->tagLink($tag);
}
return implode(','.DOKU_LF.DOKU_TAB, $links);
}
/**
* Returns the link for one given tag
*
* @param string $tag the tag the link shall point to
* @param string $title the title of the link (optional)
* @param bool $dynamic if the link class shall be changed if no pages with the specified tag exist
* @return string The HTML code of the link
*/
public function tagLink($tag, $title = '', $dynamic = false) {
global $conf;
$svtag = $tag;
$tagTitle = str_replace('_', ' ', noNS($tag));
// Igor and later
if (class_exists('dokuwiki\File\PageResolver')) {
$resolver = new dokuwiki\File\PageResolver($this->namespace . ':something');
$tag = $resolver->resolveId($tag);
$exists = page_exists($tag);
} else {
// Compatibility with older releases
resolve_pageid($this->namespace, $tag, $exists);
}
if ($exists) {
$class = 'wikilink1';
$url = wl($tag);
if ($conf['useheading']) {
// important: set render param to false to prevent recursion!
$heading = p_get_first_heading($tag, false);
if ($heading) {
$tagTitle = $heading;
}
}
} else {
if ($dynamic) {
$pages = $this->getTopic('', 1, $svtag);
if (empty($pages)) {
$class = 'wikilink2';
} else {
$class = 'wikilink1';
}
} else {
$class = 'wikilink1';
}
$url = wl($tag, ['do'=>'showtag', 'tag'=>$svtag]);
}
if (!$title) {
$title = $tagTitle;
}
$link = [
'href' => $url,
'class' => $class,
'tooltip' => hsc($tag),
'title' => hsc($title)
];
Event::createAndTrigger('PLUGIN_TAG_LINK', $link);
return ''
.$link['title']
.'';
}
/**
* Returns a list of pages with a certain tag; very similar to ft_backlinks()
*
* @param string $ns A namespace to which all pages need to belong, "." for only the root namespace
* @param int $num The maximum number of pages that shall be returned
* @param string $tagquery The tag string that shall be searched e.g. 'tag +tag -tag'
* @return array The list of pages
*
* @author Esther Brunner
*/
public function getTopic($ns = '', $num = null, $tagquery = '') {
global $INPUT;
if (!$tagquery) {
$tagquery = $INPUT->str('tag');
}
$queryTags = $this->parseTagList($tagquery, true);
$result = [];
// find the pages using subject_w.idx
$pages = $this->getIndexedPagesMatchingTagQuery($queryTags);
if (!count($pages)) {
return $result;
}
foreach ($pages as $page) {
// exclude pages depending on ACL and namespace
if($this->isNotVisible($page, $ns)) continue;
$pageTags = $this->getTagsFromPageMetadata($page);
// don't trust index
if (!$this->matchWithPageTags($pageTags, $queryTags)) continue;
// get metadata
$meta = p_get_metadata($page);
$perm = auth_quickaclcheck($page);
// skip drafts unless for users with create privilege
$isDraft = isset($meta['type']) && $meta['type'] == 'draft';
if ($isDraft && $perm < AUTH_CREATE) continue;
$title = $meta['title'] ?? '';
$date = ($this->sort == 'mdate' ? $meta['date']['modified'] : $meta['date']['created'] );
$taglinks = $this->tagLinks($pageTags);
// determine the sort key
switch($this->sort) {
case 'id':
$sortkey = $page;
break;
case 'ns':
$pos = strrpos($page, ':');
if ($pos === false) {
$sortkey = "\0".$page;
} else {
$sortkey = substr_replace($page, "\0\0", $pos, 1);
}
$sortkey = str_replace(':', "\0", $sortkey);
break;
case 'pagename':
$sortkey = noNS($page);
break;
case 'title':
$sortkey = PhpString::strtolower($title);
if (empty($sortkey)) {
$sortkey = str_replace('_', ' ', noNS($page));
}
break;
default:
$sortkey = $date;
}
// make sure that the key is unique
$sortkey = $this->uniqueKey($sortkey, $result);
$result[$sortkey] = [
'id' => $page,
'title' => $title,
'date' => $date,
'user' => $meta['creator'],
'desc' => $meta['description']['abstract'],
'cat' => $pageTags[0],
'tags' => $taglinks,
'perm' => $perm,
'exists' => true,
'draft' => $isDraft
];
if ($num && count($result) >= $num) {
break;
}
}
// finally sort by sort key
if ($this->sortorder == 'ascending') {
ksort($result);
} else {
krsort($result);
}
return $result;
}
/**
* Refine found pages with tags (+tag: AND, -tag: (AND) NOT)
*
* @param array $pages The pages that shall be filtered, each page needs to be an array with a key "id"
* @param string $tagquery The list of tags in the form "tag +tag2 -tag3". The tags will be cleaned.
* @return array The filtered list of pages
*/
public function tagRefine($pages, $tagquery) {
if (!is_array($pages)) {
// wrong data type
return $pages;
}
$queryTags = $this->parseTagList($tagquery, true);
$allMatchedPages = $this->getIndexedPagesMatchingTagQuery($queryTags);
foreach ($pages as $key => $page) {
if (!in_array($page['id'], $allMatchedPages)) {
unset($pages[$key]);
}
}
return $pages;
}
/**
* Get count of occurrences for a list of tags
*
* @param array $tags array of tags
* @param array $namespaces array of namespaces where to count the tags
* @param boolean $allTags boolean if all available tags should be counted
* @param boolean $isRecursive boolean if counting of pages in subnamespaces is allowed
* @return array with:
* $tag => int count
*/
public function tagOccurrences($tags, $namespaces = null, $allTags = false, $isRecursive = null) {
// map with trim here in order to remove newlines from tags
if($allTags) {
$tags = array_map('trim', idx_getIndex('subject', '_w'));
}
$tags = $this->cleanTagList($tags);
$tagOccurrences = []; //occurrences
// $namespaces not specified
if(!$namespaces || $namespaces[0] == '' || !is_array($namespaces)) {
$namespaces = null;
}
$indexer = idx_get_indexer();
$indexedPagesWithTags = $indexer->lookupKey('subject', $tags, array($this, 'tagCompare'));
$isRootAllowed = !($namespaces === null) && in_array('.', $namespaces);
if ($isRecursive === null) {
$isRecursive = $this->getConf('list_tags_of_subns');
}
foreach ($tags as $tag) {
if (!isset($indexedPagesWithTags[$tag])) continue;
// just to be sure remove duplicate pages from the list of pages
$pages = array_unique($indexedPagesWithTags[$tag]);
// don't count hidden pages or pages the user can't access
// for performance reasons this doesn't take drafts into account
$pages = array_filter($pages, [$this, 'isVisible']);
if (empty($pages)) continue;
if ($namespaces == null || ($isRootAllowed && $isRecursive)) {
// count all pages
$tagOccurrences[$tag] = count($pages);
} else if (!$isRecursive) {
// filter by exact namespace
$tagOccurrences[$tag] = 0;
foreach ($pages as $page) {
$ns = getNS($page);
if (($ns === false && $isRootAllowed) || in_array($ns, $namespaces)) {
$tagOccurrences[$tag]++;
}
}
} else { // recursive, no root
$tagOccurrences[$tag] = 0;
foreach ($pages as $page) {
foreach ($namespaces as $ns) {
if(strpos($page, $ns.':') === 0 ) {
$tagOccurrences[$tag]++ ;
break;
}
}
}
}
// don't return tags without pages
if ($tagOccurrences[$tag] == 0) {
unset($tagOccurrences[$tag]);
}
}
return $tagOccurrences;
}
/**
* Get tags from the 'subject' metadata field
*
* @param string $id the page id
* @return array
*/
protected function getTagsFromPageMetadata($id){
$tags = p_get_metadata($id, 'subject');
if (!is_array($tags)) {
$tags = explode(' ', $tags);
}
return array_unique($tags);
}
/**
* Returns pages from index matching the tag query
*
* @param array $queryTags the tags to filter e.g. ['tag'(OR), '+tag'(AND), '-tag'(NOT)]
* @return array the matching page ids
*/
public function getIndexedPagesMatchingTagQuery($queryTags) {
$result = []; // array of page ids
$cleanTags = [];
foreach ($queryTags as $i => $tag) {
if ($tag[0] == '+' || $tag[0] == '-') {
$cleanTags[$i] = substr($tag, 1);
} else {
$cleanTags[$i] = $tag;
}
}
$indexer = idx_get_indexer();
$pages = $indexer->lookupKey('subject', $cleanTags, [$this, 'tagCompare']);
// use all pages as basis if the first tag isn't an "or"-tag or if there are no tags given
if (empty($queryTags) || $cleanTags[0] != $queryTags[0]) {
$result = $indexer->getPages();
}
foreach ($queryTags as $i => $queryTag) {
$tag = $cleanTags[$i];
if (!is_array($pages[$tag])) {
$pages[$tag] = [];
}
if ($queryTag[0] == '+') { // AND: add only if in both arrays
$result = array_intersect($result, $pages[$tag]);
} elseif ($queryTag[0] == '-') { // NOT: remove array from docs
$result = array_diff($result, $pages[$tag]);
} else { // OR: add array to docs
$result = array_unique(array_merge($result, $pages[$tag]));
}
}
return $result;
}
/**
* Splits a string into an array of tags
*
* @param string $tags tag string, if containing spaces use quotes e.g. "tag with spaces", will be replaced by underscores
* @param bool $clean replace placeholders and clean id
* @return string[]
*/
public function parseTagList($tags, $clean = false) {
// support for "quoted phrase tags", replaces spaces by underscores
if (preg_match_all('#".*?"#', $tags, $matches)) {
foreach ($matches[0] as $match) {
$replace = str_replace(' ', '_', substr($match, 1, -1));
$tags = str_replace($match, $replace, $tags);
}
}
$tags = preg_split('/ /', $tags, -1, PREG_SPLIT_NO_EMPTY);
if ($clean) {
return $this->cleanTagList($tags);
} else {
return $tags;
}
}
/**
* Clean a list (array) of tags using _cleanTag
*
* @param string[] $tags
* @return string[]
*/
public function cleanTagList($tags) {
return array_unique(array_map([$this, 'cleanTag'], $tags));
}
/**
* callback: Cleans a tag using cleanID while preserving a possible prefix of + or -, and replace placeholders
*
* @param string $tag
* @return string
*/
protected function cleanTag($tag) {
$prefix = substr($tag, 0, 1);
$tag = $this->replacePlaceholders($tag);
if ($prefix === '-' || $prefix === '+') {
return $prefix.cleanID($tag);
} else {
return cleanID($tag);
}
}
/**
* Makes user or date dependent topic lists possible by replacing placeholders in tags
*
* @param string $tag
* @return string
*/
protected function replacePlaceholders($tag) {
global $USERINFO, $INPUT;
$user = $INPUT->server->str('REMOTE_USER');
//only available for logged-in users
if(isset($USERINFO)) {
if(is_array($USERINFO) && isset($USERINFO['name'])) {
$name = cleanID($USERINFO['name']);
}
else {
$name = '';
}
// FIXME or delete, is unreliable because just first entry of group array is used, regardless the order of groups..
if(is_array($USERINFO) && is_array($USERINFO['grps']) && isset($USERINFO['grps'][0])) {
$group = cleanID($USERINFO['grps'][0]);
}
else {
$group = '';
}
} else {
$name = '';
$group = '';
}
$replace = [
'@USER@' => cleanID($user),
'@NAME@' => $name,
'@GROUP@' => $group,
'@YEAR@' => date('Y'),
'@MONTH@' => date('m'),
'@DAY@' => date('d'),
];
return str_replace(array_keys($replace), array_values($replace), $tag);
}
/**
* Non-recursive function to check whether an array key is unique
*
* @param int|string $key
* @param array $result
* @return float|int|string
*
* @author Ilya S. Lebedev
* @author Esther Brunner
*/
protected function uniqueKey($key, $result) {
// increase numeric keys by one
if (is_numeric($key)) {
while (array_key_exists($key, $result)) {
$key++;
}
return $key;
// append a number to literal keys
} else {
$num = 0;
$testkey = $key;
while (array_key_exists($testkey, $result)) {
$testkey = $key.$num;
$num++;
}
return $testkey;
}
}
/**
* Opposite of isNotVisible()
*
* @param string $id the page id
* @param string $ns
* @return bool if the page is shown
*/
public function isVisible($id, $ns='') {
return !$this->isNotVisible($id, $ns);
}
/**
* Check visibility of the page
*
* @param string $id the page id
* @param string $ns the namespace authorized
* @return bool if the page is hidden
*/
public function isNotVisible($id, $ns="") {
// discard hidden pages
if (isHiddenPage($id)) {
return true;
}
// discard if user can't read
if (auth_quickaclcheck($id) < AUTH_READ) {
return true;
}
// filter by namespace, root namespace is identified with a dot
if($ns == '.') {
// root namespace is specified, discard all pages who lay outside the root namespace
if(getNS($id) !== false) {
return true;
}
} else {
// hide if ns is not matching the page id (match gives strpos===0)
if ($ns && strpos(':'.getNS($id).':', ':'.$ns.':') !== 0) {
return true;
}
}
return !page_exists($id, '', false);
}
/**
* callback Helper function for the indexer in order to avoid interpreting wildcards
*
* @param string $tag1 tag being searched
* @param string $tag2 tag from index
* @return bool is equal?
*/
public function tagCompare($tag1, $tag2) {
return $tag1 === $tag2;
}
/**
* Check if the page is a real candidate for the result of the getTopic by comparing its tags with the wanted tags
*
* @param string[] $pageTags cleaned tags from the metadata of the page
* @param string[] $queryTags tags we are looking ['tag', '+tag', '-tag']
* @return bool
*/
protected function matchWithPageTags($pageTags, $queryTags) {
$result = false;
foreach($queryTags as $tag) {
if ($tag[0] == "+" and !in_array(substr($tag, 1), $pageTags)) {
$result = false;
}
if ($tag[0] == "-" and in_array(substr($tag, 1), $pageTags)) {
$result = false;
}
if (in_array($tag, $pageTags)) {
$result = true;
}
}
return $result;
}
/**
* @deprecated 2022-08-31 use parseTagList() instead !
*
* @param string $tags
* @param bool $clean
* @return string[]
*/
public function _parseTagList($tags, $clean = false) {
return $this->parseTagList($tags, $clean);
}
/**
* Opposite of isNotVisible()
*
* @deprecated 2022-08-31 use isVisible() instead !
*
* @param string $id
* @param string $ns
* @return bool
*/
public function _isVisible($id, $ns='') {
return $this->isVisible($id, $ns);
}
/**
* Clean a list (array) of tags using _cleanTag
*
* @deprecated 2022-08-31 use cleanTagList() instead !
*
* @param string[] $tags
* @return string[]
*/
public function _cleanTagList($tags) {
return $this->cleanTagList($tags);
}
/**
* Returns pages from index matching the tag query
*
* @param array $queryTags the tags to filter e.g. ['tag'(OR), '+tag'(AND), '-tag'(NOT)]
* @return array the matching page ids
*
* @deprecated 2022-08-31 use getIndexedPagesMatchingTagQuery() instead !
*/
function _tagIndexLookup($queryTags) {
return $this->getIndexedPagesMatchingTagQuery($queryTags);
}
/**
* Get the subject metadata cleaning the result
*
* @deprecated 2022-08-31 use getTagsFromPageMetadata() instead !
*
* @param string $id the page id
* @return array
*/
public function _getSubjectMetadata($id){
return $this->getTagsFromPageMetadata($id);
}
}