Duolingo LevelJumper

Provides an menu to easy jump to the first lesson of a skill level (e.g. 3 crowns) and to the last learned level.

// ==UserScript==
// @name         Duolingo LevelJumper
// @namespace    esh
// @version      3.0.1
// @description  Provides an menu to easy jump to the first lesson of a skill level (e.g. 3 crowns) and to the last learned level.
// @match        https://*.duolingo.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=duolingo.com
// ==/UserScript==

const VERSION = '3.0.1';
const LOG_STRING = 'DuolingoLevelJumper ' + VERSION;
const SKILL_QS = '[data-test="skill"]';
const FLOATING_BUTTONS = '._1Hxe4';
const config = {};
config.init = false;
config.ignoreLegendaray = true;
config.autoScroll = 'off';

// Print nice debug statements
function debug(s) {
  let name = (debug.caller !== null) ? debug.caller.name : '';
  if (typeof (s) === 'object') {
    console.debug(LOG_STRING + ' ' + name + '(): ');
    console.debug(s);
  } else {
    console.debug(LOG_STRING + ' ' + name + '(): ' + s);
  }
}

(function () {
  'use strict';
  new MutationObserver(start).observe(document.body, {
    childList: true,
    subtree: true
  });
  debug('MutationOberserver running');
})();

window.addEventListener('load', function () {
  autoScroll();
});

// entry point for mutation observer
function start() {
  // only run, when we are in the learning tree page
  if (window.location.pathname.includes('/learn')) {
    init();
  } else {
    // reset config to allow reindexing when we come back to the learning tree page
    config.init = false;
  }
}

// init the one time run functions
function init() {
  const skills = document.querySelectorAll(SKILL_QS);
  if (skills.length > 0 && !config.init) {
    config.init = true;
    getConfig();
    prepareJumpTargets(skills);
    addJumpMenu();
  }
}
// gets stored config data
function getConfig() {
  const duoState = JSON.parse(localStorage.getItem('duo.state'));
  config.lLang = duoState.user.learningLanguage;
  config.fLang = duoState.user.fromLanguage;
  config.langConfig = config.lLang + '_' + config.fLang;
  config.autoJump = GM_getValue('DuolingoLevelJumper-autoJump-' + config.langConfig, '0');
  config.level0 = GM_getValue('DuolingoLevelJumper-level0-' + config.langConfig, '0');
  config.level1 = GM_getValue('DuolingoLevelJumper-level1-' + config.langConfig, '0');
  config.level2 = GM_getValue('DuolingoLevelJumper-level2-' + config.langConfig, '0');
  config.level3 = GM_getValue('DuolingoLevelJumper-level3-' + config.langConfig, '0');
  config.level4 = GM_getValue('DuolingoLevelJumper-level4-' + config.langConfig, '0');
  config.level5 = GM_getValue('DuolingoLevelJumper-level5-' + config.langConfig, '0');
  config.levelCrown = GM_getValue('DuolingoLevelJumper-levelCrown-' + config.langConfig, '0');
  config.levelBroken = GM_getValue('DuolingoLevelJumper-levelBroken-' + config.langConfig, '0');
  config.tree = GM_getValue('DuolingoLevelJumper-tree-' + config.langConfig, '');
  config.lastLevel = GM_getValue('DuolingoLevelJumper-lastLevel-' + config.langConfig, 'DuolingoLevelJumper_0');

  buildIndex(duoState.skills);

  debug('loading config');
}

// stores config data
function setConfig() {
  GM_setValue('DuolingoLevelJumper-lastLevel-' + config.langConfig, config.lastLevel);
}

// build a sorted skills list
function buildIndex(skillList) {
  const index = {};
  const rows = {};
  const skillElements = {};
  index[0] = [];
  index[1] = [];
  index[2] = [];
  index[3] = [];
  index[4] = [];
  index[5] = [];
  index['finalLevel'] = [];
  index['decayed'] = [];
  index['crown'] = [];
  index['lastLevel'] = [];
  let i = 0;
  for (element in skillList) {
    const skill = skillList[element];
    skill.id = 'DuolingoLevelJumper_' + i;
    rows[skill.row] = skill.id;
    skillElements[skill.id] = skill;
    if (skill.accessible) {
      if (skill.id === config.lastLevel) {
        index['lastLevel'] = [skill];
      }
      if (skill.finishedLevels === skill.levels) {
        index['crown'].push(skill);
      } else if (skill.decayed) {
        index['decayed'].push(skill);
      } else {
        if (config.ignoreLegendaray && skill.finishedLevels === skill.levels - 1) {
          index['finalLevel'].push(skill);
        } else {
          index[skill.finishedLevels].push(skill);
        }
      }
      i++;
    }
  }
  config.skillElements = skillElements;
  config.skillRows = rows;
  config.skills = index;
}

