// ==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();