diff --git a/assets/css/app.scss b/assets/css/app.scss index b66717650b1..9d7034ce0a1 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -985,6 +985,34 @@ img.course-tool__icon { } .app-breadcrumb .p-breadcrumb-separator { padding-inline: .25rem; } +// Glossary auto-highlighted term +.glossary-term { + position: relative; + cursor: help; + font-weight: 500; + color: #2563eb; + border-bottom: 1px dotted currentColor; + text-decoration: none; + + &::after { + content: "ⓘ"; + font-size: 0.7em; + margin-left: 0.15rem; + vertical-align: super; + opacity: 0.7; + } + + &:hover { + color: #1d4ed8; + border-bottom-style: solid; + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } +} + @import "~@fancyapps/fancybox/dist/jquery.fancybox.css"; @import "~timepicker/jquery.timepicker.min.css"; @import "~qtip2/dist/jquery.qtip.min.css"; diff --git a/assets/js/glossary-auto.js b/assets/js/glossary-auto.js new file mode 100644 index 00000000000..d4469655397 --- /dev/null +++ b/assets/js/glossary-auto.js @@ -0,0 +1,292 @@ +/* For licensing terms, see /license.txt */ + +;(function () { + "use strict" + + console.log("[Glossary] glossary_auto.js bundle loaded") + + function getConfig() { + const cfg = window.chamiloGlossaryConfig || {} + console.log("[Glossary] Loaded with config:", cfg) + return cfg + } + + function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + } + + /** + * Normalize ApiPlatform/Chamilo glossary payload into: + * [{ term: "AFP", definition: "..." }, ...] + */ + function normalizeTermsPayload(data) { + let items = [] + + if (Array.isArray(data)) { + items = data + } else if (data && Array.isArray(data["hydra:member"])) { + items = data["hydra:member"] + } else if (data && Array.isArray(data.items)) { + items = data.items + } else { + console.warn("[Glossary] Unknown payload format, no terms extracted") + return [] + } + + const terms = items + .map((item) => { + if (!item) { + return null + } + + const term = + item.term || + item.title || + item.name || + null + + const definition = + item.definition || + item.description || + "" + + if (!term) { + return null + } + + return { + term: String(term), + definition: String(definition || ""), + } + }) + .filter(Boolean) + + console.log("[Glossary] Normalized terms:", terms) + + return terms + } + + async function fetchTerms() { + const cfg = getConfig() + + if (!cfg.termsEndpoint) { + console.warn("[Glossary] No termsEndpoint configured in chamiloGlossaryConfig") + return [] + } + + if (!cfg.resourceNodeParentId) { + console.warn("[Glossary] Missing resourceNodeParentId; skipping glossary fetch", cfg) + return [] + } + + const params = new URLSearchParams() + + params.append("resourceNode.parent", cfg.resourceNodeParentId) + + if (cfg.courseId) { + params.append("cid", cfg.courseId) + } + + if (cfg.sessionId) { + params.append("sid", cfg.sessionId) + } + + const url = + cfg.termsEndpoint + + (cfg.termsEndpoint.includes("?") ? "&" : "?") + + params.toString() + + try { + const response = await fetch(url, { + credentials: "same-origin", + headers: { + "X-Requested-With": "XMLHttpRequest", + Accept: "application/json", + }, + }) + + if (!response.ok) { + console.error("[Glossary] Failed to fetch terms, status:", response.status) + return [] + } + + const raw = await response.json() + return normalizeTermsPayload(raw) + } catch (error) { + console.error("[Glossary] Error while fetching terms", error) + return [] + } + } + + function getTextNodesUnder(root) { + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + if (!node || !node.nodeValue || !node.nodeValue.trim()) { + return NodeFilter.FILTER_REJECT + } + + const parent = node.parentNode + if (!parent) { + return NodeFilter.FILTER_REJECT + } + + const tagName = parent.nodeName.toLowerCase() + + if ( + tagName === "script" || + tagName === "style" || + tagName === "noscript" || + tagName === "textarea" || + tagName === "iframe" + ) { + return NodeFilter.FILTER_REJECT + } + + if (parent.classList && parent.classList.contains("glossary-term")) { + return NodeFilter.FILTER_REJECT + } + + return NodeFilter.FILTER_ACCEPT + }, + }, + false, + ) + + const nodes = [] + let current + while ((current = walker.nextNode())) { + nodes.push(current) + } + return nodes + } + + function applyGlossary(terms) { + if (!terms || !terms.length) { + console.log("[Glossary] No terms to apply") + return + } + + const map = new Map() + const patternParts = [] + + terms.forEach((item) => { + if (!item || !item.term) { + return + } + + const term = String(item.term) + const normalized = term.toLowerCase() + map.set(normalized, { + term, + definition: item.definition || "", + }) + patternParts.push(escapeRegExp(term)) + }) + + if (!patternParts.length) { + console.log("[Glossary] No valid terms after normalization") + return + } + + const pattern = "\\b(" + patternParts.join("|") + ")\\b" + const regex = new RegExp(pattern, "gi") + + console.log("[Glossary] Applying glossary with pattern:", regex) + + const textNodes = getTextNodesUnder(document.body) + textNodes.forEach((node) => { + const text = node.nodeValue + if (!regex.test(text)) { + regex.lastIndex = 0 + return + } + + regex.lastIndex = 0 + const fragment = document.createDocumentFragment() + let lastIndex = 0 + let match + + while ((match = regex.exec(text)) !== null) { + const matchText = match[0] + const index = match.index + + if (index > lastIndex) { + fragment.appendChild( + document.createTextNode(text.slice(lastIndex, index)), + ) + } + + const normalized = matchText.toLowerCase() + const info = map.get(normalized) + + const span = document.createElement("span") + span.className = "glossary-term" + span.textContent = matchText + + if (info) { + span.dataset.glossaryTerm = info.term + if (info.definition) { + span.setAttribute("title", info.definition) + } + } + + fragment.appendChild(span) + lastIndex = index + matchText.length + } + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))) + } + + if (node.parentNode) { + node.parentNode.replaceChild(fragment, node) + } + }) + + if (window.jQuery && jQuery.fn && typeof jQuery.fn.qtip === "function") { + jQuery(".glossary-term[title]").qtip({ + content: { + attr: "title", + }, + style: { + classes: "qtip-light qtip-shadow", + }, + position: { + my: "top center", + at: "bottom center", + }, + }) + } + } + + async function initGlossary() { + console.log("[Glossary] initGlossary() called") + + const cfg = getConfig() + if (!cfg.termsEndpoint) { + console.warn("[Glossary] termsEndpoint not set, glossary auto-link disabled") + return + } + + try { + const terms = await fetchTerms() + if (!terms.length) { + console.log("[Glossary] No terms returned from API") + return + } + + applyGlossary(terms) + } catch (e) { + console.error("[Glossary] Unexpected error in initGlossary", e) + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initGlossary) + } else { + initGlossary() + } +})() diff --git a/public/main/exercise/exercise_submit.php b/public/main/exercise/exercise_submit.php index c372a9af9b7..dd16b13971e 100644 --- a/public/main/exercise/exercise_submit.php +++ b/public/main/exercise/exercise_submit.php @@ -54,8 +54,11 @@ $showGlossary = in_array($glossaryExtraTools, ['true', 'lp', 'exercise_and_lp']); } if ($showGlossary) { - $htmlHeadXtra[] = ''; - $htmlHeadXtra[] = api_get_js('jquery.highlight.js'); + $htmlHeadXtra[] = api_get_glossary_auto_snippet( + api_get_course_int_id(), + api_get_session_id(), + null + ); } $htmlHeadXtra[] = api_get_build_js('legacy_exercise.js'); $htmlHeadXtra[] = ''; diff --git a/public/main/inc/lib/api.lib.php b/public/main/inc/lib/api.lib.php index 850e6967dd3..df8daf3c7e9 100644 --- a/public/main/inc/lib/api.lib.php +++ b/public/main/inc/lib/api.lib.php @@ -7538,3 +7538,38 @@ function api_email_reached_registration_limit(string $email): bool return $count >= $limit; } +/** + * Build the HTML snippet required to bootstrap the automatic glossary tooltips. + */ +function api_get_glossary_auto_snippet(?int $courseId, ?int $sessionId, ?int $resourceNodeParentId = null): string +{ + if (null === $resourceNodeParentId && $courseId) { + try { + $courseEntity = Container::getCourseRepository()->find($courseId); + + if ($courseEntity && $courseEntity->getResourceNode()) { + $resourceNodeParentId = (int) $courseEntity->getResourceNode()->getId(); + } + } catch (\Throwable $exception) { + error_log('[Glossary] Failed to resolve resourceNodeParentId from course: '.$exception->getMessage()); + } + } + + $course = $courseId ?: 'null'; + $session = $sessionId ?: 'null'; + $parent = $resourceNodeParentId ?: 'null'; + + return ' + + ' . api_get_build_js("glossary_auto.js") . ' + '; +} + + diff --git a/public/main/lp/lp_controller.php b/public/main/lp/lp_controller.php index f6c7c1e6a8c..a4ea7019bb4 100644 --- a/public/main/lp/lp_controller.php +++ b/public/main/lp/lp_controller.php @@ -56,20 +56,26 @@ $qs['isStudentView'] = Security::remove_XSS($_GET['isStudentView']); } $listUrl = api_get_path(WEB_PATH).'resources/lp/'.$nodeId.'?'.http_build_query($qs); -$glossaryExtraTools = api_get_setting('show_glossary_in_extra_tools'); -$showGlossary = in_array($glossaryExtraTools, ['true', 'lp', 'exercise_and_lp']); +$glossaryExtraTools = api_get_setting('glossary.show_glossary_in_extra_tools'); +$glossaryDocumentsMode = api_get_setting('document.show_glossary_in_documents'); +$glossaryDocumentsEnabled = in_array( + $glossaryDocumentsMode, + ['ismanual', 'isautomatic'], + true +); + +$showGlossary = $glossaryDocumentsEnabled && in_array( + $glossaryExtraTools, + ['true', 'lp', 'exercise_and_lp'], + true + ); + if ($showGlossary) { - if ('ismanual' === api_get_setting('show_glossary_in_documents') || - 'isautomatic' === api_get_setting('show_glossary_in_documents') - ) { - $htmlHeadXtra[] = ''; - $htmlHeadXtra[] = ''; - $htmlHeadXtra[] = ''; - } + $htmlHeadXtra[] = api_get_glossary_auto_snippet( + (int) $courseId, + $sessionId ?: null, + null + ); } $ajax_url = api_get_path(WEB_AJAX_PATH).'lp.ajax.php?lp_id='.$lpId.'&'.api_get_cidreq(); diff --git a/src/CoreBundle/Controller/ResourceController.php b/src/CoreBundle/Controller/ResourceController.php index 055c6f5a1e1..584002a9e8b 100644 --- a/src/CoreBundle/Controller/ResourceController.php +++ b/src/CoreBundle/Controller/ResourceController.php @@ -654,6 +654,8 @@ private function processFile(Request $request, ResourceNode $resourceNode, strin $content = str_replace($tagsToReplace, $replacementValues, $content); } + $content = $this->injectGlossaryJs($request, $content, $resourceNode); + $response = new Response(); $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_INLINE, @@ -662,7 +664,7 @@ private function processFile(Request $request, ResourceNode $resourceNode, strin $response->headers->set('Content-Disposition', $disposition); $response->headers->set('Content-Type', 'text/html; charset=UTF-8'); - // @todo move into a function/class + // Existing translate_html logic if ('true' === $this->getSettingsManager()->getSetting('editor.translate_html')) { $user = $this->userHelper->getCurrent(); if (null !== $user) { @@ -706,6 +708,128 @@ function () use ($resourceNodeRepo, $resourceFile, $start, $length): void { return $response; } + private function injectGlossaryJs( + Request $request, + string $content, + ?ResourceNode $resourceNode = null + ): string { + // First normalize broken HTML coming from templates/editors + $content = $this->normalizeGeneratedHtml($content); + + $tool = (string) $request->attributes->get('tool'); + + if ('document' !== $tool) { + return $content; + } + + $settingsManager = $this->getSettingsManager(); + $glossaryDocuments = $settingsManager->getSetting('document.show_glossary_in_documents'); + + if ('isautomatic' !== $glossaryDocuments) { + return $content; + } + + $course = $this->getCourse(); + $session = $this->getSession(); + + $resourceNodeParentId = null; + if ($resourceNode && $resourceNode->getParent()) { + $resourceNodeParentId = $resourceNode->getParent()->getId(); + } + + $jsConfig = $this->renderView( + '@ChamiloCore/Glossary/glossary_auto.html.twig', + [ + 'course' => $course, + 'session' => $session, + 'resourceNodeParentId' => $resourceNodeParentId, + ] + ); + + if (false !== stripos($content, '
...')) { + return str_ireplace('', $jsConfig.'', $content); + } + + return $content.$jsConfig; + } + + + /** + * Normalize generated HTML documents coming from templates/editors. + * + * This method tries to fix the pattern: + *
......
...