[UserScript] Advanced Context Sentence

Here is the updated version of this script that should work with the recent changes to the lesson screen:

"use strict";

// ==UserScript==
// @name         Advanced Context Sentence
// @namespace    https://openuserjs.org/users/abdullahalt
// @version      1.40
// @description  Enhance the context sentence section, highlighting kanji and adding audio
// @author       abdullahalt
// @match        https://www.wanikani.com/lesson/session
// @match        https://www.wanikani.com/review/session
// @match        https://www.wanikani.com/vocabulary/*
// @match        https://preview.wanikani.com/lesson/session
// @match        https://preview.wanikani.com/review/session
// @match        https://preview.wanikani.com/vocabulary/*
// @require      https://unpkg.com/@popperjs/core@2.5.4/dist/umd/popper.min.js
// @require      https://unpkg.com/tippy.js@6.2.7/dist/tippy-bundle.umd.min.js
// @require      https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=962341
// @grant        none
// @copyright    2019, abdullahalt (https://openuserjs.org//users/abdullahalt)
// @license      MIT
// ==/UserScript==

// ==OpenUserJS==
// @author abdullahalt
// ==/OpenUserJS==

(() => {
  //--------------------------------------------------------------------------------------------------------------//
  //-----------------------------------------------INITIALIZATION-------------------------------------------------//
  //--------------------------------------------------------------------------------------------------------------//
  const wkof = window.wkof;

  const scriptId = "AdvancedContextSentence";
  const scriptName = "Advanced Context Sentence";
  const recognizedSelector = "a.recognized";
  const unrecognizedSelector = "a.unrecognized";

  let state = {
    settings: {
      recognizedKanjiColor: "#f100a1",
      unrecognizedKanjiColor: "#888888",
      recognitionLevel: "5",
      tooltip: {
        show: true,
        delay: 0,
        position: "top"
      },
      voice: "browser"
    },
    kanjis: [],
    jiff: false // JLPT, Joyo and Frequency Filters
  };

  // Application start Point
  main();

  function main() {
	init(() => wkItemInfo.forType(`vocabulary`).under(`examples`).notify(() => evolveContextSentence(sentences => sentences[0].previousElementSibling)));
  }

  function init(callback) {
    createReferrer();
    createStyle();

    if (wkof) {
      wkof.include("ItemData,Settings");
      wkof
        .ready("ItemData,Settings")
        .then(loadSettings)
        .then(proccessLoadedSettings)
        .then(getKanji)
        .then(extractKanjiFromResponse)
        .then(callback);
    } else {
      console.warn(
        `${scriptName}: You are not using Wanikani Open Framework which this script utlizes to see the kanji you learned and highlights it with a different color, it also provides the settings dialog for the script. You can still use Advanced Context Sentence normally though`
      );
      callback();
    }
  }

  function evolveContextSentence(getHeader) {
    const sentences = document.querySelectorAll(".context-sentence-group:not(.advanced)");
    sentences.forEach(s => s.classList.add("advanced"));
    if (sentences.length === 0) return;

    if (wkof) evolveHeader(getHeader(sentences));

    sentences.forEach(sentence => {
      const japaneseSentence = sentence.querySelector('p[lang="ja"]');
      const sentenceText = japaneseSentence.textContent;
      const audioButton = createAudioButton(sentenceText);
      const chars = Array.from(sentenceText);
      const newNodes = chars.map(char => tagAndLinkKanji(char));
      japaneseSentence.replaceChildren(...newNodes);
      japaneseSentence.append(audioButton);
    });

    highlightKanji();
  }

  function evolveHeader(header) {
    const settings = document.createElement("i");
    settings.setAttribute("class", "icon-gear");
    settings.setAttribute(
      "style",
      "font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;"
    );
    settings.onclick = openSettings;

    if (!header.querySelector("i.icon-gear")) header.append(settings);
  }

  function recreateAudioButtons() {
    document.querySelectorAll(`.context-sentence-group > p > span:last-child, .context-sentence-group > p > button:last-child`).forEach(audioButton => audioButton.remove());
    const sentences = document.querySelectorAll(".context-sentence-group");
    sentences.forEach(sentence => {
      const japaneseSentence = sentence.querySelector('p[lang="ja"]');
      const sentenceText = japaneseSentence.textContent;
      const audioButton = createAudioButton(sentenceText);
      japaneseSentence.append(audioButton);
    });
  }

  function createAudioButton(sentence) {
    if (state.settings.voice === "google") {
      return createAudioButtonGoogleTL(sentence);
    } else {
      return createAudioButtonSpeechSynthesis(sentence);
    }
  }

  /**
   * To fix a weird issue that occur in the session pages(where all audios play
   * if the audio for reading the word is clicked),
   * we have to create the audio element only for the time of palying the audio
   * and remove it afterward
   * @param {*} sentence
   */
  function createAudioButtonGoogleTL(sentence) {
    // contains audio and button as sibiling elements
    const audioContainer = document.createElement("span");

    const mpegSource = createSource("audio/mpeg", sentence);
    const oogSource = createSource("audio/oog", sentence);

    const button = document.createElement("button");
    button.setAttribute("class", "audio-btn audio-idle");

    button.onclick = () => {
      if (audioContainer.childElementCount > 1) {
        const audio = audioContainer.querySelector("audio");
        audio.pause();
        button.setAttribute("class", "audio-btn audio-idle");
        audio.remove();
        return;
      }

      const audio = document.createElement("audio");
      audio.setAttribute("display", "none");
      audio.append(mpegSource, oogSource);

      audio.onplay = () => {
        button.setAttribute("class", "audio-btn audio-play");
      };

      audio.onended = () => {
        button.setAttribute("class", "audio-btn audio-idle");
        audio.remove();
      };

      audioContainer.append(audio);
      audio.play();
    };

    audioContainer.append(button);
    return audioContainer;
  }

  function createAudioButtonSpeechSynthesis(sentence) {
    if (!window.SpeechSynthesisUtterance) {
      console.warn("Advanced Context Sentence: your browser does not support SpeechSynthesisUtterance which this script utilizes to implement the audio feature. update your browser or use another one if you want that feature");
      return null;
    }

    const button = document.createElement("button");
    button.setAttribute("class", "audio-btn audio-idle");

    button.onclick = () => {
      var msg = new SpeechSynthesisUtterance(sentence);
      msg.lang = "ja-JP";
      window.speechSynthesis.speak(msg);

      msg.onstart = () => {
        button.setAttribute("class", "audio-btn audio-play");
      };

      msg.onend = () => {
        button.setAttribute("class", "audio-btn audio-idle");
      };
    };

    return button;
  }

  //--------------------------------------------------------------------------------------------------------------//
  //----------------------------------------------SETTINGS--------------------------------------------------------//
  //--------------------------------------------------------------------------------------------------------------//

  function loadSettings() {
    return wkof.Settings.load(scriptId, state.settings);
  }

  function proccessLoadedSettings() {
    state.settings = wkof.settings[scriptId];
  }

  function openSettings() {
    var config = {
      script_id: scriptId,
      title: scriptName,
      on_save: updateSettings,
      content: {
        highlightColors: {
          type: "section",
          label: "Highlights"
        },
        recognizedKanjiColor: {
          type: "color",
          label: "Recognized Kanji",
          hover_tip:
            "Kanji you should be able to recognize will be highlighted using this color",
          default: state.settings.recognizedKanjiColor
        },
        unrecognizedKanjiColor: {
          type: "color",
          label: "Unrecognized Kanji",
          hover_tip:
            "Kanji you shouldn't be able to recognize will be highlighted using this color",
          default: state.settings.unrecognizedKanjiColor
        },
        recognitionLevel: {
          type: "dropdown",
          label: "Recognition Level",
          hover_tip:
            "Any kanji with this level or higher will be highlighted with the 'Recognized Kanji' color",
          default: state.settings.recognitionLevel,
          content: {
            1: stringfySrs(1),
            2: stringfySrs(2),
            3: stringfySrs(3),
            4: stringfySrs(4),
            5: stringfySrs(5),
            6: stringfySrs(6),
            7: stringfySrs(7),
            8: stringfySrs(8),
            9: stringfySrs(9)
          }
        },
        tooltip: {
          type: "section",
          label: "Tooltip"
        },
        show: {
          type: "checkbox",
          label: "Show Tooltip",
          hover_tip:
            "Display a tooltip when hovering on kanji that will display some of its properties",
          default: state.settings.tooltip.show,
          path: "@tooltip.show"
        },
        delay: {
          type: "number",
          label: "Delay",
          hover_tip: "Delay in ms before the tooltip is shown",
          default: state.settings.tooltip.delay,
          path: "@tooltip.delay"
        },
        position: {
          type: "dropdown",
          label: "Position",
          hover_tip: "The placement of the tooltip",
          default: state.settings.tooltip.position,
          path: "@tooltip.position",
          content: {
            top: "Top",
            bottom: "Bottom",
            right: "Right",
            left: "Left"
          }
        },
        voiceSection: {
          type: "section",
          label: "Voice"
        },
        voice: {
          type: "dropdown",
          label: "Voice",
          hover_tip: "Select the machine voice that reads the sentence aloud",
          default: state.settings.voice,
          content: {
            browser: "Web Browser",
            google: "Google Translate"
          }
        }
      }
    };
    var dialog = new wkof.Settings(config);
    dialog.open();
  }

  // Called when the user clicks the Save button on the Settings dialog.
  function updateSettings() {
    state.settings = wkof.settings[scriptId];
    highlightKanji();
    recreateAudioButtons();
  }

  //---------------------------------------------------------------------------------------------------------------//
  //-------------------------------------------HELPER FUNCTIONS----------------------------------------------------//
  //---------------------------------------------------------------------------------------------------------------//

  function isPage(page) {
    const path = window.location.pathname;
    return path.includes(page);
  }

  function getSessionDependingOnPage() {
    let result = null;
    sessions.forEach(session => {
      if (isPage(session.page)) result = session;
    });

    return result;
  }

  function tagAndLinkKanji(char) {
    return isKanji(char) ? wrapInAnchor(char) : document.createTextNode(char);
  }

  /**
   * Determine if the character is a Kanji, inspired by https://stackoverflow.com/a/15034560
   */
  function isKanji(char) {
    return isCommonOrUncommonKanji(char) || isRareKanji(char);
  }

  function isCommonOrUncommonKanji(char) {
    return char >= "\u4e00" && char <= "\u9faf";
  }

  function isRareKanji(char) {
    char >= "\u3400" && char <= "\u4dbf";
  }

  /**
   * Renders the link for the kanji
   * Knji pages always use https://www.wanikani.com/kanji/{kanji} where {kanji} is the kanji character
   */
  function wrapInAnchor(char) {
    const anchor = document.createElement("a");
    anchor.setAttribute("target", "_blank");
    anchor.setAttribute("class", "recognized");

    if (!wkof) {
      anchor.setAttribute("href", `https://www.wanikani.com/kanji/${char}`);
      anchor.innerText = char;
      return anchor;
    }

    const kanji = state.kanjis.find(item => item.char == char);

    anchor.setAttribute("data-srs", kanji ? kanji.srs : -1);
    anchor.setAttribute("data-kanji", char);
    anchor.setAttribute(
      "href",
      kanji ? kanji.url : `https://jisho.org/search/${char}`
    );

    anchor.innerText = char;
    return anchor;
  }

  function createTooltip(kanji) {
    if (!wkof) {
      const container = document.createElement("span");
      return container;
    }

    const container = document.createElement("div");
    container.setAttribute("class", "acs-tooltip");

    if (!kanji) {
      const span = document.createElement("span");
      span.innerText = "Wanikani doesn't have this kanji! :(";
      container.append(span);
      return container;
    }

    const onyomis = kanji.readings.filter(
      item => item.type.toLocaleLowerCase() === "onyomi"
    );
    const kunyomis = kanji.readings.filter(
      item => item.type.toLocaleLowerCase() === "kunyomi"
    );

    const onyomi = stringfyArray(onyomis, item => item.reading);
    const kunyomi = stringfyArray(kunyomis, item => item.reading);
    const meaning = stringfyArray(kanji.meanings, item => item.meaning);

    container.append(generateInfo("LV", kanji.level));

    container.append(generateInfo("EN", meaning));

    if (onyomi !== "None" && onyomi !== "")
      container.append(generateInfo("ON", onyomi));
    if (kunyomi !== "None" && kunyomi !== "")
      container.append(generateInfo("KN", kunyomi));
    container.append(generateInfo("SRS", stringfySrs(kanji.srs)));

    if (state.jiff) {
      container.append(generateInfo("JOYO", kanji.joyo));
      container.append(generateInfo("JLPT", kanji.jlpt));
      container.append(generateInfo("FREQ", kanji.frequency));
    }
    return container;
  }

  function stringfyArray(array, pathToString) {
    let stringfied = "";
    array.forEach(item => {
      stringfied = stringfied.concat(pathToString(item) + ", ");
    });
    stringfied = stringfied.substring(0, stringfied.length - 2);
    return stringfied;
  }

  function stringfySrs(srs) {
    switch (srs) {
      case -1:
        return "Locked";
      case 0:
        return "Ready To Learn";
      case 1:
        return "Apprentice 1";
      case 2:
        return "Apprentice 2";
      case 3:
        return "Apprentice 3";
      case 4:
        return "Apprentice 4";
      case 5:
        return "Guru 1";
      case 6:
        return "Guru 2";
      case 7:
        return "Master";
      case 8:
        return "Enlightened";
      case 9:
        return "Burned";
      default:
        return "";
    }
  }

  function generateInfo(title, info) {
    const container = document.createElement("div");
    const key = document.createElement("span");
    key.setAttribute("class", "acs-tooltip-header");
    const value = document.createElement("span");
    key.innerText = title;
    value.innerText = info;
    container.append(key, " ", value);
    return container;
  }

  function getKanji() {
    const filters = {
      item_type: ["kan"]
    };

    if (wkof.get_state("JJFFilters") === "ready") {
      state.jiff = true;
      filters.include_frequency_data = true;
      filters.include_jlpt_data = true;
      filters.include_joyo_data = true;
    } else {
      console.warn(
        `${scriptName}: You don't have Open Framework JLPT Joyo and Frequency Filters by @Kumirei installed (version 0.1.4 or later). Install the script if you want to get more information while hovering on Kanji on Context Sentences. Script URL: https://community.wanikani.com/t/userscript-open-framework-jlpt-joyo-and-frequency-filters/35096`
      );
    }

    return wkof.ItemData.get_items({
      wk_items: {
        options: {
          assignments: true
        },
        filters
      }
    });
  }

  function extractKanjiFromResponse(items) {
    const kanjis = [];

    items.forEach(item => {
      const kanji = {
        char: item.data.characters,
        readings: item.data.readings,
        level: item.data.level,
        meanings: item.data.meanings,
        url: item.data.document_url,
        srs: item.assignments ? item.assignments.srs_stage : -1,
        jlpt: item.jlpt_level,
        joyo: item.joyo_grade,
        frequency: item.frequency
      };

      kanjis.push(enhanceWithAditionalFilters(kanji, item));
    });

    state.kanjis = kanjis;
  }

  function enhanceWithAditionalFilters(kanji, item) {
    if (state.jiff) {
      kanji.jlpt = item.jlpt_level;
      kanji.joyo = item.joyo_grade;
      kanji.frequency = item.frequency;
    }
    return kanji;
  }

  function createSource(type, sentence) {
    const source = document.createElement("source");
    source.setAttribute("type", type);
    source.setAttribute(
      "src",
      `https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&total=1&idx=0&q=${encodeURIComponent(sentence)}`
    );
    return source;
  }

  let tippys = new Set();
  function highlightKanji() {
    const rules = document.querySelector("#acs-style").sheet.cssRules;
    rules[0].style.color = state.settings.recognizedKanjiColor;
    rules[1].style.color = state.settings.unrecognizedKanjiColor;

    if (!wkof) return;

    tippys.forEach(t => t.destroy());
    tippys = new Set();

    const anchors = document.querySelectorAll(".context-sentence-group a");
    anchors.forEach(anchor => {
      const srs = anchor.getAttribute("data-srs");
      const char = anchor.getAttribute("data-kanji");

      if (srs >= state.settings.recognitionLevel)
        anchor.setAttribute("class", "recognized");
      else {
        anchor.setAttribute("class", "unrecognized");
      }

      if (state.settings.tooltip.show) {
        const kanji = state.kanjis.find(item => item.char == char);
        const tooltip = createTooltip(kanji);

        tippy(anchor, {
          content: tooltip,
          size: "small",
          arrow: true,
          placement: state.settings.tooltip.position,
          delay: [state.settings.tooltip.delay, 20]
        });
        tippys.add(anchor._tippy);
      }
    });
  }

  // Neccessary in order for audio to work
  function createReferrer() {
    const remRef = document.createElement("meta");
    remRef.name = "referrer";
    remRef.content = "no-referrer";
    document.querySelector("head").append(remRef);
  }

  // Styles
  function createStyle() {
    const style = document.createElement("style");
    style.setAttribute("id", "acs-style");
    style.innerHTML = `

      /* Kanji */
      /* It's important for this one to be the first rule*/
      ${recognizedSelector} {

      }
      /* It's important for this one to be the second rule*/
      ${unrecognizedSelector} {

      }

      .context-sentence-group p a {
        text-decoration: none;
      }

      .context-sentence-group p a:hover {
        text-decoration: none;
      }

      .acs-tooltip {
        text-align: left
      }

      .acs-tooltip-header {
        color: #929292
      }

    `;

    document.querySelector("head").append(style);
  }
})();

