JPDB Nadeshiko Examples

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

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

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