"Fossies" - the Fresh Open Source Software Archive

Member "moodle/search/classes/manager.php" (6 Sep 2019, 59073 Bytes) of package /linux/www/moodle-3.6.6.tgz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) PHP source code syntax highlighting (style: standard) with prefixed line numbers and code folding option. Alternatively you can here view or download the uninterpreted source code file. For more information about "manager.php" see the Fossies "Dox" file reference documentation and the latest Fossies "Diffs" side-by-side code changes report: 3.6.5_vs_3.6.6.

    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 \core_search\base[] Enabled search areas.
  102      */
  103     protected static $enabledsearchareas = null;
  104 
  105     /**
  106      * @var \core_search\base[] All system search areas.
  107      */
  108     protected static $allsearchareas = null;
  109 
  110     /**
  111      * @var \core_search\manager
  112      */
  113     protected static $instance = null;
  114 
  115     /**
  116      * @var \core_search\engine
  117      */
  118     protected $engine = null;
  119 
  120     /**
  121      * Note: This should be removed once possible (see MDL-60644).
  122      *
  123      * @var float Fake current time for use in PHPunit tests
  124      */
  125     protected static $phpunitfaketime = 0;
  126 
  127     /**
  128      * @var int Result count when used with mock results for Behat tests.
  129      */
  130     protected $behatresultcount = 0;
  131 
  132     /**
  133      * Constructor, use \core_search\manager::instance instead to get a class instance.
  134      *
  135      * @param \core_search\base The search engine to use
  136      */
  137     public function __construct($engine) {
  138         $this->engine = $engine;
  139     }
  140 
  141     /**
  142      * @var int Record time of each successful schema check, but not more than once per 10 minutes.
  143      */
  144     const SCHEMA_CHECK_TRACKING_DELAY = 10 * 60;
  145 
  146     /**
  147      * @var int Require a new schema check at least every 4 hours.
  148      */
  149     const SCHEMA_CHECK_REQUIRED_EVERY = 4 * 3600;
  150 
  151     /**
  152      * Returns an initialised \core_search instance.
  153      *
  154      * While constructing the instance, checks on the search schema may be carried out. The $fast
  155      * parameter provides a way to skip those checks on pages which are used frequently. It has
  156      * no effect if an instance has already been constructed in this request.
  157      *
  158      * @see \core_search\engine::is_installed
  159      * @see \core_search\engine::is_server_ready
  160      * @param bool $fast Set to true when calling on a page that requires high performance
  161      * @throws \core_search\engine_exception
  162      * @return \core_search\manager
  163      */
  164     public static function instance($fast = false) {
  165         global $CFG;
  166 
  167         // One per request, this should be purged during testing.
  168         if (static::$instance !== null) {
  169             return static::$instance;
  170         }
  171 
  172         if (empty($CFG->searchengine)) {
  173             throw new \core_search\engine_exception('enginenotselected', 'search');
  174         }
  175 
  176         if (!$engine = static::search_engine_instance()) {
  177             throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine);
  178         }
  179 
  180         // Get time now and at last schema check.
  181         $now = (int)self::get_current_time();
  182         $lastschemacheck = get_config($engine->get_plugin_name(), 'lastschemacheck');
  183 
  184         // On pages where performance matters, tell the engine to skip schema checks.
  185         $skipcheck = false;
  186         if ($fast && $now < $lastschemacheck + self::SCHEMA_CHECK_REQUIRED_EVERY) {
  187             $skipcheck = true;
  188             $engine->skip_schema_check();
  189         }
  190 
  191         if (!$engine->is_installed()) {
  192             throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine);
  193         }
  194 
  195         $serverstatus = $engine->is_server_ready();
  196         if ($serverstatus !== true) {
  197             // Skip this error in Behat when faking seach results.
  198             if (!defined('BEHAT_SITE_RUNNING') || !get_config('core_search', 'behat_fakeresult')) {
  199                 // Clear the record of successful schema checks since it might have failed.
  200                 unset_config('lastschemacheck', $engine->get_plugin_name());
  201                 // Error message with no details as this is an exception that any user may find if the server crashes.
  202                 throw new \core_search\engine_exception('engineserverstatus', 'search');
  203             }
  204         }
  205 
  206         // If we did a successful schema check, record this, but not more than once per 10 minutes
  207         // (to avoid updating the config db table/cache too often in case it gets called frequently).
  208         if (!$skipcheck && $now >= $lastschemacheck + self::SCHEMA_CHECK_TRACKING_DELAY) {
  209             set_config('lastschemacheck', $now, $engine->get_plugin_name());
  210         }
  211 
  212         static::$instance = new \core_search\manager($engine);
  213         return static::$instance;
  214     }
  215 
  216     /**
  217      * Returns whether global search is enabled or not.
  218      *
  219      * @return bool
  220      */
  221     public static function is_global_search_enabled() {
  222         global $CFG;
  223         return !empty($CFG->enableglobalsearch);
  224     }
  225 
  226     /**
  227      * Returns whether indexing is enabled or not (you can enable indexing even when search is not
  228      * enabled at the moment, so as to have it ready for students).
  229      *
  230      * @return bool True if indexing is enabled.
  231      */
  232     public static function is_indexing_enabled() {
  233         global $CFG;
  234         return !empty($CFG->enableglobalsearch) || !empty($CFG->searchindexwhendisabled);
  235     }
  236 
  237     /**
  238      * Returns an instance of the search engine.
  239      *
  240      * @return \core_search\engine
  241      */
  242     public static function search_engine_instance() {
  243         global $CFG;
  244 
  245         $classname = '\\search_' . $CFG->searchengine . '\\engine';
  246         if (!class_exists($classname)) {
  247             return false;
  248         }
  249 
  250         return new $classname();
  251     }
  252 
  253     /**
  254      * Returns the search engine.
  255      *
  256      * @return \core_search\engine
  257      */
  258     public function get_engine() {
  259         return $this->engine;
  260     }
  261 
  262     /**
  263      * Returns a search area class name.
  264      *
  265      * @param string $areaid
  266      * @return string
  267      */
  268     protected static function get_area_classname($areaid) {
  269         list($componentname, $areaname) = static::extract_areaid_parts($areaid);
  270         return '\\' . $componentname . '\\search\\' . $areaname;
  271     }
  272 
  273     /**
  274      * Returns a new area search indexer instance.
  275      *
  276      * @param string $areaid
  277      * @return \core_search\base|bool False if the area is not available.
  278      */
  279     public static function get_search_area($areaid) {
  280 
  281         // We have them all here.
  282         if (!empty(static::$allsearchareas[$areaid])) {
  283             return static::$allsearchareas[$areaid];
  284         }
  285 
  286         $classname = static::get_area_classname($areaid);
  287 
  288         if (class_exists($classname) && static::is_search_area($classname)) {
  289             return new $classname();
  290         }
  291 
  292         return false;
  293     }
  294 
  295     /**
  296      * Return the list of available search areas.
  297      *
  298      * @param bool $enabled Return only the enabled ones.
  299      * @return \core_search\base[]
  300      */
  301     public static function get_search_areas_list($enabled = false) {
  302 
  303         // Two different arrays, we don't expect these arrays to be big.
  304         if (static::$allsearchareas !== null) {
  305             if (!$enabled) {
  306                 return static::$allsearchareas;
  307             } else {
  308                 return static::$enabledsearchareas;
  309             }
  310         }
  311 
  312         static::$allsearchareas = array();
  313         static::$enabledsearchareas = array();
  314 
  315         $plugintypes = \core_component::get_plugin_types();
  316         foreach ($plugintypes as $plugintype => $unused) {
  317             $plugins = \core_component::get_plugin_list($plugintype);
  318             foreach ($plugins as $pluginname => $pluginfullpath) {
  319 
  320                 $componentname = $plugintype . '_' . $pluginname;
  321                 $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
  322                 foreach ($searchclasses as $classname => $classpath) {
  323                     $areaname = substr(strrchr($classname, '\\'), 1);
  324 
  325                     if (!static::is_search_area($classname)) {
  326                         continue;
  327                     }
  328 
  329                     $areaid = static::generate_areaid($componentname, $areaname);
  330                     $searchclass = new $classname();
  331 
  332                     static::$allsearchareas[$areaid] = $searchclass;
  333                     if ($searchclass->is_enabled()) {
  334                         static::$enabledsearchareas[$areaid] = $searchclass;
  335                     }
  336                 }
  337             }
  338         }
  339 
  340         $subsystems = \core_component::get_core_subsystems();
  341         foreach ($subsystems as $subsystemname => $subsystempath) {
  342             $componentname = 'core_' . $subsystemname;
  343             $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
  344 
  345             foreach ($searchclasses as $classname => $classpath) {
  346                 $areaname = substr(strrchr($classname, '\\'), 1);
  347 
  348                 if (!static::is_search_area($classname)) {
  349                     continue;
  350                 }
  351 
  352                 $areaid = static::generate_areaid($componentname, $areaname);
  353                 $searchclass = new $classname();
  354                 static::$allsearchareas[$areaid] = $searchclass;
  355                 if ($searchclass->is_enabled()) {
  356                     static::$enabledsearchareas[$areaid] = $searchclass;
  357                 }
  358             }
  359         }
  360 
  361         if ($enabled) {
  362             return static::$enabledsearchareas;
  363         }
  364         return static::$allsearchareas;
  365     }
  366 
  367     /**
  368      * Clears all static caches.
  369      *
  370      * @return void
  371      */
  372     public static function clear_static() {
  373 
  374         static::$enabledsearchareas = null;
  375         static::$allsearchareas = null;
  376         static::$instance = null;
  377 
  378         base_block::clear_static();
  379         engine::clear_users_cache();
  380     }
  381 
  382     /**
  383      * Generates an area id from the componentname and the area name.
  384      *
  385      * There should not be any naming conflict as the area name is the
  386      * class name in component/classes/search/.
  387      *
  388      * @param string $componentname
  389      * @param string $areaname
  390      * @return void
  391      */
  392     public static function generate_areaid($componentname, $areaname) {
  393         return $componentname . '-' . $areaname;
  394     }
  395 
  396     /**
  397      * Returns all areaid string components (component name and area name).
  398      *
  399      * @param string $areaid
  400      * @return array Component name (Frankenstyle) and area name (search area class name)
  401      */
  402     public static function extract_areaid_parts($areaid) {
  403         return explode('-', $areaid);
  404     }
  405 
  406     /**
  407      * Returns information about the areas which the user can access.
  408      *
  409      * The returned value is a stdClass object with the following fields:
  410      * - everything (bool, true for admin only)
  411      * - usercontexts (indexed by area identifier then context
  412      * - separategroupscontexts (contexts within which group restrictions apply)
  413      * - visiblegroupscontextsareas (overrides to the above when the same contexts also have
  414      *   'visible groups' for certain search area ids - hopefully rare)
  415      * - usergroups (groups which the current user belongs to)
  416      *
  417      * The areas can be limited by course id and context id. If specifying context ids, results
  418      * are limited to the exact context ids specified and not their children (for example, giving
  419      * the course context id would result in including search items with the course context id, and
  420      * not anything from a context inside the course). For performance, you should also specify
  421      * course id(s) when using context ids.
  422      *
  423      * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting.
  424      * @param array|false $limitcontextids An array of context ids to limit the search to. False for no limiting.
  425      * @return \stdClass Object as described above
  426      */
  427     protected function get_areas_user_accesses($limitcourseids = false, $limitcontextids = false) {
  428         global $DB, $USER;
  429 
  430         // All results for admins (unless they have chosen to limit results). Eventually we could
  431         // add a new capability for managers.
  432         if (is_siteadmin() && !$limitcourseids && !$limitcontextids) {
  433             return (object)array('everything' => true);
  434         }
  435 
  436         $areasbylevel = array();
  437 
  438         // Split areas by context level so we only iterate only once through courses and cms.
  439         $searchareas = static::get_search_areas_list(true);
  440         foreach ($searchareas as $areaid => $unused) {
  441             $classname = static::get_area_classname($areaid);
  442             $searcharea = new $classname();
  443             foreach ($classname::get_levels() as $level) {
  444                 $areasbylevel[$level][$areaid] = $searcharea;
  445             }
  446         }
  447 
  448         // This will store area - allowed contexts relations.
  449         $areascontexts = array();
  450 
  451         // Initialise two special-case arrays for storing other information related to the contexts.
  452         $separategroupscontexts = array();
  453         $visiblegroupscontextsareas = array();
  454         $usergroups = array();
  455 
  456         if (empty($limitcourseids) && !empty($areasbylevel[CONTEXT_SYSTEM])) {
  457             // We add system context to all search areas working at this level. Here each area is fully responsible of
  458             // the access control as we can not automate much, we can not even check guest access as some areas might
  459             // want to allow guests to retrieve data from them.
  460 
  461             $systemcontextid = \context_system::instance()->id;
  462             if (!$limitcontextids || in_array($systemcontextid, $limitcontextids)) {
  463                 foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) {
  464                     $areascontexts[$areaid][$systemcontextid] = $systemcontextid;
  465                 }
  466             }
  467         }
  468 
  469         if (!empty($areasbylevel[CONTEXT_USER])) {
  470             if ($usercontext = \context_user::instance($USER->id, IGNORE_MISSING)) {
  471                 if (!$limitcontextids || in_array($usercontext->id, $limitcontextids)) {
  472                     // Extra checking although only logged users should reach this point, guest users have a valid context id.
  473                     foreach ($areasbylevel[CONTEXT_USER] as $areaid => $searchclass) {
  474                         $areascontexts[$areaid][$usercontext->id] = $usercontext->id;
  475                     }
  476                 }
  477             }
  478         }
  479 
  480         if (is_siteadmin()) {
  481             // Admins have access to all courses regardless of enrolment.
  482             if ($limitcourseids) {
  483                 list ($coursesql, $courseparams) = $DB->get_in_or_equal($limitcourseids);
  484                 $coursesql = 'id ' . $coursesql;
  485             } else {
  486                 $coursesql = '';
  487                 $courseparams = [];
  488             }
  489             // Get courses using the same list of fields from enrol_get_my_courses.
  490             $courses = $DB->get_records_select('course', $coursesql, $courseparams, '',
  491                     'id, category, sortorder, shortname, fullname, idnumber, startdate, visible, ' .
  492                     'groupmode, groupmodeforce, cacherev');
  493         } else {
  494             // Get the courses where the current user has access.
  495             $courses = enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [],
  496                     (bool)get_config('core', 'searchallavailablecourses'));
  497         }
  498 
  499         if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) {
  500             $courses[SITEID] = get_course(SITEID);
  501         }
  502 
  503         // Keep a list of included course context ids (needed for the block calculation below).
  504         $coursecontextids = [];
  505         $modulecms = [];
  506 
  507         foreach ($courses as $course) {
  508             if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) {
  509                 // Skip non-included courses.
  510                 continue;
  511             }
  512 
  513             $coursecontext = \context_course::instance($course->id);
  514             $coursecontextids[] = $coursecontext->id;
  515             $hasgrouprestrictions = false;
  516 
  517             // Info about the course modules.
  518             $modinfo = get_fast_modinfo($course);
  519 
  520             if (!empty($areasbylevel[CONTEXT_COURSE]) &&
  521                     (!$limitcontextids || in_array($coursecontext->id, $limitcontextids))) {
  522                 // Add the course contexts the user can view.
  523                 foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) {
  524                     if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
  525                         $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id;
  526                     }
  527                 }
  528             }
  529 
  530             if (!empty($areasbylevel[CONTEXT_MODULE])) {
  531                 // Add the module contexts the user can view (cm_info->uservisible).
  532 
  533                 foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) {
  534 
  535                     // Removing the plugintype 'mod_' prefix.
  536                     $modulename = substr($searchclass->get_component_name(), 4);
  537 
  538                     $modinstances = $modinfo->get_instances_of($modulename);
  539                     foreach ($modinstances as $modinstance) {
  540                         // Skip module context if not included in list of context ids.
  541                         if ($limitcontextids && !in_array($modinstance->context->id, $limitcontextids)) {
  542                             continue;
  543                         }
  544                         if ($modinstance->uservisible) {
  545                             $contextid = $modinstance->context->id;
  546                             $areascontexts[$areaid][$contextid] = $contextid;
  547                             $modulecms[$modinstance->id] = $modinstance;
  548 
  549                             if (!has_capability('moodle/site:accessallgroups', $modinstance->context) &&
  550                                     ($searchclass instanceof base_mod) &&
  551                                     $searchclass->supports_group_restriction()) {
  552                                 if ($searchclass->restrict_cm_access_by_group($modinstance)) {
  553                                     $separategroupscontexts[$contextid] = $contextid;
  554                                     $hasgrouprestrictions = true;
  555                                 } else {
  556                                     // Track a list of anything that has a group id (so might get
  557                                     // filtered) and doesn't want to be, in this context.
  558                                     if (!array_key_exists($contextid, $visiblegroupscontextsareas)) {
  559                                         $visiblegroupscontextsareas[$contextid] = array();
  560                                     }
  561                                     $visiblegroupscontextsareas[$contextid][$areaid] = $areaid;
  562                                 }
  563                             }
  564                         }
  565                     }
  566                 }
  567             }
  568 
  569             // Insert group information for course (unless there aren't any modules restricted by
  570             // group for this user in this course, in which case don't bother).
  571             if ($hasgrouprestrictions) {
  572                 $groups = groups_get_all_groups($course->id, $USER->id, 0, 'g.id');
  573                 foreach ($groups as $group) {
  574                     $usergroups[$group->id] = $group->id;
  575                 }
  576             }
  577         }
  578 
  579         // Chuck away all the 'visible groups contexts' data unless there is actually something
  580         // that does use separate groups in the same context (this data is only used as an
  581         // 'override' in cases where the search is restricting to separate groups).
  582         foreach ($visiblegroupscontextsareas as $contextid => $areas) {
  583             if (!array_key_exists($contextid, $separategroupscontexts)) {
  584                 unset($visiblegroupscontextsareas[$contextid]);
  585             }
  586         }
  587 
  588         // Add all supported block contexts, in a single query for performance.
  589         if (!empty($areasbylevel[CONTEXT_BLOCK])) {
  590             // Get list of all block types we care about.
  591             $blocklist = [];
  592             foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
  593                 $blocklist[$searchclass->get_block_name()] = true;
  594             }
  595             list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist));
  596 
  597             // Get list of course contexts.
  598             list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids);
  599 
  600             // Get list of block context (if limited).
  601             $blockcontextwhere = '';
  602             $blockcontextparams = [];
  603             if ($limitcontextids) {
  604                 list ($blockcontextsql, $blockcontextparams) = $DB->get_in_or_equal($limitcontextids);
  605                 $blockcontextwhere = 'AND x.id ' . $blockcontextsql;
  606             }
  607 
  608             // Query all blocks that are within an included course, and are set to be visible, and
  609             // in a supported page type (basically just course view). This query could be
  610             // extended (or a second query added) to support blocks that are within a module
  611             // context as well, and we could add more page types if required.
  612             $blockrecs = $DB->get_records_sql("
  613                         SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid
  614                           FROM {block_instances} bi
  615                           JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ?
  616                      LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id
  617                                AND bp.contextid = bi.parentcontextid
  618                                AND bp.pagetype LIKE 'course-view-%'
  619                                AND bp.subpage = ''
  620                                AND bp.visible = 0
  621                          WHERE bi.parentcontextid $contextsql
  622                                $blockcontextwhere
  623                                AND bi.blockname $blocknamesql
  624                                AND bi.subpagepattern IS NULL
  625                                AND (bi.pagetypepattern = 'site-index'
  626                                    OR bi.pagetypepattern LIKE 'course-view-%'
  627                                    OR bi.pagetypepattern = 'course-*'
  628                                    OR bi.pagetypepattern = '*')
  629                                AND bp.id IS NULL",
  630                     array_merge([CONTEXT_BLOCK], $contextparams, $blockcontextparams, $blocknameparams));
  631             $blockcontextsbyname = [];
  632             foreach ($blockrecs as $blockrec) {
  633                 if (empty($blockcontextsbyname[$blockrec->blockname])) {
  634                     $blockcontextsbyname[$blockrec->blockname] = [];
  635                 }
  636                 \context_helper::preload_from_record($blockrec);
  637                 $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance(
  638                         $blockrec->blockinstanceid);
  639             }
  640 
  641             // Add the block contexts the user can view.
  642             foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
  643                 if (empty($blockcontextsbyname[$searchclass->get_block_name()])) {
  644                     continue;
  645                 }
  646                 foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) {
  647                     if (has_capability('moodle/block:view', $context)) {
  648                         $areascontexts[$areaid][$context->id] = $context->id;
  649                     }
  650                 }
  651             }
  652         }
  653 
  654         // Return all the data.
  655         return (object)array('everything' => false, 'usercontexts' => $areascontexts,
  656                 'separategroupscontexts' => $separategroupscontexts, 'usergroups' => $usergroups,
  657                 'visiblegroupscontextsareas' => $visiblegroupscontextsareas);
  658     }
  659 
  660     /**
  661      * Returns requested page of documents plus additional information for paging.
  662      *
  663      * This function does not perform any kind of security checking for access, the caller code
  664      * should check that the current user have moodle/search:query capability.
  665      *
  666      * If a page is requested that is beyond the last result, the last valid page is returned in
  667      * results, and actualpage indicates which page was returned.
  668      *
  669      * @param stdClass $formdata
  670      * @param int $pagenum The 0 based page number.
  671      * @return object An object with 3 properties:
  672      *                    results    => An array of \core_search\documents for the actual page.
  673      *                    totalcount => Number of records that are possibly available, to base paging on.
  674      *                    actualpage => The actual page returned.
  675      */
  676     public function paged_search(\stdClass $formdata, $pagenum) {
  677         $out = new \stdClass();
  678 
  679         $perpage = static::DISPLAY_RESULTS_PER_PAGE;
  680 
  681         // Make sure we only allow request up to max page.
  682         $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1);
  683 
  684         // Calculate the first and last document number for the current page, 1 based.
  685         $mindoc = ($pagenum * $perpage) + 1;
  686         $maxdoc = ($pagenum + 1) * $perpage;
  687 
  688         // Get engine documents, up to max.
  689         $docs = $this->search($formdata, $maxdoc);
  690 
  691         $resultcount = count($docs);
  692         if ($resultcount < $maxdoc) {
  693             // This means it couldn't give us results to max, so the count must be the max.
  694             $out->totalcount = $resultcount;
  695         } else {
  696             // Get the possible count reported by engine, and limit to our max.
  697             $out->totalcount = $this->engine->get_query_total_count();
  698             if (defined('BEHAT_SITE_RUNNING') && $this->behatresultcount) {
  699                 // Override results when using Behat mock results.
  700                 $out->totalcount = $this->behatresultcount;
  701             }
  702             $out->totalcount = min($out->totalcount, static::MAX_RESULTS);
  703         }
  704 
  705         // Determine the actual page.
  706         if ($resultcount < $mindoc) {
  707             // We couldn't get the min docs for this page, so determine what page we can get.
  708             $out->actualpage = floor(($resultcount - 1) / $perpage);
  709         } else {
  710             $out->actualpage = $pagenum;
  711         }
  712 
  713         // Split the results to only return the page.
  714         $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true);
  715 
  716         return $out;
  717     }
  718 
  719     /**
  720      * Returns documents from the engine based on the data provided.
  721      *
  722      * This function does not perform any kind of security checking, the caller code
  723      * should check that the current user have moodle/search:query capability.
  724      *
  725      * It might return the results from the cache instead.
  726      *
  727      * Valid formdata options include:
  728      * - q (query text)
  729      * - courseids (optional list of course ids to restrict)
  730      * - contextids (optional list of context ids to restrict)
  731      * - context (Moodle context object for location user searched from)
  732      * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance')
  733      * - userids (optional list of user ids to restrict)
  734      *
  735      * @param \stdClass $formdata Query input data (usually from search form)
  736      * @param int $limit The maximum number of documents to return
  737      * @return \core_search\document[]
  738      */
  739     public function search(\stdClass $formdata, $limit = 0) {
  740         // For Behat testing, the search results can be faked using a special step.
  741         if (defined('BEHAT_SITE_RUNNING')) {
  742             $fakeresult = get_config('core_search', 'behat_fakeresult');
  743             if ($fakeresult) {
  744                 // Clear config setting.
  745                 unset_config('core_search', 'behat_fakeresult');
  746 
  747                 // Check query matches expected value.
  748                 $details = json_decode($fakeresult);
  749                 if ($formdata->q !== $details->query) {
  750                     throw new \coding_exception('Unexpected search query: ' . $formdata->q);
  751                 }
  752 
  753                 // Create search documents from the JSON data.
  754                 $docs = [];
  755                 foreach ($details->results as $result) {
  756                     $doc = new \core_search\document($result->itemid, $result->componentname,
  757                             $result->areaname);
  758                     foreach ((array)$result->fields as $field => $value) {
  759                         $doc->set($field, $value);
  760                     }
  761                     foreach ((array)$result->extrafields as $field => $value) {
  762                         $doc->set_extra($field, $value);
  763                     }
  764                     $area = $this->get_search_area($doc->get('areaid'));
  765                     $doc->set_doc_url($area->get_doc_url($doc));
  766                     $doc->set_context_url($area->get_context_url($doc));
  767                     $docs[] = $doc;
  768                 }
  769 
  770                 // Store the mock count, and apply the limit to the returned results.
  771                 $this->behatresultcount = count($docs);
  772                 if ($this->behatresultcount > $limit) {
  773                     $docs = array_slice($docs, 0, $limit);
  774                 }
  775 
  776                 return $docs;
  777             }
  778         }
  779 
  780         $limitcourseids = false;
  781         if (!empty($formdata->courseids)) {
  782             $limitcourseids = $formdata->courseids;
  783         }
  784 
  785         $limitcontextids = false;
  786         if (!empty($formdata->contextids)) {
  787             $limitcontextids = $formdata->contextids;
  788         }
  789 
  790         // Clears previous query errors.
  791         $this->engine->clear_query_error();
  792 
  793         $contextinfo = $this->get_areas_user_accesses($limitcourseids, $limitcontextids);
  794         if (!$contextinfo->everything && !$contextinfo->usercontexts) {
  795             // User can not access any context.
  796             $docs = array();
  797         } else {
  798             // If engine does not support groups, remove group information from the context info -
  799             // use the old format instead (true = admin, array = user contexts).
  800             if (!$this->engine->supports_group_filtering()) {
  801                 $contextinfo = $contextinfo->everything ? true : $contextinfo->usercontexts;
  802             }
  803 
  804             // Execute the actual query.
  805             $docs = $this->engine->execute_query($formdata, $contextinfo, $limit);
  806         }
  807 
  808         return $docs;
  809     }
  810 
  811     /**
  812      * Merge separate index segments into one.
  813      */
  814     public function optimize_index() {
  815         $this->engine->optimize();
  816     }
  817 
  818     /**
  819      * Index all documents.
  820      *
  821      * @param bool $fullindex Whether we should reindex everything or not.
  822      * @param float $timelimit Time limit in seconds (0 = no time limit)
  823      * @param \progress_trace|null $progress Optional class for tracking progress
  824      * @throws \moodle_exception
  825      * @return bool Whether there was any updated document or not.
  826      */
  827     public function index($fullindex = false, $timelimit = 0, \progress_trace $progress = null) {
  828         global $DB;
  829 
  830         // Cannot combine time limit with reindex.
  831         if ($timelimit && $fullindex) {
  832             throw new \coding_exception('Cannot apply time limit when reindexing');
  833         }
  834         if (!$progress) {
  835             $progress = new \null_progress_trace();
  836         }
  837 
  838         // Unlimited time.
  839         \core_php_time_limit::raise();
  840 
  841         // Notify the engine that an index starting.
  842         $this->engine->index_starting($fullindex);
  843 
  844         $sumdocs = 0;
  845 
  846         $searchareas = $this->get_search_areas_list(true);
  847 
  848         if ($timelimit) {
  849             // If time is limited (and therefore we're not just indexing everything anyway), select
  850             // an order for search areas. The intention here is to avoid a situation where a new
  851             // large search area is enabled, and this means all our other search areas go out of
  852             // date while that one is being indexed. To do this, we order by the time we spent
  853             // indexing them last time we ran, meaning anything that took a very long time will be
  854             // done last.
  855             uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) {
  856                 return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration();
  857             });
  858 
  859             // Decide time to stop.
  860             $stopat = self::get_current_time() + $timelimit;
  861         }
  862 
  863         foreach ($searchareas as $areaid => $searcharea) {
  864 
  865             $progress->output('Processing area: ' . $searcharea->get_visible_name());
  866 
  867             // Notify the engine that an area is starting.
  868             $this->engine->area_index_starting($searcharea, $fullindex);
  869 
  870             $indexingstart = (int)self::get_current_time();
  871             $elapsed = self::get_current_time();
  872 
  873             // This is used to store this component config.
  874             list($componentconfigname, $varname) = $searcharea->get_config_var_name();
  875 
  876             $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
  877 
  878             if ($fullindex === true) {
  879                 $referencestarttime = 0;
  880 
  881                 // For full index, we delete any queued context index requests, as those will
  882                 // obviously be met by the full index.
  883                 $DB->delete_records('search_index_requests');
  884             } else {
  885                 $partial = get_config($componentconfigname, $varname . '_partial');
  886                 if ($partial) {
  887                     // When the previous index did not complete all data, we start from the time of the
  888                     // last document that was successfully indexed. (Note this will result in
  889                     // re-indexing that one document, but we can't avoid that because there may be
  890                     // other documents in the same second.)
  891                     $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun'));
  892                 } else {
  893                     $referencestarttime = $prevtimestart;
  894                 }
  895             }
  896 
  897             // Getting the recordset from the area.
  898             $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime);
  899             $initialquerytime = self::get_current_time() - $elapsed;
  900             if ($initialquerytime > self::DISPLAY_LONG_QUERY_TIME) {
  901                 $progress->output('Initial query took ' . round($initialquerytime, 1) .
  902                         ' seconds.', 1);
  903             }
  904 
  905             // Pass get_document as callback.
  906             $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing();
  907             $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart);
  908             if ($timelimit) {
  909                 $options['stopat'] = $stopat;
  910             }
  911             $options['progress'] = $progress;
  912             $iterator = new skip_future_documents_iterator(new \core\dml\recordset_walk(
  913                     $recordset, array($searcharea, 'get_document'), $options));
  914             $result = $this->engine->add_documents($iterator, $searcharea, $options);
  915             $recordset->close();
  916             if (count($result) === 5) {
  917                 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
  918             } else {
  919                 // Backward compatibility for engines that don't support partial adding.
  920                 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result;
  921                 debugging('engine::add_documents() should return $partial (4-value return is deprecated)',
  922                         DEBUG_DEVELOPER);
  923                 $partial = false;
  924             }
  925 
  926             if ($numdocs > 0) {
  927                 $elapsed = round((self::get_current_time() - $elapsed), 1);
  928 
  929                 $partialtext = '';
  930                 if ($partial) {
  931                     $partialtext = ' (not complete; done to ' . userdate($lastindexeddoc,
  932                             get_string('strftimedatetimeshort', 'langconfig')) . ')';
  933                 }
  934 
  935                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
  936                         ' documents, in ' . $elapsed . ' seconds' . $partialtext . '.', 1);
  937             } else {
  938                 $progress->output('No new documents to index.', 1);
  939             }
  940 
  941             // Notify the engine this area is complete, and only mark times if true.
  942             if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) {
  943                 $sumdocs += $numdocs;
  944 
  945                 // Store last index run once documents have been committed to the search engine.
  946                 set_config($varname . '_indexingstart', $indexingstart, $componentconfigname);
  947                 set_config($varname . '_indexingend', (int)self::get_current_time(), $componentconfigname);
  948                 set_config($varname . '_docsignored', $numdocsignored, $componentconfigname);
  949                 set_config($varname . '_docsprocessed', $numdocs, $componentconfigname);
  950                 set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname);
  951                 if ($lastindexeddoc > 0) {
  952                     set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname);
  953                 }
  954                 if ($partial) {
  955                     set_config($varname . '_partial', 1, $componentconfigname);
  956                 } else {
  957                     unset_config($varname . '_partial', $componentconfigname);
  958                 }
  959             } else {
  960                 $progress->output('Engine reported error.');
  961             }
  962 
  963             if ($timelimit && (self::get_current_time() >= $stopat)) {
  964                 $progress->output('Stopping indexing due to time limit.');
  965                 break;
  966             }
  967         }
  968 
  969         if ($sumdocs > 0) {
  970             $event = \core\event\search_indexed::create(
  971                     array('context' => \context_system::instance()));
  972             $event->trigger();
  973         }
  974 
  975         $this->engine->index_complete($sumdocs, $fullindex);
  976 
  977         return (bool)$sumdocs;
  978     }
  979 
  980     /**
  981      * Indexes or reindexes a specific context of the system, e.g. one course.
  982      *
  983      * The function returns an object with field 'complete' (true or false).
  984      *
  985      * This function supports partial indexing via the time limit parameter. If the time limit
  986      * expires, it will return values for $startfromarea and $startfromtime which can be passed
  987      * next time to continue indexing.
  988      *
  989      * @param \context $context Context to restrict index.
  990      * @param string $singleareaid If specified, indexes only the given area.
  991      * @param float $timelimit Time limit in seconds (0 = no time limit)
  992      * @param \progress_trace|null $progress Optional class for tracking progress
  993      * @param string $startfromarea Area to start from
  994      * @param int $startfromtime Timestamp to start from
  995      * @return \stdClass Object indicating success
  996      */
  997     public function index_context($context, $singleareaid = '', $timelimit = 0,
  998             \progress_trace $progress = null, $startfromarea = '', $startfromtime = 0) {
  999         if (!$progress) {
 1000             $progress = new \null_progress_trace();
 1001         }
 1002 
 1003         // Work out time to stop, if limited.
 1004         if ($timelimit) {
 1005             // Decide time to stop.
 1006             $stopat = self::get_current_time() + $timelimit;
 1007         }
 1008 
 1009         // No PHP time limit.
 1010         \core_php_time_limit::raise();
 1011 
 1012         // Notify the engine that an index starting.
 1013         $this->engine->index_starting(false);
 1014 
 1015         $sumdocs = 0;
 1016 
 1017         // Get all search areas, in consistent order.
 1018         $searchareas = $this->get_search_areas_list(true);
 1019         ksort($searchareas);
 1020 
 1021         // Are we skipping past some that were handled previously?
 1022         $skipping = $startfromarea ? true : false;
 1023 
 1024         foreach ($searchareas as $areaid => $searcharea) {
 1025             // If we're only processing one area id, skip all the others.
 1026             if ($singleareaid && $singleareaid !== $areaid) {
 1027                 continue;
 1028             }
 1029 
 1030             // If we're skipping to a later area, continue through the loop.
 1031             $referencestarttime = 0;
 1032             if ($skipping) {
 1033                 if ($areaid !== $startfromarea) {
 1034                     continue;
 1035                 }
 1036                 // Stop skipping and note the reference start time.
 1037                 $skipping = false;
 1038                 $referencestarttime = $startfromtime;
 1039             }
 1040 
 1041             $progress->output('Processing area: ' . $searcharea->get_visible_name());
 1042 
 1043             $elapsed = self::get_current_time();
 1044 
 1045             // Get the recordset of all documents from the area for this context.
 1046             $recordset = $searcharea->get_document_recordset($referencestarttime, $context);
 1047             if (!$recordset) {
 1048                 if ($recordset === null) {
 1049                     $progress->output('Skipping (not relevant to context).', 1);
 1050                 } else {
 1051                     $progress->output('Skipping (does not support context indexing).', 1);
 1052                 }
 1053                 continue;
 1054             }
 1055 
 1056             // Notify the engine that an area is starting.
 1057             $this->engine->area_index_starting($searcharea, false);
 1058 
 1059             // Work out search options.
 1060             $options = [];
 1061             $options['indexfiles'] = $this->engine->file_indexing_enabled() &&
 1062                     $searcharea->uses_file_indexing();
 1063             if ($timelimit) {
 1064                 $options['stopat'] = $stopat;
 1065             }
 1066 
 1067             // Construct iterator which will use get_document on the recordset results.
 1068             $iterator = new \core\dml\recordset_walk($recordset,
 1069                     array($searcharea, 'get_document'), $options);
 1070 
 1071             // Use this iterator to add documents.
 1072             $result = $this->engine->add_documents($iterator, $searcharea, $options);
 1073             if (count($result) === 5) {
 1074                 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
 1075             } else {
 1076                 // Backward compatibility for engines that don't support partial adding.
 1077                 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result;
 1078                 debugging('engine::add_documents() should return $partial (4-value return is deprecated)',
 1079                         DEBUG_DEVELOPER);
 1080                 $partial = false;
 1081             }
 1082 
 1083             if ($numdocs > 0) {
 1084                 $elapsed = round((self::get_current_time() - $elapsed), 3);
 1085                 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
 1086                         ' documents, in ' . $elapsed . ' seconds' .
 1087                         ($partial ? ' (not complete)' : '') . '.', 1);
 1088             } else {
 1089                 $progress->output('No documents to index.', 1);
 1090             }
 1091 
 1092             // Notify the engine this area is complete, but don't store any times as this is not
 1093             // part of the 'normal' search index.
 1094             if (!$this->engine->area_index_complete($searcharea, $numdocs, false)) {
 1095                 $progress->output('Engine reported error.', 1);
 1096             }
 1097 
 1098             if ($partial && $timelimit && (self::get_current_time() >= $stopat)) {
 1099                 $progress->output('Stopping indexing due to time limit.');
 1100                 break;
 1101             }
 1102         }
 1103 
 1104         if ($sumdocs > 0) {
 1105             $event = \core\event\search_indexed::create(
 1106                     array('context' => $context));
 1107             $event->trigger();
 1108         }
 1109 
 1110         $this->engine->index_complete($sumdocs, false);
 1111 
 1112         // Indicate in result whether we completed indexing, or only part of it.
 1113         $result = new \stdClass();
 1114         if ($partial) {
 1115             $result->complete = false;
 1116             $result->startfromarea = $areaid;
 1117             $result->startfromtime = $lastindexeddoc;
 1118         } else {
 1119             $result->complete = true;
 1120         }
 1121         return $result;
 1122     }
 1123 
 1124     /**
 1125      * Resets areas config.
 1126      *
 1127      * @throws \moodle_exception
 1128      * @param string $areaid
 1129      * @return void
 1130      */
 1131     public function reset_config($areaid = false) {
 1132 
 1133         if (!empty($areaid)) {
 1134             $searchareas = array();
 1135             if (!$searchareas[$areaid] = static::get_search_area($areaid)) {
 1136                 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid);
 1137             }
 1138         } else {
 1139             // Only the enabled ones.
 1140             $searchareas = static::get_search_areas_list(true);
 1141         }
 1142 
 1143         foreach ($searchareas as $searcharea) {
 1144             list($componentname, $varname) = $searcharea->get_config_var_name();
 1145             $config = $searcharea->get_config();
 1146 
 1147             foreach ($config as $key => $value) {
 1148                 // We reset them all but the enable/disabled one.
 1149                 if ($key !== $varname . '_enabled') {
 1150                     set_config($key, 0, $componentname);
 1151                 }
 1152             }
 1153         }
 1154     }
 1155 
 1156     /**
 1157      * Deletes an area's documents or all areas documents.
 1158      *
 1159      * @param string $areaid The area id or false for all
 1160      * @return void
 1161      */
 1162     public function delete_index($areaid = false) {
 1163         if (!empty($areaid)) {
 1164             $this->engine->delete($areaid);
 1165             $this->reset_config($areaid);
 1166         } else {
 1167             $this->engine->delete();
 1168             $this->reset_config();
 1169         }
 1170     }
 1171 
 1172     /**
 1173      * Deletes index by id.
 1174      *
 1175      * @param int Solr Document string $id
 1176      */
 1177     public function delete_index_by_id($id) {
 1178         $this->engine->delete_by_id($id);
 1179     }
 1180 
 1181     /**
 1182      * Returns search areas configuration.
 1183      *
 1184      * @param \core_search\base[] $searchareas
 1185      * @return \stdClass[] $configsettings
 1186      */
 1187     public function get_areas_config($searchareas) {
 1188 
 1189         $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored',
 1190                 'docsprocessed', 'recordsprocessed', 'partial');
 1191 
 1192         $configsettings = [];
 1193         foreach ($searchareas as $searcharea) {
 1194 
 1195             $areaid = $searcharea->get_area_id();
 1196 
 1197             $configsettings[$areaid] = new \stdClass();
 1198             list($componentname, $varname) = $searcharea->get_config_var_name();
 1199 
 1200             if (!$searcharea->is_enabled()) {
 1201                 // We delete all indexed data on disable so no info.
 1202                 foreach ($vars as $var) {
 1203                     $configsettings[$areaid]->{$var} = 0;
 1204                 }
 1205             } else {
 1206                 foreach ($vars as $var) {
 1207                     $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var);
 1208                 }
 1209             }
 1210 
 1211             // Formatting the time.
 1212             if (!empty($configsettings[$areaid]->lastindexrun)) {
 1213                 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun);
 1214             } else {
 1215                 $configsettings[$areaid]->lastindexrun = get_string('never');
 1216             }
 1217         }
 1218         return $configsettings;
 1219     }
 1220 
 1221     /**
 1222      * Triggers search_results_viewed event
 1223      *
 1224      * Other data required:
 1225      * - q: The query string
 1226      * - page: The page number
 1227      * - title: Title filter
 1228      * - areaids: Search areas filter
 1229      * - courseids: Courses filter
 1230      * - timestart: Time start filter
 1231      * - timeend: Time end filter
 1232      *
 1233      * @since Moodle 3.2
 1234      * @param array $other Other info for the event.
 1235      * @return \core\event\search_results_viewed
 1236      */
 1237     public static function trigger_search_results_viewed($other) {
 1238         $event = \core\event\search_results_viewed::create([
 1239             'context' => \context_system::instance(),
 1240             'other' => $other
 1241         ]);
 1242         $event->trigger();
 1243 
 1244         return $event;
 1245     }
 1246 
 1247     /**
 1248      * Checks whether a classname is of an actual search area.
 1249      *
 1250      * @param string $classname
 1251      * @return bool
 1252      */
 1253     protected static function is_search_area($classname) {
 1254         if (is_subclass_of($classname, 'core_search\base')) {
 1255             return (new \ReflectionClass($classname))->isInstantiable();
 1256         }
 1257 
 1258         return false;
 1259     }
 1260 
 1261     /**
 1262      * Requests that a specific context is indexed by the scheduled task. The context will be
 1263      * added to a queue which is processed by the task.
 1264      *
 1265      * This is used after a restore to ensure that restored items are indexed, even though their
 1266      * modified time will be older than the latest indexed. It is also used by the 'Gradual reindex'
 1267      * admin feature from the search areas screen.
 1268      *
 1269      * @param \context $context Context to index within
 1270      * @param string $areaid Area to index, '' = all areas
 1271      * @param int $priority Priority (INDEX_PRIORITY_xx constant)
 1272      */
 1273     public static function request_index(\context $context, $areaid = '',
 1274             $priority = self::INDEX_PRIORITY_NORMAL) {
 1275         global $DB;
 1276 
 1277         // Check through existing requests for this context or any parent context.
 1278         list ($contextsql, $contextparams) = $DB->get_in_or_equal(
 1279                 $context->get_parent_context_ids(true));
 1280         $existing = $DB->get_records_select('search_index_requests',
 1281                 'contextid ' . $contextsql, $contextparams, '',
 1282                 'id, searcharea, partialarea, indexpriority');
 1283         foreach ($existing as $rec) {
 1284             // If we haven't started processing the existing request yet, and it covers the same
 1285             // area (or all areas) then that will be sufficient so don't add anything else.
 1286             if ($rec->partialarea === '' && ($rec->searcharea === $areaid || $rec->searcharea === '')) {
 1287                 // If the existing request has the same (or higher) priority, no need to add anything.
 1288                 if ($rec->indexpriority >= $priority) {
 1289                     return;
 1290                 }
 1291                 // The existing request has lower priority. If it is exactly the same, then just
 1292                 // adjust the priority of the existing request.
 1293                 if ($rec->searcharea === $areaid) {
 1294                     $DB->set_field('search_index_requests', 'indexpriority', $priority,
 1295                             ['id' => $rec->id]);
 1296                     return;
 1297                 }
 1298                 // The existing request would cover this area but is a lower priority. We need to
 1299                 // add the new request even though that means we will index part of it twice.
 1300             }
 1301         }
 1302 
 1303         // No suitable existing request, so add a new one.
 1304         $newrecord = [ 'contextid' => $context->id, 'searcharea' => $areaid,
 1305                 'timerequested' => (int)self::get_current_time(),
 1306                 'partialarea' => '', 'partialtime' => 0,
 1307                 'indexpriority' => $priority ];
 1308         $DB->insert_record('search_index_requests', $newrecord);
 1309     }
 1310 
 1311     /**
 1312      * Processes outstanding index requests. This will take the first item from the queue (taking
 1313      * account the indexing priority) and process it, continuing until an optional time limit is
 1314      * reached.
 1315      *
 1316      * If there are no index requests, the function will do nothing.
 1317      *
 1318      * @param float $timelimit Time limit (0 = none)
 1319      * @param \progress_trace|null $progress Optional progress indicator
 1320      */
 1321     public function process_index_requests($timelimit = 0.0, \progress_trace $progress = null) {
 1322         global $DB;
 1323 
 1324         if (!$progress) {
 1325             $progress = new \null_progress_trace();
 1326         }
 1327 
 1328         $before = self::get_current_time();
 1329         if ($timelimit) {
 1330             $stopat = $before + $timelimit;
 1331         }
 1332         while (true) {
 1333             // Retrieve first request, using fully defined ordering.
 1334             $requests = $DB->get_records('search_index_requests', null,
 1335                     'indexpriority DESC, timerequested, contextid, searcharea',
 1336                     'id, contextid, searcharea, partialarea, partialtime', 0, 1);
 1337             if (!$requests) {
 1338                 // If there are no more requests, stop.
 1339                 break;
 1340             }
 1341             $request = reset($requests);
 1342 
 1343             // Calculate remaining time.
 1344             $remainingtime = 0;
 1345             $beforeindex = self::get_current_time();
 1346             if ($timelimit) {
 1347                 $remainingtime = $stopat - $beforeindex;
 1348 
 1349                 // If the time limit expired already, stop now. (Otherwise we might accidentally
 1350                 // index with no time limit or a negative time limit.)
 1351                 if ($remainingtime <= 0) {
 1352                     break;
 1353                 }
 1354             }
 1355 
 1356             // Show a message before each request, indicating what will be indexed.
 1357             $context = \context::instance_by_id($request->contextid, IGNORE_MISSING);
 1358             if (!$context) {
 1359                 $DB->delete_records('search_index_requests', ['id' => $request->id]);
 1360                 $progress->output('Skipped deleted context: ' . $request->contextid);
 1361                 continue;
 1362             }
 1363             $contextname = $context->get_context_name();
 1364             if ($request->searcharea) {
 1365                 $contextname .= ' (search area: ' . $request->searcharea . ')';
 1366             }
 1367             $progress->output('Indexing requested context: ' . $contextname);
 1368 
 1369             // Actually index the context.
 1370             $result = $this->index_context($context, $request->searcharea, $remainingtime,
 1371                     $progress, $request->partialarea, $request->partialtime);
 1372 
 1373             // Work out shared part of message.
 1374             $endmessage = $contextname . ' (' . round(self::get_current_time() - $beforeindex, 1) . 's)';
 1375 
 1376             // Update database table and continue/stop as appropriate.
 1377             if ($result->complete) {
 1378                 // If we completed the request, remove it from the table.
 1379                 $DB->delete_records('search_index_requests', ['id' => $request->id]);
 1380                 $progress->output('Completed requested context: ' . $endmessage);
 1381             } else {
 1382                 // If we didn't complete the request, store the partial details (how far it got).
 1383                 $DB->update_record('search_index_requests', ['id' => $request->id,
 1384                         'partialarea' => $result->startfromarea,
 1385                         'partialtime' => $result->startfromtime]);
 1386                 $progress->output('Ending requested context: ' . $endmessage);
 1387 
 1388                 // The time limit must have expired, so stop looping.
 1389                 break;
 1390             }
 1391         }
 1392     }
 1393 
 1394     /**
 1395      * Gets information about the request queue, in the form of a plain object suitable for passing
 1396      * to a template for rendering.
 1397      *
 1398      * @return \stdClass Information about queued index requests
 1399      */
 1400     public function get_index_requests_info() {
 1401         global $DB;
 1402 
 1403         $result = new \stdClass();
 1404 
 1405         $result->total = $DB->count_records('search_index_requests');
 1406         $result->topten = $DB->get_records('search_index_requests', null,
 1407                 'indexpriority DESC, timerequested, contextid, searcharea',
 1408                 'id, contextid, timerequested, searcharea, partialarea, partialtime, indexpriority',
 1409                 0, 10);
 1410         foreach ($result->topten as $item) {
 1411             $context = \context::instance_by_id($item->contextid);
 1412             $item->contextlink = \html_writer::link($context->get_url(),
 1413                     s($context->get_context_name()));
 1414             if ($item->searcharea) {
 1415                 $item->areaname = $this->get_search_area($item->searcharea)->get_visible_name();
 1416             }
 1417             if ($item->partialarea) {
 1418                 $item->partialareaname = $this->get_search_area($item->partialarea)->get_visible_name();
 1419             }
 1420             switch ($item->indexpriority) {
 1421                 case self::INDEX_PRIORITY_REINDEXING :
 1422                     $item->priorityname = get_string('priority_reindexing', 'search');
 1423                     break;
 1424                 case self::INDEX_PRIORITY_NORMAL :
 1425                     $item->priorityname = get_string('priority_normal', 'search');
 1426                     break;
 1427             }
 1428         }
 1429 
 1430         // Normalise array indices.
 1431         $result->topten = array_values($result->topten);
 1432 
 1433         if ($result->total > 10) {
 1434             $result->ellipsis = true;
 1435         }
 1436 
 1437         return $result;
 1438     }
 1439 
 1440     /**
 1441      * Gets current time for use in search system.
 1442      *
 1443      * Note: This should be replaced with generic core functionality once possible (see MDL-60644).
 1444      *
 1445      * @return float Current time in seconds (with decimals)
 1446      */
 1447     public static function get_current_time() {
 1448         if (PHPUNIT_TEST && self::$phpunitfaketime) {
 1449             return self::$phpunitfaketime;
 1450         }
 1451         return microtime(true);
 1452     }
 1453 }