// ==UserScript==
// @name WaniKani User Synonyms++
// @namespace http://www.wanikani.com
// @version 0.2.5
// @description Better and Not-only User Synonyms
// @author polv
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=wanikani.com
// @license MIT
// @require https://greasyfork.org/scripts/470201-wanikani-answer-checker/code/WaniKani%20Answer%20Checker.js?version=1215595
// @require https://unpkg.com/dexie@3/dist/dexie.js
// @homepage https://greasyfork.org/en/scripts/470180-wanikani-user-synonyms
// @supportURL https://community.wanikani.com/t/userscript-user-synonyms/62481
// @source https://github.com/patarapolw/wanikani-userscript/blob/master/userscripts/synonyms-plus.user.js
// @grant none
// ==/UserScript==
// @ts-check
/// <reference path="./types/answer-checker.d.ts" />
(function () {
'use strict';
const entryClazz = 'synonyms-plus';
///////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
const _Dexie = /** @type {typeof import('dexie').default} */ (Dexie);
/**
* @typedef {{
* id: string;
* kunyomi?: string[];
* onyomi?: string[];
* nanori?: string[];
* aux: { questionType: string; text: string; type: AuxiliaryType; message: string }[];
* }} EntrySynonym
*/
class Database extends _Dexie {
/** @type {import('dexie').Table<EntrySynonym, string>} */
synonym;
constructor() {
super(entryClazz);
this.version(1).stores({
synonym: 'id',
});
}
}
const db = new Database();
//////////////////////////////////////////////////////////////////////////////
/** @type {EvaluationParam | null} */
let answerCheckerParam = null;
const wkSynonyms = {
add: {
kunyomi(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
return this.reading(r, type, 'kunyomi');
},
onyomi(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
return this.reading(r, type, 'onyomi');
},
nanori(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
return this.reading(r, type, 'nanori');
},
reading(
r,
type = /** @type {AuxiliaryType} */ ('whitelist'),
questionType = 'reading',
) {
if (!wkSynonyms.entry.id) return;
r = toHiragana(r).trim();
if (!/^\p{sc=Hiragana}+$/u.test(r)) return;
wkSynonyms.remove.reading(r, type, questionType);
if (type === 'whitelist') {
if (['kunyomi', 'onyomi', 'nanori'].includes(questionType)) {
wkSynonyms.entry[questionType] = [
...(wkSynonyms.entry[questionType] || []),
r,
];
}
}
wkSynonyms.entry.aux.push({
questionType,
text: r,
type,
message:
type === 'whitelist'
? ''
: `Not the ${questionType} YOU are looking for`,
});
db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
return 'added';
},
meaning(r, type = /** @type {AuxiliaryType} */ ('whitelist')) {
if (!wkSynonyms.entry.id) return;
r = r.trim();
if (!r) return;
wkSynonyms.remove.meaning(r);
const questionType = 'meaning';
wkSynonyms.entry.aux.push({
questionType,
text: r,
type,
message:
type === 'whitelist'
? ''
: `Not the ${questionType} YOU are looking for`,
});
db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
return 'added';
},
},
remove: {
kunyomi(r) {
return this.reading(r, null, 'kunyomi');
},
onyomi(r) {
return this.reading(r, null, 'onyomi');
},
nanori(r) {
return this.reading(r, null, 'nanori');
},
reading(r, _type, questionType) {
if (!wkSynonyms.entry.id) return;
r = toHiragana(r).trim();
if (!/^\p{sc=Hiragana}+$/u.test(r)) return;
const newAux = wkSynonyms.entry.aux.filter(
(a) => a.questionType !== 'meaning' && a.text !== r,
);
let isChanged = false;
if (['kunyomi', 'onyomi', 'nanori'].includes(questionType)) {
if (wkSynonyms.entry[questionType]) {
const newArr = wkSynonyms.entry[questionType].filter(
(a) => a !== r,
);
if (newArr.length < wkSynonyms.entry[questionType].length) {
wkSynonyms.entry[questionType] = newArr;
wkSynonyms.entry.aux = newAux;
isChanged = true;
}
}
}
if (isChanged || newAux.length < wkSynonyms.entry.aux.length) {
wkSynonyms.entry.aux = newAux;
db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
return 'removed';
}
return 'not removed';
},
meaning(r) {
if (!wkSynonyms.entry.id) return;
r = r.trim();
if (!r) return;
const newAux = wkSynonyms.entry.aux.filter(
(a) => a.questionType === 'meaning' && a.text !== r,
);
if (newAux.length < wkSynonyms.entry.aux.length) {
wkSynonyms.entry.aux = newAux;
db.synonym.put(wkSynonyms.entry, wkSynonyms.entry.id);
return 'removed';
}
return 'not removed';
},
},
entry: /** @type {EntrySynonym} */ ({
id: '',
aux: [],
}),
commit() {
if (!this.entry.id) return;
db.synonym.put(this.entry, this.entry.id);
},
};
Object.assign(window, { wkSynonyms });
let isFirstRender = false;
window.modAnswerChecker.register((e, tryCheck) => {
answerCheckerParam = e;
e = JSON.parse(JSON.stringify(e));
e.item.readings = e.item.readings || [];
e.item.auxiliary_readings = e.item.auxiliary_readings || [];
let aux = wkSynonyms.entry.aux;
for (const kanjiReading of /** @type {('kunyomi' | 'onyomi' | 'nanori')[]} */ ([
'kunyomi',
'onyomi',
'nanori',
])) {
const rs = wkSynonyms.entry[kanjiReading];
if (rs) {
e.item[kanjiReading] = [...(e.item[kanjiReading] || []), ...rs];
e.item.auxiliary_readings = e.item.auxiliary_readings.filter(
(a) => !rs.includes(a.reading),
);
}
}
for (const { questionType, ...it } of aux) {
if (questionType === 'meaning') {
const text = normalize(it.text);
e.item.meanings = e.item.meanings.filter((a) => normalize(a) !== text);
e.item.auxiliary_meanings = e.item.auxiliary_meanings.filter(
(a) => normalize(a.meaning) !== text,
);
e.userSynonyms = e.userSynonyms.filter((s) => normalize(s) !== text);
e.item.auxiliary_meanings.push({ ...it, meaning: it.text });
} else {
if (e.item.readings) {
e.item.readings = e.item.readings.filter((a) => a !== it.text);
}
if (!(e.item.type === 'Kanji' && it.type === 'whitelist')) {
for (const kanjiReading of /** @type {('kunyomi' | 'onyomi' | 'nanori')[]} */ ([
'kunyomi',
'onyomi',
'nanori',
])) {
const rs = e.item[kanjiReading];
if (rs) {
e.item[kanjiReading] = rs.filter((a) => a !== it.text);
}
}
let { auxiliary_readings = [] } = e.item;
auxiliary_readings = auxiliary_readings.filter(
(a) => a.reading !== it.text,
);
auxiliary_readings.push({ ...it, reading: it.text });
e.item.auxiliary_readings = auxiliary_readings;
}
}
}
return tryCheck(e);
});
addEventListener('willShowNextQuestion', (ev) => {
document.querySelectorAll(`.${entryClazz}`).forEach((el) => el.remove());
answerCheckerParam = null;
wkSynonyms.entry = {
id: String(/** @type {any} */ (ev).detail.subject.id),
aux: [],
};
isFirstRender = true;
db.synonym.get(wkSynonyms.entry.id).then((it) => {
if (it) {
wkSynonyms.entry = it;
}
});
});
addEventListener('turbo:load', (ev) => {
// @ts-ignore
const url = ev.detail.url;
if (!url) return;
if (/wanikani\.com\/(radicals?|kanji|vocabulary)/.test(url)) {
answerCheckerParam = null;
}
});
const updateListing = () => {
const frame = document.querySelector(
'turbo-frame.user-synonyms',
)?.parentElement;
if (!frame?.parentElement) return;
let divList = frame.parentElement.querySelector(`.${entryClazz}`);
if (!divList) {
divList = document.createElement('div');
divList.className = entryClazz;
frame.insertAdjacentElement('beforebegin', divList);
}
divList.textContent = '';
const listing = {};
wkSynonyms.entry.aux.map((a) => {
const t = capitalize(a.type);
listing[t] = listing[t] || [];
listing[t].push(a);
});
for (const [k, auxs] of Object.entries(listing)) {
const div = document.createElement('div');
div.className = 'subject-section__meanings';
divList.append(div);
const h = document.createElement('h2');
h.className = 'subject-section__meanings-title';
h.innerText = k;
div.append(h);
const ul = document.createElement('ul');
ul.className = 'user-synonyms__items';
div.append(ul);
for (const a of auxs) {
const li = document.createElement('li');
li.className = 'user-synonyms_item';
ul.append(li);
const span = document.createElement('span');
span.className = 'user-synonym';
span.innerText = a.text;
if (a.questionType !== 'meaning') {
span.innerText += ` (${a.questionType})`;
}
li.append(span);
}
}
};
let updateAux = () => {};
addEventListener('didUpdateUserSynonyms', (ev) => {
updateAux();
});
addEventListener('turbo:frame-render', (ev) => {
// @ts-ignore
const { fetchResponse } = ev.detail;
if (/wanikani\.com\/subject_info\/(\d+)/.test(fetchResponse.response.url)) {
updateListing();
return;
}
const [, subject_id] =
/wanikani\.com\/user_synonyms.*\?.*subject_id=(\d+)/.exec(
fetchResponse.response.url,
) || [];
if (!subject_id) return;
db.synonym.get(subject_id).then((it) => {
if (it) {
wkSynonyms.entry = it;
updateAux();
}
});
updateAux = () => {
updateListing();
const elContainer = document.querySelector(
'.user-synonyms__form_container',
);
if (!elContainer) return;
const elForm = elContainer.querySelector('form.user-synonyms__form');
if (!(elForm instanceof HTMLFormElement)) return;
const elInput = elContainer.querySelector('input[type="text"]');
if (!(elInput instanceof HTMLInputElement)) return;
if (isFirstRender && answerCheckerParam?.questionType === 'meaning') {
elInput.value = answerCheckerParam?.response || '';
}
elInput.autocomplete = 'off';
elInput.onkeydown = (ev) => {
if (ev.key === 'Escape' || ev.code === 'Escape') {
if (elInput.value) {
elInput.value = '';
} else {
return;
}
}
ev.stopImmediatePropagation();
ev.stopPropagation();
};
elForm.onsubmit = (ev) => {
isFirstRender = false;
if (elInput.value.length < 2) return;
const signs = ['-', '*', '?', '+', ''];
let sign = '';
let str = elInput.value.trim();
for (sign of signs) {
if (str.startsWith(sign)) {
str = str.substring(sign.length);
break;
}
if (str.endsWith(sign)) {
str = str.substring(0, str.length - sign.length);
break;
}
}
/** @type {AuxiliaryType | null} */
let type = null;
if (['-', '*'].includes(sign)) {
type = 'blacklist';
} else if (['?'].includes(sign)) {
type = 'warn';
} else if (['+'].includes(sign)) {
type = 'whitelist';
}
let questionType = 'meaning';
const [, readingType, reading] =
/^(kunyomi|onyomi|nanori|reading):([\p{sc=Hiragana}\p{sc=Katakana}]+)$/iu.exec(
str,
) || [];
if (reading) {
str = reading;
questionType = readingType;
type = type || 'whitelist';
}
if (!type) return;
ev.preventDefault();
setTimeout(() => {
updateAux();
elInput.value = '';
});
if (questionType === 'meaning') {
wkSynonyms.add.meaning(str, type);
} else {
wkSynonyms.add.reading(str, type, readingType);
}
};
let elExtraContainer = elContainer.querySelector(`.${entryClazz}`);
if (!elExtraContainer) {
elExtraContainer = document.createElement('div');
elExtraContainer.className = entryClazz;
elContainer.append(elExtraContainer);
}
elExtraContainer.textContent = '';
for (const a of wkSynonyms.entry.aux) {
let elAux = elExtraContainer.querySelector(
`[data-${entryClazz}="${a.type}"]`,
);
if (!elAux) {
elAux = document.createElement('div');
elAux.className = 'user-synonyms__synonym-buttons';
elAux.setAttribute(`data-${entryClazz}`, a.type);
const h = document.createElement('h2');
h.className =
'wk-title wk-title--medium wk-title--underlined wk-title-custom';
h.innerText = capitalize(a.type);
elExtraContainer.append(h);
elExtraContainer.append(elAux);
}
const btn = document.createElement('a');
elAux.append(btn);
btn.className = 'user-synonyms__synonym-button';
btn.addEventListener('click', () => {
if (a.questionType === 'meaning') {
wkSynonyms.remove.meaning(a.text);
} else {
wkSynonyms.remove.reading(a.text, null, a.questionType);
}
updateAux();
});
const icon = document.createElement('i');
btn.append(icon);
icon.className = 'wk-icon fa-regular fa-times';
const span = document.createElement('span');
btn.append(span);
span.className = 'user-synonym__button-text';
span.innerText = a.text;
if (a.questionType !== 'meaning') {
span.innerText += ` (${a.questionType})`;
}
}
if (!answerCheckerParam) return;
const { item } = answerCheckerParam;
const aux = [
...item.auxiliary_meanings.map(({ meaning, ...t }) => ({
text: meaning,
questionType: 'meaning',
...t,
})),
];
if (item.auxiliary_readings) {
aux.push(
...item.auxiliary_readings.map(({ reading, ...t }) => ({
text: reading,
questionType: 'reading',
...t,
})),
);
}
if (aux.length) {
elExtraContainer.append(
(() => {
const elDetails = document.createElement('details');
const title = document.createElement('summary');
elDetails.append(title);
title.innerText = `WaniKani auxiliaries`;
const elButtonSet = document.createElement('div');
elDetails.append(elButtonSet);
elButtonSet.className = 'user-synonyms__synonym-buttons';
for (const a of aux) {
let elAux = elDetails.querySelector(
`[data-${entryClazz}="wk-${a.type}"]`,
);
if (!elAux) {
elAux = document.createElement('div');
elAux.className = 'user-synonyms__synonym-buttons';
elAux.setAttribute(`data-${entryClazz}`, `wk-${a.type}`);
const h = document.createElement('h2');
h.className =
'wk-title wk-title--medium wk-title--underlined wk-title-custom';
h.innerText = capitalize(a.type);
elDetails.append(h);
elDetails.append(elAux);
}
const span = document.createElement('span');
elAux.append(span);
span.className = 'user-synonym__button-text';
span.innerText = a.text;
if (a.questionType !== 'meaning') {
span.innerText += ` (${a.questionType})`;
}
}
return elDetails;
})(),
);
}
};
updateAux();
});
/** @param {string} s */
function capitalize(s) {
return s.replace(
/[a-z]+/gi,
(p) => p[0].toLocaleUpperCase() + p.substring(1),
);
}
/** @param {string} s */
function normalize(s) {
return s.toLocaleLowerCase().replace(/\W/g, ' ').trim();
}
const CP_KATA_A = 'ア'.charCodeAt(0);
const CP_HIRA_A = 'あ'.charCodeAt(0);
/** @param {string} s */
function toHiragana(s) {
return s.replace(/\p{sc=Katakana}/gu, (c) =>
String.fromCharCode(c.charCodeAt(0) - CP_KATA_A + CP_HIRA_A),
);
}
(function add_css() {
const style = document.createElement('style');
style.append(
document.createTextNode(/* css */ `
:root {
--color-modal-mask: unset;
}
.wk-modal__content {
/* top: unset;
bottom: 0; */
border-radius: 5px;
box-shadow: 0 0 4px 2px gray;
}
.subject-section__meanings-title {
min-width: 6em;
}
.user-synonyms__form_container::-webkit-scrollbar {
display: none;
}
.${entryClazz} .user-synonym__button-text {
line-height: 1.5em;
}
.${entryClazz} .user-synonym__button-text:not(:last-child)::after,
.${entryClazz} .user-synonyms_item:not(:last-child)::after {
content: ',';
margin-right: 0.5em;
}
.${entryClazz} details,
.${entryClazz} .wk-title-custom {
margin-top: 1em;
}
.${entryClazz} summary {
cursor: pointer;
}
`),
);
document.head.append(style);
})();
})();