Last active
January 12, 2018 23:26
-
-
Save an-electric-sheep/ce0cd642b2bff8508f39931e902588b1 to your computer and use it in GitHub Desktop.
meguca multi-upload userscript - click RAW to install
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 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
Can't PR Gists, so please merge https://gist.github.com/bakape/45507ab226a8b3104814662adccbae85 to fix duplicate detection.
@bakape updated, thanks for the fix
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TODO: show a panel to reorder files by names in case they got pasted in the wrong order