Non-Reviewable Course Policy Active: Quiz review access is restricted in this course. Only 100% perfect score attempts are harvested to maintain absolute database accuracy. Unreviewed attempts use adaptive probing across attempts.
` : ''}
`;
// Bind Card Action Buttons
const btnHighlight = card.querySelector('#amaes-card-btn-highlight');
if (btnHighlight) {
btnHighlight.addEventListener('click', (e) => {
e.preventDefault();
if (typeof highlightMissingOrUnansweredQuizzes === 'function') {
highlightMissingOrUnansweredQuizzes().then(res => {
if (res && res.message) {
if (typeof showToast === 'function') showToast(res.message, 3500);
} else if (res && typeof res.count === 'number') {
const msg = res.count > 0 ? `Highlighted ${res.count} quiz(zes) needing attention` : 'All quizzes on page are passed with ≥ 80%!';
if (typeof showToast === 'function') showToast(msg, 3500);
}
}).catch(err => {
if (typeof showToast === 'function') showToast(`Highlight note: ${err.message}`, 3000);
});
}
});
}
const btnNextQuiz = card.querySelector('#amaes-card-btn-next-quiz');
if (btnNextQuiz) {
btnNextQuiz.addEventListener('click', (e) => {
e.preventDefault();
if (typeof cycleToNextQuizOrExam === 'function') {
cycleToNextQuizOrExam();
}
});
}
}
async function injectCourseReviewabilityNotice() {
const existing = document.getElementById('amaes-course-reviewability-notice');
if (!window.location.pathname.includes('/course/view.php')) {
existing?.remove();
return;
}
const subjectCode = detectCourseInfo().subjectCode;
if (!subjectCode) {
existing?.remove();
return;
}
let locallyObservedRestricted = false;
try {
const localStatus = JSON.parse(localStorage.getItem(`amaes_course_reviewability_${subjectCode}`) || 'null');
locallyObservedRestricted = localStatus && localStatus.status === 'restricted-observed';
} catch (error) {
logDebug(`Course reviewability status read note: ${error.message}`);
}
let sharedStatus = null;
try {
const statusUrl = new URL(`${communityRelayUrl}/course-reviewability`);
statusUrl.searchParams.set('subjectCode', subjectCode);
const response = await fetch(statusUrl.href);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sharedStatus = await response.json();
} catch (error) {
logDebug(`Shared course reviewability lookup note for ${subjectCode}: ${error.message}`);
}
const sharedRestricted = sharedStatus && sharedStatus.subjectCode === subjectCode &&
sharedStatus.status === 'restricted-reported';
if (!locallyObservedRestricted && !sharedRestricted) {
existing?.remove();
return;
}
const target = document.querySelector('#region-main .course-content, #region-main, .course-content');
if (!target) return;
const notice = existing || document.createElement('div');
notice.id = 'amaes-course-reviewability-notice';
notice.setAttribute('role', 'status');
notice.style.cssText = 'margin: 12px 0; padding: 10px 12px; border-left: 4px solid #f59e0b; border-radius: 5px; background: rgba(245, 158, 11, 0.12); color: inherit; font-size: 13px; line-height: 1.5;';
const notices = [];
if (sharedRestricted) {
if (sharedStatus.reportCount >= sharedStatus.threshold) {
notices.push(`Review access reports for ${subjectCode}: at least ${sharedStatus.threshold} distinct installations reported that quiz review was restricted in this course within the last ${sharedStatus.windowDays} days. This does not prove that every quiz has the same policy.`);
} else {
notices.push(`Answer sharing is disabled for ${subjectCode} because the course is designated non-reviewable. Study-guide and AI suggestions may still be available, but they are not verified answer keys.`);
}
}
if (locallyObservedRestricted) {
notices.push(`This browser also observed a quiz that blocked review. Its overall grade cannot verify individual answers.`);
}
notices.push('Answers suggested by study guides or AI are unverified and should not be treated as harvested answers.');
notice.textContent = notices.join(' ');
if (!existing) target.prepend(notice);
}
// Removed dashboard guide banner per user request to keep My Courses clean and unboxed
function injectDashboardGuideBanner() {
const existing = document.getElementById('amaes-dashboard-guide-banner');
if (existing) existing.remove();
}
// ==========================================
// Theme Management
// ==========================================
const THEMES = {
dark: {
bg: "#18181b",
surface: "#27272a",
surfaceSubtle: "#202023",
border: "#3f3f46",
borderSubtle: "#2e2e33",
textPrimary: "#f4f4f5",
textSecondary: "#a1a1aa",
textMuted: "#71717a",
accentBlue: "#3b82f6",
accentBlueHover: "#2563eb",
accentPink: "#f43f5e",
accentPinkHover: "#e11d48",
accentPurple: "#a855f7",
accentPurpleHover: "#9333ea",
accentGreen: "#10b981",
accentGreenHover: "#059669",
accentAmber: "#f59e0b",
accentGray: "#52525b",
shadow: "0 14px 36px rgba(0, 0, 0, 0.45)",
statusBg: "#111114"
},
light: {
bg: "#ffffff",
surface: "#f4f4f5",
surfaceSubtle: "#fafafa",
border: "#e4e4e7",
borderSubtle: "#ececee",
textPrimary: "#18181b",
textSecondary: "#52525b",
textMuted: "#71717a",
accentBlue: "#2563eb",
accentBlueHover: "#1d4ed8",
accentPink: "#e11d48",
accentPinkHover: "#be123c",
accentPurple: "#7c3aed",
accentPurpleHover: "#6d28d9",
accentGreen: "#059669",
accentGreenHover: "#047857",
accentAmber: "#d97706",
accentGray: "#e4e4e7",
shadow: "0 14px 36px rgba(0, 0, 0, 0.12)",
statusBg: "#f8fafc"
}
};
let currentTheme = localStorage.getItem('amaes_toolkit_theme') || 'dark';
if (!THEMES[currentTheme]) currentTheme = 'dark';
function detectDeviceMode() {
const w = (typeof window !== 'undefined') ? (window.innerWidth || document.documentElement.clientWidth || (screen && screen.width) || 1024) : 1024;
const isTouch = (typeof navigator !== 'undefined' && (navigator.maxTouchPoints > 0 || 'ontouchstart' in window));
const isMobile = w <= 640 || (w <= 768 && isTouch);
const isTablet = !isMobile && (w <= 1024 || (w <= 1280 && isTouch && w > 640));
const root = (typeof document !== 'undefined') ? document.documentElement : null;
if (root) {
root.classList.toggle('amaes-is-mobile', isMobile);
root.classList.toggle('amaes-is-tablet', isTablet);
root.classList.toggle('amaes-is-touch', isTouch);
root.classList.toggle('amaes-is-desktop', !isMobile && !isTablet);
}
return { isMobile, isTablet, isTouch, width: w };
}
if (typeof window !== 'undefined') {
detectDeviceMode();
window.addEventListener('resize', detectDeviceMode, { passive: true });
window.addEventListener('orientationchange', () => {
setTimeout(detectDeviceMode, 150);
}, { passive: true });
}
function applyTheme(themeKey) {
const t = THEMES[themeKey] || THEMES.dark;
currentTheme = themeKey;
localStorage.setItem('amaes_toolkit_theme', themeKey);
let varSheet = document.getElementById('amaes-theme-variables');
if (!varSheet) {
varSheet = document.createElement('style');
varSheet.id = 'amaes-theme-variables';
(document.head || document.documentElement).appendChild(varSheet);
}
varSheet.textContent = `
:root {
--bg: ${t.bg} !important;
--surface: ${t.surface} !important;
--surface-subtle: ${t.surfaceSubtle} !important;
--border: ${t.border} !important;
--border-subtle: ${t.borderSubtle} !important;
--text-primary: ${t.textPrimary} !important;
--text-secondary: ${t.textSecondary} !important;
--text-muted: ${t.textMuted} !important;
--accent-blue: ${t.accentBlue} !important;
--accent-blue-hover: ${t.accentBlueHover} !important;
--accent-pink: ${t.accentPink} !important;
--accent-pink-hover: ${t.accentPinkHover} !important;
--accent-purple: ${t.accentPurple} !important;
--accent-purple-hover: ${t.accentPurpleHover} !important;
--accent-green: ${t.accentGreen} !important;
--accent-green-hover: ${t.accentGreenHover} !important;
--accent-amber: ${t.accentAmber} !important;
--accent-gray: ${t.accentGray} !important;
--shadow: ${t.shadow} !important;
--status-bg: ${t.statusBg} !important;
}
#amaes-toolkit-panel input[type="checkbox"],
.amaes-modal-container input[type="checkbox"],
#amaes-welcome-modal input[type="checkbox"] {
accent-color: var(--accent-green) !important;
}
`;
if (document.documentElement) {
document.documentElement.setAttribute('data-amaes-theme', themeKey);
}
if (typeof window !== 'undefined' && typeof window._amaesUpdatePanelTheme === 'function') {
window._amaesUpdatePanelTheme(themeKey);
}
if (typeof ICONS !== 'undefined') {
document.querySelectorAll('#amaes-theme-btn').forEach(btn => {
btn.innerHTML = currentTheme === 'dark'
? `${ICONS.sun} Light Mode`
: `${ICONS.moon} Dark Mode`;
});
}
}
// Apply active theme variables immediately
applyTheme(currentTheme);
let autoHighlightQuiz = localStorage.getItem('amaes_auto_highlight_quiz') !== 'false'; // default true
let autoCopyQuizForAI = localStorage.getItem('amaes_auto_copy_ai') !== 'false'; // default true
let autoQuizMode = localStorage.getItem('amaes_auto_quiz_mode') === 'true'; // default false (Master autonomous switch)
let fastQuizMode = localStorage.getItem('amaes_fast_quiz_mode') !== 'false'; // default true (built-in fast answers & accelerated transitions)
let autoPickQuiz = localStorage.getItem('amaes_auto_pick_quiz') !== 'false'; // default true: auto-select verified answers
let autoNextVerified = localStorage.getItem('amaes_auto_next_verified') !== 'false'; // default true: auto-advance when solver answers verified question
let autoPickStudyGuideFallback = localStorage.getItem('amaes_auto_pick_study_guide_fallback') !== 'false'; // default true: automatically use online study guides (Jenny & AMA)
let autoNextQuiz = localStorage.getItem('amaes_auto_next_quiz') === 'true'; // default false: manual answers do NOT auto-advance by default (safe review)
let adaptiveProbeQuiz = localStorage.getItem('amaes_adaptive_probe_quiz') !== 'false'; // default true: rotate choices across unreviewed attempts until 100%
let adaptiveProbeBudget = parseInt(localStorage.getItem('amaes_adaptive_probe_budget') || '2', 10); // default: probe at most 2 unverified questions per attempt
if (isNaN(adaptiveProbeBudget) || adaptiveProbeBudget < 1) adaptiveProbeBudget = 2;
let isWaitingForUserAnswer = false; // session state: true when paused on an unknown question waiting for student input
let autoSubmitQuiz = localStorage.getItem('amaes_auto_submit_quiz') === 'true'; // default false: safe manual review before final submission
let autoSubmitDelay = parseInt(localStorage.getItem('amaes_auto_submit_delay') || '10', 10);
if (isNaN(autoSubmitDelay) || autoSubmitDelay < 0) autoSubmitDelay = 10;
let autoMarkDoneOn100 = localStorage.getItem('amaes_auto_mark_done_100') !== 'false'; // default true: auto-mark activity as done on 100% quiz score
let autoNextTimer = null;
let pageLoadSolverTimer = null;
let sessionQuizzesSolved = parseInt(sessionStorage.getItem('amaes_session_quizzes_solved') || '0', 10);
if (isNaN(sessionQuizzesSolved)) sessionQuizzesSolved = 0;
function getAnonymousInstallId() {
let anonId = localStorage.getItem('amaes_anon_install_id');
if (!anonId || typeof anonId !== 'string' || anonId.length < 10) {
try {
anonId = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'anon-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10);
} catch (_) {
anonId = 'anon-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 10);
}
localStorage.setItem('amaes_anon_install_id', anonId);
}
return anonId;
}
let smartSkipQuiz = localStorage.getItem('amaes_smart_skip_quiz') !== 'false'; // default true: skip answered questions
let autoCloudSync = localStorage.getItem('amaes_auto_cloud_sync') !== 'false'; // default true
let autoScrapeAmauoed = localStorage.getItem('amaes_auto_scrape_amauoed') !== 'false'; // default true
let autoHarvestGrades = localStorage.getItem('amaes_auto_harvest_grades') !== 'false'; // default true: auto-harvest past quizzes on course/grades open
let storedCloudDbUrl = localStorage.getItem('amaes_cloud_db_url');
if (storedCloudDbUrl && storedCloudDbUrl.includes('lms-study-hub')) {
storedCloudDbUrl = storedCloudDbUrl.replace('lms-study-hub', 'Acads-Tools');
localStorage.setItem('amaes_cloud_db_url', storedCloudDbUrl);
}
let cloudDbBaseUrl = storedCloudDbUrl || 'https://raw.githubusercontent.com/Acads-Tools/database/main/data/verified/';
const communityDbBaseUrl = 'https://raw.githubusercontent.com/Acads-Tools/database/main/data/community/';
const CLOUD_DB_FALLBACK_URL = 'https://raw.githubusercontent.com/Acads-Tools/database/main/data/';
const CLOUD_DB_AMAUOED_URL = 'https://raw.githubusercontent.com/Acads-Tools/database/main/data/amauoed/';
const CLOUD_DB_JENNYSONLINE_URL = 'https://raw.githubusercontent.com/Acads-Tools/database/main/data/jennysonline/';
const DEFAULT_COMMUNITY_RELAY_URL = COMMUNITY_RELAY_URL;
let storedRelayUrl = localStorage.getItem('amaes_community_relay_url');
if (storedRelayUrl && (!storedRelayUrl.includes('acads-tools.workers.dev') || storedRelayUrl === 'https://amaes-community-relay.workers.dev')) {
localStorage.removeItem('amaes_community_relay_url');
storedRelayUrl = null;
}
let communityRelayUrl = storedRelayUrl || DEFAULT_COMMUNITY_RELAY_URL;
// A random, non-identifying installation token lets the relay deduplicate
// retries and count distinct contributors without receiving account data.
function getAnonymousContributorId() {
let contributorId = localStorage.getItem(CONTRIBUTOR_ID_STORAGE_KEY);
if (!contributorId) {
contributorId = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `anon-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(CONTRIBUTOR_ID_STORAGE_KEY, contributorId);
}
return contributorId;
}
let aiPromptHint = localStorage.getItem('amaes_ai_prompt_hint') !== 'false'; // default true for clean a/b/c/d answers
let copyIncludeConfidence = localStorage.getItem('amaes_copy_include_confidence') !== 'false'; // default true: include DB answer hints & confidence
let showInQuestionAiBtns = localStorage.getItem('amaes_show_in_question_ai_btns') !== 'false'; // default true
let enableKeyboardShortcuts = localStorage.getItem('amaes_enable_hotkeys') !== 'false'; // default true: N, Space, 1-4, C, P, H
let autoCommunityShare = localStorage.getItem('amaes_auto_community_share') !== 'false'; // default true: auto-share on review / harvest
let autoMinimizeQuiz = localStorage.getItem('amaes_auto_min_quiz') !== 'false'; // default true: smart pill in quiz
let enableAudioAlerts = localStorage.getItem('amaes_enable_audio_alerts') !== 'false'; // default true: audio chime on quiz completion & intervention alert
const GEMINI_API_KEY_STORAGE_KEY = 'amaes_gemini_api_key';
const GEMINI_API_KEYS_STORAGE_KEY = 'amaes_gemini_api_keys';
const GEMINI_MODEL = 'gemini-1.5-flash';
const GEMINI_TIMEOUT_MS = 8000;
const SHARED_AI_FALLBACK_STORAGE_KEY = 'amaes_shared_ai_fallback_enabled';
const CONTRIBUTOR_KEY_ID_STORAGE_KEY = 'amaes_contributor_key_id';
const CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY = 'amaes_contributor_owner_token';
const CONTRIBUTOR_MODE_STORAGE_KEY = 'amaes_contributor_mode';
const CONTRIBUTOR_KEY_FINGERPRINT_STORAGE_KEY = 'amaes_contributor_key_fingerprint';
const CONTRIBUTOR_ACTIVITY_SENT_STORAGE_KEY = 'amaes_contributor_activity_sent';
function getContributorValue(key) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(key, '');
} catch (_) {}
return localStorage.getItem(key) || '';
}
function setContributorValue(key, value) {
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
return;
}
} catch (_) {}
localStorage.setItem(key, value);
}
function removeContributorValue(key) {
try {
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(key);
return;
}
} catch (_) {}
localStorage.removeItem(key);
}
function isContributorSharingEnabled() {
return getContributorValue(CONTRIBUTOR_MODE_STORAGE_KEY) === 'contributor' &&
Boolean(getContributorValue(CONTRIBUTOR_KEY_ID_STORAGE_KEY)) &&
Boolean(getContributorValue(CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY));
}
function createContributorOwnerToken() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
}
async function contributorKeyFingerprint(value) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
}
async function deleteContributorKey() {
const keyId = getContributorValue(CONTRIBUTOR_KEY_ID_STORAGE_KEY);
const ownerToken = getContributorValue(CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY);
if (!keyId || !ownerToken) return;
try {
await fetch(`${communityRelayUrl}/keys/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': CLIENT_VERSION,
'X-AMAES-Owner-Token': ownerToken
},
body: JSON.stringify({ keyId })
});
} catch (_) {
// Local removal still prevents this installation from using the credential.
}
[CONTRIBUTOR_KEY_ID_STORAGE_KEY, CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY,
CONTRIBUTOR_MODE_STORAGE_KEY, CONTRIBUTOR_KEY_FINGERPRINT_STORAGE_KEY,
CONTRIBUTOR_ACTIVITY_SENT_STORAGE_KEY]
.forEach(removeContributorValue);
}
async function registerContributorKey(geminiKey) {
const ownerToken = createContributorOwnerToken();
const response = await fetch(`${communityRelayUrl}/keys/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': CLIENT_VERSION
},
body: JSON.stringify({
geminiKey,
ownerToken,
consent: true,
mode: 'contributor'
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.keyId) {
throw new Error(data.error || `Contributor registration failed (${response.status})`);
}
setContributorValue(CONTRIBUTOR_KEY_ID_STORAGE_KEY, data.keyId);
setContributorValue(CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY, ownerToken);
setContributorValue(CONTRIBUTOR_MODE_STORAGE_KEY, 'contributor');
setContributorValue(CONTRIBUTOR_KEY_FINGERPRINT_STORAGE_KEY, await contributorKeyFingerprint(geminiKey));
return data;
}
async function noteContributorActivity() {
if (!isContributorSharingEnabled()) return;
const lastSent = Number(getContributorValue(CONTRIBUTOR_ACTIVITY_SENT_STORAGE_KEY) || 0);
if (Date.now() - lastSent < 10 * 60 * 1000) return;
const keyId = getContributorValue(CONTRIBUTOR_KEY_ID_STORAGE_KEY);
const ownerToken = getContributorValue(CONTRIBUTOR_OWNER_TOKEN_STORAGE_KEY);
try {
const response = await fetch(`${communityRelayUrl}/keys/activity`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': CLIENT_VERSION,
'X-AMAES-Owner-Token': ownerToken
},
body: JSON.stringify({ keyId })
});
if (response.ok) setContributorValue(CONTRIBUTOR_ACTIVITY_SENT_STORAGE_KEY, String(Date.now()));
} catch (_) {
// A failed activity update never affects the local personal-key request.
}
}
function isSharedAiFallbackEnabled() {
return toolkitStorage.get(SHARED_AI_FALLBACK_STORAGE_KEY) !== 'false';
}
function setSharedAiFallbackEnabled(enabled) {
toolkitStorage.set(SHARED_AI_FALLBACK_STORAGE_KEY, enabled ? 'true' : 'false');
}
function getGeminiApiKeys() {
try {
const raw = toolkitStorage.get(GEMINI_API_KEYS_STORAGE_KEY);
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (Array.isArray(parsed)) {
const clean = parsed.map(k => (k || '').trim()).filter(Boolean);
if (clean.length > 0) return clean;
}
}
} catch (_) {}
const single = String(toolkitStorage.get(GEMINI_API_KEY_STORAGE_KEY) || '').trim();
return single ? [single] : [];
}
function setGeminiApiKeys(keys) {
const clean = Array.isArray(keys) ? keys.map(k => (k || '').trim()).filter(Boolean) : [];
if (clean.length > 0) {
try {
toolkitStorage.set(GEMINI_API_KEYS_STORAGE_KEY, JSON.stringify(clean));
} catch (_) {}
toolkitStorage.set(GEMINI_API_KEY_STORAGE_KEY, clean[0]);
geminiApiKey = clean[0];
} else {
toolkitStorage.remove(GEMINI_API_KEYS_STORAGE_KEY);
toolkitStorage.remove(GEMINI_API_KEY_STORAGE_KEY);
geminiApiKey = '';
}
updateAiAssistantUI();
}
let geminiApiKey = (getGeminiApiKeys()[0] || '');
let aiQuizEnabled = toolkitStorage.get('amaes_ai_quiz_enabled') !== 'false'; // default true
let aiAutoSelect = toolkitStorage.get('amaes_ai_auto_select') !== 'false'; // default true: auto-select AI suggestion
let aiRetryCount = parseInt(toolkitStorage.get('amaes_ai_retry_count') || '2', 10);
if (isNaN(aiRetryCount) || aiRetryCount < 1) aiRetryCount = 2;
let aiAutoCopyOnFail = toolkitStorage.get('amaes_ai_auto_copy_on_fail') !== 'false'; // default true: auto-copy on fail
const aiAnswerSessionCache = new Map(); // In-memory session cache for instant reuse
let activeAiAbortController = null;
function getAiRetryCount() {
const val = parseInt(toolkitStorage.get('amaes_ai_retry_count') || '2', 10);
return (isNaN(val) || val < 1) ? 2 : Math.min(5, val);
}
function setAiRetryCount(val) {
aiRetryCount = Math.max(1, Math.min(5, parseInt(val, 10) || 2));
toolkitStorage.set('amaes_ai_retry_count', String(aiRetryCount));
}
function getAiAutoCopyOnFail() {
return toolkitStorage.get('amaes_ai_auto_copy_on_fail') !== 'false';
}
function setAiAutoCopyOnFail(val) {
aiAutoCopyOnFail = Boolean(val);
toolkitStorage.set('amaes_ai_auto_copy_on_fail', aiAutoCopyOnFail ? 'true' : 'false');
}
let aiAutoNextOnAiAnswer = toolkitStorage.get('amaes_ai_auto_next_on_ai') !== 'false'; // default true
function getAiAutoNextOnAiAnswer() { return toolkitStorage.get('amaes_ai_auto_next_on_ai') !== 'false'; }
function setAiAutoNextOnAiAnswer(val) { aiAutoNextOnAiAnswer = Boolean(val); toolkitStorage.set('amaes_ai_auto_next_on_ai', aiAutoNextOnAiAnswer ? 'true' : 'false'); }
const GEMINI_FREE_RPM = 15; // 15 requests per minute limit on Google AI Studio Free Tier
let aiPlanTier = toolkitStorage.get('amaes_ai_plan_tier') || 'free'; // 'free' or 'paid'
let aiRateLimitCooldownUntil = 0;
try {
const storedCooldown = parseInt(sessionStorage.getItem('amaes_ai_cooldown_until') || '0', 10);
if (storedCooldown > Date.now()) aiRateLimitCooldownUntil = storedCooldown;
} catch (_) {}
let activeRateLimitTimerInterval = null;
function getAiPlanTier() {
return toolkitStorage.get('amaes_ai_plan_tier') || 'free';
}
function setAiPlanTier(tier) {
aiPlanTier = tier === 'paid' ? 'paid' : 'free';
toolkitStorage.set('amaes_ai_plan_tier', aiPlanTier);
}
function getAiKeyId(key) {
if (!key) return 'primary';
return key.length > 8 ? key.slice(-8) : key;
}
function getStoredRequestTimestamps() {
const key = arguments[0] || null;
try {
const storageKey = key ? `amaes_ai_req_timestamps_${getAiKeyId(key)}` : 'amaes_ai_req_timestamps';
const raw = sessionStorage.getItem(storageKey);
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
const cutoff = Date.now() - 60000;
return arr.filter(t => typeof t === 'number' && t > cutoff);
}
}
} catch (_) {}
return [];
}
function saveRequestTimestamps(arr, key = null) {
try {
const storageKey = key ? `amaes_ai_req_timestamps_${getAiKeyId(key)}` : 'amaes_ai_req_timestamps';
sessionStorage.setItem(storageKey, JSON.stringify(arr));
} catch (_) {}
}
function recordAiRequest() {
const key = arguments[0] || null;
const now = Date.now();
const cutoff = now - 60000;
// Always maintain global timestamps for backwards compatibility
const currentGlobal = getStoredRequestTimestamps(null).filter(t => t > cutoff);
currentGlobal.push(now);
saveRequestTimestamps(currentGlobal, null);
// Also record per-key if key provided
if (key) {
const currentPerKey = getStoredRequestTimestamps(key).filter(t => t > cutoff);
currentPerKey.push(now);
saveRequestTimestamps(currentPerKey, key);
}
}
function triggerAiRateLimitCooldown(suggestedWaitSec = 20) {
const key = arguments[1] || null;
const waitSec = Math.max(5, Math.min(60, suggestedWaitSec));
const cooldownUntil = Date.now() + (waitSec * 1000);
aiRateLimitCooldownUntil = Math.max(aiRateLimitCooldownUntil, cooldownUntil);
try {
sessionStorage.setItem('amaes_ai_cooldown_until', String(aiRateLimitCooldownUntil));
if (key) {
sessionStorage.setItem(`amaes_ai_cooldown_${getAiKeyId(key)}`, String(cooldownUntil));
}
} catch (_) {}
return Math.ceil((aiRateLimitCooldownUntil - Date.now()) / 1000);
}
function getKeyRateLimitStatus(key) {
const now = Date.now();
const keyId = getAiKeyId(key);
let cooldownUntil = 0;
try {
const stored = parseInt(sessionStorage.getItem(`amaes_ai_cooldown_${keyId}`) || '0', 10);
if (stored > now) cooldownUntil = stored;
} catch (_) {}
if (cooldownUntil > now) {
const remainingSec = Math.max(1, Math.ceil((cooldownUntil - now) / 1000));
return { isLimited: true, remainingSec, reason: 'cooldown', key };
}
if (getAiPlanTier() === 'free') {
const timestamps = getStoredRequestTimestamps(key);
if (timestamps.length >= GEMINI_FREE_RPM) {
const oldest = timestamps[0];
const remainingSec = Math.max(1, Math.ceil((oldest + 60000 - now) / 1000));
return { isLimited: true, remainingSec, reason: 'rpm_cap', key };
}
}
return { isLimited: false, remainingSec: 0, reason: null, key };
}
function getAiRateLimitStatus() {
const now = Date.now();
try {
const storedCooldown = parseInt(sessionStorage.getItem('amaes_ai_cooldown_until') || '0', 10);
if (storedCooldown > aiRateLimitCooldownUntil) aiRateLimitCooldownUntil = storedCooldown;
} catch (_) {}
const keys = typeof getGeminiApiKeys === 'function' ? getGeminiApiKeys() : [];
// Single key or unconfigured: standard global behavior
if (keys.length <= 1) {
if (aiRateLimitCooldownUntil > now) {
const remainingSec = Math.max(1, Math.ceil((aiRateLimitCooldownUntil - now) / 1000));
return { isLimited: true, remainingSec, reason: 'cooldown', activeKey: keys[0] || null };
}
if (getAiPlanTier() === 'free') {
const timestamps = getStoredRequestTimestamps();
if (timestamps.length >= GEMINI_FREE_RPM) {
const oldest = timestamps[0];
const remainingSec = Math.max(1, Math.ceil((oldest + 60000 - now) / 1000));
return { isLimited: true, remainingSec, reason: 'rpm_cap', activeKey: keys[0] || null };
}
}
return { isLimited: false, remainingSec: 0, reason: null, activeKey: keys[0] || null };
}
// Multiple keys: check each key individually
const statuses = keys.map(k => getKeyRateLimitStatus(k));
const available = statuses.filter(s => !s.isLimited);
if (available.length > 0) {
// Sort by request count in last 60s (least loaded key first for optimal load-balancing)
available.sort((a, b) => {
const countA = getStoredRequestTimestamps(a.key).length;
const countB = getStoredRequestTimestamps(b.key).length;
return countA - countB;
});
return {
isLimited: false,
remainingSec: 0,
reason: null,
activeKey: available[0].key,
availableCount: available.length,
totalKeys: keys.length
};
}
// All keys limited: find soonest recovery time
const minWait = Math.min(...statuses.map(s => s.remainingSec));
return {
isLimited: true,
remainingSec: Math.max(1, minWait),
reason: 'all_keys_limited',
availableCount: 0,
totalKeys: keys.length
};
}
function getAvailableGeminiKey() {
const keys = getGeminiApiKeys();
if (keys.length === 0) return '';
if (keys.length === 1) return keys[0];
const status = getAiRateLimitStatus();
if (!status.isLimited && status.activeKey) {
return status.activeKey;
}
return keys[0];
}
function getAiSessionCacheStorageKey() {
return `amaes_ai_session_cache_${getQuizSessionKey()}`;
}
function saveAiAnswerToCache(qData, matched) {
if (!qData || !matched) return;
const qKey = normalizeText(qData.qText || '');
if (!qKey) return;
const record = {
choiceIndex: matched.choiceIndex,
choiceText: matched.choiceText,
timestamp: Date.now()
};
aiAnswerSessionCache.set(qKey, record);
try {
const raw = sessionStorage.getItem(getAiSessionCacheStorageKey());
const cacheObj = raw ? JSON.parse(raw) : {};
cacheObj[qKey] = record;
sessionStorage.setItem(getAiSessionCacheStorageKey(), JSON.stringify(cacheObj));
} catch (_) {}
}
function getCachedAiAnswer(qData) {
if (!qData) return null;
const qKey = normalizeText(qData.qText || '');
if (!qKey) return null;
if (aiAnswerSessionCache.has(qKey)) {
return aiAnswerSessionCache.get(qKey);
}
try {
const raw = sessionStorage.getItem(getAiSessionCacheStorageKey());
if (raw) {
const cacheObj = JSON.parse(raw);
if (cacheObj && cacheObj[qKey]) {
aiAnswerSessionCache.set(qKey, cacheObj[qKey]);
return cacheObj[qKey];
}
}
} catch (_) {}
return null;
}
function isChoiceRowEliminated(row) {
if (!row) return false;
if (row.classList && row.classList.contains('amaes-eliminated-choice')) return true;
if (row.querySelector && row.querySelector('.amaes-eliminated-badge')) return true;
const label = row.querySelector ? (row.querySelector('label') || row) : row;
if (label && label.style && label.style.textDecoration && label.style.textDecoration.includes('line-through')) return true;
if (typeof hasChoiceCross === 'function' && (hasChoiceCross(row) || hasChoiceCross(label))) return true;
return false;
}
function getEliminatedChoicesForQuestion(que, qData, courseCode = '') {
const eliminatedTexts = new Set();
if (que) {
const choiceRows = que.querySelectorAll('.answer > div, .answer div.r0, .answer div.r1, .answer li, .answer tr, .answer label');
choiceRows.forEach(r => {
if (isChoiceRowEliminated(r)) {
const lbl = r.querySelector('label') || r;
const txt = normalizeChoice(cleanDOMToAI(lbl));
if (txt) eliminatedTexts.add(txt);
}
});
// Inspect que dataset for known wrong answers (essential for shortanswer / fill-in-the-blank)
if (que.dataset && que.dataset.amaesKnownWrongAnswers) {
try {
const parsed = JSON.parse(que.dataset.amaesKnownWrongAnswers);
if (Array.isArray(parsed)) {
parsed.forEach(item => {
const val = typeof item === 'string' ? item : (item.text || item.norm);
const n = normalizeChoice(val);
if (n) eliminatedTexts.add(n);
});
}
} catch (_) {}
}
// Inspect live inputs with known-wrong markings or datasets
que.querySelectorAll('.amaes-known-wrong-selection, [data-amaes-known-wrong-answers]').forEach(inp => {
if (inp.dataset && inp.dataset.amaesKnownWrongAnswers) {
try {
const parsed = JSON.parse(inp.dataset.amaesKnownWrongAnswers);
if (Array.isArray(parsed)) {
parsed.forEach(item => {
const val = typeof item === 'string' ? item : (item.text || item.norm);
const n = normalizeChoice(val);
if (n) eliminatedTexts.add(n);
});
}
} catch (_) {}
}
const val = (inp.value || inp.getAttribute('value') || '').trim();
const n = normalizeChoice(val);
if (n) eliminatedTexts.add(n);
});
}
const cCode = courseCode || (typeof detectCourseInfo === 'function' ? (detectCourseInfo().subjectCode || '') : '');
if (cCode) {
try {
const cached = getCachedAnswers(cCode);
if (Array.isArray(cached) && qData && qData.qText) {
const qNorm = normalizeText(qData.qText);
const candidates = cached.filter(item => questionTextMatches(item.qNorm || item.qRaw || item.question, qNorm));
candidates.forEach(cand => {
if (Array.isArray(cand.wrongAnswers)) {
cand.wrongAnswers.forEach(w => {
const wNorm = typeof w === 'string' ? normalizeChoice(w) : (w.norm || normalizeChoice(w.text || ''));
if (wNorm) eliminatedTexts.add(wNorm);
});
}
});
}
} catch (_) {}
}
return eliminatedTexts;
}
// ==========================================
// Course & Activity Detection
// ==========================================
const KNOWN_COURSES = {
// Computer Science
"CS6202": "Algorithms and Complexity",
"CS6204": "Computer Architecture and Organization",
"CS6205": "Automata Theory and Formal Languages",
"CS6206": "Principles of Operating Systems",
"CS6209": "Software Engineering 1",
"CS6300": "Software Engineering 2",
"CS6301": "Logic Design and Digital Computer Circuits",
"CS6309": "Introduction to Machine Learning",
"CS6326": "Mobile Application Development",
// Information Technology
"IT6201": "Data Structures and Algorithm Analysis",
"IT6202": "Data Structures and Algorithms",
"IT6203": "Web Systems and Technologies 1",
"IT6204": "Web Systems and Technologies 2",
"IT6205": "Information Assurance and Security 1",
"IT6205A": "Information Assurance and Security 1",
"IT6206": "Information Assurance and Security 2",
"IT6207": "Database Systems 1",
"IT6208": "System Integration and Architecture 1",
"IT6209": "Introduction to Multimedia",
"IT6210": "Systems Administration and Maintenance",
"IT6220": "Information Management",
"IT6221": "Data Communications and Networking 1",
"IT6222": "Data Communications and Networking 2",
"IT6224": "Data Communications and Networking 3",
"IT6224B": "Data Communications and Networking 3",
"IT6300": "Cloud Computing",
"IT6301": "Technopreneurship",
"IT6302": "System Analysis and Design",
"IT6310": "Network Security",
"IT6320": "Social and Professional Issues",
"IT6322": "Mobile Application Development",
"IT6322A": "Mobile Application Development",
"IT6323": "Human Computer Interaction",
"IT6324": "Information Assurance and Security",
// Information Technology Education Core
"ITE6100": "Introduction to Computing",
"ITE6101": "Computer Programming 1",
"ITE6102": "Computer Programming 1",
"ITE6103": "Computer Programming 2",
"ITE6104": "Computer Programming 2",
"ITE6200": "Application Development and Emerging Technology",
"ITE6201": "Data Structures and Algorithm Analysis",
"ITE6220": "Information Management",
"ITE6300": "Cloud Computing and Internet of Things",
"ITE6301": "Technopreneurship",
// Mathematics & Sciences
"MATH6100": "Calculus 1",
"MATH6101": "Calculus 2",
"MATH6102": "Discrete Mathematics",
// General Education & Institutional
"GE6100": "Understanding the Self",
"GE6101": "Readings in Philippine History",
"GE6102": "The Contemporary World",
"GE6103": "Mathematics in the Modern World",
"GE6104": "Purposive Communication",
"GE6105": "Art Appreciation",
"GE6106": "Science, Technology and Society",
"GE6107": "Ethics",
"GE6108": "Rizal's Life and Works",
"GE6301": "Gender and Society",
"GE6115": "Art Appreciation",
"ETHNS6101": "Euthenics 1",
"ETHNS6102": "Euthenics 2",
"NSTP6101": "National Service Training Program 1",
"NSTP6102": "National Service Training Program 2",
"PE6101": "Physical Education 1",
"PE6102": "Physical Education 2",
"PE6103": "Physical Education 3",
"PE6104": "Physical Education 4"
};
function resolveKnownCourseName(code) {
if (!code) return "";
const clean = String(code).trim().toUpperCase();
if (KNOWN_COURSES[clean]) return KNOWN_COURSES[clean];
const base = clean.replace(/[A-Za-z]+$/, '');
if (base && KNOWN_COURSES[base]) return KNOWN_COURSES[base];
return clean;
}
function detectAssessmentOrigin() {
const host = (window.location && window.location.hostname) ? window.location.hostname.toLowerCase() : '';
const isExamHost = host.includes('exam');
const breadcrumbItems = Array.from(document.querySelectorAll('.breadcrumb-item, .breadcrumb a, nav.breadcrumb a, .breadcrumb span'))
.map(el => (el.innerText || '').trim())
.filter(Boolean);
const breadcrumbText = breadcrumbItems.join(' ').toLowerCase();
const heading = (document.querySelector('.page-header-headings h1, #page-header h1, #region-main h2, #region-main h3')?.innerText || '').trim().toLowerCase();
const title = (document.title || '').toLowerCase();
const combined = `${breadcrumbText} ${heading} ${title}`;
const isExam = isExamHost || /\b(?:exam|examination|prelim\s+exam|midterm\s+exam|prefinal\s+exam|final\s+exam)\b/i.test(combined);
let examPhase = 'Quiz';
if (isExam) {
if (/\bprelim(?:\s+lab)?\s+exam\b/i.test(combined) || /\bprelim\b/i.test(combined)) {
examPhase = combined.includes('lab') ? 'Prelim Lab Exam' : 'Prelim Exam';
} else if (/\bmidterm(?:\s+lab)?\s+exam\b/i.test(combined) || /\bmidterm\b/i.test(combined)) {
examPhase = combined.includes('lab') ? 'Midterm Lab Exam' : 'Midterm Exam';
} else if (/\bprefinal\b/i.test(combined)) {
examPhase = 'Prefinal Exam';
} else if (/\bfinal\b/i.test(combined)) {
examPhase = 'Final Exam';
} else {
examPhase = 'Exam';
}
} else if (/\bquiz\b/i.test(combined)) {
const quizMatch = combined.match(/\bquiz\s*\d+\b/i) || heading.match(/\bquiz\s*\d+\b/i);
examPhase = quizMatch ? quizMatch[0].replace(/\bquiz\s*/i, 'Quiz ') : 'Quiz';
}
return {
originType: isExam ? 'exam' : 'quiz',
originLabel: examPhase,
isExam,
isExamHost
};
}
function detectCourseInfo() {
let fullTitle = '';
const codeRegex = /\b([A-Za-z]{2,6}\d{3,4}[A-Za-z]*)\b/;
const candidates = [];
const heading = document.querySelector('.page-header-headings h1, #page-header h1, .page-header-title');
if (heading && heading.innerText.trim()) candidates.push(heading.innerText.trim());
const breadcrumbLinks = document.querySelectorAll('.breadcrumb-item a, nav.breadcrumb a, .breadcrumb a');
for (const link of breadcrumbLinks) {
const text = link.innerText.trim();
if (text) candidates.push(text);
}
if (document.title) candidates.push(document.title);
let bestCandidate = '';
for (const cand of candidates) {
if (codeRegex.test(cand)) {
bestCandidate = cand;
break;
}
}
if (!bestCandidate && candidates.length > 0) {
bestCandidate = candidates[0];
}
fullTitle = bestCandidate;
let subjectCode = '';
let subjectName = '';
const codeMatch = fullTitle.match(codeRegex) || fullTitle.match(/[-_]\s*([A-Za-z0-9]+)\b/);
if (codeMatch) {
subjectCode = codeMatch[1].toUpperCase();
const codeIndex = fullTitle.indexOf(codeMatch[1]);
if (codeIndex !== -1) {
const after = fullTitle.substring(codeIndex + codeMatch[1].length).trim().replace(/^[:\-–\s]+/, '').split('|')[0].trim();
const before = fullTitle.substring(0, codeIndex).trim().replace(/[:\-–\s]+$/, '').split('|')[0].trim();
if (after && after.length > 2) {
subjectName = after;
} else if (before && before.length > 2) {
subjectName = before;
}
} else {
subjectName = fullTitle.replace(codeMatch[1], '').replace(/^[:\-–\s]+/, '').trim();
}
}
if (!subjectName || subjectName.toUpperCase() === subjectCode) {
const fallback = resolveKnownCourseName(subjectCode);
if (fallback && fallback !== subjectCode) {
subjectName = fallback;
}
}
let currentActivityTitle = '';
if (window.location.pathname.includes('/mod/quiz/')) {
const quizHeader = document.querySelector('.page-header-headings h1, #region-main h2, #region-main h3');
if (quizHeader) {
currentActivityTitle = quizHeader.innerText.trim();
}
}
let courseId = '';
if (window.location.pathname.includes('/course/view.php') || window.location.pathname.includes('/grade/report/')) {
courseId = new URLSearchParams(window.location.search).get('id') || '';
}
if (!courseId) {
const courseSelectors = [
'ol.breadcrumb a[href*="/course/view.php"]',
'.breadcrumb a[href*="/course/view.php"]',
'#page-navbar a[href*="/course/view.php"]',
'a[data-key="coursehome"]',
'li[data-key="coursehome"] a',
'.secondary-navigation a[href*="/course/view.php"]',
'#courseindex a[href*="/course/view.php"]',
'[data-region="courseindex"] a[href*="/course/view.php"]',
'a[href*="/course/view.php?id="]',
'a[href*="/grade/report/user/index.php?id="]'
];
for (const sel of courseSelectors) {
const el = document.querySelector(sel);
if (el && el.href) {
const m = el.href.match(/[?&]id=(\d+)/);
if (m && m[1]) {
courseId = m[1];
break;
}
}
}
}
if (!courseId) {
const bodyMatch = document.body.className ? document.body.className.match(/\bcourse-(\d+)\b/) : null;
if (bodyMatch && bodyMatch[1] !== '1') {
courseId = bodyMatch[1];
}
}
if (!courseId) {
try {
courseId = sessionStorage.getItem('amaes_current_course_id') || localStorage.getItem('amaes_last_active_course_id') || '';
} catch (_) {}
}
if (courseId) {
try {
sessionStorage.setItem('amaes_current_course_id', courseId);
localStorage.setItem('amaes_last_active_course_id', courseId);
} catch (_) {}
}
const assessmentOrigin = detectAssessmentOrigin();
return {
fullTitle,
subjectCode,
subjectName,
courseId,
currentActivityTitle,
assessmentOrigin,
originType: assessmentOrigin.originType,
originLabel: assessmentOrigin.originLabel
};
}
function checkIsCoursePage() {
return window.location.pathname.includes('/course/view.php') ||
window.location.pathname.includes('/mod/') ||
Boolean(document.querySelector('.course-content, .course-section, #region-main .activity, .activity-item, .que'));
}
function checkIsQuizPage() {
return window.location.pathname.includes('/mod/quiz/attempt.php') ||
window.location.pathname.includes('/mod/quiz/summary.php') ||
window.location.pathname.includes('/mod/quiz/review.php') ||
window.location.pathname.includes('/mod/quiz/view.php') ||
Boolean(document.querySelector('.que, .quizsummarytable, #region-main .summarytable, .quizattemptsummary'));
}
function checkIsReviewPage() {
if (window.location.pathname.includes('/mod/quiz/review.php')) return true;
if (document.querySelector('#page-mod-quiz-review, body.path-mod-quiz-review, .quizreviewsummary, table.quizreviewsummary')) return true;
if (document.querySelector('.que .outcome, .que .rightanswer')) return true;
if (Array.from(document.querySelectorAll('a, button, input[type="submit"], .submitbtns a')).some(el => /finish\s+review/i.test(el.textContent || el.value || ''))) return true;
return false;
}
function checkIsQuizAttemptPage() {
return window.location.pathname.includes('/mod/quiz/attempt.php');
}
function checkIsQuizSummaryPage() {
return window.location.pathname.includes('/mod/quiz/summary.php') ||
Boolean(document.querySelector('.quizsummarytable, #region-main .summarytable'));
}
function checkIsQuizViewPage() {
return window.location.pathname.includes('/mod/quiz/view.php') ||
Boolean(document.querySelector('.quizattemptsummary, #region-main .generaltable, .generaltable.quizattemptsummary'));
}
// String Normalization for Question & Answer Matching
function normalizeText(str) {
if (!str) return '';
// Unescape HTML entities
const doc = new DOMParser().parseFromString(str, 'text/html');
let text = doc.body.textContent || '';
text = text.toLowerCase().trim();
text = unscriptDigits(text);
// Remove question numbering like "1.", "(1)", "question 1:", "question no. 1", "question #1", "q1.", "item 1:"
text = text.replace(/^(?:(?:question|item|q)\s*(?:no\.?|#)?\s*\d+[\s:.-]*|\(?\d+\)?[\s:.)-]+)/i, '');
// Strip common question instructions / preambles:
// e.g. "Choose the best answer.", "Select the correct answer.", "Read the statement carefully and select the best answer."
const preambleRegex = /^(?:(?:direction|directions|instruction|instructions)\s*[:.\-–]\s*)?(?:read\s+(?:each\s+|the\s+)?(?:statement|question|passage)s?\s+(?:carefully\s+)?(?:and\s+)?)?(?:choose|select|pick|identify|mark)\s+(?:the\s+)?(?:best|correct|appropriate|right)\s+(?:answer|choice|option)[.:?!;\s–-]*/i;
while (preambleRegex.test(text)) {
const nextText = text.replace(preambleRegex, '').trim();
if (nextText.length === 0) break;
text = nextText;
}
// Strip trailing prompt instructions like "select one:", "choose one:", etc.
const trailingPromptRegex = /\b(?:select\s+one|select\s+one\s+or\s+more|choose\s+one|choose\s+one\s+or\s+more|choose\s+the\s+best\s+answer)[:.]?\s*$/i;
if (trailingPromptRegex.test(text)) {
const nextText = text.replace(trailingPromptRegex, '').trim();
if (nextText.length > 0) text = nextText;
}
// Strip multiple underscores down to single placeholder
text = text.replace(/_{2,}/g, '___');
// Strip extra whitespace
text = text.replace(/\s+/g, ' ');
// Strip trailing punctuation
text = text.replace(/[.:?!;,]+$/, '');
return text.trim();
}
function stripQuestionMarkup(text) {
if (!text) return '';
let prev;
let curr = String(text);
// Iteratively strip innermost bracket tokens first (moodle-asset, dropdown, blank, ____, image)
do {
prev = curr;
curr = curr.replace(/\[\s*(?:moodle-asset|dropdown|blank|image|___+)[^\[\]]*\]/gi, ' ');
} while (curr !== prev);
// Strip any remaining outer container tags (e.g. outer [Image: ...])
do {
prev = curr;
curr = curr.replace(/\[\s*(?:image|dropdown|blank)[^\[\]]*\]/gi, ' ');
} while (curr !== prev);
return curr;
}
function normalizeQuestionMatchKey(value) {
return normalizeText(stripQuestionMarkup(value))
.replace(/\[\s*_{2,}(?:\s*:\s*[\s\S]*?)?\s*\]/g, ' ')
.replace(/[_\u00a0]+/g, ' ')
.trim()
.replace(/^[:?!;,—–-]+\s*/g, '')
.replace(/^(?:(?:identify\s+(?:the\s+)?(?:following|term|what\s+is\s+being\s+described)|fill\s+in\s+the\s+blanks?|complete\s+the\s+(?:statement|sentence|following)|identification|identify))\s*[:.-]*\s*/i, '')
.replace(/^[:?!;,—–-]+\s*/g, '')
.replace(/^(?:answer\s+)?question\s*(?:no\.?|#)?\s*\d+[\s:.-]*/i, '')
.replace(/^(?:(?:this\s+(?:command|method|function)|jquery\s+(?:method|command|function)|it)\s+(?:is\s+)?(?:used\s+to\s+|that\s+is\s+used\s+to\s+))/i, '')
.replace(/\bcreates\b/gi, 'create')
.replace(/\s+/g, ' ')
.replace(/^[:?!;,—–-]+\s*/g, '')
.replace(/[.:?!;,—–-]+$/g, '')
.trim();
}
function questionTextMatches(left, right) {
const leftNorm = normalizeQuestionMatchKey(left);
const rightNorm = normalizeQuestionMatchKey(right);
if (!leftNorm || !rightNorm) return false;
if (leftNorm === rightNorm) return true;
const longer = leftNorm.length >= rightNorm.length ? leftNorm : rightNorm;
const shorter = leftNorm.length >= rightNorm.length ? rightNorm : leftNorm;
// Permit harmless prompt markup differences, but never short-token matches
// or collisions where extra content contains code identifiers or qualifiers.
if (shorter.length > 20 && longer.includes(shorter)) {
const idx = longer.indexOf(shorter);
const prefix = longer.slice(0, idx).trim();
const suffix = longer.slice(idx + shorter.length).trim();
const extra = `${prefix} ${suffix}`.trim();
if (/\b(?:not|never|no|false|true)\b/i.test(extra)) return false;
if (/^\.[a-z0-9_]+/i.test(prefix) || /\.[a-z0-9_]+\s*\(/i.test(extra)) return false;
return (shorter.length / longer.length) >= 0.70;
}
return false;
}
// Helper to unscript unicode superscript and subscript digits to standard digits
function unscriptDigits(str) {
if (!str) return '';
const map = {
'⁰':'0','¹':'1','²':'2','³':'3','⁴':'4','⁵':'5','⁶':'6','⁷':'7','⁸':'8','⁹':'9',
'₀':'0','₁':'1','₂':'2','₃':'3','₄':'4','₅':'5','₆':'6','₇':'7','₈':'8','₉':'9'
};
return str.replace(/[⁰¹²³⁴⁵⁶⁷⁸⁹₀₁₂₃₄₅₆₇₈₉]/g, ch => map[ch] || ch);
}
function normalizeChoice(str) {
if (!str) return '';
let text = str;
if (typeof DOMParser !== 'undefined' && /<[a-z][\s\S]*>/i.test(str)) {
try {
const doc = new DOMParser().parseFromString(str, 'text/html');
const imgs = doc.body.querySelectorAll('img');
let extracted = doc.body.textContent || '';
if (!extracted.trim() && imgs.length > 0) {
extracted = Array.from(imgs).map(img => {
const m = (img.src || '').match(/\/pluginfile\.php\/\d+\/question\/(?:answer|questiontext|feedback)\/\d+(?:\/\d+)?\/([^?#\s]+)/i);
return img.alt || (m ? `[moodle-asset:${m[1]}]` : img.src) || '';
}).join(' ');
}
text = extracted || str;
} catch (_) {}
}
text = text.toLowerCase().trim();
// Moodle answer labels commonly include "a.", "b)", etc.; the
// stored answer value normally contains only the choice text.
text = text.replace(/^[a-z]\s*[\.)]\s+/i, '');
// Normalize Moodle pluginfile image URLs to stable image asset tokens across quiz attempts
text = text.replace(/https?:\/\/[^\/]+\/pluginfile\.php\/\d+\/question\/(?:answer|questiontext|feedback)\/\d+(?:\/\d+)?\/([^?#\s]+)/gi, '[moodle-asset:$1]');
// Normalize unicode dashes/minus signs to standard hyphen
text = text.replace(/[\u2212\u2013\u2014]/g, '-');
// Remove prefix like "select one: ", "select one or more: ", "a. ", "b) " safely without stripping negative signs
// Strip unicode checkmarks/crosses and trailing feedback words
text = text.replace(/[✓✔✗✘✕✖]/g, '');
text = text.replace(/\s*[\(\[]?(?:correct|incorrect)[\)\]]?\s*$/i, '');
text = text.replace(/\s+/g, ' ');
text = text.replace(/[.:?!;,]+$/, '');
text = unscriptDigits(text);
return text.trim();
}
// Normalize and structure eliminated wrong choices with weighting & deduplication
function normalizeWrongAnswers(rawWrong) {
if (!rawWrong || !Array.isArray(rawWrong)) return [];
return rawWrong.map(item => {
if (typeof item === 'string') {
const clean = item.replace(/^[a-zA-Z0-9][.)]\s*/, '').trim();
const norm = normalizeChoice(clean);
if (!norm) return null;
return { text: clean, norm: norm, count: 1, sources: [] };
}
if (typeof item === 'object' && item) {
const txt = (item.text || item.ansRaw || item.choice || '').replace(/^[a-zA-Z0-9][.)]\s*/, '').trim();
const norm = item.norm || normalizeChoice(txt);
if (!norm) return null;
return {
text: txt,
norm: norm,
count: typeof item.count === 'number' ? item.count : (typeof item.weight === 'number' ? item.weight : 1),
sources: Array.isArray(item.sources) ? item.sources : []
};
}
return null;
}).filter(Boolean);
}
// Auto-closes Moodle 4 Course Index Drawer on course view pages by default
function autoCloseCourseIndexDrawer() {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (!window.location.pathname.includes('/course/view.php')) return false;
const autoClose = typeof localStorage !== 'undefined'
? localStorage.getItem('amaes_auto_close_course_index') !== 'false'
: true;
if (!autoClose) return false;
function tryClose() {
const drawer = document.querySelector('#theme_boost-drawers-courseindex, .drawer-left');
const isOpen = (drawer && drawer.classList.contains('show')) ||
document.body.classList.contains('drawer-open-left');
if (!isOpen) return false;
const closeBtn = document.querySelector(
'button[data-action="closedrawer"][data-side="left"], ' +
'button[title*="Close course index" i], ' +
'button[aria-label*="Close course index" i], ' +
'#theme_boost-drawers-courseindex [data-action="closedrawer"], ' +
'.drawer-left [data-action="closedrawer"]'
);
if (closeBtn) {
closeBtn.click();
return true;
}
return false;
}
if (!tryClose()) {
setTimeout(tryClose, 200);
setTimeout(tryClose, 500);
setTimeout(tryClose, 1000);
}
return true;
}
// ==========================================
// AMAUOED Engine (Fetch, Cache & Match)
// ==========================================
// Cross-origin safe HTTP fetch using GM_xmlhttpRequest
function fetchAmauoedPage(url) {
return new Promise((resolve, reject) => {
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gmReq) {
gmReq({
method: "GET",
url: url,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
},
onload: function (res) {
if (res.status >= 200 && res.status < 300) {
resolve(res.responseText);
} else {
reject(new Error(`HTTP ${res.status}: ${res.statusText}`));
}
},
onerror: function (err) {
reject(err);
}
});
} else {
fetch(url)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(resolve)
.catch(reject);
}
});
}
// Extract questions and correct answers from AMAUOED HTML
// Extract questions, choices, distractors, and correct answers from AMAUOED HTML
function parseAmauoedHtml(html) {
const results = [];
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
let cards = Array.from(doc.querySelectorAll('.card.mb-2, .card'));
if (cards.length === 0) {
cards = Array.from(doc.querySelectorAll('.card-body'));
}
cards.forEach(card => {
const qElem = card.querySelector('.mb-2, h5, .card-title, p.card-text');
if (!qElem) return;
const qRaw = qElem.innerText.trim();
const qNorm = normalizeText(qRaw);
if (!qNorm) return;
// Extract all correct answer chips (supports multi-choice questions)
const correctList = [];
const correctChips = card.querySelectorAll('.chip.bg-success, span.bg-success, .badge.bg-success, .badge-success');
if (correctChips.length > 0) {
correctChips.forEach(ch => {
const li = ch.closest('li') || ch.parentElement;
if (li) {
const clone = li.cloneNode(true);
clone.querySelectorAll('.chip, span, .badge').forEach(c => c.remove());
const txt = clone.innerText.trim();
if (txt && !correctList.includes(txt)) {
correctList.push(txt);
}
}
});
} else {
// Fallback: check strong tag inside li
const strongs = card.querySelectorAll('li strong');
strongs.forEach(st => {
const txt = st.innerText.trim();
if (txt && !correctList.includes(txt)) {
correctList.push(txt);
}
});
}
// Extract choices and wrong answers (distractors)
const allChoices = [];
const wrongAnswers = [];
const liElements = card.querySelectorAll('li');
liElements.forEach(li => {
const clone = li.cloneNode(true);
clone.querySelectorAll('.chip, span, .badge').forEach(c => c.remove());
const choiceTxt = clone.innerText.trim();
if (choiceTxt) {
allChoices.push(choiceTxt);
const isCorrect = correctList.some(ans => normalizeChoice(ans) === normalizeChoice(choiceTxt));
if (!isCorrect && !wrongAnswers.includes(choiceTxt)) {
wrongAnswers.push(choiceTxt);
}
}
});
if (correctList.length > 0) {
const ansRaw = correctList.join(', ');
const ansNorm = normalizeChoice(ansRaw);
const entry = {
qRaw,
qNorm,
ansRaw,
ansNorm,
source: 'amauoed',
verified: false,
confirmations: 1,
evidenceType: 'study_guide_candidate'
};
if (correctList.length > 1) {
entry.answers = correctList;
}
if (wrongAnswers.length > 0) {
entry.wrongAnswers = normalizeWrongAnswers(wrongAnswers);
}
if (allChoices.length > 0) {
entry.choices = allChoices;
}
const existingIdx = results.findIndex(r => r.qNorm === qNorm);
if (existingIdx >= 0) {
if (entry.answers && Array.isArray(entry.answers)) {
const curAnswers = results[existingIdx].answers || [results[existingIdx].ansRaw];
results[existingIdx].answers = Array.from(new Set(curAnswers.concat(entry.answers)));
results[existingIdx].ansRaw = results[existingIdx].answers.join(', ');
results[existingIdx].ansNorm = normalizeChoice(results[existingIdx].ansRaw);
}
} else {
results.push(entry);
}
}
});
return results;
}
// Crawl all pages of an AMAUOED course URL
async function loadAllAmauoedAnswers(baseUrl, onProgress) {
// Strip query params to get clean base URL
const cleanBase = baseUrl.split('?')[0];
let allQuestions = [];
let page = 1;
let hasMore = true;
const maxPages = 40; // High limit to cover 500+ question banks
while (hasMore && page <= maxPages) {
const pageUrl = page === 1 ? cleanBase : `${cleanBase}?page=${page}`;
if (onProgress) onProgress(page, allQuestions.length);
try {
logDebug(`Fetching amauoed page ${page}: ${pageUrl}`);
const html = await fetchAmauoedPage(pageUrl);
const questions = parseAmauoedHtml(html).map(question => ({
...question,
sourceUrl: cleanBase
}));
if (questions.length === 0) {
hasMore = false;
break;
}
// Merge and deduplicate questions on append
questions.forEach(newQ => {
const existing = allQuestions.find(q => q.qNorm === newQ.qNorm);
if (!existing) {
allQuestions.push(newQ);
} else if (newQ.answers && Array.isArray(newQ.answers)) {
const curAnswers = existing.answers || [existing.ansRaw];
existing.answers = Array.from(new Set(curAnswers.concat(newQ.answers)));
existing.ansRaw = existing.answers.join(', ');
existing.ansNorm = normalizeChoice(existing.ansRaw);
}
});
// DOM-based pagination check with string fallback
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const nextLink = doc.querySelector(`.pagination .page-item:not(.disabled) a[href*="page="], a[rel="next"], .pagination a.page-link[href*="page=${page + 1}"]`);
const hasNextPage = !!nextLink || html.includes(`page=${page + 1}`) || html.includes(`page=${page + 1}"`);
if (!hasNextPage) {
hasMore = false;
} else {
page++;
// Polite delay between pages
await new Promise(r => setTimeout(r, 400));
}
} catch (err) {
console.error("Error loading page:", err);
hasMore = false;
}
}
return allQuestions;
}
// Automatic Subject Matcher for AMAUOED Course Links
function checkUrlCourseMatch(url, courseInfo) {
if (!url || !url.trim()) {
return {
status: 'empty',
html: 'Paste the amauoed.com link for this subject above.'
};
}
const cleanUrl = url.trim().toLowerCase();
if (!cleanUrl.includes('amauoed.com/courses/')) {
return {
status: 'invalid',
html: 'Invalid link: Must start with https://amauoed.com/courses/...'
};
}
const currentCode = (courseInfo && courseInfo.subjectCode ? courseInfo.subjectCode.toUpperCase() : '');
const currentCodeNum = currentCode.replace(/\D+/g, '');
const currentCodePrefix = currentCode.replace(/\d+/g, '').trim().toLowerCase();
// Extract department and slug from amauoed URL
const match = cleanUrl.match(/\/courses\/([a-z0-9_-]+)\/([^/?#]+)/i);
if (!match) {
return {
status: 'unknown',
html: 'Unable to parse course slug from link.'
};
}
const dept = match[1].toLowerCase();
const slug = match[2].toLowerCase();
const numMatch = slug.match(/(\d{3,4})/);
const urlNum = numMatch ? numMatch[1] : '';
const urlCode = (dept + urlNum).toUpperCase();
const presetUrl = getStoredAmauoedUrl(currentCode);
const numMatches = Boolean(currentCodeNum && urlNum && currentCodeNum === urlNum);
const deptMatches = Boolean(currentCodePrefix && dept && (currentCodePrefix === dept || dept.includes(currentCodePrefix) || currentCodePrefix.includes(dept)));
if (numMatches && deptMatches) {
return {
status: 'match',
html: `Verified Match: Link belongs to ${currentCode}!`
};
}
let fixBtn = '';
if (presetUrl && presetUrl !== cleanUrl) {
fixBtn = ` Switch to ${currentCode} Link`;
}
if (numMatches && !deptMatches) {
return {
status: 'mismatch',
html: `Subject Mismatch: Link is for ${urlCode}, but you are in ${currentCode}!${fixBtn}`
};
}
return {
status: 'mismatch',
html: `Subject Mismatch: Link (${urlCode || slug.substring(0, 16)}) doesn't match ${currentCode || 'current course'}.${fixBtn}`
};
}
// Storage helpers & Known AMAUOED Catalog
const KNOWN_AMAUOED_COURSES = {
'CS6301': 'https://amauoed.com/courses/cs/logic-design-and-digital-computer-circuits-6301-cs',
'ITE6301': 'https://amauoed.com/courses/ite/technopreneurship-6301-ite',
'ITE6300': 'https://amauoed.com/courses/ite/cloud-computing-and-the-internet-of-things-6300-ite',
'ITE6200': 'https://amauoed.com/courses/ite/application-development-and-emerging-technology-6200-ite',
'ITE6201': 'https://amauoed.com/courses/ite/data-structures-and-algorithm-analysis-6201-ite',
'ITE6100': 'https://amauoed.com/courses/ite/introduction-to-computing-6100-ite',
'ITE6102': 'https://amauoed.com/courses/ite/computer-programming-1-6102-ite',
'ITE6104': 'https://amauoed.com/courses/ite/computer-programming-2-6104-ite',
'ITE6220': 'https://amauoed.com/courses/ite/information-management-6220-ite',
'CS6202': 'https://amauoed.com/courses/cs/algorithms-and-complexity-6202-cs',
'CS6204': 'https://amauoed.com/courses/cs/computer-architecture-and-organization-6204-cs',
'CS6205': 'https://amauoed.com/courses/cs/automata-theory-and-formal-language-6205-cs',
'CS6206': 'https://amauoed.com/courses/cs/principles-of-operating-systems-and-its-applications-6206-cs',
'CS6209': 'https://amauoed.com/courses/cs/software-engineering-1-6209-cs',
'CS6300': 'https://amauoed.com/courses/cs/software-engineering-2-6300-cs',
'CS6309': 'https://amauoed.com/courses/cs/introduction-to-machine-learning-6309-cs',
'CS6326': 'https://amauoed.com/courses/cs/mobile-application-development-6326-cs',
'MATH6100': 'https://amauoed.com/courses/math/calculus-1-6100-math',
'GE6107': 'https://amauoed.com/courses/ge/ethics-6107-ge',
'GE6115': 'https://amauoed.com/courses/ge/art-appreciation-6115-ge',
'ETHNS6101': 'https://amauoed.com/courses/ethns/euthenics-1-6101-ethns',
'ETHNS6102': 'https://amauoed.com/courses/ethns/euthenics-2-6102-ethns'
};
function getStoredAmauoedUrl(code) {
if (!code) return "";
const clean = code.toUpperCase().trim();
const stored = localStorage.getItem(`amaes_amauoed_url_${clean}`);
if (stored) return stored;
if (KNOWN_AMAUOED_COURSES[clean]) {
return KNOWN_AMAUOED_COURSES[clean];
}
// Check dynamic directory cache if populated
try {
const dir = JSON.parse(localStorage.getItem('amaes_known_amauoed_directory') || '{}');
if (dir[clean]) return dir[clean];
} catch (e) {}
const num = clean.replace(/\D+/g, '');
for (const [k, url] of Object.entries(KNOWN_AMAUOED_COURSES)) {
if (num && k.includes(num)) {
if (clean.includes('CS') && k.includes('CS')) return url;
if (clean.includes('ITE') && k.includes('ITE')) return url;
}
}
return "";
}
function setStoredAmauoedUrl(code, url) {
localStorage.setItem(`amaes_amauoed_url_${code}`, url);
}
// Auto-Search & Discover AMAUOED Link from Online Directory with DOM Card Parsing & 5-Tier Matching
async function autoFindAmauoedLink(code, courseTitle = '') {
if (!code) {
setLog('No subject code detected to search', 'var(--accent-pink)', 'Plan: Open a course or quiz page first');
return null;
}
const cleanCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '').trim();
const codeNum = cleanCode.replace(/\D+/g, '');
const codeDept = cleanCode.replace(/\d+/g, '').toUpperCase();
setLog(`Searching amauoed.com catalog for ${cleanCode}...`, 'var(--accent-blue)', 'Plan: Discover course directory and harvest question bank');
// Check stored or built-in first
const direct = getStoredAmauoedUrl(cleanCode);
if (direct) {
setStoredAmauoedUrl(cleanCode, direct);
setLog(`Auto-matched known link for ${cleanCode}!`, 'var(--accent-green)', 'Plan: Ready to fetch and sync question database');
return direct;
}
// Helper to match catalog entries
function matchInCatalog(courses) {
if (!Array.isArray(courses) || courses.length === 0) return null;
// Tier 1: Exact code match (e.g. CS6202 matches CS-6202 or CS6202)
const exact = courses.find(c => c.cleanCode === cleanCode || c.rawCode === cleanCode);
if (exact) return exact.url;
// Tier 2: Dept + Number exact match
if (codeDept && codeNum) {
const deptNum = courses.find(c => c.dept === codeDept && c.num === codeNum);
if (deptNum) return deptNum.url;
}
// Tier 3: Dept alias match + Number match
const ALIAS_MAP = {
'IT': ['ITE', 'IT'],
'ITE': ['IT', 'ITE'],
'CS': ['COMP', 'CSC', 'CS'],
'COMP': ['CS', 'COMP'],
'MATH': ['MTH', 'MATH'],
'MTH': ['MATH', 'MTH'],
'ACTG': ['ACC', 'IA', 'ACTG'],
'ACC': ['ACTG', 'ACC'],
'IA': ['ACTG', 'IA'],
'ENG': ['ENGL', 'ENG'],
'ENGL': ['ENG', 'ENGL'],
'PED': ['PE', 'PATHFIT', 'PED'],
'PE': ['PED', 'PATHFIT', 'PE'],
'PATHFIT': ['PE', 'PED', 'PATHFIT'],
'GE': ['GEC', 'GE'],
'GEC': ['GE', 'GEC'],
'NSTP': ['CWTS', 'NSTP'],
'CWTS': ['NSTP', 'CWTS']
};
const aliases = ALIAS_MAP[codeDept] || [codeDept];
if (codeNum) {
const aliasMatch = courses.find(c => aliases.includes(c.dept) && c.num === codeNum);
if (aliasMatch) return aliasMatch.url;
}
// Tier 4: Unique 4-digit number match (if exactly 1 course in catalog has this number)
if (codeNum && codeNum.length >= 3) {
const numMatches = courses.filter(c => c.num === codeNum);
if (numMatches.length === 1) return numMatches[0].url;
}
// Tier 5: Course title keyword overlap
if (courseTitle) {
const queryWords = courseTitle.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter(w => w.length > 3 && !['college', 'university', 'education', 'online', 'bachelor', 'science'].includes(w));
if (queryWords.length >= 2) {
let bestMatch = null;
let bestHits = 0;
courses.forEach(c => {
const titleLower = (c.title || '').toLowerCase();
const hits = queryWords.filter(w => titleLower.includes(w)).length;
if (hits > bestHits && hits >= 2) {
bestHits = hits;
bestMatch = c;
}
});
if (bestMatch && (bestHits >= queryWords.length * 0.5 || bestHits >= 2)) {
return bestMatch.url;
}
}
}
return null;
}
// Check local catalog cache (valid for 24 hours)
try {
const rawCat = localStorage.getItem('amaes_amauoed_catalog_v2');
if (rawCat) {
const parsedCat = JSON.parse(rawCat);
if (parsedCat && Array.isArray(parsedCat.courses) && (Date.now() - parsedCat.timestamp < 86400000)) {
const found = matchInCatalog(parsedCat.courses);
if (found) {
setStoredAmauoedUrl(cleanCode, found);
setLog(`Auto-matched link for ${cleanCode} from catalog!`, 'var(--accent-green)', 'Plan: Ready to fetch and sync question database');
return found;
}
}
}
} catch (e) {
logDebug(`Catalog cache check error: ${e.message}`);
}
// Live crawl of amauoed.com/courses directory
try {
const html = await fetchAmauoedPage('https://amauoed.com/courses');
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const parsedCourses = [];
const linkElements = doc.querySelectorAll('a[href*="/courses/"]');
linkElements.forEach(a => {
let href = a.getAttribute('href') || '';
if (!href) return;
if (href.startsWith('/')) href = 'https://amauoed.com' + href;
if (!href.includes('/courses/')) return;
const card = a.closest('.card') || a.parentElement || a;
const titleElem = card.querySelector('.card-title') || a;
const subElem = card.querySelector('.card-subtitle');
const title = (titleElem ? titleElem.innerText : a.innerText || '').trim();
const subtitle = subElem ? subElem.innerText.trim() : '';
// Extract code from subtitle (e.g. "CS-6202 - 107 answers") or slug
let rawCode = '';
const subMatch = subtitle.match(/([A-Za-z]+[- ]?\d{3,4})/);
if (subMatch) {
rawCode = subMatch[1];
} else {
const slug = href.split('/').pop() || '';
const slugMatch = slug.match(/([a-zA-Z]+[- ]?\d{3,4}|\d{3,4}[- ]?[a-zA-Z]+)/);
if (slugMatch) rawCode = slugMatch[1];
}
const clean = rawCode.toUpperCase().replace(/[^A-Z0-9]/g, '');
const num = clean.replace(/\D+/g, '');
const dept = clean.replace(/\d+/g, '').toUpperCase();
if (href && !parsedCourses.some(c => c.url === href)) {
parsedCourses.push({
url: href,
title,
subtitle,
rawCode,
cleanCode: clean,
dept,
num
});
}
});
// Save refreshed catalog to cache
if (parsedCourses.length > 0) {
localStorage.setItem('amaes_amauoed_catalog_v2', JSON.stringify({
timestamp: Date.now(),
courses: parsedCourses
}));
}
const match = matchInCatalog(parsedCourses);
if (match) {
setStoredAmauoedUrl(cleanCode, match);
setLog(`Found & verified link for ${cleanCode}!`, 'var(--accent-green)', 'Plan: Saved to database. Ready to fetch.');
return match;
} else {
setLog(`No exact amauoed link found for ${cleanCode}`, 'var(--accent-amber)', 'Plan: Paste link manually or check Google');
return null;
}
} catch (err) {
console.error('AMAUOED auto-find error:', err);
setLog(`Auto-search failed: Network error`, 'var(--accent-pink)', 'Plan: Check internet connection or enter link manually');
return null;
}
}
function getCachedAnswers(code) {
const sharingDisabled = ANSWER_SHARING_DISABLED_COURSES.has(String(code || '').toUpperCase());
const raw = localStorage.getItem(`amaes_amauoed_cache_${code}`);
if (!raw) return null;
try {
const storedSchema = Number(localStorage.getItem(`amaes_cache_schema_${code}`) || 1);
if (Number.isFinite(storedSchema) && storedSchema > ANSWER_DB_SCHEMA_VERSION) {
logDebug(`Ignoring newer answer cache schema ${storedSchema} for ${code}; current schema is ${ANSWER_DB_SCHEMA_VERSION}.`);
return null;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
logDebug(`Ignoring incompatible answer cache for ${code}.`);
return null;
}
let normalized = parsed
.filter(item => item && typeof item === 'object')
.map(item => ({
...item,
qNorm: item.qNorm || normalizeText(item.qRaw || item.question || '')
}))
.filter(item => Boolean(item.qNorm));
if (sharingDisabled) {
normalized = normalized.filter(item => item.verified !== true &&
item.evidenceType === 'study_guide_candidate' &&
/amauoed|jennysonline/i.test(String(item.source || '')));
localStorage.setItem(`amaes_amauoed_cache_${code}`, JSON.stringify(normalized));
}
if (storedSchema < ANSWER_DB_SCHEMA_VERSION) {
localStorage.setItem(`amaes_cache_schema_${code}`, String(ANSWER_DB_SCHEMA_VERSION));
}
return normalized;
} catch (e) {
logDebug(`Ignoring unreadable answer cache for ${code}.`);
return null;
}
}
function setCachedAnswers(code, questions) {
if (!Array.isArray(questions)) {
throw new TypeError('Answer cache must be an array');
}
if (ANSWER_SHARING_DISABLED_COURSES.has(String(code || '').toUpperCase())) {
questions = questions.filter(item => item && item.verified !== true &&
item.evidenceType === 'study_guide_candidate' &&
/amauoed|jennysonline/i.test(String(item.source || '')));
}
localStorage.setItem(`amaes_amauoed_cache_${code}`, JSON.stringify(questions));
localStorage.setItem(`amaes_cache_schema_${code}`, String(ANSWER_DB_SCHEMA_VERSION));
}
// ==========================================
// Jenny's Online public study-guide sources
// ==========================================
const jennysonlineSessionCache = new Map();
const JENNYSONLINE_CACHE_TTL = 30 * 24 * 60 * 60 * 1000;
const JENNYSONLINE_SESSION_RECHECK_TTL = 2 * 60 * 1000;
function getJennysonlineCacheKey(code) {
return `amaes_jennysonline_cache_v1_${code}`;
}
function readJennysonlinePersistentCache(code) {
try {
const raw = localStorage.getItem(getJennysonlineCacheKey(code));
if (!raw) return null;
const cached = JSON.parse(raw);
if (!cached || !Number.isFinite(cached.savedAt) || Date.now() - cached.savedAt < 0 ||
Date.now() - cached.savedAt >= JENNYSONLINE_CACHE_TTL ||
!Array.isArray(cached.answers) || cached.answers.length > 5000 ||
cached.answers.some(answer => !answer || answer.verified !== false ||
answer.source !== 'jennysonline' || answer.evidenceType !== 'study_guide_candidate')) {
localStorage.removeItem(getJennysonlineCacheKey(code));
return null;
}
return cached.answers;
} catch (error) {
if (typeof logDebug === 'function') logDebug(`Jenny's Online local cache read note: ${error.message}`);
return null;
}
}
function writeJennysonlinePersistentCache(code, answers, savedAt = Date.now()) {
try {
localStorage.setItem(getJennysonlineCacheKey(code), JSON.stringify({
savedAt,
answers
}));
} catch (error) {
if (typeof logDebug === 'function') logDebug(`Jenny's Online local cache write note: ${error.message}`);
}
}
function queueStudyGuideRefresh(code) {
try {
const key = `amaes_study_guide_refresh_request_v1_${code}`;
const requestedAt = Number(localStorage.getItem(key) || 0);
if (Date.now() - requestedAt < 15 * 60 * 1000) return;
localStorage.setItem(key, String(Date.now()));
fetch(`${communityRelayUrl}/study-guides/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Installation': getAnonymousContributorId(),
'X-AMAES-Client-Version': CLIENT_VERSION
},
body: JSON.stringify({ subjectCode: code })
}).then(async response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
logDebug(`Shared study-guide refresh for ${code}: ${result.queued ? 'queued' : 'not queued'}`);
}).catch(error => {
localStorage.removeItem(key);
logDebug(`Shared study-guide refresh queue note for ${code}: ${error.message}`);
});
} catch (error) {
logDebug(`Shared study-guide refresh queue note for ${code}: ${error.message}`);
}
}
function fetchJennysonlineText(url) {
return new Promise((resolve, reject) => {
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (!gmReq) {
fetch(url).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
}).then(resolve).catch(reject);
return;
}
gmReq({
method: 'GET',
url,
onload: response => {
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText);
} else {
reject(new Error(`HTTP ${response.status}: ${response.statusText || 'Request failed'}`));
}
},
onerror: () => reject(new Error(`Network error fetching ${new URL(url).hostname}`))
});
});
}
function parseJennysonlineSharedSnapshot(registry) {
if (!Array.isArray(registry.questions) || registry.questions.length === 0) return [];
return registry.questions
.filter(question => question && question.verified === false)
.map(question => {
const qRaw = question.question || question.qRaw || '';
const ansRaw = question.answer || question.ansRaw || '';
return {
qRaw,
qNorm: normalizeText(qRaw),
ansRaw,
ansNorm: normalizeChoice(ansRaw),
answers: Array.isArray(question.answers) ? question.answers : undefined,
choices: Array.isArray(question.choices) ? question.choices : [],
verified: false,
confirmations: 1,
source: 'jennysonline',
evidenceType: 'study_guide_candidate',
sourceUrl: question.sourceUrl || registry.sourceUrl
};
})
.filter(question => question.qNorm && question.ansNorm);
}
async function loadJennysonlineAnswersForCourse(subjectCode) {
const code = String(subjectCode || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (!code || code === 'GENERAL' || code === 'DEFAULT') return [];
const sessionEntry = jennysonlineSessionCache.get(code);
if (sessionEntry) {
if (typeof sessionEntry.then === 'function') return sessionEntry;
if (sessionEntry.expiresAt > Date.now()) return sessionEntry.answers;
jennysonlineSessionCache.delete(code);
}
const cachedAnswers = readJennysonlinePersistentCache(code);
const loading = (async () => {
let staleSharedAnswers = [];
try {
const registryUrl = `${CLOUD_DB_JENNYSONLINE_URL}${code}.json`;
const registry = JSON.parse(await fetchJennysonlineText(registryUrl));
if (registry.subjectCode !== code || registry.verified !== false) {
throw new Error(`Invalid or unverified Jenny's Online snapshot for ${code}`);
}
const sharedAnswers = parseJennysonlineSharedSnapshot(registry);
if (sharedAnswers.length > 0) {
staleSharedAnswers = sharedAnswers;
const updatedAt = Date.parse(registry.refreshedAt || registry.updatedAt || '');
const age = Date.now() - updatedAt;
if (Number.isFinite(updatedAt) && age >= 0 && age < JENNYSONLINE_CACHE_TTL) {
writeJennysonlinePersistentCache(code, sharedAnswers, updatedAt);
return {
answers: sharedAnswers,
expiresAt: updatedAt + JENNYSONLINE_CACHE_TTL
};
}
}
} catch (error) {
if (!/HTTP 404/.test(error.message)) {
logDebug(`Jenny's Online shared snapshot note for ${code}: ${error.message}`);
}
}
queueStudyGuideRefresh(code);
const fallbackAnswers = staleSharedAnswers.length > 0
? staleSharedAnswers
: cachedAnswers || [];
return {
answers: fallbackAnswers,
expiresAt: Date.now() + JENNYSONLINE_SESSION_RECHECK_TTL
};
})();
const pending = loading.then(result => {
jennysonlineSessionCache.set(code, result);
return result.answers;
}).catch(error => {
jennysonlineSessionCache.delete(code);
throw error;
});
jennysonlineSessionCache.set(code, pending);
return pending;
}
// ==========================================
// Robust Quiz Navigation & Autonomous Solver Engine
// ==========================================
// Inspect Moodle Quiz Navigation block to detect answered vs unanswered questions
function getQuizNavQuestionStates() {
const navBlock = document.querySelector('#mod_quiz_navblock, .block_quiz_navigation, .qn_buttons');
if (!navBlock) return null;
const buttons = Array.from(navBlock.querySelectorAll('.qnbutton, a[id^="quiznavbutton"], button[id^="quiznavbutton"]'));
if (buttons.length === 0) return null;
const questionList = [];
let currentIndex = -1;
const thisPageIndices = [];
buttons.forEach((btn, idx) => {
const isCurrent = btn.classList.contains('thispage') || btn.getAttribute('aria-current') === 'true';
if (isCurrent) thisPageIndices.push(idx);
// In Moodle, answered questions have class .answersaved, or title/aria-label containing "Answer saved"
const title = (btn.getAttribute('title') || '').toLowerCase();
const ariaLabel = (btn.getAttribute('aria-label') || '').toLowerCase();
const hasAnswerSavedClass = btn.classList.contains('answersaved');
const isAnswered = hasAnswerSavedClass || title.includes('answer saved') || ariaLabel.includes('answer saved');
const qNum = parseInt(btn.innerText.trim(), 10) || (idx + 1);
questionList.push({
btn,
qNum,
index: idx,
isCurrent: false,
isAnswered
});
});
if (thisPageIndices.length === 1) {
currentIndex = thisPageIndices[0];
} else if (thisPageIndices.length > 1) {
// Multi-question page: resolve active question card on screen
let activeQue = userSelectedActiveQuestion;
if (!activeQue || !document.contains(activeQue)) {
activeQue = document.querySelector('.que.amaes-active-focus-que');
}
if (!activeQue) {
const blockedQue = document.querySelector('.que .amaes-blockage-hud')?.closest('.que');
if (blockedQue) activeQue = blockedQue;
}
if (activeQue) {
const qData = extractQuestionData(activeQue);
const qNum = qData ? qData.qNum : null;
if (qNum) {
const matchIdx = questionList.findIndex(q => q.qNum === qNum);
if (matchIdx >= 0) currentIndex = matchIdx;
}
}
// Fallback: first unanswered question among this page's questions, or the first question on this page
if (currentIndex < 0) {
const firstUnanswered = thisPageIndices.find(idx => !questionList[idx].isAnswered);
currentIndex = (firstUnanswered !== undefined) ? firstUnanswered : thisPageIndices[0];
}
} else if (questionList.length > 0) {
currentIndex = 0;
}
if (currentIndex >= 0 && currentIndex < questionList.length) {
questionList[currentIndex].isCurrent = true;
}
// Find the next UNANSWERED question AFTER the current question
let nextUnanswered = null;
if (currentIndex >= 0) {
for (let i = currentIndex + 1; i < questionList.length; i++) {
if (!questionList[i].isAnswered) {
nextUnanswered = questionList[i];
break;
}
}
// If none ahead, wrap around to check earlier skipped questions
if (!nextUnanswered) {
for (let i = 0; i < currentIndex; i++) {
if (!questionList[i].isAnswered) {
nextUnanswered = questionList[i];
break;
}
}
}
}
const totalUnanswered = questionList.filter(q => !q.isAnswered).length;
const totalAnswered = questionList.length - totalUnanswered;
return {
navBlock,
questions: questionList,
currentIndex,
nextUnanswered,
totalUnanswered,
totalAnswered,
totalQuestions: questionList.length,
allAnswered: totalUnanswered === 0
};
}
// Helper to find the Next Page button on Moodle Quiz Attempt page
function findQuizNextButton() {
// 1. Primary: Next button inside Moodle's submitbtns or responseform
let btn = document.querySelector(
'#responseform .submitbtns input[name="next"], ' +
'#responseform .submitbtns button[name="next"], ' +
'#responseform .submitbtns input[type="submit"]:not([name="previous"]):not([value*="Previous"]), ' +
'#responseform .submitbtns button:not([name="previous"]):not(.btn-secondary), ' +
'.submitbtns input[name="next"], ' +
'.submitbtns button[name="next"], ' +
'.submitbtns input[value*="Next"], ' +
'.submitbtns button[value*="Next"], ' +
'input[name="next"].mod_quiz-next-nav, ' +
'#mod_quiz-next-nav, ' +
'.mod_quiz-next-nav'
);
// 2. If on the final question, look for "Finish attempt ..."
if (!btn) {
btn = document.querySelector(
'#responseform .submitbtns input[value*="Finish attempt"], ' +
'#responseform .submitbtns button[value*="Finish attempt"], ' +
'.submitbtns input[value*="Finish attempt"], ' +
'.submitbtns button[value*="Finish attempt"], ' +
'input[value*="Finish attempt"], ' +
'button[value*="Finish attempt"], ' +
'input[value*="Finish"], ' +
'button[value*="Finish"]'
);
}
// 3. Fallback: Any submit button inside responseform that does not navigate backwards
if (!btn) {
const submits = document.querySelectorAll('#responseform input[type="submit"], #responseform button[type="submit"]');
for (const s of submits) {
const val = (s.value || s.innerText || '').toLowerCase();
if (!val.includes('prev') && !val.includes('back')) {
btn = s;
break;
}
}
}
return btn;
}
// Helper to find the Previous Page button on Moodle Quiz Attempt page
function findQuizPrevButton() {
let btn = document.querySelector(
'#responseform .submitbtns input[name="previous"], ' +
'#responseform .submitbtns button[name="previous"], ' +
'#responseform .submitbtns input[value*="Previous page"], ' +
'#responseform .submitbtns button[value*="Previous page"], ' +
'#responseform .submitbtns input[value*="Previous"], ' +
'#responseform .submitbtns button[value*="Previous"], ' +
'.submitbtns input[name="previous"], ' +
'.submitbtns button[name="previous"], ' +
'.submitbtns input[value*="Previous page"], ' +
'.submitbtns button[value*="Previous page"], ' +
'.submitbtns input[value*="Previous"], ' +
'.submitbtns button[value*="Previous"], ' +
'input[name="previous"].mod_quiz-prev-nav, ' +
'#mod_quiz-prev-nav, ' +
'.mod_quiz-prev-nav'
);
if (!btn) {
const submits = document.querySelectorAll('#responseform input[type="submit"], #responseform button[type="submit"]');
for (const s of submits) {
const val = (s.value || s.innerText || '').toLowerCase();
if (val.includes('prev') || val.includes('back')) {
btn = s;
break;
}
}
}
return btn;
}
// Active Question State & Visual Focus Tracking
let userSelectedActiveQuestion = null;
let userActiveLockUntil = 0;
function setActiveQuestion(que, userClicked = false) {
if (!que) return;
userSelectedActiveQuestion = que;
if (userClicked) {
userActiveLockUntil = Date.now() + 8000;
}
document.querySelectorAll('.que').forEach(q => {
if (q === que) {
q.classList.add('amaes-active-focus-que');
} else {
q.classList.remove('amaes-active-focus-que');
}
});
}
// Resolve the question currently in viewport, active, or first unanswered on multi-question pages
function getActiveViewportQuestion() {
const questions = Array.from(document.querySelectorAll('.que'));
if (questions.length === 0) return null;
if (questions.length === 1) {
setActiveQuestion(questions[0], false);
return questions[0];
}
// If user explicitly clicked a question and it is still visible within/near viewport
if (userSelectedActiveQuestion && document.contains(userSelectedActiveQuestion)) {
const rect = userSelectedActiveQuestion.getBoundingClientRect();
const vh = window.innerHeight || 800;
if (rect.bottom > 60 && rect.top < vh - 60) {
setActiveQuestion(userSelectedActiveQuestion, false);
return userSelectedActiveQuestion;
}
}
const viewportHeight = window.innerHeight || 800;
let bestQue = null;
let bestScore = -1;
for (const q of questions) {
const rect = q.getBoundingClientRect();
// Discard if element is completely scrolled out of viewport
if (rect.bottom < 40 || rect.top > viewportHeight - 40) continue;
const visibleTop = Math.max(0, rect.top);
const visibleBottom = Math.min(viewportHeight, rect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight <= 0) continue;
// Prioritize questions whose header or body is in the primary reading band (-80px to 60% viewport)
let score = visibleHeight;
if (rect.top >= -80 && rect.top <= viewportHeight * 0.6) {
score += 600;
}
if (score > bestScore) {
bestScore = score;
bestQue = q;
}
}
if (!bestQue) {
bestQue = questions.find(q => !q.classList.contains('answered') && !q.classList.contains('complete')) || questions[0];
}
if (bestQue) {
setActiveQuestion(bestQue, false);
}
return bestQue;
}
// Check whether every question on the current attempt page has an answer selected/entered
function areAllPageQuestionsAnswered() {
const queList = document.querySelectorAll('.que');
if (queList.length === 0) return false;
for (const que of queList) {
// 1. Radio: check if any radio in question is checked
const radios = que.querySelectorAll('.answer input[type="radio"]');
if (radios.length > 0) {
const anyChecked = Array.from(radios).some(r => r.checked);
if (!anyChecked) return false;
continue;
}
// 2. Checkbox: multi-choice questions (require all verified highlighted choices to be checked)
const checkboxes = que.querySelectorAll('.answer input[type="checkbox"]');
if (checkboxes.length > 0) {
const highlightedBoxes = que.querySelectorAll('.amaes-highlighted-choice input[type="checkbox"]');
if (highlightedBoxes.length > 0) {
const allVerifiedChecked = Array.from(highlightedBoxes).every(c => c.checked);
if (!allVerifiedChecked) return false;
} else {
const anyChecked = Array.from(checkboxes).some(c => c.checked);
if (!anyChecked) return false;
}
continue;
}
// 3. Select dropdowns (matching questions)
const selects = que.querySelectorAll('select');
if (selects.length > 0) {
const allSelected = Array.from(selects).every(s => s.value && s.value !== '0' && s.value !== '');
if (!allSelected) return false;
continue;
}
// 4. Text input / textarea (shortanswer questions)
const textInputs = que.querySelectorAll('input[type="text"], input.form-control, textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="reset"])');
if (textInputs.length > 0) {
const allFilled = Array.from(textInputs).every(inp => inp.value && inp.value.trim().length > 0);
if (!allFilled) return false;
continue;
}
// 5. Drag and Drop questions (ddwtos, ddimageortext, ddmarker)
const dropZones = que.querySelectorAll('.drop, .dropzone, span.droptarget, .droppable');
const placeInputs = que.querySelectorAll('input.placeinput, input[type="hidden"][name*="_p"]');
if (dropZones.length > 0 || placeInputs.length > 0) {
if (placeInputs.length > 0) {
const allPlaced = Array.from(placeInputs).every(inp => inp.value && inp.value !== '0' && inp.value.trim() !== '');
if (!allPlaced) return false;
} else {
const allFilled = Array.from(dropZones).every(dz => dz.innerText && dz.innerText.trim().length > 0);
if (!allFilled) return false;
}
continue;
}
// 6. Moodle state classes
if (que.classList.contains('answered') || que.classList.contains('complete')) {
continue;
}
// Fallback: unrecognized question type with no checked answer
return false;
}
return true;
}
function isQuestionAnswered(que) {
if (!que) return false;
let answered = false;
const radios = que.querySelectorAll('.answer input[type="radio"]');
if (radios.length > 0) {
answered = Array.from(radios).some(input => input.checked);
} else {
const checkboxes = que.querySelectorAll('.answer input[type="checkbox"]');
if (checkboxes.length > 0) {
const highlighted = que.querySelectorAll('.amaes-highlighted-choice input[type="checkbox"]');
answered = highlighted.length > 0
? Array.from(highlighted).every(input => input.checked)
: Array.from(checkboxes).some(input => input.checked);
} else {
const selects = que.querySelectorAll('select');
if (selects.length > 0) {
answered = Array.from(selects).every(select => select.value && select.value !== '0');
} else {
const textInputs = que.querySelectorAll(
'input[type="text"], input.form-control, textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="reset"])'
);
if (textInputs.length > 0) {
answered = Array.from(textInputs).every(input => input.value && input.value.trim());
} else {
const placeInputs = que.querySelectorAll('input.placeinput, input[type="hidden"][name*="_p"]');
const dropZones = que.querySelectorAll('.drop, .dropzone, span.droptarget, .droppable');
if (placeInputs.length > 0 || dropZones.length > 0) {
answered = placeInputs.length > 0 &&
Array.from(placeInputs).every(input => input.value && input.value !== '0' && input.value.trim());
} else {
const anyInputs = que.querySelectorAll('input:not([type="hidden"]), select, textarea');
if (anyInputs.length > 0) {
const hasChecked = Array.from(anyInputs).some(i => (i.type === 'radio' || i.type === 'checkbox') && i.checked);
const hasValue = Array.from(anyInputs).some(i => i.type !== 'radio' && i.type !== 'checkbox' && i.value && i.value.trim() && i.value !== '0');
answered = Boolean(hasChecked || hasValue);
} else {
answered = que.classList.contains('answered') || que.classList.contains('complete');
}
}
}
}
}
}
que.dataset.answered = answered ? 'true' : 'false';
return answered;
}
function findNextUnansweredOnCurrentPage(sourceQue) {
const questions = Array.from(document.querySelectorAll('.que'));
if (questions.length < 2) return null;
const startIndex = sourceQue ? questions.indexOf(sourceQue) : -1;
const ordered = startIndex >= 0
? questions.slice(startIndex + 1).concat(questions.slice(0, startIndex))
: questions;
return ordered.find(que => !isQuestionAnswered(que)) || null;
}
function findPreviousQuestionOnCurrentPage(sourceQue) {
const questions = Array.from(document.querySelectorAll('.que'));
if (questions.length < 2) return null;
const currentIndex = sourceQue ? questions.indexOf(sourceQue) : -1;
if (currentIndex > 0) {
return questions[currentIndex - 1];
}
return null;
}
function findFirstUnansweredOnCurrentPage() {
const questions = Array.from(document.querySelectorAll('.que'));
return questions.find(que => !isQuestionAnswered(que)) || null;
}
function isConfirmedCandidate(item) {
if (!item) return false;
const src = String(item.source || '').toLowerCase();
const evType = String(item.evidenceType || '').toLowerCase();
const trustedSource = [
'review_screen',
'moodle_review',
'moodle_review_verified',
'moodle_100_percent',
'verified_db'
].includes(src);
const trustedEvidence = ['moodle_review', '100_percent', 'moodle_100_percent', 'moodle_review_elimination'].includes(evType);
const unconfirmedSource = /community|gemini|(^|[^a-z])ai([^a-z]|$)|amauoed|jennysonline/.test(src);
// Unconfirmed source labels are overridden only by explicit Moodle evidence.
if ((unconfirmedSource || item.isAiSuggestion) && !trustedEvidence) {
return false;
}
if (item.verified !== true) return false;
// Only exact, traceable evidence labels qualify. A low/high overall
// score or a generic "deduced" flag does not prove an answer.
return trustedSource || trustedEvidence;
}
function getStudyGuideInfo(item) {
const sources = [item && item.source, ...(Array.isArray(item && item.sources) ? item.sources : [])]
.map(source => String(source || '').toLowerCase())
.join(' ');
if (sources.includes('jennysonline') || sources.includes("jenny's online")) {
return {
label: "Jenny's Online",
url: item.sourceUrl || 'https://jennysonline.blogspot.com/2021/03/information-assurance-and-security-1.html'
};
}
if (sources.includes('amauoed')) {
const subCode = detectCourseInfo().subjectCode || 'CS6301';
return {
label: 'AMAUOED',
url: getStoredAmauoedUrl(subCode) || 'https://amauoed.com/courses'
};
}
return null;
}
function setQuestionStudyGuideTag(que, label) {
if (!que) return;
que.querySelectorAll('.amaes-study-guide-question-tag').forEach(el => el.remove());
const formulation = que.querySelector('.formulation, .content') || que;
const tag = document.createElement('div');
tag.className = 'amaes-study-guide-question-tag';
tag.style.cssText = `
display: inline-flex;
align-items: center;
margin-bottom: 8px;
padding: 4px 10px;
background: rgba(2, 132, 199, 0.09);
border: 1px solid rgba(2, 132, 199, 0.32);
border-left: 3px solid #0284c7;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 11px;
line-height: 1.35;
box-sizing: border-box;
`;
tag.title = `This answer matches an unverified ${label} study-guide suggestion. Review before submitting.`;
tag.innerHTML = `
STUDY-GUIDE MATCHSuggested by ${escapeHtml(label)}(Unverified — review before submitting)
`;
const qtextElem = que.querySelector('.qtext, .formulation .qtext');
if (qtextElem && qtextElem.parentNode === formulation) {
formulation.insertBefore(tag, qtextElem);
} else {
formulation.insertBefore(tag, formulation.firstChild);
}
}
// Contextual in-question action chip guiding students when unsure or stuck
function renderUnsureActionChip(que) {
if (!que || que.querySelector('.amaes-unsure-help-chip') || que.querySelector('.amaes-blockage-hud') || que.querySelector('.amaes-new-question-focus-banner') || checkIsReviewPage()) return;
const formulation = que.querySelector('.formulation, .content') || que;
const chip = document.createElement('div');
chip.className = 'amaes-unsure-help-chip';
chip.style.cssText = `
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 8px;
margin-bottom: 6px;
padding: 6px 12px;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.25);
border-radius: 6px;
font-size: 11px;
color: var(--accent-blue, #3b82f6);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
`;
chip.title = 'Click to skip to the next question or page';
chip.innerHTML = `
${ICONS.lightbulb}Unsure? Pick an answer or press N to skip to next questionSkip →
`;
chip.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof triggerNextQuizPage === 'function') {
triggerNextQuizPage();
} else {
const nextBtn = document.querySelector('.submitbtns input[value*="Next"], input[value*="Next page"], button[value*="Next"]');
if (nextBtn) nextBtn.click();
}
};
formulation.appendChild(chip);
}
// Connects keyboard shortcut [N] directly to the Moodle Next Page button on screen
// Connects keyboard shortcut [N] directly inside Moodle's native Next Page button on screen
function updateNextButtonIndicator() {
if (!checkIsQuizAttemptPage()) return;
const nextBtn = findQuizNextButton() || document.querySelector('.submitbtns input[type="submit"], .submitbtns button[type="submit"], input[value*="Next page"], input[value*="Next"], button[value*="Next"]');
if (!nextBtn) return;
// Unify text directly into Moodle's native button HTML / label
if (!nextBtn.dataset.amaesOriginalText) {
nextBtn.dataset.amaesOriginalText = nextBtn.tagName === 'INPUT' ? nextBtn.value : nextBtn.textContent;
}
const orig = nextBtn.dataset.amaesOriginalText || 'Next page';
if (!orig.includes('(Press N)')) {
const unifiedText = `${orig.trim()} (Press N)`;
if (nextBtn.tagName === 'INPUT') {
nextBtn.value = unifiedText;
} else {
nextBtn.textContent = unifiedText;
}
}
// Clean up standalone beacon to unify directly into Moodle button
document.getElementById('amaes-next-btn-beacon')?.remove();
}
// High-visibility focused banner for unanswered questions: clear, non-technical guidance and 1-click actions
function renderNewQuestionFocusBanner(que, qNum) {
if (!que || checkIsReviewPage()) return;
// Clean up any stale hints/chips on this question to guarantee a single banner
que.querySelectorAll('.amaes-unanswered-hint, .amaes-unsure-help-chip, .amaes-ai-fallback-bar, .amaes-new-question-focus-banner, .amaes-blockage-hud').forEach(el => el.remove());
const formulation = que.querySelector('.formulation, .content') || que;
const banner = document.createElement('div');
banner.className = 'amaes-new-question-focus-banner';
banner.style.cssText = `
margin-bottom: 12px;
padding: 10px 14px;
background: #fffbeb;
border: 1.5px solid #f59e0b;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.1);
box-sizing: border-box;
color: #78350f;
transition: all 0.2s ease;
`;
const isMatchBanner = que && ((typeof identifyQuestionType === 'function' && identifyQuestionType(que) === 'match') ||
que.classList.contains('match') ||
que.classList.contains('qtype_match') ||
que.querySelectorAll('select').length > 1);
banner.innerHTML = `
NO SAVED ANSWERQuestion #${qNum || ''}: No saved answer yet
${isMatchBanner ? '' : 'Question copied'}
${isMatchBanner ?
'No saved answers for these matching items yet. Select the matching choices below, or consult Web AI tools above.' :
'Question copied to clipboard! Pick an answer below or click Next Question, or use the AI tools at the side or bottom.'
}
No answer known to the system yet — be the first to answer and share it!
Review is not permitted for the previous attempt (${summary.percentage}% overall)
This score does not identify which individual answers were correct. Your first attempt is only the available baseline, not proof of which answers were right; a retry may score lower. Check the grading method and attempt limit before retrying.${isHighestGrade ? ' Moodle currently lists Highest grade for this quiz.' : ''}
Your latest attempt scored below the 80% passing grade. If the quiz has a review link above, open the review so the toolkit can harvest confirmed wrong answers. If answers were missing or incorrect, please report them below.
`;
} else {
warning.remove();
return;
}
const reportBtn = warning.querySelector('#amaes-btn-warning-report-bug');
if (reportBtn) {
reportBtn.onclick = () => {
if (typeof showBugReportModal === 'function') {
showBugReportModal({
subjectCode: subCode,
description: `Quiz "${quizTitle}" scored ${summary.percentage}% (below 80% passing grade). Answers appear outdated, missing, or incorrect.`
});
}
};
}
}
function promoteAttemptEvidenceFromScore() {
if (!checkIsQuizSummaryPage() && !checkIsQuizViewPage()) return;
const initialCourseCode = (detectCourseInfo().subjectCode) || 'GENERAL';
showUnreviewableQuizRetryWarning(getQuizAttemptSummary(initialCourseCode));
let earned = null;
let maximum = null;
let isReviewPermitted = null;
document.querySelectorAll('table, .quizattemptsummary, .generaltable').forEach(table => {
if (earned !== null) return;
const rows = Array.from(table.querySelectorAll('tr'));
const headerRow = rows.find(row => Array.from(row.children).some(cell => /grade|marks/i.test(cell.innerText || '')));
const attemptRows = rows.filter(row => /finished|submitted|complete/i.test(row.innerText || ''));
if (attemptRows.length === 0) return;
const latest = attemptRows[attemptRows.length - 1];
// 1. Direct cell parsing (e.g. cell has "15.00 / 20.00" or "75.00 / 100.00")
for (const cell of latest.children) {
const text = (cell.innerText || '').trim();
const slashMatch = text.match(/([0-9]+(?:\.[0-9]+)?)\s*\/\s*([0-9]+(?:\.[0-9]+)?)/);
if (slashMatch) {
earned = Number(slashMatch[1]);
maximum = Number(slashMatch[2]);
break;
}
}
// 2. Header-based parsing if no slash in cell
if (earned === null && headerRow) {
const headers = Array.from(headerRow.children);
const marksIndex = headers.findIndex(cell => /marks/i.test(cell.innerText || ''));
const gradeIndex = headers.findIndex(cell => /grade/i.test(cell.innerText || ''));
const targetIndex = marksIndex >= 0 ? marksIndex : gradeIndex;
if (targetIndex >= 0 && latest.children[targetIndex]) {
const headerText = headers[targetIndex].innerText || '';
const headerMax = headerText.match(/\/\s*([0-9]+(?:\.[0-9]+)?)/);
const cellVal = (latest.children[targetIndex].innerText || '').match(/([0-9]+(?:\.[0-9]+)?)/);
if (cellVal && headerMax) {
earned = Number(cellVal[1]);
maximum = Number(headerMax[1]);
}
}
}
// 3. Review permission check
const reviewIndex = headerRow ? Array.from(headerRow.children).findIndex(cell => /review/i.test(cell.innerText || '')) : -1;
if (reviewIndex >= 0 && latest.children[reviewIndex]) {
const revText = (latest.children[reviewIndex].innerText || '').toLowerCase();
if (revText.includes('not permitted') || revText.includes('not allowed')) {
isReviewPermitted = false;
} else if (latest.querySelector('a[href*="review.php"]')) {
isReviewPermitted = true;
}
} else {
const hasReviewLink = Boolean(latest.querySelector('a[href*="review.php"]'));
if (hasReviewLink) isReviewPermitted = true;
}
});
// 4. Overall feedback fallback parsing if table wasn't matched
if (earned === null || maximum === null) {
const feedbackText = document.body.innerText || '';
const gradeMatch = feedbackText.match(/(?:overall\s+feedback|grade\s+for\s+this\s+quiz)[^0-9]*([0-9]+(?:\.[0-9]+)?)\s*\/\s*([0-9]+(?:\.[0-9]+)?)/i);
if (gradeMatch) {
earned = Number(gradeMatch[1]);
maximum = Number(gradeMatch[2]);
}
}
if (earned === null || maximum === null) return;
if (!Number.isFinite(earned) || !Number.isFinite(maximum) || maximum <= 0) return;
const courseInfo = detectCourseInfo();
const subCode = courseInfo.subjectCode || 'GENERAL';
const percentage = Math.round((earned / maximum) * 100);
// Always record latest quiz attempt summary even if prior attempt was done manually without toolkit
saveQuizAttemptSummary(subCode, {
earned,
maximum,
percentage,
isReviewPermitted,
recordedAt: Date.now()
});
if (isReviewPermitted === false) reportCourseReviewRestriction(subCode);
showUnreviewableQuizRetryWarning({ percentage, isReviewPermitted });
let evidence = [];
try {
evidence = JSON.parse(sessionStorage.getItem(getAttemptEvidenceKey()) || '[]');
} catch (_) {}
if (!Array.isArray(evidence) || evidence.length === 0) return;
function dispatchQuizLowScoreAnomaly(subCode, earned, maximum, percentage, evidence) {
if (percentage >= 80) return;
const isCritical = percentage < 70;
const quizTitle = (typeof getQuizTitle === 'function' ? getQuizTitle() : '') || (document.title ? document.title.replace(/\s*\|.*$/, '').trim() : 'Quiz Attempt');
if (typeof detectAndReportQuestionAnomaly === 'function' && Array.isArray(evidence) && evidence.length > 0) {
const sampleQue = evidence[0];
const samplePrompt = sampleQue ? (sampleQue.qRaw || sampleQue.qText || '') : `Quiz scored ${earned}/${maximum} (${percentage}%) in ${subCode}`;
detectAndReportQuestionAnomaly({
anomalyType: isCritical ? 'LOW_QUIZ_SCORE_CRITICAL' : 'QUIZ_SCORE_BELOW_PASSING',
subjectCode: subCode,
activityTitle: quizTitle,
questionRaw: samplePrompt,
questionNorm: (typeof normalizeText === 'function' ? normalizeText(samplePrompt) : samplePrompt.toLowerCase()).slice(0, 100),
domType: 'quiz_attempt_score',
submittedAnswer: `Attempt scored ${earned}/${maximum} (${percentage}%) across ${evidence.length} questions`,
markScored: earned,
maxMark: maximum,
attemptHistory: evidence.slice(0, 5).map(e => `${(e.qRaw || e.qText || '').slice(0, 40)}: ${e.ansRaw || 'N/A'}`),
dbCandidate: null
});
}
const alertMsg = isCritical
? `Quiz score (${earned}/${maximum} - ${percentage}%) is critical (<70%). Anomaly telemetry dispatched.`
: `Quiz score (${earned}/${maximum} - ${percentage}%) is below passable grade (80%).`;
setLog(`[Score Anomaly] ${alertMsg}`, 'var(--accent-amber)');
if (typeof showToast === 'function') {
showToast(`${alertMsg} Click to report question issues.`, 8000, () => {
if (typeof showBugReportModal === 'function') {
showBugReportModal({
subjectCode: subCode,
description: `Quiz "${quizTitle}" scored ${earned}/${maximum} (${percentage}%). One or more questions appear to have mismatched, rotated, or incorrect answers.`
});
}
});
}
}
if (isReviewPermitted !== true) {
if (earned < maximum) {
saveUnreviewedAttemptEvidence(subCode, evidence, earned, maximum, isReviewPermitted);
setLog(`Attempt score recorded (${earned}/${maximum} - ${percentage}%). Review permission is ${isReviewPermitted === false ? 'restricted' : 'unknown'}; overall scores cannot verify individual answers, so these answers were not promoted or shared.`, 'var(--accent-amber)');
dispatchQuizLowScoreAnomaly(subCode, earned, maximum, percentage, evidence);
return;
}
}
if (earned < maximum) {
saveUnreviewedAttemptEvidence(subCode, evidence, earned, maximum, isReviewPermitted);
recordAdaptiveProbeAttempt(subCode, evidence, earned, maximum, isReviewPermitted);
setLog(`Attempt score recorded (${earned}/${maximum} - ${percentage}%). This overall score does not identify which individual answers were correct. Unconfirmed choices remain unverified.`, 'var(--accent-amber)');
dispatchQuizLowScoreAnomaly(subCode, earned, maximum, percentage, evidence);
return;
}
const promoted = evidence.map(item => ({
...item,
verified: true,
isAiSuggestion: false,
source: 'moodle_100_percent',
evidenceType: 'moodle_100_percent'
}));
mergeAnswersIntoCache(subCode, promoted, 'moodle_100_percent');
markProbeQuestionsSolved(subCode, promoted);
queueCommunityContribution(subCode, promoted, {
source: 'moodle_100_percent',
evidenceType: 'moodle_100_percent'
});
setLog(`100% score confirmed ${promoted.length} recorded answers and queued them for the verified database.`, 'var(--accent-green)');
showToast(`100% confirmed: ${promoted.length} answers saved to the study database.`, 4000);
sessionStorage.removeItem(getAttemptEvidenceKey());
}
// Schedule automatic advancement to the next target on a one-page quiz,
// or to the next Moodle page after all questions on this page are answered.
function scheduleAutoNextAfterAnswer(delayMs = 800, isManualAnswer = false) {
const sourceQue = arguments[2] || null;
const allowAiAutoNext = arguments[3] === true;
if (!autoQuizMode) return;
// While Auto-Quiz is running, manual answers are completed targets too.
// Continue to the next unanswered question instead of leaving the user
// on the question that was just answered.
if (isManualAnswer && !autoNextQuiz) return;
if (!isManualAnswer && !autoNextVerified && !allowAiAutoNext) return;
if (!checkIsQuizAttemptPage()) return;
const effectiveDelay = fastQuizMode ? Math.min(delayMs, 200) : delayMs;
const nextOnPage = findNextUnansweredOnCurrentPage(sourceQue);
if (nextOnPage) {
clearTimeout(autoNextTimer);
const secText = (effectiveDelay / 1000).toFixed(1) + 's';
setLog(`Question Answered: Moving to the next unanswered question in ${secText}...`, "var(--accent-blue)");
showToast("Answer recorded! Moving to next...", 1000);
autoNextTimer = setTimeout(() => {
if (!autoQuizMode) return;
setActiveQuestion(nextOnPage, false);
nextOnPage.scrollIntoView({ behavior: 'smooth', block: 'center' });
runAutoQuizSolver();
}, effectiveDelay);
return;
}
if (!areAllPageQuestionsAnswered()) return;
const nextBtn = findQuizNextButton();
if (!nextBtn) return;
const btnText = (nextBtn.value || nextBtn.innerText || '').toLowerCase();
const isFinish = btnText.includes('finish') || btnText.includes('submit');
clearTimeout(autoNextTimer);
if (isFinish) {
const finishDelay = fastQuizMode ? 200 : 1000;
setLog(`All Questions Answered! Advancing to summary in ${(finishDelay / 1000).toFixed(1)}s...`, "var(--accent-green)");
showToast("All questions answered! Advancing to summary...", 1200);
autoNextTimer = setTimeout(() => {
if (!autoQuizMode) return;
if (isManualAnswer && !autoNextQuiz) return;
clickQuizNextButton(nextBtn, true);
}, finishDelay);
} else {
const pageDelay = effectiveDelay;
setLog(`Question Answered: Advancing to next page in ${(pageDelay / 1000).toFixed(1)}s...`, "var(--accent-blue)");
showToast("Answer selected! Advancing to next page...", 1000);
autoNextTimer = setTimeout(() => {
if (!autoQuizMode) return;
if (isManualAnswer && !autoNextQuiz) return;
clickQuizNextButton(nextBtn, true);
}, pageDelay);
}
}
// Bind event listeners to question inputs to trigger auto-next immediately when choices are selected
function setupQuizAnswerListeners() {
if (!checkIsQuizAttemptPage()) return;
const inputs = document.querySelectorAll(
'.que .answer input[type="radio"], .que .answer input[type="checkbox"], .que select'
);
inputs.forEach(inp => {
if (inp.dataset.amaesAutoNextBound) return;
inp.dataset.amaesAutoNextBound = 'true';
inp.addEventListener('change', () => {
if (autoQuizMode) {
scheduleAutoNextAfterAnswer(inp.type === 'checkbox' ? 1200 : 800, true, inp.closest('.que'));
}
});
});
const textInputs = document.querySelectorAll(
'.que input[type="text"].form-control, .que input.form-control, .que textarea'
);
textInputs.forEach(inp => {
if (inp.dataset.amaesAutoNextBound) return;
inp.dataset.amaesAutoNextBound = 'true';
inp.addEventListener('blur', () => {
if (autoQuizMode && inp.value && inp.value.trim().length > 0) {
scheduleAutoNextAfterAnswer(1000, true, inp.closest('.que'));
}
});
});
}
// Fail-safe click & smart navigation executor (skips already-answered questions)
function clickQuizNextButton(btn, forceAllow = false) {
try { syncCurrentPageSelectionsToEvidence(); } catch (_) {}
if (!autoQuizMode && !forceAllow && !autoNextQuiz) {
logDebug("Blocked clickQuizNextButton: Auto-Quiz is PAUSED.");
return false;
}
// SMART NAVIGATION: If smart skip is enabled, jump directly to next unanswered question!
if (smartSkipQuiz) {
const navState = getQuizNavQuestionStates();
if (navState && navState.nextUnanswered && navState.nextUnanswered.btn) {
const target = navState.nextUnanswered;
logDebug(`Smart Jump: Bypassing answered questions, jumping directly to Question #${target.qNum}`);
setLog(`[Skip] Smart Skip: Jumping to unanswered Question #${target.qNum} (${navState.totalUnanswered} remaining)...`, "var(--accent-blue)");
showToast(`Smart Jump: Question #${target.qNum}`, 1800);
target.btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
try {
target.btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
target.btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
target.btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch (e) {}
target.btn.click();
return true;
} else if (navState && navState.allAnswered) {
// All questions on the entire quiz are answered! Proceed to finish attempt
const finishBtn = document.querySelector(
'.submitbtns input[value*="Finish attempt"], .submitbtns button[value*="Finish attempt"], input[value*="Finish attempt"], a.endtestlink, #mod_quiz_navblock a[href*="summary.php"]'
);
if (finishBtn) {
logDebug("Smart Navigation: All questions answered! Proceeding to finish attempt.");
setLog("All Questions Answered! Proceeding to summary screen...", "var(--accent-green)");
showToast("All questions answered! Finishing attempt...", 2500);
finishBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
finishBtn.click();
return true;
}
}
}
if (!btn) {
logDebug("Cannot navigate: Next button not found on page.");
return false;
}
logDebug(`Executing Quiz Next Navigation: ${btn.value || btn.innerText || btn.name}`);
btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
btn.focus();
// Dispatch realistic user click event
try {
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
} catch (e) {
console.warn("MouseEvent dispatch error:", e);
}
// Native click call
btn.click();
return true;
}
let isSolverRunning = false;
let studyGuideFallbackRunPending = false;
const quizStudyGuideLoads = new Map();
async function runAutoQuizSolver(forceRun = false) {
if (localStorage.getItem('amaes_terms_acknowledged') !== 'true') {
autoQuizMode = false;
return;
}
if (typeof isFeatureDisabledByAdmin === 'function' && isFeatureDisabledByAdmin('autoQuiz')) {
autoQuizMode = false;
setLog("[Admin Notice] Auto-Quiz is temporarily disabled by administrator.", "var(--accent-amber)");
showToast("Auto-Quiz is temporarily disabled by administrator.", 4000);
return;
}
if (!checkIsQuizAttemptPage()) return;
// STRICT PAUSE CHECK: If Auto-Quiz is not explicitly active or force-run, halt completely
const isAutoActive = forceRun || autoQuizMode;
if (!isAutoActive || !autoQuizMode) {
logDebug("Auto Quiz Solver is PAUSED. Suppressing auto-clicks and auto-navigation.");
clearTimeout(autoNextTimer);
autoNextTimer = null;
clearTimeout(pageLoadSolverTimer);
pageLoadSolverTimer = null;
isSolverRunning = false;
return;
}
if (isSolverRunning) return;
isSolverRunning = true;
try {
const courseInfo = detectCourseInfo();
const subCode = courseInfo.subjectCode || 'CS6301';
let cached = getCachedAnswers(subCode);
const externalStudyGuideAnswers = typeof readJennysonlinePersistentCache === 'function'
? (readJennysonlinePersistentCache(subCode) || [])
: [];
const queContainers = document.querySelectorAll('.que');
// Restore per-question controls when the solver is no longer
// blocked; they are hidden below when the full waiting HUD exists.
document.querySelectorAll('.amaes-que-top-toolbar').forEach(toolbar => {
toolbar.style.display = '';
});
if (queContainers.length === 0) {
isSolverRunning = false;
return;
}
// 1. Highlight and Auto-Select verified answers from database (ONLY click if autoQuizMode is true)
let res = { matched: 0, total: queContainers.length };
// AUTO-FETCH FROM CLOUD DATABASE / AMAUOED FALLBACK: If local cache is empty, check cloud DB & AMAUOED!
if (!cached || cached.length === 0) {
logDebug(`Local cache empty for ${subCode}. Checking community cloud database & AMAUOED...`);
setLog(`Checking database & AMAUOED for ${subCode}...`, "var(--accent-blue)");
try {
if (typeof autoFetchCloudAnswersIfMissing === 'function') {
await autoFetchCloudAnswersIfMissing(subCode);
} else {
await syncAnswersFromCloud(subCode);
}
const freshCached = getCachedAnswers(subCode);
if (freshCached && freshCached.length > 0) {
cached = freshCached;
setLog(`Auto-loaded ${freshCached.length} verified answers for ${subCode}!`, "var(--accent-green)");
showToast(`Loaded ${freshCached.length} answers!`);
syncAutoQuizUI();
}
} catch (e) {
logDebug(`Auto-fetch fallback note: ${e.message}`);
}
}
const freshExternalStudyGuideAnswers = typeof readJennysonlinePersistentCache === 'function'
? (readJennysonlinePersistentCache(subCode) || [])
: externalStudyGuideAnswers;
const availableAnswers = (cached || []).concat(freshExternalStudyGuideAnswers);
if (availableAnswers.length > 0) {
res = highlightQuizAnswers(availableAnswers, Boolean(autoQuizMode || autoPickQuiz));
} else {
setLog(`No Answers in DB: Open amauoed or click Cloud Sync for ${subCode}!`, "var(--accent-amber)");
}
if (typeof loadJennysonlineAnswersForCourse === 'function' && !quizStudyGuideLoads.has(subCode)) {
const attemptUrl = window.location.href;
const guideLoad = loadJennysonlineAnswersForCourse(subCode)
.then(answers => {
if (!answers.length || window.location.href !== attemptUrl || !checkIsQuizAttemptPage()) return;
const freshCache = getCachedAnswers(subCode) || [];
const autoPickFallback = autoQuizMode && autoPickStudyGuideFallback;
highlightQuizAnswers(freshCache.concat(answers), autoPickFallback);
if (autoPickFallback) {
studyGuideFallbackRunPending = true;
if (!isSolverRunning) {
studyGuideFallbackRunPending = false;
runAutoQuizSolver();
}
}
})
.catch(error => {
logDebug(`Jenny's Online source note for ${subCode}: ${error.message}`);
})
.finally(() => {
quizStudyGuideLoads.delete(subCode);
});
quizStudyGuideLoads.set(subCode, guideLoad);
}
// 2. Identify questions verified by the database vs unverified/unknown questions
const unverifiedQuestions = [];
const unreviewedOrProbeQuestions = [];
queContainers.forEach(que => {
if (isQuestionAnswered(que)) return;
const hasVerifiedBadge = que.querySelector('.amaes-verified-badge');
const hasAdaptiveBadge = que.querySelector('.amaes-adaptive-probe-badge');
const hasAiBadge = que.querySelector('.amaes-ai-suggested-badge');
const hasUnverifiedBadge = que.querySelector('.amaes-unverified-badge');
const hasDragHint = que.querySelector('.amaes-drag-hint');
const hasShortAnsHint = que.querySelector('.amaes-shortans-hint');
const hasSelectHint = que.querySelector('.amaes-select-hint');
// Track unverified, AI-suggested, or prior unreviewed attempts
const isAutoPickedStudyGuideFallback = autoQuizMode && autoPickStudyGuideFallback &&
que.dataset.amaesStudyGuideFallbackPicked === 'true';
if (!isAutoPickedStudyGuideFallback &&
(hasAdaptiveBadge || hasAiBadge || hasUnverifiedBadge)) {
unreviewedOrProbeQuestions.push(que);
}
const dropZones = que.querySelectorAll('.drop, .dropzone, span.droptarget, .droppable');
const selectInputs = que.querySelectorAll('select');
const textInputs = que.querySelectorAll('input[type="text"], input.form-control, textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="reset"])');
let isFullyVerified = false;
if (hasVerifiedBadge && !hasAdaptiveBadge && !hasAiBadge && !hasUnverifiedBadge) {
isFullyVerified = true;
} else if (dropZones.length > 0) {
isFullyVerified = que.querySelectorAll('.amaes-drag-hint').length >= dropZones.length;
} else if (selectInputs.length > 0) {
isFullyVerified = que.querySelectorAll('.amaes-select-hint').length >= selectInputs.length;
} else if (textInputs.length > 0) {
isFullyVerified = que.querySelectorAll('.amaes-shortans-hint').length >= textInputs.length;
} else if (hasShortAnsHint || hasSelectHint || hasDragHint) {
isFullyVerified = true;
}
if (!isFullyVerified) {
unverifiedQuestions.push(que);
}
});
const nextBtn = findQuizNextButton();
// Safe review pause: If any question on this page was an unreviewed probe or unverified suggestion,
// do NOT silently breeze through unless user explicitly enabled auto-advance on manual/unverified clicks!
// When autoQuizMode is running, let Case B proceed to AI resolution or auto-copy without halting automation.
if (unreviewedOrProbeQuestions.length > 0 && !autoNextQuiz && !fastQuizMode && !autoQuizMode) {
const firstProbeQue = unreviewedOrProbeQuestions[0];
setActiveQuestion(firstProbeQue, false);
firstProbeQue.scrollIntoView({ behavior: 'smooth', block: 'center' });
clearTimeout(autoNextTimer);
autoNextTimer = null;
isWaitingForUserAnswer = true;
syncAutoQuizUI(true);
const qData = extractQuestionData(firstProbeQue);
const isFlippedProbe = Boolean(firstProbeQue.querySelector('.amaes-adaptive-probe-choice'));
setLog(`[Review Pause] Question #${qData ? qData.qNum : ''}: ${isFlippedProbe ? 'Adaptive probe flipped to alternative choice' : 'Unverified candidate selected'}. Paused for review—press N or click Next page when ready.`, "var(--accent-purple)");
isSolverRunning = false;
return;
}
// Co-Pilot: Auto-Pick & Next IF Known, WAIT if Unknown
// Case A: 100% of questions on this page were verified & answered by database!
if (unverifiedQuestions.length === 0 && res.total > 0) {
const allAnswered = areAllPageQuestionsAnswered();
if (allAnswered) {
setLog(`All ${res.total} question(s) verified & picked!`, "var(--accent-green)");
// Answered by Auto-Quiz: automatically advance to next page!
if (nextBtn && autoNextVerified) {
const btnText = (nextBtn.value || nextBtn.innerText || '').toLowerCase();
const isFinish = btnText.includes('finish') || btnText.includes('submit');
clearTimeout(autoNextTimer);
const navDelay = fastQuizMode ? 200 : (isFinish ? 1200 : 1000);
if (isFinish) {
if (autoSubmitQuiz || fastQuizMode || autoQuizMode) {
setLog(`All Questions Answered! Advancing to summary in ${(navDelay / 1000).toFixed(1)}s...`, "var(--accent-green)");
showToast("Finishing attempt...", navDelay + 1000);
autoNextTimer = setTimeout(() => {
if (!autoQuizMode) return;
clickQuizNextButton(nextBtn);
}, navDelay);
} else {
setLog("Last Question Answered! Paused for review before final submit.", "var(--accent-green)");
showToast("Last question answered! Review before submitting.", 4000);
}
isSolverRunning = false;
return;
}
setLog(`[Auto-Next] Auto-Next: Advancing to next question in ${(navDelay / 1000).toFixed(1)}s...`, "var(--accent-blue)");
autoNextTimer = setTimeout(() => {
if (!autoQuizMode) return;
clickQuizNextButton(nextBtn);
}, navDelay);
}
} else {
// Verified choices highlighted, but not all picked (e.g. auto-pick disabled)
setLog(`Verified answers found! Select your choice${autoNextQuiz ? ' (advances automatically)' : ' and click Next page'}.`, "var(--accent-blue)");
}
isSolverRunning = false;
return;
}
// Case B: UNKNOWN QUESTION DETECTED (Auto-solver cannot answer it!)
// Safe wait state on unknown question: solver pauses on THIS question for user input,
// while keeping autoQuizMode active so subsequent known questions are answered directly!
clearTimeout(autoNextTimer);
autoNextTimer = null;
isWaitingForUserAnswer = true;
syncAutoQuizUI(true);
const firstBlockedQue = unverifiedQuestions[0];
const qData = extractQuestionData(firstBlockedQue);
const willIncludeContext = shouldInjectAiContext(qData ? qData.qNum : null);
const aiPromptText = formatQuestionForAI(firstBlockedQue, aiPromptHint);
// Feature 1: Instant Auto-Copy — copy prompt immediately on unknown question detection,
// before AI is invoked or any condition is checked (aiAutoCopyOnFail guard respected).
if (aiAutoCopyOnFail && qData && qData.questionType !== 'unknown') {
copyQuestionWithOptionalImage(firstBlockedQue, aiPromptText).catch(() => {});
}
setActiveQuestion(firstBlockedQue, false);
firstBlockedQue.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstBlockedQue.style.outline = '2.5px solid #f59e0b';
firstBlockedQue.style.borderRadius = '8px';
// The waiting HUD already has the only needed stop control.
firstBlockedQue.querySelectorAll('.amaes-que-top-toolbar').forEach(toolbar => {
toolbar.style.display = 'none';
});
// If question type is unknown / unrecognized:
// Do not alter or interfere with the question card at all ("dont do anythibg but notify").
// It is already reported and pushed to database by recordUnknownQuestionType.
if (qData && qData.questionType === 'unknown') {
recordUnknownQuestionType(firstBlockedQue, qData);
firstBlockedQue.style.outline = '';
isSolverRunning = false;
return;
}
// Feature 4: Moodle Server Error Guard — detect empty/failed question load
if (detectMoodleServerError(firstBlockedQue)) {
firstBlockedQue.querySelectorAll('.amaes-blockage-hud').forEach(el => el.remove());
const errHud = document.createElement('div');
errHud.className = 'amaes-blockage-hud';
errHud.style.cssText = `
margin-bottom: 12px; padding: 8px 12px;
background: #fff7ed; border: 1.5px solid #f97316;
border-radius: 8px; display: flex; align-items: center; gap: 8px;
font-size: 11px; color: #9a3412; font-family: -apple-system, sans-serif;
`;
errHud.innerHTML = `
Warning
Moodle Server Glitch Detected
Question content failed to load from Moodle database. Try refreshing the page.
`;
const formEl = firstBlockedQue.querySelector('.formulation, .content') || firstBlockedQue;
formEl.insertBefore(errHud, formEl.firstChild);
setLog('[Server Error] Moodle database glitch detected. Question content is empty — cannot solve. Try refreshing.', 'var(--accent-amber)');
showToast('Moodle server glitch: Question failed to load.', 4000);
isSolverRunning = false;
return;
}
const navState = getQuizNavQuestionStates();
// STRICT ANSWER HIERARCHY GUARD:
// 1. If question is already answered (by student or prior pick), DO NOT run AI!
if (isQuestionAnswered(firstBlockedQue)) {
firstBlockedQue.querySelectorAll('.amaes-blockage-hud, .amaes-ai-fallback-bar').forEach(el => el.remove());
isSolverRunning = false;
return;
}
// 2. If question already has an unconfirmed community or study-guide candidate,
// and study guide fallback is enabled, pick it now and skip AI inference!
const unconfirmedChoice = firstBlockedQue.querySelector('.amaes-highlighted-choice input[type="radio"], .amaes-highlighted-choice input[type="checkbox"]');
if (unconfirmedChoice && (autoPickStudyGuideFallback || autoPickQuiz)) {
if (!unconfirmedChoice.checked) {
unconfirmedChoice.checked = true;
unconfirmedChoice.click();
unconfirmedChoice.dispatchEvent(new Event('change', { bubbles: true }));
}
firstBlockedQue.querySelectorAll('.amaes-blockage-hud, .amaes-ai-fallback-bar').forEach(el => el.remove());
setLog(`[Answer Hierarchy] Selected community/study-guide candidate for Question #${qData ? qData.qNum : ''}; skipped AI.`, "var(--accent-blue)");
isSolverRunning = false;
return;
}
// GOOGLE GEMINI AI ASSISTANT: Handle uncertain Multiple Choice, True/False, and row-mapped Matching questions.
// Other complex types (drag & drop, gapselect, text inputs) fall back to manual copy.
const isEligibleChoice = isEligibleForAiSolver(firstBlockedQue, qData);
const existingAiChoice = firstBlockedQue.querySelector('.amaes-ai-suggested-choice');
if (existingAiChoice && !isChoiceRowEliminated(existingAiChoice)) {
// Clean up any stale blockage HUD if previously injected
firstBlockedQue.querySelectorAll('.amaes-blockage-hud').forEach(el => el.remove());
firstBlockedQue.querySelectorAll('.amaes-que-top-toolbar').forEach(toolbar => {
toolbar.style.display = 'flex';
});
firstBlockedQue.style.outline = '2px solid rgba(139, 92, 246, 0.7)';
firstBlockedQue.style.borderRadius = '8px';
setQuestionAiTag(firstBlockedQue, true);
// Already solved by AI and highlighted! Keep paused for review without re-querying API.
setLog(`[AI Suggestion] Question #${qData ? qData.qNum : ''} has an AI suggestion. (Prompt copied) Paused for review—press N or click Next page when ready.`, "var(--accent-purple)");
isSolverRunning = false;
return;
}
const activeGeminiKey = getAvailableGeminiKey();
if (activeGeminiKey && aiQuizEnabled && isEligibleChoice) {
// Check if client-side or cooldown rate limit is currently active
const rateLimitStatus = getAiRateLimitStatus();
if (rateLimitStatus.isLimited) {
firstBlockedQue.querySelectorAll('.amaes-blockage-hud').forEach(el => el.remove());
copyQuestionWithOptionalImage(firstBlockedQue, aiPromptText).catch(() => {});
const rlReason = `Google is temporarily limiting AI requests. Try again in ${rateLimitStatus.remainingSec} seconds.`;
setLog(`[AI Rate Limit] Question #${qData ? qData.qNum : ''}: ${rlReason} (Prompt copied)`, "var(--accent-amber)");
showToast(`AI rate limit: available in ${rateLimitStatus.remainingSec}s`, 3500);
if (!firstBlockedQue.dataset.amaesInterventionAlertPlayed) {
firstBlockedQue.dataset.amaesInterventionAlertPlayed = 'true';
playToolkitSound('manual_intervention');
}
showAiFallbackBar(firstBlockedQue, qData, aiPromptText, async () => {
isSolverRunning = false;
autoSolveQuizQuestion();
}, {
reason: rlReason,
isRateLimit: true,
waitSeconds: rateLimitStatus.remainingSec
});
firstBlockedQue.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstBlockedQue.style.outline = '2px solid #f97316';
firstBlockedQue.style.borderRadius = '8px';
isSolverRunning = false;
return;
}
// Remove any manual blockage HUD so it does not conflict with active AI solving
firstBlockedQue.querySelectorAll('.amaes-blockage-hud').forEach(el => el.remove());
// Auto-copy question prompt in background as seamless backup for the student
copyQuestionWithOptionalImage(firstBlockedQue, aiPromptText).catch(() => {});
const courseInfo = detectCourseInfo();
const courseCode = courseInfo.subjectCode || '';
const promptText = buildGeminiCompactPrompt(qData, courseCode, firstBlockedQue);
await handleGeminiQuestionInference({
que: firstBlockedQue,
qData: qData,
promptText: promptText,
onSuccess: async (matched) => {
if (matched && matched.choiceText && !matched.matchingRows) {
recordAttemptAnswerEvidence(firstBlockedQue, matched.choiceText, 'ai_inference');
}
// Ensure blockage HUD is removed upon successful AI resolution
firstBlockedQue.querySelectorAll('.amaes-blockage-hud, .amaes-unanswered-hint').forEach(el => el.remove());
firstBlockedQue.querySelectorAll('.amaes-que-top-toolbar').forEach(toolbar => {
toolbar.style.display = 'flex';
});
firstBlockedQue.style.outline = '2px solid rgba(139, 92, 246, 0.7)';
firstBlockedQue.style.borderRadius = '8px';
setQuestionAiTag(firstBlockedQue, true);
if (matched && Array.isArray(matched.matchingRows)) {
showToast(`Gemini suggested ${matched.matchingRows.length} matching answer${matched.matchingRows.length === 1 ? '' : 's'} (unverified).`, 3500);
setLog(`[AI Suggestion] Gemini suggested ${matched.matchingRows.length} matching row answers (unverified). Review them before continuing.`, "var(--accent-purple)");
} else if (aiAutoSelect && matched && matched.input) {
const anyChecked = Boolean(firstBlockedQue.querySelector('.answer input[type="radio"]:checked, .answer input[type="checkbox"]:checked'));
if (!anyChecked) {
safelySelectInput(matched.input);
}
showToast(`Gemini selected a choice for #${qData ? qData.qNum : ''}. (Paused for review)`, 3000);
setLog(`[AI Suggestion] Gemini selected ${escapeHtml(matched.choiceText)} for #${qData ? qData.qNum : ''}. (Prompt copied) Paused for review—press N or click Next page when ready.`, "var(--accent-purple)");
} else {
showToast(`Gemini suggested an answer for #${qData ? qData.qNum : ''} (Paused for review)`, 3000);
setLog(`[AI Suggestion] Gemini suggested ${escapeHtml(matched ? matched.choiceText : '')} for #${qData ? qData.qNum : ''}. (Prompt copied) Paused for review—click to select and proceed.`, "var(--accent-purple)");
}
// Feature 2: Auto-Advance after AI answer if setting is enabled
if (aiAutoNextOnAiAnswer || autoNextVerified) {
scheduleAutoNextAfterAnswer(800, false, firstBlockedQue, aiAutoNextOnAiAnswer);
}
if (typeof updateQuestionAiDrawerState === 'function') {
updateQuestionAiDrawerState(firstBlockedQue, false);
}
}
});
// Built-in acceleration on multi-question pages: concurrently solve other visible unknown questions
if (fastQuizMode && queContainers.length > 1 && unverifiedQuestions.length > 1) {
const otherQue = unverifiedQuestions.slice(1);
otherQue.forEach(oQue => {
if (oQue.querySelector('.amaes-ai-suggested-choice, .amaes-ai-text-badge') || isQuestionAnswered(oQue)) return;
const oQData = extractQuestionData(oQue);
if (oQData && isEligibleForAiSolver(oQue, oQData)) {
const oPrompt = buildGeminiCompactPrompt(oQData, courseCode, oQue);
handleGeminiQuestionInference({
que: oQue,
qData: oQData,
promptText: oPrompt,
onSuccess: async (oMatched) => {
if (oMatched && oMatched.choiceText && !oMatched.matchingRows) {
recordAttemptAnswerEvidence(oQue, oMatched.choiceText, 'ai_inference');
}
oQue.querySelectorAll('.amaes-blockage-hud, .amaes-unanswered-hint').forEach(el => el.remove());
oQue.style.outline = '2px solid rgba(139, 92, 246, 0.7)';
oQue.style.borderRadius = '8px';
setQuestionAiTag(oQue, true);
if (aiAutoSelect && oMatched && oMatched.input) {
safelySelectInput(oMatched.input);
}
}
}).catch(() => {});
}
});
}
// If AI successfully resolved and highlighted a choice, finish here without showing redundant blockage HUD!
if (firstBlockedQue.querySelector('.amaes-ai-suggested-choice, .amaes-ai-matching-suggestion')) {
isSolverRunning = false;
return;
}
} else {
// Copy question for AI helper
copyQuestionWithOptionalImage(firstBlockedQue, aiPromptText).then((res) => {
showToast(res && res.withImage ? `Visual snippet & Question #${qData ? qData.qNum : ''} copied to clipboard!` : `Question #${qData ? qData.qNum : ''} copied to clipboard — ready to paste!`, 3000);
}).catch(() => {});
// Built-in acceleration: even if first question requires manual intervention, background-solve remaining eligible questions
if (fastQuizMode && queContainers.length > 1 && unverifiedQuestions.length > 1) {
const activeKey = getAvailableGeminiKey();
if (activeKey && aiQuizEnabled) {
const courseInfo = detectCourseInfo();
const courseCode = courseInfo.subjectCode || '';
unverifiedQuestions.slice(1).forEach(oQue => {
if (oQue.querySelector('.amaes-ai-suggested-choice, .amaes-ai-text-badge') || isQuestionAnswered(oQue)) return;
const oQData = extractQuestionData(oQue);
if (oQData && isEligibleForAiSolver(oQue, oQData)) {
const oPrompt = buildGeminiCompactPrompt(oQData, courseCode, oQue);
handleGeminiQuestionInference({
que: oQue,
qData: oQData,
promptText: oPrompt,
onSuccess: async (oMatched) => {
if (oMatched && oMatched.choiceText && !oMatched.matchingRows) {
recordAttemptAnswerEvidence(oQue, oMatched.choiceText, 'ai_inference');
}
oQue.querySelectorAll('.amaes-blockage-hud, .amaes-unanswered-hint').forEach(el => el.remove());
oQue.style.outline = '2px solid rgba(139, 92, 246, 0.7)';
oQue.style.borderRadius = '8px';
setQuestionAiTag(oQue, true);
if (aiAutoSelect && oMatched && oMatched.input) {
safelySelectInput(oMatched.input);
} else if (aiAutoSelect && oMatched && Array.isArray(oMatched.matchingRows)) {
oMatched.matchingRows.forEach(item => {
if (item.select && item.option) {
item.select.value = item.option.value;
item.select.dispatchEvent(new Event('input', { bubbles: true }));
item.select.dispatchEvent(new Event('change', { bubbles: true }));
item.select.dispatchEvent(new Event('blur', { bubbles: true }));
item.select.style.outline = '2px solid #a855f7';
item.select.style.backgroundColor = 'rgba(168, 85, 247, 0.1)';
}
});
}
}
}).catch(() => {});
}
});
}
}
}
// If reaching here: either question is ineligible for AI (e.g. text/drag), AI is not enabled, or AI failed
// IMPORTANT: If AI was triggered and is still processing (thinking indicator visible), do NOT show
// the "WAITING FOR ANSWER" HUD — it would conflict with the AI thinking indicator.
const aiIsHandling = firstBlockedQue.querySelector('.amaes-ai-thinking-indicator');
const aiAlreadySolved = firstBlockedQue.querySelector('.amaes-ai-suggested-choice, .amaes-ai-text-badge, .amaes-ai-matching-suggestion');
if (aiIsHandling || aiAlreadySolved) {
isSolverRunning = false;
return;
}
firstBlockedQue.dataset.amaesAiFailed = 'true';
if (typeof updateQuestionAiDrawerState === 'function') {
updateQuestionAiDrawerState(firstBlockedQue, true);
}
setLog(
`Question #${qData ? qData.qNum : ''} Auto-Copied: Prompt copied to clipboard. ` +
`Choose an answer and click Next page, or use Ask AI to get an answer.`,
"var(--accent-amber)"
);
if (!firstBlockedQue.querySelector('.amaes-blockage-hud')) {
// The solver HUD is the full unknown-answer notice. Remove
// compact matcher hints, previous focus banners, and chips so no warning is shown twice.
firstBlockedQue.querySelectorAll('.amaes-unanswered-hint').forEach(hint => hint.remove());
firstBlockedQue.querySelectorAll('.amaes-new-question-focus-banner').forEach(b => b.remove());
firstBlockedQue.querySelectorAll('.amaes-unsure-help-chip').forEach(c => c.remove());
firstBlockedQue.querySelectorAll('.amaes-ai-fallback-bar').forEach(bar => bar.remove());
setQuestionAiTag(firstBlockedQue, false);
if (!firstBlockedQue.dataset.amaesInterventionAlertPlayed) {
firstBlockedQue.dataset.amaesInterventionAlertPlayed = 'true';
playToolkitSound('manual_intervention');
}
const hud = document.createElement('div');
hud.className = 'amaes-blockage-hud amaes-new-question-focus-banner';
hud.style.cssText = `
margin-bottom: 12px;
padding: 10px 14px;
background: #fffbeb;
border: 1.5px solid #f59e0b;
border-radius: 8px;
font-size: 11.5px;
color: #78350f;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.4;
box-sizing: border-box;
transition: all 0.2s ease;
`;
const isMatchBlocked = Boolean(firstBlockedQue && (
(typeof identifyQuestionType === 'function' && identifyQuestionType(firstBlockedQue) === 'match') ||
firstBlockedQue.classList.contains('match') ||
firstBlockedQue.classList.contains('qtype_match') ||
firstBlockedQue.querySelectorAll('select').length > 1
));
const retryCount = Number(firstBlockedQue.dataset.amaesAiRetryCount) || 0;
hud.innerHTML = `
WAITINGQuestion #${qData ? qData.qNum : ''}: No saved answer yet
Auto-quiz paused
${isMatchBlocked ?
'No saved answers for these matching items yet. Select the matching choices below, or consult Web AI tools above.' :
'Question copied to clipboard! Pick an answer below or click Next Question, or use the AI tools at the side or bottom.'
}
`;
hint.style.cssText = `font-size: 11px; margin-top: 5px; margin-bottom: 3px; padding: 5px 9px; background: ${sourceBg}; border-left: 3px solid ${sourceColor}; border-radius: 4px; cursor: pointer; transition: background 0.15s;`;
hint.title = `Click to place "${targetAns}" into Blank ${idx + 1}`;
const placeFn = (e) => {
if (e && e.target && e.target.closest('a')) return;
if (e) {
e.preventDefault();
e.stopPropagation();
}
const hiddenInput = que.querySelector(`input.placeinput.place${idx + 1}, input[name$="_p${idx + 1}"], input[name*="_p${idx + 1}"]`) || que.querySelectorAll('input.placeinput, input[type="hidden"][name*="_p"]')[idx];
const choiceClass = Array.from(matchingDrag.classList).find(c => /^(?:choice|c)(\d+)$/i.test(c));
let choiceNum = choiceClass ? choiceClass.replace(/^[^\d]+/, '') : String(dragHomes.indexOf(matchingDrag) + 1);
if (hiddenInput && choiceNum) {
hiddenInput.value = choiceNum;
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
hiddenInput.dispatchEvent(new Event('blur', { bubbles: true }));
}
matchingDrag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
dropZone.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
if (isConfirmedCandidate(bestCand)) {
dropZone.innerText = targetAns;
}
dropZone.style.outline = `2px solid ${sourceColor}`;
dropZone.style.fontWeight = '700';
showToast(`Placed: ${targetAns}`);
hint.style.background = 'rgba(16, 185, 129, 0.2)';
const btn = hint.querySelector('.amaes-drag-btn');
if (btn) btn.innerText = 'Placed';
};
hint.onclick = placeFn;
const placeBtn = hint.querySelector('.amaes-drag-btn');
if (placeBtn) placeBtn.onclick = placeFn;
if (dropZone.parentElement && dropZone.parentElement !== que) {
dropZone.parentElement.appendChild(hint);
} else {
dropZone.insertAdjacentElement('afterend', hint);
}
// Auto-place when autoPickQuiz is enabled and auto-quiz is running, or user triggered manual select
const canAutoPick = (isManualSelect && isConfirmedCandidate(bestCand)) ||
(Boolean(autoSelect) && (autoPickQuiz || autoQuizMode) &&
isConfirmedCandidate(bestCand));
if (canAutoPick) {
placeFn();
}
}
}
});
// Only consider the question matched if ALL drop zones are matched!
if (matchedDragCount === dropZones.length) {
foundMatchForQuestion = true;
} else if (matchedDragCount > 0) {
let partialBanner = que.querySelector('.amaes-partial-drag-hint');
if (!partialBanner) {
partialBanner = document.createElement('div');
partialBanner.className = 'amaes-partial-drag-hint';
partialBanner.style.cssText = `
font-size: 11px;
margin-top: 6px;
margin-bottom: 6px;
padding: 6px 10px;
background: rgba(245, 158, 11, 0.12);
border-left: 3px solid #f59e0b;
border-radius: 4px;
color: #b45309;
display: flex;
align-items: center;
gap: 6px;
`;
partialBanner.innerHTML = `
Partial Answer Known:Matched ${matchedDragCount} of ${dropZones.length} blanks. Please review and place remaining blanks manually.
`;
const formulation = que.querySelector('.formulation, .content') || que;
formulation.insertBefore(partialBanner, formulation.firstChild);
}
}
}
}
if (!foundMatchForQuestion) {
if (checkIsQuizAttemptPage() && !que.querySelector('.amaes-new-question-focus-banner') && !que.querySelector('.amaes-blockage-hud')) {
que.classList.add('amaes-no-answer-que');
const numElem = que.querySelector('.info .no, .qno');
const qNum = numElem ? numElem.innerText.replace(/[^0-9]/g, '') : '';
renderNewQuestionFocusBanner(que, qNum);
updateNextButtonIndicator();
} else if (checkIsQuizAttemptPage()) {
que.classList.add('amaes-no-answer-que');
}
} else {
que.classList.remove('amaes-no-answer-que');
matchedCount++;
}
if (typeof updateQuestionAiDrawerState === 'function') {
updateQuestionAiDrawerState(que);
}
});
return { matched: matchedCount, total: queContainers.length };
}
// ==========================================
// Activity Classifier for Course Page
// ==========================================
function getQuizRegex() {
return /\b(quiz|prelim|prelims|midterm|midterms|prefinal|prefinals|final|finals|exam|examination|assessment|eval)\b/i;
}
function getVideoRegex() {
return /\b(video|vid|vids|watch|recording|recordings|webinar|clip|panopto|zoom|stream)\b/i;
}
function getLectureRegex() {
return /\b(lec|lecture|lectures|lesson|reading|topic|module|handout|guide|discussion|notes)\b/i;
}
function classifyActivity(activityElem) {
const text = (activityElem.innerText || '').toLowerCase();
const html = activityElem.innerHTML.toLowerCase();
// 1. Video
const isVideoMod = Boolean(activityElem.querySelector(
'[data-modname*="video"], [data-modtype*="video"], a[href*="youtube"], a[href*="vimeo"], a[href*="panopto"], iframe, video'
)) || activityElem.classList.contains('modtype_videostream') || activityElem.classList.contains('modtype_kalvidres');
const videoRegex = getVideoRegex();
if (isVideoMod || videoRegex.test(text) || html.includes('youtube.com') || html.includes('youtu.be') || html.includes('vimeo.com')) {
return {
type: 'video',
reason: isVideoMod ? 'video-mod' : 'video-keyword',
color: 'var(--accent-purple)',
badge: 'Video'
};
}
// 2. Quiz / Exam
const isQuizMod = activityElem.classList.contains('modtype_quiz') ||
Boolean(activityElem.querySelector('[data-modname="quiz"], [data-modtype="quiz"], a[href*="/mod/quiz/"], img[src*="quiz"]'));
const iconContainer = activityElem.querySelector('.activityiconcontainer, .activity-icon');
let hasPinkIcon = false;
let iconBgColor = '';
if (iconContainer) {
iconBgColor = window.getComputedStyle(iconContainer).backgroundColor;
if (iconContainer.classList.contains('assessment') || iconContainer.classList.contains('evaluations') || iconContainer.classList.contains('bg-pink')) {
hasPinkIcon = true;
} else {
const rgb = iconBgColor.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const [r, g, b] = rgb.map(Number);
if (r > 160 && b > 100 && g < 140) hasPinkIcon = true;
}
}
}
const quizRegex = getQuizRegex();
if (isQuizMod || hasPinkIcon || quizRegex.test(text)) {
const isExam = /exam|examination|prelim|prelims|midterm|midterms|prefinal|prefinals|final\b|finals/i.test(text) ||
(typeof detectAssessmentOrigin === 'function' && detectAssessmentOrigin().isExam);
return {
type: 'quiz',
isExam: isExam,
reason: isQuizMod ? 'quiz-module' : hasPinkIcon ? 'pink-icon' : 'keyword',
color: isExam ? 'var(--accent-blue, #2563eb)' : 'var(--accent-pink)',
badge: isExam ? 'Exam' : 'Quiz'
};
}
// 3. Lecture / Reading
const isLectureMod = Boolean(activityElem.querySelector(
'.modtype_page, .modtype_resource, .modtype_url, .modtype_book, .modtype_folder, ' +
'[data-modname="page"], [data-modname="resource"], [data-modname="url"], [data-modname="book"], ' +
'a[href*="/mod/page/"], a[href*="/mod/resource/"], a[href*="/mod/url/"], a[href*="/mod/book/"]'
)) || activityElem.classList.contains('modtype_page') || activityElem.classList.contains('modtype_resource');
let hasBlueIcon = false;
if (iconContainer) {
if (iconContainer.classList.contains('content') || iconContainer.classList.contains('bg-blue') || iconContainer.classList.contains('content-blue')) {
hasBlueIcon = true;
} else {
const rgb = iconBgColor.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const [r, g, b] = rgb.map(Number);
if (b > 150 && b > r + 30) hasBlueIcon = true;
}
}
}
const lectureRegex = getLectureRegex();
if (isLectureMod || hasBlueIcon || lectureRegex.test(text)) {
return {
type: 'lecture',
reason: isLectureMod ? 'resource-module' : hasBlueIcon ? 'blue-icon' : 'keyword',
color: 'var(--accent-blue)',
badge: 'Lecture'
};
}
return { type: 'lecture', reason: 'default-non-quiz', color: 'var(--accent-blue)', badge: 'Lecture' };
}
// ==========================================
// Quiz Passable Grade (≥80%) Safety Evaluator
// ==========================================
async function fetchCourseGradesMap() {
const courseInfo = typeof detectCourseInfo === 'function' ? detectCourseInfo() : {};
const courseId = courseInfo && courseInfo.courseId ? courseInfo.courseId : '';
// 1. In-memory cache valid for 60s
if (typeof window !== 'undefined' && window.__amaesCourseGradesCache && window.__amaesCourseGradesCacheTime && (Date.now() - window.__amaesCourseGradesCacheTime < 60000)) {
return window.__amaesCourseGradesCache;
}
// 2. Persistent localStorage cache valid for 10 minutes
if (courseId) {
try {
const storedRaw = localStorage.getItem(`amaes_course_grades_cache_${courseId}`);
const storedTime = parseInt(localStorage.getItem(`amaes_course_grades_cache_time_${courseId}`) || '0', 10);
if (storedRaw && storedTime && (Date.now() - storedTime < 600000)) {
const parsed = JSON.parse(storedRaw);
if (typeof window !== 'undefined') {
window.__amaesCourseGradesCache = parsed;
window.__amaesCourseGradesCacheTime = storedTime;
}
return parsed;
}
} catch (_) {}
}
let gradesUrl = null;
if (typeof document !== 'undefined') {
const gradesLink = document.querySelector('a[href*="/grade/report/user/index.php"]');
if (gradesLink && gradesLink.href) {
gradesUrl = gradesLink.href;
} else if (courseId) {
const semPath = typeof getSemesterBasePath === 'function' ? getSemesterBasePath() : '/';
gradesUrl = `${window.location.origin}${semPath}grade/report/user/index.php?id=${courseId}`;
}
}
if (!gradesUrl) return {};
try {
const resp = await fetch(gradesUrl);
if (!resp.ok) return {};
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const map = {};
const rows = Array.from(doc.querySelectorAll('.user-grade tr, .generaltable tr, table.table tr, tr'));
rows.forEach(r => {
const quizLink = r.querySelector('a[href*="/mod/quiz/"], a[href*="quiz"], a.gradeitemheader')
|| r.querySelector('.column-itemname a, th a, td:first-child a');
if (!quizLink) return;
const href = quizLink.getAttribute('href') || quizLink.href || '';
const rawTitle = quizLink.innerText.trim();
if (!rawTitle) return;
const gradeCell = r.querySelector('.column-grade, [headers*="grade"], td.grade');
const pctCell = r.querySelector('.column-percentage, [headers*="percentage"]');
let pct = null;
let hasGrade = false;
let gradeStr = '';
if (pctCell) {
const pText = pctCell.innerText.trim();
const mPct = pText.match(/(\d+(?:\.\d+)?)\s*%/);
if (mPct) {
pct = parseFloat(mPct[1]);
hasGrade = true;
gradeStr = `${pct}%`;
}
}
if (gradeCell) {
const gText = gradeCell.innerText.trim();
if (gText && gText !== '-' && gText !== '–' && /\d/.test(gText)) {
hasGrade = true;
if (!gradeStr) gradeStr = gText;
if (pct === null) {
const mFrac = gText.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (mFrac) {
const earned = parseFloat(mFrac[1]);
const max = parseFloat(mFrac[2]);
if (max > 0) pct = Math.round((earned / max) * 100);
} else {
const mNum = gText.match(/(\d+(?:\.\d+)?)/);
if (mNum && parseFloat(mNum[1]) <= 100) {
pct = parseFloat(mNum[1]);
}
}
}
}
}
const mCmid = href.match(/[?&]id=(\d+)/);
const cmid = mCmid ? mCmid[1] : null;
const normTitle = rawTitle.toLowerCase().replace(/[^a-z0-9]/g, '');
const entry = {
title: rawTitle,
normTitle: normTitle,
href: href,
cmid: cmid,
hasGrade: hasGrade,
percentage: pct,
gradeStr: gradeStr,
isPassable: Boolean(hasGrade && pct !== null && pct >= 80)
};
map[normTitle] = entry;
if (cmid) map[`cmid_${cmid}`] = entry;
});
if (typeof window !== 'undefined') {
window.__amaesCourseGradesCache = map;
window.__amaesCourseGradesCacheTime = Date.now();
if (courseId) {
try {
localStorage.setItem(`amaes_course_grades_cache_${courseId}`, JSON.stringify(map));
localStorage.setItem(`amaes_course_grades_cache_time_${courseId}`, String(Date.now()));
} catch (_) {}
}
}
return map;
} catch (e) {
console.warn('Failed to fetch course grades map:', e);
return {};
}
}
function evaluateQuizPassableGrade(container, title = '', gradesMap = null) {
// Resolve container cmid if available (id="module-123" or data-id="123")
let moduleId = (container && container.id ? container.id.replace(/^module-/, '') : '') || (container && container.dataset ? container.dataset.id : '') || '';
if (!moduleId && container && container.querySelector) {
const link = container.querySelector('a[href*="/mod/quiz/view.php?id="], a[href*="id="]');
if (link) {
const m = (link.getAttribute('href') || link.href || '').match(/[?&]id=(\d+)/);
if (m) moduleId = m[1];
}
}
const normTitle = (title || (container && container.innerText ? container.innerText.split('\n')[0] : '') || '').toLowerCase().replace(/[^a-z0-9]/g, '');
// 0. Fast-track explicit 100% quiz completion cache check
if (typeof localStorage !== 'undefined') {
if (moduleId && localStorage.getItem(`amaes_quiz_100_cmid_${moduleId}`) === 'true') {
return { isPassable: true, hasGrade: true, percentage: 100, cmid: moduleId, source: 'quiz-100-cache-cmid' };
}
if (normTitle && localStorage.getItem(`amaes_quiz_100_title_${normTitle}`) === 'true') {
return { isPassable: true, hasGrade: true, percentage: 100, cmid: moduleId, source: 'quiz-100-cache-title' };
}
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('amaes_quiz_100_title_')) {
const savedNorm = key.replace('amaes_quiz_100_title_', '');
if (savedNorm && normTitle && (savedNorm === normTitle || (savedNorm.length > 5 && (normTitle.includes(savedNorm) || savedNorm.includes(normTitle))))) {
return { isPassable: true, hasGrade: true, percentage: 100, cmid: moduleId, source: 'quiz-100-cache-title-fuzzy' };
}
}
}
} catch (_) {}
}
// 1. Check gradesMap if provided or cached
const map = gradesMap || (typeof window !== 'undefined' ? window.__amaesCourseGradesCache : null);
if (map && Object.keys(map).length > 0) {
if (moduleId && map[`cmid_${moduleId}`]) {
const entry = map[`cmid_${moduleId}`];
return {
isPassable: entry.isPassable,
hasGrade: entry.hasGrade,
percentage: entry.percentage,
cmid: moduleId,
source: 'grades-report'
};
}
if (normTitle && map[normTitle]) {
const entry = map[normTitle];
return {
isPassable: entry.isPassable,
hasGrade: entry.hasGrade,
percentage: entry.percentage,
cmid: entry.cmid || moduleId,
source: 'grades-report'
};
}
// Fuzzy match title
for (const k in map) {
if (k.startsWith('cmid_')) continue;
if (k.length > 5 && (normTitle.includes(k) || k.includes(normTitle))) {
const entry = map[k];
return {
isPassable: entry.isPassable,
hasGrade: entry.hasGrade,
percentage: entry.percentage,
source: 'grades-report-fuzzy'
};
}
}
}
// 2. Fallback: Parse container text for explicit grade or pass/fail markers
const text = (container.innerText || '').toLowerCase();
// Check for explicit fail markers
if (/did not achieve pass grade|failed to pass|\bfailed\b/i.test(text)) {
return { isPassable: false, hasGrade: true, percentage: 0, source: 'container-fail' };
}
// Check for explicit pass markers
if (/achieved pass grade|pass grade achieved|\bpassed\b/i.test(text)) {
return { isPassable: true, hasGrade: true, percentage: 100, source: 'container-pass' };
}
// Check for explicit percentage in container
const mPct = text.match(/(\d+(?:\.\d+)?)\s*%/);
if (mPct) {
const val = parseFloat(mPct[1]);
return { isPassable: val >= 80, hasGrade: true, percentage: val, source: 'container-pct' };
}
// Check for fractions e.g. "8/10" or "80/100" or "Grade: 9 out of 10"
const mFrac = text.match(/(\d+(?:\.\d+)?)\s*(?:\/|out of)\s*(\d+(?:\.\d+)?)/i);
if (mFrac) {
const earned = parseFloat(mFrac[1]);
const max = parseFloat(mFrac[2]);
if (max > 0) {
const val = Math.round((earned / max) * 100);
return { isPassable: val >= 80, hasGrade: true, percentage: val, source: 'container-fraction' };
}
}
// 3. No grade recorded or unattempted: MUST NOT TOUCH!
return { isPassable: false, hasGrade: false, percentage: null, source: 'no-grade' };
}
function isActivityAlreadyComplete(container) {
if (!container) return false;
// 0. Immediate short-circuit: If container has an uncompleted manual completion button, it is NOT complete!
const manualUncompletedBtn = container.querySelector(
'button[data-toggletype="manual:mark-done"], button[data-action="toggle-manual-completion"]:not([data-toggletype="manual:undo"])'
);
if (manualUncompletedBtn) {
const btnText = (manualUncompletedBtn.innerText || manualUncompletedBtn.getAttribute('aria-label') || manualUncompletedBtn.textContent || '').trim().toLowerCase();
if (btnText.includes('mark as done') || btnText.includes('to do') || manualUncompletedBtn.getAttribute('data-toggletype') === 'manual:mark-done') {
return false;
}
}
const anyBtns = container.querySelectorAll('button, input[type="submit"], input[type="button"]');
for (const b of anyBtns) {
const bText = (b.innerText || b.getAttribute('aria-label') || b.value || '').trim().toLowerCase();
if (bText.includes('mark as done') || b.getAttribute('data-toggletype') === 'manual:mark-done') {
return false;
}
}
// 1. Direct class checks on container
if (container.classList.contains('completed')) return true;
// 2. Completed icons or toggled buttons
if (container.querySelector(
'.iscompleted, [data-toggled="true"], button[aria-checked="true"], button.btn-success, button.btn-outline-success, button[data-toggletype="manual:undo"]'
)) return true;
// 3. Scan completion area for completion markers
const completionArea = container.querySelector(
'.activity-completion, [data-region="completion-info"], .automatic-completion-conditions, .completion-info'
) || container;
if (completionArea.querySelector('.badge-success, .text-success, [data-region="completion-info"] .badge-success')) return true;
// 4. Check completionArea text content for "Done" or "Completed" (strictly excluding "mark as done" and "to do")
const fullText = (completionArea.innerText || completionArea.textContent || '').trim().toLowerCase();
if (!fullText.includes('mark as done') && !fullText.includes('to do')) {
if (fullText.includes('done:') || fullText.includes('done :') || /\bdone\b/i.test(fullText) || fullText.includes('completed')) {
return true;
}
}
// 5. Scan all buttons inside container for manual undo or done text (strictly excluding "mark as done")
for (const b of anyBtns) {
const bText = (b.innerText || b.getAttribute('aria-label') || b.value || '').trim().toLowerCase();
const bToggle = (b.getAttribute('data-toggletype') || '').toLowerCase();
if (bToggle === 'manual:undo' || (b.classList && (b.classList.contains('btn-outline-success') || b.classList.contains('btn-success')))) {
return true;
}
if (!bText.includes('mark as done') && !bText.includes('to do')) {
if (bText === 'done' || bText.startsWith('done') || bText.includes('✓') || bText.includes('✔') || bText.includes('completed')) {
return true;
}
}
}
return false;
}
function isActivityLockedOrRestricted(container) {
if (!container) return false;
// 1. Direct class checks indicating Moodle restriction or dimmed state
if (container.classList.contains('dimmed') ||
container.classList.contains('dimmed_text') ||
container.classList.contains('conditionalhidden') ||
container.classList.contains('isrestricted') ||
container.classList.contains('restricted') ||
container.classList.contains('locked')) {
return true;
}
// 2. Specific restriction / availability / lock indicators in children
if (container.querySelector('.dimmed, .dimmed_text, .conditionalhidden, .restrictions, .availabilityinfo, [data-region="activity-information"], .fa-lock, .icon-lock, [data-region="locked"], [aria-label*="locked" i], [aria-label*="restricted" i]')) {
const restrictElem = container.querySelector('.restrictions, .availabilityinfo, [data-region="activity-information"], .badge-info, .badge, .fa-lock, .icon-lock');
if (restrictElem) {
const rText = (restrictElem.innerText || restrictElem.textContent || '').trim();
if (/not available unless|restricted|not available until|hidden from students|takeexamcode|locked/i.test(rText) || restrictElem.matches('.fa-lock, .icon-lock')) {
return true;
}
}
}
// 3. Text content scan for Moodle conditional access strings (e.g. "TakeExamCode contains 4", "Restricted Not available unless:")
const fullText = (container.innerText || container.textContent || '').trim();
if (/restricted:?\s+not available|not available unless|not available until|takeexamcode|prerequisite|must be completed first|activity.*is marked complete/i.test(fullText)) {
return true;
}
// 4. Missing, disabled, or dimmed link on activity container (locked exams have no active clickable link)
// If an item has an interactive manual completion button, it is never an inaccessible locked exam!
const hasManualBtn = Boolean(container.querySelector('button[data-action="toggle-manual-completion"], button[data-toggletype], form.togglecompletion'));
if (!hasManualBtn) {
const isQuizOrExam = container.classList.contains('modtype_quiz') || Boolean(container.querySelector('[data-modname="quiz"], a[href*="/mod/quiz/"]'));
if (isQuizOrExam) {
const link = container.querySelector('a.aal_link, a[href*="/mod/quiz/"], a[href*="/mod/"]');
if (!link || link.getAttribute('aria-disabled') === 'true' || link.classList.contains('dimmed') || link.hasAttribute('disabled')) {
if (container.matches('.activity, .activity-item, li.activity, div[data-region="activity-card"]')) {
return true;
}
}
}
}
return false;
}
function findButtons(goal = 'mark_done', category = 'lecture', gradesMap = null) {
const results = [];
const activityElements = document.querySelectorAll(
'li.activity, .activity-item, .course-section .activity, div[data-region="activity-card"]'
);
const processedButtons = new Set();
const processedContainers = new Set();
const scanBlock = (container) => {
if (!container) return;
// Smart Skip: If goal is to mark done, completely skip activities that are already done
if (goal === 'mark_done' && isActivityAlreadyComplete(container)) {
return;
}
// Locked / Restricted Skip: If activity or exam is restricted/locked, cannot be clicked or auto-marked done!
if (isActivityLockedOrRestricted(container)) {
return;
}
const buttons = container.querySelectorAll(
'button[data-action="toggle-manual-completion"], ' +
'button[data-toggletype], ' +
'button.btn-outline-secondary, ' +
'button.btn-outline-success, ' +
'button.btn-success, ' +
'button, ' +
'input[type="submit"][name*="completion" i], ' +
'input[type="submit"][value*="done" i], ' +
'input[type="image"][name*="completion" i], ' +
'form.togglecompletion input[type="submit"], ' +
'form.togglecompletion button'
);
let foundManualButton = false;
for (const btn of buttons) {
if (processedButtons.has(btn)) continue;
const text = (btn.innerText || btn.getAttribute('aria-label') || btn.value || '').trim().toLowerCase();
const toggleType = (btn.getAttribute('data-toggletype') || '').toLowerCase();
const isToggleAction = btn.getAttribute('data-action') === 'toggle-manual-completion';
const isToggleCompletionForm = Boolean(btn.closest('form.togglecompletion'));
const isMarkDoneText = text.includes('mark as done') || text === 'mark as done' || toggleType === 'manual:mark-done';
const isCurrentlyDone = !isMarkDoneText && (
toggleType === 'manual:undo' ||
((text === 'done' || text.startsWith('done') || text.includes('✓') || text.includes('✔') || (text.includes('completed') && !text.includes('incomplete'))) &&
(isToggleAction || toggleType || isToggleCompletionForm || btn.classList.contains('btn-outline-success') || btn.classList.contains('btn-success'))) ||
((btn.classList.contains('btn-success') || btn.classList.contains('btn-outline-success')) && (isToggleAction || toggleType))
);
const isCurrentlyUncompleted = isMarkDoneText ||
(text === 'to do' && (isToggleAction || toggleType)) ||
(isToggleAction && !isCurrentlyDone) ||
(isToggleCompletionForm && !isCurrentlyDone);
let matchesGoal = false;
if (goal === 'mark_done' && isCurrentlyUncompleted && !isCurrentlyDone) {
matchesGoal = true;
} else if (goal === 'undo' && isCurrentlyDone) {
matchesGoal = true;
}
if (matchesGoal) {
processedButtons.add(btn);
foundManualButton = true;
processedContainers.add(container);
const classification = classifyActivity(container);
const titleElem = container.querySelector('.instancename, .activityname, a.aal_link, .activity-title');
const title = titleElem ? titleElem.innerText.trim() : (container.innerText.split('\n')[0] || 'Activity');
let gradeInfo = null;
// Safety Guard: For quizzes when marking done, ONLY mark if they have a passable grade (>= 80%)!
if (goal === 'mark_done' && classification.type === 'quiz') {
gradeInfo = evaluateQuizPassableGrade(container, title, gradesMap);
if (!gradeInfo.isPassable) {
// Leave untouched!
continue;
}
}
let matchesCategory = false;
if (category === 'all') matchesCategory = true;
else if (category === 'lecture' && (classification.type === 'lecture' || classification.type === 'video')) matchesCategory = true;
else if (category === 'quiz' && classification.type === 'quiz') matchesCategory = true;
if (matchesCategory) {
results.push({
button: btn,
container,
title,
classification,
isAutoView: false,
gradePct: gradeInfo ? gradeInfo.percentage : null
});
}
}
}
// 2. Automatic "To do: View" completion detection (activities requiring view to complete)
if (!foundManualButton && goal === 'mark_done' && !processedContainers.has(container)) {
const completionArea = container.querySelector(
'.activity-completion, [data-region="completion-info"], .automatic-completion-conditions, .completion-info'
) || container;
const badges = completionArea.querySelectorAll('.badge, button, [data-region="completion-info"] span, .automatic-completion-conditions span');
let uncompletedViewBadge = null;
for (const badge of badges) {
if (badge.closest('.badge-success, .completed, .iscompleted')) continue;
const text = (badge.innerText || badge.textContent || '').trim().toLowerCase();
const isTodoView = text.includes('to do: view') || text.includes('to do:view') || (text.includes('to do') && text.includes('view')) || text === 'view';
if (isTodoView && !text.includes('done') && !text.includes('completed') && !badge.classList.contains('badge-success')) {
uncompletedViewBadge = badge;
break;
}
}
if (uncompletedViewBadge) {
const linkElem = container.querySelector('a.aal_link, a[href*="/mod/"], a.activityname, a.stretched-link, a[href*="view.php"]');
const activityUrl = linkElem ? linkElem.href : null;
if (activityUrl) {
const classification = classifyActivity(container);
const titleElem = container.querySelector('.instancename, .activityname, a.aal_link, .activity-title');
const title = titleElem ? titleElem.innerText.trim() : (container.innerText.split('\n')[0] || 'Activity');
let matchesCategory = false;
if (category === 'all') matchesCategory = true;
else if (category === 'lecture' && (classification.type === 'lecture' || classification.type === 'video')) matchesCategory = true;
else if (category === 'quiz' && classification.type === 'quiz') matchesCategory = true;
if (matchesCategory) {
processedContainers.add(container);
results.push({
button: null,
autoViewUrl: activityUrl,
badgeElem: uncompletedViewBadge,
container,
title,
classification,
isAutoView: true,
gradePct: null
});
}
}
}
}
};
if (activityElements.length > 0) {
activityElements.forEach(scanBlock);
} else {
const rawButtons = document.querySelectorAll(
'button[data-action="toggle-manual-completion"], button[data-toggletype], form.togglecompletion input[type="submit"], form.togglecompletion button'
);
rawButtons.forEach(btn => {
const parent = btn.closest('li, div.activity, div.row, div.card, div.activity-item') || btn.parentElement;
scanBlock(parent);
});
}
return results;
}
async function autoViewActivity(url, badgeElem = null, container = null) {
if (!url) return false;
let originalBadgeText = '';
if (badgeElem) {
originalBadgeText = badgeElem.innerText || badgeElem.textContent || '';
badgeElem.dataset.originalText = originalBadgeText;
badgeElem.textContent = 'Viewing...';
badgeElem.style.opacity = '0.7';
badgeElem.style.cursor = 'wait';
}
try {
await fetch(url, {
method: 'GET',
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
} catch (err) {
// Even if fetch throws (e.g. cross-origin redirect on URL module), Moodle logged course_module_viewed
logDebug(`Auto-view fetch handled for ${url}: ${err.message}`);
if (typeof GM_xmlhttpRequest === 'function') {
try {
await new Promise(res => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
onload: () => res(true),
onerror: () => res(false),
ontimeout: () => res(false)
});
});
} catch (_) {}
}
}
if (badgeElem) {
badgeElem.textContent = 'Done: View';
badgeElem.style.opacity = '1';
badgeElem.style.cursor = 'default';
badgeElem.classList.remove('badge-secondary', 'badge-light', 'badge-info', 'badge-warning', 'badge-danger');
badgeElem.classList.add('badge-success');
badgeElem.style.backgroundColor = '#198754';
badgeElem.style.color = '#ffffff';
badgeElem.style.borderColor = '#198754';
badgeElem.removeAttribute('title');
}
if (container) {
container.classList.add('completed');
const compIcon = container.querySelector('.iscompleted, .activity-completion');
if (compIcon) compIcon.classList.add('completed');
}
return true;
}
function setupAutoViewBadges() {
if (typeof checkIsCoursePage === 'function' && !checkIsCoursePage()) return;
const containers = document.querySelectorAll(
'li.activity, .activity-item, .course-section .activity, div[data-region="activity-card"], .activity-instance'
);
containers.forEach(container => {
// Smart Skip: If activity is already done/completed, do not bind or view
if (isActivityAlreadyComplete(container)) return;
// Locked / Restricted Skip: If activity or exam is restricted/locked, cannot be clicked or auto-viewed
if (isActivityLockedOrRestricted(container)) return;
const completionArea = container.querySelector(
'.activity-completion, [data-region="completion-info"], .automatic-completion-conditions, .completion-info'
) || container;
const badges = completionArea.querySelectorAll('.badge, button, [data-region="completion-info"] span, .automatic-completion-conditions span');
for (const badge of badges) {
if (badge.closest('.badge-success, .completed, .iscompleted')) continue;
const text = (badge.innerText || badge.textContent || '').trim().toLowerCase();
const isTodoView = text.includes('to do: view') || text.includes('to do:view') || (text.includes('to do') && text.includes('view'));
if (isTodoView && !text.includes('done') && !text.includes('completed') && !badge.classList.contains('badge-success')) {
if (badge.dataset.amaesAutoViewBound) continue;
badge.dataset.amaesAutoViewBound = 'true';
const linkElem = container.querySelector('a.aal_link, a[href*="/mod/"], a.activityname, a.stretched-link, a[href*="view.php"]');
const activityUrl = linkElem ? linkElem.href : null;
if (!activityUrl) continue;
// Style badge to indicate 1-click completion
badge.style.cursor = 'pointer';
badge.style.transition = 'all 0.2s ease';
badge.title = 'Click to auto-view and mark as done!';
badge.setAttribute('role', 'button');
// Hover effects
badge.addEventListener('mouseenter', () => {
if (!badge.textContent.includes('Done')) {
badge.style.transform = 'scale(1.05)';
badge.style.boxShadow = '0 0 6px rgba(46, 204, 113, 0.6)';
}
});
badge.addEventListener('mouseleave', () => {
badge.style.transform = '';
badge.style.boxShadow = '';
});
badge.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (badge.dataset.amaesAutoViewing === 'true') return;
badge.dataset.amaesAutoViewing = 'true';
const titleElem = container.querySelector('.instancename, .activityname, a.aal_link, .activity-title');
const title = titleElem ? titleElem.innerText.trim() : 'Activity';
showToast(`Auto-viewing "${title.substring(0, 24)}"...`, 1500);
setLog(`Auto-Viewing: ${title.substring(0, 30)}...`, "var(--accent-blue)");
await autoViewActivity(activityUrl, badge, container);
playToolkitSound('quest_done');
showToast(`Marked "${title.substring(0, 24)}" as viewed & complete!`, 3000);
setLog(`Auto-Viewed: ${title.substring(0, 30)}... Completed!`, "var(--accent-green)");
badge.dataset.amaesAutoViewing = 'false';
});
break;
}
}
});
}
// ==========================================
// Visual Course Highlighter
// ==========================================
function clearAllHighlights() {
const highlighted = document.querySelectorAll('.amaes-highlighted-item');
highlighted.forEach(el => {
el.classList.remove('amaes-highlighted-item');
el.style.outline = '';
el.style.boxShadow = '';
el.style.position = '';
const badge = el.querySelector('.amaes-type-badge');
if (badge) badge.remove();
});
}
function highlightItems(targetCategory = 'all') {
clearAllHighlights();
if (!checkIsCoursePage()) {
return { count: 0, error: 'Not a course page' };
}
const activities = document.querySelectorAll(
'li.activity, .activity-item, .course-section .activity, div[data-region="activity-card"]'
);
let count = 0;
activities.forEach(el => {
const classification = classifyActivity(el);
let shouldHighlight = false;
if (targetCategory === 'all') shouldHighlight = true;
else if (targetCategory === classification.type) shouldHighlight = true;
if (shouldHighlight) {
count++;
el.classList.add('amaes-highlighted-item');
el.style.position = 'relative';
const isLocked = isActivityLockedOrRestricted(el);
const borderColor = isLocked ? 'var(--accent-amber, #f59e0b)' : classification.color;
el.style.outline = isLocked ? `2px dashed ${borderColor}` : `2px solid ${borderColor}`;
el.style.borderRadius = '8px';
el.style.boxShadow = `0 0 10px ${borderColor}33`;
const badge = document.createElement('span');
badge.className = 'amaes-type-badge';
badge.innerText = isLocked ? `[Locked] ${classification.badge}` : classification.badge;
badge.style.cssText = `
position: absolute;
top: 6px;
right: 6px;
background: ${isLocked ? '#d97706' : classification.color};
color: #ffffff;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
z-index: 10;
pointer-events: none;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
`;
el.appendChild(badge);
}
});
return { count, targetCategory };
}
// ==========================================
// Quiz Landing Page Data & Attempt Parser
// ==========================================
function parseQuizLandingData(doc = document, pageUrl = (typeof window !== 'undefined' ? window.location.href : '')) {
let cmid = null;
const mId = (pageUrl || '').match(/[?&]id=(\d+)/);
if (mId) cmid = mId[1];
const text = (doc.body ? doc.body.innerText : (doc.documentElement ? doc.documentElement.innerText : '')) || '';
// 1. Attempts allowed
let attemptsAllowed = null;
const mAllowed = text.match(/attempts allowed:\s*(\d+)/i);
if (mAllowed) {
attemptsAllowed = parseInt(mAllowed[1], 10);
}
// 2. Count finished attempts from summary table
let attemptsUsed = 0;
const attemptRows = doc.querySelectorAll('.generaltable tbody tr, table.generaltable tr');
attemptRows.forEach(r => {
const rText = r.innerText.toLowerCase();
if (rText.includes('finished') || rText.includes('submitted') || rText.includes('review') || /attempt\s*\d+/i.test(rText)) {
if (/\d/.test(r.querySelector('td') ? r.querySelector('td').innerText : '')) {
attemptsUsed++;
}
}
});
if (attemptsUsed === 0) {
const matches = Array.from(text.matchAll(/(?:attempt|preview)\s+(\d+)/gi));
if (matches.length > 0) {
const nums = matches.map(m => parseInt(m[1], 10)).filter(n => !isNaN(n));
if (nums.length > 0) attemptsUsed = Math.max(...nums);
}
}
// 3. Highest grade / Overall grade
let highestGradeStr = '';
let highestPct = null;
let hasGrade = false;
const mHighest = text.match(/highest grade:\s*(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i)
|| text.match(/overall grade for this quiz:\s*(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i);
if (mHighest) {
const earned = parseFloat(mHighest[1]);
const max = parseFloat(mHighest[2]);
if (max > 0) {
highestPct = Math.round((earned / max) * 100);
highestGradeStr = `${earned} / ${max} (${highestPct}%)`;
hasGrade = true;
}
}
if (highestPct === null) {
const gradeCells = doc.querySelectorAll('.generaltable td.c3, .generaltable td.c2, .generaltable td');
let maxFoundPct = -1;
gradeCells.forEach(cell => {
const cText = cell.innerText.trim();
const mCellFrac = cText.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
if (mCellFrac) {
const earned = parseFloat(mCellFrac[1]);
const max = parseFloat(mCellFrac[2]);
if (max > 0) {
const p = Math.round((earned / max) * 100);
if (p > maxFoundPct) {
maxFoundPct = p;
highestGradeStr = cText;
hasGrade = true;
}
}
} else {
const mNum = cText.match(/^(\d+(?:\.\d+)?)$/);
if (mNum) {
const val = parseFloat(mNum[1]);
if (val <= 100 && val > maxFoundPct) {
maxFoundPct = val;
highestGradeStr = `${val}%`;
hasGrade = true;
}
}
}
});
if (maxFoundPct >= 0) {
highestPct = maxFoundPct;
}
}
// 4. Can re-attempt / start attempt
const startBtn = doc.querySelector(
'form[action*="attempt.php"] button, form[action*="attempt.php"] input[type="submit"], ' +
'.quizstartbutton button, .quizstartbutton input[type="submit"], ' +
'#region-main button.btn-primary, #region-main input.btn-primary'
);
const noMoreAllowed = text.toLowerCase().includes('no more attempts are allowed');
const maxAttemptsReached = Boolean(noMoreAllowed || (attemptsAllowed !== null && attemptsUsed >= attemptsAllowed));
const canReattempt = Boolean(startBtn && !maxAttemptsReached);
const isPassable = Boolean(hasGrade && highestPct !== null && highestPct >= 80);
return {
cmid,
attemptsAllowed,
attemptsUsed,
highestGradeStr,
highestPct,
hasGrade,
isPassable,
canReattempt,
maxAttemptsReached
};
}
// Auto-Highlight unanswered / missing quizzes on Grades, Course, or Quiz view pages
async function highlightMissingOrUnansweredQuizzes() {
clearAllHighlights();
const isGrades = typeof window !== 'undefined' && window.location.pathname.includes('/grade/report/user/index.php');
const isCourse = checkIsCoursePage();
const isQuizLanding = typeof window !== 'undefined' && window.location.pathname.includes('/mod/quiz/view.php');
if (!isGrades && !isCourse && !isQuizLanding) {
return { count: 0, error: 'Not on a course, grades, or quiz page', message: 'Open a course, Grades, or Quiz page first to highlight missing quizzes.' };
}
let count = 0;
let firstScrolled = false;
if (isQuizLanding) {
const data = parseQuizLandingData(document, window.location.href);
if (data.cmid) {
try {
localStorage.setItem('amaes_quiz_attempts_' + data.cmid, JSON.stringify(data));
} catch (e) {}
}
if (data.isPassable) {
return {
count: 0,
isQuizLanding: true,
passed: true,
message: `Quiz already completed with passing grade (${data.highestPct}% ≥ 80%)! No further attempts required.`
};
}
const startBtn = document.querySelector(
'form[action*="attempt.php"] button, form[action*="attempt.php"] input[type="submit"], ' +
'.quizstartbutton button, .quizstartbutton input[type="submit"], ' +
'#region-main button.btn-primary, #region-main input.btn-primary'
);
if (startBtn) {
count++;
startBtn.classList.add('amaes-highlighted-item');
startBtn.style.outline = '3px solid #f59e0b';
startBtn.style.boxShadow = '0 0 15px rgba(245, 158, 11, 0.6)';
const badge = document.createElement('span');
badge.className = 'amaes-type-badge';
badge.innerText = data.hasGrade
? `NEEDS 80%+ (Current: ${data.highestPct}%)`
: 'UNATTEMPTED';
badge.style.cssText = `
background: #f59e0b;
color: #000;
font-size: 10px;
font-weight: 800;
padding: 3px 8px;
border-radius: 4px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
`;
startBtn.parentNode.insertBefore(badge, startBtn.nextSibling);
startBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else if (data.maxAttemptsReached) {
return {
count: 0,
isQuizLanding: true,
message: `Max attempts reached (${data.attemptsUsed}/${data.attemptsAllowed}). Score: ${data.highestPct}%. No more attempts allowed.`
};
}
return { count, isQuizLanding: true, attemptsUsed: data.attemptsUsed, attemptsAllowed: data.attemptsAllowed };
} else if (isGrades) {
const rows = Array.from(document.querySelectorAll('.user-grade tr, .generaltable tr, table.table tr, tr'));
rows.forEach(r => {
if (r.classList.contains('coursetotal') || r.classList.contains('category') || r.classList.contains('categoryitem') || r.classList.contains('courseitem')) return;
const quizLink = r.querySelector('a[href*="/mod/quiz/"], a[href*="quiz"], a.gradeitemheader')
|| r.querySelector('.column-itemname a, th a, td:first-child a');
if (!quizLink) return;
const href = quizLink.getAttribute('href') || quizLink.href || '';
const rawTitle = quizLink.innerText.trim();
if (!rawTitle || (!href.includes('quiz') && !r.innerText.toLowerCase().includes('quiz'))) return;
// Check grade
const gradeCell = r.querySelector('.column-grade, [headers*="grade"], td.grade');
const pctCell = r.querySelector('.column-percentage, [headers*="percentage"]');
let hasGrade = false;
let pct = null;
if (pctCell) {
const pText = pctCell.innerText.trim();
const mPct = pText.match(/(\d+(?:\.\d+)?)\s*%/);
if (mPct) {
pct = parseFloat(mPct[1]);
hasGrade = true;
}
}
if (gradeCell) {
const gText = gradeCell.innerText.trim();
if (gText && gText !== '-' && gText !== '–' && /\d/.test(gText)) {
hasGrade = true;
if (pct === null) {
const mFrac = gText.match(/(\d+(?:\.\d+)?)\s*(?:\/|\s*out of\s*)\s*(\d+(?:\.\d+)?)/i);
if (mFrac) {
const earned = parseFloat(mFrac[1]);
const max = parseFloat(mFrac[2]);
if (max > 0) pct = Math.round((earned / max) * 100);
} else {
const mNum = gText.match(/(\d+(?:\.\d+)?)/);
if (mNum && parseFloat(mNum[1]) <= 100) {
pct = parseFloat(mNum[1]);
}
}
}
}
}
// Strict 80%+ Rule: Only grades >= 80% are accepted!
const isAccepted = Boolean(hasGrade && pct !== null && pct >= 80);
if (isAccepted) {
return; // Quiz passed and accepted! Do NOT highlight!
}
count++;
r.classList.add('amaes-highlighted-item');
r.style.background = 'rgba(245, 158, 11, 0.18)';
r.style.outline = '2px solid #f59e0b';
const badge = document.createElement('span');
badge.className = 'amaes-type-badge';
badge.innerText = (hasGrade && pct !== null) ? `NEEDS 80%+ (${pct}%)` : 'UNATTEMPTED';
badge.style.cssText = `
background: #f59e0b;
color: #000;
font-size: 9px;
font-weight: 800;
padding: 1px 5px;
border-radius: 3px;
margin-left: 6px;
display: inline-block;
vertical-align: middle;
`;
quizLink.appendChild(badge);
if (!firstScrolled) {
r.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstScrolled = true;
}
});
} else if (isCourse) {
const gradesMap = await fetchCourseGradesMap();
const activities = Array.from(document.querySelectorAll(
'li.activity, .activity-item, .course-section .activity, div[data-region="activity-card"]'
));
const quizActivities = activities.filter(el => classifyActivity(el).type === 'quiz');
for (const el of quizActivities) {
const titleElem = el.querySelector('.instancename, .activityname, a.aal_link, .activity-title');
const rawTitle = titleElem ? titleElem.innerText.trim() : (el.innerText.split('\n')[0] || '');
const gradeInfo = evaluateQuizPassableGrade(el, rawTitle, gradesMap);
// 1. Strict Acceptance Guard: Overall grade >= 80% is accepted! Never highlight as missing!
if (gradeInfo.hasGrade && gradeInfo.percentage !== null && gradeInfo.percentage >= 80) {
continue;
}
// If gradesMap has no grade, check fallback completion only if gradesMap was empty
if (!gradeInfo.hasGrade && (!gradesMap || Object.keys(gradesMap).length === 0)) {
if (isActivityAlreadyComplete(el)) {
continue;
}
}
let cmid = gradeInfo.cmid;
const linkElem = el.querySelector('a[href*="/mod/quiz/view.php?id="], a[href*="id="]');
if (!cmid && linkElem) {
const m = (linkElem.getAttribute('href') || linkElem.href || '').match(/[?&]id=(\d+)/);
if (m) cmid = m[1];
}
// Check cached attempt details from prior visits to quiz landing
let attemptsInfo = null;
if (cmid) {
try {
const raw = localStorage.getItem('amaes_quiz_attempts_' + cmid);
if (raw) attemptsInfo = JSON.parse(raw);
} catch (e) {}
}
const isMaxReached = attemptsInfo && attemptsInfo.maxAttemptsReached;
const attemptsAllowed = attemptsInfo ? attemptsInfo.attemptsAllowed : null;
const attemptsUsed = attemptsInfo ? attemptsInfo.attemptsUsed : null;
const pct = (attemptsInfo && attemptsInfo.highestPct !== null) ? attemptsInfo.highestPct : gradeInfo.percentage;
let badgeText = 'MISSING / PENDING';
let badgeColor = '#f59e0b';
let badgeTextColor = '#000000';
if (isMaxReached) {
badgeText = pct !== null ? `MAX ATTEMPTS (${pct}%)` : 'MAX ATTEMPTS REACHED';
badgeColor = '#ef4444';
badgeTextColor = '#ffffff';
} else if (pct !== null) {
if (attemptsAllowed !== null && attemptsUsed !== null) {
badgeText = `RE-ATTEMPT (${pct}% | ${attemptsUsed}/${attemptsAllowed})`;
} else {
badgeText = `NEEDS 80%+ (${pct}%)`;
}
} else if (attemptsAllowed !== null) {
badgeText = `UNATTEMPTED (0/${attemptsAllowed})`;
} else {
badgeText = 'MISSING / PENDING';
}
count++;
el.classList.add('amaes-highlighted-item');
el.style.position = 'relative';
el.style.outline = `2px solid ${badgeColor}`;
el.style.borderRadius = '8px';
el.style.boxShadow = `0 0 12px ${badgeColor}66`;
const badge = document.createElement('span');
badge.className = 'amaes-type-badge';
badge.innerText = badgeText;
badge.style.cssText = `
position: absolute;
top: 6px;
right: 6px;
background: ${badgeColor};
color: ${badgeTextColor};
font-size: 9px;
font-weight: 800;
padding: 2px 6px;
border-radius: 4px;
z-index: 10;
pointer-events: none;
text-transform: uppercase;
`;
el.appendChild(badge);
if (!firstScrolled) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstScrolled = true;
}
}
}
return { count, isGrades, isCourse, isQuizLanding };
}
// Check if a quiz review attempt or score indicates a 100% perfect score
function checkQuizAttemptIs100Percent(harvested, rootDoc = (typeof document !== 'undefined' ? document : null)) {
if (!harvested && !rootDoc) return false;
// 1. Check harvested grade text
if (harvested && harvested.gradeText) {
const gradeStr = String(harvested.gradeText).toLowerCase();
if (gradeStr.includes('100%') || gradeStr.includes('100.00%') || gradeStr.includes('100.0%')) {
return true;
}
const matchOutOf = gradeStr.match(/(\d+(?:\.\d+)?)\s*(?:out of|\/)\s*(\d+(?:\.\d+)?)/i);
if (matchOutOf) {
const scored = parseFloat(matchOutOf[1]);
const max = parseFloat(matchOutOf[2]);
if (!isNaN(scored) && !isNaN(max) && max > 0 && scored >= max) {
return true;
}
}
}
// 2. Check review summary table in DOM
if (rootDoc) {
const summaryTable = rootDoc.querySelector('.quizreviewsummary, table.generaltable, #region-main table');
if (summaryTable) {
const text = summaryTable.innerText.toLowerCase();
if (text.includes('100%') || text.includes('100.00%')) return true;
const m = text.match(/grade[\s:]*(\d+(?:\.\d+)?)\s*(?:out of|\/)\s*(\d+(?:\.\d+)?)/i);
if (m) {
const sc = parseFloat(m[1]);
const mx = parseFloat(m[2]);
if (!isNaN(sc) && !isNaN(mx) && mx > 0 && sc >= mx) return true;
}
}
}
// 3. Check harvested question count: all questions correct
if (harvested && harvested.totalQuestions > 0 && harvested.harvestedCount === harvested.totalQuestions) {
return true;
}
return false;
}
// Trigger manual completion toggle on the current page if activity is not yet marked Done
function triggerManualCompletionOnPage() {
if (typeof document === 'undefined') return false;
const compButtons = document.querySelectorAll(
'button[data-action="toggle-manual-completion"], ' +
'button[data-toggletype="manual:mark-done"], ' +
'button.btn-outline-secondary, ' +
'button.btn-outline-success, ' +
'form.togglecompletion input[type="submit"], ' +
'form.togglecompletion button'
);
for (const btn of compButtons) {
const text = (btn.innerText || btn.getAttribute('aria-label') || btn.value || '').trim().toLowerCase();
const toggleType = (btn.getAttribute('data-toggletype') || '').toLowerCase();
const isToggleAction = btn.getAttribute('data-action') === 'toggle-manual-completion';
const isToggleCompletionForm = Boolean(btn.closest('form.togglecompletion'));
const isMarkDoneText = text.includes('mark as done') || toggleType === 'manual:mark-done';
const isAlreadyDone = toggleType === 'manual:undo' || text === 'done' || text.startsWith('done') || btn.classList.contains('btn-success');
if (!isAlreadyDone && (isMarkDoneText || isToggleAction || isToggleCompletionForm)) {
if (typeof triggerClick === 'function') triggerClick(btn);
else btn.click();
try {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-outline-success');
btn.style.opacity = '0.8';
} catch (_) {}
if (typeof showToast === 'function') showToast("Quiz scored 100%! Auto-marked activity as Done.", 3500);
if (typeof setLog === 'function') setLog("Auto-Completion: Quiz achieved 100%! Auto-marked activity as Done.", "var(--accent-green)");
return true;
}
}
return false;
}
// Auto-mark activity as Done on Quiz Landing page (/mod/quiz/view.php) if 100% grade is recorded
function checkAndAutoMarkDoneOnQuizLanding() {
if (typeof window === 'undefined' || !window.location.pathname.includes('/mod/quiz/view.php')) return;
const autoMark100 = (typeof localStorage !== 'undefined' ? localStorage.getItem('amaes_auto_mark_done_100') !== 'false' : true);
if (!autoMark100) return;
const cmid = new URLSearchParams(window.location.search).get('id') || '';
const cached100Cmid = cmid && localStorage.getItem(`amaes_quiz_100_cmid_${cmid}`) === 'true';
let rawTitle = '';
if (typeof document !== 'undefined') {
const h = document.querySelector('.page-header-headings h1, #region-main h2, h2, h3');
if (h) rawTitle = h.innerText.trim();
}
if (!rawTitle && typeof detectCourseInfo === 'function') {
rawTitle = detectCourseInfo().currentActivityTitle || '';
}
const normTitle = rawTitle.toLowerCase().replace(/[^a-z0-9]/g, '');
const cached100Title = normTitle && localStorage.getItem(`amaes_quiz_100_title_${normTitle}`) === 'true';
const pageText = document.body ? document.body.innerText : '';
let has100Grade = /highest grade:\s*(\d+(?:\.\d+)?)\s*\/\s*\1/i.test(pageText) ||
/highest grade:\s*100(?:\.00)?%/i.test(pageText) ||
/overall feedback[^\n]*100%/i.test(pageText) ||
/final grade[^\n]*100%/i.test(pageText) ||
/grade:[^\n]*100%/i.test(pageText) ||
cached100Cmid ||
cached100Title;
// Inspect previous attempts table in DOM
if (!has100Grade && typeof document !== 'undefined') {
const tableRows = document.querySelectorAll('table.generaltable tbody tr, .quizattemptsummary tbody tr, #region-main table tbody tr');
for (const row of tableRows) {
const rText = (row.innerText || '').toLowerCase();
if (rText.includes('100%') || rText.includes('100.00%') || /\b100(?:\.00)?\b/.test(rText)) {
has100Grade = true;
break;
}
const mFrac = rText.match(/(\d+(?:\.\d+)?)\s*(?:\/|out of)\s*(\d+(?:\.\d+)?)/i);
if (mFrac) {
const scored = parseFloat(mFrac[1]);
const max = parseFloat(mFrac[2]);
if (!isNaN(scored) && !isNaN(max) && max > 0 && scored >= max) {
has100Grade = true;
break;
}
}
}
}
if (has100Grade) {
if (cmid) localStorage.setItem(`amaes_quiz_100_cmid_${cmid}`, 'true');
if (normTitle) localStorage.setItem(`amaes_quiz_100_title_${normTitle}`, 'true');
const done = triggerManualCompletionOnPage();
if (!done) {
setTimeout(() => triggerManualCompletionOnPage(), 500);
setTimeout(() => triggerManualCompletionOnPage(), 1200);
}
}
}
// Auto-mark completed 100% quizzes on Course page (/course/view.php)
function scanAndAutoMark100PercentQuizzesOnCoursePage() {
if (typeof checkIsCoursePage === 'function' && !checkIsCoursePage()) return;
const autoMark100 = (typeof localStorage !== 'undefined' ? localStorage.getItem('amaes_auto_mark_done_100') !== 'false' : true);
if (!autoMark100) return;
const activityElements = document.querySelectorAll(
'li.activity, .activity-item, .course-section .activity, div[data-region="activity-card"]'
);
if (!activityElements || activityElements.length === 0) return;
let markedAny = false;
activityElements.forEach(container => {
if (!container) return;
if (isActivityAlreadyComplete(container)) return;
if (isActivityLockedOrRestricted(container)) return;
const classification = classifyActivity(container);
if (classification.type !== 'quiz') return;
const titleElem = container.querySelector('.instancename, .activityname, a.aal_link, .activity-title');
const title = titleElem ? titleElem.innerText.trim() : (container.innerText.split('\n')[0] || 'Quiz');
const gradeInfo = evaluateQuizPassableGrade(container, title);
if (!gradeInfo.isPassable) return;
// Find uncompleted manual completion button
const buttons = container.querySelectorAll(
'button[data-action="toggle-manual-completion"], ' +
'button[data-toggletype="manual:mark-done"], ' +
'button.btn-outline-secondary, ' +
'form.togglecompletion input[type="submit"], ' +
'form.togglecompletion button'
);
for (const btn of buttons) {
const text = (btn.innerText || btn.getAttribute('aria-label') || btn.value || '').trim().toLowerCase();
const toggleType = (btn.getAttribute('data-toggletype') || '').toLowerCase();
const isToggleAction = btn.getAttribute('data-action') === 'toggle-manual-completion';
const isToggleCompletionForm = Boolean(btn.closest('form.togglecompletion'));
const isMarkDoneText = text.includes('mark as done') || text === 'mark as done' || toggleType === 'manual:mark-done';
const isAlreadyDone = toggleType === 'manual:undo' || text === 'done' || text.startsWith('done') || btn.classList.contains('btn-success');
if (!isAlreadyDone && (isMarkDoneText || isToggleAction || isToggleCompletionForm)) {
try {
btn.click();
} catch (_) {
if (typeof triggerClick === 'function') triggerClick(btn);
}
try {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-outline-success');
btn.style.opacity = '0.8';
} catch (_) {}
markedAny = true;
if (typeof setLog === 'function') {
setLog(`100% Quiz Auto-Marked:${title.substring(0, 30)} marked as Done.`, "var(--accent-green)");
}
break;
}
}
});
if (markedAny && typeof showToast === 'function') {
showToast("Auto-marked completed 100% quiz as Done!", 3000);
}
}
const ACCOUNT_SWITCHER_ACCOUNTS_KEY = 'amaes_account_switcher_accounts';
const ACCOUNT_SWITCHER_RETURN_KEY = 'amaes_account_switcher_return_url';
const ACCOUNT_SWITCHER_PENDING_KEY = 'amaes_account_switcher_pending';
const ACCOUNT_SWITCHER_RETURN_OPTION_KEY = 'amaes_account_switcher_return_enabled';
const ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY = 'amaes_account_switcher_login_capture';
const ACCOUNT_SWITCHER_INTENT_TTL_MS = 10 * 60 * 1000;
async function getAccountSwitcherValue(key, fallbackValue = null) {
if (typeof GM_getValue !== 'function') {
throw new Error('Account Switcher requires a userscript manager with GM storage support.');
}
return await GM_getValue(key, fallbackValue);
}
async function setAccountSwitcherValue(key, value) {
if (typeof GM_setValue !== 'function') {
throw new Error('Account Switcher requires a userscript manager with GM storage support.');
}
await GM_setValue(key, value);
}
async function deleteAccountSwitcherValue(key) {
if (typeof GM_deleteValue !== 'function') {
throw new Error('Account Switcher requires a userscript manager with GM storage support.');
}
await GM_deleteValue(key);
}
async function getAccountSwitcherAccounts() {
const stored = await getAccountSwitcherValue(ACCOUNT_SWITCHER_ACCOUNTS_KEY, []);
const accounts = typeof stored === 'string' ? JSON.parse(stored) : stored;
if (!Array.isArray(accounts)) {
throw new Error('Saved Account Switcher data is invalid. Remove and re-add the affected accounts.');
}
return accounts.filter(account =>
account &&
typeof account.id === 'string' &&
typeof account.username === 'string' &&
typeof account.password === 'string' &&
typeof account.nickname === 'string'
);
}
async function saveAccountSwitcherAccounts(accounts) {
await setAccountSwitcherValue(ACCOUNT_SWITCHER_ACCOUNTS_KEY, accounts);
}
function accountSwitcherSetStatus(message, isError = false) {
const status = document.getElementById('amaes-account-switcher-status');
if (status) {
status.textContent = message;
status.style.color = isError ? 'var(--accent-pink, #f43f5e)' : 'var(--text-secondary)';
}
if (isError) console.error(`[AMAES Account Switcher] ${message}`);
}
function accountSwitcherIsVisible(element) {
return Boolean(element && element.getClientRects().length);
}
function findAccountSwitcherLogoutLink() {
const links = Array.from(document.querySelectorAll('a[href]'));
const hrefMatch = links.find(link => {
if (!accountSwitcherIsVisible(link)) return false;
try {
const url = new URL(link.href, window.location.href);
return url.origin === window.location.origin && /\/logout\.php$/i.test(url.pathname);
} catch (_) {
return false;
}
});
if (hrefMatch) return hrefMatch;
const menu = document.querySelector('.usermenu .dropdown-menu, [data-region="popover-region-container"], [data-region="user-menu"]');
if (!menu) return null;
return Array.from(menu.querySelectorAll('a, button')).find(element =>
accountSwitcherIsVisible(element) && /^log\s*out$/i.test((element.textContent || '').trim())
) || null;
}
function findAccountSwitcherProfileToggle() {
const selectors = [
'#user-menu-toggle',
'[data-region="user-menu-toggle"]',
'.usermenu .dropdown-toggle',
'#action-menu-toggle-0'
];
for (const selector of selectors) {
const toggle = document.querySelector(selector);
if (accountSwitcherIsVisible(toggle)) return toggle;
}
return null;
}
function findAccountSwitcherLogoutConfirmation() {
const candidates = Array.from(document.querySelectorAll(
'button, input[type="submit"], input[type="button"], a.btn, [role="button"]'
));
return candidates.find(element => {
const label = (element.textContent || element.value || '').trim();
return accountSwitcherIsVisible(element) && /^log\s*out$/i.test(label);
}) || null;
}
function findAccountSwitcherLoginFields() {
const username = document.querySelector(
'#username, input[name="username"], input[autocomplete="username"]'
);
const password = document.querySelector(
'#password, input[name="password"], input[autocomplete="current-password"]'
);
if (!username || !password || !username.form || username.form !== password.form) return null;
return { username, password, form: username.form };
}
function getCurrentAccountSwitcherProfileName() {
const selectors = [
'.usermenu .usertext',
'[data-region="user-menu-toggle"] .usertext',
'#user-menu-toggle .usertext',
'.logininfo a[href*="/user/profile.php"]',
'#user-menu-toggle',
'[data-region="user-menu-toggle"]'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
const value = (element?.getAttribute('aria-label') || element?.textContent || '').replace(/\s+/g, ' ').trim();
if (value && !/^user menu$/i.test(value)) return value;
}
return '';
}
function normalizeAccountSwitcherIdentity(value) {
return String(value || '').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
}
async function setupAccountSwitcherLoginCapture() {
if (typeof isSupportedMoodleHost === 'function' ? !isSupportedMoodleHost(window.location.hostname) : (window.location.hostname !== 'semestral.amaes.com' && window.location.hostname !== 'semestralexam.amaes.com')) return;
const loginFields = findAccountSwitcherLoginFields();
if (!loginFields || loginFields.form.querySelector('#amaes-login-save-account')) return;
try {
const previousCapture = await getAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY, null);
if (previousCapture) await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY);
} catch (error) {
console.error(`[AMAES Account Switcher] Could not clear an earlier login save: ${error.message || error}`);
}
const container = document.createElement('div');
container.style.cssText = 'margin: 10px 0; padding: 8px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px;';
let savedAccounts = [];
try {
savedAccounts = await getAccountSwitcherAccounts();
} catch (error) {
console.error(`[AMAES Account Switcher] Could not load quick-login accounts: ${error.message || error}`);
}
if (savedAccounts.length > 0) {
const quickSwitch = document.createElement('div');
quickSwitch.style.cssText = 'display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-bottom: 8px;';
const quickSwitchLabel = document.createElement('span');
quickSwitchLabel.textContent = 'Quick sign in:';
quickSwitchLabel.style.cssText = 'font-size: 12px; font-weight: 600; color: #374151;';
quickSwitch.appendChild(quickSwitchLabel);
savedAccounts.forEach(account => {
const quickLoginButton = document.createElement('button');
quickLoginButton.type = 'button';
quickLoginButton.textContent = account.nickname;
quickLoginButton.title = `Sign in as ${account.nickname}`;
quickLoginButton.style.cssText = 'max-width: 100%; padding: 5px 8px; border: 1px solid #9ca3af; border-radius: 5px; background: #f3f4f6; color: #111827; font-size: 12px; cursor: pointer;';
quickLoginButton.addEventListener('click', () => {
accountSwitcherSetInputValue(loginFields.username, account.username);
accountSwitcherSetInputValue(loginFields.password, account.password);
loginFields.form.requestSubmit();
});
quickSwitch.appendChild(quickLoginButton);
});
container.appendChild(quickSwitch);
}
const label = document.createElement('label');
label.style.cssText = 'display: flex; align-items: flex-start; gap: 7px; cursor: pointer;';
const checkbox = document.createElement('input');
checkbox.id = 'amaes-login-save-account';
checkbox.type = 'checkbox';
checkbox.style.cssText = 'margin: 2px 0 0; flex: 0 0 auto;';
const labelText = document.createElement('span');
labelText.textContent = 'Save this account on this device for quick switching';
label.append(checkbox, labelText);
const nicknameInput = document.createElement('input');
nicknameInput.id = 'amaes-login-account-nickname';
nicknameInput.type = 'text';
nicknameInput.autocomplete = 'off';
nicknameInput.placeholder = 'Display name for your account';
nicknameInput.setAttribute('aria-label', 'Account display name');
nicknameInput.style.cssText = 'display: none; box-sizing: border-box; width: 100%; margin-top: 7px; padding: 6px 7px; border: 1px solid #d1d5db; border-radius: 5px; font-size: 12px;';
const feedback = document.createElement('div');
feedback.setAttribute('role', 'status');
feedback.style.cssText = 'display: none; margin-top: 5px; color: #b91c1c; font-size: 11px;';
checkbox.addEventListener('change', () => {
nicknameInput.style.display = checkbox.checked ? 'block' : 'none';
if (checkbox.checked && !nicknameInput.value.trim()) {
nicknameInput.value = loginFields.username.value.trim();
}
});
container.append(label, nicknameInput, feedback);
loginFields.password.insertAdjacentElement('afterend', container);
let isSavingLoginCapture = false;
let allowCaptureSubmission = false;
loginFields.form.addEventListener('submit', event => {
if (!checkbox.checked) return;
if (isSavingLoginCapture) {
if (allowCaptureSubmission) {
allowCaptureSubmission = false;
} else {
event.preventDefault();
}
return;
}
const username = loginFields.username.value.trim();
const password = loginFields.password.value;
const nickname = nicknameInput.value.trim() || username;
if (!username || !password || !nickname) return;
event.preventDefault();
isSavingLoginCapture = true;
const captureExpiresAt = Date.now() + ACCOUNT_SWITCHER_INTENT_TTL_MS;
setAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY, {
username,
password,
nickname,
nicknameFromUsername: nickname === username,
expiresAt: captureExpiresAt
}).then(() => {
setTimeout(async () => {
try {
if (window.location.pathname.includes('/login/')) {
const capture = await getAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY, null);
if (capture?.expiresAt === captureExpiresAt) {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY);
}
}
} catch (error) {
console.error(`[AMAES Account Switcher] Could not clear an unsuccessful login save: ${error.message || error}`);
}
}, 20000);
allowCaptureSubmission = true;
loginFields.form.requestSubmit(event.submitter || undefined);
})
.catch(error => {
feedback.textContent = `${error.message || 'Could not save this account.'} Continuing login without saving.`;
feedback.style.display = 'block';
isSavingLoginCapture = true;
allowCaptureSubmission = true;
loginFields.form.requestSubmit(event.submitter || undefined);
});
});
}
async function completeAccountSwitcherLoginCapture() {
try {
const capture = await getAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY, null);
if (!capture) return false;
if (!capture.expiresAt || capture.expiresAt < Date.now()) {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY);
return false;
}
const accounts = await getAccountSwitcherAccounts();
const profileName = getCurrentAccountSwitcherProfileName();
const nickname = capture.nicknameFromUsername && profileName
? profileName
: capture.nickname;
const existing = accounts.find(account => account.username.toLocaleLowerCase() === capture.username.toLocaleLowerCase());
if (existing) {
existing.password = capture.password;
existing.nickname = nickname;
await saveAccountSwitcherAccounts(accounts);
} else {
const id = crypto.randomUUID
? crypto.randomUUID()
: `account-${Date.now()}-${Math.random().toString(36).slice(2)}`;
accounts.push({ id, username: capture.username, password: capture.password, nickname });
await saveAccountSwitcherAccounts(accounts);
}
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_LOGIN_CAPTURE_KEY);
return true;
} catch (error) {
console.error(`[AMAES Account Switcher] Could not save the account after login: ${error.message || error}`);
return false;
}
}
function accountSwitcherSetInputValue(input, value) {
const prototype = Object.getPrototypeOf(input);
const valueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
if (valueSetter) valueSetter.call(input, value);
else input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
async function waitForAccountSwitcherElement(findElement, timeoutMs = 7000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const element = findElement();
if (element) return element;
await new Promise(resolve => setTimeout(resolve, 150));
}
return null;
}
async function startAccountSwitch(accountId, returnToCurrentPage) {
try {
const accounts = await getAccountSwitcherAccounts();
const account = accounts.find(saved => saved.id === accountId);
if (!account) throw new Error('The selected account could not be found.');
if (returnToCurrentPage) {
await setAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY, {
url: window.location.href,
expiresAt: Date.now() + ACCOUNT_SWITCHER_INTENT_TTL_MS
});
} else {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
}
await setAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY, {
accountId: account.id,
stage: 'logout',
createdAt: Date.now()
});
let logoutLink = findAccountSwitcherLogoutLink();
if (!logoutLink) {
const profileToggle = findAccountSwitcherProfileToggle();
if (!profileToggle) throw new Error('Could not find the Moodle profile menu.');
profileToggle.click();
logoutLink = await waitForAccountSwitcherElement(findAccountSwitcherLogoutLink);
}
if (!logoutLink) throw new Error('Could not find the Log out option in the Moodle profile menu.');
accountSwitcherSetStatus(`Signing out before switching to ${account.nickname}…`);
logoutLink.click();
} catch (error) {
try {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
} catch (cleanupError) {
console.error(`[AMAES Account Switcher] Could not clear incomplete switch state: ${cleanupError.message || cleanupError}`);
}
accountSwitcherSetStatus(error.message || 'Could not start account switching.', true);
}
}
async function handleAccountSwitcherNavigation() {
if (typeof isSupportedMoodleHost === 'function' ? !isSupportedMoodleHost(window.location.hostname) : (window.location.hostname !== 'semestral.amaes.com' && window.location.hostname !== 'semestralexam.amaes.com')) return;
let pending;
try {
pending = await getAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY, null);
if (!pending || typeof pending !== 'object') return;
if (!pending.createdAt || Date.now() - pending.createdAt > ACCOUNT_SWITCHER_INTENT_TTL_MS) {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
return;
}
if (!window.location.pathname.includes('/login/')) return;
if (pending.stage === 'attempted') {
try {
const accounts = await getAccountSwitcherAccounts();
const acc = accounts.find(saved => saved.id === pending.accountId);
if (acc) {
acc.lastLoginFailed = true;
await saveAccountSwitcherAccounts(accounts);
}
} catch (_) {}
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
accountSwitcherSetStatus('Login did not complete. Check the credentials and try again.', true);
return;
}
let loginFields = findAccountSwitcherLoginFields();
if (!loginFields) {
let confirmation = findAccountSwitcherLogoutConfirmation();
if (confirmation) {
confirmation.click();
return;
}
loginFields = await waitForAccountSwitcherElement(findAccountSwitcherLoginFields, 9000);
if (!loginFields) {
confirmation = findAccountSwitcherLogoutConfirmation();
if (confirmation) confirmation.click();
return;
}
}
const accounts = await getAccountSwitcherAccounts();
const account = accounts.find(saved => saved.id === pending.accountId);
if (!account) {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
throw new Error('The selected account no longer exists.');
}
await setAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY, {
...pending,
stage: 'attempted'
});
accountSwitcherSetInputValue(loginFields.username, account.username);
accountSwitcherSetInputValue(loginFields.password, account.password);
const submitButton = loginFields.form.querySelector(
'button[type="submit"], input[type="submit"], #loginbtn'
);
if (typeof loginFields.form.requestSubmit === 'function') {
loginFields.form.requestSubmit(submitButton || undefined);
} else if (submitButton) {
submitButton.click();
} else {
throw new Error('Could not find a login submit button.');
}
setTimeout(async () => {
try {
if (!window.location.pathname.includes('/login/')) return;
const latestPending = await getAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY, null);
if (latestPending?.stage === 'attempted') {
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
accountSwitcherSetStatus('Login did not complete. Check the credentials and try again.', true);
try {
const currentAccounts = await getAccountSwitcherAccounts();
const acc = currentAccounts.find(saved => saved.id === pending.accountId);
if (acc) {
acc.lastLoginFailed = true;
await saveAccountSwitcherAccounts(currentAccounts);
}
} catch (_) {}
}
} catch (error) {
console.error(`[AMAES Account Switcher] Could not clear a failed login attempt: ${error.message || error}`);
}
}, 10000);
} catch (error) {
console.error(`[AMAES Account Switcher] ${error.message || error}`);
}
}
async function completeAccountSwitcherReturn() {
try {
if (typeof GM_getValue !== 'function' || typeof GM_deleteValue !== 'function') return false;
const pending = await getAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY, null);
if (!pending || pending.stage !== 'attempted') return false;
if (pending.accountId) {
try {
const accounts = await getAccountSwitcherAccounts();
const acc = accounts.find(a => a.id === pending.accountId);
if (acc && acc.lastLoginFailed) {
delete acc.lastLoginFailed;
await saveAccountSwitcherAccounts(accounts);
}
} catch (_) {}
}
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_PENDING_KEY);
const returnTarget = await getAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY, null);
await deleteAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_KEY);
if (!returnTarget || typeof returnTarget.url !== 'string' || returnTarget.expiresAt < Date.now()) {
return false;
}
const target = new URL(returnTarget.url);
if (target.origin !== window.location.origin) {
console.error('[AMAES Account Switcher] Refusing to restore a return address on another site.');
return false;
}
window.location.replace(target.href);
return true;
} catch (error) {
console.error(`[AMAES Account Switcher] Could not restore the saved return address: ${error.message || error}`);
return false;
}
}
async function setupAccountSwitcherUI() {
const form = document.getElementById('amaes-account-switcher-form');
const list = document.getElementById('amaes-account-switcher-list');
const returnToggle = document.getElementById('amaes-account-switcher-return');
const submitButton = document.getElementById('amaes-account-switcher-submit');
const cancelEditButton = document.getElementById('amaes-account-switcher-cancel-edit');
if (!form || !list || !returnToggle || !submitButton || !cancelEditButton) return;
let editingAccountId = null;
const resetAccountForm = () => {
editingAccountId = null;
form.reset();
submitButton.textContent = 'Add account';
cancelEditButton.style.display = 'none';
};
const renderAccounts = async () => {
const accounts = await getAccountSwitcherAccounts();
const currentProfileName = normalizeAccountSwitcherIdentity(getCurrentAccountSwitcherProfileName());
list.replaceChildren();
if (accounts.length === 0) {
const empty = document.createElement('div');
empty.textContent = 'No saved accounts yet.';
empty.style.cssText = 'font-size: 10px; color: var(--text-muted); padding: 4px 2px;';
list.appendChild(empty);
return;
}
accounts.forEach(account => {
const row = document.createElement('div');
row.style.cssText = 'display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 5px; align-items: center; width: 100%;';
const switchButton = document.createElement('button');
switchButton.type = 'button';
switchButton.className = 'amaes-btn amaes-btn-monotone';
switchButton.textContent = account.nickname;
switchButton.title = `Switch to ${account.nickname}`;
switchButton.style.cssText = 'width: 100%; justify-content: flex-start; min-width: 0; overflow: hidden; text-overflow: ellipsis;';
switchButton.addEventListener('click', () => {
startAccountSwitch(account.id, returnToggle.checked);
});
const status = document.createElement('span');
const isCurrentAccount = Boolean(currentProfileName && (
currentProfileName === normalizeAccountSwitcherIdentity(account.nickname) ||
currentProfileName === normalizeAccountSwitcherIdentity(account.username)
));
if (account.lastLoginFailed) {
status.textContent = 'Login Failed';
status.title = 'Last login attempt failed. Click Edit to update your password or username.';
status.style.cssText = 'font-size: 8.5px; font-weight: 700; white-space: nowrap; color: #ef4444; background: rgba(239, 68, 68, 0.15); border: 1px solid rgba(239, 68, 68, 0.35); padding: 1px 4px; border-radius: 3px;';
} else {
status.textContent = isCurrentAccount ? 'Active' : 'Saved';
status.title = isCurrentAccount
? 'Matches the name shown in the current Moodle profile.'
: 'Saved in this browser; not matched to the current Moodle profile name.';
status.style.cssText = `font-size: 9px; white-space: nowrap; color: ${isCurrentAccount ? 'var(--accent-green, #22c55e)' : 'var(--text-muted)'};`;
}
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'amaes-btn amaes-btn-outline';
editButton.textContent = 'Edit';
editButton.setAttribute('aria-label', `Edit ${account.nickname}`);
editButton.style.cssText = 'width: auto; white-space: nowrap; padding: 4px 7px; font-size: 9px;';
editButton.addEventListener('click', async () => {
editingAccountId = account.id;
document.getElementById('amaes-account-switcher-username').value = account.username;
document.getElementById('amaes-account-switcher-password').value = account.password;
document.getElementById('amaes-account-switcher-nickname').value = account.nickname;
submitButton.textContent = 'Save changes';
cancelEditButton.style.display = 'block';
document.getElementById('amaes-account-switcher-username').focus();
accountSwitcherSetStatus(`Editing ${account.nickname}.`);
});
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'amaes-btn amaes-btn-outline';
removeButton.textContent = 'Remove';
removeButton.setAttribute('aria-label', `Remove ${account.nickname}`);
removeButton.style.cssText = 'width: auto; white-space: nowrap; padding: 4px 7px; font-size: 9px;';
removeButton.addEventListener('click', async () => {
try {
const latest = await getAccountSwitcherAccounts();
const remainingAccounts = latest.filter(saved => saved.id !== account.id);
await saveAccountSwitcherAccounts(remainingAccounts);
if (editingAccountId === account.id) resetAccountForm();
await renderAccounts();
accountSwitcherSetStatus(`${account.nickname} removed.`);
} catch (error) {
accountSwitcherSetStatus(error.message || 'Could not remove the saved account.', true);
}
});
row.append(switchButton, status, editButton, removeButton);
list.appendChild(row);
});
};
try {
returnToggle.checked = (await getAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_OPTION_KEY, false)) === true;
returnToggle.addEventListener('change', async () => {
try {
await setAccountSwitcherValue(ACCOUNT_SWITCHER_RETURN_OPTION_KEY, returnToggle.checked);
} catch (error) {
accountSwitcherSetStatus(error.message || 'Could not save the return-page setting.', true);
}
});
cancelEditButton.addEventListener('click', resetAccountForm);
form.addEventListener('submit', async event => {
event.preventDefault();
const usernameInput = document.getElementById('amaes-account-switcher-username');
const passwordInput = document.getElementById('amaes-account-switcher-password');
const nicknameInput = document.getElementById('amaes-account-switcher-nickname');
const username = usernameInput.value.trim();
const password = passwordInput.value;
const nickname = nicknameInput.value.trim() || username;
if (!username || !password) {
accountSwitcherSetStatus('Enter a username and password.', true);
return;
}
try {
const wasEditing = Boolean(editingAccountId);
let accounts = await getAccountSwitcherAccounts();
const isDuplicate = accounts.some(a =>
a.id !== editingAccountId &&
a.username.trim().toLowerCase() === username.toLowerCase()
);
if (isDuplicate) {
accountSwitcherSetStatus(`An account with username "${username}" already exists.`, true);
return;
}
const id = editingAccountId || (crypto.randomUUID
? crypto.randomUUID()
: `account-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const updatedAccount = { id, username, password, nickname };
if (editingAccountId) {
accounts = accounts.map(account => account.id === editingAccountId ? updatedAccount : account);
} else {
accounts.push(updatedAccount);
}
await saveAccountSwitcherAccounts(accounts);
resetAccountForm();
await renderAccounts();
accountSwitcherSetStatus(`${nickname} ${wasEditing ? 'updated' : 'saved'} on this device.`);
} catch (error) {
accountSwitcherSetStatus(error.message || 'Could not save the account.', true);
}
});
await renderAccounts();
} catch (error) {
accountSwitcherSetStatus(error.message || 'Could not load saved accounts.', true);
}
}
// ==========================================
// Encrypted one-time account/settings transfer
// ==========================================
const ACCOUNT_TRANSFER_FORMAT = 'amaes-toolkit-transfer';
const ACCOUNT_TRANSFER_VERSION = 1;
const ACCOUNT_TRANSFER_SETTINGS_KEYS = [
'amaes_toolkit_theme',
'amaes_auto_highlight_quiz',
'amaes_auto_copy_ai',
'amaes_auto_quiz_mode',
'amaes_fast_quiz_mode',
'amaes_auto_pick_quiz',
'amaes_auto_next_verified',
'amaes_auto_pick_study_guide_fallback',
'amaes_auto_next_quiz',
'amaes_adaptive_probe_quiz',
'amaes_adaptive_probe_budget',
'amaes_quiz_personality',
'amaes_auto_submit_quiz',
'amaes_smart_skip_quiz',
'amaes_auto_cloud_sync',
'amaes_auto_scrape_amauoed',
'amaes_auto_harvest_grades',
'amaes_enable_hotkeys',
'amaes_auto_copy_search',
'amaes_copy_include_confidence',
'amaes_show_in_question_ai_btns',
'amaes_ai_prompt_hint',
'amaes_auto_community_share',
'amaes_auto_min_quiz',
'amaes_enable_audio_alerts',
'amaes_ai_quiz_enabled',
'amaes_ai_auto_select',
'amaes_ai_retry_count',
'amaes_ai_auto_copy_on_fail',
'amaes_ai_auto_next_on_ai',
'amaes_ai_plan_tier',
'amaes_shared_ai_fallback_enabled',
'amaes_account_switcher_return_enabled',
'amaes_preferred_web_ai',
'amaes_pref_minimized',
'amaes_pref_show_logs',
'amaes_active_tab'
];
let pendingAccountTransfer = null;
function accountTransferStatus(message, isError = false) {
const status = document.getElementById('amaes-account-transfer-status');
if (status) {
status.textContent = message;
status.style.color = isError ? 'var(--accent-pink, #f43f5e)' : 'var(--text-secondary)';
}
}
function accountTransferBase64Url(bytes) {
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function accountTransferDecodeBase64Url(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
throw new Error('Transfer data has an invalid encoding.');
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - value.length % 4) % 4);
const binary = atob(base64);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
async function accountTransferLookupHash(codeBytes) {
const prefix = new TextEncoder().encode('AMAES transfer lookup v1:');
const input = new Uint8Array(prefix.length + codeBytes.length);
input.set(prefix);
input.set(codeBytes, prefix.length);
return accountTransferBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', input)));
}
function accountTransferValidateSettings(settings) {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
throw new Error('The transfer does not contain valid settings.');
}
if (Object.keys(settings).some(key => !ACCOUNT_TRANSFER_SETTINGS_KEYS.includes(key))) {
throw new Error('The transfer contains an unsupported setting.');
}
const result = {};
for (const key of ACCOUNT_TRANSFER_SETTINGS_KEYS) {
if (!Object.prototype.hasOwnProperty.call(settings, key)) continue;
const value = settings[key];
if (typeof value !== 'string' || value.length > 128) {
throw new Error(`Invalid setting value for ${key}.`);
}
if (key === 'amaes_toolkit_theme' && !['dark', 'light'].includes(value)) {
throw new Error('The transferred theme value is invalid.');
}
if (key === 'amaes_quiz_personality' &&
!['passive', 'aggressive', 'active', 'balanced'].includes(value)) {
throw new Error('The transferred quiz preference is invalid.');
}
if (key === 'amaes_ai_plan_tier' && !['free', 'paid'].includes(value)) {
throw new Error('The transferred AI plan preference is invalid.');
}
if (key === 'amaes_adaptive_probe_budget' && !/^[1-9]\d?$/.test(value)) {
throw new Error('The transferred probe setting is invalid.');
}
if (key === 'amaes_ai_retry_count' && !/^[1-9]\d?$/.test(value)) {
throw new Error('The transferred retry setting is invalid.');
}
if (key === 'amaes_preferred_web_ai' && !['chatgpt', 'perplexity', 'gemini'].includes(value)) {
throw new Error('The transferred preferred AI setting is invalid.');
}
if (key === 'amaes_active_tab') {
result[key] = ['quiz', 'db', 'course', 'accounts'].includes(value) ? value : 'quiz';
continue;
}
if (key !== 'amaes_toolkit_theme' && key !== 'amaes_quiz_personality' &&
key !== 'amaes_ai_plan_tier' && key !== 'amaes_adaptive_probe_budget' &&
key !== 'amaes_ai_retry_count' && key !== 'amaes_preferred_web_ai' &&
key !== 'amaes_active_tab' && !['true', 'false'].includes(value)) {
throw new Error(`Invalid boolean setting for ${key}.`);
}
result[key] = value;
}
return result;
}
function accountTransferValidatePayload(payload) {
const allowedPayloadKeys = ['format', 'version', 'createdAt', 'accounts', 'apiKeys', 'settings'];
if (!payload || payload.format !== ACCOUNT_TRANSFER_FORMAT ||
payload.version !== ACCOUNT_TRANSFER_VERSION ||
Object.keys(payload).some(key => !allowedPayloadKeys.includes(key)) ||
typeof payload.createdAt !== 'string' || Number.isNaN(Date.parse(payload.createdAt))) {
throw new Error('Unsupported or invalid transfer code.');
}
const accounts = payload.accounts;
const apiKeys = payload.apiKeys;
if (!Array.isArray(accounts) || accounts.length > 30 ||
accounts.some(account => !account || typeof account.id !== 'string' ||
Object.keys(account).some(key => !['id', 'username', 'password', 'nickname'].includes(key)) ||
account.id.length > 128 || typeof account.username !== 'string' ||
!account.username.trim() || account.username.length > 256 ||
typeof account.password !== 'string' || !account.password ||
account.password.length > 2048 || typeof account.nickname !== 'string' ||
!account.nickname.trim() || account.nickname.length > 128)) {
throw new Error('The transfer contains invalid account profiles.');
}
if (!Array.isArray(apiKeys) || apiKeys.length > 10 ||
apiKeys.some(key => typeof key !== 'string' || key.length < 10 || key.length > 512)) {
throw new Error('The transfer contains invalid API keys.');
}
return {
format: payload.format,
version: payload.version,
createdAt: payload.createdAt,
accounts: accounts.map(account => ({
id: account.id,
username: account.username,
password: account.password,
nickname: account.nickname
})),
apiKeys: Array.from(new Set(apiKeys)),
settings: accountTransferValidateSettings(payload.settings)
};
}
async function accountTransferEncrypt(codeBytes, payload) {
const key = await crypto.subtle.importKey('raw', codeBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
if (plaintext.length > 65_536) throw new Error('Selected transfer data exceeds the 64 KiB secure transfer limit.');
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
return {
iv: accountTransferBase64Url(iv),
ciphertext: accountTransferBase64Url(new Uint8Array(ciphertext))
};
}
async function accountTransferDecrypt(codeBytes, encrypted) {
const iv = accountTransferDecodeBase64Url(encrypted.iv);
const ciphertext = accountTransferDecodeBase64Url(encrypted.ciphertext);
if (iv.length !== 12 || ciphertext.length < 16 || ciphertext.length > 65_552) {
throw new Error('Transfer data is invalid or too large.');
}
const key = await crypto.subtle.importKey('raw', codeBytes, { name: 'AES-GCM' }, false, ['decrypt']);
let plaintext;
try {
plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
} catch (_) {
throw new Error('Transfer code is incorrect or the encrypted data was altered.');
}
let payload;
try {
payload = JSON.parse(new TextDecoder().decode(plaintext));
} catch (_) {
throw new Error('Decrypted transfer data is not valid JSON.');
}
return accountTransferValidatePayload(payload);
}
async function accountTransferReadSelectedData(includeAccounts, includeApiKeys, includeSettings) {
const savedAccounts = includeAccounts ? await getAccountSwitcherAccounts() : [];
const apiKeys = includeApiKeys && typeof getGeminiApiKeys === 'function'
? getGeminiApiKeys()
: [];
const settings = includeSettings
? Object.fromEntries(ACCOUNT_TRANSFER_SETTINGS_KEYS
.filter(key => key !== 'amaes_account_switcher_return_enabled')
.map(key => [
key,
key === 'amaes_active_tab' && localStorage.getItem(key) === 'accounts' ? 'quiz' : localStorage.getItem(key)
])
.filter(([, value]) => value !== null))
: {};
if (includeSettings) {
const returnEnabled = await getAccountSwitcherValue('amaes_account_switcher_return_enabled', null);
if (returnEnabled !== null) {
settings.amaes_account_switcher_return_enabled =
String(returnEnabled === true || returnEnabled === 'true');
}
}
return {
format: ACCOUNT_TRANSFER_FORMAT,
version: ACCOUNT_TRANSFER_VERSION,
createdAt: new Date().toISOString(),
accounts: savedAccounts.map(account => ({
id: account.id,
username: account.username,
password: account.password,
nickname: account.nickname
})),
apiKeys: apiKeys.filter(key => typeof key === 'string' && key.trim()).map(key => key.trim()),
settings
};
}
async function accountTransferPost(path, body) {
const response = await fetch(`${communityRelayUrl}/transfer/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': CLIENT_VERSION,
'X-AMAES-Installation': getAnonymousContributorId()
},
body: JSON.stringify(body)
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || `Secure transfer request failed (${response.status}).`);
return result;
}
async function createAccountTransferCode() {
const includeAccounts = document.getElementById('amaes-transfer-include-accounts')?.checked ?? true;
const includeApiKeys = document.getElementById('amaes-transfer-include-api-keys')?.checked ?? true;
const includeSettings = document.getElementById('amaes-transfer-include-settings')?.checked ?? true;
if (!includeAccounts && !includeApiKeys && !includeSettings) {
throw new Error('Select at least one item to transfer.');
}
const consentEl = document.getElementById('amaes-transfer-secret-consent');
if (consentEl && !consentEl.checked) {
consentEl.checked = true;
}
const codeBytes = crypto.getRandomValues(new Uint8Array(32));
const code = accountTransferBase64Url(codeBytes);
const payload = accountTransferValidatePayload(
await accountTransferReadSelectedData(includeAccounts, includeApiKeys, includeSettings)
);
const encrypted = await accountTransferEncrypt(codeBytes, payload);
const lookupHash = await accountTransferLookupHash(codeBytes);
await accountTransferPost('create', { lookupHash, ...encrypted });
const output = document.getElementById('amaes-transfer-code');
if (output) {
output.value = code;
output.hidden = false;
output.select();
}
const copyButton = document.getElementById('amaes-transfer-copy');
if (copyButton) copyButton.hidden = false;
try {
if (typeof GM_setClipboard === 'function') GM_setClipboard(code);
else if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(code);
accountTransferStatus('Code copied to clipboard! On your other device, paste it in "Receive Setup".');
if (typeof showToast === 'function') showToast('Transfer code created & copied to clipboard!', 4000);
} catch (_) {
accountTransferStatus('Code created. Click Copy Code or copy the text above, then paste it in "Receive Setup" on your other device.');
}
}
async function importAccountTransferCode() {
const input = document.getElementById('amaes-transfer-import-code');
const code = input ? input.value.trim() : '';
if (!code) throw new Error('Please paste your transfer code first.');
const codeBytes = accountTransferDecodeBase64Url(code);
if (codeBytes.length !== 32) throw new Error('Invalid code length. Make sure to paste the complete code.');
const lookupHash = await accountTransferLookupHash(codeBytes);
const encrypted = await accountTransferPost('consume', { lookupHash });
const payload = await accountTransferDecrypt(codeBytes, encrypted);
const preview = document.getElementById('amaes-transfer-preview');
if (preview) {
preview.textContent = `Found ${payload.accounts.length} account(s), ${payload.apiKeys.length} API key(s), and ${Object.keys(payload.settings).length} setting(s). Click "Confirm & Load Setup" below to apply.`;
preview.hidden = false;
}
if (input) input.value = '';
const importBtn = document.getElementById('amaes-transfer-import');
if (importBtn) importBtn.hidden = true;
const applyBtn = document.getElementById('amaes-transfer-apply');
if (applyBtn) {
applyBtn.hidden = false;
applyBtn.focus();
}
const discardBtn = document.getElementById('amaes-transfer-discard');
if (discardBtn) discardBtn.hidden = false;
pendingAccountTransfer = payload;
}
async function applyAccountTransfer() {
const payload = pendingAccountTransfer;
if (!payload) throw new Error('There is no decrypted transfer awaiting confirmation.');
const existing = await getAccountSwitcherAccounts();
const byUsername = new Map(existing.map(account => [account.username.toLocaleLowerCase(), account]));
payload.accounts.forEach(account => byUsername.set(account.username.toLocaleLowerCase(), account));
await saveAccountSwitcherAccounts(Array.from(byUsername.values()));
if (payload.apiKeys.length) {
const current = typeof getGeminiApiKeys === 'function' ? getGeminiApiKeys() : [];
const keys = Array.from(new Set(current.concat(payload.apiKeys)));
if (typeof setGeminiApiKeys !== 'function') {
throw new Error('Gemini API key storage is unavailable in this toolkit version.');
}
setGeminiApiKeys(keys);
}
for (const [key, value] of Object.entries(payload.settings)) {
if (key === 'amaes_account_switcher_return_enabled') {
await setAccountSwitcherValue(key, value === 'true');
} else {
localStorage.setItem(key, value);
}
}
pendingAccountTransfer = null;
document.getElementById('amaes-transfer-preview').hidden = true;
document.getElementById('amaes-transfer-apply').hidden = true;
document.getElementById('amaes-transfer-discard').hidden = true;
document.getElementById('amaes-transfer-import').hidden = false;
document.getElementById('amaes-transfer-import-code').value = '';
accountTransferStatus('Transfer complete! Your accounts and settings are active. Reloading...');
if (typeof showToast === 'function') showToast('Setup imported successfully! Reloading...', 3000);
setTimeout(() => {
if (typeof window !== 'undefined' && window.location) window.location.reload();
}, 1200);
}
function discardAccountTransfer() {
pendingAccountTransfer = null;
document.getElementById('amaes-transfer-preview').hidden = true;
document.getElementById('amaes-transfer-apply').hidden = true;
document.getElementById('amaes-transfer-discard').hidden = true;
document.getElementById('amaes-transfer-import').hidden = false;
accountTransferStatus('Decrypted preview discarded. This one-time code has already been consumed.');
}
function setupAccountTransferUI() {
const exportButton = document.getElementById('amaes-transfer-create');
const importButton = document.getElementById('amaes-transfer-import');
if (!exportButton || !importButton) return;
exportButton.addEventListener('click', async () => {
try {
exportButton.disabled = true;
await createAccountTransferCode();
} catch (error) {
accountTransferStatus(error.message || 'Could not create transfer code.', true);
} finally {
exportButton.disabled = false;
}
});
document.getElementById('amaes-transfer-copy').addEventListener('click', async () => {
const code = document.getElementById('amaes-transfer-code').value;
try {
if (typeof GM_setClipboard === 'function') GM_setClipboard(code);
else await navigator.clipboard.writeText(code);
accountTransferStatus('Transfer code copied. Share it only with the intended device owner.');
} catch (error) {
accountTransferStatus(error.message || 'Could not copy the transfer code.', true);
}
});
importButton.addEventListener('click', async () => {
try {
importButton.disabled = true;
await importAccountTransferCode();
} catch (error) {
accountTransferStatus(error.message || 'Could not import transfer code.', true);
} finally {
importButton.disabled = false;
}
});
document.getElementById('amaes-transfer-apply').addEventListener('click', async () => {
try {
await applyAccountTransfer();
} catch (error) {
accountTransferStatus(error.message || 'Could not apply transferred settings.', true);
}
});
document.getElementById('amaes-transfer-discard').addEventListener('click', discardAccountTransfer);
}
// ==========================================
// Debug Report Generator
// ==========================================
function generateDebugReport() {
const isCourse = checkIsCoursePage();
const isQuiz = checkIsQuizPage();
const courseInfo = detectCourseInfo();
const cachedAns = getCachedAnswers(courseInfo.subjectCode);
const report = {
toolkitVersion: SCRIPT_VERSION,
timestamp: new Date().toISOString(),
theme: currentTheme,
courseInfo,
isQuizPage: isQuiz,
cachedQuestionsCount: cachedAns ? cachedAns.length : 0,
recentLogs: debugLogs.slice(-20)
};
return "```json\n" + JSON.stringify(report, null, 2) + "\n```";
}
function generateDiagnosticAuditLog() {
const lines = (typeof activityHistory !== 'undefined' && Array.isArray(activityHistory))
? activityHistory.map(item => `[${item.time}] ${item.text}`).reverse()
: [];
const userAgent = (typeof navigator !== 'undefined' && navigator.userAgent) ? navigator.userAgent : 'Unknown';
const platform = (typeof navigator !== 'undefined' && (navigator.userAgentData?.platform || navigator.platform)) ? (navigator.userAgentData?.platform || navigator.platform) : 'Unknown';
const screenSize = (typeof window !== 'undefined' && window.screen) ? `${window.screen.width}x${window.screen.height}` : 'Unknown';
const currentUrl = (typeof window !== 'undefined' && window.location) ? window.location.href : 'Unknown';
const currentSub = (typeof subCode !== 'undefined' && subCode) ? subCode : ((typeof detectCourseInfo === 'function') ? (detectCourseInfo().subjectCode || 'General') : 'General');
const cachedCount = (typeof getCachedAnswers === 'function' && currentSub) ? (getCachedAnswers(currentSub) || []).length : 0;
const unknownTypes = typeof getUnknownQuestionTypes === 'function' ? getUnknownQuestionTypes() : [];
const diagnosticHeader = [
`=== AMAES MOODLE TOOLKIT DIAGNOSTIC AUDIT LOG ===`,
`Timestamp: ${new Date().toISOString()}`,
`Toolkit Version: ${SCRIPT_VERSION}`,
`Subject / Course: ${currentSub}`,
`Page URL: ${currentUrl}`,
`User Agent: ${userAgent}`,
`Platform: ${platform}`,
`Screen: ${screenSize}`,
`Active Mode: ${(typeof autoQuizMode !== 'undefined' && autoQuizMode) ? 'Auto-Quiz' : 'Passive'} | Auto-Pick: ${(typeof autoPickQuiz !== 'undefined') ? autoPickQuiz : false} | Smart-Next: ${(typeof autoNextVerified !== 'undefined') ? autoNextVerified : false}`,
`Cloud Sync: ${localStorage.getItem('amaes_auto_cloud_sync') !== 'false'}`,
`Cached DB Questions: ${cachedCount}`,
`Recorded Unknown Question Types: ${unknownTypes.length}`,
...(unknownTypes.length > 0 ? [
``,
`--- RECORDED UNKNOWN QUESTION TYPES JSON ---`,
JSON.stringify(unknownTypes, null, 2)
] : []),
``,
`--- ACTIVITY LOG TIMELINE ---`
].join('\n');
return `${diagnosticHeader}\n` + lines.join('\n') + `\n=== END DIAGNOSTIC LOG ===`;
}
function copyToClipboard(text) {
if (typeof GM_setClipboard === 'function') {
try {
GM_setClipboard(text, 'text');
return Promise.resolve();
} catch (e) {
logDebug('GM_setClipboard error:', e.message);
}
}
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise((resolve, reject) => {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) resolve();
else reject(new Error('execCommand copy failed'));
} catch (err) {
reject(err);
}
});
}
// Floating Non-intrusive Stacking Toast Notifications
function showToast(message, duration = 2400, onClick = null) {
if (!message) return;
let container = document.getElementById('amaes-toast-notification');
if (!container) {
container = document.createElement('div');
container.id = 'amaes-toast-notification';
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 10000000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 420px;
pointer-events: none;
`;
document.body.appendChild(container);
}
// Anti-spam guard: if identical notification is already visible, refresh its timer
const existingToasts = Array.from(container.querySelectorAll('.amaes-toast-item:not([data-leaving="true"])'));
const duplicate = existingToasts.find(t => t.dataset.toastMessage === message);
if (duplicate) {
clearTimeout(duplicate._timer);
duplicate._timer = setTimeout(() => dismissToast(duplicate), duration);
duplicate.style.borderColor = 'var(--accent-purple, #a855f7)';
setTimeout(() => { duplicate.style.borderColor = '#334155'; }, 300);
return;
}
function dismissToast(el) {
if (!el || el.dataset.leaving === 'true') return;
el.dataset.leaving = 'true';
clearTimeout(el._timer);
el.style.opacity = '0';
el.style.transform = 'translateY(-8px)';
setTimeout(() => el.remove(), 250);
}
// Stack limit guard: max 5 active toasts
if (existingToasts.length >= 5) {
dismissToast(existingToasts[0]);
}
const toastItem = document.createElement('div');
toastItem.className = 'amaes-toast-item';
toastItem.dataset.toastMessage = message;
toastItem.style.cssText = `
background: rgba(15, 23, 42, 0.94);
backdrop-filter: blur(8px);
color: #f8fafc;
border: 1px solid #334155;
padding: 8px 14px;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 11.5px;
font-weight: 600;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 7px;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
pointer-events: auto;
cursor: pointer;
user-select: none;
`;
toastItem.title = typeof onClick === 'function' ? 'Click to open details' : 'Click to dismiss';
toastItem.innerHTML = `${ICONS.sparkles} ${message}`;
container.appendChild(toastItem);
// Animate in on next frame
requestAnimationFrame(() => {
toastItem.style.opacity = '1';
toastItem.style.transform = 'translateY(0)';
});
// Click to dismiss immediately
toastItem.onclick = () => dismissToast(toastItem);
if (typeof onClick === 'function') {
toastItem.addEventListener('click', (e) => {
try { onClick(e); } catch (_) {}
});
}
// Independent timer per toast
toastItem._timer = setTimeout(() => dismissToast(toastItem), duration);
}
let capabilityTipsInterval = null;
let capabilityTipIndex = 0;
const CAPABILITY_TIPS = [
'Tip: Auto-Quiz can scan every question on a page and move to the next unanswered one.',
'Tip: Verified community answers are highlighted before any AI request is made.',
'Tip: Gemini is used as a fallback for unknown multiple-choice and true/false questions.',
'Tip: Copy AI prepares a clean prompt with the question and choices for any AI assistant.',
'Tip: Paste AI matches an answer to the Moodle choice without changing your sharing format.',
'Tip: One-page quizzes are not advanced until every question on that page has an answer.',
'Tip: Complex questions such as essays, dropdowns, and drag-and-drop stay available for manual review.',
'Tip: Cloud Sync keeps your local answer database updated with community contributions.',
'Tip: Use the floating toolkit pill to pause automation, open settings, or review the current status.',
'Tip: Personal Gemini keys stay in your browser; contributor sharing is optional and encrypted.',
'Tip: Course Tools > Activity Auto-Marker > Mark Lectures & Vids marks unfinished lecture and video activities complete.',
'Tip: Course Tools > Activity Auto-Marker > Mark Quizzes / Exams Only safely marks quizzes with a passable score (≥80%) as done, leaving failing or unattempted quizzes strictly untouched.',
'Tip: Passable Grade Guard: Quizzes scoring below 80% or unattempted are never auto-marked as done, ensuring incomplete attempts are never closed accidentally.',
'Tip: Course Tools > Activity Auto-Marker > Mark ALL as Done processes unfinished activities, respecting the strict ≥80% passable grade threshold for all quizzes.',
'Tip: Use Course Tools > Highlight Missing Quizzes to find unanswered or unattempted quizzes on a Grades or Course page.',
'Tip: Course Tools > Search Helper copies a subject-aware search phrase and opens Google for study-guide lookup.',
'Tip: In the AI Assistant card, Auto-Copy Question on AI Failure puts a clean fallback prompt on your clipboard.',
'Tip: In the AI Assistant card, Auto-Advance After AI Answer automatically proceeds to the next page after an AI choice is made (Default: ON).',
'Tip: Click inside a quiz question and press C to copy the question and choices, ready to paste into another AI tool.',
'Tip: After another AI tool gives you an answer, click the question and press V to paste it back and match the choice.',
'Tip: Click inside a question and press N when you are ready to continue to the next question or page.',
'Tip: Press P to pause or resume automatic quiz help while you review a question.',
'Tip: Press ] to cycle forward to the next quiz or exam in academic sequence.',
'Tip: Press [ to cycle backward to the previous quiz or exam in sequence.',
'Tip: Toggle Skip Passed Quizzes (≥80%) in Advanced Settings to automatically bypass quizzes you already passed.',
'Tip: On the Quiz Summary page, press P or click Pause to halt the Auto-Submit countdown timer.',
'Tip: Press N, Space, or Enter during the Auto-Submit countdown to hasten submission by -2 seconds per tap.',
'Tip: On course pages, the Course Intelligence Card displays Autonomous Confidence and question bank coverage across all terms.',
'Tip: On Non-Reviewable courses, the toolkit only harvests 100% score attempts to maintain absolute database accuracy.',
'Tip: Quizzes with 100% scores automatically mark their Moodle activities as Done (Enabled in Advanced Settings).',
'Tip: On the Quiz Summary page, pressing N or Space automatically confirms and submits the Moodle submission modal.'
];
function startCapabilityTips() {
if (capabilityTipsInterval || CAPABILITY_TIPS.length === 0) return;
capabilityTipsInterval = setInterval(() => {
const tip = CAPABILITY_TIPS[capabilityTipIndex % CAPABILITY_TIPS.length];
capabilityTipIndex += 1;
showToast(tip, 4000);
}, 15000);
}
// Character Maps for Mathematical / Circuit Superscripts & Subscripts
const SUP_MAP = {'0':'⁰','1':'¹','2':'²','3':'³','4':'⁴','5':'⁵','6':'⁶','7':'⁷','8':'⁸','9':'⁹','+':'⁺','-':'⁻','=':'⁼','(':'⁽',')':'⁾','n':'ⁿ','i':'ⁱ','x':'ˣ','y':'ʸ','m':'ᵐ','a':'ᵃ','b':'ᵇ','c':'ᶜ'};
const SUB_MAP = {'0':'₀','1':'₁','2':'₂','3':'₃','4':'₄','5':'₅','6':'₆','7':'₇','8':'₈','9':'₉','+':'₊','-':'₋','=':'₌','(':'₍',')':'₎','a':'ₐ','e':'ₑ','o':'ₒ','x':'ₓ','i':'ᵢ','j':'ⱼ','n':'ₙ','p':'ₚ'};
// Converts DOM tree to clean formatted text preserving Superscripts, Subscripts, Math, Truth Tables, and Images
function cleanDOMToAI(rootNode) {
if (!rootNode) return '';
if (rootNode.matches && rootNode.matches('script, style, noscript, .accesshide, .sr-only')) return '';
const clone = rootNode.cloneNode(true);
// Strip non-content scripts, toolkit buttons, injected UI badges & Moodle feedback icons/accessibility text
clone.querySelectorAll('script, style, noscript, .amaes-verified-badge, .amaes-eliminated-badge, .amaes-probability-hint, .amaes-shortans-hint, .amaes-select-hint, .amaes-drag-hint, .amaes-unanswered-hint, .amaes-blockage-hud, .amaes-new-question-focus-banner, .amaes-unsure-help-chip, .amaes-card-btn-container, .amaes-copy-ai-card-btn, .amaes-ask-ai-card-btn, .amaes-paste-ai-card-btn, .amaes-web-ai-container, .amaes-web-ai-split-btn, .amaes-web-ai-main-action, .amaes-web-ai-arrow-btn, .amaes-web-ai-btn, .amaes-web-ai-menu, .amaes-web-ai-item, .amaes-web-ai-row, .amaes-web-ai-pill, .amaes-active-focus-badge, .amaes-review-status-pill, .amaes-review-outcome-banner, .amaes-que-top-toolbar, .amaes-que-stop-btn, .amaes-ai-thinking-indicator, .amaes-ai-fallback-bar, .amaes-ai-suggested-badge, .amaes-ai-text-badge, .amaes-ai-question-tag, .amaes-study-guide-question-tag, .feedbackimage, .fa-check, .fa-remove, .fa-times, .fa-close, .accesshide, .sr-only').forEach(el => el.remove());
// Convert Superscripts (e.g. 2^3 -> 2³, x^2 -> x², or ^{complex})
clone.querySelectorAll('sup').forEach(sup => {
const txt = sup.innerText.trim();
if (txt) {
if ([...txt].every(ch => ch in SUP_MAP)) {
sup.replaceWith([...txt].map(ch => SUP_MAP[ch]).join(''));
} else {
sup.replaceWith(`^{${txt}}`);
}
} else {
sup.remove();
}
});
// Convert Subscripts (e.g. 10_2 -> 10₂, A_0 -> A₀, or _{complex})
clone.querySelectorAll('sub').forEach(sub => {
const txt = sub.innerText.trim();
if (txt) {
if ([...txt].every(ch => ch in SUB_MAP)) {
sub.replaceWith([...txt].map(ch => SUB_MAP[ch]).join(''));
} else {
sub.replaceWith(`_{${txt}}`);
}
} else {
sub.remove();
}
});
// Convert MathJax / LaTeX formulas
clone.querySelectorAll('.math, .MathJax, [data-mathml]').forEach(mathElem => {
const tex = mathElem.getAttribute('data-mathml') ||
(mathElem.querySelector('annotation[encoding*="tex"]') ? mathElem.querySelector('annotation[encoding*="tex"]').textContent : null) ||
mathElem.getAttribute('alt');
if (tex) {
mathElem.replaceWith(` $${tex.trim()}$ `);
}
});
// Convert images into markdown links with alt text & cross-attempt normalized tokens
clone.querySelectorAll('img').forEach(img => {
const src = img.src || img.getAttribute('data-src') || '';
const alt = (img.alt || '').trim();
if (src) {
const moodleImgMatch = src.match(/\/pluginfile\.php\/\d+\/question\/(?:answer|questiontext|feedback)\/\d+(?:\/\d+)?\/([^?#\s]+)/i);
const assetName = moodleImgMatch ? moodleImgMatch[1] : '';
const cleanSrc = (moodleImgMatch && assetName) ? `[moodle-asset:${assetName}]` : src;
const imgText = alt ? `[Image: ${alt} - ${cleanSrc}]` : `[Image: ${cleanSrc}]`;
img.replaceWith(document.createTextNode(`\n${imgText}\n`));
} else {
img.remove();
}
});
// Convert drop zones into readable blank indicators (e.g. [Blank 1], [Blank 2])
let dropCount = 0;
clone.querySelectorAll('.drop, .dropzone, span.droptarget').forEach(dz => {
dropCount++;
const placed = dz.innerText.trim();
const textNode = document.createTextNode(placed ? `[Blank ${dropCount}: ${placed}]` : `[Blank ${dropCount}]`);
dz.replaceWith(textNode);
});
// Convert fill-in-the-blank text inputs into ____ so AI sees where the answer goes
let blankCount = 0;
clone.querySelectorAll('input[type="text"], input[type="number"], input:not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="hidden"])').forEach(inp => {
// Only convert inputs inside the question text area (not answer choice inputs)
if (inp.closest('.answer, .submitbtns, .amaes-card-btn-container')) return;
blankCount++;
const blankLabel = `[____${blankCount > 1 ? ' ' + blankCount : ''}]`;
inp.replaceWith(document.createTextNode(` ${blankLabel} `));
});
// Convert select / dropdown elements (e.g. gapselect inline dropdowns) into readable options
let selectCount = 0;
clone.querySelectorAll('select').forEach(sel => {
selectCount++;
const selectedOpt = (sel.selectedIndex >= 0 && sel.options) ? sel.options[sel.selectedIndex] : null;
const selectedVal = (selectedOpt && selectedOpt.value && selectedOpt.value !== '0' && !selectedOpt.text.toLowerCase().includes('choose')) ? selectedOpt.text.trim() : '';
const options = Array.from(sel.querySelectorAll('option'))
.map(o => (o.innerText || o.textContent || '').trim())
.filter(o => o && !o.toLowerCase().includes('choose'));
let selLabel = '';
if (selectedVal) {
selLabel = `[Dropdown ${selectCount}: ${selectedVal}]`;
} else if (options.length > 0) {
selLabel = `[Dropdown ${selectCount} (${options.join(' | ')})]`;
} else {
selLabel = `[Dropdown ${selectCount}]`;
}
sel.replaceWith(document.createTextNode(` ${selLabel} `));
});
// Convert tables (Truth Tables, Logic Mappings) to markdown rows
clone.querySelectorAll('table').forEach(table => {
const rows = [];
table.querySelectorAll('tr').forEach(tr => {
const cells = Array.from(tr.querySelectorAll('th, td')).map(td => td.innerText.trim());
if (cells.length > 0) rows.push(cells.join(' | '));
});
if (rows.length > 0) {
table.replaceWith(document.createTextNode('\n' + rows.join('\n') + '\n'));
}
});
// Normalize spaces and clean up
let text = clone.innerText || clone.textContent || '';
text = text.replace(/[✓✔✗✘✕✖]/g, '');
text = text.replace(/\r\n/g, '\n');
text = text.split('\n').map(line => line.replace(/[ \t]+/g, ' ').trim()).filter((line, i, arr) => {
return !(line === '' && arr[i - 1] === '');
}).join('\n');
return text.trim();
}
// Fetch or convert an image URL to a PNG blob
async function getImageBlob(imgUrl) {
if (!imgUrl) return null;
try {
const response = await fetch(imgUrl);
const blob = await response.blob();
if (blob.type === 'image/png') return blob;
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = imgUrl;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return await new Promise(r => canvas.toBlob(r, 'image/png'));
} catch (err) {
logDebug('getImageBlob error:', err.message);
return null;
}
}
// Capture visual snapshot/snippet of a question card as a PNG Blob (diagrams, figures, and drag & drop)
async function captureQuestionSnippetBlob(que) {
if (!que || typeof document === 'undefined') return null;
try {
const formulation = que.querySelector('.formulation') || que.querySelector('.content') || que;
if (!formulation) return null;
// Clone formulation to render clean snippet without toolkit controls
const clone = formulation.cloneNode(true);
clone.querySelectorAll('.amaes-card-btn-container, .amaes-web-ai-row, .amaes-que-top-toolbar, .amaes-ai-thinking-indicator, .amaes-ai-fallback-bar, .amaes-ai-suggested-badge, .amaes-ai-text-badge, .amaes-ai-question-tag, .accesshide, .sr-only').forEach(el => el.remove());
// Convert images in clone to data URLs to avoid cross-origin canvas security errors
const origImgs = formulation.querySelectorAll('img');
const cloneImgs = clone.querySelectorAll('img');
for (let i = 0; i < cloneImgs.length && i < origImgs.length; i++) {
const orig = origImgs[i];
const cl = cloneImgs[i];
if (orig.complete && orig.naturalWidth > 0) {
try {
const canvas = document.createElement('canvas');
canvas.width = orig.naturalWidth;
canvas.height = orig.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(orig, 0, 0);
cl.src = canvas.toDataURL('image/png');
} catch (e) {}
}
}
const rect = formulation.getBoundingClientRect();
const width = Math.max(Math.min(rect.width || 750, 1000), 450);
const height = Math.max(rect.height || 350, 180);
const xhtml = new XMLSerializer().serializeToString(clone);
const svg = ``;
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
const pngBlob = await new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 1800);
img.onload = () => {
clearTimeout(timeout);
try {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(svgUrl);
canvas.toBlob(resolve, 'image/png');
} catch (err) {
URL.revokeObjectURL(svgUrl);
resolve(null);
}
};
img.onerror = () => {
clearTimeout(timeout);
URL.revokeObjectURL(svgUrl);
resolve(null);
};
img.src = svgUrl;
});
if (pngBlob) return pngBlob;
} catch (err) {
logDebug('captureQuestionSnippetBlob error:', err.message);
}
return null;
}
// Copy question to clipboard: if question has images or is drag & drop, copies image snippet AND pure text simultaneously!
async function copyQuestionWithOptionalImage(que, text) {
if (!text) return { success: false, withImage: false };
const qData = que && typeof extractQuestionData === 'function' ? extractQuestionData(que) : null;
const hasImg = Boolean(que && que.querySelector('.formulation img, .qtext img, .ddarea img, .drop img, .drags img'));
const isDragDrop = Boolean((qData && qData.isDragDrop) ||
(que && que.classList && (que.classList.contains('que_dragdrop') || que.classList.contains('ddwtos') || que.classList.contains('ddmarker') || que.classList.contains('ddimageortext'))) ||
(que && que.querySelector('.drop, .draghome, .drags, .ddarea, .dragboxes, .place1, .drag')));
let pngBlob = null;
if (hasImg || isDragDrop) {
pngBlob = await captureQuestionSnippetBlob(que);
if (!pngBlob && hasImg) {
const firstImg = que.querySelector('.formulation img, .qtext img, .ddarea img');
if (firstImg && firstImg.src) {
pngBlob = await getImageBlob(firstImg.src);
}
}
}
// Dual-MIME clipboard: copies BOTH image/png AND text/plain together so image-aware AI sees the snippet, while textboxes get pure text
if (pngBlob && typeof ClipboardItem !== 'undefined' && navigator.clipboard && navigator.clipboard.write) {
try {
const textBlob = new Blob([text], { type: 'text/plain' });
await navigator.clipboard.write([
new ClipboardItem({
'image/png': pngBlob,
'text/plain': textBlob
})
]);
return { success: true, withImage: true };
} catch (err) {
logDebug('Dual clipboard copy failed, falling back to pure text copy:', err.message);
}
}
// Standard pure text copy fallback
await copyToClipboard(text);
return { success: true, withImage: false };
}
// Copy an image directly to the OS clipboard as a PNG blob
async function copyImageBlobToClipboard(imgUrl) {
try {
const response = await fetch(imgUrl);
const blob = await response.blob();
let pngBlob = blob;
if (blob.type !== 'image/png') {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = imgUrl;
});
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
pngBlob = await new Promise(res => canvas.toBlob(res, 'image/png'));
}
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob })
]);
return { success: true };
} catch (err) {
logDebug('Image blob copy failed, falling back to URL copy:', err.message);
await copyToClipboard(imgUrl);
return { success: false, fallbackUrl: imgUrl };
}
}
function mapQuestionTypeCategory(rawType) {
if (!rawType) return 'unknown';
const t = rawType.toLowerCase();
if (t === 'multichoice' || t === 'multichoiceset') return 'multichoice';
if (t === 'truefalse') return 'truefalse';
if (t === 'shortanswer' || t === 'numerical' || t === 'calculated' || t === 'calculatedsimple' || t === 'calculatedmulti') return 'shortanswer';
if (t === 'gapselect' || t === 'select' || t === 'cloze_select') return 'gapselect';
if (t === 'match' || t === 'matching') return 'match';
if (t.startsWith('dd') || t.includes('drag') || t.includes('drop')) return 'dragdrop';
if (t === 'essay') return 'essay';
if (t === 'multianswer') return 'shortanswer';
return 'unknown';
}
function identifyQuestionType(que) {
if (!que) return 'unknown';
// 1. Inspect Moodle's class on .que container
const classList = Array.from(que.classList || []);
for (const cls of classList) {
const lower = cls.toLowerCase();
if (lower.startsWith('qtype_')) {
const sub = lower.replace('qtype_', '');
if (sub === 'multianswer') {
if (que.querySelectorAll('select').length > 0) return 'gapselect';
if (que.querySelectorAll('input[type="text"], input.form-control, input[type="number"], input:not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="hidden"])').length > 0) {
return 'shortanswer';
}
}
return mapQuestionTypeCategory(sub);
}
if (['multichoice', 'truefalse', 'shortanswer', 'gapselect', 'match', 'ddwtos', 'ddimageortext', 'ddmarker', 'essay', 'numerical', 'multianswer', 'calculated'].includes(lower)) {
if (lower === 'multianswer') {
if (que.querySelectorAll('select').length > 0) return 'gapselect';
if (que.querySelectorAll('input[type="text"], input.form-control, input[type="number"], input:not([type="radio"]):not([type="checkbox"]):not([type="submit"]):not([type="button"]):not([type="hidden"])').length > 0) {
return 'shortanswer';
}
}
return mapQuestionTypeCategory(lower);
}
}
// 2. DOM signature checks
if (que.querySelector('.draghome, .drags, .drop, .dropzone, span.droptarget, .droppable')) {
return 'dragdrop';
}
const selects = que.querySelectorAll('select');
if (selects.length > 0) {
if (que.querySelector('.answer table select, td.control select')) {
return 'match';
}
return 'gapselect';
}
if (que.querySelector('textarea, [contenteditable="true"], [data-fieldtype="editor"]')) {
return 'essay';
}
const radios = que.querySelectorAll('.answer input[type="radio"]');
if (radios.length > 0) {
if (radios.length === 2) {
const labels = Array.from(que.querySelectorAll('.answer label, .answer div.r0, .answer div.r1')).map(l => (l.innerText || '').toLowerCase());
if (labels.some(l => l.includes('true')) && labels.some(l => l.includes('false'))) {
return 'truefalse';
}
}
return 'multichoice';
}
if (que.querySelectorAll('.answer input[type="checkbox"]').length > 0) {
return 'multichoice';
}
if (que.querySelector('input[type="text"].form-control, input.form-control, input[type="text"], input[type="number"]')) {
return 'shortanswer';
}
return 'unknown';
}
function recordUnknownQuestionType(que, qData = null) {
try {
if (!que) return;
const raw = localStorage.getItem('amaes_unknown_question_types');
const list = raw ? JSON.parse(raw) : [];
const classList = Array.from(que.classList || []).filter(c => !c.startsWith('amaes') && c !== 'clearfix');
const inputs = Array.from(que.querySelectorAll('input, select, textarea, button, [contenteditable], .drop, .draghome')).map(el => {
let tag = el.tagName.toLowerCase();
if (el.type) tag += `[type=${el.type}]`;
if (el.className) tag += `.${el.className.split(/\s+/).filter(c => c && !c.startsWith('amaes')).join('.')}`;
return tag;
});
const signature = `${classList.sort().join(' ')} | ${inputs.sort().join(', ')}`;
if (list.some(item => item.signature === signature)) {
return;
}
const qSnippet = (que.querySelector('.qtext, .formulation') ? que.querySelector('.qtext, .formulation').innerText.slice(0, 160) : (qData && qData.qText ? qData.qText.slice(0, 160) : '')).trim();
let rawHtml = que.querySelector('.formulation, .content') ? que.querySelector('.formulation, .content').innerHTML : '';
rawHtml = rawHtml.replace(/data:image\/[^;]+;base64,[a-zA-Z0-9+/=]+/g, '[image: embedded]');
rawHtml = rawHtml.replace(/([?&])(sesskey|attempt|token)=[a-zA-Z0-9]+/g, '$1$2=REDACTED');
const formulationHtml = rawHtml.slice(0, 500);
const entry = {
id: `unk_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
timestamp: new Date().toISOString(),
url: (typeof window !== 'undefined' && window.location) ? window.location.href.split('?')[0] : '',
classes: classList,
signature: signature,
inputCount: inputs.length,
inputsSummary: inputs.slice(0, 10),
snippet: qSnippet,
htmlSample: formulationHtml
};
list.push(entry);
if (list.length > 25) list.splice(0, list.length - 25);
localStorage.setItem('amaes_unknown_question_types', JSON.stringify(list));
logDebug(`Recorded unknown question type signature: ${signature}`);
// Automatically push unknown question telemetry to the community database relay
try {
const courseInfo = (typeof detectCourseInfo === 'function') ? detectCourseInfo() : {};
const currentSubCode = courseInfo.subjectCode || (typeof subCode !== 'undefined' ? subCode : 'GENERAL');
pushUnknownQuestionToRelay(entry, currentSubCode);
} catch (_) {}
// Reassure user with a gentle notice without altering the question DOM or interrupting
if (!hasNotifiedUnknownQuestion && (typeof checkIsQuizPage === 'function' ? checkIsQuizPage() : true)) {
hasNotifiedUnknownQuestion = true;
if (typeof showToast === 'function') {
showToast("New question type detected — reported to maintainer.", 4000);
}
if (typeof setLog === 'function') {
setLog("New question format detected. Reported to database for developer review.", "var(--accent-amber)");
}
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
try {
window.dispatchEvent(new CustomEvent('amaes-unknown-question-recorded', { detail: entry }));
} catch (_) {}
}
} catch (e) {
logDebug('Failed to record unknown question type:', e && e.message);
}
}
const pushedUnknownSignatures = new Set();
let hasNotifiedUnknownQuestion = false;
function pushUnknownQuestionToRelay(entry, subjectCode = 'GENERAL') {
try {
if (!entry || !entry.signature) return;
if (pushedUnknownSignatures.has(entry.signature)) return;
pushedUnknownSignatures.add(entry.signature);
const relayUrl = (typeof communityRelayUrl !== 'undefined' && communityRelayUrl) ? communityRelayUrl : COMMUNITY_RELAY_URL;
if (!relayUrl) return;
const payload = {
subjectCode: subjectCode || 'GENERAL',
clientVersion: SCRIPT_VERSION.replace(/^v/i, ''),
contributorId: (typeof getAnonymousContributorId === 'function') ? getAnonymousContributorId() : 'anon',
signature: entry.signature,
classes: entry.classes || [],
inputCount: entry.inputCount || 0,
inputsSummary: entry.inputsSummary || [],
snippet: entry.snippet || '',
htmlSample: entry.htmlSample || '',
submittedAt: entry.timestamp || new Date().toISOString()
};
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gmReq) {
gmReq({
method: 'POST',
url: `${relayUrl}/unknown-question`,
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
data: JSON.stringify(payload),
onload: (res) => {
logDebug(`Pushed unknown question telemetry to database relay (status: ${res.status})`);
},
onerror: (err) => {
logDebug('Failed to push unknown question telemetry:', err);
}
});
} else if (typeof fetch !== 'undefined') {
fetch(`${relayUrl}/unknown-question`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
body: JSON.stringify(payload)
}).then(res => {
logDebug(`Pushed unknown question telemetry via fetch (status: ${res.status})`);
}).catch(err => {
logDebug('Failed to push unknown question telemetry via fetch:', err);
});
}
} catch (e) {
logDebug('Exception in pushUnknownQuestionToRelay:', e && e.message);
}
}
function recordSessionQuizCompleted() {
try {
sessionQuizzesSolved = (sessionQuizzesSolved || 0) + 1;
sessionStorage.setItem('amaes_session_quizzes_solved', String(sessionQuizzesSolved));
dispatchUsageTelemetry('quiz_completed');
} catch (_) {}
}
function dispatchUsageTelemetry(eventType = 'heartbeat') {
try {
const now = Date.now();
const lastPing = parseInt(localStorage.getItem('amaes_last_telemetry_ping') || '0', 10);
if (eventType === 'heartbeat' && now - lastPing < 6 * 60 * 60 * 1000) {
return;
}
const relayUrl = (typeof communityRelayUrl !== 'undefined' && communityRelayUrl) ? communityRelayUrl : COMMUNITY_RELAY_URL;
if (!relayUrl) return;
const payload = {
anon_id: getAnonymousInstallId(),
version: SCRIPT_VERSION.replace(/^v/i, ''),
event: eventType,
session_quizzes_solved: sessionQuizzesSolved || 0,
fast_mode_enabled: Boolean(fastQuizMode)
};
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gmReq) {
gmReq({
method: 'POST',
url: `${relayUrl}/telemetry`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
timeout: 8000,
onload: () => {
if (eventType === 'heartbeat') {
localStorage.setItem('amaes_last_telemetry_ping', String(now));
}
}
});
} else if (typeof fetch !== 'undefined') {
fetch(`${relayUrl}/telemetry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(() => {
if (eventType === 'heartbeat') {
localStorage.setItem('amaes_last_telemetry_ping', String(now));
}
}).catch(() => {});
}
} catch (_) {}
}
function getUnknownQuestionTypes() {
try {
const raw = localStorage.getItem('amaes_unknown_question_types');
return raw ? JSON.parse(raw) : [];
} catch (_) {
return [];
}
}
function clearUnknownQuestionTypes() {
localStorage.removeItem('amaes_unknown_question_types');
}
function exportUnknownQuestionTypesJson() {
const types = getUnknownQuestionTypes();
return JSON.stringify(types, null, 2);
}
// Community Bug Reporting (Direct to GitHub Issues via Cloudflare Relay)
function submitBugReportToRelay(reportData) {
return new Promise((resolve, reject) => {
try {
const relayUrl = (typeof communityRelayUrl !== 'undefined' && communityRelayUrl) ? communityRelayUrl : COMMUNITY_RELAY_URL;
if (!relayUrl) {
return reject(new Error('Community relay URL is not configured.'));
}
const payload = {
description: reportData.description || '',
subjectCode: reportData.subjectCode || 'GENERAL',
clientVersion: SCRIPT_VERSION.replace(/^v/i, ''),
pageType: reportData.pageType || 'unknown',
environment: reportData.environment || `${(typeof navigator !== 'undefined' && navigator.userAgent) || 'Unknown'} (Screen: ${(typeof window !== 'undefined' && window.innerWidth) || 0}x${(typeof window !== 'undefined' && window.innerHeight) || 0})`,
logs: Array.isArray(reportData.logs) ? reportData.logs : [],
breadcrumbs: Array.isArray(reportData.breadcrumbs) ? reportData.breadcrumbs : [],
questionsSummary: Array.isArray(reportData.questionsSummary) ? reportData.questionsSummary : [],
settings: (reportData.settings && typeof reportData.settings === 'object') ? reportData.settings : {},
contributorId: (typeof getAnonymousContributorId === 'function') ? getAnonymousContributorId() : 'anon'
};
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gmReq) {
gmReq({
method: 'POST',
url: `${relayUrl}/report-bug`,
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
data: JSON.stringify(payload),
onload: (res) => {
try {
const data = JSON.parse(res.responseText || '{}');
if (res.status >= 200 && res.status < 300 && data.success) {
resolve(data);
} else {
reject(new Error(data.error || `HTTP ${res.status}`));
}
} catch (err) {
reject(new Error(`Server returned unexpected response (status ${res.status})`));
}
},
onerror: (err) => {
reject(new Error(err && err.statusText ? err.statusText : 'Network error communicating with bug relay'));
}
});
} else if (typeof fetch !== 'undefined') {
fetch(`${relayUrl}/report-bug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
body: JSON.stringify(payload)
}).then(async res => {
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
resolve(data);
} else {
reject(new Error(data.error || `HTTP ${res.status}`));
}
}).catch(err => {
reject(err);
});
} else {
reject(new Error('No HTTP transport available.'));
}
} catch (e) {
reject(e);
}
});
}
function submitAnomalyReportToRelay(payload) {
return new Promise((resolve) => {
try {
const relayUrl = (typeof communityRelayUrl !== 'undefined' && communityRelayUrl) ? communityRelayUrl : COMMUNITY_RELAY_URL;
if (!relayUrl) return resolve({ success: false, error: 'No relay configured' });
const body = JSON.stringify({
event: 'quiz_anomaly',
anomalyType: payload.anomalyType || 'UNKNOWN_ANOMALY',
subjectCode: (payload.subjectCode || 'GENERAL').toUpperCase(),
activityTitle: payload.activityTitle || '',
questionRaw: String(payload.questionRaw || '').slice(0, 1000),
questionNorm: String(payload.questionNorm || '').slice(0, 1000),
domType: payload.domType || 'unknown',
submittedAnswer: String(payload.submittedAnswer || '').slice(0, 500),
markScored: Number(payload.markScored || 0),
maxMark: Number(payload.maxMark || 1),
attemptHistory: Array.isArray(payload.attemptHistory) ? payload.attemptHistory.slice(0, 10) : [],
dbCandidate: payload.dbCandidate || null,
clientVersion: SCRIPT_VERSION.replace(/^v/i, ''),
contributorId: (typeof getAnonymousContributorId === 'function') ? getAnonymousContributorId() : 'anon'
});
const gmReq = (typeof GM_xmlhttpRequest !== 'undefined') ? GM_xmlhttpRequest :
(typeof GM !== 'undefined' && GM.xmlHttpRequest) ? GM.xmlHttpRequest : null;
if (gmReq) {
gmReq({
method: 'POST',
url: `${relayUrl}/telemetry/anomaly`,
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
data: body,
onload: (res) => {
try {
const data = JSON.parse(res.responseText || '{}');
resolve(data);
} catch (_) {
resolve({ success: true, mode: 'raw' });
}
},
onerror: () => resolve({ success: false, error: 'Network error' })
});
} else if (typeof fetch !== 'undefined') {
fetch(`${relayUrl}/telemetry/anomaly`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AMAES-Client-Version': SCRIPT_VERSION.replace(/^v/i, '')
},
body: body,
keepalive: true
}).then(r => r.json()).then(resolve).catch(err => resolve({ success: false, error: err.message }));
} else {
resolve({ success: false, error: 'No transport available' });
}
} catch (err) {
resolve({ success: false, error: err.message });
}
});
}
function detectAndReportQuestionAnomaly(anomalyData) {
if (!anomalyData || !anomalyData.anomalyType || !anomalyData.questionRaw) return;
const subCode = String(anomalyData.subjectCode || 'GENERAL').toUpperCase();
const qNorm = anomalyData.questionNorm || (typeof normalizeText === 'function' ? normalizeText(anomalyData.questionRaw) : anomalyData.questionRaw.toLowerCase());
const hash = Array.from(qNorm).reduce((acc, char) => ((acc << 5) - acc) + char.charCodeAt(0), 0) >>> 0;
const dedupKey = `amaes_anomaly_${subCode}_${anomalyData.anomalyType}_${hash}`;
// 1. Session deduplication: do not send the exact same anomaly twice in one session
if (sessionStorage.getItem(dedupKey)) return;
sessionStorage.setItem(dedupKey, '1');
// 2. Rate limit: max 5 anomalies per session attempt
const countKey = `amaes_anomalies_sent_${subCode}`;
const count = Number(sessionStorage.getItem(countKey) || '0');
if (count >= 5) return;
sessionStorage.setItem(countKey, String(count + 1));
logDebug(`[Anomaly Detector] Logging ${anomalyData.anomalyType} for ${subCode}: "${anomalyData.questionRaw.slice(0, 60)}..."`);
if (typeof setLog === 'function') {
setLog(`[Anomaly Auto-Reported] Dispatched ${anomalyData.anomalyType} diagnostic report to database maintainers.`, 'var(--accent-amber)');
}
submitAnomalyReportToRelay(anomalyData).catch(err => {
logDebug(`[Anomaly Dispatch Note]: ${err.message}`);
});
}
function showBugReportModal() {
const prefill = arguments[0] || {};
const existing = document.getElementById('amaes-bug-modal');
if (existing) {
const ta = existing.querySelector('textarea');
if (ta) {
if (prefill.description && !ta.value.trim()) {
ta.value = prefill.description;
ta.dispatchEvent(new Event('input'));
}
ta.focus();
}
return;
}
const courseInfo = (typeof detectCourseInfo === 'function') ? detectCourseInfo() : {};
const activeSubCode = prefill.subjectCode || courseInfo.subjectCode || (typeof subCode !== 'undefined' ? subCode : 'GENERAL');
const activePageType = prefill.context || ((typeof checkIsQuizPage === 'function' && checkIsQuizPage()) ? 'quiz_attempt'
: ((typeof checkIsReviewPage === 'function' && checkIsReviewPage()) ? 'quiz_review'
: ((typeof checkIsCoursePage === 'function' && checkIsCoursePage()) ? 'course_view' : 'dashboard')));
const modal = document.createElement('div');
modal.id = 'amaes-bug-modal';
modal.style.cssText = `
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(4px);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
animation: amaesFadeIn 0.15s ease-out;
`;
modal.innerHTML = `
${ICONS.bug || ICONS.alertTriangle}Report a Problem / Bug
Found a bug, broken question, or unexpected behavior? Submit details below to immediately open a tracked GitHub issue for the maintainers. No GitHub account needed.
${ICONS.shieldCheck || ICONS.shield}Strict Privacy Guarantee: No personal student data, IDs, names, passwords, or session tokens are ever collected or leaked.
Read SECURITY.md
Moodle has hidden answer feedback and marks for this assessment. Answer scraping is safely disabled to prevent unverified data pollution.
`;
const mainArea = document.querySelector('#region-main, main, [role="main"]') || document.body;
mainArea.prepend(banner);
}
setLog("Non-Reviewable Assessment: Answer feedback and marks are hidden by Moodle for this attempt. Answer scraping safely paused.", "var(--accent-amber)");
showToast("Non-reviewable assessment: answer scraping safely paused.", 4000);
return;
}
// MutationObserver may see the review page before Moodle finishes
// inserting all questions. Include the question signature in the guard
// so a later multi-question DOM is harvested too.
const questionSignature = Array.from(document.querySelectorAll('.que'))
.map(getReviewQuestionStateSignature)
.filter(Boolean)
.join('|');
const processingKey = `${attemptId}:${questionSignature}`;
// Always inject visual markers for questions on review page
injectReviewQuestionMarkers(harvested);
if (!harvested || !harvested.success ||
(harvested.harvestedCount === 0 && (harvested.eliminatedCount || 0) === 0)) {
const retryKey = `amaes_review_retry_${attemptId}`;
const retryCount = Number(sessionStorage.getItem(retryKey) || '0');
if (retryCount < 5) {
sessionStorage.setItem(retryKey, String(retryCount + 1));
setTimeout(() => {
if (checkIsReviewPage()) handleQuizReviewPageLoad();
}, 700);
} else {
sessionStorage.removeItem(retryKey);
}
return;
}
sessionStorage.removeItem(`amaes_review_retry_${attemptId}`);
if (lastProcessedReviewAttempt === processingKey) return;
lastProcessedReviewAttempt = processingKey;
// Inject Reviewable Assessment Status Badge
let reviewBadge = document.getElementById('amaes-reviewable-page-badge');
if (!reviewBadge) {
const summaryTable = document.querySelector('.quizreviewsummary, table.quizreviewsummary, #region-main, main') || document.body;
reviewBadge = document.createElement('div');
reviewBadge.id = 'amaes-reviewable-page-badge';
reviewBadge.style.cssText = 'display:inline-flex; align-items:center; gap:6px; margin:8px 0; padding:5px 12px; background:rgba(16,185,129,0.12); border:1px solid rgba(16,185,129,0.35); border-radius:6px; color:#059669; font-size:11.5px; font-weight:700; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;';
reviewBadge.innerHTML = `${ICONS.check}Reviewable Assessment: Ground truth available (${harvested.harvestedCount} verified answers, ${harvested.eliminatedCount || 0} wrong choices harvested)`;
if (summaryTable.parentNode) {
summaryTable.parentNode.insertBefore(reviewBadge, summaryTable);
}
}
// Mount top quiz mover navigation right after review badge so user can immediately cycle to next quiz
if (typeof injectQuizBottomNavigation === 'function') {
injectQuizBottomNavigation();
}
const topNav = document.getElementById('amaes-quiz-top-nav');
if (reviewBadge && reviewBadge.parentNode && topNav && topNav.previousElementSibling !== reviewBadge) {
reviewBadge.parentNode.insertBefore(topNav, reviewBadge.nextSibling);
}
// Mount Retake Quiz action bar directly below review summary table
if (typeof injectReviewRetakeButton === 'function') {
injectReviewRetakeButton();
}
// Check for 100% score and trigger auto-mark activity as Done if enabled
if (typeof checkQuizAttemptIs100Percent === 'function' && checkQuizAttemptIs100Percent(harvested, document)) {
const urlParams = new URLSearchParams(window.location.search);
let cmid = urlParams.get('cmid') || urlParams.get('id') || '';
if (!cmid) {
const idInput = document.querySelector('input[name="cmid"], input[name="id"]');
if (idInput && idInput.value) cmid = idInput.value.trim();
}
if (!cmid) {
const mBody = (document.body?.className || '').match(/\bcmid-(\d+)\b/);
if (mBody) cmid = mBody[1];
}
if (!cmid) {
const viewLinks = document.querySelectorAll('a[href*="/mod/quiz/view.php?id="], a[href*="/mod/quiz/view.php?cmid="], .breadcrumb a[href*="id="], .breadcrumb-item a[href*="id="]');
for (const l of viewLinks) {
const m = (l.getAttribute('href') || l.href || '').match(/[?&](?:id|cmid)=(\d+)/);
if (m) {
cmid = m[1];
break;
}
}
}
let rawTitle = (harvested && harvested.quizTitle) || '';
if (!rawTitle) {
const h = document.querySelector('.page-header-headings h1, #region-main h2, h2, h3, .breadcrumb-item:last-child');
if (h) rawTitle = h.innerText.trim();
}
if (!rawTitle && typeof detectCourseInfo === 'function') {
rawTitle = detectCourseInfo().currentActivityTitle || '';
}
const normTitle = rawTitle.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!cmid && normTitle && typeof getCourseQuizzesList === 'function') {
const qList = getCourseQuizzesList();
const m = qList.find(q => {
const qNorm = (q.title || '').toLowerCase().replace(/[^a-z0-9]/g, '');
return qNorm && (qNorm === normTitle || normTitle.includes(qNorm) || qNorm.includes(normTitle));
});
if (m && m.cmid) cmid = m.cmid;
}
if (cmid) {
localStorage.setItem(`amaes_quiz_100_cmid_${cmid}`, 'true');
}
if (normTitle) {
localStorage.setItem(`amaes_quiz_100_title_${normTitle}`, 'true');
}
// Immediately update and invalidate course grades cache with 100% passable score
const courseInfo = typeof detectCourseInfo === 'function' ? detectCourseInfo() : {};
const courseId = courseInfo && courseInfo.courseId ? courseInfo.courseId : '';
if (courseId) {
try {
const cachedRaw = localStorage.getItem(`amaes_course_grades_cache_${courseId}`);
const map = cachedRaw ? JSON.parse(cachedRaw) : {};
const gradeEntry = {
title: rawTitle,
normTitle: normTitle,
cmid: cmid,
hasGrade: true,
percentage: 100,
gradeStr: '100%',
isPassable: true,
source: 'review-100'
};
if (normTitle) map[normTitle] = gradeEntry;
if (cmid) map[`cmid_${cmid}`] = gradeEntry;
localStorage.setItem(`amaes_course_grades_cache_${courseId}`, JSON.stringify(map));
localStorage.setItem(`amaes_course_grades_cache_time_${courseId}`, String(Date.now()));
if (typeof window !== 'undefined') {
window.__amaesCourseGradesCache = map;
window.__amaesCourseGradesCacheTime = Date.now();
}
} catch (_) {}
}
const autoMark100 = (typeof localStorage !== 'undefined' ? localStorage.getItem('amaes_auto_mark_done_100') !== 'false' : true);
if (autoMark100 && typeof triggerManualCompletionOnPage === 'function') {
triggerManualCompletionOnPage();
}
}
// Auto-save verified answers and eliminated wrong choices to local subject cache
const cacheRes = mergeAnswersIntoCache(harvested.subjectCode, harvested.questions, 'Review');
// Re-highlight choices so newly deduced or eliminated choices display updated badges
highlightQuizAnswers(getCachedAnswers(harvested.subjectCode), false);
// Update markers with fresh cache state
injectReviewQuestionMarkers(harvested);
const autoShareEnabled = localStorage.getItem('amaes_auto_community_share') !== 'false';
const hasNewDiscoveries = Boolean(cacheRes && (cacheRes.added > 0 || cacheRes.confirmed > 0 || cacheRes.eliminated > 0 || cacheRes.conflicts > 0));
if (hasNewDiscoveries) {
const newTotal = (cacheRes.added || 0) + (cacheRes.confirmed || 0);
if (autoShareEnabled) {
setLog(`Quiz Review Checked: Extracted ${harvested.harvestedCount} verified answers (${newTotal} updated) & ${harvested.eliminatedCount || 0} wrong choices for ${harvested.subjectCode}. Sharing queued.`, "var(--accent-green)");
showToast(`Review Checked: ${newTotal > 0 ? `Collected ${newTotal} verified answer${newTotal > 1 ? 's' : ''}` : `${cacheRes.eliminated} wrong choice${cacheRes.eliminated > 1 ? 's' : ''} eliminated`}; sharing queued.`, 4000);
} else {
setLog(`Quiz Review Checked: Extracted ${harvested.harvestedCount} verified answers (${newTotal} updated) for ${harvested.subjectCode} (Saved to Local DB, sharing is OFF).`, "var(--accent-blue)");
showToast(`Review Checked: ${newTotal > 0 ? `Collected ${newTotal} verified answer${newTotal > 1 ? 's' : ''}` : `${cacheRes.eliminated} wrong choice${cacheRes.eliminated > 1 ? 's' : ''} eliminated`} & saved locally!`, 4000);
}
} else {
// Already cataloged: quiet debug log, no repetitive popup toast when reviewing past attempts
logDebug(`Quiz Review: All ${harvested.harvestedCount} verified answers for ${harvested.subjectCode} are already cataloged.`);
}
const autoPushEnabled = localStorage.getItem('amaes_auto_push_github') === 'true';
const hasGithubToken = Boolean(localStorage.getItem('amaes_github_token'));
// Prevent repeated auto-actions on refresh using sessionStorage attempt keys
const pushKey = `amaes_autopush_${attemptId}`;
const shareKey = `amaes_autoshare_${attemptId}_${harvested.subjectCode}`;
// 2. Auto-Push to GitHub if configured
if (autoPushEnabled && hasGithubToken && !sessionStorage.getItem(pushKey)) {
sessionStorage.setItem(pushKey, '1');
setTimeout(async () => {
try {
const res = await pushAnswersToGitHub(harvested.subjectCode, harvested.questions, {
quizTitle: harvested.quizTitle,
gradeText: harvested.gradeText,
courseTitle: harvested.course
});
if (res && res.success && res.mode === 'api') {
showToast(`Auto-pushed ${harvested.harvestedCount} answers directly to GitHub database!`);
}
} catch (e) {
console.error('Auto-push to GitHub failed:', e);
}
}, 1000);
}
// 3. Auto-share review evidence. Persist deduplication keys only after relay acceptance,
// so a failed request can be retried on the next review visit.
if (autoShareEnabled && (harvested.harvestedCount > 0 || (harvested.eliminatedCount || 0) > 0)) {
let sharedKeys = [];
try {
sharedKeys = JSON.parse(localStorage.getItem(shareKey) || sessionStorage.getItem(shareKey) || '[]');
if (!Array.isArray(sharedKeys)) sharedKeys = [];
} catch (e) {
sharedKeys = [];
}
const sharedSet = new Set(sharedKeys);
const pendingQuestions = harvested.questions.filter(question => {
if (!question.ansRaw && !(question.evidenceType === 'moodle_review_elimination' &&
Array.isArray(question.wrongAnswers) && question.wrongAnswers.length > 0)) return false;
const key = getReviewShareKey(question);
if (sharedSet.has(key) || pendingReviewShares.has(key)) return false;
return true;
});
if (pendingQuestions.length > 0) {
const pendingKeys = pendingQuestions.map(getReviewShareKey);
pendingKeys.forEach(key => pendingReviewShares.add(key));
setTimeout(async () => {
try {
const result = await dispatchCommunityContribution(harvested.subjectCode, pendingQuestions, {
source: 'review_screen',
evidenceType: 'moodle_review',
contributionId: `review-${processingKey}`
});
if (!result || !result.success || !String(result.mode).startsWith('relay')) {
throw new Error('The relay did not confirm receipt.');
}
let acceptedKeys = [];
try {
acceptedKeys = JSON.parse(localStorage.getItem(shareKey) || sessionStorage.getItem(shareKey) || '[]');
if (!Array.isArray(acceptedKeys)) acceptedKeys = [];
} catch (e) {
acceptedKeys = [];
}
const acceptedSet = new Set(acceptedKeys);
pendingKeys.forEach(key => acceptedSet.add(key));
const serializedKeys = JSON.stringify(Array.from(acceptedSet));
localStorage.setItem(shareKey, serializedKeys);
sessionStorage.setItem(shareKey, serializedKeys);
} catch (err) {
logDebug(`Review community share failed; it can be retried on the next review visit: ${err.message}`);
setLog(`Review evidence was saved locally but not confirmed by the community relay: ${err.message}`, "var(--accent-amber)");
} finally {
pendingKeys.forEach(key => pendingReviewShares.delete(key));
}
}, 1200);
}
}
}
const injectReviewScreenBanner = handleQuizReviewPageLoad;
// Helper to retrieve all locally cached subject databases
function getAllSavedSubjectDatabases() {
const dbs = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('amaes_amauoed_cache_')) {
const code = key.replace('amaes_amauoed_cache_', '').toUpperCase();
try {
const data = JSON.parse(localStorage.getItem(key));
if (Array.isArray(data) && data.length > 0) {
dbs[code] = data;
}
} catch (e) {}
}
}
return dbs;
}
// Community Database Contribution & Anti-Sabotage Submission Modal
function showCommunityContributionModal(initialSubCode) {
const existing = document.getElementById('amaes-contribute-modal');
if (existing) existing.remove();
const allDbs = getAllSavedSubjectDatabases();
const availableCodes = Object.keys(allDbs);
// Sort availableCodes by question count descending
availableCodes.sort((a, b) => (allDbs[b]?.length || 0) - (allDbs[a]?.length || 0));
let targetCode = (initialSubCode && allDbs[initialSubCode.toUpperCase()]) ?
initialSubCode.toUpperCase() :
(availableCodes[0] || (initialSubCode ? initialSubCode.toUpperCase() : 'GENERAL'));
const modal = document.createElement('div');
modal.id = 'amaes-contribute-modal';
modal.style.cssText = `
position: fixed;
top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
z-index: 100003;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-sizing: border-box;
`;
function generatePayload(code, qList) {
// Only export questions that have a verified or deduced answer
const validQuestions = qList.filter(q => Boolean(q.ansRaw || q.answer || q.correctAnswer));
const cInfo = (typeof courseInfo !== 'undefined' && courseInfo) ? courseInfo :
(typeof detectedCourseInfo !== 'undefined' && detectedCourseInfo) ? detectedCourseInfo :
(typeof detectCourseInfo === 'function' ? detectCourseInfo() : null);
const resolvedName = (cInfo && (cInfo.subjectCode === code || cInfo.code === code) && cInfo.subjectName) ?
cInfo.subjectName : resolveKnownCourseName(code);
return {
subjectCode: code,
subjectName: resolvedName || code,
contributor: "community",
timestamp: new Date().toISOString(),
totalQuestions: validQuestions.length,
questions: validQuestions.map(q => ({
question: q.qRaw || q.question || q.qText || "",
answer: q.ansRaw || q.answer || q.correctAnswer || "",
choices: q.choices || [],
wrongAnswers: Array.isArray(q.wrongAnswers) ? q.wrongAnswers.map(w => typeof w === 'string' ? w : (w && w.text ? w.text : w)) : [],
confidence: typeof q.confidence === 'number' ? q.confidence : 1.0,
period: q.period || q.term || 'General',
quizTitle: q.quizTitle || '',
source: q.source || "community_contribute"
}))
};
}
function renderModal() {
const questions = allDbs[targetCode] || [];
const payload = generatePayload(targetCode, questions);
const payloadStr = JSON.stringify(payload, null, 2);
const verifiedCount = questions.filter(q => q.ansRaw || q.answer || q.correctAnswer).length;
const eliminatedCount = questions.reduce((acc, q) => acc + (Array.isArray(q.wrongAnswers) ? q.wrongAnswers.length : 0), 0);
const hasPatToken = Boolean(localStorage.getItem('amaes_github_token'));
modal.innerHTML = `
${ICONS.upload}
Share Database to Community Hub
"Solve once, share together, never guess twice."
${ICONS.shieldCheck} A community where each student answers what's missing
Submissions are processed automatically by GitHub Actions CI. Teacher keys and consensus answers are strictly protected from overwrites. Zero tokens or accounts required!
${availableCodes.length === 0 ? `
No Saved Answers in Local Storage Yet
Complete or review a quiz in Moodle to harvest verified answers, or scrape answers from amauoed.com. Once answers are saved in your local storage, you can share them here with 1 click!
How it works: Clicking "Submit via 1-Click Issue" opens a pre-filled GitHub issue in the open community repository. Click "Submit new issue" on GitHub and the automated merger bot incorporates the new questions within 60 seconds!
${ICONS.checkCircle} Successfully Pushed to Cloud Database!
${questions.length} answers were committed directly to the official community repository.
`;
}
} catch (err) {
alert(`Push failed: ${err.message}`);
tokenPushBtn.disabled = false;
tokenPushBtn.innerHTML = `${ICONS.github} Direct Push (via Token)`;
}
};
}
const submitBtn = modal.querySelector('#amaes-contribute-submit-btn');
if (submitBtn) {
submitBtn.onclick = () => {
if (!questions || questions.length === 0) {
alert("No questions found for this subject to submit.");
return;
}
const issueTitle = `[Contribution] ${targetCode} (${questions.length} questions)`;
let issueBody = `### Community Answer Contribution\n\n**Subject**: ${targetCode}\n**Total Questions**: ${questions.length}\n**Submitted At**: ${new Date().toISOString()}\n\n\`\`\`json\n${payloadStr}\n\`\`\`\n`;
let autoCopied = false;
// If URL query string exceeds safe browser/HTTP limits (~3500 chars), auto-copy full JSON and provide clean paste prompt
if (encodeURIComponent(issueBody).length > 3500) {
copyToClipboard(payloadStr);
autoCopied = true;
issueBody = `### Community Answer Contribution\n\n**Subject**: ${targetCode}\n**Total Questions**: ${questions.length}\n**Submitted At**: ${new Date().toISOString()}\n\n> [!NOTE]\n> The JSON payload (${questions.length} questions) was automatically copied to your clipboard!\n> Simply press **Ctrl + V** (or Cmd + V) below inside this text box to paste it, then click **"Submit new issue"**.\n\n\`\`\`json\nPASTE_HERE\n\`\`\`\n`;
}
const ghUrl = `https://github.com/Acads-Tools/database/issues/new?title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}&labels=community-contribution`;
window.open(ghUrl, '_blank');
// Provide immediate, unmissable in-modal feedback
const feedbackContainer = modal.querySelector('#amaes-contribute-feedback-area');
if (feedbackContainer) {
feedbackContainer.style.display = 'block';
feedbackContainer.innerHTML = `
${ICONS.checkCircle} Submission Page Opened in New Tab!
${autoCopied ? `Payload Copied! Your ${questions.length} questions were auto-copied to your clipboard. Go to the new GitHub tab, press Ctrl + V in the box, and click "Submit new issue"!` : `Final Step: Go to the newly opened GitHub tab and click the green "Submit new issue" button. The automated bot will merge your answers into the cloud database within 60 seconds!`}
Accounts are saved only in your userscript manager on this device. They are not sent to the toolkit relay; your login details are entered only on Moodle.
`)}
${renderCardContent('mod-accounts-transfer', ICONS.share || ICONS.cloud, 'Setup Transfer (Sync Devices)', '', `
Easily sync your saved Moodle accounts, Gemini AI keys, and settings to your other phone, laptop, or computer in two simple steps.
Step 1: Send Setup (This Device)
Step 2: Receive Setup (New Device)
Paste the 43-character code generated from your other device:
The code does not expire, but works once only. End-to-end encrypted; credentials are saved directly to this browser.