您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances Moodle participants pages with CSV/JSON export, a print-friendly view, and role-based highlighting.
// ==UserScript== // @name Improvements to "Participants" // @name:ca Millores a "Participants" // @name:en Improvements to "Participants" // @name:es Mejoras en "Participantes" // @version 1.0.1 // @author Antonio Bueno <[email protected]> // @description Enhances Moodle participants pages with CSV/JSON export, a print-friendly view, and role-based highlighting. // @description:ca Millora les pàgines de participants de Moodle amb exportació CSV/JSON, una vista per imprimir i ressaltat segons el rol. // @description:en Enhances Moodle participants pages with CSV/JSON export, a print-friendly view, and role-based highlighting. // @description:es Mejora las páginas de participantes de Moodle con exportación CSV/JSON, una vista para imprimir, y resaltado según el rol. // @license MIT // @namespace https://github.com/buenoudg/Ajudant-UdGMoodle // @supportURL https://github.com/buenoudg/Ajudant-UdGMoodle/issues // @match https://moodle.udg.edu/user/* // @match https://moodle2.udg.edu/user/* // @icon https://raw.githubusercontent.com/buenoudg/Ajudant-UdGMoodle/master/udgmoodle_icon_38x38%402x.png // @run-at document-idle // @noframes // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // ==/UserScript== /* ---------------------------------------------------------------------- Improvements to "Participants" (v1.0.0, 2025-10-08) Author: Antonio Bueno <[email protected]> Enhancements: - CSV/JSON export of participant list - Print-friendly, editable view - Role-based coloring (persistent) - Optional higher-quality photos (persistent) - Locale-aware UI (ca, es, en) - Resilient toolbar re-injection when Moodle updates the DOM - Tested with Violentmonkey (recommended) and Tampermonkey in Firefox and Vivaldi Repository: https://github.com/buenoudg/Ajudant-UdGMoodle Changelog: https://github.com/buenoudg/Ajudant-UdGMoodle/releases v1.0.1 (2025-10-09) Compatible with Safari via the Userscripts extension (macOS, iPadOS, iOS). ---------------------------------------------------------------------- */ (() => { 'use strict'; // -------------------------------- // GM_* polyfills for Safari // -------------------------------- if (typeof GM_getValue === 'undefined') { window.GM_getValue = function (name, defaultValue) { const raw = localStorage.getItem(name); if (raw === null) return defaultValue; return JSON.parse(raw); }; console.log("GM_getValue() polyfilled"); } if (typeof GM_setValue === 'undefined') { window.GM_setValue = function (name, value) { localStorage.setItem(name, JSON.stringify(value)); }; console.log("GM_setValue() polyfilled"); } if (typeof GM_addStyle === 'undefined') { window.GM_addStyle = function (css) { const el = document.createElement('style'); el.type = 'text/css'; el.appendChild(document.createTextNode(css)); (document.head || document.documentElement).appendChild(el); return el; }; console.log("GM_addStyle() polyfilled"); } // -------------------------------- // LOCALE DETECTION // -------------------------------- const langAttr = (document.documentElement.getAttribute('lang') || '').toLowerCase(); const locale = /^ca\b/.test(langAttr) ? 'ca' : /^es\b/.test(langAttr) ? 'es' : 'en'; // -------------------------------- // CSS (via GM_addStyle) // -------------------------------- const ROLE_COLOR_CSS = ` /* Roles-based background colors (scoped to .roles-colored) */ .roles-colored tr.professor, .roles-colored tr.professor-no-editor { background-color: #EDC !important; } .roles-colored tr.coordinador, .roles-colored tr.sotsdirector { background-color: #CDE !important; } .roles-colored tr.inactive { background-color: #F99 !important; } .roles-colored tr.inactive .bg-warning { background-color: #FE0 !important; } `; GM_addStyle(` /* Toolbar layout */ .participants-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0 0 0.5rem 0; } .participants-toolbar label { display: inline-flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; margin-left: 0.5rem; user-select: none; } ${ROLE_COLOR_CSS} /* Placeholder for the improved photos styles */ #participants [srcset] { background-color: gray; color: white; font-weight: bold; width: 50px; height: 50px; border-radius: 10%; } `); // -------------------------------- // FIELD KEYS (for JSON/CSV output) // -------------------------------- const FIELD_KEYS = { photo: 'photo_url', name: ['surname', 'name'], id: 'univ_id', email: 'email', roles: 'roles', groups: 'groups', active: 'active', }; // -------------------------------- // I18N // -------------------------------- const I18N = { en: { csv: 'Download CSV', json: 'Download JSON', colorByRoles: 'Color by roles', print: 'Print view', improvePhotos: 'Improve photos' }, es: { csv: 'Descargar CSV', json: 'Descargar JSON', colorByRoles: 'Colorear por roles', print: 'Vista para imprimir', improvePhotos: 'Mejorar fotos' }, ca: { csv: 'Descarrega CSV', json: 'Descarrega JSON', colorByRoles: 'Coloreja per rols', print: 'Vista per imprimir', improvePhotos: 'Millora fotos' } }[locale]; // -------------------------------- // MICRO HELPERS // -------------------------------- const normalizeWhitespace = (s) => (s || '').replace(/\s+/g, ' ').trim(); const normalizeLabelKey = (s) => s.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/\s+/g,' ').trim(); const csvEscape = (s, delimiter = ';') => { const t = (s ?? '').toString(); const mustQuote = t.includes('"') || t.includes('\n') || t.includes(delimiter); return mustQuote ? `"${t.replace(/"/g, '""')}"` : t; }; const getCellText = (td) => normalizeWhitespace(td?.textContent || ''); const splitComma = (s) => s ? s.split(',').map(v => normalizeWhitespace(v)).filter(Boolean) : []; // Safe filename from <h1> and filters function safeFilename(extension) { // Get and sanitize course title const courseTitle = document.querySelector('h1').textContent.trim() .replace(/[\\/:*?"<>|]+/g, '-') .replace(/\s+/g, ' ') .trim(); // Collect active filters const filters = [...document.querySelectorAll('div[data-filterregion="value"] span.badge')] .map(s => s.textContent.trim()) .filter(Boolean); // Build filter text (if any) const filterText = filters.length ? filters.join(', ') : 'participants'; // Add date suffix (yyyy-MM-dd) const dateSuffix = (() => { const d = new Date(); return d.toISOString().split('T')[0]; // e.g. 2025-10-07 })(); // Assemble the final filename return `${courseTitle} ${filterText} (${dateSuffix}).${extension}`.trim(); } // Blob download helper function saveTextFile(filename, text, mime = 'text/plain;charset=utf-8') { const blob = new Blob([text], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } const createButton = (id, text) => { const t = document.createElement('template'); t.innerHTML = `<button id="${id}" type="button" class="btn btn-primary">${text}</button>`; return t.content.firstChild; }; function upgradePhotos(table) { table.querySelectorAll('img.userpicture, span.userinitials').forEach(elem => { elem.setAttribute('srcset', elem.src?elem.src.replace('/f2', '/f1'):''); }); } function downgradePhotos(table) { table.querySelectorAll("img.userpicture, span.userinitials").forEach(elem => { elem.removeAttribute("srcset"); }); } // -------------------------------- // NAME / PHOTO UTILITIES // -------------------------------- /** * Extract human-friendly full name from the "fullname" cell. * Prefers aria/tooltip on .userinitials; strips badges/images. * @param {HTMLElement} cell * @returns {string} */ function getFullNameTextFromCell(cell) { const anchor = cell?.querySelector('a'); if (anchor) { const initials = anchor.querySelector('.userinitials'); const labeled = initials && initials.getAttribute('title'); if (labeled) return normalizeWhitespace(labeled); const cleaned = anchor.cloneNode(true); cleaned.querySelectorAll('.userinitials, img').forEach((n) => n.remove()); return normalizeWhitespace(cleaned.textContent || ''); } return normalizeWhitespace(cell?.innerText || ''); } /** * Split "Surname, Name" into [surname, name]. * If no comma is present, returns [s, '']. * @param {string} s * @returns {[string,string]} */ function splitSurnameAndName(s) { const i = s.indexOf(','); if (i === -1) return [s, '']; return [normalizeWhitespace(s.slice(0, i)), normalizeWhitespace(s.slice(i + 1))]; } /** * Collect comma-separated text from chips/links/list items; fallback to cell text. * @param {HTMLElement} cell * @returns {string} */ function getMultiItemText(cell) { if (!cell) return ''; const items = [...cell.querySelectorAll('a, .badge, .chip, li')] .map((n) => normalizeWhitespace(n.textContent || '')) .filter(Boolean); const fallback = normalizeWhitespace(cell.innerText || ''); return items.length ? items.join(', ') : fallback; } // Identify empty data rows function isRowEmpty(row, cells) { if (row.classList.contains('emptyrow')) return true; if (row.closest('thead')) return false; return cells.slice(1).every((c) => normalizeWhitespace(c.innerText || '') === ''); } // -------------------------------- // COLUMN DETECTION & VISIBILITY // -------------------------------- /** * Detect column indexes using <th> “Hide” controls first, then sort links as fallback. * @param {HTMLTableElement} table * @returns {{nameCol:number,idCol:number,emailCol:number,rolesCol:number,groupsCol:number,statusCol:number,headCells:HTMLElement[]}} */ function getColumnIndexMap(table) { const headRow = table.querySelector('thead tr'); const headCells = headRow ? [...headRow.children] : []; const indexOfTh = (th) => (th ? headCells.indexOf(th) : -1); const byHideControl = (key) => headRow?.querySelector(`th .commands a[data-action="hide"][data-column="${key}"]`)?.closest('th') || null; const bySortLink = (key) => headRow?.querySelector(`th a[data-sortby="${key}"]`)?.closest('th') || null; const thFullname = byHideControl('fullname') || bySortLink('lastname') || bySortLink('firstname'); const thId = byHideControl('idnumber') || bySortLink('idnumber'); const thEmail = byHideControl('email') || bySortLink('email'); const thRoles = byHideControl('roles'); const thGroups = byHideControl('groups'); const thStatus = byHideControl('status'); return { nameCol: indexOfTh(thFullname), idCol: indexOfTh(thId), emailCol: indexOfTh(thEmail), rolesCol: indexOfTh(thRoles), groupsCol: indexOfTh(thGroups), statusCol: indexOfTh(thStatus), headCells, }; } function isThVisible(th) { if (!th) return false; return !!th.querySelector('.commands a[data-action="hide"][data-column]'); } /** * Visibility snapshot. * A column is considered visible when its <th> shows a “Hide” control. * @param {HTMLTableElement} table * @param {ReturnType<typeof getColumnIndexMap>} idx * @returns {{name:boolean,id:boolean,email:boolean,roles:boolean,groups:boolean,status:boolean}} */ function getVisibleColumns(table, idx) { const at = (i) => idx.headCells?.[i] || null; return { name: idx.nameCol >= 0 && isThVisible(at(idx.nameCol)), id: idx.idCol >= 0 && isThVisible(at(idx.idCol)), email: idx.emailCol >= 0 && isThVisible(at(idx.emailCol)), roles: idx.rolesCol >= 0 && isThVisible(at(idx.rolesCol)), groups: idx.groupsCol >= 0 && isThVisible(at(idx.groupsCol)), status: idx.statusCol >= 0 && isThVisible(at(idx.statusCol)), }; } // -------------------------------- // LOOKUPS FROM <select> FILTERS // -------------------------------- /** * Build lookups from a Moodle filter <select>. * - labelsMap: normalized label -> {value,label} * - valueMap: value -> label * - emptyKey: normalized label for sentinel “none” (e.g., "Sin grupo") * - positiveLabelKeys: Set of normalized labels that have numeric value > 0 * * @param {'roles'|'groups'|'status'} fieldName * @returns {{labelsMap:Map<string,{value:string,label:string}>,valueMap:Map<string,string>,emptyKey:string|null,positiveLabelKeys:Set<string>}} */ function buildSelectLookup(fieldName) { const select = document.querySelector(`select[data-field-name="${fieldName}"]`); const labelsMap = new Map(); // normalized label -> { value, label } const valueMap = new Map(); // value -> label const positiveLabelKeys = new Set(); // normalized labels with value > 0 let emptyKey = null; if (!select) return { labelsMap, valueMap, emptyKey, positiveLabelKeys }; for (const opt of select.querySelectorAll('option')) { const value = opt.value; const label = normalizeWhitespace(opt.textContent || ''); const key = normalizeLabelKey(label); labelsMap.set(key, { value, label }); valueMap.set(value, label); if (value === '-1') emptyKey = key; // Track labels that correspond to real (positive) group IDs. if (/^\d+$/.test(value) && Number(value) > 0) positiveLabelKeys.add(key); } return { labelsMap, valueMap, emptyKey, positiveLabelKeys }; } // -------------------------------- // PARSE → SERIALIZE // -------------------------------- /** * Parse the visible participants rows into structured objects. * * Outputs: * - photo_url: string|null * - surname: string * - name: string * - univ_id: string * - email: string * - roles: string[] // localized labels; sentinel “none” removed * - groups: string[] // if single label is unknown or sentinel ⇒ [] * - active: boolean // true when status label matches “Active” * * Special rules: * - Groups: if a cell shows exactly one label and it’s not in the groups <select> * as a positive-ID option (or equals the select’s “none” label), export []. * * @param {HTMLTableElement} table * @returns {Array<Object>} */ function parseVisibleRows(table) { const idx = getColumnIndexMap(table); const vis = getVisibleColumns(table, idx); // Lookups for mapping const { emptyKey: rolesNoneKey } = buildSelectLookup('roles'); // Groups: we need sentinel “none” and the set of known positive-ID labels. // If a cell shows a single label that is not one of these known labels (e.g., “No hay grupos”), // we’ll treat it as “no groups” even if the wording differs from the select option. const { emptyKey: groupsNoneKey, positiveLabelKeys: knownGroupLabelKeys } = buildSelectLookup('groups'); const { valueMap: statusByValue } = buildSelectLookup('status'); const activeLabel = statusByValue?.get('0') || 'Active'; const rows = []; for (const tr of table.querySelectorAll('tbody tr')) { const cells = [...tr.querySelectorAll('th,td')]; if (!cells.length) continue; if (isRowEmpty(tr, cells)) continue; const row = {}; // Name → {photo_url, surname, name} from the "fullname" cell if (vis.name) { const nameCell = cells[idx.nameCol]; const img = nameCell?.querySelector('img'); const photoUrl = img ? img.src : null; const [surname, name] = splitSurnameAndName(getFullNameTextFromCell(nameCell)); row[FIELD_KEYS.photo] = photoUrl; row[FIELD_KEYS.name[0]] = surname; row[FIELD_KEYS.name[1]] = name; } // University ID if (vis.id) row[FIELD_KEYS.id] = getCellText(cells[idx.idCol]); // Email if (vis.email) row[FIELD_KEYS.email] = getCellText(cells[idx.emailCol]); // Roles → array of localized labels (drop sentinel “none”) if (vis.roles) { const labels = splitComma(getMultiItemText(cells[idx.rolesCol])); row[FIELD_KEYS.roles] = rolesNoneKey ? labels.filter(l => normalizeLabelKey(l) !== rolesNoneKey) : labels; } // Groups → labels[]; single “unknown/none” label ⇒ [] // (If exactly one label: [] when it equals the select’s “none” label, // or when it’s not among known positive-ID labels from the groups <select>.) if (vis.groups) { const labels = splitComma(getCellText(cells[idx.groupsCol])); if (labels.length === 1) { const k = normalizeLabelKey(labels[0]); const matchesNone = groupsNoneKey && k === groupsNoneKey; const hasLookup = knownGroupLabelKeys && knownGroupLabelKeys.size > 0; const matchesKnownPositive = hasLookup && knownGroupLabelKeys.has(k); row[FIELD_KEYS.groups] = (matchesNone || (hasLookup && !matchesKnownPositive)) ? [] : labels; } else { row[FIELD_KEYS.groups] = labels; } } // Status → active:boolean (compare with “Active” label) if (vis.status) { const statusCell = cells[idx.statusCol]; const label = (statusCell?.querySelector('div')?.getAttribute('data-status') || statusCell?.innerText || '').trim(); row[FIELD_KEYS.active] = !!(label && normalizeLabelKey(label) === normalizeLabelKey(activeLabel)); } if (Object.keys(row).length) rows.push(row); } return rows; } /** * Serialize parsed rows as pretty JSON. * @param {HTMLTableElement} table * @returns {string} JSON string */ const exportVisibleRowsAsJson = (table) => JSON.stringify(parseVisibleRows(table), null, 2); /** * Serialize parsed rows as CSV. * Header is derived from fields present in any row. * Cells are escaped with RFC4180-compatible quoting. * * @param {HTMLTableElement} table * @param {string} [delimiter=';'] // Moodle-friendly default * @returns {string} CSV text */ function exportVisibleRowsAsCsv(table, delimiter = ';') { const rows = parseVisibleRows(table); const has = (k) => rows.some(o => Object.prototype.hasOwnProperty.call(o, k)); const header = []; const includeName = has('surname') || has('name') || has('photo_url'); if (includeName) header.push('photo_url', 'surname', 'name'); if (has('univ_id')) header.push('univ_id'); if (has('email')) header.push('email'); if (has('roles')) header.push('roles'); if (has('groups')) header.push('groups'); if (has('active')) header.push('active'); const lines = [header.join(delimiter)]; for (const o of rows) { const fields = []; if (includeName) fields.push(csvEscape(o.photo_url ?? '', delimiter), csvEscape(o.surname || '', delimiter), csvEscape(o.name || '', delimiter)); if (has('univ_id')) fields.push(csvEscape(o.univ_id || '', delimiter)); if (has('email')) fields.push(csvEscape(o.email || '', delimiter)); if (has('roles')) fields.push(csvEscape((o.roles || []).join(', '), delimiter)); if (has('groups')) fields.push(csvEscape((o.groups || []).join(', '), delimiter)); if (has('active')) fields.push(csvEscape(String(!!o.active), delimiter)); lines.push(fields.join(delimiter)); } return lines.join('\r\n'); } // -------------------------------- // UI (Toolbar) // -------------------------------- /** * Ensure the toolbar exists and is wired (idempotent). * - Clones paging links. * - Adds CSV/JSON buttons. * - Adds printable button. * - Adds "color by roles" toggle. * - Adds "improve photos" toggle. * - Applies role-based row classes using detected roles column. * @param {HTMLTableElement} table */ function ensureToolbar(table) { if (document.getElementById('participants-toolbar')) return; const toolbar = document.createElement('div'); toolbar.id = 'participants-toolbar'; toolbar.classList.add('participants-toolbar'); // Clone Moodle paging links (e.g., "Show all") document.querySelectorAll('a[data-action="showcount"]').forEach(link => { const show = link.cloneNode(true); show.classList.add('btn', 'btn-primary'); toolbar.appendChild(show); }); // Action buttons const btnCsv = createButton('participants-csv-btn', I18N.csv); const btnJson = createButton('participants-json-btn', I18N.json); const btnPrint = createButton('participants-print-btn', I18N.print); // Wire up actions btnCsv.addEventListener('click', () => { saveTextFile( safeFilename('csv'), '\uFEFF' + exportVisibleRowsAsCsv(table, locale === 'en' ? ',' : ';'), 'text/csv;charset=utf-8' ); }); btnJson.addEventListener('click', () => { saveTextFile(safeFilename('json'), exportVisibleRowsAsJson(table), 'application/json;charset=utf-8'); }); btnPrint.addEventListener('click', () => { const rows = parseVisibleRows(table); if (!rows.length) return; const allRoles = new Set(rows.flatMap(r => r.roles || [])); const roleLabel = allRoles.size === 1 ? Array.from(allRoles)[0] : 'Participant'; const filename = safeFilename('csv').replace(/\.[^.]+$/, ''); // drop extension const showId = rows.some(r => 'univ_id' in r); const showEmail = rows.some(r => 'email' in r); const showGroups = rows.some(r => r.groups && r.groups.length); // Build HTML table rows const htmlRows = rows.map(r => { const roleClasses = (r.roles || []) .map(role => normalizeLabelKey(role).replace(/[^a-z0-9-]+/g, '-')) .join(' '); const activeClass = r.active ? '' : 'inactive'; const photo = (r.photo_url || '').replace('/f2', '/f3'); const detailsLine = [showId && r.univ_id, showEmail && r.email] .filter(Boolean) .join(', '); const detailsHtml = detailsLine ? `<div class="details">${detailsLine}</div>` : ''; const groupsLine = showGroups && r.groups?.length ? `<div class="groups">${r.groups.join(', ')}</div>` : ''; return ` <tr class="${[roleClasses, activeClass].filter(Boolean).join(' ')}"> <td> <div class="entry"> <img src="${photo}"> <div class="info"> <div class="fullname">${r.surname}, ${r.name}</div> ${detailsHtml} ${groupsLine} </div> </div> </td> <td></td> </tr>`; }).join(''); // Write clean document document.open(); document.write(` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${filename}</title> <style> body { font-family: sans-serif; margin: 2em; } h1 { border: 1px dashed #999; font-size: 1.75rem; margin-bottom: 0.75rem; padding: 0.2rem 0.4rem; position: relative; } h1::after { content: "EDITABLE"; color: white; background-color: gray; font-size: 1rem; padding: 0 0.2rem; position: absolute; top: -1px; right: -1px; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; vertical-align: top; border-bottom: 1px solid #999; padding: 0.5rem; } td:first-child { white-space: nowrap; width: 1%; } .entry { display: flex; align-items: flex-start; gap: 0.5rem; } img { width: 4rem; height: 4rem; border-radius: 10%; } img[src=""] { visibility: hidden; } .info div { line-height: 1.3; } .fullname { font-weight: bold; } .details { font-style: italic; } ${ROLE_COLOR_CSS} @media print { body { margin: 0; } h1 { border: none; } h1::after { display: none; } tr { page-break-inside: avoid; } th, td { border-bottom: 0.25pt solid #999; } } </style> </head> <body> <h1 contenteditable="true">${filename}</h1> <table class="${GM_getValue('colorRoles', true) ? 'roles-colored' : ''}"> <thead> <tr><th>${roleLabel}</th><th>Notes</th></tr> </thead> <tbody> ${htmlRows} </tbody> </table> </body> </html> `); document.close(); }); // Roles-based coloring const { rolesCol } = getColumnIndexMap(table); if (rolesCol >= 0) { for (const tr of table.querySelectorAll('tbody tr')) { const td = tr.children[rolesCol]; if (!td) continue; (td.textContent || '') .toLowerCase() .split(',') .map(s => s.trim()) .filter(Boolean) .map(role => normalizeLabelKey(role).replace(/[^a-z0-9-]+/g, '-')) .forEach(cls => tr.classList.add(cls)); } } // Status-based "inactive" marker const { statusCol } = getColumnIndexMap(table); if (statusCol >= 0) { // Figure out the localized "Active" label (value "0" in the status <select>) const { valueMap: statusByValue } = buildSelectLookup('status'); const activeLabel = statusByValue?.get('0') || 'Active'; for (const tr of table.querySelectorAll('tbody tr')) { const cells = [...tr.querySelectorAll('th,td')]; const statusCell = cells[statusCol]; const raw = (statusCell?.querySelector('div')?.getAttribute('data-status') || statusCell?.innerText || '').trim(); const isActive = !!(raw && normalizeLabelKey(raw) === normalizeLabelKey(activeLabel)); if (!isActive) tr.classList.add('inactive'); } } // Role coloring toggle (persistent) const chkColors = document.createElement('input'); chkColors.type = 'checkbox'; chkColors.checked = GM_getValue('colorRoles', true); const wrapColors = document.createElement('label'); wrapColors.appendChild(chkColors); wrapColors.append(I18N.colorByRoles); // Apply + persist table.classList.toggle('roles-colored', chkColors.checked); chkColors.addEventListener('change', () => { table.classList.toggle('roles-colored', chkColors.checked); GM_setValue('colorRoles', chkColors.checked); }); // Photo improvement toggle (persistent) const chkPhotos = document.createElement('input'); chkPhotos.type = 'checkbox'; chkPhotos.checked = GM_getValue('improvePhotos', false); const wrapPhotos = document.createElement('label'); wrapPhotos.appendChild(chkPhotos); wrapPhotos.append(I18N.improvePhotos); // Apply + persist if (chkPhotos.checked) upgradePhotos(table); chkPhotos.addEventListener('change', () => { if (chkPhotos.checked) upgradePhotos(table); else downgradePhotos(table); GM_setValue('improvePhotos', chkPhotos.checked); }); // Compose toolbar toolbar.appendChild(btnCsv); toolbar.appendChild(btnJson); toolbar.appendChild(btnPrint); toolbar.appendChild(wrapColors); toolbar.appendChild(wrapPhotos); // Insert before table table.parentNode.insertBefore(toolbar, table); } // -------------------------------- // BOOTSTRAP & RESILIENCE // -------------------------------- /** * Initializes the script safely; does nothing if participants table is missing. */ function init() { const table = document.getElementById('participants'); if (table) ensureToolbar(table); } // Run once when the DOM is ready (document-idle ensures it, but just in case) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Moodle dynamically re-renders the participants table via AJAX, so the toolbar may disappear. // Watch the DOM and re-run init() when needed. const uiObserver = new MutationObserver(init); uiObserver.observe(document.documentElement, { childList: true, subtree: true, }); // Disconnect the observer when navigating away to avoid leaks. window.addEventListener('beforeunload', () => { uiObserver.disconnect(); }); })();