[Userscript] WaniKani Pitch Info

Hii, anyone know if similar is available in any of the android apps?

You can download kiwi browser for android and install the violent monkey extension and then just install this userscript like you’d do on pc

2 Likes

Another option would be to add this over the input section where you wrote your answer in hiragana anyways.

Alternative you could also use a minimized version with bars like in the takoboto dictionary app.

1 Like

2 things:

  • I absolutely love the new update of showing the pitch info while you do reviews. That is amazing and 10/10

  • When I am doing lessons now, I no longer have any pitch info show up under “reading” tab. This was my spot to initally learn the pitch with each new word.

Am I doing something wrong? It’s been working while doing new lessons up until very recently

Is it still an issue after the 0.76 update I pushed today?

I made some changes so this will also work for Chrome, as the settings were not working for me. It also won’t nag you if you don’t happen to use the framework, you’ll just miss out on the option.

1 Like

Sorry! I didn’t realize that my commit caused issues.
My pull request was what I was using on Chrome (ViolentMoney), so it’s pretty strange. :thinking:

Unfortunately it is still an issue for me…made sure to double check that I was on 0.76. I did a few reviews and “Extra Practice” and had the wonderful new pitch information showing up during the reviews…however, when doing “new” lessons of vocabulary there is no more pitch info under the reading tab! :frowning: Let me know If I am missing something! Thank you for your hard work! Pitch Info has been essential in my WK journey since day 1!

Are you starting your lessons through the lesson picker? Then the problem might be that wkof.Menu.insert_script_link() throws an exception while on the lesson picker page (@rfindley, in case you want to look into this).

@Invertex I have wrapped that call in a try/catch block for a quick fix, but I don’t know if you have other plans to address this issue. Sorry for taking advantage of my write access to the git repository :sweat_smile:

1 Like

Bingo! That is the source of the problem. If I use the lesson picker, I get no pitch info under the reading tab, but if i do the “daily 15” and let it auto sort for me, then I do see it. However, previously I was using pretty much the lesson picker exclusively, and I could still see pitch info. It does seem to be a new issue, related to the lesson picker.

Then your problem should be fixed in version 0.77. wkof.Menu.insert_script_link(), which was causing the problem, was added two days ago, so it makes sense that it was working until recently.

1 Like

That is indeed, the fix. Thank you so much for working so quickly. It’s perfect now!

1 Like

Nah that should be a fine fix for now, thanks!

And no worries lol, you got write access for a reason!

Thanks for all the work you’ve done on keeping this script going :sparkling_heart:

1 Like

I appreciate the update with the settings!

Just a couple other small suggestions.
image

First, from what I understand (without having looked into it directly, myself), script authors get to decide the menu name and most of the scripts so far utilize one of the first two words (shown in the picture above) for the initial category depending on the purpose.
Would be nice if this went into the Settings menu as well.
Edit: The above is implemented. Thanks again!

Second, an additional setting to show/hide the one in the Reading area would make it feel more complete.

1 Like

Heya, I’m using the latest version and for some reason the pitch is not showing up at the top bar next to the vocab. I think it worked last week, but I’m not sure. It still shows up in the Reading tab below but not in the area above, if that makes sense.

There’s an option for it now, and it’s not on by default. Did you check the options cog at the top left of the reviews and check the box in it to turn it on?

My bad, it works now. I guess it just randomly turned on for me once and then turned itself off somehow? It’s a really nice feature, thank you!

1 Like

Ah no, it’s just that when the change first got pushed it was on by default and there was no option to turn it off.
But then an option was added and made off by default!
Sorry for the confusion.

1 Like

It’s all good! The feature’s great, thanks a lot!

Hello,
does anybody else have problems when this script and the double check script are both activated? It was working fine for the longest time, but maybe since a week ago, I got the following problem:
Whenever I activate Pitch Info, double check doesn’t work anymore, so it’s either one or the other for me at the moment…

Yeah, made an edit to Pitch info and it currently works with my other scripts (Open framework, Jitai (modified), Double-Check, and Heatmap)

  • Added a check to ensure the pitch info is not added multiple times if it already exists in the divQuestion.
  • Added a check to ensure divQuestion is not null before proceeding with adding pitch info.
  • Ensured that event listeners for injecting pitch info are not affected by other scripts.
