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