1<?php 2 3use dokuwiki\Utf8\PhpString; 4 5class PageQuery 6{ 7 const MSORT_KEEP_ASSOC = 'msort01'; 8 const MSORT_NUMERIC = 'msort02'; 9 const MSORT_REGULAR = 'msort03'; 10 const MSORT_STRING = 'msort04'; 11 const MSORT_STRING_CASE = 'msort05'; 12 const MSORT_NAT = 'msort06'; 13 const MSORT_NAT_CASE = 'msort07'; 14 const MSORT_ASC = 'msort08'; 15 const MSORT_DESC = 'msort09'; 16 const MSORT_DEFAULT_DIRECTION = self::MSORT_ASC; 17 const MSORT_DEFAULT_TYPE = self::MSORT_STRING; 18 const MGROUP_NONE = 'mgrp00'; 19 const MGROUP_HEADING = 'mgrp01'; 20 const MGROUP_NAMESPACE = 'mgrp02'; 21 const MGROUP_REALDATE = '__realdate__'; 22 private $lang; 23 24 // returns first $count letters from $text in lowercase 25 private $snippet_cnt = 0; 26 27 public function __construct(array $lang) 28 { 29 $this->lang = $lang; 30 } 31 32 /** 33 * Render a simple "no results" message 34 * 35 * @param string $query => original query 36 * @param string $error 37 * @return string 38 */ 39 final public function renderAsEmpty($query, $error = ''): string 40 { 41 $render = '<div class="pagequery no-border">' . DOKU_LF; 42 $render .= '<p class="no-results"><span>pagequery</span>' . sprintf( 43 $this->lang["no_results"], 44 '<strong>' . $query . '</strong>' 45 ) . '</p>' . DOKU_LF; 46 if (!empty($error)) { 47 $render .= '<p class="no-results">' . $error . '</p>' . DOKU_LF; 48 } 49 $render .= '</div>' . DOKU_LF; 50 return $render; 51 } 52 53 final public function renderAsHtml(string $layout, $sorted_results, $opt, $count) 54 { 55 $this->snippet_cnt = $opt['snippet']['count']; 56 $render_type = 'renderAsHtml' . $layout; 57 return $this->$render_type($sorted_results, $opt, $count); 58 } 59 60 /** 61 * Parse out the namespace, and convert to a regex for array search. 62 * 63 * @param string $query user page query 64 * @return array processed query with necessary regex markup for namespace recognition 65 */ 66 final public function parseNamespaceQuery(string $query): array 67 { 68 global $INFO; 69 70 $cur_ns = $INFO['namespace']; 71 $incl_ns = array(); 72 $excl_ns = array(); 73 $page_qry = ''; 74 $tokens = explode(' ', trim($query)); 75 if (count($tokens) == 1) { 76 $page_qry = $query; 77 } else { 78 foreach ($tokens as $token) { 79 if (preg_match('/^(?:\^|-ns:)(.+)$/u', $token, $matches)) { 80 $excl_ns[] = resolve_id($cur_ns, $matches[1]); // also resolve relative and parent ns 81 } elseif (preg_match('/^(?:@|ns:)(.+)$/u', $token, $matches)) { 82 $incl_ns[] = resolve_id($cur_ns, $matches[1]); 83 } else { 84 $page_qry .= ' ' . $token; 85 } 86 } 87 } 88 $page_qry = trim($page_qry); 89 return array($page_qry, $incl_ns, $excl_ns); 90 } 91 92 /** 93 * Builds the sorting array: array of arrays (0 = id, 1 = name, 2 = abstract, 3 = ... , etc) 94 * 95 * @param array $ids array of page ids to be sorted 96 * @param array $opt all user options/settings 97 * 98 * @return array $sort_array array of array(one value for each key to be sorted) 99 * $sort_opts sorting options for the msort function 100 * $group_opts grouping options for the mgroup function 101 */ 102 final public function buildSortingArray(array $ids, array $opt): array 103 { 104 global $conf; 105 106 $sort_array = array(); 107 $sort_opts = array(); 108 $group_opts = array(); 109 110 $dformat = array(); 111 $wformat = array(); 112 113 $cnt = 0; 114 115 // look for 'abc' by title instead of name ('abc' by page-id makes little sense) 116 // title takes precedence over name (should you try to sort by both...why?) 117 $from_title = (isset($opt['sort']['title'])) ? true : false; 118 119 // is it necessary to cache the abstract column? 120 $get_abstract = ($opt['snippet']['type'] !== 'none'); 121 122 // add any extra columns needed for filtering! 123 $extrakeys = array_diff_key($opt['filter'], $opt['sort']); 124 $col_keys = array_merge($opt['sort'], $extrakeys); 125 126 // it is more efficient to get all the back-links at once from the indexer metadata 127 if (isset($col_keys['backlinks'])) { 128 $backlinks = idx_get_indexer()->lookupKey('relation_references', $ids); 129 } 130 131 foreach ($ids as $id) { 132 // getting metadata is very time-consuming, hence ONCE per displayed row 133 $meta = p_get_metadata($id, '', METADATA_DONT_RENDER); 134 135 if (!isset($meta['date']['created'])) { 136 $meta['date']['created'] = 0; 137 } 138 if (!isset($meta['date']['modified'])) { 139 $meta['date']['modified'] = $meta['date']['created']; 140 } 141 // establish page name (without namespace) 142 $name = noNS($id); 143 144 // ref to current row, used through out function 145 $row = &$sort_array[$cnt]; 146 147 // first column is the basic page id 148 $row['id'] = $id; 149 150 // second column is the display 'name' (used when sorting by 'name') 151 // this also avoids rebuilding the display name when building links later (DRY) 152 $row['name'] = $name; 153 154 // third column: cache the display name; taken from metadata => 1st heading 155 // used when sorting by 'title' 156 $title = (isset($meta['title']) && !empty($meta['title'])) ? $meta['title'] : $name; 157 $row['title'] = $title; 158 159 // needed later in the a, ab ,abc clauses 160 $abc = ($from_title) ? $title : $name; 161 162 // fourth column: cache the page abstract if needed; this saves a lot of time later 163 // and avoids repeated slow metadata retrievals (v. slow!) 164 $abstract = ($get_abstract) ? $meta['description']['abstract'] : ''; 165 $row['abstract'] = $abstract; 166 167 // fifth column is the displayed text for links; set below 168 $row['display'] = ''; 169 170 // reset cache of full date for this row 171 $real_date = 0; 172 173 // ...optional columns 174 foreach ($col_keys as $key => $void) { 175 $value = ''; 176 switch ($key) { 177 case 'a': 178 case 'ab': 179 case 'abc': 180 $value = $this->first($abc, strlen($key)); 181 break; 182 case 'name': 183 case 'title': 184 // name/title columns already exists by default (col 1,2) 185 // save a few microseconds by just moving on to the next key 186 continue 2; 187 case 'id': 188 $value = $id; 189 break; 190 case 'ns': 191 $value = getNS($id); 192 if (empty($value)) { 193 $value = '[' . $conf['start'] . ']'; 194 } 195 break; 196 case 'creator': 197 $value = $meta['creator']; 198 break; 199 case 'contributor': 200 $value = implode(' ', $meta['contributor']); 201 break; 202 case 'mdate': 203 $value = $meta['date']['modified']; 204 break; 205 case 'cdate': 206 $value = $meta['date']['created']; 207 break; 208 case 'links': 209 $value = $this->joinKeysIf(' ', $meta['relation']['references']); 210 break; 211 case 'backlinks': 212 $value = implode(' ', current($backlinks)); 213 next($backlinks); 214 break; 215 default: 216 // date sorting types (groupable) 217 $dtype = $key[0]; 218 if ($dtype === 'c' || $dtype === 'm') { 219 // we only set real date once per id (needed for grouping) 220 // not per sort column--the date should remain same across all columns 221 // this is always the last column! 222 if ($real_date == 0) { 223 $real_date = ($dtype === 'c') ? 224 $meta['date']['created'] : $meta['date']['modified']; 225 $row[self::MGROUP_REALDATE] = $real_date; 226 } 227 // only set date formats once per sort column/key (not per id!), i.e. on first row 228 if ($cnt == 0) { 229 $dformat[$key] = $this->dateFormat($key); 230 // collect date in word format for potential use in grouping 231 $wformat[$key] = ($opt['spelldate']) ? $this->dateFormatWords($dformat[$key]) : ''; 232 } 233 // create a string date used for sorting only 234 // (we cannot just use the real date otherwise it would not group correctly) 235 $value = strftime($dformat[$key], $real_date); 236 } 237 } 238 // set the optional column 239 $row[$key] = $value; 240 } 241 242 /* provide custom display formatting via string templating {...} */ 243 244 $matches = array(); 245 $display = $opt['display']; 246 $matched = preg_match_all('/\{(.+?)\}/', $display, $matches, PREG_SET_ORDER); 247 248 // first try to use the custom column names as entered by user 249 if ($matched > 0) { 250 foreach ($matches as $match) { 251 $key = $match[1]; 252 $value = null; 253 if (isset($row[$key])) { 254 $value = $row[$key]; 255 } elseif (isset($meta[$key])) { 256 $value = $meta[$key]; 257 // allow for nested meta keys (e.g. date:created) 258 } elseif (strpos($key, ':') !== false) { 259 $keys = explode(':', $key); 260 if (isset($meta[$keys[0]][$keys[1]])) { 261 $value = $meta[$keys[0]][$keys[1]]; 262 } 263 } elseif ($key === 'mdate') { 264 $value = $meta['date']['modified']; 265 } elseif ($key === 'cdate') { 266 $value = $meta['date']['created']; 267 } 268 if (!is_null($value)) { 269 if (strpos($key, 'date') !== false) { 270 $value = utf8_encode(strftime($opt['dformat'], $value)); 271 } 272 $display = str_replace($match[0], $value, $display); 273 } 274 } 275 276 // try to match any metadata field; to allow for plain single word display settings 277 // e.g. display=title or display=name 278 } elseif (isset($row[$display])) { 279 $display = $row[$display]; 280 // if all else fails then use the page name (always available) 281 } else { 282 $display = $row['name']; 283 } 284 $row['display'] = $display; 285 286 $cnt++; 287 } 288 289 $idx = 0; 290 foreach ($opt['sort'] as $key => $value) { 291 $sort_opts['key'][] = $key; 292 293 // now the sort direction 294 switch ($value) { 295 case 'a': 296 case 'asc': 297 $dir = self::MSORT_ASC; 298 break; 299 case 'd': 300 case 'desc': 301 $dir = self::MSORT_DESC; 302 break; 303 default: 304 switch ($key) { 305 // sort dates descending by default; text ascending 306 case 'a': 307 case 'ab': 308 case 'abc': 309 case 'name': 310 case 'title': 311 case 'id': 312 case 'ns': 313 case 'creator': 314 case 'contributor': 315 $dir = self::MSORT_ASC; 316 break; 317 default: 318 $dir = self::MSORT_DESC; 319 break; 320 } 321 } 322 $sort_opts['dir'][] = $dir; 323 324 // set the sort array's data type 325 switch ($key) { 326 case 'mdate': 327 case 'cdate': 328 $type = self::MSORT_NUMERIC; 329 break; 330 default: 331 if ($opt['casesort']) { 332 // case sensitive: a-z then A-Z 333 $type = ($opt['natsort']) ? self::MSORT_NAT : self::MSORT_STRING; 334 } else { 335 // case-insensitive 336 $type = ($opt['natsort']) ? self::MSORT_NAT_CASE : self::MSORT_STRING_CASE; 337 } 338 } 339 $sort_opts['type'][] = $type; 340 341 // now establish grouping options 342 switch ($key) { 343 // name strings and full dates cannot be meaningfully grouped (no duplicates!) 344 case 'mdate': 345 case 'cdate': 346 case 'name': 347 case 'title': 348 case 'id': 349 $group_by = self::MGROUP_NONE; 350 break; 351 case 'ns': 352 $group_by = self::MGROUP_NAMESPACE; 353 break; 354 default: 355 $group_by = self::MGROUP_HEADING; 356 } 357 if ($group_by !== self::MGROUP_NONE) { 358 $group_opts['key'][$idx] = $key; 359 $group_opts['type'][$idx] = $group_by; 360 $group_opts['dformat'][$idx] = $wformat[$key]; 361 $idx++; 362 } 363 } 364 365 return array($sort_array, $sort_opts, $group_opts); 366 } 367 368 private function first(string $text, $count): string 369 { 370 return ($count > 0) ? PhpString::substr(PhpString::strtolower($text), 0, $count) : ''; 371 } 372 373 private function joinKeysIf($delim, $arr) 374 { 375 $result = ''; 376 if (!empty($arr)) { 377 foreach ($arr as $key => $value) { 378 if ($value === true) { 379 $result .= $key . $delim; 380 } 381 } 382 if (!empty($result)) { 383 $result = substr($result, 0, -1); 384 } 385 } 386 return $result; 387 } 388 389 /** 390 * Parse the c|m-year-month-day option; used for sorting/grouping 391 * 392 * @param string $key 393 * @return string 394 */ 395 private function dateFormat(string $key): string 396 { 397 $dkey = array(); 398 if (strpos($key, 'year') !== false) { 399 $dkey[] = '%Y'; 400 } 401 if (strpos($key, 'month') !== false) { 402 $dkey[] = '%m'; 403 } 404 if (strpos($key, 'day') !== false) { 405 $dkey[] = '%d'; 406 } 407 return implode('-', $dkey); 408 } 409 410 /** 411 * Provide month and day format in real words if required 412 * used for display only ($dformat is used for sorting/grouping) 413 * 414 * @param string $dformat 415 * @return string 416 */ 417 private function dateFormatWords(string $dformat): string 418 { 419 $wformat = ''; 420 switch ($dformat) { 421 case '%m': 422 $wformat = "%B"; 423 break; 424 case '%d': 425 $wformat = "%#d–%A "; 426 break; 427 case '%Y-%m': 428 $wformat = "%B %Y"; 429 break; 430 case '%m-%d': 431 $wformat = "%B %#d, %A "; 432 break; 433 case '%Y-%m-%d': 434 $wformat = "%A, %B %#d, %Y"; 435 break; 436 } 437 return $wformat; 438 } 439 440 /** 441 * Just a wrapper around the Dokuwiki pageSearch function. 442 * 443 * @param string $query 444 * @return int[]|string[] 445 */ 446 final public function pageSearch(string $query): array 447 { 448 $highlight = []; 449 return array_keys(ft_pageSearch($query, $highlight)); 450 } 451 452 /** 453 * A heavily customised version of _ft_pageLookup in inc/fulltext.php 454 * no sorting! 455 */ 456 final public function pageLookup($query, $fullregex, $incl_ns, $excl_ns) 457 { 458 global $conf; 459 460 $query = trim($query); 461 $pages = file($conf['indexdir'] . '/page.idx'); 462 463 if (!$fullregex) { 464 // first deal with excluded namespaces, then included, order matters! 465 $pages = $this->filterNamespaces($pages, $excl_ns, true); 466 $pages = $this->filterNamespaces($pages, $incl_ns, false); 467 } 468 469 foreach ($pages as $i => $iValue) { 470 $page = $iValue; 471 if (!page_exists($page) || isHiddenPage($page)) { 472 unset($pages[$i]); 473 continue; 474 } 475 if (!$fullregex) { 476 $page = noNS($page); 477 } 478 /* 479 * This is the actual "search" expression. 480 * Note: preg_grep cannot be used because we need to 481 * allow for beginning of string "^" regex on normal page search 482 * and the page-exists check above 483 * The @ prevents problems with invalid queries! 484 */ 485 $matched = @preg_match('/' . $query . '/i', $page); 486 if ($matched === false) { 487 return false; 488 } elseif ($matched == 0) { 489 unset($pages[$i]); 490 } 491 } 492 if (count($pages) > 0) { 493 return $pages; 494 } else { 495 // we always return an array type 496 return array(); 497 } 498 } 499 500 /** 501 * Include/Exclude specific namespaces from a list of pages. 502 * @param array $pages a list of wiki page ids 503 * @param array $ns_qry namespace(s) to include/exclude 504 * @param string $exclude true = exclude 505 * @return array 506 */ 507 private function filterNamespaces(array $pages, array $ns_qry, string $exclude): array 508 { 509 $invert = ($exclude) ? PREG_GREP_INVERT : 0; 510 foreach ($ns_qry as $ns) { 511 // we only look for namespace from beginning of the id 512 $regexes[] = '^' . $ns . ':.*'; 513 } 514 if (!empty($regexes)) { 515 $regex = '/(' . implode('|', $regexes) . ')/'; 516 $result = array_values(preg_grep($regex, $pages, $invert)); 517 } else { 518 $result = $pages; 519 } 520 return $result; 521 } 522 523 final public function validatePages(array $pages, bool $nostart = true, int $maxns = 0): array 524 { 525 global $conf; 526 527 $pages = array_map('trim', $pages); 528 529 // check ACL permissions, too many ns levels, and remove any 'start' pages as needed 530 $start = $conf['start']; 531 $offset = strlen($start); 532 foreach ($pages as $idx => $name) { 533 if ($nostart && substr($name, -$offset) == $start) { 534 unset($pages[$idx]); 535 } elseif ($maxns > 0 && (substr_count($name, ':')) > $maxns) { 536 unset($pages[$idx]); 537 // TODO: this function is one of slowest in the plugin; solutions? 538 } elseif (auth_quickaclcheck($pages[$idx]) < AUTH_READ) { 539 unset($pages[$idx]); 540 } 541 } 542 return $pages; 543 } 544 545 /** 546 * filter array of pages by specific meta data keys (or columns) 547 * 548 * @param array $sort_array full sorting array, all meta columns included 549 * @param array $filter meta-data filter: <meta key>:<query> 550 * @return array 551 */ 552 final public function filterMetadata(array $sort_array, array $filter): array 553 { 554 foreach ($filter as $metakey => $expr) { 555 // allow for exclusion matches (put ^ or ! in front of meta key) 556 $exclude = false; 557 if ($metakey[0] === '^' || $metakey[0] === '!') { 558 $exclude = true; 559 $metakey = substr($metakey, 1); 560 } 561 $that = $this; 562 $sort_array = array_filter($sort_array, static function ($row) use ($metakey, $expr, $exclude, $that) { 563 if (!isset($row[$metakey])) { 564 return false; 565 } 566 if (strpos($metakey, 'date') !== false) { 567 $match = $that->filterByDate($expr, $row[$metakey]); 568 } else { 569 $match = preg_match('`' . $expr . '`', $row[$metakey]) > 0; 570 } 571 if ($exclude) { 572 $match = !$match; 573 } 574 return $match; 575 }); 576 } 577 return $sort_array; 578 } 579 580 private function filterByDate($filter, $date): bool 581 { 582 $filter = str_replace('/', '.', $filter); // allow for Euro style date formats 583 $filters = explode('->', $filter); 584 $begin = (empty($filters[0]) ? null : strtotime($filters[0])); 585 $end = (empty($filters[1]) ? null : strtotime($filters[1])); 586 587 $matched = false; 588 if ($begin !== null && $end !== null) { 589 $matched = ($date >= $begin && $date <= $end); 590 } elseif ($begin !== null) { 591 $matched = ($date >= $begin); 592 } elseif ($end !== null) { 593 $matched = ($date <= $end); 594 } 595 return $matched; 596 } 597 598 /** 599 * A replacement for "array_mulitsort" which permits natural and case-less sorting 600 * This function will sort an 'array of rows' only (not array of 'columns') 601 * 602 * @param array $sort_array : multi-dimensional array of arrays, where the first index refers to 603 * the row number and the second to the column number 604 * (e.g. $array[row_number][column_number]) 605 * i.e. = array( 606 * array('name1', 'job1', 'start_date1', 'rank1'), 607 * array('name2', 'job2', 'start_date2', 'rank2'), 608 * ... 609 * ); 610 * 611 * @param mixed $sort_opts : options for how the array should be sorted 612 * :AS ARRAY 613 * $sort_opts['key'][<column>] = 'key' 614 * $sort_opts['type'][<column>] = 'type' 615 * $sort_opts['dir'][<column>] = 'dir' 616 * $sort_opts['assoc'][<column>] = MSORT_KEEP_ASSOC | true 617 * @return boolean 618 */ 619 final public function msort(array &$sort_array, $sort_opts): bool 620 { 621 // if a full sort_opts array was passed 622 $keep_assoc = false; 623 if (is_array($sort_opts) && !empty($sort_opts)) { 624 if (isset($sort_opts['assoc'])) { 625 $keep_assoc = true; 626 } 627 } else { 628 return false; 629 } 630 631 // Determine which u..sort function (with or without associations). 632 $sort_func = ($keep_assoc) ? 'uasort' : 'usort'; 633 634 $keys = $sort_opts['key']; 635 636 // HACK: self:: does not work inside a closure so... 637 $self = __CLASS__; 638 639 // Sort the data and get the result. 640 $result = $sort_func ($sort_array, function (array $left, array $right) use ($sort_opts, $keys, $self) { 641 // Assume that the entries are the same. 642 $cmp = 0; 643 644 // Work through each sort column 645 foreach ($keys as $idx => $key) { 646 // Handle the different sort types. 647 switch ($sort_opts['type'][$idx]) { 648 case $self::MSORT_NUMERIC: 649 $key_cmp = (((int)$left[$key] == (int)$right[$key]) ? 650 0 : (((int)$left[$key] < (int)$right[$key]) ? -1 : 1)); 651 break; 652 653 case $self::MSORT_STRING: 654 $key_cmp = strcmp((string)$left[$key], (string)$right[$key]); 655 break; 656 657 case $self::MSORT_STRING_CASE: //case-insensitive 658 $key_cmp = strcasecmp((string)$left[$key], (string)$right[$key]); 659 break; 660 661 case $self::MSORT_NAT: 662 $key_cmp = strnatcmp((string)$left[$key], (string)$right[$key]); 663 break; 664 665 case $self::MSORT_NAT_CASE: //case-insensitive 666 $key_cmp = strnatcasecmp((string)$left[$key], (string)$right[$key]); 667 break; 668 669 case $self::MSORT_REGULAR: 670 default : 671 $key_cmp = (($left[$key] == $right[$key]) ? 672 0 : (($left[$key] < $right[$key]) ? -1 : 1)); 673 break; 674 } 675 676 // Is the column in the two arrays the same? 677 if ($key_cmp == 0) { 678 continue; 679 } 680 681 // Are we sorting descending? 682 $cmp = $key_cmp * (($sort_opts['dir'][$idx] === $self::MSORT_DESC) ? -1 : 1); 683 684 // no need for remaining keys as there was a difference 685 break; 686 } 687 return $cmp; 688 }); 689 return $result; 690 } 691 692 /** 693 * group a multi-dimensional array by each level heading 694 * @param array $sort_array : array to be grouped (result of 'msort' function) 695 * __realdate__' column should contain real dates if you need dates in words 696 * @param array $keys : which keys (columns) should be returned in results array? (as keys) 697 * @param mixed $group_opts : AS ARRAY: 698 * $group_opts['key'][<order>] = column key to group by 699 * $group_opts['type'][<order>] = grouping type [MGROUP...] 700 * $group_opts['dformat'][<order>] = date formatting string 701 * 702 * @return array $results : array of arrays: (level, name, page_id, title), e.g. array(1, 'Main Title') 703 * array(0, '...') => 0 = normal row item (not heading) 704 */ 705 final public function mgroup(array $sort_array, array $keys, $group_opts = array()): array 706 { 707 $prevs = array(); 708 $results = array(); 709 $idx = 0; 710 711 if (empty($sort_array)) { 712 $results = array(); 713 } elseif (empty($group_opts)) { 714 foreach ($sort_array as $row) { 715 $result = array(0); 716 foreach ($keys as $key) { 717 $result[] = $row[$key]; 718 } 719 $results[] = $result; 720 } 721 } else { 722 $level = count($group_opts['key']) - 1; 723 foreach ($sort_array as $row) { 724 $this->addHeading($results, $sort_array, $group_opts, $level, $idx, $prevs); 725 $result = array(0); // basic item (page link) is level 0 726 foreach ($keys as $iValue) { 727 $result[] = $row[$iValue]; 728 } 729 $results[] = $result; 730 $idx++; 731 } 732 } 733 return $results; 734 } 735 736 /** 737 * private function used by mgroup only! 738 */ 739 private function addHeading(&$results, $sort_array, $group_opts, $level, $idx, &$prevs): void 740 { 741 global $conf; 742 743 // recurse to find all parent headings 744 if ($level > 0) { 745 $this->addHeading($results, $sort_array, $group_opts, $level - 1, $idx, $prevs); 746 } 747 $group_type = $group_opts['type'][$level]; 748 749 $prev = $prevs[$level] ?? ''; 750 $key = $group_opts['key'][$level]; 751 $cur = $sort_array[$idx][$key]; 752 if ($cur != $prev) { 753 $prevs[$level] = $cur; 754 755 if ($group_type === self::MGROUP_HEADING) { 756 $date_format = $group_opts['dformat'][$level]; 757 if (!empty($date_format)) { 758 // the real date is always the '__realdate__' column (MGROUP_REALDATE) 759 $cur = strftime($date_format, $sort_array[$idx][self::MGROUP_REALDATE]); 760 } 761 // args : $level, $name, $id, $_, $abstract, $display 762 $results[] = array($level + 1, $cur, ''); 763 } elseif ($group_type === self::MGROUP_NAMESPACE) { 764 $cur_ns = explode(':', $cur); 765 $prev_ns = explode(':', $prev); 766 // only show namespaces that are different from the previous heading 767 for ($i = 0, $iMax = count($cur_ns); $i < $iMax; $i++) { 768 if ($cur_ns[$i] != $prev_ns[$i]) { 769 $hl = $level + $i + 1; 770 $id = implode(':', array_slice($cur_ns, 0, $i + 1)) . ':' . $conf['start']; 771 if (page_exists($id)) { 772 $ns_start = $id; 773 // allow the first heading to be used instead of page id/name 774 $display = p_get_metadata($id, 'title'); 775 } else { 776 $ns_start = $display = ''; 777 } 778 // args : $level, $name, $id, $_, $abstract, $display 779 $results[] = array($hl, $cur_ns[$i], $ns_start, '', '', $display); 780 } 781 } 782 } 783 } 784 } 785 786 /** 787 * Render the final pagequery results list as HTML, indented and in columns as required. 788 * 789 * **DEPRECATED** --- I would like to scrap this ASAP (old browsers only). 790 * It's complicated and it's hard to maintain. 791 * 792 * @param array $sorted_results 793 * @param array $opt 794 * @param int $count => count of results 795 * @return string => HTML rendered list 796 */ 797 protected function renderAsHtmltable($sorted_results, $opt, $count): string 798 { 799 $ratios = array(.80, 1.3, 1.17, 1.1, 1.03, .96, .90); // height ratios: link, h1, h2, h3, h4, h5, h6 800 $render = ''; 801 $prev_was_heading = false; 802 $can_start_col = true; 803 $cont_level = 1; 804 $col = 0; 805 $multi_col = $opt['cols'] > 1; // single columns are displayed without tables (better for TOC) 806 $col_height = $this->adjustedHeight($sorted_results, $ratios) / $opt['cols']; 807 $cur_height = 0; 808 $width = floor(100 / $opt['cols']); 809 $is_first = true; 810 $fontsize = ''; 811 $list_style = ''; 812 $indent_style = ''; 813 814 // basic result page markup (always needed) 815 $outer_border = ($opt['border'] === 'outside' || $opt['border'] === 'both') ? 'border' : ''; 816 $inner_border = ($opt['border'] === 'inside' || $opt['border'] === 'both') ? 'border' : ''; 817 $tableless = (!$multi_col) ? 'tableless' : ''; 818 819 // fixed anchor point to jump back to at top of the table 820 $top_id = 'top-' . mt_rand(); 821 822 if (!empty($opt['fontsize'])) { 823 $fontsize = 'font-size:' . $opt['fontsize']; 824 } 825 if ($opt['bullet'] !== 'none') { 826 $list_style = 'list-style-position:inside;list-style-type:' . $opt['bullet']; 827 } 828 $can_indent = $opt['group']; 829 830 $render .= '<div class="pagequery ' . $outer_border . " " . $tableless . '" id="' . $top_id . '" style="' 831 . $fontsize . '">' . DOKU_LF; 832 833 if ($opt['showcount'] === true) { 834 $render .= '<div class="count">' . $count . '</div>' . DOKU_LF; 835 } 836 if ($opt['label'] !== '') { 837 $render .= '<h1 class="title">' . $opt['label'] . '</h1>' . DOKU_LF; 838 } 839 if ($multi_col) { 840 $render .= '<table><tbody><tr>' . DOKU_LF; 841 } 842 843 // now render the pagequery list 844 foreach ($sorted_results as $line) { 845 [$level, $name, $id, $_, $abstract, $display] = $line; 846 847 $heading = ''; 848 $is_heading = ($level > 0); 849 if ($is_heading) { 850 $heading = $name; 851 } 852 853 // is it time to start a new column? 854 if ($can_start_col === false && $col < $opt['cols'] && $cur_height >= $col_height) { 855 $can_start_col = true; 856 $col++; 857 } 858 859 // no need for indenting if there is no grouping 860 if ($can_indent) { 861 $indent = ($is_heading) ? $level - 1 : $cont_level - 1; 862 $indent_style = 'margin-left:' . $indent * 10 . 'px;'; 863 } 864 865 // Begin new column if: 1) we are at the start, 2) last item was not a heading or 3) if there is no grouping 866 if ($can_start_col && !$prev_was_heading) { 867 $jump_tip = sprintf($this->lang['jump_section'], $heading); 868 // close the previous column if necessary; also adds a 'jump to anchor' 869 $col_close = (!$is_heading) ? '<a title="' . $jump_tip . '" href="#' . $top_id . '">' 870 . "<h$cont_level>§... </h$cont_level></a>" : ''; 871 $col_close = (!$is_first) ? $col_close . '</ul></td>' . DOKU_LF : ''; 872 $col_open = (!$is_first && !$is_heading) ? '<h' . $cont_level . ' style="' . $indent_style . '">' 873 . $heading . '...</h' . $cont_level . '>' : ''; 874 $td = ($multi_col) ? '<td class="' . $inner_border . '" valign="top" width="' 875 . $width . '%">' : ''; 876 $render .= $col_close . $td . $col_open . DOKU_LF; 877 $can_start_col = false; 878 879 // needed to correctly style page link lists <ul>... 880 $prev_was_heading = true; 881 $cur_height = 0; 882 } 883 884 // finally display the appropriate heading or page link(s) 885 if ($is_heading) { 886 // close previous sub list if necessary 887 if (!$prev_was_heading) { 888 $render .= '</ul>' . DOKU_LF; 889 } 890 if ($opt['nstitle'] && !empty($display)) { 891 $heading = $display; 892 } 893 if ($opt['proper'] === 'header' || $opt['proper'] === 'both') { 894 $heading = $this->proper($heading); 895 } 896 if (!empty($id)) { 897 $heading = $this->htmlWikilink($id, $heading, '', $opt, false, true); 898 } 899 $render .= '<h' . $level . ' style="' . $indent_style . '">' . $heading 900 . '</h' . $level . '>' . DOKU_LF; 901 $prev_was_heading = true; 902 $cont_level = $level + 1; 903 } else { 904 // open a new sub list if necessary 905 if ($prev_was_heading || $is_first) { 906 $render .= '<ul style="' . $indent_style . $list_style . '">'; 907 } 908 // deal with normal page links 909 if ($opt['proper'] === 'name' || $opt['proper'] === 'both') { 910 $display = $this->proper($display); 911 } 912 $link = $this->htmlWikilink($id, $display, $abstract, $opt); 913 $render .= $link; 914 $prev_was_heading = false; 915 } 916 $cur_height += $ratios[$level]; 917 $is_first = false; 918 } 919 $render .= '</ul>' . DOKU_LF; 920 if ($multi_col) { 921 $render .= '</td></tr></tbody></table>' . DOKU_LF; 922 } 923 if ($opt['hidejump'] === false) { 924 $render .= '<a class="top" href="#' . $top_id . '">' . $this->lang['link_to_top'] . '</a>' . DOKU_LF; 925 } 926 $render .= '</div>' . DOKU_LF; 927 928 return $render; 929 } 930 931 /** 932 * Used by the render_as_html_table function below 933 * **DEPRECATED** 934 * 935 * @param $sorted_results 936 * @param $ratios 937 * @return int 938 */ 939 private function adjustedHeight($sorted_results, $ratios): int 940 { 941 // ratio of different heading heights (%), to ensure more even use of columns (h1 -> h6) 942 $adjusted_height = 0; 943 foreach ($sorted_results as $row) { 944 $adjusted_height += $ratios[$row[0]]; 945 } 946 return $adjusted_height; 947 } 948 949 /** 950 * Changes a wiki page id into proper case (allowing for :'s etc...) 951 * @param string $id page id 952 * @return string 953 */ 954 private function proper(string $id): string 955 { 956 $id = str_replace(':', ': ', $id); // make a little whitespace before words so ucwords can work! 957 $id = str_replace('_', ' ', $id); 958 $id = ucwords($id); 959 return str_replace(': ', ':', $id); 960 } 961 962 /** 963 * Renders the page link, plus tooltip, abstract, casing, etc... 964 * @param string $id 965 * @param string $display 966 * @param string $abstract 967 * @param array $opt 968 * @param bool $track_snippets 969 * @param bool $raw non-formatted (no html) 970 * @return string 971 */ 972 private function htmlWikilink( 973 string $id, 974 string $display, 975 string $abstract, 976 array $opt, 977 bool $track_snippets = true, 978 bool $raw = false 979 ): string { 980 $id = (strpos($id, ':') === false) ? ':' . $id : $id; // : needed for root pages (root level) 981 $link = html_wikilink($id, $display); 982 $type = $opt['snippet']['type']; 983 $inline = ''; 984 $after = ''; 985 986 if ($type === 'tooltip') { 987 $tooltip = str_replace("\n\n", "\n", $abstract); 988 $tooltip = htmlentities($tooltip, ENT_QUOTES, 'UTF-8'); 989 $link = $this->addTooltip($link, $tooltip); 990 } elseif (in_array($type, array('quoted', 'plain', 'inline')) && $this->snippet_cnt > 0) { 991 $short = $this->shorten($abstract, $opt['snippet']['extent']); 992 $short = htmlentities($short, ENT_QUOTES, 'UTF-8'); 993 if (!empty($short)) { 994 if ($type === 'quoted' || $type === 'plain') { 995 $more = html_wikilink($id, 'more'); 996 $after = trim($short); 997 $after = str_replace("\n\n", "\n", $after); 998 $after = str_replace("\n", '<br/>', $after); 999 $after = '<div class="' . $type . '">' . $after . $more . '</div>' . DOKU_LF; 1000 } elseif ($type === 'inline') { 1001 $inline .= '<span class=inline>' . $short . '</span>'; 1002 } 1003 } 1004 } 1005 1006 $border = ($opt['underline']) ? 'border' : ''; 1007 if ($raw) { 1008 $wikilink = $link . $inline; 1009 } else { 1010 $wikilink = '<li class="' . $border . '">' . $link . $inline . DOKU_LF . $after . '</li>'; 1011 } 1012 if ($track_snippets) { 1013 $this->snippet_cnt--; 1014 } 1015 return $wikilink; 1016 } 1017 1018 /** 1019 * Swap normal link title (popup) for a more useful preview 1020 * 1021 * @param string $link 1022 * @param string $tooltip title 1023 * @return string complete href link 1024 */ 1025 private function addTooltip(string $link, string $tooltip): string 1026 { 1027 $tooltip = str_replace("\n", ' ', $tooltip); 1028 return preg_replace('/title=\".+?\"/', 'title="' . $tooltip . '"', $link, 1); 1029 } 1030 1031 // real date column 1032 1033 /** 1034 * Return the first part of the text according to the extent given. 1035 * 1036 * @param string $text 1037 * @param string $extent c? = ? chars, w? = ? words, l? = ? lines, ~? = search up to text/char/symbol 1038 * @param string $more symbol to show if more text 1039 * @return string 1040 */ 1041 private function shorten(string $text, string $extent, string $more = '... '): string 1042 { 1043 $elem = $extent[0]; 1044 $cnt = (int)substr($extent, 1); 1045 switch ($elem) { 1046 case 'c': 1047 $result = substr($text, 0, $cnt); 1048 if ($cnt > 0 && strlen($result) < strlen($text)) { 1049 $result .= $more; 1050 } 1051 break; 1052 case 'w': 1053 $words = str_word_count($text, 1, '.'); 1054 $result = implode(' ', array_slice($words, 0, $cnt)); 1055 if ($cnt > 0 && $cnt <= count($words) && $words[$cnt - 1] !== '.') { 1056 $result .= $more; 1057 } 1058 break; 1059 case 'l': 1060 $lines = explode("\n", $text); 1061 $lines = array_filter($lines); // remove blank lines 1062 $result = implode("\n", array_slice($lines, 0, $cnt)); 1063 if ($cnt > 0 && $cnt < count($lines)) { 1064 $result .= $more; 1065 } 1066 break; 1067 case "~": 1068 $result = strstr($text, $cnt, true); 1069 break; 1070 default: 1071 $result = $text; 1072 } 1073 return $result; 1074 } 1075 1076 /** 1077 * Render the final pagequery results list in an HTML column, indented and in columns as required 1078 * 1079 * @param array $sorted_results 1080 * @param array $opt 1081 * @param int $count => count of results 1082 * 1083 * @return string HTML rendered list 1084 */ 1085 protected function renderAsHtmlcolumn(array $sorted_results, array $opt, int $count): string 1086 { 1087 $prev_was_heading = false; 1088 $cont_level = 1; 1089 $is_first = true; 1090 $top_id = 'top-' . mt_rand(); // A fixed anchor at top to jump back to 1091 1092 // CSS for the various display options 1093 $fontsize = (!empty($opt['fontsize'])) ? 'font-size:' . $opt['fontsize'] : ''; 1094 $outer_border = ($opt['border'] === 'outside' || $opt['border'] === 'both') ? 'border' : ''; 1095 $inner_border = ($opt['border'] === 'inside' || $opt['border'] === 'both') ? 'inner-border' : ''; 1096 $show_count = ($opt['showcount'] === true) ? '<div class="count">' . $count . ' ∞</div>' . DOKU_LF : ''; 1097 $label = ($opt['label'] !== '') ? '<h1 class="title">' . $opt['label'] . '</h1>' . DOKU_LF : ''; 1098 $show_jump = ($opt['hidejump'] === false) ? '<a class="top" href="#' . $top_id . '">' 1099 . $this->lang['link_to_top'] . '</a>' . DOKU_LF : ''; 1100 $list_style = ($opt['bullet'] !== 'none') ? 'list-style-position:inside;list-style-type:' 1101 . $opt['bullet'] : ''; 1102 1103 // no grouping => no indenting 1104 $can_indent = $opt['group']; 1105 1106 // now prepare the actual pagequery list 1107 $pagequery = ''; 1108 foreach ($sorted_results as $line) { 1109 [$level, $name, $id, $_, $abstract, $display] = $line; 1110 1111 $is_heading = ($level > 0); 1112 $heading = ($is_heading) ? $name : ''; 1113 1114 if ($can_indent) { 1115 $indent = ($is_heading) ? $level - 1 : $cont_level - 1; 1116 $indent_style = 'margin-left:' . $indent * 10 . 'px;'; 1117 } else { 1118 $indent_style = ''; 1119 } 1120 1121 // finally display the appropriate heading... 1122 if ($is_heading) { 1123 // close previous subheading list if necessary 1124 if (!$prev_was_heading) { 1125 $pagequery .= '</ul>' . DOKU_LF; 1126 } 1127 if ($opt['nstitle'] && !empty($display)) { 1128 $heading = $display; 1129 } 1130 if ($opt['proper'] === 'header' || $opt['proper'] === 'both') { 1131 $heading = $this->proper($heading); 1132 } 1133 if (!empty($id)) { 1134 $heading = $this->htmlWikilink($id, $heading, '', $opt, false, true); 1135 } 1136 $pagequery .= '<h' . $level . ' style="' . $indent_style . '">' . $heading 1137 . '</h' . $level . '>' . DOKU_LF; 1138 $prev_was_heading = true; 1139 $cont_level = $level + 1; 1140 // ...or page link(s) 1141 } else { 1142 // open a new sub list if necessary 1143 if ($prev_was_heading || $is_first) { 1144 $pagequery .= '<ul style="' . $indent_style . $list_style . '">'; 1145 } 1146 // deal with normal page links 1147 if ($opt['proper'] === 'name' || $opt['proper'] === 'both') { 1148 $display = $this->proper($display); 1149 } 1150 $link = $this->htmlWikilink($id, $display, $abstract, $opt); 1151 $pagequery .= $link; 1152 $prev_was_heading = false; 1153 } 1154 $is_first = false; 1155 } 1156 1157 // and put it all together for display 1158 $render = ''; 1159 $render .= '<div class="pagequery ' . $outer_border . '" id="' . $top_id . '" style="' 1160 . $fontsize . '">' . DOKU_LF; 1161 $render .= $show_count . $show_jump . $label . DOKU_LF; 1162 $render .= '<div class="inner ' . $inner_border . '">' . DOKU_LF; 1163 $render .= $pagequery . DOKU_LF; 1164 $render .= '</ul>' . DOKU_LF; 1165 $render .= '</div></div>' . DOKU_LF; 1166 return $render; 1167 } 1168 1169} 1170