Wanikani Open Framework - Apiv2 module

Apiv2 module for Wanikani Open Framework

Verzia zo dňa 17.02.2018. Pozri najnovšiu verziu.

Tento skript by nemal byť nainštalovaný priamo. Je to knižnica pre ďalšie skripty, ktorú by mali používať cez meta príkaz // @require https://update.greasyfork.org/scripts/38581/252065/Wanikani%20Open%20Framework%20-%20Apiv2%20module.js

// ==UserScript==
// @name        Wanikani Open Framework - Apiv2 module
// @namespace   rfindley
// @description Apiv2 module for Wanikani Open Framework
// @version     1.0.0
// @copyright   2018+, Robin Findley
// @license     MIT; http://opensource.org/licenses/MIT
// ==/UserScript==

(function(global) {

	//########################################################################
	//------------------------------
	// Published interface.
	//------------------------------
	global.wkof.Apiv2 = {
		clear_cache: clear_cache,       // Clear the user cache
		fetch_endpoint: fetch_endpoint, // Fetch a complete API endpoint, including pagination
		get_endpoint: get_endpoint,     // Scripts can signal which API endpoints they need
		is_valid_apikey_format: is_valid_apikey_format, // Check if string is a valid API key
	};
	//########################################################################

	function promise(){var a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}

	var available_endpoints = [
		'assignments','level_progressions','resets','review_statistics',
		'reviews','study_materials','subjects','summary','user'
	];
	var using_apikey_override = false;
	var skip_username_check = false;

	//------------------------------
	// Retrieve the username from the page.
	//------------------------------
	function get_username() {
		try {
			return ($('.account a[href^="/users/"]').attr('href') || '').match(/[^\/]+$/)[0];
		} catch(e) {
			return undefined;
		}
	}

	//------------------------------
	// Check if a string is a valid apikey format.
	//------------------------------
	function is_valid_apikey_format(str) {
		return ((typeof str === 'string') &&
			(str.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) !== null));
	}

	//------------------------------
	// Clear any datapoint cache not belonging to the current user.
	//------------------------------
	function clear_cache() {
		var clear_promises = [];
		var dir = wkof.file_cache.dir;
		for (var idx in available_endpoints) {
			var filename = 'Apiv2.'+available_endpoints[idx];
			if (filename === 'Apiv2.subjects' || !dir[filename]) continue;
			clear_promises.push(filename);
		}
		clear_promises = clear_promises.map(delete_file);

		if (clear_promises.length > 0) {
			console.log('Clearing user cache...');
			return Promise.all(clear_promises);
		} else {
			return Promise.resolve();
		}

		function delete_file(filename){
			return wkof.file_cache.delete(filename);
		}
	}

	wkof.set_state('wkof.Apiv2.key', 'not_ready');

	//------------------------------
	// Get the API key (either from localStorage, or from the Account page).
	//------------------------------
	function get_apikey() {
		// If we already have the apikey, just return it.
		if (is_valid_apikey_format(wkof.Apiv2.key))
			return Promise.resolve(wkof.Apiv2.key);

		// If we don't have the apikey, but override was requested, return error.
		if (using_apikey_override) 
			return Promise.reject('Invalid api2_key_override in localStorage!');

		// Fetch the apikey from the account page.
		console.log('Fetching API key...');
		wkof.set_state('wkof.Apiv2.key', 'fetching');
		return wkof.load_file('https://www.wanikani.com/settings/account')
		.then(parse_page);

		function parse_page(page){
			var page = $(page);
			var apikey = page.find('#user_api_key_v2').val();
			if (!wkof.Apiv2.is_valid_apikey_format(apikey))
				return Promise.reject('No API key (version 2) found on account page!');

			// Store the api key.
			wkof.Apiv2.key = apikey;
			localStorage.setItem('apiv2_key', apikey);
			wkof.set_state('wkof.Apiv2.key', 'ready');
			return apikey;
		};
	}

	//------------------------------
	// Fetch a URL asynchronously, and pass the result as resolved Promise data.
	//------------------------------
	function fetch_endpoint(endpoint, options) {
		var retry_cnt, endpoint_data, url, headers;
		var progress_data = {name:'wk_api_'+endpoint, label:'Wanikani '+endpoint, value:0, max:100};
		var bad_key_cnt = 0;

		// Parse options.
		if (!options) options = {};
		var filters = options.filters;
		if (!filters) filters = {};
		var progress_callback = options.progress_callback;

		// Get timestamp of last fetch from options (if specified)
		var last_update = options.last_update;

		// If no prior fetch, set last_update to ancient date.
		if (typeof last_update !== 'string') {
			if (filters.updated_after === undefined)
				last_update = '1999-01-01T01:00:00.000000Z';
			else
				last_update = filters.updated_after;
		}

		// Set up URL and headers
		url = "https://www.wanikani.com/api/v2/" + endpoint;

		// Add user-specified data filters to the URL
		filters.updated_after = last_update;
		var arr = [];
		for (var name in filters) {
			var value = filters[name];
			if (Array.isArray(value)) value = value.join(',');
			arr.push(name+'='+value);
		}
		url += '?'+arr.join('&');

		// Get API key and fetch the data.
		var fetch_promise = promise();
		get_apikey()
		.then(setup_and_fetch);

		return fetch_promise;

		//============
		function setup_and_fetch() {
			wkof.Progress.update(progress_data);
			headers = {
			//	'Wanikani-Revision': '20170710', // Placeholder?
				'Authorization': 'Bearer '+wkof.Apiv2.key,
			};
			headers['If-Modified-Since'] = new Date(last_update).toUTCString(last_update);

			retry_cnt = 0;
			fetch();
		}

		//============
		function fetch() {
			retry_cnt++;
			var request = new XMLHttpRequest();
			request.onreadystatechange = received;
			request.open('GET', url, true);
			for (var key in headers)
				request.setRequestHeader(key, headers[key]);
			request.send();
		}

		//============
		function received(event) {
			// ReadyState of 4 means transaction is complete.
			if (this.readyState !== 4) return;

			// Check for rate-limit error.  Delay and retry if necessary.
			if (this.status === 429 && retry_cnt < 40) {
				var delay = Math.min((retry_cnt * 250), 2000);
				setTimeout(fetch, delay);
				return;
			}

			// Check for bad API key.
			if (this.status === 401) return bad_apikey();

			// Check of 'no updates'.
			if (this.status >= 300) return fetch_promise.reject({status:this.status, url:url});

			// Process the response data.
			var json = JSON.parse(event.target.response);

			// Data may be a single object, or collection of objects.
			// Collections are paginated, so we may need more fetches.
			if (json.object === 'collection') {
				// It's a multi-page endpoint.
				var first_new, so_far, total;
				if (endpoint_data === undefined) {
					// First page of results.
					first_new = 0;
					so_far = json.data.length;
				} else {
					// Nth page of results.
					first_new = endpoint_data.data.length;
					so_far = first_new + json.data.length;
					json.data = endpoint_data.data.concat(json.data);
				}
				endpoint_data = json;
				total = json.total_count;

				// Call the 'progress' callback.
				if (typeof progress_callback === 'function')
					progress_callback(endpoint, first_new, so_far, total);
				progress_data.value = so_far;
				progress_data.max = total;
				wkof.Progress.update(progress_data);

				// If there are more pages, fetch the next one.
				if (json.pages.next_url !== null) {
					retry_cnt = 0;
					url = json.pages.next_url;
					fetch();
					return;
				}

				// This was the last page.  Return the data.
				fetch_promise.resolve(endpoint_data);

			} else {
				// Single-page result.  Report single-page progress, and return data.
				if (typeof progress_callback === 'function')
					progress_callback(endpoint, 0, 1, 1);
				progress_data.value = 1;
				progress_data.max = 1;
				wkof.Progress.update(progress_data);
				fetch_promise.resolve(json);
			}
		}

		//============
		function bad_apikey(){
			// If we are using an override key, abort and return error.
			if (using_apikey_override) {
				fetch_promise.reject('Wanikani doesn\'t recognize the apiv2_key_override key ("'+wkof.Apiv2.key+'")');
				return;
			}

			// If bad key received too many times, abort and return error.
			bad_key_cnt++;
			if (bad_key_cnt > 1) {
				fetch_promise.reject('Aborting fetch: Bad key reported multiple times!');
				return;
			}

			// We received a bad key.  Report on the console, then try fetching the key (and data) again.
			console.log('Seems we have a bad API key.  Erasing stored info.');
			localStorage.removeItem('apiv2_key');
			wkof.Apiv2.key = undefined;
			get_apikey()
			.then(populate_user_cache)
			.then(setup_and_fetch);
		}
	}


	var min_update_interval = 60;
	var ep_cache = {};

	//------------------------------
	// Get endpoint data from cache with updates from API.
	//------------------------------
	function get_endpoint(ep_name, options) {
		if (!options) options = {};

		// We cache data for 'min_update_interval' seconds.
		// If within that interval, we return the cached data.
		// User can override cache via "options.force_update = true"
		var ep_info = ep_cache[ep_name];
		if (ep_info) {
			// If still awaiting prior fetch return pending promise.
			// Also, not force_update, return non-expired cache (i.e. resolved promise)
			if (options.force_update !== true || ep_info.timer === undefined)
				return ep_info.promise;
			// User is requesting force_update, and we have unexpired cache.
			// Clear the expiration timer since we will re-fetch anyway.
			clearTimeout(ep_info.timer);
		}

		// Create a promise to fetch data.  The resolved promise will also serve as cache.
		var get_promise = promise();
		ep_cache[ep_name] = {promise: get_promise};

		// Make sure the requested endpoint is valid.
		var merged_data;
		if (available_endpoints.indexOf(ep_name) < 0) {
			get_promise.reject(new Error('Invalid endpoint name "'+ep_name+'"'));
			return get_promise;
		}

		// Perform the fetch, and process the data.
		wkof.file_cache.load('Apiv2.'+ep_name)
		.then(fetch, fetch);
		return get_promise;

		//============
		function fetch(cache_data) {
			if (typeof cache_data === 'string') cache_data = {last_update:null};
			merged_data = cache_data;
			var fetch_options = Object.assign({}, options);
			fetch_options.last_update = cache_data.last_update;
			fetch_endpoint(ep_name, fetch_options)
			.then(process_api_data, handle_error);
		}

		//============
		function process_api_data(fetched_data) {
			// Mark the data with the last_update timestamp reported by the server.
			if (fetched_data.data_updated_at !== null) merged_data.last_update = fetched_data.data_updated_at;

			// Process data according to whether it is paginated or not.
			if (fetched_data.object === 'collection') {
				if (merged_data.data === undefined) merged_data.data = {};
				for (var idx = 0; idx < fetched_data.data.length; idx++) {
					var item = fetched_data.data[idx];
					merged_data.data[item.id] = item;
				}
			} else {
				merged_data.data = fetched_data.data;
			}

			// If it's the 'user' endpoint, we insert the apikey before caching.
			if (ep_name === 'user') merged_data.data.apikey = wkof.Apiv2.key;

			// Save data to cache and finish up.
			wkof.file_cache.save('Apiv2.'+ep_name, merged_data)
			.then(finish);
		}

		//============
		function finish() {
			// Return the data, then set up a cache expiration timer.
			get_promise.resolve(merged_data.data);
			ep_cache[ep_name].timer = setTimeout(expire_cache, min_update_interval*1000);
		}

		//============
		function expire_cache() {
			// Delete the data from cache.
			delete ep_cache[ep_name];
		}

		//============
		function handle_error(error) {
			if (typeof error === 'string')
				get_promise.reject(error);
			if (error.status >= 300 && error.status <= 399)
				finish();
			else
				get_promise.reject('Error '+error.status+' fetching "'+error.url+'"');
		}
	}

	//########################################################################
	//------------------------------
	// Make sure user cache matches the current (or override) user.
	//------------------------------
	function validate_user_cache() {
		var user = get_username();
		if (!user) {
			// Username unavailable if not logged in, or if on Lessons or Reviews pages.
			// If not logged in, stop running the framework.
			if (location.pathname.match(/^(\/|\/login)$/) !== null)
				return Promise.reject('Couldn\'t extract username from user menu!  Not logged in?');
			skip_username_check = true;
		}

		var apikey = localStorage.getItem('apiv2_key_override');
		if (apikey !== null) {
			// It looks like we're trying to override the apikey (e.g. for debug)
			using_apikey_override = true;
			if (!is_valid_apikey_format(apikey)) {
				return Promise.reject('Invalid api2_key_override in localStorage!');
			}
			console.log('Using apiv2_key_override key ('+apikey+')');
		} else {
			// Use regular apikey (versus override apikey)
			apikey = localStorage.getItem('apiv2_key');
			if (!is_valid_apikey_format(apikey)) apikey = undefined;
		}

		wkof.Apiv2.key = apikey;

		// Make sure cache is still valid
		return wkof.file_cache.load('Apiv2.user')
		.then(process_user_info)
		.catch(retry);

		//============
		function process_user_info(user_info) {
			// If cache matches, we're done.
			if (user_info.data.apikey === wkof.Apiv2.key) {
				// We don't check username when using override key.
				if (using_apikey_override || skip_username_check || (user_info.data.username === user)) {
					wkof.Apiv2.user = user_info.data.username;
					wkof.user = user_info.data;
					return;
				}
			}
			// Cache doesn't match.
			if (!using_apikey_override) {
				// Fetch the key from the accounts page.
				wkof.Apiv2.key = undefined;
				throw 'fetch key';
			} else {
				// We're using override.  No need to fetch key, just populate cache.
				return clear_cache().then(populate_user_cache);
			}
		}

		//============
		function retry() {
			// Either empty cache, or user mismatch.  Fetch key, then populate cache.
			return get_apikey().then(clear_cache).then(populate_user_cache);
		}
	}

	//------------------------------
	// Populate the user info into cache.
	//------------------------------
	function populate_user_cache() {
		console.log('Fetching user info...');
		return fetch_endpoint('user')
		.then(function(user_info){
			// Store the apikey in the cache.
			user_info.data.apikey = wkof.Apiv2.key;
			wkof.Apiv2.user = user_info.data.username;
			wkof.user = user_info.data
			console.log('Caching user info...');
			return wkof.file_cache.save('Apiv2.user', user_info);
		})
	}

	//------------------------------
	// Do initialization once document is loaded.
	//------------------------------
	function notify_ready() {
		// Notify listeners that we are ready.
		// Delay guarantees include() callbacks are called before ready() callbacks.
		setTimeout(function(){wkof.set_state('wkof.Apiv2', 'ready');},0);
	}

	//------------------------------
	// Do initialization once document is loaded.
	//------------------------------
	wkof.include('Progress');
	wkof.ready('document,Progress').then(startup);
	function startup() {
		validate_user_cache()
		.then(notify_ready);
	}

})(window);