Skip to content

Instantly share code, notes, and snippets.

@an-electric-sheep
Last active January 12, 2018 23:26
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save an-electric-sheep/ce0cd642b2bff8508f39931e902588b1 to your computer and use it in GitHub Desktop.
Save an-electric-sheep/ce0cd642b2bff8508f39931e902588b1 to your computer and use it in GitHub Desktop.
meguca multi-upload userscript - click RAW to install
// ==UserScript==
// @name meguca multi-upload
// @downloadURL https://gist.github.com/an-electric-sheep/ce0cd642b2bff8508f39931e902588b1/raw/meguca.user.js
// @namespace https://github.com/an-electric-sheep
// @description drag&drop 2 or more files onto meguca to auto-dump
// @include *://meguca.org/*
// @version 2.13
// @grant none
// @run-at document-start
// @noframes
// ==/UserScript==
"use strict";
const ffSandbox = !!(window.wrappedJSObject && window.wrappedJSObject !== window);
const untrustedWindow = window.wrappedJSObject || window;
const xrayClone = (typeof cloneInto === 'function') ? cloneInto : null;
const queue = [];
const skip = Symbol("skip")
const style = `
.replylinkwrapper {
display: flex; flex-direction:row;
}
.multiupload {z-index: 1;}
.multiupload::before {content: '[';}
.multiupload::after {content: ']';}
.multifilepanel[hidden] {
display:none;
}
.multifilepanel {
position: fixed;
right: 5px;
}
#upload-queue {
overflow-y: auto;
max-height: 70vh;
}
#upload-queue:empty, .multifilepanel:not(.has-items) .queue-controls, .multifilepanel:not(.has-items) #start-dump {
display:none;
}
.queue-controls {
float: right;
}
.queue-controls > a {
margin-right: 0.5em;
}
.multifilepanel svg {
fill: currentColor;
width: 1em;height: 1em;
cursor: pointer;
}
#dump-delay {width: 4em;}
`;
// https://useiconic.com/open/
const icons = {
del: `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M3 0c-.55 0-1 .45-1 1h-1c-.55 0-1 .45-1 1h7c0-.55-.45-1-1-1h-1c0-.55-.45-1-1-1h-1zm-2 3v4.81c0 .11.08.19.19.19h4.63c.11 0 .19-.08.19-.19v-4.81h-1v3.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-3.5h-1v3.5c0 .28-.22.5-.5.5s-.5-.22-.5-.5v-3.5h-1z" />
</svg>`,
dedup: `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M0 0v4h4v-4h-4zm5 2v3h-3v1h4v-4h-1zm2 2v3h-3v1h4v-4h-1z" />
</svg>`,
sort: `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M2 0v6h-2l2.5 2 2.5-2h-2v-6h-1zm2 0v1h2v-1h-2zm0 2v1h3v-1h-3zm0 2v1h4v-1h-4z" />
</svg>`,
random: `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
<path d="M6 0v1h-.5c-.35 0-.56.1-.78.38l-1.41 1.78-1.53-1.78c-.22-.26-.44-.38-.78-.38h-1v1h1c-.05 0 .01.04.03.03l1.63 1.91-1.66 2.06h-1v1h1c.35 0 .56-.1.78-.38l1.53-1.91 1.66 1.91c.22.26.44.38.78.38h.25v1l2-1.5-2-1.5v1h-.22c-.01-.01-.05-.04-.06-.03l-1.75-2.06 1.53-1.91h.5v1l2-1.5-2-1.5z" />
</svg>`
};
const panel = `<aside class="multifilepanel" hidden>
Multi-Upload
<div class="queue-controls">
<a id="remove-dupes" title="Remove dupes from current thread">${icons.dedup}</a>
<a id="name-sort" title="Sort by filename">${icons.sort}</a>
<a id="shuffle-queue" title="Shuffle">${icons.random}</a>
<a id="clear-queue" title="Clear Queue">${icons.del}</a>
<input id="dump-delay" type=number min=0 value=31 title="delay between posts in seconds"><label for="dump-delay">s</label>
</div>
<ol id="upload-queue"></ol>
<input id="multi-dump" type="file" multiple>
<button id="start-dump">Start Dump</button>
</aside>`;
document.addEventListener("DOMContentLoaded", init)
let currentThread = null;
function init() {
let stel = document.createElement("style");
stel.textContent = style;
document.head.append(stel)
const obs = new MutationObserver(() => {
let newThread = document.querySelector("#thread-container");
if(newThread !== currentThread) {
currentThread = newThread;
pageNav();
}
});
obs.observe(document.querySelector("#threads"), {childList: true});
pageNav()
}
function pageNav() {
currentThread = document.querySelector("#thread-container");
if(!currentThread)
return;
currentThread.insertAdjacentHTML("beforebegin", panel)
document.querySelector("#remove-dupes").addEventListener("click", removeDupes)
document.querySelector("#shuffle-queue").addEventListener("click", () => {
for(let i=0; i<queue.length; i++) {
const idx = (Math.random()*queue.length)|0;
const tmp = queue[i];
queue[i] = queue[idx];
queue[idx] = tmp;
}
displayQueue();
});
document.querySelector("#start-dump").addEventListener("click", processQueue)
document.querySelector("#clear-queue").addEventListener("click", () => {
queue.length = 0
displayQueue()
})
document.querySelector("#name-sort").addEventListener("click", () => {
queue.sort((a, b) => a.name.localeCompare(b.name, "en", {numeric: true}))
displayQueue()
})
document.querySelector("#multi-dump").addEventListener("change", (e) => {
let el = e.target;
queue.push(...el.files)
el.value = ""
displayQueue()
})
let replyLink = document.querySelector("aside.posting")
let wrapper = document.createElement("div")
wrapper.classList.add("replylinkwrapper")
replyLink.replaceWith(wrapper)
wrapper.append(replyLink)
wrapper.insertAdjacentHTML('beforeend', `<aside class="act glass multiupload"><a>Multi File Upload</a></aside>`)
wrapper.querySelector(".multiupload a").addEventListener("click", () => {
let panel = document.querySelector(".multifilepanel")
panel.hidden = false
panel.querySelector("input[type=file]").click()
})
if(queue.length > 0)
displayQueue()
}
function removeDupes() {
let url = new URL(window.location)
url.pathname = "/json/boards" + url.pathname
let threadinfo = fetch(url).then(rsp => rsp.json())
let hashes = Promise.all(queue.map(f => {
return new Promise((res, rej) => {
let r = new FileReader();
r.readAsArrayBuffer(f);
r.onload = (e) => res(r.result)
}).then((buf) => {
return window.crypto.subtle.digest("SHA-1", buf)
}).then(dig => {
return {file: f,digest: dig};
});
}))
Promise.all([threadinfo, hashes]).then(([ti, hs]) => {
let existing = new Set();
for(let p of ti.posts) {
if(p.image)
existing.add(p.image.SHA1);
}
for(let hashed of hs) {
const id = hex(hashed.digest)
if (existing.has(id)) {
let i = queue.indexOf(hashed.file)
queue.splice(i, 1)
} else {
// Also remove duplicates from the queue
existing.add(id)
}
}
displayQueue()
})
}
// copypasta from MDN
function hex(buffer) {
var hexCodes = [];
var view = new DataView(buffer);
for (var i = 0; i < view.byteLength; i += 4) {
// Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
var value = view.getUint32(i)
// toString(16) will give the hex representation of the number without padding
var stringValue = value.toString(16)
// We use concatenation and slice for padding
var padding = '00000000'
var paddedValue = (padding + stringValue).slice(-padding.length)
hexCodes.push(paddedValue);
}
// Join all the hex strings into one
return hexCodes.join("");
}
// https://meguca.org/uploadHash
// postbody = sha1 hash
// returns token if exists
// /json/boards/:board/:thread -> posts -> image -> sha1
function awaitForm(image, doneVisible) {
let _res;
let p = new Promise((res, rej) => {_res = res;})
let obs = new MutationObserver(() => {
// wait for thumbnail
if(image && !document.querySelector(".reply-form figure img"))
return;
// then wait for done button
let done = document.querySelector(".reply-form input[name=done]")
if(done && (!done.hidden || !doneVisible)) {
_res(done);
obs.disconnect();
}
});
obs.observe(document.querySelector("#thread-container"), {childList: true, subtree: true});
return p;
}
function awaitReplyClose() {
if(queue.length == 0)
return;
let obs = new MutationObserver(() => {
obs.disconnect();
processQueue();
});
obs.observe(document.querySelector("#thread-container"), {childList: true});
}
function processQueue() {
if(queue.length == 0)
return;
// reply form open?
if(document.querySelector(".reply-form")) {
awaitReplyClose();
return;
}
console.log("dropping");
let file = queue[0];
let fakeUpload = {
files: [file],
style: {},
remove: () => {}
};
if(xrayClone && untrustedWindow)
fakeUpload = xrayClone(fakeUpload, untrustedWindow, {cloneFunctions: true});
awaitForm(false, false).then((d) => {
console.log("awaited")
// monkeypatch meguca JS state
const {trigger} = untrustedWindow.require("./util/index");
const post = trigger("getPostModel");
post.view.upload.input = fakeUpload;
post.uploadFile();
}).then(() => {
return awaitForm(true, true)
}).then((d) => {
/*
const upload = document.querySelector("input[name=image]");
upload.insertAdjacentText("afterend", file.name);
upload.remove();
*/
let delay = document.querySelector("#dump-delay").value | 0;
let idx = queue.indexOf(file);
if(idx >= 0) {
queue.splice(idx, 1);
displayQueue()
}
// image has been injected and we could click done
// check if text has been entered into the form, if so, don't auto-close, wait for user instead
let textArea = document.querySelector("#text-input")
if(textArea && textArea.value !== "") {
setTimeout(processQueue, delay * 1000);
return;
}
window.requestAnimationFrame(() => {console.log("click", d);d.click()});
setTimeout(processQueue, delay * 1000);
});
const reply = document.querySelector("aside.posting a");
reply.click();
}
function displayQueue() {
let list = document.querySelector("#upload-queue");
while(list.firstChild)
list.firstChild.remove();
for(let f of queue) {
let li = document.createElement("li");
li.insertAdjacentHTML('beforeend', `<a>${icons.del}</a>`)
li.firstChild.addEventListener("click", () => {
queue.splice(queue.indexOf(f), 1)
displayQueue()
})
let input = document.createElement("span")
input.contentEditable = true
input.textContent = f.name
input.addEventListener("keydown", (e) => e.stopPropagation())
input.addEventListener("input", () => {
let oldFile = f;
f = new File([f], input.textContent, {type: f.type})
let idx = queue.indexOf(oldFile);
queue[idx] = f
})
li.append(input)
list.append(li)
}
// toggle controls
let panel = document.querySelector(".multifilepanel")
panel.classList.toggle("has-items", queue.length > 0)
}
let registered = false;
function dropHandler(e) {
if(ffSandbox) {
document.documentElement.removeEventListener("drop", dropHandler, true);
registered = false;
}
if(e[skip])
return;
let multifiledrop = !!e.target.closest(".multiupload, .multifilepanel")
let files = e.dataTransfer.files;
if(files.length < 2 && !multifiledrop)
return;
e.preventDefault();
e.stopPropagation();
queue.push(...Array.from(files));
// show on drop
let panel = document.querySelector(".multifilepanel")
panel.hidden = false
// render
displayQueue();
}
if(ffSandbox) {
/*
when running in a firefox sandbox we must only listen for drop events when we actually want to intercept them,
otherwise it will screw up the special properties (event.dataTransfer) for drop handlers in the page content
*/
for(const eventName of ['dragstart', 'dragover', 'dragend', 'dragenter']) {
document.addEventListener(eventName, (e) => {
if(!currentThread)
return;
//console.log(e.dataTransfer, e.dataTransfer.items && e.dataTransfer.items.length);
if(e.dataTransfer.items.length > 1 || e.target.closest(".multiupload, .multifilepanel")) {
if(!registered) {
document.documentElement.addEventListener("drop", dropHandler, true);
registered = true;
}
} else {
document.documentElement.removeEventListener("drop", dropHandler, true);
registered = false;
}
//e.preventDefault();
//e.stopPropagation();
}, true);
}
} else {
document.documentElement.addEventListener("drop", dropHandler, true);
}
@an-electric-sheep
Copy link
Author

TODO: show a panel to reorder files by names in case they got pasted in the wrong order

@bakape
Copy link

bakape commented Aug 22, 2017

@an-electric-sheep
Can't PR Gists, so please merge https://gist.github.com/bakape/45507ab226a8b3104814662adccbae85 to fix duplicate detection.

@an-electric-sheep
Copy link
Author

@bakape updated, thanks for the fix

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment