WaniKani Vocab Note Linker

Creates links for vocabulary in the Meaning Note and Reading Note sections.

// ==UserScript==
// @name         WaniKani Vocab Note Linker
// @namespace    http://tampermonkey.net/
// @description  Creates links for vocabulary in the Meaning Note and Reading Note sections.
// @version      1.9.8
// @author       Mark Hennessy
// @match        https://www.wanikani.com/kanji/*
// @match        https://www.wanikani.com/vocabulary/*
// @license      MIT
// ==/UserScript==

/*
WaniKani Vocab Note Linker
==
Creates links for vocabulary referenced in the **Meaning Note** and **Reading Note**
sections.

Also adds an update button to auto-update notes when updates are available. The
updates are not saved until you open the note and click the `Save` button.
Clicking `Cancel` or refreshing the page will undo the changes.

I created this script as a productivity tool for myself and my own kanji learning process.

Example Meaning Note
==
木材(もくざい)Wood, Lumber<br>
材木(ざいもく)Lumber, Timber, Wood

Some text

Constraints & Limitations
==
* The script only works for vocabulary at the start of each new line
* The script only works for vocabulary immediately followed by a Japanese opening parenthesis `(`
* The `All` link will only work if you enable multiple popups/tabs in your browser settings
* The `Update note` link requires the WaniKani Open Framework UserScript to be installed
* Tampermonkey should be configured to load WaniKani Open Framework as the first UserScript, or at least before this one

Enable multiple popups/tabs in Chrome
==
1. Click the `All` link
2. Check the URL bar for a notification icon telling you that popups were blocked
3. Click the icon and tell chrome to stop blocking popups from WaniKani

Useful Links
==
* [GreasyFork - WaniKani Vocab Note Linker](https://greasyfork.org/en/scripts/392752-wanikani-vocab-note-linker)
* [GreasyFork - WaniKani Open Framework](https://greasyfork.org/en/scripts/38582-wanikani-open-framework)
* [GitHub](https://github.com/mark-hennessy/wanikani-vocab-note-linker)

License
==
MIT
*/

