PLGradingHelper

Usage: This script makes copying rubrics easier. Navigate to the grading page containing your desired rubric. In the Rubric dialog, click on "Copy Rubric" to copy the rubric. Navigate to the grading page where you wish to apply the rubric. In the Rubric dialog, press "Ctrl+V" to paste the rubric. Don't forget to click "Save rubric".

// ==UserScript==
// @name         PLGradingHelper
// @namespace    http://tampermonkey.net/
// @version      2024-04-04
// @description  Usage: This script makes copying rubrics easier. Navigate to the grading page containing your desired rubric. In the Rubric dialog, click on "Copy Rubric" to copy the rubric. Navigate to the grading page where you wish to apply the rubric. In the Rubric dialog, press "Ctrl+V" to paste the rubric. Don't forget to click "Save rubric".
// @author       Yufeng Du
// @match        https://us.prairielearn.com/pl/course_instance/*/instructor/assessment/*/manual_grading/instance_question/*
// @icon         https://us.prairielearn.com/favicon.ico
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';
    let modal = document.querySelector('.modal');
    let modalContent = document.querySelector('.modal-content');
    // find the first div with class 'modal-body'
    let modalBody = document.querySelector('.modal-body');
    // create a new div inside the modal header to hold the "Copy" button
    let ButtonDiv = document.createElement('div');
    // create a new button inside the new div
    let copyButton = document.createElement('div');
    // add the classes 'btn' 'btn-light' to the new button
    // newButton.classList.add('btn', 'btn-sm', 'btn-secondary', 'js-add-rubric-item-button');
    // add the text "Copy" to the new button
    copyButton.textContent = 'Copy Rubric';
    // set the width to auto
    copyButton.style.width = 'auto';
    copyButton.style.color = 'red';
    copyButton.style.cursor = 'pointer';
    copyButton.style.textDecoration = 'underline';

    copyButton.style.display = "inline-block";
    // append the new button to the new div
    ButtonDiv.appendChild(copyButton);
    // insert a space
    let space = document.createElement('div');
    space.textContent = ' ';
    space.style.display = "inline-block";
    space.style.width = "10px";
    ButtonDiv.appendChild(space);

    let pastePseudoButton = document.createElement('div');
    let col6 = modalBody.querySelector('.col-6');
    // add the classes 'btn' 'btn-light' to the new button
    // newButton.classList.add('btn', 'btn-sm', 'btn-secondary', 'js-add-rubric-item-button');
    // add the text "Copy" to the new button
    pastePseudoButton.textContent = 'Paste Rubric (Ctrl+V)';
    // set the width to auto
    pastePseudoButton.style.width = 'auto';
    pastePseudoButton.style.color = '#888888';

    pastePseudoButton.style.display = "inline-block";
    // append the new button to the new div
    ButtonDiv.appendChild(pastePseudoButton);

    // insert help button
    let divHTML = `
    <button type="button" class="btn btn-sm" data-toggle="tooltip" data-placement="bottom" title="" data-original-title='Click on "Copy Rubric" to copy the rubric. Press "Ctrl+V" to paste the rubric. Click this information button for details.'>
                      <svg class="svg-inline--fa fa-circle-info text-info" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="circle-info" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" data-fa-i2svg=""><path fill="currentColor" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"></path></svg><!-- <i class="text-info fas fa-circle-info"></i> Font Awesome fontawesome.com -->
                    </button>
                    `;
    // create a new button inside the new div
    let helpButton = document.createElement('div');
    helpButton.style.display = "inline-block";
    helpButton.innerHTML = divHTML;
    ButtonDiv.appendChild(helpButton);

    // insert the new div
    modalBody.insertBefore(ButtonDiv, modalBody.firstChild);

    // create a function to parse the data from the modal body
    function getParsedData() {
        // find the first "col-6" div inside modalBody

        // there are two checkboxes inside col6. Get these two checkboxes
        let checkboxes = col6.querySelectorAll('input');
        // create an array to hold the values of the checkboxes. Look at the "checked" property of each checkbox.
        let checkboxValues = 0;
        for (let i = 0; i < checkboxes.length; i++) {
            if (checkboxes[i].checked === false) {
                checkboxValues = checkboxValues * 2;
            } else {
                checkboxValues = checkboxValues * 2 + 1;
            }
        }
        let minPoints = modalBody.querySelector('input[name="min_points"]').value;
        let maxExtraPoints = modalBody.querySelector('input[name="max_extra_points"]').value;

        let rubricItemTable = modalBody.querySelector('.js-rubric-items-table');
        let rubricItemRows = rubricItemTable.querySelectorAll('tr');
        let rubricItemData = [];
        for (let i = 1; i < rubricItemRows.length; i++) {
            let rubricItemRow = rubricItemRows[i];
            if (rubricItemRow.classList.contains('js-no-rubric-item-note')) {
                continue;
            }
            let rubricItemCells = rubricItemRow.querySelectorAll('td');
            if (rubricItemCells.length < 6) {
                continue;
            }
            function getTextContent(element) {
                // if first child is a label tag, return the data-current-value
                let secondChild = element.firstChild.nextSibling;
                if (secondChild.tagName === "LABEL") {
                    return secondChild.getAttribute('data-current-value');
                } else {
                    return secondChild.innerHTML;
                }
            }
            let rubricItem = {
                "points": rubricItemCells[1].querySelector('input').value,
                "description": rubricItemCells[2].querySelector('input').value,
                "detailedExplanation": getTextContent(rubricItemCells[3]),
                "graderNote": getTextContent(rubricItemCells[4]),
                "showToStudents": rubricItemCells[5].querySelector('input').checked ? "always" : "ifSelected",
            };
            rubricItemData.push(rubricItem);
        }

        let ret_json = {
            "checkboxValues": checkboxValues,
            "minimumRubricScore": minPoints,
            "maximumExtraCredit": maxExtraPoints,
            "rubricItems": rubricItemData
        };
        return JSON.stringify(ret_json);
    }
    function restoreParsedData(ret_json_string) {
        // update the rubric settings with the parsed data

        let ret_json;
        try {
            ret_json = JSON.parse(ret_json_string);
        } catch {
            return false;
        }
        let checkboxValues = ret_json.checkboxValues;
        let minPoints = ret_json.minimumRubricScore;
        let maxExtraPoints = ret_json.maximumExtraCredit;
        let rubricItemData = ret_json.rubricItems;
        // check the validity of the parsed data
        if (checkboxValues !== 1 && checkboxValues !== 2) {
            console.log("Invalid checkboxValues: " + checkboxValues);
            checkboxValues = 2;  // default to positive grading
            // return false;
        }
        // check if minPoints and maxExtraPoints are integers
        if (isNaN(minPoints)) {
            console.log("Invalid minPoints: " + minPoints);
            minPoints = 0;  // default
            // return false;
        }
        if (isNaN(maxExtraPoints)) {
            console.log("Invalid maxExtraPoints: " + maxExtraPoints);
            maxExtraPoints = 0; // default
            // return false;
        }
        // check each rubric item
        for (let i = 0; i < rubricItemData.length; i++) {
            let rubricItem = rubricItemData[i];
            if (isNaN(rubricItem.points)) {
                console.log("Invalid rubricItem.points: " + rubricItem.points);
                return false;
            }
            // if rubricItem.description is not a string, return
            if (typeof rubricItem.description !== "string") {
                console.log("Invalid rubricItem.description: " + rubricItem.description);
                return false;
            }
            // if rubricItem.detailedExplanation is not a string, return
            if (typeof rubricItem.detailedExplanation !== "string") {
                console.log("Invalid rubricItem.detailedExplanation: " + rubricItem.detailedExplanation);
                return false;
            }
            // if rubricItem.graderNote is not a string, return
            if (typeof rubricItem.graderNote !== "string") {
                console.log("Invalid rubricItem.graderNote: " + rubricItem.graderNote);
                return false;
            }
            // if rubricItem.showToStudents is not one of the two strings, return
            if (rubricItem.showToStudents !== "always" && rubricItem.showToStudents !== "ifSelected") {
                console.log("Invalid rubricItem.showToStudents: " + rubricItem.showToStudents);
                return false;
            }
        }
        console.log("Validation passed");
        // update the checkboxes
        let checkboxes = col6.querySelectorAll('input');
        if (checkboxValues === 1) {
            checkboxes[1].checked = true;
        }
        if (checkboxValues === 2) {
            checkboxes[0].checked = true;
        }
        // update the minPoints and maxExtraPoints
        modalBody.querySelector('input[name="min_points"]').value = +minPoints;
        modalBody.querySelector('input[name="max_extra_points"]').value = +maxExtraPoints;
        // update the rubric items
        let rubricItemTable = modalBody.querySelector('.js-rubric-items-table');
        let rubricItemBody = rubricItemTable.querySelector('tbody');
        let rubricItemRows = rubricItemBody.querySelectorAll('tr');
        let _j = 0;
        while (rubricItemRows.length > _j) {
            let deleteButton = rubricItemRows[_j].querySelector('.js-rubric-item-delete');
            if (deleteButton) {
                deleteButton.click();
                rubricItemRows = rubricItemBody.querySelectorAll('tr');
            } else {
                _j += 1;
            }
        }
        let addRubricItemButton = modalBody.querySelector('.js-add-rubric-item-button');
        for (let i = 0; i < rubricItemData.length; i++) {
            addRubricItemButton.click();
        }
        rubricItemRows = rubricItemBody.querySelectorAll('tr');
        _j = 0;
        for (let i = 0; i < rubricItemRows.length; i++) {
            let rubricItem = rubricItemData[_j];
            let rubricItemCells = rubricItemRows[i].querySelectorAll('td');
            if (rubricItemCells.length < 6) {
                continue;
            }
            rubricItemCells[1].querySelector('input').value = +rubricItem.points;
            rubricItemCells[2].querySelector('input').value = rubricItem.description;
            let modifyDetailedExplanationButton = rubricItemCells[3].querySelector('button');
            modifyDetailedExplanationButton.click();
            rubricItemCells[3].querySelector('textarea').value = rubricItem.detailedExplanation;
            let modifyGraderNoteButton = rubricItemCells[4].querySelector('button');
            modifyGraderNoteButton.click();
            rubricItemCells[4].querySelector('textarea').value = rubricItem.graderNote;
            if (rubricItem.showToStudents === "always") {
                rubricItemCells[5].querySelectorAll('input')[0].checked = true;
            }
            else {
                rubricItemCells[5].querySelectorAll('input')[1].checked = true;
            }
            _j = _j + 1;
        }
        return true;
    }

    function popup(Button, popupText) {
        let absolutePos = Button.getBoundingClientRect();
        let width = Button.offsetWidth;
        let height = Button.offsetHeight;
// get absolute position of the modal
        let modalPos = modalBody.getBoundingClientRect();
// get the relative position of the button
        let relativePos = {
            x: absolutePos.x - modalPos.x + width,
            y: absolutePos.y - modalPos.y - height / 2
        };
        let divtext = "<div class=\"popover fade bs-popover-right show\" role=\"tooltip\" id=\"popover687467\" " +
            "x-placement=\"right\" style=\"position: absolute; transform: translate3d(" + relativePos.x + "px, " +
            relativePos.y + "px, 0px); top: 0px; left: 0px; will-change: transform;\"><div class=\"arrow\" styl" +
            "e=\"top: 6px;\"></div><h3 class=\"popover-header\"></h3><div class=\"popover-body\">" + popupText + "</div></div>";
// create a new div that lasts for 3 seconds
        let popup = document.createElement('div');
        let styles = document.createElement('style');
        styles.innerHTML = ".BtnPopupFadeOut {opacity: 0; animation: btnPopupFadeOut 1s;}\n@keyframes btnPopupFadeOut {0% {opacity: 1;} 50% {opacity: 1;}  100% {opacity: 0;}}\n";
        document.head.appendChild(styles);
        popup.classList.add('BtnPopupFadeOut');
        popup.innerHTML = divtext;
// append the new div to the body
        ButtonDiv.appendChild(popup);
        setTimeout(function() {
            popup.remove();
        }, 1300);
    }
