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