- // ==UserScript==
- // @name JPDB Immersion Kit Examples
- // @version 1.5
- // @description Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.
- // @author awoo
- // @namespace jpdb-immersion-kit-examples
- // @match https://jpdb.io/review*
- // @match https://jpdb.io/vocabulary/*
- // @match https://jpdb.io/kanji/*
- // @grant GM_addElement
- // @grant GM_xmlhttpRequest
- // @license MIT
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- const CONFIG = {
- IMAGE_WIDTH: '400px',
- ENABLE_EXAMPLE_TRANSLATION: true,
- SENTENCE_FONT_SIZE: '120%',
- TRANSLATION_FONT_SIZE: '85%',
- COLORED_SENTENCE_TEXT: true,
- AUTO_PLAY_SOUND: true,
- SOUND_VOLUME: 0.8,
- NUM_PRELOADS: 1,
- WIDE_MODE: true,
- PAGE_MAX_WIDTH: '75rem'
-
- };
-
- const state = {
- currentExampleIndex: 0,
- examples: [],
- apiDataFetched: false,
- vocab: '',
- embedAboveSubsectionMeanings: false,
- preloadedIndices: new Set(),
- currentAudio: null,
- exactSearch: true
- };
-
- function getImmersionKitData(vocab, exactSearch, callback) {
- const searchVocab = exactSearch ? `「${vocab}」` : vocab;
- const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${encodeURIComponent(searchVocab)}&sort=shortness`;
-
- GM_xmlhttpRequest({
- method: "GET",
- url: url,
- onload: function(response) {
- if (response.status === 200) {
- try {
- const jsonData = JSON.parse(response.responseText);
- if (jsonData.data && jsonData.data[0] && jsonData.data[0].examples) {
- state.examples = jsonData.data[0].examples;
- state.apiDataFetched = true;
- }
- } catch (e) {
- console.error('Error parsing JSON response:', e);
- }
- }
- callback();
- },
- onerror: function(error) {
- console.error('Error fetching data:', error);
- callback();
- }
- });
- }
-
- function getStoredData(key) {
- const storedValue = localStorage.getItem(key);
- if (storedValue) {
- const [index, exactState] = storedValue.split(',');
- return {
- index: parseInt(index, 10),
- exactState: exactState === '1'
- };
- }
- return { index: 0, exactState: state.exactSearch };
- }
-
- function storeData(key, index, exactState) {
- const value = `${index},${exactState ? 1 : 0}`;
- localStorage.setItem(key, value);
- }
-
- function exportFavorites() {
- const favorites = {};
- for (let i = 0; i < localStorage.length; i++) {
- const key = localStorage.key(i);
- favorites[key] = localStorage.getItem(key);
- }
- const blob = new Blob([JSON.stringify(favorites, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = 'favorites.json';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
-
- function importFavorites(event) {
- const file = event.target.files[0];
- if (!file) return;
-
- const reader = new FileReader();
- reader.onload = function(e) {
- try {
- const favorites = JSON.parse(e.target.result);
- for (const key in favorites) {
- localStorage.setItem(key, favorites[key]);
- }
- alert('Favorites imported successfully!');
- } catch (error) {
- alert('Error importing favorites:', error);
- }
- };
- reader.readAsText(file);
- }
-
- function parseVocabFromAnswer() {
- const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
- for (const element of elements) {
- const href = element.getAttribute('href');
- const text = element.textContent.trim();
- const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
- if (match) return match[2].trim();
- if (text) return text.trim();
- }
- return '';
- }
-
- function parseVocabFromReview() {
- const kindElement = document.querySelector('.kind');
- if (!kindElement) return '';
-
- const kindText = kindElement.textContent.trim();
- if (kindText !== 'Kanji' && kindText !== 'Vocabulary') return '';
-
- if (kindText === 'Vocabulary') {
- const plainElement = document.querySelector('.plain');
- if (!plainElement) return '';
-
- let vocabulary = plainElement.textContent.trim();
- const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
- if (nestedVocabularyElement) {
- vocabulary = nestedVocabularyElement.textContent.trim();
- }
- const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
- if (specificVocabularyElement) {
- vocabulary = specificVocabularyElement.textContent.trim();
- }
-
- const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
- if (kanjiRegex.test(vocabulary) || vocabulary) {
- return vocabulary;
- }
- } else if (kindText === 'Kanji') {
- const hiddenInput = document.querySelector('input[name="c"]');
- if (!hiddenInput) return '';
-
- const vocab = hiddenInput.value.split(',')[1];
- const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
- if (kanjiRegex.test(vocab)) {
- return vocab;
- }
- }
- return '';
- }
-
- function parseVocabFromVocabulary() {
- const url = window.location.href;
- const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#]*)#a/);
- if (match) {
- let vocab = match[2];
- state.embedAboveSubsectionMeanings = true;
- vocab = vocab.split('/')[0];
- return decodeURIComponent(vocab);
- }
- return '';
- }
-
- function parseVocabFromKanji() {
- const url = window.location.href;
- const match = url.match(/https:\/\/jpdb\.io\/kanji\/([^#]*)#a/);
- if (match) {
- return decodeURIComponent(match[1]);
- }
- return '';
- }
-
- function highlightVocab(sentence, vocab) {
- if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
-
- if (state.exactSearch) {
- const regex = new RegExp(`(${vocab})`, 'g');
- return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
- } else {
- return vocab.split('').reduce((acc, char) => {
- const regex = new RegExp(char, 'g');
- return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
- }, sentence);
- }
- }
-
- function createIconLink(iconClass, onClick, marginLeft = '0') {
- const link = document.createElement('a');
- link.href = '#';
- link.style.border = '0';
- link.style.display = 'inline-flex';
- link.style.verticalAlign = 'middle';
- link.style.marginLeft = marginLeft;
-
- const icon = document.createElement('i');
- icon.className = iconClass;
- icon.style.fontSize = '1.4rem';
- icon.style.opacity = '1.0';
- icon.style.verticalAlign = 'baseline';
- icon.style.color = '#3d81ff';
-
- link.appendChild(icon);
- link.addEventListener('click', onClick);
- return link;
- }
-
- function createQuoteButton() {
- const link = document.createElement('a');
- link.href = '#';
- link.style.border = '0';
- link.style.display = 'inline-flex';
- link.style.verticalAlign = 'middle';
- link.style.marginLeft = '0rem';
-
- const quoteIcon = document.createElement('span');
- quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
- quoteIcon.style.fontSize = '1.1rem';
- quoteIcon.style.color = '#3D8DFF';
- quoteIcon.style.verticalAlign = 'middle';
- quoteIcon.style.position = 'relative';
- quoteIcon.style.top = '0px';
-
- link.appendChild(quoteIcon);
- link.addEventListener('click', (event) => {
- event.preventDefault();
- state.exactSearch = !state.exactSearch;
- quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
-
- const storedData = getStoredData(state.vocab);
- if (storedData && storedData.exactState === state.exactSearch) {
- state.currentExampleIndex = storedData.index;
- } else {
- state.currentExampleIndex = 0;
- }
-
- getImmersionKitData(state.vocab, state.exactSearch, () => {
- embedImageAndPlayAudio();
- });
- });
- return link;
- }
-
- function createStarLink() {
- const link = document.createElement('a');
- link.href = '#';
- link.style.border = '0';
- link.style.display = 'inline-flex';
- link.style.verticalAlign = 'middle';
-
- const storedValue = localStorage.getItem(state.vocab);
- const starIcon = document.createElement('span');
-
- if (!storedValue) {
- starIcon.textContent = '☆';
- } else {
- const [storedIndex, storedExactState] = storedValue.split(',');
- const index = parseInt(storedIndex, 10);
- const exactState = storedExactState === '1';
- starIcon.textContent = (state.currentExampleIndex === index && state.exactSearch === exactState) ? '★' : '☆';
- }
-
- starIcon.style.fontSize = '1.4rem';
- starIcon.style.marginLeft = '0.5rem';
- starIcon.style.color = '#3D8DFF';
- starIcon.style.verticalAlign = 'middle';
- starIcon.style.position = 'relative';
- starIcon.style.top = '-2px';
-
- link.appendChild(starIcon);
- link.addEventListener('click', (event) => {
- event.preventDefault();
- const storedValue = localStorage.getItem(state.vocab);
- if (storedValue) {
- const [storedIndex, storedExactState] = storedValue.split(',');
- const index = parseInt(storedIndex, 10);
- const exactState = storedExactState === '1';
- if (index === state.currentExampleIndex && exactState === state.exactSearch) {
- localStorage.removeItem(state.vocab);
- } else {
- localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
- }
- } else {
- localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
- }
- // Refresh the embed without playing the audio
- renderImageAndPlayAudio(state.vocab, false);
- });
- return link;
- }
-
- function createExportImportButtons() {
- const exportButton = document.createElement('button');
- exportButton.textContent = 'Export Favorites';
- exportButton.style.marginRight = '10px';
- exportButton.addEventListener('click', exportFavorites);
-
- const importButton = document.createElement('button');
- importButton.textContent = 'Import Favorites';
- importButton.addEventListener('click', () => {
- const fileInput = document.createElement('input');
- fileInput.type = 'file';
- fileInput.accept = 'application/json';
- fileInput.addEventListener('change', importFavorites);
- fileInput.click();
- });
-
- const buttonContainer = document.createElement('div');
- buttonContainer.style.textAlign = 'center';
- buttonContainer.style.marginTop = '10px';
- buttonContainer.append(exportButton, importButton);
-
- document.body.appendChild(buttonContainer);
- }
-
- function playAudio(soundUrl) {
- if (soundUrl) {
- // Stop the current audio if it's playing
- if (state.currentAudio) {
- state.currentAudio.pause();
- state.currentAudio.src = '';
- }
-
- // Fetch the audio file as a blob and create a blob URL
- GM_xmlhttpRequest({
- method: 'GET',
- url: soundUrl,
- responseType: 'blob',
- onload: function(response) {
- const blobUrl = URL.createObjectURL(response.response);
- const audioElement = new Audio(blobUrl);
- audioElement.volume = CONFIG.SOUND_VOLUME; // Adjust volume as needed
- audioElement.play();
- state.currentAudio = audioElement; // Keep reference to the current audio
- },
- onerror: function(error) {
- console.error('Error fetching audio:', error);
- }
- });
- }
- }
-
- function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
- const example = state.examples[state.currentExampleIndex] || {};
- const imageUrl = example.image_url || null;
- const soundUrl = example.sound_url || null;
- const sentence = example.sentence || null;
-
- const resultVocabularySection = document.querySelector('.result.vocabulary');
- const hboxWrapSection = document.querySelector('.hbox.wrap');
- const subsectionMeanings = document.querySelector('.subsection-meanings');
- const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
- const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
- const subsectionLabels = document.querySelectorAll('h6.subsection-label');
- const vboxGap = document.querySelector('.vbox.gap');
-
- // Remove any existing container before creating a new one
- const existingContainer = document.getElementById('immersion-kit-container');
- if (existingContainer) {
- existingContainer.remove();
- }
-
- if (resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3) {
- const wrapperDiv = document.createElement('div');
- wrapperDiv.id = 'image-wrapper';
- wrapperDiv.style.textAlign = 'center';
- wrapperDiv.style.padding = '5px 0';
-
- const textDiv = document.createElement('div');
- textDiv.style.marginBottom = '5px';
- textDiv.style.lineHeight = '1.4rem';
-
- const contentText = document.createElement('span');
- contentText.textContent = 'Immersion Kit';
- contentText.style.color = 'var(--subsection-label-color)';
- contentText.style.fontSize = '85%';
- contentText.style.marginRight = '0.5rem';
- contentText.style.verticalAlign = 'middle';
-
- const speakerLink = createIconLink('ti ti-volume', (event) => {
- event.preventDefault();
- playAudio(soundUrl);
- }, '0.5rem');
-
- const starLink = createStarLink();
- const quoteButton = createQuoteButton(); // Create the quote button
-
- textDiv.append(contentText, speakerLink, starLink, quoteButton); // Append the quote button
- wrapperDiv.appendChild(textDiv);
-
- if (imageUrl) {
- const imageElement = GM_addElement(wrapperDiv, 'img', {
- src: imageUrl,
- alt: 'Embedded Image',
- style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
- });
-
- if (imageElement) {
- imageElement.addEventListener('click', () => {
- speakerLink.click();
- });
- }
-
- if (sentence) {
- const sentenceText = document.createElement('div');
- sentenceText.innerHTML = highlightVocab(sentence, vocab);
- sentenceText.style.marginTop = '10px';
- sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
- sentenceText.style.color = 'lightgray';
- sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
- sentenceText.style.whiteSpace = 'pre-wrap';
- wrapperDiv.appendChild(sentenceText);
-
- if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && example.translation) {
- const translationText = document.createElement('div');
- translationText.innerHTML = replaceSpecialCharacters(example.translation);
- translationText.style.marginTop = '5px';
- translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
- translationText.style.color = 'var(--subsection-label-color)';
- translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
- translationText.style.whiteSpace = 'pre-wrap';
- wrapperDiv.appendChild(translationText);
- }
- } else {
- const noneText = document.createElement('div');
- noneText.textContent = 'None';
- noneText.style.marginTop = '10px';
- noneText.style.fontSize = '85%';
- noneText.style.color = 'var(--subsection-label-color)';
- wrapperDiv.appendChild(noneText);
- }
- }
-
- // Create a fixed-width container for arrows and image
- const navigationDiv = document.createElement('div');
- navigationDiv.id = 'immersion-kit-embed';
- navigationDiv.style.display = 'flex';
- navigationDiv.style.justifyContent = 'center';
- navigationDiv.style.alignItems = 'center';
- navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
- navigationDiv.style.margin = '0 auto';
-
- const leftArrow = document.createElement('button');
- leftArrow.textContent = '<';
- leftArrow.style.marginRight = '10px';
- leftArrow.disabled = state.currentExampleIndex === 0;
- leftArrow.addEventListener('click', () => {
- if (state.currentExampleIndex > 0) {
- state.currentExampleIndex--;
- renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
- preloadImages();
- }
- });
-
- const rightArrow = document.createElement('button');
- rightArrow.textContent = '>';
- rightArrow.style.marginLeft = '10px';
- rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
- rightArrow.addEventListener('click', () => {
- if (state.currentExampleIndex < state.examples.length - 1) {
- state.currentExampleIndex++;
- renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
- preloadImages();
- }
- });
-
- const embedWrapper = document.createElement('div');
- embedWrapper.style.flex = '0 0 auto';
- embedWrapper.style.marginLeft = '10px';
- embedWrapper.appendChild(navigationDiv);
-
- const containerDiv = document.createElement('div');
- containerDiv.id = 'immersion-kit-container'; // Added ID for targeting
- containerDiv.style.display = 'flex';
- containerDiv.style.alignItems = 'center';
- containerDiv.style.justifyContent = CONFIG.WIDE_MODE ? 'flex-start' : 'center'; // Center if wide_mode is false
- containerDiv.append(leftArrow, wrapperDiv, rightArrow, embedWrapper);
-
- if (CONFIG.WIDE_MODE && subsectionMeanings) {
- const wrapper = document.createElement('div');
- wrapper.style.display = 'flex';
- wrapper.style.alignItems = 'flex-start';
-
- const originalContentWrapper = document.createElement('div');
- originalContentWrapper.style.flex = '1';
- originalContentWrapper.appendChild(subsectionMeanings);
-
- // Append the new subsections with a newline before each
- if (subsectionComposedOfKanji) {
- const newline1 = document.createElement('br');
- originalContentWrapper.appendChild(newline1);
- originalContentWrapper.appendChild(subsectionComposedOfKanji);
- }
- if (subsectionPitchAccent) {
- const newline2 = document.createElement('br');
- originalContentWrapper.appendChild(newline2);
- originalContentWrapper.appendChild(subsectionPitchAccent);
- }
-
- wrapper.appendChild(originalContentWrapper);
- wrapper.appendChild(containerDiv);
-
- if (vboxGap) {
- // Remove only the dynamically added div under vboxGap
- const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
- if (existingDynamicDiv) {
- existingDynamicDiv.remove();
- }
-
- const dynamicDiv = document.createElement('div');
- dynamicDiv.id = 'dynamic-content';
- dynamicDiv.appendChild(wrapper);
-
- // Insert after the first child if the URL contains vocabulary
- if (window.location.href.includes('vocabulary')) {
- vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
- } else {
- vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild); // Insert at the top
- }
- }
- } else {
- if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
- subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
- } else if (resultVocabularySection) {
- resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
- } else if (hboxWrapSection) {
- hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
- } else if (subsectionLabels.length >= 4) {
- subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
- }
- }
- }
-
- if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
- playAudio(soundUrl);
- }
- }
-
- function embedImageAndPlayAudio() {
- const existingNavigationDiv = document.getElementById('immersion-kit-embed');
- if (existingNavigationDiv) existingNavigationDiv.remove();
-
- const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
-
- if (state.vocab && !state.apiDataFetched) {
- getImmersionKitData(state.vocab, state.exactSearch, () => {
- renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
- preloadImages();
- });
- } else {
- renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
- preloadImages();
- }
- }
-
- function replaceSpecialCharacters(text) {
- return text.replace(/<br>/g, '\n').replace(/"/g, '"').replace(/\n/g, '<br>');
- }
-
- function preloadImages() {
- const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
-
- for (let i = Math.max(0, state.currentExampleIndex - CONFIG.NUM_PRELOADS); i <= Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUM_PRELOADS); i++) {
- if (!state.preloadedIndices.has(i)) {
- const example = state.examples[i];
- if (example.image_url) {
- GM_addElement(preloadDiv, 'img', { src: example.image_url });
- state.preloadedIndices.add(i);
- }
- }
- }
- }
-
- function onUrlChange() {
- state.embedAboveSubsectionMeanings = false;
- if (window.location.href.includes('/vocabulary/')) {
- state.vocab = parseVocabFromVocabulary();
- } else if (window.location.href.includes('c=')) {
- state.vocab = parseVocabFromAnswer();
- } else if (window.location.href.includes('/kanji/')) {
- state.vocab = parseVocabFromKanji();
- } else {
- state.vocab = parseVocabFromReview();
- }
-
- const storedData = getStoredData(state.vocab);
- state.currentExampleIndex = storedData.index;
- state.exactSearch = storedData.exactState;
-
- const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
- const shouldAutoPlaySound = !reviewUrlPattern.test(window.location.href);
-
- if (state.vocab) {
- embedImageAndPlayAudio();
- }
- }
-
- const observer = new MutationObserver(() => {
- if (window.location.href !== observer.lastUrl) {
- observer.lastUrl = window.location.href;
- onUrlChange();
-
- setPageWidth();
- }
- });
-
- function setPageWidth(){
- if (CONFIG.WIDE_MODE) {
- document.body.style.maxWidth = CONFIG.PAGE_MAX_WIDTH;
- } };
-
- observer.lastUrl = window.location;
- observer.lastUrl = window.location.href;
- observer.observe(document, { subtree: true, childList: true });
-
- window.addEventListener('load', onUrlChange);
- window.addEventListener('popstate', onUrlChange);
- window.addEventListener('hashchange', onUrlChange);
-
-
- setPageWidth();
- createExportImportButtons();
- })();