您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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 })();