WaniKani Kanjidamage Mnemonics

Includes Kanjidamage Mnemonics in WaniKani

// ==UserScript==
// @name         WaniKani Kanjidamage Mnemonics
// @namespace    https://greasyfork.org/users/649
// @version      2.0.7
// @description  Includes Kanjidamage Mnemonics in WaniKani
// @author       Adrien Pyke
// @match        *://www.wanikani.com/kanji/*
// @match        *://www.wanikani.com/level/*/kanji/*
// @match        *://www.wanikani.com/review/session
// @match        *://www.wanikani.com/lesson/session
// @require      https://cdn.jsdelivr.net/gh/fuzetsu/userscripts@ec863aa92cea78a20431f92e80ac0e93262136df/wait-for-elements/wait-for-elements.js
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(() => {
  'use strict';

  const SCRIPT_NAME = 'WaniKani Kanjidamage Mnemonics';

  const Util = {
    log(...args) {
      args.unshift(`%c${SCRIPT_NAME}:`, 'font-weight: bold;color: #233c7b;');
      console.log(...args);
    },
    fromEntries:
      Object.fromEntries ||
      (iterable =>
        [...iterable].reduce((obj, [key, val]) => ((obj[key] = val), obj), {})),
    q: (query, context = document) => context.querySelector(query),
    qq: (query, context = document) =>
      Array.from(context.querySelectorAll(query)),
    appendAfter: (elem, elemToAppend) =>
      elem.parentNode.insertBefore(elemToAppend, elem.nextElementSibling),
    makeElem: (type, { classes, ...opts } = {}) => {
      const node = Object.assign(
        document.createElement(type),
        Util.fromEntries(
          Object.entries(opts).filter(([_, value]) => value != null)
        )
      );
      classes && classes.forEach(c => node.classList.add(c));
      return node;
    },
    fetch: (url, method = 'GET') =>
      new Promise((resolve, reject) =>
        GM_xmlhttpRequest({
          url,
          method,
          onload: resolve,
          onerror: reject
        })
      ),
    newTabLink: { target: '_blank', rel: 'noopener noreferrer' }
  };

  const App = {
    cachedKanji: [],
    getKanjiDamageInfo: async (kanji, inLesson) => {
      if (App.cachedKanji[kanji]) {
        Util.log(`${kanji} cached`);
        return App.cachedKanji[kanji];
      }
      Util.log(`Loading Kanjidamage information for ${kanji}`);

      try {
        const response = await Util.fetch(
          `http://www.kanjidamage.com/kanji/search?q=${kanji}`
        );

        Util.log(`Found Kanjidamage information for ${kanji}`);

        const tempDiv = Util.makeElem('div', {
          innerHTML: response.responseText
        });

        const replaceClasses = elem => {
          if (elem.classList.contains('onyomi')) {
            elem.classList.remove('onyomi');
            elem.classList.add(
              inLesson ? 'highlight-reading' : 'reading-highlight'
            );
          }
          if (elem.classList.contains('component')) {
            elem.classList.remove('component');
            elem.classList.add(
              inLesson ? 'highlight-radical' : 'radical-highlight'
            );
          }
          if (elem.classList.contains('translation')) {
            elem.classList.remove('translation');
            elem.classList.add(
              inLesson ? 'highlight-kanji' : 'kanji-highlight'
            );
          }
        };

        const readTableHtml = header => {
          const section = Util.qq('h2', tempDiv).find(elem =>
            elem.textContent.includes(header)
          );
          if (!section) return;
          const content = Util.q('td:nth-child(2)', section.nextElementSibling);
          Util.qq('span', content).forEach(replaceClasses);
          Util.qq('img', content)
            .filter(img => img.getAttribute('src').startsWith('/'))
            .forEach(
              img =>
                (img.src =
                  'http://www.kanjidamage.com' + img.getAttribute('src'))
            );
          return content.innerHTML;
        };

        const reading = readTableHtml('Onyomi');
        const mnemonic = readTableHtml('Mnemonic');

        App.cachedKanji[kanji] = {
          character: kanji,
          reading,
          mnemonic,
          url: response.finalUrl
        };

        return App.cachedKanji[kanji];
      } catch (e) {
        Util.log(`Could not find Kanjidamage information for ${kanji}`);
      }
    },
    createH2() {
      const h2 = Util.makeElem('h2');
      const link = Util.makeElem('a', {
        textContent: 'Kanjidamage',
        ...Util.newTabLink
      });
      h2.appendChild(link);
      return { h2, link };
    },
    createSection(node) {
      const { h2, link } = App.createH2();
      const section = Util.makeElem('section');
      if (node) {
        Util.appendAfter(node, h2);
        Util.appendAfter(h2, section);
      }
      return { h2, link, section };
    },
    createContainer(sel, selNode) {
      const container = Util.makeElem('section');
      const { h2, link, section } = App.createSection();
      container.appendChild(h2);
      container.appendChild(section);
      if (typeof sel === 'string')
        waitForElems({
          sel,
          onmatch: elem =>
            Util.q(selNode).classList.contains('kanji') &&
            Util.appendAfter(elem, container)
        });
      else Util.appendAfter(sel, container);
      return { container, h2, link, section };
    },
    getKanjiObjHtml: ({ reading, mnemonic }) =>
      (reading || '') + (mnemonic || ''),
    initWatch: (sel, selKanji, cb, cbClear) =>
      waitForElems({
        context: Util.q(sel),
        config: {
          attributes: true,
          childList: true,
          characterData: true,
          subtree: true
        },
        onchange: async () => {
          cbClear && cbClear();
          if (!Util.q(sel).classList.contains('kanji')) return;
          const kanji = Util.q(selKanji).textContent.trim();
          const kanjiObj = await App.getKanjiDamageInfo(kanji, true);
          kanji === kanjiObj.character && cb && cb(kanjiObj);
        }
      }),
    runOnLesson: () =>
      waitForElems({
        sel: '#main-info',
        stop: true,
        onmatch() {
          const { link: meaningLink, section: meaningSection } =
            App.createSection(Util.q('#supplement-kan-meaning-notes'));
          const { link: readingLink, section: readingSection } =
            App.createSection(Util.q('#supplement-kan-reading-notes'));
          const { link: reviewLink, section: reviewSection } =
            App.createContainer('#note-reading', '#main-info');

          const clearOutput = () =>
            (meaningLink.href =
              readingLink.href =
              reviewLink.href =
              meaningSection.innerHTML =
              readingSection.innerHTML =
              reviewSection.innerHTML =
                '');

          const outputKanjidamage = kanjiObj => {
            meaningLink.href =
              readingLink.href =
              reviewLink.href =
                kanjiObj.url;
            meaningSection.innerHTML =
              readingSection.innerHTML =
              reviewSection.innerHTML =
                App.getKanjiObjHtml(kanjiObj);
          };

          App.initWatch(
            '#main-info',
            '#character',
            outputKanjidamage,
            clearOutput
          );
        }
      }),
    runOnReview: () =>
      waitForElems({
        sel: '#character',
        onmatch() {
          const { link, section } = App.createContainer(
            '#note-reading',
            '#character'
          );

          const outputKanjidamage = kanjiObj => {
            link.href = kanjiObj.url;
            section.innerHTML = App.getKanjiObjHtml(kanjiObj);
          };

          App.initWatch('#character', '#character > span', outputKanjidamage);
        }
      }),
    runOnKanjiPage: async () => {
      const kanji = Util.q('.kanji-icon').textContent;
      const kanjiObj = await App.getKanjiDamageInfo(kanji, false);
      const { link, section } = App.createContainer(
        Util.q('#note-reading').parentNode
      );

      link.href = kanjiObj.url;
      section.innerHTML = App.getKanjiObjHtml(kanjiObj);
    }
  };

  const isLesson = window.location.pathname.includes('/lesson/');
  const isReview = window.location.pathname.includes('/review/');

  isLesson
    ? App.runOnLesson()
    : isReview
    ? App.runOnReview()
    : App.runOnKanjiPage();
})();