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