Greasyfork script-set-edit button

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

2024-02-28 يوللانغان نەشرى. ئەڭ يېڭى نەشرىنى كۆرۈش.

  1. /* eslint-disable no-multi-spaces */
  2. /* eslint-disable no-return-assign */
  3.  
  4. // ==UserScript==
  5. // @name Greasyfork script-set-edit button
  6. // @name:zh-CN Greasyfork 快捷编辑收藏
  7. // @name:zh-TW Greasyfork 快捷編輯收藏
  8. // @name:en Greasyfork script-set-edit button
  9. // @name:en-US Greasyfork script-set-edit button
  10. // @name:fr Greasyfork Set Edit+
  11. // @namespace Greasyfork-Favorite
  12. // @version 0.2.6
  13. // @description Add / Remove script into / from script set directly in GF script info page
  14. // @description:zh-CN 在GF脚本页直接编辑收藏集
  15. // @description:zh-TW 在GF腳本頁直接編輯收藏集
  16. // @description:en Add / Remove script into / from script set directly in GF script info page
  17. // @description:en-US Add / Remove script into / from script set directly in GF script info page
  18. // @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
  19. // @author PY-DNG
  20. // @license GPL-3.0-or-later
  21. // @match http*://*.greatest.deepsurf.us/*
  22. // @match http*://*.sleazyfork.org/*
  23. // @match http*://greatest.deepsurf.us/*
  24. // @match http*://sleazyfork.org/*
  25. // @require https://update.greatest.deepsurf.us/scripts/456034/1303041/Basic%20Functions%20%28For%20userscripts%29.js
  26. // @require https://update.greatest.deepsurf.us/scripts/449583/1324274/ConfigManager.js
  27. // @require https://greatest.deepsurf.us/scripts/460385-gm-web-hooks/code/script.js?version=1221394
  28. // @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==
  29. // @grant GM_xmlhttpRequest
  30. // @grant GM_setValue
  31. // @grant GM_getValue
  32. // @grant GM_listValues
  33. // @grant GM_deleteValue
  34. // ==/UserScript==
  35.  
  36. /* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager */
  37. /* global GMXHRHook GMDLHook ConfigManager */
  38.  
  39. const GFScriptSetAPI = (function() {
  40. const API = {
  41. async getScriptSets() {
  42. const userpage = API.getUserpage();
  43. const oDom = await API.getDocument(userpage);
  44.  
  45. const list = Array.from($(oDom, 'ul#user-script-sets').children);
  46. const NoSets = list.length === 1 && list.every(li => li.children.length === 1);
  47. const script_sets = NoSets ? [] : Array.from($(oDom, 'ul#user-script-sets').children).filter(li => li.children.length === 2).map(li => {
  48. try {
  49. return {
  50. name: li.children[0].innerText,
  51. link: li.children[0].href,
  52. linkedit: li.children[1].href,
  53. id: getUrlArgv(li.children[0].href, 'set')
  54. }
  55. } catch(err) {
  56. DoLog(LogLevel.Error, [li, err, li.children.length, li.children[0]?.innerHTML, li.children[1]?.innerHTML], 'error');
  57. Err(err);
  58. }
  59. });
  60.  
  61. return script_sets;
  62. },
  63.  
  64. async getSetScripts(url) {
  65. return [...$All(await API.getDocument(url), '#script-set-scripts>input[name="scripts-included[]"]')].map(input => input.value);
  66. },
  67.  
  68. getUserpage() {
  69. const a = $('#nav-user-info>.user-profile-link>a');
  70. return a ? a.href : null;
  71. },
  72.  
  73. // editCallback recieves:
  74. // true: edit doc load success
  75. // false: already in set
  76. // finishCallback recieves:
  77. // text: successfully added to set with text tip `text`
  78. // true: successfully loaded document but no text tip found
  79. // false: xhr error
  80. addFav(url, sid, editCallback, finishCallback) {
  81. API.modifyFav(url, oDom => {
  82. const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
  83. if (existingInput) {
  84. editCallback(false);
  85. return false;
  86. }
  87.  
  88. const input = $CrE('input');
  89. input.value = sid;
  90. input.name = 'scripts-included[]';
  91. input.type = 'hidden';
  92. $(oDom, '#script-set-scripts').appendChild(input);
  93. editCallback(true);
  94. }, oDom => {
  95. const status = $(oDom, 'p.notice');
  96. const status_text = status ? status.innerText : true;
  97. finishCallback(status_text);
  98. }, err => finishCallback(false));
  99. },
  100.  
  101. // editCallback recieves:
  102. // true: edit doc load success
  103. // false: already not in set
  104. // finishCallback recieves:
  105. // text: successfully removed from set with text tip `text`
  106. // true: successfully loaded document but no text tip found
  107. // false: xhr error
  108. removeFav(url, sid, editCallback, finishCallback) {
  109. API.modifyFav(url, oDom => {
  110. const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
  111. if (!existingInput) {
  112. editCallback(false);
  113. return false;
  114. }
  115.  
  116. existingInput.remove();
  117. editCallback(true);
  118. }, oDom => {
  119. const status = $(oDom, 'p.notice');
  120. const status_text = status ? status.innerText : true;
  121. finishCallback(status_text);
  122. }, err => finishCallback(false));
  123. },
  124.  
  125. async modifyFav(url, editCallback, finishCallback, onerror) {
  126. const oDom = await API.getDocument(url);
  127. if (editCallback(oDom) === false) { return false; }
  128.  
  129. const form = $(oDom, '.change-script-set');
  130. const data = new FormData(form);
  131. data.append('save', '1');
  132.  
  133. // Use XMLHttpRequest insteadof GM_xmlhttpRequest because there's unknown issue with GM_xmlhttpRequest
  134. // Use XMLHttpRequest insteadof GM_xmlhttpRequest before Tampermonkey 5.0.0 because of FormData posting issues
  135. if (true || typeof GM_xmlhttpRequest !== 'function' || (GM_info.scriptHandler === 'Tampermonkey' && !API.GM_hasVersion('5.0'))) {
  136. const xhr = new XMLHttpRequest();
  137. xhr.open('POST', API.toAbsoluteURL(form.getAttribute('action')));
  138. xhr.responseType = 'blob';
  139. xhr.onload = async e => finishCallback(await API.parseDocument(xhr.response));
  140. xhr.onerror = onerror;
  141. xhr.send(data);
  142. } else {
  143. GM_xmlhttpRequest({
  144. method: 'POST',
  145. url: API.toAbsoluteURL(form.getAttribute('action')),
  146. data,
  147. responseType: 'blob',
  148. onload: async response => finishCallback(await API.parseDocument(response.response)),
  149. onerror
  150. });
  151. }
  152. },
  153.  
  154. // Download and parse a url page into a html document(dom).
  155. // Returns a promise fulfills with dom
  156. async getDocument(url, retry=5) {
  157. try {
  158. const response = await fetch(url, {
  159. method: 'GET',
  160. cache: 'reload',
  161. });
  162. if (response.status === 200) {
  163. const blob = await response.blob();
  164. const oDom = await API.parseDocument(blob);
  165. return oDom;
  166. } else {
  167. throw new Error(`response.status is not 200 (${response.status})`);
  168. }
  169. } catch(err) {
  170. if (--retry > 0) {
  171. return API.getDocument(url, retry);
  172. } else {
  173. throw err;
  174. }
  175. }
  176.  
  177. /*
  178. return new Promise((resolve, reject) => {
  179. GM_xmlhttpRequest({
  180. method : 'GET',
  181. url : url,
  182. responseType : 'blob',
  183. onload : function(response) {
  184. if (response.status === 200) {
  185. const htmlblob = response.response;
  186. API.parseDocument(htmlblob).then(resolve).catch(reject);
  187. } else {
  188. re(response);
  189. }
  190. },
  191. onerror: err => re(err)
  192. });
  193.  
  194. function re(err) {
  195. DoLog(`Get document failed, retrying: (${retry}) ${url}`);
  196. --retry > 0 ? API.getDocument(url, retry).then(resolve).catch(reject) : reject(err);
  197. }
  198. });
  199. */
  200. },
  201.  
  202. // Returns a promise fulfills with dom
  203. parseDocument(htmlblob) {
  204. return new Promise((resolve, reject) => {
  205. const reader = new FileReader();
  206. reader.onload = function(e) {
  207. const htmlText = reader.result;
  208. const dom = new DOMParser().parseFromString(htmlText, 'text/html');
  209. resolve(dom);
  210. }
  211. reader.onerror = err => reject(err);
  212. reader.readAsText(htmlblob, document.characterSet);
  213. });
  214. },
  215.  
  216. toAbsoluteURL(relativeURL, base=`${location.protocol}//${location.host}/`) {
  217. return new URL(relativeURL, base).href;
  218. },
  219.  
  220. GM_hasVersion(version) {
  221. return hasVersion(GM_info?.version || '0', version);
  222.  
  223. function hasVersion(ver1, ver2) {
  224. return compareVersions(ver1.toString(), ver2.toString()) >= 0;
  225.  
  226. // https://greatest.deepsurf.us/app/javascript/versioncheck.js
  227. // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version/format
  228. function compareVersions(a, b) {
  229. if (a == b) {
  230. return 0;
  231. }
  232. let aParts = a.split('.');
  233. let bParts = b.split('.');
  234. for (let i = 0; i < aParts.length; i++) {
  235. let result = compareVersionPart(aParts[i], bParts[i]);
  236. if (result != 0) {
  237. return result;
  238. }
  239. }
  240. // If all of a's parts are the same as b's parts, but b has additional parts, b is greater.
  241. if (bParts.length > aParts.length) {
  242. return -1;
  243. }
  244. return 0;
  245. }
  246.  
  247. function compareVersionPart(partA, partB) {
  248. let partAParts = parseVersionPart(partA);
  249. let partBParts = parseVersionPart(partB);
  250. for (let i = 0; i < partAParts.length; i++) {
  251. // "A string-part that exists is always less than a string-part that doesn't exist"
  252. if (partAParts[i].length > 0 && partBParts[i].length == 0) {
  253. return -1;
  254. }
  255. if (partAParts[i].length == 0 && partBParts[i].length > 0) {
  256. return 1;
  257. }
  258. if (partAParts[i] > partBParts[i]) {
  259. return 1;
  260. }
  261. if (partAParts[i] < partBParts[i]) {
  262. return -1;
  263. }
  264. }
  265. return 0;
  266. }
  267.  
  268. // It goes number, string, number, string. If it doesn't exist, then
  269. // 0 for numbers, empty string for strings.
  270. function parseVersionPart(part) {
  271. if (!part) {
  272. return [0, "", 0, ""];
  273. }
  274. let partParts = /([0-9]*)([^0-9]*)([0-9]*)([^0-9]*)/.exec(part)
  275. return [
  276. partParts[1] ? parseInt(partParts[1]) : 0,
  277. partParts[2],
  278. partParts[3] ? parseInt(partParts[3]) : 0,
  279. partParts[4]
  280. ];
  281. }
  282. }
  283. }
  284. };
  285.  
  286. return API;
  287. }) ();
  288.  
  289. (function __MAIN__() {
  290. 'use strict';
  291.  
  292. const CONST = {
  293. Text: {
  294. 'zh-CN': {
  295. FavEdit: '收藏集:',
  296. Add: '加入此集',
  297. Remove: '移出此集',
  298. Edit: '手动编辑',
  299. EditIframe: '页内编辑',
  300. CloseIframe: '关闭编辑',
  301. CopySID: '复制脚本ID',
  302. Sync: '同步',
  303. NotLoggedIn: '请先登录Greasyfork',
  304. NoSetsYet: '您还没有创建过收藏集',
  305. NewSet: '新建收藏集',
  306. Working: ['工作中...', '就快好了...'],
  307. InSetStatus: ['[ ]', '[✔]'],
  308. Groups: {
  309. Server: 'GreasyFork收藏集',
  310. Local: '本地收藏集',
  311. New: '新建'
  312. },
  313. Refreshing: {
  314. List: '获取收藏集列表...',
  315. Script: '获取收藏集内容...'
  316. },
  317. Error: {
  318. AlreadyExist: '脚本已经在此收藏集中了',
  319. NotExist: '脚本不在此收藏集中',
  320. NetworkError: '网络错误',
  321. Unknown: '未知错误'
  322. }
  323. },
  324. 'zh-TW': {
  325. FavEdit: '收藏集:',
  326. Add: '加入此集',
  327. Remove: '移出此集',
  328. Edit: '手動編輯',
  329. EditIframe: '頁內編輯',
  330. CloseIframe: '關閉編輯',
  331. CopySID: '複製腳本ID',
  332. Sync: '同步',
  333. NotLoggedIn: '請先登錄Greasyfork',
  334. NoSetsYet: '您還沒有創建過收藏集',
  335. NewSet: '新建收藏集',
  336. Working: ['工作中...', '就快好了...'],
  337. InSetStatus: ['[ ]', '[✔]'],
  338. Groups: {
  339. Server: 'GreasyFork收藏集',
  340. Local: '本地收藏集',
  341. New: '新建'
  342. },
  343. Refreshing: {
  344. List: '獲取收藏集清單...',
  345. Script: '獲取收藏集內容...'
  346. },
  347. Error: {
  348. AlreadyExist: '腳本已經在此收藏集中了',
  349. NotExist: '腳本不在此收藏集中',
  350. NetworkError: '網絡錯誤',
  351. Unknown: '未知錯誤'
  352. }
  353. },
  354. 'en': {
  355. FavEdit: 'Script set: ',
  356. Add: 'Add',
  357. Remove: 'Remove',
  358. Edit: 'Edit Manually',
  359. EditIframe: 'In-Page Edit',
  360. CloseIframe: 'Close Editor',
  361. CopySID: 'Copy Script-ID',
  362. Sync: 'Sync',
  363. NotLoggedIn: 'Login to greasyfork to use script sets',
  364. NoSetsYet: 'You haven\'t created a collection yet',
  365. NewSet: 'Create a new set',
  366. Working: ['Working...', 'Just a moment...'],
  367. InSetStatus: ['[ ]', '[✔]'],
  368. Groups: {
  369. Server: 'GreasyFork',
  370. Local: 'Local',
  371. New: 'New'
  372. },
  373. Refreshing: {
  374. List: 'Fetching script sets...',
  375. Script: 'Fetching set content...'
  376. },
  377. Error: {
  378. AlreadyExist: 'Script is already in set',
  379. NotExist: 'Script is not in set yet',
  380. NetworkError: 'Network Error',
  381. Unknown: 'Unknown Error'
  382. }
  383. },
  384. 'default': {
  385. FavEdit: 'Script set: ',
  386. Add: 'Add',
  387. Remove: 'Remove',
  388. Edit: 'Edit Manually',
  389. EditIframe: 'In-Page Edit',
  390. CloseIframe: 'Close Editor',
  391. CopySID: 'Copy Script-ID',
  392. Sync: 'Sync',
  393. NotLoggedIn: 'Login to greasyfork to use script sets',
  394. NoSetsYet: 'You haven\'t created a collection yet',
  395. NewSet: 'Create a new set',
  396. Working: ['Working...', 'Just a moment...'],
  397. InSetStatus: ['[ ]', '[✔]'],
  398. Groups: {
  399. Server: 'GreasyFork',
  400. Local: 'Local',
  401. New: 'New'
  402. },
  403. Refreshing: {
  404. List: 'Fetching script sets...',
  405. Script: 'Fetching set content...'
  406. },
  407. Error: {
  408. AlreadyExist: 'Script is already in set',
  409. NotExist: 'Script is not in set yet',
  410. NetworkError: 'Network Error',
  411. Unknown: 'Unknown Error'
  412. }
  413. },
  414. },
  415. ConfigRule: {
  416. 'version-key': 'config-version',
  417. ignores: [],
  418. defaultValues: {
  419. 'script-sets': {
  420. sets: [],
  421. time: 0,
  422. 'config-version': 1,
  423. },
  424. },
  425. 'updaters': {
  426. /*'config-key': [
  427. function() {
  428. // This function contains updater for config['config-key'] from v0 to v1
  429. },
  430. function() {
  431. // This function contains updater for config['config-key'] from v1 to v2
  432. }
  433. ]*/
  434. 'script-sets': [
  435. config => {
  436. // Fill set.id
  437. const sets = config.sets;
  438. sets.forEach(set => {
  439. const id = getUrlArgv(set.link, 'set');
  440. set.id = id;
  441. set.scripts = null; // After first refresh, it should be an array of SIDs:string
  442. });
  443.  
  444. // Delete old version identifier
  445. delete config.version;
  446.  
  447. return config;
  448. }
  449. ]
  450. },
  451. }
  452. };
  453.  
  454. // Get i18n code
  455. let i18n = $('#language-selector-locale') ? $('#language-selector-locale').value : navigator.language;
  456. if (!Object.keys(CONST.Text).includes(i18n)) {i18n = 'default';}
  457.  
  458. const CM = new ConfigManager(CONST.ConfigRule);
  459. const CONFIG = CM.Config;
  460. CM.updateAllConfigs();
  461.  
  462. loadFuncs([{
  463. name: 'Hook GM_xmlhttpRequest',
  464. checker: {
  465. type: 'switch',
  466. value: true
  467. },
  468. func: () => GMXHRHook(5)
  469. }, {
  470. name: 'Favorite panel',
  471. checker: {
  472. type: 'func',
  473. value: () => {
  474. const path = location.pathname.split('/').filter(p=>p);
  475. const index = path.indexOf('scripts');
  476. return [0,1].includes(index) && [undefined, 'code', 'feedback'].includes(path[index+2])
  477. }
  478. },
  479. func: addFavPanel
  480. }]);
  481.  
  482. function addFavPanel() {
  483. //if (!GFScriptSetAPI.getUserpage()) {return false;}
  484.  
  485. class FavoritePanel {
  486. #CM;
  487. #sid;
  488. #sets;
  489. #elements;
  490. #disabled;
  491.  
  492. constructor(CM) {
  493. this.#CM = CM;
  494. this.#sid = location.pathname.match(/scripts\/(\d+)/)[1];
  495. this.#sets = this.#CM.getConfig('script-sets').sets;
  496. this.#elements = {};
  497. this.disabled = false;
  498.  
  499. const script_after = $('#script-feedback-suggestion+*') || $('#new-script-discussion');
  500. const script_parent = script_after.parentElement;
  501.  
  502. // Container
  503. const script_favorite = this.#elements.container = $$CrE({
  504. tagName: 'div',
  505. props: {
  506. id: 'script-favorite',
  507. innerHTML: CONST.Text[i18n].FavEdit
  508. },
  509. styles: { margin: '0.75em 0' }
  510. });
  511.  
  512. // Selecter
  513. const favorite_groups = this.#elements.select = $$CrE({
  514. tagName: 'select',
  515. props: { id: 'favorite-groups' },
  516. styles: { maxWidth: '40vw' },
  517. listeners: [['change', (() => {
  518. let lastSelected = 0;
  519. const record = () => lastSelected = favorite_groups.selectedIndex;
  520. const recover = () => favorite_groups.selectedIndex = lastSelected;
  521.  
  522. return e => {
  523. const value = favorite_groups.value;
  524. const type = /^\d+$/.test(value) ? 'set-id' : 'command';
  525.  
  526. switch (type) {
  527. case 'set-id': {
  528. const set = this.#sets.find(set => set.id === favorite_groups.value);
  529. favorite_edit.href = set.linkedit;
  530. break;
  531. }
  532. case 'command': {
  533. recover();
  534. this.#execCommand(value);
  535. }
  536. }
  537.  
  538. this.#refreshButtonDisplay();
  539. record();
  540. }
  541. }) ()]]
  542. });
  543. favorite_groups.id = 'favorite-groups';
  544.  
  545. // Buttons
  546. const makeBtn = (id, innerHTML, onClick, isLink=false) => $$CrE({
  547. tagName: 'a',
  548. props: {
  549. id, innerHTML,
  550. [isLink ? 'target' : 'href']: isLink ? '_blank' : 'javascript:void(0);'
  551. },
  552. styles: { margin: '0px 0.5em' },
  553. listeners: [['click', onClick]]
  554. });
  555.  
  556. const favorite_add = this.#elements.btnAdd = makeBtn('favorite-add', CONST.Text[i18n].Add, e => this.#addFav());
  557. const favorite_remove = this.#elements.btnRemove = makeBtn('favorite-remove', CONST.Text[i18n].Remove, e => this.#removeFav());
  558. const favorite_edit = this.#elements.btnEdit = makeBtn('favorite-edit', CONST.Text[i18n].Edit, e => {}, true);
  559. const favorite_iframe = this.#elements.btnIframe = makeBtn('favorite-edit-in-page', CONST.Text[i18n].EditIframe, e => this.#editInPage(e));
  560. const favorite_copy = this.#elements.btnCopy = makeBtn('favorite-add', CONST.Text[i18n].CopySID, e => copyText(this.#sid));
  561. const favorite_sync = this.#elements.btnSync = makeBtn('favorite-sync', CONST.Text[i18n].Sync, e => this.#refresh());
  562.  
  563. script_favorite.appendChild(favorite_groups);
  564. script_after.before(script_favorite);
  565. [favorite_add, favorite_remove, favorite_edit, favorite_iframe, favorite_copy, favorite_sync].forEach(button => script_favorite.appendChild(button));
  566.  
  567. // Text tip
  568. const tip = this.#elements.tip = $CrE('span');
  569. script_favorite.appendChild(tip);
  570.  
  571. // Display cached sets first
  572. this.#displaySets();
  573.  
  574. // Request GF document to update sets
  575. this.#refresh();
  576. }
  577.  
  578. get sid() {
  579. return this.#sid;
  580. }
  581.  
  582. get sets() {
  583. return FavoritePanel.#deepClone(this.#sets);
  584. }
  585.  
  586. get elements() {
  587. return FavoritePanel.#lightClone(this.#elements);
  588. }
  589.  
  590. // Request document: get sets list and
  591. async #refresh() {
  592. this.#disable();
  593. this.#tip(CONST.Text[i18n].Refreshing.List);
  594.  
  595. // Check login status
  596. if (!GFScriptSetAPI.getUserpage()) {
  597. this.#tip(CONST.Text[i18n].NotLoggedIn);
  598. return;
  599. }
  600.  
  601. // Refresh sets list
  602. this.#sets = CONFIG['script-sets'].sets = await GFScriptSetAPI.getScriptSets();
  603. this.#displaySets();
  604.  
  605. // Refresh each set's script list
  606. this.#tip(CONST.Text[i18n].Refreshing.Script);
  607. await Promise.all(this.#sets.map(async set => {
  608. // Fetch scripts
  609. set.scripts = await GFScriptSetAPI.getSetScripts(set.linkedit);
  610. this.#displaySets();
  611.  
  612. // Save to GM_storage
  613. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  614. CONFIG['script-sets'].sets[setIndex].scripts = set.scripts;
  615. }));
  616.  
  617. this.#tip();
  618. this.#enable();
  619. this.#refreshButtonDisplay();
  620. }
  621.  
  622. #addFav() {
  623. const set = this.#getCurrentSet();
  624. const option = set.elmOption;
  625.  
  626. this.#displayNotice(CONST.Text[i18n].Working[0]);
  627. GFScriptSetAPI.addFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
  628. if (!editStatus) {
  629. this.#displayNotice(CONST.Text[i18n].Error.AlreadyExist);
  630. option.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
  631. } else {
  632. this.#displayNotice(CONST.Text[i18n].Working[1]);
  633. }
  634. }, finishStatus => {
  635. if (finishStatus) {
  636. // Save to this.#sets and GM_storage
  637. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  638. CONFIG['script-sets'].sets[setIndex].scripts.push(this.#sid);
  639. this.#sets = CM.getConfig('script-sets').sets;
  640.  
  641. // Display
  642. this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
  643. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
  644. this.#displaySets();
  645. } else {
  646. this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
  647. }
  648. });
  649. }
  650.  
  651. #removeFav() {
  652. const set = this.#getCurrentSet();
  653. const option = set.elmOption;
  654.  
  655. this.#displayNotice(CONST.Text[i18n].Working[0]);
  656. GFScriptSetAPI.removeFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
  657. if (!editStatus) {
  658. this.#displayNotice(CONST.Text[i18n].Error.NotExist);
  659. option.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
  660. } else {
  661. this.#displayNotice(CONST.Text[i18n].Working[1]);
  662. }
  663. }, finishStatus => {
  664. if (finishStatus) {
  665. // Save to this.#sets and GM_storage
  666. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  667. const scriptIndex = CONFIG['script-sets'].sets[setIndex].scripts.indexOf(this.#sid);
  668. CONFIG['script-sets'].sets[setIndex].scripts.splice(scriptIndex, 1);
  669. this.#sets = CM.getConfig('script-sets').sets;
  670.  
  671. // Display
  672. this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
  673. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
  674. this.#displaySets();
  675. } else {
  676. this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
  677. }
  678. });
  679. }
  680.  
  681. #editInPage(e) {
  682. e.preventDefault();
  683.  
  684. const _iframes = [...$All(this.#elements.container, '.script-edit-page')];
  685. if (_iframes.length) {
  686. // Iframe exists, close iframe
  687. this.#elements.btnIframe.innerText = CONST.Text[i18n].EditIframe;
  688. _iframes.forEach(ifr => ifr.remove());
  689. this.#refresh();
  690. } else {
  691. // Iframe not exist, make iframe
  692. this.#elements.btnIframe.innerText = CONST.Text[i18n].CloseIframe;
  693.  
  694. const iframe = $$CrE({
  695. tagName: 'iframe',
  696. props: {
  697. src: this.#getCurrentSet().linkedit
  698. },
  699. styles: {
  700. width: '100%',
  701. height: '60vh'
  702. },
  703. classes: ['script-edit-page'],
  704. listeners: [['load', e => {
  705. //this.#refresh();
  706. //iframe.style.height = iframe.contentDocument.body.parentElement.offsetHeight + 'px';
  707. }]]
  708. });
  709. this.#elements.container.appendChild(iframe);
  710. }
  711. }
  712.  
  713. #displayNotice(text) {
  714. const notice = $CrE('p');
  715. notice.classList.add('notice');
  716. notice.id = 'fav-notice';
  717. notice.innerText = text;
  718. const old_notice = $('#fav-notice');
  719. old_notice && old_notice.parentElement.removeChild(old_notice);
  720. $('#script-content').insertAdjacentElement('afterbegin', notice);
  721. }
  722.  
  723. #tip(text='', timeout=0) {
  724. this.#elements.tip.innerText = text;
  725. timeout > 0 && setTimeout(() => this.#elements.tip.innerText = '', timeout);
  726. }
  727.  
  728. // Apply this.#sets to gui
  729. #displaySets() {
  730. const elements = this.#elements;
  731.  
  732. // Save selected set
  733. const old_value = elements.select.value;
  734. [...elements.select.children].forEach(child => child.remove());
  735.  
  736. // Make <optgroup>s and <option>s
  737. const serverGroup = elements.serverGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.Server } });
  738. this.#sets.forEach(set => {
  739. // Create <option>
  740. set.elmOption = $$CrE({
  741. tagName: 'option',
  742. props: {
  743. innerText: set.name,
  744. value: set.id
  745. }
  746. });
  747. // Display inset status
  748. if (set.scripts) {
  749. const inSet = set.scripts.includes(this.#sid);
  750. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[inSet+0]} ${set.name}`;
  751. }
  752. // Append <option> into <select>
  753. serverGroup.appendChild(set.elmOption);
  754. });
  755. if (this.#sets.length === 0) {
  756. const optEmpty = elements.optEmpty = $$CrE({
  757. tagName: 'option',
  758. props: {
  759. innerText: CONST.Text[i18n].NoSetsYet,
  760. value: 'empty',
  761. selected: true
  762. }
  763. });
  764. serverGroup.appendChild(optEmpty);
  765. }
  766.  
  767. const newGroup = elements.newGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.New } });
  768. const newSet = elements.newSet = $$CrE({
  769. tagName: 'option',
  770. props: {
  771. innerText: CONST.Text[i18n].NewSet,
  772. value: 'new',
  773. }
  774. });
  775. newGroup.appendChild(newSet);
  776. [serverGroup, newGroup].forEach(optgroup => elements.select.appendChild(optgroup));
  777.  
  778. // Adjust <select> width
  779. elements.select.style.width = Math.max.apply(null, Array.from($All(elements.select, 'option')).map(o => o.innerText.length)).toString() + 'em';
  780.  
  781. // Select previous selected set's <option>
  782. const selected = old_value ? [...$All(elements.select, 'option')].find(option => option.value === old_value) : null;
  783. selected && (selected.selected = true);
  784.  
  785. // Set edit-button.href
  786. if (elements.select.value !== 'empty') {
  787. const curset = this.#sets.find(set => set.id === elements.select.value);
  788. elements.btnEdit.href = curset.linkedit;
  789. }
  790.  
  791. // Display correct button
  792. this.#refreshButtonDisplay();
  793. }
  794.  
  795. // Display only add button when script in current set, otherwise remove button
  796. // Disable set-related buttons when not selecting options that not represents a set
  797. #refreshButtonDisplay() {
  798. const set = this.#getCurrentSet();
  799. !this.#disabled && ([this.#elements.btnAdd, this.#elements.btnRemove, this.#elements.btnEdit, this.#elements.btnIframe]
  800. .forEach(element => set ? FavoritePanel.#enableElement(element) : FavoritePanel.#disableElement(element)));
  801. if (!set || !set.scripts) { return null; }
  802. if (set.scripts.includes(this.#sid)) {
  803. this.#elements.btnAdd.style.setProperty('display', 'none');
  804. this.#elements.btnRemove.style.removeProperty('display');
  805. return true;
  806. } else {
  807. this.#elements.btnRemove.style.setProperty('display', 'none');
  808. this.#elements.btnAdd.style.removeProperty('display');
  809. return false;
  810. }
  811. }
  812.  
  813. #execCommand(command) {
  814. switch (command) {
  815. case 'new': {
  816. const url = GFScriptSetAPI.getUserpage() + (this.#getCurrentSet() ? '/sets/new' : '/sets/new?fav=1');
  817. window.open(url);
  818. break;
  819. }
  820. case 'empty': {
  821. // Do nothing
  822. break;
  823. }
  824. }
  825. }
  826.  
  827. // Returns null if no <option>s yet
  828. #getCurrentSet() {
  829. return this.#sets.find(set => set.id === this.#elements.select.value) || null;
  830. }
  831.  
  832. #disable() {
  833. [
  834. this.#elements.select,
  835. this.#elements.btnAdd, this.#elements.btnRemove,
  836. this.#elements.btnEdit, this.#elements.btnIframe,
  837. this.#elements.btnCopy, this.#elements.btnSync
  838. ].forEach(element => FavoritePanel.#disableElement(element));
  839. this.#disabled = true;
  840. }
  841.  
  842. #enable() {
  843. [
  844. this.#elements.select,
  845. this.#elements.btnAdd, this.#elements.btnRemove,
  846. this.#elements.btnEdit, this.#elements.btnIframe,
  847. this.#elements.btnCopy, this.#elements.btnSync
  848. ].forEach(element => FavoritePanel.#enableElement(element));
  849. this.#disabled = false;
  850. }
  851.  
  852. static #disableElement(element) {
  853. element.style.filter = 'grayscale(1) brightness(0.95)';
  854. element.style.opacity = '0.25';
  855. element.style.pointerEvents = 'none';
  856. element.tabIndex = -1;
  857. }
  858.  
  859. static #enableElement(element) {
  860. element.style.removeProperty('filter');
  861. element.style.removeProperty('opacity');
  862. element.style.removeProperty('pointer-events');
  863. element.tabIndex = 0;
  864. }
  865.  
  866. static #deepClone(val) {
  867. if (typeof structuredClone === 'function') {
  868. return structuredClone(val);
  869. } else {
  870. return JSON.parse(JSON.stringify(val));
  871. }
  872. }
  873.  
  874. static #lightClone(val) {
  875. if (['string', 'number', 'boolean', 'undefined', 'bigint', 'symbol', 'function'].includes(val) || val === null) {
  876. return val;
  877. }
  878. if (Array.isArray(val)) {
  879. return val.slice();
  880. }
  881. if (typeof val === 'object') {
  882. return Object.fromEntries(Object.entries(val));
  883. }
  884. }
  885. }
  886.  
  887. const panel = new FavoritePanel(CM);
  888. }
  889.  
  890. // Basic functions
  891.  
  892. // Copy text to clipboard (needs to be called in an user event)
  893. function copyText(text) {
  894. // Create a new textarea for copying
  895. const newInput = document.createElement('textarea');
  896. document.body.appendChild(newInput);
  897. newInput.value = text;
  898. newInput.select();
  899. document.execCommand('copy');
  900. document.body.removeChild(newInput);
  901. }
  902.  
  903. // Check whether current page url matches FuncInfo.checker rule
  904. // This code is copy and modified from FunctionLoader.check
  905. function testChecker(checker) {
  906. if (!checker) {return true;}
  907. const values = Array.isArray(checker.value) ? checker.value : [checker.value]
  908. return values.some(value => {
  909. switch (checker.type) {
  910. case 'regurl': {
  911. return !!location.href.match(value);
  912. }
  913. case 'func': {
  914. try {
  915. return value();
  916. } catch (err) {
  917. DoLog(LogLevel.Error, CONST.Text.Loader.CheckerError);
  918. DoLog(LogLevel.Error, err);
  919. return false;
  920. }
  921. }
  922. case 'switch': {
  923. return value;
  924. }
  925. case 'starturl': {
  926. return location.href.startsWith(value);
  927. }
  928. case 'startpath': {
  929. return location.pathname.startsWith(value);
  930. }
  931. default: {
  932. DoLog(LogLevel.Error, CONST.Text.Loader.CheckerInvalid);
  933. return false;
  934. }
  935. }
  936. });
  937. }
  938.  
  939. // Load all function-objs provided in funcs asynchronously, and merge return values into one return obj
  940. // funcobj: {[checker], [detectDom], func}
  941. function loadFuncs(oFuncs) {
  942. const returnObj = {};
  943.  
  944. oFuncs.forEach(oFunc => {
  945. if (!oFunc.checker || testChecker(oFunc.checker)) {
  946. if (oFunc.detectDom) {
  947. detectDom(oFunc.detectDom, e => execute(oFunc));
  948. } else {
  949. setTimeout(e => execute(oFunc), 0);
  950. }
  951. }
  952. });
  953.  
  954. return returnObj;
  955.  
  956. function execute(oFunc) {
  957. setTimeout(e => {
  958. const rval = oFunc.func(returnObj) || {};
  959. copyProps(rval, returnObj);
  960. }, 0);
  961. }
  962. }
  963.  
  964. function randint(min, max) {
  965. return Math.floor(Math.random() * (max - min + 1)) + min;
  966. }
  967. })();