ITACA MD2 docent.edu.gva.es - Importador de Calificaciones y Observaciones

Importa calificaciones y observaciones desde un archivo Excel a la plataforma "mòdul docent 2" (MD2) de ITACA.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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.');
        }
    }
})();