WaniKani Lesson Filter

Filter your lessons by type, while maintaining WaniKani's lesson order.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name          WaniKani Lesson Filter
// @namespace     https://www.wanikani.com
// @description   Filter your lessons by type, while maintaining WaniKani's lesson order.
// @author        seanblue
// @version       2.1.4
// @match         https://www.wanikani.com/subjects*
// @match         https://preview.wanikani.com/subjects*
// @grant         none
// ==/UserScript==

(async function(global) {
	'use strict';

	var wkofMinimumVersion = '1.1.0';

	if (!global.wkof) {
		var response = confirm('WaniKani Lesson Filter 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 (!global.wkof.version || global.wkof.version.compare_to(wkofMinimumVersion) === 'older') {
		alert(`WaniKani Lesson Filter requires at least version ${wkofMinimumVersion} of WaniKani Open Framework.`);
		return;
	}

	const localStorageSettingsKey = 'lessonFilter_inputData';
	const localStorageSettingsVersion = 2;

	const radicalSubjectType = 'radical';
	const kanjiSubjectType = 'kanji';
	const vocabSubjectType = 'vocabulary';
	const kanaVocabSubjectType = 'kana_vocabulary';

	const batchSizeInputSelector = '#lf-batch-size';
	const radicalInputSelector = '#lf-radical';
	const kanjiInputSelector = '#lf-kanji';
	const vocabInputSelector = '#lf-vocab';

	const radicalCountSelector = '.subject-statistic-counts__item-count-text[data-category="Radical"]';
	const kanjiCountSelector = '.subject-statistic-counts__item-count-text[data-category="Kanji"]';
	const vocabCountSelector = '.subject-statistic-counts__item-count-text[data-category="Vocabulary"]';

	const pages = {
		lessonPage: 'lesson',
		quizPage: 'quiz',
		other: 'other'
	};

	const style =
		`<style>
			#lf-main { width: 100%; margin: 10px auto; padding: 10px 20px; border-radius: 6px; text-align: center; background-color: #444; color: #fff; }

			.lf-title { font-size: 1.6em; font-weight: bold; padding-bottom: 5px; }

			.lf-list { margin: 0px; padding: 0px; }
			.lf-list-item { display: inline-block; list-style: none; text-align: center; padding: 8px; }
			.lf-list-item input { display: block; width: 45px; color: #fff; border-width: 2px; border-style: inset; }
			.lf-list-item span { display: block; padding-bottom: 3px; }
			#lf-batch-size { background-color: #ff5500; }
			#lf-radical { background-color: #0af; }
			#lf-kanji { background-color: #f0a; }
			#lf-vocab { background-color: #a0f; }

			.lf-filter-section { padding-top: 10px; }
			.lf-filter-section input { font-size: 0.9em; margin: 0px 10px; padding: 3px; border-width: 2px; border-style: outset; border-radius: 6px; }
		</style>`;

	const html =
		`<div id="lf-main">
			<div class="lf-title">Items to Learn</div>
			<div class="lf-list">
				<div class="lf-list-item">
					<span lang="ja">バッチ</span>
					<input id="lf-batch-size" type="text" autocomplete="off" data-lpignore="true" maxlength="4" />
				</div>
				<div class="lf-list-item">
					<span lang="ja">部首</span>
					<input id="lf-radical" type="text" autocomplete="off" data-lpignore="true" maxlength="4" />
				</div>
				<div class="lf-list-item">
					<span lang="ja">漢字</span>
					<input id="lf-kanji" type="text" autocomplete="off" data-lpignore="true" maxlength="4" />
				</div>
				<div class="lf-list-item">
					<span lang="ja">単語</span>
					<input id="lf-vocab" type="text" autocomplete="off" data-lpignore="true" maxlength="4" />
				</div>
			</div>
			<div class="lf-filter-section">
				<input type="button" value="Filter" id="lf-apply-filter"></input>
				<input type="button" value="Shuffle" id="lf-apply-shuffle"></input>
			</div>
		</div>`;

	let queueInitializedPromise;

	let initialLessonQueue;
	let initialBatchSize;

	let currentLessonQueue;
	let currentBatchSize;

	async function initialize() {
		queueInitializedPromise = initializeLessonQueue();
		await queueInitializedPromise;
	}

	async function initializeLessonQueue() {
		global.wkof.include('Apiv2');
		await global.wkof.ready('Apiv2');

		let [ unsortedLessonQueue, userPreferences ] = await Promise.all([getUnsortedLessonQueue(), getUserPreferences()]);

		initialBatchSize = userPreferences.batchSize;
		initialLessonQueue = sortInitialLessonQueue(unsortedLessonQueue, userPreferences.lessonOrder);

		currentLessonQueue = [...initialLessonQueue];
		currentBatchSize = initialBatchSize;

		return Promise.resolve('done');
	}

	async function getUnsortedLessonQueue() {
		let summary = await global.wkof.Apiv2.fetch_endpoint('summary');
		let lessonIds = summary.data.lessons.flatMap(l => l.subject_ids);

		let lessonData = await global.wkof.Apiv2.fetch_endpoint('subjects', { filters: { ids: lessonIds } });

		return lessonData.data.map(d => ({ id: d.id, level: d.data.level, subjectType: d.object, lessonPosition: d.data.lesson_position }));
	}

	async function getUserPreferences() {
		let response = await global.wkof.Apiv2.fetch_endpoint('user');
		return {
			batchSize: response.data.preferences.lessons_batch_size,
			lessonOrder: response.data.preferences.lessons_presentation_order
		};
	}

	function sortInitialLessonQueue(queue, lessonOrder) {
		let typeOrder = {
			[radicalSubjectType]: 0,
			[kanjiSubjectType]: 1,
			[vocabSubjectType]: 2,
			[kanaVocabSubjectType]: 2
		};

		if (lessonOrder === 'ascending_level_then_subject') {
			return queue.sort((a, b) => a.level - b.level || typeOrder[a.subjectType] - typeOrder[b.subjectType] || a.lessonPosition - b.lessonPosition);
		}

		shuffle(queue);

		if (lessonOrder === 'ascending_level_then_shuffled') {
			queue.sort((a, b) => a.level - b.level);
		}

		return queue;
	}

	function setupStyles(head) {
		head.insertAdjacentHTML('beforeend', style);
	}

	function setupUI(body) {
		let page = getPage(window.location);
		if (page === pages.lessonPage || page === pages.quizPage) {
			updateItemCountsInUI(body);
		}

		if (page !== pages.lessonPage) {
			return;
		}

		let existingLessonFilterSection = body.querySelector('#lf-main');
		if (existingLessonFilterSection) {
			return;
		}

		let queueItemsSection = body.querySelector('.subject-queue__items');
		if (!queueItemsSection) {
			return;
		}

		queueItemsSection.insertAdjacentHTML('beforeend', html);

		loadSavedInputData(body);
		setupEvents(body);
	}

	function getPage(location) {
		if ((/(\/?)subjects(\/\d+)\/lesson(\/?)/.test(location.pathname))) {
			return pages.lessonPage;
		}

		if ((/(\/?)subjects\/lesson\/quiz(\/?)/.test(location.pathname))) {
			return pages.quizPage;
		}

		return pages.other;
	}

	function loadSavedInputData(body) {
		let savedDataString = localStorage[localStorageSettingsKey];

		if (!savedDataString) {
			return;
		}

		let savedData = JSON.parse(savedDataString);

		if (savedData.version !== localStorageSettingsVersion) {
			delete localStorage[localStorageSettingsKey];
			return;
		}

		let data = savedData.data;
		body.querySelector(batchSizeInputSelector).value = data.batchSize;
		body.querySelector(radicalInputSelector).value = data.radicals;
		body.querySelector(kanjiInputSelector).value = data.kanji;
		body.querySelector(vocabInputSelector).value = data.vocab;
	}

	function updateItemCountsInUI(body) {
		var lessonQueueByType = getLessonQueueByType(currentLessonQueue);

		updateItemCountInUI(body, radicalCountSelector, lessonQueueByType[radicalSubjectType]);
		updateItemCountInUI(body, kanjiCountSelector, lessonQueueByType[kanjiSubjectType]);
		updateItemCountInUI(body, vocabCountSelector, lessonQueueByType[vocabSubjectType]);
	}

	function updateItemCountInUI(body, selector, queueForType) {
		let lessonQueueByType = getLessonQueueByType(currentLessonQueue);

		let el = body.querySelector(selector);
		if (el) {
			el.innerText = queueForType.length;
		}
	}

	function setupEvents(body) {
		body.querySelector('#lf-apply-filter').addEventListener('click', applyFilter);
		body.querySelector('#lf-apply-shuffle').addEventListener('click', applyShuffle);
	}

	async function applyFilter(e) {
		let rawFilterValues = getRawFilterValuesFromUI(document);
		let filtered = await filterLessonsInternal(rawFilterValues);

		if (filtered) {
			saveRawFilterValues(rawFilterValues);
		}
	}

	async function filterLessonsInternal(rawFilterValues) {
		await queueInitializedPromise;

		let newBatchSize = getCheckedBatchSize(rawFilterValues.batchSize);

		if (newBatchSize === null || newBatchSize < 1) {
			alert('Batch size must be a positive number');
			return false;
		}

		let newFilteredQueue = getFilteredQueue(rawFilterValues);

		if (newFilteredQueue.length === 0) {
			alert('Cannot remove all lessons');
			return false;
		}

		currentLessonQueue = newFilteredQueue;
		currentBatchSize = newBatchSize;

		visitUrlForCurrentBatch();

		return true;
	}
	function getRawFilterValuesFromUI(body) {
		return {
			'batchSize': body.querySelector(batchSizeInputSelector).value.trim(),
			'radicals': body.querySelector(radicalInputSelector).value.trim(),
			'kanji': body.querySelector(kanjiInputSelector).value.trim(),
			'vocab': body.querySelector(vocabInputSelector).value.trim()
		};
	}

	function getFilteredQueue(rawFilterValues) {
		let idToIndex = { };

		for (let i = 0; i < initialLessonQueue.length; i++) {
			idToIndex[initialLessonQueue[i].id] = i;
		}

		var lessonQueueByType = getLessonQueueByType(initialLessonQueue);

		let filteredRadicalQueue = getFilteredQueueForType(lessonQueueByType[radicalSubjectType], rawFilterValues.radicals);
		let filteredKanjiQueue = getFilteredQueueForType(lessonQueueByType[kanjiSubjectType], rawFilterValues.kanji);
		let filteredVocabQueue = getFilteredQueueForType(lessonQueueByType[vocabSubjectType], rawFilterValues.vocab);

		return filteredRadicalQueue.concat(filteredKanjiQueue).concat(filteredVocabQueue).sort((a, b) => idToIndex[a.id] - idToIndex[b.id]);
	}

	function getLessonQueueByType(lessonQueue) {
		let vocabSubjectTypeGroup = localStorage.lessonFilter_skipKanaVocab ? [vocabSubjectType] : [vocabSubjectType, kanaVocabSubjectType];

		return {
			[radicalSubjectType]: getQueueForType(lessonQueue, [radicalSubjectType]),
			[kanjiSubjectType]: getQueueForType(lessonQueue, [kanjiSubjectType]),
			[vocabSubjectType]: getQueueForType(lessonQueue, vocabSubjectTypeGroup)
		};
	}

	function getQueueForType(lessonQueue, subjectTypes) {
		return lessonQueue.filter(item => subjectTypes.includes(item.subjectType));
	}

	function getFilteredQueueForType(queueForType, rawFilterValue) {
		let filterValue = parseInt(rawFilterValue);

		if (filterValue <= 0) {
			return [];
		}

		if (isNaN(filterValue)) {
			return queueForType;
		}

		return queueForType.slice(0, filterValue);
	}

	function getCheckedBatchSize(rawValue) {
		if (rawValue === '') {
			return initialBatchSize;
		}

		let value = parseInt(rawValue);

		if (isNaN(value)) {
			return null;
		}

		return value;
	}

	function applyShuffle(e) {
		shuffleLessonsInternal();
	}

	async function shuffleLessonsInternal() {
		await queueInitializedPromise;

		shuffle(currentLessonQueue);
		visitUrlForCurrentBatch();
	}

	function shuffle(array) {
		// https://stackoverflow.com/a/12646864
		// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
		for (let i = array.length - 1; i > 0; i--) {
			let j = Math.floor(Math.random() * (i + 1));
			let temp = array[i];
			array[i] = array[j];
			array[j] = temp;
		}
	}

	function visitUrlForCurrentBatch() {
		if (currentLessonQueue.length === 0) {
			global.Turbo.visit(`/dashboard`);
		}

		let lessonBatchQueryParam = getCurrentLessonBatchIds().join('-');
		global.Turbo.visit(`/subjects/${currentLessonQueue[0].id}/lesson?queue=${lessonBatchQueryParam}`);
	}

	function getCurrentLessonBatchIds() {
		return currentLessonQueue.slice(0, currentBatchSize).map(item => item.id);
	}

	function saveRawFilterValues(rawFilterValues) {
		let settings = {
			'version': localStorageSettingsVersion,
			'data': rawFilterValues
		};

		localStorage[localStorageSettingsKey] = JSON.stringify(settings);
	}

	function isNewBatchUrl(url) {
		return new URL(url).pathname === '/subjects/lesson';
	}

	function setsAreEqual(set1, set2) {
		return set1.size === set2.size && [...set1].every(v => set2.has(v));
	}

	window.addEventListener('turbo:before-visit', function(e) {
		if (isNewBatchUrl(e.detail.url)) {
			e.preventDefault();

			let currentLessonBatchIdSet = new Set(getCurrentLessonBatchIds());

			initialLessonQueue = initialLessonQueue.filter(item => !currentLessonBatchIdSet.has(item.id));
			currentLessonQueue = currentLessonQueue.filter(item => !currentLessonBatchIdSet.has(item.id));

			visitUrlForCurrentBatch();
		}
	});

	window.addEventListener('turbo:before-render', function(e) {
		e.preventDefault();
		setupUI(e.detail.newBody);
		e.detail.resume();
	});

	window.lessonFilter = Object.freeze({
		shuffle: () => {
			shuffleLessonsInternal()
		},
		filter: (radicalCount, kanjiCount, vocabCount, batchSize) => {
			let rawFilterValues = {
				'radicals': radicalCount,
				'kanji': kanjiCount,
				'vocab': vocabCount,
				'batchSize': batchSize
			};

			filterLessonsInternal(rawFilterValues);
		}
	});

	await initialize();
	setupStyles(document.head);
	setupUI(document.body);
})(window);