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