Greasy Fork

Duolingo LevelJumper

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

目前为 2021-10-18 提交的版本。查看 最新版本

// ==UserScript==
// @name        Duolingo LevelJumper
// @description Provides jump buttons to the next lesson for leveling up (based on minirock / oltodosel autoScroller).
// @version     2.14
// @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
// 2.13: it works with new changes in Duolingo somehow

// TODO: update after lesson finished

// 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('._1Hxe4')!=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;
        }
    }
    // 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('._1Hxe4');//('._3NYLT');
    let jumpMark = document.createElement('div');
    jumpMark.setAttribute('class','_20MSV np6Tv');
    jumpMark.innerHTML = '<div id="jumpMark"></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" 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','_20MSV np6Tv');
    config.setAttribute('style','display: none; margin-top:-30px;');
    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">
        <div class="_3p5e9 _3lagd SSzTP"></div>
      </div>
    </div>`;

    insertElement.insertAdjacentElement('beforeend',jumpMark);
    insertElement.insertAdjacentElement('beforeend',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" 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;
}