WaniKani Open Framework Additional Filters

Additional filters for the WaniKani Open Framework

  1. // ==UserScript==
  2. // @name WaniKani Open Framework Additional Filters
  3. // @namespace https://www.wanikani.com
  4. // @description Additional filters for the WaniKani Open Framework
  5. // @author seanblue
  6. // @version 1.3.3
  7. // @include https://www.wanikani.com/*
  8. // @grant none
  9. // ==/UserScript==
  10.  
  11. (function(wkof) {
  12. 'use strict';
  13.  
  14. var wkofMinimumVersion = '1.0.18';
  15.  
  16. if (!wkof) {
  17. var response = confirm('WaniKani Open Framework Additional Filters requires WaniKani Open Framework.\n Click "OK" to be forwarded to installation instructions.');
  18.  
  19. if (response) {
  20. window.location.href = 'https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549';
  21. }
  22.  
  23. return;
  24. }
  25.  
  26. if (!wkof.version || wkof.version.compare_to(wkofMinimumVersion) === 'older') {
  27. alert('WaniKani Open Framework Additional Filters requires at least version ' + wkofMinimumVersion + ' of WaniKani Open Framework.');
  28. return;
  29. }
  30.  
  31. var settingsDialog;
  32. var settingsScriptId = 'additionalFilters';
  33. var settingsTitle = 'Additional Filters';
  34.  
  35. var needToRegisterFilters = true;
  36. var settingsLoadedPromise = promise();
  37.  
  38. var filterNamePrefix = 'additionalFilters_';
  39. var recentLessonsFilterName = filterNamePrefix + 'recentLessons';
  40. var leechTrainingFilterName = filterNamePrefix + 'leechTraining';
  41. var timeUntilReviewFilterName = filterNamePrefix + 'timeUntilReview';
  42. var failedLastReviewName = filterNamePrefix + 'failedLastReview';
  43. var relatedItemsName = filterNamePrefix + 'relatedItems';
  44.  
  45. var supportedFilters = [recentLessonsFilterName, leechTrainingFilterName, timeUntilReviewFilterName, failedLastReviewName, relatedItemsName];
  46.  
  47. var defaultSettings = {};
  48. defaultSettings[recentLessonsFilterName] = true;
  49. defaultSettings[leechTrainingFilterName] = true;
  50. defaultSettings[timeUntilReviewFilterName] = true;
  51. defaultSettings[failedLastReviewName] = true;
  52. defaultSettings[relatedItemsName] = true;
  53.  
  54. var recentLessonsHoverTip = 'Only include lessons taken in the last X hours.';
  55. var leechesSummaryHoverTip = 'Only include leeches. Formula: incorrect / currentStreak^1.5.';
  56. 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.';
  57.  
  58. var timeUntilReviewSummaryHoverTip = 'Only include items that have at least X% of their SRS interval remaining.';
  59. 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.';
  60.  
  61. var failedLastReviewSummaryHoverTip = 'Only include items where the most recent review was failed.';
  62. var failedLastReviewHoverTip = failedLastReviewSummaryHoverTip + '\nOnly look at items whose most recent review was in the last X hours.';
  63.  
  64. var relatedItemsSummaryHoverTip = 'Only include items that contain at least one of the given kanji.';
  65. var relatedItemsHoverTip = relatedItemsSummaryHoverTip + ' Examples:\n "金": All items containing the kanji 金.\n "金髪 -曜": All items containing the kanji 金 or 髪, but not 曜.';
  66.  
  67. var msPerHour = 3600000;
  68.  
  69. var nowForTimeUntilReview;
  70. var nowForFailedLastReview;
  71. var regularSrsIntervals = [0, 4, 8, 23, 47, 167, 335, 719, 2879];
  72. var acceleratedSrsIntervals = [0, 2, 4, 8, 23, 167, 335, 719, 2879];
  73. var acceleratedLevels = [1, 2];
  74.  
  75. function promise(){var a,b,c=new Promise(function(d,e){a=d;b=e;});c.resolve=a;c.reject=b;return c;}
  76.  
  77. function getSrsIntervalInHours(srsStage, level) {
  78. var srsInvervals = acceleratedLevels.includes(level) ? acceleratedSrsIntervals : regularSrsIntervals;
  79. return srsInvervals[srsStage];
  80. }
  81.  
  82. wkof.include('Menu, Settings');
  83.  
  84. wkof.ready('Menu').then(installMenu);
  85. waitForItemDataRegistry().then(installSettings);
  86.  
  87. function waitForItemDataRegistry() {
  88. return wkof.wait_state('wkof.ItemData.registry', 'ready');
  89. }
  90.  
  91. function installMenu() {
  92. loadSettings().then(function() {
  93. addMenuItem();
  94. });
  95. }
  96.  
  97. function addMenuItem() {
  98. wkof.Menu.insert_script_link({
  99. script_id: settingsScriptId,
  100. submenu: 'Settings',
  101. title: settingsTitle,
  102. on_click: function() { settingsDialog.open(); }
  103. });
  104. }
  105.  
  106. function installSettings() {
  107. wkof.ItemData.pause_ready_event(true);
  108.  
  109. loadSettings().then(function() {
  110. wkof.ItemData.pause_ready_event(false);
  111. });
  112. }
  113.  
  114. function loadSettings(postLoadAction) {
  115. wkof.ready('Settings').then(function() {
  116. if (settingsDialog) {
  117. return;
  118. }
  119.  
  120. var settings = {};
  121. settings[recentLessonsFilterName] = { type: 'checkbox', label: 'Recent Lessons', hover_tip: recentLessonsHoverTip };
  122. settings[leechTrainingFilterName] = { type: 'checkbox', label: 'Leech Training', hover_tip: leechesSummaryHoverTip };
  123. settings[timeUntilReviewFilterName] = { type: 'checkbox', label: 'Time Until Review', hover_tip: timeUntilReviewSummaryHoverTip };
  124. settings[failedLastReviewName] = { type: 'checkbox', label: 'Failed Last Review', hover_tip: failedLastReviewSummaryHoverTip };
  125. settings[relatedItemsName] = { type: 'checkbox', label: 'Related Items', hover_tip: relatedItemsSummaryHoverTip };
  126.  
  127. settingsDialog = new wkof.Settings({
  128. script_id: settingsScriptId,
  129. title: settingsTitle,
  130. on_save: saveSettings,
  131. settings: settings
  132. });
  133.  
  134. settingsDialog.load(defaultSettings).then(function() {
  135. updateFiltersWhenReady();
  136. settingsLoadedPromise.resolve();
  137. });
  138. });
  139.  
  140. return settingsLoadedPromise;
  141. }
  142.  
  143. function saveSettings(){
  144. settingsDialog.save().then(function() {
  145. updateFiltersWhenReady();
  146. });
  147. }
  148.  
  149. function updateFiltersWhenReady() {
  150. needToRegisterFilters = true;
  151. waitForItemDataRegistry().then(registerFilters);
  152. }
  153.  
  154. function registerFilters() {
  155. if (!needToRegisterFilters) {
  156. return;
  157. }
  158.  
  159. supportedFilters.forEach(function(filterName) {
  160. delete wkof.ItemData.registry.sources.wk_items.filters[filterName];
  161. });
  162.  
  163. if (wkof.settings[settingsScriptId][recentLessonsFilterName]) {
  164. registerRecentLessonsFilter();
  165. }
  166.  
  167. if (wkof.settings[settingsScriptId][leechTrainingFilterName]) {
  168. registerLeechTrainingFilter();
  169. }
  170.  
  171. if (wkof.settings[settingsScriptId][timeUntilReviewFilterName]) {
  172. registerTimeUntilReviewFilter();
  173. }
  174.  
  175. if (wkof.settings[settingsScriptId][failedLastReviewName]) {
  176. registerFailedLastReviewFilter();
  177. }
  178.  
  179. if (wkof.settings[settingsScriptId][relatedItemsName]) {
  180. registerRelatedItemsFilter();
  181. }
  182.  
  183. needToRegisterFilters = false;
  184. }
  185.  
  186. // BEGIN Recent Lessons
  187. function registerRecentLessonsFilter() {
  188. wkof.ItemData.registry.sources.wk_items.filters[recentLessonsFilterName] = {
  189. type: 'number',
  190. label: 'Recent Lessons',
  191. default: 24,
  192. placeholder: '24',
  193. filter_func: recentLessonsFilter,
  194. set_options: function(options) { options.assignments = true; },
  195. hover_tip: recentLessonsHoverTip
  196. };
  197. }
  198.  
  199. function recentLessonsFilter(filterValue, item) {
  200. if (item.assignments === undefined) {
  201. return false;
  202. }
  203.  
  204. var startedAt = item.assignments.started_at;
  205. if (startedAt === null || startedAt === undefined) {
  206. return false;
  207. }
  208.  
  209. var startedAtDate = new Date(startedAt);
  210. var timeSinceStart = Date.now() - startedAtDate;
  211.  
  212. return (timeSinceStart / msPerHour) < filterValue;
  213. }
  214. // END Recent Lessons
  215.  
  216. // BEGIN Leeches
  217. function registerLeechTrainingFilter() {
  218. wkof.ItemData.registry.sources.wk_items.filters[leechTrainingFilterName] = {
  219. type: 'number',
  220. label: 'Leech Training',
  221. default: 1,
  222. placeholder: '1',
  223. filter_func: leechTrainingFilter,
  224. set_options: function(options) { options.review_statistics = true; },
  225. hover_tip: leechesHoverTip
  226. };
  227. }
  228.  
  229. function leechTrainingFilter(filterValue, item) {
  230. if (item.review_statistics === undefined) {
  231. return false;
  232. }
  233.  
  234. var reviewStats = item.review_statistics;
  235. var meaningScore = getLeechScore(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak);
  236. var readingScore = getLeechScore(reviewStats.reading_incorrect, reviewStats.reading_current_streak);
  237.  
  238. return meaningScore >= filterValue || readingScore >= filterValue;
  239. }
  240.  
  241. function getLeechScore(incorrect, currentStreak) {
  242. return incorrect / Math.pow((currentStreak || 0.5), 1.5);
  243. }
  244. // END Leeches
  245.  
  246. // BEGIN Time Until Review
  247. function registerTimeUntilReviewFilter() {
  248. wkof.ItemData.registry.sources.wk_items.filters[timeUntilReviewFilterName] = {
  249. type: 'number',
  250. label: 'Time Until Review',
  251. default: 50,
  252. placeholder: '50',
  253. prepare: timeUntilReviewPrepare,
  254. filter_value_map: convertPercentageToDecimal,
  255. filter_func: timeUntilReviewFilter,
  256. set_options: function(options) { options.assignments = true; },
  257. hover_tip: timeUntilReviewHoverTip
  258. };
  259. }
  260.  
  261. function timeUntilReviewPrepare() {
  262. // Only set "now" once so that all items use the same value when filtering.
  263. nowForTimeUntilReview = Date.now();
  264. }
  265.  
  266. function convertPercentageToDecimal(percentage) {
  267. if (percentage < 0) {
  268. return 0;
  269. }
  270.  
  271. if (percentage > 100) {
  272. return 1;
  273. }
  274.  
  275. return percentage / 100;
  276. }
  277.  
  278. function timeUntilReviewFilter(decimal, item) {
  279. if (item.assignments === undefined) {
  280. return false;
  281. }
  282.  
  283. var srsStage = item.assignments.srs_stage;
  284. if (srsStage === 0) {
  285. return false;
  286. }
  287.  
  288. if (srsStage === 9) {
  289. return true;
  290. }
  291.  
  292. var level = item.data.level;
  293. var reviewAvailableAt = item.assignments.available_at;
  294. var srsInvervalInHours = getSrsIntervalInHours(srsStage, level);
  295.  
  296. return isAtLeastMinimumHoursUntilReview(srsInvervalInHours, reviewAvailableAt, decimal);
  297. }
  298.  
  299. function isAtLeastMinimumHoursUntilReview(srsInvervalInHours, reviewAvailableAt, decimal) {
  300. var hoursUntilReview = (Date.parse(reviewAvailableAt) - nowForTimeUntilReview) / msPerHour;
  301. var minimumHoursUntilReview = srsInvervalInHours * decimal;
  302.  
  303. return minimumHoursUntilReview <= hoursUntilReview;
  304. }
  305. // END Time Until Review
  306.  
  307. // BEGIN Failed Last Review
  308. function registerFailedLastReviewFilter() {
  309. wkof.ItemData.registry.sources.wk_items.filters[failedLastReviewName] = {
  310. type: 'number',
  311. label: 'Failed Last Review',
  312. default: 24,
  313. placeholder: '24',
  314. prepare: failedLastReviewPrepare,
  315. filter_func: failedLastReviewFilter,
  316. set_options: function(options) { options.review_statistics = true; options.assignments = true; },
  317. hover_tip: failedLastReviewHoverTip
  318. };
  319. }
  320.  
  321. function failedLastReviewPrepare() {
  322. // Only set "now" once so that all items use the same value when filtering.
  323. nowForFailedLastReview = Date.now();
  324. }
  325.  
  326. function failedLastReviewFilter(filterValue, item) {
  327. // review_statistics is undefined for new lessons.
  328. if (item.assignments === undefined || item.review_statistics === undefined) {
  329. return false;
  330. }
  331.  
  332. var assignments = item.assignments;
  333. var srsStage = assignments.srs_stage;
  334.  
  335. if (srsStage === 0) {
  336. return false;
  337. }
  338.  
  339. if (srsStage === 9) {
  340. return false;
  341. }
  342.  
  343. if (!failedLastReview(item.review_statistics)) {
  344. return false;
  345. }
  346.  
  347. var srsInvervalInHours = getSrsIntervalInHours(srsStage, item.data.level);
  348. var lastReviewTimeInMs = getLastReviewTimeInMs(srsInvervalInHours, assignments.available_at);
  349. var hoursSinceLastReview = (nowForFailedLastReview - lastReviewTimeInMs) / msPerHour;
  350.  
  351. return hoursSinceLastReview <= filterValue;
  352. }
  353.  
  354. function failedLastReview(reviewStats) {
  355. return failedLastReviewOfType(reviewStats.meaning_incorrect, reviewStats.meaning_current_streak) || failedLastReviewOfType(reviewStats.reading_incorrect, reviewStats.reading_current_streak);
  356. }
  357.  
  358. function failedLastReviewOfType(totalIncorrect, currentStreak) {
  359. return totalIncorrect > 0 && currentStreak === 1;
  360. }
  361.  
  362. function getLastReviewTimeInMs(srsInvervalInHours, reviewAvailableAt) {
  363. var srsIntervalInMs = srsInvervalInHours * msPerHour;
  364.  
  365. return Date.parse(reviewAvailableAt) - srsIntervalInMs;
  366. }
  367. // END Failed Last Review
  368.  
  369. // BEGIN Related Items
  370. function registerRelatedItemsFilter() {
  371. wkof.ItemData.registry.sources.wk_items.filters[relatedItemsName] = {
  372. type: 'text',
  373. label: 'Related Items',
  374. default: '',
  375. placeholder: '入力',
  376. filter_value_map: relatedItemsMap,
  377. filter_func: relatedItemsFilter,
  378. hover_tip: relatedItemsHoverTip
  379. };
  380. }
  381.  
  382. function relatedItemsMap(kanjiString) {
  383. var parts = kanjiString.split(' ');
  384.  
  385. var includeList = [];
  386. var excludeList = [];
  387.  
  388. for (var i = 0; i < parts.length; i++) {
  389. var part = parts[i];
  390. if (part.startsWith('-')) {
  391. concat(excludeList, part.substr(1).split(''));
  392. }
  393. else {
  394. concat(includeList, part.split(''));
  395. }
  396. }
  397.  
  398. return {
  399. include: includeList,
  400. exclude: excludeList
  401. };
  402. }
  403.  
  404. function concat(array1, array2) {
  405. Array.prototype.push.apply(array1, array2);
  406. }
  407.  
  408. function relatedItemsFilter(filterValue, item) {
  409. var characters = item.data.characters;
  410. if (characters === null || characters === undefined) {
  411. return false;
  412. }
  413.  
  414. var itemCharacterArray = characters.split('');
  415.  
  416. return containsAny(filterValue.include, itemCharacterArray) && !containsAny(filterValue.exclude, itemCharacterArray);
  417. }
  418.  
  419. function containsAny(filterValueArray, itemCharacterArray) {
  420. return itemCharacterArray.some(function(itemCharacter) {
  421. return filterValueArray.includes(itemCharacter);
  422. });
  423. }
  424. // END Related Items
  425. })(window.wkof);