diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php
index 43909fba34690..f225b0a901fe5 100644
--- a/admin/settings/plugins.php
+++ b/admin/settings/plugins.php
@@ -751,6 +751,12 @@
$ADMIN->add('searchplugins', new admin_externalpage('searchareas', new lang_string('searchareas', 'admin'),
new moodle_url('/admin/searchareas.php')));
+ // Only add reindex page if search indexing is enabled.
+ if (\core_search\manager::is_indexing_enabled()) {
+ $ADMIN->add('searchplugins', new admin_externalpage('searchreindex', new lang_string('reindexcourse', 'search'),
+ new moodle_url('/search/reindex.php')));
+ }
+
core_collator::asort_objects_by_property($pages, 'visiblename');
foreach ($pages as $page) {
$ADMIN->add('searchplugins', $page);
diff --git a/lang/en/search.php b/lang/en/search.php
index 540b6184b05d3..ef4c6ff2544de 100644
--- a/lang/en/search.php
+++ b/lang/en/search.php
@@ -105,6 +105,8 @@
$string['progress'] = 'Progress';
$string['queryerror'] = 'The query you provided could not be parsed by the search engine: {$a}';
$string['queueheading'] = 'Additional indexing queue ({$a} items)';
+$string['reindexcourse'] = 'Reindex course or activity';
+$string['reindexcourse_info'] = 'If a course or activity gives incomplete search results, you can use this form to reindex it. This is not necessary as part of normal operation.';
$string['resultsreturnedfor'] = 'results returned for';
$string['runindexer'] = 'Run indexer (real)';
$string['runindexertest'] = 'Run indexer test';
diff --git a/lib/db/services.php b/lib/db/services.php
index 1886f7de5976a..751a2a9a3177a 100644
--- a/lib/db/services.php
+++ b/lib/db/services.php
@@ -1752,6 +1752,12 @@
'type' => 'read',
'ajax' => true
),
+ 'core_search_get_course_activities' => [
+ 'classname' => '\core_search\external\get_course_activities',
+ 'description' => 'Gets activities on a particular course',
+ 'type' => 'read',
+ 'ajax' => true
+ ],
'core_search_get_results' => [
'classname' => '\core_search\external\get_results',
'description' => 'Get search results.',
diff --git a/search/amd/build/activityselector.min.js b/search/amd/build/activityselector.min.js
new file mode 100644
index 0000000000000..61c2b960da607
--- /dev/null
+++ b/search/amd/build/activityselector.min.js
@@ -0,0 +1,10 @@
+define("core_search/activityselector",["exports","core/ajax"],(function(_exports,_ajax){var obj;
+/**
+ * Activity selector for the reindex form.
+ *
+ * @module core_search/activityselector
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.processResults=function(selector,results){const output=[];for(let result of results)output.push({value:result.cmid,label:result.name});return output},_exports.transport=async function(selector,query,success,failure){const courseSelection=document.querySelector(selector).closest("form").querySelector("select[name=courseid] ~ .form-autocomplete-selection"),courseId=parseInt(courseSelection.dataset.activeValue);try{const response=await _ajax.default.call([{methodname:"core_search_get_course_activities",args:{courseid:courseId,query:query}}]);success(response)}catch(e){failure(e)}},_ajax=(obj=_ajax)&&obj.__esModule?obj:{default:obj}}));
+
+//# sourceMappingURL=activityselector.min.js.map
\ No newline at end of file
diff --git a/search/amd/build/activityselector.min.js.map b/search/amd/build/activityselector.min.js.map
new file mode 100644
index 0000000000000..6d5e9ae478965
--- /dev/null
+++ b/search/amd/build/activityselector.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"activityselector.min.js","sources":["../src/activityselector.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Activity selector for the reindex form.\n *\n * @module core_search/activityselector\n * @copyright 2024 The Open University\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\n\n/**\n * Obtains list of activities for Ajax autocomplete element.\n *\n * @param {String} selector Selector of the element\n * @param {String} query The query string\n * @param {Function} success Callback function to be called with an array of results\n * @param {Function} failure Callback to be called in case of failure, with error message\n */\nexport async function transport(selector, query, success, failure) {\n // Get course id.\n const element = document.querySelector(selector);\n const courseSelection = element.closest('form').querySelector(\n 'select[name=courseid] ~ .form-autocomplete-selection');\n const courseId = parseInt(courseSelection.dataset.activeValue);\n\n // Do AJAX request to list activities on course matching query.\n try {\n const response = await Ajax.call([{methodname: 'core_search_get_course_activities', args: {\n courseid: courseId,\n query: query\n }}]);\n success(response);\n } catch (e) {\n failure(e);\n }\n}\n\n/**\n * Processes results for Ajax autocomplete element.\n *\n * @param {String} selector Selector of the element\n * @param {Array} results Array of results\n * @return {Object[]} Array of results with 'value' and 'label' fields\n */\nexport function processResults(selector, results) {\n const output = [];\n for (let result of results) {\n output.push({value: result.cmid, label: result.name});\n }\n return output;\n}\n"],"names":["selector","results","output","result","push","value","cmid","label","name","query","success","failure","courseSelection","document","querySelector","closest","courseId","parseInt","dataset","activeValue","response","Ajax","call","methodname","args","courseid","e"],"mappings":";;;;;;;8FA2D+BA,SAAUC,eAC/BC,OAAS,OACV,IAAIC,UAAUF,QACfC,OAAOE,KAAK,CAACC,MAAOF,OAAOG,KAAMC,MAAOJ,OAAOK,cAE5CN,0CA/BqBF,SAAUS,MAAOC,QAASC,eAGhDC,gBADUC,SAASC,cAAcd,UACPe,QAAQ,QAAQD,cAC5C,wDACEE,SAAWC,SAASL,gBAAgBM,QAAQC,uBAIxCC,eAAiBC,cAAKC,KAAK,CAAC,CAACC,WAAY,oCAAqCC,KAAM,CACtFC,SAAUT,SACVP,MAAOA,UAEXC,QAAQU,UACV,MAAOM,GACLf,QAAQe"}
\ No newline at end of file
diff --git a/search/amd/src/activityselector.js b/search/amd/src/activityselector.js
new file mode 100644
index 0000000000000..00ee286158c6d
--- /dev/null
+++ b/search/amd/src/activityselector.js
@@ -0,0 +1,66 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Activity selector for the reindex form.
+ *
+ * @module core_search/activityselector
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Ajax from 'core/ajax';
+
+/**
+ * Obtains list of activities for Ajax autocomplete element.
+ *
+ * @param {String} selector Selector of the element
+ * @param {String} query The query string
+ * @param {Function} success Callback function to be called with an array of results
+ * @param {Function} failure Callback to be called in case of failure, with error message
+ */
+export async function transport(selector, query, success, failure) {
+ // Get course id.
+ const element = document.querySelector(selector);
+ const courseSelection = element.closest('form').querySelector(
+ 'select[name=courseid] ~ .form-autocomplete-selection');
+ const courseId = parseInt(courseSelection.dataset.activeValue);
+
+ // Do AJAX request to list activities on course matching query.
+ try {
+ const response = await Ajax.call([{methodname: 'core_search_get_course_activities', args: {
+ courseid: courseId,
+ query: query
+ }}]);
+ success(response);
+ } catch (e) {
+ failure(e);
+ }
+}
+
+/**
+ * Processes results for Ajax autocomplete element.
+ *
+ * @param {String} selector Selector of the element
+ * @param {Array} results Array of results
+ * @return {Object[]} Array of results with 'value' and 'label' fields
+ */
+export function processResults(selector, results) {
+ const output = [];
+ for (let result of results) {
+ output.push({value: result.cmid, label: result.name});
+ }
+ return output;
+}
diff --git a/search/classes/external/get_course_activities.php b/search/classes/external/get_course_activities.php
new file mode 100644
index 0000000000000..d87f7e6e1191c
--- /dev/null
+++ b/search/classes/external/get_course_activities.php
@@ -0,0 +1,104 @@
+.
+
+namespace core_search\external;
+
+use core_external\external_api;
+use core_external\external_function_parameters;
+use core_external\external_single_structure;
+use core_external\external_multiple_structure;
+use core_external\external_value;
+
+/**
+ * External function for listing activities on a course.
+ *
+ * @package core_search
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_course_activities extends external_api {
+
+ /**
+ * Webservice parameters.
+ *
+ * @return external_function_parameters
+ */
+ public static function execute_parameters(): external_function_parameters {
+ return new external_function_parameters(
+ [
+ 'courseid' => new external_value(PARAM_INT, 'Course id'),
+ 'query' => new external_value(PARAM_NOTAGS, 'Optional query (blank = all activities)'),
+ ]
+ );
+ }
+
+ /**
+ * Webservice returns.
+ *
+ * @return external_multiple_structure
+ */
+ public static function execute_returns(): external_multiple_structure {
+ return new external_multiple_structure(new external_single_structure([
+ 'cmid' => new external_value(PARAM_INT, 'Course-module id'),
+ 'name' => new external_value(PARAM_INT, 'Activity name e.g. "General discussion forum"'),
+ 'modname' => new external_value(PARAM_ALPHANUMEXT, 'Module name e.g. "forum"'),
+ ]));
+ }
+
+ /**
+ * Gets activities on a course.
+ *
+ * @param int $courseid Course id
+ * @param string $query Optional search query ('' = all)
+ * @return array List of activities
+ */
+ public static function execute(int $courseid, string $query): array {
+ global $PAGE;
+
+ ['courseid' => $courseid, 'query' => $query] = self::validate_parameters(
+ self::execute_parameters(),
+ [
+ 'courseid' => $courseid,
+ 'query' => $query,
+ ]
+ );
+
+ // Get the context. This should ensure that user is allowed to access the course.
+ $context = \context_course::instance($courseid);
+ external_api::validate_context($context);
+
+ // Get details for all activities in course.
+ $modinfo = get_fast_modinfo($courseid);
+ $results = [];
+ $lowerquery = \core_text::strtolower($query);
+ foreach ($modinfo->get_cms() as $cm) {
+ // When there is a query, skip activities that don't match it.
+ if ($lowerquery !== '') {
+ $lowername = \core_text::strtolower($cm->name);
+ if (strpos($lowername, $lowerquery) === false) {
+ continue;
+ }
+ }
+ $results[] = (object)[
+ 'cmid' => $cm->id,
+ 'name' => $cm->name,
+ 'modname' => $cm->modname,
+ ];
+ }
+
+ return $results;
+ }
+}
diff --git a/search/classes/form/reindex.php b/search/classes/form/reindex.php
new file mode 100644
index 0000000000000..21d0bd4a205a0
--- /dev/null
+++ b/search/classes/form/reindex.php
@@ -0,0 +1,56 @@
+.
+
+/**
+ * Form to reindex a course or activity.
+ *
+ * @package core_search
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_search\form;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/formslib.php');
+
+/**
+ * Form to reindex a course or activity.
+ *
+ * @package core_search
+ */
+class reindex extends \moodleform {
+ /**
+ * Defines form contents.
+ */
+ public function definition() {
+ $mform = $this->_form;
+
+ $mform->addElement('static', 'info', '', get_string('reindexcourse_info', 'search'));
+
+ $mform->addElement('course', 'courseid', get_string('course'));
+ $mform->addRule('courseid', null, 'required', null, 'client');
+
+// $options = [
+// 'ajax' => 'core_search/activityselector'
+// ];
+// $mform->addElement('autocomplete', 'cmid', get_string('activity'), [], $options);
+// $mform->disabledIf('cmid', 'courseid', 'eq', 0);
+
+ $mform->addElement('submit', 'reindex', get_string('reindexcourse', 'search'));
+ }
+}
diff --git a/search/reindex.php b/search/reindex.php
new file mode 100644
index 0000000000000..e40de02ef607b
--- /dev/null
+++ b/search/reindex.php
@@ -0,0 +1,46 @@
+.
+
+/**
+ * Manually request a reindex of a particular context for cases when the index is incorrect.
+ *
+ * @package core_search
+ * @copyright 2024 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+admin_externalpage_setup('searchreindex');
+
+$PAGE->set_primary_active_tab('siteadminnode');
+
+$mform = new core_search\form\reindex();
+
+if ($mform->get_data()) {
+
+}
+
+echo $OUTPUT->header();
+
+// Throw an error if search indexing is off - we don't show the page link in that case.
+if (!\core_search\manager::is_indexing_enabled()) {
+ throw new \moodle_exception('notavailable');
+}
+
+echo $mform->render();
+
+echo $OUTPUT->footer();