]> git.aero2k.de Git - dfde/quickmods.git/blob - quickmod.user.js
Bump version to 1.5
[dfde/quickmods.git] / quickmod.user.js
1 // ==UserScript==
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
7 // @author        JTH
8 // @version       1.5
9 // ==/UserScript==
10
11 const ARCHIVFORUMID = 35;
12
13 async function archiveThread(firstPage, reason) {
14     const moveProm = (async () => {
15         const moveLink = firstPage.querySelector("#quickmod .dropdown-contents a[href*='action=move']");
16
17         let form, formData;
18         try {
19             [form, formData] = await openForm(toAbsoluteURL(moveLink.href), "form#confirm");
20         } catch (err) {
21             throw `Konnte Formular zum Verschieben des Themas nicht öffnen: ${err}`;
22         }
23
24         formData.set("to_forum_id", ARCHIVFORUMID);
25         try {
26             /* Unlike splitting a thread, moving does not have a second
27              * confirmation step.
28              */
29             await postForm(form, formData, "confirm");
30         } catch (err) {
31             throw `Konnte Thema nicht verschieben: ${err}`;
32         }
33     })();
34
35     const editProm = (async () => {
36         const editLink = firstPage.querySelector(".post .post-buttons a[href*='mode=edit']");
37
38         let form, formData;
39         try {
40             [form, formData] = await openForm(toAbsoluteURL(editLink.href), "form#postform");
41         } catch (err) {
42             throw `Konnte Formular zum Bearbeiten des ersten Beitrags nicht öffnen: ${err}`;
43         }
44
45         formData.set("subject", prefixSubject(form.elements["subject"], reason));
46
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.
50          *
51          * So we artificially delay the postForm() for a second.
52          */
53         await new Promise((resolve) => {
54             setTimeout(async () => {
55                 try {
56                     await postForm(form, formData, "post");
57                 } catch (err) {
58                     throw `Konnte Thema nicht umbenennen: ${err}`;
59                 }
60
61                 resolve();
62             }, 1001);
63         });
64     })();
65
66     /* An mcp action and a post edit can actually be done concurrently! :-) */
67     await Promise.all([moveProm, editProm]);
68 }
69
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;
77
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. */
81         return;
82     }
83
84     const archivingThread = archiveThread(firstPage, archiveReason);
85
86     /* Prompting for a separate ban reason in case there is something more
87      * specific to note here.
88      */
89     const userStillExists = usernameElem.nodeName === "A";
90     const banReasonPrompt = userStillExists &&
91         asyncPrompt(`Benutzer „${username}“, der das Thema eröffnet hat, sperren?\n\nGrund:`, "Spam");
92
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.
98      *
99      * This means we cannot really execute the actions concurrently here,
100      * unfortunately. User interaction is still done in parallel to one action at
101      * a time, though.
102      */
103     const errors = [];
104     try {
105         await archivingThread;
106     } catch (err) {
107         errors.push(err);
108     }
109
110     let banningUser;
111     const banReason = await banReasonPrompt;
112     if (banReason) {
113         banningUser = banUser(username, banReason);
114     } else if (!userStillExists) {
115         await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
116     }
117
118     const shouldCloseReport = isPostReported(firstPost) &&
119         asyncConfirm("Meldung zum ersten Beitrag schließen?");
120
121     try {
122         await banningUser;
123     } catch (err) {
124         errors.push(err);
125     }
126
127     if (await shouldCloseReport) {
128         try {
129             await closeReport(firstPost);
130         } catch (err) {
131             errors.push(err);
132         }
133     }
134
135     for (const error of errors) {
136         console.log(error);
137         window.alert(`ACHTUNG!\n\n${error}`);
138     }
139
140     if (errors.length === 0) {
141         redirectToArchive();
142     }
143 }
144
145 async function asyncAlert(message) {
146     return showDialog(message);
147 }
148
149 async function asyncConfirm(message) {
150     return showDialog(message, (dialog) => dialog.returnValue === "OK", true);
151 }
152
153 async function asyncPrompt(message, defaultValue) {
154     return showDialog(message, (dialog) =>
155         dialog.returnValue === "OK" ? dialog.firstChild.elements["value"].value : null,
156         true, defaultValue);
157 }
158
159 async function banUser(username, reason) {
160     /* The URL to the ban form does not need any IDs or hidden inputs. We
161      * hardcode it here.
162      */
163     let form, formData;
164     try {
165         [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"),
166             "form#mcp_ban");
167     } catch (err) {
168         throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`;
169     }
170
171     formData.set("ban", username);
172     formData.set("banreason", reason);
173     //formData.set("bangivereason", reason);
174     try {
175         await postForm(form, formData, "bansubmit", true);
176     } catch (err) {
177         throw `Konnte Benutzer nicht sperren: ${err}`;
178     }
179 }
180
181 async function closeReport(post) {
182     const reportLink = post.querySelector(".post-notice.reported a");
183
184     let form, formData;
185     try {
186         [form, formData] = await openForm(toAbsoluteURL(reportLink.href),
187             "form#mcp_report");
188     } catch (err) {
189         throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`;
190     }
191
192     try {
193         await postForm(form, formData, "action[close]", true);
194     } catch (err) {
195         throw `Konnte Meldung nicht schließen: ${err}`;
196     }
197 }
198
199 async function confirmAction(response) {
200     const [form, formData] = await openForm(response, "form#confirm");
201     await postForm(form, formData, "confirm");
202 }
203
204 function ellipsify(str, maxlen) {
205     const ell = str.length > maxlen ? " […]" : "";
206     return str.substring(0, maxlen - ell.length) + ell;
207 }
208
209 function isPostReported(post) {
210     return post.querySelector(".post-notice.reported a") !== null;
211 }
212
213 async function openForm(urlOrResponse, selector) {
214     const doc = await openDoc(urlOrResponse);
215     const form = doc.querySelector(selector);
216     return [form, new FormData(form)];
217 }
218
219 async function openDoc(urlOrResponse) {
220     const resp = urlOrResponse instanceof Response ? urlOrResponse :
221         await fetch(urlOrResponse);
222     if (!resp.ok) {
223         throw `${resp.url}: ${resp.status}`;
224     }
225
226     const parser = new DOMParser();
227     const txt = await resp.text();
228     return parser.parseFromString(txt, "text/html");
229 }
230
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);
235
236     /* Have to use explicit getAttribute() below since there is an input with
237      * name="action" which would be accessed with `form.action` :-/
238      */
239     const resp = await fetch(toAbsoluteURL(form.getAttribute("action")),
240         { body: new URLSearchParams(formData), method: "POST" });
241     if (!resp.ok) {
242         throw `${resp.url}: ${resp.status}`;
243     }
244
245     if (requiresConfirmation) {
246         await confirmAction(resp);
247     }
248 }
249
250 function prefixSubject(input, reason) {
251     let subject = input.value;
252
253     if (!reason) {
254         return subject;
255     }
256
257     const prefix = `[${reason}]`;
258     if (subject.toLowerCase().includes(prefix.toLowerCase())) {
259         return subject;
260     }
261
262     subject = `${prefix} ${subject}`;
263     const maxLen = input.getAttribute("maxlength") ?? subject.length;
264     return subject.slice(0, maxLen);
265 }
266
267 function redirectToArchive() {
268     /* TODO: Make the location configurable, redirect to homepage, "Aktive
269      * Themen", "Neue Beiträge", or other?
270      */
271     window.location = `./viewforum.php?f=${ARCHIVFORUMID}`;
272 }
273
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);
280
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. */
284         return;
285     }
286
287     const archivingPost = send_mcp_request_archival(post, splitReason);
288
289     /* Prompting for a separate ban reason in case there is something more
290      * specific to note here.
291      */
292     const userStillExists = usernameElem.nodeName === "A";
293     const banReasonPrompt = userStillExists &&
294         asyncPrompt(`Benutzer „${username}“ sperren?\n\nGrund:`, "Spam");
295
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.
301      *
302      * This means we cannot really execute the actions concurrently here,
303      * unfortunately. User interaction is still done in parallel to one action at
304      * a time, though.
305      */
306     const errors = [];
307     try {
308         await archivingPost;
309     } catch (err) {
310         errors.push(err);
311     }
312
313     let banningUser;
314     const banReason = await banReasonPrompt;
315     if (banReason) {
316         banningUser = banUser(username, banReason);
317     } else if (!userStillExists) {
318         await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
319     }
320
321     const shouldCloseReport = isPostReported(post) &&
322         asyncConfirm("Meldung zum Beitrag schließen?");
323
324     try {
325         await banningUser;
326     } catch (err) {
327         errors.push(err);
328     }
329
330     if (await shouldCloseReport) {
331         try {
332             await closeReport(post);
333         } catch (err) {
334             errors.push(err);
335         }
336     }
337
338     for (const error of errors) {
339         console.log(error);
340         window.alert(`ACHTUNG!\n\n${error}`);
341     }
342
343     if (errors.length === 0) {
344         updatePageAfterSplit(post);
345     }
346 }
347
348 async function send_mcp_request_archival(post, reason) {
349     const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']");
350
351     let form, formData;
352     try {
353         [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp");
354     } catch (err) {
355         throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`;
356     }
357
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);
362
363     try {
364         await postForm(form, formData, "mcp_topic_submit", true);
365     } catch (err) {
366         throw `Konnte Thema nicht aufteilen: ${err}`;
367     }
368 }
369
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";
375
376     const form = dialog.appendChild(document.createElement("form"));
377     form.method = "dialog";
378
379     const p = form.appendChild(document.createElement("p"));
380     p.style.whiteSpace = "pre-line";
381     p.textContent = message;
382
383     if (defaultValue !== null) {
384         const inputP = form.appendChild(document.createElement("p"));
385         inputP.innerHTML = `<input class="inputbox" name="value" type="text" value="${defaultValue}">`;
386     }
387
388     const submitButtons = form.appendChild(document.createElement("fieldset"));
389     submitButtons.className = "submit-buttons";
390     submitButtons.innerHTML = '<input class="button1" type="submit" value="OK"> ';
391     if (abortable) {
392         const abortBtn = submitButtons.appendChild(document.createElement("input"));
393         abortBtn.className = "button2";
394         abortBtn.type = "submit";
395         abortBtn.value = "Abbrechen";
396     }
397
398     dialog.showModal();
399
400     return new Promise((resolve) => {
401         dialog.addEventListener("close", (event) => {
402             event.currentTarget.remove();
403             resolve(returnFunc?.(event.currentTarget));
404         });
405     });
406 }
407
408 function toAbsoluteURL(relativeOrAbsoluteURL) {
409     return new URL(relativeOrAbsoluteURL, window.location);
410 }
411
412 function updatePageAfterSplit(post) {
413     if (document.querySelectorAll(".post").length > 1) {
414         post.parentNode.removeChild(post);
415     } else {
416         redirectToArchive();
417     }
418 }
419
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);
428
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);
433     }
434
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";
442
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 }");
446 }
447
448 add_buttons();