// set target ids to all skills
function prepareJumpTargets(skills) {
  skills.forEach(function (element, index) {
    element.id = 'DuolingoLevelJumper_' + index;
    element.addEventListener('click', function () {
      config.skills['lastLevel'] = config.skillElements[element.id];
      config.lastLevel = element.id;
      setConfig();
      document.querySelector('#jumpMenu_lastLevel').children[1].innerText = index;
      document.querySelector('#jumpMenu_lastLevel').href = getJumpTarget(config.skillElements[element.id]);
      // TODO: prevent setting it for unaccessible skills
    })
  });
  debug('setting all jump targets');
}


// adds the jump menu underneath the floating plus button
function addJumpMenu() {
  let insertElement = document.querySelector(FLOATING_BUTTONS);
  let jumpMenu = document.createElement('div');
  jumpMenu.innerHTML = '<button class="_2qIaj _2kfEr _1nlVc _2fOC9 UCrz7 t5wFJ _3tP0w">';
  jumpMenu.innerHTML += prepareJumpMenuEntry('decayed');
  // does it make any sense displaying crowns?
  // jumpMenu.innerHTML += prepareJumpMenuEntry('crown');
  jumpMenu.innerHTML += prepareJumpMenuEntry('finalLevel');
  jumpMenu.innerHTML += prepareJumpMenuEntry('5');
  jumpMenu.innerHTML += prepareJumpMenuEntry('4');
  jumpMenu.innerHTML += prepareJumpMenuEntry('3');
  jumpMenu.innerHTML += prepareJumpMenuEntry('2');
  jumpMenu.innerHTML += prepareJumpMenuEntry('1');
  jumpMenu.innerHTML += prepareJumpMenuEntry('0');
  jumpMenu.innerHTML += prepareJumpMenuEntry('lastLevel');
  jumpMenu.innerHTML += '</button>';
  insertElement.insertAdjacentElement('beforeend', jumpMenu);
  debug('adding jump menu');
}

// One entry with right crown image and number
function prepareJumpMenuEntry(skill) {
  const listLength = config.skills[skill].length;
  if (listLength === 0) return '';
  let target = getJumpTarget(config.skills[skill][0]);
  let crownImage;
  let crownClass;
  let crownNumber = skill;
  if (skill === '0') {
    // grey crown
    crownImage = '//d35aaqx5ub95lt.cloudfront.net/images/fafe27c9c1efa486f49f87a3d691a66e.svg';
    crownNumber = '';
    // } else if (skill === 'finalLevel') {
    // legendary crown
    //crownImage = '//d35aaqx5ub95lt.cloudfront.net/images/crowns/dc4851466463c85bbfcaaaaae18e1925.svg'
  } else {
    // golden crown
    crownImage = '//d35aaqx5ub95lt.cloudfront.net/images/b3ede3d53c932ee30d981064671c8032.svg';
  }
  const normalSkills = ['0', '1', '2', '3', '4', '5', 'finalLevel', 'lastLevel'];
  if (normalSkills.includes(skill)) {
    crownClass = "GkDDe";
  } else {
    crownClass = "GkDDe _1m7gz";
    crownNumber = '';
  }
  if (skill === 'finalLevel') crownNumber = '';
  if (skill === 'lastLevel') crownNumber = ((config.skills[skill][0].id).split('_'))[1];
  return `<div class="_2-dXY" style="font-size: 14.84px;">
  <a id="jumpMenu_${skill}" href="${target}">
    <img alt="crown" class="_18sNN" style="width:32px; height:26px;" src="${crownImage}">
    <div class="${crownClass}" style="top:40%;" data-test="level-crown">${crownNumber}</div>
  </a>
</div>`;
}

// get jump target (one row before) given element
function getJumpTarget(element) {
  let target = config.skillRows[element.row - 1];
  target = (target === undefined) ? 'javascript:scroll(0,0);' : '#' + target;
  return target;
}

// triggers autoScrolling
function autoScroll() {
  if (config.autoScroll !== 'off') {
    document.getElementById('jumpMenu_' + config.autoScroll).click();
    debug(config.autoScroll);
  }
}