-
-
Save bakape/45507ab226a8b3104814662adccbae85 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.10 | |
// @grant none | |
// @run-at document-start | |
// @noframes | |
// ==/UserScript== | |
"use strict"; | |
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>` | |
}; | |
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="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("#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() { | |
let _res; | |
let p = new Promise((res, rej) => {_res = res;}) | |
let obs = new MutationObserver(() => { | |
// wait for thumbnail | |
if(!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) { | |
_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; | |
} | |
let file = queue[0]; | |
let newEvent = new DragEvent("drop"); | |
Object.defineProperty(newEvent, "dataTransfer", { | |
value: {files: [file]} | |
}) | |
newEvent[skip] = true; | |
document.querySelector("#threads").dispatchEvent(newEvent); | |
awaitForm().then((d) => { | |
d.click() | |
queue.shift() | |
displayQueue() | |
let delay = document.querySelector("#dump-delay").value | 0; | |
setTimeout(processQueue, delay * 1000); | |
}) | |
} | |
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) | |
} | |
function dropHandler(e) { | |
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(); | |
} | |
document.documentElement.addEventListener("drop", dropHandler, true); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment