JPDB Immersion Kit Examples

Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.

目前為 2024-11-12 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name JPDB Immersion Kit Examples
  3. // @version 1.14
  4. // @description Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API. Compatible only with TamperMonkey.
  5. // @author awoo
  6. // @namespace jpdb-immersion-kit-examples
  7. // @match https://jpdb.io/review*
  8. // @match https://jpdb.io/vocabulary/*
  9. // @match https://jpdb.io/kanji/*
  10. // @match https://jpdb.io/search*
  11. // @grant GM_addElement
  12. // @grant GM_xmlhttpRequest
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. const CONFIG = {
  20. IMAGE_WIDTH: '400px',
  21. WIDE_MODE: true,
  22. PAGE_WIDTH: '75rem',
  23. SOUND_VOLUME: 80,
  24. ENABLE_EXAMPLE_TRANSLATION: true,
  25. SENTENCE_FONT_SIZE: '120%',
  26. TRANSLATION_FONT_SIZE: '85%',
  27. COLORED_SENTENCE_TEXT: true,
  28. AUTO_PLAY_SOUND: true,
  29. NUMBER_OF_PRELOADS: 1,
  30. VOCAB_SIZE: '250%',
  31. MINIMUM_EXAMPLE_LENGTH: 0
  32. };
  33.  
  34. const state = {
  35. currentExampleIndex: 0,
  36. examples: [],
  37. apiDataFetched: false,
  38. vocab: '',
  39. embedAboveSubsectionMeanings: false,
  40. preloadedIndices: new Set(),
  41. currentAudio: null,
  42. exactSearch: true,
  43. error: false
  44. };
  45.  
  46.  
  47. // IndexedDB Manager
  48. const IndexedDBManager = {
  49. MAX_ENTRIES: 1000,
  50. EXPIRATION_TIME: 30 * 24 * 60 * 60 * 1000, // 30 days in milliseconds
  51.  
  52. open() {
  53. return new Promise((resolve, reject) => {
  54. const request = indexedDB.open('ImmersionKitDB', 1);
  55. request.onupgradeneeded = function(event) {
  56. const db = event.target.result;
  57. if (!db.objectStoreNames.contains('dataStore')) {
  58. db.createObjectStore('dataStore', { keyPath: 'keyword' });
  59. }
  60. };
  61. request.onsuccess = function(event) {
  62. resolve(event.target.result);
  63. };
  64. request.onerror = function(event) {
  65. reject('IndexedDB error: ' + event.target.errorCode);
  66. };
  67. });
  68. },
  69.  
  70. get(db, keyword) {
  71. return new Promise((resolve, reject) => {
  72. const transaction = db.transaction(['dataStore'], 'readonly');
  73. const store = transaction.objectStore('dataStore');
  74. const request = store.get(keyword);
  75. request.onsuccess = async function(event) {
  76. const result = event.target.result;
  77. if (result) {
  78. const isExpired = Date.now() - result.timestamp >= this.EXPIRATION_TIME;
  79. const validationError = validateApiResponse(result.data);
  80.  
  81. if (isExpired) {
  82. console.log(`Deleting entry for keyword "${keyword}" because it is expired.`);
  83. await this.deleteEntry(db, keyword);
  84. resolve(null);
  85. } else if (validationError) {
  86. console.log(`Deleting entry for keyword "${keyword}" due to validation error: ${validationError}`);
  87. await this.deleteEntry(db, keyword);
  88. resolve(null);
  89. } else {
  90. resolve(result.data);
  91. }
  92. } else {
  93. resolve(null);
  94. }
  95. }.bind(this);
  96. request.onerror = function(event) {
  97. reject('IndexedDB get error: ' + event.target.errorCode);
  98. };
  99. });
  100. },
  101.  
  102. deleteEntry(db, keyword) {
  103. return new Promise((resolve, reject) => {
  104. const transaction = db.transaction(['dataStore'], 'readwrite');
  105. const store = transaction.objectStore('dataStore');
  106. const request = store.delete(keyword);
  107. request.onsuccess = () => resolve();
  108. request.onerror = (e) => reject('IndexedDB delete error: ' + e.target.errorCode);
  109. });
  110. },
  111.  
  112.  
  113. getAll(db) {
  114. return new Promise((resolve, reject) => {
  115. const transaction = db.transaction(['dataStore'], 'readonly');
  116. const store = transaction.objectStore('dataStore');
  117. const entries = [];
  118. store.openCursor().onsuccess = function(event) {
  119. const cursor = event.target.result;
  120. if (cursor) {
  121. entries.push(cursor.value);
  122. cursor.continue();
  123. } else {
  124. resolve(entries);
  125. }
  126. };
  127. store.openCursor().onerror = function(event) {
  128. reject('Failed to retrieve entries via cursor: ' + event.target.errorCode);
  129. };
  130. });
  131. },
  132.  
  133. save(db, keyword, data) {
  134. return new Promise(async (resolve, reject) => {
  135. try {
  136. const validationError = validateApiResponse(data);
  137. if (validationError) {
  138. console.log(`Invalid data detected: ${validationError}. Not saving to IndexedDB.`);
  139. resolve();
  140. return;
  141. }
  142.  
  143. const entries = await this.getAll(db);
  144. const transaction = db.transaction(['dataStore'], 'readwrite');
  145. const store = transaction.objectStore('dataStore');
  146.  
  147. if (entries.length >= this.MAX_ENTRIES) {
  148. // Sort entries by timestamp and delete oldest ones
  149. entries.sort((a, b) => a.timestamp - b.timestamp);
  150. const entriesToDelete = entries.slice(0, entries.length - this.MAX_ENTRIES + 1);
  151.  
  152. // Delete old entries
  153. entriesToDelete.forEach(entry => {
  154. store.delete(entry.keyword).onerror = function() {
  155. console.error('Failed to delete entry:', entry.keyword);
  156. };
  157. });
  158. }
  159.  
  160. // Add the new entry
  161. const addRequest = store.put({ keyword, data, timestamp: Date.now() });
  162. addRequest.onsuccess = () => resolve();
  163. addRequest.onerror = (e) => reject('IndexedDB save error: ' + e.target.errorCode);
  164.  
  165. transaction.oncomplete = function() {
  166. console.log('IndexedDB updated successfully.');
  167. };
  168.  
  169. transaction.onerror = function(event) {
  170. reject('IndexedDB update failed: ' + event.target.errorCode);
  171. };
  172.  
  173. } catch (error) {
  174. reject(`Error in saveToIndexedDB: ${error}`);
  175. }
  176. });
  177. },
  178.  
  179. delete() {
  180. return new Promise((resolve, reject) => {
  181. const request = indexedDB.deleteDatabase('ImmersionKitDB');
  182. request.onsuccess = function() {
  183. console.log('IndexedDB deleted successfully');
  184. resolve();
  185. };
  186. request.onerror = function(event) {
  187. console.error('Error deleting IndexedDB:', event.target.errorCode);
  188. reject('Error deleting IndexedDB: ' + event.target.errorCode);
  189. };
  190. request.onblocked = function() {
  191. console.warn('Delete operation blocked. Please close all other tabs with this site open and try again.');
  192. reject('Delete operation blocked');
  193. };
  194. });
  195. }
  196. };
  197.  
  198.  
  199. // API FUNCTIONS=====================================================================================================================
  200. function getImmersionKitData(vocab, exactSearch) {
  201. return new Promise(async (resolve, reject) => {
  202. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  203. const url = `https://api.immersionkit.com/look_up_dictionary?keyword=${encodeURIComponent(searchVocab)}&sort=shortness&min_length=${CONFIG.MINIMUM_EXAMPLE_LENGTH}`;
  204. const maxRetries = 5;
  205. let attempt = 0;
  206.  
  207. async function fetchData() {
  208. try {
  209. const db = await IndexedDBManager.open();
  210. const cachedData = await IndexedDBManager.get(db, searchVocab);
  211. if (cachedData && Array.isArray(cachedData.data) && cachedData.data.length > 0) {
  212. console.log('Data retrieved from IndexedDB');
  213. state.examples = cachedData.data[0].examples;
  214. state.apiDataFetched = true;
  215. resolve();
  216. } else {
  217. console.log(`Calling API for: ${searchVocab}`);
  218. GM_xmlhttpRequest({
  219. method: "GET",
  220. url: url,
  221. onload: async function(response) {
  222. if (response.status === 200) {
  223. const jsonData = parseJSON(response.responseText);
  224. console.log("API JSON Received");
  225. console.log(url);
  226. const validationError = validateApiResponse(jsonData);
  227. if (!validationError) {
  228. state.examples = jsonData.data[0].examples;
  229. state.apiDataFetched = true;
  230. await IndexedDBManager.save(db, searchVocab, jsonData);
  231. resolve();
  232. } else {
  233. attempt++;
  234. if (attempt < maxRetries) {
  235. console.log(`Validation error: ${validationError}. Retrying... (${attempt}/${maxRetries})`);
  236. fetchData();
  237. } else {
  238. reject(`Invalid API response after ${maxRetries} attempts: ${validationError}`);
  239. state.error = true;
  240. embedImageAndPlayAudio(); //update displayed text
  241. }
  242. }
  243. } else {
  244. reject(`API call failed with status: ${response.status}`);
  245. }
  246. },
  247. onerror: function(error) {
  248. reject(`An error occurred: ${error}`);
  249. }
  250. });
  251. }
  252. } catch (error) {
  253. reject(`Error: ${error}`);
  254. }
  255. }
  256.  
  257. fetchData();
  258. });
  259. }
  260.  
  261. function parseJSON(responseText) {
  262. try {
  263. return JSON.parse(responseText);
  264. } catch (e) {
  265. console.error('Error parsing JSON:', e);
  266. return null;
  267. }
  268. }
  269.  
  270. function validateApiResponse(jsonData) {
  271. state.error = false;
  272. if (!jsonData) {
  273. return 'Not a valid JSON';
  274. }
  275. if (!jsonData.data || !jsonData.data[0] || !jsonData.data[0].examples) {
  276. return 'Missing required data fields';
  277. }
  278.  
  279. const categoryCount = jsonData.data[0].category_count;
  280. if (!categoryCount) {
  281. return 'Missing category count';
  282. }
  283.  
  284. // Check if all category counts are zero
  285. const allZero = Object.values(categoryCount).every(count => count === 0);
  286. if (allZero) {
  287. return 'Blank API';
  288. }
  289.  
  290. return null; // No error
  291. }
  292.  
  293.  
  294. //FAVORITE DATA FUNCTIONS=====================================================================================================================
  295. function getStoredData(key) {
  296. // Retrieve the stored value from localStorage using the provided key
  297. const storedValue = localStorage.getItem(key);
  298.  
  299. // If a stored value exists, split it into index and exactState
  300. if (storedValue) {
  301. const [index, exactState] = storedValue.split(',');
  302. return {
  303. index: parseInt(index, 10), // Convert index to an integer
  304. exactState: exactState === '1' // Convert exactState to a boolean
  305. };
  306. }
  307.  
  308. // Return default values if no stored value exists
  309. return { index: 0, exactState: state.exactSearch };
  310. }
  311.  
  312. function storeData(key, index, exactState) {
  313. // Create a string value from index and exactState to store in localStorage
  314. const value = `${index},${exactState ? 1 : 0}`;
  315.  
  316. // Store the value in localStorage using the provided key
  317. localStorage.setItem(key, value);
  318. }
  319.  
  320.  
  321. // PARSE VOCAB FUNCTIONS =====================================================================================================================
  322. function parseVocabFromAnswer() {
  323. // Select all links containing "/kanji/" or "/vocabulary/" in the href attribute
  324. const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
  325. console.log("Parsing Answer Page");
  326.  
  327. // Iterate through the matched elements
  328. for (const element of elements) {
  329. const href = element.getAttribute('href');
  330. const text = element.textContent.trim();
  331.  
  332. // Match the href to extract kanji or vocabulary (ignoring ID if present)
  333. const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
  334. if (match) return match[2].trim();
  335. if (text) return text.trim();
  336. }
  337. return '';
  338. }
  339.  
  340. function parseVocabFromReview() {
  341. // Select the element with class 'kind' to determine the type of content
  342. const kindElement = document.querySelector('.kind');
  343. console.log("Parsing Review Page");
  344.  
  345. // If kindElement doesn't exist, set kindText to ''
  346. const kindText = kindElement ? kindElement.textContent.trim() : '';
  347.  
  348. // Accept 'Kanji', 'Vocabulary', or 'New' kindText
  349. if (kindText !== 'Kanji' && kindText !== 'Vocabulary' && kindText !== 'New') return ''; // Return empty if it's neither kanji nor vocab
  350.  
  351. if (kindText === 'Vocabulary' || kindText === 'New') {
  352. // Select the element with class 'plain' to extract vocabulary
  353. const plainElement = document.querySelector('.plain');
  354. if (!plainElement) return '';
  355.  
  356. let vocabulary = plainElement.textContent.trim();
  357. const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
  358.  
  359. if (nestedVocabularyElement) {
  360. vocabulary = nestedVocabularyElement.textContent.trim();
  361. }
  362. const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
  363.  
  364. if (specificVocabularyElement) {
  365. vocabulary = specificVocabularyElement.textContent.trim();
  366. }
  367.  
  368. // Regular expression to check if the vocabulary contains kanji characters
  369. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  370. if (kanjiRegex.test(vocabulary) || vocabulary) {
  371. console.log("Found Vocabulary:", vocabulary);
  372. return vocabulary;
  373. }
  374. } else if (kindText === 'Kanji') {
  375. // Select the hidden input element to extract kanji
  376. const hiddenInput = document.querySelector('input[name="c"]');
  377. if (!hiddenInput) return '';
  378.  
  379. const vocab = hiddenInput.value.split(',')[1];
  380. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  381. if (kanjiRegex.test(vocab)) {
  382. console.log("Found Kanji:", vocab);
  383. return vocab;
  384. }
  385. }
  386. return '';
  387. }
  388.  
  389. function parseVocabFromVocabulary() {
  390. // Get the current URL
  391. let url = window.location.href;
  392.  
  393. // Remove query parameters (e.g., ?lang=english) and fragment identifiers (#)
  394. url = url.split('?')[0].split('#')[0];
  395.  
  396. // Match the URL structure for a vocabulary page
  397. const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#\/]*)/);
  398. console.log("Parsing Vocabulary Page");
  399.  
  400. if (match) {
  401. // Extract and decode the vocabulary part from the URL
  402. let vocab = match[2];
  403. state.embedAboveSubsectionMeanings = true; // Set state flag
  404. return decodeURIComponent(vocab);
  405. }
  406.  
  407. // Return empty string if no match
  408. return '';
  409. }
  410.  
  411. function parseVocabFromKanji() {
  412. // Get the current URL
  413. const url = window.location.href;
  414.  
  415. // Match the URL structure for a kanji page
  416. const match = url.match(/https:\/\/jpdb\.io\/kanji\/(\d+)\/([^\#]*)#a/);
  417. console.log("Parsing Kanji Page");
  418.  
  419. if (match) {
  420. // Extract and decode the kanji part from the URL
  421. let kanji = match[2];
  422. state.embedAboveSubsectionMeanings = true; // Set state flag
  423. kanji = kanji.split('/')[0];
  424. return decodeURIComponent(kanji);
  425. }
  426.  
  427. // Return empty string if no match
  428. return '';
  429. }
  430.  
  431. function parseVocabFromSearch() {
  432. // Get the current URL
  433. let url = window.location.href;
  434.  
  435. // Match the URL structure for a search query, capturing the vocab between `?q=` and either `&` or `+`
  436. const match = url.match(/https:\/\/jpdb\.io\/search\?q=([^&+]*)/);
  437. console.log("Parsing Search Page");
  438.  
  439. if (match) {
  440. // Extract and decode the vocabulary part from the URL
  441. let vocab = match[1];
  442. return decodeURIComponent(vocab);
  443. }
  444.  
  445. // Return empty string if no match
  446. return '';
  447. }
  448.  
  449. //EMBED FUNCTIONS=====================================================================================================================
  450. function createAnchor(marginLeft) {
  451. // Create and style an anchor element
  452. const anchor = document.createElement('a');
  453. anchor.href = '#';
  454. anchor.style.border = '0';
  455. anchor.style.display = 'inline-flex';
  456. anchor.style.verticalAlign = 'middle';
  457. anchor.style.marginLeft = marginLeft;
  458. return anchor;
  459. }
  460.  
  461. function createIcon(iconClass, fontSize = '1.4rem', color = '#3d81ff') {
  462. // Create and style an icon element
  463. const icon = document.createElement('i');
  464. icon.className = iconClass;
  465. icon.style.fontSize = fontSize;
  466. icon.style.opacity = '1.0';
  467. icon.style.verticalAlign = 'baseline';
  468. icon.style.color = color;
  469. return icon;
  470. }
  471.  
  472. function createSpeakerButton(soundUrl) {
  473. // Create a speaker button with an icon and click event for audio playback
  474. const anchor = createAnchor('0.5rem');
  475. const icon = createIcon('ti ti-volume');
  476. anchor.appendChild(icon);
  477. anchor.addEventListener('click', (event) => {
  478. event.preventDefault();
  479. playAudio(soundUrl);
  480. });
  481. return anchor;
  482. }
  483.  
  484. function createStarButton() {
  485. // Create a star button with an icon and click event for toggling favorite state
  486. const anchor = createAnchor('0.5rem');
  487. const starIcon = document.createElement('span');
  488. const storedValue = localStorage.getItem(state.vocab);
  489.  
  490. // Determine the star icon (filled or empty) based on stored value
  491. if (!storedValue) {
  492. starIcon.textContent = '☆';
  493. } else {
  494. const [storedIndex, storedExactState] = storedValue.split(',');
  495. const index = parseInt(storedIndex, 10);
  496. const exactState = storedExactState === '1';
  497. starIcon.textContent = (state.currentExampleIndex === index && state.exactSearch === exactState) ? '★' : '☆';
  498. }
  499.  
  500. // Style the star icon
  501. starIcon.style.fontSize = '1.4rem';
  502. starIcon.style.color = '#3D8DFF';
  503. starIcon.style.verticalAlign = 'middle';
  504. starIcon.style.position = 'relative';
  505. starIcon.style.top = '-2px';
  506.  
  507. // Append the star icon to the anchor and set up the click event to toggle star state
  508. anchor.appendChild(starIcon);
  509. anchor.addEventListener('click', (event) => {
  510. event.preventDefault();
  511. toggleStarState(starIcon);
  512. });
  513.  
  514. return anchor;
  515. }
  516.  
  517. function toggleStarState(starIcon) {
  518. // Toggle the star state between filled and empty
  519. const storedValue = localStorage.getItem(state.vocab);
  520.  
  521. if (storedValue) {
  522. const [storedIndex, storedExactState] = storedValue.split(',');
  523. const index = parseInt(storedIndex, 10);
  524. const exactState = storedExactState === '1';
  525. if (index === state.currentExampleIndex && exactState === state.exactSearch) {
  526. localStorage.removeItem(state.vocab);
  527. starIcon.textContent = '☆';
  528. } else {
  529. localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
  530. starIcon.textContent = '★';
  531. }
  532. } else {
  533. localStorage.setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
  534. starIcon.textContent = '★';
  535. }
  536. }
  537.  
  538. function createQuoteButton() {
  539. // Create a quote button with an icon and click event for toggling quote style
  540. const anchor = createAnchor('0rem');
  541. const quoteIcon = document.createElement('span');
  542.  
  543. // Set the icon based on exact search state
  544. quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
  545.  
  546. // Style the quote icon
  547. quoteIcon.style.fontSize = '1.1rem';
  548. quoteIcon.style.color = '#3D8DFF';
  549. quoteIcon.style.verticalAlign = 'middle';
  550. quoteIcon.style.position = 'relative';
  551. quoteIcon.style.top = '0px';
  552.  
  553. // Append the quote icon to the anchor and set up the click event to toggle quote state
  554. anchor.appendChild(quoteIcon);
  555. anchor.addEventListener('click', (event) => {
  556. event.preventDefault();
  557. toggleQuoteState(quoteIcon);
  558. });
  559.  
  560. return anchor;
  561. }
  562.  
  563. function toggleQuoteState(quoteIcon) {
  564. // Toggle between single and double quote styles
  565. state.exactSearch = !state.exactSearch;
  566. quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
  567.  
  568. // Update state based on stored data
  569. const storedData = getStoredData(state.vocab);
  570. if (storedData && storedData.exactState === state.exactSearch) {
  571. state.currentExampleIndex = storedData.index;
  572. } else {
  573. state.currentExampleIndex = 0;
  574. }
  575.  
  576. state.apiDataFetched = false;
  577. getImmersionKitData(state.vocab, state.exactSearch)
  578. .then(() => {
  579. embedImageAndPlayAudio();
  580. })
  581. .catch(error => {
  582. console.error(error);
  583. });
  584. }
  585.  
  586. function createMenuButton() {
  587. // Create a menu button with a dropdown menu
  588. const anchor = createAnchor('0.5rem');
  589. const menuIcon = document.createElement('span');
  590. menuIcon.innerHTML = '☰';
  591.  
  592. // Style the menu icon
  593. menuIcon.style.fontSize = '1.4rem';
  594. menuIcon.style.color = '#3D8DFF';
  595. menuIcon.style.verticalAlign = 'middle';
  596. menuIcon.style.position = 'relative';
  597. menuIcon.style.top = '-2px';
  598.  
  599. // Append the menu icon to the anchor and set up the click event to show the overlay menu
  600. anchor.appendChild(menuIcon);
  601. anchor.addEventListener('click', (event) => {
  602. event.preventDefault();
  603. const overlay = createOverlayMenu();
  604. document.body.appendChild(overlay);
  605. });
  606.  
  607. return anchor;
  608. }
  609.  
  610. function createTextButton(vocab, exact) {
  611. // Create a text button for the Immersion Kit
  612. const textButton = document.createElement('a');
  613. textButton.textContent = 'Immersion Kit';
  614. textButton.style.color = 'var(--subsection-label-color)';
  615. textButton.style.fontSize = '85%';
  616. textButton.style.marginRight = '0.5rem';
  617. textButton.style.verticalAlign = 'middle';
  618. textButton.href = `https://www.immersionkit.com/dictionary?keyword=${encodeURIComponent(vocab)}&sort=shortness${exact ? '&exact=true' : ''}`;
  619. textButton.target = '_blank';
  620. return textButton;
  621. }
  622.  
  623. function createButtonContainer(soundUrl, vocab, exact) {
  624. // Create a container for all buttons
  625. const buttonContainer = document.createElement('div');
  626. buttonContainer.className = 'button-container';
  627. buttonContainer.style.display = 'flex';
  628. buttonContainer.style.justifyContent = 'space-between';
  629. buttonContainer.style.alignItems = 'center';
  630. buttonContainer.style.marginBottom = '5px';
  631. buttonContainer.style.lineHeight = '1.4rem';
  632.  
  633. // Create individual buttons
  634. const menuButton = createMenuButton();
  635. const textButton = createTextButton(vocab, exact);
  636. const speakerButton = createSpeakerButton(soundUrl);
  637. const starButton = createStarButton();
  638. const quoteButton = createQuoteButton();
  639.  
  640. // Center the buttons within the container
  641. const centeredButtonsWrapper = document.createElement('div');
  642. centeredButtonsWrapper.style.display = 'flex';
  643. centeredButtonsWrapper.style.justifyContent = 'center';
  644. centeredButtonsWrapper.style.flex = '1';
  645.  
  646. centeredButtonsWrapper.append(textButton, speakerButton, starButton, quoteButton);
  647. buttonContainer.append(centeredButtonsWrapper, menuButton);
  648.  
  649. return buttonContainer;
  650. }
  651.  
  652. function stopCurrentAudio() {
  653. // Stop any currently playing audio
  654. if (state.currentAudio) {
  655. state.currentAudio.source.stop();
  656. state.currentAudio.context.close();
  657. state.currentAudio = null;
  658. }
  659. }
  660.  
  661. function playAudio(soundUrl) {
  662. if (soundUrl) {
  663. stopCurrentAudio();
  664.  
  665. GM_xmlhttpRequest({
  666. method: 'GET',
  667. url: soundUrl,
  668. responseType: 'arraybuffer',
  669. onload: function(response) {
  670. const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  671. audioContext.decodeAudioData(response.response, function(buffer) {
  672. const source = audioContext.createBufferSource();
  673. source.buffer = buffer;
  674.  
  675. const gainNode = audioContext.createGain();
  676.  
  677. // Connect the source to the gain node and the gain node to the destination
  678. source.connect(gainNode);
  679. gainNode.connect(audioContext.destination);
  680.  
  681. // Mute the first part and then ramp up the volume
  682. gainNode.gain.setValueAtTime(0, audioContext.currentTime);
  683. gainNode.gain.linearRampToValueAtTime(CONFIG.SOUND_VOLUME / 100, audioContext.currentTime + 0.1);
  684.  
  685. // Play the audio, skip the first part to avoid any "pop"
  686. source.start(0, 0.05);
  687.  
  688. // Save the current audio context and source for stopping later
  689. state.currentAudio = {
  690. context: audioContext,
  691. source: source
  692. };
  693. }, function(error) {
  694. console.error('Error decoding audio:', error);
  695. });
  696. },
  697. onerror: function(error) {
  698. console.error('Error fetching audio:', error);
  699. }
  700. });
  701. }
  702. }
  703.  
  704. function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
  705. const example = state.examples[state.currentExampleIndex] || {};
  706. const imageUrl = example.image_url || null;
  707. const soundUrl = example.sound_url || null;
  708. const sentence = example.sentence || null;
  709.  
  710. // Remove any existing container
  711. removeExistingContainer();
  712. if (!shouldRenderContainer()) return;
  713.  
  714. // Create and append the main wrapper and text button container
  715. const wrapperDiv = createWrapperDiv();
  716. const textDiv = createButtonContainer(soundUrl, vocab, state.exactSearch);
  717. wrapperDiv.appendChild(textDiv);
  718.  
  719. // Handle image rendering and click event for playing audio
  720. if (state.apiDataFetched) {
  721. if (imageUrl) {
  722. const imageElement = createImageElement(wrapperDiv, imageUrl, vocab, state.exactSearch);
  723. if (imageElement) {
  724. imageElement.addEventListener('click', () => playAudio(soundUrl));
  725. }
  726. } else {
  727. const noImageText = document.createElement('div');
  728. noImageText.textContent = `NO IMAGE\n(${state.examples[state.currentExampleIndex].deck_name})`;
  729. noImageText.style.padding = '100px 0';
  730. noImageText.style.whiteSpace = 'pre'; // This ensures that newlines are respected
  731. wrapperDiv.appendChild(noImageText);
  732. }
  733. } else if (state.error) {
  734. const errorText = document.createElement('div');
  735. errorText.textContent = 'ERROR\nNO EXAMPLES FOUND\n\nRARE WORD OR\nIMMERSIONKIT API IS TEMPORARILY DOWN';
  736. errorText.style.padding = '100px 0';
  737. errorText.style.whiteSpace = 'pre'; // This ensures that newlines are respected
  738. wrapperDiv.appendChild(errorText);
  739. } else {
  740. const loadingText = document.createElement('div');
  741. loadingText.textContent = 'LOADING';
  742. loadingText.style.padding = '100px 0';
  743. wrapperDiv.appendChild(loadingText);
  744. }
  745.  
  746. // Append sentence and translation or a placeholder text
  747. sentence ? appendSentenceAndTranslation(wrapperDiv, sentence, example.translation) : appendNoneText(wrapperDiv);
  748.  
  749. // Create navigation elements
  750. const navigationDiv = createNavigationDiv();
  751. const leftArrow = createLeftArrow(vocab, shouldAutoPlaySound);
  752. const rightArrow = createRightArrow(vocab, shouldAutoPlaySound);
  753.  
  754. // Create and append the main container
  755. const containerDiv = createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv);
  756. appendContainer(containerDiv);
  757.  
  758. // Auto-play sound if configured
  759. if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
  760. playAudio(soundUrl);
  761. }
  762. }
  763.  
  764. function removeExistingContainer() {
  765. // Remove the existing container if it exists
  766. const existingContainer = document.getElementById('immersion-kit-container');
  767. if (existingContainer) {
  768. existingContainer.remove();
  769. }
  770. }
  771.  
  772. function shouldRenderContainer() {
  773. // Determine if the container should be rendered based on the presence of certain elements
  774. const resultVocabularySection = document.querySelector('.result.vocabulary');
  775. const hboxWrapSection = document.querySelector('.hbox.wrap');
  776. const subsectionMeanings = document.querySelector('.subsection-meanings');
  777. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  778. return resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3;
  779. }
  780.  
  781. function createWrapperDiv() {
  782. // Create and style the wrapper div
  783. const wrapperDiv = document.createElement('div');
  784. wrapperDiv.id = 'image-wrapper';
  785. wrapperDiv.style.textAlign = 'center';
  786. wrapperDiv.style.padding = '5px 0';
  787. return wrapperDiv;
  788. }
  789.  
  790. function createImageElement(wrapperDiv, imageUrl, vocab, exactSearch) {
  791. // Create and return an image element with specified attributes
  792. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  793. const titleText = `${searchVocab} #${state.currentExampleIndex + 1} \n${state.examples[state.currentExampleIndex].deck_name}`;
  794. return GM_addElement(wrapperDiv, 'img', {
  795. src: imageUrl,
  796. alt: 'Embedded Image',
  797. title: titleText,
  798. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  799. });
  800. }
  801.  
  802. function highlightVocab(sentence, vocab) {
  803. // Highlight vocabulary in the sentence based on configuration
  804. if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
  805.  
  806. if (state.exactSearch) {
  807. const regex = new RegExp(`(${vocab})`, 'g');
  808. return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
  809. } else {
  810. return vocab.split('').reduce((acc, char) => {
  811. const regex = new RegExp(char, 'g');
  812. return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
  813. }, sentence);
  814. }
  815. }
  816.  
  817. function appendSentenceAndTranslation(wrapperDiv, sentence, translation) {
  818. // Append sentence and translation to the wrapper div
  819. const sentenceText = document.createElement('div');
  820. sentenceText.innerHTML = highlightVocab(sentence, state.vocab);
  821. sentenceText.style.marginTop = '10px';
  822. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  823. sentenceText.style.color = 'lightgray';
  824. sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  825. sentenceText.style.whiteSpace = 'pre-wrap';
  826. wrapperDiv.appendChild(sentenceText);
  827.  
  828. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && translation) {
  829. const translationText = document.createElement('div');
  830. translationText.innerHTML = replaceSpecialCharacters(translation);
  831. translationText.style.marginTop = '5px';
  832. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  833. translationText.style.color = 'var(--subsection-label-color)';
  834. translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  835. translationText.style.whiteSpace = 'pre-wrap';
  836. wrapperDiv.appendChild(translationText);
  837. }
  838. }
  839.  
  840. function appendNoneText(wrapperDiv) {
  841. // Append a "None" text to the wrapper div
  842. const noneText = document.createElement('div');
  843. noneText.textContent = 'None';
  844. noneText.style.marginTop = '10px';
  845. noneText.style.fontSize = '85%';
  846. noneText.style.color = 'var(--subsection-label-color)';
  847. wrapperDiv.appendChild(noneText);
  848. }
  849.  
  850. function createNavigationDiv() {
  851. // Create and style the navigation div
  852. const navigationDiv = document.createElement('div');
  853. navigationDiv.id = 'immersion-kit-embed';
  854. navigationDiv.style.display = 'flex';
  855. navigationDiv.style.justifyContent = 'center';
  856. navigationDiv.style.alignItems = 'center';
  857. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  858. navigationDiv.style.margin = '0 auto';
  859. return navigationDiv;
  860. }
  861.  
  862. function createLeftArrow(vocab, shouldAutoPlaySound) {
  863. // Create and configure the left arrow button
  864. const leftArrow = document.createElement('button');
  865. leftArrow.textContent = '<';
  866. leftArrow.style.marginRight = '10px';
  867. leftArrow.disabled = state.currentExampleIndex === 0;
  868. leftArrow.addEventListener('click', () => {
  869. if (state.currentExampleIndex > 0) {
  870. state.currentExampleIndex--;
  871. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  872. preloadImages();
  873. }
  874. });
  875. return leftArrow;
  876. }
  877.  
  878. function createRightArrow(vocab, shouldAutoPlaySound) {
  879. // Create and configure the right arrow button
  880. const rightArrow = document.createElement('button');
  881. rightArrow.textContent = '>';
  882. rightArrow.style.marginLeft = '10px';
  883. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  884. rightArrow.addEventListener('click', () => {
  885. if (state.currentExampleIndex < state.examples.length - 1) {
  886. state.currentExampleIndex++;
  887. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  888. preloadImages();
  889. }
  890. });
  891. return rightArrow;
  892. }
  893.  
  894. function createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv) {
  895. // Create and configure the main container div
  896. const containerDiv = document.createElement('div');
  897. containerDiv.id = 'immersion-kit-container';
  898. containerDiv.style.display = 'flex';
  899. containerDiv.style.alignItems = 'center';
  900. containerDiv.style.justifyContent = 'center';
  901. containerDiv.style.flexDirection = 'column';
  902.  
  903. const arrowWrapperDiv = document.createElement('div');
  904. arrowWrapperDiv.style.display = 'flex';
  905. arrowWrapperDiv.style.alignItems = 'center';
  906. arrowWrapperDiv.style.justifyContent = 'center';
  907.  
  908. arrowWrapperDiv.append(leftArrow, wrapperDiv, rightArrow);
  909. containerDiv.append(arrowWrapperDiv, navigationDiv);
  910.  
  911. return containerDiv;
  912. }
  913.  
  914. function appendContainer(containerDiv) {
  915. // Append the container div to the appropriate section based on configuration
  916. const resultVocabularySection = document.querySelector('.result.vocabulary');
  917. const hboxWrapSection = document.querySelector('.hbox.wrap');
  918. const subsectionMeanings = document.querySelector('.subsection-meanings');
  919. const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
  920. const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
  921. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  922. const vboxGap = document.querySelector('.vbox.gap');
  923.  
  924. if (CONFIG.WIDE_MODE && subsectionMeanings) {
  925. const wrapper = document.createElement('div');
  926. wrapper.style.display = 'flex';
  927. wrapper.style.alignItems = 'flex-start';
  928.  
  929. const originalContentWrapper = document.createElement('div');
  930. originalContentWrapper.style.flex = '1';
  931. originalContentWrapper.appendChild(subsectionMeanings);
  932.  
  933. if (subsectionComposedOfKanji) {
  934. const newline1 = document.createElement('br');
  935. originalContentWrapper.appendChild(newline1);
  936. originalContentWrapper.appendChild(subsectionComposedOfKanji);
  937. }
  938. if (subsectionPitchAccent) {
  939. const newline2 = document.createElement('br');
  940. originalContentWrapper.appendChild(newline2);
  941. originalContentWrapper.appendChild(subsectionPitchAccent);
  942. }
  943.  
  944. wrapper.appendChild(originalContentWrapper);
  945. wrapper.appendChild(containerDiv);
  946.  
  947. if (vboxGap) {
  948. const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
  949. if (existingDynamicDiv) {
  950. existingDynamicDiv.remove();
  951. }
  952.  
  953. const dynamicDiv = document.createElement('div');
  954. dynamicDiv.id = 'dynamic-content';
  955. dynamicDiv.appendChild(wrapper);
  956.  
  957. if (window.location.href.includes('vocabulary')) {
  958. vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
  959. } else {
  960. vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild);
  961. }
  962. }
  963. } else {
  964. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  965. subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
  966. } else if (resultVocabularySection) {
  967. resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
  968. } else if (hboxWrapSection) {
  969. hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
  970. } else if (subsectionLabels.length >= 4) {
  971. subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
  972. }
  973. }
  974. }
  975.  
  976. function embedImageAndPlayAudio() {
  977. // Embed the image and play audio, removing existing navigation div if present
  978. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  979. if (existingNavigationDiv) existingNavigationDiv.remove();
  980.  
  981. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  982.  
  983. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  984. preloadImages();
  985. }
  986.  
  987. function replaceSpecialCharacters(text) {
  988. // Replace special characters in the text
  989. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  990. }
  991.  
  992. function preloadImages() {
  993. // Preload images around the current example index
  994. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  995. const startIndex = Math.max(0, state.currentExampleIndex - CONFIG.NUMBER_OF_PRELOADS);
  996. const endIndex = Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUMBER_OF_PRELOADS);
  997.  
  998. for (let i = startIndex; i <= endIndex; i++) {
  999. if (!state.preloadedIndices.has(i) && state.examples[i].image_url) {
  1000. GM_addElement(preloadDiv, 'img', { src: state.examples[i].image_url });
  1001. state.preloadedIndices.add(i);
  1002. }
  1003. }
  1004. }
  1005.  
  1006.  
  1007. //MENU FUNCTIONS=====================================================================================================================
  1008. ////FILE OPERATIONS=====================================================================================================================
  1009. function handleImportButtonClick() {
  1010. handleFileInput('application/json', importFavorites);
  1011. }
  1012.  
  1013. function handleFileInput(acceptType, callback) {
  1014. const fileInput = document.createElement('input');
  1015. fileInput.type = 'file';
  1016. fileInput.accept = acceptType;
  1017. fileInput.addEventListener('change', callback);
  1018. fileInput.click();
  1019. }
  1020.  
  1021. function createBlobAndDownload(data, filename, type) {
  1022. const blob = new Blob([data], { type });
  1023. const url = URL.createObjectURL(blob);
  1024. const a = document.createElement('a');
  1025. a.href = url;
  1026. a.download = filename;
  1027. document.body.appendChild(a);
  1028. a.click();
  1029. document.body.removeChild(a);
  1030. URL.revokeObjectURL(url);
  1031. }
  1032.  
  1033. function exportFavorites() {
  1034. const favorites = {};
  1035. for (let i = 0; i < localStorage.length; i++) {
  1036. const key = localStorage.key(i);
  1037. if (!key.startsWith('CONFIG')) {
  1038. favorites[key] = localStorage.getItem(key);
  1039. }
  1040. }
  1041. const data = JSON.stringify(favorites, null, 2);
  1042. createBlobAndDownload(data, 'favorites.json', 'application/json');
  1043. }
  1044.  
  1045. function importFavorites(event) {
  1046. const file = event.target.files[0];
  1047. if (!file) return;
  1048.  
  1049. const reader = new FileReader();
  1050. reader.onload = function(e) {
  1051. try {
  1052. const favorites = JSON.parse(e.target.result);
  1053. for (const key in favorites) {
  1054. localStorage.setItem(key, favorites[key]);
  1055. }
  1056. alert('Favorites imported successfully!');
  1057. location.reload();
  1058. } catch (error) {
  1059. alert('Error importing favorites:', error);
  1060. }
  1061. };
  1062. reader.readAsText(file);
  1063. }
  1064.  
  1065. ////CONFIRMATION
  1066. function createConfirmationPopup(messageText, onYes, onNo) {
  1067. // Create a confirmation popup with Yes and No buttons
  1068. const popupOverlay = document.createElement('div');
  1069. popupOverlay.style.position = 'fixed';
  1070. popupOverlay.style.top = '0';
  1071. popupOverlay.style.left = '0';
  1072. popupOverlay.style.width = '100%';
  1073. popupOverlay.style.height = '100%';
  1074. popupOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1075. popupOverlay.style.zIndex = '1001';
  1076. popupOverlay.style.display = 'flex';
  1077. popupOverlay.style.justifyContent = 'center';
  1078. popupOverlay.style.alignItems = 'center';
  1079.  
  1080. const popupContent = document.createElement('div');
  1081. popupContent.style.backgroundColor = 'var(--background-color)';
  1082. popupContent.style.padding = '20px';
  1083. popupContent.style.borderRadius = '5px';
  1084. popupContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1085. popupContent.style.textAlign = 'center';
  1086.  
  1087. const message = document.createElement('p');
  1088. message.textContent = messageText;
  1089.  
  1090. const yesButton = document.createElement('button');
  1091. yesButton.textContent = 'Yes';
  1092. yesButton.style.backgroundColor = '#C82800';
  1093. yesButton.style.marginRight = '10px';
  1094. yesButton.addEventListener('click', () => {
  1095. onYes();
  1096. document.body.removeChild(popupOverlay);
  1097. });
  1098.  
  1099. const noButton = document.createElement('button');
  1100. noButton.textContent = 'No';
  1101. noButton.addEventListener('click', () => {
  1102. onNo();
  1103. document.body.removeChild(popupOverlay);
  1104. });
  1105.  
  1106. popupContent.appendChild(message);
  1107. popupContent.appendChild(yesButton);
  1108. popupContent.appendChild(noButton);
  1109. popupOverlay.appendChild(popupContent);
  1110.  
  1111. document.body.appendChild(popupOverlay);
  1112. }
  1113.  
  1114. ////BUTTONS
  1115. function createActionButtonsContainer() {
  1116. const actionButtonWidth = '100px';
  1117.  
  1118. const closeButton = createButton('Close', '10px', closeOverlayMenu, actionButtonWidth);
  1119. const saveButton = createButton('Save', '10px', saveConfig, actionButtonWidth);
  1120. const defaultButton = createDefaultButton(actionButtonWidth);
  1121. const deleteButton = createDeleteButton(actionButtonWidth);
  1122.  
  1123. const actionButtonsContainer = document.createElement('div');
  1124. actionButtonsContainer.style.textAlign = 'center';
  1125. actionButtonsContainer.style.marginTop = '10px';
  1126. actionButtonsContainer.append(closeButton, saveButton, defaultButton, deleteButton);
  1127.  
  1128. return actionButtonsContainer;
  1129. }
  1130.  
  1131. function createMenuButtons() {
  1132. const exportImportContainer = createExportImportContainer();
  1133. const actionButtonsContainer = createActionButtonsContainer();
  1134.  
  1135. const buttonContainer = document.createElement('div');
  1136. buttonContainer.append(exportImportContainer, actionButtonsContainer);
  1137.  
  1138. return buttonContainer;
  1139. }
  1140.  
  1141. function createButton(text, margin, onClick, width) {
  1142. // Create a button element with specified properties
  1143. const button = document.createElement('button');
  1144. button.textContent = text;
  1145. button.style.margin = margin;
  1146. button.style.width = width;
  1147. button.style.textAlign = 'center';
  1148. button.style.display = 'inline-block';
  1149. button.style.lineHeight = '30px';
  1150. button.style.padding = '5px 0';
  1151. button.addEventListener('click', onClick);
  1152. return button;
  1153. }
  1154. ////IMPORT/EXPORT BUTTONS
  1155. function createExportImportContainer() {
  1156. const exportImportButtonWidth = '200px';
  1157.  
  1158. const exportButton = createButton('Export Favorites', '10px', exportFavorites, exportImportButtonWidth);
  1159. const importButton = createButton('Import Favorites', '10px', handleImportButtonClick, exportImportButtonWidth);
  1160.  
  1161. const exportImportContainer = document.createElement('div');
  1162. exportImportContainer.style.textAlign = 'center';
  1163. exportImportContainer.style.marginTop = '10px';
  1164. exportImportContainer.append(exportButton, importButton);
  1165.  
  1166. return exportImportContainer;
  1167. }
  1168. ////CLOSE BUTTON
  1169. function closeOverlayMenu() {
  1170. loadConfig();
  1171. document.body.removeChild(document.getElementById('overlayMenu'));
  1172. }
  1173.  
  1174. ////SAVE BUTTON
  1175. function saveConfig() {
  1176. const overlay = document.getElementById('overlayMenu');
  1177. if (!overlay) return;
  1178.  
  1179. const inputs = overlay.querySelectorAll('input, span');
  1180. const { changes, minimumExampleLengthChanged, newMinimumExampleLength } = gatherChanges(inputs);
  1181.  
  1182. if (minimumExampleLengthChanged) {
  1183. handleMinimumExampleLengthChange(newMinimumExampleLength, changes);
  1184. } else {
  1185. applyChanges(changes);
  1186. finalizeSaveConfig();
  1187. setVocabSize();
  1188. setPageWidth();
  1189. }
  1190. }
  1191.  
  1192. function gatherChanges(inputs) {
  1193. let minimumExampleLengthChanged = false;
  1194. let newMinimumExampleLength;
  1195. const changes = {};
  1196.  
  1197. inputs.forEach(input => {
  1198. const key = input.getAttribute('data-key');
  1199. const type = input.getAttribute('data-type');
  1200. let value;
  1201.  
  1202. if (type === 'boolean') {
  1203. value = input.checked;
  1204. } else if (type === 'number') {
  1205. value = parseFloat(input.textContent);
  1206. } else if (type === 'string') {
  1207. value = input.textContent;
  1208. }
  1209.  
  1210. if (key && type) {
  1211. const typePart = input.getAttribute('data-type-part');
  1212. const originalFormattedType = typePart.slice(1, -1);
  1213.  
  1214. if (key === 'MINIMUM_EXAMPLE_LENGTH' && CONFIG.MINIMUM_EXAMPLE_LENGTH !== value) {
  1215. minimumExampleLengthChanged = true;
  1216. newMinimumExampleLength = value;
  1217. }
  1218.  
  1219. changes[`CONFIG.${key}`] = value + originalFormattedType;
  1220. }
  1221. });
  1222.  
  1223. return { changes, minimumExampleLengthChanged, newMinimumExampleLength };
  1224. }
  1225.  
  1226. function handleMinimumExampleLengthChange(newMinimumExampleLength, changes) {
  1227. createConfirmationPopup(
  1228. 'Changing Minimum Example Length will break your current favorites. They will all be deleted. Are you sure?',
  1229. async () => {
  1230. await IndexedDBManager.delete();
  1231. CONFIG.MINIMUM_EXAMPLE_LENGTH = newMinimumExampleLength;
  1232. localStorage.setItem('CONFIG.MINIMUM_EXAMPLE_LENGTH', newMinimumExampleLength);
  1233. applyChanges(changes);
  1234. clearNonConfigLocalStorage();
  1235. finalizeSaveConfig();
  1236. location.reload();
  1237. },
  1238. () => {
  1239. const overlay = document.getElementById('overlayMenu');
  1240. document.body.removeChild(overlay);
  1241. document.body.appendChild(createOverlayMenu());
  1242. }
  1243. );
  1244. }
  1245.  
  1246. function clearNonConfigLocalStorage() {
  1247. for (let i = 0; i < localStorage.length; i++) {
  1248. const key = localStorage.key(i);
  1249. if (key && !key.startsWith('CONFIG')) {
  1250. localStorage.removeItem(key);
  1251. i--; // Adjust index after removal
  1252. }
  1253. }
  1254. }
  1255.  
  1256. function applyChanges(changes) {
  1257. for (const key in changes) {
  1258. localStorage.setItem(key, changes[key]);
  1259. }
  1260. }
  1261.  
  1262. function finalizeSaveConfig() {
  1263. loadConfig();
  1264. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1265. const overlay = document.getElementById('overlayMenu');
  1266. if (overlay) {
  1267. document.body.removeChild(overlay);
  1268. }
  1269. }
  1270.  
  1271.  
  1272. ////DEFAULT BUTTON
  1273. function createDefaultButton(width) {
  1274. const defaultButton = createButton('Default', '10px', () => {
  1275. createConfirmationPopup(
  1276. 'This will reset all your settings to default. Are you sure?',
  1277. () => {
  1278. Object.keys(localStorage).forEach(key => {
  1279. if (key.startsWith('CONFIG')) {
  1280. localStorage.removeItem(key);
  1281. }
  1282. });
  1283. location.reload();
  1284. },
  1285. () => {
  1286. const overlay = document.getElementById('overlayMenu');
  1287. if (overlay) {
  1288. document.body.removeChild(overlay);
  1289. }
  1290. loadConfig();
  1291. document.body.appendChild(createOverlayMenu());
  1292. }
  1293. );
  1294. }, width);
  1295. defaultButton.style.backgroundColor = '#C82800';
  1296. defaultButton.style.color = 'white';
  1297. return defaultButton;
  1298. }
  1299.  
  1300.  
  1301. ////DELETE BUTTON
  1302. function createDeleteButton(width) {
  1303. const deleteButton = createButton('DELETE', '10px', () => {
  1304. createConfirmationPopup(
  1305. 'This will delete all your favorites and cached data. Are you sure?',
  1306. async () => {
  1307. await IndexedDBManager.delete();
  1308. Object.keys(localStorage).forEach(key => {
  1309. if (!key.startsWith('CONFIG')) {
  1310. localStorage.removeItem(key);
  1311. }
  1312. });
  1313. location.reload();
  1314. },
  1315. () => {
  1316. const overlay = document.getElementById('overlayMenu');
  1317. if (overlay) {
  1318. document.body.removeChild(overlay);
  1319. }
  1320. loadConfig();
  1321. document.body.appendChild(createOverlayMenu());
  1322. }
  1323. );
  1324. }, width);
  1325. deleteButton.style.backgroundColor = '#C82800';
  1326. deleteButton.style.color = 'white';
  1327. return deleteButton;
  1328. }
  1329.  
  1330. function createOverlayMenu() {
  1331. // Create and return the overlay menu for configuration settings
  1332. const overlay = document.createElement('div');
  1333. overlay.id = 'overlayMenu';
  1334. overlay.style.position = 'fixed';
  1335. overlay.style.top = '0';
  1336. overlay.style.left = '0';
  1337. overlay.style.width = '100%';
  1338. overlay.style.height = '100%';
  1339. overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1340. overlay.style.zIndex = '1000';
  1341. overlay.style.display = 'flex';
  1342. overlay.style.justifyContent = 'center';
  1343. overlay.style.alignItems = 'center';
  1344.  
  1345. const menuContent = document.createElement('div');
  1346. menuContent.style.backgroundColor = 'var(--background-color)';
  1347. menuContent.style.color = 'var(--text-color)';
  1348. menuContent.style.padding = '20px';
  1349. menuContent.style.borderRadius = '5px';
  1350. menuContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1351. menuContent.style.width = '80%';
  1352. menuContent.style.maxWidth = '550px';
  1353. menuContent.style.maxHeight = '80%';
  1354. menuContent.style.overflowY = 'auto';
  1355.  
  1356. for (const [key, value] of Object.entries(CONFIG)) {
  1357. const optionContainer = document.createElement('div');
  1358. optionContainer.style.marginBottom = '10px';
  1359. optionContainer.style.display = 'flex';
  1360. optionContainer.style.alignItems = 'center';
  1361.  
  1362. const leftContainer = document.createElement('div');
  1363. leftContainer.style.flex = '1';
  1364. leftContainer.style.display = 'flex';
  1365. leftContainer.style.alignItems = 'center';
  1366.  
  1367. const rightContainer = document.createElement('div');
  1368. rightContainer.style.flex = '1';
  1369. rightContainer.style.display = 'flex';
  1370. rightContainer.style.alignItems = 'center';
  1371. rightContainer.style.justifyContent = 'center';
  1372.  
  1373. const label = document.createElement('label');
  1374. label.textContent = key.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
  1375. label.style.marginRight = '10px';
  1376.  
  1377. leftContainer.appendChild(label);
  1378.  
  1379. if (typeof value === 'boolean') {
  1380. const checkboxContainer = document.createElement('div');
  1381. checkboxContainer.style.display = 'flex';
  1382. checkboxContainer.style.alignItems = 'center';
  1383. checkboxContainer.style.justifyContent = 'center';
  1384.  
  1385. const checkbox = document.createElement('input');
  1386. checkbox.type = 'checkbox';
  1387. checkbox.checked = value;
  1388. checkbox.setAttribute('data-key', key);
  1389. checkbox.setAttribute('data-type', 'boolean');
  1390. checkbox.setAttribute('data-type-part', '');
  1391. checkboxContainer.appendChild(checkbox);
  1392.  
  1393. rightContainer.appendChild(checkboxContainer);
  1394. } else if (typeof value === 'number') {
  1395. const numberContainer = document.createElement('div');
  1396. numberContainer.style.display = 'flex';
  1397. numberContainer.style.alignItems = 'center';
  1398. numberContainer.style.justifyContent = 'center';
  1399.  
  1400. const decrementButton = document.createElement('button');
  1401. decrementButton.textContent = '-';
  1402. decrementButton.style.marginRight = '5px';
  1403.  
  1404. const input = document.createElement('span');
  1405. input.textContent = value;
  1406. input.style.margin = '0 10px';
  1407. input.style.minWidth = '3ch';
  1408. input.style.textAlign = 'center';
  1409. input.setAttribute('data-key', key);
  1410. input.setAttribute('data-type', 'number');
  1411. input.setAttribute('data-type-part', '');
  1412.  
  1413. const incrementButton = document.createElement('button');
  1414. incrementButton.textContent = '+';
  1415. incrementButton.style.marginLeft = '5px';
  1416.  
  1417. const updateButtonStates = () => {
  1418. let currentValue = parseFloat(input.textContent);
  1419. if (currentValue <= 0) {
  1420. decrementButton.disabled = true;
  1421. decrementButton.style.color = 'grey';
  1422. } else {
  1423. decrementButton.disabled = false;
  1424. decrementButton.style.color = '';
  1425. }
  1426. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1427. incrementButton.disabled = true;
  1428. incrementButton.style.color = 'grey';
  1429. } else {
  1430. incrementButton.disabled = false;
  1431. incrementButton.style.color = '';
  1432. }
  1433. };
  1434.  
  1435. decrementButton.addEventListener('click', () => {
  1436. let currentValue = parseFloat(input.textContent);
  1437. if (currentValue > 0) {
  1438. if (currentValue > 200) {
  1439. input.textContent = currentValue - 25;
  1440. } else if (currentValue > 20) {
  1441. input.textContent = currentValue - 5;
  1442. } else {
  1443. input.textContent = currentValue - 1;
  1444. }
  1445. updateButtonStates();
  1446. }
  1447. });
  1448.  
  1449. incrementButton.addEventListener('click', () => {
  1450. let currentValue = parseFloat(input.textContent);
  1451. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1452. return;
  1453. }
  1454. if (currentValue >= 200) {
  1455. input.textContent = currentValue + 25;
  1456. } else if (currentValue >= 20) {
  1457. input.textContent = currentValue + 5;
  1458. } else {
  1459. input.textContent = currentValue + 1;
  1460. }
  1461. updateButtonStates();
  1462. });
  1463.  
  1464. numberContainer.appendChild(decrementButton);
  1465. numberContainer.appendChild(input);
  1466. numberContainer.appendChild(incrementButton);
  1467.  
  1468. rightContainer.appendChild(numberContainer);
  1469.  
  1470. // Initialize button states
  1471. updateButtonStates();
  1472. } else if (typeof value === 'string') {
  1473. const typeParts = value.split(/(\d+)/).filter(Boolean);
  1474. const numberParts = typeParts.filter(part => !isNaN(part)).map(Number);
  1475.  
  1476. const numberContainer = document.createElement('div');
  1477. numberContainer.style.display = 'flex';
  1478. numberContainer.style.alignItems = 'center';
  1479. numberContainer.style.justifyContent = 'center';
  1480.  
  1481. const typeSpan = document.createElement('span');
  1482. const formattedType = '(' + typeParts.filter(part => isNaN(part)).join('').replace(/_/g, ' ').toLowerCase() + ')';
  1483. typeSpan.textContent = formattedType;
  1484. typeSpan.style.marginRight = '10px';
  1485.  
  1486. leftContainer.appendChild(typeSpan);
  1487.  
  1488. typeParts.forEach(part => {
  1489. if (!isNaN(part)) {
  1490. const decrementButton = document.createElement('button');
  1491. decrementButton.textContent = '-';
  1492. decrementButton.style.marginRight = '5px';
  1493.  
  1494. const input = document.createElement('span');
  1495. input.textContent = part;
  1496. input.style.margin = '0 10px';
  1497. input.style.minWidth = '3ch';
  1498. input.style.textAlign = 'center';
  1499. input.setAttribute('data-key', key);
  1500. input.setAttribute('data-type', 'string');
  1501. input.setAttribute('data-type-part', formattedType);
  1502.  
  1503. const incrementButton = document.createElement('button');
  1504. incrementButton.textContent = '+';
  1505. incrementButton.style.marginLeft = '5px';
  1506.  
  1507. const updateButtonStates = () => {
  1508. let currentValue = parseFloat(input.textContent);
  1509. if (currentValue <= 0) {
  1510. decrementButton.disabled = true;
  1511. decrementButton.style.color = 'grey';
  1512. } else {
  1513. decrementButton.disabled = false;
  1514. decrementButton.style.color = '';
  1515. }
  1516. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1517. incrementButton.disabled = true;
  1518. incrementButton.style.color = 'grey';
  1519. } else {
  1520. incrementButton.disabled = false;
  1521. incrementButton.style.color = '';
  1522. }
  1523. };
  1524.  
  1525. decrementButton.addEventListener('click', () => {
  1526. let currentValue = parseFloat(input.textContent);
  1527. if (currentValue > 0) {
  1528. if (currentValue > 200) {
  1529. input.textContent = currentValue - 25;
  1530. } else if (currentValue > 20) {
  1531. input.textContent = currentValue - 5;
  1532. } else {
  1533. input.textContent = currentValue - 1;
  1534. }
  1535. updateButtonStates();
  1536. }
  1537. });
  1538.  
  1539. incrementButton.addEventListener('click', () => {
  1540. let currentValue = parseFloat(input.textContent);
  1541. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1542. return;
  1543. }
  1544. if (currentValue >= 200) {
  1545. input.textContent = currentValue + 25;
  1546. } else if (currentValue >= 20) {
  1547. input.textContent = currentValue + 5;
  1548. } else {
  1549. input.textContent = currentValue + 1;
  1550. }
  1551. updateButtonStates();
  1552. });
  1553.  
  1554. numberContainer.appendChild(decrementButton);
  1555. numberContainer.appendChild(input);
  1556. numberContainer.appendChild(incrementButton);
  1557.  
  1558. // Initialize button states
  1559. updateButtonStates();
  1560. }
  1561. });
  1562.  
  1563. rightContainer.appendChild(numberContainer);
  1564. }
  1565.  
  1566. optionContainer.appendChild(leftContainer);
  1567. optionContainer.appendChild(rightContainer);
  1568. menuContent.appendChild(optionContainer);
  1569. }
  1570.  
  1571. const menuButtons = createMenuButtons();
  1572. menuContent.appendChild(menuButtons);
  1573.  
  1574. overlay.appendChild(menuContent);
  1575.  
  1576. return overlay;
  1577. }
  1578.  
  1579. function loadConfig() {
  1580. for (const key in localStorage) {
  1581. if (!localStorage.hasOwnProperty(key) || !key.startsWith('CONFIG.')) continue;
  1582.  
  1583. const configKey = key.substring('CONFIG.'.length);
  1584. if (!CONFIG.hasOwnProperty(configKey)) continue;
  1585.  
  1586. const savedValue = localStorage.getItem(key);
  1587. if (savedValue === null) continue;
  1588.  
  1589. const valueType = typeof CONFIG[configKey];
  1590. if (valueType === 'boolean') {
  1591. CONFIG[configKey] = savedValue === 'true';
  1592. } else if (valueType === 'number') {
  1593. CONFIG[configKey] = parseFloat(savedValue);
  1594. } else if (valueType === 'string') {
  1595. CONFIG[configKey] = savedValue;
  1596. }
  1597. }
  1598. }
  1599.  
  1600.  
  1601. //MAIN FUNCTIONS=====================================================================================================================
  1602. function onPageLoad() {
  1603. // Initialize state and determine vocabulary based on URL
  1604. state.embedAboveSubsectionMeanings = false;
  1605.  
  1606. const url = window.location.href;
  1607. const machineTranslationFrame = document.getElementById('machine-translation-frame');
  1608.  
  1609. // Proceed only if the machine translation frame is not present
  1610. if (!machineTranslationFrame) {
  1611.  
  1612. //display embed for first time with loading text
  1613. embedImageAndPlayAudio();
  1614. setPageWidth();
  1615.  
  1616. if (url.includes('/vocabulary/')) {
  1617. state.vocab = parseVocabFromVocabulary();
  1618. } else if (url.includes('/search?q=')) {
  1619. state.vocab = parseVocabFromSearch();
  1620. } else if (url.includes('c=')) {
  1621. state.vocab = parseVocabFromAnswer();
  1622. } else if (url.includes('/kanji/')) {
  1623. state.vocab = parseVocabFromKanji();
  1624. } else {
  1625. state.vocab = parseVocabFromReview();
  1626. }
  1627. } else {
  1628. console.log('Machine translation frame detected, skipping vocabulary parsing.');
  1629. }
  1630.  
  1631. // Retrieve stored data for the current vocabulary
  1632. const { index, exactState } = getStoredData(state.vocab);
  1633. state.currentExampleIndex = index;
  1634. state.exactSearch = exactState;
  1635.  
  1636. // Fetch data and embed image/audio if necessary
  1637. if (state.vocab && !state.apiDataFetched) {
  1638. getImmersionKitData(state.vocab, state.exactSearch)
  1639. .then(() => {
  1640. preloadImages();
  1641. if (!/https:\/\/jpdb\.io\/review(#a)?$/.test(url)) {
  1642. embedImageAndPlayAudio();
  1643. }
  1644. })
  1645. .catch(console.error);
  1646. } else if (state.apiDataFetched) {
  1647. embedImageAndPlayAudio();
  1648. preloadImages();
  1649. setVocabSize();
  1650. setPageWidth();
  1651. }
  1652. }
  1653.  
  1654. function setPageWidth() {
  1655. // Set the maximum width of the page
  1656. document.body.style.maxWidth = CONFIG.PAGE_WIDTH;
  1657. }
  1658.  
  1659. // Observe URL changes and reload the page content accordingly
  1660. const observer = new MutationObserver(() => {
  1661. if (window.location.href !== observer.lastUrl) {
  1662. observer.lastUrl = window.location.href;
  1663. onPageLoad();
  1664. }
  1665. });
  1666.  
  1667. // Function to apply styles
  1668. function setVocabSize() {
  1669. // Create a new style element
  1670. const style = document.createElement('style');
  1671. style.type = 'text/css';
  1672. style.innerHTML = `
  1673. .answer-box > .plain {
  1674. font-size: ${CONFIG.VOCAB_SIZE} !important; /* Use the configurable font size */
  1675. padding-bottom: 0.1rem !important; /* Retain padding */
  1676. }
  1677. `;
  1678.  
  1679. // Append the new style to the document head
  1680. document.head.appendChild(style);
  1681. }
  1682. observer.lastUrl = window.location.href;
  1683. observer.observe(document, { subtree: true, childList: true });
  1684.  
  1685. // Add event listeners for page load and URL changes
  1686. window.addEventListener('load', onPageLoad);
  1687. window.addEventListener('popstate', onPageLoad);
  1688. window.addEventListener('hashchange', onPageLoad);
  1689.  
  1690. // Initial configuration and preloading
  1691. loadConfig();
  1692. setPageWidth();
  1693. setVocabSize();
  1694. preloadImages();
  1695.  
  1696. })();