Greasy Fork is available in English.

Wanikani Open Framework - ItemData module

ItemData module for Wanikani Open Framework

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://update.greasyfork.org/scripts/38580/1187212/Wanikani%20Open%20Framework%20-%20ItemData%20module.js

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

(function(global) {

	//########################################################################
	//------------------------------
	// Published interface.
	//------------------------------
	global.wkof.ItemData = {
		presets: {},
		registry: {
			sources: {},
			indices: {},
		},
		get_items: get_items,
		get_index: get_index,
		pause_ready_event: pause_ready_event
	};
	//########################################################################

	function promise(){let a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
	function split_list(str) {return str.replace(/、/g,',').replace(/[\s ]+/g,' ').trim().replace(/ *, */g, ',').split(',').filter(function(name) {return (name.length > 0);});}

	//------------------------------
	// Get the items specified by the configuration.
	//------------------------------
	function get_items(config, global_options) {
		// Default to WK 'subjects' only.
		if (!config) config = {wk_items:{}};

		// Allow comma-separated list of WK-only endpoints.
		if (typeof config === 'string') {
			let endpoints = split_list(config)
			config = {wk_items:{options:{}}};
			for (let idx in endpoints)
				config.wk_items.options[endpoints[idx]] = true;
		}

		// Fetch the requested endpoints.
		let fetch_promise = promise();
		let items = [];
		let remaining = 0;
		for (let cfg_name in config) {
			let cfg = config[cfg_name];
			let spec = wkof.ItemData.registry.sources[cfg_name];
			if (!spec || typeof spec.fetcher !== 'function') {
				console.log('wkof.ItemData.get_items() - Config "'+cfg_name+'" not registered!');
				continue;
			}
			remaining++;
			spec.fetcher(cfg, global_options)
			.then(function(data){
				let filter_promise;
				if (typeof spec === 'object')
					filter_promise = apply_filters(data, cfg, spec);
				else
					filter_promise = Promise.resolve(data);
				filter_promise.then(function(data){
					items = items.concat(data);
					remaining--;
					if (!remaining) fetch_promise.resolve(items);
				});
			})
			.catch(function(e){
				if (e) throw e;
				console.log('wkof.ItemData.get_items() - Failed for config "'+cfg_name+'"');
				remaining--;
				if (!remaining) fetch_promise.resolve(items);
			});
		}
		if (remaining === 0) fetch_promise.resolve(items);
		return fetch_promise;
	}

	//------------------------------
	// Get the wk_items specified by the configuration.
	//------------------------------
	function get_wk_items(config, options) {
		let cfg_options = config.options || {};
		options = options || {};
		let now = new Date().getTime();

		// Endpoints that we can fetch (subjects MUST BE FIRST!!)
		let available_endpoints = ['subjects','assignments','review_statistics','study_materials'];
		let spec = wkof.ItemData.registry.sources.wk_items;
		for (let filter_name in config.filters) {
			let filter_spec = spec.filters[filter_name];
			if (!filter_spec || typeof filter_spec.set_options !== 'function') continue;
			let filter_cfg = config.filters[filter_name];
			filter_spec.set_options(cfg_options, filter_cfg.value);
		}

		// Fetch all of the endpoints
		let ep_promises = [];
		for (let idx in available_endpoints) {
			let ep_name = available_endpoints[idx];
			if (ep_name === 'subjects' || cfg_options[ep_name] === true)
				ep_promises.push(
					wkof.Apiv2.get_endpoint(ep_name, options)
					.then(process_data.bind(null, ep_name))
				);
		}
		return Promise.all(ep_promises)
		.then(function(all_data){
			return all_data[0];
		});

		//============
		function process_data(ep_name, ep_data) {
			if (ep_name === 'subjects') return ep_data;
			// Merge with 'subjects' when 'subjects' is done fetching.
			return ep_promises[0].then(cross_link.bind(null, ep_name, ep_data));
		}

		//============
		function cross_link(ep_name, ep_data, subjects) {
			for (let id in ep_data) {
				let record = ep_data[id];
				let subject_id = record.data.subject_id;
				subjects[subject_id][ep_name] = record.data;
			}
		}
	}

	//------------------------------
	// Filter the items array according to the specified filters and options.
	//------------------------------
	function apply_filters(items, config, spec) {
		let prep_promises = [];
		let options = config.options || {};
		let filters = [];
		let is_wk_items = (spec === wkof.ItemData.registry.sources.wk_items);
		for (let filter_name in config.filters) {
			let filter_cfg = config.filters[filter_name];
			if (typeof filter_cfg !== 'object' || filter_cfg.value === undefined)
				filter_cfg = {value:filter_cfg};
			let filter_value = filter_cfg.value;
			let filter_spec = spec.filters[filter_name];
			if (filter_spec === undefined) throw new Error('wkof.ItemData.get_item() - Invalid filter "'+filter_name+'"');
			if (typeof filter_spec.filter_value_map === 'function')
				filter_value = filter_spec.filter_value_map(filter_cfg.value);
			if (typeof filter_spec.prepare === 'function') {
				let result = filter_spec.prepare(filter_value);
				if (result instanceof Promise) prep_promises.push(result);
			}
			filters.push({
				name: filter_name,
				func: filter_spec.filter_func,
				filter_value: filter_value,
				invert: (filter_cfg.invert === true)
			});
		}
		if (is_wk_items && (options.include_hidden !== true)) {
			filters.push({
				name: 'remove_deleted',
				func: function(filter_value, item){return item.data.hidden_at === null;},
				filter_value: true,
				invert: false
			});
		}

		return Promise.all(prep_promises).then(function(){
			let result = [];
			let max_level = Math.max(wkof.user.subscription.max_level_granted, wkof.user.override_max_level || 0);
			for (let item_idx in items) {
				let keep = true;
				let item = items[item_idx];
				if (is_wk_items && (item.data.level > max_level)) continue;
				for (let filter_idx in filters) {
					let filter = filters[filter_idx];
					try {
						keep = filter.func(filter.filter_value, item);
						if (filter.invert) keep = !keep;
						if (!keep) break;
					} catch(e) {
						keep = false;
						break;
					}
				}
				if (keep) result.push(item);
			}
			return result;
		});
	}

	//------------------------------
	// Return the items indexed by an indexing function.
	//------------------------------
	function get_index(items, index_name) {
		let index_func = wkof.ItemData.registry.indices[index_name];
		if (typeof index_func !== 'function') throw new Error('wkof.ItemData.index_by() - Invalid index function "'+index_name+'"');
		return index_func(items);
	}

	//------------------------------
	// Register wk_items data source.
	//------------------------------
	wkof.ItemData.registry.sources['wk_items'] = {
		description: 'Wanikani',
		fetcher: get_wk_items,
		options: {
			assignments: {
				type: 'checkbox',
				label: 'Assignments',
				default: false,
				hover_tip: 'Include the "/assignments" endpoint (SRS status, burn status, progress dates)'
			},
			review_statistics: {
				type: 'checkbox',
				label: 'Review Statistics',
				default: false,
				hover_tip: 'Include the "/review_statistics" endpoint:\n  * Per-item review count\n  *Correct/incorrect count\n  * Longest streak'
			},
			study_materials: {
				type: 'checkbox',
				label: 'Study Materials',
				default: false,
				hover_tip: 'Include the "/study_materials" endpoint:\n  * User synonyms\n  * User notes'
			},
		},
		filters: {
			item_type: {
				type: 'multi',
				label: 'Item type',
				content: {radical:'Radicals',kanji:'Kanji',vocabulary:'Vocabulary',kana_vocabulary:'Kana Vocabulary'},
				default: [],
				filter_value_map: item_type_to_arr,
				filter_func: function(filter_value, item){return filter_value[item.object] === true;},
				hover_tip: 'Filter by item type (radical, kanji, vocabulary, kana_vocabulary)',
			},
			level: {
				type: 'text',
				label: 'Level',
				placeholder: '(e.g. "1..3,5")',
				default: '',
				filter_value_map: levels_to_arr,
				filter_func: function(filter_value, item){return filter_value[item.data.level] === true;},
				hover_tip: 'Filter by Wanikani level\nExamples:\n  "*" (All levels)\n  "1..3,5" (Levels 1 through 3, and level 5)\n  "1..-1" (From level 1 to your current level minus 1)\n  "-5..+0" (Your current level and previous 5 levels)\n  "+1" (Your next level)',
			},
			srs: {
				type: 'multi',
				label: 'SRS Level',
				content: {lock:'Locked',init:'Initiate (Lesson Queue)',appr1:'Apprentice 1',appr2:'Apprentice 2',appr3:'Apprentice 3',appr4:'Apprentice 4',guru1:'Guru 1',guru2:'Guru 2',mast:'Master',enli:'Enlightened',burn:'Burned'},
				default: [],
				set_options: function(options){options.assignments = true;},
				filter_value_map: srs_to_arr,
				filter_func: function(filter_value, item){return filter_value[(item.assignments && item.assignments.unlocked_at ? item.assignments.srs_stage : -1)] === true;},
				hover_tip: 'Filter by SRS level (Apprentice 1, Apprentice 2, ..., Burn)',
			},
			have_burned: {
				type: 'checkbox',
				label: 'Have burned',
				default: true,
				set_options: function(options){options.assignments = true;},
				filter_func: function(filter_value, item){return ((item.assignments !== undefined) && (item.assignments.burned_at !== null)) === filter_value;},
				hover_tip: 'Filter items by whether they have ever been burned.\n  * If checked, select burned items (including resurrected)\n  * If unchecked, select items that have never been burned',
			},
		}
	};

	//------------------------------
	// Macro to build a function to index by a specific field.
	// Set make_subarrays to true if more than one item can share the same field value (e.g. same item_type).
	//------------------------------
	function make_index_func(name, field, entry_type) {
		let fn = '';
		fn +=
			'let index = {}, value;\n'+
			'for (let idx in items) {\n'+
			'    let item = items[idx];\n'+
			'    try {\n'+
			'        value = '+field+';\n'+
			'    } catch(e) {continue;}\n'+
			'    if (value === null || value === undefined) continue;\n';
		if (entry_type === 'array') {
			fn +=
				'    if (index[value] === undefined) {\n'+
				'        index[value] = [item];\n'+
				'        continue;\n'+
				'    }\n';
		} else {
			fn +=
				'    if (index[value] === undefined) {\n'+
				'        index[value] = item;\n'+
				'        continue;\n'+
				'    }\n';
			if (entry_type === 'single_or_array') {
				fn +=
					'    if (!Array.isArray(index[value]))\n'+
					'        index[value] = [index[value]];\n';
			}
		}
		fn +=
			'    index[value].push(item);\n'+
			'}\n'+
			'return index;'
		wkof.ItemData.registry.indices[name] = new Function('items', fn);
	}

	// Build some index functions.
	make_index_func('item_type', 'item.object', 'array');
	make_index_func('level', 'item.data.level', 'array');
	make_index_func('slug', 'item.data.slug', 'single_or_array');
	make_index_func('srs_stage', '(item.assignments && item.assignments.unlocked_at ? item.assignments.srs_stage : -1)', 'array');
	make_index_func('srs_stage_name', '(item.assignments && item.assignments.unlocked_at ? item.assignments.srs_stage_name : "Locked")', 'array');
	make_index_func('subject_id', 'item.id', 'single');


	//------------------------------
	// Index by reading
	//------------------------------
	wkof.ItemData.registry.indices['reading'] = function(items) {
		let index = {};
		for (let idx in items) {
			let item = items[idx];
			if (!item.hasOwnProperty('data') || !item.data.hasOwnProperty('readings')) continue;
			if (!Array.isArray(item.data.readings)) continue;
			let readings = item.data.readings;
			for (let idx2 in readings) {
				let reading = readings[idx2].reading;
				if (reading === 'None') continue;
				if (!index[reading]) index[reading] = [];
				index[reading].push(item);
			}
		}
		return index;
	}

	//------------------------------
	// Given an array of item type criteria (e.g. ['rad', 'kan', 'voc','kana_voc']), return
	// an array containing 'true' for each item type contained in the criteria.
	//------------------------------
	function item_type_to_arr(filter_value) {
		let xlat = {rad:'radical',kan:'kanji',voc:'vocabulary',kana_voc:'kana_vocabulary'};
		let arr = {}, value;
		if (typeof filter_value === 'string') filter_value = split_list(filter_value);
		if (typeof filter_value !== 'object') return {};
		if (Array.isArray(filter_value)) {
			for (let idx in filter_value) {
				value = filter_value[idx];
				value = xlat[value] || value;
				arr[value] = true;
			}
		} else {
			for (value in filter_value) {
				arr[xlat[value] || value] = (filter_value[value] === true);
			}
		}
		return arr;
	}

	//------------------------------
	// Given an array of srs criteria (e.g. ['mast', 'enli', 'burn']), return an
	// array containing 'true' for each srs level contained in the criteria.
	//------------------------------
	function srs_to_arr(filter_value) {
		let index = ['lock','init','appr1','appr2','appr3','appr4','guru1','guru2','mast','enli','burn'];
		let arr = [], value;
		if (typeof filter_value === 'string') filter_value = split_list(filter_value);
		if (typeof filter_value !== 'object') return {};
		if (Array.isArray(filter_value)) {
			for (let idx in filter_value) {
				value = Number(filter_value[idx]);
				if (isNaN(value)) value = index.indexOf(filter_value[idx]) - 1;
				arr[value] = true;
			}
		} else {
			for (value in filter_value) {
				arr[index.indexOf(value) - 1] = (filter_value[value] === true);
			}
		}
		return arr;
	}

	//------------------------------
	// Given an level criteria string (e.g. '1..3,5,8'), return an array containing
	// 'true' for each level contained in the criteria.
	//------------------------------
	function levels_to_arr(filter_value) {
		let levels = [], crit_idx, start, stop, lvl;

		// Process each comma-separated criteria separately.
		let criteria = filter_value.split(',');
		for (crit_idx = 0; crit_idx < criteria.length; crit_idx++) {
			let crit = criteria[crit_idx];
			let value = true;

			// Match '*' = all levels
			let match = crit.match(/^\s*["']?\s*(\*)\s*["']?\s*$/);
			if (match !== null) {
				start = to_num('1');
				stop = to_num('9999'); // All levels
				for (lvl = start; lvl <= stop; lvl++)
					levels[lvl] = value;
				continue;
			}

			// Match 'a..b' = range of levels (or exclude if preceded by '!')
			match = crit.match(/^\s*["']?\s*(\!?)\s*((\+|-)?\d+)\s*(-|\.\.\.?|to)\s*((\+|-)?\d+)\s*["']?\s*$/);
			if (match !== null) {
				start = to_num(match[2]);
				stop = to_num(match[5]);
				if (match[1] === '!') value = false;
				for (lvl = start; lvl <= stop; lvl++)
					levels[lvl] = value;
				continue;
			}

			// Match 'a' = specific level (or exclude if preceded by '!')
			match = crit.match(/^\s*["']?\s*(\!?)\s*((\+|-)?\d+)\s*["']?\s*$/);
			if (match !== null) {
				lvl = to_num(match[2]);
				if (match[1] === '!') value = false;
				levels[lvl] = value;
				continue;
			}
			let err = 'wkof.ItemData::levels_to_arr() - Bad filter criteria "'+filter_value+'"';
			console.log(err);
			throw err;
		}
		return levels;

		//============
		function to_num(num) {
			num = (num[0] < '0' ? wkof.user.level : 0) + Number(num)
			return Math.min(Math.max(1, num), wkof.user.subscription.max_level_granted);
		}
	}

	let registration_promise;
	let registration_timeout;
	let registration_counter = 0;
	//------------------------------
	// Ask clients to add items to the registry.
	//------------------------------
	function call_for_registration() {
		registration_promise = promise();
		wkof.set_state('wkof.ItemData.registry', 'ready');
		setTimeout(check_registration_counter, 1);
		registration_timeout = setTimeout(function(){
			registration_timeout = undefined;
			check_registration_counter(true /* force_ready */);
		}, 3000);
		return registration_promise;
	}

	//------------------------------
	// Request to pause the 'ready' event.
	//------------------------------
	function pause_ready_event(value) {
		if (value === true) {
			registration_counter++;
		} else {
			registration_counter--;
			check_registration_counter();
		}
	}

	//------------------------------
	// If registration is complete or timed out, mark it as resolved.
	//------------------------------
	function check_registration_counter(force_ready) {
		if (!force_ready && registration_counter > 0) return false;
		if (registration_timeout !== undefined) clearTimeout(registration_timeout);
		registration_promise.resolve();
		return true;
	}

	//------------------------------
	// Notify listeners that we are ready.
	//------------------------------
	function notify_ready() {
		// Delay guarantees include() callbacks are called before ready() callbacks.
		setTimeout(function(){wkof.set_state('wkof.ItemData', 'ready');},0);
	}
	wkof.include('Apiv2');
	wkof.ready('Apiv2').then(call_for_registration).then(notify_ready);

})(this);