From ebcf1312d133bb81bac16af0c4af1e893943fc15 Mon Sep 17 00:00:00 2001 From: heznpc Date: Tue, 17 Mar 2026 22:01:23 +0900 Subject: [PATCH] feat: exam mode + version 2.0.0 - Detect exam/quiz/assessment pages via URL patterns and DOM selectors - Skip translation of answer choices (radio/checkbox labels) on exam pages - Show exam mode banner warning users that answers are not translated - AI Tutor warns about academic integrity on exam pages - Add exam UI terms (Pass/Fail/Score/Retake etc.) to all 6 language dicts - Bump version to 2.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- manifest.json | 2 +- package.json | 2 +- src/content/content.css | 36 +++++++++++++++++++++++++++++++ src/content/content.js | 37 +++++++++++++++++++++++++++++++ src/content/sidebar-chat.js | 12 +++++++++++ src/data/de.json | 18 ++++++++++++++++ src/data/es.json | 18 ++++++++++++++++ src/data/fr.json | 18 ++++++++++++++++ src/data/ja.json | 18 ++++++++++++++++ src/data/ko.json | 18 ++++++++++++++++ src/data/zh-CN.json | 18 ++++++++++++++++ src/lib/constants.js | 43 +++++++++++++++++++++++++++++++++++++ src/lib/selectors.js | 7 ++++++ tests/constants.test.js | 9 ++++++-- tests/translator.test.js | 10 ++++++--- 15 files changed, 259 insertions(+), 7 deletions(-) diff --git a/manifest.json b/manifest.json index 93d7d13..04d2dfb 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.0.1", + "version": "2.0.0", "minimum_chrome_version": "120", "author": "SkillBridge Contributors", "homepage_url": "https://github.com/heznpc/skillbridge", diff --git a/package.json b/package.json index 668e324..46dcb0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "skillbridge", - "version": "1.0.1", + "version": "2.0.0", "private": true, "scripts": { "test": "jest --verbose", diff --git a/src/content/content.css b/src/content/content.css index 9f7a727..22f36d1 100644 --- a/src/content/content.css +++ b/src/content/content.css @@ -2044,3 +2044,39 @@ html.si18n-dark .si18n-shortcut-key { color: #aaa; box-shadow: 0 1px 0 #333; } + +/* ============================================================ + EXAM MODE BANNER + ============================================================ */ + +.si18n-exam-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 10001; + background: #FFF3CD; + color: #856404; + text-align: center; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + border-bottom: 1px solid #FFEEBA; + transform: translateY(-100%); + transition: transform 0.3s ease; +} + +.si18n-exam-banner.visible { + transform: translateY(0); +} + +html.si18n-dark .si18n-exam-banner { + background: #3D3200; + color: #FFD54F; + border-bottom-color: #5C4B00; +} + +.si18n-exam-warning { + border-left: 3px solid #FFC107 !important; + background: rgba(255, 193, 7, 0.08) !important; +} diff --git a/src/content/content.js b/src/content/content.js index 31a684f..8d3dc7c 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -45,6 +45,7 @@ let translator = null; let subtitleManager = null; let currentLang = 'en'; + let isExamPage = false; let isReady = false; let sidebarVisible = false; let originalTexts = new Map(); @@ -72,12 +73,37 @@ // (header-controls.js, text-selection.js, sidebar-chat.js) // ============================================================ + // ============================================================ + // EXAM PAGE DETECTION + // ============================================================ + + function detectExamPage() { + const url = location.href; + if (EXAM_URL_PATTERNS.some(p => p.test(url))) return true; + // DOM-based detection: check for quiz forms or answer option containers + if (document.querySelector(SKILLJAR_SELECTORS.quizForm)) return true; + if (document.querySelector(SKILLJAR_SELECTORS.answerOption)) return true; + return false; + } + + function showExamBanner() { + if (document.getElementById('si18n-exam-banner')) return; + const banner = document.createElement('div'); + banner.id = 'si18n-exam-banner'; + banner.className = 'si18n-exam-banner'; + banner.setAttribute('role', 'alert'); + banner.textContent = t(EXAM_BANNER_LABELS); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + } + window._sb = { get currentLang() { return currentLang; }, set currentLang(v) { currentLang = v; }, get sidebarVisible() { return sidebarVisible; }, set sidebarVisible(v) { sidebarVisible = v; }, get translator() { return translator; }, + get isExamPage() { return isExamPage; }, t, escapeHtml, switchLanguage, @@ -217,6 +243,7 @@ const stored = await chrome.storage.local.get(['targetLanguage', 'autoTranslate', 'welcomeShown', 'darkMode']); if (stored.darkMode) document.documentElement.classList.add('si18n-dark'); currentLang = stored.targetLanguage || 'en'; + isExamPage = detectExamPage(); translator = new SkilljarTranslator(); @@ -349,6 +376,9 @@ function applyStaticTranslations(targetLang) { buildProtectedTermsMap(targetLang); updateLangClass(targetLang); + // Re-detect exam page (DOM may have loaded since init) + if (!isExamPage) isExamPage = detectExamPage(); + if (isExamPage && targetLang !== 'en') showExamBanner(); const elements = getTranslatableElements(); if (elements.length === 0) return; @@ -671,8 +701,12 @@ } function getTranslatableElements() { + const examSkip = isExamPage ? EXAM_SKIP_SELECTORS.join(', ') : null; return Array.from(document.querySelectorAll(TRANSLATABLE_SELECTOR)).filter(el => { if (el.closest(EXCLUDE_SELECTOR)) return false; + // On exam pages, skip answer choice elements + if (examSkip && el.matches(examSkip)) return false; + if (examSkip && el.closest(examSkip)) return false; const parent = el.parentElement; if (parent && parent.matches && parent.matches(TRANSLATABLE_SELECTOR) && !parent.closest(EXCLUDE_SELECTOR)) { @@ -925,6 +959,9 @@ RULES: function getPageContext() { const title = document.querySelector(`h1, h2, ${SKILLJAR_SELECTORS.courseTitle}`)?.textContent || document.title || ''; + if (isExamPage) { + return `Certification Exam: ${title}. Page type: exam/assessment. DO NOT help with answers.`; + } const headings = Array.from(document.querySelectorAll('h1, h2, h3, h4')) .map(h => h.textContent.trim()) .slice(0, 5) diff --git a/src/content/sidebar-chat.js b/src/content/sidebar-chat.js index a2a0dcf..13b63a2 100644 --- a/src/content/sidebar-chat.js +++ b/src/content/sidebar-chat.js @@ -149,6 +149,18 @@ const messages = document.getElementById('si18n-chat-messages'); const text = input.value.trim(); if (!text) return; + + // Exam mode guard — warn user, add system instruction to prevent answer leaking + if (sb.isExamPage) { + const warningMsg = sb.t(TUTOR_EXAM_LABELS); + messages.insertAdjacentHTML('beforeend', ` +
+
AI
+
${sb.escapeHtml(warningMsg)}
+
+ `); + } + isSending = true; const quoteEl = document.querySelector('.si18n-chat-quote'); diff --git a/src/data/de.json b/src/data/de.json index d772bdf..b65897d 100644 --- a/src/data/de.json +++ b/src/data/de.json @@ -653,5 +653,23 @@ "hooks": [ "Haken" ] + }, + "exam_ui": { + "Pass": "Bestanden", + "Fail": "Nicht bestanden", + "Passing score": "Mindestpunktzahl", + "Your score": "Ihre Punktzahl", + "Retake": "Erneut versuchen", + "Attempt": "Versuch", + "Attempts remaining": "Verbleibende Versuche", + "Time remaining": "Verbleibende Zeit", + "Next question": "Nächste Frage", + "Previous question": "Vorherige Frage", + "Review answers": "Antworten überprüfen", + "Submit exam": "Prüfung abgeben", + "Exam complete": "Prüfung abgeschlossen", + "You passed!": "Herzlichen Glückwunsch, Sie haben bestanden!", + "You did not pass": "Sie haben nicht bestanden", + "Question": "Frage" } } \ No newline at end of file diff --git a/src/data/es.json b/src/data/es.json index 3158132..2c76836 100644 --- a/src/data/es.json +++ b/src/data/es.json @@ -653,5 +653,23 @@ "hooks": [ "ganchos" ] + }, + "exam_ui": { + "Pass": "Aprobado", + "Fail": "Reprobado", + "Passing score": "Puntuación para aprobar", + "Your score": "Tu puntuación", + "Retake": "Volver a intentar", + "Attempt": "Intento", + "Attempts remaining": "Intentos restantes", + "Time remaining": "Tiempo restante", + "Next question": "Siguiente pregunta", + "Previous question": "Pregunta anterior", + "Review answers": "Revisar respuestas", + "Submit exam": "Enviar examen", + "Exam complete": "Examen completado", + "You passed!": "¡Felicidades, aprobaste!", + "You did not pass": "No aprobaste", + "Question": "Pregunta" } } \ No newline at end of file diff --git a/src/data/fr.json b/src/data/fr.json index 431fcc0..c791230 100644 --- a/src/data/fr.json +++ b/src/data/fr.json @@ -653,5 +653,23 @@ "hooks": [ "crochets" ] + }, + "exam_ui": { + "Pass": "Réussi", + "Fail": "Échoué", + "Passing score": "Note de passage", + "Your score": "Votre score", + "Retake": "Repasser", + "Attempt": "Tentative", + "Attempts remaining": "Tentatives restantes", + "Time remaining": "Temps restant", + "Next question": "Question suivante", + "Previous question": "Question précédente", + "Review answers": "Réviser les réponses", + "Submit exam": "Soumettre l'examen", + "Exam complete": "Examen terminé", + "You passed!": "Félicitations, vous avez réussi !", + "You did not pass": "Vous n'avez pas réussi", + "Question": "Question" } } \ No newline at end of file diff --git a/src/data/ja.json b/src/data/ja.json index 6d96bf5..3a5ce02 100644 --- a/src/data/ja.json +++ b/src/data/ja.json @@ -653,5 +653,23 @@ "hooks": [ "フック" ] + }, + "exam_ui": { + "Pass": "合格", + "Fail": "不合格", + "Passing score": "合格点", + "Your score": "あなたのスコア", + "Retake": "再受験", + "Attempt": "受験回", + "Attempts remaining": "残りの受験回数", + "Time remaining": "残り時間", + "Next question": "次の問題", + "Previous question": "前の問題", + "Review answers": "解答を確認", + "Submit exam": "試験を提出", + "Exam complete": "試験完了", + "You passed!": "おめでとうございます、合格です!", + "You did not pass": "不合格です", + "Question": "問題" } } \ No newline at end of file diff --git a/src/data/ko.json b/src/data/ko.json index 7be2ec7..3e4a1ac 100644 --- a/src/data/ko.json +++ b/src/data/ko.json @@ -643,5 +643,23 @@ "subagent": ["하위 에이전트", "서브에이전트"], "hook": ["후크"], "hooks": ["후크들", "후크"] + }, + "exam_ui": { + "Pass": "합격", + "Fail": "불합격", + "Passing score": "합격 점수", + "Your score": "내 점수", + "Retake": "재응시", + "Attempt": "시도", + "Attempts remaining": "남은 시도 횟수", + "Time remaining": "남은 시간", + "Next question": "다음 문제", + "Previous question": "이전 문제", + "Review answers": "답안 검토", + "Submit exam": "시험 제출", + "Exam complete": "시험 완료", + "You passed!": "축하합니다, 합격하셨습니다!", + "You did not pass": "불합격하셨습니다", + "Question": "문제" } } \ No newline at end of file diff --git a/src/data/zh-CN.json b/src/data/zh-CN.json index 4166662..58a1563 100644 --- a/src/data/zh-CN.json +++ b/src/data/zh-CN.json @@ -656,5 +656,23 @@ "钩子", "挂钩" ] + }, + "exam_ui": { + "Pass": "通过", + "Fail": "未通过", + "Passing score": "及格分数", + "Your score": "您的分数", + "Retake": "重新考试", + "Attempt": "尝试", + "Attempts remaining": "剩余尝试次数", + "Time remaining": "剩余时间", + "Next question": "下一题", + "Previous question": "上一题", + "Review answers": "查看答案", + "Submit exam": "提交考试", + "Exam complete": "考试完成", + "You passed!": "恭喜您,考试通过!", + "You did not pass": "您未通过考试", + "Question": "题目" } } \ No newline at end of file diff --git a/src/lib/constants.js b/src/lib/constants.js index bf3c032..8dce1a0 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -19,6 +19,49 @@ const DEFAULT_PROTECTED_TERMS = 'API, SDK, Claude, Anthropic, Claude Code, Enter // YouTube InnerTube client version — update periodically as needed const YOUTUBE_CLIENT_VERSION = '2.20240101.00.00'; +// ==================== EXAM / ASSESSMENT ==================== + +const EXAM_URL_PATTERNS = [ + /\/quiz\b/i, + /\/exam\b/i, + /\/assessment\b/i, + /\/certification\b/i, + /[?&]type=quiz/i, + /[?&]type=exam/i, +]; + +// Elements whose text should NOT be translated on exam pages +// (answer choices, form inputs — translating these could alter meaning) +const EXAM_SKIP_SELECTORS = [ + 'input[type="radio"] + label', + 'input[type="checkbox"] + label', + `${SKILLJAR_SELECTORS.answerOption}`, + `${SKILLJAR_SELECTORS.answerLabel}`, + `${SKILLJAR_SELECTORS.quizForm} label`, + `${SKILLJAR_SELECTORS.quizForm} .option`, + `${SKILLJAR_SELECTORS.quizForm} li`, +]; + +const EXAM_BANNER_LABELS = { + 'en': 'Exam mode — answer choices are not translated to preserve accuracy.', + 'ko': '시험 모드 — 정확성을 위해 답안 선택지는 번역되지 않습니다.', + 'ja': '試験モード — 正確性のため、回答選択肢は翻訳されません。', + 'zh-CN': '考试模式 — 为确保准确性,答案选项不会被翻译。', + 'es': 'Modo examen — las opciones de respuesta no se traducen para mayor precisión.', + 'fr': 'Mode examen — les choix de réponse ne sont pas traduits pour préserver la précision.', + 'de': 'Prüfungsmodus — Antwortmöglichkeiten werden nicht übersetzt, um die Genauigkeit zu wahren.', +}; + +const TUTOR_EXAM_LABELS = { + 'en': "I can't help with exam answers directly, but I can explain concepts after you submit.", + 'ko': '시험 답안은 직접 도와드릴 수 없지만, 제출 후 개념 설명은 가능합니다.', + 'ja': '試験の回答は直接お手伝いできませんが、提出後にコンセプトを説明できます。', + 'zh-CN': '我不能直接帮助回答考试题目,但提交后可以解释相关概念。', + 'es': 'No puedo ayudar directamente con las respuestas del examen, pero puedo explicar conceptos después de enviar.', + 'fr': "Je ne peux pas aider directement avec les réponses d'examen, mais je peux expliquer les concepts après soumission.", + 'de': 'Ich kann nicht direkt bei Prüfungsantworten helfen, aber nach der Abgabe kann ich Konzepte erklären.', +}; + // ==================== THRESHOLDS ==================== const SKILLBRIDGE_THRESHOLDS = { diff --git a/src/lib/selectors.js b/src/lib/selectors.js index 3c0aa22..2b6550e 100644 --- a/src/lib/selectors.js +++ b/src/lib/selectors.js @@ -39,4 +39,11 @@ const SKILLJAR_SELECTORS = { // FAQ faqTitle: '.faq-title', faqPost: '.faq-post', + + // Quiz / Assessment / Certification + quizForm: '.quiz-form, .assessment-form, form[class*="quiz"], form[class*="assessment"]', + answerOption: '.answer-option, .answer-choice, .quiz-option, .assessment-option', + answerLabel: 'label[class*="answer"], label[class*="option"], label[class*="choice"]', + quizResult: '.quiz-result, .assessment-result, .quiz-score, .score-display', + certificateSection: '.certificate-section, .certificate-panel, .certificate-container', }; diff --git a/tests/constants.test.js b/tests/constants.test.js index d23b10f..8b523f1 100644 --- a/tests/constants.test.js +++ b/tests/constants.test.js @@ -8,17 +8,22 @@ const fs = require('fs'); const path = require('path'); +const selectorsSrc = fs.readFileSync( + path.join(__dirname, '..', 'src', 'lib', 'selectors.js'), 'utf8' +); const constantsSrc = fs.readFileSync( path.join(__dirname, '..', 'src', 'lib', 'constants.js'), 'utf8' ); -// Eval constants into a returned object so they're accessible in test scope -const constants = new Function(`${constantsSrc}; return { +// Eval selectors first (constants.js references SKILLJAR_SELECTORS), then constants +const constants = new Function(`${selectorsSrc}\n${constantsSrc}; return { SKILLBRIDGE_MODELS, SKILLBRIDGE_THRESHOLDS, SKILLBRIDGE_DELAYS, SKILLBRIDGE_LIMITS, PREMIUM_LANGUAGES, AVAILABLE_LANGUAGES, AVAILABLE_LANGUAGE_CODES, SUPPORTED_LANGUAGE_MAP, POPUP_LABELS, DEFAULT_PROTECTED_TERMS, YOUTUBE_CLIENT_VERSION, SKILLBRIDGE_MODEL_LABELS, SHORTCUT_LABELS, SHORTCUT_DESCRIPTIONS, + EXAM_URL_PATTERNS, EXAM_SKIP_SELECTORS, EXAM_BANNER_LABELS, TUTOR_EXAM_LABELS, + SKILLJAR_SELECTORS, };`)(); const { diff --git a/tests/translator.test.js b/tests/translator.test.js index f72b354..2c8a301 100644 --- a/tests/translator.test.js +++ b/tests/translator.test.js @@ -17,7 +17,10 @@ global.window = { addEventListener: () => {} }; const fs = require('fs'); const path = require('path'); -// Load shared constants first (translator.js depends on them) +// Load selectors + constants first (constants.js depends on selectors, translator.js depends on constants) +const selectorsSrc = fs.readFileSync( + path.join(__dirname, '..', 'src', 'lib', 'selectors.js'), 'utf8' +); const constantsSrc = fs.readFileSync( path.join(__dirname, '..', 'src', 'lib', 'constants.js'), 'utf8' ); @@ -25,12 +28,13 @@ const src = fs.readFileSync( path.join(__dirname, '..', 'src', 'lib', 'translator.js'), 'utf8' ); -// Combine constants + translator in a single eval so constants are in scope +// Combine selectors + constants + translator in a single eval so all are in scope let SkilljarTranslator; try { - const combined = `(function() { ${constantsSrc}; ${src}; return SkilljarTranslator; })()`; + const combined = `(function() { ${selectorsSrc}; ${constantsSrc}; ${src}; return SkilljarTranslator; })()`; SkilljarTranslator = eval(combined); } catch (e) { + eval(selectorsSrc); eval(constantsSrc); eval(src); SkilljarTranslator = global.SkilljarTranslator;