]> git.aero2k.de Git - dfde/quickmods.git/blob - quickmod.user.js
2c06ddea486cb88af02ac40d0626c508d2759939
[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 asyncAlert(message) {
14     window.alert(message);
15 }
16
17 async function asyncConfirm(message) {
18     return window.confirm(message);
19 }
20
21 async function asyncPrompt(message, defaultValue) {
22     return window.prompt(message, defaultValue);
23 }
24
25 async function banUser(username, reason) {
26     /* The URL to the ban form does not need any IDs or hidden inputs. We
27      * hardcode it here.
28      */
29     let form, formData;
30     try {
31         [form, formData] = await openForm(toAbsoluteURL("./mcp.php?i=ban&mode=user"),
32             "form#mcp_ban");
33     } catch (err) {
34         throw `Konnte Formular zum Sperren von Benutzern nicht öffnen: ${err}`;
35     }
36
37     formData.set("ban", username);
38     formData.set("banreason", reason);
39     //formData.set("bangivereason", reason);
40     try {
41         await postForm(form, formData, "bansubmit", true);
42     } catch (err) {
43         throw `Konnte Benutzer nicht sperren: ${err}`;
44     }
45 }
46
47 async function closeReport(post) {
48     const reportLink = post.querySelector(".post-notice.reported a");
49
50     let form, formData;
51     try {
52         [form, formData] = await openForm(toAbsoluteURL(reportLink.href),
53             "form#mcp_report");
54     } catch (err) {
55         throw `Konnte Formular zum Schließen der Meldung nicht öffnen: ${err}`;
56     }
57
58     try {
59         await postForm(form, formData, "action[close]", true);
60     } catch (err) {
61         throw `Konnte Meldung nicht schließen: ${err}`;
62     }
63 }
64
65 async function confirmAction(response) {
66     const [form, formData] = await openForm(response, "form#confirm");
67     await postForm(form, formData, "confirm");
68 }
69
70 function ellipsify(str, maxlen) {
71     const ell = str.length > maxlen ? " […]" : "";
72     return str.substring(0, maxlen - ell.length) + ell;
73 }
74
75 function isPostReported(post) {
76     return post.querySelector(".post-notice.reported a") !== null;
77 }
78
79 async function openForm(urlOrResponse, selector) {
80     const resp = urlOrResponse instanceof Response ? urlOrResponse :
81         await fetch(urlOrResponse);
82     if (!resp.ok) {
83         throw `${resp.url}: ${resp.status}`;
84     }
85
86     const parser = new DOMParser();
87     const txt = await resp.text();
88     const doc = parser.parseFromString(txt, "text/html");
89     const form = doc.querySelector(selector);
90     return [form, new FormData(form)];
91 }
92
93 async function postForm(form, formData, submitName, requiresConfirmation = false) {
94     /* "Press" the right submit button. */
95     const submitBtn = form.elements[submitName];
96     formData.set(submitBtn.name, submitBtn.value);
97
98     /* Have to use explicit getAttribute() below since there is an input with
99      * name="action" which would be accessed with `form.action` :-/
100      */
101     const resp = await fetch(toAbsoluteURL(form.getAttribute("action")),
102         { body: new URLSearchParams(formData), method: "POST" });
103     if (!resp.ok) {
104         throw `${resp.url}: ${resp.status}`;
105     }
106
107     if (requiresConfirmation) {
108         await confirmAction(resp);
109     }
110 }
111
112 function prefixSubject(input, reason) {
113     let subject = input.value;
114
115     if (!reason) {
116         return subject;
117     }
118
119     const prefix = `[${reason}]`;
120     if (subject.toLowerCase().includes(prefix.toLowerCase())) {
121         return subject;
122     }
123
124     subject = `${prefix} ${subject}`;
125     const maxLen = input.getAttribute("maxlength") ?? subject.length;
126     return subject.slice(0, maxLen);
127 }
128
129 function redirectToArchive() {
130     /* TODO: Make the location configurable, redirect to homepage, "Aktive
131      * Themen", "Neue Beiträge", or other?
132      */
133     window.location = `./viewforum.php?f=${ARCHIVFORUMID}`;
134 }
135
136 async function remove_post_handler(event) {
137     const post = event.currentTarget.closest('.post');
138     const usernameElem = post.querySelector(".author .username,.author .username-coloured");
139     const username = usernameElem.textContent;
140     const thread_title = document.querySelector('.topic-title a').text;
141     const content = ellipsify(post.querySelector(".content").innerText, 250);
142
143     const splitReason = await asyncPrompt(`Folgenden Beitrag von „${username}“ im Thema „${ellipsify(thread_title, 100)}“ wirklich als Spam archivieren?\n\n„${content}“\n\nGrund:`, "Spam");
144     if (splitReason === null) {
145         /* Don't do any of the other actions if splitting was cancelled. */
146         return;
147     }
148
149     const archivingPost = send_mcp_request_archival(post, splitReason);
150
151     /* Prompting for a separate ban reason in case there is something more
152      * specific to note here.
153      */
154     const userStillExists = usernameElem.nodeName === "A";
155     const banReasonPrompt = userStillExists &&
156         asyncPrompt(`Benutzer „${username}“ sperren?\n\nGrund:`, "Spam");
157
158     /* Mod actions via mcp.php involve a confirm_key which is stored in the
159      * database when an action is requested until it is confirmed. There can only
160      * be one confirm_key stored at a time---meaning there cannot be multiple mcp
161      * actions executed concurrently. See confirm_box() in
162      * phpBB/includes/functions.php.
163      *
164      * This means we cannot really execute the actions concurrently here,
165      * unfortunately. User interaction is still done in parallel to one action at
166      * a time, though.
167      */
168     const errors = [];
169     try {
170         await archivingPost;
171     } catch (err) {
172         errors.push(err);
173     }
174
175     let banningUser;
176     const banReason = await banReasonPrompt;
177     if (banReason) {
178         banningUser = banUser(username, banReason);
179     } else if (!userStillExists) {
180         await asyncAlert(`Benutzer „${username}“ wurde schon gelöscht und kann nicht mehr gesperrt werden.`);
181     }
182
183     const shouldCloseReport = isPostReported(post) &&
184         asyncConfirm("Meldung zum Beitrag schließen?");
185
186     try {
187         await banningUser;
188     } catch (err) {
189         errors.push(err);
190     }
191
192     if (await shouldCloseReport) {
193         try {
194             await closeReport(post);
195         } catch (err) {
196             errors.push(err);
197         }
198     }
199
200     for (const error of errors) {
201         console.log(error);
202         window.alert(`ACHTUNG!\n\n${error}`);
203     }
204
205     if (errors.length === 0) {
206         updatePageAfterSplit(post);
207     }
208 }
209
210 async function send_mcp_request_archival(post, reason) {
211     const splitLink = document.querySelector("#quickmod .dropdown-contents a[href*='action=split']");
212
213     let form, formData;
214     try {
215         [form, formData] = await openForm(toAbsoluteURL(splitLink.href), "form#mcp");
216     } catch (err) {
217         throw `Konnte Formular zum Aufteilen des Themas nicht öffnen: ${err}`;
218     }
219
220     const post_id = post.id.slice(1);
221     formData.set("post_id_list[]", post_id);
222     formData.set("subject", prefixSubject(form.elements["subject"], reason));
223     formData.set("to_forum_id", ARCHIVFORUMID);
224
225     try {
226         await postForm(form, formData, "mcp_topic_submit", true);
227     } catch (err) {
228         throw `Konnte Thema nicht aufteilen: ${err}`;
229     }
230 }
231
232 function toAbsoluteURL(relativeOrAbsoluteURL) {
233     return new URL(relativeOrAbsoluteURL, window.location);
234 }
235
236 function updatePageAfterSplit(post) {
237     if (document.querySelectorAll(".post").length > 1) {
238         post.parentNode.removeChild(post);
239     } else {
240         redirectToArchive();
241     }
242 }
243
244 function add_buttons() {
245     const del_post_btn_outer = document.createElement('li');
246     const del_post_btn = document.createElement('a');
247     del_post_btn.className = 'button button-icon-only';
248     del_post_btn.innerHTML = '<i class="icon fa-fire-extinguisher fa-fw" aria-hidden="true"></i><span class="sr-only">Abfall</span>';
249     del_post_btn.addEventListener("click", remove_post_handler);
250     del_post_btn.title = "Als Spam archivieren";
251     del_post_btn_outer.append(del_post_btn);
252
253     for (const postButtons of document.querySelectorAll(".post-buttons")) {
254         const del_post_btn_outer_clone = del_post_btn_outer.cloneNode(true);
255         del_post_btn_outer_clone.addEventListener("click", remove_post_handler);
256         postButtons.appendChild(del_post_btn_outer_clone);
257     }
258 }
259
260 add_buttons();