[GMT] Flexible Search Links

Appends versatile search links bar to linkbar

当前为 2021-09-12 提交的版本,查看 最新版本

// ==UserScript==
// @name         [GMT] Flexible Search Links
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.07.1
// @description  Appends versatile search links bar to linkbar
// @author       Anakunda
// @copyright    2021, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @match        https://*/torrents.php?id=*
// @match        https://*/artist.php?id=*
// @match        https://*/requests.php?action=view&id=*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// ==/UserScript==

'use strict';

let userAuth = document.body.querySelector('input[name="auth"]');
if (userAuth != null) userAuth = userAuth.value; else throw 'User auth could not be located';
const urlParams = new URLSearchParams(document.location.search),
			action = urlParams.get('action'),
			artistEdit = Boolean(action) && action.toLowerCase() == 'edit',
			artistId = parseInt(urlParams.get('artistid') || urlParams.get('id'));
if (!(artistId > 0)) throw 'Assertion failed: could not extract artist id';
let userId = document.body.querySelector('li#nav_userinfo > a.username');
if (userId != null) {
	userId = new URLSearchParams(userId.search);
	userId = parseInt(userId.get('id')) || null;
}
const hasStyleSheet = styleSheet => document.styleSheetSets && document.styleSheetSets.contains(styleSheet);
const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light'].some(hasStyleSheet);
const isDarkTheme = ['kuro', 'minimal', 'red_dark'].some(hasStyleSheet);
const createElements = (...tagNames) => tagNames.map(Document.prototype.createElement.bind(document));

