[Userscript] ConfusionGuesser

Hi @Sinyaven I wonder if it’s a bug or something, because the script is not appearing in the list of scripts I have installed.

List of scripts


image

image

I even tried to downgrade to 1.6, but didn’t work either. I miss your script ;-; Is a good one

1 Like

Never mind. It’s suddenly working again! :sweat_smile: :partying_face:

1 Like

I’m glad to hear that it’s working again :slight_smile:

If I’m not mistaken, the screenshots were of the Tampermonkey popup menu that appears when you click the extension icon? I think this list only shows the scripts that run (or could run) on the current website, and considering that the list contains scripts like “Wanikani Heatmap” I assume this list was from the WaniKani dashboard? The ConfusionGuesser script only runs on the review page, so it isn’t supposed to appear in the list on the dashboard.

1 Like

Ohhh, that makes sense. Yeah, was from the dashboard, but I swear I couldn’t see it appear in the list even when I was on the reviews page.

At least it’s working now :+1:

image (reviews page)

2 Likes

Hey, fantastic userscript!!

I just have one small feature request: in the interest of making the display more neat and tidy, could we maybe get an option to disable/hide the “label” part? (i.e. the “丸 ⇔ 九,” “on ⇔ kun,” etc.)

Mousing over could still provide a breakdown of what exactly went wrong, but I feel like 99% of the time, I can already tell what went wrong simply by looking at what’s in the list (at which point, the labels are just visual clutter).

1 Like

Thank you!

Sure, no problem.

Version 1.8 + 1.9 patch-notes:

Added option to hide guess type

The settings menu now contains an option “Show guess types” in the “Interface” tab which allows to hide the guess type (丸⬄九, on⬄kun etc.).

Added hover tip to guesses

Hovering with the mouse cursor above a guess now displays a short description of the guess type (“Visually similar”, “Used on’yomi but needed kun’yomi” etc.).

If I broke something please let me know.
Link to previous script version for downgrading in case version 1.9 doesn’t work for you

1 Like

Awesome, thank you! :slight_smile:

1 Like

New question: what code can I add to the script to blacklist 私, 僕, 俺, and 吾輩?

I commonly type “I” to force an incorrect answer (it’s probably the easiest/fastest vowel for me to type, and typing any consonant instead would result in unresolved kana on reading reviews).

I can’t imagine genuinely confusing these words with anything else, anyway :rofl:

 

Edit 1:
Or, maybe it would be better to just block the script from displaying anything if I only type 1-2 letters, since there might be a long list of other suggestions waiting behind all the pronouns

Edit 2:
Yep, just had い bring up ~位

What exactly is the problem when the script shows guesses even if your input was not intended as a serious answer to the review?

When I created this script, I first had planned to add a filter that removes highly unlikely guesses, but after some considering I came to the conclusion that this would not be beneficial. Even if two kanji are visually completely distinct, there is still a chance that the user has confused them anyway. And I don’t see any harm in sometimes showing superfluous guesses, since the bad guesses should be sorted to the end of the list. And if you know that you don’t need guesses (for example because you entered “i” to signify that you don’t know), you can just ignore them?

If you really hate getting guesses when you enter “i” or “い”, you could search in the script for the lines

