// ==UserScript== // @name Canvas Rubric Uploader (Enhanced Version) // @namespace http://tampermonkey.net/ // @version 3.3 // @description Uploads rubrics to Canvas LMS from a CSV file without needing a manual access token and without overwriting existing ones. Enhanced for clarity and robustness. // @author Pablo Gómez, ChatGPT, Gemini // @match *://*.instructure.com/courses/*/rubrics // @grant none // @require https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js // ==/UserScript== (function() { 'use strict'; // Access the global Canvas ENV object to securely get context information. const ENV = (typeof unsafeWindow !== 'undefined') ? unsafeWindow.ENV : window.ENV; /** * Retrieves the CSRF token from Canvas using multiple methods for robustness. * The token is necessary to authenticate API requests. * @returns {string|null} The CSRF token, or null if not found. */ function getCsrfToken() { if (ENV && ENV.CSRF_TOKEN) return ENV.CSRF_TOKEN; const cookieValue = document.cookie.split('; ').find(row => row.startsWith('_csrf_token=')); if (cookieValue) return decodeURIComponent(cookieValue.split('=')[1]); const metaTag = document.querySelector('meta[name="csrf-token"]'); if (metaTag) return metaTag.getAttribute('content'); console.error("CSRF Token not found. The script cannot proceed."); return null; } /** * Injects the "Upload Rubric from CSV" button into the Canvas UI. * The button is placed right after the standard "Add Rubric" button. */ function initializeUploadButton() { const addButton = document.querySelector('a.add_rubric_link'); if (addButton) { const uploadButton = document.createElement('a'); uploadButton.innerText = 'Upload Rubric from CSV'; // Translated uploadButton.classList.add('btn', 'button-sidebar-wide'); uploadButton.style.marginTop = '10px'; uploadButton.href = "#"; // Use href for better accessibility uploadButton.addEventListener('click', (e) => { e.preventDefault(); showUploadModal(); }); addButton.insertAdjacentElement('afterend', uploadButton); } } /** * Creates and displays a modal dialog for the user to enter the rubric title and select the CSV file. */ function showUploadModal() { // Prevent creating duplicate modals if (document.getElementById('rubricUploadModal')) return; const modal = document.createElement('div'); modal.id = 'rubricUploadModal'; // Apply styles for the modal container Object.assign(modal.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', padding: '25px', backgroundColor: '#fff', boxShadow: '0 4px 15px rgba(0, 0, 0, 0.2)', zIndex: 1001, borderRadius: '8px', width: 'clamp(300px, 50%, 500px)' }); // Modal HTML content modal.innerHTML = `

Upload Rubric to Canvas

