// ==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 // @downloadURL none // ==/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 = $('', {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 = $('').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 = $('', {class: labelClass}).css({'float':'right', 'color': labelColor}).text('[--:--]'); // create placholder for Module Time Duration label with cumulated time (master for cloning) var moduleLabelMaster = $('').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