Created
March 18, 2017 00:47
-
-
Save Tiny-Giant/93276060326770b61b4efa7f9675c2f3 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name CV Request Generator | |
// @namespace https://github.com/SO-Close-Vote-Reviewers/ | |
// @version 2.0.0.2 | |
// @description This script generates formatted close vote requests and sends them to a specified chat room | |
// @author @TinyGiant | |
// @include /^https?:\/\/(?!chat)\w*.?(stackexchange.com|stackoverflow.com|serverfault.com|superuser.com|askubuntu.com|stackapps.com|mathoverflow.net)\/.*/ | |
// @updateURL https://github.com/Tiny-Giant/myuserscripts/raw/master/CVRequestGenerator.user.js | |
// @grant GM_xmlhttpRequest | |
// @grant unsafeWindow | |
// ==/UserScript== | |
/* jshint -W097 */ | |
/* jshint esnext: true */ | |
/* globals GM_info, unsafeWindow, GM_xmlhttpRequest, console */ | |
'use strict'; | |
const scriptName = GM_info.script.name.replace(/\s+/g, ''); | |
const scriptVersion = GM_info.script.version; | |
const scriptURL = GM_info.script.updateURL; | |
const scriptVersionURL = 'https://github.com/Tiny-Giant/myuserscripts/raw/master/CVRequestGenerator.version'; | |
const template = ` | |
<a href="#" class="request-gui-toggle">request</a> | |
<div class="request-gui-dialog" style="display: none"> | |
<form class="request-gui-form"><!-- | |
--><div class="request-gui-type-select"></div><!-- | |
--><div class="request-gui-tag-select"></div><!-- | |
--><input type="text" class="request-gui-reason" placeholder="Enter reason..."><!-- | |
--><input type="submit" class="request-gui-submit" value="Send Request"><!-- | |
--></form> | |
<a href="#" class="request-gui-update">Check for updates</a> | |
</div> | |
`; | |
document.body.appendChild(Object.assign(document.createElement('style'), { textContent: ` | |
.request-gui-toggle { | |
padding:0 3px 2px 3px; | |
color:#888; | |
} | |
.request-gui-toggle:hover { | |
color:#444; | |
text-decoration:none; | |
} | |
.request-gui-wrapper { | |
position:relative; | |
display:inline-block; | |
} | |
.request-gui-wrapper * { | |
box-sizing: border-box; | |
} | |
.request-gui-dialog { | |
z-index:1; | |
position:absolute; | |
white-space:nowrap; | |
border:1px solid #ccc; | |
border-radius: 5px; | |
background:#FFF; | |
box-shadow:0px 5px 10px -5px rgb(0,0,0,0.5); | |
} | |
.request-gui-form { | |
padding: 6px; | |
} | |
.request-gui-form input { | |
display: inline-block; | |
font-size: 13px; | |
line-height: 15px; | |
padding: 8px 10px; | |
box-sizing: border-box; | |
border-radius: 0; | |
margin: 0px; | |
} | |
.request-gui-reason { | |
border-top-left-radius: 3px; | |
border-bottom-left-radius: 3px; | |
height: 32px; | |
} | |
.request-gui-submit { | |
border-top-right-radius: 3px; | |
border-bottom-right-radius: 3px; | |
height: 32px; | |
} | |
.request-gui-update { | |
border-top: 1px solid #ccc; | |
display: block; | |
padding: 10px; | |
text-align: center; | |
} | |
.request-gui-update:hover { | |
background: #eee; | |
border-bottom-left-radius: 5px; | |
border-bottom-right-radius: 5px; | |
} | |
.request-gui-dialog * { | |
vertical-align: middle; | |
} | |
.request-gui-type-select, .request-gui-tag-select { | |
display: inline-block; | |
vertical-align: middle; | |
height: 32px; | |
overflow: hidden; | |
} | |
.request-gui-type-select a, .request-gui-tag-select a { | |
display: block; | |
font-size: 13px; | |
line-height: 20px; | |
margin: 0px; | |
} | |
.request-gui-label { | |
padding-right: 30px; | |
} | |
`})); | |
let StackExchange = window.StackExchange; | |
if (typeof StackExchange === 'undefined') { | |
StackExchange = unsafeWindow.StackExchange; | |
} | |
const notify = (() => { | |
let count = 0; | |
return (message, time) => { | |
StackExchange.notify.show(message, ++count); | |
if (!isNaN(+time)) setTimeout(() => StackExchange.notify.close(count), time); | |
}; | |
})(); | |
const storage = new Proxy(localStorage, { | |
get: (target, key) => target[scriptName + '_' + key], | |
set: (target, key, val) => target[scriptName + '_' + key] = val | |
}); | |
const addXHRListener = callback => { | |
const open = XMLHttpRequest.prototype.open; | |
XMLHttpRequest.prototype.open = function(...args) { | |
this.addEventListener('load', event => callback(event), false); | |
open.apply(this, args); | |
}; | |
}; | |
class RequestGeneratorError extends Error { | |
constructor(...args) { | |
super(...args); | |
notify('An error occured, check the console for more information'); | |
} | |
} | |
class RequestGenerator { | |
constructor() { | |
this.debugging = false; | |
this.room = { | |
host: "https://chat.stackoverflow.com", | |
id: this.debugging ? 68414 : 41570 | |
}; | |
this.base = window.location.protocol + '//' + window.location.host; | |
} | |
async update(force) { | |
const result = await new Promise((resolve, reject) => { | |
const check = (proposed, current) => { | |
while(proposed.length < current.length) proposed.push("0"); | |
while(proposed.length > current.length) current.push("0"); | |
for(let i = 0; i < proposed.length; i++) { | |
if (+proposed[i] > +current[i]) { | |
return true; | |
} | |
if (+proposed[i] < +current[i]) { | |
return false; | |
} | |
} | |
return false; | |
}; | |
const load = xhr => { | |
const proposed = xhr.responseText.trim(); | |
const message = 'A new version of the Request Generator is available, would you like to install it now?'; | |
if (check(proposed.split("."), scriptVersion.split("."))) { | |
if ((storage.LastAcknowledgedVersion !== (storage.LastAcknowledgedVersion = proposed) || force) && window.confirm(message)) { | |
window.location.href = scriptURL; | |
return resolve('Updated'); | |
} | |
} else if (force) { | |
return resolve('No new version available'); | |
} | |
resolve(xhr); | |
}; | |
const error = xhr => { | |
throw new RequestGeneratorError('Failed querying new script version.', reject, xhr); | |
}; | |
const init = { | |
method: 'GET', | |
url: scriptVersionURL, | |
onload: load, | |
onerror: error | |
}; | |
GM_xmlhttpRequest(init); | |
}); | |
return result; | |
} | |
async send(request) { | |
const self = this; | |
const result = await new Promise(async resolve => { | |
const fkey = await RequestGenerator.fkey; | |
const load = xhr => { | |
if (xhr.status !== 200 || !JSON.parse(xhr.responseText).id) { | |
throw new RequestGeneratorError('Failed sending request'); | |
} | |
resolve(xhr); | |
}; | |
const error = xhr => { | |
throw new RequestGeneratorError('Failed sending request.'); | |
}; | |
const init = { | |
method: 'POST', | |
url: `${ self.room.host }/chats/${ self.room.id }/messages/new`, | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
data: `text=${ encodeURIComponent(request) }&fkey=${ fkey }`, | |
onload: load, | |
onerror: error | |
}; | |
GM_xmlhttpRequest(init); | |
}); | |
return result; | |
} | |
get fkey() { | |
const self = this; | |
return new Promise((resolve, reject) => { | |
const extract = HTML => (Object.assign(document.createElement('div'), { innerHTML: HTML }).querySelector('#fkey') || {}).value; | |
const load = xhr => { | |
if (xhr.status !== 200) { | |
throw new RequestGeneratorError('Failed retrieving key', reject, xhr); | |
} | |
let fkey = extract(xhr.responseText); | |
if (!fkey) { | |
throw new RequestGeneratorError('Failed retrieving key.', reject, xhr); | |
} | |
resolve(fkey); | |
}; | |
const error = xhr => { | |
throw new RequestGeneratorError('Failed retrieving key.', reject, xhr); | |
}; | |
const init = { | |
method: 'GET', | |
url: `${ self.room.host }/rooms/${ self.room.id }`, | |
onload: load, | |
onerror: error | |
}; | |
GM_xmlhttpRequest(init); | |
}); | |
} | |
} | |
class RequestGeneratorPost { | |
constructor(scope) { | |
this.QUESTION = 1; | |
this.ANSWER = 2; | |
if (!(scope instanceof HTMLElement)) { | |
throw new RequestGeneratorError('Element passed is not an element.'); | |
} | |
const type = (scope.dataset.questionid && QUESTION) || (scope.dataset.answerid && ANSWER) || undefined; | |
if (typeof type === undefined) { | |
throw new RequestGeneratorError('Element passed is not a post.'); | |
} | |
Object.assign(this, { | |
type: type, | |
scope: scope, | |
id: scope.dataset[`${ ['question', 'answer'][type - 1] }id`], | |
title: ((document.querySelector(`a[href*="questions/${ this.id }"]`) || {}).textContent || 'Title not found').replace(/\[(.*)\]/g, '($1)'), | |
url: `${ location.protocol }//${ location.host }/q/${ this.id }`, | |
time: (scope.querySelector('.post-signature:not([align="right"]) .relativetime') || {}).title || '', | |
author: { | |
name: ((scope.querySelector('.post-signature:not([align="right"]) .user-details') || {}).textContent || 'Author not found').trim().split('\n')[0].trim(), | |
url: (scope.querySelector('.owner a') || {}).href | |
}; | |
tags: [...scope.querySelectorAll('.post-taglist .post-tag')].map(e => e.textContent) | |
}); | |
} | |
} | |
class RequestGeneratorGUI extends RequestGenerator { | |
constructor(scope) { | |
super(); | |
const self = this; | |
let selectedTag, selectedType; | |
const post = new RequestGeneratorPost(scope); | |
const nodes = new Proxy({ | |
scope: scope, | |
menu: scope.querySelector('.post-menu'), | |
wrapper: Object.assign(document.createElement('span'), { | |
className: 'request-gui-wrapper', | |
innerHTML: template | |
}) | |
}, { | |
get: (target, key) => { | |
if (!(key in target)) { | |
target[key] = nodes.wrapper.querySelector(`.request-gui-${ key.replace(/[A-Z]/g, match => '-' + match.toLowerCase()) }`); | |
} | |
return target[key]; | |
}, | |
has: (target, key) => { | |
if (!(key in target)) { | |
target[key] = nodes.wrapper.querySelector(`.request-gui-${ key.replace(/[A-Z]/g, match => '-' + match.toLowerCase()) }`); | |
} | |
return !!target[key]; | |
}, | |
});; | |
Object.assign(nodes.dialog, { | |
hide: () => { | |
nodes.dialog.style.display = 'none'; | |
}, | |
show: () => { | |
nodes.dialog.style.display = ''; | |
}, | |
toggle: () => { | |
nodes.dialog[['hide', 'show'][+!!nodes.dialog.style.display]](); | |
} | |
}); | |
nodes.tagSelect.insertAdjacentHTML('beforeend', question.tags.reduce((m, e) => m + `<a href="#" class="post-tag">${ e }</a>`, '')); | |
if (typeof nodes.menu !== "undefined") { | |
nodes.menu.appendChild(nodes.wrapper); | |
} | |
const listeners = { | |
wrapper: { | |
click: event => event.stopPropagation() | |
}, | |
toggle: { | |
click: event => { | |
nodes.dialog.toggle(); | |
event.preventDefault(); | |
} | |
}, | |
form: { | |
submit: event => { | |
event.preventDefault(); | |
let reason = nodes.reason.value; | |
if (reason === '') { | |
throw new RequestGeneratorError('No reason supplied'); | |
} | |
send(reason); | |
} | |
}, | |
update: { | |
click: event => { | |
event.preventDefault(); | |
funcs.update(true); | |
} | |
}, | |
typeSelect: (function() { | |
let open = false; | |
return { | |
click: event => { | |
event.preventDefault(); | |
nodes.typeSelect.style.overflow = open ? '' : 'visible'; | |
if (open) { | |
nodes.typeSelect.scrollTop = event.target.offsetTop - 6; | |
selectedType = event.target.textContent; | |
} | |
open = !open; | |
} | |
}; | |
})(), | |
tagSelect: (function() { | |
let open = false; | |
return { | |
click: event => { | |
event.preventDefault(); | |
nodes.tagSelect.style.overflow = open ? '' : 'visible'; | |
if (open) { | |
nodes.tagSelect.scrollTop = event.target.offsetTop - 6; | |
selectedTag = event.target.textContent; | |
} | |
open = !open; | |
} | |
}; | |
})() | |
}; | |
for (let node in listeners) { | |
if (node in listeners && node in nodes) { | |
for (let type in listeners[node]) { | |
if(type in listeners[node]) { | |
nodes[node].addEventListener(type, listeners[node][type], false); | |
} | |
} | |
} | |
} | |
document.addEventListener('click', event => !nodes.dialog.style.display && nodes.dialog.hide(), false); | |
Object.assign(this, { | |
post: post, | |
question: question, | |
nodes: nodes, | |
listeners: listeners | |
}); | |
}, | |
set(reason) { | |
const post = this.post; | |
const title = post.url ? `[${ post.title }](${ post.url })` : post.title; | |
const user = post.user.url ? `[${ post.user.name }](${ post.user.url })` : post.user.name; | |
const time = post.time ? ` - ${ time }` : ''; | |
const before = `[tag:cv-pls] [tag:${ selectedTag || question.tags[0] }] `; | |
const after = ` ${ title } - ${ user }${time}`; | |
const remaining = 500 - (before.length + after.length); | |
if (reason.length > remaining) { | |
reason = `${ reason.substr(0, remaining - 3).trim() }...`; | |
} | |
this.reason = before + reason + after; | |
}, | |
async send() { | |
super.send(); | |
this.nodes.dialog.hide(); | |
StackExchange.helpers.showInfoMessage(this.nodes.wrapper, '<span style="white-space: nowrap">Your request has been sent.</span>'); | |
} | |
} | |
let questions = Array.from(document.querySelectorAll('.question')); | |
let RequestGUIs = {}; | |
for(let question of questions) { | |
let gui = new RequestGUI(question); | |
if (typeof gui !== 'undefined') { | |
RequestGUIs[gui.question.id] = gui; | |
} | |
} | |
funcs.addXHRListener(event => { | |
if (/ajax-load-realtime|review.next\-task|review.task\-reviewed/.test(event.target.responseURL)) { | |
var matches = /data-questionid=\\?"(\d+)/.exec(event.target.responseText); | |
if (matches === null) { | |
return; | |
} | |
let question = document.querySelector('[data-questionid="' + matches[1] + '"]'); | |
if (question === null) { | |
return; | |
} | |
let gui = new RequestGUI(question); | |
RequestGUIs[gui.question.id] = gui; | |
} | |
}); | |
(() => { | |
let nodes = {}; | |
const HTML = ` | |
<label class="request-gui-label"> | |
<input type="checkbox" class="request-gui-checkbox"> | |
Send request | |
</label> | |
`; | |
let reasons = { | |
101: "Duplicate", | |
102: { | |
2: "Belongs on another site", | |
3: "Custom reason", | |
4: "General computing hardware / software", | |
7: "Professional server / networking administration", | |
11: "Typographical error or cannot reproduce", | |
13: "Debugging / no MCVE", | |
16: "Request for off-site resource", | |
}, | |
103: "Unclear what you're asking", | |
104: "Too broad", | |
105: "Primarily opinion-based" | |
}; | |
funcs.addXHRListener(event => { | |
if (/close\/popup/.test(event.target.responseURL)) { | |
nodes.popup = document.querySelector('#popup-close-question'); | |
if (nodes.popup === null) { | |
return; | |
} | |
nodes.votes = nodes.popup.querySelector('.remaining-votes'); | |
if (nodes.votes === null) { | |
return; | |
} | |
nodes.votes.insertAdjacentHTML('beforebegin', HTML); | |
Object.assign(nodes, { | |
checkbox: nodes.popup.querySelector('.request-gui-checkbox'), | |
textare: nodes.popup.querySelector('textarea'), | |
submit: nodes.popup.querySelector('.popup-submit') | |
}); | |
} | |
}); | |
funcs.addXHRListener(event => { | |
if (/close\/add/.test(event.target.responseURL)) { | |
let questionid = /\d+/.exec(event.target.responseURL); | |
if (questionid === null) { | |
return; | |
} | |
questionid = questionid[0]; | |
if (!(questionid in RequestGUIs)) { | |
return; | |
} | |
let gui = RequestGUIs[questionid]; | |
if (nodes.textarea instanceof HTMLElement) { | |
reasons[102][3] = nodes.textarea.value; | |
} | |
let data = JSON.parse(event.target.responseText); | |
let reason = reasons[data.CloseReason]; | |
if (typeof data.CloseAsOffTopicReasonId !== 'undefined') { | |
reason = reason[data.CloseAsOffTopicReasonId]; | |
} | |
if (/tools\/new-answers-old-questions/.test(window.location.href)) { | |
reason += ' (new activity)'; | |
} | |
gui.nodes.reason.value = reason; | |
if (nodes.checkbox.checked) { | |
gui.send(reason); | |
} | |
} | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment