Duolingo LevelJumper

Provides jump buttons to the next lesson for leveling up (based on minirock / oltodosel autoScroller).

Version au 07/06/2021. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name        Duolingo LevelJumper
// @description Provides jump buttons to the next lesson for leveling up (based on minirock / oltodosel autoScroller).
// @version     2.12.2
// @namespace   esh
// @match       https://*.duolingo.com/*
// @grant       GM_setValue
// @grant       GM_getValue
// ==/UserScript==

// 2.3: find a better solution to move to the first lesson of this level
// 2.4: take into concern if there are different levels in one row
// 2.4: better jumpMark more satisfying
// 2.5: set jumpMark to the crown-div, so it moves the old lessons out of view
// 2.5: jumpMark for Crown 3 does not work????
// 2.6: fix if jumpMark is the very first lesson
// 2.7: should work now without plus button
// 2.8: auto-scroll if you want, set it below at const AUTO_SCROLL
// 2.8.1: what happens, if some crowns do not exist, E.g. lessons level 4 and lessons level 2, but no lessons level 3 available
// I suppose it's just set to the lower level, in this example level 2.
// yeah, of course it broke the script ... but not any longer
// 2.9: changed from selecting the last element from the previous level to the first element from the first level and got rid of having maxed out lessons in view
// 2.10: added broken levels
// 2.11: added auto scroll feature
// 2.11.1: autoScroll = false fixed
// 2.11.2: works with different base languages than english
// 2.12: autoscroll is different for different learning languages
// 2.12.1: display disabled lessons at autoscroll selection
// 2.12.2: removed bug with checkpoints


// TODO: autoscroll every time you change language or come back from a lesson
// probably tackle checkJumpMark()

// TODO: clean up the code, move selectors to constants

// TODO: parse over url
// Tampermonkey access variables

// TODO: fix bug with anchors in URL ...

// TODO: fix bug with target's in URL
// not worth the time
// great, it happens, when you reload a page with a target in the url, so it's mostly a developer problem nothing more
// can fix it with location.url or something

let autoScroll = false;
let lang;

const CROWN = 'level-crown';
const CROWN_QS = '[data-test="' + CROWN + '"]';
const CROWN_IMG_CLASS = '_18sNN';
const CROWN_IMG_CLASS_QS = '[class~="' + CROWN_IMG_CLASS + '"]';
const SKILL_ICON = 'skill-icon';
const SKILL_ICON_QS = '[data-test="' + SKILL_ICON +'"]';

new MutationObserver(checkJumpMark).observe(document.body, {
	childList: true,
	subtree: true
});

function getConfig() {
    lang = JSON.parse(localStorage.getItem('duo.state')).user.learningLanguage;
    autoScroll = GM_getValue('autoScroll-'+lang, false);
    // console.debug('Duolingo LevelJumper: get autoScroll = ' + autoScroll);
    autoScroll === 'undefined' ? autoScroll = false : '';
    autoScroll === 'false' ? autoScroll = false : '';
}

function setConfig(value) {
    GM_setValue('autoScroll-'+lang, value);
    // console.debug('Duolingo LevelJumper: set autoScroll to ' + value);
}

function checkJumpMark() {
    //setConfig();

    if (document.querySelectorAll('.GkDDe').length!=0) {
        if (document.querySelector('#jumpMark')===null) {
            if (document.querySelector('._3yqw1')!=null) addJumpMarks();
        }
    }
}

function getAnchorElement(elem) {
    // if the element is the first in the tree
    if (elem[0] === document.querySelector('[data-test="skill-tree"]').querySelector(CROWN_IMG_CLASS_QS)) {
        return elem[0];
    } else if (elem[0] === undefined) {
        return null;
    } else {
        // first lesson element
        let lesson = elem[0].parentNode.parentNode.parentNode.parentNode.parentNode.parentNode.parentNode;
        let row = lesson.parentNode.previousSibling;
        // sometimes the row is a checkpoint and so it turns out null
        if (row === null) {
            // totally understandable ;) go up, go to last tree section and to the last row there
            row = lesson.parentNode.parentNode.parentNode.previousSibling.previousSibling.firstChild.lastChild;
        }
        // sometimes the previous row is a divider
        // has to be CROWN_IMG_CLASS_QS to select possible lessons lvl 0
        if (!row.querySelector(CROWN_IMG_CLASS_QS)) {
            row = row.previousSibling;
        }
        // select the crown image as anchor element
        // has to be CROWN_IMG_CLASS_QS to select possible lessons lvl 0
        return row.querySelector(CROWN_IMG_CLASS_QS);
    }
}

// toggles visibility
function togglePopout(id) {
    let popout = document.getElementById(id);
    popout.style.display === "none" ? popout.style.display = "block" : popout.style.display = "none";
}

function addJumpMarks() {
    let level0 = document.querySelectorAll('img[src="//d35aaqx5ub95lt.cloudfront.net/images/fafe27c9c1efa486f49f87a3d691a66e.svg"]');
    // has to be CROWN_IMG_CLASS_QS to select possible lessons lvl 0
    let firstLesson = document.querySelector('[data-test="skill-tree"]').querySelector(CROWN_IMG_CLASS_QS);
    let level = document.querySelectorAll(CROWN_QS);
    let level1 = [];
    let level2 = [];
    let level3 = [];
    let level4 = [];
    let level5 = [];
    let levelMissing = [];
    for (let i=0;i<level.length;i++) {
        switch(level[i].innerHTML) {
            case '1':
                isMaxedOut(level[i]) ? isBroken(level[i]) ? level5.push(level[i].previousSibling) : '' : level1.push(level[i].previousSibling);
                break;
            case '2':
                isMaxedOut(level[i]) ? isBroken(level[i]) ? level5.push(level[i].previousSibling) : '' : level2.push(level[i].previousSibling);
                break;
            case '3':
                isMaxedOut(level[i]) ? isBroken(level[i]) ? level5.push(level[i].previousSibling) : '' : level3.push(level[i].previousSibling);
                break;
            case '4':
                isMaxedOut(level[i]) ? isBroken(level[i]) ? level5.push(level[i].previousSibling) : '' : level4.push(level[i].previousSibling);
                break;
            case '5':
                isBroken(level[i]) ? level5.push(level[i].previousSibling) : '';
                break;
        }
    }
/*    levelMissing = [null, level1.length, level2.length, level3.length, level4.length, level5.length];
    // if some level inbetween is missing
    // E.g. level 3 is missing and we need it to anchor the first level 2
    // so we replace level 3 with level 4
    if (level5.length === 0) level5[0] = null;
    if (level4.length === 0) level4 = level5;
    if (level3.length === 0) level3 = level4;
    if (level2.length === 0) level2 = level3;
    if (level1.length === 0) level1 = level2;
    // the last element of the higher level is used to anchor the level below
    // E.g. First level 4 = last level 5
    // if it doesn't exist, we have to the first element of the level below
    if(level5[0] === null && level4[0] !== null) level5[0] = level4[0];
    if(level4[0] === null && level3[0] !== null) level4[0] = level3[0];
    if(level3[0] === null && level2[0] !== null) level3[0] = level2[0];
    if(level2[0] === null && level1[0] !== null) level2[0] = level1[0];
    // freaky selecting the previous row
    */
    // level1 is the anchor element for new lessons
    let anchor0 = null;
    if(level0[0] !== null) anchor0 = getAnchorElement(level0);
    // level2 is the anchor element for level 1 lessons
    let anchor1 = null;
    if(level1[0] !== null) anchor1 = getAnchorElement(level1);
    // level3 is the anchor element for level 2 lessons
    let anchor2 = null;
    if(level2[0] !== null) anchor2 = getAnchorElement(level2);
    // level4 is the anchor element for level 3 lessons
    let anchor3 = null;
    if(level3[0] !== null) anchor3 = getAnchorElement(level3);
    // level5 is the anchor element for level 4 lessons
    let anchor4 = null;
    if(level4[0] !== null) anchor4 = getAnchorElement(level4);
    let anchor5 = null;
    if(level5[0] !== null) anchor5 = getAnchorElement(level5);
    // _3NYLT instead of _3yqw1 (plus button)
    let insertElement = document.querySelector('._3NYLT');
    let jumpMark = document.createElement('div');
    jumpMark.setAttribute('class','_3yqw1 np6Tv');
    jumpMark.setAttribute('style','padding-top: 0.6rem; padding-right: 0.6rem; top: 148px;');
    jumpMark.innerHTML = '<div id="jumpMark"></div>';
    //console.group('Not overriding double id tags');
/*    if(anchor0 !== null) {
        // last level 1 element get the id = level0 for jumping to new lessons
        let id = 'level0';
        anchor0.id = id;
        anchor0 === firstLesson? id = 'javascript:scroll(0,0);' : id = '#'+id;
        jumpMark.innerHTML += // `<a href="#notDone"><img src="//d35aaqx5ub95lt.cloudfront.net/images/fafe27c9c1efa486f49f87a3d691a66e.svg"/></a>
`<div class="_2-dXY _1swBH" style="font-size: 14.84px;">
  <a href="${id}">
    <img alt="crown" class="_18sNN" src="//d35aaqx5ub95lt.cloudfront.net/images/fafe27c9c1efa486f49f87a3d691a66e.svg">
  </a>
</div>`;
    } */
    // jumpMarks only if there are corresponding levels
    if (anchor5 !== null) jumpMark.innerHTML += prepareJumpMark(anchor5, 5, firstLesson);
    if (anchor4 !== null) jumpMark.innerHTML += prepareJumpMark(anchor4, 4, firstLesson);
    if (anchor3 !== null) jumpMark.innerHTML += prepareJumpMark(anchor3, 3, firstLesson);
    if (anchor2 !== null) jumpMark.innerHTML += prepareJumpMark(anchor2, 2, firstLesson);
    if (anchor1 !== null) jumpMark.innerHTML += prepareJumpMark(anchor1, 1, firstLesson);
    if (anchor0 !== null) jumpMark.innerHTML += prepareJumpMark(anchor0, 0, firstLesson);
    jumpMark.innerHTML += `<div class="_2-dXY _1swBH" style="font-size: 14.84px;">
  <a id="ljToggleConfig">
    <img alt="crown" class="_18sNN" style="padding-left: 0.2rem; padding-top: 0.1rem;" src="//d35aaqx5ub95lt.cloudfront.net/images/gear.svg">
  </a>
</div>`;

    //console.groupEnd();
    // beforebegin instead of afterend plus button
    let config = document.createElement('div');
    config.setAttribute('class','_3yqw1 np6Tv');
    config.setAttribute('style','width: 300px; display: none;');
    config.setAttribute('id','ljConfig');
    let options = '<option value="false">--</option>';
    options += anchor5 !== null ? '<option value="5">5</option>' : '<option value="5" disabled>5</option>';
    options += anchor4 !== null ? '<option value="4">4</option>' : '<option value="4" disabled>4</option>';
    options += anchor3 !== null ? '<option value="3">3</option>' : '<option value="3" disabled>3</option>';
    options += anchor2 !== null ? '<option value="2">2</option>' : '<option value="2" disabled>2</option>';
    options += anchor1 !== null ? '<option value="1">1</option>' : '<option value="1" disabled>1</option>';
    options += anchor0 !== null ? '<option value="0">0</option>' : '<option value="0" disabled>0</option>';
    config.innerHTML = `
    <div class="_3uS_y eIZ_c" data-test="skill-popout" style="--margin:20px;">
      <div class="_2O14B _2XlFZ _1v2Gj WCcVn" style="z-index: 1;">
        <div class="_1KUxv _1GJUD _3lagd SSzTP"><div class="_1cv-y"></div>
        <div class="QowCP">
          <div class="_1m77f" style="text-align: center">AutoScroll &nbsp;
            <select style="background-color: #ffc800; color: white;"name="levels" id="configLevels">
            ${options}
            </select>
          </div>
        </div>
      </div>
      <div class="ite_X" style="left: calc(90% - 15px);">
        <div class="_3p5e9 _3lagd SSzTP"></div>
      </div>
    </div>`;

    insertElement.insertAdjacentElement('beforebegin',jumpMark);
    insertElement.insertAdjacentElement('beforebegin',config);
    let top = jumpMark.offsetHeight + 160;
    console.debug('Duolingo LevelJumper: top = ' + top);
    config.style.top = top + 'px';
    getConfig();
    //console.debug('Duolingo LevelJumper: BoundingHeight = ' + jumpMark.offsetHeight);
    console.debug('Duolingo LevelJumper: AutoScroll = ' + autoScroll);
    let configLevels = document.getElementById('configLevels')
    configLevels.querySelector('[value="' + autoScroll + '"]').setAttribute('selected', true);
    configLevels.addEventListener('change', function() {
        setConfig(configLevels.options[configLevels.selectedIndex].value);
    });
    document.getElementById('ljToggleConfig').addEventListener('click', function () { togglePopout('ljConfig'); });
    if (autoScroll) document.getElementById('myJumpTo' + autoScroll).click();
}

// returns true if element has the highest level
function isMaxedOut(element) {
    // TODO move to constant
    if (element.parentNode.parentNode.parentNode.querySelector('[href="//d35aaqx5ub95lt.cloudfront.net/images/9dc5f133240809ea530649bca27c7eca.svg"]')) {
        //console.debug(element);
        return true;
    } else { return false; }
}

// returns true if element is broken
function isBroken(element) {
    // TODO move to constant
    if (element.parentNode.parentNode.previousSibling.querySelector('._1m7gz')) { return true; } else { return false; }
}

function prepareJumpMark(anchor, number, firstLesson) {
    let innerHTML = '';
    if(anchor !== null) {
        // show the first level 'number' lesson
        let id = 'level' + number;
        // if there have two level the same jump mark, it uses the given one instead of overriding it
        if(anchor.id !== '') {
            console.debug('Duolingo LevelJumper: Id ' + anchor.id + ' already exists');
            id = anchor.id;
        } else {
            anchor.id = id;
        }
        anchor === firstLesson? id = 'javascript:scroll(0,0);' : id = '#'+id;
        let crownImage;
        let crownClass;
        let crownNumber = number;
        if (number === 0) {
            // grey crown
            crownImage = '//d35aaqx5ub95lt.cloudfront.net/images/fafe27c9c1efa486f49f87a3d691a66e.svg';
            crownNumber = '';
        } else {
            // golden crown
            crownImage = '//d35aaqx5ub95lt.cloudfront.net/images/b3ede3d53c932ee30d981064671c8032.svg';
        }
        if (number !== 5) {
            crownClass = "GkDDe";
        } else {
            crownClass = "GkDDe _1m7gz";
            crownNumber = '';
        }
        innerHTML =
            `<div class="_2-dXY _1swBH" style="font-size: 14.84px;">
  <a id="myJumpTo${number}" href="${id}">
    <img alt="crown" class="_18sNN" src="${crownImage}">
    <div class="${crownClass}" data-test="level-crown">${crownNumber}</div>
  </a>
</div>`;
    }
    return innerHTML;
}