MasteringPhysics Extractor

Extract problems from MasteringPhysics (single or full assignment) with progress bar and save as JSON

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         MasteringPhysics Extractor
// @namespace    http://tampermonkey.net/
// @version      3.0.0
// @description  Extract problems from MasteringPhysics (single or full assignment) with progress bar and save as JSON
// @author       You & AI Assistant
// @match        https://session.physics-mastering.pearson.com/myct/itemView*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

/**
 * MasteringPhysics Extractor - Extract problems into a JSON file
 *
 * Features:
 * - Extract single problems or entire assignments
 * - Properly handles MathJax and scientific notation
 * - Preserves figures and complex question types (MCQ, ranking)
 * - Includes progress tracking with cancellation option
 * - Robust error handling and navigation
 * - User-configurable options
 *
 * @version 3.0.0
 */
(function() {
    'use strict';

    // --- Configuration ---
    const CONFIG = {
        // Data storage keys
        STORAGE_KEY: 'masteringPhysicsExtractionData',
        EXTRACTION_IN_PROGRESS_KEY: 'masteringPhysicsExtractionInProgress',
        CONFIG_STORAGE_KEY: 'masteringPhysicsExtractorConfig',

        // UI identifiers
        PROGRESS_BAR_ID: 'mp-extractor-progress-bar',
        BUTTON_CONTAINER_ID: 'mp-extractor-button-container',

        // Default settings (can be overridden by user)
        defaults: {
            debugMode: false,                 // Enable verbose logging
            navigationTimeout: 15000,         // Max wait time for navigation elements (ms)
            extractionDelay: 500,             // Delay between extraction steps (ms)
            buttonPosition: 'bottom-right',   // Position of control buttons
            showProgressText: true,           // Show detailed progress text
            downloadFormat: 'json',           // Download format (only json for now)
            theme: {                          // UI theme colors
                extractButton: '#007bff',
                assignmentButton: '#28a745',
                cancelButton: '#dc3545',
                progressBar: '#4CAF50',
                progressBackground: '#555'
            }
        },

        // Version information
        VERSION: '3.0.0',
        DATA_FORMAT_VERSION: '1.0'
    };

    // --- Logger ---
    const Logger = {
        _enabled: true,
        _debugMode: CONFIG.defaults.debugMode,

        /**
         * Initialize logger with settings
         * @param {boolean} enabled - Enable/disable logging
         * @param {boolean} debugMode - Enable verbose debug logs
         */
        init(enabled = true, debugMode = false) {
            this._enabled = enabled;
            this._debugMode = debugMode;
            this.log('Logger initialized', { enabled, debugMode });
        },

        /**
         * Log a message to console
         * @param {string} message - Message to log
         * @param {*} [data] - Optional data to log
         */
        log(message, data) {
            if (!this._enabled) return;
            if (data !== undefined) {
                console.log(`MP Extractor: ${message}`, data);
            } else {
                console.log(`MP Extractor: ${message}`);
            }
        },

        /**
         * Log a debug message (only shown in debug mode)
         * @param {string} message - Message to log
         * @param {*} [data] - Optional data to log
         */
        debug(message, data) {
            if (!this._enabled || !this._debugMode) return;
            if (data !== undefined) {
                console.debug(`MP Extractor [DEBUG]: ${message}`, data);
            } else {
                console.debug(`MP Extractor [DEBUG]: ${message}`);
            }
        },

        /**
         * Log a warning message
         * @param {string} message - Message to log
         * @param {*} [data] - Optional data to log
         */
        warn(message, data) {
            if (!this._enabled) return;
            if (data !== undefined) {
                console.warn(`MP Extractor [WARNING]: ${message}`, data);
            } else {
                console.warn(`MP Extractor [WARNING]: ${message}`);
            }
        },

        /**
         * Log an error message
         * @param {string} message - Message to log
         * @param {Error|*} [error] - Optional error to log
         */
        error(message, error) {
            if (!this._enabled) return;
            if (error !== undefined) {
                console.error(`MP Extractor [ERROR]: ${message}`, error);
            } else {
                console.error(`MP Extractor [ERROR]: ${message}`);
            }
        }
    };

    // --- Storage Service ---
    const StorageService = {
        /**
         * Get an item from storage
         * @param {string} key - Storage key
         * @param {*} defaultValue - Default value if not found
         * @returns {Promise<*>} - Stored value or default
         */
        async getItem(key, defaultValue = null) {
            try {
                const value = await GM_getValue(key, defaultValue);
                Logger.debug(`Retrieved from storage: ${key}`, value);
                return value;
            } catch (error) {
                Logger.error(`Failed to get item from storage: ${key}`, error);
                return defaultValue;
            }
        },

        /**
         * Save an item to storage
         * @param {string} key - Storage key
         * @param {*} value - Value to store
         * @returns {Promise<boolean>} - Success status
         */
        async setItem(key, value) {
            try {
                await GM_setValue(key, value);
                Logger.debug(`Saved to storage: ${key}`, value);
                return true;
            } catch (error) {
                Logger.error(`Failed to save item to storage: ${key}`, error);
                return false;
            }
        },

        /**
         * Remove an item from storage
         * @param {string} key - Storage key
         * @returns {Promise<boolean>} - Success status
         */
        async removeItem(key) {
            try {
                await GM_deleteValue(key);
                Logger.debug(`Removed from storage: ${key}`);
                return true;
            } catch (error) {
                Logger.error(`Failed to remove item from storage: ${key}`, error);
                return false;
            }
        },

        /**
         * Save JSON data to storage
         * @param {string} key - Storage key
         * @param {Object} data - Object to store as JSON
         * @returns {Promise<boolean>} - Success status
         */
        async setJSON(key, data) {
            try {
                const jsonString = JSON.stringify(data);
                return await this.setItem(key, jsonString);
            } catch (error) {
                Logger.error(`Failed to save JSON to storage: ${key}`, error);
                return false;
            }
        },

        /**
         * Get JSON data from storage
         * @param {string} key - Storage key
         * @param {Object} defaultValue - Default value if not found or invalid
         * @returns {Promise<Object>} - Parsed object or default
         */
        async getJSON(key, defaultValue = {}) {
            try {
                const jsonString = await this.getItem(key);
                if (!jsonString) return defaultValue;
                return JSON.parse(jsonString);
            } catch (error) {
                Logger.error(`Failed to parse JSON from storage: ${key}`, error);
                return defaultValue;
            }
        }
    };

    // --- DOM Utilities ---
    const DOMUtils = {
        /**
         * Wait for an element to be available and ready in the DOM
         * @param {string} selector - CSS selector for element
         * @param {number} timeout - Max wait time in ms
         * @returns {Promise<Element>} - Found element
         */
        async waitForElement(selector, timeout = CONFIG.defaults.navigationTimeout) {
            return new Promise((resolve, reject) => {
                const intervalTime = 100;
                let elapsedTime = 0;

                // Check if element already exists
                const existingElement = document.querySelector(selector);
                if (existingElement && existingElement.offsetParent !== null &&
                    !existingElement.disabled && !existingElement.classList.contains('disabled')) {
                    return resolve(existingElement);
                }

                const interval = setInterval(() => {
                    const element = document.querySelector(selector);
                    if (element && element.offsetParent !== null &&
                        !element.disabled && !element.classList.contains('disabled')) {
                        clearInterval(interval);
                        resolve(element);
                    } else {
                        elapsedTime += intervalTime;
                        if (elapsedTime >= timeout) {
                            clearInterval(interval);
                            reject(new Error(`Element "${selector}" not found/ready within ${timeout}ms`));
                        }
                    }
                }, intervalTime);
            });
        },

        /**
         * Get the current navigation state of the app
         * @returns {Object} Navigation state object
         */
        getNavigationState() {
            const navPosElement = document.querySelector('#navigation .pos');
            const nextLink = document.querySelector('#next-item-link');
            const prevLinkDisabled = document.querySelector('#navigation .nav-circle.prev.disabled');

            let current = 0, total = 0, isLast = true, isFirst = true, hasNext = false;

            if (navPosElement) {
                const posText = navPosElement.textContent.trim();
                const match = posText.match(/(\d+)\s+of\s+(\d+)/);

                if (match) {
                    current = parseInt(match[1], 10);
                    total = parseInt(match[2], 10);
                    hasNext = !!nextLink && !nextLink.classList.contains('disabled');
                    isLast = current === total || !hasNext;
                    isFirst = current === 1 || !!prevLinkDisabled;
                } else {
                    Logger.warn("Could not parse navigation position:", posText);
                    current = 1;
                    total = 1;
                }
            } else {
                Logger.warn("Navigation position element not found.");
                current = 1;
                total = 1;
            }

            return { current, total, isLast, isFirst, hasNext };
        },

        /**
         * Create an HTML element with properties
         * @param {string} tag - Element tag name
         * @param {Object} props - Element properties
         * @param {Object} styles - CSS styles to apply
         * @param {HTMLElement[]} children - Child elements to append
         * @returns {HTMLElement} Created element
         */
        createElement(tag, props = {}, styles = {}, children = []) {
            const element = document.createElement(tag);

            // Apply properties
            Object.entries(props).forEach(([key, value]) => {
                if (key === 'textContent') {
                    element.textContent = value;
                } else if (key === 'innerHTML') {
                    element.innerHTML = value;
                } else if (key === 'className') {
                    element.className = value;
                } else if (key === 'events') {
                    Object.entries(value).forEach(([event, handler]) => {
                        element.addEventListener(event, handler);
                    });
                } else {
                    element.setAttribute(key, value);
                }
            });

            // Apply styles
            Object.assign(element.style, styles);

            // Append children
            children.forEach(child => element.appendChild(child));

            return element;
        }
    };

    // --- Text Processing Utilities ---
    const TextUtils = {
        /**
         * Process scientific notation in an element
         * @param {HTMLElement} element - Element to process
         */
        processScientificNotation(element) {
            if (!element) return;

            const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
            const nodesToProcess = [];
            let currentNode;

            // Find text nodes with scientific notation
            while ((currentNode = walker.nextNode())) {
                if (currentNode.textContent.match(/\d+(\.\d+)?\s*[×xX]\s*10/)) {
                    nodesToProcess.push(currentNode);
                }
            }

            // Process each text node
            nodesToProcess.forEach(node => {
                let nextElement = node.nextSibling;

                // Find the next element sibling
                while (nextElement && nextElement.nodeType !== Node.ELEMENT_NODE) {
                    nextElement = nextElement.nextSibling;
                }

                // Handle superscript exponent
                if (nextElement && nextElement.tagName === 'SUP') {
                    const exponent = nextElement.textContent.trim();
                    const text = node.textContent;
                    const match = text.match(/(\d+(?:\.\d+)?)\s*[×xX]\s*10$/);

                    if (match) {
                        const newText = text.replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10$/, `$1 × 10^{${exponent}}`);
                        node.textContent = newText;

                        if (nextElement.parentNode) {
                            try {
                                nextElement.parentNode.removeChild(nextElement);
                            } catch (e) {
                                Logger.warn("Could not remove sup element:", e);
                            }
                        }
                    }
                } else {
                    // Normalize existing notation
                    const text = node.textContent;
                    const newText = text.replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\^\{(-?\d+)\}/g, '$1 × 10^{$2}');

                    if (newText !== text) {
                        node.textContent = newText;
                    }
                }
            });
        },

        /**
         * Process MathJax elements and replace with LaTeX
         * @param {HTMLElement} element - Element containing MathJax
         * @returns {string} - Processed text with LaTeX notations
         */
        processMathJax(element) {
            if (!element) return '';

            const mathMap = new Map();
            const mathScripts = element.querySelectorAll('script[type="math/tex"]');

            // Map placeholders to LaTeX content
            mathScripts.forEach((script, index) => {
                const placeholder = `__MATH_PLACEHOLDER_${index}__`;
                mathMap.set(placeholder, script.textContent.trim());

                const span = document.createElement('span');
                span.textContent = placeholder;

                try {
                    if (script.parentNode) {
                        script.parentNode.replaceChild(span, script);
                    }
                } catch(e) {
                    Logger.warn("Could not replace MathJax script:", e);
                }
            });

            // Remove MathJax rendered elements
            const mathJaxElements = element.querySelectorAll('.MathJax_Preview, .MathJax');
            mathJaxElements.forEach(el => el.remove());

            // Extract text content and replace placeholders with LaTeX
            let text = element.textContent || '';

            mathMap.forEach((latex, placeholder) => {
                const regex = new RegExp(placeholder.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
                text = text.replace(regex, `$${latex}$`);
            });

            // Clean up text formatting
            text = text
                .replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\s*\^\s*\{(-?\d+)\}/g, '$1 × 10^{$2}')
                .replace(/(\d+(?:\.\d+)?)\s*[×xX]\s*10\s*\^\s*(-?\d+)/g, '$1 × 10^{$2}')
                .replace(/\s{2,}/g, ' ')
                .trim();

            return text;
        },

        /**
         * Extract text with proper MathJax/LaTeX handling
         * @param {HTMLElement} element - Element to extract text from
         * @returns {string} - Extracted text with LaTeX
         */
        extractTextWithMathJax(element) {
            if (!element) return '';

            const clone = element.cloneNode(true);
            this.processScientificNotation(clone);
            return this.processMathJax(clone);
        }
    };

    // --- Content Extractors ---
    const ContentExtractors = {
        /**
         * Extract the introduction text
         * @returns {string} - Extracted introduction
         */
        extractIntroduction() {
            const introElement = document.querySelector('.introduction.edible');
            return introElement ? TextUtils.extractTextWithMathJax(introElement) : "";
        },

        /**
         * Extract figures from the problem
         * @returns {Array<Object>} - Array of figure objects
         */
        extractFigures() {
            const figures = [];
            const flipperElement = document.querySelector('.flipper');

            if (!flipperElement) return figures;

            const figureCountText = flipperElement.querySelector('#itemcount')?.textContent;
            const figureCount = figureCountText ? parseInt(figureCountText.trim(), 10) : 0;
            const mediaElements = flipperElement.querySelectorAll('.media');

            mediaElements.forEach((media, index) => {
                const imageElement = media.querySelector('img');

                if (imageElement) {
                    figures.push({
                        index: index + 1,
                        totalFigures: figureCount,
                        src: imageElement.getAttribute('src') || '',
                        alt: imageElement.getAttribute('alt') || '',
                        title: imageElement.getAttribute('title') || ''
                    });
                }
            });

            return figures;
        },

        /**
         * Extract ranking items from a problem
         * @param {HTMLElement} problemElement - Problem container element
         * @returns {Object|null} - Ranking items data or null if not present
         */
        extractRankingItems(problemElement) {
            const rankingElement = problemElement.querySelector('.solutionAppletRanking');

            if (!rankingElement) return null;

            const items = [];
            const rankItems = problemElement.querySelectorAll('.rank-item');

            rankItems.forEach(item => {
                const mathScript = item.querySelector('script[type="math/tex"]');

                if (mathScript && mathScript.textContent) {
                    items.push({ text: `$${mathScript.textContent.trim()}$` });
                } else {
                    items.push({ text: TextUtils.extractTextWithMathJax(item).trim() });
                }
            });

            let largestText = 'Largest', smallestText = 'Smallest';
            const preText = problemElement.querySelector('.rank-pre-text');
            const postText = problemElement.querySelector('.rank-post-text');

            if (preText) largestText = TextUtils.extractTextWithMathJax(preText).trim();
            if (postText) smallestText = TextUtils.extractTextWithMathJax(postText).trim();

            return { items, directions: { largest: largestText, smallest: smallestText } };
        },

        /**
         * Extract multiple choice options from a problem
         * @param {HTMLElement} problemElement - Problem container element
         * @returns {Array<Object>|null} - Multiple choice options or null if not present
         */
        extractMultipleChoice(problemElement) {
            const multipleChoiceElement = problemElement.querySelector('.solutionMultipleChoiceRadio');

            if (!multipleChoiceElement) return null;

            const choices = [];
            const optionContainers = multipleChoiceElement.querySelectorAll('table.tidy-options > tbody > tr.grouper');

            if (optionContainers.length === 0) {
                // Fallback for simpler layouts
                const simpleOptions = multipleChoiceElement.querySelectorAll('.option-label');

                if (simpleOptions.length > 0) {
                    Logger.debug("Using fallback MC extractor for simple options.");

                    simpleOptions.forEach((option, index) => {
                        choices.push({
                            index: index + 1,
                            text: TextUtils.extractTextWithMathJax(option).trim()
                        });
                    });

                    return choices.length > 0 ? choices : null;
                } else {
                    return null;
                }
            }

            // Process table rows for complex layouts
            optionContainers.forEach((container, index) => {
                const choiceData = { index: index + 1 };
                const label = container.querySelector('label.option-label');

                if (!label) {
                    Logger.warn("MC option container missing label:", container);
                    return;
                }

                // Extract figure if present
                const imageElement = label.querySelector('img');
                if (imageElement) {
                    choiceData.figure = {
                        src: imageElement.getAttribute('src') || '',
                        alt: imageElement.getAttribute('alt') || '',
                        title: imageElement.getAttribute('title') || ''
                    };
                }

                // Extract text (excluding figure)
                const labelClone = label.cloneNode(true);
                const imageInClone = labelClone.querySelector('img');

                if (imageInClone) {
                    imageInClone.remove();
                }

                const textContent = TextUtils.extractTextWithMathJax(labelClone).trim();

                if (textContent) {
                    choiceData.text = textContent;
                }

                // Add choice if it has either text or figure
                if (choiceData.figure || choiceData.text) {
                    choices.push(choiceData);
                } else {
                    Logger.warn("MC option label yielded no text or figure:", label);
                }
            });

            return choices.length > 0 ? choices : null;
        },

        /**
         * Extract units from a problem
         * @param {HTMLElement} problemElement - Problem container element
         * @returns {string} - Unit text
         */
        extractUnit(problemElement) {
            const postTextDiv = problemElement.querySelector('.postTextDiv');
            return postTextDiv ? TextUtils.extractTextWithMathJax(postTextDiv).trim() : '';
        },

        /**
         * Extract data for a single problem part
         * @param {HTMLElement} problemElement - Problem part container
         * @returns {Object} - Problem part data
         */
        extractProblemPart(problemElement) {
            const partLabel = problemElement.querySelector('.autolabel')?.textContent.trim() || 'Unknown Part';
            const questionElement = problemElement.querySelector('.text.edible');
            const questionText = questionElement
                ? TextUtils.extractTextWithMathJax(questionElement)
                : 'No question text found';

            const instructionsElement = problemElement.querySelector('.instructions.edible');
            const instructions = instructionsElement
                ? TextUtils.extractTextWithMathJax(instructionsElement)
                : '';

            const equationLabel = problemElement.querySelector('.preTextDiv');
            const equationText = equationLabel
                ? TextUtils.extractTextWithMathJax(equationLabel)
                : '';

            const unit = this.extractUnit(problemElement);
            const multipleChoiceOptions = this.extractMultipleChoice(problemElement);
            const rankingItems = this.extractRankingItems(problemElement);

            const partData = { part: partLabel, question: questionText };

            if (instructions) partData.instructions = instructions;
            if (equationText) partData.equation = equationText;
            if (unit) partData.unit = unit;
            if (multipleChoiceOptions) partData.multipleChoiceOptions = multipleChoiceOptions;
            if (rankingItems) partData.rankingItems = rankingItems;

            return partData;
        },

        /**
         * Extract all problem parts from the current page
         * @returns {Array<Object>} - Array of problem part data
         */
        extractAllProblemParts() {
            const partSections = document.querySelectorAll('.section.part');

            if (partSections.length === 0) {
                const mainProblemArea = document.querySelector('.problem-view');

                if (mainProblemArea) {
                    const singlePart = this.extractProblemPart(mainProblemArea);

                    if (singlePart.question && singlePart.question !== 'No question text found') {
                        if (singlePart.part === 'Unknown Part') singlePart.part = "Part A";
                        return [singlePart];
                    }
                }

                return [];
            }

            return Array.from(partSections).map(section => this.extractProblemPart(section));
        },

        /**
         * Extract all data from the current page
         * @returns {Object} - Complete page data
         */
        extractCurrentPageData() {
            const url = window.location.href;
            const timestamp = new Date().toISOString();
            const navState = DOMUtils.getNavigationState();
            const currentPosition = navState.current > 0 ? navState.current : 'N/A';
            const totalItems = navState.total > 0 ? navState.total : 'N/A';

            return {
                pageUrl: url,
                extractedAt: timestamp,
                position: currentPosition,
                totalItemsInAssignment: totalItems,
                introduction: this.extractIntroduction(),
                figures: this.extractFigures(),
                problemParts: this.extractAllProblemParts()
            };
        }
    };

    // --- Data Export Service ---
    const DataExporter = {
        /**
         * Download data as JSON file
         * @param {Object} data - Data to download
         * @param {string} filename - Filename for the download
         * @returns {Promise<boolean>} - Success status
         */
        async downloadAsJSON(data, filename) {
            try {
                // Add extractor version to metadata
                if (data && typeof data === 'object') {
                    if (!data.metadata) data.metadata = {};
                    data.metadata.extractorVersion = CONFIG.VERSION;
                    data.metadata.dataFormatVersion = CONFIG.DATA_FORMAT_VERSION;
                }

                const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
                const url = URL.createObjectURL(blob);

                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                a.style.display = 'none';

                document.body.appendChild(a);
                a.click();

                Logger.log(`Downloading data as ${filename}`);

                // Clean up
                return new Promise(resolve => {
                    setTimeout(() => {
                        try {
                            if (a.parentNode) document.body.removeChild(a);
                            URL.revokeObjectURL(url);
                            Logger.debug("Download link revoked.");
                            resolve(true);
                        } catch(cleanupError) {
                            Logger.warn("Error during download link cleanup:", cleanupError);
                            resolve(false);
                        }
                    }, 200);
                });
            } catch (error) {
                Logger.error("Error during JSON download:", error);
                alert("Error creating download file.");
                return false;
            }
        }
    };

    // --- UI Components ---
    const UI = {
        /**
         * Progress bar component
         */
        ProgressBar: {
            element: null,
            textElement: null,
            barElement: null,
            cancelButton: null,
            controller: null,

            /**
             * Initialize progress bar
             * @param {Object} controller - App controller reference
             * @param {Object} theme - Theme colors
             */
            init(controller, theme) {
                this.controller = controller;
                this.theme = theme;
            },

            /**
             * Show the progress bar
             * @param {number} current - Current progress
             * @param {number} total - Total items
             */
            show(current = 0, total = 1) {
                if (!this.element) this.create();
                this.update(current, total);
                this.element.style.display = 'flex';
                this.controller.setButtonsDisabled(true);
            },

            /**
             * Create the progress bar DOM elements
             */
            create() {
                this.element = DOMUtils.createElement('div',
                    { id: CONFIG.PROGRESS_BAR_ID },
                    {
                        position: 'fixed',
                        top: '10px',
                        left: '50%',
                        transform: 'translateX(-50%)',
                        width: '80%',
                        maxWidth: '600px',
                        backgroundColor: 'rgba(0, 0, 0, 0.8)',
                        color: 'white',
                        padding: '10px 15px',
                        borderRadius: '8px',
                        zIndex: '10001',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'space-between',
                        boxShadow: '0 2px 10px rgba(0,0,0,0.5)',
                        fontSize: '14px'
                    }
                );

                this.textElement = DOMUtils.createElement('span',
                    {},
                    { flexGrow: '1', marginRight: '15px' }
                );

                const barContainer = DOMUtils.createElement('div',
                    {},
                    {
                        width: '100px',
                        height: '10px',
                        backgroundColor: this.theme.progressBackground || '#555',
                        borderRadius: '5px',
                        overflow: 'hidden',
                        marginRight: '15px'
                    }
                );

                this.barElement = DOMUtils.createElement('div',
                    {},
                    {
                        width: '0%',
                        height: '100%',
                        backgroundColor: this.theme.progressBar || '#4CAF50',
                        transition: 'width 0.3s ease-in-out'
                    }
                );

                this.cancelButton = DOMUtils.createElement('button',
                    {
                        textContent: 'Cancel',
                        events: { click: () => this.controller.cancelAssignmentExtraction() }
                    },
                    {
                        padding: '5px 10px',
                        backgroundColor: this.theme.cancelButton || '#dc3545',
                        color: 'white',
                        border: 'none',
                        borderRadius: '5px',
                        cursor: 'pointer',
                        fontWeight: 'bold',
                        fontSize: '12px'
                    }
                );

                barContainer.appendChild(this.barElement);
                this.element.appendChild(this.textElement);
                this.element.appendChild(barContainer);
                this.element.appendChild(this.cancelButton);

                document.body.appendChild(this.element);
            },

            /**
             * Update progress bar status
             * @param {number} current - Current progress
             * @param {number} total - Total items
             */
            update(current, total) {
                if (!this.element || !this.textElement || !this.barElement) return;

                const percentage = total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0;
                this.textElement.textContent = `Extracting Item ${current} of ${total}... (${percentage}%)`;
                this.barElement.style.width = `${percentage}%`;
            },

            /**
             * Hide the progress bar
             */
            hide() {
                if (this.element) this.element.style.display = 'none';
                this.controller.setButtonsDisabled(false);
            },

            /**
             * Remove the progress bar from DOM
             */
            remove() {
                if (this.element && this.element.parentNode) {
                    this.element.parentNode.removeChild(this.element);
                    this.element = null;
                    this.textElement = null;
                    this.barElement = null;
                    this.cancelButton = null;
                }

                this.controller.setButtonsDisabled(false);
            }
        },

        /**
         * Settings dialog component
         */
        SettingsDialog: {
            element: null,
            controller: null,

            /**
             * Initialize settings dialog
             * @param {Object} controller - App controller reference
             */
            init(controller) {
                this.controller = controller;
            },

            /**
             * Show the settings dialog
             */
            show() {
                if (!this.element) this.create();
                this.element.style.display = 'block';
            },

            /**
             * Create the settings dialog DOM elements
             */
            create() {
                // Implementation would go here
                // Creates a dialog with settings options from CONFIG.defaults
                // For brevity, not fully implemented in this version
                alert("Settings functionality will be added in a future version");
            },

            /**
             * Hide the settings dialog
             */
            hide() {
                if (this.element) this.element.style.display = 'none';
            }
        }
    };

    // --- App Controller ---
    class AppController {
        constructor() {
            this.config = { ...CONFIG.defaults };
            this.initialButtons = {};
            this.init();
        }

        /**
         * Initialize the app controller
         */
        async init() {
            Logger.log("Initializing...");

            try {
                // Load user configuration
                const storedConfig = await StorageService.getJSON(CONFIG.CONFIG_STORAGE_KEY);
                if (storedConfig && Object.keys(storedConfig).length > 0) {
                    this.config = { ...this.config, ...storedConfig };
                    Logger.debug("Loaded stored configuration", this.config);
                }

                // Initialize logger with config settings
                Logger.init(true, this.config.debugMode);

                // Initialize UI components
                UI.ProgressBar.init(this, this.config.theme);

                // Register menu commands
                this.registerMenuCommands();

                // Add control buttons
                this.addInitialButtons();

                // Check for ongoing extraction
                setTimeout(async () => {
                    try {
                        const isInProgress = await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY) === 'true';

                        if (isInProgress) {
                            Logger.log("Continuing assignment extraction...");
                            await this.handleExtractionStep();
                        } else {
                            Logger.log("Ready.");
                            UI.ProgressBar.remove();
                            this.setButtonsDisabled(false);
                        }
                    } catch (error) {
                        Logger.error("Error during init check:", error);
                        alert("Error during script initialization.");
                        await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
                        UI.ProgressBar.remove();
                        this.setButtonsDisabled(false);
                    }
                }, this.config.extractionDelay);

            } catch (error) {
                Logger.error("Initialization error:", error);
                alert("Failed to initialize MasteringPhysics Extractor.");
            }
        }

        /**
         * Register Tampermonkey menu commands
         */
        registerMenuCommands() {
            if (typeof GM_registerMenuCommand !== 'undefined') {
                GM_registerMenuCommand('Extract Current Problem', () => this.extractSingleProblem());
                GM_registerMenuCommand('Extract Full Assignment', () => this.startAssignmentExtraction());
                GM_registerMenuCommand('About MasteringPhysics Extractor', () => {
                    alert(
                        `MasteringPhysics Extractor v${CONFIG.VERSION}\n\n` +
                        `Extract problems from MasteringPhysics to JSON format.\n\n` +
                        `Use the buttons in the bottom-right corner to extract the current problem or the entire assignment.`
                    );
                });
            }
        }

        /**
         * Add control buttons to the page
         */
        addInitialButtons() {
            if (document.getElementById(CONFIG.BUTTON_CONTAINER_ID)) {
                Logger.log("Buttons already added.");
                return;
            }

            // Container for buttons
            const buttonContainer = DOMUtils.createElement('div',
                { id: CONFIG.BUTTON_CONTAINER_ID },
                {
                    position: 'fixed',
                    bottom: '20px',
                    right: '20px',
                    zIndex: '10000',
                    display: 'flex',
                    flexDirection: 'column',
                    gap: '10px'
                }
            );

            // Helper to create a button
            const createButton = (text, color, onClick) => {
                return DOMUtils.createElement('button',
                    {
                        textContent: text,
                        events: { click: onClick }
                    },
                    {
                        padding: '8px 12px',
                        backgroundColor: color,
                        color: 'white',
                        border: 'none',
                        borderRadius: '5px',
                        cursor: 'pointer',
                        fontWeight: 'bold',
                        fontSize: '13px'
                    }
                );
            };

            // Create and add buttons
            this.initialButtons.single = createButton(
                'Extract This Item',
                this.config.theme.extractButton,
                () => this.extractSingleProblem()
            );

            this.initialButtons.assignment = createButton(
                'Extract Full Assignment',
                this.config.theme.assignmentButton,
                () => this.startAssignmentExtraction()
            );

            buttonContainer.appendChild(this.initialButtons.single);
            buttonContainer.appendChild(this.initialButtons.assignment);

            document.body.appendChild(buttonContainer);
            Logger.log("Extractor buttons added to page.");
        }

        /**
         * Enable/disable control buttons
         * @param {boolean} disabled - Whether buttons should be disabled
         */
        setButtonsDisabled(disabled) {
            Object.values(this.initialButtons).forEach(button => {
                if (button) button.disabled = disabled;
            });
        }

        /**
         * Extract the current problem
         */
        async extractSingleProblem() {
            Logger.log("Extracting single item...");

            try {
                const problemData = ContentExtractors.extractCurrentPageData();

                // Construct metadata for single item download
                const singleItemOutput = {
                    metadata: {
                        url: problemData.pageUrl,
                        extractedAt: problemData.extractedAt,
                        type: 'MasteringPhysics Single Item',
                        position: problemData.position,
                        totalItemsInAssignment: problemData.totalItemsInAssignment,
                        extractorVersion: CONFIG.VERSION,
                        dataFormatVersion: CONFIG.DATA_FORMAT_VERSION
                    },
                    item: problemData
                };

                const filename = `mastering_item_${problemData.position || Date.now()}.json`;
                await DataExporter.downloadAsJSON(singleItemOutput, filename);

                Logger.log("Single item extracted.");
            } catch (error) {
                Logger.error("Error extracting single problem:", error);
                alert("Failed to extract the current problem.");
            }
        }

        /**
         * Start extracting the full assignment
         */
        async startAssignmentExtraction() {
            Logger.log("Starting assignment extraction...");

            try {
                // Check if extraction already in progress
                if (await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY) === 'true') {
                    alert("An extraction is already in progress.");
                    return;
                }

                // Confirm with user
                if (!confirm("This will navigate through all problems in the assignment. Continue?")) {
                    Logger.log("Assignment extraction cancelled by user.");
                    return;
                }

                // Show progress
                const navState = DOMUtils.getNavigationState();
                UI.ProgressBar.show(navState.current, navState.total);

                // Initialize extraction state
                const initialState = {
                    metadata: {
                        startUrl: window.location.href,
                        extractedAt: new Date().toISOString(),
                        type: 'MasteringPhysics Assignment',
                        extractorVersion: CONFIG.VERSION,
                        dataFormatVersion: CONFIG.DATA_FORMAT_VERSION
                    },
                    items: []
                };

                // Save initial state
                await StorageService.setJSON(CONFIG.STORAGE_KEY, initialState);
                await StorageService.setItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY, 'true');

                Logger.log("Extraction state initialized.");

                // Start extraction
                await this.handleExtractionStep();
            } catch (error) {
                Logger.error("Error starting assignment extraction:", error);
                alert("Failed to initialize extraction. Aborting.");
                UI.ProgressBar.remove();
            }
        }

        /**
         * Handle a single step in the extraction process
         */
        async handleExtractionStep() {
            try {
                // Check if extraction still in progress
                const extractionState = await StorageService.getItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);

                if (extractionState !== 'true') {
                    Logger.log("Extraction not in progress flag found. Stopping.");
                    UI.ProgressBar.remove();
                    return;
                }

                // Get current navigation state
                const navState = DOMUtils.getNavigationState();
                UI.ProgressBar.show(navState.current, navState.total);

                Logger.log(`Handling extraction step for item: ${navState.current}/${navState.total}`);

                // Extract data from current page
                const currentPageData = ContentExtractors.extractCurrentPageData();
                Logger.log(`Extracted data for item ${currentPageData.position}`);

                // Get and update stored data
                let assignmentData = await StorageService.getJSON(CONFIG.STORAGE_KEY, { metadata: {}, items: [] });

                // Check if this item is already stored
                const exists = assignmentData.items.some(item =>
                    item.position === currentPageData.position &&
                    item.pageUrl === currentPageData.pageUrl
                );

                if (!exists) {
                    assignmentData.items.push(currentPageData);
                    await StorageService.setJSON(CONFIG.STORAGE_KEY, assignmentData);
                    Logger.log(`Added item ${currentPageData.position}. Total stored: ${assignmentData.items.length}`);
                } else {
                    Logger.log(`Item ${currentPageData.position} already stored. Skipping add.`);
                }

                // Re-check navigation state after processing
                const currentNavState = DOMUtils.getNavigationState();
                UI.ProgressBar.update(currentNavState.current, currentNavState.total);

                // Decide next action
                if (!currentNavState.isLast && currentNavState.hasNext) {
                    Logger.log("Attempting navigation...");

                    try {
                        const nextButton = await DOMUtils.waitForElement('#next-item-link', this.config.navigationTimeout);
                        Logger.log("Next button ready. Clicking.");
                        nextButton.click();
                    } catch (navError) {
                        Logger.error("Failed to find/click next button:", navError);
                        alert("Error navigating. Extraction stopped.");
                        await this.finalizeExtraction();
                    }
                } else {
                    Logger.log("Last item reached or cannot navigate.");
                    await this.finalizeExtraction();
                }
            } catch (error) {
                Logger.error("Error in extraction step:", error);
                alert("Error during extraction process. Stopping.");
                await this.finalizeExtraction();
            }
        }

        /**
         * Finalize the extraction process
         */
        async finalizeExtraction() {
            Logger.log("Finalizing extraction...");

            let finalData = null;
            let initialMetadata = {};

            try {
                // Get stored data
                const storedDataString = await StorageService.getItem(CONFIG.STORAGE_KEY);

                if (storedDataString) {
                    finalData = JSON.parse(storedDataString);

                    // Preserve initial metadata
                    if (finalData && finalData.metadata) {
                        initialMetadata = {
                            startUrl: finalData.metadata.startUrl,
                            extractedAt: finalData.metadata.extractedAt,
                            type: finalData.metadata.type,
                            extractorVersion: finalData.metadata.extractorVersion,
                            dataFormatVersion: finalData.metadata.dataFormatVersion
                        };
                    } else {
                        Logger.warn("Metadata missing from stored data during finalization.");

                        if (!finalData) finalData = { items: [] };
                        finalData.metadata = {};
                    }
                }
            } catch (parseError) {
                Logger.error("Failed to parse final stored data. Cannot generate final file.", parseError);
                alert("Error reading final data. Extraction cannot be saved.");

                // Cleanup even on error
                await StorageService.removeItem(CONFIG.STORAGE_KEY);
                await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
                UI.ProgressBar.remove();
                return;
            }

            // Check if we have items to save
            if (!finalData || !finalData.items || finalData.items.length === 0) {
                Logger.error("Finalizing, but no items found in data!");
                alert("Extraction finished, but no items were collected.");
            } else {
                // Update metadata with final information
                const finalMetadata = {
                    ...initialMetadata,
                    totalItemsExtracted: finalData.items.length,
                    extractionFinishedAt: new Date().toISOString()
                };

                finalData.metadata = finalMetadata;

                // Download the file
                const filename = `mastering_assignment_${Date.now()}.json`;
                await DataExporter.downloadAsJSON(finalData, filename);

                Logger.log(`Download initiated for ${finalData.items.length} items.`);
            }

            // Clean up
            try {
                await StorageService.removeItem(CONFIG.STORAGE_KEY);
                await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);
                Logger.log("Storage cleaned up.");
            } catch (cleanupError) {
                Logger.error("Error cleaning storage after finalization:", cleanupError);
            } finally {
                UI.ProgressBar.remove();

                if (finalData && finalData.items && finalData.items.length > 0) {
                    alert(`Assignment extraction complete! ${finalData.items.length} items processed.`);
                }
            }
        }

        /**
         * Cancel the ongoing assignment extraction
         * @param {boolean} confirmFirst - Whether to ask for confirmation
         */
        async cancelAssignmentExtraction(confirmFirst = true) {
            Logger.log("Attempting to cancel extraction...");

            if (confirmFirst && !confirm("Cancel the ongoing assignment extraction?")) {
                Logger.log("Cancellation aborted by user.");
                return;
            }

            try {
                await StorageService.removeItem(CONFIG.STORAGE_KEY);
                await StorageService.removeItem(CONFIG.EXTRACTION_IN_PROGRESS_KEY);

                Logger.log("Assignment extraction cancelled and storage cleaned.");

                if (confirmFirst) {
                    alert("Assignment extraction cancelled.");
                }
            } catch (error) {
                Logger.error("Error cleaning up storage during cancellation:", error);
                alert("Error during cancellation cleanup.");
            } finally {
                UI.ProgressBar.remove();
            }
        }
    }

    // --- Script Initialization ---
    window.addEventListener('load', () => {
        // Check if required Tampermonkey functions are available
        if (typeof GM_setValue === 'undefined' ||
            typeof GM_getValue === 'undefined' ||
            typeof GM_deleteValue === 'undefined') {

            console.error("MP Extractor: GM_* functions not available. Check @grant in userscript header.");
            alert("MP Extractor Error: Tampermonkey API functions not found. Make sure you've installed this script correctly.");
            return;
        }

        // Initialize the controller
        new AppController();
    });
})();