Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
292 changes: 292 additions & 0 deletions assets/js/glossary-auto.js
Original file line number Diff line number Diff line change
@@ -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()
}
})()
7 changes: 5 additions & 2 deletions public/main/exercise/exercise_submit.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@
$showGlossary = in_array($glossaryExtraTools, ['true', 'lp', 'exercise_and_lp']);
}
if ($showGlossary) {
$htmlHeadXtra[] = '<script src="'.api_get_path(WEB_CODE_PATH).'glossary/glossary.js.php?add_ready=1&'.api_get_cidreq().'"></script>';
$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[] = '<link rel="stylesheet" href="'.api_get_path(WEB_LIBRARY_JS_PATH).'hotspot/css/hotspot.css">';
Expand Down
35 changes: 35 additions & 0 deletions public/main/inc/lib/api.lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '
<script>
window.chamiloGlossaryConfig = {
courseId: ' . $course . ',
sessionId: ' . $session . ',
resourceNodeParentId: ' . $parent . ',
termsEndpoint: "/api/glossaries"
};
</script>
' . api_get_build_js("glossary_auto.js") . '
';
}


Loading
Loading