function loadArtist() {
	const siteArtistsCache = { }, notSiteArtistsCache = [ ];

	function getSiteArtist(artistName) {
		if (!artistName) throw 'Invalid argument';
		if (notSiteArtistsCache.some(a => a.toLowerCase() == artistName.toLowerCase())) return Promise.reject('not found');
		const key = Object.keys(siteArtistsCache).find(artist => artist.toLowerCase() == artistName.toLowerCase());
		return key ? Promise.resolve(siteArtistsCache[key]) : queryAjaxAPI('artist', {
			artistname: artistName,
			artistreleases: 1,
		}).then(function(artist) {
			for (let prop of ['torrentgroup', 'requests', 'similarArtists']) if (prop in artist) delete artist[prop];
			return siteArtistsCache[artistName] = artist;
		}, function(reason) {
			if (reason == 'not found' && !notSiteArtistsCache.includes(artistName)) notSiteArtistsCache.push(artistName);
			return Promise.reject(reason);
		});
	}

	queryAjaxAPI('artist', { id: artistId }).then(function(artist) {
		siteArtistsCache[artist.name] = artist;
		const rdExtractor = /\(\s*writes\s+redirect\s+to\s+(\d+)\s*\)/i;
		let activeElement = null;

		function getAlias(li) {
			console.assert(li instanceof HTMLLIElement, 'li instanceof HTMLLIElement');
			if (!(li instanceof HTMLLIElement)) return;
			if (typeof li.alias == 'object') return li.alias;
			const alias = {
				id: li.querySelector(':scope > span:nth-of-type(1)'),
				name: li.querySelector(':scope > span:nth-of-type(2)'),
				redirectId: rdExtractor.exec(li.textContent),
			};
			if (alias.id == null || alias.name == null || !(alias.id = parseInt(alias.id.textContent))
				|| !(alias.name = alias.name.textContent)) return;
			if (alias.redirectId != null) alias.redirectId = parseInt(alias.redirectId[1]); else delete alias.redirectId;
			return alias;
		}

		const findArtistId = artistName => artistName ? localXHR('/artist.php?' + new URLSearchParams({
			artistname: artistName,
		}).toString(), { method: 'HEAD' }).then(function(xhr) {
			const url = new URL(xhr.responseURL);
			return url.pathname == '/artist.php' && parseInt(url.searchParams.get('id')) || Promise.reject('Artist wasnot found');
		}) : Promise.reject('Invalid argument');
		const resolveArtistId = artistIdOrName => artistIdOrName > 0 ?
			Promise.resolve(artistIdOrName) : findArtistId(artistIdOrName);
		const resloveArtistName = artistIdOrName => artistIdOrName > 0 ? artistIdOrName == artist.id ? artist.name
			: queryAjaxAPI('artist', { id: artistIdOrName }).then(artist => artist.name) : Promise.resolve(artistIdOrName);
		const findAliasId = (aliasName, artistIdOrName = artist.id, resolveFinalAlias = false) => (function() {
			if (artistEdit && artistIdOrName < 0) return Promise.resolve(window.document);
			return resolveArtistId(artistIdOrName).then(artistId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'edit',
				artistid: artistId,
			}).toString()));
		})().then(function(document) {
			const addForm = document.body.querySelector('form.add_form');
			if (addForm == null) return Promise.reject('Invalid page structure');
			aliasName = aliasName.toLowerCase();
			for (let li of addForm.parentNode.parentNode.querySelectorAll('div.box > div > ul > li')) {
				const alias = getAlias(li);
				if (alias && alias.name.toLowerCase() == aliasName) return resolveFinalAlias && alias.redirectId || alias.id;
			}
			return Promise.reject('Alias name not defined for this artist');
		});
		const resolveAliasId = (aliasIdOrName, artistIdOrName = artist.id) => aliasIdOrName >= 0 ?
			Promise.resolve(aliasIdOrName) : findAliasId(aliasIdOrName, artistIdOrName);

		const addAlias = (name, redirectTo = 0, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName).then(artistId =>
					resolveAliasId(redirectTo, artistId).then(redirectTo => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'add_alias',
				artistid: artistId,
				name: name,
				redirect: redirectTo > 0 ? redirectTo : 0,
				auth: userAuth,
			}))));
		const deleteAlias = (aliasIdOrName, artistIdOrName = artist.id) => resolveAliasId(aliasIdOrName, artistIdOrName)
			.then(aliasId => localXHR('/artist.php?' + new URLSearchParams({
				action: 'delete_alias',
				aliasid: aliasId,
				auth: userAuth,
			}).toString(), { responseType: null }));

		const renameArtist = (newName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
			.then(artistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
				action: 'rename',
				artistid: artistId,
				name: newName,
				auth: userAuth,
			})));
		const addSimilarArtist = (relatedIdOrName, artistIdOrName = artist.id) => resolveArtistId(artistIdOrName)
				.then(artistId => resloveArtistName(relatedIdOrName).then(artistName =>
					localXHR('/artist.php', { responseType: null }, new URLSearchParams({
			action: 'add_similar',
			artistid: artistId,
			artistname: artistName,
			auth: userAuth,
		}))));
		const changeArtistId = (newArtistIdOrName, artistIdOrName = artist.id) =>
			resolveArtistId(artistIdOrName).then(artistId => resolveArtistId(newArtistIdOrName)
				.then(newArtistId => localXHR('/artist.php', { responseType: null }, new URLSearchParams({
					action: 'change_artistid',
					artistid: artistId,
					newartistid: newArtistId,
					confirm: 1,
					auth: userAuth,
				}))));

		function addAliasToGroup(groupId, aliasName, importances) {
			if (!(groupId > 0) || !aliasName || !Array.isArray(importances))
				return Promise.resolve('One or more arguments invalid');
			const payLoad = new URLSearchParams({
				action: 'add_alias',
				groupid: groupId,
				auth: userAuth,
			});
			for (let importance of importances) {
				payLoad.append('aliasname[]', aliasName);
				payLoad.append('importance[]', importance);
			}
			return localXHR('/torrents.php', { responseType: null }, payLoad);
		}
		const deleteArtistFromGroup = (groupId, artistIdOrName = artist.id, importances) =>
			groupId > 0 && Array.isArray(importances) ? resolveArtistId(artistIdOrName)
				.then(artistId => Promise.all(importances.map(importance => localXHR('/torrents.php?' + new URLSearchParams({
					action: 'delete_alias',
					groupid: groupId,
					artistid: artistId,
					importance: importance,
					auth: userAuth,
				}).toString(), { responseType: null })))) : Promise.reject('One or more arguments invalid');

		function gotoArtistPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName)
				.then(artistId => { document.location.assign('/artist.php?id=' + artistId.toString()) });
		}
		function gotoArtistEditPage(artistIdOrName = artist.id) {
			resolveArtistId(artistIdOrName).then(function(artistId) {
				document.location.assign('/artist.php?' + new URLSearchParams({
					action: 'edit',
					artistid: artistId,
				}).toString() + '#aliases');
			});
		}
		const wait = param => new Promise(resolve => { setTimeout(param => { resolve(param) }, 200, param) });

		const clearRecoveryInfo = () => { GM_deleteValue('damage_control') };
		function hasRecoveryInfo() {
			const recoveryInfo = GM_getValue('damage_control');
			return recoveryInfo && recoveryInfo.artist.id == artist.id;
		}

		if (artistEdit) {
			String.prototype.toASCII = function() {
				return this.normalize("NFKD").replace(/[\x00-\x1F\u0080-\uFFFF]/g, '');
			};
			String.prototype.properTitleCase = function(langCode = 'en') {
				if (![this.toUpperCase(), this.toLowerCase()].some(str => this == str)) {
					if (langCode) langCode = langCode.toLowerCase(); else return this;
					if (Array.isArray(caseFixes[langCode]))
						return caseFixes[langCode].reduce((result, replacer) => result.replace(...replacer), this);
					console.warn('String.prototype.properTitleCase() called with invalid language id:', langCode);
				}
				return this;
			};

			const caseFixes = {
				en: [
					/*[
						/\b(\w+)\b/g, match => match[0].toUpperCase() + match.slice(1).toLowerCase()
					], */[
						new RegExp(`(\\w+|[\\,\\)\\]\\}\\"\\'\\‘\\’\\“\\‟\\”]) +(${[
							'And In', 'And', 'By A', 'By An', 'By The', 'By', 'For A', 'For An', 'For', 'From', 'If', 'In To',
							'In', 'Into', 'Nor', 'Not', 'Of An', 'Of The', 'Of', 'Off', 'On', 'Onto', 'Or', 'Out Of', 'Out',
							'Over', 'With', 'Without', 'Yet',
						].join('|')})(?=\\s+)`, 'g'), (match, preWord, shortWord) => preWord + ' ' + shortWord.toLowerCase(),
					], [
						new RegExp(`\\b(${['by', 'in', 'of', 'on', 'or', 'to', 'for', 'out', 'into', 'from', 'with'].join('|')})$`, 'g'),
						(match, shortWord) => ' ' + shortWord[0].toUpperCase() + shortWord.slice(1).toLowerCase(),
					],
					[/([\-\:\&\;]) +(the|an?)(?=\s+)/g, (match, sym, article) => sym + ' ' + article[0].toUpperCase() + article.slice(1).toLowerCase()],
					[/\b(?:Best +of)\b/g, 'Best Of'],
				],
			};

			function findAliasName(id) {
				console.assert(id > 0, 'id > 0');
				if (id > 0) for (let li of aliases) {
					const alias = getAlias(li);
					if (alias && alias.id == id) return alias.name;
				}
			}
			function rmDelLink(li) {
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') li.removeChild(a);
			}

			let aliasesRoot = document.body.querySelector('form.add_form');
			if (aliasesRoot != null) (aliasesRoot = aliasesRoot.parentNode.parentNode).id = 'aliases';
				else throw 'Add alias form could not be located';
			console.assert(aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad',
				"aliasesRoot.nodeName == 'DIV' && aliasesRoot.className == 'box pad'");
			const aliases = aliasesRoot.querySelectorAll('div.box > div > ul > li'),
						dropDown = aliasesRoot.querySelector('select[name="redirect"]');
			const epitaph = `

Don't navigate away, close or reload current page, till it reloads self.

The operation can take longer to complete and can be reverted only
by hand, sure to proceed?`;
			let mainIdentityId, inProgress = false;

			class TorrentGroupsManager {
				constructor(aliasId) {
					if (!(aliasId > 0)) throw 'Invalid argument';
					this.groups = { };
					for (let torrentGroup of artist.torrentgroup) {
						console.assert(!(torrentGroup.groupId in this.groups), '!(torrentGroup.groupId in this.groups)');
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(artist => artist.aliasid == aliasId))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						if (importances.length > 0) this.groups[torrentGroup.groupId] = importances;
					}
				}

				get size() {
					return this.groups ? Object.keys(this.groups).filter(groupId =>
						Array.isArray(this.groups[groupId]) && this.groups[groupId].length > 0).length : 0;
				}
				get aliasUsed() { return this.size > 0 }

				removeAliasFromGroups() {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					const groupIds = Object.keys(this.groups);
					return (function removeAliasFromGroup(index = 0) {
						if (!(index >= 0 && index < groupIds.length))
							return Promise.resolve('Artist alias removed from all groups');
						const importances = this.groups[groupIds[index]];
						console.assert(Array.isArray(importances) && importances.length > 0,
							'Array.isArray(importances) && importances.length > 0');
						return Array.isArray(importances) && importances.length > 0 ?
							deleteArtistFromGroup(groupIds[index], artist.id, importances)
								.then(results => removeAliasFromGroup.call(this, index + 1))
							: removeAliasFromGroup.call(this, index + 1);
					}).call(this);
				}
				addAliasToGroups(aliasName = artist.name) {
					if (!this.aliasUsed) return Promise.resolve('No groups block this alias');
					if (!aliasName) return Promise.reject('Argument is invalid');
					const groupIds = Object.keys(this.groups);
					return (function _addAliasToGroup(index = 0) {
						if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Artist alias re-added to all groups');
						const importances = this.groups[groupIds[index]];
						console.assert(Array.isArray(importances) && importances.length > 0,
							'Array.isArray(importances) && importances.length > 0');
						return Array.isArray(importances) && importances.length > 0 ?
							addAliasToGroup(groupIds[index], aliasName, importances)
								.then(result => _addAliasToGroup.call(this, index + 1))
							: _addAliasToGroup.call(this, index + 1);
					}).call(this);
				}
			}

			class AliasDependantsManager {
				constructor(aliasId) {
					console.assert(aliasId > 0, 'aliasId > 0');
					if (aliasId > 0) this.redirectTo = aliasId; else throw 'Invalid argument';
					this.aliases = Array.from(aliases).map(function(li) {
						const alias = getAlias(li);
						return alias && alias.redirectId == aliasId && { [alias.id]: alias.name };
					}).filter(Boolean);
					if (this.aliases.length > 0) this.aliases = Object.assign.apply({ }, this.aliases); else delete this.aliases;
				}

				get size() { return Array.isArray(this.aliases) ? this.aliases.length : 0 }
				get hasDependants() { return this.size > 0 }

				removeAll() {
					return this.aliases && typeof this.aliases == 'object' ?
						Promise.all(Object.keys(this.aliases).map(deleteAlias)) : Promise.resolve('No dependants');
				}
				restoreAll(redirectTo = this.redirectTo, artistIdOrName = artist.id) {
					return this.aliases && typeof this.aliases == 'object' ? resolveArtistId(artistIdOrName).then(artistId =>
						resolveAliasId(redirectTo, artistId).then(redirectTo => Promise.all(Object.keys(this.aliases).map(aliasId =>
							addAlias(this.aliases[aliasId], redirectTo, artistId))))) : Promise.resolve('No dependants');
				}
			}

			class ArtistGroupKeeper {
				constructor() {
					for (let torrentGroup of artist.torrentgroup) for (let importance in torrentGroup.extendedArtists) {
						const artists = torrentGroup.extendedArtists[importance];
						if (Array.isArray(artists) && artists.length > 0) continue;
						this.artistId = artist.id;
						this.aliasName = `__${artist.id.toString()}__${Date.now().toString(16)}`;
						this.groupId = torrentGroup.groupId;
						this.importance = parseInt(importance);
						this.locked = false;
						return this;
					}
					throw 'Unable to find a spare group';
				}

				hold() {
					if (this.locked) return Promise.reject('Not available');
					this.locked = true;
					return addAlias(this.aliasName).then(wait)
						.then(() => addAliasToGroup(this.groupId, this.aliasName, [this.importance])).then(() => this.locked);
				}
				release(artistIdOrName = this.artistId) {
					if (!this.locked) return Promise.reject('Not available');
					return resolveArtistId(artistIdOrName).then(artistId =>
						deleteArtistFromGroup(this.groupId, artistId, [this.importance])
							.then(wait().then(() => deleteAlias(this.aliasName, artistId))).then(() => this.locked = false));
				}
			}

			function getSelectedRedirect(defaultsToMain = false) {
				let redirect = aliasesRoot.querySelector('select[name="redirect"]');
				if (redirect == null) throw 'Assertion failed: can not locate redirect selector';
				redirect = {
					id: parseInt(redirect.options[redirect.selectedIndex].value),
					name: redirect.options[redirect.selectedIndex].label,
				};
				console.assert(redirect.id >= 0 && redirect.name, 'redirect.id >= 0 && redirect.name');
				if (defaultsToMain && redirect.id == 0) {
					redirect.id = mainIdentityId;
					redirect.name = artist.name;
				}
				return Object.freeze(redirect);
			}
			function failHandler(reason) {
				if (activeElement instanceof HTMLElement && activeElement.parentNode != null) {
					activeElement.style.color = null;
					if (activeElement.dataset.caption) activeElement.value = activeElement.dataset.caption;
					activeElement.disabled = false;
					activeElement = null;
					inProgress = false;
				}
				alert(reason);
			}

			// Damage control
			function setRecoveryInfo(action, aliases, param) {
				console.assert(aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action,
					"aliases && (typeof aliases == 'object' || Array.isArray(aliases)) && action");
				const damageControl = {
					artist: artist,
					action: action,
					aliases: aliases,
				};
				if (param) damageControl.param = param;
				GM_setValue('damage_control', damageControl);
			}
			function recoverFromFailure() {
				const recoveryInfo = GM_getValue('damage_control');
				if (!recoveryInfo) return Promise.reject('No unfinished operation present');
				if (recoveryInfo.artist.id != artist.id)
					return Promise.reject('Unfinished operation for this artist not present');
				//artist = recoveryInfo.artist; // ?
				return eval(recoveryInfo.action)(recoveryInfo.artist, aliases, recoveryInfo.param).then(clearRecoveryInfo);
			}

			const redirectAliasTo = (alias, redirectId) => resolveAliasId(redirectId).then(function(redirectId) {
				if (redirectId == alias.id)
					return Promise.reject('Alias can\'t redirect to itself');
				else if (alias.redirectId > 0)
					return alias.redirectId == redirectId ? Promise.resolve('Redirect doesnot change')
						: deleteAlias(alias.id).then(() => wait().then(() => addAlias(alias.name, redirectId)));
				else if (alias && typeof alias.tgm == 'object')
					if (alias.tgm && alias.tgm.aliasUsed) {
						console.info(alias.name, 'present in these groups:', alias.tgm.groups);
						if (artist.torrentgroup.length > alias.tgm.size)
							return alias.dependants.removeAll().then(() => alias.tgm.removeAliasFromGroups())
								.then(() => wait(alias.id).then(deleteAlias))
								.then(() => wait().then(() => addAlias(alias.name, redirectId)))
								.then(() => wait(alias.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
								.then(() => alias.dependants.restoreAll(redirectId));
						else {
							const agk = new ArtistGroupKeeper;
							return alias.dependants.removeAll().then(() => agk.hold())
								.then(() => alias.tgm.removeAliasFromGroups()).then(() => wait(alias.id).then(deleteAlias))
								.then(() => wait().then(() => addAlias(alias.name, redirectId)))
								.then(() => wait(alias.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
								.then(() => wait().then(() => agk.release()))
								.then(() => alias.dependants.restoreAll(redirectId));
						}
					} else return alias.dependants.removeAll().then(() => deleteAlias(alias.id))
						.then(() => wait().then(() => addAlias(alias.name, redirectId)))
						.then(() => alias.dependants.restoreAll(redirectId));
				else throw 'Assertion failed: renounced alias';
			});

			function renameAlias(alias, newName) {
				if (alias.redirectId > 0)
					return deleteAlias(alias.id).then(() => wait().then(() => addAlias(newName, alias.redirectId)));
				else if (alias.tgm && alias.tgm.aliasUsed) {
					console.info(alias.name, 'present in these groups:', alias.tgm.groups);
					if (artist.torrentgroup.length > alias.tgm.size)
						return alias.dependants.removeAll().then(() => alias.tgm.removeAliasFromGroups())
							.then(() => wait(alias.id).then(deleteAlias)).then(() => wait(newName).then(addAlias))
							.then(() => wait(newName).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
							//.then(() => findArtistId(newName)).then(newArtistId => changeArtistId(artist.id, newArtistId))
							.then(() => wait(newName).then(AliasDependantsManager.prototype.restoreAll.bind(alias.dependants)));
					else {
						const agk = new ArtistGroupKeeper;
						return alias.dependants.removeAll().then(() => agk.hold())
							.then(() => alias.tgm.removeAliasFromGroups())
							.then(() => wait(alias.id).then(deleteAlias)).then(() => wait(newName).then(addAlias))
							.then(() => wait(newName).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
							.then(() => wait().then(() => agk.release()))
							.then(() => wait(newName).then(AliasDependantsManager.prototype.restoreAll.bind(alias.dependants)));
					}
				} else return alias.dependants.removeAll().then(() => deleteAlias(alias.id))
					.then(() => wait(newName).then(addAlias))
					.then(() => wait(newName).then(AliasDependantsManager.prototype.restoreAll.bind(alias.dependants)));
			}

			const recoveryQuestion = `Last operation for current artist was not successfull,
if you continue, recovery information will be invalidated or lost.`;

			// NRA actions

			function makeItMain(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				let nagText = `CAUTION

This action makes alias "${alias.name}" the main identity for artist ${artist.name},
while "${artist.name}" becomes it's subordinate N-R alias.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				const agk = new ArtistGroupKeeper;
				if (alias.tgm.aliasUsed) {
					console.info(alias.name, 'present in these groups:', alias.tgm.groups);
					if (artist.torrentgroup.length > alias.tgm.size) {
						alias.dependants.removeAll().then(() => alias.tgm.removeAliasFromGroups())
							.then(() => wait(alias.id).then(deleteAlias))
							.then(() => wait(alias.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
							.then(() => wait(alias.name).then(findArtistId))
							.then(newArtistId => changeArtistId(newArtistId)
								.then(() => alias.dependants.restoreAll(alias.name, newArtistId))
								.then(() => { gotoArtistEditPage(newArtistId) })).catch(failHandler);
					} else alias.dependants.removeAll().then(() => agk.hold())
						.then(() => alias.tgm.removeAliasFromGroups()).then(() => wait(alias.id).then(deleteAlias))
						.then(() => wait(alias.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
						.then(() => wait(alias.name).then(findArtistId)).then(newArtistId => changeArtistId(newArtistId)
							.then(() => wait(newArtistId).then(ArtistGroupKeeper.prototype.release.bind(agk)))
							.then(() => alias.dependants.restoreAll(alias.name, newArtistId))
							.then(() => { gotoArtistEditPage(newArtistId) })).catch(failHandler);
				} else {
					const tgm = new TorrentGroupsManager(mainIdentityId), midm = new AliasDependantsManager(mainIdentityId);
					if (artist.torrentgroup.length > tgm.size)
						Promise.all([midm.removeAll(), alias.dependants.removeAll()])
							.then(() => tgm.removeAliasFromGroups())
							.then(() => wait(alias.name).then(renameArtist)).then(() => wait(artist.name).then(deleteAlias))
							.then(() => wait(artist.name).then(addAlias))
							.then(() => wait(artist.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(tgm)))
							.then(() => wait().then(() =>
								Promise.all([alias.dependants.restoreAll(alias.name), midm.restoreAll(artist.name)])))
							.then(() => { document.location.reload() }, failHandler);
					else Promise.all([midm.removeAll(), alias.dependants.removeAll()])
						.then(() => agk.hold()).then(() => wait().then(() => tgm.removeAliasFromGroups()))
						.then(() => wait(alias.name).then(renameArtist)).then(() => wait(artist.name).then(deleteAlias))
						.then(() => wait(artist.name).then(addAlias))
						.then(() => wait(artist.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(tgm)))
						.then(() => wait().then(() => agk.release()))
						.then(() => Promise.all([alias.dependants.restoreAll(alias.name), midm.restoreAll(artist.name)]))
						.then(() => { document.location.reload() }, failHandler);
				}
				return false;
			}

			function changeToRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode), redirect = getSelectedRedirect(true);
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == alias.id) return false;
				let nagText = `CAUTION

This action makes alias "${alias.name}" redirect to artist\'s variant "${redirect.name}",
and replaces the alias in all involved groups (if any) with this variant.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				redirectAliasTo(alias, redirect.id).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function renameNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.dependants, 'alias.dependants');
				let nagText = `CAUTION

This action renames alias
"${alias.name}",
and replaces the alias in all involved groups (if any) with the new name.
New name can't be artist name or alias already taken on the site.`;
				if (alias.tgm.aliasUsed) nagText += '\n\nBlocked by ' + alias.tgm.size + ' groups';
				let newName = prompt(nagText + '\n\nThe operation can be reverted only by hand, to proceed enter and confirm new name\n\n', alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				(newName.toLowerCase() == alias.name.toLowerCase() ? Promise.reject('Case change') : findAliasId(newName, -1).then(function(aliasId) {
					alert(`Name is already taken by alias id ${aliasId}, the operation is aborted`);
				}, reason => queryAjaxAPI('artist', { artistname: newName }).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				}))).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					//setRecoveryInfo('renameAlias', alias, newName);
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function cutOffNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				let nagText = 'CAUTION\n\nThis action ';
				nagText += alias.tgm.aliasUsed ? `cuts off identity "${alias.name}"
from artist ${artist.name} and leaves it in separate group.

Blocked by ${alias.tgm.size} groups`
					: `deletes identity "${alias.name}" and all it's dependants (${alias.dependants.size}).

(Not used in any release)`;
				if (artist.torrentgroup.length <= alias.tgm.size) nagText += `

This action also vanishes this artist group as no other name variants
are used in any release`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				//setRecoveryInfo('cutOff', alias);
				if (alias.tgm.aliasUsed) {
					console.info(alias.name, 'present in these groups:', alias.tgm.groups);
					alias.dependants.removeAll().then(() => alias.tgm.removeAliasFromGroups())
						.then(() => wait(alias.id).then(deleteAlias))
						.then(() => wait(alias.name).then(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm)))
						.then(() => findArtistId(alias.name)).then(newArtistId => alias.dependants.restoreAll(alias.name, newArtistId).then(function() {
							if (artist.torrentgroup.length > alias.tgm.size) {
								GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), true);
								document.location.reload();
							} else gotoArtistPage(newArtistId);
						})).catch(failHandler);
				} else alias.dependants.removeAll().then(() => deleteAlias(alias.id)).then(() => { document.location.reload() }, failHandler);
				return false;
			}

			function split(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				let newName, newNames = [ ];
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				const prologue = () => {
					let result = `CAUTION

This action splits artist's identity "${alias.name}" into two or more names
and replaces the identity in all involved groups with new names. No linking of new names
to current artist will be performed, profile pages of names that aren't existing aliases already
will open in separate tabs for review.`;
					if (alias.tgm.aliasUsed) result += '\n\nBlocked by ' + alias.tgm.size + ' groups';
					if (newNames.length > 0) result += '\n\n' + newNames.map(n => '\t' + n).join('\n');
					return result;
				};
				if (!alias.tgm.aliasUsed) return false;
				do {
					if ((newName = prompt(prologue().replace(/^CAUTION\s*/, '') +
						`\n\nEnter carefully new artist name #${newNames.length + 1}, to finish submit empty input\n\n`,
						newNames.length < 2 ? alias.name : undefined)) == undefined) return false;
					if ((newName = newName.trim()) && !newNames.includes(newName)) newNames.push(newName);
				} while (newName);
				if (newNames.length < 2 || !confirm(prologue() + epitaph)) return false;
				const currentTarget = evt.currentTarget;
				inProgress = true;
				activeElement = currentTarget;
				currentTarget.textContent = 'processing ...';
				currentTarget.style.color = 'red';
				console.info(alias.name, 'present in these groups:', alias.tgm.groups);
				alias.tgm.removeAliasFromGroups().then(() => wait().then(() =>
						Promise.all(newNames.map(TorrentGroupsManager.prototype.addAliasToGroups.bind(alias.tgm))))).then(function() {
					newNames.forEach(function(newName, index) {
						if (index > 0 || artist.torrentgroup.length > alias.tgm.size) findArtistId(newName).then(artistId =>
							{ if (artistId != artist.id) GM_openInTab(document.location.origin + '/artist.php?id=' + artistId.toString(), true) });
					});
					if (artist.torrentgroup.length > alias.tgm.size) document.location.reload();
						else findArtistId(newNames[0]).then(artistId => { if (artistId != artist.id) gotoArtistPage(artistId) });
				}, failHandler);
				return false;
			}

			function select(evt) {
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				console.assert(dropDown instanceof HTMLSelectElement, 'dropDown instanceof HTMLSelectElement');
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!alias.redirectId && dropDown != null) {
					dropDown.value = alias.id;
					if (typeof dropDown.onchange == 'function') dropDown.onchange();
				}
				return false;
			}

			// RDA actions

			function changeToNra(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm(`This action makes artist's identity "${alias.name}" distinct`)) return false;
				console.assert(alias && typeof alias == 'object');
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				deleteAlias(alias.id).then(() => wait(alias.name).then(addAlias))
					.then(() => { document.location.reload() }).catch(failHandler);
				return false;
			}

			function changeRedirect(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode), redirect = getSelectedRedirect();
				console.assert(alias && typeof alias == 'object');
				if (redirect.id == 0) return changeToNra(evt); else if (redirect.id == alias.redirectId) return false;
				if (!confirm(`This action changes alias "${alias.name}"'s to resolve to "${redirect.name}"`))
					return false;
				inProgress = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				redirectAliasTo(alias, redirect.id).then(() => { document.location.reload() }).catch(failHandler);
				return false;
			}

			function renameRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				console.assert(alias.redirectId, 'alias.redirectId');
				let newName = prompt(`This action renames alias "${alias.name}"`, alias.name);
				if (newName) newName = newName.trim(); else return false;
				if (!newName || newName == alias.name) return false;
				const currentTarget = evt.currentTarget;
				findAliasId(newName, -1).then(function(aliasId) {
					alert(`Name is already taken by alias id ${aliasId}, the operation is aborted`);
				}, reason => queryAjaxAPI('artist', { artistname: newName }).then(function(dupeTo) {
					siteArtistsCache[dupeTo.name] = dupeTo;
					alert(`Name is already taken by artist "${dupeTo.name}" (${dupeTo.id}), the operation is aborted`);
					GM_openInTab(document.location.origin + '/artist.php?id=' + dupeTo.id.toString(), false);
				})).catch(function(reason) {
					inProgress = true;
					activeElement = currentTarget;
					currentTarget.textContent = 'processing ...';
					currentTarget.style.color = 'red';
					renameAlias(alias, newName).then(() => { document.location.reload() }, failHandler);
				});
				return false;
			}

			function X(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return false;
				console.assert(evt.currentTarget.nodeName == 'A', "evt.currentTarget.nodeName == 'A'");
				const alias = getAlias(evt.currentTarget.parentNode);
				console.assert(alias && typeof alias == 'object');
				if (!confirm('Delete this alias?')) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				deleteAlias(alias.id).then(() => { document.location.reload() }).catch(failHandler);
				return false;
			}

			// batch actions
			function batchAction(actions, condition) {
				console.assert(typeof actions == 'function', "typeof actions == 'function'");
				if (typeof actions != 'function') throw 'Invalid argument';
				let selAliases = aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]:checked');
				if (selAliases.length <= 0) return Promise.reject('No aliases selected');
				selAliases = Array.from(selAliases).map(checkbox => getAlias(checkbox.parentNode));
				console.assert(selAliases.every(Boolean), 'selAliases.every(Boolean)');
				if (!selAliases.every(Boolean)) throw 'Assertion failed: element(s) without linked alias';
				if (typeof condition == 'function') selAliases = selAliases.filter(condition);
				if (selAliases.length <= 0) return Promise.reject('No alias fulfils for this action');
				//setRecoveryInfo('batchRecovery', selAliases, actions.toString());
				return Promise.all(selAliases.map(actions)).then(function() {
					clearRecoveryInfo();
					document.location.reload();
				}, failHandler);
			}
			function batchRecovery(artist, aliases, actions) {
				if (typeof actions == 'string') actions = eval(actions);
				if (typeof actions != 'function') return Promise.reject('Action not valid callback');
				console.assert(Array.isArray(aliases) && aliases.length > 0, 'Array.isArray(aliases) && aliases.length > 0');
				return Promise.all(aliases.map(actions));
			}

			function batchChangeToRDA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				const redirect = getSelectedRedirect();
				if (redirect.id == 0) return batchChangeToNRA(evt);
				let nagText = `CAUTION

This action makes all selected aliases redirect to artist\'s variant
"${redirect.name}",
and replaces all non-redirect aliases in their involved groups (if any) with this variant.`;
				if (!confirm(nagText + epitaph)) return false;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => redirectAliasTo(alias, redirect.id), alias => alias.id != redirect.id).catch(failHandler);
			}

			function batchChangeToNRA(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action makes all selected RDAs distinct within artist`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.disabled = true;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => deleteAlias(alias.id).then(() => wait(alias.name).then(addAlias)),
					alias => alias.redirectId > 0).catch(failHandler);
			}

			function batchRemove(evt) {
				if (inProgress || hasRecoveryInfo() && !confirm(recoveryQuestion)) return;
				let nagText = `This action deletes all selected RDAs and unused NRAs`;
				if (!confirm(nagText)) return;
				inProgress = true;
				activeElement = evt.currentTarget;
				evt.currentTarget.textContent = 'processing ...';
				evt.currentTarget.style.color = 'red';
				batchAction(alias => deleteAlias(alias.id), alias => alias.redirectId > 0 || !alias.tgm || !alias.tgm.aliasUsed)
					.catch(failHandler);
			}

			if (hasRecoveryInfo()) for (let h2 of document.body.querySelectorAll('div#content h2')) {
				if (!h2.textContent.includes('Artist aliases')) continue;
				let input = document.createElement('INPUT');
				input.type = 'button';
				input.dataset.caption = input.value = 'Recover from unfinished operation';
				document.tooltipster.then(() => { input.tooltipster({
					content: 'Unfinished operation information was found for this artist<br>Recovery will try to finish.',
				}) });
				input.style.marginLeft = '2em';
				input.value = '[ processing... ]';
				input.onclick = function(evt) {
					if (inProgress || !confirm('This will try to finalize last interrputed operation. Continue?')) return;
					inProgress = true;
					(activeElement = evt.currentTarget).disabled = true;
					activeElement.style.color = 'red';
					recoverFromFailure().then(function() {
						activeElement.value = 'Recovery successfull, reloading...';
						//document.location.reload();
					}, function(reason) {
						activeElement.style.color = null;
						activeElement.value = input.dataset.caption;
						activeElement.disabled = false;
						alert('Recovery was not successfull: ' + reason);
						document.location.reload();
					});
				};
				h2.insertAdjacentElement('afterend', input);
			}

			for (let li of aliases) if (!rdExtractor.test(li.textContent)) {
				const alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
				};
				if (Object.keys(alias).some(key => key == null) || !(alias.id = parseInt(alias.id.textContent))) continue;
				const elem = alias.name;
				if (!(alias.name = alias.name.textContent) || alias.name != artist.name) continue;
				mainIdentityId = alias.id;
				rmDelLink(li);
				elem.style.fontWeight = 900;
				break;
			}
			console.assert(mainIdentityId > 0, 'mainIdentityId > 0');

			function applyDynaFilter(str) {
				const filterById = Number.isInteger(str), norm = str => str.toLowerCase();
				if (!filterById) str = str ? parseInt(str) || norm(str.trim()) : undefined;

				function isHidden(li) {
					if (!str) return false;
					let elem = li.querySelector(':scope > span:nth-of-type(2)');
					console.assert(elem != null, 'elem != null');
					if (!filterById && (elem == null || norm(elem.textContent).includes(str))) return false;
					if (!Number.isInteger(str)) return true;
					elem = li.querySelector(':scope > span:nth-of-type(1)');
					if (elem != null && str == parseInt(elem.textContent)) return false;
					return (elem = rdExtractor.exec(li.textContent)) == null || str != parseInt(elem[1]);
				}
				for (let li of aliases) li.hidden = isHidden(li);
			}

			for (let li of aliases) {
				li.alias = {
					id: li.querySelector(':scope > span:nth-of-type(1)'),
					name: li.querySelector(':scope > span:nth-of-type(2)'),
					redirectId: rdExtractor.exec(li.textContent),
				};
				if (li.alias.id == null || li.alias.name == null) {
					delete li.alias;
					continue;
				}
				if (li.alias.redirectId == null) {
					li.alias.id.style.cursor = 'pointer';
					li.alias.id.onclick = function(evt) {
						const aliasId = parseInt(evt.currentTarget.textContent);
						console.assert(aliasId >= 0, 'aliasId >= 0');
						if (!(aliasId >= 0)) throw 'Invalid node value';
						applyDynaFilter(aliasId);
						const dynaFilter = document.getElementById('aliases-dynafilter');
						if (dynaFilter != null) dynaFilter.value = aliasId;
					};
					li.alias.id.title = 'Click to filter';
					(elem => { document.tooltipster.then(() => { $(elem).tooltipster() }) })(li.alias.id);
				}
				if (!(li.alias.id = parseInt(li.alias.id.textContent)) || !(li.alias.name = li.alias.name.textContent)) continue; // assertion failed

				function addButton(caption, tooltip, cls, margin, callback) {
					const a = document.createElement('A');
					a.className = 'brackets';
					if (cls) a.classList.add(cls);
					if (margin) a.style.marginLeft = margin;
					a.href = '#';
					if (caption) a.dataset.caption = a.textContent = caption.toUpperCase();
					if (tooltip) document.tooltipster.then(() => { $(a).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						a.title = tooltip;
						console.warn(reason);
					});
					if (typeof callback == 'function') a.onclick = callback;
					li.append(a);
				}

				if (li.alias.redirectId != null) { // RDA
					li.alias.redirectId = parseInt(li.alias.redirectId[1]);
					console.assert(li.alias.redirectId > 0, 'li.alias.redirectId > 0');
					for (let span of li.getElementsByTagName('SPAN')) if (parseInt(span.textContent) == li.alias.redirectId) {
						const deref = findAliasName(li.alias.redirectId);
						if (deref) document.tooltipster.then(function() {
							const tooltip = '<span style="font-size: 10pt; padding: 1em;">' + deref + '</span>';
							if ($(span).data('plugin_tooltipster'))
								$(span).tooltipster('update', tooltip).data('plugin_tooltipster').options.delay = 100;
							else $(span).tooltipster({ delay: 100, content: tooltip });
						}).catch(function(reason) {
							//span.textContent = deref + ' (' + span.textContent + ')';
							span.title = deref;
							console.warn(reason);
						});
						span.style.cursor = 'pointer';
						span.onclick = function(evt) {
							applyDynaFilter(li.alias.redirectId);
							const dynaFilter = document.getElementById('aliases-dynafilter');
							if (dynaFilter != null) dynaFilter.value = li.alias.redirectId;
						};
					}

					addButton('NRA', 'Change to non-redirecting alias', 'make-nra', '3pt', changeToNra);
					addButton('CHG', 'Change redirect', 'redirect-to', '5pt', changeRedirect);
					addButton('RN', 'Rename this alias', 'rename', '5pt', renameRDA);
				} else { // NRA
					delete li.alias.redirectId;
					li.alias.tgm = new TorrentGroupsManager(li.alias.id);
					li.alias.dependants = new AliasDependantsManager(li.alias.id);
					li.style.color = isLightTheme ? 'peru' : isDarkTheme ? 'antiquewhite' : 'darkorange';
					if (li.alias.name != artist.name) {
						addButton('MAIN', 'Make this alias main artist\'s identity', 'make-main', '3pt', makeItMain);
						addButton('RD', 'Change to redirecting alias to artist\'s identity selected in dropdown below',
							'redirect-to', '5pt', changeToRedirect);
						addButton('RN', 'Rename this alias while keeping it distinguished from the main identity',
							'rename', '5pt', renameNRA);
						addButton('CUT', 'Just unlink this alias from the artist and leave it in separate group; unused aliases will be deleted',
							'cut-off', '5pt', cutOffNRA);
					}
					if (li.alias.tgm.aliasUsed) addButton('S', 'Split this ' + (li.alias.name == artist.name ? 'artist': 'alias') +
						' to two or more names', 'split', '5pt', split);
					addButton('SEL', 'Select as redirect target', 'select', '5pt', select);
					if (li.alias.tgm.aliasUsed) {
						rmDelLink(li);
						const span = document.createElement('span');
						span.textContent = '(' + li.alias.tgm.size + ')';
						span.style.marginLeft = '5pt';
						document.tooltipster.then(() => { $(span).tooltipster({ content: 'Amount of groups blocking this alias' }) }).catch(function(reason) {
							span.title = 'Amount of groups blocking this alias';
							console.warn(reason);
						});
						li.append(span);
					}
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'X') {
					a.href = '#';
					a.dataset.caption = a.textContent;
					a.onclick = X;
				}
				for (let a of li.getElementsByTagName('A')) if (a.textContent.trim() == 'User') {
					const href = new URL(a.href);
					if (userId > 0 && parseInt(href.searchParams.get('id')) == userId) {
						const span = document.createElement('SPAN');
						span.className = 'brackets';
						span.style.color = 'skyblue';
						span.textContent = 'Me';
						li.replaceChild(span, a);
					}
				}
			}

			const h3 = aliasesRoot.getElementsByTagName('H3');
			if (h3.length > 0 && aliases.length > 1) {
				const elems = createElements('LABEL', 'INPUT', 'INPUT', 'DIV', 'LABEL', 'INPUT', 'IMG', 'SPAN');
				elems[3].style = 'transition: height 0.5s; height: 0; overflow: hidden;';
				elems[3].id = 'batch-controls';
				elems[4].style = 'margin-left: 15pt; padding: 5pt; line-height: 0;';
				elems[5].type = 'checkbox';
				elems[5].onclick = function(evt) {
					for (let input of aliasesRoot.querySelectorAll('div > ul > li > input.aam[type="checkbox"]'))
						if (!input.parentNode.hidden) input.checked = evt.currentTarget.checked;
				};
				elems[4].append(elems[5]);
				elems[3].append(elems[4]);

				function addButton(caption, callback, tooltip, margin = '5pt') {
					const input = document.createElement('INPUT');
					input.type = 'button';
					if (caption) input.dataset.caption = input.value = caption;
					if (margin) input.style.marginLeft = margin;
					if (tooltip) document.tooltipster.then(() => { $(input).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) })
						.catch(reason => { console.warn(reason) });
					if (typeof callback == 'function') input.onclick = callback;
					elems[3].append(input);
				}
				addButton('Redirect', batchChangeToRDA, 'Make selected aliases redirect to selected identity', '1em');
				addButton('Distinct', batchChangeToNRA, 'Make selected aliases distinct (make them NRA)');
				addButton('Delete', batchRemove, 'Remove all selected aliases (except used NRAs)');

				h3[0].insertAdjacentElement('afterend', elems[3]);
				elems[2].type = 'button';
				elems[2].value = 'Show batch controls';
				elems[2].style.marginLeft = '2em';
				elems[2].onclick = function(evt) {
					if ((elems[3] = document.getElementById('batch-controls')) != null) elems[3].style.height = 'auto';
					evt.currentTarget.remove();
					for (let li of aliasesRoot.querySelectorAll('div > ul > li')) {
						let elem = li.querySelector(':scope > span:nth-of-type(2)');
						if (elem == null || elem.textContent == artist.name) continue;
						elem = document.createElement('INPUT');
						elem.type = 'checkbox';
						elem.className = 'aam';
						elem.style = 'margin-right: 2pt; position: relative; left: -2pt;';
						li.prepend(elem);
						li.style.listStyleType = 'none';
					}
				};
				h3[0].insertAdjacentElement('afterend', elems[2]);
				elems[0].textContent = 'Filter by';
				elems[1].type = 'text';
				elems[1].id = 'aliases-dynafilter';
				elems[1].style = 'margin-left: 1em; width: 20em; padding-right: 25pt;';
				elems[1].ondblclick = evt => { applyDynaFilter(evt.currentTarget.value = '') };
				elems[1].oninput = evt => { applyDynaFilter(evt.currentTarget.value) };
				elems[1].ondragover = elems[1].onpaste = evt => { evt.currentTarget.value = '' };
				elems[6].height = 17;
				elems[6].style = 'position: relative; left: -22pt; top: 1pt;';
				elems[6].src = 'https://ptpimg.me/d005eu.png';
				elems[6].onclick = evt => {
					applyDynaFilter();
					const input = document.getElementById('aliases-dynafilter');
					if (input != null) input.value = '';
				};
				elems[0].append(elems[1]);
				elems[0].append(elems[6]);
				h3[0].insertAdjacentElement('afterend', elems[0]);
				elems[7].textContent = '(' + aliases.length + ')';
				elems[7].style = 'margin-left: 1em; font: normal 9pt Helvetica, Arial, sans-serif;';
				h3[0].append(elems[7]);
			}
			if (dropDown != null) dropDown.onchange = function(evt) {
				const redirectId = parseInt((evt instanceof Event ? evt.currentTarget : dropDown).value);
				if (!(redirectId >= 0)) throw 'Unknown selection';
				for (let li of aliases) {
					let id = li.querySelector(':scope > span:nth-of-type(1)');
					if (id != null) id = parseInt(id.textContent); else continue;
					li.style.backgroundColor = id == redirectId ?
						isLightTheme ? 'blanchedalmond' : isDarkTheme ? 'darkslategray' : 'orange' : null;
				}
			};
			if (typeof dropDown.onchange == 'function') dropDown.onchange();

			function addDiscogsImport() {
				const dcToken = GM_getValue('discogs_token', 'quWuUAoqSkAOIuRudSIrwofPIruOzyDwcSxOgPBu'),
							apiRateControl = { };

				function queryAPI(endPoint, params) {
					if (endPoint) endPoint = new URL(endPoint, 'https://api.discogs.com');
						else return Promise.reject('No endpoint provided');
					if (typeof params == 'object') for (let key in params) endPoint.searchParams.set(key, params[key]);
						else if (params) endPoint.search = new URLSearchParams(params);
					return new Promise((resolve, reject) => (function request(retryCounter = 0) {
						const postpone = () => setTimeout(request, apiRateControl.timeFrameExpiry - now, retryCounter + 1);
						const now = Date.now();
						if (!apiRateControl.timeFrameExpiry || now > apiRateControl.timeFrameExpiry) {
							apiRateControl.timeFrameExpiry = now + 60 * 1000 + 500;
							apiRateControl.requestCounter = 0;
						}
						if (++apiRateControl.requestCounter <= 60) GM_xmlhttpRequest({ method: 'GET', url: endPoint,
							responseType: 'json',
							headers: {
								'Accept': 'application/json',
								'X-Requested-With': 'XMLHttpRequest',
								'Authorization': 'Discogs token=' + dcToken,
							},
							onload: function(response) {
								if (response.status >= 200 && response.status < 400) resolve(response.response); else {
									if (response.status == 429 && retryCounter < 10) {
										console.warn(defaultErrorHandler(response), response.response.message, '(' + retryCounter + ')');
										postpone();
									} else reject(defaultErrorHandler(response));
								}
							},
							onerror: response => { reject(defaultErrorHandler(response)) },
							ontimeout: response => { reject(defaultTimeoutHandler(response)) },
						}); else postpone();
					})());
				}
				const getArtist = artistId => artistId > 0 ? queryAPI('artists/' + artistId.toString())
					: Promise.reject('Invalid artist id');
				const search = query => queryAPI('database/search', query);

				function getDcArtistId() {
					console.assert(dcInput instanceof HTMLInputElement, 'dcInput instanceof HTMLInputElement');
					let m = /^(https?:\/\/(?:\w+\.)?discogs\.com\/artist\/(\d+))\b/i.exec(dcInput.value.trim());
					if (m != null) return parseInt(m[2]);
					console.warn('Discogs link isnot valid:', dcInput.value);
					return (m = /^\/artist\/(\d+)\b/i.exec(dcInput.value)) != null ? parseInt(m[1]) : undefined;
				}
				const dcNameNormalizer = artist => artist.replace(/\s+/g, ' ').replace(/\s+\(\d+\)$/, '');

				function getAliases(evt) {
					function cleanUp() {
						if (info.parentNode != null) info.remove();
						if (button.dataset.caption) button.value = button.dataset.caption;
						button.style.color = null;
					}

					if (inProgress) return false;
					const artistId = getDcArtistId();
					if (artistId > 0) inProgress = true; else throw 'Invalid Discogs link';
					const button = evt.currentTarget;
					button.disabled = true;
					//button.style.color = 'red';
					const info = document.createElement('SPAN');
					info.className = 'discogs-import-info';
					info.style.marginLeft = '2em';
					dcForm.append(info);
					getArtist(artistId).then(function(dcArtist) {
						function weakAlias(anv, mainDent = dcArtist.name) {
							if (!anv) throw 'Assertion failed: invalid argument (anv)';
							const norm = str => str.toASCII().toLowerCase(), anl = norm(mainDent), alnv = norm(anv);
							return alnv == anl || 'the ' + alnv == anl || alnv == 'the ' + anl ? 1
								: /^(?:[a-z](?:\.\s*|\s+))+(\w{2,})\w/.test(alnv) ? anl.includes(norm(RegExp.$1)) ? 3 : 2 : 0;
						}

						console.log('Discogs data for ' + artistId.toString() + ':', dcArtist);
						setAjaxApiLogger(function(action, apiTimeFrame, timeStamp) {
							info.textContent = `Please wait... (${apiTimeFrame.requestCounter - 5} name queries queued)`;
						});
						const resultsAdaptor = results => Array.isArray(results) && (results = results.filter(Boolean)).length > 0 ?
							Object.assign.apply({ }, results) : null;
						const querySiteStatus = arrRef => Array.isArray(arrRef) && arrRef.length > 0 ?
								Promise.all(arrRef.map(dcNameNormalizer).filter((name, ndx, arr) => arr.indexOf(name) == ndx).map(function(anv) {
							const result = value => ({ [anv]: value });
							return findAliasId(anv, -1).then(result,
								reason => !evt.ctrlKey ? getSiteArtist(anv).then(a => result(a.id != artist.id ? a : 0 /* implicit RDA */),
								reason => result(null) /* not found on site */) : result(null));
						})).then(resultsAdaptor) : Promise.resolve(null);
						const findAliasRedirectId = alias => alias && typeof alias == 'object' ? Promise.all([
							findAliasId(dcNameNormalizer(alias.name), -1, false),
							findAliasId(dcNameNormalizer(alias.name), -1, true),
						]).then(ids => ids[0] == ids[1] ? ids[0] : Promise.reject('Redirecting alias'))
							.catch(reason => mainIdentityId) : Promise.reject('Invalid argument');
						const relationsAdaptor = (alias, exploreANVs = true) => getArtist(alias.id).then(function(artist) {
							const aliases = [artist.name];
							if (exploreANVs && !evt.shiftKey && Array.isArray(artist.namevariations))
								Array.prototype.push.apply(aliases, artist.namevariations);
							return findAliasRedirectId(alias).then(redirectId => querySiteStatus(aliases.filter((alias, ndx, arr) =>
									arr.indexOf(alias) == ndx)).then(aliases => ({ [artist.id]: {
								name: artist.name,
								realName: artist.realname,
								image: artist.images && artist.images.length > 0 ? artist.images[0].uri150 : undefined,
								profile: artist.profile,
								urls: artist.urls,
								anvs: aliases,
								redirectTo: redirectId,
								matchByRealName: Boolean(artist.realname && dcArtist.realname
									&& artist.realname.toLowerCase() == dcArtist.realname.toLowerCase()),
							} })));
						}, function(reason) {
							alert(`${alias.name}: ${reason}`);
							return null;
						});
						const basedOnArtist = alias => {
							const cmpNorm = str => dcNameNormalizer(str).toASCII().replace(/\W+/g, '').toLowerCase();
							const testForName = n => (n = cmpNorm(n)).length > 0 && (an.startsWith(n + ' ') || an.endsWith(' ' + n)
								|| an.includes(' ' + n + ' ') || n.length > 4 && an.includes(n));
							const an = cmpNorm(alias.name);
							return an.length > 0 && (testForName(dcArtist.name) || Array.isArray(dcArtist.namevariations)
								&& dcArtist.namevariations.some(testForName));
						};
						const isImported = cls => ['everything', cls + '-only'].some(cls => button.id == 'fetch-' + cls);
						const anvs = [dcArtist.name];
						if (Array.isArray(dcArtist.namevariations)) Array.prototype.push.apply(anvs, dcArtist.namevariations);
						if (dcArtist.realname && !anvs.includes(dcArtist.realname)) anvs.push(dcArtist.realname);
						return Promise.all([
							// Artist's ANVs
							isImported('anvs') ? querySiteStatus(anvs.filter((anv, ndx, arr) => arr.indexOf(anv) == ndx))
								: Promise.resolve(null),
							// Music groups based on artist
							isImported('groups') && Array.isArray(dcArtist.groups) ? Promise.all(dcArtist.groups.filter(basedOnArtist)
								.map(group => relationsAdaptor(group))).then(resultsAdaptor) : Promise.resolve(null),
							// Artist's aliases
							isImported('aliases') && Array.isArray(dcArtist.aliases) ? Promise.all(dcArtist.aliases.map(alias =>
								relationsAdaptor(alias))).then(resultsAdaptor) : Promise.resolve(null),
							// Other music groups
							evt.altKey && isImported('groups') && Array.isArray(dcArtist.groups) ?
								Promise.all(dcArtist.groups.filter(group => !basedOnArtist(group)).map(group =>
									relationsAdaptor(group, false))).then(resultsAdaptor) : Promise.resolve(null),
							// Group members
							button.id == 'fetch-members-only' && Array.isArray(dcArtist.members) ?
								Promise.all(dcArtist.members.map(member => relationsAdaptor(member))).then(resultsAdaptor)
									: Promise.resolve(null),
						]).then(function(aliases) {
							if (aliases.every((el, ndx) => !el)) {
								info.textContent = 'Nothing to import';
								return setTimeout(cleanUp, 5000);
							} else cleanUp();
							console.debug('Discogs fetched aliases:', aliases);

							function showModal() {
								if (dropDown == null) throw 'Unexpected document structure';

								function addMergeIcon(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const bitmap = 'data:image/png;base64,' +
										'iVBORw0KGgoAAAANSUhEUgAAADMAAAAgCAYAAAC/40AfAAAACXBIWXMAAAsSAAALEgHS3X78' +
										'AAAHYklEQVR4nL2Yd1AUVxjA393tHccVOhygKMYuoIAJgqKIMIgQsIGKRp2MGmNiok5iokYT' +
										'IwmWGbEQHROTP+JoyowVNaKRopGiGErsBaOjdE44uF7zfQtrkLmjyMo33Bx77+3b7/e++pZa' +
										't+ughXQiHEIsHC7XKOBTKkeJ+KmPp0eR3xDfk0MGeGdTPJ6us3v7WigOh9PVHI7ZbOZrtHon' +
										'lUbr9Ky2PuDqP3eW9ZO5lU4aO3pn4IjBv8Ia5r5Qtiuh1Gq17VEA5XK5hMfjEQo/XB6BC2Kx' +
										'WEhlbX3QL39kHy679zAlcfL4Na5ODg/6Tm3rQj2rrrY5yFgNgfgURezt7YlELCZCOztCwTVC' +
										'3Xr4OL6qTh6YHBOxbJhv/3N9pbg1ocwW2yHDwTEAsphMxAQfjU5HmhQKIgIoF2dnYi8U0pCK' +
										'FlW/n0+dPz4jKnzlW/7Df2JLOXg8p7KuPrC/zL20WzAeLs5P4DZCh3oHMZpMIq1OJ1JrdWKj' +
										'0QSuxqXdTAWuqdFqibOjIw1FUTxiNJuFR/+8/INWr5dODA7YzQYM7uPFwpJNA71lxZEhgVu7' +
										'hNm1fpW/hbYA/r0MBDD2aq1WWlMvH3LjfkVkUfmt2U+r64YiFN4jb2wkOr2eyNzd6biCRME9' +
										'nVeYDmNcSA7pbAChUrBmGp/iacK72CTKXmin7GRcKRWL6mWuLo/GjBh6YVZM5Na8qyWLj57P' +
										'2ShvUngI+HyiVKloF/SSyeg4AiLOmUtFO4GVinhz9I7esnA5HBPGLm4SOn54sP8emzA9WVgk' +
										'tGuOiwjLCBw59MKB304cKLvzYLKdgE+7XFVNDfHy9KRjCIDI2UtF241Go11UaHBqb4EQBj5g' +
										'oYJ0sJQlPMh/b69hGPH2cLu3YfnihO9/P7k/u/D6QgSC2KKBvBkgYiZZ+cVbmlVq7/hJ49aC' +
										'FTvzgG4BgXBP5xbswkI+Icg/gxUYFKGdQLlyQdIS+NadzStYikA6Bghcjg8uiArkl958v6qu' +
										'ISg+IvQTX2/PfDaAMnMLdmMwTQjyewnolWFQeDyuYVly4grsAM7k5r8naAOqRJfz8KDrEVrp' +
										'cVXtuINHz+b4DRl0bOyooYd8ZO7FInuhvJsAJutA+bsx240P9PuOFRh6VejbliYlfgCBasjM' +
										'ufIh9HBEDxmuEoqxu5sbkUokdPdgMpkFJbfvp5TffZjiKBVXuzg6VEhE9g0wpuC01gZrYnlW' +
										'2xDCw5JgBehUTj4mAwTaxwpMKxDHtCQp4SOwjOb4hbxPMU1DWqddztXFhbhCLUIFWmOJQJFV' +
										'ezU2K71a63WnfS6d8rlW+sf/gQoYoP2swLQtblk0fdpaZwdp9ZHTF76GdC1xdnIiDlKpFQWh' +
										'3yNcK6v0TBASOhgepO29YGEjazCMJESGpwv4AgpqzXapREz/hgWW6fMgXROD0UAMBiMWWXqs' +
										't2Iym3mHTmXtYR2m/F7FvOu3H6yWiEUvIPAbi2uLUkmncCyybECg4DoGaLXmJ8SksQpzpeTm' +
										'mjOXCnfAA6Dj4dLNFUI8h7YHIdoLWqWzJpcRWzGDgiCQWMi8+OjUeXHRqazB5F4r+/zcX9e2' +
										'QTJg+jTSIJcTRXPzCwsZwMXwf4lIpHZxcqh0EIsaIWlANrN9uHtcWR2oUKpkHYFwHWx+58RF' +
										'pc5/O+ZL/I0VmJyrpRvPXbmWitbAsw/GRU1dHVFpNPSugk/TTWzAsMGXI0KCD0G9uQyHuWfQ' +
										'FWi6Wnv7j4ePFpTcmI0pvz0IutbcuKhvGBBWYC4WlWw+n1/8FQNiMBhIVW0tXTwRRA/X/WTu' +
										'jxYmxq4LDfQ/1tMjNnbgHa5pkDnTpnwLIJvaj70yDCzKA2ukgXt9xoCg4tVQW/BYgG6l0xtI' +
										'WJB/5vK5M1ZAkax61WdZAUlbkDB1Y8fxV4JRqbUemXkFe/6+/WAeVneMk/Yg2DchSPzk8QeW' +
										'zE5YBYc3PVsgybGRCPKFtTk9gkFr3Kp4kpgFFqlpeD4CK3qrBfSkGlxLr2/VGevIjOhJ+96d' +
										'Ff8xG29u2oFsfScx1ioICr4D4NDZBq86Zgyzha83GkRwxvf5t7JmYtndihTILuGYUREEYwKD' +
										'vLa+no6VVhAjmRkdkbF4ZtxqlkA4uCbExzYA2dDZXCrjyIliW4OQXsVanV6q1Gg9YPf5aAV0' +
										'K6YQNioUpOH5czoNMyBwGk1fND12LVvv0iD9CpKmRu5dOD12fVdzqcrahrE2R9veC3AgJvCI' +
										'zIgWTpbypiaigoLIgIGFLbBzm5Njp2xhAwIFrYLt0ejhg/O6M5/CNytdCe487jpWcWxJ1OBa' +
										'+BtdCKHHEomELXAMWDMldCxrr5lQsHmFI3p2d+dTWNxsCb3joDS281gIsadqewjdihghToYP' +
										'Gnh9aXLiquGDBhT0Xv3eCdXc0tLtyUwvhCCebi5Pp0WE7Zs2MWwfHqFfo47dlu68OEexYK2Q' +
										'ikUNvt5eZSFjRp0MCRh1ykEirn/dCvZEsM7Y7I8w9TpKJU0+XrJy/2Fv5AWNHJbl28+rvA/1' +
										'65H8B5hDiewM+feSAAAAAElFTkSuQmCC';
									const img = document.createElement('IMG');
									img.height = 10;
									img.style.marginLeft = '3pt';
									img.src = bitmap;
									img.title = 'Successfully merged with current artist (link should be invalid)';
									target.insertAdjacentElement('afterend', img);
								}
								function addLinkIcon(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									// const bitmap = 'data:image/png;base64,' +
									// 	'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAACXBIWXMAAA7DAAAOwwHHb6hk' +
									// 	'AAAA2klEQVR4nG3QPwtBURjH8StHNoXJpCxSBjGYrLwBuXW9ApPRIOUFyEswSDc2gySrFyB/' +
									// 	'UorCYJFBLDIc33Nddbvc+jzn9PS75zz3Ciml9vOYnjC1hxTueIg/IT+1gyhyOGMlXCEfNY8Z' +
									// 	'qpohD/TUCwHhCIWoA2Sg5knSa7BOsBB2KEgdQc1Wxx5xTHGDLghF2HTtUAEtNOHFEiVGuKoT' +
									// 	'+0gga89UY1/EzrrFkE91qQqmccKRUIx1iC3ahF7fT1BB3Wpq2ty+foOKM/QJGnLMSeorTaxR' +
									// 	'pndx/943gbU8uSQHli8AAAAASUVORK5CYII=';
									const img = document.createElement('IMG');
									img.height = 10;
									img.style.marginLeft = '3pt';
									img.src = 'https://ptpimg.me/4o67uu.png';
									img.title = 'Linked as similar artist';
									target.insertAdjacentElement('afterend', img);
								}
								function visualizeMerge(target, method, color) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									if (method) {
										const span = document.createElement('SPAN');
										span.textContent = 'Merged via ' + method;
										span.style.color = color;
										if (target.parentNode.firstChild.nodeType == Node.TEXT_NODE)
											target.parentNode.removeChild(target.parentNode.firstChild);
										if (target.parentNode.lastChild.nodeName == 'IMG')
											target.parentNode.removeChild(target.parentNode.lastChild);
										target.parentNode.replaceChild(span, target);
									} else addMergeIcon(target);
								}
								function visualizeMerges(target, method, color) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									visualizeMerge(target, method, color);
									const artistId = parseInt(target.dataset.artistId);
									if (!artistId) return; //throw 'Artist identification missing';
									for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
										if (parseInt(a.dataset.artistId) == artistId) visualizeMerge(a, method, color);
								}
								function mergeNRA(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if (confirm(`Artist ${artistName} is going to be merged via non-redirecting alias`))
										changeArtistId(artist.id, artistId).then(function() {
											target.onclick = null;
											visualizeMerges(target, 'non-redirecting alias', 'fuchsia'); //target.style.color = 'fuchsia';
											//alert(`${artistName} merged with ${artist.name} via non-redirecting alias`);
										}, alert);
								}
								function mergeRD(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if (confirm(`Artist ${artistName} is going to be merged via redirect`))
										renameArtist(artist.name, artistId).then(function() {
											target.onclick = null;
											visualizeMerges(target, 'redirect', 'limegreen'); //target.style.color = 'limegreen';
											//alert(`${artistName} merged with ${artist.name} via redirect`);
										}, alert);
								}
								function makeSimilar(target) {
									console.assert(target instanceof HTMLAnchorElement, 'target instanceof HTMLAnchorElement');
									const artistId = parseInt(target.dataset.artistId), artistName = target.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									if ('similar' in target.dataset) alert('This artist is already linked or merged');
									else if (confirm(`Artist ${artistName} is going to be added as similar artist`))
										addSimilarArtist(artistName).then(function() {
											target.dataset.similar = true;
											addLinkIcon(target);
											for (let a of document.body.querySelectorAll('table#dicogs-aliases > tbody > tr > td > a.local-artist-group'))
												if (parseInt(a.dataset.artistId) == artistId && !('similar' in a.dataset)) {
													a.dataset.similar = true;
													addLinkIcon(a);
												}
											//alert(`${artistName} added as similar artist of ${artist.name}`);
										}, alert);
								}

								const menuId = '16bbbc39-65a0-4c21-b6af-3d6c6ff79166';
								let menu = document.getElementById(menuId);
								if (menu == null) {
									menu = document.createElement('MENU');
									menu.type = 'context';
									menu.id = menuId;

									function addEntry(caption, callback) {
										const menuItem = document.createElement('MENUITEM');
										if (caption) menuItem.label = caption; else return;
										menuItem.type = 'command';
										if (typeof callback == 'function') menuItem.onclick = callback;
										menu.append(menuItem);
									}
									addEntry('Merge with current artist via non-redirecting alias', evt => { mergeNRA(menu) });
									addEntry('Merge with current artist via redirect to main identity', evt => { mergeRD(menu) });
									addEntry('Link as similar artist', evt => { makeSimilar(menu) });
									document.body.append(menu);
								}

								function clickHandler(evt) {
									const artistId = parseInt(evt.currentTarget.dataset.artistId),
												artistName = evt.currentTarget.dataset.artistName;
									if (!artistId || !artistName) throw 'Artist identification missing';
									console.assert(artistId != artist.id, 'artistId != artist.id');
									if (evt.altKey && !evt.ctrlKey && !evt.shiftKey) return makeSimilar(evt.currentTarget), false;
									else if (evt.ctrlKey && !evt.shiftKey && !evt.altKey) return mergeNRA(evt.currentTarget), false;
									else if (evt.shiftKey && !evt.ctrlKey && !evt.altKey) return mergeRD(evt.currentTarget), false;
								}
								function addImportEntry(aliasName, status, redirectTo = -1, id = artistId, highlight = false,
										redirectId = mainIdentityId, prefix) {
									var elems = createElements('TR', 'TD', 'A', 'SPAN', 'TD');
									elems[1].style = 'width: 100%; padding: 0 7pt;';
									elems[2].className = 'alias-name';
									elems[2].dataset.aliasName = elems[2].textContent = aliasName.properTitleCase();
									elems[2].style = highlight ? 'font-weight: bold;' : 'font-weight: normal;';
									elems[2].href = 'https://www.discogs.com/artist/' + id.toString() + '?' + new URLSearchParams({
										anv: aliasName,
										filter_anv: 1,
									}).toString();
									elems[2].target = '_blank';
									elems[3].textContent = '(' + (prefix ? prefix + id : id) + ')';
									elems[3].style = 'font-weight: 100; font-stretch: condensed; margin-left: 3pt;';
									if (id == artistId) elems[3].hidden = true;
									elems[2].append(elems[3]);
									elems[1].append(elems[2]);
									elems[0].append(elems[1]);
									if (!status) {
										elems[0].style.height = null;
										elems[4].style = 'padding: 0;';
										elems[5] = dropDown.cloneNode(true);
										elems[5].className = 'redirect-to';
										elems[5].style = 'max-width: 25em; margin: 1pt 3pt 1pt 0;';
										if (redirectId > 0) elems[5].dataset.defaultRedirectId = redirectId;
										for (let option of elems[5].children) if (option.nodeName == 'OPTION')
											option.text = parseInt(option.value) == 0 ?
												'Make it non-redirecting alias' : 'Redirect to ' + option.text;
										elems[6] = document.createElement('OPTION');
										elems[6].text = status == null ? 'Do not import' : 'Keep implicit redirect';
										elems[6].value = -1;
										elems[5].prepend(elems[6]);
										elems[5].value = status == 0 ? -1 : redirectTo;
										elems[5].tabIndex = ++tabIndex;
										elems[4].append(elems[5]);
									} else {
										elems[4].style = 'height: 18pt; padding: 0 7pt 0 3pt; text-align: left;';
										elems[4].style.minWidth = 'max-content';
										if (!elems[4].style.minWidth) elems[4].style.minWidth = '-moz-max-content';
										if (status > 0) {
											elems[4].textContent = 'Defined (' + status + ')';
										} else if (status == 0) {
											elems[4].textContent = 'Implicit redirect';
										} else if (typeof status == 'object') {
											elems[4].textContent = 'Taken by artist ';
											elems[5] = document.createElement('A');
											elems[5].className = 'local-artist-group';
											elems[5].href = '/artist.php?id=' + status.id;
											elems[5].target = '_blank';
											elems[5].textContent = status.name;
											elems[5].onclick = clickHandler;
											elems[5].setAttribute('contextmenu', menuId);
											elems[5].oncontextmenu = evt => { menu = evt.currentTarget };
											elems[5].dataset.artistName = status.name;
											elems[5].dataset.artistId = status.id;
											let tooltip = `Ctrl + click to merge with current artist via non-redirecting alias
Shift + click to merge with current artist via redirect to main identity`;
											if (artist.similarArtists
													&& artist.similarArtists.some(similarArtist => similarArtist.artistId == status.id))
												elems[5].dataset.similar = true;
											else tooltip += '\nAlt + click to link as similar artist';
											document.tooltipster.then(function() {
												if (status.body) tooltip += '\n\n' + status.body;
												if (status.image) tooltip +=
													`<img style="margin-left: 5pt; float: right; max-width: 90px; max-height: 90px;" src="${status.image}" />`;
												tooltip += '\n\n';
												if (status.tags && status.tags.length > 0) tooltip += 'Tags: ' +
													status.tags.map(tag => tag.name).join(', ') + '\n';
												tooltip += 'Releases: ' + status.statistics.numGroups;
												$(elems[5]).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>'), maxWidth: 500 });
											}).catch(function(reason) {
												console.warn(reason);
												elems[5].title = tooltip;
											});
											elems[4].append(elems[5]);
											if ('similar' in elems[5].dataset) addLinkIcon(elems[5]);
										}
									}
									elems[0].append(elems[4]);
									modal[5].append(elems[0]);
								}

								document.tooltipster.then(() => { $(button).tooltipster('hide') });
								const redirect = getSelectedRedirect(true);
								const modal = createElements('DIV', 'DIV', 'DIV', 'TABLE', 'THEAD', 'TBODY', 'DIV', 'INPUT', 'INPUT');
								modal[0].className = 'modal discogs-import';
								modal[0].style = 'position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); opacity: 0; visibility: hidden; transform: scale(1.1); transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s; z-index: 999999;';
								modal[0].onclick = evt => { if (evt.target == evt.currentTarget) closeModal() };
								modal[1].className = 'modal-content';
								modal[1].style = 'position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 65em; border-radius: 0.5rem; padding: 1rem 1rem 1rem 1rem;';
								if (isLightTheme) modal[1].style.color = 'black';
								modal[1].style.backgroundColor = isDarkTheme ? 'darkslategrey' : 'FloralWhite';
								modal[1].style.width = 'max-content';
								if (!modal[1].style.width) modal[1].style.width = '-moz-max-content';
								// Header
								modal[2].textContent = 'Review how to import ANVs found';
								modal[2].style = 'margin-bottom: 1em; font-weight: bold; font-size: 12pt;'
								modal[1].append(modal[2]);
								// Table
								modal[3].id = 'dicogs-aliases';
								modal[3].style = 'display: block; max-height: 45em; padding: 3pt 0px 2pt; overflow-y: scroll; scroll-behavior: auto;';
								modal[3].append(modal[4]);
								const nonLatin = artistName => dcNameNormalizer(artistName)
									.replace(/[\s\.\-\,\&]+/g, '').toASCII().length <= 0;
								let tabIndex = 0;
								// Artist's NVAs
								if (aliases[0]) for (let anv in aliases[0]) addImportEntry(anv, aliases[0][anv],
									nonLatin(anv) ? -1 : weakAlias(anv) > 0 ? mainIdentityId : 0, artistId,
									anv == dcNameNormalizer(dcArtist.name));
								// Music groups based on artist
								if (aliases[1]) for (let artistId in aliases[1]) for (let anv in aliases[1][artistId].anvs)
									addImportEntry(anv, aliases[1][artistId].anvs[anv],
										/*weakAlias(anv) > 1 ? -1 : */0, artistId, anv == dcNameNormalizer(aliases[1][artistId].name),
										aliases[1][artistId].redirectTo || redirect.id, 'G');
								// Artist's aliases
								if (aliases[2]) for (let artistId in aliases[2]) for (let anv in aliases[2][artistId].anvs)
									addImportEntry(anv, aliases[2][artistId].anvs[anv],
										aliases[2][artistId].matchByRealName/* || weakAlias(anv) == 1*/ ? 0 : -1,
										artistId, anv == dcNameNormalizer(aliases[2][artistId].name),
										aliases[2][artistId].redirectTo || redirect.id, 'A');
								// Other music groups possibly involved in
								if (aliases[3]) for (let artistId in aliases[3]) for (let anv in aliases[3][artistId].anvs)
									addImportEntry(anv, aliases[3][artistId].anvs[anv],
										weakAlias(anv) == 1 ? 0 : -1, artistId, anv == dcNameNormalizer(aliases[3][artistId].name),
										aliases[3][artistId].redirectTo || redirect.id, 'G');
								// Group members
								if (aliases[4]) for (let artistId in aliases[4]) for (let anv in aliases[4][artistId].anvs)
									addImportEntry(anv, aliases[4][artistId].anvs[anv],
										-1, artistId, anv == dcNameNormalizer(aliases[4][artistId].name),
										aliases[4][artistId].redirectTo || redirect.id, 'M');
								modal[3].append(modal[5]);
								modal[1].append(modal[3]);
								const allDropdowns = modal[3].querySelectorAll('tbody > tr > td:nth-of-type(2) > select');
								// Buttonbar
								modal[6].style = 'margin-top: 1em;';
								modal[7].type = 'button';
								modal[7].value = 'Import now';
								if (allDropdowns.length <= 0) modal[7].disabled = true;
								modal[7].onclick = function(evt) {
									const importTable = document.body.querySelectorAll('table#dicogs-aliases > tbody > tr');
									closeModal();
									Promise.all(Array.from(importTable).map(function(tr) {
										let aliasName = tr.querySelector('a.alias-name'),
												redirectId = tr.querySelector('select.redirect-to');
										return aliasName != null && redirectId != null && (redirectId = parseInt(redirectId.value)) >= 0 ?
											addAlias(aliasName.dataset.aliasName, redirectId) : null;
									}).filter(Boolean)).then(function(results) {
										console.info('Total', results.length, 'artist aliases imported from Discogs');
										if (results.length > 0) document.location.reload();
									});
								};
								modal[7].tabIndex = ++tabIndex;
								modal[6].append(modal[7]);
								modal[8].type = 'button';
								modal[8].value = 'Close';
								modal[8].onclick = closeModal;
								modal[8].tabIndex = ++tabIndex;
								modal[6].append(modal[8]);

								function addQSBtn(caption, value, margin, tooltip) {
									const a = document.createElement('A');
									a.textContent = caption;
									a.href = '#';
									a.style.color = isDarkTheme ? 'lightgrey' : '#0A84AF';
									if (margin) a.style.marginLeft = margin;
									if (tooltip) {
										a.title = 'Resolve all to ' + tooltip;
										document.tooltipster.then(() => { $(a).tooltipster() }).catch(reason => { console.warn(e) });
									}
									a.onclick = function(evt) {
										for (let select of allDropdowns) switch (typeof value) {
											case 'number': select.value = value; break;
											case 'function': select.value = value(select); break;
										}
										return false;
									};
									modal[6].append(a);
								}
								addQSBtn('Import none', -1, '3em');
								addQSBtn('All NRA', 0, '10pt');
								addQSBtn('All RD', select => select instanceof HTMLElement && select.dataset.defaultRedirectId ?
									parseInt(select.dataset.defaultRedirectId) : redirect.id, '10pt');

								modal[1].append(modal[6]);
								modal[0].append(modal[1]);
								document.body.style.overflow = 'hidden';
								document.body.append(modal[0]);
								modal[0].style.opacity = 1;
								modal[0].style.visibility = 'visible';
								modal[0].style.transform = 'scale(1.0)';
								modal[0].style.transition = 'visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s';
								if ((elem = modal[5].querySelector('select[tabindex="1"]')) != null) elem.focus();
							}
							function closeModal() {
								document.body.removeChild(document.body.querySelector('div.modal.discogs-import'));
								document.body.style.overflow = 'auto';
							}

							showModal();
							button.disabled = false;
							inProgress = false;
						});
					}).catch(alert).then(function() {
						cleanUp();
						button.disabled = false;
						inProgress = false;
					});
				}
				function addFetchButton(caption, id = caption) {
					const button = document.createElement('INPUT');
					button.type = 'button';
					if (id) button.id = 'fetch-' + id;
					if (caption) button.value = button.dataset.caption = 'Fetch ' + caption;
					button.style.display = 'none';
					button.onclick = getAliases;
					const tooltip = `Available keyboard modifiers (can be combined):
+CTRL: don't perform site names lookup (faster and less API requests consuming, doesn't reveal local separated identities)
+SHIFT: don't include aliases', groups' and members' name variants
+ALT: (only applies if including groups) include also groups not based on artist's name`;
					document.tooltipster.then(() => { $(button).tooltipster({ content: tooltip.replace(/\r?\n/g, '<br>') }) }).catch(function(reason) {
						button.title = tooltip;
						console.warn(reason);
					});
					dcForm.append(button);
				}

				const fetchers = {
					anvs: ['namevariations', 'ANVs'],
					aliases: ['aliases'],
					groups: ['groups'],
					members: ['members'],
				};
				aliasesRoot.append(document.createElement('BR'));
				let elem = document.createElement('H3');
				elem.textContent = 'Import from Discogs';
				aliasesRoot.append(elem);
				elem = document.createElement('DIV');
				elem.className = 'pad';
				const dcForm = document.createElement('FORM');
				dcForm.name = dcForm.className = 'discogs-import';
				const dcInput = document.createElement('INPUT');
				dcInput.type = 'text';
				dcInput.className = 'discogs_link tooltip';
				dcInput.style.width = '35em';
				dcInput.ondragover = dcInput.onpaste = evt => { evt.currentTarget.value = '' };
				dcInput.oninput = function(evt) {
					document.tooltipster.then(() => { $(dcInput).tooltipster('disable') }).catch(reason => { console.warn(reason) });
					button = document.getElementById('fetch-everything');
					getArtist(getDcArtistId()).then(function(dcArtist) {
						button.style.display = 'inline';
						if ((button = document.getElementById('dc-view')) != null) button.disabled = false;
						for (let key in fetchers) if ((button = document.getElementById(`fetch-${key}-only`)) != null)
							button.style.display = Array.isArray(dcArtist[fetchers[key][0]])
								&& dcArtist[fetchers[key][0]].length > 0 ? 'inline' : 'none';
						let tooltip = '<span style="font-size: 10pt;"><b style="color: tomato;">' + dcArtist.name + '</b>';
						if (dcArtist.realname) tooltip += ' (' + dcArtist.realname + ')';
						tooltip += '</span>';
						if (Array.isArray(dcArtist.images) && dcArtist.images.length > 0)
							tooltip += '<img style="margin-left: 5pt; float: right;" src="' + dcArtist.images[0].uri150 + '">';
						if (dcArtist.profile) tooltip += '<br><br><p>' + dcArtist.profile.trim()
							.replace(/\[[aglmr]=(.+?)\]/g, (m, name) => name)
							.replace(/\[[agm](\d+)\]/g, '<a href="https://www.discogs.com/artist/$1" target="_blank">a$1</a>')
							.replace(/\[[r](\d+)\]/g, '<a href="https://www.discogs.com/release/$1" target="_blank">r$1</a>')
							.replace(/\[[l](\d+)\]/g, '<a href="https://www.discogs.com/label/$1" target="_blank">l$1</a>')
							.replace(/\[url=(.+?)\](.*?)\[\/url\]/g, '<a href="$1" target="_blank">$2</a>') + '</p>';
						document.tooltipster.then(function() {
							$(dcInput).tooltipster('update', tooltip).tooltipster('enable').tooltipster('reposition');
						}).catch(reason => { console.warn(reason) });
					}, function(reason) {
						button.style.display = 'hidden';
						if ((button = document.getElementById('dc-view')) != null) button.disabled = true;
					});
				};
				document.tooltipster.then(function() {
					$(dcInput).tooltipster({ maxWidth: 640, content: '</>', interactive: true }).tooltipster('disable');
				}).catch(reason => { console.warn(reason) });
				dcForm.append(dcInput);
				let button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-view';
				button.value = 'View';
				button.disabled = true;
				button.onclick = function(evt) {
					const artistId = getDcArtistId();
					if (artistId) GM_openInTab('https://www.discogs.com/artist/' + artistId.toString(), false);
				};
				dcForm.append(button);
				button = document.createElement('INPUT');
				button.type = 'button';
				button.id = 'dc-search';
				button.value = 'Search artist';
				button.onclick = function(evt) {
					GM_openInTab('https://www.discogs.com/search/?' + new URLSearchParams({
						q: artist.name,
						type: 'artist',
						layout: 'med',
					}).toString(), false);
				};
				dcForm.append(button);
				dcForm.append(document.createElement('BR'));
				addFetchButton('everything');
				for (let key in fetchers) addFetchButton((fetchers[key][1] || fetchers[key][0]) + ' only', key + '-only');
				elem.append(dcForm);
				aliasesRoot.append(elem);

				const noMatches = 'No matches';
				const dcSearch = (term = artist.name) => search({
					query: term,
					type: 'artist',
					sort: 'score,desc',
					strict: true,
				}).then(function(response) {
					if (response.items <= 0) return Promise.reject(noMatches);
					let results = response.results.filter(result => result.type == 'artist'
						&& dcNameNormalizer(result.title).toLowerCase() == term.toLowerCase());
					if ((button = document.getElementById('dc-search')) != null)
						button.value = 'Search artist [' + results.length.toString() + ']';
					if (results.length <= 0) return Promise.reject(noMatches);
					console.log('Discogs search results for ' + term + ':', results);
					if (results.length > 1) {
						console.info('Discogs returns ambiguous results for "' + term + '":', results)	;
						//return Promise.reject('Ambiguity');
					}
					dcInput.value = 'https://www.discogs.com' + results[0].uri;
					dcInput.oninput();
				});
				dcSearch(artist.name).catch(function(reason) {
					if (reason != noMatches) return Promise.reject(reason);
					const m = /^(.+?)\s*\((.+)\)$/.exec(artist.name);
					if (m != null) dcSearch(m[1]).catch(reason =>
						reason == noMatches && isNaN(parseInt(m[2])) ? dcSearch(m[2]) : Promise.reject(reason));
				});
			}

			addDiscogsImport();
		} else {
			const selBase = 'div#discog_table > table > tbody > tr.group > td:first-of-type';
			const selCheckboxes = selBase + ' input[type="checkbox"][name="separate"]';
			const check = () => input.value.trim().length > 0
				&& document.body.querySelectorAll(selCheckboxes + ':checked').length > 0;

			function changeArtist(evt) {
				if (!check()) return false;
				const button = evt.currentTarget;
				let newArtist = parseInt(input.value);
				if (!(newArtist > 0) && (newArtist = input.value.trim())) try {
					let url = new URL(newArtist);
					if (url.origin == document.location.origin && url.pathname == '/artist.php'
							&& (url = parseInt(url.searchParams.get('id'))) > 0) newArtist = url;
				} catch(e) { }
				queryAjaxAPI('artist', { [newArtist > 0 ? 'id' : 'artistname']: newArtist }).catch(reason => reason).then(function(targetArtist) {
					if (targetArtist.id && targetArtist.name) siteArtistsCache[targetArtist.name] = targetArtist;
					if (newArtist > 0 && !(newArtist = targetArtist.name))
						return Promise.reject('Artist with this ID doesn\'t exist');
					const selectedGroups = Array.from(document.body.querySelectorAll(selCheckboxes + ':checked'))
						.map(checkbox => checkbox.parentNode.parentNode.parentNode.querySelector('div.group_info > strong > a:last-of-type'))
						.filter(a => a instanceof HTMLElement).map(a => parseInt(new URLSearchParams(a.search).get('id')))
						.filter(groupId => groupId > 0);
					const torrentGroups = { };
					for (let torrentGroup of artist.torrentgroup.filter(tg => selectedGroups.includes(tg.groupId))) {
						console.assert(!(torrentGroup.groupId in torrentGroups), '!(torrentGroup.groupId in this.groups)');
						const importances = Object.keys(torrentGroup.extendedArtists)
							.filter(importance => Array.isArray(torrentGroup.extendedArtists[importance])
								&& torrentGroup.extendedArtists[importance].some(_artist => _artist.id == artist.id))
							.map(key => parseInt(key)).filter((el, ndx, arr) => arr.indexOf(el) == ndx);
						console.assert(importances.length > 0, 'importances.length > 0');
						if (importances.length > 0) torrentGroups[torrentGroup.groupId] = importances;
					}
					const groupIds = Object.keys(torrentGroups);
					console.assert(selectedGroups.length == groupIds.length, 'selectedGroups.length == groupIds.length',
						selectedGroups, groupIds);
					if (groupIds.length <= 0) throw 'Assertion failed: none of selected releases include this artist';
					let nagText = `
You're going to replace all instances of ${artist.name}
in ${groupIds.length} releases by identity "${newArtist}"`;
					if (targetArtist.id) nagText += ' (' + targetArtist.id + ')';
					if (!confirm(nagText + '\n\nConfirm your choice to proceed')) return;
					button.disabled = true;
					button.style.color = 'red';
					button.value = '[ processing... ]';
					button.title = 'Don\'t break the operation, navigate away, reload or close current page';
					return (function changeArtistInGroup(index = 0) {
						if (!(index >= 0 && index < groupIds.length)) return Promise.resolve('Current artist removed from all groups');
						const importances = torrentGroups[groupIds[index]];
						console.assert(Array.isArray(importances) && importances.length > 0,
							'Array.isArray(importances) && importances.length > 0');
						return Array.isArray(importances) && importances.length > 0 ?
							deleteArtistFromGroup(groupIds[index], artist.id, importances)
								.then(() => addAliasToGroup(groupIds[index], newArtist, importances))
								.then(() => changeArtistInGroup(index + 1))
							: changeArtistInGroup(index + 1);
					})().then(() => targetArtist.id > 0 ? targetArtist.id : findArtistId(newArtist)).then(function(newArtistId) {
						if (groupIds.length < artist.torrentgroup.length) {
							if (newArtistId != artist.id)
								GM_openInTab(document.location.origin + '/artist.php?id=' + newArtistId.toString(), false);
							document.location.reload();
						} else if (newArtistId != artist.id) gotoArtistPage(newArtistId); else document.location.reload();
					}, function(reason) {
						button.removeAttribute('title');
						button.value = button.dataset.caption;
						button.style.color = null;
						button.disabled = false;
						alert(reason);
					});
				}).catch(alert);
			}

			const form = document.getElementById('artist-replacer');
			if (form == null) throw 'Assertion failed: form cannot be found';
			const input = document.createElement('INPUT');
			input.type = 'text';
			input.placeholder = 'New artist/alias name or artist id';
			input.style.width = '94%';
			input.dataset.gazelleAutocomplete = true;
			input.autocomplete = 'off';
			input.spellcheck = false;
			try { $(input).autocomplete({ serviceUrl: 'artist.php?action=autocomplete' }) } catch(e) { console.error(e) }
			form.append(input);
			form.append(document.createElement('BR'));
			let elem = document.createElement('INPUT');
			elem.type = 'button';
			elem.value = elem.dataset.caption = 'GO';
			elem.onclick = changeArtist;
			form.append(elem);

			function addQS(caption) {
				elem = document.createElement('A');
				elem.className = 'brackets'
				elem.style = 'float: right; margin: 5pt 5pt 0 0;';
				elem.textContent = caption;
				elem.href = '#';
				form.append(elem);
				return elem;
			}
			addQS('Unselect all').onclick = function(evt) {
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue; // don't touch filtered out
					input.checked = false;
					input.dispatchEvent(new Event('change'));
				}
				return false;
			};
			addQS('Select all').onclick = function(evt) {
				for (let input of document.body.querySelectorAll(selCheckboxes)) {
					if (input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue; // don't touch filtered out
					input.checked = true;
					input.dispatchEvent(new Event('change'));
				}
				return false;
			};

			function isOutside(target, related) {
				if (target instanceof HTMLElement) {
					target = target.parentNode;
					while (related instanceof HTMLElement) if ((related = related.parentNode) == target) return false;
				}
				return true;
			}
			for (let td of document.body.querySelectorAll('div#discog_table > table > tbody > tr.colhead_dark > td.small')) {
				const label = document.createElement('LABEL');
				label.style = 'padding: 1pt 5pt; cursor: pointer; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'select-category';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					for (let input of evt.currentTarget.parentNode.parentNode.parentNode.parentNode
							 .querySelectorAll('tr.group > td:first-of-type input[type="checkbox"][name="separate"]')) {
						if (input.parentNode.parentNode.parentNode.offsetWidth <= 0) continue; // don't touch filtered out
						input.checked = evt.currentTarget.checked;
						input.dispatchEvent(new Event('change'));
					}
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
			}
			for (let tr of document.body.querySelectorAll(['edition', 'torrent_row']
				.map(cls => 'div#discog_table > table > tbody > tr.' + cls).join(', '))) tr.remove();
			for (let td of document.body.querySelectorAll(selBase)) {
				while (td.firstChild != null) td.removeChild(td.firstChild);
				const label = document.createElement('LABEL');
				label.style = 'padding: 7pt; cursor: pointer; opacity: 1; transition: 0.25s;';
				elem = document.createElement('INPUT');
				elem.type = 'checkbox';
				elem.name = 'separate';
				elem.style.cursor = 'pointer';
				elem.onchange = function(evt) {
					evt.currentTarget.parentNode.parentNode.parentNode.style.opacity = evt.currentTarget.checked ? 1 : 0.75;
				};
				label.onmouseenter = evt => { label.style.backgroundColor = 'orange' };
				label.onmouseleave = evt =>
					{ if (isOutside(evt.currentTarget, evt.relatedTarget)) label.style.backgroundColor = null };
				label.append(elem);
				td.append(label);
				td.parentNode.style.opacity = 0.75;
			}
		}
	});
}

