noplanman / Duolingo Unlocker

// ==UserScript==
// @name        Duolingo Unlocker
// @namespace   noplanman
// @description Allows you to practice any skill and adds a few niceties to the UI.
// @include     https://www.duolingo.com/
// @version     1.1
// @author      Armando Lüscher
// @oujs:author noplanman
// @copyright   2016 Armando Lüscher
// @grant       GM_addStyle
// @grant       window
// @require     https://code.jquery.com/jquery-1.12.4.min.js
// @homepageURL https://github.com/noplanman/Duolingo-Unlocker
// @supportURL  https://github.com/noplanman/Duolingo-Unlocker/issues
// ==/UserScript==

/**
 * Main Duolingo Unlocker object.
 *
 * @type {Object}
 */
var DU = {};

/**
 * Debugging level. (disabled,[l]og,[i]nfo,[w]arning,[e]rror)
 *
 * @type {Boolean}
 */
DU.debugLevel = 'l';

/**
 * Load the necessary data variables.
 */
DU.loadVariables = function() {
  DU.user = unsafeWindow.duo.user.attributes;
  DU.lang = DU.user.language_data[DU.user.learning_language];
  DU.skills = {};
  jQuery.each(DU.lang.skills.models, function(i, skill) {
    DU.skills[skill.attributes.short] = skill.attributes;
  });
  DU.log('Variables loaded');
};

/**
 * Unlock all the locked items and convert them to links.
 */
DU.unlockTree = function() {
  var unlockedSkills = [];
  jQuery('.skill-tree-row:not(.bonus-row, .row-shortcut) .skill-badge-small.locked').each(function() {
    var $skillItemOld = jQuery(this).removeClass('locked').addClass('skill-item');

    // Get just the text of the skill (without the number of excercises)
    var skillNameShort = $skillItemOld.find('.skill-badge-name')
      .clone().children().remove()
      .end().text().trim();
    var skill = DU.skills[skillNameShort];

    $skillItem = jQuery('<a/>', {
      'html'       : $skillItemOld.html(),
      'class'      : $skillItemOld.attr('class'),
      'data-skill' : skill.name,
      'href'       : '/skill/' + DU.lang.language + '/' + skill.url_title,
    });

    $skillItem.find('.skill-icon')
      .removeClass('locked')
      .addClass('unlocked')
      .addClass(skill.icon_color);

    // Replace the <span/> with the new <a/> element
    $skillItemOld.replaceWith($skillItem);

    unlockedSkills.push(skill);
  });

  DU.log('Skill tree unlocked: ' + unlockedSkills.length + ' new skills unlocked');
};

/**
 * Add the progress bar for the level, showing how many points are needed to level up.
 *
 * @todo What happens when a tree is finished? It should just be a full bar.
 */
DU.progressBar = function() {
  var progressText = DU.lang.level_percent + '%  ( ' + DU.lang.level_progress + ' / ' + DU.lang.level_points + ' )';
  var $levelTextLeft = jQuery('.level-text');
  var $levelTextRight = $levelTextLeft
    .clone(true)
    .addClass('right')
    .text(
      (DU.lang.level_percent < 100)
      ? $levelTextLeft.text().replace(/(\d+)+/g, function(match, number) {
          // Increase the level number.
          return parseInt(number) + 1;
        })
      : 'MAX'
    )
    .insertAfter($levelTextLeft);

    // Add the progress bar after the level text fields.
  $levelTextRight.after(
    '<div class="progress-bar-dynamic strength-bar DU-strength-bar">' +
    '  <div class="DU-meter-text">' + progressText + '</div>' +
    '  <div style="opacity: 1; width: ' + DU.lang.level_percent + '%;" class="DU-meter-bar bar gold"></div>' +
    '</div>'
  );

  DU.log('Progress bar updated');
};

/**
 * Start the party.
 */
DU.init = function() {
  // Add the global CSS rules.
  GM_addStyle(
    '.meter           { -moz-border-radius: 25px; -webkit-border-radius: 25px; background: #555; border-radius: 25px; box-shadow: inset 0 -1px 1px rgba(255,255,255,0.3); height: 20px; padding: 2px; position: relative; display: block; }' +
    '.meter-level     { display: block; height: 100%; border-top-right-radius: 8px; border-bottom-right-radius: 8px; border-top-left-radius: 20px; border-bottom-left-radius: 20px; background-color: #ffa200; background-image: linear-gradient(   center bottom,   #ffa200 37%,   rgb(84,240,84) 69% ); box-shadow: inset 0 2px 9px  rgba(255,255,255,0.3),inset 0 -2px 6px rgba(0,0,0,0.4); position: relative; overflow: hidden; }' +
    '.DU-meter-text   { width: 100%; position: absolute; z-index: 1; color: #000; opacity: .5; text-align: center; font-size: .8em; }' +
    '.DU-strength-bar { width: 100% !important; left: 0 !important; margin-top: 10px }' +
    '.DU-meter-bar    { height: 100% !important; margin: 0 !important; }'
  );

  // Initial execution.
  DU.loadVariables();
  DU.unlockTree();
  DU.progressBar();

  // Observe main page for changes.
  DU.Observer.add('#app', [DU.loadVariables, DU.unlockTree, DU.progressBar]);
};

// source: https://muffinresearch.co.uk/does-settimeout-solve-the-domcontentloaded-problem/
if (/(?!.*?compatible|.*?webkit)^mozilla|opera/i.test(navigator.userAgent)) { // Feeling dirty yet?
  document.addEventListener('DOMContentLoaded', DU.init, false);
} else {
  window.setTimeout(DU.init, 0);
}

/**
 * Make a log entry if debug mode is active.
 * @param {string}  logMessage Message to write to the log console.
 * @param {string}  level      Level to log ([l]og,[i]nfo,[w]arning,[e]rror).
 * @param {boolean} alsoAlert  Also echo the message in an alert box.
 */
DU.log = function(logMessage, level, alsoAlert) {
  if (!DU.debugLevel) {
    return;
  }

  var logLevels = { l : 0, i : 1, w : 2, e : 3 };

  // Default to "log" if nothing is provided.
  level = level || 'l';

  if ('disabled' !== DU.debugLevel && logLevels[DU.debugLevel] <= logLevels[level]) {
    switch(level) {
      case 'l' : console.log(  logMessage); break;
      case 'i' : console.info( logMessage); break;
      case 'w' : console.warn( logMessage); break;
      case 'e' : console.error(logMessage); break;
    }
    alsoAlert && alert(logMessage);
  }
};

/**
 * The MutationObserver to detect page changes.
 *
 * @type {Object}
 */
DU.Observer = {
  /**
   * The mutation observer objects.
   *
   * @type {Array}
   */
  observers : [],

  /**
   * Add an observer to observe for DOM changes.
   *
   * @param {String}         queryToObserve Query string of elements to observe.
   * @param {Array|Function} cbs            Callback function(s) for the observer.
   */
  add : function(queryToObserve, cbs) {
    // Check if we can use the MutationObserver.
    if ('MutationObserver' in window) {
      var toObserve = document.querySelector(queryToObserve);
      if (toObserve) {
        if (!jQuery.isArray(cbs)) {
          cbs = [cbs];
        }
        cbs.forEach(function(cb) {
          var mo = new MutationObserver(cb);

          // No need to observe subtree changes!
          mo.observe(toObserve, {
            childList: true
          });

          DU.Observer.observers.push(mo);
        });
      }
    }
  }
};