nazikus / Khan Academy timestamps

// ==UserScript==
// @name         Khan Academy timestamps
// @namespace    https://www.khanacademy.org/
// @version      1.3
// @description  Adds time duration labels near each lesson title
// @author       nazikus
// @require      http://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js
// @match        https://www.khanacademy.org/*/*
// @run-at       document-end
// ==/UserScript==
/* jshint -W097 */

// TODO speed parameter button to adjust time labels accordingly
// https://developers.google.com/youtube/iframe_api_reference#Queueing_Functions

// TODO progress time on time label (text clipping background)
// http://nimbupani.com/using-background-clip-for-text-with-css-fallback.html

(function() {
'use strict';

console.log = function() {};  // disable debugging logs

// Vocabulary used here to correspond to KhanAcademy website structure:
// - Subject     major section (math, science, computing)
// - Supertopic  topic containing subtopics (Intergral Calculus)
// - Topic       contains video lessons (Vector and Spaces, Integrals)
// - Module      groups lessons of a single topic
// - Lesson      actual video lesson (contains links to other lesons in a module)

var startProcessing = function(){

  var currUrl = window.location.href;

  // CASE Lesson - video lesson page
  // Check if url contains a directory with a single character (e.g., '/v/', '/e/', etc)
  if ( /\/\w\/[\w\-]+$/.test(currUrl) )  {
    processLessonPage();
  }

  // CASE Topic - Topic page with Table of Contents (ToC)
  // valid url examples:
  // https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
  // https://www.khanacademy.org/math/calculus-home/integral-calculus/indefinite-definite-integrals
  // Check if current path has a sufficient depth for topic page and contains a ToC <div>
  else if ([6,7].indexOf(currUrl.split('/').length) >= 0 && $('div[class^="contents"]').length ){
    processTopicPage();
  }

  // Case Subject - page containing all the topics in the subject or supertopic
  // valid url examples:
  // https://www.khanacademy.org/math/linear-algebra
  // https://www.khanacademy.org/math/calculus-home/integral-calculus
  // Check if current path has a sufficient depth for subject/supertopic page and contains Topics <span>
  else if ([5,6].indexOf(currUrl.split('/').length) >= 0 && $('span:contains("Topics")').length ){
    processSubjectPage();
  }

  // Skip the rest
  else {
    console.log('No topics to label here, skipping...');
  }
};

////////////////////////////////////////////////////////////////////////////////
var processLessonPage = function() {
  console.log('Lesson page processing started...');
  var hrefs = $('div[class^="tutorialNavOnSide"] a');

  // create placeholder for Time Duration label of each lesson (master for cloning)
  var labelClass  = hrefs.find('div[class^="title"]').attr('class');
  var labelMaster = $('<span>', {class: labelClass}).css({'float':'left'});

  // select module header (where module time label to be appended)
  var moduleLabelTarget = $('div[class^="navHeaderOnSide"]').eq(0);
  // create placholder for Module Time Duration label with cumulated time (master for cloning)
  var moduleLabelMaster = $('<span>').css({'float':'left'});

  var moduleTimer = moduleTimerFactory(hrefs.length, {
    targetEl: moduleLabelTarget,
    labelEl: moduleLabelMaster.clone(),
    title: moduleLabelTarget.find('a').text(),
  });

  hrefs.each( (function(labelToClone, modTimer) {
    return function(idx, lessonHref){
      var href = $(lessonHref);
      var labelTarget = href.find('div[class^="info"]');
      var lessonUrl = href.attr('href');

      // get rgb colors of play button (as num array)
      var bg = href.find('div[class^="circle"]:first').css('background');
      var c = (bg ? bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/).slice(1).map(function(v){return ~~v;}) : [0,0,0]);
      // naive rgb channels deviation
      var rgbDev = c.map(function(v){return Math.abs(v-this);},
           c.reduce(function(s,v){return s+v;},0)/c.length )
                .reduce(function(s,v){return s+v;})/c.length;
      var isLessonPassed = rgbDev > 20;  // if not white/grey button

      // time duration label object
      var labelObject = {
        targetEl: labelTarget,
        labelEl: labelToClone.clone(),
        title: href.text(),
        isPassed: isLessonPassed
      };

      // FETCH URL
      fetchAndAppendVideoDurationLabel(lessonUrl, labelObject, modTimer);
    };
  })(labelMaster, moduleTimer) );
};

////////////////////////////////////////////////////////////////////////////////
var processTopicPage = function() {
  console.log('Topic page processing starged...');

  // cleanup (while debugging)
  // $('span[class^="nodeTitle"]').remove();
  // $('span[style^="float: right"]').remove();

  // select topic modules, skip (slice) first div which is ToC
  var topicModules = $('div[class^="moduleList"] > div[class^="module"]').slice(1);

  // create placeholder for time duration label of each lesson (master for cloning)
  var labelClass = topicModules.find('> div > a div[class^="nodeTitle"]').attr('class'),
      labelColor = $('div[class^="header"][style^="background"').css('background-color'),
      labelMaster = $('<span>', {class: labelClass}).css({'float':'right', 'color': labelColor});

  // create placholder for Module Time Duration label with cumulated time (master for cloning)
  var moduleLabelMaster = $('<span>').css({'float':'right'}),
      topicLabel = $('<span>');
  $('h1[class^="title"]').after( topicLabel );

  var topicTimerInstance = topicTimerFactory(topicModules.length, topicLabel);
  console.log('Modules in topic: %s', topicModules.length);

  // iterate over each topic module in a separate worker
  topicModules.each(function(){
    window.setTimeout(function(that, lMaster, mlMaster, topicTimerInst){
      var hrefs = $(that).find('> div > a');
      // get all hrefs links in current module
      // get urls as string array (for debugging)
      // var urls = hrefs.map(function(){return this.href;}).get();

      // change dipslay alignment of divs containing time label
      hrefs.find('div[class^="nodeTitle"]').css('display', 'inline-block');

      // select module header (where module time label to be appended)
      var moduleLabelTarget = $(that).find('> h2 > div[style^="color:"]');
      var moduleObject = {
        targetEl: moduleLabelTarget,
        labelEl: mlMaster.clone(),
        title: moduleLabelTarget.find('a').text(),
      };

      // module time tracker, lesson counter & label creation
      var moduleTimer = moduleTimerFactory(hrefs.length, moduleObject, topicTimerInst);

      var moduleHasVideos = hrefs.map(function(){ return Boolean( $(this).attr('href').match(/\/v\/[\w\-]+$/) );})
                                 .toArray().reduce(function(s,v){return s+v;},0) > 0;
      
      if (hrefs.length === 0) { topicTimerInst.decSize(); }
      if (!moduleHasVideos)   { topicTimerInst.decSize(); }

      console.log('Module "%s", lessons %d', moduleObject.title, hrefs.length);

      // Info: extra closure here is to pass params into $.each()'s lambda
      hrefs.each( (function(labelToClone, modTimer){
        return function(idx, lessonHref){
          var href = $(lessonHref),
              labelTarget = href.find('> div[class^="nodeInfo"]'),
              lessonUrl = href.attr('href');

          // get rgb colors of play button (as num array)
          var bg = href.find('div[class^="circle"]:first').css('background');
          var c = (bg ? bg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/).slice(1).map(function(v){return ~~v;}) : [0,0,0]);
          // naive rgb channels deviation
          var rgbDev = c.map(function(v){return Math.abs(v-this);},
               c.reduce(function(s,v){return s+v;},0)/c.length )
                    .reduce(function(s,v){return s+v;})/c.length;
          var isLessonPassed = rgbDev > 20;  // if not white/grey button

          // time duration label object
          var labelObject = {
            targetEl: labelTarget,
            labelEl: labelToClone.clone(),
            title: href.text(),
            isPassed: isLessonPassed
          };

          // FETCH URL
          fetchAndAppendVideoDurationLabel(lessonUrl, labelObject, modTimer);

        };})(lMaster, moduleTimer, topicTimerInst) ); // return hrefs.each()

    }, 0, this, labelMaster, moduleLabelMaster, topicTimerInstance); // return window.setTimeout()

  }); // return topicModules.each();
};


