Skip to content

Instantly share code, notes, and snippets.

@Tiny-Giant
Created March 18, 2017 00:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Tiny-Giant/93276060326770b61b4efa7f9675c2f3 to your computer and use it in GitHub Desktop.
Save Tiny-Giant/93276060326770b61b4efa7f9675c2f3 to your computer and use it in GitHub Desktop.
// ==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