JPDB Immersion Kit Examples

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

当前为 2025-05-20 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name JPDB Immersion Kit Examples
  3. // @version 1.23
  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. // @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. function createImageElement(wrapperDiv, imageUrl, vocab, exactSearch) {
  1177. // Create and return an image element with specified attributes
  1178. const searchVocab = exactSearch ? `「${vocab}」` : vocab;
  1179. const example = state.examples[state.currentExampleIndex] || {};
  1180. const title = example.title || null;
  1181.  
  1182. // Extract the file name from the URL
  1183. let file_name = imageUrl.substring(imageUrl.lastIndexOf('/') + 1);
  1184.  
  1185. // Remove prefixes "Anime_", "A_", or "Z" from the file name
  1186. file_name = file_name.replace(/^(Anime_|A_|Z)/, '');
  1187.  
  1188. const titleText = `${searchVocab} #${state.currentExampleIndex + 1} \n${title} \n${file_name}`;
  1189.  
  1190. return GM_addElement(wrapperDiv, 'img', {
  1191. src: imageUrl,
  1192. alt: 'Embedded Image',
  1193. title: titleText,
  1194. style: `max-width: ${CONFIG.IMAGE_WIDTH}; margin-top: 10px; cursor: pointer;`
  1195. });
  1196. }
  1197.  
  1198. function highlightVocab(sentence, vocab) {
  1199. // Highlight vocabulary in the sentence based on configuration
  1200. if (!CONFIG.COLORED_SENTENCE_TEXT) return sentence;
  1201.  
  1202. if (state.exactSearch) {
  1203. const regex = new RegExp(`(${vocab})`, 'g');
  1204. return sentence.replace(regex, '<span style="color: var(--outline-input-color);">$1</span>');
  1205. } else {
  1206. return vocab.split('').reduce((acc, char) => {
  1207. const regex = new RegExp(char, 'g');
  1208. return acc.replace(regex, `<span style="color: var(--outline-input-color);">${char}</span>`);
  1209. }, sentence);
  1210. }
  1211. }
  1212.  
  1213. function appendSentenceAndTranslation(wrapperDiv, sentence, translation) {
  1214. // Append sentence and translation to the wrapper div
  1215. const sentenceText = document.createElement('div');
  1216. sentenceText.innerHTML = highlightVocab(sentence, state.vocab);
  1217. sentenceText.style.marginTop = '10px';
  1218. sentenceText.style.fontSize = CONFIG.SENTENCE_FONT_SIZE;
  1219. sentenceText.style.color = 'lightgray';
  1220. sentenceText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1221. sentenceText.style.whiteSpace = 'pre-wrap';
  1222. wrapperDiv.appendChild(sentenceText);
  1223.  
  1224. if (CONFIG.ENABLE_EXAMPLE_TRANSLATION && translation) {
  1225. const translationText = document.createElement('div');
  1226. translationText.innerHTML = replaceSpecialCharacters(translation);
  1227. translationText.style.marginTop = '5px';
  1228. translationText.style.fontSize = CONFIG.TRANSLATION_FONT_SIZE;
  1229. translationText.style.color = 'var(--subsection-label-color)';
  1230. translationText.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1231. translationText.style.whiteSpace = 'pre-wrap';
  1232. wrapperDiv.appendChild(translationText);
  1233. }
  1234. }
  1235.  
  1236. function appendNoneText(wrapperDiv) {
  1237. // Append a "None" text to the wrapper div
  1238. const noneText = document.createElement('div');
  1239. noneText.textContent = 'None';
  1240. noneText.style.marginTop = '10px';
  1241. noneText.style.fontSize = '85%';
  1242. noneText.style.color = 'var(--subsection-label-color)';
  1243. wrapperDiv.appendChild(noneText);
  1244. }
  1245.  
  1246. function createNavigationDiv() {
  1247. // Create and style the navigation div
  1248. const navigationDiv = document.createElement('div');
  1249. navigationDiv.id = 'immersion-kit-embed';
  1250. navigationDiv.style.display = 'flex';
  1251. navigationDiv.style.justifyContent = 'center';
  1252. navigationDiv.style.alignItems = 'center';
  1253. navigationDiv.style.maxWidth = CONFIG.IMAGE_WIDTH;
  1254. navigationDiv.style.margin = '0 auto';
  1255. return navigationDiv;
  1256. }
  1257.  
  1258. function createLeftArrow(vocab, shouldAutoPlaySound) {
  1259. // Create and configure the left arrow button
  1260. const leftArrow = document.createElement('button');
  1261. leftArrow.textContent = '<';
  1262. leftArrow.style.marginRight = '10px';
  1263. leftArrow.style.width = CONFIG.ARROW_WIDTH;
  1264. leftArrow.style.height = CONFIG.ARROW_HEIGHT;
  1265. leftArrow.style.lineHeight = '25px';
  1266. leftArrow.style.textAlign = 'center';
  1267. leftArrow.style.display = 'flex';
  1268. leftArrow.style.justifyContent = 'center';
  1269. leftArrow.style.alignItems = 'center';
  1270. leftArrow.style.padding = '0'; // Remove padding
  1271. leftArrow.disabled = state.currentExampleIndex === 0;
  1272. leftArrow.addEventListener('click', () => {
  1273. if (state.currentExampleIndex > 0) {
  1274. state.currentExampleIndex--;
  1275. state.currentlyPlayingAudio = false;
  1276. stopCurrentAudio();
  1277. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  1278. preloadImages();
  1279. }
  1280. });
  1281. return leftArrow;
  1282. }
  1283.  
  1284. function createRightArrow(vocab, shouldAutoPlaySound) {
  1285. // Create and configure the right arrow button
  1286. const rightArrow = document.createElement('button');
  1287. rightArrow.textContent = '>';
  1288. rightArrow.style.marginLeft = '10px';
  1289. rightArrow.style.width = CONFIG.ARROW_WIDTH;
  1290. rightArrow.style.height = CONFIG.ARROW_HEIGHT;
  1291. rightArrow.style.lineHeight = '25px';
  1292. rightArrow.style.textAlign = 'center';
  1293. rightArrow.style.display = 'flex';
  1294. rightArrow.style.justifyContent = 'center';
  1295. rightArrow.style.alignItems = 'center';
  1296. rightArrow.style.padding = '0'; // Remove padding
  1297. rightArrow.disabled = state.currentExampleIndex >= state.examples.length - 1;
  1298. rightArrow.addEventListener('click', () => {
  1299. if (state.currentExampleIndex < state.examples.length - 1) {
  1300. state.currentExampleIndex++;
  1301. state.currentlyPlayingAudio = false;
  1302. stopCurrentAudio();
  1303. renderImageAndPlayAudio(vocab, shouldAutoPlaySound);
  1304. preloadImages();
  1305. }
  1306. });
  1307. return rightArrow;
  1308. }
  1309.  
  1310. function createContainerDiv(leftArrow, wrapperDiv, rightArrow, navigationDiv) {
  1311. // Create and configure the main container div
  1312. const containerDiv = document.createElement('div');
  1313. containerDiv.id = 'immersion-kit-container';
  1314. containerDiv.style.display = 'flex';
  1315. containerDiv.style.alignItems = 'center';
  1316. containerDiv.style.justifyContent = 'center';
  1317. containerDiv.style.flexDirection = 'column';
  1318.  
  1319. const arrowWrapperDiv = document.createElement('div');
  1320. arrowWrapperDiv.style.display = 'flex';
  1321. arrowWrapperDiv.style.alignItems = 'center';
  1322. arrowWrapperDiv.style.justifyContent = 'center';
  1323.  
  1324. arrowWrapperDiv.append(leftArrow, wrapperDiv, rightArrow);
  1325. containerDiv.append(arrowWrapperDiv, navigationDiv);
  1326.  
  1327. return containerDiv;
  1328. }
  1329.  
  1330. function appendContainer(containerDiv) {
  1331. // Append the container div to the appropriate section based on configuration
  1332. const resultVocabularySection = document.querySelector('.result.vocabulary');
  1333. const hboxWrapSection = document.querySelector('.hbox.wrap');
  1334. const subsectionMeanings = document.querySelector('.subsection-meanings');
  1335. const subsectionComposedOfKanji = document.querySelector('.subsection-composed-of-kanji');
  1336. const subsectionPitchAccent = document.querySelector('.subsection-pitch-accent');
  1337. const subsectionLabels = document.querySelectorAll('h6.subsection-label');
  1338. const vboxGap = document.querySelector('.vbox.gap');
  1339. const styleSheet = document.querySelector('link[rel="stylesheet"]').sheet;
  1340.  
  1341. if (CONFIG.WIDE_MODE && subsectionMeanings) {
  1342. const wrapper = document.createElement('div');
  1343. wrapper.style.display = 'flex';
  1344. wrapper.style.alignItems = 'flex-start';
  1345. styleSheet.insertRule('.subsection-meanings { max-width: none !important; }', styleSheet.cssRules.length);
  1346.  
  1347. const originalContentWrapper = document.createElement('div');
  1348. originalContentWrapper.style.flex = '1';
  1349. originalContentWrapper.appendChild(subsectionMeanings);
  1350.  
  1351. if (subsectionComposedOfKanji) {
  1352. const newline1 = document.createElement('br');
  1353. originalContentWrapper.appendChild(newline1);
  1354. originalContentWrapper.appendChild(subsectionComposedOfKanji);
  1355. }
  1356. if (subsectionPitchAccent) {
  1357. const newline2 = document.createElement('br');
  1358. originalContentWrapper.appendChild(newline2);
  1359. originalContentWrapper.appendChild(subsectionPitchAccent);
  1360. }
  1361.  
  1362. if (CONFIG.DEFINITIONS_ON_RIGHT_IN_WIDE_MODE) {
  1363. wrapper.appendChild(containerDiv);
  1364. wrapper.appendChild(originalContentWrapper);
  1365. } else {
  1366. wrapper.appendChild(originalContentWrapper);
  1367. wrapper.appendChild(containerDiv);
  1368. }
  1369.  
  1370. if (vboxGap) {
  1371. const existingDynamicDiv = vboxGap.querySelector('#dynamic-content');
  1372. if (existingDynamicDiv) {
  1373. existingDynamicDiv.remove();
  1374. }
  1375.  
  1376. const dynamicDiv = document.createElement('div');
  1377. dynamicDiv.id = 'dynamic-content';
  1378. dynamicDiv.appendChild(wrapper);
  1379.  
  1380. if (window.location.href.includes('vocabulary')) {
  1381. vboxGap.insertBefore(dynamicDiv, vboxGap.children[1]);
  1382. } else {
  1383. vboxGap.insertBefore(dynamicDiv, vboxGap.firstChild);
  1384. }
  1385. }
  1386. } else {
  1387. if (state.embedAboveSubsectionMeanings && subsectionMeanings) {
  1388. subsectionMeanings.parentNode.insertBefore(containerDiv, subsectionMeanings);
  1389. } else if (resultVocabularySection) {
  1390. resultVocabularySection.parentNode.insertBefore(containerDiv, resultVocabularySection);
  1391. } else if (hboxWrapSection) {
  1392. hboxWrapSection.parentNode.insertBefore(containerDiv, hboxWrapSection);
  1393. } else if (subsectionLabels.length >= 4) {
  1394. subsectionLabels[3].parentNode.insertBefore(containerDiv, subsectionLabels[3]);
  1395. }
  1396. }
  1397. }
  1398.  
  1399. function embedImageAndPlayAudio() {
  1400. // Embed the image and play audio, removing existing navigation div if present
  1401. const existingNavigationDiv = document.getElementById('immersion-kit-embed');
  1402. if (existingNavigationDiv) existingNavigationDiv.remove();
  1403.  
  1404. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1405. preloadImages();
  1406. }
  1407.  
  1408. function replaceSpecialCharacters(text) {
  1409. // Replace special characters in the text
  1410. return text.replace(/<br>/g, '\n').replace(/&quot;/g, '"').replace(/\n/g, '<br>');
  1411. }
  1412.  
  1413. function preloadImages() {
  1414. // Preload images around the current example index
  1415. const preloadDiv = GM_addElement(document.body, 'div', { style: 'display: none;' });
  1416. const startIndex = Math.max(0, state.currentExampleIndex - CONFIG.NUMBER_OF_PRELOADS);
  1417. const endIndex = Math.min(state.examples.length - 1, state.currentExampleIndex + CONFIG.NUMBER_OF_PRELOADS);
  1418.  
  1419. for (let i = startIndex; i <= endIndex; i++) {
  1420. if (!state.preloadedIndices.has(i) && state.examples[i].image) {
  1421. const example = state.examples[i];
  1422. const imageUrl = `https://us-southeast-1.linodeobjects.com/immersionkit/media/${example.media}/${example.title}/media/${example.image}`;
  1423. GM_addElement(preloadDiv, 'img', { src: imageUrl });
  1424. state.preloadedIndices.add(i);
  1425. }
  1426. }
  1427. }
  1428.  
  1429.  
  1430. //MENU FUNCTIONS=====================================================================================================================
  1431. ////FILE OPERATIONS=====================================================================================================================
  1432. function handleImportButtonClick() {
  1433. handleFileInput('application/json', importFavorites);
  1434. }
  1435.  
  1436. function handleImportDButtonClick() {
  1437. handleFileInput('application/json', importData);
  1438. }
  1439.  
  1440. function handleFileInput(acceptType, callback) {
  1441. const fileInput = document.createElement('input');
  1442. fileInput.type = 'file';
  1443. fileInput.accept = acceptType;
  1444. fileInput.addEventListener('change', callback);
  1445. fileInput.click();
  1446. }
  1447.  
  1448. function createBlobAndDownload(data, filename, type) {
  1449. const blob = new Blob([data], { type });
  1450. const url = URL.createObjectURL(blob);
  1451. const a = document.createElement('a');
  1452. a.href = url;
  1453. a.download = filename;
  1454. document.body.appendChild(a);
  1455. a.click();
  1456. document.body.removeChild(a);
  1457. URL.revokeObjectURL(url);
  1458. }
  1459.  
  1460. function addBlacklist() {
  1461. setItem(state.vocab, `0,2`);
  1462. location.reload();
  1463. }
  1464.  
  1465. function remBlacklist() {
  1466. removeItem(state.vocab);
  1467. location.reload();
  1468. }
  1469.  
  1470. function exportFavorites() {
  1471. const favorites = {};
  1472. for (let i = 0; i < localStorage.length; i++) {
  1473. const key = localStorage.key(i);
  1474. if (key.startsWith(scriptPrefix)) {
  1475. const keyPrefixless = key.substring(scriptPrefix.length); // chop off the script prefix
  1476. if (!keyPrefixless.startsWith(configPrefix)) {
  1477. favorites[keyPrefixless] = localStorage.getItem(key);
  1478. // For backwards compatibility keep the exported keys prefixless
  1479. }
  1480. }
  1481. }
  1482. const data = JSON.stringify(favorites, null, 2);
  1483. createBlobAndDownload(data, 'favorites.json', 'application/json');
  1484. }
  1485.  
  1486. function importFavorites(event) {
  1487. const file = event.target.files[0];
  1488. if (!file) return;
  1489.  
  1490. const reader = new FileReader();
  1491. reader.onload = function(e) {
  1492. try {
  1493. const favorites = JSON.parse(e.target.result);
  1494. for (const key in favorites) {
  1495. setItem(key, favorites[key]);
  1496. }
  1497. alert('Favorites imported successfully!');
  1498. location.reload();
  1499. } catch (error) {
  1500. alert('Error importing favorites:', error);
  1501. }
  1502. };
  1503. reader.readAsText(file);
  1504. }
  1505.  
  1506. async function exportData() {
  1507. const dataEntries = {};
  1508.  
  1509. try {
  1510. const db = await IndexedDBManager.open();
  1511. const indexedDBData = await IndexedDBManager.getAll(db);
  1512. indexedDBData.forEach(item => {
  1513. dataEntries[item.keyword] = item.data;
  1514. });
  1515.  
  1516. const data = JSON.stringify(dataEntries, null, 2);
  1517. createBlobAndDownload(data, 'data.json', 'application/json');
  1518. } catch (error) {
  1519. console.error('Error exporting data from IndexedDB:', error);
  1520. }
  1521. }
  1522.  
  1523. async function importData(event) {
  1524. const file = event.target.files[0];
  1525. if (!file) return;
  1526.  
  1527. const reader = new FileReader();
  1528. reader.onload = async function(e) {
  1529. try {
  1530. const dataEntries = JSON.parse(e.target.result);
  1531.  
  1532. const db = await IndexedDBManager.open();
  1533. for (const key in dataEntries) {
  1534. await IndexedDBManager.save(db, key, dataEntries[key]);
  1535. }
  1536.  
  1537. alert('Data imported successfully!');
  1538. location.reload();
  1539. } catch (error) {
  1540. alert('Error importing data:', error);
  1541. }
  1542. };
  1543. reader.readAsText(file);
  1544. }
  1545.  
  1546.  
  1547. ////CONFIRMATION
  1548. function createConfirmationPopup(messageText, onYes, onNo) {
  1549. // Create a confirmation popup with Yes and No buttons
  1550. const popupOverlay = document.createElement('div');
  1551. popupOverlay.style.position = 'fixed';
  1552. popupOverlay.style.top = '0';
  1553. popupOverlay.style.left = '0';
  1554. popupOverlay.style.width = '100%';
  1555. popupOverlay.style.height = '100%';
  1556. popupOverlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1557. popupOverlay.style.zIndex = '1001';
  1558. popupOverlay.style.display = 'flex';
  1559. popupOverlay.style.justifyContent = 'center';
  1560. popupOverlay.style.alignItems = 'center';
  1561.  
  1562. const popupContent = document.createElement('div');
  1563. popupContent.style.backgroundColor = 'var(--background-color)';
  1564. popupContent.style.padding = '20px';
  1565. popupContent.style.borderRadius = '5px';
  1566. popupContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1567. popupContent.style.textAlign = 'center';
  1568.  
  1569. const message = document.createElement('p');
  1570. message.textContent = messageText;
  1571.  
  1572. const yesButton = document.createElement('button');
  1573. yesButton.textContent = 'Yes';
  1574. yesButton.style.backgroundColor = '#C82800';
  1575. yesButton.style.marginRight = '10px';
  1576. yesButton.addEventListener('click', () => {
  1577. onYes();
  1578. document.body.removeChild(popupOverlay);
  1579. });
  1580.  
  1581. const noButton = document.createElement('button');
  1582. noButton.textContent = 'No';
  1583. noButton.addEventListener('click', () => {
  1584. onNo();
  1585. document.body.removeChild(popupOverlay);
  1586. });
  1587.  
  1588. popupContent.appendChild(message);
  1589. popupContent.appendChild(yesButton);
  1590. popupContent.appendChild(noButton);
  1591. popupOverlay.appendChild(popupContent);
  1592.  
  1593. document.body.appendChild(popupOverlay);
  1594. }
  1595.  
  1596. ////BUTTONS
  1597. function createActionButtonsContainer() {
  1598. const actionButtonWidth = '100px';
  1599.  
  1600. const closeButton = createButton('Close', '10px', closeOverlayMenu, actionButtonWidth);
  1601. const saveButton = createButton('Save', '10px', saveConfig, actionButtonWidth);
  1602. const defaultButton = createDefaultButton(actionButtonWidth);
  1603. const deleteButton = createDeleteButton(actionButtonWidth);
  1604. const deleteCurrentVocabButton = createDeleteCurrentVocabButton('400px');
  1605.  
  1606. const actionButtonsContainer = document.createElement('div');
  1607. actionButtonsContainer.style.textAlign = 'center';
  1608. actionButtonsContainer.style.marginTop = '10px';
  1609. actionButtonsContainer.append(closeButton, saveButton, defaultButton, deleteButton, deleteCurrentVocabButton);
  1610.  
  1611. return actionButtonsContainer;
  1612. }
  1613.  
  1614. function createMenuButtons() {
  1615. const blacklistContainer = createBlacklistContainer();
  1616. const favoritesContainer = createFavoritesContainer();
  1617. const dataContainer = createDataContainer();
  1618. const actionButtonsContainer = createActionButtonsContainer();
  1619.  
  1620. const buttonContainer = document.createElement('div');
  1621. buttonContainer.append(blacklistContainer,favoritesContainer,dataContainer,actionButtonsContainer);
  1622.  
  1623. return buttonContainer;
  1624. }
  1625.  
  1626. function createButton(text, margin, onClick, width) {
  1627. // Create a button element with specified properties
  1628. const button = document.createElement('button');
  1629. button.textContent = text;
  1630. button.style.margin = margin;
  1631. button.style.width = width;
  1632. button.style.textAlign = 'center';
  1633. button.style.display = 'inline-block';
  1634. button.style.lineHeight = '30px';
  1635. button.style.padding = '5px 0';
  1636. button.addEventListener('click', onClick);
  1637. return button;
  1638. }
  1639.  
  1640. ////BLACKLIST BUTTONS
  1641. function createBlacklistContainer() {
  1642. const blacklistButtonWidth = '200px';
  1643.  
  1644. const addBlacklistButton = createButton('Add to Blacklist', '10px', addBlacklist, blacklistButtonWidth);
  1645. const remBlacklistButton = createButton('Remove from Blacklist', '10px', remBlacklist, blacklistButtonWidth);
  1646.  
  1647. const blacklistContainer = document.createElement('div');
  1648. blacklistContainer.style.textAlign = 'center';
  1649. blacklistContainer.style.marginTop = '10px';
  1650. blacklistContainer.append(addBlacklistButton, remBlacklistButton);
  1651.  
  1652. return blacklistContainer;
  1653. }
  1654. ////FAVORITE BUTTONS
  1655. function createFavoritesContainer() {
  1656. const favoritesButtonWidth = '200px';
  1657.  
  1658. const exportButton = createButton('Export Favorites', '10px', exportFavorites, favoritesButtonWidth);
  1659. const importButton = createButton('Import Favorites', '10px', handleImportButtonClick, favoritesButtonWidth);
  1660.  
  1661. const favoritesContainer = document.createElement('div');
  1662. favoritesContainer.style.textAlign = 'center';
  1663. favoritesContainer.style.marginTop = '10px';
  1664. favoritesContainer.append(exportButton, importButton);
  1665.  
  1666. return favoritesContainer;
  1667.  
  1668. }
  1669. ////DATA BUTTONS
  1670. function createDataContainer() {
  1671. const dataButtonWidth = '200px';
  1672.  
  1673. const exportButton = createButton('Export Data', '10px', exportData, dataButtonWidth);
  1674. const importButton = createButton('Import Data', '10px', handleImportDButtonClick, dataButtonWidth);
  1675.  
  1676. const dataContainer = document.createElement('div');
  1677. dataContainer.style.textAlign = 'center';
  1678. dataContainer.style.marginTop = '10px';
  1679. dataContainer.append(exportButton, importButton);
  1680.  
  1681. return dataContainer;
  1682. }
  1683.  
  1684. ////CLOSE BUTTON
  1685. function closeOverlayMenu() {
  1686. loadConfig();
  1687. document.body.removeChild(document.getElementById('overlayMenu'));
  1688. }
  1689.  
  1690. ////SAVE BUTTON
  1691. function saveConfig() {
  1692. const overlay = document.getElementById('overlayMenu');
  1693. if (!overlay) return;
  1694.  
  1695. const inputs = overlay.querySelectorAll('input, span');
  1696. const changes = gatherChanges(inputs);
  1697.  
  1698. applyChanges(changes);
  1699. finalizeSaveConfig();
  1700. setVocabSize();
  1701. setPageWidth();
  1702. }
  1703.  
  1704. function gatherChanges(inputs) {
  1705. const changes = {};
  1706.  
  1707. inputs.forEach(input => {
  1708. const key = input.getAttribute('data-key');
  1709. const type = input.getAttribute('data-type');
  1710. let value;
  1711.  
  1712. if (type === 'boolean') {
  1713. value = input.checked;
  1714. } else if (type === 'number') {
  1715. value = parseFloat(input.textContent);
  1716. } else if (type === 'string') {
  1717. value = input.textContent;
  1718. } else if (type === 'object' && key === 'HOTKEYS') {
  1719. value = input.textContent.replace(' and ', ' ');
  1720. }
  1721.  
  1722. if (key && type) {
  1723. const typePart = input.getAttribute('data-type-part');
  1724. const originalFormattedType = typePart.slice(1, -1);
  1725.  
  1726. changes[configPrefix + key] = value + originalFormattedType;
  1727. }
  1728. });
  1729.  
  1730. return changes;
  1731. }
  1732.  
  1733. function applyChanges(changes) {
  1734. for (const key in changes) {
  1735. setItem(key, changes[key]);
  1736. }
  1737. }
  1738.  
  1739. function finalizeSaveConfig() {
  1740. loadConfig();
  1741. window.removeEventListener('keydown', hotkeysListener);
  1742. renderImageAndPlayAudio(state.vocab, CONFIG.AUTO_PLAY_SOUND);
  1743. const overlay = document.getElementById('overlayMenu');
  1744. if (overlay) {
  1745. document.body.removeChild(overlay);
  1746. }
  1747. }
  1748.  
  1749.  
  1750.  
  1751. ////DEFAULT BUTTON
  1752. function createDefaultButton(width) {
  1753. const defaultButton = createButton('Default', '10px', () => {
  1754. createConfirmationPopup(
  1755. 'This will reset all your settings to default. Are you sure?',
  1756. () => {
  1757. Object.keys(localStorage).forEach(key => {
  1758. if (key.startsWith(scriptPrefix + configPrefix)) {
  1759. localStorage.removeItem(key);
  1760. }
  1761. });
  1762. location.reload();
  1763. },
  1764. () => {
  1765. const overlay = document.getElementById('overlayMenu');
  1766. if (overlay) {
  1767. document.body.removeChild(overlay);
  1768. }
  1769. loadConfig();
  1770. document.body.appendChild(createOverlayMenu());
  1771. }
  1772. );
  1773. }, width);
  1774. defaultButton.style.backgroundColor = '#C82800';
  1775. defaultButton.style.color = 'white';
  1776. return defaultButton;
  1777. }
  1778.  
  1779.  
  1780. ////DELETE BUTTON
  1781. async function deleteCurrentVocab() {
  1782. try {
  1783. const db = await IndexedDBManager.open();
  1784. let currentVocab = state.vocab;
  1785.  
  1786. // Wrap currentVocab with angle quotes if exactSearch is true
  1787. if (state.exactSearch) {
  1788. currentVocab = `「${currentVocab}」`;
  1789. }
  1790.  
  1791. // Delete from IndexedDB
  1792. await IndexedDBManager.deleteEntry(db, currentVocab);
  1793. console.log('Deleting from IndexedDB:', currentVocab);
  1794.  
  1795. // Delete from local storage
  1796. const localStorageKey = scriptPrefix + state.vocab;
  1797. if (localStorage.getItem(localStorageKey)) {
  1798. localStorage.removeItem(localStorageKey);
  1799. console.log('Deleting from local storage:', localStorageKey);
  1800. }
  1801.  
  1802. alert('Current vocabulary deleted successfully!');
  1803. location.reload();
  1804. } catch (error) {
  1805. console.error('Error deleting current vocabulary:', error);
  1806. alert('Error deleting current vocabulary.');
  1807. }
  1808. }
  1809.  
  1810. function createDeleteCurrentVocabButton(width) {
  1811. const deleteCurrentVocabButton = createButton('Refresh Current Vocab from API', '10px', deleteCurrentVocab, width);
  1812. deleteCurrentVocabButton.style.backgroundColor = '#C82800';
  1813. deleteCurrentVocabButton.style.color = 'white';
  1814. return deleteCurrentVocabButton;
  1815. }
  1816.  
  1817. function createDeleteButton(width) {
  1818. const deleteButton = createButton('DELETE', '10px', () => {
  1819. createConfirmationPopup(
  1820. 'This will delete all your favorites and cached data. Are you sure?',
  1821. async () => {
  1822. await IndexedDBManager.delete();
  1823. Object.keys(localStorage).forEach(key => {
  1824. if (key.startsWith(scriptPrefix) && !key.startsWith(scriptPrefix + configPrefix)) {
  1825. localStorage.removeItem(key);
  1826. }
  1827. });
  1828. location.reload();
  1829. },
  1830. () => {
  1831. const overlay = document.getElementById('overlayMenu');
  1832. if (overlay) {
  1833. document.body.removeChild(overlay);
  1834. }
  1835. loadConfig();
  1836. document.body.appendChild(createOverlayMenu());
  1837. }
  1838. );
  1839. }, width);
  1840. deleteButton.style.backgroundColor = '#C82800';
  1841. deleteButton.style.color = 'white';
  1842. return deleteButton;
  1843. }
  1844.  
  1845. function createOverlayMenu() {
  1846. // Create and return the overlay menu for configuration settings
  1847. const overlay = document.createElement('div');
  1848. overlay.id = 'overlayMenu';
  1849. overlay.style.position = 'fixed';
  1850. overlay.style.top = '0';
  1851. overlay.style.left = '0';
  1852. overlay.style.width = '100%';
  1853. overlay.style.height = '100%';
  1854. overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  1855. overlay.style.zIndex = '1000';
  1856. overlay.style.display = 'flex';
  1857. overlay.style.justifyContent = 'center';
  1858. overlay.style.alignItems = 'center';
  1859.  
  1860. const menuContent = document.createElement('div');
  1861. menuContent.style.backgroundColor = 'var(--background-color)';
  1862. menuContent.style.color = 'var(--text-color)';
  1863. menuContent.style.padding = '20px';
  1864. menuContent.style.borderRadius = '5px';
  1865. menuContent.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  1866. menuContent.style.width = '80%';
  1867. menuContent.style.maxWidth = '550px';
  1868. menuContent.style.maxHeight = '80%';
  1869. menuContent.style.overflowY = 'auto';
  1870.  
  1871. for (const [key, value] of Object.entries(CONFIG)) {
  1872. const optionContainer = document.createElement('div');
  1873. optionContainer.style.marginBottom = '10px';
  1874. optionContainer.style.display = 'flex';
  1875. optionContainer.style.alignItems = 'center';
  1876.  
  1877. const leftContainer = document.createElement('div');
  1878. leftContainer.style.flex = '1';
  1879. leftContainer.style.display = 'flex';
  1880. leftContainer.style.alignItems = 'center';
  1881.  
  1882. const rightContainer = document.createElement('div');
  1883. rightContainer.style.flex = '1';
  1884. rightContainer.style.display = 'flex';
  1885. rightContainer.style.alignItems = 'center';
  1886. rightContainer.style.justifyContent = 'center';
  1887.  
  1888. const label = document.createElement('label');
  1889. label.textContent = key.replace(/_/g, ' ').split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
  1890. label.style.marginRight = '10px';
  1891.  
  1892. leftContainer.appendChild(label);
  1893.  
  1894. if (typeof value === 'boolean') {
  1895. const checkboxContainer = document.createElement('div');
  1896. checkboxContainer.style.display = 'flex';
  1897. checkboxContainer.style.alignItems = 'center';
  1898. checkboxContainer.style.justifyContent = 'center';
  1899.  
  1900. const checkbox = document.createElement('input');
  1901. checkbox.type = 'checkbox';
  1902. checkbox.checked = value;
  1903. checkbox.setAttribute('data-key', key);
  1904. checkbox.setAttribute('data-type', 'boolean');
  1905. checkbox.setAttribute('data-type-part', '');
  1906. checkboxContainer.appendChild(checkbox);
  1907.  
  1908. rightContainer.appendChild(checkboxContainer);
  1909. } else if (typeof value === 'number') {
  1910. const numberContainer = document.createElement('div');
  1911. numberContainer.style.display = 'flex';
  1912. numberContainer.style.alignItems = 'center';
  1913. numberContainer.style.justifyContent = 'center';
  1914.  
  1915. const decrementButton = document.createElement('button');
  1916. decrementButton.textContent = '-';
  1917. decrementButton.style.marginRight = '5px';
  1918.  
  1919. const input = document.createElement('span');
  1920. input.textContent = value;
  1921. input.style.margin = '0 10px';
  1922. input.style.minWidth = '3ch';
  1923. input.style.textAlign = 'center';
  1924. input.setAttribute('data-key', key);
  1925. input.setAttribute('data-type', 'number');
  1926. input.setAttribute('data-type-part', '');
  1927.  
  1928. const incrementButton = document.createElement('button');
  1929. incrementButton.textContent = '+';
  1930. incrementButton.style.marginLeft = '5px';
  1931.  
  1932. const updateButtonStates = () => {
  1933. let currentValue = parseFloat(input.textContent);
  1934. if (currentValue <= 0) {
  1935. decrementButton.disabled = true;
  1936. decrementButton.style.color = 'grey';
  1937. } else {
  1938. decrementButton.disabled = false;
  1939. decrementButton.style.color = '';
  1940. }
  1941. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1942. incrementButton.disabled = true;
  1943. incrementButton.style.color = 'grey';
  1944. } else {
  1945. incrementButton.disabled = false;
  1946. incrementButton.style.color = '';
  1947. }
  1948. };
  1949.  
  1950. decrementButton.addEventListener('click', () => {
  1951. let currentValue = parseFloat(input.textContent);
  1952. if (currentValue > 0) {
  1953. if (currentValue > 200) {
  1954. input.textContent = currentValue - 25;
  1955. } else if (currentValue > 20) {
  1956. input.textContent = currentValue - 5;
  1957. } else {
  1958. input.textContent = currentValue - 1;
  1959. }
  1960. updateButtonStates();
  1961. }
  1962. });
  1963.  
  1964. incrementButton.addEventListener('click', () => {
  1965. let currentValue = parseFloat(input.textContent);
  1966. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  1967. return;
  1968. }
  1969. if (currentValue >= 200) {
  1970. input.textContent = currentValue + 25;
  1971. } else if (currentValue >= 20) {
  1972. input.textContent = currentValue + 5;
  1973. } else {
  1974. input.textContent = currentValue + 1;
  1975. }
  1976. updateButtonStates();
  1977. });
  1978.  
  1979. numberContainer.appendChild(decrementButton);
  1980. numberContainer.appendChild(input);
  1981. numberContainer.appendChild(incrementButton);
  1982.  
  1983. rightContainer.appendChild(numberContainer);
  1984.  
  1985. // Initialize button states
  1986. updateButtonStates();
  1987. } else if (typeof value === 'string') {
  1988. const typeParts = value.split(/(\d+)/).filter(Boolean);
  1989. const numberParts = typeParts.filter(part => !isNaN(part)).map(Number);
  1990.  
  1991. const numberContainer = document.createElement('div');
  1992. numberContainer.style.display = 'flex';
  1993. numberContainer.style.alignItems = 'center';
  1994. numberContainer.style.justifyContent = 'center';
  1995.  
  1996. const typeSpan = document.createElement('span');
  1997. const formattedType = '(' + typeParts.filter(part => isNaN(part)).join('').replace(/_/g, ' ').toLowerCase() + ')';
  1998. typeSpan.textContent = formattedType;
  1999. typeSpan.style.marginRight = '10px';
  2000.  
  2001. leftContainer.appendChild(typeSpan);
  2002.  
  2003. typeParts.forEach(part => {
  2004. if (!isNaN(part)) {
  2005. const decrementButton = document.createElement('button');
  2006. decrementButton.textContent = '-';
  2007. decrementButton.style.marginRight = '5px';
  2008.  
  2009. const input = document.createElement('span');
  2010. input.textContent = part;
  2011. input.style.margin = '0 10px';
  2012. input.style.minWidth = '3ch';
  2013. input.style.textAlign = 'center';
  2014. input.setAttribute('data-key', key);
  2015. input.setAttribute('data-type', 'string');
  2016. input.setAttribute('data-type-part', formattedType);
  2017.  
  2018. const incrementButton = document.createElement('button');
  2019. incrementButton.textContent = '+';
  2020. incrementButton.style.marginLeft = '5px';
  2021.  
  2022. const updateButtonStates = () => {
  2023. let currentValue = parseFloat(input.textContent);
  2024. if (currentValue <= 0) {
  2025. decrementButton.disabled = true;
  2026. decrementButton.style.color = 'grey';
  2027. } else {
  2028. decrementButton.disabled = false;
  2029. decrementButton.style.color = '';
  2030. }
  2031. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2032. incrementButton.disabled = true;
  2033. incrementButton.style.color = 'grey';
  2034. } else {
  2035. incrementButton.disabled = false;
  2036. incrementButton.style.color = '';
  2037. }
  2038. };
  2039.  
  2040. decrementButton.addEventListener('click', () => {
  2041. let currentValue = parseFloat(input.textContent);
  2042. if (currentValue > 0) {
  2043. if (currentValue > 200) {
  2044. input.textContent = currentValue - 25;
  2045. } else if (currentValue > 20) {
  2046. input.textContent = currentValue - 5;
  2047. } else {
  2048. input.textContent = currentValue - 1;
  2049. }
  2050. updateButtonStates();
  2051. }
  2052. });
  2053.  
  2054. incrementButton.addEventListener('click', () => {
  2055. let currentValue = parseFloat(input.textContent);
  2056. if (key === 'SOUND_VOLUME' && currentValue >= 100) {
  2057. return;
  2058. }
  2059. if (currentValue >= 200) {
  2060. input.textContent = currentValue + 25;
  2061. } else if (currentValue >= 20) {
  2062. input.textContent = currentValue + 5;
  2063. } else {
  2064. input.textContent = currentValue + 1;
  2065. }
  2066. updateButtonStates();
  2067. });
  2068.  
  2069. numberContainer.appendChild(decrementButton);
  2070. numberContainer.appendChild(input);
  2071. numberContainer.appendChild(incrementButton);
  2072.  
  2073. // Initialize button states
  2074. updateButtonStates();
  2075. }
  2076. });
  2077.  
  2078. rightContainer.appendChild(numberContainer);
  2079. } else if (typeof value === 'object') {
  2080. const maxAllowedIndex = hotkeyOptions.length - 1
  2081.  
  2082. let currentValue = value;
  2083. let choiceIndex = hotkeyOptions.indexOf(currentValue.join(' '));
  2084. if (choiceIndex === -1) {
  2085. currentValue = hotkeyOptions[0].split(' ');
  2086. choiceIndex = 0;
  2087. }
  2088. const textContainer = document.createElement('div');
  2089. textContainer.style.display = 'flex';
  2090. textContainer.style.alignItems = 'center';
  2091. textContainer.style.justifyContent = 'center';
  2092.  
  2093. const decrementButton = document.createElement('button');
  2094. decrementButton.textContent = '<';
  2095. decrementButton.style.marginRight = '5px';
  2096.  
  2097. const input = document.createElement('span');
  2098. input.textContent = currentValue.join(' and ');
  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', 'object');
  2104. input.setAttribute('data-type-part', '');
  2105.  
  2106. const incrementButton = document.createElement('button');
  2107. incrementButton.textContent = '>';
  2108. incrementButton.style.marginLeft = '5px';
  2109.  
  2110. const updateButtonStates = () => {
  2111. if (choiceIndex <= 0) {
  2112. decrementButton.disabled = true;
  2113. decrementButton.style.color = 'grey';
  2114. } else {
  2115. decrementButton.disabled = false;
  2116. decrementButton.style.color = '';
  2117. }
  2118. if (choiceIndex >= maxAllowedIndex) {
  2119. incrementButton.disabled = true;
  2120. incrementButton.style.color = 'grey';
  2121. } else {
  2122. incrementButton.disabled = false;
  2123. incrementButton.style.color = '';
  2124. }
  2125. };
  2126.  
  2127. decrementButton.addEventListener('click', () => {
  2128. if (choiceIndex > 0) {
  2129. choiceIndex -= 1;
  2130. currentValue = hotkeyOptions[choiceIndex].split(' ');
  2131. input.textContent = currentValue.join(' and ');
  2132. updateButtonStates();
  2133. }
  2134. });
  2135.  
  2136. incrementButton.addEventListener('click', () => {
  2137. if (choiceIndex < maxAllowedIndex) {
  2138. choiceIndex += 1;
  2139. currentValue = hotkeyOptions[choiceIndex].split(' ');
  2140. input.textContent = currentValue.join(' and ');
  2141. updateButtonStates();
  2142. }
  2143. });
  2144.  
  2145. textContainer.appendChild(decrementButton);
  2146. textContainer.appendChild(input);
  2147. textContainer.appendChild(incrementButton);
  2148.  
  2149. // Initialize button states
  2150. updateButtonStates();
  2151.  
  2152. rightContainer.appendChild(textContainer);
  2153. }
  2154.  
  2155. optionContainer.appendChild(leftContainer);
  2156. optionContainer.appendChild(rightContainer);
  2157. menuContent.appendChild(optionContainer);
  2158. }
  2159.  
  2160. const menuButtons = createMenuButtons();
  2161. menuContent.appendChild(menuButtons);
  2162.  
  2163. overlay.appendChild(menuContent);
  2164.  
  2165. return overlay;
  2166. }
  2167.  
  2168. function loadConfig() {
  2169. for (const key in localStorage) {
  2170. if (!key.startsWith(scriptPrefix + configPrefix) || !localStorage.hasOwnProperty(key)) {continue};
  2171.  
  2172. const configKey = key.substring((scriptPrefix + configPrefix).length); // chop off script prefix and config prefix
  2173. if (!CONFIG.hasOwnProperty(configKey)) {continue};
  2174.  
  2175. const savedValue = localStorage.getItem(key);
  2176. if (savedValue === null) {continue};
  2177.  
  2178. const valueType = typeof CONFIG[configKey];
  2179. if (configKey === 'HOTKEYS') {
  2180. CONFIG[configKey] = savedValue.split(' ')
  2181. } else if (valueType === 'boolean') {
  2182. CONFIG[configKey] = savedValue === 'true';
  2183. if (configKey === 'DEFAULT_TO_EXACT_SEARCH') { state.exactSearch = CONFIG.DEFAULT_TO_EXACT_SEARCH }
  2184. // I wonder if this is the best way to do this...
  2185. // Probably not because we could just have a single variable to store both, but it would have to be in config and
  2186. // it would be a bit weird to have the program modifying config when the actual config settings aren't changing
  2187. } else if (valueType === 'number') {
  2188. CONFIG[configKey] = parseFloat(savedValue);
  2189. } else if (valueType === 'string') {
  2190. CONFIG[configKey] = savedValue;
  2191. }
  2192. }
  2193. }
  2194.  
  2195.  
  2196. //MAIN FUNCTIONS=====================================================================================================================
  2197. function onPageLoad() {
  2198. // Initialize state and determine vocabulary based on URL
  2199. state.embedAboveSubsectionMeanings = false;
  2200.  
  2201. const url = window.location.href;
  2202. const machineTranslationFrame = document.getElementById('machine-translation-frame');
  2203.  
  2204. // Proceed only if the machine translation frame is not present
  2205. if (!machineTranslationFrame) {
  2206.  
  2207. //display embed for first time with loading text
  2208. embedImageAndPlayAudio();
  2209. setPageWidth();
  2210.  
  2211. if (url.includes('/vocabulary/')) {
  2212. state.vocab = parseVocabFromVocabulary();
  2213. } else if (url.includes('/search?q=')) {
  2214. state.vocab = parseVocabFromSearch();
  2215. } else if (url.includes('c=')) {
  2216. state.vocab = parseVocabFromAnswer();
  2217. } else if (url.includes('/kanji/')) {
  2218. state.vocab = parseVocabFromKanji();
  2219. } else {
  2220. state.vocab = parseVocabFromReview();
  2221. }
  2222. } else {
  2223. console.log('Machine translation frame detected, skipping vocabulary parsing.');
  2224. }
  2225.  
  2226. // Retrieve stored data for the current vocabulary
  2227. const { index, exactState } = getStoredData(state.vocab);
  2228. state.currentExampleIndex = index;
  2229. state.exactSearch = exactState;
  2230.  
  2231. // Fetch data and embed image/audio if necessary
  2232. if (state.vocab && !state.apiDataFetched) {
  2233. getImmersionKitData(state.vocab, state.exactSearch)
  2234. .then(() => {
  2235. preloadImages();
  2236. embedImageAndPlayAudio();
  2237. })
  2238. .catch(console.error);
  2239. } else if (state.apiDataFetched) {
  2240. embedImageAndPlayAudio();
  2241. //preloadImages();
  2242. setVocabSize();
  2243. setPageWidth();
  2244. }
  2245. }
  2246.  
  2247. function setPageWidth() {
  2248. // Set the maximum width of the page
  2249. document.body.style.maxWidth = CONFIG.PAGE_WIDTH;
  2250. }
  2251.  
  2252. // Observe URL changes and reload the page content accordingly
  2253. const observer = new MutationObserver(() => {
  2254. if (window.location.href !== observer.lastUrl) {
  2255. observer.lastUrl = window.location.href;
  2256. onPageLoad();
  2257. }
  2258. });
  2259.  
  2260. // Function to apply styles
  2261. function setVocabSize() {
  2262. // Create a new style element
  2263. const style = document.createElement('style');
  2264. style.type = 'text/css';
  2265. style.innerHTML = `
  2266. .answer-box > .plain {
  2267. font-size: ${CONFIG.VOCAB_SIZE} !important; /* Use the configurable font size */
  2268. padding-bottom: 0.1rem !important; /* Retain padding */
  2269. }
  2270. `;
  2271.  
  2272. // Append the new style to the document head
  2273. document.head.appendChild(style);
  2274. }
  2275. observer.lastUrl = window.location.href;
  2276. observer.observe(document, { subtree: true, childList: true });
  2277.  
  2278. // Add event listeners for page load and URL changes
  2279. window.addEventListener('load', onPageLoad);
  2280. window.addEventListener('popstate', onPageLoad);
  2281. window.addEventListener('hashchange', onPageLoad);
  2282.  
  2283. // Initial configuration and preloading
  2284. loadConfig();
  2285. setPageWidth();
  2286. setVocabSize();
  2287. //preloadImages();
  2288.  
  2289. })();