////////////////////////////////////////////////////////////////////////////////
var processSubjectPage = function(){
  console.log('Subject page processing started...');
  var subjTiming = {sec: 0, secFin: 0};
  var topicHrefs = $('div[class^="linkArea"] > div[class^="link"] > a');

  var c = 0;
  var uMap = ['/calculus-home/precalculus', '/algebra-home/precalculus'];

  console.log('urls in subject: %s', topicHrefs.length);

  // iterate over links, get their timings from cache
  topicHrefs.each(function(){
    var href = $(this),
        url = href.attr('href'),
        urlKey = url.indexOf(uMap[0])>-1 ? url.replace(uMap[0],uMap[1]): url;
    var cachedT = localStorage.getItem(urlKey);

    console.log('url %s: %s', href.attr('href'), cachedT);
    if (cachedT){
      var cachedTs = cachedT.split('|');
      href.parent().css({'line-height':'1em', 'padding-bottom':'5px'});
      href.append('<br>'+cachedTs[2]);
      subjTiming.sec += ~~cachedTs[0];
      subjTiming.secFin += ~~cachedTs[1];
      c++;
    }
  });

  if (c === topicHrefs.length){
    var s = subjTiming.sec, f = subjTiming.secFin;
    $('div[class^="header"]').css('flex-flow', 'row wrap');
    $('h1[class^="title"]').after( $('<span>').css({'width':'100%','margin-top':'-50px','font-size':'1.4em'})
      .text( formatSeconds(s, f) ) );

    localStorage.setItem(location.pathname, s+'|'+f+'|'+formatSeconds(s,f));
  }
};


