[UserScript] Advanced Context Sentence

Thanks both for replying.

I’m also using v1.40 and I was using the modification. It was working fine until a week ago.

I just tried reinstalling it to see if that would help but it didn’t The audio doesn’t work anywhere - lessons/reviews/vocab.

I had a thought and right-clicked on the loudspeaker icon in a random example sentence in the vocab section and went to Inspect/Console. This is the message:

Specified “type” attribute of “audio/oog” is not supported. Load of media resource https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&total=1&idx=0&q=お前は一体どっちの味方なんだ? failed.
All candidate resources failed to load. Media load paused.

I also note that if I copy and paste the full URL in the error above into my browser it actually plays the audio. (The voice sounds different lately so I’m wondering if Google changed it somehow.)

Any ideas would be much appreciated. I’m using a Mac btw - don’t know if that makes a difference.

It’s working on Chrome!

1 Like

I may need to try migrating my WaniKani onto Chrome and reinstall all the userscripts.

I tried searching for the console message I got. First I think the type in the script should say ‘audio/ogg’ so it’s maybe a typo. But apart from that it’s still not working.

Seen some conflicting advice about Firefox not playing ball with some scripts that look to external sources. I think my version of Firefox did update again last week so maybe has something to do with it?

And saw some others talking about Google Text-to-Speech itself actually blocking requests if it gets too many. I don’t think it’s that because the audio plays in the browser if I paste in the address. To be honest it started to all get way beyond the scope of my technical knowledge. So maybe a switch to Chrome will be the solution for me.

I migrated my WK onto Chrome this morning and reinstalled all my userscripts and… the audio works!

So the problem appears to be limited to my current install of Firefox. Maybe on a future update it will suddenly start working again but for now I’m going to carry on with Chrome.

Two days after I migrated to Chrome the audio has stopped working on there too. :sweat_smile:

I looked in the Console and I’m getting a 403 error when I click the loudspeaker, which means access is forbidden. But again, if I insert the URL in the address bar of the browser it plays fine.

I’ve done a bit more searching around and this is what I now understand. Google Text-to-Speech was originally free. Then they’ve later changed it to a paid service with its own API key. However, some clever people found ways around this and that’s how this userscript works.

Google TTS also tries to prevent it being called by third-party software by looking at the referrer data from the website where it’s being used. Again this userscript appears to have something in it to try to mask that.

However, I think Google have been making their software more sophisticated over time and it’s starting to detect these things. So they are either blocking on the basis of word limits to prevent over-use (I don’t think I was overusing it) or by spotting that it’s not being called from the Google Translate website. Possibly the latter is what’s happened to me. I’m really not sure why because I’m using the same software as everyone else.

I may just have to accept for now that it won’t work. I can still copy paste the sentences into Google Translate which is a bit annoying to do but not the end of the world. :slight_smile:

WK should absolutely just bake this feature in. Hearing context sentences is vital.

If they could get their voice actors to read them all it would be amazing. I’m guessing it might be a tad too expensive though, if you think about how many context sentences they’ve got. Maybe they could just do it for a selection of vocab.

@Ben-S As of a few days ago my audio no longer works.

:pensive: It’ll be a shame if this features stops working for everyone. I think what I posted previously about Google is probably the case unless some bright spark knows a way around it.

Hasn’t been working on my firefox for a week now. I first thought the issue might be due to my browser hanging when I click on audio as it happened previously due to the number of scripts running. But that doesn’t seem to be the case now. :frowning:

Originally, this script used the speech synthesis provided by the browser, but switched to using the Google translate hack in version 1.10. You could try using the old createAudioButton() function from version 1.02. For convenience, here is the modified script (including the other fixes listed in this thread: my own fix, parts of @est_fills_cando’s modifications, and @rklie’s fix):

