Greasyfork 快捷编辑收藏

在GF脚本页直接编辑收藏集

目前为 2024-07-10 提交的版本。查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址