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 window.alert(message);
149 async function asyncConfirm(message) {
150 return window.confirm(message);
153 async function asyncPrompt(message, defaultValue) {
154 return window.prompt(message, defaultValue);
157 async function banUser(username, reason) {
158 /* The URL to the ban form does not need any IDs or hidden inputs. We
163 [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"),
166 throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`;
169 formData.set("ban", username);
170 formData.set("banreason", reason);
171 //formData.set("bangivereason", reason);
173 await postForm(form, formData, "bansubmit", true);
175 throw `Konnte Benutzer nicht sperren: ${err}`;
179 async function closeReport(post) {
180 const reportLink = post.querySelector(".post-notice.reported a");
184 [form, formData] = await openForm(toAbsoluteURL(reportLink.href),
187 throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`;
191 await postForm(form, formData, "action[close]", true);
193 throw `Konnte Meldung nicht schließen: ${err}`;
197 async function confirmAction(response) {
198 const [form, formData] = await openForm(response, "form#confirm");
199 await postForm(form, formData, "confirm");
202 function ellipsify(str, maxlen) {
203 const ell = str.length > maxlen ? " […]" : "";
204 return str.substring(0, maxlen - ell.length) + ell;
207 function isPostReported(post) {
208 return post.querySelector(".post-notice.reported a") !== null;
211 async function openForm(urlOrResponse, selector) {
212 const doc = await openDoc(urlOrResponse);
213 const form = doc.querySelector(selector);
214 return [form, new FormData(form)];
217 async function openDoc(urlOrResponse) {
218 const resp = urlOrResponse instanceof Response ? urlOrResponse :
219 await fetch(urlOrResponse);
221 throw `${resp.url}: ${resp.status}`;
224 const parser = new DOMParser();
225 const txt = await resp.text();
226 return parser.parseFromString(txt, "text/html");
229 async function postForm(form, formData, submitName, requiresConfirmation = false) {
230 /* "Press" the right submit button. */
231 const submitBtn = form.elements[submitName];
232 formData.set(submitBtn.name, submitBtn.value);
234 /* Have to use explicit getAttribute() below since there is an input with
235 * name="action" which would be accessed with `form.action` :-/
237 const resp = await fetch(toAbsoluteURL(form.getAttribute("action")),
238 { body: new URLSearchParams(formData), method: "POST" });
240 throw `${resp.url}: ${resp.status}`;
243 if (requiresConfirmation) {
244 await confirmAction(resp);
248 function prefixSubject(input, reason) {
249 let subject = input.value;
255 const prefix = `[${reason}]`;
256 if (subject.toLowerCase().includes(prefix.toLowerCase())) {
260 subject = `${prefix} ${subject}`;
261 const maxLen = input.getAttribute("maxlength") ?? subject.length;
262 return subject.slice(0, maxLen);
265 function redirectToArchive() {
266 /* TODO: Make the location configurable, redirect to homepage, "Aktive
267 * Themen", "Neue Beiträge", or other?
269 window.location = `./viewforum.php?f=${ARCHIVFORUMID}`;
272 async function remove_post_handler(event) {
273 const post = event.currentTarget.closest('.post');
274 const usernameElem = post.querySelector(".author .username,.author .username-coloured");
275 const username = usernameElem.textContent;
276 const thread_title = document.querySelector('.topic-title a').text;
277 const content = ellipsify(post.querySelector(".content").innerText, 250);
279 const splitReason = await asyncPrompt(`Folgenden Beitrag von „${username}“ im Thema „${ellipsify(thread_title, 100)}“ wirklich als Spam archivieren?\n\n„${content}“\n\nGrund:`, "Spam");
280 if (splitReason === null) {
281 /* Don't do any of the other actions if splitting was cancelled. */
285 const archivingPost = send_mcp_request_archival(post, splitReason);
287 /* Prompting for a separate ban reason in case there is something more
288 * specific to note here.
290 const userStillExists = usernameElem.nodeName === "A";
291 const banReasonPrompt = userStillExists &&
292 asyncPrompt(`Benutzer „${username}“ sperren?\n\nGrund:`, "Spam");
294 /* Mod actions via mcp.php involve a confirm_key which is stored in the
295 * database when an action is requested until it is confirmed. There can only
296 * be one confirm_key stored at a time---meaning there cannot be multiple mcp
297 * actions executed concurrently. See confirm_box() in
298 * phpBB/includes/functions.php.
300 * This means we cannot really execute the actions concurrently here,
301 * unfortunately. User interaction is still done in parallel to one action at
312 const banReason = await banReasonPrompt;
314 banningUser = banUser(username, banReason);
315 } else if (!userStillExists) {
316 await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
319 const shouldCloseReport = isPostReported(post) &&
320 asyncConfirm("Meldung zum Beitrag schließen?");
328 if (await shouldCloseReport) {
330 await closeReport(post);
336 for (const error of errors) {
338 window.alert(`ACHTUNG!\n\n${error}`);
341 if (errors.length === 0) {
342 updatePageAfterSplit(post);
346 async function send_mcp_request_archival(post, reason) {
347 const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']");
351 [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp");
353 throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`;
356 const post_id = post.id.slice(1);
357 formData.set("post_id_list[]", post_id);
358 formData.set("subject", prefixSubject(form.elements["subject"], reason));
359 formData.set("to_forum_id", ARCHIVFORUMID);
362 await postForm(form, formData, "mcp_topic_submit", true);
364 throw `Konnte Thema nicht aufteilen: ${err}`;
368 function toAbsoluteURL(relativeOrAbsoluteURL) {
369 return new URL(relativeOrAbsoluteURL, window.location);
372 function updatePageAfterSplit(post) {
373 if (document.querySelectorAll(".post").length > 1) {
374 post.parentNode.removeChild(post);
380 function add_buttons() {
381 const del_post_btn_outer = document.createElement('li');
382 const del_post_btn = document.createElement('a');
383 del_post_btn.className = 'button button-icon-only';
384 del_post_btn.innerHTML = '<i class="icon fa-fire-extinguisher fa-fw" aria-hidden="true"></i><span class="sr-only">Abfall</span>';
385 del_post_btn.addEventListener("click", remove_post_handler);
386 del_post_btn.title = "Als Spam archivieren";
387 del_post_btn_outer.append(del_post_btn);
389 for (const postButtons of document.querySelectorAll(".post-buttons")) {
390 const del_post_btn_outer_clone = del_post_btn_outer.cloneNode(true);
391 del_post_btn_outer_clone.addEventListener("click", remove_post_handler);
392 postButtons.appendChild(del_post_btn_outer_clone);
395 const quickmodLinks = document.querySelector("#quickmod .dropdown-contents");
396 const archiveThreadLink = quickmodLinks
397 .insertBefore(document.createElement("li"), quickmodLinks.firstChild)
398 .appendChild(document.createElement("a"));
399 archiveThreadLink.addEventListener("click", archiveThreadQuickmod);
400 archiveThreadLink.innerText = "Thema als Spam archivieren";
401 archiveThreadLink.style.cursor = "pointer";