// ==UserScript==
// @name Khan Academy timestamps
// @namespace https://www.khanacademy.org/
// @version 1.1
// @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 */
(function() {
'use strict';
console.log = function() {}; // disable debugging logs
var logicSelector = function(){
// CASE 1 - ToC page
// dirty check if current path depth corresponds to page with a ToC
// valid url example: https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
if (window.location.href.split('/').length === 6){
processTocPage();
}
// CASE 2 - if video lesson page
else if ( (/\/v\/[\w\-]+$/.test(window.location.href)) ) {
processLessonPage();
}
// Skip the rest
else {
console.log('No lessons to label here, skipping...');
}
};
////////////////////////////////////////////////////////////////////////////////
// e.g. https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces/vectors/v/linear-algebra-vector-examples
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'}).text('[--:--]');
// 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':'right'});
var moduleCounter = moduleCounterFactory(hrefs.length, {
targetEl: moduleLabelTarget,
labelEl: moduleLabelMaster.clone(),
title: moduleLabelTarget.find('a').text(),
});
hrefs.each( (function(labelToClone, modCounter) {
return function(idx, lessonHref){
var href = $(lessonHref);
var labelTarget = href.find('div[class^="info"]');
var lessonUrl = href.attr('href');
// time duration label object
var labelObject = {
targetEl: labelTarget,
labelEl: labelToClone.clone(),
title: href.text()
};
// FETCH URL
appendVideoDurationLabel(lessonUrl, labelObject, modCounter);
};
})(labelMaster, moduleCounter) );
};
////////////////////////////////////////////////////////////////////////////////
// e.g. https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces
var processTocPage = function() {
console.log('ToC 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');
var labelColor = $('div[class^="header"][style^="background"').css('background-color');
var labelMaster = $('<span>', {class: labelClass}).css({'float':'right', 'color': labelColor}).text('[--:--]');
// create placholder for Module Time Duration label with cumulated time (master for cloning)
var moduleLabelMaster = $('<span>').css({'float':'right'});
// iterate over each topic module in a separate worker
topicModules.each(function(){
window.setTimeout(function(that, lMaster, mlMaster){
var hrefs = $(that).find('> div > a');
// get all hrefs links in current module
console.log('hrefs: %d', hrefs.length);
// 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:"]');
// module time counter & label object
var moduleCounter = moduleCounterFactory(hrefs.length, {
targetEl: moduleLabelTarget,
labelEl: mlMaster.clone(),
title: moduleLabelTarget.find('a').text(),
});
// Info: extra closure here is to pass params into $.each()'s lambda
hrefs.each( (function(labelToClone, modCounter){
return function(idx, lessonHref){
var href = $(lessonHref);
var labelTarget = href.find('> div[class^="nodeInfo"]');
var lessonUrl = href.attr('href');
// time duration label object
var labelObject = {
targetEl: labelTarget,
labelEl: labelToClone.clone(),
title: href.text()
};
// FETCH URL
appendVideoDurationLabel(lessonUrl, labelObject, modCounter);
};})(lMaster, moduleCounter) ); // return hrefs.each()
}, 0, this, labelMaster, moduleLabelMaster); // return window.setTimeout()
}); // return topicModules.each();
};
////////////////////////////////////////////////////////////////////////////////
// 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 appendVideoDurationLabel = function(lessonUrl, labelObject, moduleCounter){
// 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 );
moduleCounter.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 );
moduleCounter.addTime( ~~cachedTd.split('|')[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,
modCounter: moduleCounter
})
.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,
mCounter: this.modCounter,
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 = '[' + (duration[1] ? duration[1].slice(0,-1)+':' : '') +
('0' + minutes).slice(-2)+':'+('0' + seconds).slice(-2) + ']';
// 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.mCounter.getLabel().title,
this.mCounter.getCount(), this.lObj.title, stamp);
// MODULE COUNTER
this.mCounter.addTime( totalSec );
},
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); });
}; // appendVideoDurationLabel()
// 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 moduleCounterFactory = function(moduleSize, moduleLabelObj){
var totalSeconds = 0,
count = 0,
size = moduleSize,
mlObj = moduleLabelObj;
var getTimeStr = function() {
var mHours = Math.floor(totalSeconds/60/60),
mMinutes = Math.floor(totalSeconds/60) - mHours*60;
return '[' + ('0'+mHours).slice(-2) + ':' +
('0'+mMinutes).slice(-2) + ':' +
('0'+totalSeconds%60).slice(-2) + ']';
};
var checkAndAttachToDom = function(){
// if its the last lesson link to process in the module, then
// insert module (total) time label near module title (target)
if (count >= size){
mlObj.labelEl.text( getTimeStr() );
mlObj.targetEl.append( mlObj.labelEl );
}
};
return {
// some lessons are not video lessons, so skip those and decrease size
getCount: function(){ return count; },
getLabel: function(){ return moduleLabelObj; },
decSize: function(){ size--; checkAndAttachToDom(); },
addTime: function(seconds) {
totalSeconds += seconds;
count++;
checkAndAttachToDom();
},
};
};
// START THE WHOLE THING
logicSelector();
})();
// alternative to YouTube API:
// http://stackoverflow.com/questions/30084140/youtube-video-title-with-api-v3-without-api-key