Greasy Fork is available in English.

WaniKani Vocab Reading Analyzer

Colors vocabulary on the lesson picker based on whether their readings are known

// ==UserScript==
// @name         WaniKani Vocab Reading Analyzer
// @namespace    wyverex
// @version      1.2.3
// @description  Colors vocabulary on the lesson picker based on whether their readings are known
// @author       Andreas Krügersen-Clark
// @match        https://www.wanikani.com/
// @match        https://www.wanikani.com/dashboard
// @match        https://www.wanikani.com/subject-lessons/picker
// @grant        none
// @require      https://unpkg.com/wanakana
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  if (!window.wkof) {
    alert(
      '"Wanikani Vocab Reading Analyzer" script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.'
    );
    window.location.href = "https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549";
    return;
  }

  const StoreName = "cachedReadings";

  const RendakuPrefixCandidates = {
    か: ["が"],
    き: ["ぎ"],
    く: ["ぐ"],
    け: ["げ"],
    こ: ["ご"],
    さ: ["ざ"],
    し: ["じ"],
    す: ["ず"],
    せ: ["ぜ"],
    そ: ["ぞ"],
    た: ["だ"],
    ち: ["ぢ"],
    つ: ["づ"],
    て: ["で"],
    と: ["ど"],
    は: ["ば", "ぱ"],
    ひ: ["び", "ぴ"],
    ふ: ["ぶ", "ぷ"],
    へ: ["べ", "ぺ"],
    ほ: ["ぼ", "ぽ"],
  };
  const RendakuSuffixCandidates = {
    く: "っ",
    つ: "っ",
    ち: "っ",
  };
  const DefaultColors = {
    easyColor: "#A1FA4F",
    secondaryColor: "#6DA3EE",
    rendakuColor: "#FFF200",
    newColor: "#F06356",
  };

  const wkof = window.wkof;
  const shared = {
    settings: {},
    db: undefined,
    dialog: undefined,

    vocab: undefined,
    kanji: undefined,
    learnedVocabProcessed: false,

    // KanjiId -> [learned readings]
    lastReadingCacheTime: new Date(0),
    readingsCache: {},
  };

  wkof.include("ItemData,Menu,Settings");
  if (window.location.href.includes("subject-lessons/picker")) {
    wkof.ready("ItemData").then(openDB).catch(loadError);
  }
  wkof.ready("document,Menu,Settings").then(loadSettings).then(installMenu).catch(loadError);

  function loadError(e) {
    console.error('Failed to load data from WKOF for "Vocab Analyzer"', e);
  }

  function loadSettings() {
    return wkof.Settings.load("wk_vocab_analyzer", DefaultColors).then(() => (shared.settings = wkof.settings.wk_vocab_analyzer));
  }

  function openDB() {
    const dbRequest = window.indexedDB.open("wk-vocab-analyzer", 1);
    dbRequest.onerror = (event) => {
      console.error("Could not open database for Vocab Analyzer. Analyzing vocab with learned, secondary readings is not supported.");
      startup();
    };
    dbRequest.onsuccess = (event) => {
      shared.db = event.target.result;
      const transaction = shared.db.transaction([StoreName], "readonly");
      const store = transaction.objectStore(StoreName);
      const request = store.get("main");
      request.onsuccess = () => {
        const data = request.result;
        shared.lastReadingCacheTime = data.lastReadingCacheTime;
        shared.readingsCache = data.cache;
        startup();
      };
    };
    dbRequest.onupgradeneeded = (event) => {
      const db = event.target.result;
      const store = db.createObjectStore(StoreName, { keyPath: "id" });
      store.add({ id: "main", lastReadingCacheTime: new Date(0), cache: {} });
    };
  }

  function startup() {
    const kanjiConfig = { wk_items: { options: { subjects: true }, filters: { level: "1..+0", item_type: "kanji" } } };
    wkof.ItemData.get_items(kanjiConfig).then(processKanji);
  }

  // ----------------------------------------------------------------------
  function installMenu() {
    if (window.location.href.includes("subject-lessons/picker")) {
      return;
    }
    wkof.Menu.insert_script_link({
      name: "wk_vocab_analyzer",
      submenu: "Settings",
      title: "Vocab Reading Analyzer",
      on_click: openSettings,
    });
  }

  // prettier-ignore
  function openSettings() {
    let config = {
      script_id: 'wk_vocab_analyzer',
      title: 'Vocab Reading Analyzer',
      content: {
        display: {
          type: "group", label: "Colors", content: {
            easyColor: { type: "color", label: "Easy reading", full_width: false },
            secondaryColor: { type: "color", label: "Secondary reading" },
            rendakuColor: { type: "color", label: "Rendaku reading" },
            newColor: { type: "color", label: "New reading" },
            reset: { type: "button", label: "Reset to defaults", text: "Reset", on_click: resetToDefaults }
          }
        }
      }
    };
    shared.dialog = new wkof.Settings(config);
    shared.dialog.open();
  }

  function resetToDefaults() {
    shared.settings.easyColor = DefaultColors.easyColor;
    shared.settings.secondaryColor = DefaultColors.secondaryColor;
    shared.settings.rendakuColor = DefaultColors.rendakuColor;
    shared.settings.newColor = DefaultColors.newColor;
    shared.dialog.refresh();
  }

  // ----------------------------------------------------------------------
  function processKanji(items) {
    shared.kanji = items;

    if (shared.db) {
      // Get all learned vocab
      const config = {
        wk_items: { options: { subjects: true, assignments: true }, filters: { srs: { value: [-1, 0], invert: true }, item_type: "voc" } },
      };
      wkof.ItemData.get_items(config).then(cacheNewlyLearnedReadings);
    } else {
      processVocab();
    }
  }

  function cacheNewlyLearnedReadings(items) {
    if (items.length > 0) {
      let hasUpdates = false;
      for (let vocab of items) {
        const startTime = new Date(vocab.assignments.started_at);
        if (startTime > shared.lastReadingCacheTime) {
          const analysis = analyzeVocab(vocab);
          if (analysis) {
            for (let kanji of analysis) {
              if (shared.readingsCache[kanji.id] === undefined) {
                shared.readingsCache[kanji.id] = new Set();
              }
              shared.readingsCache[kanji.id].add(kanji.reading);
              hasUpdates = true;
            }
          }
        }
      }

      if (hasUpdates) {
        const transaction = shared.db.transaction([StoreName], "readwrite");
        const store = transaction.objectStore(StoreName);
        store.put({ id: "main", lastReadingCacheTime: new Date(), cache: shared.readingsCache });
      }
    }

    processVocab();
  }

  function processVocab() {
    // Get unlocked, not yet learned vocab
    const vocabConfig = { wk_items: { options: { subjects: true }, filters: { srs: "init", item_type: "voc" } } };
    wkof.ItemData.get_items(vocabConfig).then((items) => {
      shared.vocab = items;
      processData();
    });
  }

  // ====================================================================================
  function processData() {
    if (window.location.href.includes("subject-lessons/picker")) {
      const uiResults = {};
      for (let vocab of shared.vocab) {
        const analysis = analyzeVocab(vocab);
        const isEasy = analysis !== undefined && analysis.reduce((p, c) => p && c.primary && !c.rendaku, true);
        let isNewReading = false;
        let hasRendaku = false;
        if (!isEasy) {
          if (analysis) {
            for (const kanji of analysis) {
              if (kanji.rendaku) {
                hasRendaku = true;
              } else if (!kanji.primary) {
                const cachedReadings = shared.readingsCache[kanji.id];
                if (!cachedReadings || !cachedReadings.has(kanji.reading)) {
                  isNewReading = true;
                  break;
                }
              }
            }
          } else {
            isNewReading = true;
          }
        }
        uiResults[vocab.id] = { isEasy, hasRendaku, isNewReading };
      }

      annotateVocabInLessonPicker(uiResults);
    }
  }

  // Returns [kanjiMatch]
  function analyzeVocab(vocab) {
    const data = vocab.data;
    const kanjiReadings = getKanjiReadings(data.component_subject_ids);

    for (let reading of data.readings) {
      if (reading.primary && reading.accepted_answer) {
        const tokens = getCharacterTokens(data.characters);
        const kanjiMatches = matchKanjiReadings(tokens, reading.reading, kanjiReadings);
        return kanjiMatches;
      }
    }
  }

  // Returns an object of <kanji character> -> { primaryReading[], secondaryReading[] }
  function getKanjiReadings(kanjiIds) {
    const kanjiById = wkof.ItemData.get_index(shared.kanji, "subject_id");
    let kanjiReadings = {};
    for (let id of kanjiIds) {
      let primaryReadings = [];
      let secondaryReadings = [];
      const kanji = kanjiById[id].data;
      for (let reading of kanji.readings) {
        if (reading.primary && reading.accepted_answer) {
          primaryReadings.push(reading.reading);
        } else {
          secondaryReadings.push(reading.reading);
        }
      }
      kanjiReadings[kanji.characters] = { id, primary: primaryReadings, secondary: secondaryReadings };
    }
    return kanjiReadings;
  }

  function getCharacterTokens(characters) {
    let result = [];
    const tokens = wanakana.tokenize(characters, { detailed: true });
    for (let token of tokens) {
      if (token.type === "kanji") {
        // The tokenizer returns strings of subsequent kanji as a single token, e.g. 地中海. Split them
        const subTokens = [...token.value];
        for (let sub of subTokens) {
          result.push({ type: "kanji", value: sub });
        }
      } else {
        result.push(token);
      }
    }
    return result;
  }

  function matchKanjiReadings(tokens, reading, kanjiReadings, lastChosenReading) {
    if (tokens.length == 0) {
      return reading.length == 0 ? [] : undefined;
    }

    const cToken = tokens[0];
    if (cToken.type === "kanji") {
      // Check which reading this is
      const kReadings = kanjiReadings[cToken.value];
      if (cToken.value === "々") {
        // This is a repeater of the previous reading
        if (reading.startsWith(lastChosenReading)) {
          const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(lastChosenReading.length), kanjiReadings, lastChosenReading);
          if (subResult !== undefined) {
            return [{ id: kReadings.id, character: cToken.value, reading: lastChosenReading, primary: true }, ...subResult];
          }
        }
      }
      for (let primary of kReadings.primary) {
        const match = matchReading(reading, primary);
        if (match.match) {
          const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(primary.length), kanjiReadings, primary);
          if (subResult !== undefined) {
            return [{ id: kReadings.id, character: cToken.value, reading: primary, primary: true, rendaku: match.rendaku }, ...subResult];
          }
        }
      }
      for (let secondary of kReadings.secondary) {
        const match = matchReading(reading, secondary);
        if (match.match) {
          const subResult = matchKanjiReadings(tokens.slice(1), reading.slice(secondary.length), kanjiReadings, secondary);
          if (subResult !== undefined) {
            return [
              { id: kReadings.id, character: cToken.value, reading: secondary, primary: false, rendaku: match.rendaku },
              ...subResult,
            ];
          }
        }
      }
      return undefined;
    } else if (cToken.type === "hiragana" || cToken.type === "katakana") {
      const length = cToken.value.length;
      if (length > reading.length) {
        // This is a character vs reading mismatch due to a non-matching kanji
        return undefined;
      }
      return matchKanjiReadings(tokens.slice(1), reading.slice(length), kanjiReadings);
    } else if (cToken.type === "japanesePunctuation" && cToken.value === "ー") {
      // Long vowel kana
      return matchKanjiReadings(tokens.slice(1), reading.slice(1), kanjiReadings);
    } else {
      // Skip this token, it doesn't participate in the reading
      return matchKanjiReadings(tokens.slice(1), reading, kanjiReadings);
    }
  }

  function matchReading(reading, candidate) {
    if (reading.startsWith(candidate)) {
      return { match: true, rendaku: false };
    }
    const firstKana = candidate[0];
    if (candidate.length > 1) {
      const lastKana = candidate[candidate.length - 1];
      // Try rendaku suffix
      const suffixCandidate = RendakuSuffixCandidates[lastKana];
      if (suffixCandidate !== undefined) {
        const newCandidate = candidate.slice(0, candidate.length - 1) + suffixCandidate;
        if (reading.startsWith(newCandidate)) {
          return { match: true, rendaku: true };
        }
      }
    }
    // Try rendaku prefix
    const prefixCandidates = RendakuPrefixCandidates[firstKana];
    if (prefixCandidates !== undefined) {
      for (const rendaku of prefixCandidates) {
        const newCandidate = rendaku + candidate.slice(1);
        if (reading.startsWith(newCandidate)) {
          return { match: true, rendaku: true };
        }
      }
    }
    return { match: false, rendaku: false };
  }

  // ====================================================================================
  function annotateVocabInLessonPicker(vocabResults) {
    const subjectElements = document.querySelectorAll("[data-subject-id]");
    for (let element of subjectElements) {
      const id = element.getAttribute("data-subject-id");
      if (id in vocabResults) {
        const target = element.firstElementChild.firstElementChild.firstElementChild;

        if (vocabResults[id].isEasy) {
          target.style.color = shared.settings.easyColor;
        } else if (vocabResults[id].isNewReading) {
          target.style.color = shared.settings.newColor;
        } else if (vocabResults[id].hasRendaku) {
          target.style.color = shared.settings.rendakuColor;
        } else {
          target.style.color = shared.settings.secondaryColor;
        }
      }
    }
  }
})();