// ==UserScript== // @name Export Rubric Scores // @namespace https://github.com/UCBoulder // @description Export all rubric criteria scores for an assignment to a CSV // @match https://*/courses/*/gradebook/speed_grader?* // @grant none // @run-at document-idle // @version 1.2.4 // ==/UserScript== /* globals $ */ // wait until the window jQuery is loaded function defer(method) { if (typeof $ !== 'undefined') { method(); } else { setTimeout(function() { defer(method); }, 100); } } function waitForElement(selector, callback) { if ($(selector).length) { callback(); } else { setTimeout(function() { waitForElement(selector, callback); }, 100); } } function popUp(text) { $("#export_rubric_dialog").html(`<p>${text}</p>`); $("#export_rubric_dialog").dialog({ buttons: {} }); } function popClose() { $("#export_rubric_dialog").dialog("close"); } function getAllPages(url, callback) { getRemainingPages(url, [], callback); } // Recursively work through paginated JSON list function getRemainingPages(nextUrl, listSoFar, callback) { $.getJSON(nextUrl, function(responseList, textStatus, jqXHR) { var nextLink = null; $.each(jqXHR.getResponseHeader("link").split(','), function (linkIndex, linkEntry) { if (linkEntry.split(';')[1].includes('rel="next"')) { nextLink = linkEntry.split(';')[0].slice(1, -1); } }); if (nextLink == null) { // all pages have been retrieved callback(listSoFar.concat(responseList)); } else { getRemainingPages(nextLink, listSoFar.concat(responseList), callback); } }).fail(function (jqXHR, textStatus, errorThrown) { popUp(`ERROR ${jqXHR.status} while retrieving data from Canvas. Url: ${nextUrl}<br/><br/>Please refresh and try again.`, null); window.removeEventListener("error", showError); }); } // escape commas and quotes for CSV formatting function csvEncode(string) { if (string && (string.includes('"') || string.includes(','))) { return '"' + string.replace(/"/g, '""') + '"'; } return string; } function showError(event) { popUp(event.message); window.removeEventListener("error", showError); } defer(function() { 'use strict'; // utility function for downloading a file var saveText = (function () { var a = document.createElement("a"); document.body.appendChild(a); a.style = "display: none"; return function (textArray, fileName) { var blob = new Blob(textArray, {type: "text"}), url = window.URL.createObjectURL(blob); a.href = url; a.download = fileName; a.click(); window.URL.revokeObjectURL(url); }; }()); $("body").append($('<div id="export_rubric_dialog" title="Export Rubric Scores"></div>')); // Only add the export button if a rubric is appearing if ($('#rubric_summary_holder').length > 0) { $('#gradebook_header div.statsMetric').append('<button type="button" class="Button" id="export_rubric_btn">Export Rubric Scores</button>'); $('#export_rubric_btn').click(function() { popUp("Exporting scores, please wait..."); window.addEventListener("error", showError); // Get some initial data from the current URL const courseId = window.location.href.split('/')[4]; const urlParams = window.location.href.split('?')[1].split('&'); const assignId = urlParams.find(i => i.split('=')[0] === "assignment_id").split('=')[1]; // Get the rubric data $.getJSON(`/api/v1/courses/${courseId}/assignments/${assignId}`, function(assignment) { // Get the user data getAllPages(`/api/v1/courses/${courseId}/enrollments?per_page=100`, function(enrollments) { // Get the rubric score data getAllPages(`/api/v1/courses/${courseId}/assignments/${assignId}/submissions?include[]=rubric_assessment&per_page=100`, function(submissions) { // Known Canvas bug where a rubric can appear in the UI but not in the API if (!('rubric_settings' in assignment)) { popUp(`ERROR: No rubric settings found at /api/v1/courses/${courseId}/assignments/${assignId}.<br/><br/> ` + 'This is likely due to a Canvas bug where a rubric has entered a "soft-deleted" state. ' + 'Please use the <a href="https://community.canvaslms.com/t5/Canvas-Admin-Blog/Undeleting-things-in-Canvas/ba-p/267116">Undelete feature</a> ' + 'to restore the rubric associated with this assignment or contact Canvas Support.'); return; } // If rubric is set to hide points, then also hide points in export // If rubric is set to use free form comments, then also hide ratings in export const hidePoints = assignment.rubric_settings.hide_points; const hideRatings = assignment.rubric_settings.free_form_criterion_comments; if (hidePoints && hideRatings) { popUp("ERROR: This rubric is configured to use free-form comments instead of ratings AND to hide points, so there is nothing to export!"); return; } // Fill out the csv header and map criterion ids to sort index // Also create an object that maps criterion ids to an object mapping rating ids to descriptions var critOrder = {}; var critRatingDescs = {}; var header = "Student Name,Student ID,Posted Score,Attempt Number"; $.each(assignment.rubric, function(critIndex, criterion) { critOrder[criterion.id] = critIndex; if (!hideRatings) { critRatingDescs[criterion.id] = {}; $.each(criterion.ratings, function(i, rating) { critRatingDescs[criterion.id][rating.id] = rating.description; }); header += ',' + csvEncode('Rating: ' + criterion.description); } if (!hidePoints) { header += ',' + csvEncode('Points: ' + criterion.description); } }); header += '\n'; // Iterate through submissions var csvRows = [header]; $.each(submissions, function(subIndex, submission) { const user = enrollments.find(i => i.user_id === submission.user_id).user; if (user) { var row = `${user.name},${user.sis_user_id},${submission.score},${submission.attempt}`; // Add criteria scores and ratings // Need to turn rubric_assessment object into an array var crits = [] var critIds = [] if (submission.rubric_assessment != null) { $.each(submission.rubric_assessment, function(critKey, critValue) { if (hideRatings) { crits.push({'id': critKey, 'points': critValue.points, 'rating': null}); } else { crits.push({'id': critKey, 'points': critValue.points, 'rating': critRatingDescs[critKey][critValue.rating_id]}); } critIds.push(critKey); }); } // Check for any criteria entries that might be missing; set them to null $.each(critOrder, function(critKey, critValue) { if (!critIds.includes(critKey)) { crits.push({'id': critKey, 'points': null, 'rating': null}); } }); // Sort into same order as column order crits.sort(function(a, b) { return critOrder[a.id] - critOrder[b.id]; }); $.each(crits, function(critIndex, criterion) { if (!hideRatings) { row += `,${csvEncode(criterion.rating)}`; } if (!hidePoints) { row += `,${criterion.points}`; } }); row += '\n'; csvRows.push(row); } }); popClose(); saveText(csvRows, `Rubric Scores ${assignment.name.replace(/[^a-zA-Z 0-9]+/g, '')}.csv`); window.removeEventListener("error", showError); }); }); }).fail(function (jqXHR, textStatus, errorThrown) { popUp(`ERROR ${jqXHR.status} while retrieving assignment data from Canvas. Please refresh and try again.`, null); window.removeEventListener("error", showError); }); }); } });