// call getParsedData() when the new button is clicked and copy the result to the clipboard
    copyButton.addEventListener('click', function() {
        let parsedData = getParsedData();
        navigator.clipboard.writeText(parsedData).then((e)=>{
            console.log("DUMP DATA");
            console.log(parsedData);
// get absolute position of the button
            popup(copyButton, "Copied!");
        });
    });

    document.addEventListener("paste", (event) => {
        if (modal.style.display !== "none") {
            if (event.target.tagName === "TEXTAREA" || event.target.tagName === "INPUT") {
                return;
            }

            let parsedData;
            try {
                parsedData = (event.clipboardData || window.clipboardData).getData("text");
            } catch {
                return;
            };

            let response = restoreParsedData(parsedData);
            console.log("LOAD DATA");
            console.log(parsedData);
            // get absolute position of the button
            if (response) {
                popup(pastePseudoButton, "Pasted!");
                event.preventDefault();
            } else {
                popup(pastePseudoButton, "Invalid data from clipboard!");
            }
        }
    }, false);
    pastePseudoButton.addEventListener('click', function() {
        popup(pastePseudoButton, "Press Ctrl+V!");
    });
    helpButton.addEventListener('click', function() {
        // create a dialog box with a close button
        let dialog = document.createElement('dialog');
        dialog.innerHTML = "<div>" +
            "<h2>What do the buttons do</h2>" +
            "<p>These buttons make copying rubrics easier. The original process of creating rubric by hand is tiring. With this script, you can copy the rubric from one question, and apply it to another question within just a few steps. </p>" +
            "<h2>How to use this script</h2>" +
            "<p>1. Navigate to the grading page containing your desired rubric. </p>" +
            "<p>2. In the Rubric dialog, click on \"Copy Rubric\" to copy the rubric. </p>" +
            "<p>3. Navigate to the grading page where you wish to apply the rubric.  </p>" +
            "<p>4. In the Rubric dialog, press \"Ctrl+V\" to paste the rubric. </p>" +
            "<p>5. Don't forget to click \"Save rubric\".</p>" +
            "<h2>What is done during this process</h2>" +
            "<p>The script will copy the rubric to the clipboard in JSON. You can view and modify the copied string in a text editor before pasting back to the question. When Ctrl+V is pressed, the script will validate the JSON string first. If everything is correct, the script will add the rubric as described in the JSON string.</p>" +
            "</div>";
        let closeButton = document.createElement('button');
        closeButton.innerHTML = "Close";
        closeButton.addEventListener('click', function() {
                dialog.close();

            }
        );
        dialog.appendChild(closeButton);
        document.body.appendChild(dialog);
        dialog.showModal();

    });

    // Graded instance counter
    // get username
    let usernameNavBar = document.getElementById("navbarDropdown");
    let span = usernameNavBar.querySelector("span");
    // username = usernameNavBar.innerText - span.innerText
    let username = usernameNavBar.innerText.replace(span.innerText, "").trim();
    // get div with class "js-main-grading-panel"
    let mainGradingPanel = document.querySelector(".js-main-grading-panel");
    // get li with class "list-group-item" "d-flex" and "align-items-center" from mainGradingPanel
    let listGroupItem = mainGradingPanel.querySelectorAll(".list-group-item.d-flex.align-items-center");
    // find the li that contains <a> with class "btn btn-primary", role "button"
    let backButton;
    for (let i = 0; i < listGroupItem.length; i++) {
        let button = listGroupItem[i].querySelector("a.btn.btn-primary");
        if (button) {
            backButton = button;
            break;
        }
    }
    // get href from the button
    let href = backButton.getAttribute("href");
    // get the html content from the href
    function getQuestionName(data) {
        let parser = new DOMParser();
        let doc = parser.parseFromString(data, "text/html");

        // get question name from div class="card-header bg-primary text-white", child of div class="card mb-4", child of main id="content"
        let docMainContent = doc.getElementById("content");
        let card = docMainContent.querySelector(".card.mb-4");
        let questionNameHeader = card.querySelector(".card-header.bg-primary.text-white");
        let questionName = questionNameHeader.innerText.trim();
        console.log(questionName);
        return questionName;
    }
    let mainContent = document.getElementById("content");
    let sideBar = mainContent.querySelector(".col-lg-4.col-12");
    let cardHeader = sideBar.querySelector(".card-header.bg-info.text-white");
    function appendGradedInstanceCounter(questionName, gradedInstanceCounter, totalInstances) {
        console.log("appendGradedInstanceCounter");
        // look at document for the div with class "card-header bg-info text-white"
        // create a new div with class "text-white"
        let div = document.createElement("div");
        div.classList.add("text-white");
        // add the gradedInstanceCounter to the div
        div.innerHTML = gradedInstanceCounter + "/" + totalInstances + " Graded by you for " + questionName;
        // append the new div to the cardHeader
        cardHeader.appendChild(div);
        console.log(div);
    }
    let instancesJsonURL = href + "/instances.json";
    fetch(instancesJsonURL).then(response => response.json()).then(data => {
        let gradedInstanceCounter = 0;
        for (let i = 0; i < data.instance_questions.length; i++) {
            let instance = data.instance_questions[i];
            if (instance.last_grader_name === username) {
                gradedInstanceCounter++;
            }
        }
        let totalInstances = data.instance_questions.length;
        console.log(gradedInstanceCounter);
        fetch(href).then(response => response.text()).then(data => {
            let questionName = getQuestionName(data);
            appendGradedInstanceCounter(questionName, gradedInstanceCounter, totalInstances);
        });
    })

    // comment templates
    function createCommentTemplateBox() {
        let gradingForm = sideBar.querySelector("form[name='manual-grading-form']");
        let _ = gradingForm.querySelector("li.form-group.list-group-item");
        if (!_) {
            return 1;
        }
        let feedBackList = _.querySelector("label");
        let help = document.getElementById("submission-feedback-help-main");
        // create a new div and insert it before the help
        let commentTemplates = document.createElement("div");
        // the div has a rounded border
        commentTemplates.style.border = "1px solid #d1d5da";
        commentTemplates.style.borderRadius = "3px";
        commentTemplates.style.padding = "10px";
        // the div has a title: "Comment Templates"
        let title = document.createElement("h6");
        title.innerHTML = "Comment Templates";
        commentTemplates.appendChild(title);
        // create a separator
        let separator = document.createElement("hr");
        commentTemplates.appendChild(separator);

        feedBackList.insertBefore(commentTemplates, help);
        // commentTemplates consists of a list. Each element has two buttons: one shows the comment, the other deletes the comment
        let commentList = document.createElement("ul");
        commentTemplates.appendChild(commentList);
        // get the template from the local storage
        let commentTemplateList = localStorage.getItem("commentTemplates");
        if (commentTemplateList) {
            commentTemplateList = JSON.parse(commentTemplateList);
        } else {
            commentTemplateList = [];
        }
        function setCommentButtonStyle(button) {
            button.style.border = "1px solid #d1d5da";
            button.style.borderRadius = "3px";
            // the button has a pale blue background
            button.style.backgroundColor = "#f1f8ff";
            // the button has a margin
            button.style.margin = "1px";
            button.style.display = "inline-block";
            button.style.cursor = "pointer";
        }
        function createCommentTemplateItem(comment) {
            let li = document.createElement("li");
            // don't show the bullet point
            li.style.listStyleType = "none";
            // ignore the indentation by ul
            li.style.marginLeft = "-40px";
            let label = document.createElement("div");
            label.style.display = "inline-block";
            label.innerHTML = comment;
            label.style.margin = "1px";
            label.style.border = "1px solid #d1d5da";

            let deleteButton = document.createElement("div");
            deleteButton.innerHTML = "Delete";
            setCommentButtonStyle(deleteButton);
            // the delete button is aligned to the right
            deleteButton.style.float = "right";

            let dummy = document.createElement("div");
            dummy.appendChild(label);
            dummy.appendChild(deleteButton);
            li.appendChild(dummy);
            // get the index of li
            let index = commentList.children.length;
            // if the index is even, the background is pale gray
            if (index % 2 === 0) {
                li.style.backgroundColor = "#f5f5f5";
            }
            // if mouse hovers over the li, the background becomes pale blue
            li.addEventListener('mouseover', function () {
                li.style.backgroundColor = "#e1e8ff";
            }
            );
            li.addEventListener('mouseout', function () {
                if (index % 2 === 0) {
                    li.style.backgroundColor = "#f5f5f5";
                } else {
                    li.style.backgroundColor = "white";
                }
            });
            commentList.appendChild(li);
            li.addEventListener('click', function () {
                let commentBox = feedBackList.querySelector("textarea.form-control.js-submission-feedback");
                // insert the comment to the comment box where the cursor is
                let cursorPosition = commentBox.selectionStart;
                let textBefore = commentBox.value.substring(0, cursorPosition);
                let textAfter = commentBox.value.substring(commentBox.selectionEnd, commentBox.value.length);
                // if the comment contains "[cursor]", remember the position and remove it
                let offset = comment.indexOf("[cursor]");
                if (offset !== -1) {
                    comment = comment.replace("[cursor]", "");
                }
                else {
                    offset = comment.length;
                }
                commentBox.value = textBefore + comment + textAfter;

                // move the cursor to the end of the inserted comment
                commentBox.selectionStart = cursorPosition + offset;
                commentBox.selectionEnd = cursorPosition + offset;

            });
            deleteButton.addEventListener('click', function (e) {
                commentList.removeChild(li);
                commentTemplateList.splice(commentTemplateList.indexOf(comment), 1);
                localStorage.setItem("commentTemplates", JSON.stringify(commentTemplateList));
                e.stopPropagation();
            });
        }

        for (let i = 0; i < commentTemplateList.length; i++) {
            let comment = commentTemplateList[i];
            createCommentTemplateItem(comment);
        }
        // create a new button to add a new comment
        let newCommentDiv = document.createElement("div");
        let newCommentTextArea = document.createElement("textarea");
        let newCommentButton = document.createElement("div");
        newCommentButton.innerHTML = "add";
        setCommentButtonStyle(newCommentButton);
        newCommentButton.style.float = "right";

        newCommentTextArea.style.display = "inline-block";
        // the text area has the same height as the newcommentbutton
        newCommentTextArea.style.height = "30px";
        newCommentTextArea.style.width = "80%";
        newCommentDiv.style.marginTop = "10px";

        newCommentDiv.appendChild(newCommentTextArea);
        newCommentDiv.appendChild(newCommentButton);
        commentTemplates.appendChild(newCommentDiv);
        newCommentButton.addEventListener('click', function () {
            let comment = newCommentTextArea.value;
            commentTemplateList.push(comment);
            localStorage.setItem("commentTemplates", JSON.stringify(commentTemplateList));
            createCommentTemplateItem(comment);
            // clear the text area
            newCommentTextArea.value = "";
        });
        return 0;
    }
    // frequently check if the grading form is loaded using async method
    async function checkGradingForm() {
        if (createCommentTemplateBox()) {
            setTimeout(checkGradingForm, 10);
            console.log("retrying");
        }
        else {
            console.log("done");
        }

    }
    checkGradingForm();
})();