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