if (artistEdit) {
	if (!document.tooltipster) document.tooltipster = typeof jQuery.fn.tooltipster == 'function' ?
			Promise.resolve(jQuery.fn.tooltipster) : new Promise(function(resolve, reject) {
		const script = document.createElement('SCRIPT');
		script.src = '/static/functions/tooltipster.js';
		script.type = 'text/javascript';
		script.onload = function(evt) {
			//console.log('tooltipster.js was successfully loaded', evt);
			if (typeof jQuery.fn.tooltipster == 'function') resolve(jQuery.fn.tooltipster);
				else reject('tooltipster.js loaded but core function was not found');
		};
		script.onerror = evt => { reject('Error loading tooltipster.js') };
		document.head.append(script);
		['style.css'/*, 'custom.css', 'reset.css'*/].forEach(function(css) {
			const link = document.createElement('LINK');
			link.rel = 'stylesheet';
			link.type = 'text/css';
			link.href = '/static/styles/tooltipster/' + css;
			//link.onload = evt => { console.log('style.css was successfully loaded', evt) };
			link.onerror = evt => { (css == 'style.css' ? reject : console.warn)('Error loading ' + css) };
			document.head.append(link);
		});
	});
	loadArtist();
} else {
	const sidebar = document.body.querySelector('div#content div.sidebar');
	if (sidebar == null) throw 'Assertion failed: sidebar couldnot be located';
	const elems = createElements('DIV', 'FORM', 'DIV', 'INPUT');
	elems.push(document.body.querySelector('div#content div.header > h2'));
	elems[0].className = 'box box_replace_artist';
	elems[0].innerHTML = '<div class="head"><strong>Artist replacer</strong></div>';
	elems[1].id = 'artist-replacer';
	elems[1].style.padding = '6pt';
	elems[2].textContent = 'This tool will replace all instances of ' +
		(elems[4] != null ? elems[4].textContent.trim() : 'artist') +
		' in selected releases with different name, whatever existing or new.';
	elems[2].style = 'margin-bottom: 1em; font-size: 9pt;';
	elems[1].append(elems[2]);
	elems[3].type = 'button';
	elems[3].value = 'Enter selection mode';
	elems[3].onclick = function(evt) {
		while (elems[1].firstChild != null) elems[1].removeChild(elems[1].firstChild);
		loadArtist();
	}
	elems[1].append(elems[3]);
	elems[0].append(elems[1]);
	sidebar.append(elems[0]);
}