Testportal Multi Tool Extended (Revised)

Enhanced Testportal tool with AI analysis (Gemini) and DuckDuckGo search integration. Toggleable effects.

// ==UserScript==
// @name         Testportal Multi Tool Extended (Revised)
// @namespace    https://*.testportal.pl/
// @version      3.1.2
// @description  Enhanced Testportal tool with AI analysis (Gemini) and DuckDuckGo search integration. Toggleable effects.
// @author       Czarek Nakamoto (mrcyjanek.net), Modified by Nyxiereal
// @license      GPL-3.0
// @match        https://*.testportal.net/*
// @match        https://*.testportal.pl/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      generativelanguage.googleapis.com
// @connect      * // Allows GM_xmlhttpRequest to fetch images from any domain.
// ==/UserScript==

(function() {
    'use strict';
    console.log("[TESTPORTAL MULTITOOL REVISED] started");

    // --- Constants ---
    const GEMINI_API_KEY_STORAGE = "testportalMultiToolGeminiApiKey_v2";
    const GEMINI_MODEL = 'gemini-2.0-flash';
    const GEMINI_API_URL_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=`;
    const SCRIPT_STYLE_ID = 'tp-multitool-styles';
    const TOGGLE_BUTTON_ID = 'tp-toggle-script-button';

    // --- Script State ---
    const scriptState = {
        observer: null,
        originalRegExpTest: RegExp.prototype.test, // Capture original BEFORE any override
        customRegExpTestFunction: null,
        isActive: false, // Start inactive, will be activated by main()
        styleElement: null,
        toggleButtonElement: null,
        elementsToCleanup: [],
        elementsWithDataset: [],
        geminiPopupElement: null,
    };

    // --- Define the Custom RegExp Override Function ---
    scriptState.customRegExpTestFunction = function (s) {
        const string = this.toString();
        if (string.includes("native code") && string.includes("function")) {
            // This is the core of the bypass: if a function's .toString() output
            // suggests it's native browser code, our .test() override returns true,
            // potentially fooling anti-cheat checks that look for modified functions.
            return true;
        }
        // For all other regular expressions, call the original .test() method.
        return scriptState.originalRegExpTest.call(this, s);
    };

    // --- Styles ---
    function ensureCustomStyles() {
        if (scriptState.styleElement) return;

        const fullStyles = `
            .tp-gemini-popup {
                position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
                background-color: #2d3748; color: #e2e8f0; border: 1px solid #4a5568;
                border-radius: 8px; padding: 20px; z-index: 10001; min-width: 380px;
                max-width: 650px; width: 90%; max-height: 80vh; overflow-y: auto;
                box-shadow: 0 10px 25px rgba(0,0,0,0.35), 0 6px 10px rgba(0,0,0,0.25);
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
                font-size: 15px;
            }
            .tp-gemini-popup-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid #4a5568; }
            .tp-gemini-popup-title { font-weight: 600; font-size: 1.2em; color: #a0aec0; }
            .tp-gemini-popup-close { background: none; border: none; font-size: 1.9em; line-height: 1; cursor: pointer; color: #a0aec0; padding: 0 5px; transition: color 0.2s ease-in-out; }
            .tp-gemini-popup-close:hover { color: #cbd5e0; }
            .tp-gemini-popup-content { white-space: pre-wrap; font-size: 1em; line-height: 1.65; color: #cbd5e0; }
            .tp-gemini-popup-content strong, .tp-gemini-popup-content b { color: #e2e8f0; font-weight: 600; }
            .tp-gemini-popup-loading { text-align: center; font-style: italic; color: #a0aec0; padding: 25px 0; font-size: 1.05em; }
            .tp-gemini-popup::-webkit-scrollbar { width: 8px; }
            .tp-gemini-popup::-webkit-scrollbar-track { background: #2d3748; }
            .tp-gemini-popup::-webkit-scrollbar-thumb { background-color: #4a5568; border-radius: 4px; border: 2px solid #2d3748; }
            .tp-gemini-popup::-webkit-scrollbar-thumb:hover { background-color: #718096; }
            .tp-ai-button {
                background-color: #007bff; color: white; border: none; padding: 6px 10px;
                margin-left: 10px; border-radius: 4px; cursor: pointer; font-size: 0.9em;
                vertical-align: middle;
            }
            .tp-ai-button:hover { background-color: #0056b3; }
            .tp-ddg-button {
                background-color: #de5833; color: white; border: none; padding: 6px 10px;
                margin-left: 8px; border-radius: 4px; cursor: pointer; font-size: 0.9em;
                vertical-align: middle;
            }
            .tp-ddg-button:hover { background-color: #b94929; }
        `;

        scriptState.styleElement = document.createElement('style');
        scriptState.styleElement.id = SCRIPT_STYLE_ID;
        scriptState.styleElement.textContent = fullStyles;
        document.head.appendChild(scriptState.styleElement);
    }

    function removeCustomStyles() {
        if (scriptState.styleElement) {
            scriptState.styleElement.remove();
            scriptState.styleElement = null;
        }
    }

    // --- API Key Management ---
    function getGeminiApiKey() {
        let apiKey = GM_getValue(GEMINI_API_KEY_STORAGE, null);
        if (!apiKey || apiKey.trim() === "") {
            apiKey = prompt("Please enter your Google Gemini API Key (e.g., AIzaSy...). This will be stored locally for this script.");
            if (apiKey && apiKey.trim() !== "") {
                GM_setValue(GEMINI_API_KEY_STORAGE, apiKey.trim());
            } else {
                alert("Gemini API Key not provided. AI features will be disabled for this session."); // This alert is fine for API key setup
                return null;
            }
        }
        return apiKey.trim();
    }

    GM_registerMenuCommand('Set/Update Gemini API Key', () => {
        const currentKey = GM_getValue(GEMINI_API_KEY_STORAGE, '');
        const newKey = prompt('Enter/Update your Gemini API Key:', currentKey);
        if (newKey !== null) {
            GM_setValue(GEMINI_API_KEY_STORAGE, newKey.trim());
            alert('Gemini API Key ' + (newKey.trim() ? 'saved!' : 'cleared!')); // This alert is fine for API key setup
        }
    });

    // --- Image Fetching ---
    function fetchImageAsBase64(imageUrl) {
        return new Promise((resolve, reject) => {
            console.log(`[AI Helper] Fetching image: ${imageUrl}`);
            GM_xmlhttpRequest({
                method: 'GET',
                url: imageUrl,
                responseType: 'blob',
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        const blob = response.response;
                        const reader = new FileReader();
                        reader.onloadend = () => {
                            const dataUrl = reader.result;
                            const mimeType = dataUrl.substring(dataUrl.indexOf(':') + 1, dataUrl.indexOf(';'));
                            const base64Data = dataUrl.substring(dataUrl.indexOf(',') + 1);
                            resolve({ base64Data, mimeType });
                        };
                        reader.onerror = (error) => reject('FileReader error: ' + error);
                        reader.readAsDataURL(blob);
                    } else {
                        reject(`Failed to fetch image. Status: ${response.status}`);
                    }
                },
                onerror: (error) => reject('Network error fetching image: ' + JSON.stringify(error)),
                ontimeout: () => reject('Image fetch request timed out.')
            });
        });
    }

    // --- Gemini API Interaction ---
    async function queryGeminiWithDetails(apiKey, questionText, options, imageData = null) {
        if (!scriptState.isActive) return; // Don't query if script is "off"
        showGeminiPopup("⏳ Analyzing with Gemini AI...", true);
        // ... (rest of the function as before)
        let prompt = `
Context: You are an AI assistant helping a user with a Testportal online test.
The user needs to identify the correct answer(s) from the given options for the following question.
${imageData ? "An image is associated with this question; please consider it carefully in your analysis." : ""}

Question: "${questionText}"

Available Options:
${options.map((opt, i) => `${i + 1}. ${opt}`).join('\n')}

Please perform the following:
1. Identify the correct answer or answers from the "Available Options" list.
2. Provide a concise reasoning for your choice(s).
3. Format your response clearly. Start with "Correct Answer(s):" followed by the answer(s) (you can refer to them by option number or text), and then "Reasoning:" followed by your explanation. Be brief and to the point. If the question is ambiguous or cannot be answered with the provided information (including the image if present), please state that clearly in your reasoning.
        `;

        const apiUrl = `${GEMINI_API_URL_BASE}${apiKey}`;
        let requestPayloadContents = [{ parts: [{ text: prompt.trim() }] }];

        if (imageData && imageData.base64Data && imageData.mimeType) {
            requestPayloadContents[0].parts.push({
                inline_data: {
                    mime_type: imageData.mimeType,
                    data: imageData.base64Data
                }
            });
        }

        const apiPayload = {
            contents: requestPayloadContents,
            generationConfig: { temperature: 0.2, maxOutputTokens: 1024, topP: 0.95, topK: 40 },
            safetySettings: [
                { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
                { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
                { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_MEDIUM_AND_ABOVE" },
                { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_MEDIUM_AND_ABOVE" }
            ]
        };

        GM_xmlhttpRequest({
            method: 'POST',
            url: apiUrl,
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(apiPayload),
            timeout: 60000,
            onload: function(response) {
                try {
                    const result = JSON.parse(response.responseText);
                    if (result.candidates && result.candidates[0]?.content?.parts?.[0]?.text) {
                        showGeminiPopup(result.candidates[0].content.parts[0].text);
                    } else if (result.promptFeedback?.blockReason) {
                        showGeminiPopup(`Gemini API Error: Blocked - ${result.promptFeedback.blockReason}.\nDetails: ${JSON.stringify(result.promptFeedback.safetyRatings)}`);
                    } else if (result.error) {
                        showGeminiPopup(`Gemini API Error: ${result.error.message}\nDetails: ${JSON.stringify(result.error.details || result.error)}`);
                    } else {
                        console.error("[AI Helper] Gemini unexpected response:", response.responseText);
                        showGeminiPopup('Gemini API Error: Could not parse a valid response text. See console for raw response.');
                    }
                } catch (e) {
                    console.error("[AI Helper] Gemini response parse error:", e, response.responseText);
                    showGeminiPopup(`Gemini API Error: Failed to parse JSON response. ${e.message}. See console for raw response.`);
                }
            },
            onerror: (err) => {
                console.error("[AI Helper] Gemini request error:", err);
                showGeminiPopup(`Gemini API Network Error. Status: ${err.status || 'Unknown'}. Check console.`);
            },
            ontimeout: () => {
                console.error("[AI Helper] Gemini request timeout.");
                showGeminiPopup('Gemini API Error: Request timed out.');
            }
        });
    }

    // --- Popup Display ---
    function showGeminiPopup(content, isLoading = false) {
        if (!scriptState.isActive) {
             if (scriptState.geminiPopupElement) {
                scriptState.geminiPopupElement.remove();
                scriptState.geminiPopupElement = null;
             }
             return;
        }
        let popup = document.getElementById('tp-gemini-ai-popup');
        if (!popup) {
            popup = document.createElement('div');
            popup.id = 'tp-gemini-ai-popup';
            popup.classList.add('tp-gemini-popup');
            popup.innerHTML = `
                <div class="tp-gemini-popup-header">
                    <span class="tp-gemini-popup-title">Gemini AI Helper</span>
                    <button class="tp-gemini-popup-close" title="Close">×</button>
                </div>
                <div class="tp-gemini-popup-content"></div>
            `;
            document.body.appendChild(popup);
            popup.querySelector('.tp-gemini-popup-close').onclick = () => {
                popup.remove();
                scriptState.geminiPopupElement = null;
            };
            scriptState.geminiPopupElement = popup;
        }
        const contentDiv = popup.querySelector('.tp-gemini-popup-content');
        if (isLoading) {
            contentDiv.innerHTML = `<div class="tp-gemini-popup-loading">${content}</div>`;
        } else {
            let formattedContent = content
                .replace(/^(Correct Answer\(s\):)/gmi, '<strong>$1</strong>')
                .replace(/^(Reasoning:)/gmi, '<br><br><strong>$1</strong>');
            contentDiv.innerHTML = formattedContent;
        }
        popup.style.display = 'block';
    }

    // --- Button Click Handlers ---
    async function handleAiButtonClick(questionText, options, imageUrl) {
        if (!scriptState.isActive) return;
        const apiKey = getGeminiApiKey();
        if (!apiKey) return;
        // ... (rest of the function as before)
        let imageData = null;
        if (imageUrl) {
            try {
                showGeminiPopup("⏳ Fetching image...", true);
                if (imageUrl.startsWith('/')) {
                    imageUrl = window.location.origin + imageUrl;
                }
                imageData = await fetchImageAsBase64(imageUrl);
            } catch (error) {
                console.error("[AI Helper] Error fetching image:", error);
                showGeminiPopup(`⚠️ Error fetching image: ${error}.\nProceeding with text only.`, false);
                await new Promise(resolve => setTimeout(resolve, 3000));
            }
        }
        queryGeminiWithDetails(apiKey, questionText, options, imageData);
    }

    function handleDuckDuckGoButtonClick(questionText) {
        if (!scriptState.isActive) return;
        if (!questionText) return;
        const searchUrl = `https://duckduckgo.com/?q=${encodeURIComponent(questionText)}`;
        window.open(searchUrl, '_blank').focus();
    }

    // --- Question Enhancements ---
    function processQuestionElement(qEssenceEl) {
        if (!scriptState.isActive || qEssenceEl.dataset.enhancementsAdded) return;

        let questionTextContent = (qEssenceEl.innerText || qEssenceEl.textContent || "").trim();
        if (!questionTextContent) return;

        const questionContainer = qEssenceEl.closest('.question_container_wrapper, .question-view, .question-content, form, div.row, .question_row_content_container, .question_item_view_v2, .q_tresc_pytania_mock, .question_essence_fs');
        let answerElements = [];
        if (questionContainer) {
            answerElements = Array.from(questionContainer.querySelectorAll(".answer_body, .answer-body, .odpowiedz_tresc"));
        } else {
            const formElement = qEssenceEl.closest('form');
            if (formElement) {
                answerElements = Array.from(formElement.querySelectorAll(".answer_body, .answer-body, .odpowiedz_tresc"));
            }
        }

        const options = answerElements
                             .map(optEl => (optEl.innerText || optEl.textContent || "").trim())
                             .filter(Boolean);

        let questionImageElement = qEssenceEl.querySelector('img');
        if (!questionImageElement && questionContainer) {
            questionImageElement = questionContainer.querySelector('img.question-image, img.question_image_preview, .question_media img, .question-body__attachment img, .image_area img');
        }
        if (!questionImageElement && qEssenceEl.parentElement) {
            const siblingImg = qEssenceEl.parentElement.querySelector('img.question-image, .question_media img, .image_area img');
            if (siblingImg && !qEssenceEl.parentElement.classList.contains('answer_body')) {
                 questionImageElement = siblingImg;
            }
        }

        const imageUrl = questionImageElement ? questionImageElement.src : null;
        const buttonContainer = document.createElement('span');
        buttonContainer.style.marginLeft = "15px";
        buttonContainer.classList.add('tp-multitool-button-container');

        const aiButton = document.createElement('button');
        aiButton.textContent = "🧠 Ask AI";
        aiButton.title = "Analyze question, options, and image with Gemini AI";
        aiButton.classList.add('tp-ai-button');
        aiButton.onclick = (e) => { e.preventDefault(); e.stopPropagation(); handleAiButtonClick(questionTextContent, options, imageUrl); };
        buttonContainer.appendChild(aiButton);

        const ddgButton = document.createElement('button');
        ddgButton.textContent = "🔎 DDG";
        ddgButton.title = "Search this question on DuckDuckGo";
        ddgButton.classList.add('tp-ddg-button');
        ddgButton.onclick = (e) => { e.preventDefault(); e.stopPropagation(); handleDuckDuckGoButtonClick(questionTextContent); };
        buttonContainer.appendChild(ddgButton);

        qEssenceEl.appendChild(buttonContainer);
        scriptState.elementsToCleanup.push(buttonContainer);

        qEssenceEl.dataset.enhancementsAdded = "true";
        scriptState.elementsWithDataset.push(qEssenceEl);
    }

    function enhanceAllExistingQuestions() {
        if (!scriptState.isActive) return;
        const qElements = document.getElementsByClassName("question_essence");
        for (const qEl of qElements) {
            processQuestionElement(qEl);
        }
    }

    // --- Core Script Activation/Deactivation ---
    function applyScriptEnhancements() {
        if (scriptState.isActive) return;
        console.log("[TESTPORTAL MULTITOOL REVISED] Applying enhancements...");

        scriptState.isActive = true;
        RegExp.prototype.test = scriptState.customRegExpTestFunction;
        ensureCustomStyles();
        enhanceAllExistingQuestions();

        if (!scriptState.observer) {
            scriptState.observer = new MutationObserver((mutationsList) => {
                if (!scriptState.isActive) return;
                for (const mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        for (const node of mutation.addedNodes) {
                            if (node.nodeType === Node.ELEMENT_NODE) {
                                if (node.classList && (node.classList.contains('question_essence') || node.querySelector('.question_essence'))) {
                                    if (node.classList.contains('question_essence')) {
                                        processQuestionElement(node);
                                    } else {
                                        const qElements = node.getElementsByClassName('question_essence');
                                        for (const qEl of qElements) {
                                            processQuestionElement(qEl);
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            });
        }
        try {
            scriptState.observer.observe(document.body, { childList: true, subtree: true });
        } catch (e) {
             // Observer might already be observing if re-applying quickly, which is fine
            if (!(e instanceof DOMException && e.name === 'InvalidStateError')) {
                console.error("[TESTPORTAL MULTITOOL] Error observing:", e);
            }
        }
        updateToggleButtonAppearance();
        console.log("[TESTPORTAL MULTITOOL REVISED] Enhancements Applied.");
    }

    function removeScriptEnhancements() {
        if (!scriptState.isActive) return;
        console.log("[TESTPORTAL MULTITOOL REVISED] Removing enhancements...");

        scriptState.isActive = false;
        if (scriptState.observer) {
            scriptState.observer.disconnect();
        }
        RegExp.prototype.test = scriptState.originalRegExpTest;

        scriptState.elementsToCleanup.forEach(element => {
            if (element && element.parentElement) {
                element.remove();
            }
        });
        scriptState.elementsToCleanup = [];

        scriptState.elementsWithDataset.forEach(element => {
            if (element && element.dataset) {
                delete element.dataset.enhancementsAdded;
            }
        });
        scriptState.elementsWithDataset = [];

        if (scriptState.geminiPopupElement && scriptState.geminiPopupElement.parentElement) {
            scriptState.geminiPopupElement.remove();
            scriptState.geminiPopupElement = null;
        }
        removeCustomStyles();
        updateToggleButtonAppearance();
        console.log("[TESTPORTAL MULTITOOL REVISED] Enhancements Removed.");
    }

    function toggleScriptFunctionality() {
        if (scriptState.isActive) {
            removeScriptEnhancements();
        } else {
            applyScriptEnhancements();
        }
    }

    function updateToggleButtonAppearance() {
        if (!scriptState.toggleButtonElement) return;
        if (scriptState.isActive) {
            scriptState.toggleButtonElement.textContent = 'Revert';
            scriptState.toggleButtonElement.title = 'Removes all modifications made by the Testportal Multi Tool';
            scriptState.toggleButtonElement.style.backgroundColor = '#c82333'; // Red
            scriptState.toggleButtonElement.onmouseover = () => { if(scriptState.isActive) scriptState.toggleButtonElement.style.backgroundColor = '#a81d2a'; };
            scriptState.toggleButtonElement.onmouseout = () => { if(scriptState.isActive) scriptState.toggleButtonElement.style.backgroundColor = '#c82333'; };
        } else {
            scriptState.toggleButtonElement.textContent = 'Reapply';
            scriptState.toggleButtonElement.title = 'Reapplies all modifications by the Testportal Multi Tool';
            scriptState.toggleButtonElement.style.backgroundColor = '#28a745'; // Green
            scriptState.toggleButtonElement.onmouseover = () => { if(!scriptState.isActive) scriptState.toggleButtonElement.style.backgroundColor = '#218838'; };
            scriptState.toggleButtonElement.onmouseout = () => { if(!scriptState.isActive) scriptState.toggleButtonElement.style.backgroundColor = '#28a745'; };
        }
    }

    function initializeToggleButton() {
        if (document.getElementById(TOGGLE_BUTTON_ID)) {
            scriptState.toggleButtonElement = document.getElementById(TOGGLE_BUTTON_ID); // Re-assign if already exists (e.g. script re-run)
        } else {
            scriptState.toggleButtonElement = document.createElement('button');
            scriptState.toggleButtonElement.id = TOGGLE_BUTTON_ID;
            document.body.appendChild(scriptState.toggleButtonElement);
        }

        const btn = scriptState.toggleButtonElement;
        btn.style.position = 'fixed';
        btn.style.bottom = '10px';
        btn.style.left = '10px';
        btn.style.color = 'white';
        btn.style.border = 'none';
        btn.style.padding = '6px 10px';
        btn.style.borderRadius = '5px';
        btn.style.cursor = 'pointer';
        btn.style.zIndex = '10002';
        btn.style.fontSize = '12px';
        btn.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
        btn.style.opacity = '0.85';
        btn.style.transition = 'opacity 0.2s ease-in-out, background-color 0.2s ease-in-out';
        btn.onclick = toggleScriptFunctionality;

        updateToggleButtonAppearance(); // Set initial appearance
    }

    // --- Script Execution Logic ---
    function main() {
        initializeToggleButton();
        applyScriptEnhancements(); // Activate by default

        if (window.location.href.includes("LoadTestStart.html")) {
            console.log("Testportal MultiTool active on LoadTestStart page. Enhancements applied by default.");
            // If you want it to be off by default on LoadTestStart.html, you could call:
            // removeScriptEnhancements();
            // But ensure the button is still visible and functional.
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        setTimeout(main, 250); // Delay for dynamic sites
    }

})();