// ==UserScript==
// @name WaniKani Item Marker
// @description Tool to mark and track individual radicals/kanji/vocabulary
// @namespace irx.wanikani.marker
// @include https://www.wanikani.com/*
// @version 1
// @copyright 2016, Ingo Radax
// @license MIT; http://opensource.org/licenses/MIT
// @grant none
// ==/UserScript==
(function(gobj) {
var data;
var api_key;
var localStoragePrefix = 'ItemMarker_';
//-------------------------------------------------------------------
// Main function
//-------------------------------------------------------------------
function main() {
console.log('START - WaniKani Item Marker');
loadData();
updatePage();
console.log('END - WaniKani Item Marker');
}
window.addEventListener('load', main, false);
window.addEventListener('focus', main, false);
//-------------------------------------------------------------------
// Update the current page and add the item marker features
//-------------------------------------------------------------------
function updatePage() {
var location = decodeURI(window.location);
if (location.endsWith('//www.wanikani.com/') || location.endsWith('/dashboard') || location.endsWith('/dashboard/')) {
updateDashboardPage();
}
else if (location.endsWith('/review') || location.endsWith('/review/')) {
updateReviewPage();
}
else if (location.endsWith('/review/session') || location.endsWith('/review/session/')) {
updateReviewSessionPage();
}
else {
var parsedUrl = parseItemUrl(location);
if (parsedUrl) {
updateItemPage(parsedUrl.type, parsedUrl.name);
}
}
}
//-------------------------------------------------------------------
// Try to parse the url and detect if it belongs to a single item.
// e.g. 'https://www.wanikani.com/level/1/radicals/construction'
// will be parsed as 'radicals' and 'construction'
//-------------------------------------------------------------------
function parseItemUrl(url) {
var parsed = /.*\/(radicals|kanji|vocabulary)\/(.+)/.exec(url);
if (parsed) {
return {type:parsed[1], name:parsed[2]};
}
else {
return null;
}
}
//-------------------------------------------------------------------
// Load item marker data from local storage
//-------------------------------------------------------------------
function loadData() {
data = localStorage.getItem(localStoragePrefix + 'markedItems');
if (data == null) {
data = { items:[] };
}
else {
data = JSON.parse(data);
}
}
//-------------------------------------------------------------------
// Save item marker data to local storage
//-------------------------------------------------------------------
function saveData() {
localStorage.setItem(localStoragePrefix + 'markedItems', JSON.stringify(data));
}
//-------------------------------------------------------------------
// Return the in dex of the given item in the list
// returns -1 if item isn't in list
//-------------------------------------------------------------------
function indexOf(type, name) {
return data.items.findIndex(function(item) { return (item.type == type) && (item.name == name); });
}
//-------------------------------------------------------------------
// Remove all marks
//-------------------------------------------------------------------
function unmarkAllItems() {
loadData();
data.items = [];
saveData();
updatePage();
}
//-------------------------------------------------------------------
// Force refresh of page and data
//-------------------------------------------------------------------
function forceRefresh() {
localStorage.removeItem(localStoragePrefix + 'radicalInfo');
for (var i = 0; i < data.items.length; i++) {
if (data.items[i].type == 'radicals') {
data.items[i].radical_character = null;
}
}
updatePage();
}
//-------------------------------------------------------------------
// Unmark a single item
//-------------------------------------------------------------------
function unmarkItem(type, name) {
loadData();
var index = indexOf(type, name);
if (index > -1) {
data.items.splice(index, 1);
saveData();
}
updatePage();
}
//-------------------------------------------------------------------
// Sort the list of marked items
//-------------------------------------------------------------------
function sortItems() {
loadData();
data.items.sort(
function(a, b) {
if (a.type == 'radicals') {
if (b.type != 'radicals') {
return -1;
}
}
else if (a.type == 'kanji') {
if (b.type == 'radicals') {
return 1;
}
else if (b.type == 'vocabulary') {
return -1;
}
}
else {
if (b.type != 'vocabulary') {
return 1;
}
}
return a.name.localeCompare(b.name);
});
saveData();
updatePage();
}
//-------------------------------------------------------------------
// Mark a single item
//-------------------------------------------------------------------
function markItem(type, name) {
loadData();
var index = indexOf(type, name);
if (index === -1) {
data.items.push({type:type, name:name, radical_character:null});
saveData();
}
updatePage();
}
//-------------------------------------------------------------------
// Callback if the currentItem changes
//-------------------------------------------------------------------
function onCurrentItemChanged() {
updatePage();
}
//-------------------------------------------------------------------
// Extends the review session page
// - Adds buttons to mark/unmark the current item
//-------------------------------------------------------------------
function updateReviewSessionPage() {
$.jStorage.stopListening('currentItem', onCurrentItemChanged);
$.jStorage.listenKeyChange('currentItem', onCurrentItemChanged);
var currentItem = $.jStorage.get('currentItem');
var name;
var type;
if (currentItem.hasOwnProperty('rad')) {
type = 'radicals';
name = currentItem.en[0].toLowerCase().replace(/\s/g, "-");
}
else if (currentItem.hasOwnProperty('kan')) {
type = 'kanji';
name = currentItem.kan;
}
else if (currentItem.hasOwnProperty('voc')) {
type = 'vocabulary';
name = currentItem.voc;
}
else {
return;
}
//console.log('updateReviewSessionPage - type: ' + type);
//console.log('updateReviewSessionPage - name: ' + name);
$('#option-mark').remove();
$('#option-unmark').remove();
var item_index = indexOf(type, name);
if (item_index === -1) {
$('#additional-content ul').append('<li id="option-mark"><span title="Mark this item">Mark</span></li>');
$('#option-mark').on('click', function() { markItem(type, name); });
}
else {
$('#additional-content ul').append('<li id="option-unmark"><span title="Unmark this item">Unmark</span></li>');
$('#option-unmark').on('click', function() { unmarkItem(type, name); });
}
calculateDynamicWidthForReviewPage();
}
//-------------------------------------------------------------------
// Updates the dynamic with for the review page
//-------------------------------------------------------------------
function calculateDynamicWidthForReviewPage(){
var liCount = $('#additional-content ul').children().size();
var percentage = 100 / liCount;
percentage -= 0.1;
cssDynamicWidth =
'#additional-content ul li {' +
' width: ' + percentage + '% !important' +
'} ';
addStyle(cssDynamicWidth);
}
//-------------------------------------------------------------------
// Adds a css to the page
//-------------------------------------------------------------------
function addStyle(aCss) {
var head, style;
head = document.getElementsByTagName('head')[0];
if (head) {
style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = aCss;
head.appendChild(style);
return style;
}
return null;
}
//-------------------------------------------------------------------
// Extends the review page
// - Adds a black/white border around marked/unmarked items displayed on the page
//-------------------------------------------------------------------
function updateReviewPage() {
var query = $('div.active li');
if (query.length == 0) { // Page not completly loaded yet
setTimeout(updatePage, 1000);
return;
}
query.each(function(index) {
var href = $(this).find('a').attr('href');
var parsedUrl = parseItemUrl(href);
if (parsedUrl) {
var item_index = indexOf(parsedUrl.type, parsedUrl.name);
if (item_index === -1) {
$(this).css('border', '3px solid white');
}
else {
$(this).css('border', '3px solid black');
}
}
});
}
//-------------------------------------------------------------------
// Extends the dashboard
// - Adds a 'marked items' section
// - Adds common buttons and marked items list to section
//-------------------------------------------------------------------
function updateDashboardPage() {
var query = $('section.progression');
if (query.length != 1) {
return;
}
$('#marked_items').remove();
$('section.progression').after('<section id="marked_items" />');
$('#marked_items').append('<h2>Marked items</h2>');
$('#marked_items').append('<p id="marked_items_buttons" />');
addCommonButtons();
addMarkedItemsList();
}
//-------------------------------------------------------------------
// Extends the an item page
// - Adds a 'marked items' section
// - Adds common buttons and marked items list to section
// - Adds buttons to mark/unmark the current item
//-------------------------------------------------------------------
function updateItemPage(type, name) {
var query = $('section#information');
if (query.length != 1) {
return;
}
$('#marked_items').remove();
$('section#information').after('<section id="marked_items" />');
$('#marked_items').append('<h2>Marked items</h2>');
$('#marked_items').append('<p id="marked_items_buttons" />');
var index = indexOf(type, name);
if (index === -1) {
var button = $('<button>Mark "' + name + '"</button>');
button.on('click', function() { markItem(type, name); });
$('#marked_items_buttons').append(button)
}
else {
var button = $('<button>Unmark "' + name + '"</button>');
button.on('click', function() { unmarkItem(type, name); });
$('#marked_items_buttons').append(button)
}
addCommonButtons();
addMarkedItemsList();
}
//-------------------------------------------------------------------
// Adds common buttons that are used on multiple locations
//-------------------------------------------------------------------
function addCommonButtons() {
if (data.items.length > 0)
{
var button = $('<button>Unmark all</button>');
button.on('click', function() { unmarkAllItems(); });
$('#marked_items_buttons').append(button)
}
if (data.items.length > 0)
{
var button = $('<button>Sort</button>');
button.on('click', function() { sortItems(); });
$('#marked_items_buttons').append(button)
}
{
var button = $('<button>Force refresh</button>');
button.on('click', function() { forceRefresh(); });
$('#marked_items_buttons').append(button)
}
}
//-------------------------------------------------------------------
// Adds the list of marked items to the current page
//-------------------------------------------------------------------
function addMarkedItemsList() {
$('#marked_items').append('<span id="marked_items_list" />')
if (data.items.length == 0) {
$('#marked_items_list').append('<p>No marked items</p>');
}
else {
var buildItemList = function() {
for (var i = 0; i < data.items.length; i++) {
var item = data.items[i];
var typeForClass = item.type;
if (typeForClass == 'radicals')
typeForClass = 'radical';
var itemText = item.name;
if (item.type == 'radicals') {
if (item.radical_character != '') {
itemText = item.radical_character;
}
else {
itemText = '<i class="radical-' + item.name + '"></i>';
}
}
$('#marked_items_list').append(
'<a href="/' + item.type + '/' + item.name + '">' +
' <span class="' + typeForClass + '-icon" lang="ja">' +
' <span style="display: inline-block; margin-top: 0.1em;" class="japanese-font-styling-correction">' +
itemText +
' </span>' +
' </span>' +
'</a>');
}
};
var refetchRadicalInfo = detect_radical_characters();
if (refetchRadicalInfo) {
get_api_key()
.then(fetch_radical_info)
.then(buildItemList);
}
else {
buildItemList();
}
}
}
//-------------------------------------------------------------------
// Fetches radical information from the WaniKani API
// Some radicals use an image intead of a character. The radical
// info helps us determine that.
//-------------------------------------------------------------------
function fetch_radical_info() {
console.log('Calling: "/api/user/' + api_key + '/radicals/"');
return new Promise(function(resolve, reject) {
$.getJSON('/api/user/' + api_key + '/radicals/', function(json){
if (json.error && json.error.code === 'user_not_found') {
localStorage.removeItem(localStoragePrefix + 'radicalInfo');
location.reload();
reject();
return;
}
localStorage.setItem(localStoragePrefix + 'radicalInfo', JSON.stringify(json));
detect_radical_characters();
resolve();
});
});
}
//-------------------------------------------------------------------
// Uses the radical info to determine if a radical uses a character
// or an image.
//-------------------------------------------------------------------
function detect_radical_characters() {
var radicalInfo = localStorage.getItem(localStoragePrefix + 'radicalInfo');
if (radicalInfo == null)
return true;
radicalInfo = JSON.parse(radicalInfo);
$(data.items).each(function(i, item) {
if ((item.type == 'radicals') && (item.radical_character == null)) {
var radical_character = null;
$(radicalInfo.requested_information).each(function(j, info){
if (item.name == info.meaning) {
if (info.image == null) {
radical_character = info.character;
}
else {
radical_character = '';
}
}
});
console.log('detect_radical_characters: ' + item.name + ' -> ' + radical_character);
data.items[i].radical_character = radical_character;
}
});
saveData();
$(data.items).each(function(i, item) {
if ((item.type == 'radicals') && (item.radical_character == null)) {
return true;
}
});
return false;
}
//-------------------------------------------------------------------
// Fetch a document from the server.
//-------------------------------------------------------------------
function ajax_retry(url, retries, timeout) {
retries = retries || 2;
timeout = timeout || 3000;
function action(resolve, reject) {
$.ajax({
url: url,
timeout: timeout
})
.done(function(data, status){
if (status === 'success')
resolve(data);
else
reject();
})
.fail(function(xhr, status, error){
if (status === 'error' && --retries > 0)
action(resolve, reject);
else
reject();
});
}
return new Promise(action);
}
//-------------------------------------------------------------------
// Fetch API key from account page.
//-------------------------------------------------------------------
function get_api_key() {
return new Promise(function(resolve, reject) {
api_key = localStorage.getItem(localStoragePrefix + 'apiKey');
if (typeof api_key === 'string' && api_key.length == 32) return resolve();
ajax_retry('/account').then(function(page) {
// --[ SUCCESS ]----------------------
// Make sure what we got is a web page.
if (typeof page !== 'string') {return reject();}
// Extract the user name.
page = $(page);
// Extract the API key.
api_key = page.find('#api-button').parent().find('input').attr('value');
if (typeof api_key !== 'string' || api_key.length !== 32) {return reject();}
localStorage.setItem(localStoragePrefix + 'apiKey', api_key);
resolve();
},function(result) {
// --[ FAIL ]-------------------------
reject(new Error('Failed to fetch API key!'));
});
});
}
}());