(async function () {
  const isWaniKani = window.location.host === 'www.wanikani.com';

  const pathParts = decodeURI(window.location.pathname)
    .split('/')
    .filter(Boolean);

  const currentSubjectType = isWaniKani ? pathParts[0] : 'vocabulary';
  const isKanji = currentSubjectType === 'kanji';

  const currentSlug = isWaniKani ? pathParts[1] : '大変';

  const noteLineDelimiter = '\n';

  const CSS = `
  .vnl-button {
    padding: 0 6px;
    background-image: linear-gradient(to bottom, #fff, #e6e6e6);
    font-family: "Ubuntu", Helvetica, Arial, sans-serif;
    font-size: 10.5px;
    line-height: 20px;
    color: #333;
    border: 1px solid #ccc;
    border-radius: 3px;
    cursor: pointer;
  }

  .vnl-link-section {
    margin-top: 30px;
  }

  .vnl-link {
    font-size: 14px;
    line-height: 1.5rem;
    color: #08c;
    text-decoration: none;
    margin-right: 15px;
  }

  .vnl-link--current {
    color: #666666;
  }

  .vnl-link:hover, .vnl-link:focus {
    color: #005580;
    text-decoration: underline;
  }

  .vnl-hidden {
    display: none;
  }

  /* add space between paragraphs in the WK Rich Text Editor */
  .user-note__text {
    margin-bottom: 20px;
  }
`;

  injectStyle(CSS);

  const noteElements = getNoteElements();

  // do this first, before calling getSlugDB, to reduce flashing
  for (const noteElement of noteElements) {
    noteElement.parentElement?.setAttribute('lang', 'ja');
  }

  injectCopyButton();

  // slugDB will be globally available to other functions
  const slugDB = await getSlugDB();

  for (const noteElement of noteElements) {
    injectLinkSection(noteElement);
    injectUpdateNoteButton(noteElement);
  }

  function getNoteElements() {
    const noteSelectors = ['#user_meaning_note', '#user_reading_note'];

    const noteElements = noteSelectors
      .map((noteSelector) => document.querySelector(noteSelector))
      .filter(Boolean);

    return noteElements;
  }

  function injectCopyButton() {
    const siblingElement = document.querySelector('.page-nav');
    if (!siblingElement) {
      return;
    }

    const button = getOrCreateElement({
      tagName: 'button',
      selectorClass: 'vnl-copy-button',
      classes: 'vnl-button',
      siblingElement,
      attributes: {
        type: 'button',
      },
    });

    const initialButtonText = 'Copy';
    button.innerHTML = initialButtonText;
    button.onclick = () => {
      const entry = screenScrapeCurrentEntry();
      const entryLine = createEntryLine(entry);

      navigator.clipboard.writeText(entryLine);

      button.innerHTML = getNewButtonText(button, initialButtonText, 'Copied');
    };
  }

  function screenScrapeCurrentEntry() {
    const primaryMeanings = document.querySelector(
      '.subject-section--meaning .subject-section__meanings:nth-of-type(1) .subject-section__meanings-items',
    );

    const secondaryMeanings = document.querySelector(
      '.subject-section--meaning .subject-section__meanings:nth-of-type(2) .subject-section__meanings-items',
    );

    const meanings = [primaryMeanings, secondaryMeanings]
      .filter(Boolean)
      .map((el) => el.textContent.trim())
      .filter((v) => v !== 'None')
      .join(', ');

    const readingNodeList = document.querySelectorAll(
      isKanji
        ? '.subject-readings__reading-items'
        : '.reading-with-audio__reading',
    );

    const metadata = Array.from(readingNodeList)
      .map((el) => el.textContent.trim())
      .filter((v) => v !== 'None')
      .join('、');

    return {
      slug: currentSlug,
      metadata,
      meanings,
    };
  }

  function createEntryLine(entry) {
    return `${entry.slug}(${entry.metadata})${entry.meanings}`;
  }

  function injectLinkSection(noteElement) {
    // initialization
    updateLinkSection(noteElement);

    // register a DOM change handler
    registerMutationObserver(noteElement, () => {
      updateLinkSection(noteElement);
    });
  }

  function updateLinkSection(noteElement) {
    // The note, i.e. rich text editor, will never be open when this function
    // is called on initial script load, but it might be open when this function
    // is called by the DOM mutation handler.
    if (isNoteOpen(noteElement)) {
      return;
    }

    const linkSectionElement = getOrCreateElement({
      tagName: 'div',
      selectorClass: 'vnl-link-section',
      parentElement: noteElement.parentElement,
    });

    const note = getNote(noteElement);
    if (!note) {
      return;
    }

    let groups = parseGroups(note);
    groups = addAllLinks(groups);
    groups = addCopyLinks(groups);
    groups = addEverythingLink(groups);

    linkSectionElement.innerHTML = generateLinkSectionContent(groups);
  }

  function isNoteOpen(noteElement) {
    return noteElement.firstElementChild?.nodeName === 'FORM';
  }

  function getNote(noteElement) {
    return noteElement.firstElementChild?.textContent.trim();
  }

  function parseGroups(note) {
    const groups = [[]];

    const lines = splitNoteIntoLines(note);
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      const currentGroup = groups[groups.length - 1];

      const entry = parseEntry(line, i);

      if (!entry) {
        if (currentGroup.length) {
          // start a new group
          groups.push([]);
        }

        // continue to the next line
        continue;
      }

      currentGroup.push(entry);
    }

    // there may be empty groups, that need to be filtered out,
    // if the note ended in blank lines or remarks
    const groupsWithEntries = groups.filter((group) => group.length);

    return groupsWithEntries;
  }

  function splitNoteIntoLines(note) {
    return note.split(noteLineDelimiter).map((line) => line.trim());
  }

  function parseEntry(line, lineIndex) {
    // match text before a Japanese opening parenthesis and assume it's kanji
    const entryMatchResult = line.match(/^(.*)(/);
    if (!entryMatchResult) {
      return null;
    }

    const slug = entryMatchResult[1];

    // math text between Japanese opening and closing parentheses and assume
    // it's metadata
    const metadataMatchResult = line.match(/^.*((.*))/);
    const metadata = metadataMatchResult ? metadataMatchResult[1] : null;

    // match text after Japanese opening and closing parentheses and assume
    // it's a list of English meanings
    const meaningsMatchResult = line.match(/^.*(.*)(.*)/);
    const meanings = meaningsMatchResult ? meaningsMatchResult[1] : null;

    const notOnWk = /not on WK/.test(metadata);
    const override = /override/.test(metadata);
    const url = !notOnWk ? createUrl(slug) : null;
    const link = !notOnWk ? createLink(url, slug) : null;

    return {
      slug,
      metadata,
      meanings,
      url,
      link,
      notOnWk,
      override,
      lineIndex,
    };
  }

  function createUrl(slug) {
    return `https://www.wanikani.com/${currentSubjectType}/${slug}`;
  }

  function createLink(url, slug) {
    return `<a class="${cn({
      'vnl-link': true,
      'vnl-link--current': slug === currentSlug,
    })}" href="${url}" target="_blank" rel="noopener noreferrer">${slug}</a>`;
  }

  function addAllLinks(groups) {
    return groups.map((group) => {
      const entriesWithUrls = group.filter((entry) => entry.url);

      return entriesWithUrls.length > 1
        ? [...group, createAllEntry(group)]
        : group;
    });
  }

  function createAllEntry(group) {
    const urls = group
      .filter((entry) => entry.slug !== currentSlug)
      // ignore 'All' and 'not on/in WK' entries
      .filter((entry) => entry.url)
      .map((entry) => entry.url);

    const uniqueURLs = [...new Set(urls)];

    const onclick =
      uniqueURLs
        // _blank is needed for Firefox
        .map((url) => `window.open('${url}', '_blank');`)
        .join('') + 'return false;';

    const allLink = `<a class="vnl-link" href="#" onclick="${onclick}">All</a>`;

    return {
      link: allLink,
    };
  }

  function addCopyLinks(groups) {
    return groups.map((group) => {
      const entriesWithSlug = getEntriesWithSlug(group);

      // don't add the 'Copy' entry to the group at the bottom with a single
      // 'All' entry
      return entriesWithSlug.length > 0
        ? [...group, createCopyEntry(group)]
        : group;
    });
  }

  function getEntriesWithSlug(group) {
    // filter out the 'All' link entry
    return group.filter((entry) => entry.slug);
  }

  function createCopyEntry(group) {
    const entriesWithSlug = getEntriesWithSlug(group);

    const groupText = entriesWithSlug
      .map(createEntryLine)
      .join('\\n')
      // The onclick function is defined as one big string surrounded by double
      // quotes, and clipboard.writeText (inside of onclick) surrounds text
      // with single quotes, so both single quotes and double quotes inside of
      // the text need to be escaped!
      .replace(/'/g, "\\'")
      .replace(/"/g, '\\"');

    const onclick = `navigator.clipboard.writeText('${groupText}');return false;`;

    const hasLinks = entriesWithSlug.some((entry) => entry.link);

    const copyLink = `<a class="vnl-link" href="#" onclick="${onclick}">${
      hasLinks ? 'Copy' : 'Copy (not on WK)'
    }</a>`;

    return {
      link: copyLink,
    };
  }

  function addEverythingLink(groups) {
    const groupsWithAtLeastOneUrl = groups.filter(
      (group) => group.filter((entry) => entry.url).length,
    );

    return groupsWithAtLeastOneUrl.length > 1
      ? [...groups, [createEverythingEntry(groups)]]
      : groups;
  }

  function createEverythingEntry(groups) {
    return createAllEntry(groups.flatMap((group) => group));
  }

  function generateLinkSectionContent(groups) {
    return groups
      .filter((group) => group.some((entry) => entry.link))
      .map((group) => group.map((entry) => entry.link))
      .map((group) => group.join(''))
      .join('<br>');
  }

  function injectUpdateNoteButton(noteElement) {
    // initialization
    updateUpdateNoteButton(noteElement);

    // register a DOM change handler
    registerMutationObserver(noteElement, () => {
      updateUpdateNoteButton(noteElement);
    });
  }

  function updateUpdateNoteButton(noteElement) {
    const ignoreUpdateAttributeName = 'data-ignore-update';

    // if the ignore-update attribute is present, then assume
    // this is the update caused by opening the note to save or cancel
    if (noteElement.hasAttribute(ignoreUpdateAttributeName)) {
      noteElement.removeAttribute(ignoreUpdateAttributeName);
      return;
    }

    const button = getOrCreateElement({
      tagName: 'button',
      selectorClass: 'vnl-update-note-button',
      classes: 'vnl-button',
      parentElement: noteElement.parentElement,
      attributes: {
        type: 'button',
      },
    });

    function hideButton() {
      // hide the button
      button.classList.add('vnl-hidden');
    }

    if (isNoteOpen(noteElement)) {
      hideButton();
      return;
    }

    const existingNote = getNote(noteElement);
    if (!existingNote) {
      return;
    }

    const generatedNote = generateNote(existingNote);
    if (existingNote === generatedNote) {
      hideButton();
      return;
    }

    const initialButtonText = 'Update note';
    button.innerHTML = initialButtonText;
    button.classList.remove('vnl-hidden');

    button.onclick = async () => {
      noteElement.setAttribute(ignoreUpdateAttributeName, '');

      const observer = registerMutationObserver(noteElement, () => {
        // assume the textArea loaded and disconnect the observer
        observer.disconnect();

        const textArea = noteElement.querySelector('.user-note__input');
        if (textArea) {
          textArea.innerHTML = generatedNote;
        }
      });

      // click the RTE anchor element to load the textArea
      noteElement.firstElementChild.click();
    };
  }

  function generateNote(existingNote) {
    const lines = splitNoteIntoLines(existingNote);
    const groups = parseGroups(existingNote);

    const wkEntries = groups
      .flatMap((group) => group)
      .filter((entry) => !entry.notOnWk && !entry.override);

    for (const entry of wkEntries) {
      const subject = slugDB[entry.slug];

      // if no info is available, then assume the existing line is up-to-date
      if (!subject) {
        continue;
      }

      const { meanings } = subject.data;
      const generatedMeanings = [
        ...meanings.filter((m) => m.primary),
        ...meanings.filter((m) => !m.primary),
      ]
        .map((m) => m.meaning)
        .join(', ');

      const entryLine = createEntryLine({
        slug: entry.slug,
        metadata: entry.metadata,
        meanings: generatedMeanings,
      });

      const { lineIndex } = entry;
      lines[lineIndex] = entryLine;
    }

    return createNoteFromLines(lines);
  }

  function createNoteFromLines(lines) {
    return lines.join(noteLineDelimiter);
  }

  // START Utilities
  function injectStyle(css) {
    const [head] = document.getElementsByTagName('head');
    if (!head) {
      return;
    }

    const STYLE_ID = 'wk-vocab-note-linker';
    const existingStyleElement = document.getElementById(STYLE_ID);
    if (existingStyleElement) {
      return;
    }

    const styleElement = document.createElement('style');
    styleElement.setAttribute('id', STYLE_ID);
    styleElement.innerHTML = css;
    head.appendChild(styleElement);
  }

  function cn(classConfig) {
    return Object.entries(classConfig)
      .filter((entry) => entry[1])
      .map((entry) => entry[0])
      .join(' ');
  }

  function registerMutationObserver(element, mutationCallback) {
    const observer = new MutationObserver(mutationCallback);
    observer.observe(element, { childList: true, subtree: true });
    return observer;
  }

  function getOrCreateElement({
    tagName,
    selectorClass,
    classes,
    parentElement,
    siblingElement,
    attributes,
  }) {
    const selector = `.${selectorClass}`;

    let element;
    if (parentElement) {
      element = parentElement.querySelector(selector);
    } else if (siblingElement) {
      element = siblingElement.parentElement?.querySelector(selector);
    }

    if (!element) {
      element = document.createElement(tagName);
      const cn = [selectorClass, classes].filter(Boolean).join(' ');
      if (cn) {
        element.className = cn;
      }

      for (const attributeKey in attributes) {
        element.setAttribute(attributeKey, attributes[attributeKey]);
      }

      if (parentElement) {
        parentElement.appendChild(element);
      } else if (siblingElement) {
        siblingElement.after(element);
      }
    }

    return element;
  }

  function getNewButtonText(button, initialText, endText) {
    const currentText = button.innerHTML;

    if (!currentText) {
      return initialText;
    }

    if (currentText === initialText) {
      return endText;
    }

    if (currentText.startsWith(endText)) {
      return currentText + '!';
    }

    return currentText;
  }

  async function getSlugDB() {
    /* eslint-disable no-undef */
    // wkof is a global variable added by another user script
    if (typeof wkof === 'undefined') {
      return [];
    }

    // Jquery needs to be included to prevent wkof from crashing when apiv2_key
    // is not already set in local storage
    const wkofModules = 'Jquery, ItemData';
    wkof.include(wkofModules);
    await wkof.ready(wkofModules);

    const config = {
      wk_items: {
        options: { subjects: true },
        filters: {
          item_type: 'kan, voc',
        },
      },
    };

    // The WaniKani API supports an updated_after param to request data
    // that changed after a certain timestamp.
    // The Wanikani Open Framework (wkof) uses this updated_after param
    // to update it's local cache efficiently.
    const items = await wkof.ItemData.get_items(config);

    const subjectTypeDB = wkof.ItemData.get_index(items, 'item_type');
    const subjects = subjectTypeDB[currentSubjectType];
    return wkof.ItemData.get_index(subjects, 'slug');
  }
  // END Utilities
})();