Greasyfork script-set-edit button

Add / Remove script into / from script set directly in GF script info page

La data de 19-02-2024. Vezi ultima versiune.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */

// ==UserScript==
// @name               Greasyfork script-set-edit button
// @name:zh-CN         Greasyfork 快捷编辑收藏
// @name:zh-TW         Greasyfork 快捷編輯收藏
// @name:en            Greasyfork script-set-edit button
// @name:en-US         Greasyfork script-set-edit button
// @name:fr            Greasyfork Set Edit+
// @namespace          Greasyfork-Favorite
// @version            0.2.4.2
// @description        Add / Remove script into / from script set directly in GF script info page
// @description:zh-CN  在GF脚本页直接编辑收藏集
// @description:zh-TW  在GF腳本頁直接編輯收藏集
// @description:en     Add / Remove script into / from script set directly in GF script info page
// @description:en-US  Add / Remove script into / from script set directly in GF script info page
// @description:fr     Ajouter un script à un jeu de scripts / supprimer un script d'un jeu de scripts directement sur la page d'informations sur les scripts GF
// @author             PY-DNG
// @license            GPL-3.0-or-later
// @match              http*://*.greatest.deepsurf.us/*
// @match              http*://*.sleazyfork.org/*
// @match              http*://greatest.deepsurf.us/*
// @match              http*://sleazyfork.org/*
// @require            https://update.greatest.deepsurf.us/scripts/456034/1303041/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://update.greatest.deepsurf.us/scripts/449583/1324274/ConfigManager.js
// @require            https://greatest.deepsurf.us/scripts/460385-gm-web-hooks/code/script.js?version=1221394
// @icon               data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAbBJREFUOE+Vk7GKGlEUhr8pAiKKDlqpCDpLUCzWBxCENBa+hBsL9wHsLWxXG4tNtcGH0MIiWopY7JSGEUWsbESwUDMw4Z7siLsZDbnlPff/7n/+e67G38sA6sAXIPVWXgA/gCdgfinRPuhfCoXCw3Q65XA4eLBl6zvw1S2eAZqmvTqOc5/NZhkMBqRSKWzbvgYxgbwquoAX4MGyLHK5HIlEgtFo9C+IOFEAo1gsWsvlUmyPx2MymYxAhsMh6XT6lpM7BXjWdf1xNpuRz+fl8GQywTAMGo0G1WpVnJxOJ692vinADPgcDAaZz+cCOR6PmKZJPB4XUb/fp1wuewF+KoBCf1JVBVE5dDodms3mWdDtdqlUKl6AX+8ALmS9XgtM0/5kvNlspKX9fv8RIgBp4bISCoXo9XqsVitKpRK6rrPb7STQ7XZ7eVRaeAYerz14OBxGOfL7/eIgmUwKzHEcJZEQ1eha1wBqPxqNihufzyeQWCzmtiPPqJYM0jWIyiISibBYLAgEAtTrdVqt1nmQXN0rcH/LicqmVqvRbrdN27bfjbKru+nk7ZD3Z7q4+b++82/YPKIrXsKZ3AAAAABJRU5ErkJggg==
// @grant              GM_xmlhttpRequest
// @grant              GM_setValue
// @grant              GM_getValue
// @grant              GM_listValues
// @grant              GM_deleteValue
// ==/UserScript==

/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager */
/* global GMXHRHook GMDLHook ConfigManager */

