Greasy Fork is available in English.

Greasyfork script-set-edit button

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

/* 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.6
// @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*://*.greasyfork.org/*
// @match              http*://*.sleazyfork.org/*
// @match              http*://greasyfork.org/*
// @match              http*://sleazyfork.org/*
// @require            https://update.greasyfork.org/scripts/456034/1303041/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://update.greasyfork.org/scripts/449583/1324274/ConfigManager.js
// @require            https://greasyfork.org/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 list =  Array.from($(oDom, 'ul#user-script-sets').children);
			const NoSets = list.length === 1 && list.every(li => li.children.length === 1);
			const script_sets = NoSets ? [] : Array.from($(oDom, 'ul#user-script-sets').children).filter(li => li.children.length === 2).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 || typeof GM_xmlhttpRequest !== 'function' || (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
		async getDocument(url, retry=5) {
			try {
				const response = await fetch(url, {
					method: 'GET',
					cache: 'reload',
				});
				if (response.status === 200) {
					const blob = await response.blob();
					const oDom = await API.parseDocument(blob);
					return oDom;
				} else {
					throw new Error(`response.status is not 200 (${response.status})`);
				}
			} catch(err) {
				if (--retry > 0) {
					return API.getDocument(url, retry);
				} else {
					throw err;
				}
			}

			/*
			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://greasyfork.org/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: '同步',
				NotLoggedIn: '请先登录Greasyfork',
				NoSetsYet: '您还没有创建过收藏集',
				NewSet: '新建收藏集',
				Working: ['工作中...', '就快好了...'],
				InSetStatus: ['[ ]', '[✔]'],
				Groups: {
					Server: 'GreasyFork收藏集',
					Local: '本地收藏集',
					New: '新建'
				},
				Refreshing: {
					List: '获取收藏集列表...',
					Script: '获取收藏集内容...'
				},
				Error: {
					AlreadyExist: '脚本已经在此收藏集中了',
					NotExist: '脚本不在此收藏集中',
					NetworkError: '网络错误',
					Unknown: '未知错误'
				}
			},
			'zh-TW': {
				FavEdit: '收藏集:',
				Add: '加入此集',
				Remove: '移出此集',
				Edit: '手動編輯',
				EditIframe: '頁內編輯',
				CloseIframe: '關閉編輯',
				CopySID: '複製腳本ID',
				Sync: '同步',
				NotLoggedIn: '請先登錄Greasyfork',
				NoSetsYet: '您還沒有創建過收藏集',
				NewSet: '新建收藏集',
				Working: ['工作中...', '就快好了...'],
				InSetStatus: ['[ ]', '[✔]'],
				Groups: {
					Server: 'GreasyFork收藏集',
					Local: '本地收藏集',
					New: '新建'
				},
				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',
				NotLoggedIn: 'Login to greasyfork to use script sets',
				NoSetsYet: 'You haven\'t created a collection yet',
				NewSet: 'Create a new set',
				Working: ['Working...', 'Just a moment...'],
				InSetStatus: ['[ ]', '[✔]'],
				Groups: {
					Server: 'GreasyFork',
					Local: 'Local',
					New: 'New'
				},
				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',
				NotLoggedIn: 'Login to greasyfork to use script sets',
				NoSetsYet: 'You haven\'t created a collection yet',
				NewSet: 'Create a new set',
				Working: ['Working...', 'Just a moment...'],
				InSetStatus: ['[ ]', '[✔]'],
				Groups: {
					Server: 'GreasyFork',
					Local: 'Local',
					New: 'New'
				},
				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': {
					sets: [],
					time: 0,
					'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;
			#disabled;

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

				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.select = $$CrE({
					tagName: 'select',
					props: { id: 'favorite-groups' },
					styles: { maxWidth: '40vw' },
					listeners: [['change', (() => {
						let lastSelected = 0;
						const record = () => lastSelected = favorite_groups.selectedIndex;
						const recover = () => favorite_groups.selectedIndex = lastSelected;

						return e => {
							const value = favorite_groups.value;
							const type = /^\d+$/.test(value) ? 'set-id' : 'command';

							switch (type) {
								case 'set-id': {
									const set = this.#sets.find(set => set.id === favorite_groups.value);
									favorite_edit.href = set.linkedit;
									break;
								}
								case 'command': {
									recover();
									this.#execCommand(value);
								}
							}

							this.#refreshButtonDisplay();
							record();
						}
					}) ()]]
				});
				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);

				// Check login status
				if (!GFScriptSetAPI.getUserpage()) {
					this.#tip(CONST.Text[i18n].NotLoggedIn);
					return;
				}

				// 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();
				this.#refreshButtonDisplay();
			}

			#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() {
				const elements = this.#elements;

				// Save selected set
				const old_value = elements.select.value;
				[...elements.select.children].forEach(child => child.remove());

				// Make <optgroup>s and <option>s
				const serverGroup = elements.serverGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.Server } });
				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>
					serverGroup.appendChild(set.elmOption);
				});
				if (this.#sets.length === 0) {
					const optEmpty = elements.optEmpty = $$CrE({
						tagName: 'option',
						props: {
							innerText: CONST.Text[i18n].NoSetsYet,
							value: 'empty',
							selected: true
						}
					});
					serverGroup.appendChild(optEmpty);
				}

				const newGroup = elements.newGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.New } });
				const newSet = elements.newSet = $$CrE({
					tagName: 'option',
					props: {
						innerText: CONST.Text[i18n].NewSet,
						value: 'new',
					}
				});
				newGroup.appendChild(newSet);
				[serverGroup, newGroup].forEach(optgroup => elements.select.appendChild(optgroup));

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

				// Select previous selected set's <option>
				const selected = old_value ? [...$All(elements.select, 'option')].find(option => option.value === old_value) : null;
				selected && (selected.selected = true);

				// Set edit-button.href
				if (elements.select.value !== 'empty') {
					const curset = this.#sets.find(set => set.id === elements.select.value);
					elements.btnEdit.href = curset.linkedit;
				}

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

			// Display only add button when script in current set, otherwise remove button
			// Disable set-related buttons when not selecting options that not represents a set
			#refreshButtonDisplay() {
				const set = this.#getCurrentSet();
				!this.#disabled && ([this.#elements.btnAdd, this.#elements.btnRemove, this.#elements.btnEdit, this.#elements.btnIframe]
						.forEach(element => set ? FavoritePanel.#enableElement(element) : FavoritePanel.#disableElement(element)));
				if (!set || !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;
				}
			}

			#execCommand(command) {
				switch (command) {
					case 'new': {
						const url = GFScriptSetAPI.getUserpage() + (this.#getCurrentSet() ? '/sets/new' : '/sets/new?fav=1');
						window.open(url);
						break;
					}
					case 'empty': {
						// Do nothing
						break;
					}
				}
			}

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

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

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

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