diff --git a/lang/en/search.php b/lang/en/search.php index 4d515bbb121..21291421386 100644 --- a/lang/en/search.php +++ b/lang/en/search.php @@ -81,6 +81,9 @@ $string['notitle'] = 'No title'; $string['normalsearch'] = 'Normal search'; $string['openedon'] = 'opened on'; $string['optimize'] = 'Optimize'; +$string['order'] = 'Results order'; +$string['order_location'] = 'Prioritise results related to {$a}'; +$string['order_relevance'] = 'Most relevant results first'; $string['priority'] = 'Priority'; $string['priority_reindexing'] = 'Reindexing'; $string['priority_normal'] = 'Normal'; diff --git a/search/classes/engine.php b/search/classes/engine.php index ef3d05bd978..4bbb631b194 100644 --- a/search/classes/engine.php +++ b/search/classes/engine.php @@ -543,4 +543,17 @@ abstract class engine { public function supports_group_filtering() { return false; } + + /** + * Obtain a list of results orders (and names for them) that are supported by this + * search engine in the given context. + * + * By default, engines sort by relevance only. + * + * @param \context $context Context that the user requested search from + * @return array Array from order name => display text + */ + public function get_supported_orders(\context $context) { + return ['relevance' => get_string('order_relevance', 'search')]; + } } diff --git a/search/classes/manager.php b/search/classes/manager.php index 34a93d33435..e80c899e97b 100644 --- a/search/classes/manager.php +++ b/search/classes/manager.php @@ -684,6 +684,8 @@ class manager { * - q (query text) * - courseids (optional list of course ids to restrict) * - contextids (optional list of context ids to restrict) + * - context (Moodle context object for location user searched from) + * - order (optional ordering, one of the types supported by the search engine e.g. 'relevance') * * @param \stdClass $formdata Query input data (usually from search form) * @param int $limit The maximum number of documents to return diff --git a/search/classes/output/form/search.php b/search/classes/output/form/search.php index 3652de78bef..ed6f7fad679 100644 --- a/search/classes/output/form/search.php +++ b/search/classes/output/form/search.php @@ -55,6 +55,15 @@ class search extends \moodleform { $mform->setDefault('searchwithin', ''); } + // If the search engine provides multiple ways to order results, show options. + if (!empty($this->_customdata['orderoptions']) && + count($this->_customdata['orderoptions']) > 1) { + + $mform->addElement('select', 'order', get_string('order', 'search'), + $this->_customdata['orderoptions']); + $mform->setDefault('order', 'relevance'); + } + $mform->addElement('header', 'filtersection', get_string('filterheader', 'search')); $mform->setExpanded('filtersection', false); diff --git a/search/engine/solr/classes/engine.php b/search/engine/solr/classes/engine.php index 9921713056f..112ac927c37 100644 --- a/search/engine/solr/classes/engine.php +++ b/search/engine/solr/classes/engine.php @@ -65,6 +65,12 @@ class engine extends \core_search\engine { */ const HIGHLIGHT_END = '@@HI_E@@'; + /** @var float Boost value for matching course in location-ordered searches */ + const COURSE_BOOST = 1; + + /** @var float Boost value for matching context (in addition to course boost) */ + const CONTEXT_BOOST = 0.5; + /** * @var \SolrClient */ @@ -370,6 +376,16 @@ class engine extends \core_search\engine { $query->addFilterQuery('type:'.\core_search\manager::TYPE_TEXT); } + // If ordering by location, add in boost for the relevant course or context ids. + if (!empty($filters->order) && $filters->order === 'location') { + $coursecontext = $filters->context->get_course_context(); + $query->addBoostQuery('courseid', $coursecontext->instanceid, self::COURSE_BOOST); + if ($filters->context->contextlevel !== CONTEXT_COURSE) { + // If it's a block or activity, also add a boost for the specific context id. + $query->addBoostQuery('contextid', $filters->context->id, self::CONTEXT_BOOST); + } + } + return $query; } @@ -1357,4 +1373,24 @@ class engine extends \core_search\engine { return true; } + + /** + * Solr supports sort by location within course contexts or below. + * + * @param \context $context Context that the user requested search from + * @return array Array from order name => display text + */ + public function get_supported_orders(\context $context) { + $orders = parent::get_supported_orders($context); + + // If not within a course, no other kind of sorting supported. + $coursecontext = $context->get_course_context(false); + if ($coursecontext) { + // Within a course or activity/block, support sort by location. + $orders['location'] = get_string('order_location', 'search', + $context->get_context_name()); + } + + return $orders; + } } diff --git a/search/engine/solr/tests/engine_test.php b/search/engine/solr/tests/engine_test.php index 7a4bcde943d..439d9a79de8 100644 --- a/search/engine/solr/tests/engine_test.php +++ b/search/engine/solr/tests/engine_test.php @@ -950,4 +950,130 @@ class search_solr_engine_testcase extends advanced_testcase { sort($expected); $this->assertEquals($expected, $titles); } + + /** + * Tests the get_supported_orders function for contexts where we can only use relevance + * (system, category). + */ + public function test_get_supported_orders_relevance_only() { + global $DB; + + // System or category context: relevance only. + $orders = $this->engine->get_supported_orders(\context_system::instance()); + $this->assertCount(1, $orders); + $this->assertArrayHasKey('relevance', $orders); + + $categoryid = $DB->get_field_sql('SELECT MIN(id) FROM {course_categories}'); + $orders = $this->engine->get_supported_orders(\context_coursecat::instance($categoryid)); + $this->assertCount(1, $orders); + $this->assertArrayHasKey('relevance', $orders); + } + + /** + * Tests the get_supported_orders function for contexts where we support location as well + * (course, activity, block). + */ + public function test_get_supported_orders_relevance_and_location() { + global $DB; + + // Test with course context. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['fullname' => 'Frogs']); + $coursecontext = \context_course::instance($course->id); + + $orders = $this->engine->get_supported_orders($coursecontext); + $this->assertCount(2, $orders); + $this->assertArrayHasKey('relevance', $orders); + $this->assertArrayHasKey('location', $orders); + $this->assertContains('Course: Frogs', $orders['location']); + + // Test with activity context. + $page = $generator->create_module('page', ['course' => $course->id, 'name' => 'Toads']); + + $orders = $this->engine->get_supported_orders(\context_module::instance($page->cmid)); + $this->assertCount(2, $orders); + $this->assertArrayHasKey('relevance', $orders); + $this->assertArrayHasKey('location', $orders); + $this->assertContains('Page: Toads', $orders['location']); + + // Test with block context. + $instance = (object)['blockname' => 'html', 'parentcontextid' => $coursecontext->id, + 'showinsubcontexts' => 0, 'pagetypepattern' => 'course-view-*', + 'defaultweight' => 0, 'timecreated' => 1, 'timemodified' => 1, + 'configdata' => '']; + $blockid = $DB->insert_record('block_instances', $instance); + $blockcontext = \context_block::instance($blockid); + + $orders = $this->engine->get_supported_orders($blockcontext); + $this->assertCount(2, $orders); + $this->assertArrayHasKey('relevance', $orders); + $this->assertArrayHasKey('location', $orders); + $this->assertContains('Block: HTML', $orders['location']); + } + + /** + * Tests ordering by relevance vs location. + */ + public function test_ordering() { + // Create 2 courses and 2 activities. + $generator = $this->getDataGenerator(); + $course1 = $generator->create_course(['fullname' => 'Course 1']); + $course1context = \context_course::instance($course1->id); + $course1page = $generator->create_module('page', ['course' => $course1]); + $course1pagecontext = \context_module::instance($course1page->cmid); + $course2 = $generator->create_course(['fullname' => 'Course 2']); + $course2context = \context_course::instance($course2->id); + $course2page = $generator->create_module('page', ['course' => $course2]); + $course2pagecontext = \context_module::instance($course2page->cmid); + + // Create one search record in each activity and course. + $this->create_search_record($course1->id, $course1context->id, 'C1', 'Xyzzy'); + $this->create_search_record($course1->id, $course1pagecontext->id, 'C1P', 'Xyzzy'); + $this->create_search_record($course2->id, $course2context->id, 'C2', 'Xyzzy'); + $this->create_search_record($course2->id, $course2pagecontext->id, 'C2P', 'Xyzzy plugh'); + $this->search->index(); + + // Default search works by relevance so the one with both words should be top. + $querydata = new stdClass(); + $querydata->q = 'xyzzy plugh'; + $results = $this->search->search($querydata); + $this->assertCount(4, $results); + $this->assertEquals('C2P', $results[0]->get('title')); + + // Same if you explicitly specify relevance. + $querydata->order = 'relevance'; + $results = $this->search->search($querydata); + $this->assertEquals('C2P', $results[0]->get('title')); + + // If you specify order by location and you are in C2 or C2P then results are the same. + $querydata->order = 'location'; + $querydata->context = $course2context; + $results = $this->search->search($querydata); + $this->assertEquals('C2P', $results[0]->get('title')); + $querydata->context = $course2pagecontext; + $results = $this->search->search($querydata); + $this->assertEquals('C2P', $results[0]->get('title')); + + // But if you are in C1P then you get different results (C1P first). + $querydata->context = $course1pagecontext; + $results = $this->search->search($querydata); + $this->assertEquals('C1P', $results[0]->get('title')); + } + + /** + * Adds a record to the mock search area, so that the search engine can find it later. + * + * @param int $courseid Course id + * @param int $contextid Context id + * @param string $title Title for search index + * @param string $content Content for search index + */ + protected function create_search_record($courseid, $contextid, $title, $content) { + $record = new \stdClass(); + $record->content = $content; + $record->title = $title; + $record->courseid = $courseid; + $record->contextid = $contextid; + $this->generator->create_record($record); + } } diff --git a/search/index.php b/search/index.php index ece070ca488..6ac8c20af35 100644 --- a/search/index.php +++ b/search/index.php @@ -71,6 +71,9 @@ if ($contextid) { } $customdata['searchwithin'] = $searchwithin; } + + // Get available ordering options from search engine. + $customdata['orderoptions'] = $search->get_engine()->get_supported_orders($context); } $mform = new \core_search\output\form\search(null, $customdata); @@ -112,6 +115,11 @@ if ($data && !empty($data->searchwithin)) { } } +// Inform search engine about source context. +if (!empty($context) && $data) { + $data->context = $context; +} + // Set the page URL. $urlparams = array('page' => $page); if ($data) { diff --git a/search/tests/engine_test.php b/search/tests/engine_test.php index d1f566d5a70..cfe8667e136 100644 --- a/search/tests/engine_test.php +++ b/search/tests/engine_test.php @@ -119,4 +119,14 @@ class search_engine_testcase extends advanced_testcase { $updates = $engine->get_and_clear_schema_updates(); $this->assertCount(0, $updates); } + + /** + * Tests the get_supported_orders stub function. + */ + public function test_get_supported_orders() { + $engine = new \mock_search\engine(); + $orders = $engine->get_supported_orders(\context_system::instance()); + $this->assertCount(1, $orders); + $this->assertArrayHasKey('relevance', $orders); + } } diff --git a/search/upgrade.txt b/search/upgrade.txt index ddc5efcc77c..a5228e2eca0 100644 --- a/search/upgrade.txt +++ b/search/upgrade.txt @@ -9,6 +9,10 @@ information provided here is intended especially for developers. contexts first. If not implemented, the default behaviour for modules and blocks is to reindex the newest items first; for other types of search area it will just index the whole system context, oldest data first. +* Search engines may now implement get_supported_orders function to provide multiple ordering + options (other than 'relevance' which is default). If there is more than one order then a choice + will be shown to users. (This is an optional feature, existing search engine plugins do not need + to be modified in order to continue working.) * Module search areas that wish to support group filtering should set the new optional search document field groupid (note: to remain compatible with earlier versions, do this inside an if