/* eslint-disable no-multi-spaces */
// ==UserScript==
// @name Greasyfork 快捷编辑收藏
// @name:zh-CN Greasyfork 快捷编辑收藏
// @name:zh-TW Greasyfork 快捷編輯收藏
// @name:en Greasyfork script-set-edit button
// @name:en-US Greasyfork script-set-edit button
// @name:fr Greasyfork Set Edit+
// @namespace Greasyfork-Favorite
// @version 0.2.3
// @description 在GF脚本页添加快速打开收藏集编辑页面功能
// @description:zh-CN 在GF脚本页添加快速打开收藏集编辑页面功能
// @description:zh-TW 在GF腳本頁添加快速打開收藏集編輯頁面功能
// @description:en Add / Remove script into / from script set directly in GF script info page
// @description:en-US Add / Remove script into / from script set directly in GF script info page
// @description:fr Ajouter un script à un jeu de scripts / supprimer un script d'un jeu de scripts directement sur la page d'informations sur les scripts GF
// @author PY-DNG
// @license GPL-3
// @match http*://*.greatest.deepsurf.us/*
// @match http*://*.sleazyfork.org/*
// @match http*://greatest.deepsurf.us/*
// @match http*://sleazyfork.org/*
// @require https://update.greatest.deepsurf.us/scripts/456034/1303041/Basic%20Functions%20%28For%20userscripts%29.js
// @require https://greatest.deepsurf.us/scripts/460385-gm-web-hooks/code/script.js?version=1221394
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAbBJREFUOE+Vk7GKGlEUhr8pAiKKDlqpCDpLUCzWBxCENBa+hBsL9wHsLWxXG4tNtcGH0MIiWopY7JSGEUWsbESwUDMw4Z7siLsZDbnlPff/7n/+e67G38sA6sAXIPVWXgA/gCdgfinRPuhfCoXCw3Q65XA4eLBl6zvw1S2eAZqmvTqOc5/NZhkMBqRSKWzbvgYxgbwquoAX4MGyLHK5HIlEgtFo9C+IOFEAo1gsWsvlUmyPx2MymYxAhsMh6XT6lpM7BXjWdf1xNpuRz+fl8GQywTAMGo0G1WpVnJxOJ692vinADPgcDAaZz+cCOR6PmKZJPB4XUb/fp1wuewF+KoBCf1JVBVE5dDodms3mWdDtdqlUKl6AX+8ALmS9XgtM0/5kvNlspKX9fv8RIgBp4bISCoXo9XqsVitKpRK6rrPb7STQ7XZ7eVRaeAYerz14OBxGOfL7/eIgmUwKzHEcJZEQ1eha1wBqPxqNihufzyeQWCzmtiPPqJYM0jWIyiISibBYLAgEAtTrdVqt1nmQXN0rcH/LicqmVqvRbrdN27bfjbKru+nk7ZD3Z7q4+b++82/YPKIrXsKZ3AAAAABJRU5ErkJggg==
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager */
/* global GMXHRHook GMDLHook */
(function __MAIN__() {
'use strict';
const CONST = {
Text: {
'zh-CN': {
FavEdit: '收藏集:',
Add: '加入此集',
Remove: '移出此集',
Edit: '手动编辑',
EditIframe: '页内编辑',
CloseIframe: '关闭编辑',
CopySID: '复制脚本ID',
Working: ['工作中...', '就快好了...'],
InSetStatus: ['[ ]', '[✔]'],
Error: {
AlreadyExist: '脚本已经在此收藏集中了',
NotExist: '脚本不在此收藏集中',
Unknown: '未知错误'
}
},
'zh-TW': {
FavEdit: '收藏集:',
Add: '加入此集',
Remove: '移出此集',
Edit: '手動編輯',
EditIframe: '頁內編輯',
CloseIframe: '關閉編輯',
CopySID: '複製腳本ID',
Working: ['工作中...', '就快好了...'],
InSetStatus: ['[ ]', '[✔]'],
Error: {
AlreadyExist: '腳本已經在此收藏集中了',
NotExist: '腳本不在此收藏集中',
Unknown: '未知錯誤'
}
},
'en': {
FavEdit: 'Add to/Remove from favorite list: ',
Add: 'Add',
Remove: 'Remove',
Edit: 'Edit Manually',
EditIframe: 'In-Page Edit',
CloseIframe: 'Close Editor',
CopySID: 'Copy Script-ID',
Working: ['Working...', 'Just a moment...'],
InSetStatus: ['[ ]', '[✔]'],
Error: {
AlreadyExist: 'Script is already in set',
NotExist: 'Script is not in set yet',
Unknown: 'Unknown Error'
}
},
'default': {
FavEdit: 'Add to/Remove from favorite list: ',
Add: 'Add',
Remove: 'Remove',
Edit: 'Edit Manually',
EditIframe: 'In-Page Edit',
CloseIframe: 'Close Editor',
CopySID: 'Copy Script-ID',
Working: ['Working...', 'Just a moment...'],
InSetStatus: ['[ ]', '[✔]'],
Error: {
AlreadyExist: 'Script is already in set',
NotExist: 'Script is not in set yet',
Unknown: 'Unknown Error'
}
},
}
}
// Get i18n code
let i18n = $('#language-selector-locale') ? $('#language-selector-locale').value : navigator.language;
if (!Object.keys(CONST.Text).includes(i18n)) {i18n = 'default';}
main()
function main() {
const HOST = getHost();
const API = getAPI();
// Common actions
commons();
// API-based actions
switch(API[1]) {
case "scripts":
API[2] && centerScript(API);
break;
default:
DoLog('API is {}'.replace('{}', API));
}
}
function centerScript(API) {
switch(API[3]) {
case undefined:
pageScript();
break;
case 'code':
pageCode();
break;
case 'feedback':
pageFeedback();
break;
}
}
function commons() {
// Your common actions here...
GMXHRHook(5);
}
function pageScript() {
addFavPanel();
}
function pageCode() {
addFavPanel();
}
function pageFeedback() {
addFavPanel();
}
function addFavPanel() {
if (!getUserpage()) {return false;}
GUI();
function GUI() {
// Get elements
const script_after = $('#script-feedback-suggestion+*') || $('#new-script-discussion');
const script_parent = script_after.parentElement;
// My elements
const script_favorite = $CrE('div');
script_favorite.id = 'script-favorite';
script_favorite.style.margin = '0.75em 0';
script_favorite.innerHTML = CONST.Text[i18n].FavEdit;
const favorite_groups = $CrE('select');
favorite_groups.id = 'favorite-groups';
const stored_sets = GM_getValue('script-sets', {sets: []}).sets;
for (const set of stored_sets) {
// Make <option>
const option = $CrE('option');
option.innerText = set.name;
option.value = set.linkedit;
$APD(favorite_groups, option);
}
adjustWidth();
refresh();
$AEL(favorite_groups, 'change', function(e) {
favorite_edit.href = favorite_groups.value;
refreshButtonDisplay();
});
const makeBtn = (id, innerHTML, onClick, isLink=false) => $$CrE({
tagName: 'a',
props: {
id, innerHTML,
[isLink ? 'target' : 'href']: isLink ? '_blank' : 'javascript:void(0);'
},
styles: { margin: '0px 0.5em' },
listeners: [['click', onClick]]
});
const favorite_add = makeBtn('favorite-add', CONST.Text[i18n].Add, e => addFav());
const favorite_remove = makeBtn('favorite-remove', CONST.Text[i18n].Remove, e => removeFav());
const favorite_edit = makeBtn('favorite-edit', CONST.Text[i18n].Edit, e => addFav(), true);
const favorite_iframe = makeBtn('favorite-edit-in-page', CONST.Text[i18n].EditIframe, editInPage);
const favorite_copy = makeBtn('favorite-add', CONST.Text[i18n].CopySID, e => copyText(getStrSID()));
// Append to document
$APD(script_favorite, favorite_groups);
script_after.before(script_favorite);
[favorite_add, favorite_remove, favorite_edit, favorite_iframe, favorite_copy].forEach(button => $APD(script_favorite, button));
async function refresh() {
const sets = await getScriptSets();
const old_value = favorite_groups.value;
clearChildnodes(favorite_groups);
for (const set of sets) {
// Make <option>
const option = set.elmOption = $CrE('option');
option.innerText = set.name;
option.value = set.linkedit;
$APD(favorite_groups, option);
}
adjustWidth();
// Recover selected <option>
const selected = [...favorite_groups.children].find(option => option.value === old_value);
selected && (selected.selected = true);
// Set edit-button.href
favorite_edit.href = favorite_groups.value;
// Check script in-set status
const inSets = await getInSets(sets, getStrSID());
sets.forEach(set => {
const inSet = inSets.includes(set);
set.elmOption.innerText = `${CONST.Text[i18n].InSetStatus[inSet+0]} ${set.name}`;
set.elmOption.inSet = inSet;
});
adjustWidth();
refreshButtonDisplay();
}
function adjustWidth() {
favorite_groups.style.width = Math.max.apply(null, Array.from(favorite_groups.children).map((o) => (o.innerText.length))).toString() + 'em';
favorite_groups.style.maxWidth = '40vw';
}
function refreshButtonDisplay() {
if (favorite_groups.selectedOptions[0].inSet === true) {
favorite_add.style.setProperty('display', 'none');
favorite_remove.style.removeProperty('display');
} else if (favorite_groups.selectedOptions[0].inSet === false) {
favorite_remove.style.setProperty('display', 'none');
favorite_add.style.removeProperty('display');
}
}
function addFav() {
const option = favorite_groups.selectedOptions[0];
const set = GM_getValue('script-sets').sets.find(set => set.linkedit === option.value);
const url = favorite_groups.value;
displayNotice(CONST.Text[i18n].Working[0]);
modifyFav(favorite_groups.value, oDom => {
const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === getStrSID());
if (existingInput) {
displayNotice(CONST.Text[i18n].Error.AlreadyExist);
option.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
return false;
}
const input = $CrE('input');
input.value = getStrSID();
input.name = 'scripts-included[]';
input.type = 'hidden';
$APD($(oDom, '#script-set-scripts'), input);
displayNotice(CONST.Text[i18n].Working[1]);
}, oDom => {
const status = $(oDom, 'p.notice');
const status_text = status ? status.innerText : CONST.Text[i18n].Error.Unknown;
displayNotice(status_text);
option.innerText = `${CONST.Text[i18n].InSetStatus[1]} ${set.name}`;
option.inSet = true;
refreshButtonDisplay();
}, onerror);
}
function removeFav() {
const option = favorite_groups.selectedOptions[0];
const set = GM_getValue('script-sets').sets.find(set => set.linkedit === option.value);
const url = favorite_groups.value;
displayNotice(CONST.Text[i18n].Working[0]);
modifyFav(favorite_groups.value, oDom => {
const existingInput = [...$All(oDom, '#script-set-scripts>input[name="scripts-included[]"][type="hidden"]')].find(input => input.value === getStrSID());
if (!existingInput) {
displayNotice(CONST.Text[i18n].Error.NotExist);
option.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
return false;
}
existingInput.remove();
displayNotice(CONST.Text[i18n].Working[1]);
}, oDom => {
const status = $(oDom, 'p.notice');
const status_text = status ? status.innerText : CONST.Text[i18n].Error.Unknown;
displayNotice(status_text);
option.innerText = `${CONST.Text[i18n].InSetStatus[0]} ${set.name}`;
option.inSet = false;
refreshButtonDisplay();
}, onerror);
}
async function modifyFav(url, editCallback, finishCallback, onerror) {
const oDom = await getDocument(url);
if (editCallback(oDom) === false) { return false; }
const form = $(oDom, '.change-script-set');
const data = new FormData(form);
data.append('save', '1');
// Use XMLHttpRequest insteadof GM_xmlhttpRequest before Tampermonkey 5.0.0 because of FormData posting issues
if (GM_info.scriptHandler === 'Tampermonkey' && !GM_hasVersion('5.0')) {
const xhr = new XMLHttpRequest();
xhr.open('POST', toAbsoluteURL(form.getAttribute('action')));
xhr.responseType = 'blob';
xhr.onload = async e => finishCallback(await parseDocument(xhr.response));
xhr.onerror = onerror;
xhr.send(data);
} else {
GM_xmlhttpRequest({
method: 'POST',
url: toAbsoluteURL(form.getAttribute('action')),
data,
responseType: 'blob',
onload: async response => finishCallback(await parseDocument(response.response)),
onerror
});
}
}
function onerror() {
displayNotice(CONST.Text[i18n].Error.Unknown);
}
function editInPage(e) {
e.preventDefault();
const _iframes = [...$All(script_favorite, '.script-edit-page')];
if (_iframes.length) {
// Iframe exists, close iframe
favorite_iframe.innerText = CONST.Text[i18n].EditIframe;
_iframes.forEach(ifr => ifr.remove());
} else {
// Iframe not exist, make iframe
favorite_iframe.innerText = CONST.Text[i18n].CloseIframe;
const iframe = $$CrE({
tagName: 'iframe',
props: {
src: favorite_groups.value
},
styles: {
width: '100%',
height: '60vh'
},
classes: ['script-edit-page'],
listeners: [['load', e => {
refresh();
//iframe.style.height = iframe.contentDocument.body.parentElement.offsetHeight + 'px';
}]]
});
script_favorite.appendChild(iframe);
}
}
function displayNotice(text) {
const notice = $CrE('p');
notice.classList.add('notice');
notice.id = 'fav-notice';
notice.innerText = text;
const old_notice = $('#fav-notice');
old_notice && old_notice.parentElement.removeChild(old_notice);
$('#script-content').insertAdjacentElement('afterbegin', notice);
}
}
}
async function getScriptSets() {
const userpage = getUserpage();
const oDom = await getDocument(userpage);
/*
const user_script_sets = oDom.querySelector('#user-script-sets');
const script_sets = [];
for (const li of user_script_sets.querySelectorAll('li')) {
// Get fav info
const name = li.childNodes[0].nodeValue.trimRight();
const link = li.children[0].href;
const linkedit = li.children[1] ? li.children[1].href : 'https://greatest.deepsurf.us/' + $('#language-selector-locale').value + '/users/' + $('#nav-user-info>.user-profile-link>a').href.match(/[a-zA-Z\-]+\/users\/([^\/]*)/)[1] + '/sets/' + li.children[0].href.match(/[\?&]set=(\d+)/)[1] + '/edit';
// Append to script_sets
script_sets.push({
name: name,
link: link,
linkedit: linkedit
});
}
*/
const script_sets = Array.from($(oDom, 'ul#user-script-sets').children).map(li => {
try {
return {
name: li.children[0].innerText,
link: li.children[0].href,
linkedit: li.children[1].href
}
} catch(err) {
DoLog(LogLevel.Error, [li, err, li.children.length, li.children[0]?.innerHTML, li.children[1]?.innerHTML], 'error');
Err(err);
}
});
// Save to GM_storage
GM_setValue('script-sets', {
sets: script_sets,
time: (new Date()).getTime(),
version: '0.2'
});
return script_sets;
}
function getUserpage() {
const a = $('#nav-user-info>.user-profile-link>a');
return a ? a.href : null;
}
async function getInSet(set, sid) {
sid = sid.toString();
const oDom = await getDocument(set.linkedit);
const inSet = [...$(oDom, '#script-set-scripts').children].some(input => input.value === sid);
return inSet;
}
async function getInSets(sets, sid) {
const inSets = await Promise.all(sets.map(set => getInSet(set, sid)));
return sets.filter((set, i) => inSets[i]);
/*
const AM = new AsyncManager();
const inSets = [];
for (const set of sets) {
AM.add();
getInSet(set, sid, inSet => {
inSet && inSets.push(set);
AM.finish();
});
}
AM.onfinish = e => {
callback(inSets);
};
AM.finishEvent = true;
*/
}
function getStrSID(url=location.href) {
const API = getAPI(url);
const strSID = API[2].match(/\d+/)[0];
return strSID;
}
function getSID(url=location.href) {
return Number(getStrSID(url));
}
function GM_hasVersion(version) {
return hasVersion(GM_info?.version || '0', version);
function hasVersion(ver1, ver2) {
return compareVersions(ver1.toString(), ver2.toString()) >= 0;
// https://greatest.deepsurf.us/app/javascript/versioncheck.js
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version/format
function compareVersions(a, b) {
if (a == b) {
return 0;
}
let aParts = a.split('.');
let bParts = b.split('.');
for (let i = 0; i < aParts.length; i++) {
let result = compareVersionPart(aParts[i], bParts[i]);
if (result != 0) {
return result;
}
}
// If all of a's parts are the same as b's parts, but b has additional parts, b is greater.
if (bParts.length > aParts.length) {
return -1;
}
return 0;
}
function compareVersionPart(partA, partB) {
let partAParts = parseVersionPart(partA);
let partBParts = parseVersionPart(partB);
for (let i = 0; i < partAParts.length; i++) {
// "A string-part that exists is always less than a string-part that doesn't exist"
if (partAParts[i].length > 0 && partBParts[i].length == 0) {
return -1;
}
if (partAParts[i].length == 0 && partBParts[i].length > 0) {
return 1;
}
if (partAParts[i] > partBParts[i]) {
return 1;
}
if (partAParts[i] < partBParts[i]) {
return -1;
}
}
return 0;
}
// It goes number, string, number, string. If it doesn't exist, then
// 0 for numbers, empty string for strings.
function parseVersionPart(part) {
if (!part) {
return [0, "", 0, ""];
}
let partParts = /([0-9]*)([^0-9]*)([0-9]*)([^0-9]*)/.exec(part)
return [
partParts[1] ? parseInt(partParts[1]) : 0,
partParts[2],
partParts[3] ? parseInt(partParts[3]) : 0,
partParts[4]
];
}
}
}
// Basic functions
function $APD(a,b) {return a.appendChild(b);}
// Remove all childnodes from an element
function clearChildnodes(element) {
const cns = []
for (const cn of element.childNodes) {
cns.push(cn);
}
for (const cn of cns) {
element.removeChild(cn);
}
}
function getDocumentXHR(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'blob';
xhr.onload = e => {
const htmlblob = xhr.response;
parseDocument(htmlblob).then(resolve).catch(reject);
};
xhr.send();
});
}
// Download and parse a url page into a html document(dom).
// Returns a promise fulfills with dom
function getDocument(url, retry=5) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method : 'GET',
url : url,
responseType : 'blob',
onload : function(response) {
if (response.status === 200) {
const htmlblob = response.response;
parseDocument(htmlblob).then(resolve).catch(reject);
} else {
re(response);
}
},
onerror: err => re(err)
});
function re(err) {
DoLog(`Get document failed, retrying: (${retry}) ${url}`);
--retry > 0 ? getDocument(url, retry).then(resolve).catch(reject) : reject(err);
}
});
}
// Returns a promise fulfills with dom
function parseDocument(htmlblob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
const htmlText = reader.result;
const dom = new DOMParser().parseFromString(htmlText, 'text/html');
resolve(dom);
}
reader.onerror = err => reject(err);
reader.readAsText(htmlblob, document.characterSet);
});
}
// Copy text to clipboard (needs to be called in an user event)
function copyText(text) {
// Create a new textarea for copying
const newInput = document.createElement('textarea');
document.body.appendChild(newInput);
newInput.value = text;
newInput.select();
document.execCommand('copy');
document.body.removeChild(newInput);
}
// get '/' splited API array from a url
function getAPI(url=location.href) {
return url.replace(/https?:\/\/(.*?\.){1,2}.*?\//, '').replace(/\?.*/, '').match(/[^\/]+?(?=(\/|$))/g);
}
// get host part from a url(includes '^https://', '/$')
function getHost(url=location.href) {
const match = location.href.match(/https?:\/\/[^\/]+\//);
return match ? match[0] : match;
}
function toAbsoluteURL(relativeURL, base=`${location.protocol}//${location.host}/`) {
return new URL(relativeURL, base).href;
}
function randint(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
})();