- // ==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();
- });
- })();