WaniKani Item Marker

Tool to mark and track individual radicals/kanji/vocabulary

目前為 2016-04-07 提交的版本,檢視 最新版本

// ==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!'));
					
			});
		});
	}

}());