WaniKani User Synonyms++

Better and Not-only User Synonyms

  1. // ==UserScript==
  2. // @name WaniKani User Synonyms++
  3. // @namespace http://www.wanikani.com
  4. // @version 0.2.5
  5. // @description Better and Not-only User Synonyms
  6. // @author polv
  7. // @match https://www.wanikani.com/*
  8. // @match https://preview.wanikani.com/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
  10. // @license MIT
  11. // @require https://gf.qytechs.cn/scripts/470201-wanikani-answer-checker/code/WaniKani%20Answer%20Checker.js?version=1215595
  12. // @require https://unpkg.com/dexie@3/dist/dexie.js
  13. // @homepage https://gf.qytechs.cn/en/scripts/470180-wanikani-user-synonyms
  14. // @supportURL https://community.wanikani.com/t/userscript-user-synonyms/62481
  15. // @source https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/synonyms-plus.user.js
  16. // @grant none
  17. // ==/UserScript==
  18.  
  19. // @ts-check
  20. /// <reference path="./types/answer-checker.d.ts" />
  21. (function () {
  22. 'use strict';
  23.  
  24. const entryClazz = 'synonyms-plus';
  25.  
  26. ///////////////////////////////////////////////////////////////////////////////////////////////////
  27.  
  28. // @ts-ignore
  29. const _Dexie = /** @type {typeof import('dexie').default} */ (Dexie);
  30. /**
  31. * @typedef {{
  32. * id: string;
  33. * kunyomi?: string[];
  34. * onyomi?: string[];
  35. * nanori?: string[];
  36. * aux: { questionType: string; text: string; type: AuxiliaryType; message: string }[];
  37. * }} EntrySynonym
  38. */
  39.  
  40. class Database extends _Dexie {
  41. /** @type {import('dexie').Table<EntrySynonym, string>} */
  42. synonym;
  43.  
  44. constructor() {
  45. super(entryClazz);
  46. this.version(1).stores({
  47. synonym: 'id',
  48. });
  49. }
  50. }
  51.  
  52. const db = new Database();
  53.  
  54. //////////////////////////////////////////////////////////////////////////////
  55.  
  56. /** @type {EvaluationParam | null} */
  57. let answerCheckerParam = null;
  58.  
  59. const wkSynonyms = {
  60. add: {
  61. kunyomi(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
  62. return this.reading(r, type, 'kunyomi');
  63. },
  64. onyomi(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
  65. return this.reading(r, type, 'onyomi');
  66. },
  67. nanori(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
  68. return this.reading(r, type, 'nanori');
  69. },
  70. reading(
  71. r,
  72. type = /** @type {AuxiliaryType} */ ('whitelist'),
  73. questionType = 'reading',
  74. ) {
  75. if (!wkSynonyms.entry.id) return;
  76.  
  77. r = toHiragana(r).trim();
  78. if (!/^\p{sc=Hiragana}+$/u.test(r)) return;
  79.  
  80. wkSynonyms.remove.reading(r, type, questionType);
  81.  
  82. if (type === 'whitelist') {
  83. if (['kunyomi', 'onyomi', 'nanori'].includes(questionType)) {
  84. wkSynonyms.entry[questionType] = [
  85. ...(wkSynonyms.entry[questionType] || []),
  86. r,
  87. ];
  88. }
  89. }
  90.  
  91. wkSynonyms.entry.aux.push({
  92. questionType,
  93. text: r,
  94. type,
  95. message:
  96. type === 'whitelist'
  97. ? ''
  98. : `Not the ${questionType} YOU are looking for`,
  99. });
  100.  
  101. db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
  102. return 'added';
  103. },
  104. meaning(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
  105. if (!wkSynonyms.entry.id) return;
  106.  
  107. r = r.trim();
  108. if (!r) return;
  109.  
  110. wkSynonyms.remove.meaning(r);
  111.  
  112. const questionType = 'meaning';
  113. wkSynonyms.entry.aux.push({
  114. questionType,
  115. text: r,
  116. type,
  117. message:
  118. type === 'whitelist'
  119. ? ''
  120. : `Not the ${questionType} YOU are looking for`,
  121. });
  122.  
  123. db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
  124. return 'added';
  125. },
  126. },
  127. remove: {
  128. kunyomi(r) {
  129. return this.reading(r, null, 'kunyomi');
  130. },
  131. onyomi(r) {
  132. return this.reading(r, null, 'onyomi');
  133. },
  134. nanori(r) {
  135. return this.reading(r, null, 'nanori');
  136. },
  137. reading(r, _type, questionType) {
  138. if (!wkSynonyms.entry.id) return;
  139.  
  140. r = toHiragana(r).trim();
  141. if (!/^\p{sc=Hiragana}+$/u.test(r)) return;
  142.  
  143. const newAux = wkSynonyms.entry.aux.filter(
  144. (a) => a.questionType !== 'meaning' && a.text !== r,
  145. );
  146.  
  147. let isChanged = false;
  148.  
  149. if (['kunyomi', 'onyomi', 'nanori'].includes(questionType)) {
  150. if (wkSynonyms.entry[questionType]) {
  151. const newArr = wkSynonyms.entry[questionType].filter(
  152. (a) => a !== r,
  153. );
  154. if (newArr.length < wkSynonyms.entry[questionType].length) {
  155. wkSynonyms.entry[questionType] = newArr;
  156. wkSynonyms.entry.aux = newAux;
  157. isChanged = true;
  158. }
  159. }
  160. }
  161.  
  162. if (isChanged || newAux.length < wkSynonyms.entry.aux.length) {
  163. wkSynonyms.entry.aux = newAux;
  164. db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
  165. return 'removed';
  166. }
  167.  
  168. return 'not removed';
  169. },
  170. meaning(r) {
  171. if (!wkSynonyms.entry.id) return;
  172.  
  173. r = r.trim();
  174. if (!r) return;
  175.  
  176. const newAux = wkSynonyms.entry.aux.filter(
  177. (a) => a.questionType === 'meaning' && a.text !== r,
  178. );
  179.  
  180. if (newAux.length < wkSynonyms.entry.aux.length) {
  181. wkSynonyms.entry.aux = newAux;
  182. db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
  183. return 'removed';
  184. }
  185.  
  186. return 'not removed';
  187. },
  188. },
  189. entry: /** @type {EntrySynonym} */ ({
  190. id: '',
  191. aux: [],
  192. }),
  193. commit() {
  194. if (!this.entry.id) return;
  195. db.synonym.put(this.entry, this.entry.id);
  196. },
  197. };
  198. Object.assign(window, { wkSynonyms });
  199.  
  200. let isFirstRender = false;
  201.  
  202. window.modAnswerChecker.register((e, tryCheck) => {
  203. answerCheckerParam = e;
  204. e = JSON.parse(JSON.stringify(e));
  205.  
  206. e.item.readings = e.item.readings || [];
  207. e.item.auxiliary_readings = e.item.auxiliary_readings || [];
  208.  
  209. let aux = wkSynonyms.entry.aux;
  210.  
  211. for (const kanjiReading of /** @type {('kunyomi' | 'onyomi' | 'nanori')[]} */ ([
  212. 'kunyomi',
  213. 'onyomi',
  214. 'nanori',
  215. ])) {
  216. const rs = wkSynonyms.entry[kanjiReading];
  217. if (rs) {
  218. e.item[kanjiReading] = [...(e.item[kanjiReading] || []), ...rs];
  219. e.item.auxiliary_readings = e.item.auxiliary_readings.filter(
  220. (a) => !rs.includes(a.reading),
  221. );
  222. }
  223. }
  224.  
  225. for (const { questionType, ...it } of aux) {
  226. if (questionType === 'meaning') {
  227. const text = normalize(it.text);
  228.  
  229. e.item.meanings = e.item.meanings.filter((a) => normalize(a) !== text);
  230. e.item.auxiliary_meanings = e.item.auxiliary_meanings.filter(
  231. (a) => normalize(a.meaning) !== text,
  232. );
  233. e.userSynonyms = e.userSynonyms.filter((s) => normalize(s) !== text);
  234. e.item.auxiliary_meanings.push({ ...it, meaning: it.text });
  235. } else {
  236. if (e.item.readings) {
  237. e.item.readings = e.item.readings.filter((a) => a !== it.text);
  238. }
  239.  
  240. if (!(e.item.type === 'Kanji' && it.type === 'whitelist')) {
  241. for (const kanjiReading of /** @type {('kunyomi' | 'onyomi' | 'nanori')[]} */ ([
  242. 'kunyomi',
  243. 'onyomi',
  244. 'nanori',
  245. ])) {
  246. const rs = e.item[kanjiReading];
  247. if (rs) {
  248. e.item[kanjiReading] = rs.filter((a) => a !== it.text);
  249. }
  250. }
  251.  
  252. let { auxiliary_readings = [] } = e.item;
  253. auxiliary_readings = auxiliary_readings.filter(
  254. (a) => a.reading !== it.text,
  255. );
  256. auxiliary_readings.push({ ...it, reading: it.text });
  257. e.item.auxiliary_readings = auxiliary_readings;
  258. }
  259. }
  260. }
  261.  
  262. return tryCheck(e);
  263. });
  264.  
  265. addEventListener('willShowNextQuestion', (ev) => {
  266. document.querySelectorAll(`.${entryClazz}`).forEach((el) => el.remove());
  267. answerCheckerParam = null;
  268. wkSynonyms.entry = {
  269. id: String(/** @type {any} */ (ev).detail.subject.id),
  270. aux: [],
  271. };
  272. isFirstRender = true;
  273.  
  274. db.synonym.get(wkSynonyms.entry.id).then((it) => {
  275. if (it) {
  276. wkSynonyms.entry = it;
  277. }
  278. });
  279. });
  280.  
  281. addEventListener('turbo:load', (ev) => {
  282. // @ts-ignore
  283. const url = ev.detail.url;
  284. if (!url) return;
  285.  
  286. if (/wanikani\.com\/(radicals?|kanji|vocabulary)/.test(url)) {
  287. answerCheckerParam = null;
  288. }
  289. });
  290.  
  291. const updateListing = () => {
  292. const frame = document.querySelector(
  293. 'turbo-frame.user-synonyms',
  294. )?.parentElement;
  295. if (!frame?.parentElement) return;
  296.  
  297. let divList = frame.parentElement.querySelector(`.${entryClazz}`);
  298. if (!divList) {
  299. divList = document.createElement('div');
  300. divList.className = entryClazz;
  301. frame.insertAdjacentElement('beforebegin', divList);
  302. }
  303.  
  304. divList.textContent = '';
  305.  
  306. const listing = {};
  307.  
  308. wkSynonyms.entry.aux.map((a) => {
  309. const t = capitalize(a.type);
  310. listing[t] = listing[t] || [];
  311. listing[t].push(a);
  312. });
  313.  
  314. for (const [k, auxs] of Object.entries(listing)) {
  315. const div = document.createElement('div');
  316. div.className = 'subject-section__meanings';
  317. divList.append(div);
  318.  
  319. const h = document.createElement('h2');
  320. h.className = 'subject-section__meanings-title';
  321. h.innerText = k;
  322. div.append(h);
  323.  
  324. const ul = document.createElement('ul');
  325. ul.className = 'user-synonyms__items';
  326. div.append(ul);
  327.  
  328. for (const a of auxs) {
  329. const li = document.createElement('li');
  330. li.className = 'user-synonyms_item';
  331. ul.append(li);
  332.  
  333. const span = document.createElement('span');
  334. span.className = 'user-synonym';
  335. span.innerText = a.text;
  336. if (a.questionType !== 'meaning') {
  337. span.innerText += ` (${a.questionType})`;
  338. }
  339. li.append(span);
  340. }
  341. }
  342. };
  343.  
  344. let updateAux = () => {};
  345. addEventListener('didUpdateUserSynonyms', (ev) => {
  346. updateAux();
  347. });
  348.  
  349. addEventListener('turbo:frame-render', (ev) => {
  350. // @ts-ignore
  351. const { fetchResponse } = ev.detail;
  352.  
  353. if (/wanikani\.com\/subject_info\/(\d+)/.test(fetchResponse.response.url)) {
  354. updateListing();
  355. return;
  356. }
  357.  
  358. const [, subject_id] =
  359. /wanikani\.com\/user_synonyms.*\?.*subject_id=(\d+)/.exec(
  360. fetchResponse.response.url,
  361. ) || [];
  362.  
  363. if (!subject_id) return;
  364.  
  365. db.synonym.get(subject_id).then((it) => {
  366. if (it) {
  367. wkSynonyms.entry = it;
  368. updateAux();
  369. }
  370. });
  371.  
  372. updateAux = () => {
  373. updateListing();
  374.  
  375. const elContainer = document.querySelector(
  376. '.user-synonyms__form_container',
  377. );
  378. if (!elContainer) return;
  379.  
  380. const elForm = elContainer.querySelector('form.user-synonyms__form');
  381. if (!(elForm instanceof HTMLFormElement)) return;
  382.  
  383. const elInput = elContainer.querySelector('input[type="text"]');
  384. if (!(elInput instanceof HTMLInputElement)) return;
  385.  
  386. if (isFirstRender && answerCheckerParam?.questionType === 'meaning') {
  387. elInput.value = answerCheckerParam?.response || '';
  388. }
  389.  
  390. elInput.autocomplete = 'off';
  391. elInput.onkeydown = (ev) => {
  392. if (ev.key === 'Escape' || ev.code === 'Escape') {
  393. if (elInput.value) {
  394. elInput.value = '';
  395. } else {
  396. return;
  397. }
  398. }
  399.  
  400. ev.stopImmediatePropagation();
  401. ev.stopPropagation();
  402. };
  403.  
  404. elForm.onsubmit = (ev) => {
  405. isFirstRender = false;
  406.  
  407. if (elInput.value.length < 2) return;
  408. const signs = ['-', '*', '?', '+', ''];
  409.  
  410. let sign = '';
  411. let str = elInput.value.trim();
  412. for (sign of signs) {
  413. if (str.startsWith(sign)) {
  414. str = str.substring(sign.length);
  415. break;
  416. }
  417. if (str.endsWith(sign)) {
  418. str = str.substring(0, str.length - sign.length);
  419. break;
  420. }
  421. }
  422.  
  423. /** @type {AuxiliaryType | null} */
  424. let type = null;
  425.  
  426. if (['-', '*'].includes(sign)) {
  427. type = 'blacklist';
  428. } else if (['?'].includes(sign)) {
  429. type = 'warn';
  430. } else if (['+'].includes(sign)) {
  431. type = 'whitelist';
  432. }
  433.  
  434. let questionType = 'meaning';
  435. const [, readingType, reading] =
  436. /^(kunyomi|onyomi|nanori|reading):([\p{sc=Hiragana}\p{sc=Katakana}]+)$/iu.exec(
  437. str,
  438. ) || [];
  439. if (reading) {
  440. str = reading;
  441. questionType = readingType;
  442. type = type || 'whitelist';
  443. }
  444.  
  445. if (!type) return;
  446.  
  447. ev.preventDefault();
  448. setTimeout(() => {
  449. updateAux();
  450. elInput.value = '';
  451. });
  452.  
  453. if (questionType === 'meaning') {
  454. wkSynonyms.add.meaning(str, type);
  455. } else {
  456. wkSynonyms.add.reading(str, type, readingType);
  457. }
  458. };
  459.  
  460. let elExtraContainer = elContainer.querySelector(`.${entryClazz}`);
  461. if (!elExtraContainer) {
  462. elExtraContainer = document.createElement('div');
  463. elExtraContainer.className = entryClazz;
  464. elContainer.append(elExtraContainer);
  465. }
  466. elExtraContainer.textContent = '';
  467.  
  468. for (const a of wkSynonyms.entry.aux) {
  469. let elAux = elExtraContainer.querySelector(
  470. `[data-${entryClazz}="${a.type}"]`,
  471. );
  472. if (!elAux) {
  473. elAux = document.createElement('div');
  474. elAux.className = 'user-synonyms__synonym-buttons';
  475. elAux.setAttribute(`data-${entryClazz}`, a.type);
  476.  
  477. const h = document.createElement('h2');
  478. h.className =
  479. 'wk-title wk-title--medium wk-title--underlined wk-title-custom';
  480. h.innerText = capitalize(a.type);
  481.  
  482. elExtraContainer.append(h);
  483. elExtraContainer.append(elAux);
  484. }
  485.  
  486. const btn = document.createElement('a');
  487. elAux.append(btn);
  488. btn.className = 'user-synonyms__synonym-button';
  489.  
  490. btn.addEventListener('click', () => {
  491. if (a.questionType === 'meaning') {
  492. wkSynonyms.remove.meaning(a.text);
  493. } else {
  494. wkSynonyms.remove.reading(a.text, null, a.questionType);
  495. }
  496. updateAux();
  497. });
  498.  
  499. const icon = document.createElement('i');
  500. btn.append(icon);
  501. icon.className = 'wk-icon fa-regular fa-times';
  502.  
  503. const span = document.createElement('span');
  504. btn.append(span);
  505. span.className = 'user-synonym__button-text';
  506. span.innerText = a.text;
  507. if (a.questionType !== 'meaning') {
  508. span.innerText += ` (${a.questionType})`;
  509. }
  510. }
  511.  
  512. if (!answerCheckerParam) return;
  513.  
  514. const { item } = answerCheckerParam;
  515. const aux = [
  516. ...item.auxiliary_meanings.map(({ meaning, ...t }) => ({
  517. text: meaning,
  518. questionType: 'meaning',
  519. ...t,
  520. })),
  521. ];
  522.  
  523. if (item.auxiliary_readings) {
  524. aux.push(
  525. ...item.auxiliary_readings.map(({ reading, ...t }) => ({
  526. text: reading,
  527. questionType: 'reading',
  528. ...t,
  529. })),
  530. );
  531. }
  532.  
  533. if (aux.length) {
  534. elExtraContainer.append(
  535. (() => {
  536. const elDetails = document.createElement('details');
  537.  
  538. const title = document.createElement('summary');
  539. elDetails.append(title);
  540. title.innerText = `WaniKani auxiliaries`;
  541.  
  542. const elButtonSet = document.createElement('div');
  543. elDetails.append(elButtonSet);
  544. elButtonSet.className = 'user-synonyms__synonym-buttons';
  545.  
  546. for (const a of aux) {
  547. let elAux = elDetails.querySelector(
  548. `[data-${entryClazz}="wk-${a.type}"]`,
  549. );
  550. if (!elAux) {
  551. elAux = document.createElement('div');
  552. elAux.className = 'user-synonyms__synonym-buttons';
  553. elAux.setAttribute(`data-${entryClazz}`, `wk-${a.type}`);
  554.  
  555. const h = document.createElement('h2');
  556. h.className =
  557. 'wk-title wk-title--medium wk-title--underlined wk-title-custom';
  558. h.innerText = capitalize(a.type);
  559.  
  560. elDetails.append(h);
  561. elDetails.append(elAux);
  562. }
  563.  
  564. const span = document.createElement('span');
  565. elAux.append(span);
  566. span.className = 'user-synonym__button-text';
  567. span.innerText = a.text;
  568. if (a.questionType !== 'meaning') {
  569. span.innerText += ` (${a.questionType})`;
  570. }
  571. }
  572. return elDetails;
  573. })(),
  574. );
  575. }
  576. };
  577.  
  578. updateAux();
  579. });
  580.  
  581. /** @param {string} s */
  582. function capitalize(s) {
  583. return s.replace(
  584. /[a-z]+/gi,
  585. (p) => p[0].toLocaleUpperCase() + p.substring(1),
  586. );
  587. }
  588.  
  589. /** @param {string} s */
  590. function normalize(s) {
  591. return s.toLocaleLowerCase().replace(/\W/g, ' ').trim();
  592. }
  593.  
  594. const CP_KATA_A = 'ア'.charCodeAt(0);
  595. const CP_HIRA_A = 'あ'.charCodeAt(0);
  596.  
  597. /** @param {string} s */
  598. function toHiragana(s) {
  599. return s.replace(/\p{sc=Katakana}/gu, (c) =>
  600. String.fromCharCode(c.charCodeAt(0) - CP_KATA_A + CP_HIRA_A),
  601. );
  602. }
  603.  
  604. (function add_css() {
  605. const style = document.createElement('style');
  606. style.append(
  607. document.createTextNode(/* css */ `
  608. :root {
  609. --color-modal-mask: unset;
  610. }
  611.  
  612. .wk-modal__content {
  613. /* top: unset;
  614. bottom: 0; */
  615. border-radius: 5px;
  616. box-shadow: 0 0 4px 2px gray;
  617. }
  618.  
  619. .subject-section__meanings-title {
  620. min-width: 6em;
  621. }
  622.  
  623. .user-synonyms__form_container::-webkit-scrollbar {
  624. display: none;
  625. }
  626.  
  627. .${entryClazz} .user-synonym__button-text {
  628. line-height: 1.5em;
  629. }
  630.  
  631. .${entryClazz} .user-synonym__button-text:not(:last-child)::after,
  632. .${entryClazz} .user-synonyms_item:not(:last-child)::after {
  633. content: ',';
  634. margin-right: 0.5em;
  635. }
  636.  
  637. .${entryClazz} details,
  638. .${entryClazz} .wk-title-custom {
  639. margin-top: 1em;
  640. }
  641.  
  642. .${entryClazz} summary {
  643. cursor: pointer;
  644. }
  645. `),
  646. );
  647. document.head.append(style);
  648. })();
  649. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址