// ==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();
})();