diff --git a/filter/glossary/filter.php b/filter/glossary/filter.php index a1ddebb87b2..7e44c82ea26 100644 --- a/filter/glossary/filter.php +++ b/filter/glossary/filter.php @@ -17,7 +17,7 @@ /** * This filter provides automatic linking to * glossary entries, aliases and categories when - * found inside every Moodle text + * found inside every Moodle text. * * @package filter * @subpackage glossary @@ -28,12 +28,17 @@ defined('MOODLE_INTERNAL') || die(); /** - * Glossary filtering + * Glossary linking filter class. * - * TODO: erase the $GLOSSARY_EXCLUDECONCEPTS global => require format_text() - * to be able to pass arbitrary $options['filteroptions']['glossary'] to filter_text() + * NOTE: multilang glossary entries are not compatible with this filter. */ class filter_glossary extends moodle_text_filter { + /** @var int $cachecourseid cache invalidation flag in case content from multiple courses displayed. */ + protected $cachecourseid = null; + /** @var int $cacheuserid cache invalidation flag in case user is switched. */ + protected $cacheuserid = null; + /** @var array $cacheconceptlist page level filter cache, this should be always faster than MUC */ + protected $cacheconceptlist = null; public function setup($page, $context) { // This only requires execution once per request. @@ -49,185 +54,102 @@ class filter_glossary extends moodle_text_filter { } public function filter($text, array $options = array()) { - global $CFG, $DB, $GLOSSARY_EXCLUDECONCEPTS; - - // Trivial-cache - keyed on $cachedcontextid - static $cachedcontextid; - static $conceptlist; - - static $nothingtodo; // To avoid processing if no glossaries / concepts are found + global $CFG, $USER, $GLOSSARY_EXCLUDEENTRY; // Try to get current course. $coursectx = $this->context->get_course_context(false); if (!$coursectx) { + // Only global glossaries will be linked. + $course = null; $courseid = 0; } else { - $courseid = $coursectx->instanceid; + $course = get_course($coursectx->instanceid, false); + $courseid = (int)$course->id; } - // Initialise/invalidate our trivial cache if dealing with a different context - if (!isset($cachedcontextid) || $cachedcontextid !== $this->context->id) { - $cachedcontextid = $this->context->id; - $conceptlist = array(); - $nothingtodo = false; + if ($this->cachecourseid !== $courseid or $this->cacheuserid !== $USER->id) { + // Invalidate the page cache. + $this->cacheconceptlist = null; } - if (($nothingtodo === true) || (!has_capability('mod/glossary:view', $this->context))) { + if (is_array($this->cacheconceptlist) and empty($GLOSSARY_EXCLUDEENTRY)) { + if (empty($this->cacheconceptlist)) { + return $text; + } + return filter_phrases($text, $this->cacheconceptlist); + } + + list($glossaries, $allconcepts) = \mod_glossary\local\concept_cache::get_concepts($course); + + if (!$allconcepts) { + $this->cacheuserid = $USER->id; + $this->cachecourseid = $courseid; + $this->cacheconcepts = array(); return $text; } - // Create a list of all the concepts to search for. It may be cached already. - if (empty($conceptlist)) { + $strcategory = get_string('category', 'glossary'); - // Find all the glossaries we need to examine - if (!$glossaries = $DB->get_records_sql_menu(' - SELECT g.id, g.name - FROM {glossary} g, {course_modules} cm, {modules} m - WHERE m.name = \'glossary\' - AND cm.module = m.id - AND cm.visible = 1 - AND g.id = cm.instance - AND g.usedynalink != 0 - AND (g.course = ? OR g.globalglossary = 1) - ORDER BY g.globalglossary, g.id', array($courseid))) { - $nothingtodo = true; - return $text; - } - - // Make a list of glossary IDs for searching - $glossarylist = implode(',', array_keys($glossaries)); - - // Pull out all the raw data from the database for entries, categories and aliases - $entries = $DB->get_records_select('glossary_entries', - 'glossaryid IN ('.$glossarylist.') AND usedynalink != 0 AND approved != 0 ', null, '', - 'id,glossaryid, concept, casesensitive, 0 AS category, fullmatch'); - - $categories = $DB->get_records_select('glossary_categories', - 'glossaryid IN ('.$glossarylist.') AND usedynalink != 0', null, '', - 'id,glossaryid,name AS concept, 1 AS casesensitive, 1 AS category, 1 AS fullmatch'); - - $aliases = $DB->get_records_sql(' - SELECT ga.id, ge.id AS entryid, ge.glossaryid, - ga.alias AS concept, ge.concept AS originalconcept, - casesensitive, 0 AS category, fullmatch - FROM {glossary_alias} ga, - {glossary_entries} ge - WHERE ga.entryid = ge.id - AND ge.glossaryid IN ('.$glossarylist.') - AND ge.usedynalink != 0 - AND ge.approved != 0', null); - - // Combine them into one big list - $concepts = array(); - if ($entries and $categories) { - $concepts = array_merge($entries, $categories); - } else if ($categories) { - $concepts = $categories; - } else if ($entries) { - $concepts = $entries; - } - - if ($aliases) { - $concepts = array_merge($concepts, $aliases); - } - - if (!empty($concepts)) { - foreach ($concepts as $key => $concept) { - // Trim empty or unlinkable concepts - $currentconcept = trim(strip_tags($concept->concept)); - - // Concept must be HTML-escaped, so do the same as format_string - // to turn ampersands into &. - $currentconcept = replace_ampersands_not_followed_by_entity($currentconcept); - - if (empty($currentconcept)) { - unset($concepts[$key]); - continue; - } else { - $concepts[$key]->concept = $currentconcept; - } - - // Rule out any small integers. See bug 1446 - $currentint = intval($currentconcept); - if ($currentint && (strval($currentint) == $currentconcept) && $currentint < 1000) { - unset($concepts[$key]); - } - } - } - - if (empty($concepts)) { - $nothingtodo = true; - return $text; - } - - usort($concepts, 'filter_glossary::sort_entries_by_length'); - - $strcategory = get_string('category', 'glossary'); - - // Loop through all the concepts, setting up our data structure for the filter - $conceptlist = array(); // We will store all the concepts here + $conceptlist = array(); + $excluded = false; + foreach ($allconcepts as $concepts) { foreach ($concepts as $concept) { - $glossaryname = str_replace(':', '-', $glossaries[$concept->glossaryid]); - if ($concept->category) { // Link to a category - // TODO: Fix this string usage - $title = strip_tags($glossaryname.': '.$strcategory.' '.$concept->concept); - $href_tag_begin = 'wwwroot.'/mod/glossary/view.php?g='.$concept->glossaryid. - '&mode=cat&hook='.$concept->id.'">'; + if (!empty($GLOSSARY_EXCLUDEENTRY) and $concept->id == $GLOSSARY_EXCLUDEENTRY) { + $excluded = true; + continue; + } + if ($concept->category) { // Link to a category. + // TODO: Fix this string usage. + $title = $glossaries[$concept->glossaryid] . ': ' . $strcategory . ' ' . $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); + } else { // Link to entry or alias - if (!empty($concept->originalconcept)) { // We are dealing with an alias (so show and point to original) - $title = str_replace('"', "'", html_entity_decode( - strip_tags($glossaryname.': '.$concept->originalconcept))); - $concept->id = $concept->entryid; - } else { // This is an entry - // We need to remove entities from the content here because it - // will be escaped by html_writer below. - $title = str_replace('"', "'", html_entity_decode( - strip_tags($glossaryname.': '.$concept->concept))); - } - // hardcoding dictionary format in the URL rather than defaulting + $title = $glossaries[$concept->glossaryid] . ': ' . $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('courseid'=>$courseid, 'eid'=>$concept->id, 'displayformat'=>'dictionary')); + $link = new moodle_url('/mod/glossary/showentry.php', array('eid' => $concept->id, 'displayformat' => 'dictionary')); $attributes = array( - 'href' => $link, - 'title'=> $title, - '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 (isset($CFG->embeddedsoforcelinktarget) && $CFG->embeddedsoforcelinktarget) { - $attributes['target'] = '_top'; - } - - $href_tag_begin = html_writer::start_tag('a', $attributes); + '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'; + } + $href_tag_begin = html_writer::start_tag('a', $attributes); + $conceptlist[] = new filterobject($concept->concept, $href_tag_begin, '', $concept->casesensitive, $concept->fullmatch); } - - $conceptlist = filter_remove_duplicates($conceptlist); } - if (!empty($GLOSSARY_EXCLUDECONCEPTS)) { - $reducedconceptlist=array(); - foreach($conceptlist as $concept) { - if(!in_array($concept->phrase,$GLOSSARY_EXCLUDECONCEPTS)) { - $reducedconceptlist[]=$concept; - } - } - return filter_phrases($text, $reducedconceptlist); + usort($conceptlist, 'filter_glossary::sort_entries_by_length'); + + if (!$excluded) { + // Do not cache the excluded list here, it is used once per page only. + $this->cacheuserid = $USER->id; + $this->cachecourseid = $courseid; + $this->cacheconceptlist = $conceptlist; } + if (empty($conceptlist)) { + return $text; + } return filter_phrases($text, $conceptlist); // Actually search for concepts! } - private static function sort_entries_by_length($entry0, $entry1) { - $len0 = strlen($entry0->concept); - $len1 = strlen($entry1->concept); + $len0 = strlen($entry0->phrase); + $len1 = strlen($entry1->phrase); if ($len0 < $len1) { return 1; diff --git a/filter/glossary/tests/filter_test.php b/filter/glossary/tests/filter_test.php index c8f396689b9..ccf54eda2ad 100644 --- a/filter/glossary/tests/filter_test.php +++ b/filter/glossary/tests/filter_test.php @@ -53,18 +53,22 @@ class filter_glossary_filter_testcase extends advanced_testcase { array('course' => $course->id, 'mainglossary' => 1)); // Create two entries with ampersands and one normal entry. + /** @var mod_glossary_generator $generator */ $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary'); $normal = $generator->create_content($glossary, array('concept' => 'normal')); $amp1 = $generator->create_content($glossary, array('concept' => 'A&B')); $amp2 = $generator->create_content($glossary, array('concept' => 'C&D')); + filter_manager::reset_caches(); + \mod_glossary\local\concept_cache::reset_caches(); + // Format text with all three entries in HTML. $html = '
A&B C&D normal
'; $filtered = format_text($html, FORMAT_HTML, array('context' => $context)); // Find all the glossary links in the result. $matches = array(); - preg_match_all('~courseid=' . $course->id . '&eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches); + preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches); // There should be 3 glossary links. $this->assertEquals(3, count($matches[1])); diff --git a/mod/glossary/approve.php b/mod/glossary/approve.php index f1028d49319..c2a0ea23d32 100644 --- a/mod/glossary/approve.php +++ b/mod/glossary/approve.php @@ -49,6 +49,11 @@ if (($newstate != $entry->approved) && confirm_sesskey()) { if ($completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && $glossary->completionentries) { $completion->update_state($cm, COMPLETION_COMPLETE, $entry->userid); } + + // Reset caches. + if ($entry->usedynalink) { + \mod_glossary\local\concept_cache::reset_glossary($glossary); + } } redirect("view.php?id=$cm->id&mode=$mode&hook=$hook"); diff --git a/mod/glossary/classes/local/concept_cache.php b/mod/glossary/classes/local/concept_cache.php new file mode 100644 index 00000000000..91ead6d100a --- /dev/null +++ b/mod/glossary/classes/local/concept_cache.php @@ -0,0 +1,279 @@ +. + +/** + * Entry caching for glossary filter. + * + * @package mod_glossary + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_glossary\local; +defined('MOODLE_INTERNAL') || die(); + +/** + * Concept caching for glossary filter. + * + * @package mod_glossary + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class concept_cache { + /** + * Event observer, do not call directly. + * @param \core\event\course_module_updated $event + */ + public static function cm_updated(\core\event\course_module_updated $event) { + if ($event->other['modulename'] !== 'glossary') { + return; + } + // We do not know what changed exactly, so let's reset everything that might be affected. + concept_cache::reset_course_muc($event->courseid); + concept_cache::reset_global_muc(); + } + + /** + * Reset concept related caches. + * @param bool $phpunitreset + */ + public static function reset_caches($phpunitreset = false) { + if ($phpunitreset) { + return; + } + $cache = \cache::make('mod_glossary', 'concepts'); + $cache->purge(); + } + + /** + * Reset the cache for course concepts. + * @param int $courseid + */ + public static function reset_course_muc($courseid) { + if (empty($courseid)) { + return; + } + $cache = \cache::make('mod_glossary', 'concepts'); + $cache->delete((int)$courseid); + } + + /** + * Reset the cache for global concepts. + */ + public static function reset_global_muc() { + $cache = \cache::make('mod_glossary', 'concepts'); + $cache->delete(0); + } + + /** + * Utility method to purge caches related to given glossary. + * @param \stdClass $glossary + */ + public static function reset_glossary($glossary) { + if (!$glossary->usedynalink) { + return; + } + self::reset_course_muc($glossary->course); + if ($glossary->globalglossary) { + self::reset_global_muc(); + } + } + + /** + * Fetch concepts for given glossaries. + * @param int[] $glossaries + * @return array + */ + protected static function fetch_concepts(array $glossaries) { + global $DB; + + $glossarylist = implode(',', $glossaries); + + $sql = "SELECT id, glossaryid, concept, casesensitive, 0 AS category, fullmatch + FROM {glossary_entries} + WHERE glossaryid IN ($glossarylist) AND usedynalink = 1 AND approved = 1 + + UNION + + SELECT id, glossaryid, name AS concept, 1 AS casesensitive, 1 AS category, 1 AS fullmatch + FROM {glossary_categories} + WHERE glossaryid IN ($glossarylist) AND usedynalink = 1 + + UNION + + SELECT ge.id, ge.glossaryid, ga.alias AS concept, ge.casesensitive, 0 AS category, ge.fullmatch + FROM {glossary_alias} ga + JOIN {glossary_entries} ge ON (ga.entryid = ge.id) + WHERE ge.glossaryid IN ($glossarylist) AND ge.usedynalink = 1 AND ge.approved = 1"; + + $concepts = array(); + $rs = $DB->get_recordset_sql($sql); + foreach ($rs as $concept) { + $currentconcept = trim(strip_tags($concept->concept)); + + // Concept must be HTML-escaped, so do the same as format_string to turn ampersands into &. + $currentconcept = replace_ampersands_not_followed_by_entity($currentconcept); + + if (empty($currentconcept)) { + continue; + } + + // Rule out any small integers, see MDL-1446. + if (is_number($currentconcept) and $currentconcept < 1000) { + continue; + } + + $concept->concept = $currentconcept; + + $concepts[$concept->glossaryid][] = $concept; + } + $rs->close(); + + return $concepts; + } + + /** + * Get all linked concepts from course. + * @param \stdClass $course + * @return array + */ + protected static function get_course_concepts($course) { + global $DB; + + if (empty($course->id)) { + return array(array(), array()); + } + + $cache = \cache::make('mod_glossary', 'concepts'); + $data = $cache->get((int)$course->id); + if (is_array($data)) { + list($glossaries, $allconcepts) = $data; + + } else { + // Find all course glossaries. + $sql = "SELECT g.id, g.name + FROM {glossary} g + JOIN {course_modules} cm ON (cm.instance = g.id) + JOIN {modules} m ON (m.name = 'glossary' AND m.id = cm.module) + WHERE g.usedynalink = 1 AND g.course = :course AND cm.visible = 1 AND m.visible = 1 + ORDER BY g.globalglossary, g.id"; + $glossaries = $DB->get_records_sql_menu($sql, array('course' => $course->id)); + if (!$glossaries) { + $data = array(array(), array()); + $cache->set((int)$course->id, $data); + return $data; + } + foreach ($glossaries as $id => $name) { + $name = str_replace(':', '-', $name); + $glossaries[$id] = replace_ampersands_not_followed_by_entity(strip_tags($name)); + } + + $allconcepts = self::fetch_concepts(array_keys($glossaries)); + foreach ($glossaries as $gid => $unused) { + if (!isset($allconcepts[$gid])) { + unset($glossaries[$gid]); + } + } + $cache->set((int)$course->id, array($glossaries, $allconcepts)); + } + + $concepts = $allconcepts; + + // Verify access control to glossary instances. + $modinfo = get_fast_modinfo($course); + $cminfos = $modinfo->get_instances_of('glossary'); + foreach ($concepts as $modid => $unused) { + if (!isset($cminfos[$modid])) { + // This should not happen. + unset($concepts[$modinfo]); + continue; + } + if (!$cminfos[$modid]->uservisible) { + unset($concepts[$modinfo]); + continue; + } + } + + return array($glossaries, $concepts); + } + + /** + * Get all linked global concepts. + * @return array + */ + protected static function get_global_concepts() { + global $DB; + + $cache = \cache::make('mod_glossary', 'concepts'); + $data = $cache->get(0); + if (is_array($data)) { + list($glossaries, $allconcepts) = $data; + + } else { + // Find all global glossaries - no access control here. + $sql = "SELECT g.id, g.name + FROM {glossary} g + JOIN {course_modules} cm ON (cm.instance = g.id) + JOIN {modules} m ON (m.name = 'glossary' AND m.id = cm.module) + WHERE g.usedynalink = 1 AND g.globalglossary = 1 AND cm.visible = 1 AND m.visible = 1 + ORDER BY g.globalglossary, g.id"; + $glossaries = $DB->get_records_sql_menu($sql); + if (!$glossaries) { + $data = array(array(), array()); + $cache->set(0, $data); + return $data; + } + foreach ($glossaries as $id => $name) { + $name = str_replace(':', '-', $name); + $glossaries[$id] = replace_ampersands_not_followed_by_entity(strip_tags($name)); + } + $allconcepts = self::fetch_concepts(array_keys($glossaries)); + foreach ($glossaries as $gid => $unused) { + if (!isset($allconcepts[$gid])) { + unset($glossaries[$gid]); + } + } + $cache->set(0, array($glossaries, $allconcepts)); + } + + // NOTE: no access control is here because it would be way too expensive to check access + // to all courses that contain the global glossaries. + return array($glossaries, $allconcepts); + } + + /** + * Get all concepts that should be linked in the given course. + * @param \stdClass $course + * @return array with two elements - array of glossaries and concepts for each glossary + */ + public static function get_concepts($course) { + list($glossaries, $concepts) = self::get_course_concepts($course); + list($globalglossaries, $globalconcepts) = self::get_global_concepts(); + + foreach ($globalconcepts as $gid => $cs) { + if (!isset($concepts[$gid])) { + $concepts[$gid] = $cs; + } + } + foreach ($globalglossaries as $gid => $name) { + if (!isset($glossaries[$gid])) { + $glossaries[$gid] = $name; + } + } + + return array($glossaries, $concepts); + } +} diff --git a/mod/glossary/db/caches.php b/mod/glossary/db/caches.php new file mode 100644 index 00000000000..d8584e1c9b2 --- /dev/null +++ b/mod/glossary/db/caches.php @@ -0,0 +1,35 @@ +. + +/** + * Glossary cache definitions. + * + * @package mod_glossary + * @category cache + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$definitions = array( + // This MUST NOT be a local cache, sorry cluster lovers. + 'concepts' => array( + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, // The course id or 0 for global. + 'simpledata' => false, + 'staticacceleration' => true, + 'staticaccelerationsize' => 30, + ), +); diff --git a/mod/glossary/db/events.php b/mod/glossary/db/events.php new file mode 100644 index 00000000000..fd8fef66315 --- /dev/null +++ b/mod/glossary/db/events.php @@ -0,0 +1,32 @@ +. + +/** + * Glossary event observer. + * + * @package mod_glossary + * @copyright 2014 Petr Skoda + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$observers = array( + array ( + 'eventname' => '\core\event\course_module_updated', + 'callback' => '\mod_glossary\local\concept_cache::cm_updated', + ), +); \ No newline at end of file diff --git a/mod/glossary/deleteentry.php b/mod/glossary/deleteentry.php index a45a6986079..7f5b64d03a7 100644 --- a/mod/glossary/deleteentry.php +++ b/mod/glossary/deleteentry.php @@ -125,6 +125,12 @@ if ($confirm and confirm_sesskey()) { // the operation was confirmed. )); $event->add_record_snapshot('glossary_entries', $origentry); $event->trigger(); + + // Reset caches. + if ($entry->usedynalink and $entry->approved) { + \mod_glossary\local\concept_cache::reset_glossary($glossary); + } + redirect("view.php?id=$cm->id&mode=$prevmode&hook=$hook"); } else { // the operation has not been confirmed yet so ask the user to do so diff --git a/mod/glossary/edit.php b/mod/glossary/edit.php index 4cec1102d25..3ccb42c3f5d 100644 --- a/mod/glossary/edit.php +++ b/mod/glossary/edit.php @@ -186,6 +186,17 @@ if ($mform->is_cancelled()){ } } + // Reset caches. + if ($isnewentry) { + if ($entry->usedynalink and $entry->approved) { + \mod_glossary\local\concept_cache::reset_glossary($glossary); + } + } else { + // So many things may affect the linking, let's just purge the cache always on edit. + \mod_glossary\local\concept_cache::reset_glossary($glossary); + } + + redirect("view.php?id=$cm->id&mode=entry&hook=$entry->id"); } diff --git a/mod/glossary/editcategories.php b/mod/glossary/editcategories.php index 6e23158a120..c32444711d3 100644 --- a/mod/glossary/editcategories.php +++ b/mod/glossary/editcategories.php @@ -111,6 +111,9 @@ if ( $hook >0 ) { $event->add_record_snapshot('glossary', $glossary); $event->trigger(); + // Reset caches. + \mod_glossary\local\concept_cache::reset_glossary($glossary); + } else { echo $OUTPUT->header(); echo $OUTPUT->heading(format_string($glossary->name), 2); @@ -136,6 +139,9 @@ if ( $hook >0 ) { $event->add_record_snapshot('glossary', $glossary); $event->trigger(); + // Reset caches. + \mod_glossary\local\concept_cache::reset_glossary($glossary); + redirect("editcategories.php?id=$cm->id", get_string("categorydeleted", "glossary"), 2); } else { echo $OUTPUT->header(); @@ -201,6 +207,9 @@ if ( $hook >0 ) { $event->add_record_snapshot('glossary_categories', $cat); $event->add_record_snapshot('glossary', $glossary); $event->trigger(); + + // Reset caches. + \mod_glossary\local\concept_cache::reset_glossary($glossary); } } else { echo $OUTPUT->header(); diff --git a/mod/glossary/import.php b/mod/glossary/import.php index 39c7129b701..cb1d1310f6b 100644 --- a/mod/glossary/import.php +++ b/mod/glossary/import.php @@ -272,6 +272,10 @@ if ($xml = glossary_read_imported_file($result)) { } } } + + // Reset caches. + \mod_glossary\local\concept_cache::reset_glossary($glossary); + // processed entries echo $OUTPUT->box_start('glossarydisplay generalbox'); echo '