// ==UserScript==
// @name Duolingo HearEverything
// @namespace http://tampermonkey.net/
// @version 0.25
// @description Let's you hear phrases and words from single choice questions based on your browsers speech synthesis (right now only phrases).
// @author Esh
// @match https://*.duolingo.com/*
// @grant GM_setValue
// @grant GM_getValue
// @downloadURL none
// ==/UserScript==
// 0.7: Mutation Observer instead of setInterval
// 0.6: Add voice to choices on click
// 0.6.1: check why not the innerText of the answer is displayed in the full sentence?
// 0.8.1: fix speaking numbers for options
// 0.9: Move speak button near the continue button
// 0.10.2: set better newPage = true - deleted
// 0.10.3: debug quirks from setting newPage
// 0.11: cleaned up some code
// 0.12: finally got rid of the new page problem
// 0.13: show some debug infos on the page
// 0.14: more working reading
// 0.15: added more challenges to read
// 0.16: Challenges, which work (some partially) // FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE
// 0.17: added shortcut ALT+l
// 0.18: listening button for DIALOGUE and bugfixing TRANSLATE
// 0.19: better listening button
// 0.20: Voice selection
// 0.21: cleaned up code
// 0.22: challenge translate (tap) working
// 0.22.1: no speaker button with translate from learning language
// 0.23: Alt + l for Duo buttons, too
// 0.24: tap-complete working
// 0.25: form challenge working
/*
// TODO: maybe add listening to speaking experience?
// TODO: clean up the script even more
// TODO: translateInput
// data-test="challenge-translate-input
// but data-test="challenge challenge-listen" is not good
// TODO: selectTranscription should not bind EventListeners because they already have some from Duo
// data-test="challenge challenge-selectTranscription"
// TODO: gapFill
// data-test="challenge challenge-gapFill" to find out if it is such a challenge
// TODO: data-test="challenge challenge-translate
// data-test="challenge-translate-prompt
// select words to a sentence, word bank
// TODO: challenge dialogue
// data-test="challenge challenge-dialogue"
// looks like the same rules as challenge-form-prompt
// data-test="challenge-choice" instead of aria-select?
// TODO: completeReverseTranslation
// data-test="challenge challenge-completeReverseTranslation"
// make harder gives you: data-test="challenge-translate-input"
// TODO: start also on first page
// TODO: Tipp-Page
// TODO: Hoots
// TODO: selector where the speak button should be
// TODO: translateInput speaker position top - where to place?
// TODO: Voice selector (automagic) - 1/4 done
// TODO: Add autoplay selector
// more examples on the bottom
// TODO: popup could be toggled by mouseover/mouseout
*/
const buttonPosition = 'bottom'; // bottom / top allowed
let voiceSelect;
let lang = 'fr';
let autoplay = false;
const DEBUG = false;
let synth = window.speechSynthesis;
let voices = [];
let newPage = false;
let addedSpeech = false;
let speakerButton = `
`;
// Element definitions
const WRONG_ANSWER_CLASS = '._1UqAr._1sqiF';
const RIGHT_ANSWER_CLASS = '._1UqAr._1Nmv6';
const RIGHT_CLASS = '._1Nmv6';
const WRONG_CLASS = '._1sqiF';
const ANSWER_HEADLINE = '._1x6Dk';
const ANSWER_CONTAINER = '._2ez4I';
const DIALOGUE_SPEAKER_CLASS = '_29e-M _39MJv _2Hg6H';
// currently used
const ANSWER_CLASS = '._1UqAr';
const ANSWER = 'blame';
const ANSWER_QS = '[data-test~="' + ANSWER + '"]';
const RIGHT_ANSWER = 'blame-correct';
const RIGHT_ANSWER_QS = '[data-test~="' + RIGHT_ANSWER + '"]';
const WRONG_ANSWER = 'blame-incorrect';
const WRONG_ANSWER_QS = '[data-test~="' + WRONG_ANSWER + '"]';
const CHALLENGE_TAP_TOKEN = 'challenge-tap-token'; // challenge-translate (tap)
const CHALLENGE_TAP_TOKEN_QS = '[data-test="' + CHALLENGE_TAP_TOKEN + '"]';
const WORD_BANK = 'word-bank'; // if exists it's tap instead of keyboard (challenge-translate)
const WORD_BANK_QS = '[data-test="' + WORD_BANK + '"]';
const TRANSLATE_INPUT = 'challenge-translate-input';
const TRANSLATE_INPUT_QS = '[data-test="' + TRANSLATE_INPUT + '"]';
const SPEAKER_BUTTON = 'speaker-button';
const SPEAKER_BUTTON_QS = '[data-test="' + SPEAKER_BUTTON + '"]';
const HINT_SENTENCE = 'hint-sentence';
const HINT_SENTENCE_QS = '[data-test="' + HINT_SENTENCE + '"]';
const CHALLENGE_JUDGE = 'challenge-judge-text';
const CHALLENGE_JUDGE_QS = '[data-test="' + CHALLENGE_JUDGE + '"]';
const CHALLENGE_FORM = 'challenge-form-prompt';
const CHALLENGE_FORM_QS = '[data-test="' + CHALLENGE_FORM + '"]';
const RIGHT_OPTION_QS = '[aria-checked="true"] div';
// used page types
const FORM = 'challenge challenge-form';
const TRANSLATE = 'challenge challenge-translate';
// almost the same than challenge-form
// read both correct solutions
//
// here to add a speaker button if I want for the entry text
// or before the first data-test="hint-sentence"
// text to read = document.querySelector('[data-test="hint-sentence"]').parentNode.innerText
// unused page types
// almost the same as challenge-form
const GAP_FILL = 'challenge challenge-gapFill';
// looks like it should be optional to read it loud
const LISTEN_COMPREHENSION = 'challenge challenge-listenComprehension';
// hint-sentence > hint-token (last one = ...) + aria-checked = true
const READ_COMPREHENSION = 'challenge challenge-readComprehension';
// almost the same as challenge-translate
const COMPLETE_REVERSE_TRANSLATION = 'challenge challenge-completeReverseTranslation';
const LISTEN = 'challenge challenge-listen';
// Word bank stuff
// is not read out by Duo all the time (but at "Tap what you hear");
const LISTEN_TAP = 'challenge challenge-listenTap';
// has word bank and some filled up answer to read
const TAP_COMPLETE = 'challenge challenge-tapComplete';
// document.querySelector('[data-test="hint-sentence"]').parentNode.innerText
// = "Elle aime lire et elle a un \nlivre\n."
// maybe we have to strip the \n
// duo reads aloud
const SELECT_TRANSCRIPTION = 'challenge challenge-selectTranscription';
// allowed challenge types
const TEST = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE, LISTEN_COMPREHENSION];
var buttonDisabled = true;
function setVoice() {
voiceSelect = GM_getValue('voiceSelect', 1000);
console.debug('HearEverything: stored voice = ' + voiceSelect);
var duoState = JSON.parse(localStorage.getItem('duo.state'));
lang = duoState.user.learningLanguage;
if(voiceSelect == 1000) {
for (let i = 0; i < voices.length; i++) {
if(voices[i].lang.includes(lang)) {
voiceSelect = i;
console.debug('HearEverything: auto set voice');
}
}
}
console.debug(`HearEverything: voice = ${voiceSelect}, learning language = ${lang}`);
}
// toggles visibility
function togglePopout(id) {
let popout = document.getElementById(id);
popout.style.display === "none" ? popout.style.display = "block" : popout.style.display = "none";
}
function addConfig() {
if(!document.querySelector('#hearEverythingGear') && document.querySelector('[role="progressbar"]')) {
let configButton = document.createElement('button');
configButton.setAttribute('id', 'hearEverythingGear');
configButton.setAttribute('class', '_2hiHn _2kfEr _1nlVc _2fOC9 UCrz7 t5wFJ _1DC8p _2jNpf');
configButton.setAttribute('style', `grid-column: 3/3; background-image:url(//d35aaqx5ub95lt.cloudfront.net/images/gear.svg);
background-position: 0px 0px; background-repeat: no-repeat; background-size: contain;`);
let config = document.createElement('div');
config.setAttribute('class','_3yqw1 np6Tv _1Xlh1');
config.setAttribute('style','display: none; position: fixed; margin-top: 1rem;');
config.setAttribute('id','hearEverythingConfig');
let options = '';
for (let i = 0; i < voices.length; i++) {
options += ``;
}
config.innerHTML = `
Language
`;
document.querySelector('[role="progressbar"]').insertAdjacentElement('afterend',configButton);
configButton.insertAdjacentElement('afterend', config);
configButton.addEventListener('click', function () { togglePopout('hearEverythingConfig'); });
let configLanguage = document.getElementById('configLanguage')
configLanguage.querySelector('[value="' + voiceSelect + '"]').setAttribute('selected', true);
configLanguage.addEventListener('change', function() {
voiceSelect = configLanguage.options[configLanguage.selectedIndex].value;
GM_setValue('voiceSelect', voiceSelect);
setVoice();
});
}
}
function start() {
if (document.querySelector('[data-test="challenge-header"]')) {
addConfig();
buildDebug();
checkNewPage();
let challenge = getChallengeType();
if(challenge !== null) {
// if we have an accepted challenge, we want to process this page, so we set newPage;
// maybe replace it with blame exists and blame does not exist for addedSpeech ...
if (newPage === true) {
// ._3MD8I also available for wrong answers
// better use blame
if (document.querySelector(ANSWER_QS) !== null) {
renderAnswerSpeakButton();
} else if((addedSpeech===false)&&(document.querySelectorAll(CHALLENGE_JUDGE_QS).length!==0)) {
if (challenge[0] === LISTEN_COMPREHENSION) {
let hint = document.querySelector(HINT_SENTENCE_QS).innerText.replace('...', '');
addSpeech(hint);
addedSpeech = true;
}
if (challenge[0] === FORM) {
addSpeech();
addedSpeech = true;
}
/* console.groupCollapsed('Add hearing abilities (options)');
console.debug(getChallengeType()[0]);
// if eventListeners were not bound yet
addSpeech();
addedSpeech = true;
console.debug('Now speech is attached to options');
console.groupEnd('Add hearing abilities (options)'); */
}
}
} else {
// we detected no content to use, so we are not interested in this page
newPage = false;
}
} // end challenge-header detection
}
function prepareChallengeForm() {
let read = document.querySelector(CHALLENGE_FORM_QS).innerText.replace('\n.', '');
if (document.querySelector(RIGHT_ANSWER_QS)) {
read += document.querySelector(RIGHT_OPTION_QS).innerText;
}
if (document.querySelector(WRONG_ANSWER_QS)) {
let answer = document.querySelector(ANSWER_CLASS);
if(answer.lastElementChild) {
read += answer.lastElementChild.innerText;
} else {
read += answer.innerText;
}
}
return read;
/* let read;
let solution = document.querySelector('._1UqAr');
// if it's the right solution, we get it from the selected choice
if (solution.classList.contains('_1Nmv6')) {
read = formPrompt.getAttribute('data-prompt').replace(/_+/,document.querySelector('[aria-checked="true"] div').innerText);
console.debug('Form Prompt: right answer');
} else {
// if it's wrong, we have to get it from the right solution display
read = formPrompt.getAttribute('data-prompt').replace(/_+/,solution.innerText);
console.debug('Form Prompt: wrong answer');
}
if (DEBUG) document.querySelector('#mySentence').innerText = read;
return read;
*/
}
function prepareChallengeTranslate () {
let read;
if (document.querySelector(RIGHT_ANSWER_QS)) {
if (document.querySelector(WORD_BANK_QS)) {
read = document.querySelector(CHALLENGE_TAP_TOKEN_QS).parentNode.parentNode.innerText.replace(/\n/g, ' ');
} else {
let tI = document.querySelector(TRANSLATE_INPUT_QS);
if (tI.lang === lang) read = tI.innerText;
}
}
if (document.querySelector(WRONG_ANSWER_QS)) {
let answer = document.querySelector(ANSWER_CLASS);
if(answer.lastElementChild) {
read = answer.lastElementChild.innerText;
} else {
read = answer.innerText;
}
}
if (document.querySelector(SPEAKER_BUTTON_QS)) read = '';
// console.debug('HearEverything: read = ' + read);
return read;
/*
if(document.querySelector('._1UqAr._1Nmv6')) {
read = document.querySelector('._1UqAr._1Nmv6').innerText;
} else if(document.querySelector('._1UqAr._1sqiF')) {
if (document.querySelector('._1UqAr._1sqiF').lastElementChild) {
read = document.querySelector('._1UqAr._1sqiF').lastElementChild.innerText;
} else {
read = document.querySelector('._1UqAr._1sqiF').innerText;
}
} else {
let translateInput = document.querySelector('[data-test="challenge-translate-input"]');
if (translateInput !== null && translateInput.lang === lang) {
//read = readTranslateInput(translateInput);
read = translateInput.textContent;
}
} */
}
function prepareChallengeTapComplete () {
let read;
if (document.querySelector(RIGHT_ANSWER_QS)) {
read = document.querySelector(HINT_SENTENCE_QS).parentNode.innerText.replace(/\n/g, ' ');
}
if (document.querySelector(WRONG_ANSWER_QS)) {
read = document.querySelector(ANSWER_CLASS).innerText;
}
// console.debug('HearEverything: read = ' + read);
return read;
}
function renderAnswerSpeakButton() {
// document.querySelector('._1UqAr._1Nmv6').innerText; Always the answer?
// let test = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION];
// console.groupCollapsed('Add hearing abilities (input)');
// console.debug(getChallengeType()[0]);
// console.debug('Solution detected');
let read = '';
let challenge = getChallengeType()[0];
if (challenge === FORM) {
read = prepareChallengeForm();
/*
let formPrompt = document.querySelector('[data-test="challenge-form-prompt"]');
if (formPrompt !== null) { read = readFormPrompt(formPrompt); }
*/
}
if (challenge === TRANSLATE) {
read = prepareChallengeTranslate();
}
if (challenge === DIALOGUE) {
//let hints = document.querySelectorAll('[data-test="hint-token"]');
let speaker1 = document.querySelector('[class="' + DIALOGUE_SPEAKER_CLASS + '"]').innerText;
//hints.forEach(function(hint) { speaker1 += hint; });
let speaker2;
if(document.querySelector('[data-test="blame blame-incorrect"]')) {
speaker2 = document.querySelector('._1UqAr._1sqiF').innerText;
} else {
speaker2 = document.querySelector('[aria-checked="true"]').querySelector('[data-test="challenge-judge-text"]').innerText;
}
read = speaker1 + '\n' + speaker2;
}
if (challenge === GAP_FILL) {}
if (challenge === COMPLETE_REVERSE_TRANSLATION) {
let translateInput = document.querySelector('[data-test="challenge-translate-input"]');
if (translateInput !== null && translateInput.lang === lang) { read = readTranslateInput(translateInput); }
}
if (challenge === TAP_COMPLETE) {
read = prepareChallengeTapComplete();
}
console.debug('HearEverything: read = ' + read);
let utter = new SpeechSynthesisUtterance(read);
utter.voice = voices[voiceSelect];
// add speaker button to answer and fill in the correct answer in the headline
updateText(read);
// if we have added the speaker button, we find it in the document
let speak = document.querySelector('#speak');
if(speak) {
speak.addEventListener('click',function(){synth.speak(utter);});
// console.debug('EventListener bound to speak button');
if (DEBUG) document.querySelector('#mySentence').innerText = read;
document.getElementById('speak').title = read;
} else {
console.debug('HearEverything: No speak button found');
}
// do it for every page to trigger the Duo speaker button also
document.removeEventListener('keydown', myShortcutListener);
document.addEventListener('keydown', myShortcutListener);
newPage = false;
// console.debug('Now it\'s an old page');
addedSpeech = false;
// console.debug('Reset: speech isn\'t attached to options any more');
if (DEBUG) document.querySelector('#myOptions').innerText = 'disabled';
// if you like autoplay, it waits 1 second an plays it
if (autoplay) setTimeout(synth.speak(utter),1000);
// console.groupEnd('Add hearing abilities (input)');
}
function myShortcutListener(event) {
let speak = document.querySelector('#speak');
let duoSpeak = document.querySelector(SPEAKER_BUTTON_QS);
// ALT + l combo
if (event.altKey && event.key === 'l') {
if (speak) { speak.click(); }
else if (duoSpeak) duoSpeak.click();
console.debug('Duolingo HearEverything: alt = ' + event.altKey + ' + ' + event.key);
}
}
function readTapComplete(tap) {
let read = '';
let words = tap.childNodes;
words.forEach(function(word) {
if (word.nodeName === 'SPAN') read += word.children[0].innerText;
if (word.nodeName === 'DIV') {
read += word.querySelector('[class="_2Z2xv"]').children[0].innerText;
}
});
return read;
}
// gives some debug information directly in the Duo-GUI
function buildDebug() {
if(DEBUG) {
if(!document.querySelector('#myChallenge')) {
let debug = document.createElement('div');
debug.innerHTML = `Challenge-Name: ${getChallengeType(true)[0]}Sentence to speak: Speak options: disabled`;
debug.style = "font-size: small; text-align:left; display:grid;";
document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', debug);
}
}
}
function checkNewPage() {
if(!document.querySelector('#myNewPage')) {
let nP = document.createElement('div');
nP.id = 'myNewPage';
document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', nP);
//console.debug('---- div - newPage ----');
console.debug('HearEverything: Challenge Type = ' + getChallengeType(true)[0]);
newPage = true;
} else {
//console.debug('---- div - oldPage ----');
}
}
function readTranslateInput(translateInput) {
/* TODO: Word bank
*/
// does not exist on right answers?!
let read = '';
let solution = document.querySelector(ANSWER_CLASS);
if (document.querySelector(ANSWER_CONTAINER).childNodes.length === 1) {
// right answer
read = translateInput.innerHTML;
console.debug('Translate Input: right answer');
/* another correct answer seems not to be recognized as correct answer
it reads the other answer given not my answer
Another correct solution:
Une pizza, s'il vous plaît.
*/
} else if (solution.classList.contains(RIGHT_CLASS)) {
read = '';
// pay attention to accents
// better read the solution here
/*
Pay attention to the accents.
J'habite en Europe.
*/
// or
/*
Julia travaille à Paris.
*/
let span = solution.querySelector('span');
if(span) {
for (let i=0; i
You have a typo.
un passeport américain
*/
/* did not work for - it reads out "undefinedundefined"
Common mistake!
Correct solution:Tu prends la voiture ?
*/
console.debug('Translate Input: right answer with little mistake');
} else {
/* // totally wrong answer
let span = solution.querySelector('span');
if(span) {
for (let i=0; i
${speakerButton}
${t}