JPDB Immersion Kit Examples

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

  1. // ==UserScript==
  2. // @name JPDB Immersion Kit Examples
  3. // @version 1.24
  4. // @description Embeds anime images & audio examples into JPDB review and vocabulary pages using Immersion Kit's API.
  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. // @connect immersionkit.com
  12. // @connect linodeobjects.com
  13. // @grant GM_addElement
  14. // @grant GM_xmlhttpRequest
  15. // @license MIT
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. 'use strict';
  20.  
  21. // to use custom hotkeys just add them into this array following the same format. Any single keys except space
  22. // should work. If you want to use special keys, check the linked page for how to represent them in the array
  23. // (link leads to the arrow keys part so you can compare with the array and be sure which part to write):
  24. // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values#navigation_keys
  25. const hotkeyOptions = ['None', 'ArrowLeft ArrowRight', ', .', '[ ]', 'Q W'];
  26.  
  27. const CONFIG = {
  28. IMAGE_WIDTH: '400px',
  29. WIDE_MODE: true,
  30. DEFINITIONS_ON_RIGHT_IN_WIDE_MODE: false,
  31. ARROW_WIDTH: '75px',
  32. ARROW_HEIGHT: '45px',
  33. PAGE_WIDTH: '75rem',
  34. SOUND_VOLUME: 80,
  35. ENABLE_EXAMPLE_TRANSLATION: true,
  36. SENTENCE_FONT_SIZE: '120%',
  37. TRANSLATION_FONT_SIZE: '85%',
  38. COLORED_SENTENCE_TEXT: true,
  39. AUTO_PLAY_SOUND: true,
  40. NUMBER_OF_PRELOADS: 1,
  41. VOCAB_SIZE: '250%',
  42. MINIMUM_EXAMPLE_LENGTH: 0,
  43. HOTKEYS: ['None'],
  44. DEFAULT_TO_EXACT_SEARCH: true
  45. // On changing this config option, the icons change but the sentences don't, so you
  46. // have to click once to match up the icons and again to actually change the sentences
  47. };
  48.  
  49. const state = {
  50. currentExampleIndex: 0,
  51. examples: [],
  52. apiDataFetched: false,
  53. vocab: '',
  54. embedAboveSubsectionMeanings: false,
  55. preloadedIndices: new Set(),
  56. currentAudio: null,
  57. exactSearch: true,
  58. error: false,
  59. currentlyPlayingAudio: false,
  60. sharedAudioContext: new (window.AudioContext || window.webkitAudioContext)(),
  61. currentSource: null,
  62. lastPlayId: 0,
  63. };
  64.  
  65. // Prefixing
  66. const scriptPrefix = 'JPDBImmersionKitExamples-';
  67. const configPrefix = 'CONFIG.'; // additional prefix for config variables to go after the scriptPrefix
  68. // do not change either of the above without adding code to handle the change
  69.  
  70. const setItem = (key, value) => { localStorage.setItem(scriptPrefix + key, value) }
  71. const getItem = (key) => {
  72. const prefixedValue = localStorage.getItem(scriptPrefix + key);
  73. if (prefixedValue !== null) { return prefixedValue }
  74. const nonPrefixedValue = localStorage.getItem(key);
  75. // to move away from non-prefixed values as fast as possible
  76. if (nonPrefixedValue !== null) { setItem(key, nonPrefixedValue) }
  77. return nonPrefixedValue
  78. }
  79. const removeItem = (key) => {
  80. localStorage.removeItem(scriptPrefix + key);
  81. localStorage.removeItem(key)
  82. }
  83.  
  84. // Helper for transitioning to fully script-prefixed config state
  85. // Deletes all localStorage variables starting with configPrefix and re-adds them with scriptPrefix and configPrefix
  86. // Danger of other scripts also having localStorage variables starting with configPrefix, so we add a flag showing that
  87. // we have run this function and make sure it is not set when running it
  88.  
  89. // Check for Prefixed flag
  90. if (localStorage.getItem(`JPDBImmersionKit*Examples-CONFIG_VARIABLES_PREFIXED`) !== 'true') {
  91. const keysToModify = [];
  92.  
  93. // Collect keys that need to be modified
  94. for (let i = 0; i < localStorage.length; i++) {
  95. const key = localStorage.key(i);
  96. if (key.startsWith(configPrefix)) {
  97. keysToModify.push(key);
  98. }
  99. }
  100.  
  101. // Modify the collected keys
  102. keysToModify.forEach((key) => {
  103. const value = localStorage.getItem(key);
  104. localStorage.removeItem(key);
  105. const newKey = scriptPrefix + key;
  106. localStorage.setItem(newKey, value);
  107. });
  108. // Set flag so this only runs once
  109. // Flag has * in name to place at top in alphabetical sorting,
  110. // and most importantly, to ensure the flag is never removed or modified
  111. // by the other script functions that check for the script prefix
  112. localStorage.setItem(`JPDBImmersionKit*Examples-CONFIG_VARIABLES_PREFIXED`, 'true');
  113. }
  114.  
  115. // IndexedDB Manager
  116. const IndexedDBManager = {
  117. DB_NAME: 'ImmersionKitDB',
  118. DB_VERSION: 2, // bump version to create metaStore
  119. DATA_STORE: 'dataStore',
  120. META_STORE: 'metaStore',
  121. META_KEY: 'index_meta',
  122. MAX_ENTRIES: 100000000,
  123. EXPIRATION_TIME: 30 * 24 * 60 * 60 * 1000 * 12 * 10000, // 10000 years
  124.  
  125. open() {
  126. return new Promise((resolve, reject) => {
  127. const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
  128.  
  129. request.onupgradeneeded = event => {
  130. const db = event.target.result;
  131. if (!db.objectStoreNames.contains(this.DATA_STORE)) {
  132. db.createObjectStore(this.DATA_STORE, { keyPath: 'keyword' });
  133. }
  134. if (!db.objectStoreNames.contains(this.META_STORE)) {
  135. db.createObjectStore(this.META_STORE, { keyPath: 'key' });
  136. }
  137. };
  138.  
  139. request.onsuccess = event => resolve(event.target.result);
  140. request.onerror = event => reject('IndexedDB error: ' + event.target.errorCode);
  141. });
  142. },
  143.  
  144. // ---------- META STORE ----------
  145. getMetadata(db) {
  146. return new Promise((resolve, reject) => {
  147. const tx = db.transaction([this.META_STORE], 'readonly');
  148. const store = tx.objectStore(this.META_STORE);
  149. const req = store.get(this.META_KEY);
  150.  
  151. req.onsuccess = e => {
  152. const rec = e.target.result;
  153. resolve(rec ? rec.data : null);
  154. };
  155. req.onerror = e => reject('Failed to read metadata: ' + e.target.errorCode);
  156. });
  157. },
  158.  
  159. saveMetadata(db, metadata) {
  160. return new Promise((resolve, reject) => {
  161. const tx = db.transaction([this.META_STORE], 'readwrite');
  162. const store = tx.objectStore(this.META_STORE);
  163. const rec = { key: this.META_KEY, data: metadata, timestamp: Date.now() };
  164. const req = store.put(rec);
  165. req.onsuccess = () => resolve();
  166. req.onerror = e => reject('Failed to save metadata: ' + e.target.errorCode);
  167. });
  168. },
  169.  
  170. // ---------- DATA STORE ----------
  171. get(db, keyword) {
  172. return new Promise((resolve, reject) => {
  173. const tx = db.transaction([this.DATA_STORE], 'readonly');
  174. const store = tx.objectStore(this.DATA_STORE);
  175. const req = store.get(keyword);
  176.  
  177. req.onsuccess = async e => {
  178. const result = e.target.result;
  179. if (!result) return resolve(null);
  180.  
  181. // Return the data field directly
  182. resolve(result.data ? result.data : result);
  183. };
  184.  
  185. req.onerror = e => reject('IndexedDB get error: ' + e.target.errorCode);
  186. });
  187. },
  188.  
  189. deleteEntry(db, keyword) {
  190. return new Promise((resolve, reject) => {
  191. const tx = db.transaction([this.DATA_STORE], 'readwrite');
  192. const store = tx.objectStore(this.DATA_STORE);
  193. const req = store.delete(keyword);
  194. req.onsuccess = () => resolve();
  195. req.onerror = e => reject('IndexedDB delete error: ' + e.target.errorCode);
  196. });
  197. },
  198.  
  199. getAll(db) {
  200. return new Promise((resolve, reject) => {
  201. const tx = db.transaction([this.DATA_STORE], 'readonly');
  202. const store = tx.objectStore(this.DATA_STORE);
  203. const entries = [];
  204. store.openCursor().onsuccess = e => {
  205. const cursor = e.target.result;
  206. if (cursor) {
  207. entries.push(cursor.value);
  208. cursor.continue();
  209. } else {
  210. resolve(entries);
  211. }
  212. };
  213. store.openCursor().onerror = e => reject('Cursor error: ' + e.target.errorCode);
  214. });
  215. },
  216.  
  217. // Fallback network fetch
  218. fetchMetadata() {
  219. return new Promise((resolve, reject) => {
  220. GM_xmlhttpRequest({
  221. method: "GET",
  222. url: "https://apiv2.immersionkit.com/index_meta",
  223. onload: res => {
  224. if (res.status === 200) {
  225. try {
  226. resolve(JSON.parse(res.responseText));
  227. } catch (err) {
  228. reject('Invalid JSON: ' + err);
  229. }
  230. } else {
  231. reject('HTTP ' + res.status);
  232. }
  233. },
  234. onerror: err => reject('Network error: ' + err)
  235. });
  236. });
  237. },
  238.  
  239. save(db, keyword, data) {
  240. return new Promise(async (resolve, reject) => {
  241. try {
  242. const validationError = validateApiResponse(data);
  243. if (validationError) {
  244. console.warn(`Invalid data: ${validationError}`);
  245. return resolve();
  246. }
  247.  
  248. // 1) load metadata from DB (or fetch & save if missing)
  249. let metadata = await this.getMetadata(db);
  250. if (!metadata) {
  251. metadata = await this.fetchMetadata();
  252. await this.saveMetadata(db, metadata);
  253. }
  254.  
  255. // 2) slim down
  256. const slimData = {};
  257. if (data.category_count) slimData.category_count = data.category_count;
  258. if (!Array.isArray(data.examples)) {
  259. console.error('Unexpected examples format');
  260. return resolve();
  261. }
  262.  
  263. // 3) map & patch titles
  264. const categoryOrder = ['anime', 'drama', 'games', 'literature', 'news'];
  265. let slimExamples = await Promise.all(data.examples.map(async ex => {
  266. const slim = {
  267. image: ex.image,
  268. sound: ex.sound,
  269. sentence: ex.sentence,
  270. translation: ex.translation,
  271. title: ex.title,
  272. media: ex.id ? ex.id.split('_')[0] : undefined
  273. };
  274.  
  275. // if title not in our local metadata, re-fetch & update
  276. if (!metadata.data[slim.title]) {
  277. metadata = await this.fetchMetadata();
  278. await this.saveMetadata(db, metadata);
  279. }
  280.  
  281. const entry = metadata.data[slim.title];
  282. if (entry) slim.title = entry.title;
  283. return slim;
  284. }));
  285.  
  286. // Check state.exactSearch and filter examples
  287. console.log('State exactSearch:', state.exactSearch);
  288. console.log('State vocab:', state.vocab);
  289.  
  290. const totalExamplesBefore = slimExamples.length;
  291. console.log('Total examples before filtering:', totalExamplesBefore);
  292.  
  293. if (state.exactSearch) {
  294. const initialCount = slimExamples.length;
  295. slimExamples = slimExamples.filter(ex => ex.sentence.includes(state.vocab));
  296. const removedCount = initialCount - slimExamples.length;
  297. console.log('Number of examples removed:', removedCount);
  298. }
  299.  
  300. const totalExamplesAfter = slimExamples.length;
  301. console.log('Total examples after filtering:', totalExamplesAfter);
  302.  
  303. // 4) sort
  304. slimExamples.sort((a, b) => {
  305. const ca = categoryOrder.indexOf(a.media);
  306. const cb = categoryOrder.indexOf(b.media);
  307. if (ca !== cb) return ca - cb;
  308. return a.sentence.length - b.sentence.length;
  309. });
  310. slimData.examples = slimExamples;
  311.  
  312. // 5) enforce MAX_ENTRIES
  313. const all = await this.getAll(db);
  314. const tx = db.transaction([this.DATA_STORE], 'readwrite');
  315. const store = tx.objectStore(this.DATA_STORE);
  316.  
  317. if (all.length >= this.MAX_ENTRIES) {
  318. all
  319. .sort((a, b) => a.timestamp - b.timestamp)
  320. .slice(0, all.length - this.MAX_ENTRIES + 1)
  321. .forEach(old => store.delete(old.keyword));
  322. }
  323.  
  324. // 6) put new record
  325. store.put({ keyword, data: slimData, timestamp: Date.now() });
  326.  
  327. tx.oncomplete = () => {
  328. console.log('Save complete');
  329. resolve();
  330. };
  331. tx.onerror = e => reject('Save transaction failed: ' + e.target.errorCode);
  332.  
  333. } catch (err) {
  334. reject('Error in save(): ' + err);
  335. }
  336. });
  337. },
  338.  
  339. async versionupdate(db, searchVocab) {
  340. return new Promise(async (resolve, reject) => {
  341. try {
  342. // Fetch the existing data for the given searchVocab
  343. let cachedData = await this.get(db, searchVocab);
  344.  
  345. if (!cachedData || !cachedData.data) {
  346. return resolve(); // No data to update
  347. }
  348.  
  349. // Check if cachedData.data is an array and extract the first element
  350. const dataToTransform = Array.isArray(cachedData.data) ? cachedData.data[0] : cachedData.data;
  351.  
  352. // Transform the data
  353. const updatedData = {
  354. category_count: dataToTransform.category_count,
  355. examples: dataToTransform.examples.map(example => {
  356. const imageUrlParts = example.image_url.split('/');
  357. const soundUrlParts = example.sound_url.split('/');
  358.  
  359. // Extract media from the URL
  360. const mediaIndex = imageUrlParts.indexOf('media');
  361. const media = mediaIndex !== -1 && mediaIndex + 1 < imageUrlParts.length ? imageUrlParts[mediaIndex + 1] : '';
  362.  
  363. return {
  364. image: imageUrlParts[imageUrlParts.length - 1],
  365. sound: soundUrlParts[soundUrlParts.length - 1],
  366. sentence: example.sentence,
  367. translation: example.translation,
  368. title: example.deck_name,
  369. media: media,
  370. };
  371. })
  372. };
  373.  
  374. // Open a readwrite transaction on your data store
  375. const tx = db.transaction([this.DATA_STORE], 'readwrite');
  376. const store = tx.objectStore(this.DATA_STORE);
  377.  
  378. // Put the new record directly
  379. store.put({
  380. keyword: searchVocab,
  381. data: updatedData,
  382. timestamp: Date.now()
  383. });
  384.  
  385. tx.oncomplete = () => {
  386. console.log('Version update complete');
  387. resolve();
  388. };
  389. tx.onerror = e => {
  390. console.error('Version update transaction failed:', e.target.errorCode);
  391. reject('Version update transaction failed: ' + e.target.errorCode);
  392. };
  393.  
  394. } catch (error) {
  395. console.error('Error in versionupdate:', error);
  396. reject('Error in versionupdate: ' + error);
  397. }
  398. });
  399. }
  400. ,
  401.  
  402.  
  403. delete() {
  404. return new Promise((resolve, reject) => {
  405. const req = indexedDB.deleteDatabase(this.DB_NAME);
  406. req.onsuccess = () => resolve();
  407. req.onerror = e => reject('Delete failed: ' + e.target.errorCode);
  408. req.onblocked = () => reject('Delete blocked; close all other tabs');
  409. });
  410. }
  411. };
  412.  
  413.  
  414.  
  415. // API FUNCTIONS=====================================================================================================================
  416. function getImmersionKitData(vocab, exactSearch) {
  417. return new Promise(async (resolve, reject) => {
  418. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  419. const url = `https://apiv2.immersionkit.com/search?q=${encodeURIComponent(searchVocab)}`;
  420. const maxRetries = 5;
  421. let attempt = 0;
  422.  
  423. const storedValue = getItem(state.vocab);
  424. const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2;
  425.  
  426. // Return early if not blacklisted
  427. if (isBlacklisted) {
  428. resolve();
  429. return;
  430. }
  431.  
  432. async function fetchData() {
  433. try {
  434. const db = await IndexedDBManager.open();
  435. let cachedData = await IndexedDBManager.get(db, searchVocab);
  436.  
  437. // Check if the cached data is outdated (v1 API data with 'data' field as an array)
  438. if (cachedData && Array.isArray(cachedData.data) && cachedData.data.length > 0) {
  439. console.log('Outdated data detected, updating...');
  440. await IndexedDBManager.versionupdate(db, searchVocab);
  441. // Rerun fetchData after updating
  442. return fetchData();
  443. } else if (cachedData && cachedData.examples && Array.isArray(cachedData.examples) && cachedData.examples.length > 0) {
  444. console.log('Data retrieved from IndexedDB');
  445. state.examples = cachedData.examples;
  446. state.apiDataFetched = true;
  447. updateCurrentExampleIndex();
  448. resolve();
  449. } else {
  450. console.log(`Calling API for: ${searchVocab}`);
  451. GM_xmlhttpRequest({
  452. method: "GET",
  453. url: url,
  454. onload: async function(response) {
  455. if (response.status === 200) {
  456. const jsonData = parseJSON(response.responseText);
  457. console.log("API JSON Received");
  458. console.log(url);
  459. const validationError = validateApiResponse(jsonData);
  460. if (!validationError) {
  461. await IndexedDBManager.save(db, searchVocab, jsonData);
  462.  
  463. // Attempt to load the data from cache again after saving
  464. cachedData = await IndexedDBManager.get(db, searchVocab);
  465. if (cachedData && cachedData.examples && Array.isArray(cachedData.examples)) {
  466. console.log('Data retrieved from IndexedDB after saving');
  467. state.examples = cachedData.examples;
  468. state.apiDataFetched = true;
  469. updateCurrentExampleIndex();
  470. resolve();
  471. } else {
  472. reject('Failed to retrieve data from IndexedDB after saving');
  473. }
  474. } else {
  475. attempt++;
  476. if (attempt < maxRetries) {
  477. console.log(`Validation error: ${validationError}. Retrying... (${attempt}/${maxRetries})`);
  478. setTimeout(fetchData, 2000); // Add a 2-second delay before retrying
  479. } else {
  480. reject(`Invalid API response after ${maxRetries} attempts: ${validationError}`);
  481. state.error = true;
  482. embedImageAndPlayAudio(); // Update displayed text
  483. }
  484. }
  485. } else {
  486. reject(`API call failed with status: ${response.status}`);
  487. }
  488. },
  489. onerror: function(error) {
  490. reject(`An error occurred: ${error}`);
  491. }
  492. });
  493. }
  494. } catch (error) {
  495. reject(`Error: ${error}`);
  496. }
  497. }
  498.  
  499. function updateCurrentExampleIndex() {
  500. const storedValue = getItem(state.vocab);
  501.  
  502. if (storedValue) {
  503. // If stored data exists, use it to update the current example index
  504. const storedIndex = parseInt(storedValue, 10);
  505.  
  506. // Update the current example index with the stored index
  507. state.currentExampleIndex = storedIndex;
  508. return;
  509. }
  510.  
  511. // If no stored data exists, check sentence length
  512. for (let i = 0; i < state.examples.length; i++) {
  513. if (state.examples[i].sentence.length >= CONFIG.MINIMUM_EXAMPLE_LENGTH) {
  514. state.currentExampleIndex = i;
  515. break;
  516. }
  517. }
  518. }
  519. fetchData();
  520. });
  521. }
  522.  
  523. function parseJSON(responseText) {
  524. try {
  525. return JSON.parse(responseText);
  526. } catch (e) {
  527. console.error('Error parsing JSON:', e);
  528. return null;
  529. }
  530. }
  531.  
  532. function validateApiResponse(jsonData) {
  533. state.error = false;
  534. if (!jsonData) {
  535. return 'Not a valid JSON';
  536. }
  537. if (!jsonData.category_count || !jsonData.examples) {
  538. return 'Missing required data fields';
  539. }
  540.  
  541. const categoryCount = jsonData.category_count;
  542. if (!categoryCount || Object.keys(categoryCount).length === 0) {
  543. return 'Missing or empty category count';
  544. }
  545.  
  546. // Check if all category counts are zero
  547. const allZero = Object.values(categoryCount).every(count => count === 0);
  548. if (allZero) {
  549. return 'Blank API';
  550. }
  551.  
  552. const examples = jsonData.examples;
  553. if (!Array.isArray(examples) || examples.length === 0) {
  554. return 'Missing or empty examples array';
  555. }
  556.  
  557. return null; // No error
  558. }
  559.  
  560. //FAVORITE DATA FUNCTIONS=====================================================================================================================
  561. function getStoredData(key) {
  562. // Retrieve the stored value from localStorage using the provided key
  563. const storedValue = getItem(key);
  564.  
  565. // If a stored value exists, split it into index and exactState
  566. if (storedValue) {
  567. const [index, exactState] = storedValue.split(',');
  568. return {
  569. index: parseInt(index, 10), // Convert index to an integer
  570. exactState: exactState === '1' // Convert exactState to a boolean
  571. };
  572. }
  573.  
  574. // Return default values if no stored value exists
  575. return { index: 0, exactState: state.exactSearch };
  576. }
  577.  
  578. function storeData(key, index, exactState) {
  579. // Create a string value from index and exactState to store in localStorage
  580. const value = `${index},${exactState ? 1 : 0}`;
  581.  
  582. // Store the value in localStorage using the provided key
  583. setItem(key, value);
  584. }
  585.  
  586.  
  587. // PARSE VOCAB FUNCTIONS =====================================================================================================================
  588. function parseVocabFromAnswer() {
  589. // Select all links containing "/kanji/" or "/vocabulary/" in the href attribute
  590. const elements = document.querySelectorAll('a[href*="/kanji/"], a[href*="/vocabulary/"]');
  591. console.log("Parsing Answer Page");
  592.  
  593. // Iterate through the matched elements
  594. for (const element of elements) {
  595. const href = element.getAttribute('href');
  596. const text = element.textContent.trim();
  597.  
  598. // Match the href to extract kanji or vocabulary (ignoring ID if present)
  599. const match = href.match(/\/(kanji|vocabulary)\/(?:\d+\/)?([^\#]*)#/);
  600. if (match) return match[2].trim();
  601. if (text) return text.trim();
  602. }
  603. return '';
  604. }
  605.  
  606. function parseVocabFromReview() {
  607. console.log("Parsing Review Page");
  608.  
  609. // Select the element with class 'kind' to determine the type of content
  610. const kindElement = document.querySelector('.kind');
  611.  
  612. // If kindElement doesn't exist, set kindText to null
  613. const kindText = kindElement ? kindElement.textContent.trim() : null;
  614.  
  615. // Accept 'Kanji' or 'Vocabulary' kindText
  616. if (kindText !== 'Kanji' && kindText !== 'Vocabulary') {
  617. console.log("Not Kanji or existing Vocabulary. Attempting to parse New Vocab.");
  618.  
  619. // Attempt to parse from <a> tag with specific pattern
  620. const anchorElement = document.querySelector('a.plain[href*="/vocabulary/"]');
  621.  
  622. if (anchorElement) {
  623. const href = anchorElement.getAttribute('href');
  624.  
  625. const match = href.match(/\/vocabulary\/\d+\/([^#]+)#a/);
  626.  
  627. if (match && match[1]) {
  628. const new_vocab = match[1];
  629. console.log("Found New Vocab:", new_vocab);
  630. return new_vocab;
  631. }
  632. }
  633.  
  634. console.log("No Vocabulary found.");
  635. return '';
  636. }
  637.  
  638. if (kindText === 'Vocabulary') {
  639. // Select the element with class 'plain' to extract vocabulary
  640. const plainElement = document.querySelector('.plain');
  641. if (!plainElement) {
  642. return '';
  643. }
  644.  
  645. let vocabulary = plainElement.textContent.trim();
  646.  
  647. const nestedVocabularyElement = plainElement.querySelector('div:not([style])');
  648.  
  649. if (nestedVocabularyElement) {
  650. vocabulary = nestedVocabularyElement.textContent.trim();
  651. }
  652.  
  653. const specificVocabularyElement = plainElement.querySelector('div:nth-child(3)');
  654.  
  655. if (specificVocabularyElement) {
  656. vocabulary = specificVocabularyElement.textContent.trim();
  657. }
  658.  
  659. // Regular expression to check if the vocabulary contains kanji characters
  660. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  661. if (kanjiRegex.test(vocabulary) || vocabulary) {
  662. console.log("Found Vocabulary:", vocabulary);
  663. return vocabulary;
  664. }
  665. } else if (kindText === 'Kanji') {
  666. // Select the hidden input element to extract kanji
  667. const hiddenInput = document.querySelector('input[name="c"]');
  668. if (!hiddenInput) {
  669. return '';
  670. }
  671.  
  672. const vocab = hiddenInput.value.split(',')[1];
  673. const kanjiRegex = /[\u4e00-\u9faf\u3400-\u4dbf]/;
  674. if (kanjiRegex.test(vocab)) {
  675. console.log("Found Kanji:", vocab);
  676. return vocab;
  677. }
  678. }
  679.  
  680. console.log("No Vocabulary or Kanji found.");
  681. return '';
  682. }
  683.  
  684. function parseVocabFromVocabulary() {
  685. // Get the current URL
  686. let url = window.location.href;
  687.  
  688. // Remove query parameters (e.g., ?lang=english) and fragment identifiers (#)
  689. url = url.split('?')[0].split('#')[0];
  690.  
  691. // Match the URL structure for a vocabulary page
  692. const match = url.match(/https:\/\/jpdb\.io\/vocabulary\/(\d+)\/([^\#\/]*)/);
  693. console.log("Parsing Vocabulary Page");
  694.  
  695. if (match) {
  696. // Extract and decode the vocabulary part from the URL
  697. let vocab = match[2];
  698. state.embedAboveSubsectionMeanings = true; // Set state flag
  699. return decodeURIComponent(vocab);
  700. }
  701.  
  702. // Return empty string if no match
  703. return '';
  704. }
  705.  
  706. function parseVocabFromKanji() {
  707. // Get the current URL
  708. const url = window.location.href;
  709.  
  710. // Match the URL structure for a kanji page
  711. const match = url.match(/https:\/\/jpdb\.io\/kanji\/(\d+)\/([^\#]*)#a/);
  712. console.log("Parsing Kanji Page");
  713.  
  714. if (match) {
  715. // Extract and decode the kanji part from the URL
  716. let kanji = match[2];
  717. state.embedAboveSubsectionMeanings = true; // Set state flag
  718. kanji = kanji.split('/')[0];
  719. return decodeURIComponent(kanji);
  720. }
  721.  
  722. // Return empty string if no match
  723. return '';
  724. }
  725.  
  726. function parseVocabFromSearch() {
  727. // Get the current URL
  728. let url = window.location.href;
  729.  
  730. // Match the URL structure for a search query, capturing the vocab between `?q=` and either `&` or `+`
  731. const match = url.match(/https:\/\/jpdb\.io\/search\?q=([^&+]*)/);
  732. console.log("Parsing Search Page");
  733.  
  734. if (match) {
  735. // Extract and decode the vocabulary part from the URL
  736. let vocab = match[1];
  737. return decodeURIComponent(vocab);
  738. }
  739.  
  740. // Return empty string if no match
  741. return '';
  742. }
  743.  
  744.  
  745. //EMBED FUNCTIONS=====================================================================================================================
  746. function createAnchor(marginLeft) {
  747. // Create and style an anchor element
  748. const anchor = document.createElement('a');
  749. anchor.href = '#';
  750. anchor.style.border = '0';
  751. anchor.style.display = 'inline-flex';
  752. anchor.style.verticalAlign = 'middle';
  753. anchor.style.marginLeft = marginLeft;
  754. return anchor;
  755. }
  756.  
  757. function createIcon(iconClass, fontSize = '1.4rem', color = '#3d81ff') {
  758. // Create and style an icon element
  759. const icon = document.createElement('i');
  760. icon.className = iconClass;
  761. icon.style.fontSize = fontSize;
  762. icon.style.opacity = '1.0';
  763. icon.style.verticalAlign = 'baseline';
  764. icon.style.color = color;
  765. return icon;
  766. }
  767.  
  768. function createSpeakerButton(soundUrl) {
  769. // Create a speaker button with an icon and click event for audio playback
  770. const anchor = createAnchor('0.5rem');
  771. const icon = createIcon('ti ti-volume');
  772. anchor.appendChild(icon);
  773. anchor.addEventListener('click', (event) => {
  774. event.preventDefault();
  775. playAudio(soundUrl);
  776. });
  777. return anchor;
  778. }
  779.  
  780. function createStarButton() {
  781. // Create a star button with an icon and click event for toggling favorite state
  782. const anchor = createAnchor('0.5rem');
  783. const starIcon = document.createElement('span');
  784. const storedValue = getItem(state.vocab);
  785. // console.log(storedValue);
  786.  
  787. // Determine the star icon (filled or empty) based on stored value
  788. if (storedValue) {
  789. const [storedIndex, storedExactState] = storedValue.split(',');
  790. const index = parseInt(storedIndex, 10);
  791. const exactState = Boolean(parseInt(storedExactState, 10));
  792. starIcon.textContent = (state.currentExampleIndex === index && state.exactSearch === exactState) ? '★' : '☆';
  793. } else {
  794. starIcon.textContent = '☆';
  795. }
  796.  
  797.  
  798. // Style the star icon
  799. starIcon.style.fontSize = '1.4rem';
  800. starIcon.style.color = '#3D8DFF';
  801. starIcon.style.verticalAlign = 'middle';
  802. starIcon.style.position = 'relative';
  803. starIcon.style.top = '-2px';
  804.  
  805. // Append the star icon to the anchor and set up the click event to toggle star state
  806. anchor.appendChild(starIcon);
  807. anchor.addEventListener('click', (event) => {
  808. event.preventDefault();
  809. toggleStarState(starIcon);
  810. });
  811.  
  812. return anchor;
  813. }
  814.  
  815. function toggleStarState(starIcon) {
  816. const storedValue = getItem(state.vocab);
  817. const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2;
  818.  
  819. // Return early if blacklisted
  820. if (isBlacklisted) {
  821. starIcon.textContent = '☆';
  822. return;
  823. }
  824.  
  825. // Toggle the star state between filled and empty
  826. if (storedValue) {
  827. const [storedIndex, storedExactState] = storedValue.split(',');
  828. const index = parseInt(storedIndex, 10);
  829. const exactState = storedExactState === '1';
  830. if (index === state.currentExampleIndex && exactState === state.exactSearch) {
  831. removeItem(state.vocab);
  832. starIcon.textContent = '☆';
  833. } else {
  834. setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
  835. starIcon.textContent = '★';
  836. }
  837. } else {
  838. setItem(state.vocab, `${state.currentExampleIndex},${state.exactSearch ? 1 : 0}`);
  839. starIcon.textContent = '★';
  840. }
  841. }
  842.  
  843. function createQuoteButton() {
  844. // Create a quote button with an icon and click event for toggling quote style
  845. const anchor = createAnchor('0rem');
  846. const quoteIcon = document.createElement('span');
  847.  
  848. // Set the icon based on exact search state
  849. quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
  850.  
  851. // Style the quote icon
  852. quoteIcon.style.fontSize = '1.1rem';
  853. quoteIcon.style.color = '#3D8DFF';
  854. quoteIcon.style.verticalAlign = 'middle';
  855. quoteIcon.style.position = 'relative';
  856. quoteIcon.style.top = '0px';
  857.  
  858. // Append the quote icon to the anchor and set up the click event to toggle quote state
  859. anchor.appendChild(quoteIcon);
  860. anchor.addEventListener('click', (event) => {
  861. event.preventDefault();
  862. toggleQuoteState(quoteIcon);
  863. });
  864.  
  865. return anchor;
  866. }
  867.  
  868. function toggleQuoteState(quoteIcon) {
  869. const storedValue = getItem(state.vocab);
  870. const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2;
  871.  
  872. // Return early if blacklisted
  873. if (isBlacklisted) {
  874. return;
  875. }
  876.  
  877. // Toggle between single and double quote styles
  878. state.exactSearch = !state.exactSearch;
  879. quoteIcon.innerHTML = state.exactSearch ? '<b>「」</b>' : '『』';
  880.  
  881. // Update state based on stored data
  882. const storedData = getStoredData(state.vocab);
  883. if (storedData && storedData.exactState === state.exactSearch) {
  884. state.currentExampleIndex = storedData.index;
  885. } else {
  886. state.currentExampleIndex = 0;
  887. }
  888.  
  889. state.apiDataFetched = false;
  890. embedImageAndPlayAudio();
  891. getImmersionKitData(state.vocab, state.exactSearch)
  892. .then(() => {
  893. embedImageAndPlayAudio();
  894. })
  895. .catch(error => {
  896. console.error(error);
  897. });
  898. }
  899.  
  900. function createMenuButton() {
  901. // Create a menu button with a dropdown menu
  902. const anchor = createAnchor('0.5rem');
  903. const menuIcon = document.createElement('span');
  904. menuIcon.innerHTML = '☰';
  905.  
  906. // Style the menu icon
  907. menuIcon.style.fontSize = '1.4rem';
  908. menuIcon.style.color = '#3D8DFF';
  909. menuIcon.style.verticalAlign = 'middle';
  910. menuIcon.style.position = 'relative';
  911. menuIcon.style.top = '-2px';
  912.  
  913. // Append the menu icon to the anchor and set up the click event to show the overlay menu
  914. anchor.appendChild(menuIcon);
  915. anchor.addEventListener('click', (event) => {
  916. event.preventDefault();
  917. const overlay = createOverlayMenu();
  918. document.body.appendChild(overlay);
  919. });
  920.  
  921. return anchor;
  922. }
  923.  
  924. function createTextButton(vocab, exact) {
  925. const textButton = document.createElement('a');
  926. textButton.textContent = 'Immersion Kit';
  927. textButton.style.color = 'var(--subsection-label-color)';
  928. textButton.style.fontSize = '85%';
  929. textButton.style.marginRight = '0.5rem';
  930. textButton.style.verticalAlign = 'middle';
  931.  
  932. const url = new URL('https://www.immersionkit.com/dictionary');
  933. url.searchParams.set('keyword', vocab);
  934. url.searchParams.set('sort', 'sentence_length:asc');
  935. if (exact) url.searchParams.set('exact', 'true');
  936.  
  937. textButton.href = url.toString();
  938. textButton.target = '_blank';
  939. return textButton;
  940. }
  941.  
  942. function createButtonContainer(soundUrl, vocab, exact) {
  943. // Create a container for all buttons
  944. const buttonContainer = document.createElement('div');
  945. buttonContainer.className = 'button-container';
  946. buttonContainer.style.display = 'flex';
  947. buttonContainer.style.justifyContent = 'space-between';
  948. buttonContainer.style.alignItems = 'center';
  949. buttonContainer.style.marginBottom = '5px';
  950. buttonContainer.style.lineHeight = '1.4rem';
  951.  
  952. // Create individual buttons
  953. const menuButton = createMenuButton();
  954. const textButton = createTextButton(vocab, exact);
  955. const speakerButton = createSpeakerButton(soundUrl);
  956. const starButton = createStarButton();
  957. const quoteButton = createQuoteButton();
  958.  
  959. // Center the buttons within the container
  960. const centeredButtonsWrapper = document.createElement('div');
  961. centeredButtonsWrapper.style.display = 'flex';
  962. centeredButtonsWrapper.style.justifyContent = 'center';
  963. centeredButtonsWrapper.style.flex = '1';
  964.  
  965. centeredButtonsWrapper.append(textButton, speakerButton, starButton, quoteButton);
  966. buttonContainer.append(centeredButtonsWrapper, menuButton);
  967.  
  968. return buttonContainer;
  969. }
  970.  
  971. // ——— Stop any playing audio ———
  972. function stopCurrentAudio() {
  973. if (state.currentSource) {
  974. try {
  975. state.currentSource.onended = null;
  976. state.currentSource.stop(0);
  977. state.currentSource.disconnect();
  978. } catch (e) { /* already stopped? ignore */ }
  979. state.currentSource = null;
  980. }
  981. }
  982.  
  983. // ——— Play a new clip ———
  984. function playAudio(soundUrl) {
  985. if (!soundUrl) return;
  986.  
  987. // 1) bump play ID to cancel any in-flight requests/decodes
  988. const playId = ++state.lastPlayId;
  989.  
  990. // 2) tear down old source instantly
  991. stopCurrentAudio();
  992.  
  993. // 3) ensure context is resumed (autoplay policy)
  994. if (state.sharedAudioContext.state === 'suspended') {
  995. state.sharedAudioContext.resume().catch(() => {});
  996. }
  997.  
  998. // 4) fetch via GM_xmlhttpRequest
  999. GM_xmlhttpRequest({
  1000. method: 'GET',
  1001. url: soundUrl,
  1002. responseType: 'arraybuffer',
  1003. onload(response) {
  1004. // if a newer playAudio() ran, abort
  1005. if (playId !== state.lastPlayId) return;
  1006.  
  1007. state.sharedAudioContext.decodeAudioData(
  1008. response.response,
  1009. buffer => {
  1010. if (playId !== state.lastPlayId) return;
  1011.  
  1012. // wire up new source + gain
  1013. const src = state.sharedAudioContext.createBufferSource();
  1014. src.buffer = buffer;
  1015. const gain = state.sharedAudioContext.createGain();
  1016. gain.gain.setValueAtTime(0, state.sharedAudioContext.currentTime);
  1017. gain.gain.linearRampToValueAtTime(
  1018. (CONFIG.SOUND_VOLUME || 100) / 100,
  1019. state.sharedAudioContext.currentTime + 0.05
  1020. );
  1021. src.connect(gain).connect(state.sharedAudioContext.destination);
  1022.  
  1023. // start (skip initial 50 ms to avoid pop)
  1024. src.start(0, 0.05);
  1025.  
  1026. // when it ends, clear the reference if still “ours”
  1027. src.onended = () => {
  1028. if (state.currentSource === src) {
  1029. state.currentSource = null;
  1030. }
  1031. };
  1032.  
  1033. // hold onto it so stopCurrentAudio() can find it
  1034. state.currentSource = src;
  1035. },
  1036. err => {
  1037. console.error('decodeAudioData failed:', err);
  1038. }
  1039. );
  1040. },
  1041. onerror(err) {
  1042. console.error('GM_xmlhttpRequest failed:', err);
  1043. }
  1044. });
  1045. }
  1046.  
  1047.  
  1048.  
  1049. // has to be declared (referenced in multiple functions but definition requires variables local to one function)
  1050. let hotkeysListener;
  1051.  
  1052. function renderImageAndPlayAudio(vocab, shouldAutoPlaySound) {
  1053. const example = state.examples[state.currentExampleIndex] || {};
  1054. const imageUrl = example.image ? `https://us-southeast-1.linodeobjects.com/immersionkit/media/${example.media}/${example.title}/media/${example.image}` : null;
  1055. const soundUrl = example.sound ? `https://us-southeast-1.linodeobjects.com/immersionkit/media/${example.media}/${example.title}/media/${example.sound}` : null;
  1056. const sentence = example.sentence || null;
  1057. const translation = example.translation || null;
  1058. const title = example.title || null;
  1059. const storedValue = getItem(state.vocab);
  1060. const isBlacklisted = storedValue && storedValue.split(',').length > 1 && parseInt(storedValue.split(',')[1], 10) === 2;
  1061.  
  1062.  
  1063. // Remove any existing container
  1064. removeExistingContainer();
  1065. if (!shouldRenderContainer()) return;
  1066.  
  1067. // Create and append the main wrapper and text button container
  1068. const wrapperDiv = createWrapperDiv();
  1069. const textDiv = createButtonContainer(soundUrl, vocab, state.exactSearch);
  1070. wrapperDiv.appendChild(textDiv);
  1071.  
  1072.  
  1073. const createTextElement = (text) => {
  1074. const textElement = document.createElement('div');
  1075. textElement.textContent = text;
  1076. textElement.style.padding = '100px 0';
  1077. textElement.style.whiteSpace = 'pre'; // Ensures newlines are respected
  1078. return textElement;
  1079. };
  1080.  
  1081. if (isBlacklisted) {
  1082. wrapperDiv.appendChild(createTextElement('BLACKLISTED'));
  1083. shouldAutoPlaySound = false;
  1084. } else if (state.apiDataFetched) {
  1085. if (imageUrl) {
  1086. const imageElement = createImageElement(wrapperDiv, imageUrl, vocab, state.exactSearch);
  1087. if (imageElement) {
  1088. imageElement.addEventListener('click', () => playAudio(soundUrl));
  1089. }
  1090. } else {
  1091. wrapperDiv.appendChild(createTextElement(`NO IMAGE\n(${title})`));
  1092. }
  1093. // Append sentence and translation or a placeholder text
  1094. sentence ? appendSentenceAndTranslation(wrapperDiv, sentence, translation) : appendNoneText(wrapperDiv);
  1095. } else if (state.error) {
  1096. wrapperDiv.appendChild(createTextElement('ERROR\nNO EXAMPLES FOUND\n\nRARE WORD OR\nIMMERSIONKIT API IS TEMPORARILY DOWN'));
  1097. } else {
  1098. wrapperDiv.appendChild(createTextElement('LOADING'));
  1099. }
  1100.  
  1101. // Create navigation elements
  1102. const navigationDiv = createNavigationDiv();
  1103. const leftArrow = createLeftArrow(vocab, shouldAutoPlaySound);
  1104. const rightArrow = createRightArrow(vocab, shouldAutoPlaySound);
  1105.  
  1106. // Create and append the main container
  1107. const containerDiv = createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv);
  1108. appendContainer(containerDiv);
  1109.  
  1110. // Auto-play sound if configured
  1111. if (CONFIG.AUTO_PLAY_SOUND && shouldAutoPlaySound) {
  1112. playAudio(soundUrl);
  1113. }
  1114.  
  1115. // Link hotkeys
  1116. if (CONFIG.HOTKEYS.indexOf("None") === -1) {
  1117. const leftHotkey = CONFIG.HOTKEYS[0];
  1118. const rightHotkey = CONFIG.HOTKEYS[1];
  1119.  
  1120. hotkeysListener = (event) => {
  1121. if (event.repeat) return;
  1122. switch (event.key.toLowerCase()) {
  1123. case leftHotkey.toLowerCase():
  1124. if (leftArrow.disabled) {
  1125. // listener gets removed, so need to re-add
  1126. window.addEventListener('keydown', hotkeysListener, {once: true});
  1127. } else {
  1128. leftArrow.click(); // don't need to re-add listener because renderImageAndPlayAudio() will run again
  1129. }
  1130. break;
  1131. case rightHotkey.toLowerCase():
  1132. if (rightArrow.disabled) {
  1133. // listener gets removed, so need to re-add
  1134. window.addEventListener('keydown', hotkeysListener, {once: true});
  1135. } else {
  1136. rightArrow.click(); // don't need to re-add listener because renderImageAndPlayAudio() will run again
  1137. }
  1138. break;
  1139. default:
  1140. // listener gets removed, so need to re-add
  1141. window.addEventListener('keydown', hotkeysListener, {once: true});
  1142. }
  1143. }
  1144.  
  1145. window.addEventListener('keydown', hotkeysListener, {once: true});
  1146. }
  1147. }
  1148.  
  1149. function removeExistingContainer() {
  1150. // Remove the existing container if it exists
  1151. const existingContainer = document.getElementById('immersion-kit-container');
  1152. if (existingContainer) {
  1153. existingContainer.remove();
  1154. }
  1155. window.removeEventListener('keydown', hotkeysListener);
  1156. }
  1157.  
  1158. function shouldRenderContainer() {
  1159. // Determine if the container should be rendered based on the presence of certain elements
  1160. const resultVocabularySection = document.querySelector('.result.vocabulary');
  1161. const hboxWrapSection = document.querySelector('.hbox.wrap');
  1162. const subsectionMeanings = document.querySelector('.subsection-meanings');
  1163. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  1164. return resultVocabularySection || hboxWrapSection || subsectionMeanings || subsectionLabels.length >= 3;
  1165. }
  1166.  
  1167. function createWrapperDiv() {
  1168. // Create and style the wrapper div
  1169. const wrapperDiv = document.createElement('div');
  1170. wrapperDiv.id = 'image-wrapper';
  1171. wrapperDiv.style.textAlign = 'center';
  1172. wrapperDiv.style.padding = '5px 0';
  1173. return wrapperDiv;
  1174. }
  1175.  
  1176. // Detect iOS
  1177. function isIOS() {
  1178. return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  1179. }
  1180.  
  1181. // Preload images
  1182. function preloadImages() {
  1183. // Preload images around the current example index
  1184. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  1185. const startIndex = Math.max(0, state.currentExampleIndex - CONFIG.NUMBER_OF_PRELOADS);
  1186. const endIndex = Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUMBER_OF_PRELOADS);
  1187.  
  1188. for (let i = startIndex; i <= endIndex; i++) {
  1189. if (!state.preloadedIndices.has(i) && state.examples[i].image) {
  1190. const example = state.examples[i];
  1191. const imageUrl = `https://us-southeast-1.linodeobjects.com/immersionkit/media/${example.media}/${example.title}/media/${example.image}`;
  1192. if (isIOS()) {
  1193. GM_xmlhttpRequest({
  1194. method: 'GET',
  1195. url: imageUrl,
  1196. responseType: 'blob',
  1197. onload: function(response) {
  1198. if (response.status === 200 && response.response) {
  1199. example.blob = response.response;
  1200. state.preloadedIndices.add(i);
  1201. }
  1202. }
  1203. });
  1204. } else {
  1205. GM_addElement(preloadDiv, 'img', { src: imageUrl });
  1206. state.preloadedIndices.add(i);
  1207. }
  1208. }
  1209. }
  1210. }
  1211.  
  1212. // Create image element
  1213. function createImageElement(wrapperDiv, imageUrl, vocab, exactSearch) {
  1214. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  1215. const example = state.examples[state.currentExampleIndex] || {};
  1216. const title = example.title || '';
  1217. let file_name = imageUrl.substring(imageUrl.lastIndexOf('/') + 1).replace(/^(Anime_|A_|Z)/, '');
  1218. const titleText = `${searchVocab} #${state.currentExampleIndex + 1}\n${title}\n${file_name}`;
  1219.  
  1220. if (isIOS()) {
  1221. // --- Calculate width and 16:9 height from config ---
  1222. const width = parseInt(CONFIG.IMAGE_WIDTH, 10);
  1223. const height = Math.round(width * 9 / 16);
  1224.  
  1225. // --- Outer container ---
  1226. const imgContainer = document.createElement('div');
  1227. imgContainer.style = `width:${width}px;max-width:${width}px;margin:10px auto 0;position:relative;min-height:${height}px;`;
  1228.  
  1229. // --- Hidden image until loaded ---
  1230. const img = document.createElement('img');
  1231. img.alt = 'Embedded Image';
  1232. img.title = titleText;
  1233. img.style = `width:100%;max-width:${width}px;margin-top:10px;cursor:pointer;display:none;border-radius:4px;height:auto;`;
  1234.  
  1235. // --- Error fallback, also 16:9 ---
  1236. const errorFallback = document.createElement('div');
  1237. errorFallback.style = `display:none;width:100%;aspect-ratio:16/9;`;
  1238. errorFallback.innerHTML =
  1239. `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
  1240. <rect width="${width}" height="${height}" fill="#f8d7da"/>
  1241. <text x="50%" y="50%" text-anchor="middle" fill="#721c24" dy=".3em" font-size="18">Image failed to load</text>
  1242. </svg>`;
  1243.  
  1244. imgContainer.append(img, errorFallback);
  1245. wrapperDiv.appendChild(imgContainer);
  1246.  
  1247. // --- Use cached blob else load ---
  1248. if (example.blob) {
  1249. const objectURL = URL.createObjectURL(example.blob);
  1250. img.src = objectURL;
  1251. img.onload = () => {
  1252. errorFallback.style.display = 'none';
  1253. img.style.display = 'block';
  1254. URL.revokeObjectURL(objectURL);
  1255. };
  1256. img.onerror = () => {
  1257. img.style.display = 'none';
  1258. errorFallback.style.display = 'block';
  1259. };
  1260. } else {
  1261. GM_xmlhttpRequest({
  1262. method: 'GET',
  1263. url: imageUrl,
  1264. responseType: 'blob',
  1265. onload: function(response) {
  1266. if (response.status === 200 && response.response) {
  1267. example.blob = response.response;
  1268. const objectURL = URL.createObjectURL(response.response);
  1269. img.src = objectURL;
  1270. img.onload = () => {
  1271. errorFallback.style.display = 'none';
  1272. img.style.display = 'block';
  1273. URL.revokeObjectURL(objectURL);
  1274. };
  1275. img.onerror = () => {
  1276. img.style.display = 'none';
  1277. errorFallback.style.display = 'block';
  1278. };
  1279. } else {
  1280. errorFallback.style.display = 'block';
  1281. console.error('Failed to load image:', imageUrl);
  1282. }
  1283. },
  1284. onerror: function() {
  1285. errorFallback.style.display = 'block';
  1286. console.error('GM_xmlhttpRequest error for', imageUrl);
  1287. }
  1288. });
  1289. }
  1290. return img;
  1291. } else {
  1292. // Non-iOS: just add the image
  1293. return GM_addElement(wrapperDiv, 'img', {
  1294. src: imageUrl,
  1295. alt: 'Embedded Image',
  1296. title: titleText,
  1297. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  1298. });
  1299. }
  1300. }
  1301.  
  1302. function highlightVocab(sentence, vocab) {
  1303. // Highlight vocabulary in the sentence based on configuration
  1304. if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
  1305.  
  1306. if (state.exactSearch) {
  1307. const regex = new RegExp(`(${vocab})`, 'g');
  1308. return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
  1309. } else {
  1310. return vocab.split('').reduce((acc, char) => {
  1311. const regex = new RegExp(char, 'g');
  1312. return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
  1313. }, sentence);
  1314. }
  1315. }
  1316.  
  1317. function appendSentenceAndTranslation(wrapperDiv, sentence, translation) {
  1318. // Append sentence and translation to the wrapper div
  1319. const sentenceText = document.createElement('div');
  1320. sentenceText.innerHTML = highlightVocab(sentence, state.vocab);
  1321. sentenceText.style.marginTop = '10px';
  1322. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  1323. sentenceText.style.color = 'lightgray';
  1324. sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1325. sentenceText.style.whiteSpace = 'pre-wrap';
  1326. wrapperDiv.appendChild(sentenceText);
  1327.  
  1328. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && translation) {
  1329. const translationText = document.createElement('div');
  1330. translationText.innerHTML = replaceSpecialCharacters(translation);
  1331. translationText.style.marginTop = '5px';
  1332. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  1333. translationText.style.color = 'var(--subsection-label-color)';
  1334. translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1335. translationText.style.whiteSpace = 'pre-wrap';
  1336. wrapperDiv.appendChild(translationText);
  1337. }
  1338. }
  1339.  
  1340. function appendNoneText(wrapperDiv) {
  1341. // Append a "None" text to the wrapper div
  1342. const noneText = document.createElement('div');
  1343. noneText.textContent = 'None';
  1344. noneText.style.marginTop = '10px';
  1345. noneText.style.fontSize = '85%';
  1346. noneText.style.color = 'var(--subsection-label-color)';
  1347. wrapperDiv.appendChild(noneText);
  1348. }
  1349.  
  1350. function createNavigationDiv() {
  1351. // Create and style the navigation div
  1352. const navigationDiv = document.createElement('div');
  1353. navigationDiv.id = 'immersion-kit-embed';
  1354. navigationDiv.style.display = 'flex';
  1355. navigationDiv.style.justifyContent = 'center';
  1356. navigationDiv.style.alignItems = 'center';
  1357. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1358. navigationDiv.style.margin = '0 auto';
  1359. return navigationDiv;
  1360. }
  1361.  
  1362. function createLeftArrow(vocab, shouldAutoPlaySound) {
  1363. // Create and configure the left arrow button
  1364. const leftArrow = document.createElement('button');
  1365. leftArrow.textContent = '<';
  1366. leftArrow.style.marginRight = '10px';
  1367. leftArrow.style.width = CONFIG.ARROW_WIDTH;
  1368. leftArrow.style.height = CONFIG.ARROW_HEIGHT;
  1369. leftArrow.style.lineHeight = '25px';
  1370. leftArrow.style.textAlign = 'center';
  1371. leftArrow.style.display = 'flex';
  1372. leftArrow.style.justifyContent = 'center';
  1373. leftArrow.style.alignItems = 'center';
  1374. leftArrow.style.padding = '0'; // Remove padding
  1375. leftArrow.disabled = state.currentExampleIndex === 0;
  1376. leftArrow.addEventListener('click', () => {
  1377. if (state.currentExampleIndex > 0) {
  1378. state.currentExampleIndex--;
  1379. state.currentlyPlayingAudio = false;
  1380. stopCurrentAudio();
  1381. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  1382. preloadImages();
  1383. }
  1384. });
  1385. return leftArrow;
  1386. }
  1387.  
  1388. function createRightArrow(vocab, shouldAutoPlaySound) {
  1389. // Create and configure the right arrow button
  1390. const rightArrow = document.createElement('button');
  1391. rightArrow.textContent = '>';
  1392. rightArrow.style.marginLeft = '10px';
  1393. rightArrow.style.width = CONFIG.ARROW_WIDTH;
  1394. rightArrow.style.height = CONFIG.ARROW_HEIGHT;
  1395. rightArrow.style.lineHeight = '25px';
  1396. rightArrow.style.textAlign = 'center';
  1397. rightArrow.style.display = 'flex';
  1398. rightArrow.style.justifyContent = 'center';
  1399. rightArrow.style.alignItems = 'center';
  1400. rightArrow.style.padding = '0'; // Remove padding
  1401. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  1402. rightArrow.addEventListener('click', () => {
  1403. if (state.currentExampleIndex < state.examples.length - 1) {
  1404. state.currentExampleIndex++;
  1405. state.currentlyPlayingAudio = false;
  1406. stopCurrentAudio();
  1407. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  1408. preloadImages();
  1409. }
  1410. });
  1411. return rightArrow;
  1412. }
  1413.  
  1414. function createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv) {
  1415. // Create and configure the main container div
  1416. const containerDiv = document.createElement('div');
  1417. containerDiv.id = 'immersion-kit-container';
  1418. containerDiv.style.display = 'flex';
  1419. containerDiv.style.alignItems = 'center';
  1420. containerDiv.style.justifyContent = 'center';
  1421. containerDiv.style.flexDirection = 'column';
  1422.  
  1423. const arrowWrapperDiv = document.createElement('div');
  1424. arrowWrapperDiv.style.display = 'flex';
  1425. arrowWrapperDiv.style.alignItems = 'center';
  1426. arrowWrapperDiv.style.justifyContent = 'center';
  1427.  
  1428. arrowWrapperDiv.append(leftArrow, wrapperDiv, rightArrow);
  1429. containerDiv.append(arrowWrapperDiv, navigationDiv);
  1430.  
  1431. return containerDiv;
  1432. }
  1433.  
  1434. function appendContainer(containerDiv) {
  1435. // Append the container div to the appropriate section based on configuration
  1436. const resultVocabularySection = document.querySelector('.result.vocabulary');
  1437. const hboxWrapSection = document.querySelector('.hbox.wrap');
  1438. const subsectionMeanings = document.querySelector('.subsection-meanings');
  1439. const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
  1440. const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
  1441. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  1442. const vboxGap = document.querySelector('.vbox.gap');
  1443. const styleSheet = document.querySelector('link[rel="stylesheet"]').sheet;
  1444.  
  1445. if (CONFIG.WIDE_MODE && subsectionMeanings) {
  1446. const wrapper = document.createElement('div');
  1447. wrapper.style.display = 'flex';
  1448. wrapper.style.alignItems = 'flex-start';
  1449. styleSheet.insertRule('.subsection-meanings { max-width: none !important; }', styleSheet.cssRules.length);
  1450.  
  1451. const originalContentWrapper = document.createElement('div');
  1452. originalContentWrapper.style.flex = '1';
  1453. originalContentWrapper.appendChild(subsectionMeanings);
  1454.  
  1455. if (subsectionComposedOfKanji) {
  1456. const newline1 = document.createElement('br');
  1457. originalContentWrapper.appendChild(newline1);
  1458. originalContentWrapper.appendChild(subsectionComposedOfKanji);
  1459. }
  1460. if (subsectionPitchAccent) {
  1461. const newline2 = document.createElement('br');
  1462. originalContentWrapper.appendChild(newline2);
  1463. originalContentWrapper.appendChild(subsectionPitchAccent);
  1464. }
  1465.  
  1466. if (CONFIG.DEFINITIONS_ON_RIGHT_IN_WIDE_MODE) {
  1467. wrapper.appendChild(containerDiv);
  1468. wrapper.appendChild(originalContentWrapper);
  1469. } else {
  1470. wrapper.appendChild(originalContentWrapper);
  1471. wrapper.appendChild(containerDiv);
  1472. }
  1473.  
  1474. if (vboxGap) {
  1475. const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
  1476. if (existingDynamicDiv) {
  1477. existingDynamicDiv.remove();
  1478. }
  1479.  
  1480. const dynamicDiv = document.createElement('div');
  1481. dynamicDiv.id = 'dynamic-content';
  1482. dynamicDiv.appendChild(wrapper);
  1483.  
  1484. if (window.location.href.includes('vocabulary')) {
  1485. vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
  1486. } else {
  1487. vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild);
  1488. }
  1489. }
  1490. } else {
  1491. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  1492. subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
  1493. } else if (resultVocabularySection) {
  1494. resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
  1495. } else if (hboxWrapSection) {
  1496. hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
  1497. } else if (subsectionLabels.length >= 4) {
  1498. subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
  1499. }
  1500. }
  1501. }
  1502.  
  1503. function embedImageAndPlayAudio() {
  1504. // Embed the image and play audio, removing existing navigation div if present
  1505. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  1506. if (existingNavigationDiv) existingNavigationDiv.remove();
  1507.  
  1508. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1509. preloadImages();
  1510. }
  1511.  
  1512. function replaceSpecialCharacters(text) {
  1513. // Replace special characters in the text
  1514. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  1515. }
  1516.  
  1517.  
  1518. //MENU FUNCTIONS=====================================================================================================================
  1519. ////FILE OPERATIONS=====================================================================================================================
  1520. function handleImportButtonClick() {
  1521. handleFileInput('application/json', importFavorites);
  1522. }
  1523.  
  1524. function handleImportDButtonClick() {
  1525. handleFileInput('application/json', importData);
  1526. }
  1527.  
  1528. function handleFileInput(acceptType, callback) {
  1529. const fileInput = document.createElement('input');
  1530. fileInput.type = 'file';
  1531. fileInput.accept = acceptType;
  1532. fileInput.addEventListener('change', callback);
  1533. fileInput.click();
  1534. }
  1535.  
  1536. function createBlobAndDownload(data, filename, type) {
  1537. const blob = new Blob([data], { type });
  1538. const url = URL.createObjectURL(blob);
  1539. const a = document.createElement('a');
  1540. a.href = url;
  1541. a.download = filename;
  1542. document.body.appendChild(a);
  1543. a.click();
  1544. document.body.removeChild(a);
  1545. URL.revokeObjectURL(url);
  1546. }
  1547.  
  1548. function addBlacklist() {
  1549. setItem(state.vocab, `0,2`);
  1550. location.reload();
  1551. }
  1552.  
  1553. function remBlacklist() {
  1554. removeItem(state.vocab);
  1555. location.reload();
  1556. }
  1557.  
  1558. function exportFavorites() {
  1559. const favorites = {};
  1560. for (let i = 0; i < localStorage.length; i++) {
  1561. const key = localStorage.key(i);
  1562. if (key.startsWith(scriptPrefix)) {
  1563. const keyPrefixless = key.substring(scriptPrefix.length); // chop off the script prefix
  1564. if (!keyPrefixless.startsWith(configPrefix)) {
  1565. favorites[keyPrefixless] = localStorage.getItem(key);
  1566. // For backwards compatibility keep the exported keys prefixless
  1567. }
  1568. }
  1569. }
  1570. const data = JSON.stringify(favorites, null, 2);
  1571. createBlobAndDownload(data, 'favorites.json', 'application/json');
  1572. }
  1573.  
  1574. function importFavorites(event) {
  1575. const file = event.target.files[0];
  1576. if (!file) return;
  1577.  
  1578. const reader = new FileReader();
  1579. reader.onload = function(e) {
  1580. try {
  1581. const favorites = JSON.parse(e.target.result);
  1582. for (const key in favorites) {
  1583. setItem(key, favorites[key]);
  1584. }
  1585. alert('Favorites imported successfully!');
  1586. location.reload();
  1587. } catch (error) {
  1588. alert('Error importing favorites:', error);
  1589. }
  1590. };
  1591. reader.readAsText(file);
  1592. }
  1593.  
  1594. async function exportData() {
  1595. const dataEntries = {};
  1596.  
  1597. try {
  1598. const db = await IndexedDBManager.open();
  1599. const indexedDBData = await IndexedDBManager.getAll(db);
  1600. indexedDBData.forEach(item => {
  1601. dataEntries[item.keyword] = item.data;
  1602. });
  1603.  
  1604. const data = JSON.stringify(dataEntries, null, 2);
  1605. createBlobAndDownload(data, 'data.json', 'application/json');
  1606. } catch (error) {
  1607. console.error('Error exporting data from IndexedDB:', error);
  1608. }
  1609. }
  1610.  
  1611. async function importData(event) {
  1612. const file = event.target.files[0];
  1613. if (!file) return;
  1614.  
  1615. const reader = new FileReader();
  1616. reader.onload = async function(e) {
  1617. try {
  1618. const dataEntries = JSON.parse(e.target.result);
  1619.  
  1620. const db = await IndexedDBManager.open();
  1621. for (const key in dataEntries) {
  1622. await IndexedDBManager.save(db, key, dataEntries[key]);
  1623. }
  1624.  
  1625. alert('Data imported successfully!');
  1626. location.reload();
  1627. } catch (error) {
  1628. alert('Error importing data:', error);
  1629. }
  1630. };
  1631. reader.readAsText(file);
  1632. }
  1633.  
  1634.  
  1635. ////CONFIRMATION
  1636. function createConfirmationPopup(messageText, onYes, onNo) {
  1637. // Create a confirmation popup with Yes and No buttons
  1638. const popupOverlay = document.createElement('div');
  1639. popupOverlay.style.position = 'fixed';
  1640. popupOverlay.style.top = '0';
  1641. popupOverlay.style.left = '0';
  1642. popupOverlay.style.width = '100%';
  1643. popupOverlay.style.height = '100%';
  1644. popupOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1645. popupOverlay.style.zIndex = '1001';
  1646. popupOverlay.style.display = 'flex';
  1647. popupOverlay.style.justifyContent = 'center';
  1648. popupOverlay.style.alignItems = 'center';
  1649.  
  1650. const popupContent = document.createElement('div');
  1651. popupContent.style.backgroundColor = 'var(--background-color)';
  1652. popupContent.style.padding = '20px';
  1653. popupContent.style.borderRadius = '5px';
  1654. popupContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1655. popupContent.style.textAlign = 'center';
  1656.  
  1657. const message = document.createElement('p');
  1658. message.textContent = messageText;
  1659.  
  1660. const yesButton = document.createElement('button');
  1661. yesButton.textContent = 'Yes';
  1662. yesButton.style.backgroundColor = '#C82800';
  1663. yesButton.style.marginRight = '10px';
  1664. yesButton.addEventListener('click', () => {
  1665. onYes();
  1666. document.body.removeChild(popupOverlay);
  1667. });
  1668.  
  1669. const noButton = document.createElement('button');
  1670. noButton.textContent = 'No';
  1671. noButton.addEventListener('click', () => {
  1672. onNo();
  1673. document.body.removeChild(popupOverlay);
  1674. });
  1675.  
  1676. popupContent.appendChild(message);
  1677. popupContent.appendChild(yesButton);
  1678. popupContent.appendChild(noButton);
  1679. popupOverlay.appendChild(popupContent);
  1680.  
  1681. document.body.appendChild(popupOverlay);
  1682. }
  1683.  
  1684. ////BUTTONS
  1685. function createActionButtonsContainer() {
  1686. const actionButtonWidth = '100px';
  1687.  
  1688. const closeButton = createButton('Close', '10px', closeOverlayMenu, actionButtonWidth);
  1689. const saveButton = createButton('Save', '10px', saveConfig, actionButtonWidth);
  1690. const defaultButton = createDefaultButton(actionButtonWidth);
  1691. const deleteButton = createDeleteButton(actionButtonWidth);
  1692. const deleteCurrentVocabButton = createDeleteCurrentVocabButton('400px');
  1693.  
  1694. const actionButtonsContainer = document.createElement('div');
  1695. actionButtonsContainer.style.textAlign = 'center';
  1696. actionButtonsContainer.style.marginTop = '10px';
  1697. actionButtonsContainer.append(closeButton, saveButton, defaultButton, deleteButton, deleteCurrentVocabButton);
  1698.  
  1699. return actionButtonsContainer;
  1700. }
  1701.  
  1702. function createMenuButtons() {
  1703. const blacklistContainer = createBlacklistContainer();
  1704. const favoritesContainer = createFavoritesContainer();
  1705. const dataContainer = createDataContainer();
  1706. const actionButtonsContainer = createActionButtonsContainer();
  1707.  
  1708. const buttonContainer = document.createElement('div');
  1709. buttonContainer.append(blacklistContainer,favoritesContainer,dataContainer,actionButtonsContainer);
  1710.  
  1711. return buttonContainer;
  1712. }
  1713.  
  1714. function createButton(text, margin, onClick, width) {
  1715. // Create a button element with specified properties
  1716. const button = document.createElement('button');
  1717. button.textContent = text;
  1718. button.style.margin = margin;
  1719. button.style.width = width;
  1720. button.style.textAlign = 'center';
  1721. button.style.display = 'inline-block';
  1722. button.style.lineHeight = '30px';
  1723. button.style.padding = '5px 0';
  1724. button.addEventListener('click', onClick);
  1725. return button;
  1726. }
  1727.  
  1728. ////BLACKLIST BUTTONS
  1729. function createBlacklistContainer() {
  1730. const blacklistButtonWidth = '200px';
  1731.  
  1732. const addBlacklistButton = createButton('Add to Blacklist', '10px', addBlacklist, blacklistButtonWidth);
  1733. const remBlacklistButton = createButton('Remove from Blacklist', '10px', remBlacklist, blacklistButtonWidth);
  1734.  
  1735. const blacklistContainer = document.createElement('div');
  1736. blacklistContainer.style.textAlign = 'center';
  1737. blacklistContainer.style.marginTop = '10px';
  1738. blacklistContainer.append(addBlacklistButton, remBlacklistButton);
  1739.  
  1740. return blacklistContainer;
  1741. }
  1742. ////FAVORITE BUTTONS
  1743. function createFavoritesContainer() {
  1744. const favoritesButtonWidth = '200px';
  1745.  
  1746. const exportButton = createButton('Export Favorites', '10px', exportFavorites, favoritesButtonWidth);
  1747. const importButton = createButton('Import Favorites', '10px', handleImportButtonClick, favoritesButtonWidth);
  1748.  
  1749. const favoritesContainer = document.createElement('div');
  1750. favoritesContainer.style.textAlign = 'center';
  1751. favoritesContainer.style.marginTop = '10px';
  1752. favoritesContainer.append(exportButton, importButton);
  1753.  
  1754. return favoritesContainer;
  1755.  
  1756. }
  1757. ////DATA BUTTONS
  1758. function createDataContainer() {
  1759. const dataButtonWidth = '200px';
  1760.  
  1761. const exportButton = createButton('Export Data', '10px', exportData, dataButtonWidth);
  1762. const importButton = createButton('Import Data', '10px', handleImportDButtonClick, dataButtonWidth);
  1763.  
  1764. const dataContainer = document.createElement('div');
  1765. dataContainer.style.textAlign = 'center';
  1766. dataContainer.style.marginTop = '10px';
  1767. dataContainer.append(exportButton, importButton);
  1768.  
  1769. return dataContainer;
  1770. }
  1771.  
  1772. ////CLOSE BUTTON
  1773. function closeOverlayMenu() {
  1774. loadConfig();
  1775. document.body.removeChild(document.getElementById('overlayMenu'));
  1776. }
  1777.  
  1778. ////SAVE BUTTON
  1779. function saveConfig() {
  1780. const overlay = document.getElementById('overlayMenu');
  1781. if (!overlay) return;
  1782.  
  1783. const inputs = overlay.querySelectorAll('input, span');
  1784. const changes = gatherChanges(inputs);
  1785.  
  1786. applyChanges(changes);
  1787. finalizeSaveConfig();
  1788. setVocabSize();
  1789. setPageWidth();
  1790. }
  1791.  
  1792. function gatherChanges(inputs) {
  1793. const changes = {};
  1794.  
  1795. inputs.forEach(input => {
  1796. const key = input.getAttribute('data-key');
  1797. const type = input.getAttribute('data-type');
  1798. let value;
  1799.  
  1800. if (type === 'boolean') {
  1801. value = input.checked;
  1802. } else if (type === 'number') {
  1803. value = parseFloat(input.textContent);
  1804. } else if (type === 'string') {
  1805. value = input.textContent;
  1806. } else if (type === 'object' && key === 'HOTKEYS') {
  1807. value = input.textContent.replace(' and ', ' ');
  1808. }
  1809.  
  1810. if (key && type) {
  1811. const typePart = input.getAttribute('data-type-part');
  1812. const originalFormattedType = typePart.slice(1, -1);
  1813.  
  1814. changes[configPrefix + key] = value + originalFormattedType;
  1815. }
  1816. });
  1817.  
  1818. return changes;
  1819. }
  1820.  
  1821. function applyChanges(changes) {
  1822. for (const key in changes) {
  1823. setItem(key, changes[key]);
  1824. }
  1825. }
  1826.  
  1827. function finalizeSaveConfig() {
  1828. loadConfig();
  1829. window.removeEventListener('keydown', hotkeysListener);
  1830. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1831. const overlay = document.getElementById('overlayMenu');
  1832. if (overlay) {
  1833. document.body.removeChild(overlay);
  1834. }
  1835. }
  1836.  
  1837.  
  1838.  
  1839. ////DEFAULT BUTTON
  1840. function createDefaultButton(width) {
  1841. const defaultButton = createButton('Default', '10px', () => {
  1842. createConfirmationPopup(
  1843. 'This will reset all your settings to default. Are you sure?',
  1844. () => {
  1845. Object.keys(localStorage).forEach(key => {
  1846. if (key.startsWith(scriptPrefix + configPrefix)) {
  1847. localStorage.removeItem(key);
  1848. }
  1849. });
  1850. location.reload();
  1851. },
  1852. () => {
  1853. const overlay = document.getElementById('overlayMenu');
  1854. if (overlay) {
  1855. document.body.removeChild(overlay);
  1856. }
  1857. loadConfig();
  1858. document.body.appendChild(createOverlayMenu());
  1859. }
  1860. );
  1861. }, width);
  1862. defaultButton.style.backgroundColor = '#C82800';
  1863. defaultButton.style.color = 'white';
  1864. return defaultButton;
  1865. }
  1866.  
  1867.  
  1868. ////DELETE BUTTON
  1869. async function deleteCurrentVocab() {
  1870. try {
  1871. const db = await IndexedDBManager.open();
  1872. let currentVocab = state.vocab;
  1873.  
  1874. // Wrap currentVocab with angle quotes if exactSearch is true
  1875. if (state.exactSearch) {
  1876. currentVocab = `「${currentVocab}」`;
  1877. }
  1878.  
  1879. // Delete from IndexedDB
  1880. await IndexedDBManager.deleteEntry(db, currentVocab);
  1881. console.log('Deleting from IndexedDB:', currentVocab);
  1882.  
  1883. // Delete from local storage
  1884. const localStorageKey = scriptPrefix + state.vocab;
  1885. if (localStorage.getItem(localStorageKey)) {
  1886. localStorage.removeItem(localStorageKey);
  1887. console.log('Deleting from local storage:', localStorageKey);
  1888. }
  1889.  
  1890. alert('Current vocabulary deleted successfully!');
  1891. location.reload();
  1892. } catch (error) {
  1893. console.error('Error deleting current vocabulary:', error);
  1894. alert('Error deleting current vocabulary.');
  1895. }
  1896. }
  1897.  
  1898. function createDeleteCurrentVocabButton(width) {
  1899. const deleteCurrentVocabButton = createButton('Refresh Current Vocab from API', '10px', deleteCurrentVocab, width);
  1900. deleteCurrentVocabButton.style.backgroundColor = '#C82800';
  1901. deleteCurrentVocabButton.style.color = 'white';
  1902. return deleteCurrentVocabButton;
  1903. }
  1904.  
  1905. function createDeleteButton(width) {
  1906. const deleteButton = createButton('DELETE', '10px', () => {
  1907. createConfirmationPopup(
  1908. 'This will delete all your favorites and cached data. Are you sure?',
  1909. async () => {
  1910. await IndexedDBManager.delete();
  1911. Object.keys(localStorage).forEach(key => {
  1912. if (key.startsWith(scriptPrefix) && !key.startsWith(scriptPrefix + configPrefix)) {
  1913. localStorage.removeItem(key);
  1914. }
  1915. });
  1916. location.reload();
  1917. },
  1918. () => {
  1919. const overlay = document.getElementById('overlayMenu');
  1920. if (overlay) {
  1921. document.body.removeChild(overlay);
  1922. }
  1923. loadConfig();
  1924. document.body.appendChild(createOverlayMenu());
  1925. }
  1926. );
  1927. }, width);
  1928. deleteButton.style.backgroundColor = '#C82800';
  1929. deleteButton.style.color = 'white';
  1930. return deleteButton;
  1931. }
  1932.  
  1933. function createOverlayMenu() {
  1934. // Create and return the overlay menu for configuration settings
  1935. const overlay = document.createElement('div');
  1936. overlay.id = 'overlayMenu';
  1937. overlay.style.position = 'fixed';
  1938. overlay.style.top = '0';
  1939. overlay.style.left = '0';
  1940. overlay.style.width = '100%';
  1941. overlay.style.height = '100%';
  1942. overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1943. overlay.style.zIndex = '1000';
  1944. overlay.style.display = 'flex';
  1945. overlay.style.justifyContent = 'center';
  1946. overlay.style.alignItems = 'center';
  1947.  
  1948. const menuContent = document.createElement('div');
  1949. menuContent.style.backgroundColor = 'var(--background-color)';
  1950. menuContent.style.color = 'var(--text-color)';
  1951. menuContent.style.padding = '20px';
  1952. menuContent.style.borderRadius = '5px';
  1953. menuContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1954. menuContent.style.width = '80%';
  1955. menuContent.style.maxWidth = '550px';
  1956. menuContent.style.maxHeight = '80%';
  1957. menuContent.style.overflowY = 'auto';
  1958.  
  1959. for (const [key, value] of Object.entries(CONFIG)) {
  1960. const optionContainer = document.createElement('div');
  1961. optionContainer.style.marginBottom = '10px';
  1962. optionContainer.style.display = 'flex';
  1963. optionContainer.style.alignItems = 'center';
  1964.  
  1965. const leftContainer = document.createElement('div');
  1966. leftContainer.style.flex = '1';
  1967. leftContainer.style.display = 'flex';
  1968. leftContainer.style.alignItems = 'center';
  1969.  
  1970. const rightContainer = document.createElement('div');
  1971. rightContainer.style.flex = '1';
  1972. rightContainer.style.display = 'flex';
  1973. rightContainer.style.alignItems = 'center';
  1974. rightContainer.style.justifyContent = 'center';
  1975.  
  1976. const label = document.createElement('label');
  1977. label.textContent = key.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
  1978. label.style.marginRight = '10px';
  1979.  
  1980. leftContainer.appendChild(label);
  1981.  
  1982. if (typeof value === 'boolean') {
  1983. const checkboxContainer = document.createElement('div');
  1984. checkboxContainer.style.display = 'flex';
  1985. checkboxContainer.style.alignItems = 'center';
  1986. checkboxContainer.style.justifyContent = 'center';
  1987.  
  1988. const checkbox = document.createElement('input');
  1989. checkbox.type = 'checkbox';
  1990. checkbox.checked = value;
  1991. checkbox.setAttribute('data-key', key);
  1992. checkbox.setAttribute('data-type', 'boolean');
  1993. checkbox.setAttribute('data-type-part', '');
  1994. checkboxContainer.appendChild(checkbox);
  1995.  
  1996. rightContainer.appendChild(checkboxContainer);
  1997. } else if (typeof value === 'number') {
  1998. const numberContainer = document.createElement('div');
  1999. numberContainer.style.display = 'flex';
  2000. numberContainer.style.alignItems = 'center';
  2001. numberContainer.style.justifyContent = 'center';
  2002.  
  2003. const decrementButton = document.createElement('button');
  2004. decrementButton.textContent = '-';
  2005. decrementButton.style.marginRight = '5px';
  2006.  
  2007. const input = document.createElement('span');
  2008. input.textContent = value;
  2009. input.style.margin = '0 10px';
  2010. input.style.minWidth = '3ch';
  2011. input.style.textAlign = 'center';
  2012. input.setAttribute('data-key', key);
  2013. input.setAttribute('data-type', 'number');
  2014. input.setAttribute('data-type-part', '');
  2015.  
  2016. const incrementButton = document.createElement('button');
  2017. incrementButton.textContent = '+';
  2018. incrementButton.style.marginLeft = '5px';
  2019.  
  2020. const updateButtonStates = () => {
  2021. let currentValue = parseFloat(input.textContent);
  2022. if (currentValue <= 0) {
  2023. decrementButton.disabled = true;
  2024. decrementButton.style.color = 'grey';
  2025. } else {
  2026. decrementButton.disabled = false;
  2027. decrementButton.style.color = '';
  2028. }
  2029. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2030. incrementButton.disabled = true;
  2031. incrementButton.style.color = 'grey';
  2032. } else {
  2033. incrementButton.disabled = false;
  2034. incrementButton.style.color = '';
  2035. }
  2036. };
  2037.  
  2038. decrementButton.addEventListener('click', () => {
  2039. let currentValue = parseFloat(input.textContent);
  2040. if (currentValue > 0) {
  2041. if (currentValue > 200) {
  2042. input.textContent = currentValue - 25;
  2043. } else if (currentValue > 20) {
  2044. input.textContent = currentValue - 5;
  2045. } else {
  2046. input.textContent = currentValue - 1;
  2047. }
  2048. updateButtonStates();
  2049. }
  2050. });
  2051.  
  2052. incrementButton.addEventListener('click', () => {
  2053. let currentValue = parseFloat(input.textContent);
  2054. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2055. return;
  2056. }
  2057. if (currentValue >= 200) {
  2058. input.textContent = currentValue + 25;
  2059. } else if (currentValue >= 20) {
  2060. input.textContent = currentValue + 5;
  2061. } else {
  2062. input.textContent = currentValue + 1;
  2063. }
  2064. updateButtonStates();
  2065. });
  2066.  
  2067. numberContainer.appendChild(decrementButton);
  2068. numberContainer.appendChild(input);
  2069. numberContainer.appendChild(incrementButton);
  2070.  
  2071. rightContainer.appendChild(numberContainer);
  2072.  
  2073. // Initialize button states
  2074. updateButtonStates();
  2075. } else if (typeof value === 'string') {
  2076. const typeParts = value.split(/(\d+)/).filter(Boolean);
  2077. const numberParts = typeParts.filter(part => !isNaN(part)).map(Number);
  2078.  
  2079. const numberContainer = document.createElement('div');
  2080. numberContainer.style.display = 'flex';
  2081. numberContainer.style.alignItems = 'center';
  2082. numberContainer.style.justifyContent = 'center';
  2083.  
  2084. const typeSpan = document.createElement('span');
  2085. const formattedType = '(' + typeParts.filter(part => isNaN(part)).join('').replace(/_/g, ' ').toLowerCase() + ')';
  2086. typeSpan.textContent = formattedType;
  2087. typeSpan.style.marginRight = '10px';
  2088.  
  2089. leftContainer.appendChild(typeSpan);
  2090.  
  2091. typeParts.forEach(part => {
  2092. if (!isNaN(part)) {
  2093. const decrementButton = document.createElement('button');
  2094. decrementButton.textContent = '-';
  2095. decrementButton.style.marginRight = '5px';
  2096.  
  2097. const input = document.createElement('span');
  2098. input.textContent = part;
  2099. input.style.margin = '0 10px';
  2100. input.style.minWidth = '3ch';
  2101. input.style.textAlign = 'center';
  2102. input.setAttribute('data-key', key);
  2103. input.setAttribute('data-type', 'string');
  2104. input.setAttribute('data-type-part', formattedType);
  2105.  
  2106. const incrementButton = document.createElement('button');
  2107. incrementButton.textContent = '+';
  2108. incrementButton.style.marginLeft = '5px';
  2109.  
  2110. const updateButtonStates = () => {
  2111. let currentValue = parseFloat(input.textContent);
  2112. if (currentValue <= 0) {
  2113. decrementButton.disabled = true;
  2114. decrementButton.style.color = 'grey';
  2115. } else {
  2116. decrementButton.disabled = false;
  2117. decrementButton.style.color = '';
  2118. }
  2119. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2120. incrementButton.disabled = true;
  2121. incrementButton.style.color = 'grey';
  2122. } else {
  2123. incrementButton.disabled = false;
  2124. incrementButton.style.color = '';
  2125. }
  2126. };
  2127.  
  2128. decrementButton.addEventListener('click', () => {
  2129. let currentValue = parseFloat(input.textContent);
  2130. if (currentValue > 0) {
  2131. if (currentValue > 200) {
  2132. input.textContent = currentValue - 25;
  2133. } else if (currentValue > 20) {
  2134. input.textContent = currentValue - 5;
  2135. } else {
  2136. input.textContent = currentValue - 1;
  2137. }
  2138. updateButtonStates();
  2139. }
  2140. });
  2141.  
  2142. incrementButton.addEventListener('click', () => {
  2143. let currentValue = parseFloat(input.textContent);
  2144. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2145. return;
  2146. }
  2147. if (currentValue >= 200) {
  2148. input.textContent = currentValue + 25;
  2149. } else if (currentValue >= 20) {
  2150. input.textContent = currentValue + 5;
  2151. } else {
  2152. input.textContent = currentValue + 1;
  2153. }
  2154. updateButtonStates();
  2155. });
  2156.  
  2157. numberContainer.appendChild(decrementButton);
  2158. numberContainer.appendChild(input);
  2159. numberContainer.appendChild(incrementButton);
  2160.  
  2161. // Initialize button states
  2162. updateButtonStates();
  2163. }
  2164. });
  2165.  
  2166. rightContainer.appendChild(numberContainer);
  2167. } else if (typeof value === 'object') {
  2168. const maxAllowedIndex = hotkeyOptions.length - 1
  2169.  
  2170. let currentValue = value;
  2171. let choiceIndex = hotkeyOptions.indexOf(currentValue.join(' '));
  2172. if (choiceIndex === -1) {
  2173. currentValue = hotkeyOptions[0].split(' ');
  2174. choiceIndex = 0;
  2175. }
  2176. const textContainer = document.createElement('div');
  2177. textContainer.style.display = 'flex';
  2178. textContainer.style.alignItems = 'center';
  2179. textContainer.style.justifyContent = 'center';
  2180.  
  2181. const decrementButton = document.createElement('button');
  2182. decrementButton.textContent = '<';
  2183. decrementButton.style.marginRight = '5px';
  2184.  
  2185. const input = document.createElement('span');
  2186. input.textContent = currentValue.join(' and ');
  2187. input.style.margin = '0 10px';
  2188. input.style.minWidth = '3ch';
  2189. input.style.textAlign = 'center';
  2190. input.setAttribute('data-key', key);
  2191. input.setAttribute('data-type', 'object');
  2192. input.setAttribute('data-type-part', '');
  2193.  
  2194. const incrementButton = document.createElement('button');
  2195. incrementButton.textContent = '>';
  2196. incrementButton.style.marginLeft = '5px';
  2197.  
  2198. const updateButtonStates = () => {
  2199. if (choiceIndex <= 0) {
  2200. decrementButton.disabled = true;
  2201. decrementButton.style.color = 'grey';
  2202. } else {
  2203. decrementButton.disabled = false;
  2204. decrementButton.style.color = '';
  2205. }
  2206. if (choiceIndex >= maxAllowedIndex) {
  2207. incrementButton.disabled = true;
  2208. incrementButton.style.color = 'grey';
  2209. } else {
  2210. incrementButton.disabled = false;
  2211. incrementButton.style.color = '';
  2212. }
  2213. };
  2214.  
  2215. decrementButton.addEventListener('click', () => {
  2216. if (choiceIndex > 0) {
  2217. choiceIndex -= 1;
  2218. currentValue = hotkeyOptions[choiceIndex].split(' ');
  2219. input.textContent = currentValue.join(' and ');
  2220. updateButtonStates();
  2221. }
  2222. });
  2223.  
  2224. incrementButton.addEventListener('click', () => {
  2225. if (choiceIndex < maxAllowedIndex) {
  2226. choiceIndex += 1;
  2227. currentValue = hotkeyOptions[choiceIndex].split(' ');
  2228. input.textContent = currentValue.join(' and ');
  2229. updateButtonStates();
  2230. }
  2231. });
  2232.  
  2233. textContainer.appendChild(decrementButton);
  2234. textContainer.appendChild(input);
  2235. textContainer.appendChild(incrementButton);
  2236.  
  2237. // Initialize button states
  2238. updateButtonStates();
  2239.  
  2240. rightContainer.appendChild(textContainer);
  2241. }
  2242.  
  2243. optionContainer.appendChild(leftContainer);
  2244. optionContainer.appendChild(rightContainer);
  2245. menuContent.appendChild(optionContainer);
  2246. }
  2247.  
  2248. const menuButtons = createMenuButtons();
  2249. menuContent.appendChild(menuButtons);
  2250.  
  2251. overlay.appendChild(menuContent);
  2252.  
  2253. return overlay;
  2254. }
  2255.  
  2256. function loadConfig() {
  2257. for (const key in localStorage) {
  2258. if (!key.startsWith(scriptPrefix + configPrefix) || !localStorage.hasOwnProperty(key)) {continue};
  2259.  
  2260. const configKey = key.substring((scriptPrefix + configPrefix).length); // chop off script prefix and config prefix
  2261. if (!CONFIG.hasOwnProperty(configKey)) {continue};
  2262.  
  2263. const savedValue = localStorage.getItem(key);
  2264. if (savedValue === null) {continue};
  2265.  
  2266. const valueType = typeof CONFIG[configKey];
  2267. if (configKey === 'HOTKEYS') {
  2268. CONFIG[configKey] = savedValue.split(' ')
  2269. } else if (valueType === 'boolean') {
  2270. CONFIG[configKey] = savedValue === 'true';
  2271. if (configKey === 'DEFAULT_TO_EXACT_SEARCH') { state.exactSearch = CONFIG.DEFAULT_TO_EXACT_SEARCH }
  2272. // I wonder if this is the best way to do this...
  2273. // Probably not because we could just have a single variable to store both, but it would have to be in config and
  2274. // it would be a bit weird to have the program modifying config when the actual config settings aren't changing
  2275. } else if (valueType === 'number') {
  2276. CONFIG[configKey] = parseFloat(savedValue);
  2277. } else if (valueType === 'string') {
  2278. CONFIG[configKey] = savedValue;
  2279. }
  2280. }
  2281. }
  2282.  
  2283.  
  2284. //MAIN FUNCTIONS=====================================================================================================================
  2285. function onPageLoad() {
  2286. // Initialize state and determine vocabulary based on URL
  2287. state.embedAboveSubsectionMeanings = false;
  2288.  
  2289. const url = window.location.href;
  2290. const machineTranslationFrame = document.getElementById('machine-translation-frame');
  2291.  
  2292. // Proceed only if the machine translation frame is not present
  2293. if (!machineTranslationFrame) {
  2294.  
  2295. //display embed for first time with loading text
  2296. embedImageAndPlayAudio();
  2297. setPageWidth();
  2298.  
  2299. if (url.includes('/vocabulary/')) {
  2300. state.vocab = parseVocabFromVocabulary();
  2301. } else if (url.includes('/search?q=')) {
  2302. state.vocab = parseVocabFromSearch();
  2303. } else if (url.includes('c=')) {
  2304. state.vocab = parseVocabFromAnswer();
  2305. } else if (url.includes('/kanji/')) {
  2306. state.vocab = parseVocabFromKanji();
  2307. } else {
  2308. state.vocab = parseVocabFromReview();
  2309. }
  2310. } else {
  2311. console.log('Machine translation frame detected, skipping vocabulary parsing.');
  2312. }
  2313.  
  2314. // Retrieve stored data for the current vocabulary
  2315. const { index, exactState } = getStoredData(state.vocab);
  2316. state.currentExampleIndex = index;
  2317. state.exactSearch = exactState;
  2318.  
  2319. // Fetch data and embed image/audio if necessary
  2320. if (state.vocab && !state.apiDataFetched) {
  2321. getImmersionKitData(state.vocab, state.exactSearch)
  2322. .then(() => {
  2323. preloadImages();
  2324. embedImageAndPlayAudio();
  2325. })
  2326. .catch(console.error);
  2327. } else if (state.apiDataFetched) {
  2328. embedImageAndPlayAudio();
  2329. //preloadImages();
  2330. setVocabSize();
  2331. setPageWidth();
  2332. }
  2333. }
  2334.  
  2335. function setPageWidth() {
  2336. // Set the maximum width of the page
  2337. document.body.style.maxWidth = CONFIG.PAGE_WIDTH;
  2338. }
  2339.  
  2340. // Observe URL changes and reload the page content accordingly
  2341. const observer = new MutationObserver(() => {
  2342. if (window.location.href !== observer.lastUrl) {
  2343. observer.lastUrl = window.location.href;
  2344. onPageLoad();
  2345. }
  2346. });
  2347.  
  2348. // Function to apply styles
  2349. function setVocabSize() {
  2350. // Create a new style element
  2351. const style = document.createElement('style');
  2352. style.type = 'text/css';
  2353. style.innerHTML = `
  2354. .answer-box > .plain {
  2355. font-size: ${CONFIG.VOCAB_SIZE} !important; /* Use the configurable font size */
  2356. padding-bottom: 0.1rem !important; /* Retain padding */
  2357. }
  2358. `;
  2359.  
  2360. // Append the new style to the document head
  2361. document.head.appendChild(style);
  2362. }
  2363. observer.lastUrl = window.location.href;
  2364. observer.observe(document, { subtree: true, childList: true });
  2365.  
  2366. // Add event listeners for page load and URL changes
  2367. window.addEventListener('load', onPageLoad);
  2368. window.addEventListener('popstate', onPageLoad);
  2369. window.addEventListener('hashchange', onPageLoad);
  2370.  
  2371. // Initial configuration and preloading
  2372. loadConfig();
  2373. setPageWidth();
  2374. setVocabSize();
  2375. //preloadImages();
  2376.  
  2377. })();