1<?php 2/** 3 * Tagging Plugin (hlper component) 4 * 5 * @license GPL 2 6 */ 7class helper_plugin_tagging extends DokuWiki_Plugin { 8 9 // filter whitelist 10 const KNOWN_FILTERS = ['pid', 'tag', 'ns', 'notns', 'tagger']; 11 12 /** 13 * Gives access to the database 14 * 15 * Initializes the SQLite helper and register the CLEANTAG function 16 * 17 * @return helper_plugin_sqlite|bool false if initialization fails 18 */ 19 public function getDB() { 20 static $db = null; 21 if ($db !== null) { 22 return $db; 23 } 24 25 /** @var helper_plugin_sqlite $db */ 26 $db = plugin_load('helper', 'sqlite'); 27 if ($db === null) { 28 msg('The tagging plugin needs the sqlite plugin', -1); 29 30 return false; 31 } 32 $db->init('tagging', __DIR__ . '/db/'); 33 $db->create_function('CLEANTAG', array($this, 'cleanTag'), 1); 34 $db->create_function('GROUP_SORT', 35 function ($group, $newDelimiter) { 36 $ex = explode(',', $group); 37 sort($ex); 38 39 return implode($newDelimiter, $ex); 40 }, 2); 41 42 return $db; 43 } 44 45 /** 46 * Return the user to use for accessing tags 47 * 48 * Handles the singleuser mode by returning 'auto' as user. Returnes false when no user is logged in. 49 * 50 * @return bool|string 51 */ 52 public function getUser() { 53 if (!isset($_SERVER['REMOTE_USER'])) { 54 return false; 55 } 56 if ($this->getConf('singleusermode')) { 57 return 'auto'; 58 } 59 60 return $_SERVER['REMOTE_USER']; 61 } 62 63 /** 64 * Canonicalizes the tag to its lower case nospace form 65 * 66 * @param $tag 67 * 68 * @return string 69 */ 70 public function cleanTag($tag) { 71 $tag = str_replace(array(' ', '-', '_'), '', $tag); 72 $tag = utf8_strtolower($tag); 73 74 return $tag; 75 } 76 77 /** 78 * Canonicalizes the namespace, remove the first colon and add glob 79 * 80 * @param $namespace 81 * 82 * @return string 83 */ 84 public function globNamespace($namespace) { 85 return cleanId($namespace) . '*'; 86 } 87 88 /** 89 * Create or Update tags of a page 90 * 91 * Uses the translation plugin to store the language of a page (if available) 92 * 93 * @param string $id The page ID 94 * @param string $user 95 * @param array $tags 96 * 97 * @return bool|SQLiteResult 98 */ 99 public function replaceTags($id, $user, $tags) { 100 global $conf; 101 /** @var helper_plugin_translation $trans */ 102 $trans = plugin_load('helper', 'translation'); 103 if ($trans) { 104 $lang = $trans->realLC($trans->getLangPart($id)); 105 } else { 106 $lang = $conf['lang']; 107 } 108 109 $db = $this->getDB(); 110 $db->query('BEGIN TRANSACTION'); 111 $queries = array(array('DELETE FROM taggings WHERE pid = ? AND tagger = ?', $id, $user)); 112 foreach ($tags as $tag) { 113 $queries[] = array('INSERT INTO taggings (pid, tagger, tag, lang) VALUES(?, ?, ?, ?)', $id, $user, $tag, $lang); 114 } 115 116 foreach ($queries as $query) { 117 if (!call_user_func_array(array($db, 'query'), $query)) { 118 $db->query('ROLLBACK TRANSACTION'); 119 120 return false; 121 } 122 } 123 124 return $db->query('COMMIT TRANSACTION'); 125 } 126 127 /** 128 * Get a list of Tags or Pages matching search criteria 129 * 130 * @param array $filter What to search for array('field' => 'searchterm') 131 * @param string $type What field to return 'tag'|'pid' 132 * @param int $limit Limit to this many results, 0 for all 133 * 134 * @return array associative array in form of value => count 135 */ 136 public function findItems($filter, $type, $limit = 0) { 137 $db = $this->getDB(); 138 if (!$db) { 139 return array(); 140 } 141 142 $sql = $this->buildQuery($filter, $type, $limit); 143 144 // run query and turn into associative array 145 $data = []; 146 foreach ($filter as $key => $item) { 147 // not all filter values are arrays 148 if (!is_array($item)) $item = [$item]; 149 150 if ($key === 'ns' || $key === 'notns') { 151 $item = $this->formatNS($item); 152 } 153 $data = array_merge($data, $item); 154 } 155 $res = $db->query($sql, $data); 156 $res = $db->res2arr($res); 157 158 $ret = array(); 159 foreach ($res as $row) { 160 $ret[$row['item']] = $row['cnt']; 161 } 162 163 return $ret; 164 } 165 166 /** 167 * Constructs the URL to search for a tag 168 * 169 * @param string $tag 170 * @param string $ns 171 * 172 * @return string 173 */ 174 public function getTagSearchURL($tag, $ns = '') { 175 // wrap tag in quotes if non clean 176 $ctag = utf8_stripspecials($this->cleanTag($tag)); 177 if ($ctag != utf8_strtolower($tag)) { 178 $tag = '"' . $tag . '"'; 179 } 180 181 $ret = '?do=search&sf=1&id=' . rawurlencode($tag); 182 if ($ns) { 183 $ret .= rawurlencode(' @' . $ns); 184 } 185 186 return $ret; 187 } 188 189 /** 190 * Calculates the size levels for the given list of clouds 191 * 192 * Automatically determines sensible tresholds 193 * 194 * @param array $tags list of tags => count 195 * @param int $levels 196 * 197 * @return mixed 198 */ 199 public function cloudData($tags, $levels = 10) { 200 $min = min($tags); 201 $max = max($tags); 202 203 // calculate tresholds 204 $tresholds = array(); 205 for ($i = 0; $i <= $levels; $i++) { 206 $tresholds[$i] = pow($max - $min + 1, $i / $levels) + $min - 1; 207 } 208 209 // assign weights 210 foreach ($tags as $tag => $cnt) { 211 foreach ($tresholds as $tresh => $val) { 212 if ($cnt <= $val) { 213 $tags[$tag] = $tresh; 214 break; 215 } 216 $tags[$tag] = $levels; 217 } 218 } 219 220 return $tags; 221 } 222 223 /** 224 * Display a tag cloud 225 * 226 * @param array $tags list of tags => count 227 * @param string $type 'tag' 228 * @param Callable $func The function to print the link (gets tag and ns) 229 * @param bool $wrap wrap cloud in UL tags? 230 * @param bool $return returnn HTML instead of printing? 231 * @param string $ns Add this namespace to search links 232 * 233 * @return string 234 */ 235 public function html_cloud($tags, $type, $func, $wrap = true, $return = false, $ns = '') { 236 global $INFO; 237 238 $hidden_str = $this->getConf('hiddenprefix'); 239 $hidden_len = strlen($hidden_str); 240 241 $ret = ''; 242 if ($wrap) { 243 $ret .= '<ul class="tagging_cloud clearfix">'; 244 } 245 if (count($tags) === 0) { 246 // Produce valid XHTML (ul needs a child) 247 $this->setupLocale(); 248 $ret .= '<li><div class="li">' . $this->lang['js']['no' . $type . 's'] . '</div></li>'; 249 } else { 250 $tags = $this->cloudData($tags); 251 foreach ($tags as $val => $size) { 252 // skip hidden tags for users that can't edit 253 if ($type === 'tag' and 254 $hidden_len and 255 substr($val, 0, $hidden_len) == $hidden_str and 256 !($this->getUser() && $INFO['writable']) 257 ) { 258 continue; 259 } 260 261 $ret .= '<li class="t' . $size . '"><div class="li">'; 262 $ret .= call_user_func($func, $val, $ns); 263 $ret .= '</div></li>'; 264 } 265 } 266 if ($wrap) { 267 $ret .= '</ul>'; 268 } 269 if ($return) { 270 return $ret; 271 } 272 echo $ret; 273 274 return ''; 275 } 276 277 /** 278 * Get the link to a search for the given tag 279 * 280 * @param string $tag search for this tag 281 * @param string $ns limit search to this namespace 282 * 283 * @return string 284 */ 285 protected function linkToSearch($tag, $ns = '') { 286 return '<a href="' . hsc($this->getTagSearchURL($tag, $ns)) . '">' . $tag . '</a>'; 287 } 288 289 /** 290 * Display the Tags for the current page and prepare the tag editing form 291 * 292 * @param bool $print Should the HTML be printed or returned? 293 * 294 * @return string 295 */ 296 public function tpl_tags($print = true) { 297 global $INFO; 298 global $lang; 299 300 $filter = array('pid' => $INFO['id']); 301 if ($this->getConf('singleusermode')) { 302 $filter['tagger'] = 'auto'; 303 } 304 305 $tags = $this->findItems($filter, 'tag'); 306 307 $ret = ''; 308 309 $ret .= '<div class="plugin_tagging_edit">'; 310 $ret .= $this->html_cloud($tags, 'tag', array($this, 'linkToSearch'), true, true); 311 312 if ($this->getUser() && $INFO['writable']) { 313 $lang['btn_tagging_edit'] = $lang['btn_secedit']; 314 $ret .= '<div id="tagging__edit_buttons_group">'; 315 $ret .= html_btn('tagging_edit', $INFO['id'], '', array()); 316 if (auth_isadmin()) { 317 $ret .= '<label>' . $this->getLang('toggle admin mode') . '<input type="checkbox" id="tagging__edit_toggle_admin" /></label>'; 318 } 319 $ret .= '</div>'; 320 $form = new dokuwiki\Form\Form(); 321 $form->id('tagging__edit'); 322 $form->setHiddenField('tagging[id]', $INFO['id']); 323 $form->setHiddenField('call', 'plugin_tagging_save'); 324 $tags = $this->findItems(array( 325 'pid' => $INFO['id'], 326 'tagger' => $this->getUser(), 327 ), 'tag'); 328 $form->addTextarea('tagging[tags]')->val(implode(', ', array_keys($tags)))->addClass('edit')->attr('rows', 4); 329 $form->addButton('', $lang['btn_save'])->id('tagging__edit_save'); 330 $form->addButton('', $lang['btn_cancel'])->id('tagging__edit_cancel'); 331 $ret .= $form->toHTML(); 332 } 333 $ret .= '</div>'; 334 335 if ($print) { 336 echo $ret; 337 } 338 339 return $ret; 340 } 341 342 /** 343 * @param string $namespace empty for entire wiki 344 * 345 * @return array 346 */ 347 public function getAllTags($namespace = '', $order_by = 'tag', $desc = false) { 348 $order_fields = array('pid', 'tid', 'orig', 'taggers', 'count'); 349 if (!in_array($order_by, $order_fields)) { 350 msg('cannot sort by ' . $order_by . ' field does not exists', -1); 351 $order_by = 'tag'; 352 } 353 354 $db = $this->getDb(); 355 356 $query = 'SELECT "pid", 357 CLEANTAG("tag") AS "tid", 358 GROUP_SORT(GROUP_CONCAT("tag"), \', \') AS "orig", 359 GROUP_SORT(GROUP_CONCAT("tagger"), \', \') AS "taggers", 360 COUNT(*) AS "count" 361 FROM "taggings" 362 WHERE "pid" GLOB ? 363 GROUP BY "tid" 364 ORDER BY ' . $order_by; 365 if ($desc) { 366 $query .= ' DESC'; 367 } 368 369 $res = $db->query($query, $this->globNamespace($namespace)); 370 371 return $db->res2arr($res); 372 } 373 374 /** 375 * Get all pages with tags and their tags 376 * 377 * @return array ['pid' => ['tag1','tag2','tag3']] 378 */ 379 public function getAllTagsByPage() { 380 $query = ' 381 SELECT pid, GROUP_CONCAT(tag) AS tags 382 FROM taggings 383 GROUP BY pid 384 '; 385 $db = $this->getDb(); 386 $res = $db->query($query); 387 return array_map( 388 function ($i) { 389 return explode(',', $i); 390 }, 391 array_column($db->res2arr($res), 'tags', 'pid') 392 ); 393 } 394 395 /** 396 * Renames a tag 397 * 398 * @param string $formerTagName 399 * @param string $newTagName 400 */ 401 public function renameTag($formerTagName, $newTagName) { 402 403 if (empty($formerTagName) || empty($newTagName)) { 404 msg($this->getLang("admin enter tag names"), -1); 405 406 return; 407 } 408 409 $db = $this->getDb(); 410 411 $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ?', $this->cleanTag($formerTagName)); 412 $check = $db->res2arr($res); 413 414 if (empty($check)) { 415 msg($this->getLang("admin tag does not exists"), -1); 416 417 return; 418 } 419 420 $res = $db->query("UPDATE taggings SET tag = ? WHERE CLEANTAG(tag) = ?", $newTagName, $this->cleanTag($formerTagName)); 421 $db->res2arr($res); 422 423 msg($this->getLang("admin renamed"), 1); 424 425 return; 426 } 427 428 /** 429 * Rename or delete a tag for all users 430 * 431 * @param string $pid 432 * @param string $formerTagName 433 * @param string $newTagName 434 * 435 * @return array 436 */ 437 public function modifyPageTag($pid, $formerTagName, $newTagName) { 438 439 $db = $this->getDb(); 440 441 $res = $db->query('SELECT pid FROM taggings WHERE CLEANTAG(tag) = ? AND pid = ?', $this->cleanTag($formerTagName), $pid); 442 $check = $db->res2arr($res); 443 444 if (empty($check)) { 445 return array(true, $this->getLang('admin tag does not exists')); 446 } 447 448 if (empty($newTagName)) { 449 $res = $db->query('DELETE FROM taggings WHERE pid = ? AND CLEANTAG(tag) = ?', $pid, $this->cleanTag($formerTagName)); 450 } else { 451 $res = $db->query('UPDATE taggings SET tag = ? WHERE pid = ? AND CLEANTAG(tag) = ?', $newTagName, $pid, $this->cleanTag($formerTagName)); 452 } 453 $db->res2arr($res); 454 455 return array(false, $this->getLang('admin renamed')); 456 } 457 458 /** 459 * Deletes a tag 460 * 461 * @param array $tags 462 * @param string $namespace current namespace context as in getAllTags() 463 */ 464 public function deleteTags($tags, $namespace = '') { 465 if (empty($tags)) { 466 return; 467 } 468 469 $namespace = cleanId($namespace); 470 471 $db = $this->getDB(); 472 473 $queryBody = 'FROM taggings WHERE pid GLOB ? AND (' . 474 implode(' OR ', array_fill(0, count($tags), 'CLEANTAG(tag) = ?')) . ')'; 475 $args = array_map(array($this, 'cleanTag'), $tags); 476 array_unshift($args, $this->globNamespace($namespace)); 477 478 479 $affectedPagesQuery= 'SELECT DISTINCT pid ' . $queryBody; 480 $resAffectedPages = $db->query($affectedPagesQuery, $args); 481 $numAffectedPages = count($resAffectedPages->fetchAll()); 482 483 $deleteQuery = 'DELETE ' . $queryBody; 484 $db->query($deleteQuery, $args); 485 486 msg(sprintf($this->getLang("admin deleted"), count($tags), $numAffectedPages), 1); 487 } 488 489 /** 490 * Updates tags with a new page name 491 * 492 * @param string $oldName 493 * @param string $newName 494 */ 495 public function renamePage($oldName, $newName) { 496 $db = $this->getDb(); 497 $db->query('UPDATE taggings SET pid = ? WHERE pid = ?', $newName, $oldName); 498 } 499 500 /** 501 * Extracts tags from search query 502 * 503 * @param array $parsedQuery 504 * @return array 505 */ 506 public function getTags($parsedQuery) 507 { 508 $tags = []; 509 if (isset($parsedQuery['phrases'][0])) { 510 $tags = $parsedQuery['phrases']; 511 } elseif (isset($parsedQuery['and'][0])) { 512 $tags = $parsedQuery['and']; 513 } elseif (isset($parsedQuery['tag'])) { 514 // handle autocomplete call 515 $tags[] = $parsedQuery['tag']; 516 } 517 return $tags; 518 } 519 520 /** 521 * Returns an SQL query string constructed by the query builder 522 * from given constraints and options 523 * 524 * @param array $filter 525 * @param string $type 526 * @param int $limit 527 * 528 * @return string 529 */ 530 protected function buildQuery(&$filter, $type, $limit) 531 { 532 global $INPUT; 533 534 // search form passes a dummy filter, parsing the actual query instead 535 if (!$filter) { 536 global $QUERY; 537 $filter = ft_queryParser(new Doku_Indexer(), $QUERY); 538 } 539 540 /** @var helper_plugin_tagging_querybuilder $queryBuilder */ 541 $queryBuilder = new helper_plugin_tagging_querybuilder(); 542 $queryBuilder->setLimit($limit); 543 544 // if tags are extracted directly form query, they have to be added to the filter, 545 // which is the only source of parameters 546 $tags = $this->getTags($filter); 547 if (!isset($filter['tag'])) $filter['tag'] = $tags; 548 $queryBuilder->setTags($tags); 549 550 // remove no longer needed items from an overblown query-based filter 551 $filter = array_intersect_key($filter, array_flip(self::KNOWN_FILTERS)); 552 553 $queryBuilder->setLogicalAnd($INPUT->has('taggings') && $INPUT->str('taggings') === 'and'); 554 if (isset($filter['ns'])) $queryBuilder->includeNS($filter['ns']); 555 if (isset($filter['notns'])) $queryBuilder->excludeNS($filter['notns']); 556 if (isset($filter['tagger'])) $queryBuilder->setTagger($filter['tagger']); 557 if (isset($filter['pid'])) $queryBuilder->setPid($filter['pid']); 558 559 $queryBuilder->setField($type); 560 561 return $queryBuilder->getQuery(); 562 } 563 564 /** 565 * Converts namespaces into a wildcard form suitable for SQL queries 566 * 567 * @param array $item 568 * @return array 569 */ 570 protected function formatNS(array $item) 571 { 572 return array_map(function($ns) { 573 if (substr($ns, -1) !== ':') { 574 $ns .= ':'; 575 } 576 return $ns . '*'; 577 }, $item); 578 } 579} 580