您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Importa calificaciones y observaciones desde un archivo Excel a la plataforma "mòdul docent 2" (MD2) de ITACA.
// ==UserScript== // @name ITACA MD2 docent.edu.gva.es - Importador de Calificaciones y Observaciones // @namespace https://lpla.github.io/ // @version 0.3.1 // @description Importa calificaciones y observaciones desde un archivo Excel a la plataforma "mòdul docent 2" (MD2) de ITACA. // @author lpla // @match https://docent.edu.gva.es/md-front/www/* // @require https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js // @grant none // @supportURL https://github.com/lpla/userscripts/issues // ==/UserScript== (function() { 'use strict'; // Función que detecta si se está en la vista de un aula basándose en la URL. // Se espera que la URL tenga el formato: // #centre/<centro>/grup/<grupo>/avaluacio/<evaluacion>/dades/<datos> function isInClassroom() { const hash = window.location.hash; const regex = /^#centre\/\d+\/grup\/\d+\/avaluacio\/\d+\/dades\/[\d;A-Z,]+$/; return regex.test(hash); } // Función que crea el disclaimer con el espaciado adecuado. function getDisclaimerElement() { const disclaimer = document.createElement('p'); disclaimer.style.fontSize = '0.6em'; disclaimer.style.margin = '10px 0'; disclaimer.style.color = 'white'; disclaimer.textContent = "Todos los datos introducidos se procesan en este ordenador y no se mandan ni se procesan en ningún servidor externo."; return disclaimer; } // Crear contenedor principal del plugin (ancho ampliado) const pluginContainer = document.createElement('div'); pluginContainer.id = 'pluginContainer'; pluginContainer.style.position = 'fixed'; pluginContainer.style.top = '10px'; pluginContainer.style.left = '10px'; pluginContainer.style.zIndex = '10000'; pluginContainer.style.background = '#576670'; // BLAU secundari pluginContainer.style.color = 'white'; pluginContainer.style.border = '1px solid #ccc'; pluginContainer.style.fontFamily = 'sans-serif'; pluginContainer.style.width = '350px'; // Ancho ampliado // Encabezado con botón de minimizar/restaurar const pluginHeader = document.createElement('div'); pluginHeader.id = 'pluginHeader'; pluginHeader.style.background = '#576670'; pluginHeader.style.cursor = 'pointer'; pluginHeader.style.padding = '5px'; pluginHeader.style.display = 'flex'; pluginHeader.style.justifyContent = 'space-between'; pluginHeader.style.alignItems = 'center'; const headerTitle = document.createElement('span'); headerTitle.textContent = 'Importador de Calificaciones'; pluginHeader.appendChild(headerTitle); // Pequeño texto de autoría debajo del título const devCredit = document.createElement('div'); devCredit.style.fontSize = '0.6em'; devCredit.style.margin = '10px 0'; devCredit.style.textAlign = 'center'; devCredit.innerHTML = 'Desarrollado por <a href="https://github.com/lpla" target="_blank" style="color: white; text-decoration: underline;">lpla</a>; soporte Excel por <a href="https://docs.sheetjs.com" target="_blank" style="color: white; text-decoration: underline;">SheetJS</a>'; // Botón para minimizar/restaurar const minimizeButton = document.createElement('button'); minimizeButton.id = 'minimizeButton'; minimizeButton.textContent = '–'; minimizeButton.style.background = 'transparent'; minimizeButton.style.color = 'white'; minimizeButton.style.border = 'none'; minimizeButton.style.cursor = 'pointer'; minimizeButton.style.fontSize = '16px'; pluginHeader.appendChild(minimizeButton); pluginContainer.appendChild(pluginHeader); // Insertar el crédito debajo del header pluginContainer.appendChild(devCredit); // Contenedor del contenido del plugin (se actualizará según el modo) const pluginContent = document.createElement('div'); pluginContent.id = 'pluginContent'; pluginContent.style.padding = '5px'; pluginContent.style.background = '#576670'; pluginContainer.appendChild(pluginContent); document.body.appendChild(pluginContainer); // Contenedor de controles (selector de archivo e inputs) a mostrar en aula const controlsContainer = document.createElement('div'); // Selector de archivo const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.xlsx, .xls'; fileInput.style.width = '100%'; controlsContainer.appendChild(fileInput); // Contenedor de configuración (inputs para columnas y mensaje de estado) const configDiv = document.createElement('div'); configDiv.style.marginTop = '5px'; configDiv.innerHTML = ` <label style="display:block; margin-bottom:5px;"> Columna de nombres: <input type="number" id="col-name" min="1" value="1" style="width:50px;"> </label> <label style="display:block; margin-bottom:5px;"> Columna de notas: <input type="number" id="col-mark" min="1" value="2" style="width:50px;"> </label> <label style="display:block; margin-bottom:5px;"> Columna de observaciones: <input type="number" id="col-observation" min="1" value="3" style="width:50px;"> </label> <div id="statusMsg" style="margin-top:5px; font-weight:bold;"></div> `; controlsContainer.appendChild(configDiv); fileInput.addEventListener('change', handleFile, false); // Función para actualizar la interfaz del plugin según la URL function updatePluginUI() { if (!isInClassroom()) { // No se está en aula: mostrar instrucciones if (pluginContent.getAttribute('data-mode') !== 'instructions') { pluginContent.innerHTML = ''; const instructions = document.createElement('p'); instructions.style.padding = '5px'; instructions.style.color = 'white'; instructions.textContent = 'Primero, acceda a la pantalla de las calificaciones de un grupo en una evaluación concreta para usar esta herramienta.'; pluginContent.appendChild(instructions); pluginContent.appendChild(getDisclaimerElement()); pluginContent.setAttribute('data-mode', 'instructions'); } } else { // Se está en aula: mostrar los controles si aún no se han mostrado if (pluginContent.getAttribute('data-mode') !== 'controls') { pluginContent.innerHTML = ''; pluginContent.appendChild(controlsContainer); pluginContent.appendChild(getDisclaimerElement()); pluginContent.setAttribute('data-mode', 'controls'); } } } // Usar el evento hashchange para actualizar la UI y llamar a updatePluginUI inmediatamente. window.addEventListener("hashchange", updatePluginUI); updatePluginUI(); // Funcionalidad de minimizar/restaurar minimizeButton.addEventListener('click', function() { if (pluginContent.style.display === 'none') { pluginContent.style.display = 'block'; minimizeButton.textContent = '–'; } else { pluginContent.style.display = 'none'; minimizeButton.textContent = '+'; } }); // Función para mostrar mensajes de estado que desaparecen a los 5 segundos. // Se usa BLAU (#19afe0) para mensajes normales y ROSA (#d1a16d) para errores. function showStatus(message, isError=false) { const statusMsg = document.getElementById('statusMsg'); if (statusMsg) { statusMsg.textContent = message; statusMsg.style.backgroundColor = isError ? '#d1a16d' : '#19afe0'; statusMsg.style.color = 'white'; statusMsg.style.padding = '3px'; setTimeout(() => { statusMsg.textContent = ''; statusMsg.style.backgroundColor = 'transparent'; }, 5000); } } function handleFile(e) { const file = e.target.files[0]; if (!file) { showStatus('No se ha seleccionado ningún archivo.', true); return; } console.log("Archivo seleccionado:", file); const reader = new FileReader(); reader.onload = async function(e) { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, {type: 'array'}); const firstSheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[firstSheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet, {header: 1}); // Se asume que la primera fila contiene cabeceras const studentData = jsonData.slice(1); // Obtener índices configurados (los valores se introducen en base 1 y se convierten a base 0) const colName = parseInt(document.getElementById('col-name').value, 10) - 1; const colMark = parseInt(document.getElementById('col-mark').value, 10) - 1; const colObservation = parseInt(document.getElementById('col-observation').value, 10) - 1; showStatus('Procesando datos...'); await fillMarksAndObservations(studentData, colName, colMark, colObservation); showStatus('Proceso completado.'); } catch (error) { console.error('Error al procesar el archivo:', error); showStatus('Error al procesar el archivo.', true); } }; reader.onerror = function(e) { console.error('Error al leer el archivo:', e); showStatus('Error al leer el archivo.', true); }; reader.readAsArrayBuffer(file); } function normalizeName(name) { return name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/-/g, ' ') .trim() .toLowerCase() .replace(/\s+/g, ' '); } function extractMark(mark) { if (typeof mark === 'string' || mark instanceof String) { const match = mark.match(/^\d+/); return match ? match[0] : ''; } return ''; } function levenshtein(a, b) { const tmp = []; if (a.length === 0) { return b.length; } if (b.length === 0) { return a.length; } for (let i = 0; i <= b.length; i++) { tmp[i] = [i]; } for (let j = 0; j <= a.length; j++) { tmp[0][j] = j; } for (let i = 1; i <= b.length; i++) { for (let j = 1; j <= a.length; j++) { tmp[i][j] = b[i - 1] === a[j - 1] ? tmp[i - 1][j - 1] : Math.min(tmp[i - 1][j - 1] + 1, Math.min(tmp[i][j - 1] + 1, tmp[i - 1][j] + 1)); } } return tmp[b.length][a.length]; } function formatExcelName(name) { const parts = name.trim().split(/\s+/); if (parts.length > 2) { const firstName = parts.slice(0, parts.length - 2).join(' '); const lastNames = parts.slice(parts.length - 2).join(' '); return `${lastNames}, ${firstName}`; } else { const firstName = parts[0]; const lastNames = parts.slice(1).join(' '); return `${lastNames}, ${firstName}`; } } function formatAlternativeExcelName(name) { const parts = name.trim().split(/\s+/); const firstName = parts.slice(0, -1).join(' '); const lastName = parts.slice(-1).join(' '); return `${lastName}, ${firstName}`; } async function fillMarksAndObservations(studentData, colName, colMark, colObservation) { for (const row of studentData) { const name = row[colName]; const mark = row[colMark]; const observation = row[colObservation]; if (name && mark) { const isAlreadyFormatted = (typeof name === 'string' && name.includes(',')); let formattedName = isAlreadyFormatted ? name.trim() : formatExcelName(name); const normalizedExcelName = normalizeName(formattedName); const extractedMark = extractMark(mark); const nameElements = document.evaluate(`//div[@class='imc-nom']/p`, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); let exactMatch = false; let bestMatch = null; let bestMatchDistance = Infinity; for (let i = 0; i < nameElements.snapshotLength; i++) { const nameElement = nameElements.snapshotItem(i); const normalizedHtmlName = normalizeName(nameElement.textContent); if (normalizedExcelName === normalizedHtmlName) { exactMatch = true; bestMatch = nameElement; bestMatchDistance = 0; break; } } if (!exactMatch && !isAlreadyFormatted) { const alternativeFormat = formatAlternativeExcelName(name); const normalizedAlternativeExcelName = normalizeName(alternativeFormat); for (let i = 0; i < nameElements.snapshotLength; i++) { const nameElement = nameElements.snapshotItem(i); const normalizedHtmlName = normalizeName(nameElement.textContent); if (normalizedAlternativeExcelName === normalizedHtmlName) { exactMatch = true; bestMatch = nameElement; bestMatchDistance = 0; break; } const distance = levenshtein(normalizedAlternativeExcelName, normalizedHtmlName); if (distance < bestMatchDistance) { bestMatch = nameElement; bestMatchDistance = distance; } } } if (!exactMatch) { for (let i = 0; i < nameElements.snapshotLength; i++) { const nameElement = nameElements.snapshotItem(i); const normalizedHtmlName = normalizeName(nameElement.textContent); const distance = levenshtein(normalizedExcelName, normalizedHtmlName); if (distance < bestMatchDistance) { bestMatch = nameElement; bestMatchDistance = distance; } } } if (bestMatch && bestMatchDistance <= 15) { const markInputElement = bestMatch.parentNode.nextElementSibling.querySelector('input'); if (markInputElement) { markInputElement.value = extractedMark; markInputElement.dispatchEvent(new Event('input', { bubbles: true })); markInputElement.dispatchEvent(new Event('change', { bubbles: true })); markInputElement.dispatchEvent(new Event('blur', { bubbles: true })); } else { console.warn('No se encontró el input para la nota del alumno:', formattedName); } if (observation) { const studentRow = bestMatch.closest('li'); if (studentRow) { const obsButton = studentRow.querySelector('div.imc-qualificacio > a'); if (obsButton) { obsButton.click(); const modal = document.getElementById('imc-modul-observacions-avanzada'); if (modal && window.getComputedStyle(modal).display !== 'none') { const textarea = modal.querySelector('textarea.imc-f-observacions-avanzada'); if (textarea) { textarea.value = observation; textarea.dispatchEvent(new Event('input', { bubbles: true })); textarea.dispatchEvent(new Event('change', { bubbles: true })); textarea.dispatchEvent(new Event('blur', { bubbles: true })); } else { console.warn('No se encontró el textarea de observaciones para el alumno:', formattedName); } const finalizeButton = modal.querySelector('a.imc-bt-finalitza'); if (finalizeButton) { finalizeButton.click(); } else { console.warn('No se encontró el botón "Finaliza" en el modal de observaciones.'); } } else { console.warn('Modal de observaciones no se abrió para el alumno:', formattedName); } } else { console.warn('No se encontró el botón de observaciones para el alumno:', formattedName); } } else { console.warn('No se encontró la fila del alumno para las observaciones:', formattedName); } } } else { console.warn('No se encontró coincidencia para el alumno:', formattedName); } } } const updateButton = document.getElementById('imc-bt-guarda-avaluacio'); if (updateButton) { updateButton.disabled = false; updateButton.click(); } else { console.warn('No se encontró el botón de guardar evaluación.'); } } })();