1<?php 2 3/** 4 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 5 * @author Andreas Gohr <andi@splitbrain.org> 6 */ 7 8use dokuwiki\Extension\Plugin; 9use dokuwiki\ErrorHandler; 10use dokuwiki\plugin\sqlite\SQLiteDB; 11use dokuwiki\Utf8\PhpString; 12 13/** 14 * This is the base class for all syntax classes, providing some general stuff 15 */ 16class helper_plugin_data extends Plugin 17{ 18 /** 19 * @var SQLiteDB initialized via _getDb() 20 */ 21 protected $db; 22 23 /** 24 * @var array stores the alias definitions 25 */ 26 protected $aliases; 27 28 /** 29 * @var array stores custom key localizations 30 */ 31 protected $locs = []; 32 33 /** 34 * Constructor 35 * 36 * Loads custom translations 37 */ 38 public function __construct() 39 { 40 $this->loadLocalizedLabels(); 41 } 42 43 private function loadLocalizedLabels() 44 { 45 $lang = []; 46 $path = DOKU_CONF . '/lang/en/data-plugin.php'; 47 if (file_exists($path)) include($path); 48 $path = DOKU_CONF . '/lang/' . $this->determineLang() . '/data-plugin.php'; 49 if (file_exists($path)) include($path); 50 foreach ($lang as $key => $val) { 51 $lang[PhpString::strtolower($key)] = $val; 52 } 53 $this->locs = $lang; 54 } 55 56 /** 57 * Return language code 58 * 59 * @return mixed 60 */ 61 protected function determineLang() 62 { 63 /** @var helper_plugin_translation $trans */ 64 $trans = plugin_load('helper', 'translation'); 65 if ($trans) { 66 $value = $trans->getLangPart(getID()); 67 if ($value) return $value; 68 } 69 global $conf; 70 return $conf['lang']; 71 } 72 73 /** 74 * Simple function to check if the database is ready to use 75 * 76 * @return bool 77 */ 78 public function ready() 79 { 80 return (bool)$this->getDB(); 81 } 82 83 /** 84 * load the sqlite helper 85 * 86 * @return SQLiteDB|null SQLite class plugin or null if failed 87 */ 88 public function getDB() 89 { 90 if ($this->db === null) { 91 try { 92 $this->db = new SQLiteDB('data', __DIR__ . '/db/'); 93 $this->db->getPdo()->sqliteCreateFunction('DATARESOLVE', [$this, 'resolveData'], 2); 94 } catch (\Exception $exception) { 95 if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception); 96 ErrorHandler::logException($exception); 97 msg('Couldn\'t load sqlite.', -1); 98 return null; 99 } 100 } 101 return $this->db; 102 } 103 104 /** 105 * Makes sure the given data fits with the given type 106 * 107 * @param string $value 108 * @param string|array $type 109 * @return string 110 */ 111 public function cleanData($value, $type) 112 { 113 $value = trim((string) $value); 114 if (!$value && $value !== '0') { 115 return ''; 116 } 117 if (is_array($type)) { 118 if (isset($type['enum']) && !preg_match('/(^|,\s*)' . preg_quote_cb($value) . '($|\s*,)/', $type['enum'])) { 119 return ''; 120 } 121 $type = $type['type']; 122 } 123 switch ($type) { 124 case 'dt': 125 if (preg_match('/^(\d\d\d\d)-(\d\d?)-(\d\d?)$/', $value, $m)) { 126 return sprintf('%d-%02d-%02d', $m[1], $m[2], $m[3]); 127 } 128 if ($value === '%now%') { 129 return $value; 130 } 131 return ''; 132 case 'url': 133 if (!preg_match('!^[a-z]+://!i', $value)) { 134 $value = 'http://' . $value; 135 } 136 return $value; 137 case 'mail': 138 $email = ''; 139 $name = ''; 140 $parts = preg_split('/\s+/', $value); 141 do { 142 $part = array_shift($parts); 143 if (!$email && mail_isvalid($part)) { 144 $email = strtolower($part); 145 continue; 146 } 147 $name .= $part . ' '; 148 } while ($part); 149 150 return trim($email . ' ' . $name); 151 case 'page': 152 case 'nspage': 153 return cleanID($value); 154 default: 155 return $value; 156 } 157 } 158 159 /** 160 * Add pre and postfixs to the given value 161 * 162 * $type may be an column array with pre and postfixes 163 * 164 * @param string|array $type 165 * @param string $val 166 * @param string $pre 167 * @param string $post 168 * @return string 169 */ 170 public function addPrePostFixes($type, $val, $pre = '', $post = '') 171 { 172 if (is_array($type)) { 173 if (isset($type['prefix'])) { 174 $pre = $type['prefix']; 175 } 176 if (isset($type['postfix'])) { 177 $post = $type['postfix']; 178 } 179 } 180 $val = $pre . $val . $post; 181 $val = $this->replacePlaceholders($val); 182 return $val; 183 } 184 185 /** 186 * Resolve a value according to its column settings 187 * 188 * This function is registered as a SQL function named DATARESOLVE 189 * 190 * @param string $value 191 * @param string $colname 192 * @return string 193 */ 194 public function resolveData($value, $colname) 195 { 196 // resolve pre and postfixes 197 $column = $this->column($colname); 198 $value = $this->addPrePostFixes($column['type'], $value); 199 200 // for pages, resolve title 201 $type = $column['type']; 202 if (is_array($type)) { 203 $type = $type['type']; 204 } 205 if ($type == 'title' || ($type == 'page' && useHeading('content'))) { 206 $id = $value; 207 if ($type == 'title') { 208 [$id, ] = explode('|', $value, 2); 209 } 210 //DATARESOLVE is only used with the 'LIKE' comparator, so concatenate the different strings is fine. 211 $value .= ' ' . p_get_first_heading($id); 212 } 213 return $value; 214 } 215 216 public function ensureAbsoluteId($id) 217 { 218 if (substr($id, 0, 1) !== ':') { 219 $id = ':' . $id; 220 } 221 return $id; 222 } 223 224 /** 225 * Return XHTML formated data, depending on column type 226 * 227 * @param array $column 228 * @param string $value 229 * @param Doku_Renderer_xhtml $R 230 * @return string 231 */ 232 public function formatData($column, $value, Doku_Renderer_xhtml $R) 233 { 234 global $conf; 235 $vals = explode("\n", $value); 236 $outs = []; 237 238 //multivalued line from db result for pageid and wiki has only in first value the ID 239 $storedID = ''; 240 241 foreach ($vals as $val) { 242 $val = trim($val); 243 if ($val == '') continue; 244 245 $type = $column['type']; 246 if (is_array($type)) { 247 $type = $type['type']; 248 } 249 switch ($type) { 250 case 'page': 251 $val = $this->addPrePostFixes($column['type'], $val); 252 $val = $this->ensureAbsoluteId($val); 253 $outs[] = $R->internallink($val, null, null, true); 254 break; 255 case 'title': 256 [$id, $title] = array_pad(explode('|', $val, 2), 2, null); 257 $id = $this->addPrePostFixes($column['type'], $id); 258 $id = $this->ensureAbsoluteId($id); 259 $outs[] = $R->internallink($id, $title, null, true); 260 break; 261 case 'pageid': 262 [$id, $title] = array_pad(explode('|', $val, 2), 2, null); 263 264 //use ID from first value of the multivalued line 265 if ($title == null) { 266 $title = $id; 267 if (!empty($storedID)) { 268 $id = $storedID; 269 } 270 } else { 271 $storedID = $id; 272 } 273 274 $id = $this->addPrePostFixes($column['type'], $id); 275 276 $outs[] = $R->internallink($id, $title, null, true); 277 break; 278 case 'nspage': 279 // no prefix/postfix here 280 $val = ':' . $column['key'] . ":$val"; 281 282 $outs[] = $R->internallink($val, null, null, true); 283 break; 284 case 'mail': 285 [$id, $title] = array_pad(explode(' ', $val, 2), 2, null); 286 $id = $this->addPrePostFixes($column['type'], $id); 287 $id = obfuscate(hsc($id)); 288 if (!$title) { 289 $title = $id; 290 } else { 291 $title = hsc($title); 292 } 293 if ($conf['mailguard'] == 'visible') { 294 $id = rawurlencode($id); 295 } 296 $outs[] = '<a href="mailto:' . $id . '" class="mail" title="' . $id . '">' . $title . '</a>'; 297 break; 298 case 'url': 299 $val = $this->addPrePostFixes($column['type'], $val); 300 $outs[] = $this->external_link($val, false, 'urlextern'); 301 break; 302 case 'tag': 303 // per default use keyname as target page, but prefix on aliases 304 if (!is_array($column['type'])) { 305 $target = $column['key'] . ':'; 306 } else { 307 $target = $this->addPrePostFixes($column['type'], ''); 308 } 309 310 $outs[] = '<a href="' 311 . wl(str_replace('/', ':', cleanID($target)), $this->getTagUrlparam($column, $val)) 312 . '" title="' . sprintf($this->getLang('tagfilter'), hsc($val)) 313 . '" class="wikilink1">' . hsc($val) . '</a>'; 314 break; 315 case 'timestamp': 316 $outs[] = dformat($val); 317 break; 318 case 'wiki': 319 global $ID; 320 $oldid = $ID; 321 [$ID, $data] = explode('|', $val, 2); 322 323 //use ID from first value of the multivalued line 324 if ($data == null) { 325 $data = $ID; 326 $ID = $storedID; 327 } else { 328 $storedID = $ID; 329 } 330 $data = $this->addPrePostFixes($column['type'], $data); 331 332 // Trim document_{start,end}, p_{open,close} from instructions 333 $allinstructions = p_get_instructions($data); 334 $wraps = 1; 335 if (isset($allinstructions[1]) && $allinstructions[1][0] == 'p_open') { 336 $wraps++; 337 } 338 $instructions = array_slice($allinstructions, $wraps, -$wraps); 339 340 $outs[] = p_render('xhtml', $instructions, $byref_ignore); 341 $ID = $oldid; 342 break; 343 default: 344 $val = $this->addPrePostFixes($column['type'], $val); 345 //type '_img' or '_img<width>' 346 if (substr($type, 0, 3) == 'img') { 347 $width = (int)substr($type, 3); 348 if (!$width) { 349 $width = $this->getConf('image_width'); 350 } 351 352 [$mediaid, $title] = array_pad(explode('|', $val, 2), 2, null); 353 if ($title === null) { 354 $title = $column['key'] . ': ' . basename(str_replace(':', '/', $mediaid)); 355 } else { 356 $title = trim($title); 357 } 358 359 if (media_isexternal($val)) { 360 $html = $R->externalmedia( 361 $mediaid, 362 $title, 363 $align = null, 364 $width, 365 $height = null, 366 $cache = null, 367 $linking = 'direct', 368 true 369 ); 370 } else { 371 $html = $R->internalmedia( 372 $mediaid, 373 $title, 374 $align = null, 375 $width, 376 $height = null, 377 $cache = null, 378 $linking = 'direct', 379 true 380 ); 381 } 382 if (strpos($html, 'mediafile') === false) { 383 $html = str_replace('href', 'rel="lightbox" href', $html); 384 } 385 386 $outs[] = $html; 387 } else { 388 $outs[] = hsc($val); 389 } 390 } 391 } 392 return implode(', ', $outs); 393 } 394 395 /** 396 * Split a column name into its parts 397 * 398 * @param string $col column name 399 * @return array with key, type, ismulti, title, opt 400 */ 401 public function column($col) 402 { 403 preg_match('/^([^_]*)(?:_(.*))?((?<!s)|s)$/', $col, $matches); 404 $column = [ 405 'colname' => $col, 406 'multi' => ($matches[3] === 's'), 407 'key' => PhpString::strtolower($matches[1]), 408 'origkey' => $matches[1], //similar to key, but stores upper case 409 'title' => $matches[1], 410 'type' => PhpString::strtolower($matches[2]), 411 ]; 412 413 // fix title for special columns 414 static $specials = [ 415 '%title%' => ['page', 'title'], 416 '%pageid%' => ['title', 'page'], 417 '%class%' => ['class'], 418 '%lastmod%' => ['lastmod', 'timestamp'] 419 ]; 420 if (isset($specials[$column['title']])) { 421 $s = $specials[$column['title']]; 422 $column['title'] = $this->getLang($s[0]); 423 if ($column['type'] === '' && isset($s[1])) { 424 $column['type'] = $s[1]; 425 } 426 } 427 428 // check if the type is some alias 429 $aliases = $this->aliases(); 430 if (isset($aliases[$column['type']])) { 431 $column['origtype'] = $column['type']; 432 $column['type'] = $aliases[$column['type']]; 433 } 434 435 // use custom localization for keys 436 if (isset($this->locs[$column['key']])) { 437 $column['title'] = $this->locs[$column['key']]; 438 } 439 440 return $column; 441 } 442 443 /** 444 * Load defined type aliases 445 * 446 * @return array 447 */ 448 public function aliases() 449 { 450 if (!is_null($this->aliases)) return $this->aliases; 451 452 $sqlite = $this->getDB(); 453 if (!$sqlite) return []; 454 455 $this->aliases = []; 456 $rows = $sqlite->queryAll("SELECT * FROM aliases"); 457 foreach ($rows as $row) { 458 $name = $row['name']; 459 unset($row['name']); 460 $this->aliases[$name] = array_filter(array_map('trim', $row)); 461 if (!isset($this->aliases[$name]['type'])) { 462 $this->aliases[$name]['type'] = ''; 463 } 464 } 465 return $this->aliases; 466 } 467 468 /** 469 * Parse a filter line into an array 470 * 471 * @param $filterline 472 * @return array|bool - array on success, false on error 473 */ 474 public function parseFilter($filterline) 475 { 476 //split filterline on comparator 477 if (preg_match('/^(.*?)([\*=<>!~]{1,2})(.*)$/', $filterline, $matches)) { 478 $column = $this->column(trim($matches[1])); 479 480 $com = $matches[2]; 481 $aliasses = ['<>' => '!=', '=!' => '!=', '~!' => '!~', '==' => '=', '~=' => '~', '=~' => '~']; 482 483 if (isset($aliasses[$com])) { 484 $com = $aliasses[$com]; 485 } elseif (!preg_match('/(!?[=~])|([<>]=?)|(\*~)/', $com)) { 486 msg('Failed to parse comparison "' . hsc($com) . '"', -1); 487 return false; 488 } 489 490 $val = trim($matches[3]); 491 492 if ($com == '~~') { 493 $com = 'IN('; 494 } 495 if (strpos($com, '~') !== false) { 496 if ($com === '*~') { 497 $val = '*' . $val . '*'; 498 $com = '~'; 499 } 500 $val = str_replace('*', '%', $val); 501 if ($com == '!~') { 502 $com = 'NOT LIKE'; 503 } else { 504 $com = 'LIKE'; 505 } 506 } else { 507 // Clean if there are no asterisks I could kill 508 $val = $this->cleanData($val, $column['type']); 509 } 510 $sqlite = $this->getDB(); 511 if (!$sqlite) return false; 512 513 if ($com == 'IN(') { 514 $val = explode(',', $val); 515 $val = array_map('trim', $val); 516 $val = array_map([$sqlite->getPdo(), 'quote'], $val); 517 $val = implode(",", $val); 518 } else { 519 $val = $sqlite->getPdo()->quote($val); 520 } 521 522 return [ 523 'key' => $column['key'], 524 'value' => $val, 525 'compare' => $com, 526 'colname' => $column['colname'], 527 'type' => $column['type'] 528 ]; 529 } 530 msg('Failed to parse filter "' . hsc($filterline) . '"', -1); 531 return false; 532 } 533 534 /** 535 * Replace placeholders in sql 536 * 537 * @param $data 538 */ 539 public function replacePlaceholdersInSQL(&$data) 540 { 541 global $USERINFO; 542 global $INPUT; 543 // allow current user name in filter: 544 $data['sql'] = str_replace('%user%', $INPUT->server->str('REMOTE_USER'), $data['sql']); 545 $data['sql'] = str_replace('%groups%', implode("','", $USERINFO['grps'] ?? []), $data['sql']); 546 // allow current date in filter: 547 $data['sql'] = str_replace('%now%', dformat(null, '%Y-%m-%d'), $data['sql']); 548 549 // language filter 550 $data['sql'] = $this->makeTranslationReplacement($data['sql']); 551 } 552 553 /** 554 * Replace translation related placeholders in given string 555 * 556 * @param string $data 557 * @return string 558 */ 559 public function makeTranslationReplacement($data) 560 { 561 global $conf; 562 global $ID; 563 564 $patterns[] = '%lang%'; 565 if (isset($conf['lang_before_translation'])) { 566 $values[] = $conf['lang_before_translation']; 567 } else { 568 $values[] = $conf['lang']; 569 } 570 571 // if translation plugin available, get current translation (empty for default lang) 572 $patterns[] = '%trans%'; 573 /** @var helper_plugin_translation $trans */ 574 $trans = plugin_load('helper', 'translation'); 575 if ($trans) { 576 $local = $trans->getLangPart($ID); 577 if ($local === '') { 578 $local = $conf['lang']; 579 } 580 $values[] = $local; 581 } else { 582 $values[] = ''; 583 } 584 return str_replace($patterns, $values, $data); 585 } 586 587 /** 588 * Get filters given in the request via GET or POST 589 * 590 * @return array 591 */ 592 public function getFilters() 593 { 594 $filters = []; 595 596 if (!isset($_REQUEST['dataflt'])) { 597 $flt = []; 598 } elseif (!is_array($_REQUEST['dataflt'])) { 599 $flt = (array)$_REQUEST['dataflt']; 600 } else { 601 $flt = $_REQUEST['dataflt']; 602 } 603 foreach ($flt as $key => $line) { 604 // we also take the column and filtertype in the key: 605 if (!is_numeric($key)) { 606 $line = $key . $line; 607 } 608 $f = $this->parseFilter($line); 609 if (is_array($f)) { 610 $f['logic'] = 'AND'; 611 $filters[] = $f; 612 } 613 } 614 return $filters; 615 } 616 617 /** 618 * prepare an array to be passed through buildURLparams() 619 * 620 * @param string $name keyname 621 * @param string|array $array value or key-value pairs 622 * @return array 623 */ 624 public function a2ua($name, $array) 625 { 626 $urlarray = []; 627 foreach ((array)$array as $key => $val) { 628 $urlarray[$name . '[' . $key . ']'] = $val; 629 } 630 return $urlarray; 631 } 632 633 /** 634 * get current URL parameters 635 * 636 * @param bool $returnURLparams 637 * @return array with dataflt, datasrt and dataofs parameters 638 */ 639 public function getPurrentParam($returnURLparams = true) 640 { 641 $cur_params = []; 642 if (isset($_REQUEST['dataflt'])) { 643 $cur_params = $this->a2ua('dataflt', $_REQUEST['dataflt']); 644 } 645 if (isset($_REQUEST['datasrt'])) { 646 $cur_params['datasrt'] = $_REQUEST['datasrt']; 647 } 648 if (isset($_REQUEST['dataofs'])) { 649 $cur_params['dataofs'] = $_REQUEST['dataofs']; 650 } 651 652 //combine key and value 653 if (!$returnURLparams) { 654 $flat_param = []; 655 foreach ($cur_params as $key => $val) { 656 $flat_param[] = $key . $val; 657 } 658 $cur_params = $flat_param; 659 } 660 return $cur_params; 661 } 662 663 /** 664 * Get url parameters, remove all filters for given column and add filter for desired tag 665 * 666 * @param array $column 667 * @param string $tag 668 * @return array of url parameters 669 */ 670 public function getTagUrlparam($column, $tag) 671 { 672 $param = []; 673 674 if (isset($_REQUEST['dataflt'])) { 675 $param = (array)$_REQUEST['dataflt']; 676 677 //remove all filters equal to column 678 foreach ($param as $key => $flt) { 679 if (!is_numeric($key)) { 680 $flt = $key . $flt; 681 } 682 $filter = $this->parseFilter($flt); 683 if ($filter['key'] == $column['key']) { 684 unset($param[$key]); 685 } 686 } 687 } 688 $param[] = $column['key'] . "_=$tag"; 689 $param = $this->a2ua('dataflt', $param); 690 691 if (isset($_REQUEST['datasrt'])) { 692 $param['datasrt'] = $_REQUEST['datasrt']; 693 } 694 if (isset($_REQUEST['dataofs'])) { 695 $param['dataofs'] = $_REQUEST['dataofs']; 696 } 697 698 return $param; 699 } 700 701 /** 702 * Perform replacements on the output values 703 * 704 * @param string $value 705 * @return string 706 */ 707 private function replacePlaceholders($value) 708 { 709 return $this->makeTranslationReplacement($value); 710 } 711} 712