// ==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);