I have also added a setting for selecting the machine voice that reads the sentences aloud. The choices are “Google Translate” (sends the sentence to Google to get the audio file) and “Web Browser” (uses the text-to-speech provided by your web browser). I have added this setting because for some people in this thread (including me), the Google Translate hack did not work for several weeks in the past.

3 Likes

Thanks, I’ve installed it and switched off the compatibility mode and it’s working. Also tested the switch between Google Translate and Web Browser. That works too - yes, my Google Translate is playing the audio once more!

Actually I’ve noticed in the last few weeks Google seem to have updated the TTS in Chrome and it sounds much better than before. I’m quite impressed. It speaks quite quickly but sounds way more natural than Google Translate bizarrely. It’s now my preferred option.

Given the OP is no longer maintaining this script have you considered publishing this as a new one on greasyfork? You could start a new thread and link back to this one and then get the main userscript list updated. Would be a bit handier for other people wanting to download the script.

1 Like

I have been using the same trick in a Bunpro script and someone told me that it can be fixed by changing “no-referrer” to “same-origin”

1 Like

why don’t I found this earlier? this is really helpful!

I don’t really know much about this stuff, but after reading about these options, I’m confused why this would change anything. If I’m understanding this correctly, both the no-referrer and the same-origin setting will result in no Referer header being sent for cross-origin requests. And I would have assumed that a request to Google Translate is cross-origin?

