WaniKani Open Framework Additional Filters

Additional filters for the WaniKani Open Framework

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==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.3
// @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.data.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, item.data.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);