WaniKani Item Info Injector

Library script that other userscripts can use to inject additional item information into WaniKani.

// ==UserScript==
// @name         WaniKani Item Info Injector
// @namespace    waniKaniItemInfoInjector
// @version      3.13
// @description  Library script that other userscripts can use to inject additional item information into WaniKani.
// @author       Sinyaven
// @license      MIT-0
// @match        https://www.wanikani.com/*
// @match        https://preview.wanikani.com/*
// @homepageURL  https://community.wanikani.com/t/53823
// @run-at       document-start
// @grant        none
// ==/UserScript==

// nomenclature:
// stateSelector: {on, type, under, spoiler}; all of them are arrays
// state        : {on, type, under, hiddenSpoiler, id, meaning, typeDependentItemInfo...}; only under, meaning, and hiddenSpoiler are arrays; hiddenSpoiler contains a list of "under" sections that are currently hidden but might be added to the UI later (currently only during review/lessonQuiz while the item info is not fully expanded)
// injectorState: {on, type, under, hiddenSpoiler, id, meaning, typeDependentItemInfo..., injector}; state + injector
// callback     : function getting as argument an injectorState
// callbackEntry: {stateSelector, callback, appendedElements, injectorDeactivators, alreadyHandled, entryId}
// typeDependentItemInfo [Text Radical]: {characters}; the property "characters" is only added if the radical is not an image
// typeDependentItemInfo [       Kanji]: {characters, reading, composition, emphasis, onyomi, kunyomi, nanori}; reading, composition, onyomi, kunyomi, and nanori are arrays; composition is an array of radicals {characters, meaning} where meaning is an array
// typeDependentItemInfo [  Vocabulary]: {characters, reading, composition, partOfSpeech}; reading, composition, and partOfSpeech are arrays; composition is an array of kanji {characters, meaning, reading} where meaning and reading are arrays
// typeDependentItemInfo [  Kana Vocab]: {characters, partOfSpeech}; partOfSpeech is an array

