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-21 提交的版本,檢視 最新版本

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