Homophone Explorer

Finds homophones on Wanikani.com on each vocabulary page and during lesson and review sessions.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Homophone Explorer
// @namespace    com.konatopic.hpx
// @version      0.9.1
// @description  Finds homophones on Wanikani.com on each vocabulary page and during lesson and review sessions.
// @author       Konatopic
// @grant        GM_setValue
// @grant        GM_getValue
// @include      /^http(s)?://www\.wanikani\.com/vocabulary//
// @include      /^http(s)?://www\.wanikani\.com/level/[0-9]+/vocabulary//
// @include      /^http(s)?://www\.wanikani\.com/review/session/
// @include      /^http(s)?://www\.wanikani\.com/lesson/session/
// @homepageURL  https://gist.github.com/Konatopic/8b1a6f6dbf9ea66ee4f50c2d35908518
// ==/UserScript==

// TODOs #########################################################

// =============================== CONSTANTS =================================== //
var MAX_LEVEL = 60; // as of this version
var KEY_NAMES = { // names of entries as stored by HPX in storage - best not to change these after first use
    API_KEY:'APIKey',
    DATA:'data',
    USER_SETTINGS:'userSettings',
    LAST_UPDATED:'lastUpdated'
};
var SETTINGS_URL = 'https://www.wanikani.com/settings/account';
var API_VERSION = "v1.4"; // built with version 1.4
var API_REQUEST_TEMPLATE = {
    VOCAB_LIST:'https://www.wanikani.com/api/{VERSION_NUMBER}/user/{USER_API_KEY}/vocabulary/{levels}'
};

// =============================== GLOBALS ====================================== //

var minUpdateInterval = 60;// minimum time between each automatic refresh in minutes
var lastUpdated;

// Some common names for the API Key variable defined some other script authors
var commonAPIKeyNames = ['apiKey'];

var hpx, // app-controller
    ui; // ui-controller

// =============================== FUNCTIONS ==================================== //
// Wanikani uses jQuery (albeit possibly incomplete) -- might as well take advantage of it
(function checkJQuery(){
    if(typeof jQuery !== 'undefined') {
        (function(){
            hpx = new HPX(); // Entry point
        })();
    } else {
        setTimeout(function(){checkJQuery();},100);
    }
})();