function guessForMeaning(answer, question) {

and

function guessForReading(answer, expected, question) {

and add one line after each of them:

	function guessForMeaning(answer, question) {
		if (answer === "i" || answer === "I") return [];

and

	function guessForReading(answer, expected, question) {
		if (answer === "い") return [];

You will have to redo this change every time the script gets updated.

1 Like

Isn’t there another script which lets you fail a card on purpose if you just don’t know it and want the answer? I think it works by entering a long string that would never be correct, iirc.

@Kai_973 seems to already know about that script, but wants a variation of it:

And if you know that you don’t need guesses (for example because you entered “i” to signify that you don’t know), you can just ignore them?

The pop-out is just very attention-grabbing :stuck_out_tongue_closed_eyes:

Thanks a ton!! :slight_smile:

Version 1.10 patch-notes:

Switched to spaced_repetition_systems API endpoint

The spoiler warning feature used the srs_stages endpoint to get the review intervals. This endpoint is now deprecated and gets replaced with the spaced_repetition_systems endpoint, so I updated my script accordingly.

Please let me know if something doesn’t work.
Link to previous script version for downgrading in case version 1.10 doesn’t work for you

1 Like

Hey, thanks for this script~ It’s helped me a fair bit so far^^

But, ConfusionGuesser has seemingly been failing to load for me, like I’ll get an answer wrong and ConfusionGuesser won’t pop up and it isn’t visible in the gear/settings menu.
Web console:


Downgrading to 1.9 appears to have fixed it, I’m using Firefox 79 with Tampermonkey 4.11.6117

I was not able to recreate this problem with a newly installed Firefox 79. And it is even more surprising that the changes in version 1.10 should cause this bug.

Since you said that the settings menu doesn’t contain an entry for ConfusionGuesser, the problem seems to occur immediately when loading the webpage.

Did you have the spoiler warning feature of ConfusionGuesser enabled (in the settings menu under Functionality => Guess only learned items)? If yes, do you still get the error if you disable it (set to “Never” in version 1.9 where you can access the settings menu, then upgrade to version 1.10)? Do you still get the error if you disable all other scripts except for WaniKani Open Framework and ConfusionGuesser?

1 Like

That’s strange :thinking: I suppose I could try a fresh Firefox profile, maybe my current one glitched out? The only other thing I can think of is that I’m using Linux, but that shouldn’t effect javascript afaik?

I did not, the only setting I’ve changed is enabling “use IDS” as recommended

Oop, I should’ve tried this from the start :sweat_smile: I’ve just tried and ConfusionGuesser still doesn’t load

With 1.10 + other scripts disabled:


Vs with 1.9:

Edit: I just tested a new profile… with the same result, unfortunately :confused:

Do you know how to edit scripts in Tampermonkey? If so, could you try replacing the ConfusionGuesser source code with this:

Code
// ==UserScript==
// @name         ConfusionGuesser
// @namespace    confusionguesser
// @version      1.10
// @description  If you give a wrong answer during reviews, it tries to guess which other WaniKani item you confused it with.
// @author       Sinyaven
// @match        https://www.wanikani.com/review/session
// @match        https://preview.wanikani.com/review/session
// @grant        none
// ==/UserScript==

(async function() {
	"use strict";
	/* global $, wkof */
    /* eslint no-multi-spaces: "off" */

	if (!window.wkof) {
		alert("ConfusionGuesser script requires Wanikani Open Framework.\nYou will now be forwarded to installation instructions.");
		window.location.href = "https://community.wanikani.com/t/instructions-installing-wanikani-open-framework/28549";
		return;
	}

	wkof.include("ItemData,Menu,Settings");
	await wkof.ready("document,ItemData,Menu,Settings");

	const PROBABILITY_SCALING_RENDAKU = 0.4;
	const PROBABILITY_SCALING_GEMINATION = 0.4;
	const MIN_KANJI_SIMILARITY = 0.1;
	const LEVENSHTEIN_TRANSPOSITION_COST = 0.3;

	const defaultColors = {
		vissimColor: "#FFD700",
		ononColor: "#00AAFF",
		onkunColor: "#00AAFF",
		kunonColor: "#5571E2",
		kunkunColor: "#5571E2",
		naonColor: "#FFA500",
		nakunColor: "#FFA500",
		onnaColor: "#FFA500",
		kunnaColor: "#FFA500",
		nanaColor: "#FFA500",
		specialColor: "#555555",
		textColor: "#FFFFFF"
	}

	const idsEqualities = {
		囗: "口",
		厶: "ム",
		亻: "イ"
	}

	// approximate mappings
	const radicalIdToChar = {
		8761: "丨",  // stick
		8762: "𠂉",  // gun
		8763: "丆",  // leaf
		8764: "人",  // hat === person
		8765: "⺌",  // triceratops
		8766: "丂",  // beggar
		8767: "丷",  // horns
		8788: "丷",  // explosion === horns
		8768: "业",  // spikes
		8770: "𧘇",  // kick
		8771: "之",  // hills
		8772: "爫",  // cleat
		8773: "𥃭",  // pope, only in 盾
		8774: "𦰩",  // chinese
		8775: "龷",  // blackjack
		8776: "𠫓",  // trash
		8777: "𠂤",  // bear, 㠯 is only in 官
		8779: "𡗗",  // spring
		8780: "𠃌",  // cape
		8781: "𠮛",  // creeper
		8782: "㦮",  // bar
		8784: "袁",  // zombie
		8785: "㑒",  // squid
		8787: "廿",  // yurt, more distinctive part
		8790: "俞",  // death-star
		8793: "𠦝",  // morning
		8794: "丞",  // coral
		8797: "鬯",  // psychopath, more distinctive part
		8798: "𠙻",  // satellite, more distinctive part
		8799: "耳",  // elf === ear
		8819: "龹",  // gladiator
		8783: "𭕄",  // grass
		8769: "𭕄",  // viking === grass
//		8778: "④",  // tofu
//		8792: "⑤",  // comb
//		8796: "⑥",  // cactus
	}

	const jokeMeaningFilter = {
		3237: ["The Answer"],
		5633: ["Nic Cage"]
	}

	const typeDescription = {
		onyomionyomi     : "Wrong on'yomi",
		onyomikunyomi    : "Used on'yomi but needed kun'yomi",
		kunyomionyomi    : "Used kun'yomi but needed on'yomi",
		kunyomikunyomi   : "Wrong kun'yomi",
		nanorionyomi     : "Used nanori but needed on'yomi",
		nanorikunyomi    : "Used nanori but needed kun'yomi",
		onyominanori     : "Used on'yomi but needed nanori",
		kunyominanori    : "Used kun'yomi but needed nanori",
		nanorinanori     : "Wrong nanori",
		onyomiundefined  : "Used on'yomi but needed special reading",
		kunyomiundefined : "Used kun'yomi but needed special reading",
		nanoriundefined  : "Used nanori but needed special reading",
		visuallySimilar  : "Visually similar"
	}

	let similarityCache = {};
	let oldSettings = {};

	// variables that will be initialized by settingsChanged()
	let idsHash = {};
	let byReading = {};
	let byCharacters = {};
	let byMeaning = {};
	let srsStages = [];

	// inject the overlay into the DOM
	let fFooter = document.querySelector("#reviews footer");
	let dOverlay = document.createElement("div");
	let dCollapsible = document.createElement("div");
	dCollapsible.classList.add("collapsed");
	dOverlay.id = "confusionGuesserOverlay";
	dOverlay.appendChild(dCollapsible);
	fFooter.parentElement.insertBefore(dOverlay, fFooter);

	let iShowLess = null;
	document.addEventListener("keydown", ev => {
		if (ev.target.nodeName === "INPUT" || dCollapsible.classList.contains("collapsed")) return;
		if (iShowLess && ev.key.toLowerCase() === wkof.settings.confusionguesser.hotkeyExpand && !ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) iShowLess.checked = !iShowLess.checked;
	});

	// add entry to hotkey list
	let tHotkeyEntry = document.createElement("tr");
	let sHotkeyKey = document.createElement("span");
	let tHotkeyKeyDisplay = document.createElement("td");
	let tHotkeyDescription = document.createElement("td");
	tHotkeyDescription.innerText = "Expand/collapse guesses";
	tHotkeyKeyDisplay.appendChild(sHotkeyKey);
	tHotkeyEntry.appendChild(tHotkeyKeyDisplay);
	tHotkeyEntry.appendChild(tHotkeyDescription);
	document.querySelector("#hotkeys tbody").appendChild(tHotkeyEntry);

	installCss();
	setupMenu();

	// create observer to react to wrong answers
	let iUserInput = document.getElementById("user-response");
	let fObserverTarget = document.querySelector("#answer-form fieldset");
	let observer = new MutationObserver(m => m.forEach(handleMutation));
	observer.observe(fObserverTarget, {attributes: true, attributeFilter: ["class"]});

	function handleMutation(mutation) {
		if (mutation.target.classList.contains("incorrect")) {
			showGuesses();
		} else {
			hideGuesses();
		}
	}

	async function showGuesses() {
		// clear cache
		similarityCache = {};

		let guesses = [];
		let currentItem = $.jStorage.get("currentItem");
		let expectedReading = currentItem.emph === "onyomi" ? currentItem.on : (currentItem.emph === "kunyomi" ? currentItem.kun : (currentItem.nanori || currentItem.kana));
		let question = radicalIdToChar[currentItem.id] || currentItem.rad || currentItem.kan || currentItem.voc;

		// ensure that idsFile finished loading
		idsHash = await idsHash;

		switch ($.jStorage.get("questionType")) {
			case "reading": guesses = guessForReading(iUserInput.value, expectedReading, question); break;
			case "meaning": guesses = guessForMeaning(iUserInput.value, question); break;
		}

		// for each item-reading combination find the guess with the highest rating
		let itemIdBestGuess = {};
		guesses.forEach((g, i) => {let id = g.item.id + (g.reading || ""); itemIdBestGuess[id] = (itemIdBestGuess[id] !== undefined && g.probability <= guesses[itemIdBestGuess[id]].probability) ? itemIdBestGuess[id] : i});
		//  pass on "show" property to this item's guess with the highest rating
		guesses.forEach(g => {if (g.show) guesses[itemIdBestGuess[g.item.id + (g.reading || "")]].show = true});
		// remove duplicate guesses suggesting the same WK item with the same reading (only keep the guess with highest probability)
		guesses = guesses.filter((g, i) => itemIdBestGuess[g.item.id + (g.reading || "")] === i);
		// if the list is long, remove guesses with probability 0
		// remove the guesses for kanji that the user got correct
		guesses = guesses.filter(g => g.type !== "correct");
		// sort descending by probability; also put guesses that are to be shown to the front
		guesses.sort((g1, g2) => g1.show === g2.show ? g2.probability - g1.probability : (g1.show ? -1 : 1));

		// remove old guesses
		while (dCollapsible.firstChild) {
			dCollapsible.removeChild(dCollapsible.firstChild);
		}
		if (guesses.length === 0) return;
		// display new guesses
		let dGuesses = document.createElement("div");
		if (guesses.some(g => !g.show)) addShowMoreOption(dCollapsible);
		guesses.forEach(g => { if (isSpoiler(g)) dGuesses.appendChild(spoilerButton(g)); dGuesses.appendChild(guessToDiv(g)); });
		dGuesses.id = "guesses";
		dCollapsible.appendChild(dGuesses);
		dCollapsible.classList.toggle("collapsed", false);
	}

	function hideGuesses() {
		dCollapsible.classList.toggle("collapsed", true);
	}

	function guessForMeaning(answer, question) {
		answer = answer.toLowerCase();
		let result = searchByMeaning(answer).map(item => ({type: "visuallySimilar", item, probability: rateSimilarity(item, question)}));
		// for each guess, select the meaning that is closest to the answer, and also adapt its probability by the meaning similarity
		result.forEach(r => {let best = meaningsOfItem(r.item).reduce((best, m) => {let rating = rateSimilarity(m.toLowerCase(), answer); return rating > best.rating ? {rating, meaning: m} : best}, {rating: -1}); r.meaning = best.meaning; r.probability *= best.rating});
		result.sort((a, b) => b.probability - a.probability);
		if (result.length > 0) result[0].show = true;
		return result;
	}

	function guessForReading(answer, expected, question) {
		expected = ensureArray(expected);
		let guesses = expected.flatMap(e => guessForReading_splitByOkurigana(answer, e, question));
		return guesses.concat(guessForReading_wholeVocab(answer, question));
	}

	function guessForReading_wholeVocab(answer, question) {
		let result = searchByReading(answer).filter(item => item.object === "vocabulary").map(item => ({type: "visuallySimilar", item, reading: answer, probability: rateSimilarity(item, question)}));
		result.sort((a, b) => b.probability - a.probability);
		if (result.length > 0) result[0].show = true;
		return result;
	}

	function guessForReading_splitByOkurigana(answer, expected, question) {
		question = replaceDecimalWithJapanese(question);
		let parts = question.split(/([\u301c\u3041-\u309f\u30a0-\u30ff]+)/);
		if (parts.length === 1) return guessForReading_splitByKanji(answer, expected, question);

		// if question contains okurigana, separate at these positions using regexp (assumption: can be done unambiguously - seems to work for every WK vocab's correct reading)
		let regex = new RegExp("^" + parts.map((p, idx) => idx & 1 ? p : (p ? "(.+)" : "()")).join("") + "$");
		answer = answer.match(regex);
		expected = expected.match(regex);
		// if okurigana does not match answer return no guesses
		if (!answer) return [];

		answer.shift();
		expected.shift();
		return answer.flatMap((a, idx) => guessForReading_splitByKanji(a, expected[idx], parts[idx * 2]));
	}

	function guessForReading_splitByKanji(answer, expected, question) {
		if (!question) return [];
		question = question.replace(/(.)々/g, "$1$1");
		let splitAnswer = possibleSplits(answer, question.length);
		let splitExpected = possibleSplits(expected, question.length);
		// try to split the expected reading to on/kun of each kanji; if not successful then return no guesses
		let kanjiReadings = splitExpected.filter(s => s.every((part, idx) => validReading(question[idx], part))).pop();
		// if it was not possible to validate the reading for every kanji, try to find a split where at least every second kanji has a validated reading
		kanjiReadings = kanjiReadings || splitExpected.filter(s => s.reduce((state, part, idx) => state === 2 ? 2 : (validReading(question[idx], part) ? 0 : ++state), 0) !== 2).pop();
		// if there was still no solution found, give up
		if (!kanjiReadings) return [];

		// for each split (of the answer entered by the user), guess for each part => array of arrays of arrays
		let guesses = splitAnswer.map(s => s.map((part, idx) => guessForReading_singleKanji(part, kanjiReadings[idx], question[idx])));
		// remove splits where at least one part resulted in no guesses
		guesses = guesses.filter(forSplit => forSplit.every(forPart => forPart.length > 0));
		// calculate probability of each guess
		guesses.forEach(forSplit => forSplit.forEach((forPart, idx) => forPart.forEach(guess => {guess.probability *= calculateGuessProbability(guess, question[idx])})));
		// sort guesses for each part descending by probability
		guesses.forEach(forSplit => forSplit.forEach(forPart => forPart.sort((a, b) => b.probability - a.probability)));
		// calculate probability of each split by multiplying the highest probability of each part together
		let splitProbability = guesses.map(forSplit => forSplit.reduce((totalProb, forPart) => totalProb * forPart.reduce((highestProb, guess) => Math.max(highestProb, guess.probability), 0), 1));
		// scale the probability of each guess with the highest probabilities for the other parts in the split (overall split probability, but instead of the best guess for the current part, take the current guess)
		guesses.forEach((forSplit, idx) => forSplit.forEach(forPart => forPart.forEach((guess, i) => {if (i !== 0) guess.probability *= splitProbability[idx] / forPart[0].probability})));
		guesses.forEach((forSplit, idx) => forSplit.forEach(forPart => {forPart[0].probability = splitProbability[idx]}));
		// find the split with the highest probability
		let idx = splitProbability.reduce((best, probability, idx) => probability <= best.probability ? best : {probability, idx}, {probability: 0}).idx;
		// mark the best guess (sorted, therefore at index 0) for each part of the best split with "show"
		(guesses[idx] || []).forEach(forPart => {forPart[0].show = true});
		return guesses.flat(2);
	}

	function guessForReading_singleKanji(answer, expected, question) {
		let reading = answer;
		if (answer === expected) return [{type: "correct", item: getItem(question), reading, probability: 1}];

		let                                  guesses =                searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 1}));
		reading = removeRendaku1(answer);    guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_RENDAKU})));
		reading = removeRendaku2(answer);    guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_RENDAKU})));
		reading = removeGemination1(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_GEMINATION})));
		reading = removeGemination2(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_GEMINATION})));
		reading = removeGemination3(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_GEMINATION})));
		reading = removeGemination4(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: PROBABILITY_SCALING_GEMINATION})));
		// change the type to onyomionyomi, onyomikunyomi etc. where applicable
		guesses.filter(g => g.item.data.characters === question).forEach(g => {g.type = validReading(question, answer) + validReading(question, expected)});
		return guesses;
	}

	function calculateGuessProbability(guess, question) {
		switch (guess.type) {
			case "visuallySimilar": return rateSimilarity(guess.item, question);
			case "correct":         return 1;
			default:                return 1; // onyomikunyomi etc.
		}
	}

	function getItem(characters, preferred = ["kanji", "vocabulary", "radical"]) {
		return (byCharacters[characters] || []).reduce((result, item) => result ? (preferred.indexOf(item.object) < preferred.indexOf(result.object) ? item : result) : item, undefined);
	}

	function ensureArray(listOrEntry) {
		return Array.isArray(listOrEntry) ? listOrEntry : (listOrEntry === undefined ? [] : [listOrEntry]);
	}

	function searchByReading(reading) {
		return byReading[reading] || [];
	}

	function searchByMeaning(meaning) {
		return byMeaning.get(meaning);
	}

	function possibleSplits(reading, partCount) {
		if (partCount === 1) return [[reading]];

		let result = Array.from({length: reading.length - partCount + 1}, (val, idx) => ({start: reading.substr(0, idx + 1), end: reading.substr(idx + 1)}));
		result = result.flatMap(r => possibleSplits(r.end, partCount - 1).map(s => [r.start].concat(s)));
		result = result.filter(r => r.every(part => !"ぁぃぅぇぉっゃゅょゎん".includes(part[0])));
		return result;
	}

	function validReading(kanji, reading) {
		let item = getItem(kanji);
		return item.data.readings.reduce((type, r) => type || (r.reading === reading || applyRendaku1(r.reading) === reading || applyRendaku2(r.reading) === reading || applyGemination(r.reading) === reading ? r.type : undefined), undefined);
	}

	function applyRendaku1(reading) {
		let idx = "かきくけこさしすせそたちつてとはひふへほ".indexOf(reading[0]);
		return idx >= 0 ? "がぎぐげござじずぜぞだぢづでどばびぶべぼ"[idx] + reading.substr(1) : undefined;
	}

	function applyRendaku2(reading) {
		let idx = "はひふへほち".indexOf(reading[0]);
		return idx >= 0 ? "ぱぴぷぺぽじ"[idx] + reading.substr(1) : undefined;
	}

	function applyGemination(reading) {
		// definitely not based on any grammar rules, but it works good enough for WK vocab
		let replacementGemination = "ちつくき".includes(reading.substr(-1));
		return replacementGemination ? reading.substr(0, reading.length - 1) + "っ" : reading + "っ";
	}

	function removeRendaku1(reading) {
		let idx = "がぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽ".indexOf(reading[0]);
		return idx >= 0 ? "かきくけこさしすせそたちつてとはひふへほはひふへほ"[idx] + reading.substr(1) : undefined;
	}

	function removeRendaku2(reading) {
		return reading[0] === "じ" ? "ち" + reading.substr(1) : undefined;
	}

	function removeGemination1(reading) {
		return reading.substr(-1) === "っ" ? reading.substr(0, reading.length - 1) : undefined;
	}

	function removeGemination2(reading) {
		return reading.substr(-1) === "っ" ? reading.substr(0, reading.length - 1) + "ち" : undefined;
	}

	function removeGemination3(reading) {
		return reading.substr(-1) === "っ" ? reading.substr(0, reading.length - 1) + "つ" : undefined;
	}

	function removeGemination4(reading) {
		return reading.substr(-1) === "っ" ? reading.substr(0, reading.length - 1) + "く" : undefined;
	}

	// where is removeGemination5() with き? I don't know. I'm also dumbfounded.
	// (dumbfounded 呆気 seems to be the only WK vocab which turns き into っ, so whatever)

	// ---IDEOGRAPHIC DESCRIPTION SEQUENCE STUFF--- //

	// turns IDS into tree structure
	function getKanjiComponents(kanji) {
		let line = idsHash[kanji];
		if (!line) return [kanji];
		// get all decomposition variations that apply to the Japanese character appearance (I think that's what [J] in ids.txt means)
		let variations = line.split("\t").filter(v => !v.match(/\[[^J\]]+\]/));
		if (variations.length === 0) variations = [line.split("\t")[0]]; // fix for lines such as "U+225BB	𢖻	⿱心夂[G]	⿱心夊[T]" - TODO: research what the letters in [] mean
		return variations.map(v => v === kanji ? (idsEqualities[v] || v) : parseIds(v[Symbol.iterator]()));
	}

	function parseIds(iter) {
		let idc = iter.next().value;
		if (!"⿰⿱⿲⿳⿴⿵⿶⿷⿸⿹⿺⿻".includes(idc)) return getKanjiComponents(idc)[0];
		let node = {idc, parts: []};
		node.parts[0] = parseIds(iter);
		node.parts[1] = parseIds(iter);
		if (!"⿲⿳".includes(idc)) return node;
		node.parts[2] = parseIds(iter);
		return node;
	}

	// turns tree structure to path list
	function componentTreeToPathList(components) {
		return components.parts ? components.parts.flatMap((part, partIdx) => componentTreeToPathList(part).map(path => components.idc + partIdx + path)) : [components];
	}

	function ratePathSimilarity(path1, path2) {
		let array1 = path1.match(/[⿰-⿻][0-9]/g) || [];
		let array2 = path2.match(/[⿰-⿻][0-9]/g) || [];
		if (array1.length === 0 && array2.length === 0) return 1;
		let dist = levenshteinDistance(array1, array2, (node1, node2) => node1[0] !== node2[0] ? 1 : (node1[1] === node2[1] ? 0 : 0.5));
		// both paths lead to the same component, therefore they are always a little similar => array.length + 1
		return (1 - dist / (Math.max(array1.length, array2.length) + 1)) / array1.reduce((total, a) => total * ("⿲⿳".includes(a[0]) ? 3 : 2), 1);
	}

	function rateComponentSimilarity(components1, components2) {
		let pathList1 = componentTreeToPathList(components1);
		let pathList2 = componentTreeToPathList(components2);
		let paths1 = {};
		let paths2 = {};
		// using Array.from(p).slice(-1) instead of e.g. p.substr(-1) to maintain surrogate pairs
		pathList1.forEach(p => {let char = Array.from(p).slice(-1)[0]; paths1[char] = (paths1[char] || []).concat([p])});
		pathList2.forEach(p => {let char = Array.from(p).slice(-1)[0]; paths2[char] = (paths2[char] || []).concat([p])});
		let similarity = pathList1.reduce((total, p1) => total + (paths2[Array.from(p1).slice(-1)] || []).reduce((best, p2) => Math.max(best, ratePathSimilarity(p1, p2)), 0), 0);
		similarity    += pathList2.reduce((total, p2) => total + (paths1[Array.from(p2).slice(-1)] || []).reduce((best, p1) => Math.max(best, ratePathSimilarity(p2, p1)), 0), 0);
		return similarity / 2;
	}

	function rateKanjiSimilarityUsingIds(kanji1, kanji2) {
		let components1 = getKanjiComponents(kanji1);
		let components2 = getKanjiComponents(kanji2);
		// choose the pair with the best similarity rating
		return components1.flatMap(c1 => components2.map(c2 => [c1, c2])).reduce((best, pair) => Math.max(best, rateComponentSimilarity(pair[0], pair[1])), 0);
	}

	function rateSimilarity(itemOrText1, itemOrText2) {
		if (itemOrText1 === itemOrText2) return 1;
		let text1 = typeof itemOrText1 === "string" ? itemOrText1 : (itemOrText1.data.characters || radicalIdToChar[itemOrText1.id]);
		let text2 = typeof itemOrText2 === "string" ? itemOrText2 : (itemOrText2.data.characters || radicalIdToChar[itemOrText2.id]);
		if (!text1 || !text2) return 0;
		let cacheId1 = text1 + "§" + text2;
		let cacheId2 = text2 + "§" + text1;
		if (similarityCache[cacheId1]) return similarityCache[cacheId1];
		if (similarityCache[cacheId2]) return similarityCache[cacheId2];
		let chars1 = Array.from(text1);
		let chars2 = Array.from(text2);

		if (chars1.length > 1 || chars2.length > 1) {
			let dist = levenshteinDistance(chars1, chars2, (char1, char2) => 1 - rateSimilarity(char1, char2));
			// normalize distance to [0;1] and invert it so that 0 is complete mismatch
			return similarityCache[cacheId1] = 1 - dist / Math.max(chars1.length, chars2.length);
		}

		let item1 = typeof itemOrText1 === "string" ? getItem(itemOrText1) : itemOrText1;
		let item2 = typeof itemOrText2 === "string" ? getItem(itemOrText2) : itemOrText2;

		let similarity = item1 && item2 ? MIN_KANJI_SIMILARITY : 0;
		if (wkof.settings.confusionguesser.useWkRadicals && item1 && item2) {
			let radicals1 = item1.data.component_subject_ids || [item1.id];
			let radicals2 = item2.data.component_subject_ids || [item2.id];
			let matchCount = radicals1.reduce((matchCount, radicalId) => matchCount + (radicals2.includes(radicalId) ? 1 : 0), 0);
			similarity = Math.max(2 * matchCount / (radicals1.length + radicals2.length), similarity);
		}
		if (wkof.settings.confusionguesser.useWkSimilarity && item1 && item2) {
			if ((item1.data.visually_similar_subject_ids || []).includes(item2.id) || (item2.data.visually_similar_subject_ids || []).includes(item1.id)) similarity = Math.max(similarity, 0.5);
		}
		if (wkof.settings.confusionguesser.useIds) {
			similarity = Math.max(similarity, rateKanjiSimilarityUsingIds(text1, text2));
		}
		return similarityCache[cacheId1] = similarity;
	}

	// levenshtein distance with restricted transposition of two adjacent characters (so in fact it's not levenshtein distance but optimal string alignment distance)
	function levenshteinDistance(array1, array2, elementDistanceFunction = (e1, e2) => e1 === e2 ? 0 : 1) {
		// initialize distance matrix
		let d = Array.from(new Array(array1.length + 1), (val, i) => Array.from(new Array(array2.length + 1), (v, j) => i === 0 ? j : (j === 0 ? i : 0)));
		// fill distance matrix from top left to bottom right
		d.forEach((row, i) => {if (i > 0) row.forEach((val, j) => {if (j > 0) row[j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + elementDistanceFunction(array1[i - 1], array2[j - 1]), i > 1 && j > 1 ? d[i - 2][j - 2] + LEVENSHTEIN_TRANSPOSITION_COST + elementDistanceFunction(array1[i - 2], array2[j - 1]) + elementDistanceFunction(array1[i - 1], array2[j - 2]) : Number.MAX_SAFE_INTEGER)})});
		// result is in the bottom right of the matrix
		return d[array1.length][array2.length];
	}

	// ---DECIMAL TO JAPANESE STUFF--- //

	function replaceDecimalWithJapanese(text) {
		let parts = text.split(/([0123456789]+)/g);
		return parts.map((p, i) => i & 1 ? decimalToJapanese(p) : p).join("");
	}

	function decimalToJapanese(decimal, zero = "零") {
		let groups = Array.from(decimal).reverse().reduce((result, digit, idx) => {result[Math.floor(idx / 4)] = (result[Math.floor(idx / 4)] || []).concat([digit]); return result;}, []);
		let japanese = groups.reduce((result, group, i) => { group = decimalToJapanese_4block(group); return !group ? result : (group + " 万億兆"[i] + result); }, "").replace(" ", "");
		return japanese || zero;
	}

	function decimalToJapanese_4block(array) {
		return array.reduce((result, digit, i) => { digit = decimalToJapanese_digit(digit); return !digit ? result : ((i > 0 && digit === "一" ? "" : digit) + " 十百千"[i] + result); }, "").replace(" ", "");
	}

	function decimalToJapanese_digit(digit) {
		return " 一二三四五六七八九"["0123456789".indexOf(digit)].replace(" ", "");
	}

	async function loadIdsHash() {
		if (wkof.file_cache.dir.ideographicDescriptionSequences) return wkof.file_cache.load("ideographicDescriptionSequences");

		let result = {};
		let idsFile = await wkof.load_file("https://raw.githubusercontent.com/cjkvi/cjkvi-ids/master/ids.txt");
		let lines = idsFile.matchAll(/U\+\S+\t(\S+)\t(.+)/g);
		for (let line of lines) {
			result[line[1]] = line[2];
		}
		wkof.file_cache.save("ideographicDescriptionSequences", result);
		return result;
	}

	// ---FUZZY SEARCH STUFF (N-GRAM)--- //

	function histogram(array) {
		return [...array.reduce((result, element) => result.set(element, (result.get(element) || 0) + 1), new Map())];
	}

	function highestBins(histogram, nrOfBins, minBinHeight) {
		return histogram.sort((a, b) => b[1] - a[1]).filter((h, idx) => idx < nrOfBins && h[1] >= minBinHeight);
	}

	function hashGrams(wkItems, gramSizes = [4, 3]) {
		let result = {};
		wkItems.forEach(item => meaningsOfItem(item).forEach((m, mNr) => {let entry = {item, mNr}; toGrams(m, gramSizes).flat().forEach(g => {result[g] = (result[g] || []).concat(entry)})}));
		result.get = (text, gramSizes = [4, 3]) => gramSizes.reduce((prevResult, size) => prevResult.length > 0 ? prevResult : highestBins(histogram(toGrams(text, [size])[0].flatMap(gram => result[gram] || [])), 10, text.length / 2).map(h => h[0].item), []);
		return result;
	}

	function toGrams(text, gramSizes = [4, 3]) {
		text = "§" + text.toLowerCase() + "§";
		return gramSizes.map(size => Array.from(new Array(text.length - size + 1), (val, idx) => text.substr(idx, size)));
	}

	function meaningsOfItem(item) {
		let meanings = jokeMeaningFilter[item.id] ? item.data.meanings.filter(m => !jokeMeaningFilter[item.id].includes(m.meaning)) : item.data.meanings;
		return meanings.concat(wkof.settings.confusionguesser.includeWhitelist ? item.data.auxiliary_meanings.filter(m => m.type === "whitelist") : []).map(m => m.meaning).concat(wkof.settings.confusionguesser.includeUserSynonyms && item.study_materials ? item.study_materials.meaning_synonyms : []);
	}

	// ---DOM STUFF--- //

	function addShowMoreOption(div) {
		iShowLess = document.createElement("input");
		let lShowMore = document.createElement("label");
		let lShowLess = document.createElement("label");
		iShowLess.id = "showLess";
		iShowLess.type = "checkbox";
		iShowLess.checked = !wkof.settings.confusionguesser.showAllByDefault;
		lShowMore.htmlFor = "showLess";
		lShowLess.htmlFor = "showLess";
		lShowMore.innerText = "+";
		lShowLess.innerText = "-";
		div.appendChild(lShowMore);
		div.appendChild(iShowLess);
		div.appendChild(lShowLess);
	}

	function guessToDiv(guess) {
		let a = document.createElement("a");
		let sJapanese = document.createElement("span");
		let sEnglish = document.createElement("span");
		let sProbability = document.createElement("span");
		let rJapanese = document.createElement("ruby");
		a.href = guess.item.data.document_url;
		a.target = "_blank";
		a.title = typeDescription[guess.type];
		a.classList.add(guess.type);
		a.classList.add(guess.item.object);
		if (guess.show) a.classList.add("show");
		rJapanese.lang = "ja-JP";
		rJapanese.innerText = guess.item.data.characters || "";
		sEnglish.innerText = guess.meaning || guess.item.data.meanings[0].meaning;
		sProbability.innerText = guess.probability.toFixed(2);

		if (!guess.item.data.characters) {
			appendSvgChild(guess.item.data.character_images, rJapanese);
		}
		if (guess.item.data.readings) {
			let rFurigana = document.createElement("rt");
			rFurigana.innerText = guess.reading || guess.item.data.readings[0].reading;
			rJapanese.appendChild(rFurigana);
		}

		sJapanese.appendChild(rJapanese);
		a.appendChild(sJapanese);
		a.appendChild(sEnglish);
		a.appendChild(sProbability);
		return a;
	}

	async function appendSvgChild(character_images, element) {
		try {
			element.innerHTML += await wkof.load_file(character_images.filter(c => c.content_type === "image/svg+xml" && !c.metadata.inline_styles)[0].url);
		} catch(e) {
			let iSvg = document.createElement("img");
			iSvg.src = character_images.filter(c => c.content_type === "image/svg+xml" && c.metadata.inline_styles)[0].url;
			element.appendChild(iSvg);
		}
	}

	function spoilerButton(guess) {
		let b = document.createElement("button");
		b.innerText = "Show spoiler";
		if (guess.show) b.classList.add("show");
		b.addEventListener("click", (e) => e.target.parentElement.removeChild(e.target));
		return b;
	}

	function isSpoiler(guess) {
		if (wkof.settings.confusionguesser.spoilerHandling === "none" || !guess.item.assignments.available_at) return false;
		let nextReview = new Date(guess.item.assignments.available_at);
		let remainingTimeInMs = nextReview - new Date();
		let stage = srsStages[guess.item.data.spaced_repetition_system_id].data.stages[guess.item.assignments.srs_stage];
		if (!stage) return false; // should not happen
		return remainingTimeInMs < srsStageIntervalInMs(stage) * parseInt(wkof.settings.confusionguesser.spoilerHandling) / 100;
	}

	function srsStageIntervalInMs(stage) {
		let result = stage.interval;
		if (stage.interval_unit === "milliseconds") return result; result *= 1000;
		if (stage.interval_unit ===      "seconds") return result; result *= 60;
		if (stage.interval_unit ===      "minutes") return result; result *= 60;
		if (stage.interval_unit ===        "hours") return result; result *= 24;
		if (stage.interval_unit ===         "days") return result; result *= 7;
		if (stage.interval_unit ===        "weeks") return result;
		return null;
	}

	function fromButtonColor() {
		let button = document.querySelector("#option-kana-chart > span > i");
		let textColor = button ? getComputedStyle(button).color : "rgb(0, 0, 0)";
		let color = "rgb(255, 255, 255)";
		while (button && getComputedStyle(button).backgroundColor.match(/rgba\(.*0\)/)) button = button.parentElement;
		color = rbgToHex(button ? getComputedStyle(button).backgroundColor : color);
		let result = Object.assign({}, defaultColors);
		Object.keys(result).forEach(k => result[k] = color);
		result.textColor = rbgToHex(textColor);
		return result;
	}

	function rbgToHex(rgbString) {
		let rgb = rgbString.match(/\( *([^,]*), *([^,]*), *([^,\)]*)/);
		return rgb.slice(1, 4).reduce((result, c) => result + parseInt(c).toString(16).padStart(2, "0"), "#");
	}

	// ---SETTINGS STUFF--- //

	async function settingsChanged(settings) {
		// find changed settings
		let changes = Object.keys(settings).filter(key => oldSettings[key] !== settings[key]);
		let refetchItems = false;

		changes.forEach(key => {
			switch(key) {
				case "useIds":
					idsHash = settings.useIds ? loadIdsHash() : {};
					if (!settings.useIds) wkof.file_cache.delete("ideographicDescriptionSequences");
					return;
				case "spoilerHandling":
					if (oldSettings.guessOnlyLearnedItems || oldSettings.spoilerHandling !== "none") return;
				case "guessOnlyLearnedItems":
				case "useFuzzySearch":
				case "includeWhitelist":
				case "includeUserSynonyms":
					refetchItems = true;
					return;
				case "showAsOverlay":
					dOverlay.classList.toggle("noOverlay", !settings.showAsOverlay);
					return;
				case "showTypes":
					dOverlay.classList.toggle("hideTypes", !settings.showTypes);
					return;
				case "showRatings":
					dOverlay.classList.toggle("hideRatings", !settings.showRatings);
					return;
				case "highContrast":
					dCollapsible.classList.toggle("highContrast", settings.highContrast);
					return;
				case "fontSize":
					dOverlay.style.setProperty("font-size", settings[key]);
					return;
				case "hotkeyExpand":
					tHotkeyEntry.classList.toggle("disabled", settings.hotkeyExpand === "");
					sHotkeyKey.innerText = settings.hotkeyExpand.toUpperCase();
					return;
				default:
					if (key.endsWith("Color")) dOverlay.style.setProperty("--" + key.substr(0, key.length - 5), settings[key]);
					return;
			}
		});

		if (refetchItems) {
			let config = {wk_items: {options: {study_materials: settings.includeUserSynonyms, assignments: settings.spoilerHandling !== "none"}}};
			if (settings.guessOnlyLearnedItems) config.wk_items.filters = {srs: {value: ["lock", "init"], invert: true}};
			let items = await wkof.ItemData.get_items(config);
			byReading = wkof.ItemData.get_index(items, "reading");
			byCharacters = items.reduce((result, i) => { result[i.data.characters] = (result[i.data.characters] || []).concat([i]); return result; }, {});
			byMeaning = settings.useFuzzySearch ? hashGrams(items) : {get: text => {text = text.toLowerCase(); return items.filter(item => meaningsOfItem(item).some(m => m.toLowerCase().startsWith(text)));}};
		}

		// SRS stage durations are needed if spoiler handling is activated
		if (settings.spoilerHandling !== "none" && srsStages.length === 0) {
			if (!wkof.file_cache.dir["Apiv2.spaced_repetition_systems"] || new Date() - new Date(wkof.file_cache.dir["Apiv2.spaced_repetition_systems"].added) > 604800000) {
				srsStages = await wkof.Apiv2.get_endpoint("spaced_repetition_systems");
			} else {
				srsStages = (await wkof.file_cache.load("Apiv2.spaced_repetition_systems")).data;
			}
		}

		Object.assign(oldSettings, settings);
	}

	function setupMenu() {
		wkof.Menu.insert_script_link({name: "confusionguesser", submenu: "Settings", title: "ConfusionGuesser", on_click: openSettings});

		let defaults = {
			guessOnlyLearnedItems: true,
			spoilerHandling: "none",
			includeWhitelist: false,
			includeUserSynonyms: false,
			useFuzzySearch: true,
			useWkRadicals: true,
			useWkSimilarity: true,
			useIds: false,
			showAsOverlay: true,
			showAllByDefault: false,
			showTypes: true,
			showRatings: false,
			highContrast: false,
			fontSize: "1.12rem",
			hotkeyExpand: "e"
		}
		Object.assign(defaults, defaultColors);
		return wkof.Settings.load("confusionguesser", defaults).then(settingsChanged);
	}

	function openSettings() {
		let fontSizeOptions = {};
		fontSizeOptions["0.8rem"] = "Small";
		fontSizeOptions["1.12rem"] = "Medium";
		fontSizeOptions["1.5rem"] = "Large";
		fontSizeOptions["2rem"] = "Probably too large";
		fontSizeOptions["3rem"] = "Certainly too large";
		let spoilerHandlingOptions = {};
		spoilerHandlingOptions["none"] = "Never";
		spoilerHandlingOptions["0"] = "If in this batch";
		spoilerHandlingOptions["10"] = "If sooner than 10% of the SRS interval";
		spoilerHandlingOptions["20"] = "If sooner than 20% of the SRS interval";
		spoilerHandlingOptions["30"] = "If sooner than 30% of the SRS interval";
		spoilerHandlingOptions["50"] = "If sooner than 50% of the SRS interval";
		spoilerHandlingOptions["75"] = "If sooner than 75% of the SRS interval";
		let dialog = new wkof.Settings({
			script_id: "confusionguesser",
			title: "ConfusionGuesser Settings",
			on_save: settingsChanged,
			content: {
				tabFunctionality:            {type: "page",     label: "Functionality",                content: {
					guessOnlyLearnedItems:   {type: "checkbox", label: "Guess only learned items",     hover_tip: "When enabled, the guess list will only contain items that you have already learned on WaniKani."},
					spoilerHandling:         {type: "dropdown", label: "Hide spoiler guesses",         hover_tip: "Select if guesses for WK items that will soon come up for review should be hidden.", content: spoilerHandlingOptions},
					grpMeaningGuesses:       {type: "group",    label: "Meaning guesses",              content: {
						useFuzzySearch:      {type: "checkbox", label: "Use fuzzy search",             hover_tip: "When enabled, guesses for a wrong meaning also contain non-exact matches. Might increase loading time of the review page."},
						includeWhitelist:    {type: "checkbox", label: "Include hidden whitelist",     hover_tip: "When enabled, guesses for a wrong meaning also consider the hidden whitelist (for example 'boob grave')."},
						includeUserSynonyms: {type: "checkbox", label: "Include user synonyms",        hover_tip: "When enabled, guesses for a wrong meaning also consider your entered synonyms."},
					}},
					grpSimilarityRating:     {type: "group",    label: "Kanji similarity rating",      content: {
						useWkRadicals:       {type: "checkbox", label: "Use WK radicals",              hover_tip: "With this, kanji are considered similar if they share some WK radicals."},
						useWkSimilarity:     {type: "checkbox", label: "Use WK visually similar list", hover_tip: "With this, kanji are considered similar if WK has them listed as visually similar."},
						useIds:              {type: "checkbox", label: "Use IDS",                      hover_tip: "When enabled, a 2MB text file with ideographic description sequences will be downloaded and stored locally to improve kanji similarity ratings."}
					}}
				}},
				tabInterface:                {type: "page",     label: "Interface",                    content: {
					showAsOverlay:           {type: "checkbox", label: "Show as overlay",              hover_tip: "Display the guess list as an overlay to the right of the question or at the bottom of the page. On narrow displays, the list is always at the bottom."},
					showAllByDefault:        {type: "checkbox", label: "Show all guesses by default",  hover_tip: "When enabled, the guess list will be expanded by default."},
					showTypes:               {type: "checkbox", label: "Show guess types",             hover_tip: "Show 丸⬄九 for guesses based on visual similarity, on⬄kun if you used on'yomi but needed kun'yomi, etc."},
					showRatings:             {type: "checkbox", label: "Show ratings",                 hover_tip: "When enabled, a number between 0 and 1 to the right of each guess shows the rating of that guess."},
					highContrast:            {type: "checkbox", label: "High contrast mode",           hover_tip: "When enabled, the overlay will have a dark background. Always active if the guesses are displayed at the bottom of the page."},
					fontSize:                {type: "dropdown", label: "Font size",                    hover_tip: "Select the font size for the guesses.", content: fontSizeOptions},
					hotkeyExpand:            {type: "text",     label: "Hotkey expand guesses",        hover_tip: "Choose a hotkey to expand/collapse the list of guesses.", match: /^.?$/}
				}},
				tabGuessColors:              {type: "page",     label: "Guess colors",                 content: {
					vissimColor:             {type: "color",    label: "丸⬄九",                        hover_tip: typeDescription.visuallySimilar},
					ononColor:               {type: "color",    label: "on⬄on",                       hover_tip: typeDescription.onyomionyomi},
					onkunColor:              {type: "color",    label: "on⬄kun",                      hover_tip: typeDescription.onyomikunyomi},
					kunonColor:              {type: "color",    label: "kun⬄on",                      hover_tip: typeDescription.kunyomionyomi},
					kunkunColor:             {type: "color",    label: "kun⬄kun",                     hover_tip: typeDescription.kunyomikunyomi},
					naonColor:               {type: "color",    label: "na⬄on",                       hover_tip: typeDescription.nanorionyomi},
					nakunColor:              {type: "color",    label: "na⬄kun",                      hover_tip: typeDescription.nanorikunyomi},
					onnaColor:               {type: "color",    label: "on⬄na",                       hover_tip: typeDescription.onyominanori},
					kunnaColor:              {type: "color",    label: "kun⬄na",                      hover_tip: typeDescription.kunyominanori},
					nanaColor:               {type: "color",    label: "na⬄na",                       hover_tip: typeDescription.nanorinanori},
					specialColor:            {type: "color",    label: "Special",                      hover_tip: "Reading exception / WK does not list this reading"},
					textColor:               {type: "color",    label: "Text",                         hover_tip: "Text color"},
					resetColor:              {type: "button",   label: "Reset colors to default", text: "Reset", on_click: (name, config, on_change) => {Object.assign(wkof.settings.confusionguesser, defaultColors); dialog.refresh();}},
					buttonColor:             {type: "button",   label: "From button color", text: "Load", on_click: (name, config, on_change) => {Object.assign(wkof.settings.confusionguesser, fromButtonColor()); dialog.refresh();}, hover_tip: "Use the colors of the buttons below the input box. Useful for dark mode users."}
				}}
			}
		});
		dialog.open();
	}

	function installCss() {
		let css = "#confusionGuesserOverlay { position: absolute; top: 3rem; right: 0; padding-top: 0.6rem; z-index: 100; overflow-x: hidden; pointer-events: none; }" +
			"#confusionGuesserOverlay.noOverlay { position: relative; top: 0; margin-top: 3rem; }" +
			"#confusionGuesserOverlay > div { border-style: solid none; background: linear-gradient(to left, rgba(0,0,0,0.2), transparent 50%, transparent); border-image: linear-gradient(to left, rgba(255,255,255,0.8), transparent 65%, transparent) 1; transition: transform 0.2s; pointer-events: initial; }" +
			"#confusionGuesserOverlay > div.highContrast, #confusionGuesserOverlay.noOverlay > div { background-color: rgba(0, 0, 0, 0.4); padding-left: 0.7rem; }" +
			"#confusionGuesserOverlay > div.collapsed { transform: translateX(100%); }" +
			"#guesses { margin: 0.6rem 0; padding: 0 60px 0.6rem 0; max-height: 13rem; overflow-x: hidden; overflow-y: auto; display: grid; grid-template-columns: auto auto 1fr auto; grid-row-gap: 0.2rem; }" +
			"#confusionGuesserOverlay.noOverlay #guesses { max-height: initial; overflow-y: auto; }" +
			"#confusionGuesserOverlay.hideRatings #guesses > a > *:last-child { display: none; }" +
			"#confusionGuesserOverlay.hideTypes #guesses > a::before { display: none; }" +
			"#confusionGuesserOverlay.hideTypes #guesses > a > *:first-child { border-radius: 0.5rem 0 0 0.5rem; }" +
			"#guesses > a { display: contents; color: var(--text); text-decoration: none; --type: '?' }" +
			"#guesses > a.onyomionyomi     { --gc: var(--onon); --type: 'on⬄on' }" +
			"#guesses > a.onyomikunyomi    { --gc: var(--onkun); --type: 'on⬄kun' }" +
			"#guesses > a.kunyomionyomi    { --gc: var(--kunon); --type: 'kun⬄on' }" +
			"#guesses > a.kunyomikunyomi   { --gc: var(--kunkun); --type: 'kun⬄kun' }" +
			"#guesses > a.nanorionyomi     { --gc: var(--naon); --type: 'na⬄on' }" +
			"#guesses > a.nanorikunyomi    { --gc: var(--nakun); --type: 'na⬄kun' }" +
			"#guesses > a.onyominanori     { --gc: var(--onna); --type: 'on⬄na' }" +
			"#guesses > a.kunyominanori    { --gc: var(--kunna); --type: 'kun⬄na' }" +
			"#guesses > a.nanorinanori     { --gc: var(--nana); --type: 'na⬄na' }" +
			"#guesses > a.onyomiundefined  { --gc: var(--special); --type: 'on⬄special' }" +
			"#guesses > a.kunyomiundefined { --gc: var(--special); --type: 'kun⬄special' }" +
			"#guesses > a.nanoriundefined  { --gc: var(--special); --type: 'na⬄special' }" +
			"#guesses > a.visuallySimilar  { --gc: var(--vissim); --type: '丸⬄九' }" +
			"#guesses > a.radical          { --ic: var(--radical-color, #00AAFF); }" +
			"#guesses > a.kanji            { --ic: var(--kanji-color, #FF00AA); }" +
			"#guesses > a.vocabulary       { --ic: var(--vocabulary-color, #AA00FF); }" +
			"#guesses > a > *, #guesses > a::before { display: flex; align-items: center; padding: 0.2rem 0.5rem; }" +
			"#guesses > a > *:first-child { display: initial; grid-column: 2; }" +
			"#guesses > a::before { content: var(--type); border-radius: 0.5rem 0 0 0.5rem; color: rgba(255, 255, 255, 0.3); grid-column: 1; }" +
			"#guesses > a > *:last-child, #confusionGuesserOverlay.hideRatings #guesses > a > *:nth-last-child(2) { border-radius: 0 0.5rem 0.5rem 0; justify-content: flex-end; border-right: solid var(--ic); }" +
			"#guesses svg, #guesses ruby img { width: 1em; fill: none; stroke: currentColor; stroke-width: 85; stroke-linecap: square; stroke-miterlimit: 2; transform: translateY(0.15em); }" +
			"#guesses > a.show svg, #guesses > a.show ruby img { filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.4)); }" +
			"#guesses > a.show > *, #guesses > a.show::before, #guesses > button.show { font-size: 1.35em; text-shadow: 2px 2px 3px rgba(0,0,0,0.4); background-color: var(--gc); box-shadow: 0.5rem 0.2rem 0.2rem #0000002b; }" +
			"#guesses > button { grid-column: 1 / -1; padding: 0.5rem; color: rgba(255, 255, 255, 0.3); background: none; border: thin dashed rgba(255, 255, 255, 0.3); border-radius: 0.5rem; --gc: var(--vissim); }" +
			"#guesses > button.show { border: none; }" +
			"#guesses > button + a { display: none; }" +
			"#confusionGuesserOverlay > div > input { display: none; }" +
			"#confusionGuesserOverlay > div > label { position: absolute; top: 0; right: 25px; background-color: white; width: 1.4rem; line-height: 1.4rem; text-align: center; border-radius: 0.3rem; font-weight: bold; cursor: pointer; font-size: large; }" +
			"#confusionGuesserOverlay.noOverlay > div > label { border: solid thin rgba(0, 0, 0, 0.4); }" +
			"#confusionGuesserOverlay > div > :checked + label { display: none }" +
			"#confusionGuesserOverlay > div > :checked ~ #guesses > a:not(.show) > *, :checked ~ #guesses > a:not(.show)::before, #confusionGuesserOverlay > div > :checked ~ #guesses > button:not(.show) { display: none; }" +
			"#hotkeys tr.disabled { display: none; }" +
			"@media (max-width: 767px) {" +
			" #confusionGuesserOverlay { position: relative; top: 0; margin-top: 3rem; }" +
			" #confusionGuesserOverlay > div { background-color: rgba(0, 0, 0, 0.4); padding-left: 0.7rem; }" +
			" #confusionGuesserOverlay #guesses { max-height: initial; overflow-y: auto; }" +
			" #confusionGuesserOverlay > div > label { border: solid thin rgba(0, 0, 0, 0.4); }" +
			"}" +
			// firefox workaround (otherwise shrinks grid width when scrollbar appears, leading to line breaks in the cells)
			"@-moz-document url-prefix() { #guesses { overflow-y: scroll; } }";
		let sCss = document.createElement("style");
		let tCss = document.createTextNode(css);
		sCss.appendChild(tCss);
		document.head.appendChild(sCss);
	}
})();

and let me know if this version still produces the error.

1 Like

That version seemed to work at first, but I restarted my browser and now the error came back

I don’t understand it either^ how can a browser restart cause problems?

Edit: ok, well this is strange. I got curious and ran a vimdiff on the code you posted vs the code displayed in Tampermonkey after I restarted my browser and…


Seems like some possible corruption going on, unless this is intended?

1 Like

Well, this is definitely not intended ^^ I’m wondering what could possibly cause this. So you edit the code in Tampermonkey, save it, and after a browser restart it changed to the left version?

1 Like

Precisely.
I’ve just tested on 2 other computers (one macOS, the other Linux) and I can’t reproduce the issue. So it seems like I have a local issue to look into…

Thanks for your help and I’m sorry for any trouble I may have put you through ^^

3 Likes