// ==UserScript==
// @name Khan Academy timestamps
// @namespace https://www.khanacademy.org/
// @version 1.0
// @description Adds time duration labels into ToC of a topic
// @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 */
(function() {
'use strict';
// dirty check if current page path depth corresponds to page with topic ToC
// valid url example: https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
if (window.location.href.split('/').length !== 6)
return ;
console.log = function() {}; // disable console.log
console.log('Script started...');
// cleanup (unlikely needed in production)
// $('span[class^="nodeTitle"]').remove();
// $('span[style^="float: right"]').remove();
// select topic modules, skip (slice) first two divs which are ToC and header
var topicModules = $('div[class^="moduleList"] > div[class^="module"]').slice(2);
// create placeholder of lesson Time Duration label for each lesson (master object for cloning)
var tdClass = topicModules.find('> div > a div[class^="nodeTitle"]').attr('class');
var tdMaster = $('<span>', {class: tdClass}).css({'float':'right', 'color':'#11accd'}).text('[--:--]');
// create placholder of Module Time Duration label with cumulated time (master obj for cloning)
var mtdMaster = $('<span>').css({'float':'right'}).text('[--:--]');
var api = 'https://www.googleapis.com/youtube/v3/videos?id={id}&part=contentDetails&key={yek}';
var h = 'cmQyAhWMshjc2Go8HAnmOWhzauSnIkBfBySazIA';
// iterate over topic modules
topicModules.each(function(){
// get all hrefs links in current module
var hrefsArr = $(this).find('> div > a');
// change dipslay alignment of divs containing time label
hrefsArr.find('div[class^="nodeTitle"]').css('display', 'inline-block');
// select module header (where module time label to be inserted)
var mHeader = $(this).find('> h2 > div[style^="color:"]');
// check if time haven't been cached yet
var cachedMtd = localStorage.getItem('M:'+mHeader.find('a').text());
if ( cachedMtd ) {
mtdMaster.text(cachedMtd);
mHeader.append( mtdMaster.clone() );
console.log('Cache module (%s) %s', mHeader.find('a').text(), cachedMtd);
}
// the last async callback processing final lesson href (of each module) is detected with these helper vars
var moduleTime = 0, moduleCount = 0, moduleSize = hrefsArr.length;
// worker launched for each module
window.setTimeout(function(hrefs, _tdToClone, _mtdToClone, _moduleHeader){
// get urls as string array (for debugging)
// var urls = hrefs.map(function(){return this.href;}).get();
// fetch each url
// Info: extra closure here is to pass ModuleObj param inside $.each()'s lambda
hrefs.each( (function( ModuleObj ){
return function(idx, lessonHref){
var href = $(lessonHref);
var _tdTarget = href.find('> div[class^="nodeInfo"]');
var _url = href.attr('href');
// if not a video lesson (eg, exercise or read material)
if (!/\/v\//.test(_url)) {
// just append empty time duration and continue to the next link
ModuleObj.tdToClone.text('[--:--]');
_tdTarget.append( ModuleObj.tdToClone.clone() );
moduleSize--; // exercise/readings do not contribute to module time
return true; // true - continue, false - break from $.each()
}
// elaborate ModuleObj with extra values
var _ModObj = {
mtdElem: ModuleObj.mtdToClone,
mtdTarget: ModuleObj.moduleHeader,
mtdTitle: ModuleObj.moduleHeader.find('a').text(),
tdElem: ModuleObj.tdToClone,
tdTarget: _tdTarget,
tdTitle: href.text()
};
// check if lesson time duration is cached yet
var cachedTd = localStorage.getItem(_url);
if (cachedTd) {
_ModObj.tdElem.text(cachedTd);
_ModObj.tdTarget.append( _ModObj.tdElem.clone() );
console.log('Cache: (%s) [%s]', _ModObj.tdTitle, cachedTd);
return ;
}
// get lesson page html in async request (inside a worker)
var lessonHtml = $.ajax({
url: _url,
datatype: 'html',
ModObj: _ModObj
})
.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('')),
lessonUrl: this.url,
datatype: 'json',
mObj: this.ModObj,
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),
totals = hours * 3600 + minutes * 60 + seconds,
stamp = (duration[1] ? duration[1].slice(0,-1)+':' : '') +
('0' + minutes).slice(-2)+':'+('0' + seconds).slice(-2);
// attach cloned label to the DOM
this.mObj.tdElem.text('['+stamp+']');
this.mObj.tdTarget.append( this.mObj.tdElem.clone() );
// cached lesson time duration
localStorage.setItem(this.lessonUrl, this.mObj.tdElem.text());
console.log('(%s) %s. %s [%s]', this.mObj.mtdTitle, moduleCount, this.mObj.tdTitle, stamp);
// count total time duration of a module, and if the the last lesson request to be processed
// then cache module time and attach cloned label to the DOM as well
moduleTime += totals;
moduleCount++;
// if the last link to process then output module total time
if (moduleCount === moduleSize) {
var mHours = Math.floor(moduleTime/60/60),
mMinutes = Math.floor(moduleTime/60) - mHours*60,
moduleTimeStr = ('0'+mHours).slice(-2) + ':' + ('0'+mMinutes).slice(-2) + ':' + ('0'+moduleTime%60).slice(-2);
this.mObj.mtdElem.text( '[' + moduleTimeStr + ']' );
this.mObj.mtdTarget.append( this.mObj.mtdElem.clone() );
localStorage.setItem('M:'+this.mObj.mtdTitle, this.mObj.mtdElem.text());
console.log('Module "%s" [%s]\n', this.mObj.mtdTitle, moduleTimeStr);
}
},
error: function(data) { console.error('YouTube API error:\n%s', data); }
}); // return YouTube $.ajax():success
}) // return lesson $.ajax().done()
.fail(function(){ console.error('Could not retrieve URL: %s', this.url); });
};})( { tdToClone: _tdToClone, mtdToClone: _mtdToClone, moduleHeader: _moduleHeader } ) ); // return hrefs.each()
}, 0, hrefsArr, tdMaster, mtdMaster, mHeader); // return window.setTimeout()
}); // return topicModules.each();
})();
// alternative to YouTube API:
// http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key