////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
///////////////////  UTILITY FACTORIES & ASYNC FUNCTIONS  //////////////////////
////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
// starts requests (one for video page, and one for YouTube API),
// parses them out, caches, and appends corresponding time labels to DOM in async
var fetchAndAppendVideoDurationLabel = function(lessonUrl, labelObject, moduleTimer){

  // if url is not a video lesson (eg, exercise or read material)
  if ( !(/\/v\//.test(lessonUrl)) ) {
    // just append empty time duration and continue to the next link
    labelObject.labelEl.text('[--:--]');
    labelObject.targetEl.append( labelObject.labelEl );
    moduleTimer.decSize(); // non-video lessons do not contribute to module time
    return ; // true - continue, false - break from $.each()
  }

  // check if lesson time duration is cached yet
  var cachedTd = localStorage.getItem(lessonUrl);
  if (cachedTd) {
      labelObject.labelEl.text( cachedTd.split('|')[1] );
      labelObject.targetEl.append( labelObject.labelEl );
      moduleTimer.addTime( ~~cachedTd.split('|')[0], labelObject.isPassed ? ~~cachedTd.split('|')[0] : 0 );
      console.log('Cached: (%s) %s', labelObject.title, cachedTd.split('|')[1]);
      return ;
  }

  var api = 'https://www.googleapis.com/youtube/v3/videos?id={id}&part=contentDetails&key={yek}';
  var h = 'cmQyAhWMshjc2Go8HAnmOWhzauSnIkBfBySazIA';

  // get lesson page html in async request (inside a worker)
  var lessonHtml = $.ajax({
    url: lessonUrl,
    datatype: 'html',
    labObj: labelObject,
    modTimer: moduleTimer
  })
  .done(function(htmlData){
    // get youtube video id
    var videoId = $($.parseHTML(htmlData))
      .filter('meta[property="og:video"]').attr('content').split('/').pop();

    // perform async YouTube API call to get video duration
    $.ajax({
      url: api.replace('\x7b\x69\x64\x7d', videoId)
          .replace('\x7b\x79\x65\x6b\x7d', h.split('').reverse().join('')),
      datatype: 'json',
      lObj: this.labObj,
      mTimer: this.modTimer,
      vLessonUrl: this.url,

      success: function(jsonResponse){

        var duration = jsonResponse.items[0]
          .contentDetails.duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/);
        var hours = (parseInt(duration[1]) || 0),
            minutes = (parseInt(duration[2]) || 0),
            seconds = (parseInt(duration[3]) || 0),
            totalSec = hours * 3600 + minutes * 60 + seconds,
            stamp = formatSeconds(totalSec);

        // attach cloned label to the DOM
        this.lObj.labelEl.text( stamp );
        this.lObj.targetEl.append( this.lObj.labelEl );

        // cache lesson time duration
        localStorage.setItem(this.vLessonUrl, totalSec+'|'+this.lObj.labelEl.text());
        console.log('(%s) %s. %s %s', this.mTimer.getLabel().title,
            this.mTimer.getCount(), this.lObj.title, stamp);

        // MODULE TIMING
        this.mTimer.addTime( totalSec, this.lObj.isPassed ? totalSec : 0);
      },
      error: function(data) { console.error('YouTube API error:\n%s', data); }
    }); // YouTube $.ajax():success
  }) // lesson $.ajax().done()
  .fail(function(){ console.error('Could not retrieve URL: %s', this.url); });
};  // fetchAndAppendVideoDurationLabel()


