ConfusionGuesser

If you give a wrong answer during reviews, it tries to guess which other WaniKani item you confused it with.

当前为 2019-08-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         ConfusionGuesser
// @namespace    confusionguesser
// @version      0.9
// @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
// @grant        none
// ==/UserScript==

(async function() {
	"use strict";
	/* global $, wkof, answerChecker */

	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");

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

	let kanjiSimilarityCache = {};
	let oldSettings = {};

	// variables that will be initialized by settingsChanged()
	let idsHash = {};
	let byReading = {};
	let bySlug = {};
	let gramHash = {};

	installCss();
	setupMenu();

	// 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);

	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
		kanjiSimilarityCache = {};

		let guesses = [];
		let currentItem = $.jStorage.get("currentItem");
		let expectedReading = currentItem.emph === "onyomi" ? currentItem.on : (currentItem.kun || currentItem.kana);
		//let expectedMeaning = currentItem.en;
		let question = 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;
		}

		// remove duplicate guesses (only keep the guess with highest probability)
		let itemIdBestGuess = {};
		guesses.forEach((g, i) => {itemIdBestGuess[g.item.id] = (itemIdBestGuess[g.item.id] !== undefined && g.probability <= guesses[itemIdBestGuess[g.item.id]].probability) ? itemIdBestGuess[g.item.id] : i});
		guesses = guesses.filter((g, i) => itemIdBestGuess[g.item.id] === i);
		// if the list is long, remove guesses with probability 0
		guesses = guesses.length > 5 ? guesses.filter(g => g.probability > 0) : guesses;
		// 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 => 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.data.slug, question)}));
		result.forEach(r => {let best = r.item.data.meanings.reduce((best, m) => {let rating = rateSimilarity(m.meaning.toLowerCase(), answer); return rating > best.rating ? {rating, meaning: m.meaning} : 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.data.slug, 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)
		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)))[0];
		// 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)[0];
		// 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: getKanjiItem(question), reading, probability: 1}];

		let                                  guesses =                searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 1  }));
		reading = removeRendaku(answer);     guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 0.4})));
		reading = removeGemination1(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 0.4})));
		reading = removeGemination2(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 0.4})));
		reading = removeGemination3(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 0.4})));
		reading = removeGemination4(answer); guesses = guesses.concat(searchByReading(reading).filter(s => s.object === "kanji").map(item => ({type: "visuallySimilar", item, reading, probability: 0.4})));
		guesses.filter(g => g.item.data.slug === question).forEach(g => {g.type = validReading(question, answer) + validReading(question, expected)});
		//guesses.filter(g => ["onyomionyomi", "kunyomikunyomi", "nanorinanori"].includes(g.type)).forEach(g => {g.type = readingDetails(question, answer) === readingDetails(question, expected) ? "dakuten" : g.type});
		return guesses;
	}

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

	function getKanjiItem(kanji) {
		return ensureArray(bySlug[kanji]).filter(i => i.object === "kanji")[0];
	}

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

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

	function searchByMeaning(meaning) {
		return gramHash.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 = getKanjiItem(kanji);
		//return item.data.readings.some(r => r.reading === reading || applyRendaku1(r.reading) === reading || applyRendaku2(r.reading) === reading || applyGemination(r.reading) === reading);
		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 readingDetails(kanji, reading) {
		let item = getKanjiItem(kanji);
		let id = item.data.readings.reduce((result, r, idx) => result || (r.reading === reading                  ? idx : undefined), undefined); if (id) return {id, modified: "no"};
		id     = item.data.readings.reduce((result, r, idx) => result || (r.reading === removeRendaku(reading)   ? idx : undefined), undefined); if (id) return {id, modified: "rendaku"};
		id     = item.data.readings.reduce((result, r, idx) => result || (applyGemination(r.reading) === reading ? idx : undefined), undefined); if (id) return {id, modified: "gemination"};
	}*/

	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) {
		// those four seem to be sufficient for all of WK vocab; maybe TODO: improve
		let replacementGemination = "ちつくき".includes(reading.substr(-1));
		return replacementGemination ? reading.substr(0, reading.length - 1) + "っ" : reading + "っ";
	}

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

	function removeGemination1(reading) {
		reading.substr(-1) === "っ" ? reading.substr(0, reading.length - 1) : undefined;
		let replacementGemination = "ちつく".includes(reading.substr(-1));
		return replacementGemination ? reading.substr(0, reading.length - 1) + "っ" : reading + "っ";
	}

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

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

	function removeGemination4(reading) {
		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 vocab which turns き into っ, so whatever)

	function getKanjiComponents(kanji) {
		let line = idsHash[kanji];
		if (!line) return [];
		// get all decomposition variations that apply to the Japanese character appearance
		let variations = line.split("\t").filter(v => !v.match(/\[[^J\]]+\]/));//.map(v => v.replace(/\[[^\]]+\]/, ""));
		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 ? 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;
	}

	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));
		return (1 - dist / Math.max(array1.length, array2.length)) / 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 rateKanjiSimilarity(item, kanji) {
		if (kanjiSimilarityCache[item.data.slug + kanji]) return kanjiSimilarityCache[item.data.slug + kanji];
		let otherItem = getKanjiItem(kanji);
		let similarity = 0.1;
		if (wkof.settings.confusionguesser.useWkRadicals) {
			let matchCount = item.data.component_subject_ids.reduce((matchCount, radicalId) => matchCount + (otherItem.data.component_subject_ids.includes(radicalId) ? 1 : 0), 0);
			similarity = Math.max(2 * matchCount / (item.data.component_subject_ids.length + otherItem.data.component_subject_ids.length), similarity);
		}
		if (wkof.settings.confusionguesser.useWkSimilarity) {
			if (item.data.visually_similar_subject_ids.includes(otherItem.id) || otherItem.data.visually_similar_subject_ids.includes(item.id)) similarity = Math.max(similarity, 0.5);
		}
		if (wkof.settings.confusionguesser.useIds) {
			similarity = Math.max(similarity, rateKanjiSimilarityUsingIds(item.data.slug, kanji));
		}
		kanjiSimilarityCache[item.data.slug + kanji] = similarity;
		kanjiSimilarityCache[kanji + item.data.slug] = similarity;
		return similarity;
	}

	function rateCharacterSimilarity(char1, char2) {
		if (char1 === char2) return 1;
		let item1 = getKanjiItem(char1);
		let item2 = getKanjiItem(char2);
		return (item1 && item2) ? rateKanjiSimilarity(item1, char2) : 0;
	}

	function rateSimilarity(vocab1, vocab2) {
		let array1 = Array.from(vocab1);
		let array2 = Array.from(vocab2);
		let dist = levenshteinDistance(array1, array2, (char1, char2) => 1 - rateCharacterSimilarity(char1, char2));
		// normalize distance to [0;1] and invert it so that 0 is complete mismatch
		return 1 - dist / Math.max(array1.length, array2.length);
	}

	// levenshtein distance with restricted transposition of two adjacent characters (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] + 1 + 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];
	}

	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;
	}

	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) {
		let result = {};
		wkItems.forEach(item => item.data.meanings.forEach((m, mNr) => {let entry = {item, mNr}; toGrams(m.meaning).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 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.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 || guess.item.data.slug;
		sEnglish.innerText = guess.meaning || guess.item.data.meanings[0].meaning;
		sProbability.innerText = guess.probability.toFixed(2);

		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;
	}

	function settingsChanged(settings) {
		// a change of guessOnlyLearnedItems changes the item list and therefore also the search
		let dependencies = [{trigger: "guessOnlyLearnedItems", invalidate: "useFuzzySearch"}];
		// find changed settings
		let changes = Object.keys(settings).filter(key => oldSettings[key] !== settings[key]);
		dependencies.forEach(d => {if (changes.includes(d.trigger) && !changes.includes(d.invalidate)) changes.push(d.invalidate)});
		let items = (changes.includes("guessOnlyLearnedItems") || changes.includes("useFuzzySearch")) ? wkof.ItemData.get_items(settings.guessOnlyLearnedItems ? {wk_items: {filters: {srs: {value: ["lock", "init"], invert: true}}}} : undefined) : null;

		let promises = changes.map(key => {
			switch(key) {
				case "useIds":
					idsHash = settings.useIds ? loadIdsHash() : {};
					if (!settings.useIds) wkof.file_cache.delete("ideographicDescriptionSequences");
					return;
				case "guessOnlyLearnedItems":
					return items.then(itm => {
						byReading = wkof.ItemData.get_index(itm, "reading");
						bySlug = wkof.ItemData.get_index(itm, "slug");
					});
				case "useFuzzySearch":
					return items.then(itm => {gramHash = settings.useFuzzySearch ? hashGrams(itm) : {get: text => itm.filter(i => i.data.meanings && i.data.meanings.some(m => m.meaning.startsWith(text.replace(/\b\w/g, c => c.toUpperCase()))))}});
				case "showAsOverlay":
					dOverlay.classList.toggle("noOverlay", !settings.showAsOverlay);
					return;
				case "showRatings":
					dOverlay.classList.toggle("hideRatings", !settings.showRatings);
					return;
				case "highContrast":
					dCollapsible.classList.toggle("highContrast", settings.highContrast);
					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;
			}
		});
		Object.assign(oldSettings, settings);
		return Promise.all(promises);
	}

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

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

	function openSettings() {
		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."},
					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."},
					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."},
					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."},
					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: "Visually similar"},
					ononColor:             {type: "color",    label: "on⬄on",                       hover_tip: "Wrong on'yomi"},
					onkunColor:            {type: "color",    label: "on⬄kun",                      hover_tip: "Used on'yomi but needed kun'yomi"},
					kunonColor:            {type: "color",    label: "kun⬄on",                      hover_tip: "Used kun'yomi but needed on'yomi"},
					kunkunColor:           {type: "color",    label: "kun⬄kun",                     hover_tip: "Wrong kun'yomi"},
					naonColor:             {type: "color",    label: "na⬄on",                       hover_tip: "Used nanori but needed on'yomi"},
					nakunColor:            {type: "color",    label: "na⬄kun",                      hover_tip: "Used nanori but needed kun'yomi"},
					onnaColor:             {type: "color",    label: "on⬄na",                       hover_tip: "Used on'yomi but needed nanori"},
					kunnaColor:            {type: "color",    label: "kun⬄na",                      hover_tip: "Used kun'yomi but needed nanori"},
					nanaColor:             {type: "color",    label: "na⬄na",                       hover_tip: "Wrong nanori"},
					specialColor:          {type: "color",    label: "Special",                      hover_tip: "Reading exception / WK does not list this reading"},
					resetColor:            {type: "button",   label: "Reset to default", text: "Reset", on_click: (name, config, on_change) => {Object.assign(wkof.settings.confusionguesser, defaultColors); dialog.refresh();}}
				}}
			}
		});
		dialog.open();
	}

	function installCss() {
		let css = "#confusionGuesserOverlay { position: absolute; top: 3rem; right: 0; padding-top: 0.6rem; font-size: large; z-index: 100; overflow-x: hidden; }" +
			"#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; }" +
			"#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: unset; overflow-y: auto; }" +
			"#confusionGuesserOverlay.hideRatings #guesses { grid-template-columns: auto auto 1fr; }" +
			"#confusionGuesserOverlay.hideRatings #guesses > a > *:last-child { display: none; }" +
			"#guesses > a { display: contents; color: white; text-decoration: none; --text: '?' }" +
			"#guesses > a.onyomionyomi     { --gc: var(--onon); --text: 'on⬄on' }" +
			"#guesses > a.onyomikunyomi    { --gc: var(--onkun); --text: 'on⬄kun' }" +
			"#guesses > a.kunyomionyomi    { --gc: var(--kunon); --text: 'kun⬄on' }" +
			"#guesses > a.kunyomikunyomi   { --gc: var(--kunkun); --text: 'kun⬄kun' }" +
			"#guesses > a.nanorionyomi     { --gc: var(--naon); --text: 'na⬄on' }" +
			"#guesses > a.nanorikunyomi    { --gc: var(--nakun); --text: 'na⬄kun' }" +
			"#guesses > a.onyominanori     { --gc: var(--onna); --text: 'on⬄na' }" +
			"#guesses > a.kunyominanori    { --gc: var(--kunna); --text: 'kun⬄na' }" +
			"#guesses > a.nanorinanori     { --gc: var(--nana); --text: 'na⬄na' }" +
			"#guesses > a.onyomiundefined  { --gc: var(--special); --text: 'on⬄special' }" +
			"#guesses > a.kunyomiundefined { --gc: var(--special); --text: 'kun⬄special' }" +
			"#guesses > a.nanoriundefined  { --gc: var(--special); --text: 'na⬄special' }" +
			"#guesses > a.visuallySimilar  { --gc: var(--vissim); --text: '丸⬄九' }" +
			"#guesses > a.radical          { --ic: #00AAFF; }" +
			"#guesses > a.kanji            { --ic: #FF00AA; }" +
			"#guesses > a.vocabulary       { --ic: #AA00FF; }" +
			"#guesses > a > *, #guesses > a::before { display: flex; align-items: center; padding: 0.2rem 0.5rem; }" +
			"#guesses > a > *:first-child { display: unset; }" +
			"#guesses > a::before { content: var(--text); border-radius: 0.5rem 0 0 0.5rem; color: rgba(255, 255, 255, 0.3); }" +
			"#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 > a.show > *, #guesses > a.show::before { font-size: x-large; text-shadow: 2px 2px 3px rgba(0,0,0,0.4); background-color: var(--gc); box-shadow: 0.5rem 0.2rem 0.2rem #0000002b; }" +
			"#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; }" +
			"#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 { 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: unset; 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);
	}
})();

QingJ © 2025

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