// ==UserScript== // @name debianforum.de-quickmod-additions // @namespace org.free.for.all // @include https://debianforum.de/forum/viewtopic.php* // @match https://debianforum.de/forum/viewtopic.php* // @author Thorsten Sperber // @author JTH // @version 1.5 // ==/UserScript== const ARCHIVFORUMID = 35; async function archiveThread(firstPage, reason) { const moveProm = (async () => { const moveLink = firstPage.querySelector("#quickmod .dropdown-contents a[href*='action=move']"); let form, formData; try { [form, formData] = await openForm(toAbsoluteURL(moveLink.href), "form#confirm"); } catch (err) { throw `Konnte Formular zum Verschieben des Themas nicht öffnen: ${err}`; } formData.set("to_forum_id", ARCHIVFORUMID); try { /* Unlike splitting a thread, moving does not have a second * confirmation step. */ await postForm(form, formData, "confirm"); } catch (err) { throw `Konnte Thema nicht verschieben: ${err}`; } })(); const editProm = (async () => { const editLink = firstPage.querySelector(".post .post-buttons a[href*='mode=edit']"); let form, formData; try { [form, formData] = await openForm(toAbsoluteURL(editLink.href), "form#postform"); } catch (err) { throw `Konnte Formular zum Bearbeiten des ersten Beitrags nicht öffnen: ${err}`; } formData.set("subject", prefixSubject(form.elements["subject"], reason)); /* All "altering actions not secured by confirm_box" require a non-zero * time difference between opening and submitting a form. See * check_form_key() in phpBB/includes/functions.php. * * So we artificially delay the postForm() for a second. */ await new Promise((resolve) => { setTimeout(async () => { try { await postForm(form, formData, "post"); } catch (err) { throw `Konnte Thema nicht umbenennen: ${err}`; } resolve(); }, 1001); }); })(); /* An mcp action and a post edit can actually be done concurrently! :-) */ await Promise.all([moveProm, editProm]); } async function archiveThreadQuickmod() { const canonicalURL = new URL(document.querySelector("link[rel='canonical']").href); const firstPage = await openDoc(canonicalURL); const firstPost = firstPage.querySelector(".post"); const usernameElem = firstPost.querySelector(".author .username,.author .username-coloured"); const username = usernameElem.textContent; const thread_title = firstPage.querySelector('.topic-title a').text; const archiveReason = await asyncPrompt(`Thema ?${ellipsify(thread_title, 100)}? eröffnet von ?${username}? wirklich als Spam archivieren?\n\nGrund:`, "Spam"); if (archiveReason === null) { /* Don't do any of the other actions if moving was cancelled. */ return; } const archivingThread = archiveThread(firstPage, archiveReason); /* Prompting for a separate ban reason in case there is something more * specific to note here. */ const userStillExists = usernameElem.nodeName === "A"; const banReasonPrompt = userStillExists && asyncPrompt(`Benutzer ?${username}?, der das Thema eröffnet hat, sperren?\n\nGrund:`, "Spam"); /* Mod actions via mcp.php involve a confirm_key which is stored in the * database when an action is requested until it is confirmed. There can only * be one confirm_key stored at a time---meaning there cannot be multiple mcp * actions executed concurrently. See confirm_box() in * phpBB/includes/functions.php. * * This means we cannot really execute the actions concurrently here, * unfortunately. User interaction is still done in parallel to one action at * a time, though. */ const errors = []; try { await archivingThread; } catch (err) { errors.push(err); } let banningUser; const banReason = await banReasonPrompt; if (banReason) { banningUser = banUser(username, banReason); } else if (!userStillExists) { await asyncAlert(`Benutzer ?${username}? wurde schon gelöscht und kann nicht mehr gesperrt werden.`); } const shouldCloseReport = isPostReported(firstPost) && asyncConfirm("Meldung zum ersten Beitrag schließen?"); try { await banningUser; } catch (err) { errors.push(err); } if (await shouldCloseReport) { try { await closeReport(firstPost); } catch (err) { errors.push(err); } } for (const error of errors) { console.log(error); window.alert(`ACHTUNG!\n\n${error}`); } if (errors.length === 0) { redirectToArchive(); } } async function asyncAlert(message) { return showDialog(message); } async function asyncConfirm(message) { return showDialog(message, (dialog) => dialog.returnValue === "OK", true); } async function asyncPrompt(message, defaultValue) { return showDialog(message, (dialog) => dialog.returnValue === "OK" ? dialog.firstChild.elements["value"].value : null, true, defaultValue); } async function banUser(username, reason) { /* The URL to the ban form does not need any IDs or hidden inputs. We * hardcode it here. */ let form, formData; try { [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"), "form#mcp_ban"); } catch (err) { throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`; } formData.set("ban", username); formData.set("banreason", reason); //formData.set("bangivereason", reason); try { await postForm(form, formData, "bansubmit", true); } catch (err) { throw `Konnte Benutzer nicht sperren: ${err}`; } } async function closeReport(post) { const reportLink = post.querySelector(".post-notice.reported a"); let form, formData; try { [form, formData] = await openForm(toAbsoluteURL(reportLink.href), "form#mcp_report"); } catch (err) { throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`; } try { await postForm(form, formData, "action[close]", true); } catch (err) { throw `Konnte Meldung nicht schließen: ${err}`; } } async function confirmAction(response) { const [form, formData] = await openForm(response, "form#confirm"); await postForm(form, formData, "confirm"); } function ellipsify(str, maxlen) { const ell = str.length > maxlen ? " [?]" : ""; return str.substring(0, maxlen - ell.length) + ell; } function isPostReported(post) { return post.querySelector(".post-notice.reported a") !== null; } async function openForm(urlOrResponse, selector) { const doc = await openDoc(urlOrResponse); const form = doc.querySelector(selector); return [form, new FormData(form)]; } async function openDoc(urlOrResponse) { const resp = urlOrResponse instanceof Response ? urlOrResponse : await fetch(urlOrResponse); if (!resp.ok) { throw `${resp.url}: ${resp.status}`; } const parser = new DOMParser(); const txt = await resp.text(); return parser.parseFromString(txt, "text/html"); } async function postForm(form, formData, submitName, requiresConfirmation = false) { /* "Press" the right submit button. */ const submitBtn = form.elements[submitName]; formData.set(submitBtn.name, submitBtn.value); /* Have to use explicit getAttribute() below since there is an input with * name="action" which would be accessed with `form.action` :-/ */ const resp = await fetch(toAbsoluteURL(form.getAttribute("action")), { body: new URLSearchParams(formData), method: "POST" }); if (!resp.ok) { throw `${resp.url}: ${resp.status}`; } if (requiresConfirmation) { await confirmAction(resp); } } function prefixSubject(input, reason) { let subject = input.value; if (!reason) { return subject; } const prefix = `[${reason}]`; if (subject.toLowerCase().includes(prefix.toLowerCase())) { return subject; } subject = `${prefix} ${subject}`; const maxLen = input.getAttribute("maxlength") ?? subject.length; return subject.slice(0, maxLen); } function redirectToArchive() { /* TODO: Make the location configurable, redirect to homepage, "Aktive * Themen", "Neue Beiträge", or other? */ window.location = `./viewforum.php?f=${ARCHIVFORUMID}`; } async function remove_post_handler(event) { const post = event.currentTarget.closest('.post'); const usernameElem = post.querySelector(".author .username,.author .username-coloured"); const username = usernameElem.textContent; const thread_title = document.querySelector('.topic-title a').text; const content = ellipsify(post.querySelector(".content").innerText, 250); const splitReason = await asyncPrompt(`Folgenden Beitrag von ?${username}? im Thema ?${ellipsify(thread_title, 100)}? wirklich als Spam archivieren?\n\n?${content}?\n\nGrund:`, "Spam"); if (splitReason === null) { /* Don't do any of the other actions if splitting was cancelled. */ return; } const archivingPost = send_mcp_request_archival(post, splitReason); /* Prompting for a separate ban reason in case there is something more * specific to note here. */ const userStillExists = usernameElem.nodeName === "A"; const banReasonPrompt = userStillExists && asyncPrompt(`Benutzer ?${username}? sperren?\n\nGrund:`, "Spam"); /* Mod actions via mcp.php involve a confirm_key which is stored in the * database when an action is requested until it is confirmed. There can only * be one confirm_key stored at a time---meaning there cannot be multiple mcp * actions executed concurrently. See confirm_box() in * phpBB/includes/functions.php. * * This means we cannot really execute the actions concurrently here, * unfortunately. User interaction is still done in parallel to one action at * a time, though. */ const errors = []; try { await archivingPost; } catch (err) { errors.push(err); } let banningUser; const banReason = await banReasonPrompt; if (banReason) { banningUser = banUser(username, banReason); } else if (!userStillExists) { await asyncAlert(`Benutzer ?${username}? wurde schon gelöscht und kann nicht mehr gesperrt werden.`); } const shouldCloseReport = isPostReported(post) && asyncConfirm("Meldung zum Beitrag schließen?"); try { await banningUser; } catch (err) { errors.push(err); } if (await shouldCloseReport) { try { await closeReport(post); } catch (err) { errors.push(err); } } for (const error of errors) { console.log(error); window.alert(`ACHTUNG!\n\n${error}`); } if (errors.length === 0) { updatePageAfterSplit(post); } } async function send_mcp_request_archival(post, reason) { const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']"); let form, formData; try { [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp"); } catch (err) { throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`; } const post_id = post.id.slice(1); formData.set("post_id_list[]", post_id); formData.set("subject", prefixSubject(form.elements["subject"], reason)); formData.set("to_forum_id", ARCHIVFORUMID); try { await postForm(form, formData, "mcp_topic_submit", true); } catch (err) { throw `Konnte Thema nicht aufteilen: ${err}`; } } async function showDialog(message, returnFunc = null, abortable = false, defaultValue = null) { const dialog = document.body.appendChild(document.createElement("dialog")); dialog.className = "quickmod-dialog"; dialog.style.borderColor = "#D31141"; dialog.style.maxWidth = "60em"; const form = dialog.appendChild(document.createElement("form")); form.method = "dialog"; const p = form.appendChild(document.createElement("p")); p.style.whiteSpace = "pre-line"; p.textContent = message; if (defaultValue !== null) { const inputP = form.appendChild(document.createElement("p")); inputP.innerHTML = ``; } const submitButtons = form.appendChild(document.createElement("fieldset")); submitButtons.className = "submit-buttons"; submitButtons.innerHTML = ' '; if (abortable) { const abortBtn = submitButtons.appendChild(document.createElement("input")); abortBtn.className = "button2"; abortBtn.type = "submit"; abortBtn.value = "Abbrechen"; } dialog.showModal(); return new Promise((resolve) => { dialog.addEventListener("close", (event) => { event.currentTarget.remove(); resolve(returnFunc?.(event.currentTarget)); }); }); } function toAbsoluteURL(relativeOrAbsoluteURL) { return new URL(relativeOrAbsoluteURL, window.location); } function updatePageAfterSplit(post) { if (document.querySelectorAll(".post").length > 1) { post.parentNode.removeChild(post); } else { redirectToArchive(); } } function add_buttons() { const del_post_btn_outer = document.createElement('li'); const del_post_btn = document.createElement('a'); del_post_btn.className = 'button button-icon-only'; del_post_btn.innerHTML = 'Abfall'; del_post_btn.addEventListener("click", remove_post_handler); del_post_btn.title = "Als Spam archivieren"; del_post_btn_outer.append(del_post_btn); for (const postButtons of document.querySelectorAll(".post-buttons")) { const del_post_btn_outer_clone = del_post_btn_outer.cloneNode(true); del_post_btn_outer_clone.addEventListener("click", remove_post_handler); postButtons.appendChild(del_post_btn_outer_clone); } const quickmodLinks = document.querySelector("#quickmod .dropdown-contents"); const archiveThreadLink = quickmodLinks .insertBefore(document.createElement("li"), quickmodLinks.firstChild) .appendChild(document.createElement("a")); archiveThreadLink.addEventListener("click", archiveThreadQuickmod); archiveThreadLink.innerText = "Thema als Spam archivieren"; archiveThreadLink.style.cursor = "pointer"; const stylesheet = document.head.appendChild(document.createElement("style")).sheet; /* The pseudo element ::backdrop can only be styled through a rule. */ stylesheet.insertRule(".quickmod-dialog::backdrop { background: #333C }"); } add_buttons();