Script
"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/*
// @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
// @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 vocabularyPage = "/vocabulary";
  const recognizedSelector = "a.recognized";
  const unrecognizedSelector = "a.unrecognized";
  const sessions = [
    {
      page: "/review/session",
      mount: "#item-info-col2",
      loading: "#loading",
      getHeader: sentences => {
        return sentences[0].previousElementSibling;
      }
    },
    {
      page: "/lesson/session",
      mount: "#supplement-voc-context-sentence",
      loading: "#loading-screen",
      getHeader: sentences => {
        return sentences[0].previousElementSibling;
      }
    }
  ];

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

  // Application start Point
  main();

  function main() {
    // we don't need to observe any changes in the vocabulary page
    if (isPage(vocabularyPage)) {
      init(() =>
        evolveContextSentence(sentences => {
          return sentences[0].previousElementSibling;
        })
      );
      return;
    }

    // Get the target for the session page to watch for changes
    const session = getSessionDependingOnPage();
    if (session) startObserving(session);
  }

  function startObserving({ mount, loading, getHeader }) {
    const loadingObservationConfiguration = {
      attributes: true,
      childList: false,
      subtree: false
    };

    const itemInfoObservationConfiguration = {
      attributes: false,
      childList: true,
      subtree: false
    };

    const observeLoading = () => {
      observeChanges({
        element: loading,
        config: loadingObservationConfiguration,
        onChange: runInit
      });
    };

    const runInit = () => {
      init(() => {
        observeSentenceChanges();
      });
    };

    const observeSentenceChanges = () => {
      observeChanges({
        element: mount,
        continuesObservation: true,
        config: itemInfoObservationConfiguration,
        onChange: () => evolve(),
        onInitObserver: () => evolve()
      });
    };

    const evolve = () => evolveContextSentence(getHeader);

    /**
     * Basically, this function will fire an observer that will
     * watch when the loading screen on the session pages (lesson and review) stops,
     * then it will fire another observer to watch for changing the sentences,
     * whenever the sentence change it will fire the evolveContextSentence over it again
     *
     * why wait for the loading screen stops? because the script slows down the animation
     * which makes a really bad user experience
     */
    observeLoading();
  }

  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 dailog for the scrip. 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"]');
      let sentenceText = japaneseSentence.textContent;
      const audioButton = createAudioButton(japaneseSentence.innerHTML);
      //let advancedExampleSentence = "";
      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 createAudioButton(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;
  }

  function observeChanges(params) {
    const {
      element,
      config,
      onChange,
      onInitObserver = () => {},
      continuesObservation = false
    } = params;

    if (!window.MutationObserver) {
      console.warn(
        `${scriptName}: you're browser does not support MutationObserver which this script utilaizes to implement its features in /lesson/session and /review/sesson. update you're broswer or use another one if you want Advanced Context Sentence to work on them. This script is still useful on /vocabulary page though`
      );
      return;
    }

    onInitObserver();

    const target = document.querySelector(element);
    const observer = new MutationObserver(() => {
      observer.disconnect();
      onChange();
      continuesObservation && observer.observe(target, config);
    });

    observer.observe(target, config);
  }

  //--------------------------------------------------------------------------------------------------------------//
  //----------------------------------------------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"
          }
        }
      }
    };
    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();
  }

  //---------------------------------------------------------------------------------------------------------------//
  //-------------------------------------------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=${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);
  }
})();

However, it might still not work: the available speech synthesis languages depend on the browser. You can go to this speech synthesis documentation page and check in the dropdown box which languages are supported by your browser. Firefox seems to only offer locally installed languages (at least for me).

3 Likes

But it works if I just type the following url in the browser address bar

https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=ja&total=1&idx=0&q=じゃね

and the syntax is near identical to the script code so I think it should be some workaround that would work.

I just installed your replacement script with a copy and paste and the audio is back! Thank you so much! I’m currently using Chrome btw.

Maybe someone else could test it out as well so we can check different browsers?

If this is a long-term solution I’m wondering if you would be able to publish it on greasyfork? I don’t know how it works but obviously it’s based on someone else’s code so not sure what you’re allowed to do without permission.

1 Like

My audio is now working again, and I swear I’ve done nothing.

This seems to have stopped working in lessons for me since this morning. Anyone else experiencing the same?

It seems like they have moved the update of the Lesson screen from the preview server to the main site. I have already prepared a fix for this script, but I wanted to test it a bit more before posting the updated version. I didn’t expect the WK update to happen before they fixed all the reported bugs, so I took too much time. I will try to post the fixed version here in the next few hours. Until then, you can try the script compatibility mode that WK provided (available on the settings page).

3 Likes

Thanks! I wasn’t aware of any of this. I’ve turned on the script compatibility mode and it is working again in Lessons for now.

I’ll keep an eye out for your update.

1 Like

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