From 9e791db25af4293f55e446df6a86350861b5bbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Sun, 15 Sep 2024 08:35:48 +0200 Subject: [PATCH] MDL-51119 glossary: Allow multilang concepts. --- .../glossary_random/block_glossary_random.php | 129 ++++++----- .../behat/glossary_random_addblock.feature | 37 +++ filter/glossary/classes/text_filter.php | 8 +- mod/glossary/classes/entry_query_builder.php | 24 +- mod/glossary/classes/external.php | 3 +- mod/glossary/classes/local/concept_cache.php | 8 +- mod/glossary/editcategories.php | 2 +- mod/glossary/lib.php | 215 ++++++++++++++---- mod/glossary/showentry.php | 1 + .../tests/behat/behat_mod_glossary.php | 2 +- .../tests/behat/glossary_autolink.feature | 53 +++++ .../tests/behat/search_entries.feature | 39 +++- mod/glossary/tests/external/external_test.php | 129 +++++++++-- mod/glossary/view.php | 2 +- 14 files changed, 500 insertions(+), 152 deletions(-) create mode 100644 mod/glossary/tests/behat/glossary_autolink.feature diff --git a/blocks/glossary_random/block_glossary_random.php b/blocks/glossary_random/block_glossary_random.php index 0982014a5fd..e8e50d44f99 100644 --- a/blocks/glossary_random/block_glossary_random.php +++ b/blocks/glossary_random/block_glossary_random.php @@ -81,75 +81,92 @@ class block_glossary_random extends block_base { $glossaryctx = context_module::instance($cm->id); - $limitfrom = 0; - $limitnum = 1; + $entries = $DB->get_records_sql('SELECT id, concept, definition, definitionformat, definitiontrust + FROM {glossary_entries} + WHERE glossaryid = ? AND approved = 1 + ORDER BY timemodified ASC', [$this->config->glossary]); - $orderby = 'timemodified ASC'; + if (empty($entries)) { + $text = get_string('noentriesyet', 'block_glossary_random'); + } else { + // Now picking out the correct entry from the array. + switch ($this->config->type) { + case BGR_RANDOMLY: + $i = ($numberofentries > 1) ? rand(1, $numberofentries) : 1; + if (count($entries) == 1) { + $entry = reset($entries); + } else { + $entry = $entries[$i - 1]; + } + break; - switch ($this->config->type) { + case BGR_NEXTONE: + // The array is already sorted by last modified. + if (isset($this->config->previous)) { + $i = $this->config->previous + 1; + } else { + $i = 1; + } + if ($i > $numberofentries) { // Loop back to beginning. + $i = 1; + } + if (count($entries) == 1) { + $entry = reset($entries); + } else { + $entry = $entries[$i - 1]; + } + break; - case BGR_RANDOMLY: - $i = ($numberofentries > 1) ? rand(1, $numberofentries) : 1; - $limitfrom = $i-1; - break; + case BGR_NEXTALPHA: + // Now sort the array in regard to the current language. + usort($entries, function($a, $b) { + return format_string($a->concept) <=> format_string($b->concept); + }); + if (isset($this->config->previous)) { + $i = $this->config->previous + 1; + } else { + $i = 1; + } + if ($i > $numberofentries) { // Loop back to beginning. + $i = 1; + } + if (count($entries) == 1) { + $entry = $entries; + } else { + $entry = $entries[$i - 1]; + } + break; - case BGR_NEXTONE: - if (isset($this->config->previous)) { - $i = $this->config->previous + 1; - } else { - $i = 1; - } - if ($i > $numberofentries) { // Loop back to beginning - $i = 1; - } - $limitfrom = $i-1; - break; - - case BGR_NEXTALPHA: - $orderby = 'concept ASC'; - if (isset($this->config->previous)) { - $i = $this->config->previous + 1; - } else { - $i = 1; - } - if ($i > $numberofentries) { // Loop back to beginning - $i = 1; - } - $limitfrom = $i-1; - break; - - default: // BGR_LASTMODIFIED - $i = $numberofentries; - $limitfrom = 0; - $orderby = 'timemodified DESC, id DESC'; - break; - } - - if ($entry = $DB->get_records_sql("SELECT id, concept, definition, definitionformat, definitiontrust - FROM {glossary_entries} - WHERE glossaryid = ? AND approved = 1 - ORDER BY $orderby", array($this->config->glossary), $limitfrom, $limitnum)) { - - $entry = reset($entry); - - if (empty($this->config->showconcept)) { + default: // BGR_LASTMODIFIED + // The array is already sorted by last modified. + $i = $numberofentries; + if (count($entries) == 1) { + $entry = reset($entries); + } else { + $entry = array_pop($entries); + } + break; + } + if (empty($this->config->showconcept) || (!isset($entry->concept))) { $text = ''; } else { - $text = "

".format_string($entry->concept,true)."

"; + $text = "

" . format_string($entry->concept, true, ["context" => $glossaryctx]) . "

"; } $options = new stdClass(); - $options->trusted = $entry->definitiontrust; + if (isset($entry->definitiontrust)) { + $options->trusted = $entry->definitiontrust; + } $options->overflowdiv = true; - $entry->definition = file_rewrite_pluginfile_urls($entry->definition, 'pluginfile.php', $glossaryctx->id, 'mod_glossary', 'entry', $entry->id); - $text .= format_text($entry->definition, $entry->definitionformat, $options); - + if (isset($entry->definitiontrust) && isset($entry->id) && isset($entry->definition)) { + $entry->definition = file_rewrite_pluginfile_urls($entry->definition, 'pluginfile.php', $glossaryctx->id, + 'mod_glossary', 'entry', $entry->id); + $text .= format_text($entry->definition, $entry->definitionformat, $options); + } $this->config->nexttime = usergetmidnight(time()) + DAYSECS * $this->config->refresh; $this->config->previous = $i; - - } else { - $text = get_string('noentriesyet','block_glossary_random'); } + // store the text $this->config->cache = $text; $this->instance_config_commit(); diff --git a/blocks/glossary_random/tests/behat/glossary_random_addblock.feature b/blocks/glossary_random/tests/behat/glossary_random_addblock.feature index 3a295adf186..7d36f35e33f 100644 --- a/blocks/glossary_random/tests/behat/glossary_random_addblock.feature +++ b/blocks/glossary_random/tests/behat/glossary_random_addblock.feature @@ -8,6 +8,14 @@ Feature: Add the glossary random block when main feature is enabled Given the following "courses" exist: | fullname | shortname | format | | Course 1 | C1 | topics | + And the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | teacher1 | C1 | editingteacher | And I am on the "C1" "course" page logged in as "admin" Scenario: The glossary random block can be added when glossary module is enabled @@ -21,3 +29,32 @@ Feature: Add the glossary random block when main feature is enabled And I am on "Course 1" course homepage with editing mode on When I click on "Add a block" "link" Then I should not see "Random glossary entry" + + Scenario: View alphabetical order multilang entries in the glossary block + Given the following "activities" exist: + | activity | name | intro | course | idnumber | defaultapproval | + | glossary | Animals | An animal glossary | C1 | glossary3 | 1 | + And the following "mod_glossary > entries" exist: + | glossary | user | concept | definition | + | Animals | student1 | Aardvark | AardvarkErdferkel | + | Animals | student1 | Kangaroo | KangarooKänguru | + | Animals | student1 | Zebra | ZebraZebra | + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" + And I log out + And I log in as "teacher1" + And I am on "C1" course homepage with editing mode on + And I add the "Random glossary entry..." block + And I set the following fields to these values: + | Title | ManualGlossaryblock | + | Take entries from this glossary | Animals | + | Days before a new entry is chosen | 0 | + | How a new entry is chosen | Alphabetical order | + And I press "Save changes" + And I should see "Aardvark" in the "ManualGlossaryblock" "block" + And I should not see "AardvarkErdferkel" in the "ManualGlossaryblock" "block" + And I reload the page + And I should see "Kangaroo" in the "ManualGlossaryblock" "block" + And I reload the page + Then I should see "Zebra" in the "ManualGlossaryblock" "block" + And I log out diff --git a/filter/glossary/classes/text_filter.php b/filter/glossary/classes/text_filter.php index 6b5b0750a3f..350cb0a4687 100644 --- a/filter/glossary/classes/text_filter.php +++ b/filter/glossary/classes/text_filter.php @@ -29,8 +29,6 @@ use stdClass; /** * This filter provides automatic linking to glossary entries, aliases and categories when found inside every Moodle text. * - * NOTE: multilang glossary entries are not compatible with this filter. - * * @package filter_glossary * @copyright 2004 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -92,7 +90,7 @@ class text_filter extends \core_filters\text_filter { foreach ($allconcepts as $concepts) { foreach ($concepts as $concept) { $conceptlist[] = new filter_object( - $concept->concept, + format_string($concept->concept, true, ['context' => $this->context]), null, null, $concept->casesensitive, @@ -148,7 +146,9 @@ class text_filter extends \core_filters\text_filter { $title = get_string( 'glossaryconcept', 'filter_glossary', - ['glossary' => $glossaries[$concept->glossaryid], 'concept' => $concept->concept] + ['glossary' => replace_ampersands_not_followed_by_entity(strip_tags(format_string($glossaries[$concept->glossaryid], + true, ['context' => $this->context]))), + 'concept' => format_string($concept->concept, true, ['context' => $this->context])] ); // Hardcoding dictionary format in the URL rather than defaulting // to the current glossary format which may not work in a popup. diff --git a/mod/glossary/classes/entry_query_builder.php b/mod/glossary/classes/entry_query_builder.php index 1e548b79c2d..bfd96b47f2e 100644 --- a/mod/glossary/classes/entry_query_builder.php +++ b/mod/glossary/classes/entry_query_builder.php @@ -216,24 +216,6 @@ class mod_glossary_entry_query_builder { $this->filter_by_non_letter($field); } - /** - * Filter the concept by letter. - * - * @param string $letter The letter. - */ - public function filter_by_concept_letter($letter) { - $this->filter_by_letter($letter, self::resolve_field('concept', 'entries')); - } - - /** - * Filter the concept by special characters. - * - * @return void - */ - public function filter_by_concept_non_letter() { - $this->filter_by_non_letter(self::resolve_field('concept', 'entries')); - } - /** * Filter non approved entries. * @@ -273,11 +255,11 @@ class mod_glossary_entry_query_builder { * @param string $term What the concept or aliases should be. */ public function filter_by_term($term) { - $this->where[] = sprintf("(%s = :filterterma OR %s = :filtertermb)", + $this->where[] = sprintf("(%s LIKE :filterterma OR %s LIKE :filtertermb)", self::resolve_field('concept', 'entries'), self::resolve_field('alias', 'alias')); - $this->params['filterterma'] = $term; - $this->params['filtertermb'] = $term; + $this->params['filterterma'] = "%" . $term . "%"; + $this->params['filtertermb'] = "%" . $term . "%"; } /** diff --git a/mod/glossary/classes/external.php b/mod/glossary/classes/external.php index 60803cbd4a3..5da2d9e3e00 100644 --- a/mod/glossary/classes/external.php +++ b/mod/glossary/classes/external.php @@ -148,7 +148,8 @@ class mod_glossary_external extends external_api { $context, 'mod_glossary', 'entry', - $entry->id + $entry->id, + ['trusted' => true], ); // Author details. diff --git a/mod/glossary/classes/local/concept_cache.php b/mod/glossary/classes/local/concept_cache.php index cd5adc8046b..bd0d7ff78a5 100644 --- a/mod/glossary/classes/local/concept_cache.php +++ b/mod/glossary/classes/local/concept_cache.php @@ -122,9 +122,9 @@ class concept_cache { $concepts = array(); $rs = $DB->get_recordset_sql($sql); foreach ($rs as $concept) { - $currentconcept = trim(strip_tags($concept->concept)); + $currentconcept = trim($concept->concept); - // Concept must be HTML-escaped, so do the same as format_string to turn ampersands into &. + // Turn ampersands into & but keep HTML format for filters. $currentconcept = replace_ampersands_not_followed_by_entity($currentconcept); if (empty($currentconcept)) { @@ -188,7 +188,7 @@ class concept_cache { } foreach ($glossaries as $id => $name) { $name = str_replace(':', '-', $name); - $glossaries[$id] = replace_ampersands_not_followed_by_entity(strip_tags($name)); + $glossaries[$id] = $name; } $allconcepts = self::fetch_concepts(array_keys($glossaries)); @@ -254,7 +254,7 @@ class concept_cache { } foreach ($glossaries as $id => $name) { $name = str_replace(':', '-', $name); - $glossaries[$id] = replace_ampersands_not_followed_by_entity(strip_tags($name)); + $glossaries[$id] = replace_ampersands_not_followed_by_entity($name); } $allconcepts = self::fetch_concepts(array_keys($glossaries)); foreach ($glossaries as $gid => $unused) { diff --git a/mod/glossary/editcategories.php b/mod/glossary/editcategories.php index e239204bf8e..809da94b757 100644 --- a/mod/glossary/editcategories.php +++ b/mod/glossary/editcategories.php @@ -120,7 +120,7 @@ if ( $hook >0 ) { echo $OUTPUT->heading(format_string($glossary->name), 2); echo $OUTPUT->heading(format_string(get_string("editcategory", "glossary")), 3); - $name = $category->name; + $name = format_string($category->name, true, ['context' => $context]); $usedynalink = $category->usedynalink; require "editcategories.html"; echo $OUTPUT->footer(); diff --git a/mod/glossary/lib.php b/mod/glossary/lib.php index 5f275de2dd8..fab83aff161 100644 --- a/mod/glossary/lib.php +++ b/mod/glossary/lib.php @@ -3452,18 +3452,13 @@ function glossary_entry_view($entry, $context) { * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_letter($glossary, $context, $letter, $from, $limit, $options = array()) { $qb = new mod_glossary_entry_query_builder($glossary); - if ($letter != 'ALL' && $letter != 'SPECIAL' && core_text::strlen($letter)) { - $qb->filter_by_concept_letter($letter); - } - if ($letter == 'SPECIAL') { - $qb->filter_by_concept_non_letter(); - } if (!empty($options['includenotapproved']) && has_capability('mod/glossary:approve', $context)) { $qb->filter_by_non_approved(mod_glossary_entry_query_builder::NON_APPROVED_ALL); @@ -3476,12 +3471,59 @@ function glossary_get_entries_by_letter($glossary, $context, $letter, $from, $li $qb->add_user_fields(); $qb->order_by('concept', 'entries'); $qb->order_by('id', 'entries', 'ASC'); // Sort on ID to avoid random ordering when entries share an ordering value. - $qb->limit($from, $limit); - // Fetching the entries. - $count = $qb->count_records(); + // Fetching the entries. Those are all entries. $entries = $qb->get_records(); + // Now sorting out the array. + $filteredentries = []; + + if ($letter != 'ALL' && $letter != 'SPECIAL' && core_text::strlen($letter)) { + // Build a new array with the filtered entries. + foreach ($entries as $key => $entry) { + if (strtoupper(substr(format_string($entry->concept), 0, 1)) === strtoupper($letter)) { + // Add it when starting with the correct letter. + $filteredentries[$key] = $entry; + } + } + $entries = $filteredentries; + } + + if ($letter == 'SPECIAL') { + // Build a new array with the filtered entries. + foreach ($entries as $key => $entry) { + if (!ctype_alpha(substr(format_string($entry->concept), 0, 1))) { + // Add it when starting with a non-letter character. + $filteredentries[$key] = $entry; + } + } + $entries = $filteredentries; + } + + if ($letter == 'ALL') { + // No filtering needed. + $filteredentries = $entries; + } + + // Now sort the array in regard to the current language. + usort($filteredentries, function($a, $b) { + return format_string($a->concept) <=> format_string($b->concept); + }); + + // Size of the overall array. + $count = count($entries); + + // Now applying limit. + if (isset($limit)) { + if (isset($from)) { + $entries = array_slice($filteredentries, $from, $limit); + } else { + $entries = array_slice($filteredentries); + } + } else { + $entries = $filteredentries; + } + return array($entries, $count); } @@ -3497,7 +3539,8 @@ function glossary_get_entries_by_letter($glossary, $context, $letter, $from, $li * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_date($glossary, $context, $order, $sort, $from, $limit, $options = array()) { @@ -3539,7 +3582,8 @@ function glossary_get_entries_by_date($glossary, $context, $order, $sort, $from, * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_category($glossary, $context, $categoryid, $from, $limit, $options = array()) { @@ -3596,7 +3640,8 @@ function glossary_get_entries_by_category($glossary, $context, $categoryid, $fro * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_author($glossary, $context, $letter, $field, $sort, $from, $limit, $options = array()) { @@ -3644,7 +3689,8 @@ function glossary_get_entries_by_author($glossary, $context, $letter, $field, $s * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_author_id($glossary, $context, $authorid, $order, $sort, $from, $limit, $options = array()) { @@ -3689,7 +3735,8 @@ function glossary_get_entries_by_author_id($glossary, $context, $authorid, $orde * @param array $options Accepts: * - (bool) includenotapproved. When false, includes self even if all of their entries require approval. * When true, also includes authors only having entries pending approval. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_authors($glossary, $context, $limit, $from, $options = array()) { @@ -3729,7 +3776,8 @@ function glossary_get_authors($glossary, $context, $limit, $from, $options = arr * @param object $glossary The glossary. * @param int $from Fetch records from. * @param int $limit Number of records to fetch. - * @return array The first element being the recordset, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_categories($glossary, $from, $limit) { @@ -3833,7 +3881,8 @@ function glossary_get_search_terms_sql(array $terms, $fullsearch = true, $glossa * @param array $options Accepts: * - (bool) includenotapproved. When false, includes the non-approved entries created by * the current user. When true, also includes the ones that the user has the permission to approve. - * @return array The first element being the array of results, the second the number of entries. + * @return array The first element being the recordset (taking into account the limit), the second the number of entries the overall + * array has. * @since Moodle 3.1 */ function glossary_get_entries_by_search($glossary, $context, $query, $fullsearch, $order, $sort, $from, $limit, @@ -3915,6 +3964,7 @@ function glossary_get_entries_by_term($glossary, $context, $term, $from, $limit, } $qb->add_field('*', 'entries'); + $qb->add_field('alias', 'alias'); $qb->join_alias(); $qb->join_user(); $qb->add_user_fields(); @@ -3922,12 +3972,46 @@ function glossary_get_entries_by_term($glossary, $context, $term, $from, $limit, $qb->order_by('concept', 'entries'); $qb->order_by('id', 'entries'); // Sort on ID to avoid random ordering when entries share an ordering value. - $qb->limit($from, $limit); - // Fetching the entries. - $count = $qb->count_records(); + // Fetching the entries. Those are all entries. $entries = $qb->get_records(); + // Now sorting out the array. + $filteredentries = []; + + // Now sorting out the array. + foreach ($entries as $key => $entry) { + if (strtoupper(format_string($entry->concept)) === strtoupper($term)) { + // Add it when matching concept or alias. + $filteredentries[$key] = $entry; + } + if ((isset($entry->alias)) && (strtoupper(format_string($entry->alias)) === strtoupper($term))) { + // Add it when matching concept or alias. + $filteredentries[$key] = $entry; + } + } + $entries = $filteredentries; + // Check whether concept or alias match the term. + + // Now sort the array in regard to the current language. + usort($filteredentries, function($a, $b) { + return format_string($a->concept) <=> format_string($b->concept); + }); + + // Size of the overall array. + $count = count($entries); + + // Now applying limit. + if (isset($limit)) { + if (isset($from)) { + $entries = array_slice($filteredentries, $from, $limit); + } else { + $entries = array_slice($filteredentries); + } + } else { + $entries = $filteredentries; + } + return array($entries, $count); } @@ -3947,32 +4031,87 @@ function glossary_get_entries_by_term($glossary, $context, $term, $from, $limit, function glossary_get_entries_to_approve($glossary, $context, $letter, $order, $sort, $from, $limit) { $qb = new mod_glossary_entry_query_builder($glossary); - if ($letter != 'ALL' && $letter != 'SPECIAL' && core_text::strlen($letter)) { - $qb->filter_by_concept_letter($letter); - } - if ($letter == 'SPECIAL') { - $qb->filter_by_concept_non_letter(); - } $qb->add_field('*', 'entries'); $qb->join_user(); $qb->add_user_fields(); $qb->filter_by_non_approved(mod_glossary_entry_query_builder::NON_APPROVED_ONLY); - if ($order == 'CREATION') { - $qb->order_by('timecreated', 'entries', $sort); - } else if ($order == 'UPDATE') { - $qb->order_by('timemodified', 'entries', $sort); - } else { - $qb->order_by('concept', 'entries', $sort); - } - $qb->order_by('id', 'entries', $sort); // Sort on ID to avoid random ordering when entries share an ordering value. - $qb->limit($from, $limit); - // Fetching the entries. - $count = $qb->count_records(); + // Fetching the entries. Those are all non approved entries. $entries = $qb->get_records(); - return array($entries, $count); + // Size of the overall array. + $count = count($entries); + + // If a some filter is set, restrict by that filter. + $filteredentries = []; + + if ($letter != 'ALL' && $letter != 'SPECIAL' && core_text::strlen($letter)) { + // Build a new array with the filtered entries. + foreach ($entries as $key => $entry) { + if (strtoupper(substr(format_string($entry->concept), 0, 1)) === strtoupper($letter)) { + // Add it when starting with the correct letter. + $filteredentries[$key] = $entry; + } + } + } else if ($letter == 'SPECIAL') { + // Build a new array with the filtered entries. + foreach ($entries as $key => $entry) { + if (!ctype_alpha(substr(format_string($entry->concept), 0, 1))) { + // Add it when starting with a non-letter character. + $filteredentries[$key] = $entry; + } + } + } else { + // No filtering needed (This means CONCEPT). + $filteredentries = $entries; + } + + // Now sort the array in regard to the current language. + if ($order == 'CREATION') { + if ($sort == "DESC") { + usort($filteredentries, function($a, $b) { + return $b->timecreated <=> $a->timecreated; + }); + } else { + usort($filteredentries, function($a, $b) { + return $a->timecreated <=> $b->timecreated; + }); + } + } else if ($order == 'UPDATE') { + if ($sort == "DESC") { + usort($filteredentries, function($a, $b) { + return $b->timemodified <=> $a->timemodified; + }); + } else { + usort($filteredentries, function($a, $b) { + return $a->timemodified <=> $b->timemodified; + }); + } + } else { + // This means CONCEPT. + if ($sort == "DESC") { + usort($filteredentries, function($a, $b) { + return format_string($b->concept) <=> format_string($a->concept); + }); + } else { + usort($filteredentries, function($a, $b) { + return format_string($a->concept) <=> format_string($b->concept); + }); + } + } + + // Now applying limit. + if (isset($limit)) { + $count = count($filteredentries); + if (isset($from)) { + $filteredentries = array_slice($filteredentries, $from, $limit); + } else { + $filteredentries = array_slice($filteredentries, 0, $limit); + } + } + + return [$filteredentries, $count]; } /** diff --git a/mod/glossary/showentry.php b/mod/glossary/showentry.php index d9218c2f162..b298f26e8ab 100644 --- a/mod/glossary/showentry.php +++ b/mod/glossary/showentry.php @@ -28,6 +28,7 @@ if ($eid) { $entry->glossaryname = $glossary->name; $entry->cmid = $cm->id; $entry->courseid = $cm->course; + $entry->concept = format_string($entry->concept, true, ["escape" => false]); $entries = array($entry); } else if ($concept) { diff --git a/mod/glossary/tests/behat/behat_mod_glossary.php b/mod/glossary/tests/behat/behat_mod_glossary.php index 63d03af91e9..fa13b0654df 100644 --- a/mod/glossary/tests/behat/behat_mod_glossary.php +++ b/mod/glossary/tests/behat/behat_mod_glossary.php @@ -69,7 +69,7 @@ class behat_mod_glossary extends behat_base { $this->execute("behat_forms::press_button", get_string('addcategory', 'glossary')); - $this->execute('behat_forms::i_set_the_field_to', array('name', $this->escape($categoryname))); + $this->execute('behat_forms::i_set_the_field_to', ['name', $categoryname]); $this->execute("behat_forms::press_button", get_string('savechanges')); $this->execute("behat_forms::press_button", get_string('back', 'mod_glossary')); diff --git a/mod/glossary/tests/behat/glossary_autolink.feature b/mod/glossary/tests/behat/glossary_autolink.feature new file mode 100644 index 00000000000..b8af369ee11 --- /dev/null +++ b/mod/glossary/tests/behat/glossary_autolink.feature @@ -0,0 +1,53 @@ +@mod @mod_glossary +Feature: Glossary can set autolinked entries in text and media areas + In order to display the glossary entries for concepts in texts + As a teacher + I can set the glossary activity to autolink the entries + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | intro | displayformat | course | idnumber | + | glossary | Test glossaryglossaireglossario | Test glossary description | encyclopedia | C1 | glossary1 | + And the following "mod_glossary > entries" exist: + | glossary | concept | definition | usedynalink | + | glossary1 | EnglishAnglaisinglés | Relating to England, its people, or the language spoken there.Relatif à l'Angleterre, à son peuple ou à la langue parlée là-bas.Relacionado con Inglaterra, su gente o el idioma hablado allí. | 1 | + | glossary1 | SpanishEspagnolCastellano | Relating to Spain, its people, or the language spoken there.Relatif à l'Espagne, à son peuple ou à la langue parlée là-bas.Relacionado con España, su gente o el idioma hablado allí. | 1 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | label | Text and media area |

This is a text with the multilang syntax on the EnglishAnglaisInglés word that should be auto-linked.

This are plain text words that should also be auto-linked: English, Anglais, Inglés.

| C1 | label1 | + And the "glossary" filter is "on" + And the following "language pack" exists: + | language | fr | es | + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" + + @javascript + Scenario: Glossary entries show up in text and media areas in the correct user interface/language combination + When I am on the "Course 1" course page logged in as teacher1 + Then "English" "link" should exist in the ".modtype_label" "css_element" + And "Anglais" "link" should not exist in the ".modtype_label" "css_element" + And "Inglés" "link" should not exist in the ".modtype_label" "css_element" + And the "title" attribute of ".glossary.autolink" "css_element" should contain "Test glossary: English" + And I follow "Language" in the user menu + And I click on "//a[contains(@href, 'lang=es')]" "xpath" + Then "English" "link" should not exist in the ".modtype_label" "css_element" + And "Anglais" "link" should not exist in the ".modtype_label" "css_element" + And "Inglés" "link" should exist in the ".modtype_label" "css_element" + And the "title" attribute of ".glossary.autolink" "css_element" should contain "Test glossario: inglés" + And I follow "Idioma" in the user menu + And I click on "//a[contains(@href, 'lang=fr')]" "xpath" + Then "English" "link" should not exist in the ".modtype_label" "css_element" + And "Anglais" "link" should exist in the ".modtype_label" "css_element" + And "Inglés" "link" should not exist in the ".modtype_label" "css_element" + And the "title" attribute of ".glossary.autolink" "css_element" should contain "Test glossaire : Anglais" diff --git a/mod/glossary/tests/behat/search_entries.feature b/mod/glossary/tests/behat/search_entries.feature index 394b2399de7..99d0cb04106 100644 --- a/mod/glossary/tests/behat/search_entries.feature +++ b/mod/glossary/tests/behat/search_entries.feature @@ -20,13 +20,18 @@ Feature: Glossary entries can be searched or browsed by alphabet, category, date | activity | name | intro | displayformat | course | idnumber | | glossary | Test glossary name | Test glossary description | fullwithauthor | C1 | g1 | And the following "mod_glossary > categories" exist: - | glossary | name | - | g1 | The ones I like | - | g1 | All for you | + | glossary | name | + | g1 | The ones I likeCeux qui me plaisent | + | g1 | All for youTout pour toi | + And the following "mod_glossary > entries" exist: - | glossary | concept | definition | user | categories | - | g1 | Eggplant | Sour eggplants | teacher1 | All for you | - | g1 | Cucumber | Sweet cucumber | student1 | The ones I like | + | glossary | concept | definition | user | categories | + | g1 | EggplantAubergine | Sour eggplantsAubergines aigres | teacher1 | All for youTout pour toi | + | g1 | 7up | 7up is a softdrink7up est une boisson | teacher1 | The ones I likeCeux qui me plaisent | + | g1 | CucumberConcombre | Sweet cucumberDoux concombre | student1 | The ones I likeCeux qui me plaisent | + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" + And I log out And I am on the "Test glossary name" "glossary activity" page logged in as teacher1 @javascript @@ -35,13 +40,35 @@ Feature: Glossary entries can be searched or browsed by alphabet, category, date And I press "Search" Then I should see "Sweet cucumber" And I should see "Search: cucumber" + And I set the field "hook" to "aubergine" + And I press "Search" + And I should see "Sour eggplants" + And I should see "Search: aubergine" + And I should see "E" in the ".glossarycategoryheader" "css_element" And I click on "E" "link" in the ".entrybox" "css_element" And I should see "Sour eggplants" And I should not see "Sweet cucumber" + And I should not see "No entries found in this section" + And I click on "Special" "link" in the ".entrybox" "css_element" + And I should see "7up" + And I should not see "Sweet cucumber" + And I should not see "Sour eggplants" + And I should not see "No entries found in this section" And I click on "X" "link" in the ".entrybox" "css_element" And I should not see "Sweet cucumber" And I should see "No entries found in this section" + @javascript + Scenario: Search by keyword and browse by alphabet when several multilang entries can be found + When I add a glossary entry with the following data: + | Concept | ConcombreCucumber | + | Definition | Doux concombreSweet cucumber alternate entry | + And I set the field "hook" to "cucumber" + And I press "Search" + Then I should see "Sweet cucumber" + And I should see "Sweet cucumber alternate entry" + And I should see "Search: cucumber" + @javascript Scenario: Browse by category When I select "Browse by category" from the "Browse the glossary using this index" singleselect diff --git a/mod/glossary/tests/external/external_test.php b/mod/glossary/tests/external/external_test.php index 092fcef8cc9..967d898fdfe 100644 --- a/mod/glossary/tests/external/external_test.php +++ b/mod/glossary/tests/external/external_test.php @@ -367,7 +367,7 @@ final class external_test extends externallib_advanced_testcase { // Ordering including to approve. $return = mod_glossary_external::get_entries_by_date($g1->id, 'CREATION', 'ASC', 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_date_returns(), $return); $this->assertCount(4, $return['entries']); $this->assertEquals(4, $return['count']); @@ -378,14 +378,14 @@ final class external_test extends externallib_advanced_testcase { // Ordering including to approve and pagination. $return = mod_glossary_external::get_entries_by_date($g1->id, 'CREATION', 'ASC', 0, 2, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_date_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(4, $return['count']); $this->assertEquals($e1a->id, $return['entries'][0]['id']); $this->assertEquals($e1c->id, $return['entries'][1]['id']); $return = mod_glossary_external::get_entries_by_date($g1->id, 'CREATION', 'ASC', 2, 2, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_date_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(4, $return['count']); @@ -480,7 +480,7 @@ final class external_test extends externallib_advanced_testcase { // Including to approve. $return = mod_glossary_external::get_entries_by_category($g1->id, $cat1b->id, 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_category_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(2, $return['count']); @@ -489,7 +489,7 @@ final class external_test extends externallib_advanced_testcase { // Using limit. $return = mod_glossary_external::get_entries_by_category($g1->id, GLOSSARY_SHOW_ALL_CATEGORIES, 0, 3, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_category_returns(), $return); $this->assertCount(3, $return['entries']); $this->assertEquals(6, $return['count']); @@ -497,7 +497,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals($e1b2->id, $return['entries'][1]['id']); $this->assertEquals($e1a1->id, $return['entries'][2]['id']); $return = mod_glossary_external::get_entries_by_category($g1->id, GLOSSARY_SHOW_ALL_CATEGORIES, 3, 2, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_category_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(6, $return['count']); @@ -536,7 +536,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals($u1->id, $return['authors'][1]['id']); // Include users with entries pending approval. - $return = mod_glossary_external::get_authors($g1->id, 0, 20, array('includenotapproved' => true)); + $return = mod_glossary_external::get_authors($g1->id, 0, 20, ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_authors_returns(), $return); $this->assertCount(3, $return['authors']); $this->assertEquals(3, $return['count']); @@ -545,7 +545,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEquals($u1->id, $return['authors'][2]['id']); // Pagination. - $return = mod_glossary_external::get_authors($g1->id, 1, 1, array('includenotapproved' => true)); + $return = mod_glossary_external::get_authors($g1->id, 1, 1, ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_authors_returns(), $return); $this->assertCount(1, $return['authors']); $this->assertEquals(3, $return['count']); @@ -613,7 +613,7 @@ final class external_test extends externallib_advanced_testcase { // Including non-approved. $this->setAdminUser(); $return = mod_glossary_external::get_entries_by_author($g1->id, 'ALL', 'LASTNAME', 'ASC', 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_author_returns(), $return); $this->assertCount(7, $return['entries']); $this->assertEquals(7, $return['count']); @@ -750,7 +750,7 @@ final class external_test extends externallib_advanced_testcase { // Including non approved. $return = mod_glossary_external::get_entries_by_author_id($g1->id, $u1->id, 'CONCEPT', 'ASC', 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_author_id_returns(), $return); $this->assertCount(4, $return['entries']); $this->assertEquals(4, $return['count']); @@ -761,14 +761,14 @@ final class external_test extends externallib_advanced_testcase { // Pagination. $return = mod_glossary_external::get_entries_by_author_id($g1->id, $u1->id, 'CONCEPT', 'ASC', 0, 2, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_author_id_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(4, $return['count']); $this->assertEquals($e1a2->id, $return['entries'][0]['id']); $this->assertEquals($e1a4->id, $return['entries'][1]['id']); $return = mod_glossary_external::get_entries_by_author_id($g1->id, $u1->id, 'CONCEPT', 'ASC', 1, 2, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_author_id_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(4, $return['count']); @@ -872,7 +872,7 @@ final class external_test extends externallib_advanced_testcase { // Including not approved. $query = 'ou'; $return = mod_glossary_external::get_entries_by_search($g1->id, $query, false, 'CONCEPT', 'ASC', 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_search_returns(), $return); $this->assertCount(4, $return['entries']); $this->assertEquals(4, $return['count']); @@ -884,7 +884,7 @@ final class external_test extends externallib_advanced_testcase { // Advanced query string. $query = '+Heroes -Abcd'; $return = mod_glossary_external::get_entries_by_search($g1->id, $query, true, 'CONCEPT', 'ASC', 0, 20, - array('includenotapproved' => true)); + ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_search_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(2, $return['count']); @@ -914,7 +914,7 @@ final class external_test extends externallib_advanced_testcase { $e5 = $gg->create_content($g2, array('userid' => $u1->id, 'approved' => 1, 'concept' => 'dog'), array('cat')); // Search concept + alias. - $return = mod_glossary_external::get_entries_by_term($g1->id, 'cat', 0, 20, array('includenotapproved' => false)); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'cat', 0, 20, ['includenotapproved' => false]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); $this->assertCount(2, $return['entries']); $this->assertEquals(2, $return['count']); @@ -933,7 +933,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEqualsCanonicalizing($expected, $actual); // Search alias. - $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, array('includenotapproved' => false)); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, ['includenotapproved' => false]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); $this->assertCount(2, $return['entries']); @@ -944,7 +944,7 @@ final class external_test extends externallib_advanced_testcase { $this->assertEqualsCanonicalizing($expected, $actual); // Search including not approved. - $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, array('includenotapproved' => true)); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); $this->assertCount(3, $return['entries']); $this->assertEquals(3, $return['count']); @@ -954,13 +954,104 @@ final class external_test extends externallib_advanced_testcase { $this->assertEqualsCanonicalizing($expected, $actual); // Pagination. - $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 1, array('includenotapproved' => true)); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 1, ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); $this->assertCount(1, $return['entries']); // We don't compare the returned entry id because it may be different depending on the DBMS, // for example, Postgres does a random sorting in this case. $this->assertEquals(3, $return['count']); - $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 1, 1, array('includenotapproved' => true)); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 1, 1, ['includenotapproved' => true]); + $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); + $this->assertCount(1, $return['entries']); + $this->assertEquals(3, $return['count']); + } + + /** + * Test get_entries_by_multilingual_term. + * + * @covers \mod_glossary_external::get_entries_by_term + * @return void + * @throws \coding_exception + * @throws \invalid_response_exception + * @throws \moodle_exception + */ + public function test_get_entries_by_multilingual_term(): void { + $this->resetAfterTest(true); + + // Enable multilang filter to on content and heading. + filter_set_global_state('multilang', TEXTFILTER_ON); + filter_set_applies_to_strings('multilang', 1); + + // Generate all the things. + $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary'); + $c1 = $this->getDataGenerator()->create_course(); + $g1 = $this->getDataGenerator()->create_module('glossary', ['course' => $c1->id]); + $g2 = $this->getDataGenerator()->create_module('glossary', ['course' => $c1->id]); + $u1 = $this->getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($u1->id, $c1->id); + + $this->setAdminUser(); + + $e1 = $gg->create_content($g1, ['userid' => $u1->id, 'approved' => 1, 'concept' => 'catchat', 'tags' => ['Cats', 'Dogs']]); + $e2 = $gg->create_content($g1, ['userid' => $u1->id, 'approved' => 1], ['catchat', '' . + 'dogchien']); + $e3 = $gg->create_content($g1, ['userid' => $u1->id, 'approved' => 1], ['dogchien']); + $e4 = $gg->create_content($g1, ['userid' => $u1->id, 'approved' => 0, 'concept' => 'dogchien']); + $e5 = $gg->create_content($g2, ['userid' => $u1->id, 'approved' => 1, 'concept' => 'cogchien'], ['cat']); + + // Search concept + alias. + $return = mod_glossary_external::get_entries_by_term($g1->id, 'cat', 0, 20, ['includenotapproved' => false]); + $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); + $this->assertCount(2, $return['entries']); + $this->assertEquals(2, $return['count']); + // Compare ids, ignore ordering of array, using canonicalize parameter of assertEquals. + $expected = [$e1->id, $e2->id]; + $actual = [$return['entries'][0]['id'], $return['entries'][1]['id']]; + $this->assertEqualsCanonicalizing($expected, $actual); + // Compare rawnames of all expected tags, ignore ordering of array, using canonicalize parameter of assertEquals. + $expected = ['Cats', 'Dogs']; // Only $e1 has 2 tags. + $actual = []; // Accumulate all tags returned. + foreach ($return['entries'] as $entry) { + foreach ($entry['tags'] as $tag) { + $actual[] = $tag['rawname']; + } + } + $this->assertEqualsCanonicalizing($expected, $actual); + + // Search alias. + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, ['includenotapproved' => false]); + $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); + + $this->assertCount(2, $return['entries']); + $this->assertEquals(2, $return['count']); + // Compare ids, ignore ordering of array, using canonicalize parameter of assertEquals. + $expected = [$e2->id, $e3->id]; + $actual = [$return['entries'][0]['id'], $return['entries'][1]['id']]; + $this->assertEqualsCanonicalizing($expected, $actual); + + // Search including not approved. + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 20, ['includenotapproved' => true]); + $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); + $this->assertCount(3, $return['entries']); + $this->assertEquals(3, $return['count']); + // Compare ids, ignore ordering of array, using canonicalize parameter of assertEquals. + $expected = [$e4->id, $e2->id, $e3->id]; + $actual = [$return['entries'][0]['id'], $return['entries'][1]['id'], $return['entries'][2]['id']]; + $this->assertEqualsCanonicalizing($expected, $actual); + + // Pagination. + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 0, 1, ['includenotapproved' => true]); + $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); + $this->assertCount(1, $return['entries']); + // We don't compare the returned entry id because it may be different depending on the DBMS, + // for example, Postgres does a random sorting in this case. + $this->assertEquals(3, $return['count']); + $return = mod_glossary_external::get_entries_by_term($g1->id, 'dog', 1, 1, ['includenotapproved' => true]); $return = external_api::clean_returnvalue(mod_glossary_external::get_entries_by_term_returns(), $return); $this->assertCount(1, $return['entries']); $this->assertEquals(3, $return['count']); diff --git a/mod/glossary/view.php b/mod/glossary/view.php index ea61737e321..1f0394c7db5 100644 --- a/mod/glossary/view.php +++ b/mod/glossary/view.php @@ -418,7 +418,7 @@ if ($allentries) { // Setting the pivot for the current entry if ($printpivot) { - $pivot = $entry->{$pivotkey}; + $pivot = format_string($entry->{$pivotkey}, false, ["context" => $context]); $upperpivot = core_text::strtoupper($pivot); $pivottoshow = core_text::strtoupper(format_string($pivot, true, $fmtoptions));