MDL-60357 core_search: Future modified times cause serious problems
[moodle.git] / search / classes / manager.php
CommitLineData
db48207e
DM
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
25namespace core_search;
26
27defined('MOODLE_INTERNAL') || die;
28
29require_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 */
38class manager {
39
40 /**
41 * @var int Text contents.
42 */
43 const TYPE_TEXT = 1;
44
091973db
EM
45 /**
46 * @var int File contents.
47 */
48 const TYPE_FILE = 2;
49
db48207e
DM
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
f6b425e2
EM
75 /**
76 * @var int The id to be placed in owneruserid when there is no owner.
77 */
78 const NO_OWNER_ID = 0;
79
db48207e 80 /**
0bd8383a 81 * @var \core_search\base[] Enabled search areas.
db48207e
DM
82 */
83 protected static $enabledsearchareas = null;
84
85 /**
0bd8383a 86 * @var \core_search\base[] All system search areas.
db48207e
DM
87 */
88 protected static $allsearchareas = null;
89
90 /**
91 * @var \core_search\manager
92 */
93 protected static $instance = null;
94
95 /**
96 * @var \core_search\engine
97 */
98 protected $engine = null;
99
100 /**
101 * Constructor, use \core_search\manager::instance instead to get a class instance.
102 *
0bd8383a 103 * @param \core_search\base The search engine to use
db48207e
DM
104 */
105 public function __construct($engine) {
106 $this->engine = $engine;
107 }
108
109 /**
110 * Returns an initialised \core_search instance.
111 *
23fc1be8
DM
112 * @see \core_search\engine::is_installed
113 * @see \core_search\engine::is_server_ready
db48207e
DM
114 * @throws \core_search\engine_exception
115 * @return \core_search\manager
116 */
117 public static function instance() {
118 global $CFG;
119
120 // One per request, this should be purged during testing.
121 if (static::$instance !== null) {
122 return static::$instance;
123 }
124
379ca986
DM
125 if (empty($CFG->searchengine)) {
126 throw new \core_search\engine_exception('enginenotselected', 'search');
127 }
128
db48207e
DM
129 if (!$engine = static::search_engine_instance()) {
130 throw new \core_search\engine_exception('enginenotfound', 'search', '', $CFG->searchengine);
131 }
132
133 if (!$engine->is_installed()) {
134 throw new \core_search\engine_exception('enginenotinstalled', 'search', '', $CFG->searchengine);
135 }
136
137 $serverstatus = $engine->is_server_ready();
138 if ($serverstatus !== true) {
e36eefae 139 // Skip this error in Behat when faking seach results.
140 if (!defined('BEHAT_SITE_RUNNING') || !get_config('core_search', 'behat_fakeresult')) {
141 // Error message with no details as this is an exception that any user may find if the server crashes.
142 throw new \core_search\engine_exception('engineserverstatus', 'search');
143 }
db48207e
DM
144 }
145
146 static::$instance = new \core_search\manager($engine);
147 return static::$instance;
148 }
149
150 /**
151 * Returns whether global search is enabled or not.
152 *
153 * @return bool
154 */
155 public static function is_global_search_enabled() {
156 global $CFG;
157 return !empty($CFG->enableglobalsearch);
158 }
159
160 /**
161 * Returns an instance of the search engine.
162 *
163 * @return \core_search\engine
164 */
165 public static function search_engine_instance() {
166 global $CFG;
167
168 $classname = '\\search_' . $CFG->searchengine . '\\engine';
169 if (!class_exists($classname)) {
170 return false;
171 }
172
173 return new $classname();
174 }
175
176 /**
177 * Returns the search engine.
178 *
179 * @return \core_search\engine
180 */
181 public function get_engine() {
182 return $this->engine;
183 }
184
185 /**
186 * Returns a search area class name.
187 *
188 * @param string $areaid
189 * @return string
190 */
191 protected static function get_area_classname($areaid) {
192 list($componentname, $areaname) = static::extract_areaid_parts($areaid);
193 return '\\' . $componentname . '\\search\\' . $areaname;
194 }
195
196 /**
197 * Returns a new area search indexer instance.
198 *
199 * @param string $areaid
0bd8383a 200 * @return \core_search\base|bool False if the area is not available.
db48207e
DM
201 */
202 public static function get_search_area($areaid) {
203
b805d3f8 204 // We have them all here.
db48207e
DM
205 if (!empty(static::$allsearchareas[$areaid])) {
206 return static::$allsearchareas[$areaid];
207 }
db48207e
DM
208
209 $classname = static::get_area_classname($areaid);
f3d38863
DM
210
211 if (class_exists($classname) && static::is_search_area($classname)) {
db48207e
DM
212 return new $classname();
213 }
214
215 return false;
216 }
217
218 /**
219 * Return the list of available search areas.
220 *
221 * @param bool $enabled Return only the enabled ones.
0bd8383a 222 * @return \core_search\base[]
db48207e
DM
223 */
224 public static function get_search_areas_list($enabled = false) {
225
226 // Two different arrays, we don't expect these arrays to be big.
b805d3f8
DM
227 if (static::$allsearchareas !== null) {
228 if (!$enabled) {
229 return static::$allsearchareas;
230 } else {
231 return static::$enabledsearchareas;
232 }
db48207e
DM
233 }
234
b805d3f8
DM
235 static::$allsearchareas = array();
236 static::$enabledsearchareas = array();
db48207e
DM
237
238 $plugintypes = \core_component::get_plugin_types();
239 foreach ($plugintypes as $plugintype => $unused) {
240 $plugins = \core_component::get_plugin_list($plugintype);
241 foreach ($plugins as $pluginname => $pluginfullpath) {
242
243 $componentname = $plugintype . '_' . $pluginname;
244 $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
245 foreach ($searchclasses as $classname => $classpath) {
246 $areaname = substr(strrchr($classname, '\\'), 1);
396d6f0a
DG
247
248 if (!static::is_search_area($classname)) {
249 continue;
250 }
251
db48207e
DM
252 $areaid = static::generate_areaid($componentname, $areaname);
253 $searchclass = new $classname();
b805d3f8
DM
254
255 static::$allsearchareas[$areaid] = $searchclass;
256 if ($searchclass->is_enabled()) {
257 static::$enabledsearchareas[$areaid] = $searchclass;
db48207e
DM
258 }
259 }
260 }
261 }
262
263 $subsystems = \core_component::get_core_subsystems();
264 foreach ($subsystems as $subsystemname => $subsystempath) {
265 $componentname = 'core_' . $subsystemname;
266 $searchclasses = \core_component::get_component_classes_in_namespace($componentname, 'search');
267
268 foreach ($searchclasses as $classname => $classpath) {
269 $areaname = substr(strrchr($classname, '\\'), 1);
396d6f0a
DG
270
271 if (!static::is_search_area($classname)) {
272 continue;
273 }
274
db48207e
DM
275 $areaid = static::generate_areaid($componentname, $areaname);
276 $searchclass = new $classname();
b805d3f8
DM
277 static::$allsearchareas[$areaid] = $searchclass;
278 if ($searchclass->is_enabled()) {
279 static::$enabledsearchareas[$areaid] = $searchclass;
db48207e
DM
280 }
281 }
282 }
283
db48207e 284 if ($enabled) {
b805d3f8 285 return static::$enabledsearchareas;
db48207e 286 }
b805d3f8 287 return static::$allsearchareas;
db48207e
DM
288 }
289
290 /**
291 * Clears all static caches.
292 *
293 * @return void
294 */
295 public static function clear_static() {
296
297 static::$enabledsearchareas = null;
298 static::$allsearchareas = null;
299 static::$instance = null;
a96faa49 300
301 base_block::clear_static();
db48207e
DM
302 }
303
304 /**
305 * Generates an area id from the componentname and the area name.
306 *
307 * There should not be any naming conflict as the area name is the
308 * class name in component/classes/search/.
309 *
310 * @param string $componentname
311 * @param string $areaname
312 * @return void
313 */
314 public static function generate_areaid($componentname, $areaname) {
315 return $componentname . '-' . $areaname;
316 }
317
318 /**
319 * Returns all areaid string components (component name and area name).
320 *
321 * @param string $areaid
322 * @return array Component name (Frankenstyle) and area name (search area class name)
323 */
324 public static function extract_areaid_parts($areaid) {
325 return explode('-', $areaid);
326 }
327
328 /**
329 * Returns the contexts the user can access.
330 *
331 * The returned value is a multidimensional array because some search engines can group
332 * information and there will be a performance benefit on passing only some contexts
333 * instead of the whole context array set.
334 *
427e3cbc 335 * @param array|false $limitcourseids An array of course ids to limit the search to. False for no limiting.
db48207e
DM
336 * @return bool|array Indexed by area identifier (component + area name). Returns true if the user can see everything.
337 */
427e3cbc 338 protected function get_areas_user_accesses($limitcourseids = false) {
a96faa49 339 global $DB, $USER;
db48207e
DM
340
341 // All results for admins. Eventually we could add a new capability for managers.
342 if (is_siteadmin()) {
343 return true;
344 }
345
346 $areasbylevel = array();
347
348 // Split areas by context level so we only iterate only once through courses and cms.
349 $searchareas = static::get_search_areas_list(true);
350 foreach ($searchareas as $areaid => $unused) {
351 $classname = static::get_area_classname($areaid);
352 $searcharea = new $classname();
353 foreach ($classname::get_levels() as $level) {
354 $areasbylevel[$level][$areaid] = $searcharea;
355 }
356 }
357
358 // This will store area - allowed contexts relations.
359 $areascontexts = array();
360
427e3cbc 361 if (empty($limitcourseids) && !empty($areasbylevel[CONTEXT_SYSTEM])) {
db48207e
DM
362 // We add system context to all search areas working at this level. Here each area is fully responsible of
363 // the access control as we can not automate much, we can not even check guest access as some areas might
364 // want to allow guests to retrieve data from them.
365
366 $systemcontextid = \context_system::instance()->id;
367 foreach ($areasbylevel[CONTEXT_SYSTEM] as $areaid => $searchclass) {
25ba053f
DM
368 $areascontexts[$areaid][$systemcontextid] = $systemcontextid;
369 }
370 }
371
372 if (!empty($areasbylevel[CONTEXT_USER])) {
373 if ($usercontext = \context_user::instance($USER->id, IGNORE_MISSING)) {
374 // Extra checking although only logged users should reach this point, guest users have a valid context id.
375 foreach ($areasbylevel[CONTEXT_USER] as $areaid => $searchclass) {
376 $areascontexts[$areaid][$usercontext->id] = $usercontext->id;
377 }
db48207e
DM
378 }
379 }
380
381 // Get the courses where the current user has access.
2d94d4ea 382 $courses = enrol_get_my_courses(array('id', 'cacherev'), 'id', 0, [],
383 (bool)get_config('core', 'searchallavailablecourses'));
427e3cbc
EM
384
385 if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) {
386 $courses[SITEID] = get_course(SITEID);
387 }
388
a96faa49 389 // Keep a list of included course context ids (needed for the block calculation below).
390 $coursecontextids = [];
391
db48207e 392 foreach ($courses as $course) {
427e3cbc
EM
393 if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) {
394 // Skip non-included courses.
395 continue;
396 }
db48207e 397
a96faa49 398 $coursecontext = \context_course::instance($course->id);
399 $coursecontextids[] = $coursecontext->id;
400
db48207e
DM
401 // Info about the course modules.
402 $modinfo = get_fast_modinfo($course);
403
404 if (!empty($areasbylevel[CONTEXT_COURSE])) {
405 // Add the course contexts the user can view.
db48207e
DM
406 foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) {
407 if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
408 $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id;
409 }
410 }
411 }
412
413 if (!empty($areasbylevel[CONTEXT_MODULE])) {
414 // Add the module contexts the user can view (cm_info->uservisible).
415
416 foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) {
417
418 // Removing the plugintype 'mod_' prefix.
419 $modulename = substr($searchclass->get_component_name(), 4);
420
421 $modinstances = $modinfo->get_instances_of($modulename);
422 foreach ($modinstances as $modinstance) {
423 if ($modinstance->uservisible) {
424 $areascontexts[$areaid][$modinstance->context->id] = $modinstance->context->id;
425 }
426 }
427 }
428 }
429 }
430
a96faa49 431 // Add all supported block contexts, in a single query for performance.
432 if (!empty($areasbylevel[CONTEXT_BLOCK])) {
433 // Get list of all block types we care about.
434 $blocklist = [];
435 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
436 $blocklist[$searchclass->get_block_name()] = true;
437 }
438 list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist));
439
440 // Get list of course contexts.
441 list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids);
442
443 // Query all blocks that are within an included course, and are set to be visible, and
444 // in a supported page type (basically just course view). This query could be
445 // extended (or a second query added) to support blocks that are within a module
446 // context as well, and we could add more page types if required.
447 $blockrecs = $DB->get_records_sql("
448 SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid
449 FROM {block_instances} bi
450 JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ?
451 LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id
452 AND bp.contextid = bi.parentcontextid
453 AND bp.pagetype LIKE 'course-view-%'
454 AND bp.subpage = ''
455 AND bp.visible = 0
456 WHERE bi.parentcontextid $contextsql
457 AND bi.blockname $blocknamesql
458 AND bi.subpagepattern IS NULL
459 AND (bi.pagetypepattern = 'site-index'
460 OR bi.pagetypepattern LIKE 'course-view-%'
461 OR bi.pagetypepattern = 'course-*'
462 OR bi.pagetypepattern = '*')
463 AND bp.id IS NULL",
464 array_merge([CONTEXT_BLOCK], $contextparams, $blocknameparams));
465 $blockcontextsbyname = [];
466 foreach ($blockrecs as $blockrec) {
467 if (empty($blockcontextsbyname[$blockrec->blockname])) {
468 $blockcontextsbyname[$blockrec->blockname] = [];
469 }
470 \context_helper::preload_from_record($blockrec);
471 $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance(
472 $blockrec->blockinstanceid);
473 }
474
475 // Add the block contexts the user can view.
476 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
477 if (empty($blockcontextsbyname[$searchclass->get_block_name()])) {
478 continue;
479 }
480 foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) {
481 if (has_capability('moodle/block:view', $context)) {
482 $areascontexts[$areaid][$context->id] = $context->id;
483 }
484 }
485 }
486 }
487
db48207e
DM
488 return $areascontexts;
489 }
490
053118a1
EM
491 /**
492 * Returns requested page of documents plus additional information for paging.
493 *
494 * This function does not perform any kind of security checking for access, the caller code
495 * should check that the current user have moodle/search:query capability.
496 *
497 * If a page is requested that is beyond the last result, the last valid page is returned in
498 * results, and actualpage indicates which page was returned.
499 *
500 * @param stdClass $formdata
501 * @param int $pagenum The 0 based page number.
502 * @return object An object with 3 properties:
503 * results => An array of \core_search\documents for the actual page.
504 * totalcount => Number of records that are possibly available, to base paging on.
505 * actualpage => The actual page returned.
506 */
507 public function paged_search(\stdClass $formdata, $pagenum) {
508 $out = new \stdClass();
509
510 $perpage = static::DISPLAY_RESULTS_PER_PAGE;
511
512 // Make sure we only allow request up to max page.
513 $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1);
514
515 // Calculate the first and last document number for the current page, 1 based.
516 $mindoc = ($pagenum * $perpage) + 1;
517 $maxdoc = ($pagenum + 1) * $perpage;
518
519 // Get engine documents, up to max.
520 $docs = $this->search($formdata, $maxdoc);
521
522 $resultcount = count($docs);
523 if ($resultcount < $maxdoc) {
524 // This means it couldn't give us results to max, so the count must be the max.
525 $out->totalcount = $resultcount;
526 } else {
527 // Get the possible count reported by engine, and limit to our max.
528 $out->totalcount = $this->engine->get_query_total_count();
529 $out->totalcount = min($out->totalcount, static::MAX_RESULTS);
530 }
531
532 // Determine the actual page.
533 if ($resultcount < $mindoc) {
534 // We couldn't get the min docs for this page, so determine what page we can get.
535 $out->actualpage = floor(($resultcount - 1) / $perpage);
536 } else {
537 $out->actualpage = $pagenum;
538 }
539
540 // Split the results to only return the page.
541 $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true);
542
543 return $out;
544 }
545
db48207e
DM
546 /**
547 * Returns documents from the engine based on the data provided.
548 *
69d66020
DM
549 * This function does not perform any kind of security checking, the caller code
550 * should check that the current user have moodle/search:query capability.
551 *
db48207e
DM
552 * It might return the results from the cache instead.
553 *
554 * @param stdClass $formdata
053118a1 555 * @param int $limit The maximum number of documents to return
db48207e
DM
556 * @return \core_search\document[]
557 */
053118a1 558 public function search(\stdClass $formdata, $limit = 0) {
e36eefae 559 // For Behat testing, the search results can be faked using a special step.
560 if (defined('BEHAT_SITE_RUNNING')) {
561 $fakeresult = get_config('core_search', 'behat_fakeresult');
562 if ($fakeresult) {
563 // Clear config setting.
564 unset_config('core_search', 'behat_fakeresult');
565
566 // Check query matches expected value.
567 $details = json_decode($fakeresult);
568 if ($formdata->q !== $details->query) {
569 throw new \coding_exception('Unexpected search query: ' . $formdata->q);
570 }
571
572 // Create search documents from the JSON data.
573 $docs = [];
574 foreach ($details->results as $result) {
575 $doc = new \core_search\document($result->itemid, $result->componentname,
576 $result->areaname);
577 foreach ((array)$result->fields as $field => $value) {
578 $doc->set($field, $value);
579 }
580 foreach ((array)$result->extrafields as $field => $value) {
581 $doc->set_extra($field, $value);
582 }
583 $area = $this->get_search_area($doc->get('areaid'));
584 $doc->set_doc_url($area->get_doc_url($doc));
585 $doc->set_context_url($area->get_context_url($doc));
586 $docs[] = $doc;
587 }
588
589 return $docs;
590 }
591 }
db48207e 592
427e3cbc
EM
593 $limitcourseids = false;
594 if (!empty($formdata->courseids)) {
595 $limitcourseids = $formdata->courseids;
596 }
597
db48207e
DM
598 // Clears previous query errors.
599 $this->engine->clear_query_error();
600
427e3cbc 601 $areascontexts = $this->get_areas_user_accesses($limitcourseids);
db48207e
DM
602 if (!$areascontexts) {
603 // User can not access any context.
604 $docs = array();
605 } else {
053118a1 606 $docs = $this->engine->execute_query($formdata, $areascontexts, $limit);
db48207e
DM
607 }
608
db48207e
DM
609 return $docs;
610 }
611
db48207e
DM
612 /**
613 * Merge separate index segments into one.
614 */
615 public function optimize_index() {
616 $this->engine->optimize();
617 }
618
619 /**
620 * Index all documents.
621 *
622 * @param bool $fullindex Whether we should reindex everything or not.
67d64795 623 * @param float $timelimit Time limit in seconds (0 = no time limit)
624 * @param \progress_trace $progress Optional class for tracking progress
db48207e
DM
625 * @throws \moodle_exception
626 * @return bool Whether there was any updated document or not.
627 */
67d64795 628 public function index($fullindex = false, $timelimit = 0, \progress_trace $progress = null) {
629 // Cannot combine time limit with reindex.
630 if ($timelimit && $fullindex) {
631 throw new \coding_exception('Cannot apply time limit when reindexing');
632 }
633 if (!$progress) {
634 $progress = new \null_progress_trace();
635 }
db48207e
DM
636
637 // Unlimited time.
638 \core_php_time_limit::raise();
639
075fa912
EM
640 // Notify the engine that an index starting.
641 $this->engine->index_starting($fullindex);
642
bf2235bb 643 $sumdocs = 0;
db48207e
DM
644
645 $searchareas = $this->get_search_areas_list(true);
67d64795 646
647 if ($timelimit) {
648 // If time is limited (and therefore we're not just indexing everything anyway), select
649 // an order for search areas. The intention here is to avoid a situation where a new
650 // large search area is enabled, and this means all our other search areas go out of
651 // date while that one is being indexed. To do this, we order by the time we spent
652 // indexing them last time we ran, meaning anything that took a very long time will be
653 // done last.
654 uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) {
655 return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration();
656 });
657
658 // Decide time to stop.
659 $stopat = microtime(true) + $timelimit;
660 }
661
db48207e
DM
662 foreach ($searchareas as $areaid => $searcharea) {
663
67d64795 664 $progress->output('Processing area: ' . $searcharea->get_visible_name());
db48207e 665
075fa912
EM
666 // Notify the engine that an area is starting.
667 $this->engine->area_index_starting($searcharea, $fullindex);
668
db48207e 669 $indexingstart = time();
0a9a10f0 670 $elapsed = microtime(true);
db48207e
DM
671
672 // This is used to store this component config.
673 list($componentconfigname, $varname) = $searcharea->get_config_var_name();
674
091973db
EM
675 $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
676
db48207e 677 if ($fullindex === true) {
091973db 678 $referencestarttime = 0;
db48207e 679 } else {
67d64795 680 $partial = get_config($componentconfigname, $varname . '_partial');
681 if ($partial) {
682 // When the previous index did not complete all data, we start from the time of the
683 // last document that was successfully indexed. (Note this will result in
684 // re-indexing that one document, but we can't avoid that because there may be
685 // other documents in the same second.)
686 $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun'));
687 } else {
688 $referencestarttime = $prevtimestart;
689 }
db48207e
DM
690 }
691
692 // Getting the recordset from the area.
091973db 693 $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime);
db48207e
DM
694
695 // Pass get_document as callback.
091973db
EM
696 $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing();
697 $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart);
67d64795 698 if ($timelimit) {
699 $options['stopat'] = $stopat;
700 }
2d2fcc1c 701 $iterator = new skip_future_documents_iterator(new \core\dml\recordset_walk(
702 $recordset, array($searcharea, 'get_document'), $options));
67d64795 703 $result = $this->engine->add_documents($iterator, $searcharea, $options);
2d2fcc1c 704 $recordset->close();
67d64795 705 if (count($result) === 5) {
706 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
707 } else {
708 // Backward compatibility for engines that don't support partial adding.
709 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result;
710 debugging('engine::add_documents() should return $partial (4-value return is deprecated)',
711 DEBUG_DEVELOPER);
712 $partial = false;
713 }
714
715 if ($numdocs > 0) {
716 $elapsed = round((microtime(true) - $elapsed), 3);
717 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
718 ' documents, in ' . $elapsed . ' seconds' .
719 ($partial ? ' (not complete)' : '') . '.', 1);
720 } else {
721 $progress->output('No new documents to index.', 1);
db48207e
DM
722 }
723
075fa912
EM
724 // Notify the engine this area is complete, and only mark times if true.
725 if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) {
726 $sumdocs += $numdocs;
727
67d64795 728 // Store last index run once documents have been committed to the search engine.
075fa912
EM
729 set_config($varname . '_indexingstart', $indexingstart, $componentconfigname);
730 set_config($varname . '_indexingend', time(), $componentconfigname);
731 set_config($varname . '_docsignored', $numdocsignored, $componentconfigname);
732 set_config($varname . '_docsprocessed', $numdocs, $componentconfigname);
733 set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname);
734 if ($lastindexeddoc > 0) {
735 set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname);
736 }
67d64795 737 if ($partial) {
738 set_config($varname . '_partial', 1, $componentconfigname);
739 } else {
740 unset_config($varname . '_partial', $componentconfigname);
741 }
742 } else {
743 $progress->output('Engine reported error.');
744 }
745
746 if ($timelimit && (microtime(true) >= $stopat)) {
747 $progress->output('Stopping indexing due to time limit.');
748 break;
db48207e
DM
749 }
750 }
751
bf2235bb 752 if ($sumdocs > 0) {
db48207e
DM
753 $event = \core\event\search_indexed::create(
754 array('context' => \context_system::instance()));
755 $event->trigger();
756 }
757
bf2235bb
EM
758 $this->engine->index_complete($sumdocs, $fullindex);
759
760 return (bool)$sumdocs;
db48207e
DM
761 }
762
763 /**
764 * Resets areas config.
765 *
766 * @throws \moodle_exception
767 * @param string $areaid
768 * @return void
769 */
770 public function reset_config($areaid = false) {
771
772 if (!empty($areaid)) {
773 $searchareas = array();
774 if (!$searchareas[$areaid] = static::get_search_area($areaid)) {
775 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid);
776 }
777 } else {
778 // Only the enabled ones.
779 $searchareas = static::get_search_areas_list(true);
780 }
781
782 foreach ($searchareas as $searcharea) {
69d66020
DM
783 list($componentname, $varname) = $searcharea->get_config_var_name();
784 $config = $searcharea->get_config();
db48207e 785
69d66020
DM
786 foreach ($config as $key => $value) {
787 // We reset them all but the enable/disabled one.
788 if ($key !== $varname . '_enabled') {
789 set_config($key, 0, $componentname);
790 }
791 }
db48207e
DM
792 }
793 }
794
795 /**
796 * Deletes an area's documents or all areas documents.
797 *
798 * @param string $areaid The area id or false for all
799 * @return void
800 */
801 public function delete_index($areaid = false) {
802 if (!empty($areaid)) {
803 $this->engine->delete($areaid);
804 $this->reset_config($areaid);
805 } else {
806 $this->engine->delete();
807 $this->reset_config();
808 }
db48207e
DM
809 }
810
811 /**
812 * Deletes index by id.
813 *
814 * @param int Solr Document string $id
815 */
816 public function delete_index_by_id($id) {
817 $this->engine->delete_by_id($id);
db48207e
DM
818 }
819
820 /**
821 * Returns search areas configuration.
822 *
0bd8383a 823 * @param \core_search\base[] $searchareas
db48207e
DM
824 * @return \stdClass[] $configsettings
825 */
826 public function get_areas_config($searchareas) {
827
67d64795 828 $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored',
829 'docsprocessed', 'recordsprocessed', 'partial');
db48207e 830
0a9a10f0 831 $configsettings = [];
db48207e
DM
832 foreach ($searchareas as $searcharea) {
833
834 $areaid = $searcharea->get_area_id();
835
836 $configsettings[$areaid] = new \stdClass();
837 list($componentname, $varname) = $searcharea->get_config_var_name();
838
839 if (!$searcharea->is_enabled()) {
840 // We delete all indexed data on disable so no info.
841 foreach ($vars as $var) {
842 $configsettings[$areaid]->{$var} = 0;
843 }
844 } else {
845 foreach ($vars as $var) {
846 $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var);
847 }
848 }
849
850 // Formatting the time.
851 if (!empty($configsettings[$areaid]->lastindexrun)) {
852 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun);
853 } else {
854 $configsettings[$areaid]->lastindexrun = get_string('never');
855 }
856 }
857 return $configsettings;
858 }
396d6f0a 859
e71061a2
DM
860 /**
861 * Triggers search_results_viewed event
862 *
863 * Other data required:
864 * - q: The query string
865 * - page: The page number
866 * - title: Title filter
867 * - areaids: Search areas filter
868 * - courseids: Courses filter
869 * - timestart: Time start filter
870 * - timeend: Time end filter
871 *
872 * @since Moodle 3.2
873 * @param array $other Other info for the event.
874 * @return \core\event\search_results_viewed
875 */
876 public static function trigger_search_results_viewed($other) {
877 $event = \core\event\search_results_viewed::create([
878 'context' => \context_system::instance(),
879 'other' => $other
880 ]);
881 $event->trigger();
882
883 return $event;
884 }
885
396d6f0a
DG
886 /**
887 * Checks whether a classname is of an actual search area.
888 *
f3d38863 889 * @param string $classname
396d6f0a
DG
890 * @return bool
891 */
f3d38863
DM
892 protected static function is_search_area($classname) {
893 if (is_subclass_of($classname, 'core_search\base')) {
894 return (new \ReflectionClass($classname))->isInstantiable();
396d6f0a
DG
895 }
896
897 return false;
898 }
db48207e 899}