// ==UserScript== // @name GreasyFork User Dashboard // @name:ja GreasyFork User Dashboard // @namespace knoa.jp // @description It redesigns your own user page. // @description:ja 自分用の新しいユーザーページを提供します。 // @include https://greasyfork.org/*/users/* // @version 1.1.0 // @grant none // @downloadURL none // ==/UserScript== (function(){ const SCRIPTNAME = 'GreasyForkUserDashboard'; const DEBUG = false;/* 1.1.0 many updates and fixes. [bug] [to do] [possible] 3カラムのレイアウト崩れる(スクリプト未使用でも発生する) */ if(window === top && console.time) console.time(SCRIPTNAME); const INTERVAL = 1000;/* for fetch */ const DRAWINGDELAY = 125;/* for drawing each charts */ const UPDATELINKTEXT = '+';/* for update link text */ const DEFAULTMAX = 10;/* for chart scale */ const DAYS = 180;/* for chart length */ const STATSUPDATE = 1000*60*60;/* stats update interval of greasyfork.org */ const TRANSLATIONEXPIRE = 1000*60*60*24*30;/* cache time for translations */ let site = { targets: { userSection: () => $('body > header + div > section:nth-of-type(1)'), controlPanel: () => $('#control-panel'), newScriptSetLink: () => $('a[href$="/sets/new"]'), scriptSets: () => $('body > header + div > section:nth-of-type(2)'), scripts: () => $('body > header + div > section:nth-of-type(2) + div'), userScriptSets: () => $('#user-script-sets'), userScriptList: () => $('#user-script-list'), }, get: { language: (d) => d.documentElement.lang, firstScript: (list) => list.querySelector('li h2 > a'), translation: (d, t) => { t.info = d.querySelector('#script-links > li.current').textContent || t.info; t.code = d.querySelector('#script-links > li > a[href$="/code"]').textContent || t.code; t.history = d.querySelector('#script-links > li > a[href$="/versions"]').textContent || t.history; t.feedback = d.querySelector('#script-links > li > a[href$="/feedback"]').textContent.replace(/\s\(\d+\)/, '') || t.feedback; t.stats = d.querySelector('#script-links > li > a[href$="/stats"]').textContent || t.stats; t.derivatives = d.querySelector('#script-links > li > a[href$="/derivatives"]').textContent || t.derivatives; t.update = d.querySelector('#script-links > li > a[href$="/versions/new"]').textContent || t.update; t.delete = d.querySelector('#script-links > li > a[href$="/delete"]').textContent || t.delete; t.admin = d.querySelector('#script-links > li > a[href$="/admin"]').textContent || t.admin; t.version = d.querySelector('#script-stats > dt.script-show-version').textContent || t.version; return t; }, translationOnStats: (d, t) => { t.installs = d.querySelector('table.stats-table > thead > tr > th:nth-child(2)').textContent || t.installs; t.updateChecks = d.querySelector('table.stats-table > thead > tr > th:nth-child(3)').textContent || t.updateChecks; return t; }, props: (li) => {return { name: li.querySelector('h2 > a'), description: li.querySelector('.description'), stats: li.querySelector('dl.inline-script-stats'), dailyInstalls: li.querySelector('dd.script-list-daily-installs'), totalInstalls: li.querySelector('dd.script-list-total-installs'), ratings: li.querySelector('dd.script-list-ratings'), createdDate: li.querySelector('dd.script-list-created-date'), updatedDate: li.querySelector('dd.script-list-updated-date'), scriptVersion: li.dataset.scriptVersion, }}, scriptUrl: (li) => li.querySelector('h2 > a').href, } }; const DEFAULTTRANSLATION = { info: 'Info', code: 'Code', history: 'History', feedback: 'Feedback', stats: 'Stats', derivatives: 'Derivatives', update: 'Update', delete: 'Delete', admin: 'Admin', version: 'Version', installs: 'Installs', updateChecks: 'Update checks', }; let translations = {}, translation = {}; let elements = {}, shown = {}; let core = { initialize: function(){ core.getElements(); if(elements.length < site.targets.length) return log('Not user own page.'); core.addStyle(); core.prepareTranslations(); core.hideUserSection(); core.hideControlPanel(); core.addTabNavigation(); core.addNewScriptSetLink(); core.rebuildScriptList(); core.addChartSwitcher(); }, getElements: function(){ if(!site.targets.controlPanel()) return;/* not my own page */ for(let i = 0, keys = Object.keys(site.targets); keys[i]; i++){ let element = site.targets[keys[i]](); if(!element) return log(`Not found: ${keys[i]}`); element.dataset.selector = keys[i]; elements[keys[i]] = element; } shown = Storage.read('shown') || shown; }, prepareTranslations: function(){ let language = site.get.language(document); translations = Storage.read('translations') || {}; translation = translations[language] || DEFAULTTRANSLATION; if(!Object.keys(DEFAULTTRANSLATION).every((key) => translation[key])){/* some change in translation keys */ Object.keys(DEFAULTTRANSLATION).forEach((key) => translation[key] = translation[key] || DEFAULTTRANSLATION[key]); core.getTranslations(); }else{ if(site.get.language(document) === 'en') return; if(Date.now() < (Storage.saved('translations') || 0) + TRANSLATIONEXPIRE) return; core.getTranslations(); } }, getTranslations: function(){ let firstScript = site.get.firstScript(elements.userScriptList); fetch(firstScript.href, {credentials: 'include'}) .then(response => response.text()) .then(text => new DOMParser().parseFromString(text, 'text/html')) .then(d => translation = translations[site.get.language(d)] = site.get.translation(d, translation)) .then(() => wait(INTERVAL)) .then(() => fetch(firstScript.href + '/stats')) .then(response => response.text()) .then(text => new DOMParser().parseFromString(text, 'text/html')) .then(d => { translation = translations[site.get.language(d)] = site.get.translationOnStats(d, translation); Storage.save('translations', translations); }); }, hideUserSection: function(){ let userSection = elements.userSection, more = createElement(core.html.more()); if(!shown.userSection) userSection.classList.add('hidden'); more.addEventListener('click', function(e){ userSection.classList.toggle('hidden'); shown.userSection = !userSection.classList.contains('hidden'); Storage.save('shown', shown); }); userSection.appendChild(more); }, hideControlPanel: function(){ let controlPanel = elements.controlPanel, header = controlPanel.firstElementChild; if(!shown.controlPanel) controlPanel.classList.add('hidden'); setTimeout(function(){elements.userSection.style.minHeight = controlPanel.offsetHeight + controlPanel.offsetTop + 'px'}, 250);/* needs delay */ header.addEventListener('click', function(e){ controlPanel.classList.toggle('hidden'); shown.controlPanel = !controlPanel.classList.contains('hidden'); Storage.save('shown', shown); elements.userSection.style.minHeight = controlPanel.offsetHeight + controlPanel.offsetTop + 'px'; }); }, addTabNavigation: function(){ const keys = [ {label: elements.scriptSets.querySelector('header').textContent, selector: 'scriptSets', list: elements.userScriptSets}, {label: elements.scripts.querySelector('header').textContent, selector: 'scripts', list: elements.userScriptList, selected: true}, ]; let nav = createElement(core.html.tabNavigation()), scriptSets = elements.scriptSets; let template = nav.querySelector('li.template'); scriptSets.parentNode.insertBefore(nav, scriptSets); for(let i = 0; keys[i]; i++){ let li = template.cloneNode(true); li.classList.remove('template'); li.textContent = keys[i].label + ` (${keys[i].list.children.length})`; li.dataset.target = keys[i].selector; li.addEventListener('click', function(e){ li.parentNode.querySelector('[data-selected="true"]').dataset.selected = 'false'; $('[data-tabified][data-selected="true"]').dataset.selected = 'false'; li.dataset.selected = 'true'; $(`[data-selector="${li.dataset.target}"]`).dataset.selected = 'true'; }); let target = elements[keys[i].selector]; target.dataset.tabified = 'true'; if(keys[i].selected) li.dataset.selected = target.dataset.selected = 'true'; else li.dataset.selected = target.dataset.selected = 'false'; template.parentNode.insertBefore(li, template); } }, addNewScriptSetLink: function(){ let link = elements.newScriptSetLink.cloneNode(true), list = elements.userScriptSets, li = document.createElement('li'); li.appendChild(link); list.appendChild(li); }, rebuildScriptList: function(){ for(let i = 0, list = elements.userScriptList, li; li = list.children[i]; i++){ let more = createElement(core.html.more()), props = site.get.props(li); if(!shown[li.dataset.scriptName]) li.classList.add('hidden'); more.addEventListener('click', function(e){ li.classList.toggle('hidden'); shown[li.dataset.scriptName] = !li.classList.contains('hidden'); Storage.save('shown', shown); }); li.dataset.scriptUrl = props.name.href; li.appendChild(more); /* attatch titles */ props.dailyInstalls.previousElementSibling.title = props.dailyInstalls.previousElementSibling.textContent; props.totalInstalls.previousElementSibling.title = props.totalInstalls.previousElementSibling.textContent; props.ratings.previousElementSibling.title = props.ratings.previousElementSibling.textContent; props.createdDate.previousElementSibling.title = props.createdDate.previousElementSibling.textContent; props.updatedDate.previousElementSibling.title = props.updatedDate.previousElementSibling.textContent; /* wrap the description to make it an inline element */ let span = document.createElement('span'); span.textContent = props.description.textContent.trim(); props.description.replaceChild(span, props.description.firstChild); /* Link to Code and Update from Version */ let versionLabel = createElement(core.html.dt('script-list-version', translation.version)); let versionDd = createElement(core.html.ddLink('script-list-version', props.scriptVersion, props.name.href + '/code', translation.code)); let updateLink = document.createElement('a'); versionLabel.title = versionLabel.textContent; updateLink.href = props.name.href + '/versions/new'; updateLink.textContent = UPDATELINKTEXT; updateLink.title = translation.update; updateLink.classList.add('update'); versionDd.appendChild(updateLink); props.stats.insertBefore(versionLabel, props.createdDate.previousElementSibling); props.stats.insertBefore(versionDd, props.createdDate.previousElementSibling); /* Link to Stats from Total installs */ let statsDd = createElement(core.html.ddLink('script-list-total-installs', props.totalInstalls.textContent, props.name.href + '/stats', translation.stats)); props.stats.replaceChild(statsDd, props.totalInstalls); /* Link to History from Updated date */ let historyDd = createElement(core.html.ddLink('script-list-updated-date', props.updatedDate.textContent, props.name.href + '/versions', translation.history)); props.stats.replaceChild(historyDd, props.updatedDate); } }, addChartSwitcher: function(){ const keys = [ {label: translation.installs, selector: 'installs'}, {label: translation.updateChecks, selector: 'updateChecks'}, ]; let chartKey = Storage.read('chartKey') || 'updateChecks'; let nav = createElement(core.html.chartSwitcher()), userScriptList = elements.userScriptList; let template = nav.querySelector('li.template'); userScriptList.parentNode.appendChild(nav);/* less affected on dom */ for(let i = 0; keys[i]; i++){ let li = template.cloneNode(true); li.classList.remove('template'); li.textContent = keys[i].label; li.dataset.key = keys[i].selector; li.addEventListener('click', function(e){ li.parentNode.querySelector('[data-selected="true"]').dataset.selected = 'false'; li.dataset.selected = 'true'; core.drawCharts(li.dataset.key); Storage.save('chartKey', li.dataset.key); }); if(keys[i].selector === chartKey) li.dataset.selected = 'true'; else li.dataset.selected = 'false'; template.parentNode.insertBefore(li, template); } core.drawCharts(chartKey); }, drawCharts: function(chartKey = 'updateChecks'){ let stats = Storage.read('stats') || {}, promises = []; for(let i = 0, list = elements.userScriptList, li; li = list.children[i]; i++){ /* Draw chart of daily update checks */ let chart = li.querySelector('.chart') || createElement(core.html.chart()); if(stats[li.dataset.scriptName]){ setTimeout(function(){ core.drawChart(chartKey, chart, stats[li.dataset.scriptName].slice(-DAYS)); if(!chart.isConnected) li.appendChild(chart); }, i * DRAWINGDELAY);/* CPU friendly */ } let saved = Storage.saved('stats') || 0, past = saved % STATSUPDATE, expire = saved - past + STATSUPDATE; if(Date.now() < expire) continue;/* still up-to-date */ promises.push(new Promise(function(resolve, reject){ setTimeout(function(){ fetch(li.dataset.scriptUrl + '/stats.csv')/* less file size than json */ .then(response => response.text()) .then(csv => { let lines = csv.split('\n'); lines = lines.slice(1, -1);/* cut the labels + blank line */ stats[li.dataset.scriptName] = []; for(let i = 0; lines[i]; i++){ let p = lines[i].split(','); stats[li.dataset.scriptName][i] = { date: p[0], installs: parseInt(p[1]), updateChecks: parseInt(p[2]), }; } core.drawChart(chartKey, chart, stats[li.dataset.scriptName].slice(-DAYS)); if(!chart.isConnected) li.appendChild(chart); resolve(); }); }, i * INTERVAL);/* server friendly */ })); } if(promises.length) Promise.all(promises).then((values) => Storage.save('stats', stats)); }, drawChart: function(chartKey, chart, stats){ let max = DEFAULTMAX; for(let i = 0; stats[i]; i++){ if(stats[i][chartKey] > max) max = stats[i][chartKey]; } let dl = chart.querySelector('dl'), dt = dl.querySelector('dt'), dd = dl.querySelector('dd'); for(let i = 0, last = stats.length - 1; stats[i]; i++){ let date = stats[i].date, count = stats[i][chartKey]; let dateDt = dl.querySelector(`dt[data-date="${date}"]`) || dt.cloneNode(); let countDd = dateDt.nextElementSibling || dd.cloneNode(); if(!dateDt.isConnected){ dateDt.classList.remove('template'); countDd.classList.remove('template'); dateDt.dataset.date = dateDt.textContent = date; dl.insertBefore(dateDt, dt); dl.insertBefore(countDd, dt); } countDd.title = date + ': ' + count; countDd.dataset.count = count; dd.style.height = '0%'; if(i === last - 1){ let label = countDd.querySelector('span') || document.createElement('span'); label.textContent = toMetric(count); if(!label.isConnected) countDd.appendChild(label); } } /* for animation */ animate(function(){ for(let i = 0, dds = dl.querySelectorAll('dd.count:not(.template)'), dd; dd = dds[i]; i++){ dd.style.height = ((dd.dataset.count / max) * 100) + '%'; } }); }, addStyle: function(name = 'style'){ let style = createElement(core.html[name]()); document.head.appendChild(style); if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]); elements[name] = style; }, html: { more: () => ` `, tabNavigation: () => ` `, chartSwitcher: () => ` `, dt: (className, textContent) => `