`; // Translated document.body.appendChild(modal); // Add event listeners for modal controls document.getElementById('closeModal').addEventListener('click', () => modal.remove()); document.getElementById('rubricForm').addEventListener('submit', handleFormSubmit); } /** * Displays a notification message within the modal. * @param {string} message - The message to display. * @param {'success'|'error'} type - The type of notification, which determines the color. */ function showNotification(message, type) { const notificationArea = document.getElementById('notificationArea'); if (!notificationArea) return; notificationArea.style.display = 'block'; notificationArea.style.backgroundColor = type === 'success' ? '#28a745' : '#dc3545'; notificationArea.textContent = message; } /** * Handles the form submission event. It orchestrates the file reading, parsing, and API call. * @param {Event} event - The form submission event. */ async function handleFormSubmit(event) { event.preventDefault(); const submitButton = document.getElementById('submitRubric'); const formInputs = document.querySelectorAll('#rubricForm input, #rubricForm button'); // Disable form elements during processing formInputs.forEach(input => input.disabled = true); submitButton.innerText = 'Uploading...'; // Translated const rubricTitle = document.getElementById('rubricTitle').value; const rubricFile = document.getElementById('rubricFile').files[0]; const courseId = ENV.COURSE_ID || window.location.pathname.split('/')[2]; const fileReader = new FileReader(); fileReader.onload = async function(e) { const csvContent = e.target.result; try { const rubricData = parseCSV(csvContent); if (Object.keys(rubricData).length === 0) { throw new Error("No valid criteria were found in the CSV file. Check that the file is not empty and the format is correct."); // Translated } await createRubricInCanvas(courseId, rubricTitle, rubricData); // On success, notify the user and reload the page to show the new rubric. showNotification('Success! The rubric has been uploaded. The page will refresh in 2 seconds...', 'success'); // Translated setTimeout(() => { location.reload(); }, 2000); // 2-second delay } catch (error) { // On failure, show a detailed error and re-enable the form. showNotification(`Error: ${error.message}. If the problem persists, open the console (F12), copy the payload text and the red error message, and share it.`, 'error'); // Translated formInputs.forEach(input => input.disabled = false); submitButton.innerText = 'Try Again'; // Translated } }; fileReader.readAsText(rubricFile, 'UTF-8'); // Specify encoding for better compatibility } /** * Parses the CSV content to build the rubric object for the Canvas API. * Expected CSV format: * Header (ignored): Criterio,Descripción Larga,Descripción Calificación 1,Puntos 1,Descripción Calificación 2,Puntos 2,... * Row 1: "Criterion Name 1","Long description 1","Excellent",5,"Good",3,"Needs Improvement",1 * Row 2: "Criterion Name 2","Long description 2","Meets Expectations",10,"Does Not Meet",0 * @param {string} csvContent - The full string content of the CSV file. * @returns {Object} An object representing the rubric criteria, formatted for the Canvas API. */ function parseCSV(csvContent) { // Split content into lines and filter out any empty lines const lines = csvContent.split(/\r?\n/).filter(line => line.trim() !== ''); const criteria = {}; // Process each line except for the header lines.slice(1).forEach((line) => { const parts = parseCSVLine(line); // A valid line must have at least a criterion name and one rating (description + points) if (parts.length < 4) return; const [criterion, criterion_description] = parts.splice(0, 2); const ratings = {}; // Loop through the remaining parts, taking 2 at a time (rating description, rating points) for (let i = 0; i < parts.length; i += 2) { const rating_description = parts[i]; const rating_points_str = parts[i + 1]; if (rating_description && rating_points_str) { const rating_points = parseFloat(rating_points_str.replace(',', '.')); // Handle both comma and dot decimals if (!isNaN(rating_points)) { // Generate a unique ID for the rating const rating_id = `rating_${Math.random().toString(36).substring(2, 11)}`; ratings[rating_id] = { description: rating_description, points: rating_points }; } } } // If we successfully parsed at least one rating, add the criterion to our collection if (Object.keys(ratings).length > 0) { // Generate a unique ID for the criterion const criterion_id = `criterion_${Math.random().toString(36).substring(2, 11)}`; criteria[criterion_id] = { description: criterion, long_description: criterion_description, // The points for a criterion are the maximum possible points from its ratings points: Object.values(ratings).reduce((max, r) => Math.max(max, r.points), 0), ratings: ratings }; } }); return criteria; } /** * Parses a single line of a CSV file, correctly handling quoted fields. * This allows commas to be included within descriptions if they are wrapped in double quotes. * @param {string} line - The CSV line to parse. * @returns {string[]} An array of the fields from the line. */ function parseCSVLine(line) { const result = []; let inQuotes = false; let value = ''; for (const char of line) { if (char === '"' && (value.length === 0 || line[line.indexOf(char) - 1] !== '\\')) { inQuotes = !inQuotes; } else if (char === ',' && !inQuotes) { result.push(value.trim()); value = ''; } else { value += char; } } result.push(value.trim()); return result.map(v => v.replace(/^"|"$/g, '').replace(/\\"/g, '"')); // Clean up quotes } /** * Sends the final payload to the Canvas API to create the rubric. * @param {string} courseId - The ID of the course where the rubric will be created. * @param {string} rubricTitle - The title for the new rubric. * @param {Object} criteria - The object containing the parsed criteria data. */ async function createRubricInCanvas(courseId, rubricTitle, criteria) { const csrfToken = getCsrfToken(); if (!csrfToken) { throw new Error('Could not get authentication token (CSRF Token). Make sure you are on a Canvas page.'); // Translated } const url = `/api/v1/courses/${courseId}/rubrics`; // Construct the payload as required by the Canvas API const rubricPayload = { rubric: { title: rubricTitle, criteria: criteria, free_form_criterion_comments: false }, rubric_association: { association_id: courseId, association_type: 'Course', purpose: 'bookmark' // Makes it available in the course } }; console.log("Sending the following payload to Canvas API:", JSON.stringify(rubricPayload, null, 2)); // Translated try { // Use Axios to send the POST request await axios.post(url, rubricPayload, { headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); } catch (error) { console.error('Error in Canvas API request:', error.response || error); // Translated // Attempt to parse a more user-friendly error message from the API response const errorMessage = error.response?.data?.errors?.[0]?.message || 'An unknown error occurred during the upload.'; // Translated throw new Error(errorMessage); } } // --- Script Entry Point --- // Wait for the page to be fully loaded before adding the button. window.addEventListener('load', initializeUploadButton); })();