Greasy Fork is available in English.

Greasyfork script-set-edit button

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

  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.9
  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*://*.greasyfork.org/*
  22. // @match http*://*.sleazyfork.org/*
  23. // @match http*://greasyfork.org/*
  24. // @match http*://sleazyfork.org/*
  25. // @require https://update.greasyfork.org/scripts/456034/1348286/Basic%20Functions%20%28For%20userscripts%29.js
  26. // @require https://update.greasyfork.org/scripts/449583/1324274/ConfigManager.js
  27. // @require https://greasyfork.org/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. // @grant GM_registerMenuCommand
  35. // @grant GM_unregisterMenuCommand
  36. // ==/UserScript==
  37.  
  38. /* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask testChecker registerChecker loadFuncs */
  39. /* global GMXHRHook GMDLHook ConfigManager */
  40.  
  41. const GFScriptSetAPI = (function() {
  42. const API = {
  43. async getScriptSets() {
  44. const userpage = API.getUserpage();
  45. const oDom = await API.getDocument(userpage);
  46.  
  47. const list = Array.from($(oDom, 'ul#user-script-sets').children);
  48. const NoSets = list.length === 1 && list.every(li => li.children.length === 1);
  49. const script_sets = NoSets ? [] : Array.from($(oDom, 'ul#user-script-sets').children).filter(li => li.children.length === 2).map(li => {
  50. try {
  51. return {
  52. name: li.children[0].innerText,
  53. link: li.children[0].href,
  54. linkedit: li.children[1].href,
  55. id: getUrlArgv(li.children[0].href, 'set')
  56. }
  57. } catch(err) {
  58. DoLog(LogLevel.Error, [li, err, li.children.length, li.children[0]?.innerHTML, li.children[1]?.innerHTML], 'error');
  59. Err(err);
  60. }
  61. });
  62.  
  63. return script_sets;
  64. },
  65.  
  66. async getSetScripts(url) {
  67. return [...$All(await API.getDocument(url), '#script-set-scripts>input[name="scripts-included[]"]')].map(input => input.value);
  68. },
  69.  
  70. /**
  71. * @typedef {Object} SetsDataAPI
  72. * @property {Response} resp - api fetch response object
  73. * @property {boolean} ok - resp.ok (resp.status >= 200 && resp.status <= 299)
  74. * @property {(Object|null)} data - api response json data, or null if not resp.ok
  75. */
  76. /**
  77. * @returns {SetsDataAPI}
  78. */
  79. async getSetsData() {
  80. const userpage = API.getUserpage();
  81. const url = (userpage.endsWith('/') ? userpage : userpage + '/') + 'sets'
  82.  
  83. const resp = await fetch(url, { credentials: 'same-origin' });
  84. if (resp.ok) {
  85. return {
  86. ok: true,
  87. resp,
  88. data: await resp.json()
  89. };
  90. } else {
  91. return {
  92. ok: false,
  93. resp,
  94. data: null
  95. };
  96. }
  97. },
  98.  
  99. /**
  100. * @returns {(string|null)} the user's profile page url, from page top-right link <a>.href
  101. */
  102. getUserpage() {
  103. const a = $('#nav-user-info>.user-profile-link>a');
  104. return a ? a.href : null;
  105. },
  106.  
  107. /**
  108. * @returns {(string|null)} the user's id, in string format
  109. */
  110. getUserID() {
  111. const userpage = API.getUserpage(); //https://greasyfork.org/zh-CN/users/667968-pyudng
  112. return userpage ? userpage.match(/\/users\/(\d+)(-[^\/]*\/*)?/)[1] : null;
  113. },
  114.  
  115. // editCallback recieves:
  116. // true: edit doc load success
  117. // false: already in set
  118. // finishCallback recieves:
  119. // text: successfully added to set with text tip `text`
  120. // true: successfully loaded document but no text tip found
  121. // false: xhr error
  122. addFav(url, sid, editCallback, finishCallback) {
  123. API.modifyFav(url, oDom => {
  124. const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
  125. if (existingInput) {
  126. editCallback(false);
  127. return false;
  128. }
  129.  
  130. const input = $CrE('input');
  131. input.value = sid;
  132. input.name = 'scripts-included[]';
  133. input.type = 'hidden';
  134. $(oDom, '#script-set-scripts').appendChild(input);
  135. editCallback(true);
  136. }, oDom => {
  137. const status = $(oDom, 'p.notice');
  138. const status_text = status ? status.innerText : true;
  139. finishCallback(status_text);
  140. }, err => finishCallback(false));
  141. },
  142.  
  143. // editCallback recieves:
  144. // true: edit doc load success
  145. // false: already not in set
  146. // finishCallback recieves:
  147. // text: successfully removed from set with text tip `text`
  148. // true: successfully loaded document but no text tip found
  149. // false: xhr error
  150. removeFav(url, sid, editCallback, finishCallback) {
  151. API.modifyFav(url, oDom => {
  152. const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === sid);
  153. if (!existingInput) {
  154. editCallback(false);
  155. return false;
  156. }
  157.  
  158. existingInput.remove();
  159. editCallback(true);
  160. }, oDom => {
  161. const status = $(oDom, 'p.notice');
  162. const status_text = status ? status.innerText : true;
  163. finishCallback(status_text);
  164. }, err => finishCallback(false));
  165. },
  166.  
  167. async modifyFav(url, editCallback, finishCallback, onerror) {
  168. const oDom = await API.getDocument(url);
  169. if (editCallback(oDom) === false) { return false; }
  170.  
  171. const form = $(oDom, '.change-script-set');
  172. const data = new FormData(form);
  173. data.append('save', '1');
  174.  
  175. // Use XMLHttpRequest insteadof GM_xmlhttpRequest because there's unknown issue with GM_xmlhttpRequest
  176. // Use XMLHttpRequest insteadof GM_xmlhttpRequest before Tampermonkey 5.0.0 because of FormData posting issues
  177. if (true || typeof GM_xmlhttpRequest !== 'function' || (GM_info.scriptHandler === 'Tampermonkey' && !API.GM_hasVersion('5.0'))) {
  178. const xhr = new XMLHttpRequest();
  179. xhr.open('POST', API.toAbsoluteURL(form.getAttribute('action')));
  180. xhr.responseType = 'blob';
  181. xhr.onload = async e => finishCallback(await API.parseDocument(xhr.response));
  182. xhr.onerror = onerror;
  183. xhr.send(data);
  184. } else {
  185. GM_xmlhttpRequest({
  186. method: 'POST',
  187. url: API.toAbsoluteURL(form.getAttribute('action')),
  188. data,
  189. responseType: 'blob',
  190. onload: async response => finishCallback(await API.parseDocument(response.response)),
  191. onerror
  192. });
  193. }
  194. },
  195.  
  196. // Download and parse a url page into a html document(dom).
  197. // Returns a promise fulfills with dom
  198. async getDocument(url, retry=5) {
  199. try {
  200. const response = await fetch(url, {
  201. method: 'GET',
  202. cache: 'reload',
  203. });
  204. if (response.status === 200) {
  205. const blob = await response.blob();
  206. const oDom = await API.parseDocument(blob);
  207. return oDom;
  208. } else {
  209. throw new Error(`response.status is not 200 (${response.status})`);
  210. }
  211. } catch(err) {
  212. if (--retry > 0) {
  213. return API.getDocument(url, retry);
  214. } else {
  215. throw err;
  216. }
  217. }
  218.  
  219. /*
  220. return new Promise((resolve, reject) => {
  221. GM_xmlhttpRequest({
  222. method : 'GET',
  223. url : url,
  224. responseType : 'blob',
  225. onload : function(response) {
  226. if (response.status === 200) {
  227. const htmlblob = response.response;
  228. API.parseDocument(htmlblob).then(resolve).catch(reject);
  229. } else {
  230. re(response);
  231. }
  232. },
  233. onerror: err => re(err)
  234. });
  235.  
  236. function re(err) {
  237. DoLog(`Get document failed, retrying: (${retry}) ${url}`);
  238. --retry > 0 ? API.getDocument(url, retry).then(resolve).catch(reject) : reject(err);
  239. }
  240. });
  241. */
  242. },
  243.  
  244. // Returns a promise fulfills with dom
  245. parseDocument(htmlblob) {
  246. return new Promise((resolve, reject) => {
  247. const reader = new FileReader();
  248. reader.onload = function(e) {
  249. const htmlText = reader.result;
  250. const dom = new DOMParser().parseFromString(htmlText, 'text/html');
  251. resolve(dom);
  252. }
  253. reader.onerror = err => reject(err);
  254. reader.readAsText(htmlblob, document.characterSet);
  255. });
  256. },
  257.  
  258. toAbsoluteURL(relativeURL, base=`${location.protocol}//${location.host}/`) {
  259. return new URL(relativeURL, base).href;
  260. },
  261.  
  262. GM_hasVersion(version) {
  263. return hasVersion(GM_info?.version || '0', version);
  264.  
  265. function hasVersion(ver1, ver2) {
  266. return compareVersions(ver1.toString(), ver2.toString()) >= 0;
  267.  
  268. // https://greasyfork.org/app/javascript/versioncheck.js
  269. // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version/format
  270. function compareVersions(a, b) {
  271. if (a == b) {
  272. return 0;
  273. }
  274. let aParts = a.split('.');
  275. let bParts = b.split('.');
  276. for (let i = 0; i < aParts.length; i++) {
  277. let result = compareVersionPart(aParts[i], bParts[i]);
  278. if (result != 0) {
  279. return result;
  280. }
  281. }
  282. // If all of a's parts are the same as b's parts, but b has additional parts, b is greater.
  283. if (bParts.length > aParts.length) {
  284. return -1;
  285. }
  286. return 0;
  287. }
  288.  
  289. function compareVersionPart(partA, partB) {
  290. let partAParts = parseVersionPart(partA);
  291. let partBParts = parseVersionPart(partB);
  292. for (let i = 0; i < partAParts.length; i++) {
  293. // "A string-part that exists is always less than a string-part that doesn't exist"
  294. if (partAParts[i].length > 0 && partBParts[i].length == 0) {
  295. return -1;
  296. }
  297. if (partAParts[i].length == 0 && partBParts[i].length > 0) {
  298. return 1;
  299. }
  300. if (partAParts[i] > partBParts[i]) {
  301. return 1;
  302. }
  303. if (partAParts[i] < partBParts[i]) {
  304. return -1;
  305. }
  306. }
  307. return 0;
  308. }
  309.  
  310. // It goes number, string, number, string. If it doesn't exist, then
  311. // 0 for numbers, empty string for strings.
  312. function parseVersionPart(part) {
  313. if (!part) {
  314. return [0, "", 0, ""];
  315. }
  316. let partParts = /([0-9]*)([^0-9]*)([0-9]*)([^0-9]*)/.exec(part)
  317. return [
  318. partParts[1] ? parseInt(partParts[1]) : 0,
  319. partParts[2],
  320. partParts[3] ? parseInt(partParts[3]) : 0,
  321. partParts[4]
  322. ];
  323. }
  324. }
  325. }
  326. };
  327.  
  328. return API;
  329. }) ();
  330.  
  331. (function __MAIN__() {
  332. 'use strict';
  333.  
  334. const CONST = {
  335. Text: {
  336. 'zh-CN': {
  337. FavEdit: '收藏集:',
  338. Add: '加入此集',
  339. Remove: '移出此集',
  340. Edit: '手动编辑',
  341. EditIframe: '页内编辑',
  342. CloseIframe: '关闭编辑',
  343. CopySID: '复制脚本ID',
  344. Sync: '同步',
  345. NotLoggedIn: '请先登录Greasyfork',
  346. NoSetsYet: '您还没有创建过收藏集',
  347. NewSet: '新建收藏集',
  348. sortByApiDefault: ['默认排序', '默认倒序'],
  349. Working: ['工作中...', '就快好了...'],
  350. InSetStatus: ['[ ]', '[✔]'],
  351. Groups: {
  352. Server: 'GreasyFork收藏集',
  353. Local: '本地收藏集',
  354. New: '新建'
  355. },
  356. Refreshing: {
  357. List: '获取收藏集列表...',
  358. Script: '获取收藏集内容...',
  359. Data: '获取收藏集数据...'
  360. },
  361. UseAPI: ['[ ] 使用GF的收藏集API', '[✔]使用GF的收藏集API'],
  362. Error: {
  363. AlreadyExist: '脚本已经在此收藏集中了',
  364. NotExist: '脚本不在此收藏集中',
  365. NetworkError: '网络错误',
  366. Unknown: '未知错误'
  367. }
  368. },
  369. 'zh-TW': {
  370. FavEdit: '收藏集:',
  371. Add: '加入此集',
  372. Remove: '移出此集',
  373. Edit: '手動編輯',
  374. EditIframe: '頁內編輯',
  375. CloseIframe: '關閉編輯',
  376. CopySID: '複製腳本ID',
  377. Sync: '同步',
  378. NotLoggedIn: '請先登錄Greasyfork',
  379. NoSetsYet: '您還沒有創建過收藏集',
  380. NewSet: '新建收藏集',
  381. sortByApiDefault: ['默認排序', '默認倒序'],
  382. Working: ['工作中...', '就快好了...'],
  383. InSetStatus: ['[ ]', '[✔]'],
  384. Groups: {
  385. Server: 'GreasyFork收藏集',
  386. Local: '本地收藏集',
  387. New: '新建'
  388. },
  389. Refreshing: {
  390. List: '獲取收藏集清單...',
  391. Script: '獲取收藏集內容...',
  392. Data: '獲取收藏集數據...'
  393. },
  394. UseAPI: ['[ ] 使用GF的收藏集API', '[✔]使用GF的收藏集API'],
  395. Error: {
  396. AlreadyExist: '腳本已經在此收藏集中了',
  397. NotExist: '腳本不在此收藏集中',
  398. NetworkError: '網絡錯誤',
  399. Unknown: '未知錯誤'
  400. }
  401. },
  402. 'en': {
  403. FavEdit: 'Script set: ',
  404. Add: 'Add',
  405. Remove: 'Remove',
  406. Edit: 'Edit Manually',
  407. EditIframe: 'In-Page Edit',
  408. CloseIframe: 'Close Editor',
  409. CopySID: 'Copy Script-ID',
  410. Sync: 'Sync',
  411. NotLoggedIn: 'Login to greasyfork to use script sets',
  412. NoSetsYet: 'You haven\'t created a collection yet',
  413. NewSet: 'Create a new set',
  414. sortByApiDefault: ['Default', 'Default reverse'],
  415. Working: ['Working...', 'Just a moment...'],
  416. InSetStatus: ['[ ]', '[✔]'],
  417. Groups: {
  418. Server: 'GreasyFork',
  419. Local: 'Local',
  420. New: 'New'
  421. },
  422. Refreshing: {
  423. List: 'Fetching script sets...',
  424. Script: 'Fetching set content...',
  425. Data: 'Fetching script sets data...'
  426. },
  427. UseAPI: ['[ ] Use GF API', '[✔] Use GF API'],
  428. Error: {
  429. AlreadyExist: 'Script is already in set',
  430. NotExist: 'Script is not in set yet',
  431. NetworkError: 'Network Error',
  432. Unknown: 'Unknown Error'
  433. }
  434. },
  435. 'default': {
  436. FavEdit: 'Script set: ',
  437. Add: 'Add',
  438. Remove: 'Remove',
  439. Edit: 'Edit Manually',
  440. EditIframe: 'In-Page Edit',
  441. CloseIframe: 'Close Editor',
  442. CopySID: 'Copy Script-ID',
  443. Sync: 'Sync',
  444. NotLoggedIn: 'Login to greasyfork to use script sets',
  445. NoSetsYet: 'You haven\'t created a collection yet',
  446. NewSet: 'Create a new set',
  447. sortByApiDefault: ['Default', 'Default reverse'],
  448. Working: ['Working...', 'Just a moment...'],
  449. InSetStatus: ['[ ]', '[✔]'],
  450. Groups: {
  451. Server: 'GreasyFork',
  452. Local: 'Local',
  453. New: 'New'
  454. },
  455. Refreshing: {
  456. List: 'Fetching script sets...',
  457. Script: 'Fetching set content...',
  458. Data: 'Fetching script sets data...'
  459. },
  460. UseAPI: ['[ ] Use GF API', '[✔] Use GF API'],
  461. Error: {
  462. AlreadyExist: 'Script is already in set',
  463. NotExist: 'Script is not in set yet',
  464. NetworkError: 'Network Error',
  465. Unknown: 'Unknown Error'
  466. }
  467. },
  468. },
  469. URL: {
  470. SetLink: 'https://greasyfork.org/scripts?set=$ID',
  471. SetEdit: 'https://greasyfork.org/users/$UID/sets/$ID/edit'
  472. },
  473. ConfigRule: {
  474. 'version-key': 'config-version',
  475. ignores: ['useAPI'],
  476. defaultValues: {
  477. 'script-sets': {
  478. sets: [],
  479. time: 0,
  480. 'config-version': 2,
  481. },
  482. 'useAPI': true
  483. },
  484. 'updaters': {
  485. /*'config-key': [
  486. function() {
  487. // This function contains updater for config['config-key'] from v0 to v1
  488. },
  489. function() {
  490. // This function contains updater for config['config-key'] from v1 to v2
  491. }
  492. ]*/
  493. 'script-sets': [
  494. config => {
  495. // v0 ==> v1
  496. // Fill set.id
  497. const sets = config.sets;
  498. sets.forEach(set => {
  499. const id = getUrlArgv(set.link, 'set');
  500. set.id = id;
  501. set.scripts = null; // After first refresh, it should be an array of SIDs:string
  502. });
  503.  
  504. // Delete old version identifier
  505. delete config.version;
  506.  
  507. return config;
  508. },
  509. config => {
  510. // v1 ==> v2
  511. return config
  512. }
  513. ]
  514. },
  515. }
  516. };
  517.  
  518. // Get i18n code
  519. let i18n = $('#language-selector-locale') ? $('#language-selector-locale').value : navigator.language;
  520. if (!Object.keys(CONST.Text).includes(i18n)) {i18n = 'default';}
  521.  
  522. const CM = new ConfigManager(CONST.ConfigRule);
  523. const CONFIG = CM.Config;
  524. CM.updateAllConfigs();
  525. CM.setDefaults();
  526.  
  527. loadFuncs([{
  528. name: 'Hook GM_xmlhttpRequest',
  529. checker: {
  530. type: 'switch',
  531. value: true
  532. },
  533. func: () => GMXHRHook(5)
  534. }, {
  535. name: 'Favorite panel',
  536. checker: {
  537. type: 'func',
  538. value: () => {
  539. const path = location.pathname.split('/').filter(p=>p).map(p => p.toLowerCase());
  540. const index = path.indexOf('scripts');
  541. const scripts_exist = [0,1].includes(index);
  542. const is_scripts_list = path.length-1 === index;
  543. const is_set_page = /[\?&]set=\d+/.test(location.search);
  544. const correct_page = [undefined, 'code', 'feedback'].includes(path[index+2]);
  545. return scripts_exist && !is_scripts_list && !is_set_page && correct_page;
  546. }
  547. },
  548. func: addFavPanel
  549. }, {
  550. name: 'api-doc switch',
  551. checker: {
  552. type: 'switch',
  553. value: true
  554. },
  555. func: e => {
  556. makeBooleanSettings([{
  557. text: CONST.Text[i18n].UseAPI,
  558. key: 'useAPI',
  559. defaultValue: true
  560. }]);
  561. }
  562. }, {
  563. name: 'Set scripts sort',
  564. checker: {
  565. type: 'func',
  566. value: () => {
  567. const scripts_exist = [1, 2].map(index => location.pathname.split('/')[index]?.toLowerCase()).includes('scripts');
  568. const is_set_page = /[\?&]set=\d+/.test(location.search);
  569. return scripts_exist && is_set_page;
  570. }
  571. },
  572. detectDom: '#script-list-sort>ul',
  573. func: e => {
  574. const search = new URLSearchParams(location.search);
  575. const set_id = search.get('set');
  576. const sort = search.get('sort');
  577. if (!CONFIG['script-sets'].sets.some(set => set.id === set_id)) { return false; }
  578.  
  579. const ul = $('#script-list-sort>ul');
  580. [false, true].forEach(reverse => {
  581. const li = $$CrE({
  582. tagName: 'li',
  583. classes: ['list-option', 'gse-sort'], // gse: (G)resyfork(S)et(E)dit+
  584. attrs: { reverse: reverse ? '1' : '0' },
  585. });
  586. const a = $$CrE({
  587. tagName: 'a',
  588. props: { innerText: CONST.Text[i18n].sortByApiDefault[+reverse] },
  589. attrs: { rel: 'nofollow', href: getSortUrl(reverse) }
  590. });
  591. li.appendChild(a);
  592. ul.appendChild(li);
  593. });
  594. $AEL(ul, 'click', e => {
  595. if (e.target.matches('.gse-sort>a')) {
  596. e.preventDefault();
  597. const a = e.target;
  598. const li = a.parentElement;
  599. const reverse = !!+li.getAttribute('reverse');
  600. sortByApiDefault(reverse);
  601. buttonClicked(a);
  602. setSortUrl(reverse);
  603. }
  604. }, { capture: true });
  605.  
  606. switch (sort) {
  607. case 'gse_default':
  608. sortByApiDefault(false);
  609. buttonClicked($('.gse-sort[reverse="0"]>a'));
  610. break;
  611. case 'gse_reverse':
  612. sortByApiDefault(true);
  613. buttonClicked($('.gse-sort[reverse="1"]>a'));
  614. break;
  615. }
  616.  
  617. /**
  618. * Sort <li>s in #browse-script-list by default api order
  619. * Default api order is by add-to-set time right now (2024-07-21),
  620. * but this is not a promising feature
  621. */
  622. function sortByApiDefault(reverse=false) {
  623. const ol = $('#browse-script-list');
  624. const li_scripts = Array.from(ol.children);
  625. const set = CM.getConfig('script-sets').sets.find(set => set.id === set_id);
  626. const scripts = set.scripts;
  627. li_scripts.sort((li1, li2) => {
  628. const [sid1, sid2] = [li1, li2].map(li => li.getAttribute('data-script-id'));
  629. const [index1, index2] = [sid1, sid2].map(sid => scripts.indexOf(sid)).map(index => index >= 0 ? index : Infinity);
  630.  
  631. return (reverse ? [1, -1] : [-1, 1])[index1 > index2 ? 1 : 0];
  632. });
  633. //li_scripts.forEach(li => ol.removeChild(li));
  634. li_scripts.forEach(li => ol.appendChild(li));
  635. }
  636.  
  637. /**
  638. * Change the clicked button gui to given one
  639. */
  640. function buttonClicked(a) {
  641. const li = a.parentElement;
  642. const ul = li.parentElement;
  643. const old_li_current = Array.from(ul.children).find(li => li.classList.contains('list-current'));
  644.  
  645. li.classList.add('list-current');
  646. a.remove();
  647. li.innerText = a.innerText;
  648.  
  649. old_li_current.classList.remove('list-current');
  650. const old_li_a = $$CrE({
  651. tagName: 'a',
  652. attrs: { href: location.pathname + location.search + location.hash },
  653. props: { innerText: old_li_current.innerText }
  654. });
  655. old_li_current.innerText = '';
  656. old_li_current.appendChild(old_li_a);
  657. }
  658.  
  659. /**
  660. * Set url search params when sorting
  661. */
  662. function setSortUrl(reverse) {
  663. history.replaceState({}, '', getSortUrl(reverse));
  664. }
  665.  
  666. /**
  667. * Make corrent url search params with sorting
  668. */
  669. function getSortUrl(reverse) {
  670. const search = new URLSearchParams(location.search);
  671. search.set('sort', reverse ? 'gse_reverse' : 'gse_default');
  672. const url = location.pathname + '?' + search.toString();
  673. return url;
  674. }
  675. }
  676. }]);
  677.  
  678. function addFavPanel() {
  679. //if (!GFScriptSetAPI.getUserpage()) {return false;}
  680.  
  681. class FavoritePanel {
  682. #CM;
  683. #sid;
  684. #sets;
  685. #elements;
  686. #disabled;
  687.  
  688. constructor(CM) {
  689. this.#CM = CM;
  690. this.#sid = location.pathname.match(/scripts\/(\d+)/)[1];
  691. this.#sets = this.#CM.getConfig('script-sets').sets;
  692. this.#elements = {};
  693. this.disabled = false;
  694.  
  695. // Sort sets by name in alphabetical order
  696. FavoritePanel.#sortSetsdata(this.#sets);
  697.  
  698. const script_after = $('#script-feedback-suggestion+*') || $('#new-script-discussion');
  699. const script_parent = script_after.parentElement;
  700.  
  701. // Container
  702. const script_favorite = this.#elements.container = $$CrE({
  703. tagName: 'div',
  704. props: {
  705. id: 'script-favorite',
  706. innerHTML: CONST.Text[i18n].FavEdit
  707. },
  708. styles: { margin: '0.75em 0' }
  709. });
  710.  
  711. // Selecter
  712. const favorite_groups = this.#elements.select = $$CrE({
  713. tagName: 'select',
  714. props: { id: 'favorite-groups' },
  715. styles: { maxWidth: '40vw' },
  716. listeners: [['change', (() => {
  717. let lastSelected = 0;
  718. const record = () => lastSelected = favorite_groups.selectedIndex;
  719. const recover = () => favorite_groups.selectedIndex = lastSelected;
  720.  
  721. return e => {
  722. const value = favorite_groups.value;
  723. const type = /^\d+$/.test(value) ? 'set-id' : 'command';
  724.  
  725. switch (type) {
  726. case 'set-id': {
  727. const set = this.#sets.find(set => set.id === favorite_groups.value);
  728. favorite_edit.href = set.linkedit;
  729. break;
  730. }
  731. case 'command': {
  732. recover();
  733. this.#execCommand(value);
  734. }
  735. }
  736.  
  737. this.#refreshButtonDisplay();
  738. record();
  739. }
  740. }) ()]]
  741. });
  742. favorite_groups.id = 'favorite-groups';
  743.  
  744. // Buttons
  745. const makeBtn = (id, innerHTML, onClick, isLink=false) => $$CrE({
  746. tagName: 'a',
  747. props: {
  748. id, innerHTML,
  749. [isLink ? 'target' : 'href']: isLink ? '_blank' : 'javascript:void(0);'
  750. },
  751. styles: { margin: '0px 0.5em' },
  752. listeners: [['click', onClick]]
  753. });
  754.  
  755. const favorite_add = this.#elements.btnAdd = makeBtn('favorite-add', CONST.Text[i18n].Add, e => this.#addFav());
  756. const favorite_remove = this.#elements.btnRemove = makeBtn('favorite-remove', CONST.Text[i18n].Remove, e => this.#removeFav());
  757. const favorite_edit = this.#elements.btnEdit = makeBtn('favorite-edit', CONST.Text[i18n].Edit, e => {}, true);
  758. const favorite_iframe = this.#elements.btnIframe = makeBtn('favorite-edit-in-page', CONST.Text[i18n].EditIframe, e => this.#editInPage(e));
  759. const favorite_copy = this.#elements.btnCopy = makeBtn('favorite-add', CONST.Text[i18n].CopySID, e => copyText(this.#sid));
  760. const favorite_sync = this.#elements.btnSync = makeBtn('favorite-sync', CONST.Text[i18n].Sync, e => this.#refresh());
  761.  
  762. script_favorite.appendChild(favorite_groups);
  763. script_after.before(script_favorite);
  764. [favorite_add, favorite_remove, favorite_edit, favorite_iframe, favorite_copy, favorite_sync].forEach(button => script_favorite.appendChild(button));
  765.  
  766. // Text tip
  767. const tip = this.#elements.tip = $CrE('span');
  768. script_favorite.appendChild(tip);
  769.  
  770. // Display cached sets first
  771. this.#displaySets();
  772.  
  773. // Request GF document to update sets
  774. this.#autoRefresh();
  775. }
  776.  
  777. get sid() {
  778. return this.#sid;
  779. }
  780.  
  781. get sets() {
  782. return FavoritePanel.#deepClone(this.#sets);
  783. }
  784.  
  785. get elements() {
  786. return FavoritePanel.#lightClone(this.#elements);
  787. }
  788.  
  789. #refresh() {
  790. const that = this;
  791. const method = CONFIG.useAPI ? 'api' : 'doc';
  792. return {
  793. api: () => this.#refresh_api(),
  794. doc: () => this.#refresh_doc()
  795. }[method]();
  796. }
  797.  
  798. async #refresh_api() {
  799. const CONFIG = this.#CM.Config;
  800.  
  801. this.#disable();
  802. this.#tip(CONST.Text[i18n].Refreshing.Data);
  803.  
  804. // Check login status
  805. if (!GFScriptSetAPI.getUserpage()) {
  806. this.#tip(CONST.Text[i18n].NotLoggedIn);
  807. return;
  808. }
  809.  
  810. // Request sets data api
  811. const api_result = await GFScriptSetAPI.getSetsData();
  812. const sets_data = api_result.data;
  813. const uid = GFScriptSetAPI.getUserID();
  814.  
  815. if (!api_result.ok) {
  816. // When api fails, use doc as fallback
  817. DoLog(LogLevel.Error, 'Sets API failed.');
  818. DoLog(LogLevel.Error, api_result);
  819. return this.#refresh_doc();
  820. }
  821.  
  822. // For forward compatibility, convert all setids and scriptids to string
  823. // and fill property set.link and set.linkedit
  824. for (const set of sets_data) {
  825. // convert set id to string
  826. set.id = set.id.toString();
  827. // https://greasyfork.org/zh-CN/scripts?set=439237
  828. set.link = replaceText(CONST.URL.SetLink, { $ID: set.id });
  829. // https://greasyfork.org/zh-CN/users/667968-pyudng/sets/439237/edit
  830. set.linkedit = replaceText(CONST.URL.SetEdit, { $UID: uid, $ID: set.id });
  831.  
  832. // there's two kind of sets: Favorite and non-favorite
  833. // favorite set's data is an array of object, where each object represents a script, with script's properties
  834. // non-favorite set's data is an array of ints, where each int means a script's id
  835. // For forward compatibility, we only store script ids, in string format
  836. set.scripts.forEach((script, i, scripts) => {
  837. if (typeof script === 'number') {
  838. scripts[i] = script.toString();
  839. } else {
  840. scripts[i] = script.id.toString();
  841. }
  842. });
  843. }
  844.  
  845. // Sort sets by name in alphabetical order
  846. FavoritePanel.#sortSetsdata(sets_data);
  847.  
  848. this.#sets = CONFIG['script-sets'].sets = sets_data;
  849. CONFIG['script-sets'].time = Date.now();
  850.  
  851. this.#tip();
  852. this.#enable();
  853. this.#displaySets();
  854. this.#refreshButtonDisplay();
  855. }
  856.  
  857. // Request document: get sets list and
  858. async #refresh_doc() {
  859. const CONFIG = this.#CM.Config;
  860.  
  861. this.#disable();
  862. this.#tip(CONST.Text[i18n].Refreshing.List);
  863.  
  864. // Check login status
  865. if (!GFScriptSetAPI.getUserpage()) {
  866. this.#tip(CONST.Text[i18n].NotLoggedIn);
  867. return;
  868. }
  869.  
  870. // Refresh sets list
  871. this.#sets = CONFIG['script-sets'].sets = await GFScriptSetAPI.getScriptSets();
  872. CONFIG['script-sets'].time = Date.now();
  873. this.#displaySets();
  874.  
  875. // Refresh each set's script list
  876. this.#tip(CONST.Text[i18n].Refreshing.Script);
  877. await Promise.all(this.#sets.map(async set => {
  878. // Fetch scripts
  879. set.scripts = await GFScriptSetAPI.getSetScripts(set.linkedit);
  880. this.#displaySets();
  881.  
  882. // Save to GM_storage
  883. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  884. CONFIG['script-sets'].sets[setIndex].scripts = set.scripts;
  885. CONFIG['script-sets'].time = Date.now();
  886. }));
  887.  
  888. this.#tip();
  889. this.#enable();
  890. this.#refreshButtonDisplay();
  891. }
  892.  
  893. // Refresh on instance creation.
  894. // This should be running in low-frequecy. Refreshing makes lots of requests which may resul in a 503 error(rate limit) for the user.
  895. #autoRefresh(minTime=1*24*60*60*1000) {
  896. const CONFIG = this.#CM.Config;
  897. const lastRefresh = new Date(CONFIG['script-sets'].time);
  898. if (Date.now() - lastRefresh > minTime) {
  899. this.#refresh();
  900. return true;
  901. } else {
  902. return false;
  903. }
  904. }
  905.  
  906. #addFav() {
  907. const set = this.#getCurrentSet();
  908. const option = set.elmOption;
  909.  
  910. this.#displayNotice(CONST.Text[i18n].Working[0]);
  911. GFScriptSetAPI.addFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
  912. if (!editStatus) {
  913. this.#displayNotice(CONST.Text[i18n].Error.AlreadyExist);
  914. option.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
  915. } else {
  916. this.#displayNotice(CONST.Text[i18n].Working[1]);
  917. }
  918. }, finishStatus => {
  919. if (finishStatus) {
  920. // Save to this.#sets and GM_storage
  921. if (CONFIG['script-sets'].sets.some(set => !set.scripts)) {
  922. // If scripts property is missing, do sync(refresh)
  923. this.#refresh();
  924. } else {
  925. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  926. CONFIG['script-sets'].sets[setIndex].scripts.push(this.#sid);
  927. this.#sets = CM.getConfig('script-sets').sets;
  928. }
  929.  
  930. // Display
  931. this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
  932. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
  933. this.#displaySets();
  934. } else {
  935. this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
  936. }
  937. });
  938. }
  939.  
  940. #removeFav() {
  941. const set = this.#getCurrentSet();
  942. const option = set.elmOption;
  943.  
  944. this.#displayNotice(CONST.Text[i18n].Working[0]);
  945. GFScriptSetAPI.removeFav(this.#getCurrentSet().linkedit, this.#sid, editStatus => {
  946. if (!editStatus) {
  947. this.#displayNotice(CONST.Text[i18n].Error.NotExist);
  948. option.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
  949. } else {
  950. this.#displayNotice(CONST.Text[i18n].Working[1]);
  951. }
  952. }, finishStatus => {
  953. if (finishStatus) {
  954. // Save to this.#sets and GM_storage
  955. if (CONFIG['script-sets'].sets.some(set => !set.scripts)) {
  956. // If scripts property is missing, do sync(refresh)
  957. this.#refresh();
  958. } else {
  959. const setIndex = CONFIG['script-sets'].sets.findIndex(s => s.id === set.id);
  960. const scriptIndex = CONFIG['script-sets'].sets[setIndex].scripts.indexOf(this.#sid);
  961. CONFIG['script-sets'].sets[setIndex].scripts.splice(scriptIndex, 1);
  962. this.#sets = CM.getConfig('script-sets').sets;
  963. }
  964.  
  965. // Display
  966. this.#displayNotice(typeof finishStatus === 'string' ? finishStatus : CONST.Text[i18n].Error.Unknown);
  967. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
  968. this.#displaySets();
  969. } else {
  970. this.#displayNotice(CONST.Text[i18n].Error.NetworkError);
  971. }
  972. });
  973. }
  974.  
  975. #editInPage(e) {
  976. e.preventDefault();
  977.  
  978. const _iframes = [...$All(this.#elements.container, '.script-edit-page')];
  979. if (_iframes.length) {
  980. // Iframe exists, close iframe
  981. this.#elements.btnIframe.innerText = CONST.Text[i18n].EditIframe;
  982. _iframes.forEach(ifr => ifr.remove());
  983. this.#refresh();
  984. } else {
  985. // Iframe not exist, make iframe
  986. this.#elements.btnIframe.innerText = CONST.Text[i18n].CloseIframe;
  987.  
  988. const iframe = $$CrE({
  989. tagName: 'iframe',
  990. props: {
  991. src: this.#getCurrentSet().linkedit
  992. },
  993. styles: {
  994. width: '100%',
  995. height: '60vh'
  996. },
  997. classes: ['script-edit-page'],
  998. listeners: [['load', e => {
  999. //this.#refresh();
  1000. //iframe.style.height = iframe.contentDocument.body.parentElement.offsetHeight + 'px';
  1001. }]]
  1002. });
  1003. this.#elements.container.appendChild(iframe);
  1004. }
  1005. }
  1006.  
  1007. #displayNotice(text) {
  1008. const notice = $CrE('p');
  1009. notice.classList.add('notice');
  1010. notice.id = 'fav-notice';
  1011. notice.innerText = text;
  1012. const old_notice = $('#fav-notice');
  1013. old_notice && old_notice.parentElement.removeChild(old_notice);
  1014. $('#script-content').insertAdjacentElement('afterbegin', notice);
  1015. }
  1016.  
  1017. #tip(text='', timeout=0) {
  1018. this.#elements.tip.innerText = text;
  1019. timeout > 0 && setTimeout(() => this.#elements.tip.innerText = '', timeout);
  1020. }
  1021.  
  1022. // Apply this.#sets to gui
  1023. #displaySets() {
  1024. const elements = this.#elements;
  1025.  
  1026. // Save selected set
  1027. const old_value = elements.select.value;
  1028. [...elements.select.children].forEach(child => child.remove());
  1029.  
  1030. // Make <optgroup>s and <option>s
  1031. const serverGroup = elements.serverGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.Server } });
  1032. this.#sets.forEach(set => {
  1033. // Create <option>
  1034. set.elmOption = $$CrE({
  1035. tagName: 'option',
  1036. props: {
  1037. innerText: set.name,
  1038. value: set.id
  1039. }
  1040. });
  1041. // Display inset status
  1042. if (set.scripts) {
  1043. const inSet = set.scripts.includes(this.#sid);
  1044. set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[inSet+0]} ${set.name}`;
  1045. }
  1046. // Append <option> into <select>
  1047. serverGroup.appendChild(set.elmOption);
  1048. });
  1049. if (this.#sets.length === 0) {
  1050. const optEmpty = elements.optEmpty = $$CrE({
  1051. tagName: 'option',
  1052. props: {
  1053. innerText: CONST.Text[i18n].NoSetsYet,
  1054. value: 'empty',
  1055. selected: true
  1056. }
  1057. });
  1058. serverGroup.appendChild(optEmpty);
  1059. }
  1060.  
  1061. const newGroup = elements.newGroup = $$CrE({ tagName: 'optgroup', attrs: { label: CONST.Text[i18n].Groups.New } });
  1062. const newSet = elements.newSet = $$CrE({
  1063. tagName: 'option',
  1064. props: {
  1065. innerText: CONST.Text[i18n].NewSet,
  1066. value: 'new',
  1067. }
  1068. });
  1069. newGroup.appendChild(newSet);
  1070. [serverGroup, newGroup].forEach(optgroup => elements.select.appendChild(optgroup));
  1071.  
  1072. // Adjust <select> width
  1073. elements.select.style.width = Math.max.apply(null, Array.from($All(elements.select, 'option')).map(o => o.innerText.length)).toString() + 'em';
  1074.  
  1075. // Select previous selected set's <option>
  1076. const selected = old_value ? [...$All(elements.select, 'option')].find(option => option.value === old_value) : null;
  1077. selected && (selected.selected = true);
  1078.  
  1079. // Set edit-button.href
  1080. if (elements.select.value !== 'empty') {
  1081. const curset = this.#sets.find(set => set.id === elements.select.value);
  1082. elements.btnEdit.href = curset.linkedit;
  1083. }
  1084.  
  1085. // Display correct button
  1086. this.#refreshButtonDisplay();
  1087. }
  1088.  
  1089. // Display only add button when script in current set, otherwise remove button
  1090. // Disable set-related buttons when not selecting options that not represents a set
  1091. #refreshButtonDisplay() {
  1092. const set = this.#getCurrentSet();
  1093. !this.#disabled && ([this.#elements.btnAdd, this.#elements.btnRemove, this.#elements.btnEdit, this.#elements.btnIframe]
  1094. .forEach(element => set ? FavoritePanel.#enableElement(element) : FavoritePanel.#disableElement(element)));
  1095. if (!set || !set.scripts) { return null; }
  1096. if (set.scripts.includes(this.#sid)) {
  1097. this.#elements.btnAdd.style.setProperty('display', 'none');
  1098. this.#elements.btnRemove.style.removeProperty('display');
  1099. return true;
  1100. } else {
  1101. this.#elements.btnRemove.style.setProperty('display', 'none');
  1102. this.#elements.btnAdd.style.removeProperty('display');
  1103. return false;
  1104. }
  1105. }
  1106.  
  1107. #execCommand(command) {
  1108. switch (command) {
  1109. case 'new': {
  1110. const url = GFScriptSetAPI.getUserpage() + (this.#getCurrentSet() ? '/sets/new' : '/sets/new?fav=1');
  1111. window.open(url);
  1112. break;
  1113. }
  1114. case 'empty': {
  1115. // Do nothing
  1116. break;
  1117. }
  1118. }
  1119. }
  1120.  
  1121. // Returns null if no <option>s yet
  1122. #getCurrentSet() {
  1123. return this.#sets.find(set => set.id === this.#elements.select.value) || null;
  1124. }
  1125.  
  1126. #disable() {
  1127. [
  1128. this.#elements.select,
  1129. this.#elements.btnAdd, this.#elements.btnRemove,
  1130. this.#elements.btnEdit, this.#elements.btnIframe,
  1131. this.#elements.btnCopy, this.#elements.btnSync
  1132. ].forEach(element => FavoritePanel.#disableElement(element));
  1133. this.#disabled = true;
  1134. }
  1135.  
  1136. #enable() {
  1137. [
  1138. this.#elements.select,
  1139. this.#elements.btnAdd, this.#elements.btnRemove,
  1140. this.#elements.btnEdit, this.#elements.btnIframe,
  1141. this.#elements.btnCopy, this.#elements.btnSync
  1142. ].forEach(element => FavoritePanel.#enableElement(element));
  1143. this.#disabled = false;
  1144. }
  1145.  
  1146. static #disableElement(element) {
  1147. element.style.filter = 'grayscale(1) brightness(0.95)';
  1148. element.style.opacity = '0.25';
  1149. element.style.pointerEvents = 'none';
  1150. element.tabIndex = -1;
  1151. }
  1152.  
  1153. static #enableElement(element) {
  1154. element.style.removeProperty('filter');
  1155. element.style.removeProperty('opacity');
  1156. element.style.removeProperty('pointer-events');
  1157. element.tabIndex = 0;
  1158. }
  1159.  
  1160. static #deepClone(val) {
  1161. if (typeof structuredClone === 'function') {
  1162. return structuredClone(val);
  1163. } else {
  1164. return JSON.parse(JSON.stringify(val));
  1165. }
  1166. }
  1167.  
  1168. static #lightClone(val) {
  1169. if (['string', 'number', 'boolean', 'undefined', 'bigint', 'symbol', 'function'].includes(val) || val === null) {
  1170. return val;
  1171. }
  1172. if (Array.isArray(val)) {
  1173. return val.slice();
  1174. }
  1175. if (typeof val === 'object') {
  1176. return Object.fromEntries(Object.entries(val));
  1177. }
  1178. }
  1179.  
  1180. static #sortSetsdata(sets_data) {
  1181. // Sort sets by name in alphabetical order
  1182. const sorted_names = sets_data.map(set => set.name).sort();
  1183. if (sorted_names.includes('Favorite')) {
  1184. // Keep set `Favorite` at first place
  1185. sorted_names.splice(0, 0, sorted_names.splice(sorted_names.indexOf('Favorite'), 1)[0]);
  1186. }
  1187. sets_data.sort((setA, setB) => sorted_names.indexOf(setA.name) - sorted_names.indexOf(setB.name));
  1188. }
  1189. }
  1190.  
  1191. const panel = new FavoritePanel(CM);
  1192. }
  1193.  
  1194. // Basic functions
  1195. function makeBooleanSettings(settings) {
  1196. for (const setting of settings) {
  1197. makeBooleanMenu(setting.text, setting.key, setting.defaultValue, setting.callback, setting.initCallback);
  1198. }
  1199.  
  1200. function makeBooleanMenu(texts, key, defaultValue=false, callback=null, initCallback=false) {
  1201. const initialVal = GM_getValue(key, defaultValue);
  1202. const initialText = texts[initialVal + 0];
  1203. let id = makeMenu(initialText, onClick);
  1204. initCallback && callback(key, initialVal);
  1205.  
  1206. function onClick() {
  1207. const newValue = !GM_getValue(key, defaultValue);
  1208. const newText = texts[newValue + 0];
  1209. GM_setValue(key, newValue);
  1210. id = makeMenu(newText, onClick, id);
  1211. typeof callback === 'function' && callback(key, newValue);
  1212. }
  1213.  
  1214. function makeMenu(text, func, id) {
  1215. if (GM_info.scriptHandler === 'Tampermonkey' && GM_hasVersion('5.0')) {
  1216. return GM_registerMenuCommand(text, func, {
  1217. id,
  1218. autoClose: false,
  1219. });
  1220. } else {
  1221. GM_unregisterMenuCommand(id);
  1222. return GM_registerMenuCommand(text, func);
  1223. }
  1224. }
  1225. }
  1226.  
  1227. function GM_hasVersion(version) {
  1228. return hasVersion(GM_info?.version || '0', version);
  1229.  
  1230. function hasVersion(ver1, ver2) {
  1231. return compareVersions(ver1.toString(), ver2.toString()) >= 0;
  1232.  
  1233. // https://greasyfork.org/app/javascript/versioncheck.js
  1234. // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version/format
  1235. function compareVersions(a, b) {
  1236. if (a == b) {
  1237. return 0;
  1238. }
  1239. let aParts = a.split('.');
  1240. let bParts = b.split('.');
  1241. for (let i = 0; i < aParts.length; i++) {
  1242. let result = compareVersionPart(aParts[i], bParts[i]);
  1243. if (result != 0) {
  1244. return result;
  1245. }
  1246. }
  1247. // If all of a's parts are the same as b's parts, but b has additional parts, b is greater.
  1248. if (bParts.length > aParts.length) {
  1249. return -1;
  1250. }
  1251. return 0;
  1252. }
  1253.  
  1254. function compareVersionPart(partA, partB) {
  1255. let partAParts = parseVersionPart(partA);
  1256. let partBParts = parseVersionPart(partB);
  1257. for (let i = 0; i < partAParts.length; i++) {
  1258. // "A string-part that exists is always less than a string-part that doesn't exist"
  1259. if (partAParts[i].length > 0 && partBParts[i].length == 0) {
  1260. return -1;
  1261. }
  1262. if (partAParts[i].length == 0 && partBParts[i].length > 0) {
  1263. return 1;
  1264. }
  1265. if (partAParts[i] > partBParts[i]) {
  1266. return 1;
  1267. }
  1268. if (partAParts[i] < partBParts[i]) {
  1269. return -1;
  1270. }
  1271. }
  1272. return 0;
  1273. }
  1274.  
  1275. // It goes number, string, number, string. If it doesn't exist, then
  1276. // 0 for numbers, empty string for strings.
  1277. function parseVersionPart(part) {
  1278. if (!part) {
  1279. return [0, "", 0, ""];
  1280. }
  1281. let partParts = /([0-9]*)([^0-9]*)([0-9]*)([^0-9]*)/.exec(part)
  1282. return [
  1283. partParts[1] ? parseInt(partParts[1]) : 0,
  1284. partParts[2],
  1285. partParts[3] ? parseInt(partParts[3]) : 0,
  1286. partParts[4]
  1287. ];
  1288. }
  1289. }
  1290. }
  1291. }
  1292.  
  1293. // Copy text to clipboard (needs to be called in an user event)
  1294. function copyText(text) {
  1295. // Create a new textarea for copying
  1296. const newInput = document.createElement('textarea');
  1297. document.body.appendChild(newInput);
  1298. newInput.value = text;
  1299. newInput.select();
  1300. document.execCommand('copy');
  1301. document.body.removeChild(newInput);
  1302. }
  1303. })();