// ==UserScript==
// @name         WaniKani Pitch Info
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @namespace    https://greasyfork.org/en/scripts/31070-wanikani-pitch-info
// @version      0.80
// @description  Displays pitch accent diagrams on WaniKani vocab and session pages.
// @author       Invertex
// @supportURL   http://invertex.xyz
// @run-at       document-idle
// @require      https://greasyfork.org/scripts/430565-wanikani-item-info-injector/code/WaniKani%20Item%20Info%20Injector.user.js?version=1416982
// @resource     accents https://raw.githubusercontent.com/mifunetoshiro/kanjium/94473cd69598abf54cc338a0b89f190a6c02a01c/data/source_files/raw/accents.txt
// @grant        GM_getResourceText
// @grant        unsafeWindow
// @downloadURL https://update.greasyfork.org/scripts/31070/WaniKani%20Pitch%20Info.user.js
// @updateURL https://update.greasyfork.org/scripts/31070/WaniKani%20Pitch%20Info.meta.js
// ==/UserScript==

var wkof = null;

(function() {
  'use strict';
  /* global wkItemInfo */
  /* eslint no-multi-spaces: off */
  wkof = unsafeWindow.wkof;

  const SHOW_PITCH_DESCRIPTION = true;
  const SQUASH_DIGRAPHS        = false;
  const PRE_PARSE              = false; // load entire "accents.txt" into an object for faster lookup (true: lookup takes ~0.06ms; false: lookup takes ~0.5ms)
  const DOT_RADIUS             = 0.2;
  const STROKE_WIDTH           = 0.1;

  const WEB_URL = 'http://www.gavo.t.u-tokyo.ac.jp/ojad/search/index/curve:fujisaki/word:%s';
  let digraphs = 'ぁぃぅぇぉゃゅょゎゕゖァィゥェォャュョヮヵヶ';

  let pitchLookup = null;

  // Get the color and the pitch pattern name
  let patternObj = {
    heiban: {
      name: '平板',
      nameEng: 'heiban',
      cssClass: 'heiban',
      color: '#d20ca3',
    },
    odaka: {
      name: '尾高',
      nameEng: 'odaka',
      cssClass: 'odaka',
      color: '#0cd24d',
    },
    nakadaka: {
      name: '中高',
      nameEng: 'nakadaka',
      cssClass: 'nakadaka',
      color: '#27a2ff',
    },
    atamadaka: {
      name: '頭高',
      nameEng: 'atamadaka',
      cssClass: 'atamadaka',
      color: '#EA9316',
    },
    unknown: {
      name: '不詳',
      nameEng: 'No pitch value found, click the number for more info.',
      cssClass: 'unknown',
      color: '#CCCCCC',
    },
  };

  const JAPANESE_TO_WORD_TYPE = {
    名: 'Noun',
    代: 'Pronoun',
    副: 'Adverb',
    形動: 'な Adjective',
    感: 'Interjection'
  };

  // Check for WaniKani Open Framework
  if (!wkof) {
      console.warn('WaniKani Pitch Info has extra features enabled by the WK Open Framework.\n Visit: https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549 to install.');
      startup();
  }
  else {
      wkof.include('Menu,Settings');
      wkof.ready('Menu,Settings')
          .then(install_menu)
          .then(startup).then(setupInjectPitchIntoReviewQuestionArea);

      function install_menu() {
          try {
              wkof.Menu.insert_script_link({
                  name:      'wanikani_pitch_info',
                  submenu:   'Settings',
                  title:     'WaniKani Pitch Info',
                  on_click:  open_settings
              });
          } catch (e) {
              console.error(e);
          }

          wkof.Settings.load('wanikani_pitch_info');
      }

      function open_settings() {
          var config = {
              script_id: 'wanikani_pitch_info',
              title: 'WaniKani Pitch Info Settings',
              autosave: true,
              content: {
                  display_pitch_beside_question: {
                      type: 'checkbox',
                      label: 'Display pitch beside question',
                      default: false,
                      hover_tip: 'After successfully completing a reading review, display pitch beside the question.',
                  },
              }
          }
          var dialog = new wkof.Settings(config);
          dialog.open();
      }

      function setupInjectPitchIntoReviewQuestionArea() {
          // Injects pitch accent and reading into question area.
          // Pitch is only displayed when the user enters a correct reading
          window.wkPitchInfoScriptObjectsToRemove = [];
          window.addEventListener('didAnswerQuestion', (ev) => {
              console.log("setup listener");
              // didAnswerQuestion will be triggered whenever the user answers a question
              if (wkof.settings.wanikani_pitch_info?.display_pitch_beside_question && ev.detail.questionType == 'reading' && ev.detail.results.action == 'pass') {
                  let divQuestion = document.querySelector("#turbo-body > div.quiz > div > div.character-header.character-header--vocabulary > div > div.character-header__characters");
                  if (!divQuestion) return;

                  // Check if pitch info has already been added to avoid duplicates
                  if (divQuestion.querySelector('.question-pitch-display')) return;

                  wkItemInfo.currentState.reading.forEach(reading => {
                      console.log("reading");
                      // Create a white box in the question area
                      var divOuter = document.createElement("div");
                      divOuter.setAttribute('class', 'additional-content__content additional-content__content--open subject-section subject-section--reading subject-section--collapsible subject-section__subsection subject-readings-with-audio subject-readings-with-audio__item');

                      // Create a div to store the reading
                      var divReading = document.createElement("div");
                      divReading.setAttribute('class', 'reading-with-audio__reading question-pitch-display');
                      divReading.setAttribute('lang', 'ja');
                      divReading.innerHTML = `${reading}`;
                      divOuter.appendChild(divReading);

                      divQuestion.insertAdjacentElement('afterend', divOuter);
                      window.wkPitchInfoScriptObjectsToRemove.push(divOuter);

                      injectPitchInfoToSingleElement(wkItemInfo.currentState, divReading);
                  });
              }
          })

          // Cleans up the objects that we inject into the question area
          window.addEventListener('willShowNextQuestion', (ev) => {
              // willShowNextQuestion will be triggered whenever a new question is to be loaded
              // Register a callback here to clean up the pitches that we insert into the question area.
              window.wkPitchInfoScriptObjectsToRemove.forEach(pObject => {
                  while (pObject.firstChild) { pObject.removeChild(pObject.firstChild); }
                  pObject.remove();
              });
              window.wkPitchInfoScriptObjectsToRemove = [];
          })
      }
  }
  function startup() {
    wkItemInfo.forType('vocabulary').under('reading').notifyWhenVisible(injectPitchInfo);
    wkItemInfo.forType('kanaVocabulary').under('meaning').notifyWhenVisible(injectPitchInfo);
    addCss();
    loadWhileIdle();
  }

  function injectPitchInfoToSingleElement(injectorState, pReading) {
    let reading = pReading.textContent;
    let pitchInfo = getPitchInfo(injectorState.characters, injectorState.type === 'kanaVocabulary' ? '' : reading);
    if (!pitchInfo) return;
    let dInfo = null;
    let wordTypes = [...new Set([...pitchInfo.matchAll(/[\(;]([^\);]*)/g)].flatMap(r => r[1]))];
    if (wordTypes.length > 0) {
      let wordTypeToPitch = wordTypes.map(w => [w, [...pitchInfo.matchAll(new RegExp(w + '[^\\)]*\\)([\\d,]+)', 'g'))].flatMap(r => r[1]).join('').split(',').filter(p => p).map(p => parseInt(p))]);
      dInfo = appendPitchPatternInfo(pReading, pitchByWordTypeToInfoElements(wordTypeToPitch, injectorState.characters, reading));
      pitchInfo = [...new Set([...pitchInfo.matchAll(/\d/g)].map(r => r[0]))].map(p => parseInt(p));
    } else {
      pitchInfo = pitchInfo.split(',').map(p => parseInt(p));
      dInfo = appendPitchPatternInfo(pReading, pitchToInfoElements(pitchInfo, injectorState.characters, reading));
    }
    let diagrams = pitchInfo.map(p => drawPitchDiagram(p, reading));
    pReading.before(...diagrams);
    if ("injector" in injectorState) {
      [...diagrams, dInfo].forEach(d => { if (d) injectorState.injector.registerAppendedElement(d); });
    }
    makeMonospaced(pReading.childNodes[0]);
  }

  function injectPitchInfo(injectorState) {
    document.querySelectorAll('.pronunciation-variant:not(.question-pitch-display), .subject-readings-with-audio__reading:not(.question-pitch-display), .reading-with-audio__reading:not(.question-pitch-display)').forEach(pReading => {
      injectPitchInfoToSingleElement(injectorState, pReading);
    });
  }

  function pitchByWordTypeToInfoElements(wordTypeToPitch, vocab, reading) {
    let result = wordTypeToPitch.flatMap(([wordType, pitch]) => [`${JAPANESE_TO_WORD_TYPE[wordType]}: `, ...pitchToInfoElements(pitch, vocab, reading), ', ']);
    result.pop();
    return result;
  }

  function pitchToInfoElements(pitch, vocab, reading) {
    return pitch.flatMap((p, i) => [i === 0 ? '' : ' or ', generatePatternText(p, vocab, reading)]);
  }

  function appendPitchPatternInfo(readingElement, infoElements) {
    if (!SHOW_PITCH_DESCRIPTION) return null;
    let dInfo = document.createElement('div');
    let hInfo = document.createElement('h3');
    let pInfo = document.createElement('p');
    hInfo.textContent = 'Pitch Pattern';
    dInfo.classList.add('pitch-pattern');
    pInfo.append(...infoElements);
    dInfo.append(hInfo, pInfo);
    readingElement.after(dInfo);
    return dInfo;
  }

  function loadWhileIdle() {
    // for some reason, requestIdleCallback executes loadPitchInfo() while the page is still loading => artificially delay it with setTimeout
    window.setTimeout(() => {
      if (window.requestIdleCallback) window.requestIdleCallback(loadPitchInfo);
      else loadPitchInfo();
    }, 4000);
  }

  function loadPitchInfo() {
    if (pitchLookup) return;
    let accents = GM_getResourceText('accents');
    if (!PRE_PARSE || wkItemInfo.currentState.on === 'itemPage') {
      pitchLookup = (vocab, reading) => pitchLookupTextfile(vocab, reading, accents);
      return;
    }
    let lookupObject = {};
    let matches = accents.matchAll(/^([^\t]+\t[^\t]+)\t(.+)$/gm);
    for (const match of matches) lookupObject[match[1]] = match[2];             // fastest
//  let matches = [...accents.matchAll(/^([^\t]+\t[^\t]+)\t(.+)$/gm)];
//  lookupObject = matches.reduce((o, m) => { o[m[1]] = m[2]; return o; }, {}); // faster
//  lookupObject = Object.fromEntries(matches.map(m => [m[1], m[2]]));          // slower
    pitchLookup = (vocab, reading) => pitchLookupObject(vocab, reading, lookupObject);
  }

  function pitchLookupTextfile(vocab, reading, accents) {
    let key = `\n${vocab}\t${reading}\t`;
    let start = accents.indexOf(key);
    if (start < 0) return null;
    start += key.length;
    let end = accents.indexOf('\n', start);
    return accents.substring(start, end);
  }

  function pitchLookupObject(vocab, reading, lookupObject) {
    return lookupObject[vocab + '\t' + reading];
  }

  function getPitchInfo(vocab, reading) {
    loadPitchInfo();
    let result = pitchLookup(vocab, reading);
    if (!result) result = pitchLookup(vocab.replace(/する$/, ''), reading.replace(/する$/, ''));
    if (!result) result = pitchLookup(toHiragana(vocab), toHiragana(reading));
    if (!result) result = pitchLookup(toKatakana(vocab), toKatakana(reading));
    return result;
  }

  function toHiragana(kana) {
    let arr = [...kana];
    return arr.map(c => c.charCodeAt(0)).map(c => (12449 <= c && c <= 12534) ? c - 96 : c).map(c => String.fromCharCode(c)).join('');
  }

  function toKatakana(kana) {
    let arr = [...kana];
    return arr.map(c => c.charCodeAt(0)).map(c => (12353 <= c && c <= 12438) ? c + 96 : c).map(c => String.fromCharCode(c)).join('');
  }

  function getPitchType(pitchNum, moraCount) {
    if (pitchNum == 0) return patternObj.heiban;
    if (pitchNum == 1) return patternObj.atamadaka;
    if (pitchNum == moraCount) return patternObj.odaka;
    if (pitchNum < moraCount) return patternObj.nakadaka;
    return patternObj.unknown;
  }

  function getMoraCount(reading) {
    return [...reading].filter(c => !digraphs.includes(c)).length;
  }

  function drawPitchDiagram(pitchNum, reading) {
    let moraCount = getMoraCount(reading);
    let width = SQUASH_DIGRAPHS ? moraCount : reading.length;
    let patternType = getPitchType(pitchNum, moraCount);

    let namespace = 'http://www.w3.org/2000/svg';
    let svg = document.createElementNS(namespace, 'svg');
    svg.setAttribute('viewBox', `-0.5 -0.25 ${width + 1} 1.5`);

    let xCoords = [];
    for (let i = 0; i <= reading.length; i++) { // using "<=" to get additional iteration for particle node
      if (!SQUASH_DIGRAPHS && digraphs.includes(reading[i])) {
        xCoords[xCoords.length - 1] += 0.5;
      } else {
        xCoords.push(i);
      }
    }
    let yCoords = new Array(moraCount + 1).fill(null);
    yCoords = yCoords.map((_, i) => {
      if (pitchNum == 0) return i === 0 ? 1 : 0;
      if (i + 1 == pitchNum) return 0;
      if (i === 0) return 1;
      return i < pitchNum ? 0 : 1;
    });
    let points = yCoords.map((y, i) => ({x: xCoords[i], y}));

    let polyline = document.createElementNS(namespace, 'polyline');
    polyline.setAttribute('fill', 'none');
    polyline.setAttribute('stroke', patternType.color);
    polyline.setAttribute('stroke-width', STROKE_WIDTH);
    polyline.setAttribute('points', points.map(p => `${p.x},${p.y}`).join(' '));
    svg.appendChild(polyline);

    points.forEach((p, i) => {
      let isParticle = i === points.length - 1;
      let circle = document.createElementNS(namespace, 'circle');
      circle.setAttribute('fill', isParticle ? '#eeeeee' : patternType.color);
      circle.setAttribute('stroke', isParticle ? 'black' : patternType.color);
      circle.setAttribute('stroke-width', isParticle ? STROKE_WIDTH / 2 : 0);
      circle.setAttribute('cx', p.x);
      circle.setAttribute('cy', p.y);
      circle.setAttribute('r', DOT_RADIUS);
      svg.appendChild(circle);
    });
    let p = document.createElement('p');
    p.classList.add('pitch-diagram');
    p.lang = 'ja'; // to match the WK CSS selector containing the reading font size
    p.appendChild(svg);
    return p;
  }

  function generatePatternText(pitchNum, vocab, reading) {
    let moraCount = getMoraCount(reading);
    let patternType = getPitchType(pitchNum, moraCount);
    let sName = document.createElement('span');
    let aLink = document.createElement('a');
    aLink.href = WEB_URL.replace('%s', vocab);
    aLink.target = '_blank';
    aLink.title = `Pitch Pattern: ${patternType.nameEng} (${patternType.name})`;
    aLink.textContent = `[${pitchNum}]`;
    sName.textContent = patternType.name + ' ';
    sName.classList.add(patternType.cssClass);
    sName.appendChild(aLink);
    return sName;
  }

  function makeMonospaced(textNode) {
    let characters = [...textNode.textContent];
    if (SQUASH_DIGRAPHS) {
      characters.forEach((c, i, a) => { if (digraphs.includes(c)) a[i - 1] += c; });
      characters = characters.filter(c => !digraphs.includes(c));
    }
    let spans = characters.map(c => {
      let span = document.createElement('span');
      span.textContent = c;
      span.classList.toggle('digraph', c.length > 1);
      return span;
    });
    textNode.replaceWith(...spans);
  }

  function addCss() {
    let style = document.createElement('style');
    style.textContent = `
      .pronunciation-group svg           , .subject-readings-with-audio__item svg             { height: 1.5em; width: auto; display: block; }
      .pronunciation-variant             , .subject-readings-with-audio__reading              { line-height: 2.2em; margin: 0; }
      .pronunciation-variant span        , .subject-readings-with-audio__reading span         { width: 1em; display: inline-block; text-align: center; white-space: nowrap; }
      .pronunciation-variant span.digraph, .subject-readings-with-audio__reading span.digraph { font-feature-settings: 'hwid' on; }
      .pitch-pattern                                                                          { display: flex; margin-bottom: 0; color: #999; text-transform: uppercase; }
      .pitch-pattern h3, #item-info .pitch-pattern h3                                         { margin: 0 1em 0 0; padding: 0; font-size: 11px; font-weight: bold; letter-spacing: 0; border-bottom: none; line-height: 1.6em; }
      .pitch-pattern p                                                                        { font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 11px; flex: 1 0 auto; margin: 0; }
      .pitch-diagram.pitch-diagram.pitch-diagram.pitch-diagram                                { margin: 0; display: block; font-size: 18px; }
      .pitch-pattern + .subject-readings-with-audio__audio-items                              { margin-top: 0.6em; }
      .character-header .question-pitch-display > span                                        { color: rgb(255 255 255); }
      .character-header .additional-content__content:has(> .pitch-diagram) { background-color: rgba(0.2, 0.2, 0.2, 0.2); border-style: none; padding: 6px 8px; box-shadow: rgb(227, 227, 227) 0px 2px 4px !important; text-shadow: 0 2px black; }
      ${Object.values(patternObj).map(({color, cssClass}) => `.${cssClass} { color: ${color}; }`).join('')}`;
    document.head.appendChild(style);
  }
})();
1 Like