// App controller
function HPX(){
    console.log('HPX initializing; Using jQuery version: ' + jQuery.fn.jquery + '. Caution: minified library may be incomplete');

    var APIKey,
        thisUpdating = false, // if this instance is updating
        updateTimeoutID = 0.1; // setTimeout only returns integers

    var vocabDB, // from API_REQUEST_TEMPLATE.VOCAB_LIST
        requestedLevels;

    var comparisonVocab,
        comparisonReadings = [];

    var userInformation,
        vocabList;

    /* eg.,
    [0]{reading: 'あたり', vocabs:[[0]{name:"辺り",meaning:"area"... }]}
     */
    var homophones = [];

    // Clean up if user navigates away
    window.onbeforeunload = function(e){
        if(thisUpdating){
            setUpdatingFlag(false);
        }
    };

    // Load previous data if available
    loadDataFromLocal();

    // set up to listener for a new comparison vocab & reload & reloadapi
    $(document).on('HPX:vocabUpdate',function(e,data){
        if(data && data.exists){
            comparisonVocab = data.comparisonVocab;
            createHomophoneList();
            ui.displayHomophones(homophones);
        }
        return false;
    }).on('HPX:reloadRequest',function(e){
        // only allow force request after an autoUpdate has been called previously
        if(Number.isInteger(updateTimeoutID)){
            clearTimeout(updateTimeoutID);
            update(true);
        }
        return false;
    }).on('HPX:reloadAPIRequest',function(e){
        findAPIKey(function(key){
            if(typeof key === 'string'){
                APIKey = key;
            }
        },true);
    });

    // Setup UI
    if($(location).attr('href').search(/^http(s)?:\/\/www\.wanikani\.com\/review\/session/) === 0 ||
       $(location).attr('href').search(/^http(s)?:\/\/www\.wanikani\.com\/lesson\/session/) === 0){
        ui = new UISessionPage();
    } else {
        ui = new UIPage();
    }

    ui.setStatus('INIT');

    // Let's look for the API Key
    findAPIKey(function(key){
        if(typeof key === 'string'){
            // returned valid key
            APIKey = key;
            autoUpdate(); // only call function once!!
        } else {
            console.log('Cannot find API Key anywhere. Please manually enter your by executing "localStorage.setItem(\''+
                        commonAPIKeyNames[0] +
                        '\', API_KEY);" in your developer console while on any wanikani.com page, where API_KEY is your 32 character API Key. Please report this to the developer.');
        }
    });

    // schedules updates
    function autoUpdate(forceUpdate){

        var _lastUpdated = getLastUpdated(),
            timeSinceUpdate,
            timeUntilUpdate,
            randomTime;

        // add Math.random time to the scheduled update - javascript Chrome and Firefox extensions are probably single threaded but just to make sure
        // this is so that when more than one injected tab is open, they do not all update at the same time
        randomTime = Math.random() * 10000; // U(0,lim->10) seconds

        // set these two variables first
        if(typeof _lastUpdated === 'number'){
            timeSinceUpdate = new Date().getTime() - _lastUpdated;
            timeUntilUpdate = minUpdateInterval * 60 * 1000 - timeSinceUpdate + randomTime;
        }

        // only allow server updates on active tab
        if (pageIsHidden()){
            if(typeof _lastUpdated === 'number'){
                if(timeSinceUpdate < minUpdateInterval * 60 * 1000 && timeSinceUpdate >= 0){
                    localCreateAndDisplay(); // but do allow it to do a local update
                    console.log('Scheduled autoUpdate in '+Math.floor(timeUntilUpdate/60000)+' minutes, ' + timeUntilUpdate%60000/1000+' seconds.');
                    return (updateTimeoutID = setTimeout(function(){autoUpdate();},timeUntilUpdate)); // and schedule an autoupdate as if it were an active tab
                }
            }
            console.log('Scheduled autoUpdate in 3 seconds.');
            return (updateTimeoutID = setTimeout(function(){autoUpdate();},3000)); // try again in 3 seconds
        }

        // else in the active tab, schedule is a server update
        if(typeof _lastUpdated === 'number'){
            if(timeSinceUpdate > minUpdateInterval * 60 * 1000){
                // time for an update
                update();
            } else {
                if(timeUntilUpdate > 2147483647){
                    // some funny business huh? - the user probably doesn't want to stick around for 24.8 days for page to update
                    update();
                } else {
                    // schedule update
                    console.log('Scheduled autoUpdate in '+Math.floor(timeUntilUpdate/60000)+' minutes, ' + timeUntilUpdate%60000/1000+' seconds.');
                    updateTimeoutID = setTimeout(function(){autoUpdate();},timeUntilUpdate);

                    // reload from cache because another instance of HPX could have updated the cache
                    localCreateAndDisplay();
                }
            }
        } else {

            // first run
            update();

        }

    }

    // checks sessionStorage for updating flag. Useful when more than one injected tab is open
    function isUpdating(){
        var res = sessionStorage.getItem('HPX');
        return (res !== null && JSON.parse(res).updating) ? true : false; // return updating status or false during first run
    }

    // updates from server immediately and sets up autoUpdate()
    function update(forceReload){

        if(thisUpdating){
            return; // already updating in this instance
        } else if(isUpdating() && !(typeof forceReload === 'boolean' && forceReload)){
            // try again in 3 seconds
            updateTimeoutID = setTimeout(function(){autoUpdate();},3000);
            ui.setStatus('UPDATING_OTHER_INSTANCE');
            console.log("Update queued. Retrying in 3 seconds");
            return;
        }

        setUpdatingFlag(true);
        ui.setStatus('UPDATING');
        ui.toggleResetButton(false);

        loadListFromServer(function(success,data){

            ui.toggleResetButton(true);
            setUpdatingFlag(false);

            if(success){

                userInformation = data.user_information;
                vocabList = data.requested_information;

                console.log('Updated cache');

                // update lastUpdated
                lastUpdated = new Date().getTime();

                // schedule next update
                autoUpdate();

                ui.setStatus('IDLE');
            } else {
                console.log(data);
                console.log('Problem connecting to server. Retrying in 3 seconds');
                // schedule next update in 3 seconds
                updateTimeoutID = setTimeout(function(){autoUpdate();},3000);

                ui.setStatus('CONNECTION_ERROR');
            }

        });

    }

    // Sets HPX.updating flag in sessionStorage to prevent other HPX instances from updating
    function setUpdatingFlag(_updating){
        thisUpdating = _updating;
        sessionStorage.setItem('HPX',JSON.stringify({updating:_updating}));
    }

    function localCreateAndDisplay(){
        loadDataFromLocal();
        createHomophoneList();
        ui.displayHomophones(homophones);
    }

    // returns lastUpdated from GM_getValue
    function getLastUpdated(){
        var _lastUpdated = GM_getValue(KEY_NAMES.LAST_UPDATED);
        return (typeof _lastUpdated !== 'undefined' ? _lastUpdated:undefined); // return time lastUpdated or undefined during first run
    }

    // finds the reading for the current vocab - don't really trust the reading on the page - the layout could've been altered by other scripts
    function getComparisonReadings(){
        for (var i = 0;i < vocabList.length; i++){
            if(vocabList[i].character === comparisonVocab){
                comparisonReadings = splitReadings(vocabList[i].kana);
                console.log('Found ' + comparisonReadings.length + ' comparison readings found for ' + comparisonVocab + ': "'+vocabList[i].kana+'"');
                return;
            }
        }

        console.log('No comparison readings found for ' + comparisonVocab);
    }

    function createHomophoneList(){

        console.log('Creating homophones list using comparator: ' + comparisonVocab);

        var currentReadings = [];

        // check if comparisonVocab exists and vocabList has been defined
        if(typeof comparisonVocab === 'undefined' || !vocabList){
            homophones = [];
            return false;
        }
        
        getComparisonReadings();

        // prepare homophones array
        for (var k = 0; k < comparisonReadings.length; k++){
            homophones[k] = {
                reading:comparisonReadings[k],
                vocabs:[]
            };
        }

        // look through entire vocabList to find matching readings
        for (var vocabIndex = 0; vocabIndex < vocabList.length; vocabIndex++){
            currentReadings = splitReadings(vocabList[vocabIndex].kana);

            // make separate list for each reading - most of the time there will only be one
            for (var comparisonIndex = 0; comparisonIndex < comparisonReadings.length; comparisonIndex++){

                // compare all comparison readings with each reading of the current vocab
                for (var i = 0; i < currentReadings.length; i++){
                    if(currentReadings[i] === comparisonReadings[comparisonIndex]){
                        // found one - probably if it's not the same as the comparison
                        homophones[comparisonIndex].vocabs.push(vocabList[vocabIndex]);
                    }
                }
            }
        }

        // clean up - remove the comparison vocab from the homophone list - probably faster this way
        for (var readingIndex = 0; readingIndex < homophones.length; readingIndex++){
            for (var h = 0; h < homophones[readingIndex].vocabs.length; h++){
                if(homophones[readingIndex].vocabs[h].character === comparisonVocab){
                    homophones[readingIndex].vocabs.splice(h,1);
                    h--;
                }
            }

            // remove reading from homophones list if it does not contain any homophones
            if(homophones[readingIndex].vocabs.length < 1){
                homophones.splice(readingIndex,1);
                readingIndex--;
            }
        }
    }

    // update data and class variables from cache
    // returns true if available, else false
    function loadDataFromLocal(){
        var _lastUpdated, _data;
        var obj = {};

        _lastUpdated = getLastUpdated();
        _data = GM_getValue(KEY_NAMES.DATA);

        /* jshint eqnull:true */
        if(_data == null || _lastUpdated == null){

            return false;
        } else {

            lastUpdated = _lastUpdated; // global variable
            userInformation = JSON.parse(_data).user_information; // hpx variable
            vocabList = JSON.parse(_data).requested_information; // hpx variable

            console.log("Loading data from cache");
            return true;
        }
        /* jshint eqnull:false */
    }

    // get json using API; also saves it in GM_setValue
    // param function(bool success, object data) callback, bool forceRefresh
    // ** note getting all levels at once causes server errors - need to split up the request
    function loadListFromServer(callback){

        var LEVELS_PER_SET = 15;
        var levels_per_set;

        var level = 1;
        var setIndex = 0;
        var totalSetCount;

        var dataSets = {};
        var jsonData;
        var jsonDataValid = true;
        var responsesReceived = 0;

        var callbackSent = false;

        // speed things up the first time this program is run - just so the user knows what's up
        if(typeof lastUpdated === 'undefined'){
            levels_per_set = 5;
            console.log('First time user detected. Please rest assured that after first run, HPX will no longer be making large quantites of API requests.');
        } else {
            levels_per_set = LEVELS_PER_SET;
        }

        totalSetCount = Math.ceil(MAX_LEVEL/levels_per_set);

        while (level <= MAX_LEVEL){

            var levels = '';
            var urlBuild = API_REQUEST_TEMPLATE.VOCAB_LIST;

            // build a levels string for the {levels} part of the request
            // splitting each set into levels_per_set levels
            for (var i = 0; i < levels_per_set && level <= MAX_LEVEL; level++, i++){
                levels += level;
                // add a ',' after every level except for the last one
                if(level !== MAX_LEVEL && i + 1 !== levels_per_set){
                    levels += ',';
                }
            }

            urlBuild = urlBuild.replace('{VERSION_NUMBER}',API_VERSION);
            urlBuild = urlBuild.replace('{USER_API_KEY}',APIKey);
            urlBuild = urlBuild.replace('{levels}',levels);

            console.log(urlBuild);

            (function(setID){
                $.ajax({
                    method:'GET',
                    url:urlBuild,
                    dataType:'json'
                }).done(function(data,status,xhr){
                    buildList(setID, true, data);
                }).fail(function(xhr){
                    buildList(setID, false, xhr);
                });
            })(setIndex);

            setIndex++;

        }

        // callback function from ajax requests - builds whole json file from multiple requests
        // calls back when all requests have called back, in success or failure
        // param bool success
        function buildList(setID,success,data){
            if(success){console.log('Data Set "'+setID+'" returned '+success);}

            responsesReceived++;

            if(success){
                // check if the setID is already in dataSets and that the build has not already received a failure
                if(!dataSets.hasOwnProperty(setID.toString()) && jsonDataValid){
                    dataSets[setID.toString()] = data;
                } else {
                    // somehow got a duplicate record - failure!!
                    jsonDataValid = false;
                    jsonData = 'Duplicate record ' + setID.toString();
                }
            } else {
                // fail response
                jsonDataValid = false;
                jsonData = data;
            }


            // check if all responses have been received
            if (responsesReceived >= totalSetCount){

                // consolidate dataSet into jsonData if no failure detected - data will be defined with xhr object or string if failed
                if(jsonDataValid){
                    // copy first set **note that this process is not a true cloning process - copy by reference only

                    jsonData = dataSets['0'];
                    var setIndex = 1;

                    for (var i = 1;i < totalSetCount; i++){
                        // check that the parts come from the correct user
                        if(dataSets[i.toString()].user_information.username === jsonData.user_information.username){
                            // merge requested_information array
                            jsonData.requested_information = jsonData.requested_information.concat(dataSets[i.toString()].requested_information);
                        } else {
                            jsonData = 'User mismatch. Expected ' + jsonData.user_information.username + '. Got ' + dataSets[i.toString()].user_information.username;
                            jsonDataValid = false;
                            break;
                        }
                    }

                }

                // save if successful
                if(jsonDataValid){
                    saveJson(jsonData);
                }

                // consolidated - now callback, whether it was successful or not
                callback(jsonDataValid,jsonData);

            }
        }

        function saveJson(jsonData){
            console.log('Saving json');
            GM_setValue (KEY_NAMES.DATA, JSON.stringify(jsonData));
            GM_setValue (KEY_NAMES.LAST_UPDATED, new Date().getTime());
        }

    }

    // split kana readings into arrays
    function splitReadings(readings){
        return readings.replace(/ /g,'').split(',');
    }

}

