Differences Between: [Versions 310 and 402] [Versions 311 and 402] [Versions 39 and 402] [Versions 400 and 402] [Versions 401 and 402]
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 { 1293 throw new \coding_exception('engine::add_documents() should return 6 values'); 1294 } 1295 1296 if ($numdocs > 0) { 1297 $elapsed = round((self::get_current_time() - $elapsed), 1); 1298 1299 $partialtext = ''; 1300 if ($partial) { 1301 $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc, 1302 get_string('strftimedatetimeshort', 'langconfig')) . ')'; 1303 } 1304 1305 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . 1306 ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . $partialtext . '.', 1); 1307 } else { 1308 $progress->output('No new documents to index.', 1); 1309 } 1310 1311 // Notify the engine this area is complete, and only mark times if true. 1312 if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) { 1313 $sumdocs += $numdocs; 1314 1315 // Store last index run once documents have been committed to the search engine. 1316 set_config($varname . '_indexingstart', $indexingstart, $componentconfigname); 1317 set_config($varname . '_indexingend', (int)self::get_current_time(), $componentconfigname); 1318 set_config($varname . '_docsignored', $numdocsignored, $componentconfigname); 1319 set_config($varname . '_docsprocessed', $numdocs, $componentconfigname); 1320 set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname); 1321 if ($lastindexeddoc > 0) { 1322 set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname); 1323 } 1324 if ($partial) { 1325 set_config($varname . '_partial', 1, $componentconfigname); 1326 } else { 1327 unset_config($varname . '_partial', $componentconfigname); 1328 } 1329 } else { 1330 $progress->output('Engine reported error.'); 1331 } 1332 1333 if ($timelimit && (self::get_current_time() >= $stopat)) { 1334 $progress->output('Stopping indexing due to time limit.'); 1335 break; 1336 } 1337 } 1338 1339 if ($sumdocs > 0) { 1340 $event = \core\event\search_indexed::create( 1341 array('context' => \context_system::instance())); 1342 $event->trigger(); 1343 } 1344 1345 $this->engine->index_complete($sumdocs, $fullindex); 1346 1347 return (bool)$sumdocs; 1348 } 1349 1350 /** 1351 * Indexes or reindexes a specific context of the system, e.g. one course. 1352 * 1353 * The function returns an object with field 'complete' (true or false). 1354 * 1355 * This function supports partial indexing via the time limit parameter. If the time limit 1356 * expires, it will return values for $startfromarea and $startfromtime which can be passed 1357 * next time to continue indexing. 1358 * 1359 * @param \context $context Context to restrict index. 1360 * @param string $singleareaid If specified, indexes only the given area. 1361 * @param float $timelimit Time limit in seconds (0 = no time limit) 1362 * @param \progress_trace|null $progress Optional class for tracking progress 1363 * @param string $startfromarea Area to start from 1364 * @param int $startfromtime Timestamp to start from 1365 * @return \stdClass Object indicating success 1366 */ 1367 public function index_context($context, $singleareaid = '', $timelimit = 0, 1368 \progress_trace $progress = null, $startfromarea = '', $startfromtime = 0) { 1369 if (!$progress) { 1370 $progress = new \null_progress_trace(); 1371 } 1372 1373 // Work out time to stop, if limited. 1374 if ($timelimit) { 1375 // Decide time to stop. 1376 $stopat = self::get_current_time() + $timelimit; 1377 } 1378 1379 // No PHP time limit. 1380 \core_php_time_limit::raise(); 1381 1382 // Notify the engine that an index starting. 1383 $this->engine->index_starting(false); 1384 1385 $sumdocs = 0; 1386 1387 // Get all search areas, in consistent order. 1388 $searchareas = $this->get_search_areas_list(true); 1389 ksort($searchareas); 1390 1391 // Are we skipping past some that were handled previously? 1392 $skipping = $startfromarea ? true : false; 1393 1394 foreach ($searchareas as $areaid => $searcharea) { 1395 // If we're only processing one area id, skip all the others. 1396 if ($singleareaid && $singleareaid !== $areaid) { 1397 continue; 1398 } 1399 1400 // If we're skipping to a later area, continue through the loop. 1401 $referencestarttime = 0; 1402 if ($skipping) { 1403 if ($areaid !== $startfromarea) { 1404 continue; 1405 } 1406 // Stop skipping and note the reference start time. 1407 $skipping = false; 1408 $referencestarttime = $startfromtime; 1409 } 1410 1411 $progress->output('Processing area: ' . $searcharea->get_visible_name()); 1412 1413 $elapsed = self::get_current_time(); 1414 1415 // Get the recordset of all documents from the area for this context. 1416 $recordset = $searcharea->get_document_recordset($referencestarttime, $context); 1417 if (!$recordset) { 1418 if ($recordset === null) { 1419 $progress->output('Skipping (not relevant to context).', 1); 1420 } else { 1421 $progress->output('Skipping (does not support context indexing).', 1); 1422 } 1423 continue; 1424 } 1425 1426 // Notify the engine that an area is starting. 1427 $this->engine->area_index_starting($searcharea, false); 1428 1429 // Work out search options. 1430 $options = []; 1431 $options['indexfiles'] = $this->engine->file_indexing_enabled() && 1432 $searcharea->uses_file_indexing(); 1433 if ($timelimit) { 1434 $options['stopat'] = $stopat; 1435 } 1436 1437 // Construct iterator which will use get_document on the recordset results. 1438 $iterator = new \core\dml\recordset_walk($recordset, 1439 array($searcharea, 'get_document'), $options); 1440 1441 // Use this iterator to add documents. 1442 $result = $this->engine->add_documents($iterator, $searcharea, $options); 1443 $batchinfo = ''; 1444 if (count($result) === 6) { 1445 [$numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial, $batches] = $result; 1446 // Only show the batch count if we actually batched any requests. 1447 if ($batches !== $numdocs + $numdocsignored) { 1448 $batchinfo = ' (' . $batches . ' batch' . ($batches === 1 ? '' : 'es') . ')'; 1449 } 1450 } else { 1451 throw new \coding_exception('engine::add_documents() should return 6 values'); 1452 } 1453 1454 if ($numdocs > 0) { 1455 $elapsed = round((self::get_current_time() - $elapsed), 3); 1456 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs . 1457 ' documents' . $batchinfo . ', in ' . $elapsed . ' seconds' . 1458 ($partial ? ' (not complete)' : '') . '.', 1); 1459 } else { 1460 $progress->output('No documents to index.', 1); 1461 } 1462 1463 // Notify the engine this area is complete, but don't store any times as this is not 1464 // part of the 'normal' search index. 1465 if (!$this->engine->area_index_complete($searcharea, $numdocs, false)) { 1466 $progress->output('Engine reported error.', 1); 1467 } 1468 1469 if ($partial && $timelimit && (self::get_current_time() >= $stopat)) { 1470 $progress->output('Stopping indexing due to time limit.'); 1471 break; 1472 } 1473 } 1474 1475 if ($sumdocs > 0) { 1476 $event = \core\event\search_indexed::create( 1477 array('context' => $context)); 1478 $event->trigger(); 1479 } 1480 1481 $this->engine->index_complete($sumdocs, false); 1482 1483 // Indicate in result whether we completed indexing, or only part of it. 1484 $result = new \stdClass(); 1485 if ($partial) { 1486 $result->complete = false; 1487 $result->startfromarea = $areaid; 1488 $result->startfromtime = $lastindexeddoc; 1489 } else { 1490 $result->complete = true; 1491 } 1492 return $result; 1493 } 1494 1495 /** 1496 * Resets areas config. 1497 * 1498 * @throws \moodle_exception 1499 * @param string $areaid 1500 * @return void 1501 */ 1502 public function reset_config($areaid = false) { 1503 1504 if (!empty($areaid)) { 1505 $searchareas = array(); 1506 if (!$searchareas[$areaid] = static::get_search_area($areaid)) { 1507 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid); 1508 } 1509 } else { 1510 // Only the enabled ones. 1511 $searchareas = static::get_search_areas_list(true); 1512 } 1513 1514 foreach ($searchareas as $searcharea) { 1515 list($componentname, $varname) = $searcharea->get_config_var_name(); 1516 $config = $searcharea->get_config(); 1517 1518 foreach ($config as $key => $value) { 1519 // We reset them all but the enable/disabled one. 1520 if ($key !== $varname . '_enabled') { 1521 set_config($key, 0, $componentname); 1522 } 1523 } 1524 } 1525 } 1526 1527 /** 1528 * Deletes an area's documents or all areas documents. 1529 * 1530 * @param string $areaid The area id or false for all 1531 * @return void 1532 */ 1533 public function delete_index($areaid = false) { 1534 if (!empty($areaid)) { 1535 $this->engine->delete($areaid); 1536 $this->reset_config($areaid); 1537 } else { 1538 $this->engine->delete(); 1539 $this->reset_config(); 1540 } 1541 } 1542 1543 /** 1544 * Deletes index by id. 1545 * 1546 * @param int Solr Document string $id 1547 */ 1548 public function delete_index_by_id($id) { 1549 $this->engine->delete_by_id($id); 1550 } 1551 1552 /** 1553 * Returns search areas configuration. 1554 * 1555 * @param \core_search\base[] $searchareas 1556 * @return \stdClass[] $configsettings 1557 */ 1558 public function get_areas_config($searchareas) { 1559 1560 $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored', 1561 'docsprocessed', 'recordsprocessed', 'partial'); 1562 1563 $configsettings = []; 1564 foreach ($searchareas as $searcharea) { 1565 1566 $areaid = $searcharea->get_area_id(); 1567 1568 $configsettings[$areaid] = new \stdClass(); 1569 list($componentname, $varname) = $searcharea->get_config_var_name(); 1570 1571 if (!$searcharea->is_enabled()) { 1572 // We delete all indexed data on disable so no info. 1573 foreach ($vars as $var) { 1574 $configsettings[$areaid]->{$var} = 0; 1575 } 1576 } else { 1577 foreach ($vars as $var) { 1578 $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var); 1579 } 1580 } 1581 1582 // Formatting the time. 1583 if (!empty($configsettings[$areaid]->lastindexrun)) { 1584 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun); 1585 } else { 1586 $configsettings[$areaid]->lastindexrun = get_string('never'); 1587 } 1588 } 1589 return $configsettings; 1590 } 1591 1592 /** 1593 * Triggers search_results_viewed event 1594 * 1595 * Other data required: 1596 * - q: The query string 1597 * - page: The page number 1598 * - title: Title filter 1599 * - areaids: Search areas filter 1600 * - courseids: Courses filter 1601 * - timestart: Time start filter 1602 * - timeend: Time end filter 1603 * 1604 * @since Moodle 3.2 1605 * @param array $other Other info for the event. 1606 * @return \core\event\search_results_viewed 1607 */ 1608 public static function trigger_search_results_viewed($other) { 1609 $event = \core\event\search_results_viewed::create([ 1610 'context' => \context_system::instance(), 1611 'other' => $other 1612 ]); 1613 $event->trigger(); 1614 1615 return $event; 1616 } 1617 1618 /** 1619 * Checks whether a classname is of an actual search area. 1620 * 1621 * @param string $classname 1622 * @return bool 1623 */ 1624 protected static function is_search_area($classname) { 1625 if (is_subclass_of($classname, 'core_search\base')) { 1626 return (new \ReflectionClass($classname))->isInstantiable(); 1627 } 1628 1629 return false; 1630 } 1631 1632 /** 1633 * Requests that a specific context is indexed by the scheduled task. The context will be 1634 * added to a queue which is processed by the task. 1635 * 1636 * This is used after a restore to ensure that restored items are indexed, even though their 1637 * modified time will be older than the latest indexed. It is also used by the 'Gradual reindex' 1638 * admin feature from the search areas screen. 1639 * 1640 * @param \context $context Context to index within 1641 * @param string $areaid Area to index, '' = all areas 1642 * @param int $priority Priority (INDEX_PRIORITY_xx constant) 1643 */ 1644 public static function request_index(\context $context, $areaid = '', 1645 $priority = self::INDEX_PRIORITY_NORMAL) { 1646 global $DB; 1647 1648 // Check through existing requests for this context or any parent context. 1649 list ($contextsql, $contextparams) = $DB->get_in_or_equal( 1650 $context->get_parent_context_ids(true)); 1651 $existing = $DB->get_records_select('search_index_requests', 1652 'contextid ' . $contextsql, $contextparams, '', 1653 'id, searcharea, partialarea, indexpriority'); 1654 foreach ($existing as $rec) { 1655 // If we haven't started processing the existing request yet, and it covers the same 1656 // area (or all areas) then that will be sufficient so don't add anything else. 1657 if ($rec->partialarea === '' && ($rec->searcharea === $areaid || $rec->searcharea === '')) { 1658 // If the existing request has the same (or higher) priority, no need to add anything. 1659 if ($rec->indexpriority >= $priority) { 1660 return; 1661 } 1662 // The existing request has lower priority. If it is exactly the same, then just 1663 // adjust the priority of the existing request. 1664 if ($rec->searcharea === $areaid) { 1665 $DB->set_field('search_index_requests', 'indexpriority', $priority, 1666 ['id' => $rec->id]); 1667 return; 1668 } 1669 // The existing request would cover this area but is a lower priority. We need to 1670 // add the new request even though that means we will index part of it twice. 1671 } 1672 } 1673 1674 // No suitable existing request, so add a new one. 1675 $newrecord = [ 'contextid' => $context->id, 'searcharea' => $areaid, 1676 'timerequested' => (int)self::get_current_time(), 1677 'partialarea' => '', 'partialtime' => 0, 1678 'indexpriority' => $priority ]; 1679 $DB->insert_record('search_index_requests', $newrecord); 1680 } 1681 1682 /** 1683 * Processes outstanding index requests. This will take the first item from the queue (taking 1684 * account the indexing priority) and process it, continuing until an optional time limit is 1685 * reached. 1686 * 1687 * If there are no index requests, the function will do nothing. 1688 * 1689 * @param float $timelimit Time limit (0 = none) 1690 * @param \progress_trace|null $progress Optional progress indicator 1691 */ 1692 public function process_index_requests($timelimit = 0.0, \progress_trace $progress = null) { 1693 global $DB; 1694 1695 if (!$progress) { 1696 $progress = new \null_progress_trace(); 1697 } 1698 1699 $before = self::get_current_time(); 1700 if ($timelimit) { 1701 $stopat = $before + $timelimit; 1702 } 1703 while (true) { 1704 // Retrieve first request, using fully defined ordering. 1705 $requests = $DB->get_records('search_index_requests', null, 1706 'indexpriority DESC, timerequested, contextid, searcharea', 1707 'id, contextid, searcharea, partialarea, partialtime', 0, 1); 1708 if (!$requests) { 1709 // If there are no more requests, stop. 1710 break; 1711 } 1712 $request = reset($requests); 1713 1714 // Calculate remaining time. 1715 $remainingtime = 0; 1716 $beforeindex = self::get_current_time(); 1717 if ($timelimit) { 1718 $remainingtime = $stopat - $beforeindex; 1719 1720 // If the time limit expired already, stop now. (Otherwise we might accidentally 1721 // index with no time limit or a negative time limit.) 1722 if ($remainingtime <= 0) { 1723 break; 1724 } 1725 } 1726 1727 // Show a message before each request, indicating what will be indexed. 1728 $context = \context::instance_by_id($request->contextid, IGNORE_MISSING); 1729 if (!$context) { 1730 $DB->delete_records('search_index_requests', ['id' => $request->id]); 1731 $progress->output('Skipped deleted context: ' . $request->contextid); 1732 continue; 1733 } 1734 $contextname = $context->get_context_name(); 1735 if ($request->searcharea) { 1736 $contextname .= ' (search area: ' . $request->searcharea . ')'; 1737 } 1738 $progress->output('Indexing requested context: ' . $contextname); 1739 1740 // Actually index the context. 1741 $result = $this->index_context($context, $request->searcharea, $remainingtime, 1742 $progress, $request->partialarea, $request->partialtime); 1743 1744 // Work out shared part of message. 1745 $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)'; 1746 1747 // Update database table and continue/stop as appropriate. 1748 if ($result->complete) { 1749 // If we completed the request, remove it from the table. 1750 $DB->delete_records('search_index_requests', ['id' => $request->id]); 1751 $progress->output('Completed requested context: ' . $endmessage); 1752 } else { 1753 // If we didn't complete the request, store the partial details (how far it got). 1754 $DB->update_record('search_index_requests', ['id' => $request->id, 1755 'partialarea' => $result->startfromarea, 1756 'partialtime' => $result->startfromtime]); 1757 $progress->output('Ending requested context: ' . $endmessage); 1758 1759 // The time limit must have expired, so stop looping. 1760 break; 1761 } 1762 } 1763 } 1764 1765 /** 1766 * Gets information about the request queue, in the form of a plain object suitable for passing 1767 * to a template for rendering. 1768 * 1769 * @return \stdClass Information about queued index requests 1770 */ 1771 public function get_index_requests_info() { 1772 global $DB; 1773 1774 $result = new \stdClass(); 1775 1776 $result->total = $DB->count_records('search_index_requests'); 1777 $result->topten = $DB->get_records('search_index_requests', null, 1778 'indexpriority DESC, timerequested, contextid, searcharea', 1779 'id, contextid, timerequested, searcharea, partialarea, partialtime, indexpriority', 1780 0, 10); 1781 foreach ($result->topten as $item) { 1782 $context = \context::instance_by_id($item->contextid); 1783 $item->contextlink = \html_writer::link($context->get_url(), 1784 s($context->get_context_name())); 1785 if ($item->searcharea) { 1786 $item->areaname = $this->get_search_area($item->searcharea)->get_visible_name(); 1787 } 1788 if ($item->partialarea) { 1789 $item->partialareaname = $this->get_search_area($item->partialarea)->get_visible_name(); 1790 } 1791 switch ($item->indexpriority) { 1792 case self::INDEX_PRIORITY_REINDEXING : 1793 $item->priorityname = get_string('priority_reindexing', 'search'); 1794 break; 1795 case self::INDEX_PRIORITY_NORMAL : 1796 $item->priorityname = get_string('priority_normal', 'search'); 1797 break; 1798 } 1799 } 1800 1801 // Normalise array indices. 1802 $result->topten = array_values($result->topten); 1803 1804 if ($result->total > 10) { 1805 $result->ellipsis = true; 1806 } 1807 1808 return $result; 1809 } 1810 1811 /** 1812 * Gets current time for use in search system. 1813 * 1814 * Note: This should be replaced with generic core functionality once possible (see MDL-60644). 1815 * 1816 * @return float Current time in seconds (with decimals) 1817 */ 1818 public static function get_current_time() { 1819 if (PHPUNIT_TEST && self::$phpunitfaketime) { 1820 return self::$phpunitfaketime; 1821 } 1822 return microtime(true); 1823 } 1824 1825 /** 1826 * Check if search area categories functionality is enabled. 1827 * 1828 * @return bool 1829 */ 1830 public static function is_search_area_categories_enabled() { 1831 return !empty(get_config('core', 'searchenablecategories')); 1832 } 1833 1834 /** 1835 * Check if all results category should be hidden. 1836 * 1837 * @return bool 1838 */ 1839 public static function should_hide_all_results_category() { 1840 return get_config('core', 'searchhideallcategory'); 1841 } 1842 1843 /** 1844 * Returns default search area category name. 1845 * 1846 * @return string 1847 */ 1848 public static function get_default_area_category_name() { 1849 $default = get_config('core', 'searchdefaultcategory'); 1850 1851 if (empty($default)) { 1852 $default = self::SEARCH_AREA_CATEGORY_ALL; 1853 } 1854 1855 if ($default == self::SEARCH_AREA_CATEGORY_ALL && self::should_hide_all_results_category()) { 1856 $default = self::SEARCH_AREA_CATEGORY_COURSE_CONTENT; 1857 } 1858 1859 return $default; 1860 } 1861 1862 /** 1863 * Get a list of all courses limited by ids if required. 1864 * 1865 * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting. 1866 * @return array 1867 */ 1868 protected function get_all_courses($limitcourseids) { 1869 global $DB; 1870 1871 if ($limitcourseids) { 1872 list ($coursesql, $courseparams) = $DB->get_in_or_equal($limitcourseids); 1873 $coursesql = 'id ' . $coursesql; 1874 } else { 1875 $coursesql = ''; 1876 $courseparams = []; 1877 } 1878 1879 // Get courses using the same list of fields from enrol_get_my_courses. 1880 return $DB->get_records_select('course', $coursesql, $courseparams, '', 1881 'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' . 1882 'groupmode, groupmodeforce, cacherev'); 1883 } 1884 1885 /** 1886 * Get a list of courses as user can access. 1887 * 1888 * @param bool $allaccessible Include courses user is not enrolled in, but can access. 1889 * @return array 1890 */ 1891 protected function get_my_courses($allaccessible) { 1892 return enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [], $allaccessible); 1893 } 1894 1895 /** 1896 * Check if search all courses setting is enabled. 1897 * 1898 * @return bool 1899 */ 1900 public static function include_all_courses() { 1901 return !empty(get_config('core', 'searchincludeallcourses')); 1902 } 1903 1904 /** 1905 * Cleans up non existing search area. 1906 * 1907 * 1. Remove all configs from {config_plugins} table. 1908 * 2. Delete all related indexed documents. 1909 * 1910 * @param string $areaid Search area id. 1911 */ 1912 public static function clean_up_non_existing_area($areaid) { 1913 global $DB; 1914 1915 if (!empty(self::get_search_area($areaid))) { 1916 throw new \coding_exception("Area $areaid exists. Please use appropriate search area class to manipulate the data."); 1917 } 1918 1919 $parts = self::parse_areaid($areaid); 1920 1921 $plugin = $parts[0]; 1922 $configprefix = $parts[1]; 1923 1924 foreach (base::get_settingnames() as $settingname) { 1925 $name = $configprefix. $settingname; 1926 $DB->delete_records('config_plugins', ['name' => $name, 'plugin' => $plugin]); 1927 } 1928 1929 $engine = self::instance()->get_engine(); 1930 $engine->delete($areaid); 1931 } 1932 1933 /** 1934 * Informs the search system that a context has been deleted. 1935 * 1936 * This will clear the data from the search index, where the search engine supports that. 1937 * 1938 * This function does not usually throw an exception (so as not to get in the way of the 1939 * context deletion finishing). 1940 * 1941 * This is called for all types of context deletion. 1942 * 1943 * @param \context $context Context object that has just been deleted 1944 */ 1945 public static function context_deleted(\context $context) { 1946 if (self::is_indexing_enabled()) { 1947 try { 1948 // Hold on, are we deleting a course? If so, and this context is part of the course, 1949 // then don't bother to send a delete because we delete the whole course at once 1950 // later. 1951 if (!empty(self::$coursedeleting)) { 1952 $coursecontext = $context->get_course_context(false); 1953 if ($coursecontext && array_key_exists($coursecontext->instanceid, self::$coursedeleting)) { 1954 // Skip further processing. 1955 return; 1956 } 1957 } 1958 1959 $engine = self::instance()->get_engine(); 1960 $engine->delete_index_for_context($context->id); 1961 } catch (\moodle_exception $e) { 1962 debugging('Error deleting search index data for context ' . $context->id . ': ' . $e->getMessage()); 1963 } 1964 } 1965 } 1966 1967 /** 1968 * Informs the search system that a course is about to be deleted. 1969 * 1970 * This prevents it from sending hundreds of 'delete context' updates for all the individual 1971 * contexts that are deleted. 1972 * 1973 * If you call this, you must call course_deleting_finish(). 1974 * 1975 * @param int $courseid Course id that is being deleted 1976 */ 1977 public static function course_deleting_start(int $courseid) { 1978 self::$coursedeleting[$courseid] = true; 1979 } 1980 1981 /** 1982 * Informs the search engine that a course has now been deleted. 1983 * 1984 * This causes the search engine to actually delete the index for the whole course. 1985 * 1986 * @param int $courseid Course id that no longer exists 1987 */ 1988 public static function course_deleting_finish(int $courseid) { 1989 if (!array_key_exists($courseid, self::$coursedeleting)) { 1990 // Show a debug warning. It doesn't actually matter very much, as we will now delete 1991 // the course data anyhow. 1992 debugging('course_deleting_start not called before deletion of ' . $courseid, DEBUG_DEVELOPER); 1993 } 1994 unset(self::$coursedeleting[$courseid]); 1995 1996 if (self::is_indexing_enabled()) { 1997 try { 1998 $engine = self::instance()->get_engine(); 1999 $engine->delete_index_for_course($courseid); 2000 } catch (\moodle_exception $e) { 2001 debugging('Error deleting search index data for course ' . $courseid . ': ' . $e->getMessage()); 2002 } 2003 } 2004 } 2005 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body