const GFScriptSetAPI = (function() {
	const API = {
		async getScriptSets() {
			const userpage = API.getUserpage();
			const oDom = await API.getDocument(userpage);

			const script_sets = Array.from($(oDom, 'ul#user-script-sets').children).map(li => {
				try {
					return {
						name: li.children[0].innerText,
						link: li.children[0].href,
						linkedit: li.children[1].href,
						id: getUrlArgv(li.children[0].href, 'set')
					}
				} catch(err) {
					DoLog(LogLevel.Error, [li, err, li.children.length, li.children[0]?.innerHTML, li.children[1]?.innerHTML], 'error');
					Err(err);
				}
			});

			return script_sets;
		},

		async getSetScripts(url) {
			return [...$All(await API.getDocument(url), '#script-set-scripts>input[name="scripts-included[]"]')].map(input => input.value);
		},

		getUserpage() {
			const a = $('#nav-user-info>.user-profile-link>a');
			return a ? a.href : null;
		},

		// editCallback recieves:
		//     true: edit doc load success
		//     false: already in set
		// finishCallback recieves:
		//     text: successfully added to set with text tip `text`
		//     true: successfully loaded document but no text tip found
		//     false: xhr error
		addFav(url, sid, editCallback, finishCallback) {
			API.modifyFav(url, oDom => {
				const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
				if (existingInput) {
					editCallback(false);
					return false;
				}

				const input = $CrE('input');
				input.value = sid;
				input.name = 'scripts-included[]';
				input.type = 'hidden';
				$(oDom, '#script-set-scripts').appendChild(input);
				editCallback(true);
			}, oDom => {
				const status = $(oDom, 'p.notice');
				const status_text = status ? status.innerText : true;
				finishCallback(status_text);
			}, err => finishCallback(false));
		},

		// editCallback recieves:
		//     true: edit doc load success
		//     false: already not in set
		// finishCallback recieves:
		//     text: successfully removed from set with text tip `text`
		//     true: successfully loaded document but no text tip found
		//     false: xhr error
		removeFav(url, sid, editCallback, finishCallback) {
			API.modifyFav(url, oDom => {
				const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
				if (!existingInput) {
					editCallback(false);
					return false;
				}

				existingInput.remove();
				editCallback(true);
			}, oDom => {
				const status = $(oDom, 'p.notice');
				const status_text = status ? status.innerText : true;
				finishCallback(status_text);
			}, err => finishCallback(false));
		},

		async modifyFav(url, editCallback, finishCallback, onerror) {
			const oDom = await API.getDocument(url);
			if (editCallback(oDom) === false) { return false; }

			const form = $(oDom, '.change-script-set');
			const data = new FormData(form);
			data.append('save', '1');

			// Use XMLHttpRequest insteadof GM_xmlhttpRequest because there's unknown issue with GM_xmlhttpRequest
			// Use XMLHttpRequest insteadof GM_xmlhttpRequest before Tampermonkey 5.0.0 because of FormData posting issues
			if (true || GM_info.scriptHandler === 'Tampermonkey' && !API.GM_hasVersion('5.0')) {
				const xhr = new XMLHttpRequest();
				xhr.open('POST', API.toAbsoluteURL(form.getAttribute('action')));
				xhr.responseType = 'blob';
				xhr.onload = async e => finishCallback(await API.parseDocument(xhr.response));
				xhr.onerror = onerror;
				xhr.send(data);
			} else {
				GM_xmlhttpRequest({
					method: 'POST',
					url: API.toAbsoluteURL(form.getAttribute('action')),
					data,
					responseType: 'blob',
					onload: async response => finishCallback(await API.parseDocument(response.response)),
					onerror
				});
			}
		},

		// Download and parse a url page into a html document(dom).
		// Returns a promise fulfills with dom
		getDocument(url, retry=5) {
			return new Promise((resolve, reject) => {
				GM_xmlhttpRequest({
					method       : 'GET',
					url          : url,
					responseType : 'blob',
					onload       : function(response) {
						if (response.status === 200) {
							const htmlblob = response.response;
							API.parseDocument(htmlblob).then(resolve).catch(reject);
						} else {
							re(response);
						}
					},
					onerror: err => re(err)
				});

				function re(err) {
					DoLog(`Get document failed, retrying: (${retry}) ${url}`);
					--retry > 0 ? API.getDocument(url, retry).then(resolve).catch(reject) : reject(err);
				}
			});
		},

		// Returns a promise fulfills with dom
		parseDocument(htmlblob) {
			return new Promise((resolve, reject) => {
				const reader = new FileReader();
				reader.onload = function(e) {
					const htmlText = reader.result;
					const dom = new DOMParser().parseFromString(htmlText, 'text/html');
					resolve(dom);
				}
				reader.onerror = err => reject(err);
				reader.readAsText(htmlblob, document.characterSet);
			});
		},

		toAbsoluteURL(relativeURL, base=`${location.protocol}//${location.host}/`) {
			return new URL(relativeURL, base).href;
		},

		GM_hasVersion(version) {
			return hasVersion(GM_info?.version || '0', version);

			function hasVersion(ver1, ver2) {
				return compareVersions(ver1.toString(), ver2.toString()) >= 0;

				// https://greatest.deepsurf.us/app/javascript/versioncheck.js
				// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version/format
				function compareVersions(a, b) {
				  if (a == b) {
					return 0;
				  }
				  let aParts = a.split('.');
				  let bParts = b.split('.');
				  for (let i = 0; i < aParts.length; i++) {
					let result = compareVersionPart(aParts[i], bParts[i]);
					if (result != 0) {
					  return result;
					}
				  }
				  // If all of a's parts are the same as b's parts, but b has additional parts, b is greater.
				  if (bParts.length > aParts.length) {
					return -1;
				  }
				  return 0;
				}

				function compareVersionPart(partA, partB) {
				  let partAParts = parseVersionPart(partA);
				  let partBParts = parseVersionPart(partB);
				  for (let i = 0; i < partAParts.length; i++) {
					// "A string-part that exists is always less than a string-part that doesn't exist"
					if (partAParts[i].length > 0 && partBParts[i].length == 0) {
					  return -1;
					}
					if (partAParts[i].length == 0 && partBParts[i].length > 0) {
					  return 1;
					}
					if (partAParts[i] > partBParts[i]) {
					  return 1;
					}
					if (partAParts[i] < partBParts[i]) {
					  return -1;
					}
				  }
				  return 0;
				}

				// It goes number, string, number, string. If it doesn't exist, then
				// 0 for numbers, empty string for strings.
				function parseVersionPart(part) {
				  if (!part) {
					return [0, "", 0, ""];
				  }
				  let partParts = /([0-9]*)([^0-9]*)([0-9]*)([^0-9]*)/.exec(part)
				  return [
					partParts[1] ? parseInt(partParts[1]) : 0,
					partParts[2],
					partParts[3] ? parseInt(partParts[3]) : 0,
					partParts[4]
				  ];
				}
			}
		}
	};

	return API;
}) ();