// User interface controller
function UIPage(){

    var elements = {}; // jQuery object DOM elements

    var timeoutID;

    var currentStatus;
    var statuses = {
        INIT:function(){
            getComparisonVocab();
            return 'Initiatizing';
        },

        IDLE:function(){
            var time,days,hours,mins,secs;
            var strTime = 'Last updated: ';
            if(typeof lastUpdated === 'undefined' || lastUpdated < 1){
                strTime += 'Never';
            } else {

                time = new Date().getTime() - lastUpdated;
                days = Math.floor(time/(1000*60*60*24));

                if(days > 0){
                    strTime+= days + ' day(s), ';
                }

                time %= 1000*60*60*24;
                hours = Math.floor(time/(1000*60*60));

                if(hours > 0){
                    strTime+= hours + ' hour(s), ';
                }

                time %= 1000*60*60;
                mins = Math.floor(time/(1000*60));

                if(mins > 0){
                    strTime+= mins + ' minute(s) and ';
                }

                time %= 1000*60;
                secs = Math.floor(time/1000);

                strTime+= secs + ' second(s) ago ';

            }
            return strTime;
        },
        SEARCHING_FOR_KEY: 'Looking for your API Key.',

        SEARCH_FOR_KEY_FAILED:'Cannot find your API Key. Please try again later.',
        
        FOUND_API_KEY:function(){
            setTimeout(function(){
                if(currentStatus === 'FOUND_API_KEY'){
                    this.setStatus('IDLE');
                }
            }.bind(this),3000);
            return 'Retrieved API Key from your account page.';
        },

        CONNECTION_ERROR: function(){ // go back to idle after 2 seconds of displaying error message
            setTimeout(function(){
                if(currentStatus === 'CONNECTION_ERROR'){
                    this.setStatus('IDLE');
                }
            }.bind(this),2000);
            return 'Connection error... Retrying momentarily';
        },
        UPDATING: 'Updating cache with Wanikani servers. Please stay on the page...', // API Servers

        UPDATING_OTHER_INSTANCE: 'Updating cache on another instance.'
    };

    // build UI layout hierarchy
    // using Wanikani's .kotaba-table-list to display the vocab
    elements.hpxSection = $('<section>',{id:'hpx-ui','class':'kotoba-table-list'});
    $('.vocabulary-reading').after(elements.hpxSection);

    elements.infoResetHolder = $('<div>');

    // section title/heading
    elements.heading = $('<h2>',{text:'Homophones'});

    // info h4
    elements.info = $('<h4>',{'class':'small-caps',text:''});
    elements.info.css('display','inline-block');

    // reset button
    elements.reset = $('<a>',{'class':'btn btn-mini hpx-btn'})
        .css('margin-left','5px')
        .css('float','right')
        .text('Update cache now');
    elements.reset.on('click',function(){
        $(document).trigger('HPX:reloadRequest');
    });

    // reset API button
    elements.resetAPI = $('<a>',{'class':'btn btn-mini hpx-btn'})
        .css('margin-left','5px')
        .css('float','right')
        .text('Reload API Key');
    elements.resetAPI.on('click',function(){
        $(document).trigger('HPX:reloadAPIRequest');
    });

    // ul
    elements.ul = $('<ul>',{'class':'multi-character-grid'});

    // display p when no homophones found
    elements.noHomophones = $('<p>',{text:'No homophones founds'});

    elements.infoResetHolder
        .append(elements.info)
        .append(elements.reset)
        .append(elements.resetAPI);

    elements.hpxSection
        .append(elements.heading)
        .append(elements.infoResetHolder)
        .append(elements.ul);

    // build the list layout
    this.displayHomophones = function(homophones){

        var t = {};

        // empty ul wrapper from previous renders
        elements.ul.empty();

        // add "no homophones found" if there were no homophones found
        if (homophones.length < 1){
            elements.ul.append(elements.noHomophones);
        }

        for (var readingsIndex = 0; readingsIndex < homophones.length; readingsIndex++){

            for (var i = 0; i < homophones[readingsIndex].vocabs.length; i++){

                // time to create list item for each item
                t.liWrapper = $('<li>',{'class':'character-item', id:'vocabulary-' + homophones[readingsIndex].vocabs[i].character});

                // set classes
                if(homophones[readingsIndex].vocabs[i].user_specific === null){
                    // locked
                    t.liWrapper.addClass('locked');
                } else if (homophones[readingsIndex].vocabs[i].user_specific.srs === 'burned') {
                    // burned
                    t.liWrapper.addClass('burned');
                }

                t.spanItemBadge = $('<span>',{'class':'item-badge', lang:'ja'});
                t.anchor = $('<a>',{href:'/vocabulary/'+encodeURIComponent(homophones[readingsIndex].vocabs[i].character)});
                t.spanCharacter = $('<span>',{'class':'character', lang:'ja', text:homophones[readingsIndex].vocabs[i].character});
                t.ulWrapper = $('<ul>');
                t.liReading = $('<li>',{lang:'ja', text:homophones[readingsIndex].vocabs[i].kana});
                t.liMeaning = $('<li>',{text:homophones[readingsIndex].vocabs[i].meaning});

                // append these elements appropriately
                t.liWrapper.append(t.spanItemBadge)
                    .append(t.anchor);

                t.anchor.append(t.spanCharacter)
                    .append(t.ulWrapper);

                t.ulWrapper.append(t.liReading)
                    .append(t.liMeaning);

                elements.ul.append(t.liWrapper);

            }

            // add a separator
            if (readingsIndex < homophones.length - 1){
                elements.ul.append($('<hr>'));
            }
        }
    };


    this.toggleResetButton = function(state){

        if (typeof state === 'boolean'){
            if(state){
                elements.reset.removeAttr('disabled');
            }
            else {
                elements.reset.attr('disabled','disabled');
            }
        }
    };

    // public function that allows the view to be set
    // calls updateView() which updates the status text
    this.setStatus = function(state){

        console.log(state);
        if(statuses.hasOwnProperty(state)){
            currentStatus = state;
            updateView.call(this);
        }

        function updateView(){
            var res;

            if(typeof statuses[currentStatus] === 'function'){
                res = statuses[currentStatus].call(this);
            } else {
                res = statuses[currentStatus];
            }

            if(typeof timeoutID != 'undefined'){
                clearTimeout(timeoutID);
                elements.info.text(res);
            }
            timeoutID = setTimeout(function(){updateView(state);}.bind(this),1000);
        }

    };

    // get the vocab of the current page from url
    function getComparisonVocab(){
        var comparisonVocab,
            currentUrl = $(location).attr('href');

        // create jQuery object with <a> DOM
        var a = $('<a>',{href:currentUrl})[0];

        // extract pathname from the url
        var pathname = a.pathname;

        // at this stage, pathname could be "/vocabulary/{vocab}" or "/vocabulary/{vocab}/" or "/level/[0-9]+/vocabulary/{vocab}" or "/level/[0-9]+/vocabulary/{vocab}/"
        // remove "/vocabulary/" first then any trailing"/"
        pathname = pathname.replace(/^.*\/vocabulary\//i,'');
        pathname = pathname.replace(/\//,'');

        // decode
        comparisonVocab = decodeURIComponent(pathname);
        console.log('Comparison vocab detected as ' + comparisonVocab);

        // trigger new HPX:vocabUpdate event
        $(document).trigger('HPX:vocabUpdate',{
            exists: true,
            comparisonVocab: comparisonVocab
        });
    }

}

// for /lesson/session and /review/session
function UISessionPage(){

    var elements = {}; // jQuery object DOM elements for /review and /lesson
    var lessonPage = ($(location).attr('href').search(/^http(s)?:\/\/www\.wanikani\.com\/lesson\/session/) === 0) ? true : false;

    var currentStatus;
    var statuses = {
        INIT:function(){
            return 'Initiatizing';
        },

        IDLE:function(){
            return '';
        },

        FOUND_API_KEY:function(){
            setTimeout(function(){
                if(currentStatus === 'FOUND_API_KEY'){
                    this.setStatus('IDLE');
                }
            }.bind(this),3000);
            return 'Retrieved API Key from your account page.';
        },

        SEARCHING_FOR_KEY: 'Looking for your API Key.',

        SEARCH_FOR_KEY_FAILED:'Cannot find your API Key. Please try again later.',

        CONNECTION_ERROR: function(){
            setTimeout(function(){
                if(currentStatus === 'CONNECTION_ERROR'){
                    this.setStatus('IDLE');
                }
            }.bind(this),2000);
            return 'Connection error... Retrying momentarily';
        },
        UPDATING: 'Updating cache with Wanikani servers. Please stay on the page...',

        UPDATING_OTHER_INSTANCE: 'Updating cache on another instance.'
    };

    elements.hpxSection = $('<section>',{id:'hpx-ui'})
        .css('margin-top','21px'); // wrapper
    elements.heading = $('<h2>',{text:'Homophones'}); // section title/heading
    elements.ul = $('<ul>',{'class':'lattice-multi-character'}) // ul
        .css('padding-left','0');
    elements.noHomophones = $('<p>',{text:'No homophones founds'});// display p when no homophones found

    elements.hpxSection
        .append(elements.heading)
        .append(elements.ul);

    // hook jQuery.fn.show() - for review sections of /lesson and /review
    $.fn._hpx_show = $.fn.show;
    $.fn.show = function(a,b,c){
        var res = $.fn._hpx_show.call(this,a,b,c);

        // detect when Wanikani has loaded additional item information
        // ("#all-info").show() seems to correspond with this.
        if(typeof this[0] !== 'undefined' && this[0].id === 'information'){
            // start of wanikani ajax request

        } else if(typeof this[0] !== 'undefined' && this[0].id === 'all-info'){
            // wanikani ajax request returns - does not fire with radicals
            if(lessonPage){
                getComparisonVocab($.jStorage.get('l/currentQuizItem'));
            } else {
                getComparisonVocab($.jStorage.get('currentItem'));
            }
        } else if (typeof this[0] === 'undefined'){
            console.log(this); // i'm curious
        }

        return res;
    };

    if(lessonPage){
        // hook on to $.jStorage.get function - for lesson the section of /lesson
        $.jStorage._hpx_get = $.jStorage.get;
        $.jStorage.get = function( key , defaultValue){
            var res = $.jStorage._hpx_get( key , defaultValue);
            if(key === 'l/currentLesson'){
                if(res.voc){
                    getComparisonVocab(res);
                }
            }
            return res;
        };
    }

    // build the list layout
    this.displayHomophones = function(homophones){

        var t = {};

        // empty ul wrapper from previous renders
        elements.ul.empty();

        // add "no homophones found" if there were no homophones found
        if (homophones.length < 1){
            elements.ul.append(elements.noHomophones);
        }

        for (var readingsIndex = 0; readingsIndex < homophones.length; readingsIndex++){

            for (var i = 0; i < homophones[readingsIndex].vocabs.length; i++){

                t.liWrapper = $('<li>',{ id:'vocabulary-' + homophones[readingsIndex].vocabs[i].character});
                t.anchor = $('<a>',{
                    lang:'ja',
                    href:'/vocabulary/'+encodeURIComponent(homophones[readingsIndex].vocabs[i].character),
                    text:homophones[readingsIndex].vocabs[i].character
                });

                // append these elements appropriately
                t.liWrapper.append(t.spanItemBadge)
                    .append(t.anchor);

                elements.ul.append(t.liWrapper);

            }

            // add a separator
            if (readingsIndex < homophones.length - 1){
                elements.ul.append($('<hr>'));
            }
        }

        // don't know if it's a review/quiz or lesson? why not both
        if(lessonPage){
            $("#supplement-voc-reading div.col1") // lesson
                .append(elements.hpxSection);
        }
        $('#item-info-reading') // quiz/review
            .append(elements.hpxSection);

    };


    this.toggleResetButton = function(state){};

    // public function that allows the view to be set
    // calls updateView() which updates the status text
    this.setStatus = function(state){

        console.log(state);
        if(statuses.hasOwnProperty(state)){
            currentStatus = state;
            if(typeof statuses[currentStatus] === 'function'){
                statuses[currentStatus].call(this);
            }
        }

    };

    // get the vocab of the current page from url
    // wkObj is a vocab item plain object type. see wanikani.com/api for more info
    function getComparisonVocab(wkObj){
        var comparisonVocab = wkObj.voc ? wkObj.voc : undefined; // only for vocab
        console.log('Comparison vocab detected as ' + comparisonVocab);

        if(comparisonVocab){
            // trigger new HPX:vocabUpdate event
            $(document).trigger('HPX:vocabUpdate',{
                exists: true,
                comparisonVocab: comparisonVocab
            });
        }
    }

    // some styles
    $('head')
        .append($('<style>',{type:'text/css'})
                .html('.lattice-multi-character a {display: block;color:#fff; text-shadow: 0 1px 0 rgba(0,0,0,0.2); text-decoration: none; '+
                      'box-shadow: 0 -2px 0 rgba(0,0,0,0.2) inset; transition: text-shadow ease-out 0.3s; padding-left: 0.4em; '+
                      'padding-right: 0.4em; font-size: 13px; border-radius: 3px; background-color: #3f7fe9;}'+
                      '.lattice-multi-character li{overflow-x:hidden; overflow-y:hidden; color: rgb(51, 51, 51); display:inline-block; '+
                      'width: auto;height: 21px;margin-right: 2px;margin-bottom: 2px;line-height: 21px;text-align: center;}'+
                      '.lattice-multi-character ul{margin-left: 0;margin-right: 0;}'));

}

// Attempts to find API Key in GM_getValue, localStorage and wanikani.com in that order
// bool forceRemote - flag to skip local search
// function(key) callback - callback function after ajax function calls back
//    str key on success, else jqXhr object on failure
function findAPIKey(callback,forceRemote){

    var key;
    var keyRegex = /^[0-9a-f]{32}$/i;

    ui.setStatus('SEARCHING_FOR_KEY');

    // default value of forceRemote is false
    if (!(typeof forceRemote !== 'undefined' && forceRemote)){

        // Look for the key in userscript DB
        key = GM_getValue(KEY_NAMES.API_KEY);
        if(typeof key == 'undefined'){
            console.log('Cannot find key from GM_getValue');
            // Not in userscript DB
            // Look in localstorage - helpful if API Key is defined by other scripts
            var validKeyFound = false;

            // v0.9.1 and up: removed localStorage search for apiKey as it is unreliable
/*
            for (var i = 0; i < commonAPIKeyNames.length; i++){

                key = localStorage.getItem(commonAPIKeyNames[i]);

                // Check if key exists and fits regex
                if(isValidKey(key)){

                    //Key from localStorage valid
                    validKeyFound = true;
                    break;

                } // Else keep looping
            }
*/
            if (validKeyFound){
                console.log('Found key in localStorage: ' + key);
                saveKey(key);
                ui.setStatus('IDLE');
                return callback(key);
            }
        } else {
            console.log('Found key in GM_getValue: ' + key);
            ui.setStatus('IDLE');
            return callback(key);
        }
    }

    // find key on Wanikani settings page by way of AJAX
    $.ajax({
        method: 'GET',
        url: SETTINGS_URL,
        dataType: 'html'
    }).done(function(data,status,xhr){
        // Received successful response from server
        // Parse responseText as HTML then create jQuery object
        var page = $($.parseHTML(data));

        // find key inside input element with id user_api_key
        key = page.find('#user_api_key').val();
        if(isValidKey(key)){
            console.log('Found it from AJAX: ' + key);
            saveKey(key);

            ui.setStatus('FOUND_API_KEY');
            callback(key);

        } else {
            console.log('Key not found in WaniKani account settings page. Please report to developer.');
            ui.setStatus('SEARCH_FOR_KEY_FAILED');
        }
    }).fail( function(xhr){
        // Did not receive successful response
        console.log(xhr);

        ui.setStatus('SEARCH_FOR_KEY_FAILED');
        callback(xhr);

    });

    function saveKey(validKey){
        GM_setValue(KEY_NAMES.API_KEY,validKey);
        console.log('Key saved in GM_setValue');
    }

    function isValidKey(tryKey){
        // key would be null if not set in localStorage
        return (typeof tryKey !== 'undefined' && tryKey !== null && tryKey.search(keyRegex) != -1);
    }

}

// for testing only
function GM_clearValues(){
    var keys = GM_listValues();
    for (var i = 0; i < keys.length; i++){
        GM_deleteValue(keys[i]);
        console.log('Deleted ' + keys[i]);
    }
}

// http://www.html5rocks.com/en/tutorials/pagevisibility/intro/
function pageIsHidden(){
    var prefixes = ['webkit','moz','ms','o'];
    var property;

    // if 'hidden' is natively supported just return it
    if ('hidden' in document){
        property = 'hidden';
    } else {
        // otherwise loop over all the known prefixes until we find one
        for (var i = 0; i < prefixes.length; i++){
            if ((prefixes[i] + 'Hidden') in document){
                property = prefixes[i] + 'Hidden';
            }
        }
    }
    // otherwise hidden is not supported

    return (typeof document[property] !== 'undefined' ? document[property] : false);

}