2 // @name debianforum.de-quickmod-additions
3 // @namespace org.free.for.all
4 // @include https://debianforum.de/forum/viewtopic.php*
5 // @match https://debianforum.de/forum/viewtopic.php*
6 // @author Thorsten Sperber
11 const ARCHIVFORUMID = 35;
13 async function archiveThread(firstPage, reason) {
14 const moveProm = (async () => {
15 const moveLink = firstPage.querySelector("#quickmod .dropdown-contents a[href*='action=move']");
19 [form, formData] = await openForm(toAbsoluteURL(moveLink.href), "form#confirm");
21 throw `Konnte Formular zum Verschieben des Themas nicht öffnen: ${err}`;
24 formData.set("to_forum_id", ARCHIVFORUMID);
26 /* Unlike splitting a thread, moving does not have a second
29 await postForm(form, formData, "confirm");
31 throw `Konnte Thema nicht verschieben: ${err}`;
35 const editProm = (async () => {
36 const editLink = firstPage.querySelector(".post .post-buttons a[href*='mode=edit']");
40 [form, formData] = await openForm(toAbsoluteURL(editLink.href), "form#postform");
42 throw `Konnte Formular zum Bearbeiten des ersten Beitrags nicht öffnen: ${err}`;
45 formData.set("subject", prefixSubject(form.elements["subject"], reason));
47 /* All "altering actions not secured by confirm_box" require a non-zero
48 * time difference between opening and submitting a form. See
49 * check_form_key() in phpBB/includes/functions.php.
51 * So we artificially delay the postForm() for a second.
53 await new Promise((resolve) => {
54 setTimeout(async () => {
56 await postForm(form, formData, "post");
58 throw `Konnte Thema nicht umbenennen: ${err}`;
66 /* An mcp action and a post edit can actually be done concurrently! :-) */
67 await Promise.all([moveProm, editProm]);
70 async function archiveThreadQuickmod() {
71 const canonicalURL = new URL(document.querySelector("link[rel='canonical']").href);
72 const firstPage = await openDoc(canonicalURL);
73 const firstPost = firstPage.querySelector(".post");
74 const usernameElem = firstPost.querySelector(".author .username,.author .username-coloured");
75 const username = usernameElem.textContent;
76 const thread_title = firstPage.querySelector('.topic-title a').text;
78 const archiveReason = await asyncPrompt(`Thema „${ellipsify(thread_title, 100)}“ eröffnet von „${username}“ wirklich als Spam archivieren?\n\nGrund:`, "Spam");
79 if (archiveReason === null) {
80 /* Don't do any of the other actions if moving was cancelled. */
84 const archivingThread = archiveThread(firstPage, archiveReason);
86 /* Prompting for a separate ban reason in case there is something more
87 * specific to note here.
89 const userStillExists = usernameElem.nodeName === "A";
90 const banReasonPrompt = userStillExists &&
91 asyncPrompt(`Benutzer „${username}“, der das Thema eröffnet hat, sperren?\n\nGrund:`, "Spam");
93 /* Mod actions via mcp.php involve a confirm_key which is stored in the
94 * database when an action is requested until it is confirmed. There can only
95 * be one confirm_key stored at a time---meaning there cannot be multiple mcp
96 * actions executed concurrently. See confirm_box() in
97 * phpBB/includes/functions.php.
99 * This means we cannot really execute the actions concurrently here,
100 * unfortunately. User interaction is still done in parallel to one action at
105 await archivingThread;
111 const banReason = await banReasonPrompt;
113 banningUser = banUser(username, banReason);
114 } else if (!userStillExists) {
115 await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
118 const shouldCloseReport = isPostReported(firstPost) &&
119 asyncConfirm("Meldung zum ersten Beitrag schließen?");
127 if (await shouldCloseReport) {
129 await closeReport(firstPost);
135 for (const error of errors) {
137 window.alert(`ACHTUNG!\n\n${error}`);
140 if (errors.length === 0) {
145 async function asyncAlert(message) {
146 return showDialog(message);
149 async function asyncConfirm(message) {
150 return showDialog(message, (dialog) => dialog.returnValue === "OK", true);
153 async function asyncPrompt(message, defaultValue) {
154 return showDialog(message, (dialog) =>
155 dialog.returnValue === "OK" ? dialog.firstChild.elements["value"].value : null,
159 async function banUser(username, reason) {
160 /* The URL to the ban form does not need any IDs or hidden inputs. We
165 [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"),
168 throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`;
171 formData.set("ban", username);
172 formData.set("banreason", reason);
173 //formData.set("bangivereason", reason);
175 await postForm(form, formData, "bansubmit", true);
177 throw `Konnte Benutzer nicht sperren: ${err}`;
181 async function closeReport(post) {
182 const reportLink = post.querySelector(".post-notice.reported a");
186 [form, formData] = await openForm(toAbsoluteURL(reportLink.href),
189 throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`;
193 await postForm(form, formData, "action[close]", true);
195 throw `Konnte Meldung nicht schließen: ${err}`;
199 async function confirmAction(response) {
200 const [form, formData] = await openForm(response, "form#confirm");
201 await postForm(form, formData, "confirm");
204 function ellipsify(str, maxlen) {
205 const ell = str.length > maxlen ? " […]" : "";
206 return str.substring(0, maxlen - ell.length) + ell;
209 function isPostReported(post) {
210 return post.querySelector(".post-notice.reported a") !== null;
213 async function openForm(urlOrResponse, selector) {
214 const doc = await openDoc(urlOrResponse);
215 const form = doc.querySelector(selector);
216 return [form, new FormData(form)];
219 async function openDoc(urlOrResponse) {
220 const resp = urlOrResponse instanceof Response ? urlOrResponse :
221 await fetch(urlOrResponse);
223 throw `${resp.url}: ${resp.status}`;
226 const parser = new DOMParser();
227 const txt = await resp.text();
228 return parser.parseFromString(txt, "text/html");
231 async function postForm(form, formData, submitName, requiresConfirmation = false) {
232 /* "Press" the right submit button. */
233 const submitBtn = form.elements[submitName];
234 formData.set(submitBtn.name, submitBtn.value);
236 /* Have to use explicit getAttribute() below since there is an input with
237 * name="action" which would be accessed with `form.action` :-/
239 const resp = await fetch(toAbsoluteURL(form.getAttribute("action")),
240 { body: new URLSearchParams(formData), method: "POST" });
242 throw `${resp.url}: ${resp.status}`;
245 if (requiresConfirmation) {
246 await confirmAction(resp);
250 function prefixSubject(input, reason) {
251 let subject = input.value;
257 const prefix = `[${reason}]`;
258 if (subject.toLowerCase().includes(prefix.toLowerCase())) {
262 subject = `${prefix} ${subject}`;
263 const maxLen = input.getAttribute("maxlength") ?? subject.length;
264 return subject.slice(0, maxLen);
267 function redirectToArchive() {
268 /* TODO: Make the location configurable, redirect to homepage, "Aktive
269 * Themen", "Neue Beiträge", or other?
271 window.location = `./viewforum.php?f=${ARCHIVFORUMID}`;
274 async function remove_post_handler(event) {
275 const post = event.currentTarget.closest('.post');
276 const usernameElem = post.querySelector(".author .username,.author .username-coloured");
277 const username = usernameElem.textContent;
278 const thread_title = document.querySelector('.topic-title a').text;
279 const content = ellipsify(post.querySelector(".content").innerText, 250);
281 const splitReason = await asyncPrompt(`Folgenden Beitrag von „${username}“ im Thema „${ellipsify(thread_title, 100)}“ wirklich als Spam archivieren?\n\n„${content}“\n\nGrund:`, "Spam");
282 if (splitReason === null) {
283 /* Don't do any of the other actions if splitting was cancelled. */
287 const archivingPost = send_mcp_request_archival(post, splitReason);
289 /* Prompting for a separate ban reason in case there is something more
290 * specific to note here.
292 const userStillExists = usernameElem.nodeName === "A";
293 const banReasonPrompt = userStillExists &&
294 asyncPrompt(`Benutzer „${username}“ sperren?\n\nGrund:`, "Spam");
296 /* Mod actions via mcp.php involve a confirm_key which is stored in the
297 * database when an action is requested until it is confirmed. There can only
298 * be one confirm_key stored at a time---meaning there cannot be multiple mcp
299 * actions executed concurrently. See confirm_box() in
300 * phpBB/includes/functions.php.
302 * This means we cannot really execute the actions concurrently here,
303 * unfortunately. User interaction is still done in parallel to one action at
314 const banReason = await banReasonPrompt;
316 banningUser = banUser(username, banReason);
317 } else if (!userStillExists) {
318 await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
321 const shouldCloseReport = isPostReported(post) &&
322 asyncConfirm("Meldung zum Beitrag schließen?");
330 if (await shouldCloseReport) {
332 await closeReport(post);
338 for (const error of errors) {
340 window.alert(`ACHTUNG!\n\n${error}`);
343 if (errors.length === 0) {
344 updatePageAfterSplit(post);
348 async function send_mcp_request_archival(post, reason) {
349 const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']");
353 [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp");
355 throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`;
358 const post_id = post.id.slice(1);
359 formData.set("post_id_list[]", post_id);
360 formData.set("subject", prefixSubject(form.elements["subject"], reason));
361 formData.set("to_forum_id", ARCHIVFORUMID);
364 await postForm(form, formData, "mcp_topic_submit", true);
366 throw `Konnte Thema nicht aufteilen: ${err}`;
370 async function showDialog(message, returnFunc = null, abortable = false, defaultValue = null) {
371 const dialog = document.body.appendChild(document.createElement("dialog"));
372 dialog.className = "quickmod-dialog";
373 dialog.style.borderColor = "#D31141";
374 dialog.style.maxWidth = "60em";
376 const form = dialog.appendChild(document.createElement("form"));
377 form.method = "dialog";
379 const p = form.appendChild(document.createElement("p"));
380 p.style.whiteSpace = "pre-line";
381 p.textContent = message;
383 if (defaultValue !== null) {
384 const inputP = form.appendChild(document.createElement("p"));
385 inputP.innerHTML = `<input class="inputbox" name="value" type="text" value="${defaultValue}">`;
388 const submitButtons = form.appendChild(document.createElement("fieldset"));
389 submitButtons.className = "submit-buttons";
390 submitButtons.innerHTML = '<input class="button1" type="submit" value="OK"> ';
392 const abortBtn = submitButtons.appendChild(document.createElement("input"));
393 abortBtn.className = "button2";
394 abortBtn.type = "submit";
395 abortBtn.value = "Abbrechen";
400 return new Promise((resolve) => {
401 dialog.addEventListener("close", (event) => {
402 event.currentTarget.remove();
403 resolve(returnFunc?.(event.currentTarget));
408 function toAbsoluteURL(relativeOrAbsoluteURL) {
409 return new URL(relativeOrAbsoluteURL, window.location);
412 function updatePageAfterSplit(post) {
413 if (document.querySelectorAll(".post").length > 1) {
414 post.parentNode.removeChild(post);
420 function add_buttons() {
421 const del_post_btn_outer = document.createElement('li');
422 const del_post_btn = document.createElement('a');
423 del_post_btn.className = 'button button-icon-only';
424 del_post_btn.innerHTML = '<i class="icon fa-fire-extinguisher fa-fw" aria-hidden="true"></i><span class="sr-only">Abfall</span>';
425 del_post_btn.addEventListener("click", remove_post_handler);
426 del_post_btn.title = "Als Spam archivieren";
427 del_post_btn_outer.append(del_post_btn);
429 for (const postButtons of document.querySelectorAll(".post-buttons")) {
430 const del_post_btn_outer_clone = del_post_btn_outer.cloneNode(true);
431 del_post_btn_outer_clone.addEventListener("click", remove_post_handler);
432 postButtons.appendChild(del_post_btn_outer_clone);
435 const quickmodLinks = document.querySelector("#quickmod .dropdown-contents");
436 const archiveThreadLink = quickmodLinks
437 .insertBefore(document.createElement("li"), quickmodLinks.firstChild)
438 .appendChild(document.createElement("a"));
439 archiveThreadLink.addEventListener("click", archiveThreadQuickmod);
440 archiveThreadLink.innerText = "Thema als Spam archivieren";
441 archiveThreadLink.style.cursor = "pointer";
443 const stylesheet = document.head.appendChild(document.createElement("style")).sheet;
444 /* The pseudo element ::backdrop can only be styled through a rule. */
445 stylesheet.insertRule(".quickmod-dialog::backdrop { background: #333C }");