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.

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