See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 401 and 402] [Versions 401 and 403]
1 <?php 2 // This file is part of Moodle - http://moodle.org/ 3 // 4 // Moodle is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // Moodle is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU General Public License for more details. 13 // 14 // You should have received a copy of the GNU General Public License 15 // along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17 /** 18 * Search subsystem manager. 19 * 20 * @package core_search 21 * @copyright Prateek Sachan {@link http://prateeksachan.com} 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25 namespace core_search; 26 27 defined('MOODLE_INTERNAL') || die; 28 29 require_once($CFG->dirroot . '/lib/accesslib.php'); 30 31 /** 32 * Search subsystem manager. 33 * 34 * @package core_search 35 * @copyright Prateek Sachan {@link http://prateeksachan.com} 36 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 */ 38 class manager { 39 40 /** 41 * @var int Text contents. 42 */ 43 const TYPE_TEXT = 1; 44 45 /** 46 * @var int File contents. 47 */ 48 const TYPE_FILE = 2; 49 50 /** 51 * @var int User can not access the document. 52 */ 53 const ACCESS_DENIED = 0; 54 55 /** 56 * @var int User can access the document. 57 */ 58 const ACCESS_GRANTED = 1; 59 60 /** 61 * @var int The document was deleted. 62 */ 63 const ACCESS_DELETED = 2; 64 65 /** 66 * @var int Maximum number of results that will be retrieved from the search engine. 67 */ 68 const MAX_RESULTS = 100; 69 70 /** 71 * @var int Number of results per page. 72 */ 73 const DISPLAY_RESULTS_PER_PAGE = 10; 74 75 /** 76 * @var int The id to be placed in owneruserid when there is no owner. 77 */ 78 const NO_OWNER_ID = 0; 79 80 /** 81 * @var float If initial query takes longer than N seconds, this will be shown in cron log. 82 */ 83 const DISPLAY_LONG_QUERY_TIME = 5.0; 84 85 /** 86 * @var float Adds indexing progress within one search area to cron log every N seconds. 87 */ 88 const DISPLAY_INDEXING_PROGRESS_EVERY = 30.0; 89 90 /** 91 * @var int Context indexing: normal priority. 92 */ 93 const INDEX_PRIORITY_NORMAL = 100; 94 95 /** 96 * @var int Context indexing: low priority for reindexing. 97 */ 98 const INDEX_PRIORITY_REINDEXING = 50; 99 100 /** 101 * @var string Core search area category for all results. 102 */ 103 const SEARCH_AREA_CATEGORY_ALL = 'core-all'; 104 105 /** 106 * @var string Core search area category for course content. 107 */ 108 const SEARCH_AREA_CATEGORY_COURSE_CONTENT = 'core-course-content'; 109 110 /** 111 * @var string Core search area category for courses. 112 */ 113 const SEARCH_AREA_CATEGORY_COURSES = 'core-courses'; 114 115 /** 116 * @var string Core search area category for users. 117 */ 118 const SEARCH_AREA_CATEGORY_USERS = 'core-users'; 119 120 /** 121 * @var string Core search area category for results that do not fit into any of existing categories. 122 */ 123 const SEARCH_AREA_CATEGORY_OTHER = 'core-other'; 124 125 /** 126 * @var \core_search\base[] Enabled search areas. 127 */ 128 protected static $enabledsearchareas = null; 129 130 /** 131 * @var \core_search\base[] All system search areas. 132 */ 133 protected static $allsearchareas = null; 134 135 /** 136 * @var \core_search\area_category[] A list of search area categories. 137 */ 138 protected static $searchareacategories = null; 139 140 /** 141 * @var \core_search\manager 142 */ 143 protected static $instance = null; 144 145 /** 146 * @var array IDs (as keys) of course deletions in progress in this requuest, if any. 147 */ 148 protected static $coursedeleting = []; 149 150 /** 151 * @var \core_search\engine 152 */ 153 protected $engine = null; 154 155 /** 156 * Note: This should be removed once possible (see MDL-60644). 157 * 158 * @var float Fake current time for use in PHPunit tests 159 */ 160 protected static $phpunitfaketime = 0; 161 162 /** 163 * @var int Result count when used with mock results for Behat tests. 164 */ 165 protected $behatresultcount = 0; 166 167 /** 168 * Constructor, use \core_search\manager::instance instead to get a class instance. 169 * 170 * @param \core_search\base The search engine to use 171 */ 172 public function __construct($engine) { 173 $this->engine = $engine; 174 } 175 176 /** 177 * @var int Record time of each successful schema check, but not more than once per 10 minutes. 178 */ 179 const SCHEMA_CHECK_TRACKING_DELAY = 10 * 60; 180 181 /** 182 * @var int Require a new schema check at least every 4 hours. 183 */ 184 const SCHEMA_CHECK_REQUIRED_EVERY = 4 * 3600; 185 186 /** 187 * Returns an initialised \core_search instance. 188 * 189 * While constructing the instance, checks on the search schema may be carried out. The $fast 190 * parameter provides a way to skip those checks on pages which are used frequently. It has 191 * no effect if an instance has already been constructed in this request. 192 * 193 * The $query parameter indicates that the page is used for queries rather than indexing. If 194 * configured, this will cause the query-only search engine to be used instead of the 'normal' 195 * one. 196 * 197 * @see \core_search\engine::is_installed 198 * @see \core_search\engine::is_server_ready 199 * @param bool $fast Set to true when calling on a page that requires high performance 200 * @param bool $query Set true on a page that is used for querying 201 * @throws \core_search\engine_exception 202 * @return \core_search\manager 203 */ 204 public static function instance(bool $fast = false, bool $query = false) { 205 global $CFG; 206 207 // One per request, this should be purged during testing. 208 if (static::$instance !== null) { 209 return static::$instance; 210 } 211 212 if (empty($CFG->searchengine)) { 213 throw new \core_search\engine_exception('enginenotselected', 'search'); 214 } 215 216 if (!$engine = static::search_engine_instance($query)) { 217 throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine); 218 } 219 220 // Get time now and at last schema check. 221 $now = (int)self::get_current_time(); 222 $lastschemacheck = get_config($engine->get_plugin_name(), 'lastschemacheck'); 223 224 // On pages where performance matters, tell the engine to skip schema checks. 225 $skipcheck = false; 226 if ($fast && $now < $lastschemacheck + self::SCHEMA_CHECK_REQUIRED_EVERY) { 227 $skipcheck = true; 228 $engine->skip_schema_check(); 229 } 230 231 if (!$engine->is_installed()) { 232 throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine); 233 } 234 235 $serverstatus = $engine->is_server_ready(); 236 if ($serverstatus !== true) { 237 // Skip this error in Behat when faking seach results. 238 if (!defined('BEHAT_SITE_RUNNING') || !get_config('core_search', 'behat_fakeresult')) { 239 // Clear the record of successful schema checks since it might have failed. 240 unset_config('lastschemacheck', $engine->get_plugin_name()); 241 // Error message with no details as this is an exception that any user may find if the server crashes. 242 throw new \core_search\engine_exception('engineserverstatus', 'search'); 243 } 244 } 245 246 // If we did a successful schema check, record this, but not more than once per 10 minutes 247 // (to avoid updating the config db table/cache too often in case it gets called frequently). 248 if (!$skipcheck && $now >= $lastschemacheck + self::SCHEMA_CHECK_TRACKING_DELAY) { 249 set_config('lastschemacheck', $now, $engine->get_plugin_name()); 250 } 251 252 static::$instance = new \core_search\manager($engine); 253 return static::$instance; 254 } 255 256 /** 257 * Returns whether global search is enabled or not. 258 * 259 * @return bool 260 */ 261 public static function is_global_search_enabled() { 262 global $CFG; 263 return !empty($CFG->enableglobalsearch); 264 } 265 266 /** 267 * Tests if global search is configured to be equivalent to the front page course search. 268 * 269 * @return bool 270 */ 271 public static function can_replace_course_search(): bool { 272 global $CFG; 273 274 // Assume we can replace front page search. 275 $canreplace = true; 276 277 // Global search must be enabled. 278 if (!static::is_global_search_enabled()) { 279 $canreplace = false; 280 } 281 282 // Users must be able to search the details of all courses that they can see, 283 // even if they do not have access to them. 284 if (empty($CFG->searchincludeallcourses)) { 285 $canreplace = false; 286 } 287 288 // Course search must be enabled. 289 if ($canreplace) { 290 $areaid = static::generate_areaid('core_course', 'course'); 291 $enabledareas = static::get_search_areas_list(true); 292 $canreplace = isset($enabledareas[$areaid]); 293 } 294 295 return $canreplace; 296 } 297 298 /** 299 * Returns the search URL for course search 300 * 301 * @return moodle_url 302 */ 303 public static function get_course_search_url() { 304 if (self::can_replace_course_search()) { 305 $searchurl = '/search/index.php'; 306 } else { 307 $searchurl = '/course/search.php'; 308 } 309 310 return new \moodle_url($searchurl); 311 } 312 313 /** 314 * Returns whether indexing is enabled or not (you can enable indexing even when search is not 315 * enabled at the moment, so as to have it ready for students). 316 * 317 * @return bool True if indexing is enabled. 318 */ 319 public static function is_indexing_enabled() { 320 global $CFG; 321 return !empty($CFG->enableglobalsearch) || !empty($CFG->searchindexwhendisabled); 322 } 323 324 /** 325 * Returns an instance of the search engine. 326 * 327 * @param bool $query If true, gets the query-only search engine (where configured) 328 * @return \core_search\engine 329 */ 330 public static function search_engine_instance(bool $query = false) { 331 global $CFG; 332 333 if ($query && $CFG->searchenginequeryonly) { 334 return self::search_engine_instance_from_setting($CFG->searchenginequeryonly); 335 } else { 336 return self::search_engine_instance_from_setting($CFG->searchengine); 337 } 338 } 339 340 /** 341 * Loads a search engine based on the name given in settings, which can optionally 342 * include '-alternate' to indicate that an alternate version should be used. 343 * 344 * @param string $setting 345 * @return engine|null 346 */ 347 protected static function search_engine_instance_from_setting(string $setting): ?engine { 348 if (preg_match('~^(.*)-alternate$~', $setting, $matches)) { 349 $enginename = $matches[1]; 350 $alternate = true; 351 } else { 352 $enginename = $setting; 353 $alternate = false; 354 } 355 356 $classname = '\\search_' . $enginename . '\\engine'; 357 if (!class_exists($classname)) { 358 return null; 359 } 360 361 if ($alternate) { 362 return new $classname(true); 363 } else { 364 // Use the constructor with no parameters for compatibility. 365 return new $classname(); 366 } 367 } 368 369 /** 370 * Returns the search engine. 371 * 372 * @return \core_search\engine 373 */ 374 public function get_engine() { 375 return $this->engine; 376 } 377 378 /** 379 * Returns a search area class name. 380 * 381 * @param string $areaid 382 * @return string 383 */ 384 protected static function get_area_classname($areaid) { 385 list($componentname, $areaname) = static::extract_areaid_parts($areaid); 386 return '\\' . $componentname . '\\search\\' . $areaname; 387 } 388 389 /** 390 * Returns a new area search indexer instance. 391 * 392 * @param string $areaid 393 * @return \core_search\base|bool False if the area is not available. 394 */ 395 public static function get_search_area($areaid) { 396 397 // We have them all here. 398 if (!empty(static::$allsearchareas[$areaid])) { 399 return static::$allsearchareas[$areaid]; 400 } 401 402 $classname = static::get_area_classname($areaid); 403 404 if (class_exists($classname) && static::is_search_area($classname)) { 405 return new $classname(); 406 } 407 408 return false; 409 } 410 411 /** 412 * Return the list of available search areas. 413 * 414 * @param bool $enabled Return only the enabled ones. 415 * @return \core_search\base[] 416 */ 417 public static function get_search_areas_list($enabled = false) { 418 419 // Two different arrays, we don't expect these arrays to be big. 420 if (static::$allsearchareas !== null) { 421 if (!$enabled) { 422 return static::$allsearchareas; 423 } else { 424 return static::$enabledsearchareas; 425 } 426 } 427 428 static::$allsearchareas = array(); 429 static::$enabledsearchareas = array(); 430 $searchclasses = \core_component::get_component_classes_in_namespace(null, 'search'); 431 432 foreach ($searchclasses as $classname => $classpath) { 433 $areaname = substr(strrchr($classname, '\\'), 1); 434 $componentname = strstr($classname, '\\', 1); 435 if (!static::is_search_area($classname)) { 436 continue; 437 } 438 439 $areaid = static::generate_areaid($componentname, $areaname); 440 $searchclass = new $classname(); 441 static::$allsearchareas[$areaid] = $searchclass; 442 if ($searchclass->is_enabled()) { 443 static::$enabledsearchareas[$areaid] = $searchclass; 444 } 445 } 446 447 if ($enabled) { 448 return static::$enabledsearchareas; 449 } 450 return static::$allsearchareas; 451 } 452 453 /** 454 * Return search area category instance by category name. 455 * 456 * @param string $name Category name. If name is not valid will return default category. 457 * 458 * @return \core_search\area_category 459 */ 460 public static function get_search_area_category_by_name($name) { 461 if (key_exists($name, self::get_search_area_categories())) { 462 return self::get_search_area_categories()[$name]; 463 } else { 464 return self::get_search_area_categories()[self::get_default_area_category_name()]; 465 } 466 } 467 468 /** 469 * Return a list of existing search area categories. 470 * 471 * @return \core_search\area_category[] 472 */ 473 public static function get_search_area_categories() { 474 if (!isset(static::$searchareacategories)) { 475 $categories = self::get_core_search_area_categories(); 476 477 // Go through all existing search areas and get categories they are assigned to. 478 $areacategories = []; 479 foreach (self::get_search_areas_list() as $searcharea) { 480 foreach ($searcharea->get_category_names() as $categoryname) { 481 if (!key_exists($categoryname, $areacategories)) { 482 $areacategories[$categoryname] = []; 483 } 484 485 $areacategories[$categoryname][] = $searcharea; 486 } 487 } 488 489 // Populate core categories by areas. 490 foreach ($areacategories as $name => $searchareas) { 491 if (key_exists($name, $categories)) { 492 $categories[$name]->set_areas($searchareas); 493 } else { 494 throw new \coding_exception('Unknown core search area category ' . $name); 495 } 496 } 497 498 // Get additional categories. 499 $additionalcategories = self::get_additional_search_area_categories(); 500 foreach ($additionalcategories as $additionalcategory) { 501 if (!key_exists($additionalcategory->get_name(), $categories)) { 502 $categories[$additionalcategory->get_name()] = $additionalcategory; 503 } 504 } 505 506 // Remove categories without areas. 507 foreach ($categories as $key => $category) { 508 if (empty($category->get_areas())) { 509 unset($categories[$key]); 510 } 511 } 512 513 // Sort categories by order. 514 uasort($categories, function($category1, $category2) { 515 return $category1->get_order() <=> $category2->get_order(); 516 }); 517 518 static::$searchareacategories = $categories; 519 } 520 521 return static::$searchareacategories; 522 } 523 524 /** 525 * Get list of core search area categories. 526 * 527 * @return \core_search\area_category[] 528 */ 529 protected static function get_core_search_area_categories() { 530 $categories = []; 531 532 $categories[self::SEARCH_AREA_CATEGORY_ALL] = new area_category( 533 self::SEARCH_AREA_CATEGORY_ALL, 534 get_string('core-all', 'search'), 535 0, 536 self::get_search_areas_list(true) 537 ); 538 539 $categories[self::SEARCH_AREA_CATEGORY_COURSE_CONTENT] = new area_category( 540 self::SEARCH_AREA_CATEGORY_COURSE_CONTENT, 541 get_string('core-course-content', 'search'), 542 1 543 ); 544 545 $categories[self::SEARCH_AREA_CATEGORY_COURSES] = new area_category( 546 self::SEARCH_AREA_CATEGORY_COURSES, 547 get_string('core-courses', 'search'), 548 2 549 ); 550 551 $categories[self::SEARCH_AREA_CATEGORY_USERS] = new area_category( 552 self::SEARCH_AREA_CATEGORY_USERS, 553 get_string('core-users', 'search'), 554 3 555 ); 556 557 $categories[self::SEARCH_AREA_CATEGORY_OTHER] = new area_category( 558 self::SEARCH_AREA_CATEGORY_OTHER, 559 get_string('core-other', 'search'), 560 4 561 ); 562 563 return $categories; 564 } 565 566 /** 567 * Gets a list of additional search area categories. 568 * 569 * @return \core_search\area_category[] 570 */ 571 protected static function get_additional_search_area_categories() { 572 $additionalcategories = []; 573 574 // Allow plugins to add custom search area categories. 575 if ($pluginsfunction = get_plugins_with_function('search_area_categories')) { 576 foreach ($pluginsfunction as $plugintype => $plugins) { 577 foreach ($plugins as $pluginfunction) { 578 $plugincategories = $pluginfunction(); 579 // We're expecting a list of valid area categories. 580 if (is_array($plugincategories)) { 581 foreach ($plugincategories as $plugincategory) { 582 if (self::is_valid_area_category($plugincategory)) { 583 $additionalcategories[] = $plugincategory; 584 } else { 585 throw new \coding_exception('Invalid search area category!'); 586 } 587 } 588 } else { 589 throw new \coding_exception($pluginfunction . ' should return a list of search area categories!'); 590 } 591 } 592 } 593 } 594 595 return $additionalcategories; 596 } 597 598 /** 599 * Check if provided instance of area category is valid. 600 * 601 * @param mixed $areacategory Area category instance. Potentially could be anything. 602 * 603 * @return bool 604 */ 605 protected static function is_valid_area_category($areacategory) { 606 return $areacategory instanceof area_category; 607 } 608 609 /** 610 * Clears all static caches. 611 * 612 * @return void 613 */ 614 public static function clear_static() { 615 616 static::$enabledsearchareas = null; 617 static::$allsearchareas = null; 618 static::$instance = null; 619 static::$searchareacategories = null; 620 static::$coursedeleting = []; 621 static::$phpunitfaketime = null; 622 623 base_block::clear_static(); 624 engine::clear_users_cache(); 625 } 626 627 /** 628 * Generates an area id from the componentname and the area name. 629 * 630 * There should not be any naming conflict as the area name is the 631 * class name in component/classes/search/. 632 * 633 * @param string $componentname 634 * @param string $areaname 635 * @return void 636 */ 637 public static function generate_areaid($componentname, $areaname) { 638 return $componentname . '-' . $areaname; 639 } 640 641 /** 642 * Returns all areaid string components (component name and area name). 643 * 644 * @param string $areaid 645 * @return array Component name (Frankenstyle) and area name (search area class name) 646 */ 647 public static function extract_areaid_parts($areaid) { 648 return explode('-', $areaid); 649 } 650 651 /** 652 * Parse a search area id and get plugin name and config name prefix from it. 653 * 654 * @param string $areaid Search area id. 655 * @return array Where the first element is a plugin name and the second is config names prefix. 656 */ 657 public static function parse_areaid($areaid) { 658 $parts = self::extract_areaid_parts($areaid); 659 660 if (empty($parts[1])) { 661 throw new \coding_exception('Trying to parse invalid search area id ' . $areaid); 662 } 663 664 $component = $parts[0]; 665 $area = $parts[1]; 666 667 if (strpos($component, 'core') === 0) { 668 $plugin = 'core_search'; 669 $configprefix = str_replace('-', '_', $areaid); 670 } else { 671 $plugin = $component; 672 $configprefix = 'search_' . $area; 673 } 674 675 return [$plugin, $configprefix]; 676 } 677 678 /** 679 * Returns information about the areas which the user can access. 680 * 681 * The returned value is a stdClass object with the following fields: 682 * - everything (bool, true for admin only) 683 * - usercontexts (indexed by area identifier then context 684 * - separategroupscontexts (contexts within which group restrictions apply) 685 * - visiblegroupscontextsareas (overrides to the above when the same contexts also have 686 * 'visible groups' for certain search area ids - hopefully rare) 687 * - usergroups (groups which the current user belongs to) 688 * 689 * The areas can be limited by course id and context id. If specifying context ids, results 690 * are limited to the exact context ids specified and not their children (for example, giving 691 * the course context id would result in including search items with the course context id, and 692 * not anything from a context inside the course). For performance, you should also specify 693 * course id(s) when using context ids. 694 * 695 * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. 696 * @param array|false $limitcontextids An array of context ids to limit the search to. False for no limiting. 697 * @return \stdClass Object as described above 698 */ 699 protected function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) { 700 global $DB, $USER; 701 702 // All results for admins (unless they have chosen to limit results). Eventually we could 703 // add a new capability for managers. 704 if (is_siteadmin() && !$limitcourseids && !$limitcontextids) { 705 return (object)array('everything' => true); 706 } 707 708 $areasbylevel = array(); 709 710 // Split areas by context level so we only iterate only once through courses and cms. 711 $searchareas = static::get_search_areas_list(true); 712 foreach ($searchareas as $areaid => $unused) { 713 $classname = static::get_area_classname($areaid); 714 $searcharea = new $classname(); 715 foreach ($classname::get_levels() as $level) { 716 $areasbylevel[$level][$areaid] = $searcharea; 717 } 718 } 719 720 // This will store area - allowed contexts relations. 721 $areascontexts = array(); 722 723 // Initialise two special-case arrays for storing other information related to the contexts. 724 $separategroupscontexts = array(); 725 $visiblegroupscontextsareas = array(); 726 $usergroups = array(); 727 728 if (empty($limitcourseids) && !empty($areasbylevel[CONTEXT_SYSTEM])) { 729 // We add system context to all search areas working at this level. Here each area is fully responsible of 730 // the access control as we can not automate much, we can not even check guest access as some areas might 731 // want to allow guests to retrieve data from them. 732 733 $systemcontextid = \context_system::instance()->id; 734 if (!$limitcontextids || in_array($systemcontextid, $limitcontextids)) { 735 foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) { 736 $areascontexts[$areaid][$systemcontextid] = $systemcontextid; 737 } 738 } 739 } 740 741 if (!empty($areasbylevel[CONTEXT_USER])) { 742 if ($usercontext = \context_user::instance($USER->id, IGNORE_MISSING)) { 743 if (!$limitcontextids || in_array($usercontext->id, $limitcontextids)) { 744 // Extra checking although only logged users should reach this point, guest users have a valid context id. 745 foreach ($areasbylevel[CONTEXT_USER] as $areaid => $searchclass) { 746 $areascontexts[$areaid][$usercontext->id] = $usercontext->id; 747 } 748 } 749 } 750 } 751 752 if (is_siteadmin()) { 753 $allcourses = $this->get_all_courses($limitcourseids); 754 } else { 755 $allcourses = $mycourses = $this->get_my_courses((bool)get_config('core', 'searchallavailablecourses')); 756 757 if (self::include_all_courses()) { 758 $allcourses = $this->get_all_courses($limitcourseids); 759 } 760 } 761 762 if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) { 763 $allcourses[SITEID] = get_course(SITEID); 764 if (isset($mycourses)) { 765 $mycourses[SITEID] = get_course(SITEID); 766 } 767 } 768 769 // Keep a list of included course context ids (needed for the block calculation below). 770 $coursecontextids = []; 771 $modulecms = []; 772 773 foreach ($allcourses as $course) { 774 if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) { 775 // Skip non-included courses. 776 continue; 777 } 778 779 $coursecontext = \context_course::instance($course->id); 780 $hasgrouprestrictions = false; 781 782 if (!empty($areasbylevel[CONTEXT_COURSE]) && 783 (!$limitcontextids || in_array($coursecontext->id, $limitcontextids))) { 784 // Add the course contexts the user can view. 785 foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) { 786 if (!empty($mycourses[$course->id]) || \core_course_category::can_view_course_info($course)) { 787 $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id; 788 } 789 } 790 } 791 792 // Skip module context if a user can't access related course. 793 if (isset($mycourses) && !key_exists($course->id, $mycourses)) { 794 continue; 795 } 796 797 $coursecontextids[] = $coursecontext->id; 798 799 // Info about the course modules. 800 $modinfo = get_fast_modinfo($course); 801 802 if (!empty($areasbylevel[CONTEXT_MODULE])) { 803 // Add the module contexts the user can view (cm_info->uservisible). 804 805 foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) { 806 807 // Removing the plugintype 'mod_' prefix. 808 $modulename = substr($searchclass->get_component_name(), 4); 809 810 $modinstances = $modinfo->get_instances_of($modulename); 811 foreach ($modinstances as $modinstance) { 812 // Skip module context if not included in list of context ids. 813 if ($limitcontextids && !in_array($modinstance->context->id, $limitcontextids)) { 814 continue; 815 } 816 if ($modinstance->uservisible) { 817 $contextid = $modinstance->context->id; 818 $areascontexts[$areaid][$contextid] = $contextid; 819 $modulecms[$modinstance->id] = $modinstance; 820 821 if (!has_capability('moodle/site:accessallgroups', $modinstance->context) && 822 ($searchclass instanceof base_mod) && 823 $searchclass->supports_group_restriction()) { 824 if ($searchclass->restrict_cm_access_by_group($modinstance)) { 825 $separategroupscontexts[$contextid] = $contextid; 826 $hasgrouprestrictions = true; 827 } else { 828 // Track a list of anything that has a group id (so might get 829 // filtered) and doesn't want to be, in this context. 830 if (!array_key_exists($contextid, $visiblegroupscontextsareas)) { 831 $visiblegroupscontextsareas[$contextid] = array(); 832 } 833 $visiblegroupscontextsareas[$contextid][$areaid] = $areaid; 834 } 835 } 836 } 837 } 838 } 839 } 840 841 // Insert group information for course (unless there aren't any modules restricted by 842 // group for this user in this course, in which case don't bother). 843 if ($hasgrouprestrictions) { 844 $groups = groups_get_all_groups($course->id, $USER->id, 0, 'g.id'); 845 foreach ($groups as $group) { 846 $usergroups[$group->id] = $group->id; 847 } 848 } 849 } 850 851 // Chuck away all the 'visible groups contexts' data unless there is actually something 852 // that does use separate groups in the same context (this data is only used as an 853 // 'override' in cases where the search is restricting to separate groups). 854 foreach ($visiblegroupscontextsareas as $contextid => $areas) { 855 if (!array_key_exists($contextid, $separategroupscontexts)) { 856 unset($visiblegroupscontextsareas[$contextid]); 857 } 858 } 859 860 // Add all supported block contexts for course contexts that user can access, in a single query for performance. 861 if (!empty($areasbylevel[CONTEXT_BLOCK]) && !empty($coursecontextids)) { 862 // Get list of all block types we care about. 863 $blocklist = []; 864 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { 865 $blocklist[$searchclass->get_block_name()] = true; 866 } 867 list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist)); 868 869 // Get list of course contexts. 870 list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids); 871 872 // Get list of block context (if limited). 873 $blockcontextwhere = ''; 874 $blockcontextparams = []; 875 if ($limitcontextids) { 876 list ($blockcontextsql, $blockcontextparams) = $DB->get_in_or_equal($limitcontextids); 877 $blockcontextwhere = 'AND x.id ' . $blockcontextsql; 878 } 879 880 // Query all blocks that are within an included course, and are set to be visible, and 881 // in a supported page type (basically just course view). This query could be 882 // extended (or a second query added) to support blocks that are within a module 883 // context as well, and we could add more page types if required. 884 $blockrecs = $DB->get_records_sql(" 885 SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid 886 FROM {block_instances} bi 887 JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ? 888 LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id 889 AND bp.contextid = bi.parentcontextid 890 AND bp.pagetype LIKE 'course-view-%' 891 AND bp.subpage = '' 892 AND bp.visible = 0 893 WHERE bi.parentcontextid $contextsql 894 $blockcontextwhere 895 AND bi.blockname $blocknamesql 896 AND bi.subpagepattern IS NULL 897 AND (bi.pagetypepattern = 'site-index' 898 OR bi.pagetypepattern LIKE 'course-view-%' 899 OR bi.pagetypepattern = 'course-*' 900 OR bi.pagetypepattern = '*') 901 AND bp.id IS NULL", 902 array_merge([CONTEXT_BLOCK], $contextparams, $blockcontextparams, $blocknameparams)); 903 $blockcontextsbyname = []; 904 foreach ($blockrecs as $blockrec) { 905 if (empty($blockcontextsbyname[$blockrec->blockname])) { 906 $blockcontextsbyname[$blockrec->blockname] = []; 907 } 908 \context_helper::preload_from_record($blockrec); 909 $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance( 910 $blockrec->blockinstanceid); 911 } 912 913 // Add the block contexts the user can view. 914 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) { 915 if (empty($blockcontextsbyname[$searchclass->get_block_name()])) { 916 continue; 917 } 918 foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) { 919 if (has_capability('moodle/block:view', $context)) { 920 $areascontexts[$areaid][$context->id] = $context->id; 921 } 922 } 923 } 924 } 925 926 // Return all the data. 927 return (object)array('everything' => false, 'usercontexts' => $areascontexts, 928 'separategroupscontexts' => $separategroupscontexts, 'usergroups' => $usergroups, 929 'visiblegroupscontextsareas' => $visiblegroupscontextsareas); 930 } 931 932 /** 933 * Returns requested page of documents plus additional information for paging. 934 * 935 * This function does not perform any kind of security checking for access, the caller code 936 * should check that the current user have moodle/search:query capability. 937 * 938 * If a page is requested that is beyond the last result, the last valid page is returned in 939 * results, and actualpage indicates which page was returned. 940 * 941 * @param stdClass $formdata 942 * @param int $pagenum The 0 based page number. 943 * @return object An object with 3 properties: 944 * results => An array of \core_search\documents for the actual page. 945 * totalcount => Number of records that are possibly available, to base paging on. 946 * actualpage => The actual page returned. 947 */ 948 public function paged_search(\stdClass $formdata, $pagenum) { 949 $out = new \stdClass(); 950 951 if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { 952 $cat = self::get_search_area_category_by_name($formdata->cat); 953 if (empty($formdata->areaids)) { 954 $formdata->areaids = array_keys($cat->get_areas()); 955 } else { 956 foreach ($formdata->areaids as $key => $areaid) { 957 if (!key_exists($areaid, $cat->get_areas())) { 958 unset($formdata->areaids[$key]); 959 } 960 } 961 } 962 } 963 964 $perpage = static::DISPLAY_RESULTS_PER_PAGE; 965 966 // Make sure we only allow request up to max page. 967 $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1); 968 969 // Calculate the first and last document number for the current page, 1 based. 970 $mindoc = ($pagenum * $perpage) + 1; 971 $maxdoc = ($pagenum + 1) * $perpage; 972 973 // Get engine documents, up to max. 974 $docs = $this->search($formdata, $maxdoc); 975 976 $resultcount = count($docs); 977 if ($resultcount < $maxdoc) { 978 // This means it couldn't give us results to max, so the count must be the max. 979 $out->totalcount = $resultcount; 980 } else { 981 // Get the possible count reported by engine, and limit to our max. 982 $out->totalcount = $this->engine->get_query_total_count(); 983 if (defined('BEHAT_SITE_RUNNING') && $this->behatresultcount) { 984 // Override results when using Behat mock results. 985 $out->totalcount = $this->behatresultcount; 986 } 987 $out->totalcount = min($out->totalcount, static::MAX_RESULTS); 988 } 989 990 // Determine the actual page. 991 if ($resultcount < $mindoc) { 992 // We couldn't get the min docs for this page, so determine what page we can get. 993 $out->actualpage = floor(($resultcount - 1) / $perpage); 994 } else { 995 $out->actualpage = $pagenum; 996 } 997 998 // Split the results to only return the page. 999 $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true); 1000 1001 return $out; 1002 } 1003 1004 /** 1005 * Returns documents from the engine based on the data provided. 1006 * 1007 * This function does not perform any kind of security checking, the caller code 1008 * should check that the current user have moodle/search:query capability. 1009 * 1010 * It might return the results from the cache instead. 1011 * 1012 * Valid formdata options include: 1013 * - q (query text) 1014 * - courseids (optional list of course ids to restrict) 1015 * - contextids (optional list of context ids to restrict) 1016 * - context (Moodle context object for location user searched from) 1017 * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance') 1018 * - userids (optional list of user ids to restrict) 1019 * 1020 * @param \stdClass $formdata Query input data (usually from search form) 1021 * @param int $limit The maximum number of documents to return 1022 * @return \core_search\document[] 1023 */ 1024 public function search(\stdClass $formdata, $limit = 0) { 1025 // For Behat testing, the search results can be faked using a special step. 1026 if (defined('BEHAT_SITE_RUNNING')) { 1027 $fakeresult = get_config('core_search', 'behat_fakeresult'); 1028 if ($fakeresult) { 1029 // Clear config setting. 1030 unset_config('core_search', 'behat_fakeresult'); 1031 1032 // Check query matches expected value. 1033 $details = json_decode($fakeresult); 1034 if ($formdata->q !== $details->query) { 1035 throw new \coding_exception('Unexpected search query: ' . $formdata->q); 1036 } 1037 1038 // Create search documents from the JSON data. 1039 $docs = []; 1040 foreach ($details->results as $result) { 1041 $doc = new \core_search\document($result->itemid, $result->componentname, 1042 $result->areaname); 1043 foreach ((array)$result->fields as $field => $value) { 1044 $doc->set($field, $value); 1045 } 1046 foreach ((array)$result->extrafields as $field => $value) { 1047 $doc->set_extra($field, $value); 1048 } 1049 $area = $this->get_search_area($doc->get('areaid')); 1050 $doc->set_doc_url($area->get_doc_url($doc)); 1051 $doc->set_context_url($area->get_context_url($doc)); 1052 $docs[] = $doc; 1053 } 1054 1055 // Store the mock count, and apply the limit to the returned results. 1056 $this->behatresultcount = count($docs); 1057 if ($this->behatresultcount > $limit) { 1058 $docs = array_slice($docs, 0, $limit); 1059 } 1060 1061 return $docs; 1062 } 1063 } 1064 1065 $limitcourseids = $this->build_limitcourseids($formdata); 1066 1067 $limitcontextids = false; 1068 if (!empty($formdata->contextids)) { 1069 $limitcontextids = $formdata->contextids; 1070 } 1071 1072 // Clears previous query errors. 1073 $this->engine->clear_query_error(); 1074 1075 $contextinfo = $this->get_areas_user_accesses($limitcourseids, $limitcontextids); 1076 if (!$contextinfo->everything && !$contextinfo->usercontexts) { 1077 // User can not access any context. 1078 $docs = array(); 1079 } else { 1080 // If engine does not support groups, remove group information from the context info - 1081 // use the old format instead (true = admin, array = user contexts). 1082 if (!$this->engine->supports_group_filtering()) { 1083 $contextinfo = $contextinfo->everything ? true : $contextinfo->usercontexts; 1084 } 1085 1086 // Execute the actual query. 1087 $docs = $this->engine->execute_query($formdata, $contextinfo, $limit); 1088 } 1089 1090 return $docs; 1091 } 1092 1093 /** 1094 * Search for top ranked result. 1095 * @param \stdClass $formdata search query data 1096 * @return array|document[] 1097 */ 1098 public function search_top(\stdClass $formdata): array { 1099 global $USER; 1100 1101 // Return if the config value is set to 0. 1102 $maxtopresult = get_config('core', 'searchmaxtopresults'); 1103 if (empty($maxtopresult)) { 1104 return []; 1105 } 1106 1107 // Only process if 'searchenablecategories' is set. 1108 if (self::is_search_area_categories_enabled() && !empty($formdata->cat)) { 1109 $cat = self::get_search_area_category_by_name($formdata->cat); 1110 $formdata->areaids = array_keys($cat->get_areas()); 1111 } else { 1112 return []; 1113 } 1114 $docs = $this->search($formdata); 1115 1116 // Look for course, teacher and course content. 1117 $coursedocs = []; 1118 $courseteacherdocs = []; 1119 $coursecontentdocs = []; 1120 $otherdocs = []; 1121 foreach ($docs as $doc) { 1122 if ($doc->get('areaid') === 'core_course-course' && stripos($doc->get('title'), $formdata->q) !== false) { 1123 $coursedocs[] = $doc; 1124 } else if (strpos($doc->get('areaid'), 'course_teacher') !== false 1125 && stripos($doc->get('content'), $formdata->q) !== false) { 1126 $courseteacherdocs[] = $doc; 1127 } else if (strpos($doc->get('areaid'), 'mod_') !== false) { 1128 $coursecontentdocs[] = $doc; 1129 } else { 1130 $otherdocs[] = $doc; 1131 } 1132 } 1133 1134 // Swap current courses to top. 1135 $enroledcourses = $this->get_my_courses(false); 1136 // Move current courses of the user to top. 1137 foreach ($enroledcourses as $course) { 1138 $completion = new \completion_info($course); 1139 if (!$completion->is_course_complete($USER->id)) { 1140 foreach ($coursedocs as $index => $doc) { 1141 $areaid = $doc->get('areaid'); 1142 if ($areaid == 'core_course-course' && $course->id == $doc->get('courseid')) { 1143 unset($coursedocs[$index]); 1144 array_unshift($coursedocs, $doc); 1145 } 1146 } 1147 } 1148 } 1149 1150 $maxtopresult = get_config('core', 'searchmaxtopresults'); 1151 $result = array_merge($coursedocs, $courseteacherdocs, $coursecontentdocs, $otherdocs); 1152 return array_slice($result, 0, $maxtopresult); 1153 } 1154 1155 /** 1156 * Build a list of course ids to limit the search based on submitted form data. 1157 * 1158 * @param \stdClass $formdata Submitted search form data. 1159 * 1160 * @return array|bool 1161 */ 1162 protected function build_limitcourseids(\stdClass $formdata) { 1163 $limitcourseids = false; 1164 1165 if (!empty($formdata->mycoursesonly)) { 1166 $limitcourseids = array_keys($this->get_my_courses(false)); 1167 } 1168 1169 if (!empty($formdata->courseids)) { 1170 if (empty($limitcourseids)) { 1171 $limitcourseids = $formdata->courseids; 1172 } else { 1173 $limitcourseids = array_intersect($limitcourseids, $formdata->courseids); 1174 } 1175 } 1176 1177 return $limitcourseids; 1178 } 1179 1180 /** 1181 * Merge separate index segments into one. 1182 */ 1183 public function optimize_index() { 1184 $this->engine->optimize(); 1185 } 1186 1187 /** 1188 * Index all documents. 1189 * 1190 * @param bool $fullindex Whether we should reindex everything or not. 1191 * @param float $timelimit Time limit in seconds (0 = no time limit) 1192 * @param \progress_trace|null $progress Optional class for tracking progress 1193 * @throws \moodle_exception 1194 * @return bool Whether there was any updated document or not. 1195 */ 1196 public function index($fullindex = false, $timelimit = 0, \progress_trace $progress = null) { 1197 global $DB; 1198 1199 // Cannot combine time limit with reindex. 1200 if ($timelimit && $fullindex) { 1201 throw new \coding_exception('Cannot apply time limit when reindexing'); 1202 } 1203 if (!$progress) { 1204 $progress = new \null_progress_trace(); 1205 } 1206 1207 // Unlimited time. 1208 \core_php_time_limit::raise(); 1209 1210 // Notify the engine that an index starting. 1211 $this->engine->index_starting($fullindex); 1212 1213 $sumdocs = 0; 1214 1215 $searchareas = $this->get_search_areas_list(true); 1216 1217 if ($timelimit) { 1218 // If time is limited (and therefore we're not just indexing everything anyway), select 1219 // an order for search areas. The intention here is to avoid a situation where a new 1220 // large search area is enabled, and this means all our other search areas go out of 1221 // date while that one is being indexed. To do this, we order by the time we spent 1222 // indexing them last time we ran, meaning anything that took a very long time will be 1223 // done last. 1224 uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) { 1225 return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration(); 1226 }); 1227 1228 // Decide time to stop. 1229 $stopat = self::get_current_time() + $timelimit; 1230 } 1231 1232 foreach ($searchareas as $areaid => $searcharea) { 1233 1234 $progress->output('Processing area: ' . $searcharea->get_visible_name()); 1235 1236 // Notify the engine that an area is starting. 1237 $this->engine->area_index_starting($searcharea, $fullindex); 1238 1239 $indexingstart = (int)self::get_current_time(); 1240 $elapsed = self::get_current_time(); 1241 1242 // This is used to store this component config. 1243 list($componentconfigname, $varname) = $searcharea->get_config_var_name(); 1244 1245 $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart')); 1246 1247 if ($fullindex === true) { 1248 $referencestarttime = 0; 1249 1250 // For full index, we delete any queued context index requests, as those will 1251 // obviously be met by the full index. 1252 $DB->delete_records('search_index_requests'); 1253 } else { 1254 $partial = get_config($componentconfigname, $varname . '_partial'); 1255 if ($partial) { 1256 // When the previous index did not complete all data, we start from the time of the 1257 // last document that was successfully indexed. (Note this will result in 1258 // re-indexing that one document, but we can't avoid that because there may be 1259 // other documents in the same second.) 1260 $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun')); 1261 } else { 1262 $referencestarttime = $prevtimestart; 1263 } 1264 } 1265 1266 // Getting the recordset from the area. 1267 $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime); 1268 $initialquerytime = self::get_current_time() - $elapsed; 1269 if ($initialquerytime > self::DISPLAY_LONG_QUERY_TIME) { 1270 $progress->output('Initial query took ' . round($initialquerytime, 1) . 1271 ' seconds.', 1); 1272 } 1273 1274 // Pass get_document as callback. 1275 $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing(); 1276 $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart); 1277 if ($timelimit) { 1278 $options['stopat'] = $stopat; 1279 } 1280 $options['progress'] = $progress; 1281 $iterator = new skip_future_documents_iterator(new \core\dml\recordset_walk( 1282 $recordset, array($searcharea, 'get_document'), $options)); 1283 $result = $this->engine->add_documents($iterator, $searcharea, $options); 1284 $recordset->close(); 1285 $batchinfo = ''; 1286 if (count($result) === 6) { 1287 [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; 1288 // Only show the batch count if we actually batched any requests. 1289 if ($batches !== $numdocs + $numdocsignored) { 1290 $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; 1291 } 1292 } else if (count($result) === 5) { 1293 // Backward compatibility for engines that don't return a batch count. 1294 [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result; 1295 // Deprecated since Moodle 3.10 MDL-68690. 1296 // TODO: MDL-68776 This will be deleted in Moodle 4.2. 1297 debugging('engine::add_documents() should return $batches (5-value return is deprecated)', 1298 DEBUG_DEVELOPER); 1299 } else { 1300 throw new coding_exception('engine::add_documents() should return $partial (4-value return is deprecated)'); 1301 } 1302 1303 if ($numdocs > 0) { 1304 $elapsed = round((self::get_current_time() - $elapsed), 1); 1305 1306 $partialtext = ''; 1307 if ($partial) { 1308 $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc, 1309 get_string('strftimedatetimeshort', 'langconfig')) . ')'; 1310 } 1311 1312 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . 1313 ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . $partialtext . '.', 1); 1314 } else { 1315 $progress->output('No new documents to index.', 1); 1316 } 1317 1318 // Notify the engine this area is complete, and only mark times if true. 1319 if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) { 1320 $sumdocs += $numdocs; 1321 1322 // Store last index run once documents have been committed to the search engine. 1323 set_config($varname . '_indexingstart', $indexingstart, $componentconfigname); 1324 set_config($varname . '_indexingend', (int)self::get_current_time(), $componentconfigname); 1325 set_config($varname . '_docsignored', $numdocsignored, $componentconfigname); 1326 set_config($varname . '_docsprocessed', $numdocs, $componentconfigname); 1327 set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname); 1328 if ($lastindexeddoc > 0) { 1329 set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname); 1330 } 1331 if ($partial) { 1332 set_config($varname . '_partial', 1, $componentconfigname); 1333 } else { 1334 unset_config($varname . '_partial', $componentconfigname); 1335 } 1336 } else { 1337 $progress->output('Engine reported error.'); 1338 } 1339 1340 if ($timelimit && (self::get_current_time() >= $stopat)) { 1341 $progress->output('Stopping indexing due to time limit.'); 1342 break; 1343 } 1344 } 1345 1346 if ($sumdocs > 0) { 1347 $event = \core\event\search_indexed::create( 1348 array('context' => \context_system::instance())); 1349 $event->trigger(); 1350 } 1351 1352 $this->engine->index_complete($sumdocs, $fullindex); 1353 1354 return (bool)$sumdocs; 1355 } 1356 1357 /** 1358 * Indexes or reindexes a specific context of the system, e.g. one course. 1359 * 1360 * The function returns an object with field 'complete' (true or false). 1361 * 1362 * This function supports partial indexing via the time limit parameter. If the time limit 1363 * expires, it will return values for $startfromarea and $startfromtime which can be passed 1364 * next time to continue indexing. 1365 * 1366 * @param \context $context Context to restrict index. 1367 * @param string $singleareaid If specified, indexes only the given area. 1368 * @param float $timelimit Time limit in seconds (0 = no time limit) 1369 * @param \progress_trace|null $progress Optional class for tracking progress 1370 * @param string $startfromarea Area to start from 1371 * @param int $startfromtime Timestamp to start from 1372 * @return \stdClass Object indicating success 1373 */ 1374 public function index_context($context, $singleareaid = '', $timelimit = 0, 1375 \progress_trace $progress = null, $startfromarea = '', $startfromtime = 0) { 1376 if (!$progress) { 1377 $progress = new \null_progress_trace(); 1378 } 1379 1380 // Work out time to stop, if limited. 1381 if ($timelimit) { 1382 // Decide time to stop. 1383 $stopat = self::get_current_time() + $timelimit; 1384 } 1385 1386 // No PHP time limit. 1387 \core_php_time_limit::raise(); 1388 1389 // Notify the engine that an index starting. 1390 $this->engine->index_starting(false); 1391 1392 $sumdocs = 0; 1393 1394 // Get all search areas, in consistent order. 1395 $searchareas = $this->get_search_areas_list(true); 1396 ksort($searchareas); 1397 1398 // Are we skipping past some that were handled previously? 1399 $skipping = $startfromarea ? true : false; 1400 1401 foreach ($searchareas as $areaid => $searcharea) { 1402 // If we're only processing one area id, skip all the others. 1403 if ($singleareaid && $singleareaid !== $areaid) { 1404 continue; 1405 } 1406 1407 // If we're skipping to a later area, continue through the loop. 1408 $referencestarttime = 0; 1409 if ($skipping) { 1410 if ($areaid !== $startfromarea) { 1411 continue; 1412 } 1413 // Stop skipping and note the reference start time. 1414 $skipping = false; 1415 $referencestarttime = $startfromtime; 1416 } 1417 1418 $progress->output('Processing area: ' . $searcharea->get_visible_name()); 1419 1420 $elapsed = self::get_current_time(); 1421 1422 // Get the recordset of all documents from the area for this context. 1423 $recordset = $searcharea->get_document_recordset($referencestarttime, $context); 1424 if (!$recordset) { 1425 if ($recordset === null) { 1426 $progress->output('Skipping (not relevant to context).', 1); 1427 } else { 1428 $progress->output('Skipping (does not support context indexing).', 1); 1429 } 1430 continue; 1431 } 1432 1433 // Notify the engine that an area is starting. 1434 $this->engine->area_index_starting($searcharea, false); 1435 1436 // Work out search options. 1437 $options = []; 1438 $options['indexfiles'] = $this->engine->file_indexing_enabled() && 1439 $searcharea->uses_file_indexing(); 1440 if ($timelimit) { 1441 $options['stopat'] = $stopat; 1442 } 1443 1444 // Construct iterator which will use get_document on the recordset results. 1445 $iterator = new \core\dml\recordset_walk($recordset, 1446 array($searcharea, 'get_document'), $options); 1447 1448 // Use this iterator to add documents. 1449 $result = $this->engine->add_documents($iterator, $searcharea, $options); 1450 $batchinfo = ''; 1451 if (count($result) === 6) { 1452 [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; 1453 // Only show the batch count if we actually batched any requests. 1454 if ($batches !== $numdocs + $numdocsignored) { 1455 $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; 1456 } 1457 } else if (count($result) === 5) { 1458 // Backward compatibility for engines that don't return a batch count. 1459 [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial] = $result; 1460 // Deprecated since Moodle 3.10 MDL-68690. 1461 // TODO: MDL-68776 This will be deleted in Moodle 4.2 (as should the below bit). 1462 debugging('engine::add_documents() should return $batches (5-value return is deprecated)', 1463 DEBUG_DEVELOPER); 1464 } else { 1465 // Backward compatibility for engines that don't support partial adding. 1466 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result; 1467 debugging('engine::add_documents() should return $partial (4-value return is deprecated)', 1468 DEBUG_DEVELOPER); 1469 $partial = false; 1470 } 1471 1472 if ($numdocs > 0) { 1473 $elapsed = round((self::get_current_time() - $elapsed), 3); 1474 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . 1475 ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . 1476 ($partial ? ' (not complete)' : '') . '.', 1); 1477 } else { 1478 $progress->output('No documents to index.', 1); 1479 } 1480 1481 // Notify the engine this area is complete, but don't store any times as this is not 1482 // part of the 'normal' search index. 1483 if (!$this->engine->area_index_complete($searcharea, $numdocs, false)) { 1484 $progress->output('Engine reported error.', 1); 1485 } 1486 1487 if ($partial && $timelimit && (self::get_current_time() >= $stopat)) { 1488 $progress->output('Stopping indexing due to time limit.'); 1489 break; 1490 } 1491 } 1492 1493 if ($sumdocs > 0) { 1494 $event = \core\event\search_indexed::create( 1495 array('context' => $context)); 1496 $event->trigger(); 1497 } 1498 1499 $this->engine->index_complete($sumdocs, false); 1500 1501 // Indicate in result whether we completed indexing, or only part of it. 1502 $result = new \stdClass(); 1503 if ($partial) { 1504 $result->complete = false; 1505 $result->startfromarea = $areaid; 1506 $result->startfromtime = $lastindexeddoc; 1507 } else { 1508 $result->complete = true; 1509 } 1510 return $result; 1511 } 1512 1513 /** 1514 * Resets areas config. 1515 * 1516 * @throws \moodle_exception 1517 * @param string $areaid 1518 * @return void 1519 */ 1520 public function reset_config($areaid = false) { 1521 1522 if (!empty($areaid)) { 1523 $searchareas = array(); 1524 if (!$searchareas[$areaid] = static::get_search_area($areaid)) { 1525 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid); 1526 } 1527 } else { 1528 // Only the enabled ones. 1529 $searchareas = static::get_search_areas_list(true); 1530 } 1531 1532 foreach ($searchareas as $searcharea) { 1533 list($componentname, $varname) = $searcharea->get_config_var_name(); 1534 $config = $searcharea->get_config(); 1535 1536 foreach ($config as $key => $value) { 1537 // We reset them all but the enable/disabled one. 1538 if ($key !== $varname . '_enabled') { 1539 set_config($key, 0, $componentname); 1540 } 1541 } 1542 } 1543 } 1544 1545 /** 1546 * Deletes an area's documents or all areas documents. 1547 * 1548 * @param string $areaid The area id or false for all 1549 * @return void 1550 */ 1551 public function delete_index($areaid = false) { 1552 if (!empty($areaid)) { 1553 $this->engine->delete($areaid); 1554 $this->reset_config($areaid); 1555 } else { 1556 $this->engine->delete(); 1557 $this->reset_config(); 1558 } 1559 } 1560 1561 /** 1562 * Deletes index by id. 1563 * 1564 * @param int Solr Document string $id 1565 */ 1566 public function delete_index_by_id($id) { 1567 $this->engine->delete_by_id($id); 1568 } 1569 1570 /** 1571 * Returns search areas configuration. 1572 * 1573 * @param \core_search\base[] $searchareas 1574 * @return \stdClass[] $configsettings 1575 */ 1576 public function get_areas_config($searchareas) { 1577 1578 $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored', 1579 'docsprocessed', 'recordsprocessed', 'partial'); 1580 1581 $configsettings = []; 1582 foreach ($searchareas as $searcharea) { 1583 1584 $areaid = $searcharea->get_area_id(); 1585 1586 $configsettings[$areaid] = new \stdClass(); 1587 list($componentname, $varname) = $searcharea->get_config_var_name(); 1588 1589 if (!$searcharea->is_enabled()) { 1590 // We delete all indexed data on disable so no info. 1591 foreach ($vars as $var) { 1592 $configsettings[$areaid]->{$var} = 0; 1593 } 1594 } else { 1595 foreach ($vars as $var) { 1596 $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var); 1597 } 1598 } 1599 1600 // Formatting the time. 1601 if (!empty($configsettings[$areaid]->lastindexrun)) { 1602 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun); 1603 } else { 1604 $configsettings[$areaid]->lastindexrun = get_string('never'); 1605 } 1606 } 1607 return $configsettings; 1608 } 1609 1610 /** 1611 * Triggers search_results_viewed event 1612 * 1613 * Other data required: 1614 * - q: The query string 1615 * - page: The page number 1616 * - title: Title filter 1617 * - areaids: Search areas filter 1618 * - courseids: Courses filter 1619 * - timestart: Time start filter 1620 * - timeend: Time end filter 1621 * 1622 * @since Moodle 3.2 1623 * @param array $other Other info for the event. 1624 * @return \core\event\search_results_viewed 1625 */ 1626 public static function trigger_search_results_viewed($other) { 1627 $event = \core\event\search_results_viewed::create([ 1628 'context' => \context_system::instance(), 1629 'other' => $other 1630 ]); 1631 $event->trigger(); 1632 1633 return $event; 1634 } 1635 1636 /** 1637 * Checks whether a classname is of an actual search area. 1638 * 1639 * @param string $classname 1640 * @return bool 1641 */ 1642 protected static function is_search_area($classname) { 1643 if (is_subclass_of($classname, 'core_search\base')) { 1644 return (new \ReflectionClass($classname))->isInstantiable(); 1645 } 1646 1647 return false; 1648 } 1649 1650 /** 1651 * Requests that a specific context is indexed by the scheduled task. The context will be 1652 * added to a queue which is processed by the task. 1653 * 1654 * This is used after a restore to ensure that restored items are indexed, even though their 1655 * modified time will be older than the latest indexed. It is also used by the 'Gradual reindex' 1656 * admin feature from the search areas screen. 1657 * 1658 * @param \context $context Context to index within 1659 * @param string $areaid Area to index, '' = all areas 1660 * @param int $priority Priority (INDEX_PRIORITY_xx constant) 1661 */ 1662 public static function request_index(\context $context, $areaid = '', 1663 $priority = self::INDEX_PRIORITY_NORMAL) { 1664 global $DB; 1665 1666 // Check through existing requests for this context or any parent context. 1667 list ($contextsql, $contextparams) = $DB->get_in_or_equal( 1668 $context->get_parent_context_ids(true)); 1669 $existing = $DB->get_records_select('search_index_requests', 1670 'contextid ' . $contextsql, $contextparams, '', 1671 'id, searcharea, partialarea, indexpriority'); 1672 foreach ($existing as $rec) { 1673 // If we haven't started processing the existing request yet, and it covers the same 1674 // area (or all areas) then that will be sufficient so don't add anything else. 1675 if ($rec->partialarea === '' && ($rec->searcharea === $areaid || $rec->searcharea === '')) { 1676 // If the existing request has the same (or higher) priority, no need to add anything. 1677 if ($rec->indexpriority >= $priority) { 1678 return; 1679 } 1680 // The existing request has lower priority. If it is exactly the same, then just 1681 // adjust the priority of the existing request. 1682 if ($rec->searcharea === $areaid) { 1683 $DB->set_field('search_index_requests', 'indexpriority', $priority, 1684 ['id' => $rec->id]); 1685 return; 1686 } 1687 // The existing request would cover this area but is a lower priority. We need to 1688 // add the new request even though that means we will index part of it twice. 1689 } 1690 } 1691 1692 // No suitable existing request, so add a new one. 1693 $newrecord = [ 'contextid' => $context->id, 'searcharea' => $areaid, 1694 'timerequested' => (int)self::get_current_time(), 1695 'partialarea' => '', 'partialtime' => 0, 1696 'indexpriority' => $priority ]; 1697 $DB->insert_record('search_index_requests', $newrecord); 1698 } 1699 1700 /** 1701 * Processes outstanding index requests. This will take the first item from the queue (taking 1702 * account the indexing priority) and process it, continuing until an optional time limit is 1703 * reached. 1704 * 1705 * If there are no index requests, the function will do nothing. 1706 * 1707 * @param float $timelimit Time limit (0 = none) 1708 * @param \progress_trace|null $progress Optional progress indicator 1709 */ 1710 public function process_index_requests($timelimit = 0.0, \progress_trace $progress = null) { 1711 global $DB; 1712 1713 if (!$progress) { 1714 $progress = new \null_progress_trace(); 1715 } 1716 1717 $before = self::get_current_time(); 1718 if ($timelimit) { 1719 $stopat = $before + $timelimit; 1720 } 1721 while (true) { 1722 // Retrieve first request, using fully defined ordering. 1723 $requests = $DB->get_records('search_index_requests', null, 1724 'indexpriority DESC, timerequested, contextid, searcharea', 1725 'id, contextid, searcharea, partialarea, partialtime', 0, 1); 1726 if (!$requests) { 1727 // If there are no more requests, stop. 1728 break; 1729 } 1730 $request = reset($requests); 1731 1732 // Calculate remaining time. 1733 $remainingtime = 0; 1734 $beforeindex = self::get_current_time(); 1735 if ($timelimit) { 1736 $remainingtime = $stopat - $beforeindex; 1737 1738 // If the time limit expired already, stop now. (Otherwise we might accidentally 1739 // index with no time limit or a negative time limit.) 1740 if ($remainingtime <= 0) { 1741 break; 1742 } 1743 } 1744 1745 // Show a message before each request, indicating what will be indexed. 1746 $context = \context::instance_by_id($request->contextid, IGNORE_MISSING); 1747 if (!$context) { 1748 $DB->delete_records('search_index_requests', ['id' => $request->id]); 1749 $progress->output('Skipped deleted context: ' . $request->contextid); 1750 continue; 1751 } 1752 $contextname = $context->get_context_name(); 1753 if ($request->searcharea) { 1754 $contextname .= ' (search area: ' . $request->searcharea . ')'; 1755 } 1756 $progress->output('Indexing requested context: ' . $contextname); 1757 1758 // Actually index the context. 1759 $result = $this->index_context($context, $request->searcharea, $remainingtime, 1760 $progress, $request->partialarea, $request->partialtime); 1761 1762 // Work out shared part of message. 1763 $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)'; 1764 1765 // Update database table and continue/stop as appropriate. 1766 if ($result->complete) { 1767 // If we completed the request, remove it from the table. 1768 $DB->delete_records('search_index_requests', ['id' => $request->id]); 1769 $progress->output('Completed requested context: ' . $endmessage); 1770 } else { 1771 // If we didn't complete the request, store the partial details (how far it got). 1772 $DB->update_record('search_index_requests', ['id' => $request->id, 1773 'partialarea' => $result->startfromarea, 1774 'partialtime' => $result->startfromtime]); 1775 $progress->output('Ending requested context: ' . $endmessage); 1776 1777 // The time limit must have expired, so stop looping. 1778 break; 1779 } 1780 } 1781 } 1782 1783 /** 1784 * Gets information about the request queue, in the form of a plain object suitable for passing 1785 * to a template for rendering. 1786 * 1787 * @return \stdClass Information about queued index requests 1788 */ 1789 public function get_index_requests_info() { 1790 global $DB; 1791 1792 $result = new \stdClass(); 1793 1794 $result->total = $DB->count_records('search_index_requests'); 1795 $result->topten = $DB->get_records('search_index_requests', null, 1796 'indexpriority DESC, timerequested, contextid, searcharea', 1797 'id, contextid, timerequested, searcharea, partialarea, partialtime, indexpriority', 1798 0, 10); 1799 foreach ($result->topten as $item) { 1800 $context = \context::instance_by_id($item->contextid); 1801 $item->contextlink = \html_writer::link($context->get_url(), 1802 s($context->get_context_name())); 1803 if ($item->searcharea) { 1804 $item->areaname = $this->get_search_area($item->searcharea)->get_visible_name(); 1805 } 1806 if ($item->partialarea) { 1807 $item->partialareaname = $this->get_search_area($item->partialarea)->get_visible_name(); 1808 } 1809 switch ($item->indexpriority) { 1810 case self::INDEX_PRIORITY_REINDEXING : 1811 $item->priorityname = get_string('priority_reindexing', 'search'); 1812 break; 1813 case self::INDEX_PRIORITY_NORMAL : 1814 $item->priorityname = get_string('priority_normal', 'search'); 1815 break; 1816 } 1817 } 1818 1819 // Normalise array indices. 1820 $result->topten = array_values($result->topten); 1821 1822 if ($result->total > 10) { 1823 $result->ellipsis = true; 1824 } 1825 1826 return $result; 1827 } 1828 1829 /** 1830 * Gets current time for use in search system. 1831 * 1832 * Note: This should be replaced with generic core functionality once possible (see MDL-60644). 1833 * 1834 * @return float Current time in seconds (with decimals) 1835 */ 1836 public static function get_current_time() { 1837 if (PHPUNIT_TEST && self::$phpunitfaketime) { 1838 return self::$phpunitfaketime; 1839 } 1840 return microtime(true); 1841 } 1842 1843 /** 1844 * Check if search area categories functionality is enabled. 1845 * 1846 * @return bool 1847 */ 1848 public static function is_search_area_categories_enabled() { 1849 return !empty(get_config('core', 'searchenablecategories')); 1850 } 1851 1852 /** 1853 * Check if all results category should be hidden. 1854 * 1855 * @return bool 1856 */ 1857 public static function should_hide_all_results_category() { 1858 return get_config('core', 'searchhideallcategory'); 1859 } 1860 1861 /** 1862 * Returns default search area category name. 1863 * 1864 * @return string 1865 */ 1866 public static function get_default_area_category_name() { 1867 $default = get_config('core', 'searchdefaultcategory'); 1868 1869 if (empty($default)) { 1870 $default = self::SEARCH_AREA_CATEGORY_ALL; 1871 } 1872 1873 if ($default == self::SEARCH_AREA_CATEGORY_ALL && self::should_hide_all_results_category()) { 1874 $default = self::SEARCH_AREA_CATEGORY_COURSE_CONTENT; 1875 } 1876 1877 return $default; 1878 } 1879 1880 /** 1881 * Get a list of all courses limited by ids if required. 1882 * 1883 * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. 1884 * @return array 1885 */ 1886 protected function get_all_courses($limitcourseids) { 1887 global $DB; 1888 1889 if ($limitcourseids) { 1890 list ($coursesql, $courseparams) = $DB->get_in_or_equal($limitcourseids); 1891 $coursesql = 'id ' . $coursesql; 1892 } else { 1893 $coursesql = ''; 1894 $courseparams = []; 1895 } 1896 1897 // Get courses using the same list of fields from enrol_get_my_courses. 1898 return $DB->get_records_select('course', $coursesql, $courseparams, '', 1899 'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' . 1900 'groupmode, groupmodeforce, cacherev'); 1901 } 1902 1903 /** 1904 * Get a list of courses as user can access. 1905 * 1906 * @param bool $allaccessible Include courses user is not enrolled in, but can access. 1907 * @return array 1908 */ 1909 protected function get_my_courses($allaccessible) { 1910 return enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [], $allaccessible); 1911 } 1912 1913 /** 1914 * Check if search all courses setting is enabled. 1915 * 1916 * @return bool 1917 */ 1918 public static function include_all_courses() { 1919 return !empty(get_config('core', 'searchincludeallcourses')); 1920 } 1921 1922 /** 1923 * Cleans up non existing search area. 1924 * 1925 * 1. Remove all configs from {config_plugins} table. 1926 * 2. Delete all related indexed documents. 1927 * 1928 * @param string $areaid Search area id. 1929 */ 1930 public static function clean_up_non_existing_area($areaid) { 1931 global $DB; 1932 1933 if (!empty(self::get_search_area($areaid))) { 1934 throw new \coding_exception("Area $areaid exists. Please use appropriate search area class to manipulate the data."); 1935 } 1936 1937 $parts = self::parse_areaid($areaid); 1938 1939 $plugin = $parts[0]; 1940 $configprefix = $parts[1]; 1941 1942 foreach (base::get_settingnames() as $settingname) { 1943 $name = $configprefix. $settingname; 1944 $DB->delete_records('config_plugins', ['name' => $name, 'plugin' => $plugin]); 1945 } 1946 1947 $engine = self::instance()->get_engine(); 1948 $engine->delete($areaid); 1949 } 1950 1951 /** 1952 * Informs the search system that a context has been deleted. 1953 * 1954 * This will clear the data from the search index, where the search engine supports that. 1955 * 1956 * This function does not usually throw an exception (so as not to get in the way of the 1957 * context deletion finishing). 1958 * 1959 * This is called for all types of context deletion. 1960 * 1961 * @param \context $context Context object that has just been deleted 1962 */ 1963 public static function context_deleted(\context $context) { 1964 if (self::is_indexing_enabled()) { 1965 try { 1966 // Hold on, are we deleting a course? If so, and this context is part of the course, 1967 // then don't bother to send a delete because we delete the whole course at once 1968 // later. 1969 if (!empty(self::$coursedeleting)) { 1970 $coursecontext = $context->get_course_context(false); 1971 if ($coursecontext && array_key_exists($coursecontext->instanceid, self::$coursedeleting)) { 1972 // Skip further processing. 1973 return; 1974 } 1975 } 1976 1977 $engine = self::instance()->get_engine(); 1978 $engine->delete_index_for_context($context->id); 1979 } catch (\moodle_exception $e) { 1980 debugging('Error deleting search index data for context ' . $context->id . ': ' . $e->getMessage()); 1981 } 1982 } 1983 } 1984 1985 /** 1986 * Informs the search system that a course is about to be deleted. 1987 * 1988 * This prevents it from sending hundreds of 'delete context' updates for all the individual 1989 * contexts that are deleted. 1990 * 1991 * If you call this, you must call course_deleting_finish(). 1992 * 1993 * @param int $courseid Course id that is being deleted 1994 */ 1995 public static function course_deleting_start(int $courseid) { 1996 self::$coursedeleting[$courseid] = true; 1997 } 1998 1999 /** 2000 * Informs the search engine that a course has now been deleted. 2001 * 2002 * This causes the search engine to actually delete the index for the whole course. 2003 * 2004 * @param int $courseid Course id that no longer exists 2005 */ 2006 public static function course_deleting_finish(int $courseid) { 2007 if (!array_key_exists($courseid, self::$coursedeleting)) { 2008 // Show a debug warning. It doesn't actually matter very much, as we will now delete 2009 // the course data anyhow. 2010 debugging('course_deleting_start not called before deletion of ' . $courseid, DEBUG_DEVELOPER); 2011 } 2012 unset(self::$coursedeleting[$courseid]); 2013 2014 if (self::is_indexing_enabled()) { 2015 try { 2016 $engine = self::instance()->get_engine(); 2017 $engine->delete_index_for_course($courseid); 2018 } catch (\moodle_exception $e) { 2019 debugging('Error deleting search index data for course ' . $courseid . ': ' . $e->getMessage()); 2020 } 2021 } 2022 } 2023 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body