📜 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.");
})();