// factory (closure) for counting processed lessons (hrefs), cummulating module
// total time and attaching corresponding time label to DOM.
// Invoked for each topic module separately
var moduleTimerFactory = function(numberOfLessons, moduleLabelObj, topicTimer){
  var totalSeconds = 0,
      totalSecondsFinished = 0,
      lessonsCount = 0,
      moduleSize = numberOfLessons,
      mlObj = moduleLabelObj;

  // if its the last lesson link to process in the module, then
  // insert module (total) time label near module title (target)
  var checkAndAttachToDom = function(){
    if (lessonsCount >= moduleSize){
      mlObj.labelEl.text( formatSeconds(totalSeconds,totalSecondsFinished) );
      mlObj.targetEl.append( mlObj.labelEl );
      if (topicTimer) topicTimer.addTime(totalSeconds, totalSecondsFinished)
    }
  };

  return {
    // some lessons are not video lessons, so skip those and decrease size
    getCount: function(){ return lessonsCount; },
    getLabel: function(){ return moduleLabelObj; },
    decSize: function(){ moduleSize--; checkAndAttachToDom(); },
    addTime: function(seconds, secondsFinished) {
      totalSeconds += seconds;
      totalSecondsFinished += secondsFinished;
      lessonsCount++;
      checkAndAttachToDom();
    },
  };
};


// factory (closure) for counting modules, cummulating module
// total time and attaching corresponding time label to DOM.
// Invoked once during topic page processing
var topicTimerFactory = function(numberOfModules, targetEl){
  var topicSeconds = 0,
      topicSecondsFinished = 0,
      topicSize = numberOfModules,
      moduleCount = 0;

  // if its the last module to process in the topic, then
  // insert topic (total) time label in topic header title (targetEl)
  var checkAndAttachToDom = function(){
    if (moduleCount >= topicSize){
      console.log("Topic timing: %s", formatSeconds(topicSeconds, topicSecondsFinished));
      var timerStr = formatSeconds(topicSeconds,topicSecondsFinished);
      targetEl.text( timerStr );
      // update topic timer value in cache, to be used in processSubjectPage();
      localStorage.setItem(window.location.pathname, topicSeconds+'|'+topicSecondsFinished+'|'+timerStr);
    }
  };

  return{
    decSize: function(){ topicSize--; checkAndAttachToDom(); },
    addTime: function(seconds, secondsFinished) {
      topicSeconds += seconds;
      topicSecondsFinished += secondsFinished;
      moduleCount++;
      console.log("Module #%s %s", moduleCount, formatSeconds(seconds, secondsFinished));
      checkAndAttachToDom();
    }
  };
};

////////////////////////////////////////////////////////////////////////////////
// helper function to format number of seconds to label string to be displayed
var formatSeconds = function(seconds, secondsFinished){
  var hours = Math.floor(seconds/60/60),
      minutes = Math.floor(seconds/60) - hours*60;
  var totalStr = (hours ? ( String(hours).length>2 ? String(hours) : ('0'+hours).slice(-2)+':') : '') +
                 ('0'+minutes).slice(-2) +':'+ ('0'+seconds%60).slice(-2);

  var finishedStr = '';
  if (secondsFinished) {
    var fHours = Math.floor(secondsFinished/60/60),
        fMinutes = Math.floor(secondsFinished/60) - fHours*60,
        fSeconds = secondsFinished;
    // hourse, not fHourse, to force HH-zeros if no finished hours
    finishedStr = (hours?('0'+fHours).slice(-2)+':':'') + ('0'+fMinutes).slice(-2) +':'+ ('0'+fSeconds%60).slice(-2) + ' / ';
  }

  return '[' + finishedStr + totalStr + ']';
};


///////////////////////////////
///////////////////////////////
//// START THE WHOLE THING ////
///////////////////////////////
///////////////////////////////
startProcessing();

})();



// alternative to YouTube API:
// http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key