((global, unsafeGlobal) => {
	"use strict";
	/* eslint no-multi-spaces: off */

	// private variables and functions
	let _currentState              = {};
	let _injectAt                  = {};
	let _callbacks                 = [];
	let _appendedElements          = [];
	let _scheduledCallbackElements = [];
	let _maxEntryId                = 0;
	let _itemChanged               = true;
	let _newRootElement            = document.body;
	let _newUrl                    = document.URL;

	const _VERSION       = `3.13`;
	const _SCRIPT_NAME   = `WaniKani Item Info Injector`;
	const _CSS_NAMESPACE = `item-info-injector`;

	const _TAB_NAME_MAPPING = {};
	_TAB_NAME_MAPPING.radical = {
		[`name`             ]: `meaning`,
		[`examples`         ]: `examples`
	};
	_TAB_NAME_MAPPING.kanji = {
		[`radicals`         ]: `composition`,
		[`meaning`          ]: `meaning`,
		[`readings`         ]: `reading`,
		[`examples`         ]: `examples`
	};
	_TAB_NAME_MAPPING.vocabulary = {
		[`kanji composition`]: `composition`,
		[`meaning`          ]: `meaning`,
		[`reading`          ]: `reading`,
		[`context`          ]: `examples`
	};
	_TAB_NAME_MAPPING.kanaVocabulary = {
		[`meaning`          ]: `meaning`,
		[`context`          ]: `examples`
	};

	const _URL_HASH_MAPPING = {};
	_URL_HASH_MAPPING.radical = {
		[``              ]: `meaning`,
		[`#meaning`      ]: `meaning`,
		[`#amalgamations`]: `examples`,
	};
	_URL_HASH_MAPPING.kanji = {
		[``              ]: `composition`,
		[`#composition`  ]: `composition`,
		[`#meaning`      ]: `meaning`,
		[`#reading`      ]: `reading`,
		[`#amalgamations`]: `examples`,
	};
	_URL_HASH_MAPPING.vocabulary = {
		[``              ]: `composition`,
		[`#composition`  ]: `composition`,
		[`#meaning`      ]: `meaning`,
		[`#reading`      ]: `reading`,
		[`#context`      ]: `examples`,
	};
	_URL_HASH_MAPPING.kanaVocabulary = {
		[``              ]: `meaning`,
		[`#meaning`      ]: `meaning`,
		[`#context`      ]: `examples`,
	};

	const _ACCORDION_NAME_MAPPING = {};
	_ACCORDION_NAME_MAPPING.radical = {
		[`Name`               ]: `meaning`,
		[`Found In Kanji`     ]: `examples`
	};
	_ACCORDION_NAME_MAPPING.kanji = {
		[`Radical Combination`]: `composition`,
		[`Meaning`            ]: `meaning`,
		[`Reading`            ]: `reading`
	};
	_ACCORDION_NAME_MAPPING.vocabulary = {
		[`Related Kanji`      ]: `composition`,
		[`Kanji Composition`  ]: `composition`,
		[`Meaning`            ]: `meaning`,
		[`Reading`            ]: `reading`,
		[`Context`            ]: `examples`
	};
	_ACCORDION_NAME_MAPPING.kanaVocabulary = {
		[`Meaning`            ]: `meaning`,
		[`Context`            ]: `examples`
	};

	const _PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE = {};
	_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE.radical = [`reading`, `composition`, `partOfSpeech`, `onyomi`, `kunyomi`, `nanori`, `emphasis`];
	_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE.kanji = [`partOfSpeech`];
	_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE.vocabulary = [`onyomi`, `kunyomi`, `nanori`, `emphasis`];
	_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE.kanaVocabulary = [`reading`, `composition`, `onyomi`, `kunyomi`, `nanori`, `emphasis`];

	// REGION 0: helper functions for interacting with page and other utils

	// get the root element that should contain all item info elements; can be the document body, but also the item info Turbo frame
	function _getRootElement() {
		if (!_relevantRootElementChildren(_newRootElement).length) _newRootElement = document.body;
		return _newRootElement;
	}

	// it seems like Turbo does not move the SVG and the .wk-modal element into document.body, so let's ignore it
	function _relevantRootElementChildren(rootElement) {
		return [...rootElement?.children ?? []].filter(c => c.tagName !== `svg` && !c.classList.contains(`wk-modal`));
	}

	// from @rfindley
	function getController(name) {
		return unsafeGlobal.Stimulus.getControllerForElementAndIdentifier(document.querySelector(`[data-controller~="${name}"]`), name);
	}

	function _isFullLessonQuizUrl(url) {
		return /wanikani.com\/subject-lessons\/[\d-]+\/quiz/.test(url);
	}

	function _isFullLessonUrl(url) {
		return /wanikani.com\/subject-lessons\/[\d-]+\/\d+/.test(url) || /wanikani.com\/recent-mistakes\/[\d-]+\/subjects\/\d+\/lesson/.test(url);
	}

	function _currentIdFromLessonUrl(url) {
		let id = url.match(/wanikani.com\/subject-lessons\/[\d-]+\/(\d+)/)?.[1] ?? url.match(/wanikani.com\/recent-mistakes\/[\d-]+\/subjects\/(\d+)\/lesson/)?.[1];
		return id == null ? null : parseInt(id);
	}

	function _rootElementContentInMainPage() {
		if (document.body.contains(_relevantRootElementChildren(_getRootElement())[0] ?? document.body)) return true;
		return new Promise(resolve => {
			let observer = new MutationObserver(m => {
				if (_relevantRootElementChildren(m[0].target).length > 0) return;
				observer.disconnect();
				observer = null;
				resolve(true);
			});
			observer.observe(_newRootElement, {childList: true});
		});
	}

	function _firstCharToLower(s) {
		return (s[0]?.toLowerCase() ?? ``) + s.substring(1);
	}

	// REGION 1: detect current state and set up listeners for keeping track of state changes

	let debug = true;
	function _init() {
		document.addEventListener(`turbo:before-render`      , handleTurboBeforeRender);
		document.addEventListener(`turbo:before-frame-render`, handleTurboBeforeFrameRender);
		document.addEventListener(`turbo:click`              , handleTurboClick);
		document.addEventListener(`turbo:load`               , handleTurboLoad);
		window  .addEventListener(`hashchange`               , handleHashChange); // none of the Turbo events seem to fire when navigating the history during lessons
		document.addEventListener(`keydown`                  , handleKeyDown);
		_initOnNewPage();
	}

	function handleTurboBeforeRender(e) {
		if (e.detail.newBody.lastElementChild?.tagName === `IFRAME`) return; // sometimes in lessons, turbo:before-render gets called twice -- ignore the first time
		_newUrl = document.URL;
		if (_init.ignoreNextLessonBeforeRender && _isFullLessonUrl(document.URL)) { _init.ignoreNextLessonBeforeRender = false; return; } // when loading wanikani/subjects/lesson, turbo:before-render is fired twice, but the second time seems to be rejected => ignore the second time
		if (document.location.pathname === `/subject-lessons/start`) _init.ignoreNextLessonBeforeRender = true;
		_newRootElement = e.detail.newBody;
		_initOnNewPage();
	}

	function handleTurboBeforeFrameRender(e) {
		if (e.detail.newFrame.id !== `subject-info`) return; // only listen to changes to the subject info frame
		_newRootElement = e.detail.newFrame;
		_newUrl = document.URL;
		_initOnNewPage();
	}

	function handleTurboClick(e) {
		let newId = _currentIdFromLessonUrl(e.detail.url);
		if (newId === null || newId !== _currentIdFromLessonUrl(document.URL)) return; // only listen to tab changes which stay on the same subject
		_newRootElement = document.body;
		_newUrl = e.detail.url;
		_initOnNewLessonTab();
	}

	function handleTurboLoad() {
		// handle history navigation not caught by other listeners
		if (_newUrl === document.URL) return;
		_newUrl = document.URL;
		setTimeout(() => {
			_newRootElement = document.body;
			_initOnNewPage();
		});
	}

	function handleHashChange(e) {
		if (!e.isTrusted) return;
		handleTurboLoad();
	}

	function handleKeyDown(e) {
		if (e.key !== `e`) return;
		let collapsedNative = document.querySelectorAll(`.subject-section__toggle[aria-expanded=false]`);
		let collapsed = [...document.querySelectorAll(`.subject-info section.${_CSS_NAMESPACE}-accordion-closed > button`)];
		let expanded  = [...document.querySelectorAll(`.subject-info section.${_CSS_NAMESPACE}:not(.${_CSS_NAMESPACE}-accordion-closed) > button`)];
		(collapsedNative.length ? collapsed : expanded).forEach(e => e.click());
	}

	function removeAllInjectedElements() {
		_getRootElement().querySelectorAll(`.${_CSS_NAMESPACE}, .${_CSS_NAMESPACE}-empty`).forEach(e => e.remove());
	}

	function _initOnNewPage() {
		removeAllInjectedElements();
		_currentState = {};
		if      (document.URL.includes(`wanikani.com/radicals/`  )) { _currentState = {on: `itemPage`, type: `radical`}; _initItemPage(); }
		else if (document.URL.includes(`wanikani.com/kanji/`     )) { _currentState = {on: `itemPage`, type: `kanji`  }; _initItemPage(); }
		else if (document.URL.includes(`wanikani.com/vocabulary/`)) { _currentState = {on: `itemPage`, type: _getRootElement().querySelector(`#components`) === null ? `kanaVocabulary` : `vocabulary`}; _initItemPage(); }
		else if (document.URL.includes(`wanikani.com/subjects/review`        )) { _currentState.on =     `review`; _initReviewPage(); }
		else if (document.URL.includes(`wanikani.com/subjects/extra_study`   )) { _currentState.on = `extraStudy`; _initReviewPage(); }
		else if (/wanikani.com\/recent-mistakes\/.*quiz/   .test(document.URL)) { _currentState.on = `extraStudy`; _initReviewPage(); }
		else if (                           _isFullLessonQuizUrl(document.URL)) { _currentState.on = `lessonQuiz`; _initReviewPage(); }
		else if (document.location.pathname === `/subject-lessons/start` || _isFullLessonUrl(document.URL)) { _currentState.on = `lesson`; _initLessonPage(); }
	}

	function _initOnNewLessonTab() {
		_updateCurrentStateUnder();
		_initInjectorFunctions();
		_handleStateChange();
	}

	function _initItemPage() {
		_updateCurrentStateItemPage();
		_initInjectorFunctions();
		_handleStateChange();
	}

	function _initReviewPage() {
		if (_getRootElement().id !== `subject-info`) return;

		_updateCurrentStateReview();
		_initInjectorFunctions();
		_handleStateChange();
	}

	function _initLessonPage() {
		_updateCurrentStateLesson();
		_initInjectorFunctions();
		_handleStateChange();
	}

	function _updateCurrentState() {
		if (_currentState.on === `itemPage`) return; // no need to update after the initial call of _updateCurrentStateItemPage() on page load since there cannot be any changes

		_isFullLessonUrl(document.URL) ? _updateCurrentStateLesson() : _updateCurrentStateReview();
	}

	function _updateCurrentStateItemPage() {
		_currentState.id           = parseInt(document.head.querySelector(`meta[name=subject_id]`).content);
		_currentState.characters   = _getRootElement().querySelector(`span.subject-character__characters-text`).textContent.trim();
		_currentState.emphasis     = _getRootElement().querySelector(`.subject-readings__reading--primary h3`)?.textContent.replace(`’`, ``).toLowerCase();
		_currentState.partOfSpeech = [..._getRootElement().querySelectorAll(`.subject-section__meanings h2`)].find(h => h.textContent === `Word Type`)?.nextElementSibling.textContent.split(`,`).map(p => p.trim().replace(/\b\w/g, c => c.toUpperCase()));
		_currentState.meaning      = [..._getRootElement().querySelectorAll(`.subject-section__meanings h2`)].filter(h => [`Primary`, `Alternative`, `Alternatives`].includes(h.textContent)).flatMap(h => h.nextElementSibling.textContent.split(`,`)).map(m => m.trim());
		_currentState.onyomi       = [..._getRootElement().querySelectorAll(`.subject-readings__reading-title`)].find(s => s.textContent ===  `On’yomi`)?.nextElementSibling.textContent.split(`,`).map(r => r.trim()).filter(r => r !== `None`);
		_currentState.kunyomi      = [..._getRootElement().querySelectorAll(`.subject-readings__reading-title`)].find(s => s.textContent === `Kun’yomi`)?.nextElementSibling.textContent.split(`,`).map(r => r.trim()).filter(r => r !== `None`);
		_currentState.nanori       = [..._getRootElement().querySelectorAll(`.subject-readings__reading-title`)].find(s => s.textContent ===   `Nanori`)?.nextElementSibling.textContent.split(`,`).map(r => r.trim()).filter(r => r !== `None`);
		_currentState.composition  = [..._getRootElement().querySelectorAll(`.subject-section--components .subject-character__characters`)].map(s => { let result = {meaning: [...s.nextElementSibling.querySelectorAll(`.subject-character__meaning`)].map(m => m.textContent.trim()), reading: [...s.nextElementSibling.querySelectorAll(`.subject-character__reading`)].map(r => r.textContent.trim()), characters: s.textContent.trim()}; if (result.reading.length === 0) delete result.reading; if (result.characters === ``) delete result.characters; return result; });
		_currentState.reading      = [..._getRootElement().querySelectorAll(`.reading-with-audio__reading`)].map(p => p.textContent).concat(_currentState[_currentState.emphasis] || []);
		_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE[_currentState.type].forEach(prop => delete _currentState[prop]);

		_updateCurrentStateUnder();
	}

	function _updateCurrentStateReview() {
		let subjects               = JSON.parse((_getRootElement().querySelector(`[data-quiz-queue-target="subjects"]`) ?? document.querySelector(`[data-quiz-queue-target="subjects"]`)).textContent);
		let currentId              = parseInt(document.querySelector(`[data-subject-id]`)?.dataset.subjectId ?? subjects[0]?.id);
		let currentItem            = subjects.find(s => s.id === currentId) ?? getController(`quiz-queue`).quizQueue.activeQueue.find(b => b.id == currentId);

		_currentState.id           = currentItem.id;
		_currentState.type         = _firstCharToLower(currentItem.type);
		_currentState.characters   = currentItem.characters;
		_currentState.emphasis     = currentItem.readings?.find(r => r.kind === `primary`)?.type;
		_currentState.partOfSpeech = [..._getRootElement().querySelectorAll(`.subject-section__meanings h2`)].find(h => h.textContent === `Word Type`)?.nextElementSibling.textContent.split(`,`).map(p => p.trim().replace(/\b\w/g, c => c.toUpperCase()));
		_currentState.meaning      = currentItem.meanings?.filter(r => [`primary`, `alternative`].includes(r.kind)).map(m => m.text);
		_currentState.onyomi       = currentItem.readings?.filter(r => r.type === `onyomi`).map(r => r.text);
		_currentState.kunyomi      = currentItem.readings?.filter(r => r.type === `kunyomi`).map(r => r.text);
		_currentState.nanori       = currentItem.readings?.filter(r => r.type === `nanori`).map(r => r.text);
		_currentState.composition  = [..._getRootElement().querySelectorAll(`.subject-section--components .subject-character__characters`)].map(s => { let result = {meaning: [...s.nextElementSibling.querySelectorAll(`.subject-character__meaning`)].map(m => m.textContent.trim()), reading: [...s.nextElementSibling.querySelectorAll(`.subject-character__reading`)].map(r => r.textContent.trim()), characters: s.textContent.trim()}; if (result.reading.length === 0) delete result.reading; if (result.characters === ``) delete result.characters; return result; });
		_currentState.reading      = _currentState.type === `kanji` ? _currentState[_currentState.emphasis] : currentItem.readings?.filter(r => [`primary`, `alternative`].includes(r.kind)).map(r => r.text);
		Object.entries(_currentState).filter(e => !e[1]).forEach(e => delete _currentState[e[0]]);
		_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE[_currentState.type].forEach(prop => delete _currentState[prop]);

		_updateCurrentStateUnder();
	}

	function _updateCurrentStateLesson() {
		_currentState.id           = _currentIdFromLessonUrl(document.URL) ?? parseInt(_getRootElement().querySelector(`[id=user_synonyms]`).getAttribute(`src`).match(/subject_id=(\d+)/)?.[1]);
		_currentState.type         = [`radical`, `kanji`, `vocabulary`].find(t => _getRootElement().querySelector(`.character-header--${t}`) !== null);
		_currentState.type         = _currentState.type === `vocabulary` && _getRootElement().querySelector(`#composition`) === null ? `kanaVocabulary` : _currentState.type;
		_currentState.characters   = _getRootElement().querySelector(`.character-header__characters`).textContent;
		_currentState.partOfSpeech = [`vocabulary`, `kanaVocabulary`].includes(_currentState.type) ? [..._querySelector(`.subject-section__title-text {Word Type} ^+`)?.children ?? []].flatMap(c => c.textContent.split(`, `)) : null;
		_currentState.meaning      = [_getRootElement().querySelector(`.character-header__meaning`).textContent, ...[..._querySelector(`.subject-section__title-text {Other Meanings} ^+`)?.children ?? []].flatMap(c => c.textContent.split(`, `))];
		_currentState.onyomi       = _currentState.type === `kanji`      ? [..._querySelector(`.subject-section__title-text {Readings (on’yomi) } ^+`)?.children ?? []].map(c => c.textContent) : null;
		_currentState.kunyomi      = _currentState.type === `kanji`      ? [..._querySelector(`.subject-section__title-text {Readings (kun’yomi)} ^+`)?.children ?? []].map(c => c.textContent) : null;
		_currentState.nanori       = _currentState.type === `kanji`      ? [..._querySelector(`.subject-section__title-text {Readings (nanori)  } ^+`)?.children ?? []].map(c => c.textContent) : null;
		_currentState.emphasis     = _currentState.type === `kanji`      ? [`onyomi`, `kunyomi`, `nanori`].find(r => _currentState[r].length) : null;
		_currentState.composition  = _currentState.type === `kanji`      ? [..._querySelector(`.subject-section__title-text {Radical Composition} ^+`)?.querySelectorAll(`.subject-character__characters`) ?? []].map(s => { let result = {meaning: [...s.nextElementSibling.querySelectorAll(`.subject-character__meaning`)].map(m => m.textContent.trim()), reading: [...s.nextElementSibling.querySelectorAll(`.subject-character__reading`)].map(r => r.textContent.trim()), characters: s.textContent.trim()}; if (result.reading.length === 0) delete result.reading; if (result.characters === ``) delete result.characters; return result; }) :
		                             _currentState.type === `vocabulary` ? [..._querySelector(`.subject-section__title-text {  Kanji Composition} ^+`)?.querySelectorAll(`.subject-character__characters`) ?? []].map(s => { let result = {meaning: [...s.nextElementSibling.querySelectorAll(`.subject-character__meaning`)].map(m => m.textContent.trim()), reading: [...s.nextElementSibling.querySelectorAll(`.subject-character__reading`)].map(r => r.textContent.trim()), characters: s.textContent.trim()}; if (result.reading.length === 0) delete result.reading; if (result.characters === ``) delete result.characters; return result; }) : null;
		_currentState.reading      = _currentState.type === `radical` ? null : [..._getRootElement().querySelectorAll(`.reading-with-audio__reading`)].map(r => r.textContent).concat(_currentState[_currentState.emphasis] ?? []);
		_PROPERTIES_TO_REMOVE_FROM_CURRENT_STATE[_currentState.type].forEach(prop => delete _currentState[prop]);

		_updateCurrentStateUnder();
	}

	function _updateCurrentStateUnder() {
		// update _currentState.under and _currentState.hiddenSpoiler
		_currentState.hiddenSpoiler = [];
		if (_currentState.on === `lesson`) {
			_currentState.under = [_URL_HASH_MAPPING[_currentState.type][new URL(_newUrl).hash]];
		} else {
			switch (_currentState.type) {
				case `kanaVocabulary`:
				case `radical`       : _currentState.under = [               `meaning`,            `examples`]; break;
				case `kanji`         :
				case `vocabulary`    : _currentState.under = [`composition`, `meaning`, `reading`, `examples`]; break;
			}
			if ([`review`, `lessonQuiz`, `extraStudy`].includes(_currentState.on)) {
				let spoiler = [];
				switch (document.querySelector(`[for=user-response]`).dataset.questionType) {
					case `meaning`: spoiler = [`reading`, `composition`]; break;
					case `reading`: spoiler = [`meaning`, `composition`, `examples`]; break;
				}
				_currentState.hiddenSpoiler = _currentState.under.filter(u =>  spoiler.includes(u));
				_currentState.under         = _currentState.under.filter(u => !spoiler.includes(u));
			}
		}
	}

	// REGION 2: code that provides the item info injection functionality

	function _skipInjectedSections(location) {
		while (location?.nextElementSibling?.classList.contains(_CSS_NAMESPACE)) location = location.nextElementSibling;
		return location;
	}

	function _querySelector(selector) {
		selector = selector.replace(`%current-tab`, () => Object.entries(_URL_HASH_MAPPING[_currentState.type]).reverse().find(e => e[1] === _currentState.under[0])[0]);
		selector = selector.replace(/%(\w+)/, (_, under) => { _injectAt[under](); return `#${_CSS_NAMESPACE}-loc-${under}`; });
		let [, sel, contentFilter, indexSelector, customSuffix] = selector.match(/^(.+?)(?:{(.+)})?(\$?)([\+\-\^\~\s]*)$/);
		let result = (contentFilter || indexSelector) ? [..._getRootElement().querySelectorAll(sel)] : _getRootElement().querySelector(sel);
		if (contentFilter) {
			contentFilter = contentFilter.split(`,`).map(c => c.trim());
			result = contentFilter.reduce((element, filter) => element || result.find(r => r.textContent === filter && !r.classList.contains(_CSS_NAMESPACE)), null);
		} else if (indexSelector) {
			result = result.pop(); // for now, the only available indexSelector is "$" with the meaning "last match"
		}
		[...customSuffix].forEach(c => {
			if (c === `-`) result = result?.previousElementSibling;
			if (c === `+`) result = result?.nextElementSibling;
			if (c === `^`) result = result?.parentElement;
			if (c === `~`) result = _skipInjectedSections(result);
		});
		return result;
	}

	function _xPathSelector(selector) {
//		selector = selector.replace(/%(\w+)/, (_, under) => { _injectAt[under](); return `*[@id="${_CSS_NAMESPACE}-loc-${under}"]`; });
		return document.evaluate(selector, _getRootElement(), null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
	}

	function _injectLocatorAt(id, ...selector) {
		let locator = document.createElement(`div`);
		locator.classList.add(`${_CSS_NAMESPACE}-empty`);
		locator.id = id;
		let inject = selector.reduce((injectFunc, sel) => {
			if (injectFunc) return injectFunc;
			let [, xpath, s, customSuffix] = sel.match(/^(XPATH |)(.+?)([<>\-])?\s*$/);
			let location = xpath ? _xPathSelector(s) : _querySelector(s);
			if (!location) return null;
			if (customSuffix === `<`) return location.prepend.bind(location);
			if (customSuffix === `>`) return location.append .bind(location);
			if (customSuffix === `-`) return location.previousElementSibling?.after.bind(location.previousElementSibling) || location.before.bind(location); // workaround for inserting before space in pageList
			else                      return location.after  .bind(location);
		}, null);
		if (!inject) return null;
		inject(locator);
		_appendedElements.push(locator);
		return locator;
	}

	function _setInjectorFunc(under, ...selector) {
		let id = `${_CSS_NAMESPACE}-loc-${under}`;
		_injectAt[under] = (...elements) => {
			let locator = _getRootElement().ownerDocument?.getElementById(id) ?? _getRootElement().querySelector(`[id="${id}"]`) ?? _injectLocatorAt(id, ...selector);
			if (!locator) { console.warn(`${_SCRIPT_NAME}: Could not find location under ${under}`); return; }
			locator = _skipInjectedSections(locator);
			locator.after(...elements);
		};
	}

	// custom CSS:
	// XPATH (at start): main part is xpath selector, not css selector
	// current-tab: the currently open lesson tab
	// %top etc.: the specified anchor
	// {text content} (at end): element with given textContent
	// $ (at end): last match
	// - (at end): previousElementSibling |
	// + (at end): nextElementSibling     | any order
	// ^ (at end): parentElement          |
	// ~ (at end): skip injected sections |
	// < (at end): prepend (first child)
	// > (at end): append (last child)
	// - (at end): before

	function _initInjectorFunctions() {
		switch(_currentState.on) {
			case `itemPage`:
				_setInjectorFunc(`top`                  , `.page-nav`                                                   );
				_setInjectorFunc(`topSide`              , `%top ~`                                                      );
				_setInjectorFunc(`bottom`               , `.subject-section--progress -`, `.page-nav ^>`                );
				_setInjectorFunc(`bottomSide`           , `%bottom ~`                                                   );
				_setInjectorFunc(`composition`          , `.subject-section--components`                                );
				_setInjectorFunc(`meaning`              , `.subject-section--meaning`                                   );
				_setInjectorFunc(`reading`              , `.subject-section--reading`                                   );
				_setInjectorFunc(`examples`             , `.subject-section--amalgamations, .subject-section--context`  );
				_setInjectorFunc(`meaningSide`          , `.subject-section__meanings $`                                );
				_setInjectorFunc(`readingSide`          , `.subject-readings, .subject-readings-with-audio`             );
				_setInjectorFunc(`compositionSubsection`, `.subject-section--components >`                              );
				_setInjectorFunc(`meaningSubsection`    , `#user_meaning_note ^-`, `.subject-section--meaning >`        );
				_setInjectorFunc(`readingSubsection`    , `#user_reading_note ^-`, `.subject-section--reading >`        );
				_setInjectorFunc(`examplesSubsection`   , `.subject-section--amalgamations, .subject-section--context >`);
				_setInjectorFunc(`compositionPageList`  , `.wk-nav__item-link[href='#components'] ^`, `%topSidePageList ~`);
				_setInjectorFunc(`meaningPageList`      , `.wk-nav__item-link[href='#meaning'], .wk-nav__item-link[href='#information'] ^`);
				_setInjectorFunc(`readingPageList`      , `.wk-nav__item-link[href='#reading'] ^`                     );
				_setInjectorFunc(`examplesPageList`     , `.wk-nav__item-link[href='#amalgamations'], .wk-nav__item-link[href='#context'] ^`);
				_setInjectorFunc(`topPageList`          , `.wk-nav__items <`                                          );
				_setInjectorFunc(`topSidePageList`      , `%topPageList ~`                                              );
				_setInjectorFunc(`bottomPageList`       , `.wk-nav__item-link[href='#progress'] ^-`, `.wk-nav__items >`);
				_setInjectorFunc(`bottomSidePageList`   , `%bottomPageList ~`                                           );
				break;
			case `lesson`:
				_setInjectorFunc(`customSideInfo`       , `%current-tab .subject-slide__sections -`);
				_setInjectorFunc(`top`                  , `%current-tab .subject-slide__sections <`);
				_setInjectorFunc(`bottom`               , `%current-tab .subject-slide__sections >`);
				_setInjectorFunc(`topSide`              , `%current-tab .subject-slide__aside <`, `%customSideInfo <`);
				_setInjectorFunc(`bottomSide`           , `%current-tab .subject-slide__aside >`, `%customSideInfo >`);
				_setInjectorFunc(`composition`          , `%bottom -`);
				_setInjectorFunc(`meaning`              , `%bottom -`);
				_setInjectorFunc(`reading`              , `%bottom -`);
				_setInjectorFunc(`examples`             , `%bottom -`);
				_setInjectorFunc(`meaningSide`          , `%bottomSide -`);
				_setInjectorFunc(`readingSide`          , `%bottomSide -`);
				_setInjectorFunc(`compositionSubsection`, `%composition -`);
				_setInjectorFunc(`meaningSubsection`    , `#meaning .subject-section__title-text {Meaning Notes, Name Notes} ^^-`);
				_setInjectorFunc(`readingSubsection`    , `#reading .subject-section__title-text {Reading Notes} ^^-`);
				_setInjectorFunc(`examplesSubsection`   , `%examples -`);
				break;
			case `lessonQuiz`:
			case `review`:
			case `extraStudy`:
				_setInjectorFunc(`top`                  , `.container <`);
				_setInjectorFunc(`bottom`               , `.container >`);
				_setInjectorFunc(`topSide`              , `%top ~`      );
				_setInjectorFunc(`bottomSide`           , `%bottom ~`   );
				_setInjectorFunc(`composition`          , `.subject-section--components` );
				_setInjectorFunc(`meaning`              , `.subject-section--meaning`    );
				_setInjectorFunc(`reading`              , `.subject-section--reading`    );
				_setInjectorFunc(`examples`             , `.subject-section--context, .subject-section--amalgamations`);
				_setInjectorFunc(`meaningSide`          , `.subject-section__meanings $` );
				_setInjectorFunc(`readingSide`          , `.subject-readings-with-audio, .subject-readings`);
				_setInjectorFunc(`compositionSubsection`, `.subject-section--components .subject-section__content >`);
				_setInjectorFunc(`meaningSubsection`    , `#user_meaning_note ^-`        );
				_setInjectorFunc(`readingSubsection`    , `#user_reading_note ^-`        );
				_setInjectorFunc(`examplesSubsection`   , `.subject-section--context .subject-section__content >`, `.subject-section--amalgamations .subject-section__content >`);
				break;
			default: break;
		}
	}

	function _handleStateChange() {
		_handleCallbacks(_callbacks);
	}

	function _handleCallbacks(callbacks) {
		callbacks.forEach(c => { c.injectorDeactivators.forEach(d => d()); c.injectorDeactivators = []; });
		_appendedElements.push(...callbacks.map(c => Object.values(c.appendedElements)).flat(2));
		_appendedElements.forEach(e => e.remove());
		_appendedElements = [];

		callbacks.forEach(c => { c.appendedElements = {}; c.alreadyHandled = false; _handleCallbackEntry(c); });

		_appendElementsGeneratedByCallbacks(_callbacks); // reappend ALL callbacks to guarantee consistent order
	}

	function _appendElementsGeneratedByCallbacks(callbacks) {
		let locations = [...new Set(callbacks.flatMap(c => Object.keys(c.appendedElements)))].filter(l => l !== `others`);
		let entries = locations.map(l => [l, callbacks.flatMap(c => c.appendedElements[l] || [])]);
		entries.forEach(([location, elements]) => _injectAt[location](...elements));
		_updateCustomSideInfoVisibility();
	}

	function _scheduleAppendElementsGeneratedByCallbacks(callbacks) {
		if (_scheduledCallbackElements.length === 0) {
			global.requestAnimationFrame(() => {
				_appendElementsGeneratedByCallbacks(_scheduledCallbackElements);
				_scheduledCallbackElements = [];
			});
		}
		_scheduledCallbackElements.push(...callbacks);
	}

	function _addCallbackEntry(stateSelector, callback) {
		let entryId = ++_maxEntryId;
		let callbackEntry = {stateSelector, callback, appendedElements: {}, injectorDeactivators: [], alreadyHandled: false, entryId};
		_callbacks.push(callbackEntry);
		if (_currentState.under) {  // only handle if current state is already known
			_handleCallbackEntry(callbackEntry);
			_scheduleAppendElementsGeneratedByCallbacks([callbackEntry]);
		}
		return entryId;
	}

	async function _handleCallbackEntry(callbackEntry) {
		if (callbackEntry.alreadyHandled) return;
		let injectUnder = _matchesCurrentStateUnder(callbackEntry.stateSelector);
		if (!injectUnder) return;

		let injectorState      = _currentStateDeepCopy();
		injectorState.injector = _createInjector(injectUnder, callbackEntry.stateSelector.spoiler, callbackEntry.appendedElements, callbackEntry.injectorDeactivators);
		//try { callbackEntry.callback.call(null, injectorState); } catch(e) { console.error(e); } // error in console pointed to this line as error source => workaround: use async so that caller of _handleCallbackEntry() continues to run even after error
		callbackEntry.callback.call(null, injectorState);
		callbackEntry.alreadyHandled = true;
	}

	function _matchesCurrentStateUnder(stateSelector) {
		if (!stateSelector.on  .includes(_currentState.on  )) return null;
		if (!stateSelector.type.includes(_currentState.type)) return null;
		let allUnder = [..._currentState.under, ..._currentState.hiddenSpoiler];
		let result   = [...stateSelector.under].reverse().find(s => allUnder.includes(s));
		return result;
	}

	function _createInjector(injectUnder, spoiler, appendedElements, injectorDeactivators) {
		let sideInfo = true;
		let injActive = true;
		let injector = {get active() { return injActive; }};
		injector.registerAppendedElement              = (element                          ) => _injectorRegisterAppendedElement(element, injActive, appendedElements);
		injector.append                               = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, ``          , spoiler, appendedElements);
		injector.appendSubsection                     = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `Subsection`, spoiler, appendedElements);
		if (sideInfo) injector.appendSideInfo         = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `Side`      , spoiler, appendedElements);
		injector.appendAtTop                          = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `Top`       , spoiler, appendedElements);
		injector.appendAtBottom                       = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `Bottom`    , spoiler, appendedElements);
		if (sideInfo) injector.appendSideInfoAtTop    = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `TopSide`   , spoiler, appendedElements);
		if (sideInfo) injector.appendSideInfoAtBottom = (heading, body, additionalSettings) => _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, `BottomSide`, spoiler, appendedElements);
		injectorDeactivators.push(() => { injActive = false; });
		return injector;
	}

	function _injectorRegisterAppendedElement(element, injActive, appendedElements) {
		if (!injActive                        ) throw `${_SCRIPT_NAME}: Injector is inactive`;
		if (![1, 3].includes(element.nodeType)) throw `${_SCRIPT_NAME}: Can only register elements or text nodes`;
		if (appendedElements.others) appendedElements.others.push(element); else appendedElements.others = [element];
	}

	function _injectorAppend(heading, body, additionalSettings, injectUnder, injActive, special, spoiler, appendedElements) {
		if (!body) return null;
		let under = additionalSettings?.under || injectUnder;
		if (!injActive                                                              ) throw `${_SCRIPT_NAME}: Injector is inactive`;
		if (!_currentState.under.concat(_currentState.hiddenSpoiler).includes(under)) throw `${_SCRIPT_NAME}: Under ${under} not available`;
		if (![`meaning`, `reading`].includes(under) && special === `Side`           ) throw `${_SCRIPT_NAME}: Cannot append side info under ${under}`;

		if ([`Top`, `TopSide`, `Bottom`, `BottomSide`].includes(special)) {
			under = _firstCharToLower(special);
			special = ``;
		}
		let pageListEntry = null;
		if (_currentState.on === `itemPage` && !special) {
			let u = `${under}PageList`;
			let space = document.createTextNode(` `);
			if (appendedElements[u]) appendedElements[u].push(space); else appendedElements[u] = [space];
			pageListEntry = _createSection(under, `PageList`, appendedElements);
		}

		let section = _createSection(under, special, appendedElements);
		_insertContent(heading, body, special, spoiler, section, pageListEntry, additionalSettings?.sectionName);

		if (additionalSettings?.injectImmediately && section) _injectAt[under + special](section);
		return section;
	}

	function _createSection(under, special, appendedElements) {
		under += special;
		let itemPageSideInfo = _currentState.on !== `lesson` && special === `Side`;
		let itemPagePageList = special === `PageList`;

		let section = document.createElement(itemPageSideInfo ? `div` : itemPagePageList ? `li` : `section`);
		section.classList.add(_CSS_NAMESPACE);
		section.classList.add(`${_CSS_NAMESPACE}-empty`);
		if (itemPagePageList) section.classList.add(`wk-nav__item`);
		if (itemPageSideInfo) section.classList.add(`subject-section__meanings`);
		if (appendedElements[under]) appendedElements[under].push(section); else appendedElements[under] = [section];
		return section;
	}

	async function _insertContent(heading, body, special, spoiler, section, pageListEntry, sectionName) {
		if (!section) return;
		let onItemPageOrReview = _currentState.on !== `lesson`;
		let isSideInfo         = special === `Side`;
		let collapsibleSection = ![`itemPage`, `lesson`].includes(_currentState.on) && special !== `Subsection` && special !== `Side`;

		if ([heading, body].some(p => p && typeof p === `object` && typeof p.then === `function`)) {
			heading = await heading;
			body    = await body;
		}

		if (!body) return;
		let elements = _toElements(body, false);
		if (elements.length === 0) return;
		let hHeading = collapsibleSection ? _toAccordionHeadingElement(heading) : _toHeadingElement(heading, onItemPageOrReview, special);
		if (hHeading) {
			if (pageListEntry && sectionName !== ``) {
				let link = document.createElement(`a`);
				link.href = `#`;
				link.textContent = sectionName || hHeading.textContent;
				link.classList.add(`wk-nav__item-link`);
				link.addEventListener(`click`, e => { section.scrollIntoView({behavior: `smooth`}); e.preventDefault(); });
				pageListEntry.append(link);
				pageListEntry.classList.remove(`${_CSS_NAMESPACE}-empty`);
			}
			section.appendChild(hHeading);
		}
		if (collapsibleSection) {
			section.classList.toggle(`${_CSS_NAMESPACE}-accordion-closed`, spoiler.some(s => _currentState.hiddenSpoiler.includes(s)));
			section.classList.add(`${_CSS_NAMESPACE}-accordion`);
			let div = document.createElement(`div`);
			div.append(...elements);
			elements = [div];
		}
		if (onItemPageOrReview && isSideInfo) elements.forEach(e => e.classList.add(`text-gray-700`, `subject-section__meanings-items`));
		section.append(...elements);
		section.classList.remove(`${_CSS_NAMESPACE}-empty`);
		_updateCustomSideInfoVisibility(section.ownerDocument);
	}

	function _toElements(strOrElements, justArray) {
		if (!strOrElements) return [];
		if (!Array.isArray(strOrElements)) strOrElements = [strOrElements];
		if (justArray || strOrElements.every(e => e.nodeType === 1)) return strOrElements;
		let p = document.createElement(`p`);
		p.append(...strOrElements);
		return [p];
	}

	function _toHeadingElement(heading, onItemPageOrReview, special) {
		let isSubsection = special === `Subsection`;
		let elements = _toElements(heading, true);
		if (elements.length === 0) return null;
		let result = document.createElement((onItemPageOrReview && isSubsection) ? `H3` : `H2`);
		result.append(...elements);
		if (onItemPageOrReview) result.classList.add(isSubsection ? `subject-section__subtitle` : special === `Side` ? `subject-section__meanings-title` : `subject-section__title`);
		return result;
	}

	function _toAccordionHeadingElement(heading) {
		let  svgns = `http://www.w3.org/2000/svg`;
		let result = document.createElement(`BUTTON`);
		let arrow = document.createElement(`span`);
		let svg = document.createElementNS(svgns, `svg`);
		let use = document.createElementNS(svgns, `use`);
		arrow.classList.add(`subject-section__toggle-icon`);
		svg.classList.add(`wk-icon`, `wk-icon--chevron_right`);
		svg.setAttributeNS(svgns, `viewBox`, `0 0 320 512`);
		svg.setAttribute(`aria-hidden`, `true`);
		use.setAttribute(`href`, `#wk-icon__chevron-right`);
		result.addEventListener(`click`, _handleAccordionHeadingClick);
		svg.append(use);
		arrow.append(svg);
		result.append(arrow, ..._toElements(heading, true));
		return result;
	}

	function _handleAccordionHeadingClick(e) {
		e.currentTarget.parentElement.classList.toggle(`${_CSS_NAMESPACE}-accordion-closed`);
	}

	function _updateCustomSideInfoVisibility() {
		if (_currentState.on !== `lesson` || !_currentState.under.includes(`meaning`)) return;
		let side = _getRootElement().querySelector(`#${_CSS_NAMESPACE}-loc-customSideInfo`);
		side?.classList.add(`pure-u-1-4`, `col1`, `subject-slide__aside`);
		side?.classList.toggle(`${_CSS_NAMESPACE}-empty`, !side.querySelector(`.${_CSS_NAMESPACE}:not(.${_CSS_NAMESPACE}-empty)`));
	}

	// REGION 3: CSS injection

	function _addCss() {
		let style = document.createElement(`style`);
		style.textContent = `
			.${_CSS_NAMESPACE}-empty                                                { display: none;                           }
			.${_CSS_NAMESPACE}:not(.wk-nav__item):not(.subject-section__meanings)   { scroll-margin: 80px; margin: 0 0 30px; line-height: 1.6; font-size: 16px; text-shadow: 0 1px 0 #fff; }
			.${_CSS_NAMESPACE} > h2:not(.subject-section__meanings-title)           { line-height: 1.6; font-family: var(--font-family-title); line-height: 1.4; font-size: 28px; text-shadow: 0 1px 0 #fff; border-bottom: 1px solid #d5d5d5; margin-bottom: 10px; }
			.${_CSS_NAMESPACE} > h3                                                 { margin-top: 30px; }
			.${_CSS_NAMESPACE}.${_CSS_NAMESPACE}-accordion                          { margin: 0;                               }
			.${_CSS_NAMESPACE}-accordion-closed > div                               { display: none;                           }
			.${_CSS_NAMESPACE}-accordion > button                                   { align-items: center; display: flex; width: 100%; padding: 0 0 7px; background: none; text-align: left; cursor: pointer; font-family: var(--font-family-title); line-height: 1.4; font-size: 28px; text-shadow: 0 1px 0 #fff; border: none; border-bottom: 1px solid #d5d5d5; margin-bottom: 10px; color: inherit; }
			.${_CSS_NAMESPACE}-accordion .subject-section__toggle-icon              { display: block; transform: rotate(90deg); margin-right: var(--spacing-tight); }
			.${_CSS_NAMESPACE}-accordion-closed .subject-section__toggle-icon       { transform: rotate(0);                    }
			.${_CSS_NAMESPACE}-accordion > button > span > svg                      { width: 11.25px                           }
			.subject-readings                                                       { margin-bottom: var(--spacing-normal);    }`;
		document.head.appendChild(style);
	}

	// REGION 4: Helper functions for providing the interface that allows to register callbacks

	function _currentStateDeepCopy() {
		let copy = {..._currentState};
		let arrayProperties = [`under`, `hiddenSpoiler`, `partOfSpeech`, `meaning`, `onyomi`, `kunyomi`, `nanori`, `reading`];
		arrayProperties.forEach(prop => { if (_currentState[prop]) copy[prop] = [..._currentState[prop]]; });
		let composition = _currentState.composition?.map(c => {
			let result = {characters: c.characters, meaning: [...c.meaning]};
			if (c.reading) result.reading = [...c.reading];
			return result;
		});
		if (composition) copy.composition = composition;
		return copy;
	}

	function _argumentsToArray(args) {
		return args?.flatMap(a => a.split(`,`)).map(a => a.trim()) || [];
	}

	function _removeDuplicates(array) {
		return array.filter((a, i) => array.indexOf(a) === i);
	}

	function _checkAgainst(array, keywords) {
		let duplicateKeywords = array.filter((a, i) => array.includes(a, i + 1) && array.indexOf(a) === i);
		let unknownKeywords = _removeDuplicates(array.filter(a => !keywords.includes(a)));
		if (unknownKeywords  .length > 0) throw `${_SCRIPT_NAME}: Unknown keywords [${unknownKeywords.join(`, `)}]`;
		if (duplicateKeywords.length > 0) throw `${_SCRIPT_NAME}: Duplicate keywords [${duplicateKeywords.join(`, `)}]`;
		return array;
	}

	function _fillStateSelector(stateSelector) {
		if (!stateSelector.on   ?.length) stateSelector.on    = [`itemPage`, `lesson`, `lessonQuiz`, `review`, `extraStudy`];
		if (!stateSelector.type ?.length) stateSelector.type  = [`radical`, `kanji`, `vocabulary`, `kanaVocabulary`];
		if (!stateSelector.under?.length) stateSelector.under = [`composition`, `meaning`, `reading`, `examples`];
		stateSelector.spoiler = stateSelector.spoiler || stateSelector.under;
		return stateSelector;
	}

	function _executeCallback(strOrCallback) {
		if (typeof strOrCallback !== `function`) return strOrCallback;
		return strOrCallback(_currentStateDeepCopy());
	}

	function _removeElementsOfEntry(callbackEntry) {
		callbackEntry.injectorDeactivators.forEach(d => d());
		callbackEntry.injectorDeactivators = [];
		Object.values(callbackEntry.appendedElements).flat().forEach(e => e.remove());
		callbackEntry.appendedElements = {};
	}

	function _remove(entryId) {
		let callbackEntry = _callbacks.find(c => c.entryId === entryId);
		_removeElementsOfEntry(callbackEntry);
		_callbacks = _callbacks.filter(c => c.entryId !== entryId);
		_updateCustomSideInfoVisibility();
	}

	function _renew(entryId) {
		let callbackEntry = _callbacks.find(c => c.entryId === entryId);
		callbackEntry.alreadyHandled = false;
		_removeElementsOfEntry(callbackEntry);
		_handleCallbackEntry(callbackEntry);
		_appendElementsGeneratedByCallbacks(_callbacks);
		_updateCustomSideInfoVisibility();
	}

	function _isNewerThan(otherVersion) {
		let v1 = _VERSION.split(`.`).map(v => parseInt(v));
		let v2 = otherVersion.split(`.`).map(v => parseInt(v));
		return v1.reduce((r, v, i) => r ?? (v === v2[i] ? null : (v > (v2[i] || 0))), null) || false;
	}

	function _selectorChain(currentChainLink, stateSelector) {
		let result = {};
		switch(currentChainLink) {
			case `on`      : result.forType                = (...types     ) => _forType               (stateSelector, types   ); // fall through
			case `forType` : result.under                  = (...tabs      ) => _under                 (stateSelector, tabs    ); // fall through
			case `under`   : result.spoiling               = (...spoilers  ) => _spoiling              (stateSelector, spoilers); // fall through
			case `spoiling`: result.notify                 = (callback     ) => _notify                (stateSelector, callback);
			                 result.notifyWhenVisible      = (callback     ) => _notifyWhenVisible     (stateSelector, callback);
			                 result.append                 = (heading, body) => _append                (stateSelector, heading, body);
			                 result.appendSideInfo         = (heading, body) => _appendSideInfo        (stateSelector, heading, body);
			                 result.appendSubsection       = (heading, body) => _appendSubsection      (stateSelector, heading, body);
			                 result.appendAtTop            = (heading, body) => _appendAtTop           (stateSelector, heading, body);
			                 result.appendAtBottom         = (heading, body) => _appendAtBottom        (stateSelector, heading, body);
			                 result.appendSideInfoAtTop    = (heading, body) => _appendSideInfoAtTop   (stateSelector, heading, body);
			                 result.appendSideInfoAtBottom = (heading, body) => _appendSideInfoAtBottom(stateSelector, heading, body);
		}
		if (stateSelector.on?.length && stateSelector.on    .every(o => ![`review`, `lessonQuiz`, `extraStudy`].includes(o)))   delete result.spoiling;
		if (                            stateSelector.under?.some (u => ![`meaning`, `reading`                ].includes(u))) { delete result.appendSideInfo; delete result.appendSideInfoAtTop; delete result.appendSideInfoAtBottom; }
		return result;
	}

	function _actionHandle(entryId) {
		return entryId ? {
			remove: () => _remove(entryId),
			renew : () => _renew (entryId)
		} : null;
	}

	function _on(stateSelector, pages) {
		stateSelector.on = _checkAgainst(_argumentsToArray(pages), [`itemPage`, `lesson`, `lessonQuiz`, `review`, `extraStudy`]);
		return _selectorChain(`on`, stateSelector);
	}

	function _forType(stateSelector, types) {
		stateSelector.type = _checkAgainst(_argumentsToArray(types), [`radical`, `kanji`, `vocabulary`, `kanaVocabulary`]);
		return _selectorChain(`forType`, stateSelector);
	}

	function _under(stateSelector, tabs) {
		stateSelector.under = _checkAgainst(_argumentsToArray(tabs), [`composition`, `meaning`, `reading`, `examples`]);
		return _selectorChain(`under`, stateSelector);
	}

	function _spoiling(stateSelector, spoilers) {
		stateSelector.spoiler = (spoilers.length === 1 && spoilers[0].trim() === `nothing`) ? [] : _checkAgainst(_argumentsToArray(spoilers), [`composition`, `meaning`, `reading`, `examples`]);
		return _selectorChain(`spoiling`, stateSelector);
	}

	function _notifyWhenVisible(stateSelector, callback) {
		stateSelector.content = true;
		return _notify(stateSelector, async (...args) => { await _rootElementContentInMainPage(); callback(...args); });
	}

	function _notify(stateSelector, callback) {
		let entryId = null;
		if (callback) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), callback);
		return _actionHandle(entryId);
	}

	function _append(stateSelector, heading, body) {
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.append(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendSideInfo(stateSelector, heading, body) {
		stateSelector.content = true;
		if (!stateSelector.under?.length) stateSelector.under = [`meaning`, `reading`];
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendSideInfo(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendSubsection(stateSelector, heading, body) {
		stateSelector.content = true;
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendSubsection(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendAtTop(stateSelector, heading, body) {
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendAtTop(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendAtBottom(stateSelector, heading, body) {
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendAtBottom(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendSideInfoAtTop(stateSelector, heading, body) {
		if (!stateSelector.under?.length) stateSelector.under = [`meaning`, `reading`];
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendSideInfoAtTop(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _appendSideInfoAtBottom(stateSelector, heading, body) {
		if (!stateSelector.under?.length) stateSelector.under = [`meaning`, `reading`];
		let entryId = null;
		if (body) entryId = _addCallbackEntry(_fillStateSelector(stateSelector), injectorState => injectorState.injector.appendSideInfoAtBottom(_executeCallback(heading), _executeCallback(body)));
		return _actionHandle(entryId);
	}

	function _domReady() {
		return document.readyState === `interactive` || document.readyState === `complete`;
	}

	async function _publishInterface() {
		if (unsafeGlobal.wkItemInfo && !_isNewerThan(unsafeGlobal.wkItemInfo.version)) return;
		// if newer, register this version instead
		// the older version will also continue to run, creating a bit of an overhead
		// but this should be negligible; there probably won't be that many versions
		// of WaniKani Item Info Injector anyway, and ideally, all scripts @require
		// the newest version; as a last resort, users can also install the newest
		// version of this script manually and set it as the first executed script
		// in their script manager

		unsafeGlobal.wkItemInfo = Object.freeze({
			// public functions
			on                    : (...pages     ) => _on                    ({}, pages),
			forType               : (...types     ) => _forType               ({}, types),
			under                 : (...tabs      ) => _under                 ({}, tabs),
			spoiling              : (...spoilers  ) => _spoiling              ({}, spoilers),
			notify                : (callback     ) => _notify                ({}, callback),
			notifyWhenVisible     : (callback     ) => _notifyWhenVisible     ({}, callback),
			append                : (heading, body) => _append                ({}, heading, body),
			appendSideInfo        : (heading, body) => _appendSideInfo        ({}, heading, body),
			appendSubsection      : (heading, body) => _appendSubsection      ({}, heading, body),
			appendAtTop           : (heading, body) => _appendAtTop           ({}, heading, body),
			appendAtBottom        : (heading, body) => _appendAtBottom        ({}, heading, body),
			appendSideInfoAtTop   : (heading, body) => _appendSideInfoAtTop   ({}, heading, body),
			appendSideInfoAtBottom: (heading, body) => _appendSideInfoAtBottom({}, heading, body),
			version               : _VERSION,
			get currentState() { _updateCurrentState(); return _currentStateDeepCopy(); }
		});

		if (!_domReady()) {
			await new Promise(resolve => document.addEventListener(`readystatechange`, resolve, {once: true}));
		}

		_init();
		_addCss();
	}

	_publishInterface();
})(window, window.unsafeWindow || window);