WK Custom Review Question (KunOn+)

Changes the text of the Review or Lesson Quiz question. Specifies ON or KUN reading for Kanji. Options to have the text in English or Japanese.

// ==UserScript==
// @name        WK Custom Review Question (KunOn+)
// @namespace   WK_KunOn
// @description Changes the text of the Review or Lesson Quiz question. Specifies ON or KUN reading for Kanji. Options to have the text in English or Japanese.
// @author      polv
// @match       *://www.wanikani.com/*
// @match       *://preview.wanikani.com/*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
// @homepage    https://community.wanikani.com/t/userscript-wk-custom-review-question-kunon-2023-version/61449
// @source      https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/kun-on.user.js
// @version     1.2.0
// @license     MIT
// @grant       none
// ==/UserScript==

// @ts-check
/// <reference path="./types/wkof.d.ts" />
(function () {
  'use strict';

  /**
   * Language options
   * English "en", Japanese "ja"
   * @type {string}
   */
  const defaultLang = 'en';

  /**
   * Whether to hide subjectType string, and rely on color instead.
   */
  const default_has_subjectType = true;

  //
  // Edit replacement string to your heart's content.
  //

  // subjectType HTML
  const enKanji = 'Kanji';
  const enRadical = 'Radical';
  const enVocab = 'Vocabulary';

  // questionType HTML
  const enMeaning = 'Meaning';
  const enName = 'Name';

  const enReading = 'Reading';

  const enOn = `ON'YOMI`;
  const enKun = `kun'yo<span style="color: white">mi</span>`;
  const enNanori = 'Nanori (^o^)';

  const jaKanji = '漢字:';
  const jaRadical = '部首:';
  const jaVocab = '単語:';

  const jaMeaning = '意味';
  const jaName = '名前';

  const jaReading = '読み方';

  const jaOn = '音読み';
  const jaKun = 'くんよ<span style="color: white">み</span>';
  const jaNanori = '名乗り (^o^)';

  /**
   * Whether to use wkof Settings dialog and ignore in-script settings
   */
  const USE_WKOF = true;

  const defaultCSS = cleanCSS(/* css */ `
  [data-kunon-subject-type="Kanji"][data-kunon-question-type] [data-kunon-reading-type="onyomi"] {
    color: yellow;
  }

  [data-kunon-subject-type="Kanji"][data-kunon-question-type] [data-kunon-reading-type="kunyomi"] {
    color: cyan;
  }

  [data-kunon-subject-type="Kanji"][data-kunon-question-type] [data-kunon-reading-type="nanori"] {
    color: bisque;
  }
  `);

  // CONSTANTS
  const SCRIPT_ID = 'WK_KunOn';
  const SEL_category = '[data-quiz-input-target="category"]';
  const SEL_questionType = '[data-quiz-input-target="questionType"]';

  let cfg = USE_WKOF ? /** @type {ScriptSettings} */ ({}) : newScriptSettings();

  /** @type {HTMLStyleElement} */
  let elStyle;
  setScriptCSS();

  /**
   *
   * @typedef {{
   *   Language: string
   *   HideSubjectType: boolean
   *   Radical: string
   *   Kanji: string
   *   Vocabulary: string
   *   Meaning: string
   *   Reading: string
   *   RadicalName: string
   *   Kunyomi: string
   *   Onyomi: string
   *   Nanori: string
   *   CSS: string
   * }} ScriptSettings
   */

  function newScriptSettings(o = /** @type {ScriptSettings} */ ({})) {
    o.Language = o.Language || defaultLang;
    o.HideSubjectType =
      typeof o.HideSubjectType !== 'undefined'
        ? o.HideSubjectType
        : !default_has_subjectType;
    o.CSS = typeof o.CSS !== 'undefined' ? o.CSS : defaultCSS;

    o.Radical = o.Language === 'ja' ? jaRadical : enRadical;
    o.Kanji = o.Language === 'ja' ? jaKanji : enKanji;
    o.Vocabulary = o.Language === 'ja' ? jaVocab : enVocab;

    o.Meaning = o.Language === 'ja' ? jaMeaning : enMeaning;
    o.Reading = o.Language === 'ja' ? jaReading : enReading;
    o.RadicalName = o.Language === 'ja' ? jaName : enName;

    o.Kunyomi = o.Language === 'ja' ? jaKun : enKun;
    o.Onyomi = o.Language === 'ja' ? jaOn : enOn;
    o.Nanori = o.Language === 'ja' ? jaNanori : enNanori;

    return o;
  }

  // @ts-ignore
  if (USE_WKOF && typeof wkof !== 'undefined') {
    /**
     * @typedef {import('./types/wkof').Core.Module
     * & import('./types/wkof').Menu
     * & import('./types/wkof').Settings
     * } TWKOF
     */

    // @ts-ignore
    const WKOF = /** @type {TWKOF} */ (wkof);
    WKOF.include('Menu,Settings');
    WKOF.ready('Menu,Settings')
      .then(() => {
        WKOF.Menu.insert_script_link({
          name: 'kunon',
          title: 'Custom Review Questions (KunOn+)',
          submenu: 'Settings',
          on_click: openSettings,
        });
      })
      .then(() => {
        return WKOF.Settings.load(SCRIPT_ID, cfg);
      })
      .then(() => {
        cfg = /** @type {any} */ (WKOF.settings[SCRIPT_ID]);
        updateSettings();
      });

    function updateSettings() {
      setScriptCSS();
      setSubjectType();
      setQuestionType();
    }

    function openSettings() {
      /** @type {JQuery<HTMLElement>} */
      let $textarea;

      const dialog = new WKOF.Settings({
        script_id: SCRIPT_ID,
        title: 'Custom Review Question (KunOn+) Settings',
        pre_open: ($) => {
          $textarea = $.find('textarea[name="CSS_textarea"]').val(
            typeof cfg.CSS !== 'undefined' ? cfg.CSS : defaultCSS,
          );
        },
        on_save: () => {
          cfg.CSS = /** @type {string} */ ($textarea.val());
          updateSettings();
        },
        content: {
          tabset: {
            type: 'tabset',
            content: {
              fieldLabels: {
                type: 'page',
                label: 'Field Names',
                content: {
                  Language: {
                    type: 'dropdown',
                    label: 'Language',
                    default: 'en',
                    content: {
                      en: 'English',
                      ja: 'Japanese',
                    },
                    on_change: function (k, v) {
                      WKOF.settings[SCRIPT_ID][k] = v;
                      newScriptSettings(
                        /** @type {any} */ (WKOF.settings[SCRIPT_ID]),
                      );
                      updateSettings();
                      dialog.refresh();
                    },
                  },
                  HideSubjectType: {
                    type: 'checkbox',
                    label: 'Hide Subject Type',
                    hover_tip: `Don't show whether it is Radical, Kanji or Vocabulary`,
                    default: !default_has_subjectType,
                  },
                  divider: {
                    type: 'divider',
                  },
                  Subject: {
                    type: 'group',
                    label: 'Subject Label',
                    content: {
                      Radical: {
                        type: 'input',
                        label: 'Radical',
                        default: enRadical,
                      },
                      Kanji: {
                        type: 'input',
                        label: 'Kanji',
                        default: enKanji,
                      },
                      Vocabulary: {
                        type: 'input',
                        label: 'Vocabulary',
                        default: enVocab,
                      },
                    },
                  },
                  QuestionType: {
                    type: 'group',
                    label: 'Question Type',
                    content: {
                      Meaning: {
                        type: 'input',
                        label: 'Meaning',
                        default: enMeaning,
                      },
                      Reading: {
                        type: 'input',
                        label: 'Reading',
                        default: enReading,
                      },
                      RadicalName: {
                        type: 'input',
                        label: 'Radical name',
                        default: enName,
                      },
                      KanjiReading: {
                        type: 'group',
                        label: 'Kanji Reading',
                        content: {
                          Kunyomi: {
                            type: 'input',
                            label: `Kun'yomi`,
                            default: enKun,
                          },
                          Onyomi: {
                            type: 'input',
                            label: `On'yomi`,
                            default: enOn,
                          },
                          Nanori: {
                            type: 'input',
                            label: 'Nanori',
                            default: enName,
                          },
                        },
                      },
                    },
                  },
                  resetLabels: {
                    type: 'button',
                    label: 'Reset Labels',
                    text: 'Reset',
                    on_click: () => {
                      newScriptSettings(
                        /** @type {any} */ (WKOF.settings[SCRIPT_ID]),
                      );
                      updateSettings();
                      dialog.refresh();
                    },
                  },
                },
              },
              cssLabel: {
                type: 'page',
                label: 'CSS',
                content: {
                  cssHTML: {
                    type: 'html',
                    html: `<textarea id="${SCRIPT_ID}_CSS_textrea" name="CSS_textarea" class="settings" style="font-family: monospace"
                    autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>`,
                  },
                  CSS: {
                    type: 'input',
                    subtype: 'hidden',
                    default: defaultCSS,
                  },
                  resetCSS: {
                    type: 'button',
                    label: 'Reset CSS',
                    text: 'Reset',
                    on_click: () => {
                      WKOF.settings[SCRIPT_ID].CSS = defaultCSS;
                      updateSettings();
                      $textarea.val(defaultCSS);
                    },
                  },
                },
              },
            },
          },
        },
      });

      dialog.open();
    }
  }

  /**
   * @type {{
   *  questionType: 'reading' | 'meaning',
   *  subject: {
   *    type: 'Radical' | 'Kanji' | 'Vocabulary',
   *    primary_reading_type?: 'onyomi' | 'kunyomi' | 'nanori'
   *  }
   * } | null}
   */
  let itemDetail = null;

  function setSubjectType() {
    if (!itemDetail) return;

    const el_category = document.querySelector(SEL_category);
    if (!el_category) return;

    el_category.setAttribute('lang', cfg.Language);
    el_category.innerHTML = `<span data-kunon-subject-type="${
      itemDetail.subject.type
    }">${
      cfg.HideSubjectType
        ? ''
        : (() => {
            switch (itemDetail.subject.type) {
              case 'Radical':
                return cfg.Radical;
              case 'Kanji':
                return cfg.Kanji;
            }
            return cfg.Vocabulary;
          })()
    }</span>`;
  }

  function setQuestionType() {
    if (!itemDetail) return;

    const el_questionType = document.querySelector(SEL_questionType);
    if (!el_questionType) return;

    const {
      questionType,
      subject: { type, primary_reading_type },
    } = itemDetail;

    el_questionType.setAttribute('lang', cfg.Language);
    el_questionType.innerHTML = `<span data-kunon-subject-type="${type}" data-kunon-question-type="${questionType}">${(() => {
      if (questionType === 'reading') {
        if (primary_reading_type) {
          return `<span data-kunon-reading-type="${primary_reading_type}">${(() => {
            switch (primary_reading_type) {
              case 'onyomi':
                return cfg.Onyomi;
              case 'kunyomi':
                return cfg.Kunyomi;
              case 'nanori':
                return cfg.Nanori;
            }
            return cfg.Reading;
          })()}</span>`;
        }
        return cfg.Reading;
      }

      if (type === 'Radical') return cfg.RadicalName;

      return cfg.Meaning;
    })()}</span>`;
  }

  window.addEventListener('willShowNextQuestion', (e) => {
    if ('detail' in e) {
      itemDetail = /** @type {any} */ (e.detail);

      const el_category = document.querySelector(SEL_category);
      const el_questionType = document.querySelector(SEL_questionType);

      if (el_category && el_questionType) {
        const obs_category = new MutationObserver(() => {
          setSubjectType();
          obs_category.disconnect();
        });
        obs_category.observe(el_category, { childList: true });

        const obs_questionType = new MutationObserver(() => {
          setQuestionType();
          obs_questionType.disconnect();
        });
        obs_questionType.observe(el_questionType, { childList: true });
      }
    }
  });

  window.addEventListener('didAnswerQuestion ', () => {
    setTimeout(() => {
      if (
        location.pathname.includes('lesson') &&
        !location.pathname.includes('quiz')
      ) {
        itemDetail = null;
      }
    }, 100);
  });

  /**
   *
   * @param {string} css
   * @returns
   */
  function cleanCSS(css) {
    const indents = [];
    const lines = css
      .trim()
      .split('\n')
      .map((ln) => {
        const m = /^ +[^ ]/.exec(ln);
        if (m) {
          const idx = m[0].length - 1;
          indents.push(idx);
        }
        return ln;
      });
    if (!indents.length) return css;
    const re = new RegExp(`^ {0,${Math.min(...indents)}}`);
    return lines.map((ln) => ln.replace(re, '')).join('\n');
  }

  function setScriptCSS() {
    const css = /* css */ `
    [data-kunon-subject-type] {
      text-transform: unset;
    }

    [data-kunon-question-type] {
      text-transform: unset;
      ${cfg.Language === 'ja' ? 'font-weight: normal;' : ''}
    }

    #wkofs_${SCRIPT_ID} textarea {
      width: 100%;
      height: 300px;
      font-family: monospace;
    }

    ${cfg.CSS}
    `;

    if (elStyle) {
      elStyle.textContent = css;
    } else {
      elStyle = document.createElement('style');
      elStyle.setAttribute('data-userscript', SCRIPT_ID);
      elStyle.append(document.createTextNode(css));
      document.head.append(elStyle);
    }
  }
})();