WaniKani Open Framework Additional Filters

Additional filters for the WaniKani Open Framework

// ==UserScript==
// @name          WaniKani Open Framework Additional Filters
// @namespace     https://www.wanikani.com
// @description   Additional filters for the WaniKani Open Framework
// @author        seanblue
// @version       1.3.1
// @include       https://www.wanikani.com/*
// @grant         none
// ==/UserScript==

(function(wkof) {
	'use strict';

	var wkofMinimumVersion = '1.0.18';

	if (!wkof) {
		var response = confirm('WaniKani Open Framework Additional Filters requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');

		if (response) {
			window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
		}

		return;
	}

	if (!wkof.version || wkof.version.compare_to(wkofMinimumVersion) === 'older') {
		alert('WaniKani Open Framework Additional Filters requires at least version ' + wkofMinimumVersion + ' of WaniKani Open Framework.');
		return;
	}

	var settingsDialog;
	var settingsScriptId = 'additionalFilters';
	var settingsTitle = 'Additional Filters';

	var needToRegisterFilters = true;
	var settingsLoadedPromise = promise();

	var filterNamePrefix = 'additionalFilters_';
	var recentLessonsFilterName = filterNamePrefix + 'recentLessons';
	var leechTrainingFilterName = filterNamePrefix + 'leechTraining';
	var timeUntilReviewFilterName = filterNamePrefix + 'timeUntilReview';
	var failedLastReviewName = filterNamePrefix + 'failedLastReview';
	var relatedItemsName = filterNamePrefix + 'relatedItems';

	var supportedFilters = [recentLessonsFilterName, leechTrainingFilterName, timeUntilReviewFilterName, failedLastReviewName, relatedItemsName];

	var defaultSettings = {};
	defaultSettings[recentLessonsFilterName] = true;
	defaultSettings[leechTrainingFilterName] = true;
	defaultSettings[timeUntilReviewFilterName] = true;
	defaultSettings[failedLastReviewName] = true;
	defaultSettings[relatedItemsName] = true;

	var recentLessonsHoverTip = 'Only include lessons taken in the last X hours.';
	var leechesSummaryHoverTip = 'Only include leeches. Formula: incorrect / currentStreak^1.5.';
	var leechesHoverTip = leechesSummaryHoverTip + '\n * The higher the value, the fewer items will be included as leeches.\n * Setting the value to 1 will include items that have just been answered incorrectly for the first time.\n * Setting the value to 1.01 will exclude items that have just been answered incorrectly for the first time.';

	var timeUntilReviewSummaryHoverTip = 'Only include items that have at least X% of their SRS interval remaining.';
	var timeUntilReviewHoverTip = timeUntilReviewSummaryHoverTip + '\nValid values are from 0 to 100. Examples:\n "75": At least 75% of an item\'s SRS interval must be remaining.';

	var failedLastReviewSummaryHoverTip = 'Only include items where the most recent review was failed.';
	var failedLastReviewHoverTip = failedLastReviewSummaryHoverTip + '\nOnly look at items whose most recent review was in the last X hours.';

	var relatedItemsSummaryHoverTip = 'Only include items that contain at least one of the given kanji.';
	var relatedItemsHoverTip = relatedItemsSummaryHoverTip + ' Examples:\n "金": All items containing the kanji 金.\n "金髪 -曜": All items containing the kanji 金 or 髪, but not 曜.';

	var msPerHour = 3600000;

	var nowForTimeUntilReview;
	var nowForFailedLastReview;
	var regularSrsIntervals = [0, 4, 8, 23, 47, 167, 335, 719, 2879];
	var acceleratedSrsIntervals = [0, 2, 4, 8, 23, 167, 335, 719, 2879];
	var acceleratedLevels = [1, 2];

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

	function getSrsIntervalInHours(srsStage, level) {
		var srsInvervals = acceleratedLevels.includes(level) ? acceleratedSrsIntervals : regularSrsIntervals;
		return srsInvervals[srsStage];
	}

	wkof.include('Menu, Settings');

	wkof.ready('Menu').then(installMenu);
	waitForItemDataRegistry().then(installSettings);

	function waitForItemDataRegistry() {
		return wkof.wait_state('wkof.ItemData.registry', 'ready');
	}

	function installMenu() {
		loadSettings().then(function() {
			addMenuItem();
		});
	}

	function addMenuItem() {
		wkof.Menu.insert_script_link({
			script_id: settingsScriptId,
			submenu: 'Settings',
			title: settingsTitle,
			on_click: function() { settingsDialog.open(); }
		});
	}

	function installSettings() {
		wkof.ItemData.pause_ready_event(true);

		loadSettings().then(function() {
			wkof.ItemData.pause_ready_event(false);
		});
	}

	function loadSettings(postLoadAction) {
		wkof.ready('Settings').then(function() {
			if (settingsDialog) {
				return;
			}

			var settings = {};
			settings[recentLessonsFilterName] = { type: 'checkbox', label: 'Recent Lessons', hover_tip: recentLessonsHoverTip };
			settings[leechTrainingFilterName] = { type: 'checkbox', label: 'Leech Training', hover_tip: leechesSummaryHoverTip };
			settings[timeUntilReviewFilterName] = { type: 'checkbox', label: 'Time Until Review', hover_tip: timeUntilReviewSummaryHoverTip };
			settings[failedLastReviewName] = { type: 'checkbox', label: 'Failed Last Review', hover_tip: failedLastReviewSummaryHoverTip };
			settings[relatedItemsName] = { type: 'checkbox', label: 'Related Items', hover_tip: relatedItemsSummaryHoverTip };

			settingsDialog = new wkof.Settings({
				script_id: settingsScriptId,
				title: settingsTitle,
				on_save: saveSettings,
				settings: settings
			});

			settingsDialog.load(defaultSettings).then(function() {
				updateFiltersWhenReady();
				settingsLoadedPromise.resolve();
			});
		});

		return settingsLoadedPromise;
	}

	function saveSettings(){
		settingsDialog.save().then(function() {
			updateFiltersWhenReady();
		});
	}

	function updateFiltersWhenReady() {
		needToRegisterFilters = true;
		waitForItemDataRegistry().then(registerFilters);
	}

	function registerFilters() {
		if (!needToRegisterFilters) {
			return;
		}

		supportedFilters.forEach(function(filterName) {
			delete wkof.ItemData.registry.sources.wk_items.filters[filterName];
		});

		if (wkof.settings[settingsScriptId][recentLessonsFilterName]) {
			registerRecentLessonsFilter();
		}

		if (wkof.settings[settingsScriptId][leechTrainingFilterName]) {
			registerLeechTrainingFilter();
		}

		if (wkof.settings[settingsScriptId][timeUntilReviewFilterName]) {
			registerTimeUntilReviewFilter();
		}

		if (wkof.settings[settingsScriptId][failedLastReviewName]) {
			registerFailedLastReviewFilter();
		}

		if (wkof.settings[settingsScriptId][relatedItemsName]) {
			registerRelatedItemsFilter();
		}

		needToRegisterFilters = false;
	}

	// BEGIN Recent Lessons
	function registerRecentLessonsFilter() {
		wkof.ItemData.registry.sources.wk_items.filters[recentLessonsFilterName] = {
			type: 'number',
			label: 'Recent Lessons',
			default: 24,
			placeholder: '24',
			filter_func: recentLessonsFilter,
			set_options: function(options) { options.assignments = true; },
			hover_tip: recentLessonsHoverTip
		};
	}

	function recentLessonsFilter(filterValue, item) {
		if (item.assignments === undefined) {
			return false;
		}

		var startedAt = item.assignments.started_at;
		if (startedAt === null || startedAt === undefined) {
			return false;
		}

		var startedAtDate = new Date(startedAt);
		var timeSinceStart = Date.now() - startedAtDate;

		return (timeSinceStart / msPerHour) < filterValue;
	}
	// END Recent Lessons

	// BEGIN Leeches
	function registerLeechTrainingFilter() {
		wkof.ItemData.registry.sources.wk_items.filters[leechTrainingFilterName] = {
			type: 'number',
			label: 'Leech Training',
			default: 1,
			placeholder: '1',
			filter_func: leechTrainingFilter,
			set_options: function(options) { options.review_statistics = true; },
			hover_tip: leechesHoverTip
		};
	}

	function leechTrainingFilter(filterValue, item) {
		if (item.review_statistics === undefined) {
			return false;
		}

		var reviewStats = item.review_statistics;
		var meaningScore = getLeechScore(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak);
		var readingScore = getLeechScore(reviewStats.reading_incorrect, reviewStats.reading_current_streak);

		return meaningScore >= filterValue || readingScore >= filterValue;
	}

	function getLeechScore(incorrect, currentStreak) {
		return incorrect / Math.pow((currentStreak || 0.5), 1.5);
	}
	// END Leeches

	// BEGIN Time Until Review
	function registerTimeUntilReviewFilter() {
		wkof.ItemData.registry.sources.wk_items.filters[timeUntilReviewFilterName] = {
			type: 'number',
			label: 'Time Until Review',
			default: 50,
			placeholder: '50',
			prepare: timeUntilReviewPrepare,
			filter_value_map: convertPercentageToDecimal,
			filter_func: timeUntilReviewFilter,
			set_options: function(options) { options.assignments = true; },
			hover_tip: timeUntilReviewHoverTip
		};
	}

	function timeUntilReviewPrepare() {
		// Only set "now" once so that all items use the same value when filtering.
		nowForTimeUntilReview = Date.now();
	}

	function convertPercentageToDecimal(percentage) {
		if (percentage < 0) {
			return 0;
		}

		if (percentage > 100) {
			return 1;
		}

		return percentage / 100;
	}

	function timeUntilReviewFilter(decimal, item) {
		if (item.assignments === undefined) {
			return false;
		}

		var srsStage = item.assignments.srs_stage;
		if (srsStage === 0) {
			return false;
		}

		if (srsStage === 9) {
			return true;
		}

		var level = item.assignments.level;
		var reviewAvailableAt = item.assignments.available_at;
		var srsInvervalInHours = getSrsIntervalInHours(srsStage, level);

		return isAtLeastMinimumHoursUntilReview(srsInvervalInHours, reviewAvailableAt, decimal);
	}

	function isAtLeastMinimumHoursUntilReview(srsInvervalInHours, reviewAvailableAt, decimal) {
		var hoursUntilReview = (Date.parse(reviewAvailableAt) - nowForTimeUntilReview) / msPerHour;
		var minimumHoursUntilReview = srsInvervalInHours * decimal;

		return minimumHoursUntilReview <= hoursUntilReview;
	}
	// END Time Until Review

	// BEGIN Failed Last Review
	function registerFailedLastReviewFilter() {
		wkof.ItemData.registry.sources.wk_items.filters[failedLastReviewName] = {
			type: 'number',
			label: 'Failed Last Review',
			default: 24,
			placeholder: '24',
			prepare: failedLastReviewPrepare,
			filter_func: failedLastReviewFilter,
			set_options: function(options) { options.review_statistics = true; options.assignments = true; },
			hover_tip: failedLastReviewHoverTip
		};
	}

	function failedLastReviewPrepare() {
		// Only set "now" once so that all items use the same value when filtering.
		nowForFailedLastReview = Date.now();
	}

	function failedLastReviewFilter(filterValue, item) {
		// review_statistics is undefined for new lessons.
		if (item.assignments === undefined || item.review_statistics === undefined) {
			return false;
		}

		var assignments = item.assignments;
		var srsStage = assignments.srs_stage;

		if (srsStage === 0) {
			return false;
		}

		if (srsStage === 9) {
			return false;
		}

		if (!failedLastReview(item.review_statistics)) {
			return false;
		}

		var srsInvervalInHours = getSrsIntervalInHours(srsStage, assignments.level);
		var lastReviewTimeInMs = getLastReviewTimeInMs(srsInvervalInHours, assignments.available_at);
		var hoursSinceLastReview = (nowForFailedLastReview - lastReviewTimeInMs) / msPerHour;

		return hoursSinceLastReview <= filterValue;
	}

	function failedLastReview(reviewStats) {
		return failedLastReviewOfType(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak) || failedLastReviewOfType(reviewStats.reading_incorrect, reviewStats.reading_current_streak);
	}

	function failedLastReviewOfType(totalIncorrect, currentStreak) {
		return totalIncorrect > 0 && currentStreak === 1;
	}

	function getLastReviewTimeInMs(srsInvervalInHours, reviewAvailableAt) {
		var srsIntervalInMs = srsInvervalInHours * msPerHour;

		return Date.parse(reviewAvailableAt) - srsIntervalInMs;
	}
	// END Failed Last Review

	// BEGIN Related Items
	function registerRelatedItemsFilter() {
		wkof.ItemData.registry.sources.wk_items.filters[relatedItemsName] = {
			type: 'text',
			label: 'Related Items',
			default: '',
			placeholder: '入力',
			filter_value_map: relatedItemsMap,
			filter_func: relatedItemsFilter,
			hover_tip: relatedItemsHoverTip
		};
	}

	function relatedItemsMap(kanjiString) {
		var parts = kanjiString.split(' ');

		var includeList = [];
		var excludeList = [];

		for (var i = 0; i < parts.length; i++) {
			var part = parts[i];
			if (part.startsWith('-')) {
				concat(excludeList, part.substr(1).split(''));
			}
			else {
				concat(includeList, part.split(''));
			}
		}

		return {
			include: includeList,
			exclude: excludeList
		};
	}

	function concat(array1, array2) {
		Array.prototype.push.apply(array1, array2);
	}

	function relatedItemsFilter(filterValue, item) {
		var characters = item.data.characters;
		if (characters === null || characters === undefined) {
			return false;
		}

		var itemCharacterArray = characters.split('');

		return containsAny(filterValue.include, itemCharacterArray) && !containsAny(filterValue.exclude, itemCharacterArray);
	}

	function containsAny(filterValueArray, itemCharacterArray) {
		return itemCharacterArray.some(function(itemCharacter) {
			return filterValueArray.includes(itemCharacter);
		});
	}
	// END Related Items
})(window.wkof);