📜 Quiz Assistant - TSAWrite

// ==UserScript==
// @name         Quiz Assistant - TSAWrite
// @namespace    http://tampermonkey.net/
// @version      1.3.3 // Version bumped for Gemini API fix
// @description  Assists with quiz completion by integrating with your AI API. Ctrl/Cmd to peek answer.
// @author       TSAWrite
// @match        https://lms.uj.ac.za/mod/quiz/review.php*
// @match        https://lms.uj.ac.za/mod/quiz/attempt.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tsawrite.space
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// @grant        GM_xmlhttpRequest
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

/*
 * 🚨 SECURITY WARNING 🚨
 * Exposing your API key in a client-side script like this is highly insecure.
 * Anyone can view the script's code and steal your API key, potentially
 * leading to charges on your Google account. Use this at your own risk.
 * A server-side proxy (like your old Heroku setup) is the recommended secure method.
 */

(function () {
    'use strict';

    // Configuration
    const API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent";
    const USER_API_KEY = "AIzaSyCi30ibFqBXEQsYjPHfcH9RCbs1brcl_rc"; // THIS KEY IS PUBLIC AND INSECURE
    const MAX_RETRIES = 2;

    // State management
    let isActive = true;
    let ctrlPressed = false;
    let altPressCount = 0;
    let altPressTimeout;
    let latestAnswerText = "";

    // UI Elements
    const statusIndicator = createStatusIndicator();
    const answerDisplay = createAnswerDisplay();

    updateStatus("#8dcdff");

    function createStatusIndicator() {
        const indicator = document.createElement("div");
        indicator.id = "tsawriteStatusIndicator";
        indicator.style.cssText =
            'position: fixed; bottom: 1px; right: 1px; width: 14px; height: 5px;' +
            'background-color: blue; box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px;' +
            'z-index: 2147483647; transition: background-color 0.3s; border-radius: 2px;';
        document.body.appendChild(indicator);
        return indicator;
    }

    function createAnswerDisplay() {
        const display = document.createElement("div");
        display.id = "tsawriteAnswerDisplay";
        display.style.cssText =
            'position: fixed; bottom: 1px; left: 1px; max-width: 350px;' +
            'z-index: 2147483647; font-size: 11px; color: #333;' +
            'background-color: rgba(240, 240, 240, 0.9); padding: 3px 6px;' +
            'border: 1px solid #ccc; border-radius: 3px; word-wrap: break-word;' +
            'display: none; box-shadow: 0 2px 5px rgba(0,0,0,0.1);';
        document.body.appendChild(display);
        return display;
    }

    function updateStatus(color) {
        if (!isActive) {
            statusIndicator.style.backgroundColor = "transparent";
            return;
        }
        const validColors = ["#8dcdff", "green", "amber", "red", "transparent"];
        if (!validColors.includes(color)) {
            console.error('TSAWrite: Invalid color specified:', color);
            return;
        }
        statusIndicator.style.backgroundColor = color === "amber" ? "yellow" : color;
    }

    function displayAnswer(text) {
        latestAnswerText = String(text);
        answerDisplay.textContent = latestAnswerText;
        if (ctrlPressed) {
            answerDisplay.style.display = "block";
        } else {
            answerDisplay.style.display = "none";
        }
    }

    async function copyToClipboard(text) {
        try {
            await navigator.clipboard.writeText(String(text));
            return true;
        } catch (err) {
            console.warn("TSAWrite: navigator.clipboard.writeText failed:", err.name);
            try {
                const textArea = document.createElement("textarea");
                textArea.value = String(text);
                textArea.style.position = "absolute"; textArea.style.left = "-9999px";
                document.body.appendChild(textArea);
                textArea.select();
                const success = document.execCommand("copy");
                document.body.removeChild(textArea);
                if (!success) console.error("TSAWrite: Fallback copy using execCommand failed.");
                return success;
            } catch (fallbackErr) {
                console.error("TSAWrite: Fallback copy method also failed:", fallbackErr);
                return false;
            }
        }
    }

    function imageToBase64(url) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = "Anonymous";
            img.onload = function () {
                const canvas = document.createElement("canvas");
                canvas.width = img.width;
                canvas.height = img.height;
                const ctx = canvas.getContext("2d");
                ctx.drawImage(img, 0, 0);
                try {
                    const dataURL = canvas.toDataURL("image/png");
                    resolve(dataURL);
                } catch (error) {
                    console.error("TSAWrite: Canvas toDataURL failed:", error);
                    reject(error);
                }
            };
            img.onerror = function (errorEvent) {
                console.error("TSAWrite: Failed to load image for Base64 conversion:", url, errorEvent);
                reject(new Error("Failed to load image: " + url));
            };
            img.src = url;
        });
    }

    function processQuestionText(formulations, type) {
        let text;
        const qtextElement = formulations.find(".qtext").first();
        let isCloze = false;

        if (formulations.find("td.text p").length > 0) { // Match type question
            text = qtextElement.text().trim();
            text += "\\nFor EACH in column A, match with B\\nColumn A:\\n";
            formulations.find("td.text p").each(function (i) {
                text += (i + 1) + ". " + $(this).html().trim() + "\\n";
            });
            text += "\\nColumn B Options:\\n";
            formulations.find("td.control select").eq(0).find("option").each(function () {
                if ($(this).val() !== "") {
                    text += "- " + $(this).html().trim() + "\\n";
                }
            });
        } else { // Not a match type
            if (qtextElement.length > 0 && qtextElement.find(".cloze-question-marker").length > 0) { // Cloze question
                isCloze = true;
                let fml_qtext = qtextElement.clone();
                let nm = 0;
                fml_qtext.find(".cloze-question-marker").each(function () {
                    $(this).replaceWith("(" + (++nm) + ". ___)");
                });
                text = fml_qtext.text().trim();
            } else { // MC / direct answer
                text = qtextElement.length > 0 ? qtextElement.text().trim() : "";
                if (!text && formulations.length > 0) {
                     text = formulations.clone().find('.answer, input, label, .specificfeedback, .outcome, .rightanswer, .feedback').remove().end().text().trim();
                }

                const answerChoicesContainer = formulations.find(".answer").first();
                if (answerChoicesContainer.length > 0) {
                    let choicesString = "\n\nOptions:\n";
                    let foundOptions = false;

                    answerChoicesContainer.children('div').each(function(index) { // Iterates over .r0, .r1 etc.
                        const currentOptionDiv = $(this); // This is 
or
let optionText = ""; let moodleNumbering = ""; // To store Moodle's own numbering like "1. " const answerLabelRegion = currentOptionDiv.find('div[data-region="answer-label"]'); if (answerLabelRegion.length > 0) { const answernumberEl = answerLabelRegion.find('.answernumber'); if (answernumberEl.length > 0) { moodleNumbering = answernumberEl.text().trim(); if (moodleNumbering && !/\s$/.test(moodleNumbering) && moodleNumbering.endsWith('.')) { moodleNumbering += " "; } else if (moodleNumbering && !moodleNumbering.endsWith(".") && !/\s$/.test(moodleNumbering)){ moodleNumbering += ". "; } } const flexFillP = answerLabelRegion.find('.flex-fill p'); if (flexFillP.length > 0) { optionText = flexFillP.text().trim(); } else { const flexFillDiv = answerLabelRegion.find('.flex-fill'); if (flexFillDiv.length > 0) { optionText = flexFillDiv.text().trim(); } else { optionText = answerLabelRegion.clone().find('.answernumber').remove().end().text().trim(); } } } else { const answernumberElFallback = currentOptionDiv.find('.answernumber'); if (answernumberElFallback.length > 0) { moodleNumbering = answernumberElFallback.text().trim(); if (moodleNumbering && !/\s$/.test(moodleNumbering) && moodleNumbering.endsWith('.')) { moodleNumbering += " "; } else if (moodleNumbering && !moodleNumbering.endsWith(".") && !/\s$/.test(moodleNumbering)){ moodleNumbering += ". "; } } optionText = currentOptionDiv.clone().find('input, .accesshide, .feedbackspan, .specificfeedback, .answernumber').remove().end().text().trim(); } if (optionText) { if (moodleNumbering) { choicesString += moodleNumbering + optionText + "\n"; } else { choicesString += String.fromCharCode(65 + index) + ". " + optionText + "\n"; } foundOptions = true; } }); if (foundOptions) { text += choicesString; } } } } text = (text || "").replace(/Question \\d+ Answer/, "\\n") .replace("Question text", "") .replace("Clear my choice", "") .trim(); // <<< ADD THIS INSTRUCTION BLOCK >>> const instructions = `Analyze the following question and its options carefully. - Determine which options are correct according to general legal principles. - If multiple options may be partially correct or true in some contexts, include all that are generally accepted as true. - Respond ONLY with the letters of all correct options, separated by commas. - If the question is True or False, reply only "True" or "False". - Do NOT explain, repeat, or add any text besides the letters or True/False. --- `; text = instructions + text; // <<< END OF ADDED BLOCK >>> if (type === "answer" || (text && text.includes("\nOptions:\n")) || isCloze ) { return text; } else { return "Complete this statement and fill the blanks: " + text; } } // --- MODIFIED FOR GEMINI API --- async function callTSAaiWithGM(data) { const parts = []; if (data.text) { parts.push({ text: data.text }); } if (data.image) { const base64Data = data.image.split(',')[1]; parts.push({ inline_data: { mime_type: "image/png", data: base64Data } }); } const geminiPayload = { contents: [{ parts: parts }], }; console.log("🚀 Gemini API request payload:"); console.log(JSON.stringify(geminiPayload, null, 2)); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: API_URL + '?key=' + USER_API_KEY, // Key is now a URL parameter headers: { "Content-Type": "application/json" }, data: JSON.stringify(geminiPayload), // Send Gemini-formatted payload timeout: 30000, onload: function(response) { if (response.status >= 200 && response.status < 300) { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject({ status: response.status, data: "Error parsing API response.", rawResponse: response.responseText }); } } else { let errorMsg = "API request failed."; try { const errData = JSON.parse(response.responseText); errorMsg = errData.error?.message || ("Status: " + response.status); } catch (e) { /* ignore parse error */ } reject({ status: response.status, data: errorMsg, rawResponse: response.responseText }); } }, onerror: function(response) { reject({ status: 0, data: "Network error or API unreachable.", rawResponse: response.responseText }); }, ontimeout: function() { reject({ status: 0, data: "API request timed out.", rawResponse: "Timeout" }); } }); }); } async function completeQuiz(type = 'answer', qIndex = 0, clearfix = null) { if (!isActive) { console.log('TSAWrite: Quiz Assistant is not active.'); return; } if (!USER_API_KEY || USER_API_KEY === "null" || USER_API_KEY === "undefined") { console.error("TSAWrite: User API Key is missing or invalid in the script."); displayAnswer("Error: User API Key is missing/invalid. Please re-copy the script from the dashboard."); updateStatus("red"); return; } updateStatus("amber"); displayAnswer("Processing..."); try { const formulations = clearfix === null ? $(".formulation.clearfix").eq(qIndex) : $(clearfix); if (formulations.length === 0) throw new Error("Question element not found."); let apiData = { text: processQuestionText(formulations, type) }; const imageElement = formulations.find(".qtext img, .answer img").first(); if (imageElement.length > 0) { const imageUrl = imageElement.attr("src"); if (imageUrl) { try { apiData.image = await imageToBase64(imageUrl); } catch (error) { console.warn("TSAWrite: Image processing failed:", error.message, "Continuing with text only."); } } } let response; let lastError; for (let i = 0; i <= MAX_RETRIES; i++) { try { response = await callTSAaiWithGM(apiData); break; } catch (error) { lastError = error; if (i < MAX_RETRIES) { await new Promise(resolve_inner => setTimeout(resolve_inner, 1000 * (i + 1))); } } } // --- MODIFIED FOR GEMINI API RESPONSE --- if (!response) throw lastError || new Error("API request failed after retries."); const answerText = response.candidates?.[0]?.content?.parts?.[0]?.text || "No valid answer found in response."; await copyToClipboard(answerText); displayAnswer(answerText); console.log("TSAWrite API Response:", response); updateStatus("green"); } catch (error) { console.error("TSAWrite: Quiz completion failed overall:", error); displayAnswer(error.data || error.message || "An unknown error occurred."); updateStatus("red"); } } $(document).on('keydown', function (e) { if (e.ctrlKey || e.metaKey) { if (!ctrlPressed) { ctrlPressed = true; if (latestAnswerText) { answerDisplay.style.display = "block"; } } } if (e.key === "CapsLock") { altPressCount++; if (altPressTimeout) clearTimeout(altPressTimeout); altPressTimeout = setTimeout(() => { altPressCount = 0; }, 1000); if (altPressCount === 3) { isActive = !isActive; altPressCount = 0; clearTimeout(altPressTimeout); if (isActive) { updateStatus("#8dcdff"); console.log("TSAWrite Quiz Assistant activated."); } else { updateStatus("transparent"); answerDisplay.style.display = "none"; latestAnswerText = ""; console.log("TSAWrite Quiz Assistant deactivated."); } } } }); $(document).on('keyup', function (e) { if (e.key === "Control" || e.key === "Meta") { if (ctrlPressed) { ctrlPressed = false; answerDisplay.style.display = "none"; } } }); $(document).on('contextmenu', function (event) { if (!isActive) return; var $targetElement = $(event.target); var $clearfixParent = $targetElement.closest('.formulation.clearfix'); if (!$clearfixParent.length) { let $questionContainer = $targetElement.closest('.que'); if ($questionContainer.length) { $clearfixParent = $questionContainer.find('.formulation.clearfix').first(); } } if ($clearfixParent.length) { event.preventDefault(); console.log('TSAWrite Quiz Assistant: Processing question via context menu'); completeQuiz('answer', 0, $clearfixParent); } }); console.log("TSAWrite Quiz Assistant v1.3.3 (for Dashboard) Loaded. Press CapsLock 3x to toggle. Hold Ctrl/Cmd to view answer."); })();