1 Like

Honestly, I have no clue. I don’t have a BP membership anymore so I couldn’t test the fix myself, but the person who reported the fix told me it did indeed work. I don’t know why it does

1 Like

Thank you Sinyaven for the fix. Tried to do the new content update lessons and found a few of my scripts weren’t working!

1 Like

Thanks so much! Any chance you could fork this on greasyfork?

1 Like

I have uploaded the fix to GreasyFork: Advanced Context Sentence.

I think I would prefer it if @abdullahalt or one of the @Mods edits the installation link in the first post of this thread to something like this:

Installation

Hope you enjoy it! [outdated version]
New version maintained by @Sinyaven

If there are multiple [Userscript] Advanced Context Sentence threads, people searching for this script have a harder time finding out which version they should install. And if @abdullahalt returns and wants to continue supporting this script, they just have to change back the installation link in their post.

5 Likes

Thank you!

There are a couple of examples where the pinned wiki was updated with the new thread next to the old one so anyone reading down the list will clearly see it’s a version 2. You can edit it. But either way - altering the original post is also good.

Most importantly thank you for uploading it. Much easier this way.

Seems that this script is incompatible with the Anime Sentences Userscript. Works like a charm without it though! Both are great for enhancing context… which to choose… :thinking:

I have not encountered any problems with both scripts installed. Can you give an example where a problem occurs? Does it happen during lessons, reviews, on the item page, or everywhere?

