]> git.aero2k.de Git - dfde/quickmods.git/blob - quickmod.user.js
5f8c8856c502c13dfe9c7f0c668d047fa0ba742e
[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.4
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     window.alert(message);
147 }
148
149 async function asyncConfirm(message) {
150     return window.confirm(message);
151 }
152
153 async function asyncPrompt(message, defaultValue) {
154     return window.prompt(message, defaultValue);
155 }
156
157 async function banUser(username, reason) {
158     /* The URL to the ban form does not need any IDs or hidden inputs. We
159      * hardcode it here.
160      */
161     let form, formData;
162     try {
163         [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"),
164             "form#mcp_ban");
165     } catch (err) {
166         throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`;
167     }
168
169     formData.set("ban", username);
170     formData.set("banreason", reason);
171     //formData.set("bangivereason", reason);
172     try {
173         await postForm(form, formData, "bansubmit", true);
174     } catch (err) {
175         throw `Konnte Benutzer nicht sperren: ${err}`;
176     }
177 }
178
179 async function closeReport(post) {
180     const reportLink = post.querySelector(".post-notice.reported a");
181
182     let form, formData;
183     try {
184         [form, formData] = await openForm(toAbsoluteURL(reportLink.href),
185             "form#mcp_report");
186     } catch (err) {
187         throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`;
188     }
189
190     try {
191         await postForm(form, formData, "action[close]", true);
192     } catch (err) {
193         throw `Konnte Meldung nicht schließen: ${err}`;
194     }
195 }
196
197 async function confirmAction(response) {
198     const [form, formData] = await openForm(response, "form#confirm");
199     await postForm(form, formData, "confirm");
200 }
201
202 function ellipsify(str, maxlen) {
203     const ell = str.length > maxlen ? " […]" : "";
204     return str.substring(0, maxlen - ell.length) + ell;
205 }
206
207 function isPostReported(post) {
208     return post.querySelector(".post-notice.reported a") !== null;
209 }
210
211 async function openForm(urlOrResponse, selector) {
212     const doc = await openDoc(urlOrResponse);
213     const form = doc.querySelector(selector);
214     return [form, new FormData(form)];
215 }
216
217 async function openDoc(urlOrResponse) {
218     const resp = urlOrResponse instanceof Response ? urlOrResponse :
219         await fetch(urlOrResponse);
220     if (!resp.ok) {
221         throw `${resp.url}: ${resp.status}`;
222     }
223
224     const parser = new DOMParser();
225     const txt = await resp.text();
226     return parser.parseFromString(txt, "text/html");
227 }
228
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);
233
234     /* Have to use explicit getAttribute() below since there is an input with
235      * name="action" which would be accessed with `form.action` :-/
236      */
237     const resp = await fetch(toAbsoluteURL(form.getAttribute("action")),
238         { body: new URLSearchParams(formData), method: "POST" });
239     if (!resp.ok) {
240         throw `${resp.url}: ${resp.status}`;
241     }
242
243     if (requiresConfirmation) {
244         await confirmAction(resp);
245     }
246 }
247
248 function prefixSubject(input, reason) {
249     let subject = input.value;
250
251     if (!reason) {
252         return subject;
253     }
254
255     const prefix = `[${reason}]`;
256     if (subject.toLowerCase().includes(prefix.toLowerCase())) {
257         return subject;
258     }
259
260     subject = `${prefix} ${subject}`;
261     const maxLen = input.getAttribute("maxlength") ?? subject.length;
262     return subject.slice(0, maxLen);
263 }
264
265 function redirectToArchive() {
266     /* TODO: Make the location configurable, redirect to homepage, "Aktive
267      * Themen", "Neue Beiträge", or other?
268      */
269     window.location = `./viewforum.php?f=${ARCHIVFORUMID}`;
270 }
271
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);
278
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. */
282         return;
283     }
284
285     const archivingPost = send_mcp_request_archival(post, splitReason);
286
287     /* Prompting for a separate ban reason in case there is something more
288      * specific to note here.
289      */
290     const userStillExists = usernameElem.nodeName === "A";
291     const banReasonPrompt = userStillExists &&
292         asyncPrompt(`Benutzer „${username}“ sperren?\n\nGrund:`, "Spam");
293
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.
299      *
300      * This means we cannot really execute the actions concurrently here,
301      * unfortunately. User interaction is still done in parallel to one action at
302      * a time, though.
303      */
304     const errors = [];
305     try {
306         await archivingPost;
307     } catch (err) {
308         errors.push(err);
309     }
310
311     let banningUser;
312     const banReason = await banReasonPrompt;
313     if (banReason) {
314         banningUser = banUser(username, banReason);
315     } else if (!userStillExists) {
316         await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
317     }
318
319     const shouldCloseReport = isPostReported(post) &&
320         asyncConfirm("Meldung zum Beitrag schließen?");
321
322     try {
323         await banningUser;
324     } catch (err) {
325         errors.push(err);
326     }
327
328     if (await shouldCloseReport) {
329         try {
330             await closeReport(post);
331         } catch (err) {
332             errors.push(err);
333         }
334     }
335
336     for (const error of errors) {
337         console.log(error);
338         window.alert(`ACHTUNG!\n\n${error}`);
339     }
340
341     if (errors.length === 0) {
342         updatePageAfterSplit(post);
343     }
344 }
345
346 async function send_mcp_request_archival(post, reason) {
347     const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']");
348
349     let form, formData;
350     try {
351         [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp");
352     } catch (err) {
353         throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`;
354     }
355
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);
360
361     try {
362         await postForm(form, formData, "mcp_topic_submit", true);
363     } catch (err) {
364         throw `Konnte Thema nicht aufteilen: ${err}`;
365     }
366 }
367
368 function toAbsoluteURL(relativeOrAbsoluteURL) {
369     return new URL(relativeOrAbsoluteURL, window.location);
370 }
371
372 function updatePageAfterSplit(post) {
373     if (document.querySelectorAll(".post").length > 1) {
374         post.parentNode.removeChild(post);
375     } else {
376         redirectToArchive();
377     }
378 }
379
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);
388
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);
393     }
394
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";
402 }
403
404 add_buttons();