MDL-59926 core_search: Allow Behat testing of results screens
[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.
382 $courses = enrol_get_my_courses(array('id', 'cacherev'));
427e3cbc
EM
383
384 if (empty($limitcourseids) || in_array(SITEID, $limitcourseids)) {
385 $courses[SITEID] = get_course(SITEID);
386 }
387
a96faa49 388 // Keep a list of included course context ids (needed for the block calculation below).
389 $coursecontextids = [];
390
db48207e 391 foreach ($courses as $course) {
427e3cbc
EM
392 if (!empty($limitcourseids) && !in_array($course->id, $limitcourseids)) {
393 // Skip non-included courses.
394 continue;
395 }
db48207e 396
a96faa49 397 $coursecontext = \context_course::instance($course->id);
398 $coursecontextids[] = $coursecontext->id;
399
db48207e
DM
400 // Info about the course modules.
401 $modinfo = get_fast_modinfo($course);
402
403 if (!empty($areasbylevel[CONTEXT_COURSE])) {
404 // Add the course contexts the user can view.
db48207e
DM
405 foreach ($areasbylevel[CONTEXT_COURSE] as $areaid => $searchclass) {
406 if ($course->visible || has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
407 $areascontexts[$areaid][$coursecontext->id] = $coursecontext->id;
408 }
409 }
410 }
411
412 if (!empty($areasbylevel[CONTEXT_MODULE])) {
413 // Add the module contexts the user can view (cm_info->uservisible).
414
415 foreach ($areasbylevel[CONTEXT_MODULE] as $areaid => $searchclass) {
416
417 // Removing the plugintype 'mod_' prefix.
418 $modulename = substr($searchclass->get_component_name(), 4);
419
420 $modinstances = $modinfo->get_instances_of($modulename);
421 foreach ($modinstances as $modinstance) {
422 if ($modinstance->uservisible) {
423 $areascontexts[$areaid][$modinstance->context->id] = $modinstance->context->id;
424 }
425 }
426 }
427 }
428 }
429
a96faa49 430 // Add all supported block contexts, in a single query for performance.
431 if (!empty($areasbylevel[CONTEXT_BLOCK])) {
432 // Get list of all block types we care about.
433 $blocklist = [];
434 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
435 $blocklist[$searchclass->get_block_name()] = true;
436 }
437 list ($blocknamesql, $blocknameparams) = $DB->get_in_or_equal(array_keys($blocklist));
438
439 // Get list of course contexts.
440 list ($contextsql, $contextparams) = $DB->get_in_or_equal($coursecontextids);
441
442 // Query all blocks that are within an included course, and are set to be visible, and
443 // in a supported page type (basically just course view). This query could be
444 // extended (or a second query added) to support blocks that are within a module
445 // context as well, and we could add more page types if required.
446 $blockrecs = $DB->get_records_sql("
447 SELECT x.*, bi.blockname AS blockname, bi.id AS blockinstanceid
448 FROM {block_instances} bi
449 JOIN {context} x ON x.instanceid = bi.id AND x.contextlevel = ?
450 LEFT JOIN {block_positions} bp ON bp.blockinstanceid = bi.id
451 AND bp.contextid = bi.parentcontextid
452 AND bp.pagetype LIKE 'course-view-%'
453 AND bp.subpage = ''
454 AND bp.visible = 0
455 WHERE bi.parentcontextid $contextsql
456 AND bi.blockname $blocknamesql
457 AND bi.subpagepattern IS NULL
458 AND (bi.pagetypepattern = 'site-index'
459 OR bi.pagetypepattern LIKE 'course-view-%'
460 OR bi.pagetypepattern = 'course-*'
461 OR bi.pagetypepattern = '*')
462 AND bp.id IS NULL",
463 array_merge([CONTEXT_BLOCK], $contextparams, $blocknameparams));
464 $blockcontextsbyname = [];
465 foreach ($blockrecs as $blockrec) {
466 if (empty($blockcontextsbyname[$blockrec->blockname])) {
467 $blockcontextsbyname[$blockrec->blockname] = [];
468 }
469 \context_helper::preload_from_record($blockrec);
470 $blockcontextsbyname[$blockrec->blockname][] = \context_block::instance(
471 $blockrec->blockinstanceid);
472 }
473
474 // Add the block contexts the user can view.
475 foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
476 if (empty($blockcontextsbyname[$searchclass->get_block_name()])) {
477 continue;
478 }
479 foreach ($blockcontextsbyname[$searchclass->get_block_name()] as $context) {
480 if (has_capability('moodle/block:view', $context)) {
481 $areascontexts[$areaid][$context->id] = $context->id;
482 }
483 }
484 }
485 }
486
db48207e
DM
487 return $areascontexts;
488 }
489
053118a1
EM
490 /**
491 * Returns requested page of documents plus additional information for paging.
492 *
493 * This function does not perform any kind of security checking for access, the caller code
494 * should check that the current user have moodle/search:query capability.
495 *
496 * If a page is requested that is beyond the last result, the last valid page is returned in
497 * results, and actualpage indicates which page was returned.
498 *
499 * @param stdClass $formdata
500 * @param int $pagenum The 0 based page number.
501 * @return object An object with 3 properties:
502 * results => An array of \core_search\documents for the actual page.
503 * totalcount => Number of records that are possibly available, to base paging on.
504 * actualpage => The actual page returned.
505 */
506 public function paged_search(\stdClass $formdata, $pagenum) {
507 $out = new \stdClass();
508
509 $perpage = static::DISPLAY_RESULTS_PER_PAGE;
510
511 // Make sure we only allow request up to max page.
512 $pagenum = min($pagenum, (static::MAX_RESULTS / $perpage) - 1);
513
514 // Calculate the first and last document number for the current page, 1 based.
515 $mindoc = ($pagenum * $perpage) + 1;
516 $maxdoc = ($pagenum + 1) * $perpage;
517
518 // Get engine documents, up to max.
519 $docs = $this->search($formdata, $maxdoc);
520
521 $resultcount = count($docs);
522 if ($resultcount < $maxdoc) {
523 // This means it couldn't give us results to max, so the count must be the max.
524 $out->totalcount = $resultcount;
525 } else {
526 // Get the possible count reported by engine, and limit to our max.
527 $out->totalcount = $this->engine->get_query_total_count();
528 $out->totalcount = min($out->totalcount, static::MAX_RESULTS);
529 }
530
531 // Determine the actual page.
532 if ($resultcount < $mindoc) {
533 // We couldn't get the min docs for this page, so determine what page we can get.
534 $out->actualpage = floor(($resultcount - 1) / $perpage);
535 } else {
536 $out->actualpage = $pagenum;
537 }
538
539 // Split the results to only return the page.
540 $out->results = array_slice($docs, $out->actualpage * $perpage, $perpage, true);
541
542 return $out;
543 }
544
db48207e
DM
545 /**
546 * Returns documents from the engine based on the data provided.
547 *
69d66020
DM
548 * This function does not perform any kind of security checking, the caller code
549 * should check that the current user have moodle/search:query capability.
550 *
db48207e
DM
551 * It might return the results from the cache instead.
552 *
553 * @param stdClass $formdata
053118a1 554 * @param int $limit The maximum number of documents to return
db48207e
DM
555 * @return \core_search\document[]
556 */
053118a1 557 public function search(\stdClass $formdata, $limit = 0) {
e36eefae 558 // For Behat testing, the search results can be faked using a special step.
559 if (defined('BEHAT_SITE_RUNNING')) {
560 $fakeresult = get_config('core_search', 'behat_fakeresult');
561 if ($fakeresult) {
562 // Clear config setting.
563 unset_config('core_search', 'behat_fakeresult');
564
565 // Check query matches expected value.
566 $details = json_decode($fakeresult);
567 if ($formdata->q !== $details->query) {
568 throw new \coding_exception('Unexpected search query: ' . $formdata->q);
569 }
570
571 // Create search documents from the JSON data.
572 $docs = [];
573 foreach ($details->results as $result) {
574 $doc = new \core_search\document($result->itemid, $result->componentname,
575 $result->areaname);
576 foreach ((array)$result->fields as $field => $value) {
577 $doc->set($field, $value);
578 }
579 foreach ((array)$result->extrafields as $field => $value) {
580 $doc->set_extra($field, $value);
581 }
582 $area = $this->get_search_area($doc->get('areaid'));
583 $doc->set_doc_url($area->get_doc_url($doc));
584 $doc->set_context_url($area->get_context_url($doc));
585 $docs[] = $doc;
586 }
587
588 return $docs;
589 }
590 }
db48207e 591
427e3cbc
EM
592 $limitcourseids = false;
593 if (!empty($formdata->courseids)) {
594 $limitcourseids = $formdata->courseids;
595 }
596
db48207e
DM
597 // Clears previous query errors.
598 $this->engine->clear_query_error();
599
427e3cbc 600 $areascontexts = $this->get_areas_user_accesses($limitcourseids);
db48207e
DM
601 if (!$areascontexts) {
602 // User can not access any context.
603 $docs = array();
604 } else {
053118a1 605 $docs = $this->engine->execute_query($formdata, $areascontexts, $limit);
db48207e
DM
606 }
607
db48207e
DM
608 return $docs;
609 }
610
db48207e
DM
611 /**
612 * Merge separate index segments into one.
613 */
614 public function optimize_index() {
615 $this->engine->optimize();
616 }
617
618 /**
619 * Index all documents.
620 *
621 * @param bool $fullindex Whether we should reindex everything or not.
67d64795 622 * @param float $timelimit Time limit in seconds (0 = no time limit)
623 * @param \progress_trace $progress Optional class for tracking progress
db48207e
DM
624 * @throws \moodle_exception
625 * @return bool Whether there was any updated document or not.
626 */
67d64795 627 public function index($fullindex = false, $timelimit = 0, \progress_trace $progress = null) {
628 // Cannot combine time limit with reindex.
629 if ($timelimit && $fullindex) {
630 throw new \coding_exception('Cannot apply time limit when reindexing');
631 }
632 if (!$progress) {
633 $progress = new \null_progress_trace();
634 }
db48207e
DM
635
636 // Unlimited time.
637 \core_php_time_limit::raise();
638
075fa912
EM
639 // Notify the engine that an index starting.
640 $this->engine->index_starting($fullindex);
641
bf2235bb 642 $sumdocs = 0;
db48207e
DM
643
644 $searchareas = $this->get_search_areas_list(true);
67d64795 645
646 if ($timelimit) {
647 // If time is limited (and therefore we're not just indexing everything anyway), select
648 // an order for search areas. The intention here is to avoid a situation where a new
649 // large search area is enabled, and this means all our other search areas go out of
650 // date while that one is being indexed. To do this, we order by the time we spent
651 // indexing them last time we ran, meaning anything that took a very long time will be
652 // done last.
653 uasort($searchareas, function(\core_search\base $area1, \core_search\base $area2) {
654 return (int)$area1->get_last_indexing_duration() - (int)$area2->get_last_indexing_duration();
655 });
656
657 // Decide time to stop.
658 $stopat = microtime(true) + $timelimit;
659 }
660
db48207e
DM
661 foreach ($searchareas as $areaid => $searcharea) {
662
67d64795 663 $progress->output('Processing area: ' . $searcharea->get_visible_name());
db48207e 664
075fa912
EM
665 // Notify the engine that an area is starting.
666 $this->engine->area_index_starting($searcharea, $fullindex);
667
db48207e 668 $indexingstart = time();
0a9a10f0 669 $elapsed = microtime(true);
db48207e
DM
670
671 // This is used to store this component config.
672 list($componentconfigname, $varname) = $searcharea->get_config_var_name();
673
091973db
EM
674 $prevtimestart = intval(get_config($componentconfigname, $varname . '_indexingstart'));
675
db48207e 676 if ($fullindex === true) {
091973db 677 $referencestarttime = 0;
db48207e 678 } else {
67d64795 679 $partial = get_config($componentconfigname, $varname . '_partial');
680 if ($partial) {
681 // When the previous index did not complete all data, we start from the time of the
682 // last document that was successfully indexed. (Note this will result in
683 // re-indexing that one document, but we can't avoid that because there may be
684 // other documents in the same second.)
685 $referencestarttime = intval(get_config($componentconfigname, $varname . '_lastindexrun'));
686 } else {
687 $referencestarttime = $prevtimestart;
688 }
db48207e
DM
689 }
690
691 // Getting the recordset from the area.
091973db 692 $recordset = $searcharea->get_recordset_by_timestamp($referencestarttime);
db48207e
DM
693
694 // Pass get_document as callback.
091973db
EM
695 $fileindexing = $this->engine->file_indexing_enabled() && $searcharea->uses_file_indexing();
696 $options = array('indexfiles' => $fileindexing, 'lastindexedtime' => $prevtimestart);
67d64795 697 if ($timelimit) {
698 $options['stopat'] = $stopat;
699 }
091973db 700 $iterator = new \core\dml\recordset_walk($recordset, array($searcharea, 'get_document'), $options);
67d64795 701 $result = $this->engine->add_documents($iterator, $searcharea, $options);
702 if (count($result) === 5) {
703 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc, $partial) = $result;
704 } else {
705 // Backward compatibility for engines that don't support partial adding.
706 list($numrecords, $numdocs, $numdocsignored, $lastindexeddoc) = $result;
707 debugging('engine::add_documents() should return $partial (4-value return is deprecated)',
708 DEBUG_DEVELOPER);
709 $partial = false;
710 }
711
712 if ($numdocs > 0) {
713 $elapsed = round((microtime(true) - $elapsed), 3);
714 $progress->output('Processed ' . $numrecords . ' records containing ' . $numdocs .
715 ' documents, in ' . $elapsed . ' seconds' .
716 ($partial ? ' (not complete)' : '') . '.', 1);
717 } else {
718 $progress->output('No new documents to index.', 1);
db48207e
DM
719 }
720
075fa912
EM
721 // Notify the engine this area is complete, and only mark times if true.
722 if ($this->engine->area_index_complete($searcharea, $numdocs, $fullindex)) {
723 $sumdocs += $numdocs;
724
67d64795 725 // Store last index run once documents have been committed to the search engine.
075fa912
EM
726 set_config($varname . '_indexingstart', $indexingstart, $componentconfigname);
727 set_config($varname . '_indexingend', time(), $componentconfigname);
728 set_config($varname . '_docsignored', $numdocsignored, $componentconfigname);
729 set_config($varname . '_docsprocessed', $numdocs, $componentconfigname);
730 set_config($varname . '_recordsprocessed', $numrecords, $componentconfigname);
731 if ($lastindexeddoc > 0) {
732 set_config($varname . '_lastindexrun', $lastindexeddoc, $componentconfigname);
733 }
67d64795 734 if ($partial) {
735 set_config($varname . '_partial', 1, $componentconfigname);
736 } else {
737 unset_config($varname . '_partial', $componentconfigname);
738 }
739 } else {
740 $progress->output('Engine reported error.');
741 }
742
743 if ($timelimit && (microtime(true) >= $stopat)) {
744 $progress->output('Stopping indexing due to time limit.');
745 break;
db48207e
DM
746 }
747 }
748
bf2235bb 749 if ($sumdocs > 0) {
db48207e
DM
750 $event = \core\event\search_indexed::create(
751 array('context' => \context_system::instance()));
752 $event->trigger();
753 }
754
bf2235bb
EM
755 $this->engine->index_complete($sumdocs, $fullindex);
756
757 return (bool)$sumdocs;
db48207e
DM
758 }
759
760 /**
761 * Resets areas config.
762 *
763 * @throws \moodle_exception
764 * @param string $areaid
765 * @return void
766 */
767 public function reset_config($areaid = false) {
768
769 if (!empty($areaid)) {
770 $searchareas = array();
771 if (!$searchareas[$areaid] = static::get_search_area($areaid)) {
772 throw new \moodle_exception('errorareanotavailable', 'search', '', $areaid);
773 }
774 } else {
775 // Only the enabled ones.
776 $searchareas = static::get_search_areas_list(true);
777 }
778
779 foreach ($searchareas as $searcharea) {
69d66020
DM
780 list($componentname, $varname) = $searcharea->get_config_var_name();
781 $config = $searcharea->get_config();
db48207e 782
69d66020
DM
783 foreach ($config as $key => $value) {
784 // We reset them all but the enable/disabled one.
785 if ($key !== $varname . '_enabled') {
786 set_config($key, 0, $componentname);
787 }
788 }
db48207e
DM
789 }
790 }
791
792 /**
793 * Deletes an area's documents or all areas documents.
794 *
795 * @param string $areaid The area id or false for all
796 * @return void
797 */
798 public function delete_index($areaid = false) {
799 if (!empty($areaid)) {
800 $this->engine->delete($areaid);
801 $this->reset_config($areaid);
802 } else {
803 $this->engine->delete();
804 $this->reset_config();
805 }
db48207e
DM
806 }
807
808 /**
809 * Deletes index by id.
810 *
811 * @param int Solr Document string $id
812 */
813 public function delete_index_by_id($id) {
814 $this->engine->delete_by_id($id);
db48207e
DM
815 }
816
817 /**
818 * Returns search areas configuration.
819 *
0bd8383a 820 * @param \core_search\base[] $searchareas
db48207e
DM
821 * @return \stdClass[] $configsettings
822 */
823 public function get_areas_config($searchareas) {
824
67d64795 825 $vars = array('indexingstart', 'indexingend', 'lastindexrun', 'docsignored',
826 'docsprocessed', 'recordsprocessed', 'partial');
db48207e 827
0a9a10f0 828 $configsettings = [];
db48207e
DM
829 foreach ($searchareas as $searcharea) {
830
831 $areaid = $searcharea->get_area_id();
832
833 $configsettings[$areaid] = new \stdClass();
834 list($componentname, $varname) = $searcharea->get_config_var_name();
835
836 if (!$searcharea->is_enabled()) {
837 // We delete all indexed data on disable so no info.
838 foreach ($vars as $var) {
839 $configsettings[$areaid]->{$var} = 0;
840 }
841 } else {
842 foreach ($vars as $var) {
843 $configsettings[$areaid]->{$var} = get_config($componentname, $varname .'_' . $var);
844 }
845 }
846
847 // Formatting the time.
848 if (!empty($configsettings[$areaid]->lastindexrun)) {
849 $configsettings[$areaid]->lastindexrun = userdate($configsettings[$areaid]->lastindexrun);
850 } else {
851 $configsettings[$areaid]->lastindexrun = get_string('never');
852 }
853 }
854 return $configsettings;
855 }
396d6f0a 856
e71061a2
DM
857 /**
858 * Triggers search_results_viewed event
859 *
860 * Other data required:
861 * - q: The query string
862 * - page: The page number
863 * - title: Title filter
864 * - areaids: Search areas filter
865 * - courseids: Courses filter
866 * - timestart: Time start filter
867 * - timeend: Time end filter
868 *
869 * @since Moodle 3.2
870 * @param array $other Other info for the event.
871 * @return \core\event\search_results_viewed
872 */
873 public static function trigger_search_results_viewed($other) {
874 $event = \core\event\search_results_viewed::create([
875 'context' => \context_system::instance(),
876 'other' => $other
877 ]);
878 $event->trigger();
879
880 return $event;
881 }
882
396d6f0a
DG
883 /**
884 * Checks whether a classname is of an actual search area.
885 *
f3d38863 886 * @param string $classname
396d6f0a
DG
887 * @return bool
888 */
f3d38863
DM
889 protected static function is_search_area($classname) {
890 if (is_subclass_of($classname, 'core_search\base')) {
891 return (new \ReflectionClass($classname))->isInstantiable();
396d6f0a
DG
892 }
893
894 return false;
895 }
db48207e 896}