1 Like

For me, it seems to only interfere within the Reviews.


Here’s what it looks like.

Also when doing lessons it doesn’t seem to be active at all, even with Anime Sentences Disabled.:grimacing:

I think this problem should already be fixed in this fork of the Advanced Context Sentence script:

Will update then. Thanks for your assistance! I’m not very savvy with this kind of stuff.

The mods don’t seem to have edited the original post on this. I still think it would be beneficial to start a new thread for your version (call it v2), link back to this old thread, and edit the pinned Wiki with the new thread link. As long as the Wiki is updated people won’t have trouble finding the right one to install.

Hello @Sinyaven - I’m not sure if this is new behaviour but in Safari 15.1 if I select the web browser audio option then I get a readout of each individual character, rather than the words. The google translate version works, thankfully.

I’m still a bit reluctant to create a new thread for my modification – I have not even read through the whole code, so it feels weird to create my own thread for it. For now, I have changed the link in the pinned wiki to point to my post with the fork.

It still works as intended for me (Edge and Firefox on Windows), so I don’t know what the problem could be. If you want, you can go to this Speech Synthesis Reader and check if you have a Japanese voice in the dropdown, and also test if the problem still occurs if you listen to the context sentence through this page.

1 Like

I installed the Kyoko japanese TTS voice for my mac and now it seems to work - thanks!

1 Like