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.16
  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 stopCurrentAudio() {
  654. // Stop any currently playing audio
  655. if (state.currentAudio) {
  656. state.currentAudio.source.stop();
  657. state.currentAudio.context.close();
  658. state.currentAudio = null;
  659. }
  660. }
  661.  
  662. function playAudio(soundUrl) {
  663. // Skip playing audio if it is already playing
  664. if (state.currentlyPlayingAudio) {
  665. //console.log('Duplicate audio was skipped.');
  666. return;
  667. }
  668.  
  669. if (soundUrl) {
  670. state.currentlyPlayingAudio = true;
  671. stopCurrentAudio();
  672.  
  673. GM_xmlhttpRequest({
  674. method: 'GET',
  675. url: soundUrl,
  676. responseType: 'arraybuffer',
  677. onload: function(response) {
  678. const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  679. audioContext.decodeAudioData(response.response, function(buffer) {
  680. const source = audioContext.createBufferSource();
  681. source.buffer = buffer;
  682.  
  683. const gainNode = audioContext.createGain();
  684.  
  685. // Connect the source to the gain node and the gain node to the destination
  686. source.connect(gainNode);
  687. gainNode.connect(audioContext.destination);
  688.  
  689. // Mute the first part and then ramp up the volume
  690. gainNode.gain.setValueAtTime(0, audioContext.currentTime);
  691. gainNode.gain.linearRampToValueAtTime(CONFIG.SOUND_VOLUME / 100, audioContext.currentTime + 0.1);
  692.  
  693. // Play the audio, skip the first part to avoid any "pop"
  694. source.start(0, 0.05);
  695.  
  696. // Log when the audio starts playing
  697. //console.log('Audio has started playing.');
  698.  
  699. // Save the current audio context and source for stopping later
  700. state.currentAudio = {
  701. context: audioContext,
  702. source: source
  703. };
  704.  
  705. // Set currentlyPlayingAudio to false when the audio ends
  706. source.onended = function() {
  707. state.currentlyPlayingAudio = false;
  708. };
  709. }, function(error) {
  710. console.error('Error decoding audio:', error);
  711. state.currentlyPlayingAudio = false;
  712. });
  713. },
  714. onerror: function(error) {
  715. console.error('Error fetching audio:', error);
  716. state.currentlyPlayingAudio = false;
  717. }
  718. });
  719. }
  720. }
  721.  
  722. function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
  723. const example = state.examples[state.currentExampleIndex] || {};
  724. const imageUrl = example.image_url || null;
  725. const soundUrl = example.sound_url || null;
  726. const sentence = example.sentence || null;
  727.  
  728. // Remove any existing container
  729. removeExistingContainer();
  730. if (!shouldRenderContainer()) return;
  731.  
  732. // Create and append the main wrapper and text button container
  733. const wrapperDiv = createWrapperDiv();
  734. const textDiv = createButtonContainer(soundUrl, vocab, state.exactSearch);
  735. wrapperDiv.appendChild(textDiv);
  736.  
  737. // Handle image rendering and click event for playing audio
  738. if (state.apiDataFetched) {
  739. if (imageUrl) {
  740. const imageElement = createImageElement(wrapperDiv, imageUrl, vocab, state.exactSearch);
  741. if (imageElement) {
  742. imageElement.addEventListener('click', () => playAudio(soundUrl));
  743. }
  744. } else {
  745. const noImageText = document.createElement('div');
  746. noImageText.textContent = `NO IMAGE\n(${state.examples[state.currentExampleIndex].deck_name})`;
  747. noImageText.style.padding = '100px 0';
  748. noImageText.style.whiteSpace = 'pre'; // This ensures that newlines are respected
  749. wrapperDiv.appendChild(noImageText);
  750. }
  751. } else if (state.error) {
  752. const errorText = document.createElement('div');
  753. errorText.textContent = 'ERROR\nNO EXAMPLES FOUND\n\nRARE WORD OR\nIMMERSIONKIT API IS TEMPORARILY DOWN';
  754. errorText.style.padding = '100px 0';
  755. errorText.style.whiteSpace = 'pre'; // This ensures that newlines are respected
  756. wrapperDiv.appendChild(errorText);
  757. } else {
  758. const loadingText = document.createElement('div');
  759. loadingText.textContent = 'LOADING';
  760. loadingText.style.padding = '100px 0';
  761. wrapperDiv.appendChild(loadingText);
  762. }
  763.  
  764. // Append sentence and translation or a placeholder text
  765. sentence ? appendSentenceAndTranslation(wrapperDiv, sentence, example.translation) : appendNoneText(wrapperDiv);
  766.  
  767. // Create navigation elements
  768. const navigationDiv = createNavigationDiv();
  769. const leftArrow = createLeftArrow(vocab, shouldAutoPlaySound);
  770. const rightArrow = createRightArrow(vocab, shouldAutoPlaySound);
  771.  
  772. // Create and append the main container
  773. const containerDiv = createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv);
  774. appendContainer(containerDiv);
  775.  
  776. // Auto-play sound if configured
  777. if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
  778. playAudio(soundUrl);
  779. }
  780. }
  781.  
  782. function removeExistingContainer() {
  783. // Remove the existing container if it exists
  784. const existingContainer = document.getElementById('immersion-kit-container');
  785. if (existingContainer) {
  786. existingContainer.remove();
  787. }
  788. }
  789.  
  790. function shouldRenderContainer() {
  791. // Determine if the container should be rendered based on the presence of certain elements
  792. const resultVocabularySection = document.querySelector('.result.vocabulary');
  793. const hboxWrapSection = document.querySelector('.hbox.wrap');
  794. const subsectionMeanings = document.querySelector('.subsection-meanings');
  795. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  796. return resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3;
  797. }
  798.  
  799. function createWrapperDiv() {
  800. // Create and style the wrapper div
  801. const wrapperDiv = document.createElement('div');
  802. wrapperDiv.id = 'image-wrapper';
  803. wrapperDiv.style.textAlign = 'center';
  804. wrapperDiv.style.padding = '5px 0';
  805. return wrapperDiv;
  806. }
  807.  
  808. function createImageElement(wrapperDiv, imageUrl, vocab, exactSearch) {
  809. // Create and return an image element with specified attributes
  810. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  811. const titleText = `${searchVocab} #${state.currentExampleIndex + 1} \n${state.examples[state.currentExampleIndex].deck_name}`;
  812. return GM_addElement(wrapperDiv, 'img', {
  813. src: imageUrl,
  814. alt: 'Embedded Image',
  815. title: titleText,
  816. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  817. });
  818. }
  819.  
  820. function highlightVocab(sentence, vocab) {
  821. // Highlight vocabulary in the sentence based on configuration
  822. if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
  823.  
  824. if (state.exactSearch) {
  825. const regex = new RegExp(`(${vocab})`, 'g');
  826. return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
  827. } else {
  828. return vocab.split('').reduce((acc, char) => {
  829. const regex = new RegExp(char, 'g');
  830. return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
  831. }, sentence);
  832. }
  833. }
  834.  
  835. function appendSentenceAndTranslation(wrapperDiv, sentence, translation) {
  836. // Append sentence and translation to the wrapper div
  837. const sentenceText = document.createElement('div');
  838. sentenceText.innerHTML = highlightVocab(sentence, state.vocab);
  839. sentenceText.style.marginTop = '10px';
  840. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  841. sentenceText.style.color = 'lightgray';
  842. sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  843. sentenceText.style.whiteSpace = 'pre-wrap';
  844. wrapperDiv.appendChild(sentenceText);
  845.  
  846. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && translation) {
  847. const translationText = document.createElement('div');
  848. translationText.innerHTML = replaceSpecialCharacters(translation);
  849. translationText.style.marginTop = '5px';
  850. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  851. translationText.style.color = 'var(--subsection-label-color)';
  852. translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  853. translationText.style.whiteSpace = 'pre-wrap';
  854. wrapperDiv.appendChild(translationText);
  855. }
  856. }
  857.  
  858. function appendNoneText(wrapperDiv) {
  859. // Append a "None" text to the wrapper div
  860. const noneText = document.createElement('div');
  861. noneText.textContent = 'None';
  862. noneText.style.marginTop = '10px';
  863. noneText.style.fontSize = '85%';
  864. noneText.style.color = 'var(--subsection-label-color)';
  865. wrapperDiv.appendChild(noneText);
  866. }
  867.  
  868. function createNavigationDiv() {
  869. // Create and style the navigation div
  870. const navigationDiv = document.createElement('div');
  871. navigationDiv.id = 'immersion-kit-embed';
  872. navigationDiv.style.display = 'flex';
  873. navigationDiv.style.justifyContent = 'center';
  874. navigationDiv.style.alignItems = 'center';
  875. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  876. navigationDiv.style.margin = '0 auto';
  877. return navigationDiv;
  878. }
  879.  
  880. function createLeftArrow(vocab, shouldAutoPlaySound) {
  881. // Create and configure the left arrow button
  882. const leftArrow = document.createElement('button');
  883. leftArrow.textContent = '<';
  884. leftArrow.style.marginRight = '10px';
  885. leftArrow.disabled = state.currentExampleIndex === 0;
  886. leftArrow.addEventListener('click', () => {
  887. if (state.currentExampleIndex > 0) {
  888. state.currentExampleIndex--;
  889. state.currentlyPlayingAudio = false;
  890. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  891. preloadImages();
  892. }
  893. });
  894. return leftArrow;
  895. }
  896.  
  897. function createRightArrow(vocab, shouldAutoPlaySound) {
  898. // Create and configure the right arrow button
  899. const rightArrow = document.createElement('button');
  900. rightArrow.textContent = '>';
  901. rightArrow.style.marginLeft = '10px';
  902. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  903. rightArrow.addEventListener('click', () => {
  904. if (state.currentExampleIndex < state.examples.length - 1) {
  905. state.currentExampleIndex++;
  906. state.currentlyPlayingAudio = false;
  907. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  908. preloadImages();
  909. }
  910. });
  911. return rightArrow;
  912. }
  913.  
  914. function createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv) {
  915. // Create and configure the main container div
  916. const containerDiv = document.createElement('div');
  917. containerDiv.id = 'immersion-kit-container';
  918. containerDiv.style.display = 'flex';
  919. containerDiv.style.alignItems = 'center';
  920. containerDiv.style.justifyContent = 'center';
  921. containerDiv.style.flexDirection = 'column';
  922.  
  923. const arrowWrapperDiv = document.createElement('div');
  924. arrowWrapperDiv.style.display = 'flex';
  925. arrowWrapperDiv.style.alignItems = 'center';
  926. arrowWrapperDiv.style.justifyContent = 'center';
  927.  
  928. arrowWrapperDiv.append(leftArrow, wrapperDiv, rightArrow);
  929. containerDiv.append(arrowWrapperDiv, navigationDiv);
  930.  
  931. return containerDiv;
  932. }
  933.  
  934. function appendContainer(containerDiv) {
  935. // Append the container div to the appropriate section based on configuration
  936. const resultVocabularySection = document.querySelector('.result.vocabulary');
  937. const hboxWrapSection = document.querySelector('.hbox.wrap');
  938. const subsectionMeanings = document.querySelector('.subsection-meanings');
  939. const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
  940. const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
  941. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  942. const vboxGap = document.querySelector('.vbox.gap');
  943.  
  944. if (CONFIG.WIDE_MODE && subsectionMeanings) {
  945. const wrapper = document.createElement('div');
  946. wrapper.style.display = 'flex';
  947. wrapper.style.alignItems = 'flex-start';
  948.  
  949. const originalContentWrapper = document.createElement('div');
  950. originalContentWrapper.style.flex = '1';
  951. originalContentWrapper.appendChild(subsectionMeanings);
  952.  
  953. if (subsectionComposedOfKanji) {
  954. const newline1 = document.createElement('br');
  955. originalContentWrapper.appendChild(newline1);
  956. originalContentWrapper.appendChild(subsectionComposedOfKanji);
  957. }
  958. if (subsectionPitchAccent) {
  959. const newline2 = document.createElement('br');
  960. originalContentWrapper.appendChild(newline2);
  961. originalContentWrapper.appendChild(subsectionPitchAccent);
  962. }
  963.  
  964. wrapper.appendChild(originalContentWrapper);
  965. wrapper.appendChild(containerDiv);
  966.  
  967. if (vboxGap) {
  968. const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
  969. if (existingDynamicDiv) {
  970. existingDynamicDiv.remove();
  971. }
  972.  
  973. const dynamicDiv = document.createElement('div');
  974. dynamicDiv.id = 'dynamic-content';
  975. dynamicDiv.appendChild(wrapper);
  976.  
  977. if (window.location.href.includes('vocabulary')) {
  978. vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
  979. } else {
  980. vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild);
  981. }
  982. }
  983. } else {
  984. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  985. subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
  986. } else if (resultVocabularySection) {
  987. resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
  988. } else if (hboxWrapSection) {
  989. hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
  990. } else if (subsectionLabels.length >= 4) {
  991. subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
  992. }
  993. }
  994. }
  995.  
  996. function embedImageAndPlayAudio() {
  997. // Embed the image and play audio, removing existing navigation div if present
  998. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  999. if (existingNavigationDiv) existingNavigationDiv.remove();
  1000.  
  1001. const reviewUrlPattern = /https:\/\/jpdb\.io\/review(#a)?$/;
  1002.  
  1003. renderImageAndPlayAudio(state.vocab, !reviewUrlPattern.test(window.location.href));
  1004. preloadImages();
  1005. }
  1006.  
  1007. function replaceSpecialCharacters(text) {
  1008. // Replace special characters in the text
  1009. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  1010. }
  1011.  
  1012. function preloadImages() {
  1013. // Preload images around the current example index
  1014. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  1015. const startIndex = Math.max(0, state.currentExampleIndex - CONFIG.NUMBER_OF_PRELOADS);
  1016. const endIndex = Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUMBER_OF_PRELOADS);
  1017.  
  1018. for (let i = startIndex; i <= endIndex; i++) {
  1019. if (!state.preloadedIndices.has(i) && state.examples[i].image_url) {
  1020. GM_addElement(preloadDiv, 'img', { src: state.examples[i].image_url });
  1021. state.preloadedIndices.add(i);
  1022. }
  1023. }
  1024. }
  1025.  
  1026.  
  1027. //MENU FUNCTIONS=====================================================================================================================
  1028. ////FILE OPERATIONS=====================================================================================================================
  1029. function handleImportButtonClick() {
  1030. handleFileInput('application/json', importFavorites);
  1031. }
  1032.  
  1033. function handleFileInput(acceptType, callback) {
  1034. const fileInput = document.createElement('input');
  1035. fileInput.type = 'file';
  1036. fileInput.accept = acceptType;
  1037. fileInput.addEventListener('change', callback);
  1038. fileInput.click();
  1039. }
  1040.  
  1041. function createBlobAndDownload(data, filename, type) {
  1042. const blob = new Blob([data], { type });
  1043. const url = URL.createObjectURL(blob);
  1044. const a = document.createElement('a');
  1045. a.href = url;
  1046. a.download = filename;
  1047. document.body.appendChild(a);
  1048. a.click();
  1049. document.body.removeChild(a);
  1050. URL.revokeObjectURL(url);
  1051. }
  1052.  
  1053. function exportFavorites() {
  1054. const favorites = {};
  1055. for (let i = 0; i < localStorage.length; i++) {
  1056. const key = localStorage.key(i);
  1057. if (!key.startsWith('CONFIG')) {
  1058. favorites[key] = localStorage.getItem(key);
  1059. }
  1060. }
  1061. const data = JSON.stringify(favorites, null, 2);
  1062. createBlobAndDownload(data, 'favorites.json', 'application/json');
  1063. }
  1064.  
  1065. function importFavorites(event) {
  1066. const file = event.target.files[0];
  1067. if (!file) return;
  1068.  
  1069. const reader = new FileReader();
  1070. reader.onload = function(e) {
  1071. try {
  1072. const favorites = JSON.parse(e.target.result);
  1073. for (const key in favorites) {
  1074. localStorage.setItem(key, favorites[key]);
  1075. }
  1076. alert('Favorites imported successfully!');
  1077. location.reload();
  1078. } catch (error) {
  1079. alert('Error importing favorites:', error);
  1080. }
  1081. };
  1082. reader.readAsText(file);
  1083. }
  1084.  
  1085. ////CONFIRMATION
  1086. function createConfirmationPopup(messageText, onYes, onNo) {
  1087. // Create a confirmation popup with Yes and No buttons
  1088. const popupOverlay = document.createElement('div');
  1089. popupOverlay.style.position = 'fixed';
  1090. popupOverlay.style.top = '0';
  1091. popupOverlay.style.left = '0';
  1092. popupOverlay.style.width = '100%';
  1093. popupOverlay.style.height = '100%';
  1094. popupOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1095. popupOverlay.style.zIndex = '1001';
  1096. popupOverlay.style.display = 'flex';
  1097. popupOverlay.style.justifyContent = 'center';
  1098. popupOverlay.style.alignItems = 'center';
  1099.  
  1100. const popupContent = document.createElement('div');
  1101. popupContent.style.backgroundColor = 'var(--background-color)';
  1102. popupContent.style.padding = '20px';
  1103. popupContent.style.borderRadius = '5px';
  1104. popupContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1105. popupContent.style.textAlign = 'center';
  1106.  
  1107. const message = document.createElement('p');
  1108. message.textContent = messageText;
  1109.  
  1110. const yesButton = document.createElement('button');
  1111. yesButton.textContent = 'Yes';
  1112. yesButton.style.backgroundColor = '#C82800';
  1113. yesButton.style.marginRight = '10px';
  1114. yesButton.addEventListener('click', () => {
  1115. onYes();
  1116. document.body.removeChild(popupOverlay);
  1117. });
  1118.  
  1119. const noButton = document.createElement('button');
  1120. noButton.textContent = 'No';
  1121. noButton.addEventListener('click', () => {
  1122. onNo();
  1123. document.body.removeChild(popupOverlay);
  1124. });
  1125.  
  1126. popupContent.appendChild(message);
  1127. popupContent.appendChild(yesButton);
  1128. popupContent.appendChild(noButton);
  1129. popupOverlay.appendChild(popupContent);
  1130.  
  1131. document.body.appendChild(popupOverlay);
  1132. }
  1133.  
  1134. ////BUTTONS
  1135. function createActionButtonsContainer() {
  1136. const actionButtonWidth = '100px';
  1137.  
  1138. const closeButton = createButton('Close', '10px', closeOverlayMenu, actionButtonWidth);
  1139. const saveButton = createButton('Save', '10px', saveConfig, actionButtonWidth);
  1140. const defaultButton = createDefaultButton(actionButtonWidth);
  1141. const deleteButton = createDeleteButton(actionButtonWidth);
  1142.  
  1143. const actionButtonsContainer = document.createElement('div');
  1144. actionButtonsContainer.style.textAlign = 'center';
  1145. actionButtonsContainer.style.marginTop = '10px';
  1146. actionButtonsContainer.append(closeButton, saveButton, defaultButton, deleteButton);
  1147.  
  1148. return actionButtonsContainer;
  1149. }
  1150.  
  1151. function createMenuButtons() {
  1152. const exportImportContainer = createExportImportContainer();
  1153. const actionButtonsContainer = createActionButtonsContainer();
  1154.  
  1155. const buttonContainer = document.createElement('div');
  1156. buttonContainer.append(exportImportContainer, actionButtonsContainer);
  1157.  
  1158. return buttonContainer;
  1159. }
  1160.  
  1161. function createButton(text, margin, onClick, width) {
  1162. // Create a button element with specified properties
  1163. const button = document.createElement('button');
  1164. button.textContent = text;
  1165. button.style.margin = margin;
  1166. button.style.width = width;
  1167. button.style.textAlign = 'center';
  1168. button.style.display = 'inline-block';
  1169. button.style.lineHeight = '30px';
  1170. button.style.padding = '5px 0';
  1171. button.addEventListener('click', onClick);
  1172. return button;
  1173. }
  1174. ////IMPORT/EXPORT BUTTONS
  1175. function createExportImportContainer() {
  1176. const exportImportButtonWidth = '200px';
  1177.  
  1178. const exportButton = createButton('Export Favorites', '10px', exportFavorites, exportImportButtonWidth);
  1179. const importButton = createButton('Import Favorites', '10px', handleImportButtonClick, exportImportButtonWidth);
  1180.  
  1181. const exportImportContainer = document.createElement('div');
  1182. exportImportContainer.style.textAlign = 'center';
  1183. exportImportContainer.style.marginTop = '10px';
  1184. exportImportContainer.append(exportButton, importButton);
  1185.  
  1186. return exportImportContainer;
  1187. }
  1188. ////CLOSE BUTTON
  1189. function closeOverlayMenu() {
  1190. loadConfig();
  1191. document.body.removeChild(document.getElementById('overlayMenu'));
  1192. }
  1193.  
  1194. ////SAVE BUTTON
  1195. function saveConfig() {
  1196. const overlay = document.getElementById('overlayMenu');
  1197. if (!overlay) return;
  1198.  
  1199. const inputs = overlay.querySelectorAll('input, span');
  1200. const { changes, minimumExampleLengthChanged, newMinimumExampleLength } = gatherChanges(inputs);
  1201.  
  1202. if (minimumExampleLengthChanged) {
  1203. handleMinimumExampleLengthChange(newMinimumExampleLength, changes);
  1204. } else {
  1205. applyChanges(changes);
  1206. finalizeSaveConfig();
  1207. setVocabSize();
  1208. setPageWidth();
  1209. }
  1210. }
  1211.  
  1212. function gatherChanges(inputs) {
  1213. let minimumExampleLengthChanged = false;
  1214. let newMinimumExampleLength;
  1215. const changes = {};
  1216.  
  1217. inputs.forEach(input => {
  1218. const key = input.getAttribute('data-key');
  1219. const type = input.getAttribute('data-type');
  1220. let value;
  1221.  
  1222. if (type === 'boolean') {
  1223. value = input.checked;
  1224. } else if (type === 'number') {
  1225. value = parseFloat(input.textContent);
  1226. } else if (type === 'string') {
  1227. value = input.textContent;
  1228. }
  1229.  
  1230. if (key && type) {
  1231. const typePart = input.getAttribute('data-type-part');
  1232. const originalFormattedType = typePart.slice(1, -1);
  1233.  
  1234. if (key === 'MINIMUM_EXAMPLE_LENGTH' && CONFIG.MINIMUM_EXAMPLE_LENGTH !== value) {
  1235. minimumExampleLengthChanged = true;
  1236. newMinimumExampleLength = value;
  1237. }
  1238.  
  1239. changes[`CONFIG.${key}`] = value + originalFormattedType;
  1240. }
  1241. });
  1242.  
  1243. return { changes, minimumExampleLengthChanged, newMinimumExampleLength };
  1244. }
  1245.  
  1246. function handleMinimumExampleLengthChange(newMinimumExampleLength, changes) {
  1247. createConfirmationPopup(
  1248. 'Changing Minimum Example Length will break your current favorites. They will all be deleted. Are you sure?',
  1249. async () => {
  1250. await IndexedDBManager.delete();
  1251. CONFIG.MINIMUM_EXAMPLE_LENGTH = newMinimumExampleLength;
  1252. localStorage.setItem('CONFIG.MINIMUM_EXAMPLE_LENGTH', newMinimumExampleLength);
  1253. applyChanges(changes);
  1254. clearNonConfigLocalStorage();
  1255. finalizeSaveConfig();
  1256. location.reload();
  1257. },
  1258. () => {
  1259. const overlay = document.getElementById('overlayMenu');
  1260. document.body.removeChild(overlay);
  1261. document.body.appendChild(createOverlayMenu());
  1262. }
  1263. );
  1264. }
  1265.  
  1266. function clearNonConfigLocalStorage() {
  1267. for (let i = 0; i < localStorage.length; i++) {
  1268. const key = localStorage.key(i);
  1269. if (key && !key.startsWith('CONFIG')) {
  1270. localStorage.removeItem(key);
  1271. i--; // Adjust index after removal
  1272. }
  1273. }
  1274. }
  1275.  
  1276. function applyChanges(changes) {
  1277. for (const key in changes) {
  1278. localStorage.setItem(key, changes[key]);
  1279. }
  1280. }
  1281.  
  1282. function finalizeSaveConfig() {
  1283. loadConfig();
  1284. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1285. const overlay = document.getElementById('overlayMenu');
  1286. if (overlay) {
  1287. document.body.removeChild(overlay);
  1288. }
  1289. }
  1290.  
  1291.  
  1292. ////DEFAULT BUTTON
  1293. function createDefaultButton(width) {
  1294. const defaultButton = createButton('Default', '10px', () => {
  1295. createConfirmationPopup(
  1296. 'This will reset all your settings to default. Are you sure?',
  1297. () => {
  1298. Object.keys(localStorage).forEach(key => {
  1299. if (key.startsWith('CONFIG')) {
  1300. localStorage.removeItem(key);
  1301. }
  1302. });
  1303. location.reload();
  1304. },
  1305. () => {
  1306. const overlay = document.getElementById('overlayMenu');
  1307. if (overlay) {
  1308. document.body.removeChild(overlay);
  1309. }
  1310. loadConfig();
  1311. document.body.appendChild(createOverlayMenu());
  1312. }
  1313. );
  1314. }, width);
  1315. defaultButton.style.backgroundColor = '#C82800';
  1316. defaultButton.style.color = 'white';
  1317. return defaultButton;
  1318. }
  1319.  
  1320.  
  1321. ////DELETE BUTTON
  1322. function createDeleteButton(width) {
  1323. const deleteButton = createButton('DELETE', '10px', () => {
  1324. createConfirmationPopup(
  1325. 'This will delete all your favorites and cached data. Are you sure?',
  1326. async () => {
  1327. await IndexedDBManager.delete();
  1328. Object.keys(localStorage).forEach(key => {
  1329. if (!key.startsWith('CONFIG')) {
  1330. localStorage.removeItem(key);
  1331. }
  1332. });
  1333. location.reload();
  1334. },
  1335. () => {
  1336. const overlay = document.getElementById('overlayMenu');
  1337. if (overlay) {
  1338. document.body.removeChild(overlay);
  1339. }
  1340. loadConfig();
  1341. document.body.appendChild(createOverlayMenu());
  1342. }
  1343. );
  1344. }, width);
  1345. deleteButton.style.backgroundColor = '#C82800';
  1346. deleteButton.style.color = 'white';
  1347. return deleteButton;
  1348. }
  1349.  
  1350. function createOverlayMenu() {
  1351. // Create and return the overlay menu for configuration settings
  1352. const overlay = document.createElement('div');
  1353. overlay.id = 'overlayMenu';
  1354. overlay.style.position = 'fixed';
  1355. overlay.style.top = '0';
  1356. overlay.style.left = '0';
  1357. overlay.style.width = '100%';
  1358. overlay.style.height = '100%';
  1359. overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1360. overlay.style.zIndex = '1000';
  1361. overlay.style.display = 'flex';
  1362. overlay.style.justifyContent = 'center';
  1363. overlay.style.alignItems = 'center';
  1364.  
  1365. const menuContent = document.createElement('div');
  1366. menuContent.style.backgroundColor = 'var(--background-color)';
  1367. menuContent.style.color = 'var(--text-color)';
  1368. menuContent.style.padding = '20px';
  1369. menuContent.style.borderRadius = '5px';
  1370. menuContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1371. menuContent.style.width = '80%';
  1372. menuContent.style.maxWidth = '550px';
  1373. menuContent.style.maxHeight = '80%';
  1374. menuContent.style.overflowY = 'auto';
  1375.  
  1376. for (const [key, value] of Object.entries(CONFIG)) {
  1377. const optionContainer = document.createElement('div');
  1378. optionContainer.style.marginBottom = '10px';
  1379. optionContainer.style.display = 'flex';
  1380. optionContainer.style.alignItems = 'center';
  1381.  
  1382. const leftContainer = document.createElement('div');
  1383. leftContainer.style.flex = '1';
  1384. leftContainer.style.display = 'flex';
  1385. leftContainer.style.alignItems = 'center';
  1386.  
  1387. const rightContainer = document.createElement('div');
  1388. rightContainer.style.flex = '1';
  1389. rightContainer.style.display = 'flex';
  1390. rightContainer.style.alignItems = 'center';
  1391. rightContainer.style.justifyContent = 'center';
  1392.  
  1393. const label = document.createElement('label');
  1394. label.textContent = key.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
  1395. label.style.marginRight = '10px';
  1396.  
  1397. leftContainer.appendChild(label);
  1398.  
  1399. if (typeof value === 'boolean') {
  1400. const checkboxContainer = document.createElement('div');
  1401. checkboxContainer.style.display = 'flex';
  1402. checkboxContainer.style.alignItems = 'center';
  1403. checkboxContainer.style.justifyContent = 'center';
  1404.  
  1405. const checkbox = document.createElement('input');
  1406. checkbox.type = 'checkbox';
  1407. checkbox.checked = value;
  1408. checkbox.setAttribute('data-key', key);
  1409. checkbox.setAttribute('data-type', 'boolean');
  1410. checkbox.setAttribute('data-type-part', '');
  1411. checkboxContainer.appendChild(checkbox);
  1412.  
  1413. rightContainer.appendChild(checkboxContainer);
  1414. } else if (typeof value === 'number') {
  1415. const numberContainer = document.createElement('div');
  1416. numberContainer.style.display = 'flex';
  1417. numberContainer.style.alignItems = 'center';
  1418. numberContainer.style.justifyContent = 'center';
  1419.  
  1420. const decrementButton = document.createElement('button');
  1421. decrementButton.textContent = '-';
  1422. decrementButton.style.marginRight = '5px';
  1423.  
  1424. const input = document.createElement('span');
  1425. input.textContent = value;
  1426. input.style.margin = '0 10px';
  1427. input.style.minWidth = '3ch';
  1428. input.style.textAlign = 'center';
  1429. input.setAttribute('data-key', key);
  1430. input.setAttribute('data-type', 'number');
  1431. input.setAttribute('data-type-part', '');
  1432.  
  1433. const incrementButton = document.createElement('button');
  1434. incrementButton.textContent = '+';
  1435. incrementButton.style.marginLeft = '5px';
  1436.  
  1437. const updateButtonStates = () => {
  1438. let currentValue = parseFloat(input.textContent);
  1439. if (currentValue <= 0) {
  1440. decrementButton.disabled = true;
  1441. decrementButton.style.color = 'grey';
  1442. } else {
  1443. decrementButton.disabled = false;
  1444. decrementButton.style.color = '';
  1445. }
  1446. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1447. incrementButton.disabled = true;
  1448. incrementButton.style.color = 'grey';
  1449. } else {
  1450. incrementButton.disabled = false;
  1451. incrementButton.style.color = '';
  1452. }
  1453. };
  1454.  
  1455. decrementButton.addEventListener('click', () => {
  1456. let currentValue = parseFloat(input.textContent);
  1457. if (currentValue > 0) {
  1458. if (currentValue > 200) {
  1459. input.textContent = currentValue - 25;
  1460. } else if (currentValue > 20) {
  1461. input.textContent = currentValue - 5;
  1462. } else {
  1463. input.textContent = currentValue - 1;
  1464. }
  1465. updateButtonStates();
  1466. }
  1467. });
  1468.  
  1469. incrementButton.addEventListener('click', () => {
  1470. let currentValue = parseFloat(input.textContent);
  1471. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1472. return;
  1473. }
  1474. if (currentValue >= 200) {
  1475. input.textContent = currentValue + 25;
  1476. } else if (currentValue >= 20) {
  1477. input.textContent = currentValue + 5;
  1478. } else {
  1479. input.textContent = currentValue + 1;
  1480. }
  1481. updateButtonStates();
  1482. });
  1483.  
  1484. numberContainer.appendChild(decrementButton);
  1485. numberContainer.appendChild(input);
  1486. numberContainer.appendChild(incrementButton);
  1487.  
  1488. rightContainer.appendChild(numberContainer);
  1489.  
  1490. // Initialize button states
  1491. updateButtonStates();
  1492. } else if (typeof value === 'string') {
  1493. const typeParts = value.split(/(\d+)/).filter(Boolean);
  1494. const numberParts = typeParts.filter(part => !isNaN(part)).map(Number);
  1495.  
  1496. const numberContainer = document.createElement('div');
  1497. numberContainer.style.display = 'flex';
  1498. numberContainer.style.alignItems = 'center';
  1499. numberContainer.style.justifyContent = 'center';
  1500.  
  1501. const typeSpan = document.createElement('span');
  1502. const formattedType = '(' + typeParts.filter(part => isNaN(part)).join('').replace(/_/g, ' ').toLowerCase() + ')';
  1503. typeSpan.textContent = formattedType;
  1504. typeSpan.style.marginRight = '10px';
  1505.  
  1506. leftContainer.appendChild(typeSpan);
  1507.  
  1508. typeParts.forEach(part => {
  1509. if (!isNaN(part)) {
  1510. const decrementButton = document.createElement('button');
  1511. decrementButton.textContent = '-';
  1512. decrementButton.style.marginRight = '5px';
  1513.  
  1514. const input = document.createElement('span');
  1515. input.textContent = part;
  1516. input.style.margin = '0 10px';
  1517. input.style.minWidth = '3ch';
  1518. input.style.textAlign = 'center';
  1519. input.setAttribute('data-key', key);
  1520. input.setAttribute('data-type', 'string');
  1521. input.setAttribute('data-type-part', formattedType);
  1522.  
  1523. const incrementButton = document.createElement('button');
  1524. incrementButton.textContent = '+';
  1525. incrementButton.style.marginLeft = '5px';
  1526.  
  1527. const updateButtonStates = () => {
  1528. let currentValue = parseFloat(input.textContent);
  1529. if (currentValue <= 0) {
  1530. decrementButton.disabled = true;
  1531. decrementButton.style.color = 'grey';
  1532. } else {
  1533. decrementButton.disabled = false;
  1534. decrementButton.style.color = '';
  1535. }
  1536. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1537. incrementButton.disabled = true;
  1538. incrementButton.style.color = 'grey';
  1539. } else {
  1540. incrementButton.disabled = false;
  1541. incrementButton.style.color = '';
  1542. }
  1543. };
  1544.  
  1545. decrementButton.addEventListener('click', () => {
  1546. let currentValue = parseFloat(input.textContent);
  1547. if (currentValue > 0) {
  1548. if (currentValue > 200) {
  1549. input.textContent = currentValue - 25;
  1550. } else if (currentValue > 20) {
  1551. input.textContent = currentValue - 5;
  1552. } else {
  1553. input.textContent = currentValue - 1;
  1554. }
  1555. updateButtonStates();
  1556. }
  1557. });
  1558.  
  1559. incrementButton.addEventListener('click', () => {
  1560. let currentValue = parseFloat(input.textContent);
  1561. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1562. return;
  1563. }
  1564. if (currentValue >= 200) {
  1565. input.textContent = currentValue + 25;
  1566. } else if (currentValue >= 20) {
  1567. input.textContent = currentValue + 5;
  1568. } else {
  1569. input.textContent = currentValue + 1;
  1570. }
  1571. updateButtonStates();
  1572. });
  1573.  
  1574. numberContainer.appendChild(decrementButton);
  1575. numberContainer.appendChild(input);
  1576. numberContainer.appendChild(incrementButton);
  1577.  
  1578. // Initialize button states
  1579. updateButtonStates();
  1580. }
  1581. });
  1582.  
  1583. rightContainer.appendChild(numberContainer);
  1584. }
  1585.  
  1586. optionContainer.appendChild(leftContainer);
  1587. optionContainer.appendChild(rightContainer);
  1588. menuContent.appendChild(optionContainer);
  1589. }
  1590.  
  1591. const menuButtons = createMenuButtons();
  1592. menuContent.appendChild(menuButtons);
  1593.  
  1594. overlay.appendChild(menuContent);
  1595.  
  1596. return overlay;
  1597. }
  1598.  
  1599. function loadConfig() {
  1600. for (const key in localStorage) {
  1601. if (!localStorage.hasOwnProperty(key) || !key.startsWith('CONFIG.')) continue;
  1602.  
  1603. const configKey = key.substring('CONFIG.'.length);
  1604. if (!CONFIG.hasOwnProperty(configKey)) continue;
  1605.  
  1606. const savedValue = localStorage.getItem(key);
  1607. if (savedValue === null) continue;
  1608.  
  1609. const valueType = typeof CONFIG[configKey];
  1610. if (valueType === 'boolean') {
  1611. CONFIG[configKey] = savedValue === 'true';
  1612. } else if (valueType === 'number') {
  1613. CONFIG[configKey] = parseFloat(savedValue);
  1614. } else if (valueType === 'string') {
  1615. CONFIG[configKey] = savedValue;
  1616. }
  1617. }
  1618. }
  1619.  
  1620.  
  1621. //MAIN FUNCTIONS=====================================================================================================================
  1622. function onPageLoad() {
  1623. // Initialize state and determine vocabulary based on URL
  1624. state.embedAboveSubsectionMeanings = false;
  1625.  
  1626. const url = window.location.href;
  1627. const machineTranslationFrame = document.getElementById('machine-translation-frame');
  1628.  
  1629. // Proceed only if the machine translation frame is not present
  1630. if (!machineTranslationFrame) {
  1631.  
  1632. //display embed for first time with loading text
  1633. embedImageAndPlayAudio();
  1634. setPageWidth();
  1635.  
  1636. if (url.includes('/vocabulary/')) {
  1637. state.vocab = parseVocabFromVocabulary();
  1638. } else if (url.includes('/search?q=')) {
  1639. state.vocab = parseVocabFromSearch();
  1640. } else if (url.includes('c=')) {
  1641. state.vocab = parseVocabFromAnswer();
  1642. } else if (url.includes('/kanji/')) {
  1643. state.vocab = parseVocabFromKanji();
  1644. } else {
  1645. state.vocab = parseVocabFromReview();
  1646. }
  1647. } else {
  1648. console.log('Machine translation frame detected, skipping vocabulary parsing.');
  1649. }
  1650.  
  1651. // Retrieve stored data for the current vocabulary
  1652. const { index, exactState } = getStoredData(state.vocab);
  1653. state.currentExampleIndex = index;
  1654. state.exactSearch = exactState;
  1655.  
  1656. // Fetch data and embed image/audio if necessary
  1657. if (state.vocab && !state.apiDataFetched) {
  1658. getImmersionKitData(state.vocab, state.exactSearch)
  1659. .then(() => {
  1660. preloadImages();
  1661. if (!/https:\/\/jpdb\.io\/review(#a)?$/.test(url)) {
  1662. embedImageAndPlayAudio();
  1663. }
  1664. })
  1665. .catch(console.error);
  1666. } else if (state.apiDataFetched) {
  1667. embedImageAndPlayAudio();
  1668. //preloadImages();
  1669. setVocabSize();
  1670. setPageWidth();
  1671. }
  1672. }
  1673.  
  1674. function setPageWidth() {
  1675. // Set the maximum width of the page
  1676. document.body.style.maxWidth = CONFIG.PAGE_WIDTH;
  1677. }
  1678.  
  1679. // Observe URL changes and reload the page content accordingly
  1680. const observer = new MutationObserver(() => {
  1681. if (window.location.href !== observer.lastUrl) {
  1682. observer.lastUrl = window.location.href;
  1683. onPageLoad();
  1684. }
  1685. });
  1686.  
  1687. // Function to apply styles
  1688. function setVocabSize() {
  1689. // Create a new style element
  1690. const style = document.createElement('style');
  1691. style.type = 'text/css';
  1692. style.innerHTML = `
  1693. .answer-box > .plain {
  1694. font-size: ${CONFIG.VOCAB_SIZE} !important; /* Use the configurable font size */
  1695. padding-bottom: 0.1rem !important; /* Retain padding */
  1696. }
  1697. `;
  1698.  
  1699. // Append the new style to the document head
  1700. document.head.appendChild(style);
  1701. }
  1702. observer.lastUrl = window.location.href;
  1703. observer.observe(document, { subtree: true, childList: true });
  1704.  
  1705. // Add event listeners for page load and URL changes
  1706. window.addEventListener('load', onPageLoad);
  1707. window.addEventListener('popstate', onPageLoad);
  1708. window.addEventListener('hashchange', onPageLoad);
  1709.  
  1710. // Initial configuration and preloading
  1711. loadConfig();
  1712. setPageWidth();
  1713. setVocabSize();
  1714. //preloadImages();
  1715.  
  1716. })();