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