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