Advanced Context Sentence 2

Enhance the context sentence section, highlighting kanji and adding audio

当前为 2022-10-30 提交的版本,查看 最新版本

// ==UserScript==
// @name         Advanced Context Sentence 2
// @namespace    advancedContextSentence
// @version      2.8
// @description  Enhance the context sentence section, highlighting kanji and adding audio
// @author       Sinyaven
// @license      MIT
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @require      https://unpkg.com/@popperjs/[email protected]/dist/umd/popper.min.js
// @require      https://unpkg.com/[email protected]/dist/tippy-bundle.umd.min.js
// @require      https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1111117
// @supportURL   https://community.wanikani.com/t/55837
// @grant        none
// ==/UserScript==

// Original script by abdullahalt (https://openuserjs.org/users/abdullahalt)

(() => {
  "use strict";
  /* global wkItemInfo, tippy */
  /* eslint no-multi-spaces: off */

  //--------------------------------------------------------------------------------------------------------------//
  //-----------------------------------------------INITIALIZATION-------------------------------------------------//
  //--------------------------------------------------------------------------------------------------------------//
  const wkof = window.wkof;

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

  const audioIdleClass = wkItemInfo.currentState.on === "itemPage" ? "fa-volume-off" : "audio-idle";
  const audioPlayClass = wkItemInfo.currentState.on === "itemPage" ? "fa-volume-high" : "audio-play";

  let state = {
    settings: {
      recognizedKanjiColor: "#f100a1",
      unrecognizedKanjiColor: "#888888",
      recognitionLevel: "5",
      tooltip: {
        show: true,
        delay: 0,
        position: "top"
      },
      voice: "browser",
      soundVolume: 100,
      ttsHighlightWord: true,
      showSentenceLink: true,
      sentenceLink: "https://ichi.moe/cl/qr/?q=%s&r=kana"
    },
    kanjis: [],
    jiff: false // JLPT, Joyo and Frequency Filters
  };

  window.speechSynthesis?.getVoices(); // should initiate loading of voices in Chromium so that once the button is clicked, speechSynthesis.getVoices() actually returns the complete list of available voices

  // Application start Point
  main();

  async function main() {
    await init();
    wkItemInfo.forType("vocabulary").under("examples").notifyWhenVisible(evolveContextSentence);
  }

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

    if (wkof) {
      wkof.include("ItemData,Settings");
      await wkof
        .ready("ItemData,Settings")
        .then(loadSettings)
        .then(proccessLoadedSettings)
        .then(getKanji)
        .then(extractKanjiFromResponse);
    } else {
      console.warn(
        `${scriptName}: You are not using Wanikani Open Framework. Some functionality will be unavailable: 1. Highlighting of learned kanji, 2. Settings dialog`
      );
    }
  }

  function evolveContextSentence() {
    let sentences = document.querySelectorAll(":not(.context__common-word-combinations) > .context-sentence-group:not(.advanced), .subject-section__text--grouped");
    if (sentences.length === 0) sentences = [...document.querySelectorAll("#item-info > div > section > h2")].find(h => h.textContent === "Context Sentences")?.nextElementSibling.children || [];
    if (sentences.length === 0) return;
    sentences.forEach(s => s.classList.add("advanced"));

    if (wkof) evolveHeader(sentences[0].previousElementSibling || sentences[0].parentElement.previousElementSibling);

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

    highlightKanji();
  }

  function evolveHeader(header) {
    if (header.querySelector("i.fa-gear")) return;

    const settings = document.createElement("i");
    settings.classList.add("fa", "fa-gear");
    settings.style = "font-size: 14px; cursor: pointer; vertical-align: middle; margin-left: 10px;";
    settings.onclick = openSettings;

    header.append(settings);
  }

  function createSentenceLink(sentence) {
    let result = document.createElement("a");
    result.classList.add("fa", "fa-external-link-square", "sentence-link");
    result.href = state.settings.sentenceLink.replace("%s", sentence);
    result.target = "_blank";
    return result;
  }

  function updateSentenceLinks() {
    document.body.classList.toggle("advanced-context-sentence-hide-sentence-link", !state.settings.showSentenceLink);
    document.querySelectorAll(".advanced .sentence-link").forEach(l => {
      l.href = state.settings.sentenceLink.replace("%s", l.parentElement.textContent);
    });
  }

  function recreateAudioButtons() {
    document.querySelectorAll(".advanced > p > span:last-child, .advanced > p > button:last-child").forEach(audioButton => audioButton.remove());
    const sentences = document.querySelectorAll(".advanced");
    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 occurs 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.classList.add("audio-btn", "fa-solid", audioIdleClass);

    button.onclick = () => {
      if (audioContainer.childElementCount > 1) {
        const audio = audioContainer.querySelector("audio");
        audio.pause();
        button.classList.replace(audioPlayClass, audioIdleClass);
        audio.remove();
        return;
      }

      const audio = document.createElement("audio");
      audio.append(mpegSource, oogSource);
      audio.volume = state.settings.soundVolume / 100;

      audio.onplay = () => {
        button.classList.replace(audioIdleClass, audioPlayClass);
      };

      audio.onended = () => {
        button.classList.replace(audioPlayClass, audioIdleClass);
        audio.remove();
      };

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

    audioContainer.append(button);
    return audioContainer;
  }

  function createAudioButtonSpeechSynthesis(sentence) {
    if (!window.SpeechSynthesisUtterance) {
      console.warn(`${scriptName}: your browser does not support SpeechSynthesisUtterance. Switch to Google Translate, update your browser, or use a different one if you want that feature`);
      return null;
    }

    const button = document.createElement("button");
    button.classList.add("audio-btn", "fa-solid", audioIdleClass);

    let msg = null;

    button.onclick = () => {
      if (!msg) {
        msg = new SpeechSynthesisUtterance(sentence);
        let voice = selectVoice(state.settings.voice);
        if (voice) {
          msg.voice = voice;
        }else {
          msg.lang = "ja-JP";
        }
        msg.volume = state.settings.soundVolume / 100;

        msg.onstart = () =>   button.classList.replace(audioIdleClass, audioPlayClass);
        msg.onend   = () => { button.classList.replace(audioPlayClass, audioIdleClass); highlightSection(button.parentElement, 0, 0); };
        if (state.settings.ttsHighlightWord) msg.onboundary = e => highlightSection(button.parentElement, e.charIndex, e.charIndex + e.charLength);
      }
      window.speechSynthesis.speak(msg);
    };

    return button;
  }

  function getVoiceList() {
    let voices = window.speechSynthesis?.getVoices() || [];
    voices = [voices.filter(v => v.lang.startsWith("ja-")), voices].find(a => a.length > 0) || [];
    voices = voices.map(v => [v.name, simplifyVoiceName(v.name, v.localService)]);
    // fall back to original name if the simplification leads to duplicates
    voices.forEach(v0 => {
      if (v0[1].trim() === "" || voices.reduce((sum, v1) => sum + (v0[1] === v1[1] ? 1 : 0), 0) > 1) {
        v0[1] = v0[0];
      }
    });
    if (voices.length === 0) voices = [["browser", "Web Browser (no Japanese voice detected)"]];
    return voices;
  }

  function selectVoice(name) {
    let voices = window.speechSynthesis?.getVoices() || [];
    voices = [voices.filter(v => v.lang.startsWith("ja-")), voices].find(a => a.length > 0) || [];
    voices = [voices.filter(v => v.name === name         ), voices].find(a => a.length > 0) || [];
    return voices[0];
  }

  function simplifyVoiceName(name, localService) {
    return (localService ? "[local] " : "[online] ") + name.replace(/\bjapan(?:ese)?\b|\bnatural\b|\bonline\b/gi, "").replace(/[\s()-]+$|^[\s()-]+/g, "");
  }

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

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

  function proccessLoadedSettings() {
    state.settings = wkof.settings[scriptId];
    document.body.classList.toggle("advanced-context-sentence-hide-sentence-link", !state.settings.showSentenceLink);
  }

  function openSettings() {
    const voiceList = Object.fromEntries([...getVoiceList(), ["google", "[online] Google Translate"]]);
    var config = {
      script_id: scriptId,
      title: scriptName,
      pre_open: updateDialog,
      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 SRS level or higher will be highlighted with the 'Recognized Kanji' color",
          default: state.settings.recognitionLevel,
          content: {
            1: stringifySrs(1),
            2: stringifySrs(2),
            3: stringifySrs(3),
            4: stringifySrs(4),
            5: stringifySrs(5),
            6: stringifySrs(6),
            7: stringifySrs(7),
            8: stringifySrs(8),
            9: stringifySrs(9)
          }
        },
        tooltip: {
          type: "section",
          label: "Tooltip"
        },
        show: {
          type: "checkbox",
          label: "Show Tooltip",
          hover_tip:
            "When hovering over kanji, show a tooltip that will display some of its properties",
          default: state.settings.tooltip.show,
          path: "@tooltip.show"
        },
        delay: {
          type: "number",
          label: "Delay",
          hover_tip: "Delay in milliseconds 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: voiceList,
          on_change: updateDialog
        },
        soundVolume: {
          type: "input",
          subtype: "range",
          label: "Volume",
          hover_tip: "Select how loud the text-to-speech output should be",
          default: state.settings.soundVolume
        },
        ttsHighlightWord: {
          type: "checkbox",
          label: "Highlight word",
          hover_tip: "Highlight the section of the sentence that the synthetic voice is currently reading. Not available for the Google Translate voice"
        },
        sentenceLinkSection: {
          type: "section",
          label: "Weblink"
        },
        showSentenceLink: {
          type: "checkbox",
          label: "Show weblink",
          hover_tip: "Show a weblink after the sentence"
        },
        sentenceLink: {
          type: "text",
          label: "Weblink",
          hover_tip: "An URL with %s as a placeholder for the japanese sentence."
        }
      }
    };
    var dialog = new wkof.Settings(config);
    dialog.open();
  }

  function updateDialog() {
    document.getElementById("AdvancedContextSentence_ttsHighlightWord").disabled = wkof.settings.AdvancedContextSentence.voice === "google";
  }

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

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

  function tagAndLinkKanji(char) {
    return isKanji(char) ? wrapInAnchor(char) : wrapInSpan(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) {
    return char >= "\u3400" && char <= "\u4dbf";
  }

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

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

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

    anchor.dataset.srs = kanji ? kanji.srs : -1;
    anchor.dataset.kanji = char;
    anchor.href = kanji ? kanji.url : `https://jisho.org/search/${char}`;

    anchor.textContent = char;
    return anchor;
  }

  function wrapInSpan(char) {
    const span = document.createElement("span");
    span.textContent = char;
    return span;
  }

  function highlightSection(sentenceContainer, start, end) {
    [...sentenceContainer.children].forEach((element, i) => {
      element.classList.toggle("tts-focus", i >= start && i < end);
    });
  }

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

    const container = document.createElement("div");
    container.classList.add("acs-tooltip");

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

    const onyomi  = kanji.readings.filter(r => r.type ===  "onyomi").map(r => r.reading).join(", ");
    const kunyomi = kanji.readings.filter(r => r.type === "kunyomi").map(r => r.reading).join(", ");
    const meaning = kanji.meanings                                  .map(m => m.meaning).join(", ");

    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", stringifySrs(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 stringifySrs(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.classList.add("acs-tooltip-header");
    const value = document.createElement("span");
    key.textContent = title;
    value.textContent = 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 over kanji in context sentences. Script URL: https://community.wanikani.com/t/35096`
      );
    }

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

  function extractKanjiFromResponse(items) {
    state.kanjis = items.map(item => ({
        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
      })
    );
  }

  function createSource(type, sentence) {
    const source = document.createElement("source");
    source.type = type;
    source.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:not(.sentence-link)");
    anchors.forEach(anchor => {
      const srs = anchor.getAttribute("data-srs");
      const char = anchor.getAttribute("data-kanji");

      anchor.classList.remove("recognized", "unrecognized");
      if (srs >= state.settings.recognitionLevel) {
        anchor.classList.add("recognized");
      } else {
        anchor.classList.add("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);
      }
    });
  }

  // Necessary 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.id = "acs-style";
    style.textContent = `

      /* 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} {

      }

      .advanced p a {
        text-decoration: none;
      }

      .advanced p a:hover {
        text-decoration: none;
      }

      .advanced a.sentence-link {
        color: #a2a2a2;
      }

      body.advanced-context-sentence-hide-sentence-link .advanced a.sentence-link {
        display: none;
      }

      .advanced .audio-btn {
        vertical-align: baseline;
        border: none;
      }

      .advanced button.audio-idle:before {
        color: #a2a2a2;
      }

      .advanced .tts-focus {
        color: var(--inverted-text-color, white);
        background-color: var(--text-color, #333);
      }

      .acs-tooltip {
        text-align: left
      }

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

    `;

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