diff --git a/filter/glossary/filter.php b/filter/glossary/filter.php index 2c55c8a0e2aea..5098d50327b15 100644 --- a/filter/glossary/filter.php +++ b/filter/glossary/filter.php @@ -48,11 +48,10 @@ public function setup($page, $context) { /** * Get all the concepts for this context. - * @return filterobject[] the concepts, and filterobjects, with an added - * ->conceptid field. + * @return filterobject[] the concepts, and filterobjects. */ protected function get_all_concepts() { - global $CFG, $USER; + global $USER; if ($this->cache === null) { $this->cache = cache::make_from_params(cache_store::MODE_REQUEST, 'filter', 'glossary'); @@ -92,48 +91,16 @@ protected function get_all_concepts() { foreach ($allconcepts as $concepts) { foreach ($concepts as $concept) { - if ($concept->category) { // Link to a category. - $title = get_string('glossarycategory', 'filter_glossary', - ['glossary' => $glossaries[$concept->glossaryid], 'category' => $concept->concept]); - $link = new moodle_url('/mod/glossary/view.php', array('g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id)); - $attributes = array( - 'href' => $link, - 'title' => $title, - 'class' => 'glossary autolink category glossaryid' . $concept->glossaryid); - $conceptid = 0; - - } else { // Link to entry or alias - $title = get_string('glossaryconcept', 'filter_glossary', - ['glossary' => $glossaries[$concept->glossaryid], 'concept' => $concept->concept]); - // Hardcoding dictionary format in the URL rather than defaulting - // to the current glossary format which may not work in a popup. - // for example "entry list" means the popup would only contain - // a link that opens another popup. - $link = new moodle_url('/mod/glossary/showentry.php', array('eid' => $concept->id, 'displayformat' => 'dictionary')); - $attributes = array( - 'href' => $link, - 'title' => str_replace('&', '&', $title), // Undo the s() mangling. - 'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid); - $conceptid = $concept->id; - } - // This flag is optionally set by resource_pluginfile() - // if processing an embedded file use target to prevent getting nested Moodles. - if (!empty($CFG->embeddedsoforcelinktarget)) { - $attributes['target'] = '_top'; - } - $href_tag_begin = html_writer::start_tag('a', $attributes); - - $filterobj = new filterobject($concept->concept, $href_tag_begin, '', - $concept->casesensitive, $concept->fullmatch); - $filterobj->conceptid = $conceptid; - $conceptlist[] = $filterobj; + $conceptlist[] = new filterobject($concept->concept, null, null, + $concept->casesensitive, $concept->fullmatch, null, + [$this, 'filterobject_prepare_replacement_callback'], [$concept, $glossaries]); } } // We sort longest first, so that when we replace the terms, // the longest ones are replaced first. This does the right thing // when you have two terms like 'Moodle' and 'Moodle 3.5'. You want the longest match. - usort($conceptlist, 'filter_glossary::sort_entries_by_length'); + usort($conceptlist, [$this, 'sort_entries_by_length']); $conceptlist = filter_prepare_phrases_for_filtering($conceptlist); @@ -146,6 +113,50 @@ protected function get_all_concepts() { return $conceptlist; } + /** + * Callback used by filterobject / filter_phrases. + * + * @param object $concept the concept that is being replaced (from get_all_concepts). + * @param array $glossaries the list of glossary titles (from get_all_concepts). + * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject. + */ + public function filterobject_prepare_replacement_callback($concept, $glossaries) { + global $CFG; + + if ($concept->category) { // Link to a category. + $title = get_string('glossarycategory', 'filter_glossary', + ['glossary' => $glossaries[$concept->glossaryid], 'category' => $concept->concept]); + $link = new moodle_url('/mod/glossary/view.php', + ['g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id]); + $attributes = array( + 'href' => $link, + 'title' => $title, + 'class' => 'glossary autolink category glossaryid' . $concept->glossaryid); + + } else { // Link to entry or alias. + $title = get_string('glossaryconcept', 'filter_glossary', + ['glossary' => $glossaries[$concept->glossaryid], 'concept' => $concept->concept]); + // Hardcoding dictionary format in the URL rather than defaulting + // to the current glossary format which may not work in a popup. + // for example "entry list" means the popup would only contain + // a link that opens another popup. + $link = new moodle_url('/mod/glossary/showentry.php', + ['eid' => $concept->id, 'displayformat' => 'dictionary']); + $attributes = array( + 'href' => $link, + 'title' => str_replace('&', '&', $title), // Undo the s() mangling. + 'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid); + } + + // This flag is optionally set by resource_pluginfile() + // if processing an embedded file use target to prevent getting nested Moodles. + if (!empty($CFG->embeddedsoforcelinktarget)) { + $attributes['target'] = '_top'; + } + + return [html_writer::start_tag('a', $attributes), '', null]; + } + public function filter($text, array $options = array()) { global $GLOSSARY_EXCLUDEENTRY; @@ -157,7 +168,11 @@ public function filter($text, array $options = array()) { if (!empty($GLOSSARY_EXCLUDEENTRY)) { foreach ($conceptlist as $key => $filterobj) { - if (is_object($filterobj) && $filterobj->conceptid == $GLOSSARY_EXCLUDEENTRY) { + if (!is_object($filterobj)) { + continue; + } + $concept = $filterobj->replacementcallbackdata[0]; + if (!$concept->category && $concept->id == $GLOSSARY_EXCLUDEENTRY) { unset($conceptlist[$key]); } } @@ -170,16 +185,13 @@ public function filter($text, array $options = array()) { return filter_phrases($text, $conceptlist); // Actually search for concepts! } - private static function sort_entries_by_length($entry0, $entry1) { - $len0 = strlen($entry0->phrase); - $len1 = strlen($entry1->phrase); - - if ($len0 < $len1) { - return 1; - } else if ($len0 > $len1) { - return -1; - } else { - return 0; - } + /** + * usort helper used in get_all_concepts above. + * @param filterobject $filterobject0 first item to compare. + * @param filterobject $filterobject1 second item to compare. + * @return int -1, 0 or 1. + */ + private function sort_entries_by_length($filterobject0, $filterobject1) { + return strlen($filterobject1->phrase) <=> strlen($filterobject0->phrase); } } diff --git a/filter/glossary/tests/filter_test.php b/filter/glossary/tests/filter_test.php index 99e1b0935e78c..f22822cf03183 100644 --- a/filter/glossary/tests/filter_test.php +++ b/filter/glossary/tests/filter_test.php @@ -74,6 +74,43 @@ public function test_link_to_entry_with_alias() { $this->assertEquals($glossary->name . ': second alias', $matches[2][2]); } + public function test_longest_link_used() { + global $CFG; + $this->resetAfterTest(true); + + // Enable glossary filter at top level. + filter_set_global_state('glossary', TEXTFILTER_ON); + $CFG->glossary_linkentries = 1; + + // Create a test course. + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + // Create a glossary. + $glossary = $this->getDataGenerator()->create_module('glossary', + array('course' => $course->id, 'mainglossary' => 1)); + + // Create two entries with ampersands and one normal entry. + $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary'); + $shorter = $generator->create_content($glossary, array('concept' => 'Tim')); + $longer = $generator->create_content($glossary, array('concept' => 'Time')); + + // Format text with all three entries in HTML. + $html = '

Time will tell

'; + $filtered = format_text($html, FORMAT_HTML, array('context' => $context)); + + // Find all the glossary links in the result. + $matches = array(); + preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches); + + // There should be 1 glossary link to Time, not Tim. + $this->assertEquals(1, count($matches[1])); + $this->assertEquals($longer->id, $matches[1][0]); + + // Check text of title attribute. + $this->assertEquals($glossary->name . ': Time', $matches[2][0]); + } + public function test_link_to_category() { global $CFG; $this->resetAfterTest(true); diff --git a/lib/filterlib.php b/lib/filterlib.php index cb3fe98a51eab..393fed0da71b3 100644 --- a/lib/filterlib.php +++ b/lib/filterlib.php @@ -506,19 +506,30 @@ class filterobject { * @param bool $fullmatch whether to match complete words. If true, 'T' won't be matched in 'Tim'. * @param mixed $replacementphrase replacement text to go inside begin and end. If not set, * the body of the replacement will be the original phrase. + * @param callback $replacementcallback if set, then this will be called just before + * $hreftagbegin, $hreftagend and $replacementphrase are needed, so they can be computed only if required. + * The call made is + * list($linkobject->hreftagbegin, $linkobject->hreftagend, $linkobject->replacementphrase) = + * call_user_func_array($linkobject->replacementcallback, $linkobject->replacementcallbackdata); + * so the return should be an array [$hreftagbegin, $hreftagend, $replacementphrase], the last of which may be null. + * @param array $replacementcallbackdata data to be passed to $replacementcallback (optional). */ public function __construct($phrase, $hreftagbegin = '', - $hreftagend = '', - $casesensitive = false, - $fullmatch = false, - $replacementphrase = null) { - - $this->phrase = $phrase; - $this->hreftagbegin = $hreftagbegin; - $this->hreftagend = $hreftagend; - $this->casesensitive = !empty($casesensitive); - $this->fullmatch = !empty($fullmatch); - $this->replacementphrase = $replacementphrase; + $hreftagend = '', + $casesensitive = false, + $fullmatch = false, + $replacementphrase = null, + $replacementcallback = null, + array $replacementcallbackdata = null) { + + $this->phrase = $phrase; + $this->hreftagbegin = $hreftagbegin; + $this->hreftagend = $hreftagend; + $this->casesensitive = !empty($casesensitive); + $this->fullmatch = !empty($fullmatch); + $this->replacementphrase = $replacementphrase; + $this->replacementcallback = $replacementcallback; + $this->replacementcallbackdata = $replacementcallbackdata; } } @@ -1429,6 +1440,11 @@ function filter_prepare_phrases_for_filtering(array $linkarray) { * @param filterobject $linkobject the link object on which to set additional fields. */ function filter_prepare_phrase_for_replacement(filterobject $linkobject) { + if ($linkobject->replacementcallback !== null) { + list($linkobject->hreftagbegin, $linkobject->hreftagend, $linkobject->replacementphrase) = + call_user_func_array($linkobject->replacementcallback, $linkobject->replacementcallbackdata); + } + if (!isset($linkobject->hreftagbegin) or !isset($linkobject->hreftagend)) { $linkobject->hreftagbegin = 'hreftagend = '';