HodofHod / Duolingo - Lesson Review

// ==UserScript==
// @name         Duolingo - Lesson Review
// @description  This script allows you to go back and review all the different challenges in a lesson.
// @match        *://www.duolingo.com/*
// @author       HodofHod
// @namespace    HodofHod
// @version      0.2.4
// ==/UserScript==

/*
Copyright (c) 2013-2014 HodofHod (https://github.com/HodofHod)

Licensed under the MIT License (MIT)
Full text of the license is available at https://raw2.github.com/HodofHod/Userscripts/master/LICENSE
*/

//TODO: Inject a stylesheet and use classes instead of .css()?

function inject(f) { //Inject the script into the document
    var script;
    script = document.createElement('script');
    script.type = 'text/javascript';
    script.setAttribute('name', 'lesson_review');
    script.textContent = '(' + f.toString() + ')(jQuery)';
    document.head.appendChild(script);
}
inject(init);

function init(){
    console.log('Duolingo Lesson Review');
    var OrigSessionView  = $.extend({}, duo.SessionView.prototype),
        origTimesUp      = duo.TimedSessionView.prototype.timesUp,
        origPushState    = window.history.pushState;

    if (/^\/practice|skill|word_practice\/.*/.test(window.location.pathname)){ //for the first time the script is loaded, check if we're already on a page.
        main();
    }
    window.history.pushState = function checkPage(a, b, c){  //Hijack the window's pushState so we know whenever duolingo navigates to a new page.
        /^\/practice|skill|word_practice\/.*/.test(c) ? main() : cleanup();
        return origPushState.call(this, a, b, c);
    };

    function cleanup(){
        $(document).off('.hh'); //unbind any handlers from previous lessons.
        $.extend(duo.SessionView.prototype, OrigSessionView);
        duo.TimedSessionView.prototype.timesUp = origTimesUp;
    }

    function main(){
        cleanup();
        
        var lessons = {},
            current_id = 1,
            selected_id = current_id,
            finished = false,
            failed = false,
            header, last_lesson_id,
            redesign = duo.user.get("ab_options").site_redesign_experiment;
            
        function save_lesson(save_id){
            var $problem = $('.player-main'),
                $controls = $('.player-container>footer'),
                $discussion = $('#discussion-modal-container');
            
            lessons[save_id] = $.extend(lessons[save_id] || {}, {
                                0: $problem.add($controls), 
                                1: $discussion, 
                                2: $('#app').prop('class') });
            if (save_id !== 'end'){
                lessons[save_id][0] = $problem.after($problem.clone()).add($controls.clone(true)).detach();
                var l = lessons[save_id][0].add(lessons[save_id][1]),
                    resume_button = $('<button id="resume_button" class="btn success btn-lg right" tabindex="20">Resume</button>');
            
                //Disable or hide all controls except for discuss and report
                l.find('#submit_button, #skip_button, #home-button, #fix_mistakes_button').hide();
                l.find('#next_button, #continue_button, #retry-button')
                    .after($( $('#resume_button')[0] || resume_button) ).hide();
            }else{
                console.log('else '+ save_id);
            }
        }
        
        function replace_lesson(replace_id){
            console.log('cur: ' + current_id + ', old id: ' + selected_id + ', new id ' + replace_id);
            if (selected_id === replace_id){ return false; } //don't replace yourself with yourself.
            if (selected_id === current_id){//if switching away from cur_lesson or end
                lessons[current_id] = $.extend(lessons[current_id] || {}, 
                                        {0: $('.player-main, #end-carousel').hide()
                                            //detach footer to prevent #discussion-toggle conflicts
                                            .add($('.player-container>footer').detach()),
                                         1: $('#discussion-modal-container').detach(), 
                                         2: $('#app').prop('class')});
            }else{
                $('.player-container>footer, .player-main, #discussion-modal-container').detach();
            }
            
            var lesson = lessons[replace_id];
            $('.player-container').append(lesson[0].show());
            $('body').append(lesson[1]);//discussions
            $('#app').prop('class', lesson[2]);
            $('.hint-table, .twipsy, #controls .tooltip').hide();    
            
            if (finished && !failed){//Add or remove header as necessary, since success endview removes it.
                (replace_id !== current_id) ? $('.player-container').prepend(header)
                                            : $('.player-header').detach();
            }
            if (!$("#discussion-modal").length && current_id !== replace_id && 
                !(failed && replace_id === last_lesson_id)) {
                $('#discussion-toggle').off().one('click', function(e){
                    e.stopImmediatePropagation();
                    loadDiscussion(replace_id);//TODO: pass lessons[replace_id] instead?
                    $('#discussion-toggle, .close-modal-background').off().on('click', function(e){
                        e.stopImmediatePropagation();
                        $('#discussion-modal').modal('toggle');
                    });
                });
            }
            selected_id = replace_id;
            select_cell(selected_id);
        }
        
        function loadDiscussion(lesson_id){
            var sentence = new duo.Sentence({ id : lessons[lesson_id].key }),
                container = $( $('#discussion-modal-container')[0] || '<div id="discussion-modal-container"></div>' );
                learning_language = duo.user.get('learning_language'),
                ui_language = duo.user.get('ui_language');
            sentence.fetch({
                data: {
                    ui_language : ui_language || undefined,
                    learning_language : learning_language
                },success: function (){
                    lessons[lesson_id][1] = container.appendTo(document.body);
                    var comments = new duo.CommentModalView({
                        el : container,
                        correct : lessons[lesson_id].correct,
                        model : sentence,
                        ui_language : ui_language,
                        learning_language : learning_language
                    });
                    comments.render();
                }
            });
        }
        
        function select_cell(cell_num){
            //TODO: For redesign: add the box-shadow to .inner only. 
            //       Inherit b-color on .nothing, and reset on .done (red uses b-image, so it won't reset that);
            $('li[id^=element-]').find('.inner').andSelf().css({'box-shadow': '', 'border-right-width': '1px'});
            $('li#element-'+cell_num).find('.inner')
                                     .andSelf().css({'box-shadow': '1px 1px 3px 1px black inset',
                                                     'border-right-width': '0px'});
            $('li[id^=element-]:first-child').css({'-webkit-border-radius': '9px 0 0 9px', 
                                                  'border-radius': '9px 0 0 9px'});              
            $('li[id^=element-]:last-child').css({'-webkit-border-radius': '0 9px 9px 0', 
                                                  'border-radius': '0 9px 9px 0'});            
            activate_arrows();
        }

        //Hijack the rendered() function (it runs after each problem is rendered)
        duo.SessionView.prototype.rendered = function newRendered(){
            if ($('#prev-arrow, #next-arrow').length !== 2){
                add_arrows();
            }
            select_cell(selected_id);
            return OrigSessionView.rendered.apply(this, arguments);
        };

        //Hijack the function that switches lessons.
        duo.SessionView.prototype.next = function newNext(){
            $('#discussion-modal-container').detach();
            $('#pause_toggle').remove();
            current_id = (this.model.get('position') + 1); //adjust from 0-indexed
            save_lesson(current_id);
            current_id += 1;
            selected_id = current_id;
            return OrigSessionView.next.apply(this, arguments);
        };
        
        duo.SessionView.prototype.graded = function(){
            //check to see if this is being called from (and with) TimedSessionView 
            if (this.timer_view !== undefined){
                //add pause Button
                var timer = this.timer_view;
                $('#pause_toggle').off().remove();//don't assume graded won't be called twice.
                $('#next_button').before('<button id="pause_toggle" class="btn btn-blue btn-lg" style="margin-right:10px;">Pause</button>');
                $('#pause_toggle').on('click', function(){
                    if (timer.paused === null){
                        timer.pause();
                        $('#next_button').attr('disabled','disabled');
                        $(this).text('Resume');
                    }else{
                        timer.resume();
                        $('#next_button').removeAttr('disabled');
                        $(this).text('Pause');
                    }
                });
            }
            var solution = this.model.getSubmittedSolution();
            OrigSessionView.graded.apply(this, arguments);
            if (solution.get('incorrect') && !solution.get('try-again')){
                $('li#element-' + current_id + '>.inner').andSelf()
                    .css('background-image', '-webkit-linear-gradient(top, #EE6969, #FF0000)')
                    .css('background-image', '-moz-linear-gradient(center top , #EE6969, #FF0000)');
            }
            lessons[current_id] = $.extend(lessons[current_id] || {}, {
                key: this.model.currentElement().get("solution_key"),
                correct: this.model.getSubmittedSolution().get("correct")
            });
        };
        
        function finish(){
            var button = $('<button id="review_button" class="btn large btn-lg btn-standard right" style="margin:0 10px 0 0;">Review</button>'),
                loc = failed ? '#controls>.col-right' : '.session-end-footer';
            $(loc).prepend(button);
            button.css(!failed ? {position:'absolute', left:'40px'}
                               : {float: 'left'});                    
            finished = true;
            last_lesson_id = current_id;
            current_id = 'end';
            selected_id = 'end';
            select_cell();//none
        }
        
        duo.TimedSessionView.prototype.timesUp = function(){
            save_lesson(current_id);
            finished = true;
            origTimesUp.apply(this, arguments);
        };
        
        duo.SessionView.prototype.showFailView = function(){
            failed = true;
            save_lesson(current_id);
            var r = OrigSessionView.showFailView.apply(this, arguments);
            finish();
            $('.close-fail').hide();
            add_arrows();
            return r;
        };
        
        duo.SessionView.prototype.showEndView = function(){
            //Timed out lessons don't need this. Timeouts will set finished to true earlier.
            //newNext() increments on the last one too, adjust for that, (except for timesUp)
            current_id -= !finished; 
            header = $('.player-header').detach();
            OrigSessionView.showEndView.apply(this, arguments);
            //end view won't have loaded. Wait for mousemove on end app, where the footer exists.
            $(document).on('mousemove', '#app.slide-session-end', function(){
                if ($('.session-end-footer').length){ 
                    finish(); 
                    $(document).off('mousemove', '#app.slide-session-end');
                }
            });
        };
        
        $(document).on('click.hh', 'li[id^=element-]', function(){
            var clicked_id = parseInt(this.id.replace('element-', ''), 10);
            if (lessons[clicked_id] !== undefined){
                replace_lesson(clicked_id);
            }
        });
        
        $(document).on('click.hh', '#resume_button', function(){ 
            replace_lesson(current_id); 
            if (finished && ($('#end-carousel .left').length > 1 || !$('#end-carousel .active').length)){
                $('#end-carousel .item').removeClass('active next left');
                $('#end-carousel .item:eq(' + $('.carousel-dots .active').data('slide') + ')').addClass('active');
                $('#end-carousel .item.active').is(':last-child') && $(".carousel").carousel("pause");
            }
        });    
        
        $(document).on('click.hh', '#review_button', function(){
            $(".carousel").carousel("pause");
            replace_lesson(last_lesson_id);
        });
        
        $(document).on('click.hh', '#prev-arrow, #next-arrow', function(){
            var lesson_id = (this.id === 'prev-arrow') ? selected_id-1 : selected_id+1;
            if (selected_id === 'end'){
                $('#review_button').click();
            }else if (!lessons[lesson_id] && current_id === 'end'){
                replace_lesson('end');
            }else{
                replace_lesson(lesson_id);
            }
        });
        
        function add_arrows(){
            $('#prev-arrow, #next-arrow').remove();
            var arrow = $('<span></span>').css({
                background: 'url("//d7mj4aqfscim2.cloudfront.net/images/sprite_mv_082bd900117422dec137f596afcc1708.png") no-repeat',
                width: '24px', height: '18px', position: 'absolute', 'pointer-events':'none', 'background-position': '-323px -130px'
            });
            
            var strength = $('.strength-bar').length; 
            $.each(['prev', 'next'], function(i, val){
                var rotate = 'rotate('+(val==='prev'?'-':'')+'90deg)';
                arrow.clone().attr('id', val +'-arrow').css({
                    top: strength ? '22px' : '31px',
                    left:  val==='prev' ? (strength ? '80px' : '16px') : '' ,
                    right: val==='next' && strength ? '154px' : '',
                    transform: rotate, '-webkit-transform': rotate
                })[(val==='prev' ? 'prepend' : 'append') + 'To']('#progress-bar');
            });
            
            activate_arrows();
        }
        
        function activate_arrows(){
            var active   = {'background-position': '-323px -178px', cursor: 'pointer', 'pointer-events': ''},
                inactive = {'background-position': '-323px -130px', cursor: 'initial', 'pointer-events': 'none'};
            $('#prev-arrow').css( (selected_id > 1 || selected_id === 'end') ? active : inactive);
            $('#next-arrow').css( (selected_id !== current_id)               ? active : inactive);
        }
    }
}