您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Embeds anime images & audio examples into JPDB review and vocabulary pages using Nadeshiko's API. Compatible only with TamperMonkey.
// ==UserScript== // @name JPDB Nadeshiko Examples // @version 1.22.2 // @description Embeds anime images & audio examples into JPDB review and vocabulary pages using Nadeshiko's API. Compatible only with TamperMonkey. // @author awoo // @namespace jpdb-nadeshiko-examples // @match https://jpdb.io/review* // @match https://jpdb.io/vocabulary/* // @match https://jpdb.io/kanji/* // @match https://jpdb.io/search* // @connect api.brigadasos.xyz // @connect linodeobjects.com // @connect kanjikana.com // @grant GM_addElement // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @license MIT // ==/UserScript== /*jshint esversion: 6 */ (function () { 'use strict'; let nadeshikoApiKey = GM_getValue("nadeshiko-api-key", "") let jpdbApiKey = GM_getValue("jpdb-api-key", "") // Register menu commands GM_registerMenuCommand("Set Nadeshiko API Key", async () => { nadeshikoApiKey = fetchNadeshikoApiKey(); }); GM_registerMenuCommand("Set JPDB API Key", async () => { jpdbApiKey = fetchJPDBApiKey(); }) function fetchNadeshikoApiKey() { let apiKey = prompt("A Nadeshiko API key is required for this extension to work.\n\nYou can get one for free here after creating an account: https://nadeshiko.co/settings/developer"); GM_setValue("nadeshiko-api-key", apiKey); if (apiKey) { alert("API Key saved successfully!"); } return apiKey; } function fetchJPDBApiKey() { let apiKey = prompt("A JPDB API key is required for this extension to work.\n\nYou can get it in the settings page of your JPDB account."); GM_setValue("jpdb-api-key", apiKey); if (apiKey) { // send ping at https://jpdb.io/api/v1/ping to check if the key is valid GM_xmlhttpRequest({ method: "POST", url: "https://jpdb.io/api/v1/ping", headers: { "Authorization": `Bearer ${apiKey}`, }, onload: function (response) { if (response.status === 200) { alert("API Key saved successfully!"); } else { alert("Invalid API Key. Please check your key and try again."); } }, onerror: function (error) { alert("An error occurred while checking the API Key. Please try again."); } }); } return apiKey; } // to use custom hotkeys just add them into this array following the same format. Any single keys except space // should work. If you want to use special keys, check the linked page for how to represent them in the array // (link leads to the arrow keys part so you can compare with the array and be sure which part to write): // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#navigation_keys const hotkeyOptions = ['None', 'ArrowLeft ArrowRight', ', .', '[ ]', 'Q W']; const RANDOM_SENTENCE_ENUM = { DISABLE: 0, ON_FIRST: 1, EVERY_TIME: 2 }; const CONFIG = { IMAGE_WIDTH: '400px', WIDE_MODE: true, DEFINITIONS_ON_RIGHT_IN_WIDE_MODE: false, ARROW_WIDTH: '75px', ARROW_HEIGHT: '45px', PAGE_WIDTH: '75rem', SOUND_VOLUME: 80, ENABLE_EXAMPLE_TRANSLATION: true, SENTENCE_FONT_SIZE: '120%', TRANSLATION_FONT_SIZE: '85%', COLORED_SENTENCE_TEXT: true, AUTO_PLAY_SOUND: true, NUMBER_OF_PRELOADS: 1, VOCAB_SIZE: '250%', MINIMUM_EXAMPLE_LENGTH: 0, MAXIMUM_EXAMPLE_LENGTH: 100, HOTKEYS: ['None'], DEFAULT_TO_EXACT_SEARCH: true, // On changing this config option, the icons change but the sentences don't, so you // have to click once to match up the icons and again to actually change the sentences RANDOM_SENTENCE: RANDOM_SENTENCE_ENUM, WEIGHTED_SENTENCES: false, }; const state = { currentExampleIndex: 0, examples: [], apiDataFetched: false, vocab: '', embedAboveSubsectionMeanings: false, preloadedIndices: new Set(), currentAudio: null, exactSearch: true, error: false, currentlyPlayingAudio: false, reading: '', }; // Prefixing const scriptPrefix = 'JPDBNadeshikoExamples-'; const configPrefix = 'CONFIG.'; // additional prefix for config variables to go after the scriptPrefix // do not change either of the above without adding code to handle the change const setItem = (key, value) => { localStorage.setItem(scriptPrefix + key, value) } const getItem = (key) => { const prefixedValue = localStorage.getItem(scriptPrefix + key); if (prefixedValue !== null) { return prefixedValue } const nonPrefixedValue = localStorage.getItem(key); // to move away from non-prefixed values as fast as possible if (nonPrefixedValue !== null) { setItem(key, nonPrefixedValue) } return nonPrefixedValue } const removeItem = (key) => { localStorage.removeItem(scriptPrefix + key); localStorage.removeItem(key) } // Helper for transitioning to fully script-prefixed config state // Deletes all localStorage variables starting with configPrefix and re-adds them with scriptPrefix and configPrefix // Danger of other scripts also having localStorage variables starting with configPrefix, so we add a flag showing that // we have run this function and make sure it is not set when running it // Check for Prefixed flag if (localStorage.getItem(`JPDBNadeshiko*Examples-CONFIG_VARIABLES_PREFIXED`) !== 'true') { const keysToModify = []; // Collect keys that need to be modified for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(configPrefix)) { keysToModify.push(key); } } // Modify the collected keys keysToModify.forEach((key) => { const value = localStorage.getItem(key); localStorage.removeItem(key); const newKey = scriptPrefix + key; localStorage.setItem(newKey, value); }); // Set flag so this only runs once // Flag has * in name to place at top in alphabetical sorting, // and most importantly, to ensure the flag is never removed or modified // by the other script functions that check for the script prefix localStorage.setItem(`JPDBNadeshiko*Examples-CONFIG_VARIABLES_PREFIXED`, 'true'); } // IndexedDB Manager const IndexedDBManager = { MAX_ENTRIES: 100000000, EXPIRATION_TIME: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds open() { return new Promise((resolve, reject) => { const request = indexedDB.open('NadeshikoDB', 1); request.onupgradeneeded = function (event) { const db = event.target.result; if (!db.objectStoreNames.contains('dataStore')) { db.createObjectStore('dataStore', {keyPath: 'keyword'}); } }; request.onsuccess = function (event) { resolve(event.target.result); }; request.onerror = function (event) { reject('IndexedDB error: ' + event.target.errorCode); }; }); }, get(db, keyword) { return new Promise((resolve, reject) => { const transaction = db.transaction(['dataStore'], 'readonly'); const store = transaction.objectStore('dataStore'); const request = store.get(keyword); request.onsuccess = async function (event) { const result = event.target.result; if (result) { const isExpired = Date.now() - result.timestamp >= this.EXPIRATION_TIME; const validationError = validateApiResponse(result.data); if (isExpired) { console.log(`Deleting entry for keyword "${keyword}" because it is expired.`); await this.deleteEntry(db, keyword); resolve(null); } else if (validationError) { console.log(`Deleting entry for keyword "${keyword}" due to validation error: ${validationError}`); await this.deleteEntry(db, keyword); resolve(null); } else { resolve(result.data); } } else { resolve(null); } }.bind(this); request.onerror = function (event) { reject('IndexedDB get error: ' + event.target.errorCode); }; }); }, deleteEntry(db, keyword) { return new Promise((resolve, reject) => { const transaction = db.transaction(['dataStore'], 'readwrite'); const store = transaction.objectStore('dataStore'); const request = store.delete(keyword); request.onsuccess = () => resolve(); request.onerror = (e) => reject('IndexedDB delete error: ' + e.target.errorCode); }); }, getAll(db) { return new Promise((resolve, reject) => { const transaction = db.transaction(['dataStore'], 'readonly'); const store = transaction.objectStore('dataStore'); const entries = []; store.openCursor().onsuccess = function (event) { const cursor = event.target.result; if (cursor) { entries.push(cursor.value); cursor.continue(); } else { resolve(entries); } }; store.openCursor().onerror = function (event) { reject('Failed to retrieve entries via cursor: ' + event.target.errorCode); }; }); }, save(db, keyword, data) { return new Promise(async (resolve, reject) => { try { const validationError = validateApiResponse(data); if (validationError) { console.log(`Invalid data detected: ${validationError}. Not saving to IndexedDB.`); resolve(); return; } // Transform the JSON object to slim it down let slimData = {}; if (data) { slimData = data } else { console.error('Data does not contain expected structure. Cannot slim down.'); resolve(); return; } const entries = await this.getAll(db); const transaction = db.transaction(['dataStore'], 'readwrite'); const store = transaction.objectStore('dataStore'); if (entries.length >= this.MAX_ENTRIES) { // Sort entries by timestamp and delete oldest ones entries.sort((a, b) => a.timestamp - b.timestamp); const entriesToDelete = entries.slice(0, entries.length - this.MAX_ENTRIES + 1); // Delete old entries entriesToDelete.forEach(entry => { store.delete(entry.keyword).onerror = function () { console.error('Failed to delete entry:', entry.keyword); }; }); } // Add the new slimmed entry const addRequest = store.put({keyword, data: slimData, timestamp: Date.now()}); addRequest.onsuccess = () => resolve(); addRequest.onerror = (e) => reject('IndexedDB save error: ' + e.target.errorCode); transaction.oncomplete = function () { console.log('IndexedDB updated successfully.'); }; transaction.onerror = function (event) { reject('IndexedDB update failed: ' + event.target.errorCode); }; } catch (error) { reject(`Error in saveToIndexedDB: ${error}`); } }); }, delete() { return new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase('NadeshikoDB'); request.onsuccess = function () { console.log('IndexedDB deleted successfully'); resolve(); }; request.onerror = function (event) { console.error('Error deleting IndexedDB:', event.target.errorCode); reject('Error deleting IndexedDB: ' + event.target.errorCode); }; request.onblocked = function () { console.warn('Delete operation blocked. Please close all other tabs with this site open and try again.'); reject('Delete operation blocked'); }; }); } }; // API FUNCTIONS===================================================================================================================== function getNadeshikoData(vocab, exactSearch) { return new Promise(async (resolve, reject) => { const searchVocab = exactSearch ? `"${vocab}"` : vocab; const url = `https://api.brigadasos.xyz/api/v1/search/media/sentence`; const maxRetries = 5; let attempt = 0; const storedValue = getItem(state.vocab); const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2; // Return early if not blacklisted if (isBlacklisted) { resolve(); return; } async function fetchData() { try { const db = await IndexedDBManager.open(); const cachedData = await IndexedDBManager.get(db, searchVocab); if (cachedData && Array.isArray(cachedData) && cachedData.length > 0) { console.log('Data retrieved from IndexedDB'); state.examples = cachedData; state.apiDataFetched = true; resolve(); } else { console.log(`Calling API for: ${searchVocab}`); if (!nadeshikoApiKey) { // Ask for API Key on search if not set to prevent 401 errors nadeshikoApiKey = fetchNadeshikoApiKey(); if (!nadeshikoApiKey) return; } GM_xmlhttpRequest({ method: "POST", url: url, data: JSON.stringify({query: searchVocab, "limit": 500,}), headers: { "X-API-Key": nadeshikoApiKey, "Content-Type": "application/json" }, onload: async function (response) { if (response.status === 200) { const jsonData = parseJSON(response.response).sentences; console.log("API JSON Received"); const validationError = validateApiResponse(jsonData); if (!validationError) { state.examples = jsonData; state.apiDataFetched = true; // check if the sentence is in the vocab if (state.vocab && state.reading) { const sentenceResults = await Promise.all( state.examples.map(async sentence => { const foundMatch = await checkVocabInSentence(state, sentence); if (!foundMatch) { console.log("Removed sentence:", sentence); } sentence.nulled = true; // check if the sentence is too long or too short if (sentence.sentence.length < CONFIG.MINIMUM_EXAMPLE_LENGTH || sentence.sentence.length > CONFIG.MAXIMUM_EXAMPLE_LENGTH) { console.log("Removed sentence:", sentence); return null; } return sentence; }) ); // state.examples = sentenceResults.filter(s => s); } await IndexedDBManager.save(db, searchVocab, jsonData); resolve(); } else { attempt++; if (attempt < maxRetries) { console.log(`Validation error: ${validationError}. Retrying... (${attempt}/${maxRetries})`); setTimeout(fetchData, 5000); // Add a 5-second delay before retrying } else { reject(`Invalid API response after ${maxRetries} attempts: ${validationError}`); state.error = true; embedImageAndPlayAudio(); //update displayed text } } } else { reject(`API call failed with status: ${response.status}`); } }, onerror: function (error) { reject(`An error occurred: ${error}`); } }); } } catch (error) { reject(`Error: ${error}`); } } fetchData(); }); } function parseJSON(responseText) { try { return JSON.parse(responseText); } catch (e) { console.error('Error parsing JSON:', e); return null; } } function validateApiResponse(jsonData) { state.error = false; if (!jsonData) { return 'Not a valid JSON'; } const categoryCount = jsonData.length; if (!categoryCount) { return 'Missing category count'; } // Check if all category counts are zero const allZero = categoryCount == 0 if (allZero) { return 'Blank API'; } return null; // No error } //FAVORITE DATA FUNCTIONS===================================================================================================================== function getStoredData(key) { // Retrieve the stored value from localStorage using the provided key const storedValue = getItem(key); // If a stored value exists, split it into index and exactState if (storedValue) { const [index, exactState] = storedValue.split(','); return { index: parseInt(index, 10), // Convert index to an integer exactState: exactState === '1' // Convert exactState to a boolean }; } // Return default values if no stored value exists return {index: 0, exactState: state.exactSearch}; } function storeData(key, sentence, exactState) { // Create a string value from index and exactState to store in localStorage const value = `${sentence},${exactState ? 1 : 0}`; // Store the value in localStorage using the provided key setItem(key, value); } // PARSE VOCAB FUNCTIONS ===================================================================================================================== function parseVocabFromAnswer() { // Select all links containing "/kanji/" or "/vocabulary/" in the href attribute const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]'); console.log("Parsing Answer Page"); // Iterate through the matched elements for (const element of elements) { const href = element.getAttribute('href'); const text = element.textContent.trim(); // Match the href to extract kanji or vocabulary (ignoring ID if present) const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/); if (match) return match[2].trim(); if (text) return text.trim(); } return ''; } function parseVocabFromReview() { console.log("Parsing Review Page"); // Select the element with class 'kind' to determine the type of content const kindElement = document.querySelector('.kind'); // If kindElement doesn't exist, set kindText to null const kindText = kindElement ? kindElement.textContent.trim() : null; // Accept 'Kanji' or 'Vocabulary' kindText if (kindText !== 'Kanji' && kindText !== 'Vocabulary') { console.log("Not Kanji or existing Vocabulary. Attempting to parse New Vocab."); // Attempt to parse from <a> tag with specific pattern const anchorElement = document.querySelector('a.plain[href*="/vocabulary/"]'); if (anchorElement) { const href = anchorElement.getAttribute('href'); const match = href.match(/\/vocabulary\/\d+\/([^#]+)#a/); if (match && match[1]) { const new_vocab = match[1]; console.log("Found New Vocab:", new_vocab); return new_vocab; } } console.log("No Vocabulary found."); return ''; } if (kindText === 'Vocabulary') { // Select the element with class 'plain' to extract vocabulary const plainElement = document.querySelector('.plain'); if (!plainElement) { return ''; } const rubyElements = plainElement.querySelectorAll('ruby'); // Extract the text from <rt> children and join them. let vocabulary = "" const reading = Array.from(rubyElements) .map(ruby => { const rtElement = ruby.querySelector('rt'); // add the text not in the <rt> tag to the vocabulary vocabulary = vocabulary + (ruby.childNodes[0] ? ruby.childNodes[0].textContent.trim() : ''); if (rtElement) { rtElement.style.display = 'none'; return rtElement.textContent.trim(); } return ''; }) .join(''); // Regular expression to check if the vocabulary contains kanji characters const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/; if (kanjiRegex.test(vocabulary) || vocabulary) { console.log("Found Vocabulary:", vocabulary); return [vocabulary, reading]; } } else if (kindText === 'Kanji') { // Select the hidden input element to extract 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)) { console.log("Found Kanji:", vocab); return vocab; } } console.log("No Vocabulary or Kanji found."); return ''; } function parseVocabFromVocabulary() { // Get the current URL let url = window.location.href; // Remove query parameters (e.g., ?lang=english) and fragment identifiers (#) url = url.split('?')[0].split('#')[0]; // Match the URL structure for a vocabulary page const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#\/]*)\/([^\#\/]*)/); console.log("Parsing Vocabulary Page"); if (match) { // Extract and decode the vocabulary part from the URL let vocab = match[2]; state.embedAboveSubsectionMeanings = true; // Set state flag let reading = match[3]; return [decodeURIComponent(vocab), decodeURIComponent(reading)]; } // Return empty string if no match return ''; } function parseVocabFromKanji() { // Get the current URL const url = window.location.href; // Match the URL structure for a kanji page const match = url.match(/https:\/\/jpdb\.io\/kanji\/(\d+)\/([^\#]*)#a/); console.log("Parsing Kanji Page"); if (match) { // Extract and decode the kanji part from the URL let kanji = match[2]; state.embedAboveSubsectionMeanings = true; // Set state flag kanji = kanji.split('/')[0]; return decodeURIComponent(kanji); } // Return empty string if no match return ''; } function parseVocabFromSearch() { // Get the current URL let url = window.location.href; // Match the URL structure for a search query, capturing the vocab between `?q=` and either `&` or `+` const match = url.match(/https:\/\/jpdb\.io\/search\?q=([^&+]*)/); console.log("Parsing Search Page"); if (match) { // Extract and decode the vocabulary part from the URL let vocab = match[1]; return decodeURIComponent(vocab); } // Return empty string if no match return ''; } //EMBED FUNCTIONS===================================================================================================================== function createAnchor(marginLeft) { // Create and style an anchor element const anchor = document.createElement('a'); anchor.href = '#'; anchor.style.border = '0'; anchor.style.display = 'inline-flex'; anchor.style.verticalAlign = 'middle'; anchor.style.marginLeft = marginLeft; return anchor; } function createIcon(iconClass, fontSize = '1.4rem', color = '#3d81ff') { // Create and style an icon element const icon = document.createElement('i'); icon.className = iconClass; icon.style.fontSize = fontSize; icon.style.opacity = '1.0'; icon.style.verticalAlign = 'baseline'; icon.style.color = color; return icon; } function createSpeakerButton(soundUrl) { // Create a speaker button with an icon and click event for audio playback const anchor = createAnchor('0.5rem'); const icon = createIcon('ti ti-volume'); anchor.appendChild(icon); anchor.addEventListener('click', (event) => { event.preventDefault(); playAudio(soundUrl); }); return anchor; } function createStarButton() { // Create a star button with an icon and click event for toggling favorite state const anchor = createAnchor('0.5rem'); const starIcon = document.createElement('span'); const storedValue = getItem(state.vocab); // console.log(storedValue); // Determine the star icon (filled or empty) based on stored value if (storedValue) { const [storedIndex, storedExactState] = storedValue.split(','); const index = parseInt(storedIndex, 10); const exactState = Boolean(parseInt(storedExactState, 10)); starIcon.textContent = (state.currentExampleIndex === index && state.exactSearch === exactState) ? '★' : '☆'; } else { starIcon.textContent = '☆'; } // Style the star icon starIcon.style.fontSize = '1.4rem'; starIcon.style.color = '#3D8DFF'; starIcon.style.verticalAlign = 'middle'; starIcon.style.position = 'relative'; starIcon.style.top = '-2px'; // Append the star icon to the anchor and set up the click event to toggle star state anchor.appendChild(starIcon); anchor.addEventListener('click', (event) => { event.preventDefault(); toggleStarState(starIcon); }); return anchor; } function toggleStarState(starIcon) { const storedValue = getItem(state.vocab); const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2; // Return early if blacklisted if (isBlacklisted) { starIcon.textContent = '☆'; return; } // Toggle the star state between filled and empty if (storedValue) { const [storedIndex, storedExactState] = storedValue.split(','); const index = parseInt(storedIndex, 10); const exactState = storedExactState === '1'; if (index === state.currentExampleIndex && exactState === state.exactSearch) { removeItem(state.vocab); starIcon.textContent = '☆'; } else { setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`); starIcon.textContent = '★'; } } else { setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`); starIcon.textContent = '★'; } } function createQuoteButton() { // Create a quote button with an icon and click event for toggling quote style const anchor = createAnchor('0rem'); const quoteIcon = document.createElement('span'); // Set the icon based on exact search state quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』'; // Style the quote icon quoteIcon.style.fontSize = '1.1rem'; quoteIcon.style.color = '#3D8DFF'; quoteIcon.style.verticalAlign = 'middle'; quoteIcon.style.position = 'relative'; quoteIcon.style.top = '0px'; // Append the quote icon to the anchor and set up the click event to toggle quote state anchor.appendChild(quoteIcon); anchor.addEventListener('click', (event) => { event.preventDefault(); toggleQuoteState(quoteIcon); }); return anchor; } function toggleQuoteState(quoteIcon) { const storedValue = getItem(state.vocab); const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2; // Return early if blacklisted if (isBlacklisted) { return; } // Toggle between single and double quote styles state.exactSearch = !state.exactSearch; quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』'; // Update state based on stored data const storedData = getStoredData(state.vocab); if (storedData && storedData.exactState === state.exactSearch) { state.currentExampleIndex = storedData.index; } else { state.currentExampleIndex = 0; } state.apiDataFetched = false; embedImageAndPlayAudio(); getNadeshikoData(state.vocab, state.exactSearch) .then(() => { embedImageAndPlayAudio(); }) .catch(error => { console.error(error); }); } function createMenuButton() { // Create a menu button with a dropdown menu const anchor = createAnchor('0.5rem'); const menuIcon = document.createElement('span'); menuIcon.innerHTML = '☰'; // Style the menu icon menuIcon.style.fontSize = '1.4rem'; menuIcon.style.color = '#3D8DFF'; menuIcon.style.verticalAlign = 'middle'; menuIcon.style.position = 'relative'; menuIcon.style.top = '-2px'; // Append the menu icon to the anchor and set up the click event to show the overlay menu anchor.appendChild(menuIcon); anchor.addEventListener('click', (event) => { event.preventDefault(); const overlay = createOverlayMenu(); document.body.appendChild(overlay); }); return anchor; } function createTextButton(vocab, exact) { // Create a text button for Nadeshiko const textButton = document.createElement('a'); textButton.textContent = 'Nadeshiko'; textButton.style.color = 'var(--subsection-label-color)'; textButton.style.fontSize = '85%'; textButton.style.marginRight = '0.5rem'; textButton.style.verticalAlign = 'middle'; textButton.href = `https://nadeshiko.co/search/sentence?query=${encodeURIComponent(vocab)}`; textButton.target = '_blank'; return textButton; } function createButtonContainer(soundUrl, vocab, exact) { // Create a container for all buttons const buttonContainer = document.createElement('div'); buttonContainer.className = 'button-container'; buttonContainer.style.display = 'flex'; buttonContainer.style.justifyContent = 'space-between'; buttonContainer.style.alignItems = 'center'; buttonContainer.style.marginBottom = '5px'; buttonContainer.style.lineHeight = '1.4rem'; // Create individual buttons const menuButton = createMenuButton(); const textButton = createTextButton(vocab, exact); const speakerButton = createSpeakerButton(soundUrl); const starButton = createStarButton(); const quoteButton = createQuoteButton(); // Center the buttons within the container const centeredButtonsWrapper = document.createElement('div'); centeredButtonsWrapper.style.display = 'flex'; centeredButtonsWrapper.style.justifyContent = 'center'; centeredButtonsWrapper.style.flex = '1'; centeredButtonsWrapper.append(textButton, speakerButton, starButton, quoteButton); buttonContainer.append(centeredButtonsWrapper, menuButton); return buttonContainer; } function stopCurrentAudio() { // Stop any currently playing audio if (state.currentAudio) { state.currentAudio.source.stop(); state.currentAudio.context.close(); state.currentAudio = null; } } function playAudio(soundUrl) { // Skip playing audio if it is already playing if (state.currentlyPlayingAudio) { //console.log('Duplicate audio was skipped.'); return; } if (soundUrl) { state.currentlyPlayingAudio = true; stopCurrentAudio(); GM_xmlhttpRequest({ method: 'GET', url: soundUrl, responseType: 'arraybuffer', onload: function (response) { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); audioContext.decodeAudioData(response.response, function (buffer) { const source = audioContext.createBufferSource(); source.buffer = buffer; const gainNode = audioContext.createGain(); // Connect the source to the gain node and the gain node to the destination source.connect(gainNode); gainNode.connect(audioContext.destination); // Mute the first part and then ramp up the volume gainNode.gain.setValueAtTime(0, audioContext.currentTime); gainNode.gain.linearRampToValueAtTime(CONFIG.SOUND_VOLUME / 100, audioContext.currentTime + 0.1); // Play the audio, skip the first part to avoid any "pop" source.start(0, 0.05); // Log when the audio starts playing //console.log('Audio has started playing.'); // Save the current audio context and source for stopping later state.currentAudio = { context: audioContext, source: source }; // Set currentlyPlayingAudio to false when the audio ends source.onended = function () { state.currentlyPlayingAudio = false; }; }, function (error) { console.error('Error decoding audio:', error); state.currentlyPlayingAudio = false; }); }, onerror: function (error) { console.error('Error fetching audio:', error); state.currentlyPlayingAudio = false; } }); } } // has to be declared (referenced in multiple functions but definition requires variables local to one function) let hotkeysListener; function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) { if (state.apiDataFetched === false) { console.log("No data"); return; } const example = state.examples[state.currentExampleIndex] || {}; const imageUrl = example.media_info?.path_image || null; const soundUrl = example.media_info?.path_audio || null; const sentence = example.segment_info?.content_jp || null; const translation = example.segment_info?.content_en || ""; const deck_name = example.basic_info?.name_anime_romaji || "Unknown Anime"; const storedValue = getItem(state.vocab); const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2; // Update sentence class content with actual sentence text const sentenceElement = document.querySelector('.sentence'); if (sentenceElement) { sentenceElement.textContent = sentence; } // Update translation class content with actual translation text const translationElement = document.querySelector('.sentence-translation'); if (translationElement) { translationElement.textContent = translation; } // Remove any existing container removeExistingContainer(); if (!shouldRenderContainer()) return; // Create and append the main wrapper and text button container const wrapperDiv = createWrapperDiv(); const textDiv = createButtonContainer(soundUrl, vocab, state.exactSearch); wrapperDiv.appendChild(textDiv); const createTextElement = (text) => { const textElement = document.createElement('div'); textElement.textContent = text; textElement.style.padding = '100px 0'; textElement.style.whiteSpace = 'pre'; // Ensures newlines are respected return textElement; }; if (isBlacklisted) { wrapperDiv.appendChild(createTextElement('BLACKLISTED')); shouldAutoPlaySound = false; } else if (state.apiDataFetched) { if (imageUrl) { const imageElement = createImageElement(wrapperDiv, imageUrl, vocab, state.exactSearch); if (imageElement) { imageElement.addEventListener('click', () => playAudio(soundUrl)); } } else { wrapperDiv.appendChild(createTextElement(`NO IMAGE\n(${deck_name})`)); } // Append sentence and translation or a placeholder text // sentence ? appendSentenceAndTranslation(wrapperDiv, sentence, translation) : appendNoneText(wrapperDiv); } else if (!sentence) { wrapperDiv.appendChild(createTextElement('ERROR\nNO EXAMPLES FOUND\n\nRARE WORD OR NADESHIKO API IS TEMPORARILY DOWN')); } else { wrapperDiv.appendChild(createTextElement('LOADING')); } // Create navigation elements const navigationDiv = createNavigationDiv(); const leftArrow = createLeftArrow(vocab, shouldAutoPlaySound); const rightArrow = createRightArrow(vocab, shouldAutoPlaySound); // Create and append the main container const containerDiv = createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv); appendContainer(containerDiv); // Auto-play sound if configured if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) { playAudio(soundUrl); } // Link hotkeys if (CONFIG.HOTKEYS.indexOf("None") === -1) { const leftHotkey = CONFIG.HOTKEYS[0]; const rightHotkey = CONFIG.HOTKEYS[1]; hotkeysListener = (event) => { if (event.repeat) return; switch (event.key.toLowerCase()) { case leftHotkey.toLowerCase(): if (leftArrow.disabled) { // listener gets removed, so need to re-add window.addEventListener('keydown', hotkeysListener, {once: true}); } else { leftArrow.click(); // don't need to re-add listener because renderImageAndPlayAudio() will run again } break; case rightHotkey.toLowerCase(): if (rightArrow.disabled) { // listener gets removed, so need to re-add window.addEventListener('keydown', hotkeysListener, {once: true}); } else { rightArrow.click(); // don't need to re-add listener because renderImageAndPlayAudio() will run again } break; default: // listener gets removed, so need to re-add window.addEventListener('keydown', hotkeysListener, {once: true}); } } window.addEventListener('keydown', hotkeysListener, {once: true}); } } function removeExistingContainer() { // Remove the existing container if it exists const existingContainer = document.getElementById('nadeshiko-container'); if (existingContainer) { existingContainer.remove(); } window.removeEventListener('keydown', hotkeysListener); } function shouldRenderContainer() { // Determine if the container should be rendered based on the presence of certain elements const resultVocabularySection = document.querySelector('.result.vocabulary'); const hboxWrapSection = document.querySelector('.hbox.wrap'); const subsectionMeanings = document.querySelector('.subsection-meanings'); const subsectionLabels = document.querySelectorAll('h6.subsection-label'); return resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3; } function createWrapperDiv() { // Create and style the wrapper div const wrapperDiv = document.createElement('div'); wrapperDiv.id = 'image-wrapper'; wrapperDiv.style.textAlign = 'center'; wrapperDiv.style.padding = '5px 0'; return wrapperDiv; } function createImageElement(wrapperDiv, imageUrl, vocab, exactSearch) { // Create and return an image element with specified attributes const searchVocab = exactSearch ? `「${vocab}」` : vocab; const example = state.examples[state.currentExampleIndex] || {}; const deck_name = example.basic_info.name_anime_romaji || null; // Extract the file name from the URL let file_name = imageUrl.substring(imageUrl.lastIndexOf('/') + 1); // Remove prefixes "Anime_", "A_", or "Z" from the file name file_name = file_name.replace(/^(Anime_|A_|Z)/, ''); const titleText = `${searchVocab} #${state.currentExampleIndex + 1} \n${deck_name} \n${file_name}`; return GM_addElement(wrapperDiv, 'img', { src: imageUrl, alt: 'Embedded Image', title: titleText, style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;` }); } function highlightVocab(sentence, vocab) { // Highlight vocabulary in the sentence based on configuration 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 appendSentenceAndTranslation(wrapperDiv, sentence, translation) { // Append sentence and translation to the wrapper div const sentenceText = document.createElement('div'); sentenceText.innerHTML = highlightVocab(sentence, state.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 && translation) { const translationText = document.createElement('div'); translationText.innerHTML = replaceSpecialCharacters(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); } } function appendNoneText(wrapperDiv) { // Append a "None" text to the wrapper div 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); } function createNavigationDiv() { // Create and style the navigation div const navigationDiv = document.createElement('div'); navigationDiv.id = 'nadeshiko-embed'; navigationDiv.style.display = 'flex'; navigationDiv.style.justifyContent = 'center'; navigationDiv.style.alignItems = 'center'; navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH; navigationDiv.style.margin = '0 auto'; return navigationDiv; } function createLeftArrow(vocab, shouldAutoPlaySound) { // Create and configure the left arrow button const leftArrow = document.createElement('button'); leftArrow.textContent = '<'; leftArrow.style.marginRight = '10px'; leftArrow.style.width = CONFIG.ARROW_WIDTH; leftArrow.style.height = CONFIG.ARROW_HEIGHT; leftArrow.style.lineHeight = '25px'; leftArrow.style.textAlign = 'center'; leftArrow.style.display = 'flex'; leftArrow.style.justifyContent = 'center'; leftArrow.style.alignItems = 'center'; leftArrow.style.padding = '0'; // Remove padding leftArrow.disabled = state.currentExampleIndex === 0; leftArrow.addEventListener('click', () => { if (state.currentExampleIndex > 0) { state.currentExampleIndex--; state.currentlyPlayingAudio = false; renderImageAndPlayAudio(vocab, shouldAutoPlaySound); preloadImages(); } }); return leftArrow; } function createRightArrow(vocab, shouldAutoPlaySound) { // Create and configure the right arrow button const rightArrow = document.createElement('button'); rightArrow.textContent = '>'; rightArrow.style.marginLeft = '10px'; rightArrow.style.width = CONFIG.ARROW_WIDTH; rightArrow.style.height = CONFIG.ARROW_HEIGHT; rightArrow.style.lineHeight = '25px'; rightArrow.style.textAlign = 'center'; rightArrow.style.display = 'flex'; rightArrow.style.justifyContent = 'center'; rightArrow.style.alignItems = 'center'; rightArrow.style.padding = '0'; // Remove padding rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1; rightArrow.addEventListener('click', () => { if (state.currentExampleIndex < state.examples.length - 1) { state.currentExampleIndex++; state.currentlyPlayingAudio = false; renderImageAndPlayAudio(vocab, shouldAutoPlaySound); preloadImages(); } }); return rightArrow; } function createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv) { // Create and configure the main container div const containerDiv = document.createElement('div'); containerDiv.id = 'nadeshiko-container'; containerDiv.style.display = 'flex'; containerDiv.style.alignItems = 'center'; containerDiv.style.justifyContent = 'center'; containerDiv.style.flexDirection = 'column'; const arrowWrapperDiv = document.createElement('div'); arrowWrapperDiv.style.display = 'flex'; arrowWrapperDiv.style.alignItems = 'center'; arrowWrapperDiv.style.justifyContent = 'center'; arrowWrapperDiv.append(leftArrow, wrapperDiv, rightArrow); containerDiv.append(arrowWrapperDiv, navigationDiv); return containerDiv; } function appendContainer(containerDiv) { // Append the container div to the appropriate section based on configuration 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'); const styleSheet = document.querySelector('link[rel="stylesheet"]').sheet; if (CONFIG.WIDE_MODE && subsectionMeanings) { const wrapper = document.createElement('div'); wrapper.style.display = 'flex'; wrapper.style.alignItems = 'flex-start'; styleSheet.insertRule('.subsection-meanings { max-width: none !important; }', styleSheet.cssRules.length); const originalContentWrapper = document.createElement('div'); originalContentWrapper.style.flex = '1'; originalContentWrapper.appendChild(subsectionMeanings); 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); } if (CONFIG.DEFINITIONS_ON_RIGHT_IN_WIDE_MODE) { wrapper.appendChild(containerDiv); wrapper.appendChild(originalContentWrapper); } else { wrapper.appendChild(originalContentWrapper); wrapper.appendChild(containerDiv); } if (vboxGap) { const existingDynamicDiv = vboxGap.querySelector('#dynamic-content'); if (existingDynamicDiv) { existingDynamicDiv.remove(); } const dynamicDiv = document.createElement('div'); dynamicDiv.id = 'dynamic-content'; dynamicDiv.appendChild(wrapper); if (window.location.href.includes('vocabulary')) { vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]); } else { vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild); } } } 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]); } } } function embedImageAndPlayAudio() { // Embed the image and play audio, removing existing navigation div if present console.log("Embedding image and playing audio"); const existingNavigationDiv = document.getElementById('nadeshiko-embed'); if (existingNavigationDiv) existingNavigationDiv.remove(); const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/; renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href)); preloadImages(); } function replaceSpecialCharacters(text) { // Replace special characters in the text return text.replace(/<br>/g, '\n').replace(/"/g, '"').replace(/\n/g, '<br>'); } function preloadImages() { // Preload images around the current example index const preloadDiv = GM_addElement(document.body, 'div', {style: 'display: none;'}); const startIndex = Math.max(0, state.currentExampleIndex - CONFIG.NUMBER_OF_PRELOADS); const endIndex = Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUMBER_OF_PRELOADS); for (let i = startIndex; i <= endIndex; i++) { if (!state.preloadedIndices.has(i) && state.examples[i].image_url) { GM_addElement(preloadDiv, 'img', {src: state.examples[i].image_url}); state.preloadedIndices.add(i); } } } //MENU FUNCTIONS===================================================================================================================== ////FILE OPERATIONS===================================================================================================================== function handleImportButtonClick() { handleFileInput('application/json', importFavorites); } function handleImportDButtonClick() { handleFileInput('application/json', importData); } function handleFileInput(acceptType, callback) { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = acceptType; fileInput.addEventListener('change', callback); fileInput.click(); } function createBlobAndDownload(data, filename, type) { const blob = new Blob([data], {type}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function addBlacklist() { setItem(state.vocab, `0,2`); location.reload(); } function remBlacklist() { removeItem(state.vocab); location.reload(); } function exportFavorites() { const favorites = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(scriptPrefix)) { const keyPrefixless = key.substring(scriptPrefix.length); // chop off the script prefix if (!keyPrefixless.startsWith(configPrefix)) { favorites[keyPrefixless] = localStorage.getItem(key); // For backwards compatibility keep the exported keys prefixless } } } const data = JSON.stringify(favorites, null, 2); createBlobAndDownload(data, 'favorites.json', 'application/json'); } 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) { setItem(key, favorites[key]); } alert('Favorites imported successfully!'); location.reload(); } catch (error) { alert('Error importing favorites:', error); } }; reader.readAsText(file); } async function exportData() { const dataEntries = {}; try { const db = await IndexedDBManager.open(); const indexedDBData = await IndexedDBManager.getAll(db); indexedDBData.forEach(item => { dataEntries[item.keyword] = item.data; }); const data = JSON.stringify(dataEntries, null, 2); createBlobAndDownload(data, 'data.json', 'application/json'); } catch (error) { console.error('Error exporting data from IndexedDB:', error); } } async function importData(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async function (e) { try { const dataEntries = JSON.parse(e.target.result); const db = await IndexedDBManager.open(); for (const key in dataEntries) { await IndexedDBManager.save(db, key, dataEntries[key]); } alert('Data imported successfully!'); location.reload(); } catch (error) { alert('Error importing data:', error); } }; reader.readAsText(file); } ////CONFIRMATION function createConfirmationPopup(messageText, onYes, onNo) { // Create a confirmation popup with Yes and No buttons const popupOverlay = document.createElement('div'); popupOverlay.style.position = 'fixed'; popupOverlay.style.top = '0'; popupOverlay.style.left = '0'; popupOverlay.style.width = '100%'; popupOverlay.style.height = '100%'; popupOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)'; popupOverlay.style.zIndex = '1001'; popupOverlay.style.display = 'flex'; popupOverlay.style.justifyContent = 'center'; popupOverlay.style.alignItems = 'center'; const popupContent = document.createElement('div'); popupContent.style.backgroundColor = 'var(--background-color)'; popupContent.style.padding = '20px'; popupContent.style.borderRadius = '5px'; popupContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)'; popupContent.style.textAlign = 'center'; const message = document.createElement('p'); message.textContent = messageText; const yesButton = document.createElement('button'); yesButton.textContent = 'Yes'; yesButton.style.backgroundColor = '#C82800'; yesButton.style.marginRight = '10px'; yesButton.addEventListener('click', () => { onYes(); document.body.removeChild(popupOverlay); }); const noButton = document.createElement('button'); noButton.textContent = 'No'; noButton.addEventListener('click', () => { onNo(); document.body.removeChild(popupOverlay); }); popupContent.appendChild(message); popupContent.appendChild(yesButton); popupContent.appendChild(noButton); popupOverlay.appendChild(popupContent); document.body.appendChild(popupOverlay); } ////BUTTONS function createActionButtonsContainer() { const actionButtonWidth = '100px'; const closeButton = createButton('Close', '10px', closeOverlayMenu, actionButtonWidth); const saveButton = createButton('Save', '10px', saveConfig, actionButtonWidth); const defaultButton = createDefaultButton(actionButtonWidth); const deleteButton = createDeleteButton(actionButtonWidth); const actionButtonsContainer = document.createElement('div'); actionButtonsContainer.style.textAlign = 'center'; actionButtonsContainer.style.marginTop = '10px'; actionButtonsContainer.append(closeButton, saveButton, defaultButton, deleteButton); return actionButtonsContainer; } function createMenuButtons() { const blacklistContainer = createBlacklistContainer(); const favoritesContainer = createFavoritesContainer(); const dataContainer = createDataContainer(); const actionButtonsContainer = createActionButtonsContainer(); const buttonContainer = document.createElement('div'); buttonContainer.append(blacklistContainer, favoritesContainer, dataContainer, actionButtonsContainer); return buttonContainer; } function createButton(text, margin, onClick, width) { // Create a button element with specified properties const button = document.createElement('button'); button.textContent = text; button.style.margin = margin; button.style.width = width; button.style.textAlign = 'center'; button.style.display = 'inline-block'; button.style.lineHeight = '30px'; button.style.padding = '5px 0'; button.addEventListener('click', onClick); return button; } ////BLACKLIST BUTTONS function createBlacklistContainer() { const blacklistButtonWidth = '200px'; const addBlacklistButton = createButton('Add to Blacklist', '10px', addBlacklist, blacklistButtonWidth); const remBlacklistButton = createButton('Remove from Blacklist', '10px', remBlacklist, blacklistButtonWidth); const blacklistContainer = document.createElement('div'); blacklistContainer.style.textAlign = 'center'; blacklistContainer.style.marginTop = '10px'; blacklistContainer.append(addBlacklistButton, remBlacklistButton); return blacklistContainer; } ////FAVORITE BUTTONS function createFavoritesContainer() { const favoritesButtonWidth = '200px'; const exportButton = createButton('Export Favorites', '10px', exportFavorites, favoritesButtonWidth); const importButton = createButton('Import Favorites', '10px', handleImportButtonClick, favoritesButtonWidth); const favoritesContainer = document.createElement('div'); favoritesContainer.style.textAlign = 'center'; favoritesContainer.style.marginTop = '10px'; favoritesContainer.append(exportButton, importButton); return favoritesContainer; } ////DATA BUTTONS function createDataContainer() { const dataButtonWidth = '200px'; const exportButton = createButton('Export Data', '10px', exportData, dataButtonWidth); const importButton = createButton('Import Data', '10px', handleImportDButtonClick, dataButtonWidth); const dataContainer = document.createElement('div'); dataContainer.style.textAlign = 'center'; dataContainer.style.marginTop = '10px'; dataContainer.append(exportButton, importButton); return dataContainer; } ////CLOSE BUTTON function closeOverlayMenu() { loadConfig(); document.body.removeChild(document.getElementById('overlayMenu')); } ////SAVE BUTTON function saveConfig() { const overlay = document.getElementById('overlayMenu'); if (!overlay) return; const inputs = overlay.querySelectorAll('input, span'); console.log(inputs) const {changes, minimumExampleLengthChanged, newMinimumExampleLength} = gatherChanges(inputs); if (minimumExampleLengthChanged) { handleMinimumExampleLengthChange(newMinimumExampleLength, changes); } else { applyChanges(changes); finalizeSaveConfig(); setVocabSize(); setPageWidth(); } } function gatherChanges(inputs) { let minimumExampleLengthChanged = false; let newMinimumExampleLength; const changes = {}; inputs.forEach(input => { const key = input.getAttribute('data-key'); const type = input.getAttribute('data-type'); let value; if (type === 'boolean') { value = input.checked; } else if (type === 'number') { value = parseFloat(input.textContent); } else if (type === 'string') { value = input.textContent; } else if (type === 'object' && key === 'HOTKEYS') { value = input.textContent.replace(' and ', ' '); } if (key && type) { const typePart = input.getAttribute('data-type-part'); const originalFormattedType = typePart.slice(1, -1); if (key === 'MINIMUM_EXAMPLE_LENGTH' && CONFIG.MINIMUM_EXAMPLE_LENGTH !== value) { minimumExampleLengthChanged = true; newMinimumExampleLength = value; } if (key === 'MAXIMUM_EXAMPLE_LENGTH' && CONFIG.MAXIMUM_EXAMPLE_LENGTH !== value) { value = Math.max(value, CONFIG.MINIMUM_EXAMPLE_LENGTH); } changes[configPrefix + key] = value + originalFormattedType; } }); return {changes, minimumExampleLengthChanged, newMinimumExampleLength}; } function handleMinimumExampleLengthChange(newMinimumExampleLength, changes) { createConfirmationPopup( 'Changing Minimum Example Length will break your current favorites. They will all be deleted. Are you sure?', async () => { await IndexedDBManager.delete(); CONFIG.MINIMUM_EXAMPLE_LENGTH = newMinimumExampleLength; setItem(`${configPrefix}MINIMUM_EXAMPLE_LENGTH`, newMinimumExampleLength); applyChanges(changes); clearNonConfigLocalStorage(); finalizeSaveConfig(); location.reload(); }, () => { const overlay = document.getElementById('overlayMenu'); document.body.removeChild(overlay); document.body.appendChild(createOverlayMenu()); } ); } function clearNonConfigLocalStorage() { for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(scriptPrefix) && !key.startsWith(scriptPrefix + configPrefix)) { localStorage.removeItem(key); i--; // Adjust index after removal } } } function applyChanges(changes) { for (const key in changes) { setItem(key, changes[key]); } } function finalizeSaveConfig() { loadConfig(); window.removeEventListener('keydown', hotkeysListener); renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND); const overlay = document.getElementById('overlayMenu'); if (overlay) { document.body.removeChild(overlay); } } ////DEFAULT BUTTON function createDefaultButton(width) { const defaultButton = createButton('Default', '10px', () => { createConfirmationPopup( 'This will reset all your settings to default. Are you sure?', () => { Object.keys(localStorage).forEach(key => { if (key.startsWith(scriptPrefix + configPrefix)) { localStorage.removeItem(key); } }); location.reload(); }, () => { const overlay = document.getElementById('overlayMenu'); if (overlay) { document.body.removeChild(overlay); } loadConfig(); document.body.appendChild(createOverlayMenu()); } ); }, width); defaultButton.style.backgroundColor = '#C82800'; defaultButton.style.color = 'white'; return defaultButton; } ////DELETE BUTTON function createDeleteButton(width) { const deleteButton = createButton('DELETE', '10px', () => { createConfirmationPopup( 'This will delete all your favorites and cached data. Are you sure?', async () => { await IndexedDBManager.delete(); Object.keys(localStorage).forEach(key => { if (key.startsWith(scriptPrefix) && !key.startsWith(scriptPrefix + configPrefix)) { localStorage.removeItem(key); } }); location.reload(); }, () => { const overlay = document.getElementById('overlayMenu'); if (overlay) { document.body.removeChild(overlay); } loadConfig(); document.body.appendChild(createOverlayMenu()); } ); }, width); deleteButton.style.backgroundColor = '#C82800'; deleteButton.style.color = 'white'; return deleteButton; } function createOverlayMenu() { // Create and return the overlay menu for configuration settings const overlay = document.createElement('div'); overlay.id = 'overlayMenu'; overlay.style.position = 'fixed'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)'; overlay.style.zIndex = '1000'; overlay.style.display = 'flex'; overlay.style.justifyContent = 'center'; overlay.style.alignItems = 'center'; const menuContent = document.createElement('div'); menuContent.style.backgroundColor = 'var(--background-color)'; menuContent.style.color = 'var(--text-color)'; menuContent.style.padding = '20px'; menuContent.style.borderRadius = '5px'; menuContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)'; menuContent.style.width = '80%'; menuContent.style.maxWidth = '550px'; menuContent.style.maxHeight = '80%'; menuContent.style.overflowY = 'auto'; for (const [key, value] of Object.entries(CONFIG)) { const optionContainer = document.createElement('div'); optionContainer.style.marginBottom = '10px'; optionContainer.style.display = 'flex'; optionContainer.style.alignItems = 'center'; const leftContainer = document.createElement('div'); leftContainer.style.flex = '1'; leftContainer.style.display = 'flex'; leftContainer.style.alignItems = 'center'; const rightContainer = document.createElement('div'); rightContainer.style.flex = '1'; rightContainer.style.display = 'flex'; rightContainer.style.alignItems = 'center'; rightContainer.style.justifyContent = 'center'; const label = document.createElement('label'); label.textContent = key.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' '); label.style.marginRight = '10px'; leftContainer.appendChild(label); if (key === 'RANDOM_SENTENCE') { const select = document.createElement('select'); select.setAttribute('data-key', key); // Add options to the select dropdown for the enum values for (const [enumKey, enumValue] of Object.entries(RANDOM_SENTENCE_ENUM)) { const option = document.createElement('option'); option.value = enumValue; option.text = enumKey.replace(/_/g, ' ').toLowerCase(); option.selected = value === enumValue; // Set the current value as selected select.appendChild(option); } select.addEventListener('change', (event) => { CONFIG[key] = parseInt(event.target.value, 10); // Update the config with the selected value localStorage.setItem(`${scriptPrefix + configPrefix}${key}`, event.target.value); // Save to localStorage }); rightContainer.appendChild(select); } else if (typeof value === 'boolean') { const checkboxContainer = document.createElement('div'); checkboxContainer.style.display = 'flex'; checkboxContainer.style.alignItems = 'center'; checkboxContainer.style.justifyContent = 'center'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = value; checkbox.setAttribute('data-key', key); checkbox.setAttribute('data-type', 'boolean'); checkbox.setAttribute('data-type-part', ''); checkboxContainer.appendChild(checkbox); rightContainer.appendChild(checkboxContainer); } else if (typeof value === 'number') { const numberContainer = document.createElement('div'); numberContainer.style.display = 'flex'; numberContainer.style.alignItems = 'center'; numberContainer.style.justifyContent = 'center'; const decrementButton = document.createElement('button'); decrementButton.textContent = '-'; decrementButton.style.marginRight = '5px'; const input = document.createElement('span'); input.textContent = value; input.style.margin = '0 10px'; input.style.minWidth = '3ch'; input.style.textAlign = 'center'; input.setAttribute('data-key', key); input.setAttribute('data-type', 'number'); input.setAttribute('data-type-part', ''); const incrementButton = document.createElement('button'); incrementButton.textContent = '+'; incrementButton.style.marginLeft = '5px'; const updateButtonStates = () => { let currentValue = parseFloat(input.textContent); if (currentValue <= 0) { decrementButton.disabled = true; decrementButton.style.color = 'grey'; } else { decrementButton.disabled = false; decrementButton.style.color = ''; } if (key === 'SOUND_VOLUME' && currentValue >= 100) { incrementButton.disabled = true; incrementButton.style.color = 'grey'; } else { incrementButton.disabled = false; incrementButton.style.color = ''; } }; decrementButton.addEventListener('click', () => { let currentValue = parseFloat(input.textContent); if (currentValue > 0) { if (currentValue > 200) { input.textContent = currentValue - 25; } else if (currentValue > 20) { input.textContent = currentValue - 5; } else { input.textContent = currentValue - 1; } updateButtonStates(); } }); incrementButton.addEventListener('click', () => { let currentValue = parseFloat(input.textContent); if (key === 'SOUND_VOLUME' && currentValue >= 100) { return; } if (currentValue >= 200) { input.textContent = currentValue + 25; } else if (currentValue >= 20) { input.textContent = currentValue + 5; } else { input.textContent = currentValue + 1; } updateButtonStates(); }); numberContainer.appendChild(decrementButton); numberContainer.appendChild(input); numberContainer.appendChild(incrementButton); rightContainer.appendChild(numberContainer); // Initialize button states updateButtonStates(); } else if (typeof value === 'string') { const typeParts = value.split(/(\d+)/).filter(Boolean); const numberParts = typeParts.filter(part => !isNaN(part)).map(Number); const numberContainer = document.createElement('div'); numberContainer.style.display = 'flex'; numberContainer.style.alignItems = 'center'; numberContainer.style.justifyContent = 'center'; const typeSpan = document.createElement('span'); const formattedType = '(' + typeParts.filter(part => isNaN(part)).join('').replace(/_/g, ' ').toLowerCase() + ')'; typeSpan.textContent = formattedType; typeSpan.style.marginRight = '10px'; leftContainer.appendChild(typeSpan); typeParts.forEach(part => { if (!isNaN(part)) { const decrementButton = document.createElement('button'); decrementButton.textContent = '-'; decrementButton.style.marginRight = '5px'; const input = document.createElement('span'); input.textContent = part; input.style.margin = '0 10px'; input.style.minWidth = '3ch'; input.style.textAlign = 'center'; input.setAttribute('data-key', key); input.setAttribute('data-type', 'string'); input.setAttribute('data-type-part', formattedType); const incrementButton = document.createElement('button'); incrementButton.textContent = '+'; incrementButton.style.marginLeft = '5px'; const updateButtonStates = () => { let currentValue = parseFloat(input.textContent); if (currentValue <= 0) { decrementButton.disabled = true; decrementButton.style.color = 'grey'; } else { decrementButton.disabled = false; decrementButton.style.color = ''; } if (key === 'SOUND_VOLUME' && currentValue >= 100) { incrementButton.disabled = true; incrementButton.style.color = 'grey'; } else { incrementButton.disabled = false; incrementButton.style.color = ''; } }; decrementButton.addEventListener('click', () => { let currentValue = parseFloat(input.textContent); if (currentValue > 0) { if (currentValue > 200) { input.textContent = currentValue - 25; } else if (currentValue > 20) { input.textContent = currentValue - 5; } else { input.textContent = currentValue - 1; } updateButtonStates(); } }); incrementButton.addEventListener('click', () => { let currentValue = parseFloat(input.textContent); if (key === 'SOUND_VOLUME' && currentValue >= 100) { return; } if (currentValue >= 200) { input.textContent = currentValue + 25; } else if (currentValue >= 20) { input.textContent = currentValue + 5; } else { input.textContent = currentValue + 1; } updateButtonStates(); }); numberContainer.appendChild(decrementButton); numberContainer.appendChild(input); numberContainer.appendChild(incrementButton); // Initialize button states updateButtonStates(); } }); rightContainer.appendChild(numberContainer); } else if (typeof value === 'object') { const maxAllowedIndex = hotkeyOptions.length - 1 let currentValue = value; let choiceIndex = hotkeyOptions.indexOf(currentValue.join(' ')); if (choiceIndex === -1) { currentValue = hotkeyOptions[0].split(' '); choiceIndex = 0; } const textContainer = document.createElement('div'); textContainer.style.display = 'flex'; textContainer.style.alignItems = 'center'; textContainer.style.justifyContent = 'center'; const decrementButton = document.createElement('button'); decrementButton.textContent = '<'; decrementButton.style.marginRight = '5px'; const input = document.createElement('span'); input.textContent = currentValue.join(' and '); input.style.margin = '0 10px'; input.style.minWidth = '3ch'; input.style.textAlign = 'center'; input.setAttribute('data-key', key); input.setAttribute('data-type', 'object'); input.setAttribute('data-type-part', ''); const incrementButton = document.createElement('button'); incrementButton.textContent = '>'; incrementButton.style.marginLeft = '5px'; const updateButtonStates = () => { if (choiceIndex <= 0) { decrementButton.disabled = true; decrementButton.style.color = 'grey'; } else { decrementButton.disabled = false; decrementButton.style.color = ''; } if (choiceIndex >= maxAllowedIndex) { incrementButton.disabled = true; incrementButton.style.color = 'grey'; } else { incrementButton.disabled = false; incrementButton.style.color = ''; } }; decrementButton.addEventListener('click', () => { if (choiceIndex > 0) { choiceIndex -= 1; currentValue = hotkeyOptions[choiceIndex].split(' '); input.textContent = currentValue.join(' and '); updateButtonStates(); } }); incrementButton.addEventListener('click', () => { if (choiceIndex < maxAllowedIndex) { choiceIndex += 1; currentValue = hotkeyOptions[choiceIndex].split(' '); input.textContent = currentValue.join(' and '); updateButtonStates(); } }); textContainer.appendChild(decrementButton); textContainer.appendChild(input); textContainer.appendChild(incrementButton); // Initialize button states updateButtonStates(); rightContainer.appendChild(textContainer); } optionContainer.appendChild(leftContainer); optionContainer.appendChild(rightContainer); menuContent.appendChild(optionContainer); } const menuButtons = createMenuButtons(); menuContent.appendChild(menuButtons); overlay.appendChild(menuContent); return overlay; } function loadConfig() { for (const key in localStorage) { if (!key.startsWith(scriptPrefix + configPrefix) || !localStorage.hasOwnProperty(key)) { continue } const configKey = key.substring((scriptPrefix + configPrefix).length); // chop off script prefix and config prefix if (!CONFIG.hasOwnProperty(configKey)) { continue } const savedValue = localStorage.getItem(key); if (savedValue === null) { continue } const valueType = typeof CONFIG[configKey]; if (configKey === 'RANDOM_SENTENCE') { if (savedValue == 0) { CONFIG[configKey] = RANDOM_SENTENCE_ENUM.DISABLE } if (savedValue == 1) { CONFIG[configKey] = RANDOM_SENTENCE_ENUM.ON_FIRST } if (savedValue == 2) { CONFIG[configKey] = RANDOM_SENTENCE_ENUM.EVERY_TIME } } else if (configKey === 'HOTKEYS') { CONFIG[configKey] = savedValue.split(' ') } else if (valueType === 'boolean') { CONFIG[configKey] = savedValue === 'true'; if (configKey === 'DEFAULT_TO_EXACT_SEARCH') { state.exactSearch = CONFIG.DEFAULT_TO_EXACT_SEARCH } // I wonder if this is the best way to do this... // Probably not because we could just have a single variable to store both, but it would have to be in config and // it would be a bit weird to have the program modifying config when the actual config settings aren't changing } else if (valueType === 'number') { CONFIG[configKey] = parseFloat(savedValue); } else if (valueType === 'string') { CONFIG[configKey] = savedValue; } } } function checkVocabInSentence(state, sentence) { // Create the payload using the sentence content and required fields const payload = { input: sentence.segment_info.content_jp, }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: "https://kanjikana.com/api/furigana", data: JSON.stringify(payload), onload: function (response) { // Ensure a 200 OK response if (response.status === 200) { const result = JSON.parse(response.responseText)["furigana"]; let foundMatch = false; function parseRubyToJSON(text) { const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/html"); const rubyElements = doc.querySelectorAll("ruby"); let result = Array.from(rubyElements).map(ruby => { const word = ruby.childNodes[0].textContent; // The kanji part const reading = ruby.querySelector("rt").textContent; // The ruby (furigana) return {word, reading}; }); // Check for words without ruby const nonRubyElements = Array.from(doc.body.childNodes).filter( node => node.nodeType === Node.TEXT_NODE && node.textContent.trim() ); nonRubyElements.forEach(nonRuby => { result.push({word: nonRuby.textContent.trim(), reading: null}); }); return result; } const jsonResult = parseRubyToJSON(result); jsonResult.forEach(function (token) { // Destructure the token array const [spelling, reading] = [token.word, token.reading]; // Check if the token's spelling matches our desired vocab // and its reading matches the expected reading (from state) if (spelling === state.vocab && reading === state.reading) { foundMatch = true; } else if (spelling === state.vocab) { console.log(`Reading mismatch: ${spelling} - Expected: ${state.reading}, Found: ${reading}`); } }); resolve(foundMatch); } else { console.error("API call failed with status", response.status); reject(new Error("API call failed")); } }, onerror: function (error) { console.error("Error during the API call:", error); reject(error); } }); }) } async function process_sentences(state, sentences, first_call) { // Early return for empty array or single item (no processing needed) if (!sentences || !Array.isArray(sentences) || sentences.length <= 1) { return sentences; } // Only randomize if needed const shouldRandomize = CONFIG.RANDOM_SENTENCE > (first_call ? RANDOM_SENTENCE_ENUM.DISABLE : RANDOM_SENTENCE_ENUM.ON_FIRST); // Skip weight calculation if not needed if (!CONFIG.WEIGHTED_SENTENCES && !shouldRandomize) { return sentences; } // Set weights for each sentence by calling jpdb api (if needed) if (CONFIG.WEIGHTED_SENTENCES && jpdbApiKey) { // Create batches of API calls to reduce network overhead const BATCH_SIZE = 5; // Process 5 sentences at a time const sentencesToProcess = []; // Filter only sentences that need processing (not in cache) for (let i = 0; i < sentences.length; i++) { const sentence = sentences[i]; const content = sentence.segment_info?.content_jp; if (!content) continue; sentencesToProcess.push({sentence, index: i}); } // Process in batches for (let i = 0; i < sentencesToProcess.length; i += BATCH_SIZE) { const batch = sentencesToProcess.slice(i, i + BATCH_SIZE); const batchPromises = batch.map(({sentence, index}) => { const content = sentence.segment_info.content_jp; const data = { "text": content, "token_fields": [], "position_length_encoding": "utf16", "vocabulary_fields": [ "card_state", "spelling" ] }; if (sentence.nulled) { sentences[index].weight = 1e-6; console.log(`Ignoring "${content}" due to null`); return Promise.resolve(); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: "https://jpdb.io/api/v1/parse", headers: { "Authorization": `Bearer ${jpdbApiKey}`, }, data: JSON.stringify(data), onload: function (response) { if (response.status === 200) { try { const result = JSON.parse(response.responseText); const vocabulary = result.vocabulary || []; const amount = vocabulary.length; if (amount === 0) { sentences[index].weight = 1; return resolve(); } const VALID_CARD_STATES = ["known", "never-forget", "learning"]; let weight = 0; // Use faster loop construct for (let j = 0; j < amount; j++) { const vocabItem = vocabulary[j]; if (vocabItem && vocabItem[0] && VALID_CARD_STATES.includes(vocabItem[0][0])) { weight++; } } // Calculate and store weight sentences[index].weight = (weight * 100 / ((amount * amount))) || 1; resolve(); } catch (e) { // Default weight on error sentences[index].weight = 1; resolve(); } } else { // Default weight on error sentences[index].weight = 1; resolve(); } }, onerror: function () { // Default weight on error sentences[index].weight = 1; resolve(); } }); }); }); // Wait for current batch to complete before moving to next await Promise.all(batchPromises); } } // Randomize sentences if needed if (shouldRandomize) { // Use Fisher-Yates shuffle for better performance // Only shuffle a maximum of 50 items for large arrays to improve performance const maxShuffleItems = Math.min(sentences.length, 50); // Optimized weighted random algorithm const getWeightedRandomIndex = (max) => { // Pre-calculate weights array for performance const weights = new Array(max); let totalWeight = 0; for (let i = 0; i < max; i++) { const weight = (sentences[i].weight || 1); weights[i] = weight; totalWeight += weight; } // Get random value proportional to total weight const random = Math.random() * totalWeight; let cumulativeWeight = 0; // Find the index for (let i = 0; i < max; i++) { cumulativeWeight += weights[i]; if (random <= cumulativeWeight) { return i; } } return max - 1; // Fallback }; // Fisher-Yates shuffle with weighted randomization for (let i = maxShuffleItems - 1; i > 0; i--) { const j = CONFIG.WEIGHTED_SENTENCES ? getWeightedRandomIndex(i + 1) : Math.floor(Math.random() * (i + 1)); // Swap elements if (i !== j) { [sentences[i], sentences[j]] = [sentences[j], sentences[i]]; } } } return sentences; } //MAIN FUNCTIONS===================================================================================================================== async function onPageLoad() { // Initialize state and determine vocabulary based on URL state.embedAboveSubsectionMeanings = false; // Early layout adjustments without waiting setPageWidth(); const sentenceElement = document.querySelector('.sentence'); if (sentenceElement) { sentenceElement.textContent = "Waiting for data..."; } const machineTranslationFrame = document.getElementById('machine-translation-frame'); // Skip if machine translation frame is present if (machineTranslationFrame) return; // Determine the vocabulary based on URL — done in parallel with setting page width const url = window.location.href; if (url.includes('/vocabulary/')) { [state.vocab, state.reading] = parseVocabFromVocabulary(); } else if (url.includes('/search?q=')) { state.vocab = parseVocabFromSearch(); } else if (url.includes('c=')) { state.vocab = parseVocabFromAnswer(); } else if (url.includes('/kanji/')) { state.vocab = parseVocabFromKanji(); } else { [state.vocab, state.reading] = parseVocabFromReview(); } // Early return if no vocabulary is found if (!state.vocab) return; // Retrieve stored data for the current vocabulary const {sentence, exactState} = getStoredData(state.vocab); state.exactSearch = exactState; // Fetch data if needed, process in parallel threads where possible if (!state.apiDataFetched) { try { await getNadeshikoData(state.vocab, state.exactSearch); // Process sentences in parallel with preloading images const processingPromise = process_sentences(state, state.examples, true); const preloadPromise = Promise.resolve().then(() => preloadImages()); state.examples = await processingPromise; // Set current example index if a sentence exists if (sentence) { state.currentExampleIndex = state.examples.findIndex( example => example.segment_info.content_jp === sentence ); } // Wait for preloading to complete await preloadPromise; // Finally, display the example embedImageAndPlayAudio(); } catch (error) { // Handle errors silently for better performance state.error = true; embedImageAndPlayAudio(); // Still try to show what we can } } else if (state.apiDataFetched) { // Data already fetched, just update display if (sentence) { // Process sentence index finding without logging state.currentExampleIndex = await process_sentences( state, state.examples.findIndex(example => example.segment_info.content_jp === sentence), false ); } // Update display and settings Promise.all([ Promise.resolve().then(() => embedImageAndPlayAudio()), Promise.resolve().then(() => setVocabSize()) ]); } } function setPageWidth() { // Set the maximum width of the page document.body.style.maxWidth = CONFIG.PAGE_WIDTH; } // Observe URL changes and reload the page content accordingly const observer = new MutationObserver(() => { if (window.location.href !== observer.lastUrl) { observer.lastUrl = window.location.href; onPageLoad(); } }); // Function to apply styles function setVocabSize() { // Create a new style element const style = document.createElement('style'); style.type = 'text/css'; style.innerHTML = ` .answer-box > .plain { font-size: ${CONFIG.VOCAB_SIZE} !important; /* Use the configurable font size */ padding-bottom: 0.1rem !important; /* Retain padding */ } `; // Append the new style to the document head document.head.appendChild(style); } observer.lastUrl = window.location.href; observer.observe(document, {subtree: true, childList: true}); // Add event listeners for page load and URL changes window.addEventListener('load', onPageLoad); window.addEventListener('popstate', onPageLoad); window.addEventListener('hashchange', onPageLoad); // Initial configuration and preloading loadConfig(); setPageWidth(); setVocabSize(); //preloadImages(); })();