(function __MAIN__() {
    'use strict';

	const CONST = {
		Text: {
			'zh-CN': {
				FavEdit: '收藏集:',
				Add: '加入此集',
				Remove: '移出此集',
				Edit: '手动编辑',
				EditIframe: '页内编辑',
				CloseIframe: '关闭编辑',
				CopySID: '复制脚本ID',
				Sync: '同步',
				Working: ['工作中...', '就快好了...'],
				InSetStatus: ['[ ]', '[✔]'],
				Refreshing: {
					List: '获取收藏集列表...',
					Script: '获取收藏集内容...'
				},
				Error: {
					AlreadyExist: '脚本已经在此收藏集中了',
					NotExist: '脚本不在此收藏集中',
					NetworkError: '网络错误',
					Unknown: '未知错误'
				}
			},
			'zh-TW': {
				FavEdit: '收藏集:',
				Add: '加入此集',
				Remove: '移出此集',
				Edit: '手動編輯',
				EditIframe: '頁內編輯',
				CloseIframe: '關閉編輯',
				CopySID: '複製腳本ID',
				Sync: '同步',
				Working: ['工作中...', '就快好了...'],
				InSetStatus: ['[ ]', '[✔]'],
				Refreshing: {
					List: '獲取收藏集清單...',
					Script: '獲取收藏集內容...'
				},
				Error: {
					AlreadyExist: '腳本已經在此收藏集中了',
					NotExist: '腳本不在此收藏集中',
					NetworkError: '網絡錯誤',
					Unknown: '未知錯誤'
				}
			},
			'en': {
				FavEdit: 'Script set: ',
				Add: 'Add',
				Remove: 'Remove',
				Edit: 'Edit Manually',
				EditIframe: 'In-Page Edit',
				CloseIframe: 'Close Editor',
				CopySID: 'Copy Script-ID',
				Sync: 'Sync',
				Working: ['Working...', 'Just a moment...'],
				InSetStatus: ['[ ]', '[✔]'],
				Refreshing: {
					List: 'Fetching script sets...',
					Script: 'Fetching set content...'
				},
				Error: {
					AlreadyExist: 'Script is already in set',
					NotExist: 'Script is not in set yet',
					NetworkError: 'Network Error',
					Unknown: 'Unknown Error'
				}
			},
			'default': {
				FavEdit: 'Script set: ',
				Add: 'Add',
				Remove: 'Remove',
				Edit: 'Edit Manually',
				EditIframe: 'In-Page Edit',
				CloseIframe: 'Close Editor',
				CopySID: 'Copy Script-ID',
				Sync: 'Sync',
				Working: ['Working...', 'Just a moment...'],
				InSetStatus: ['[ ]', '[✔]'],
				Refreshing: {
					List: 'Fetching script sets...',
					Script: 'Fetching set content...'
				},
				Error: {
					AlreadyExist: 'Script is already in set',
					NotExist: 'Script is not in set yet',
					NetworkError: 'Network Error',
					Unknown: 'Unknown Error'
				}
			},
		},
		ConfigRule: {
			'version-key': 'config-version',
			ignores: [],
			defaultValues: {
				'script-sets': {
					'config-version': 1,
				},
			},
			'updaters': {
				/*'config-key': [
					function() {
						// This function contains updater for config['config-key'] from v0 to v1
					},
					function() {
						// This function contains updater for config['config-key'] from v1 to v2
					}
				]*/
				'script-sets': [
					config => {
						// Fill set.id
						const sets = config.sets;
						sets.forEach(set => {
							const id = getUrlArgv(set.link, 'set');
							set.id = id;
							set.scripts = null; // After first refresh, it should be an array of SIDs:string
						});

						// Delete old version identifier
						delete config.version;

						return config;
					}
				]
			},
		}
	}

	// Get i18n code
	let i18n = $('#language-selector-locale') ? $('#language-selector-locale').value : navigator.language;
	if (!Object.keys(CONST.Text).includes(i18n)) {i18n = 'default';}

	const CM = new ConfigManager(CONST.ConfigRule);
	const CONFIG = CM.Config;
	CM.updateAllConfigs();

	loadFuncs([{
		name: 'Hook GM_xmlhttpRequest',
		checker: {
			type: 'switch',
			value: true
		},
		func: () => GMXHRHook(5)
	}, {
		name: 'Favorite panel',
		checker: {
			type: 'func',
			value: () => {
				const path = location.pathname.split('/').filter(p=>p);
				const index = path.indexOf('scripts');
				return [0,1].includes(index) && [undefined, 'code', 'feedback'].includes(path[index+2])
			}
		},
		func: addFavPanel
	}]);

	function addFavPanel() {
		if (!GFScriptSetAPI.getUserpage()) {return false;}

		class FavoritePanel {
			#CM;
			#sid;
			#sets;
			#elements;

			constructor(CM) {
				this.#CM = CM;
				this.#sid = location.pathname.match(/scripts\/(\d+)/)[1];
				this.#sets = this.#CM.getConfig('script-sets').sets;
				this.#elements = {};

				const script_after = $('#script-feedback-suggestion+*') || $('#new-script-discussion');
				const script_parent = script_after.parentElement;

				// Container
				const script_favorite = this.#elements.container = $$CrE({
					tagName: 'div',
					props: {
						id: 'script-favorite',
						innerHTML: CONST.Text[i18n].FavEdit
					},
					styles: { margin: '0.75em 0' }
				});

				// Selecter
				const favorite_groups = this.#elements.groups = $$CrE({
					tagName: 'select',
					props: { id: 'favorite-groups' },
					styles: { maxWidth: '40vw' },
					listeners: [['change', e => {
						const set = this.#sets.find(set => set.id === favorite_groups.value);
						favorite_edit.href = set.linkedit;
						this.#refreshButtonDisplay();
					}]]
				});
				favorite_groups.id = 'favorite-groups';

				// Buttons
				const makeBtn = (id, innerHTML, onClick, isLink=false) => $$CrE({
					tagName: 'a',
					props: {
						id, innerHTML,
						[isLink ? 'target' : 'href']: isLink ? '_blank' : 'javascript:void(0);'
					},
					styles: { margin: '0px 0.5em' },
					listeners: [['click', onClick]]
				});

				const favorite_add = this.#elements.btnAdd = makeBtn('favorite-add', CONST.Text[i18n].Add, e => this.#addFav());
				const favorite_remove = this.#elements.btnRemove = makeBtn('favorite-remove', CONST.Text[i18n].Remove, e => this.#removeFav());
				const favorite_edit = this.#elements.btnEdit = makeBtn('favorite-edit', CONST.Text[i18n].Edit, e => {}, true);
				const favorite_iframe = this.#elements.btnIframe = makeBtn('favorite-edit-in-page', CONST.Text[i18n].EditIframe, e => this.#editInPage(e));
				const favorite_copy = this.#elements.btnCopy = makeBtn('favorite-add', CONST.Text[i18n].CopySID, e => copyText(this.#sid));
				const favorite_sync = this.#elements.btnSync = makeBtn('favorite-sync', CONST.Text[i18n].Sync, e => this.#refresh());

				script_favorite.appendChild(favorite_groups);
				script_after.before(script_favorite);
				[favorite_add, favorite_remove, favorite_edit, favorite_iframe, favorite_copy, favorite_sync].forEach(button => script_favorite.appendChild(button));

				// Text tip
				const tip = this.#elements.tip = $CrE('span');
				script_favorite.appendChild(tip);

				// Display cached sets first
				this.#displaySets();

				// Request GF document to update sets
				this.#refresh();
			}

			get sid() {
				return this.#sid;
			}

			get sets() {
				return FavoritePanel.#deepClone(this.#sets);
			}

			get elements() {
				return FavoritePanel.#lightClone(this.#elements);
			}

			// Request document: get sets list and
			async #refresh() {
				this.#disable();
				this.#tip(CONST.Text[i18n].Refreshing.List);

				// Refresh sets list
				this.#sets = CONFIG['script-sets'].sets = await GFScriptSetAPI.getScriptSets();
				this.#displaySets();

				// Refresh each set's script list
				this.#tip(CONST.Text[i18n].Refreshing.Script);
				await Promise.all(this.#sets.map(async set => {
					// Fetch scripts
					set.scripts = await GFScriptSetAPI.getSetScripts(set.linkedit);
					this.#displaySets();

					// Save to GM_storage
					const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
					CONFIG['script-sets'].sets[setIndex].scripts = set.scripts;
				}));

				this.#tip();
				this.#enable();
			}

			#addFav() {
				const set = this.#getCurrentSet();
				const option = set.elmOption;

				this.#displayNotice(CONST.Text[i18n].Working[0]);
				GFScriptSetAPI.addFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
					if (!editStatus) {
						this.#displayNotice(CONST.Text[i18n].Error.AlreadyExist);
						option.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
					} else {
						this.#displayNotice(CONST.Text[i18n].Working[1]);
					}
				}, finishStatus => {
					if (finishStatus) {
						// Save to this.#sets and GM_storage
						const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
						CONFIG['script-sets'].sets[setIndex].scripts.push(this.#sid);
						this.#sets = CM.getConfig('script-sets').sets;

						// Display
						this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
						set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
						this.#displaySets();
					} else {
						this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
					}
				});
			}

			#removeFav() {
				const set = this.#getCurrentSet();
				const option = set.elmOption;

				this.#displayNotice(CONST.Text[i18n].Working[0]);
				GFScriptSetAPI.removeFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
					if (!editStatus) {
						this.#displayNotice(CONST.Text[i18n].Error.NotExist);
						option.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
					} else {
						this.#displayNotice(CONST.Text[i18n].Working[1]);
					}
				}, finishStatus => {
					if (finishStatus) {
						// Save to this.#sets and GM_storage
						const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
						const scriptIndex = CONFIG['script-sets'].sets[setIndex].scripts.indexOf(this.#sid);
						CONFIG['script-sets'].sets[setIndex].scripts.splice(scriptIndex, 1);
						this.#sets = CM.getConfig('script-sets').sets;

						// Display
						this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
						set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
						this.#displaySets();
					} else {
						this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
					}
				});
			}

			#editInPage(e) {
				e.preventDefault();

				const _iframes = [...$All(this.#elements.container, '.script-edit-page')];
				if (_iframes.length) {
					// Iframe exists, close iframe
					this.#elements.btnIframe.innerText = CONST.Text[i18n].EditIframe;
					_iframes.forEach(ifr => ifr.remove());
					this.#refresh();
				} else {
					// Iframe not exist, make iframe
					this.#elements.btnIframe.innerText = CONST.Text[i18n].CloseIframe;

					const iframe = $$CrE({
						tagName: 'iframe',
						props: {
							src: this.#getCurrentSet().linkedit
						},
						styles: {
							width: '100%',
							height: '60vh'
						},
						classes: ['script-edit-page'],
						listeners: [['load', e => {
							//this.#refresh();
							//iframe.style.height = iframe.contentDocument.body.parentElement.offsetHeight + 'px';
						}]]
					});
					this.#elements.container.appendChild(iframe);
				}
			}

			#displayNotice(text) {
				const notice = $CrE('p');
				notice.classList.add('notice');
				notice.id = 'fav-notice';
				notice.innerText = text;
				const old_notice = $('#fav-notice');
				old_notice && old_notice.parentElement.removeChild(old_notice);
				$('#script-content').insertAdjacentElement('afterbegin', notice);
			}

			#tip(text='', timeout=0) {
				this.#elements.tip.innerText = text;
				timeout > 0 && setTimeout(() => this.#elements.tip.innerText = '', timeout);
			}

			// Apply this.#sets to gui
			#displaySets() {
				// Save selected set
				const old_value = this.#elements.groups.value;
				[...this.#elements.groups.children].forEach(child => child.remove());

				// Make <option>s
				this.#sets.forEach(set => {
					// Create <option>
					set.elmOption = $$CrE({
						tagName: 'option',
						props: {
							innerText: set.name,
							value: set.id
						}
					});
					// Display inset status
					if (set.scripts) {
						const inSet = set.scripts.includes(this.#sid);
						set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[inSet+0]} ${set.name}`;
					}
					// Append <option> into <select>
					this.#elements.groups.appendChild(set.elmOption);
				});

				// Adjust <select> width
				this.#elements.groups.style.width = Math.max.apply(null, Array.from(this.#elements.groups.children).map(o => o.innerText.length)).toString() + 'em';

				// Select previous selected set's <option>
				const selected = old_value ? [...this.#elements.groups.children].find(option => option.value === old_value) : null;
				selected && (selected.selected = true);

				// Set edit-button.href
				const curset = this.#sets.find(set => set.id === this.#elements.groups.value);
				this.#elements.btnEdit.href = curset.linkedit;

				// Display correct button
				this.#refreshButtonDisplay();
			}

			// Display only add button when script in current set, otherwise remove button
			#refreshButtonDisplay() {
				const set = this.#getCurrentSet();
				if (!set?.scripts) { return null; }
				if (set.scripts.includes(this.#sid)) {
					this.#elements.btnAdd.style.setProperty('display', 'none');
					this.#elements.btnRemove.style.removeProperty('display');
					return true;
				} else {
					this.#elements.btnRemove.style.setProperty('display', 'none');
					this.#elements.btnAdd.style.removeProperty('display');
					return false;
				}
			}

			// Returns null if no <option>s yet
			#getCurrentSet() {
				return this.#sets.find(set => set.id === this.#elements.groups.value) || null;
			}

			#disable() {
				[
					this.#elements.groups,
					this.#elements.btnAdd, this.#elements.btnRemove,
					this.#elements.btnEdit, this.#elements.btnIframe,
					this.#elements.btnCopy, this.#elements.btnSync
				].forEach(element => FavoritePanel.#disableElement(element));
			}

			#enable() {
				[
					this.#elements.groups,
					this.#elements.btnAdd, this.#elements.btnRemove,
					this.#elements.btnEdit, this.#elements.btnIframe,
					this.#elements.btnCopy, this.#elements.btnSync
				].forEach(element => FavoritePanel.#enableElement(element));
			}

			static #disableElement(element) {
				element.style.filter = 'grayscale(1) brightness(0.95)';
				element.style.opacity = '0.25';
				element.style.pointerEvents = 'none';
				element.tabIndex = -1;
			}

			static #enableElement(element) {
				element.style.removeProperty('filter');
				element.style.removeProperty('opacity');
				element.style.removeProperty('pointer-events');
				element.tabIndex = 0;
			}

			static #deepClone(val) {
				if (typeof structuredClone === 'function') {
					return structuredClone(val);
				} else {
					return JSON.parse(JSON.stringify(val));
				}
			}

			static #lightClone(val) {
				if (['string', 'number', 'boolean', 'undefined', 'bigint', 'symbol', 'function'].includes(val) || val === null) {
					return val;
				}
				if (Array.isArray(val)) {
					return val.slice();
				}
				if (typeof val === 'object') {
					return Object.fromEntries(Object.entries(val));
				}
			}
		}

		const panel = new FavoritePanel(CM);
	}

	// Basic functions

	// Copy text to clipboard (needs to be called in an user event)
    function copyText(text) {
        // Create a new textarea for copying
        const newInput = document.createElement('textarea');
        document.body.appendChild(newInput);
        newInput.value = text;
        newInput.select();
        document.execCommand('copy');
        document.body.removeChild(newInput);
    }

	// Check whether current page url matches FuncInfo.checker rule
	// This code is copy and modified from FunctionLoader.check
	function testChecker(checker) {
		if (!checker) {return true;}
		const values = Array.isArray(checker.value) ? checker.value : [checker.value]
		return values.some(value => {
			switch (checker.type) {
				case 'regurl': {
					return !!location.href.match(value);
				}
				case 'func': {
					try {
						return value();
					} catch (err) {
						DoLog(LogLevel.Error, CONST.Text.Loader.CheckerError);
						DoLog(LogLevel.Error, err);
						return false;
					}
				}
				case 'switch': {
					return value;
				}
				case 'starturl': {
					return location.href.startsWith(value);
				}
				case 'startpath': {
					return location.pathname.startsWith(value);
				}
				default: {
					DoLog(LogLevel.Error, CONST.Text.Loader.CheckerInvalid);
					return false;
				}
			}
		});
	}

	// Load all function-objs provided in funcs asynchronously, and merge return values into one return obj
	// funcobj: {[checker], [detectDom], func}
	function loadFuncs(oFuncs) {
		const returnObj = {};

		oFuncs.forEach(oFunc => {
			if (!oFunc.checker || testChecker(oFunc.checker)) {
				if (oFunc.detectDom) {
					detectDom(oFunc.detectDom, e => execute(oFunc));
				} else {
					setTimeout(e => execute(oFunc), 0);
				}
			}
		});

		return returnObj;

		function execute(oFunc) {
			setTimeout(e => {
				const rval = oFunc.func(returnObj) || {};
				copyProps(rval, returnObj);
			}, 0);
		}
	}

	function randint(min, max) {
		return Math.floor(Math.random() * (max - min + 1)) + min;
	}
})();