diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..33d2cfa --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "arrowParens": "avoid", + "semi": false +} diff --git a/README.md b/README.md index f38ec51..8080d86 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # Wikidot applications deletion userscript -[Tampermonkey](https://www.tampermonkey.net/) userscript for -[Wikidot](https://www.wikidot.com/) users. +[Tampermonkey](https://www.tampermonkey.net/) userscript for [Wikidot](https://www.wikidot.com/) users. Adds two buttons to the messages inbox: -* **Delete recent applications:** Deletes applications on the first page of - the user's inbox, then the second, and so on until a page is found that - already has no applications. -* **Delete all applications:** Deletes all applications in the user's - inbox. +* **Delete recent applications:** Deletes applications on the first page of the user's inbox, then the second, and so on until a page is found that already has no applications. +* **Delete all applications:** Deletes all applications in the user's inbox.

@@ -17,28 +13,27 @@ Adds two buttons to the messages inbox: Before deletion is committed, a confirmation dialogue will be raised. -Especially useful for Wikidot administrators of popular sites, whose -inboxes will quickly become full of applications, drowning out actual -messages from other users. Use at own risk. +Especially useful for Wikidot administrators of popular sites, whose inboxes will quickly become full of applications, drowning out actual messages from other users. Use at own risk. Installation instructions: https://scpwiki.com/usertools#userscripts ## Installation via Tampermonkey +This method permanently adds the two buttons to your Wikidot inbox. They will be there for as long as you have both Tampermonkey and this userscript installed. + 1. Install [Tampermonkey](https://www.tampermonkey.net/). -2. Visit the [userscript - directly](https://github.com/croque-scp/delete-applications/raw/main/delete-applications.user.js). -3. Tampermonkey will prompt you to install the userscript. Click 'install' - to do so, being sure to review the code first. +2. Visit the [userscript directly](https://github.com/croque-scp/delete-applications/raw/main/delete-applications.user.js). +3. Tampermonkey will prompt you to install the userscript. Click 'install' to do so, being sure to review the code first. +4. Visit your [Wikidot inbox](https://www.wikidot.com/account/messages). The two buttons will be there. + +Uninstallation: Go to your Tampermonkey dashboard, which can be found in your browser extensions page. Click the bin icon next to the 'Wikidot applications deleter' script. ## Usage without Tampermonkey -1. Visit the [userscript - directly](https://github.com/croque-scp/delete-applications/raw/main/delete-applications.user.js) - and copy the whole thing. -2. Visit your [Wikidot inbox](https://www.wikidot.com/account/messages) and - open the JavaScript console. -3. Paste the userscript into the console and press enter. -4. Enter one of the following, and then press enter: - * `deleteApplications()` to delete recent applications - * `deleteApplications(true)` to delete all applications +This method adds the two buttons to your Wikidot inbox once only. They will no longer be there as soon as you leave the page. + +1. Visit the [userscript directly](https://github.com/croque-scp/delete-applications/raw/main/delete-applications.user.js) and copy the whole thing. +2. Visit your [Wikidot inbox](https://www.wikidot.com/account/messages) and open the JavaScript console. +3. Paste the userscript into the console and press enter. The two buttons will appear. + +This is a one-off process that must be repeated every time you want to delete applications. Use this method if you don't want to (or can't) install this tool as a Tampermonkey userscript. \ No newline at end of file diff --git a/delete-applications.user.js b/delete-applications.user.js index 280c0a6..7959d55 100644 --- a/delete-applications.user.js +++ b/delete-applications.user.js @@ -4,6 +4,31 @@ Wikidot applications deleter userscript For installation instructions, see https://scpwiki.com/usertools */ +/* CHANGELOG + +v1.3.0 +- Added changelog. +- Removed extra commas from the confirmation popup when deleting applications from more than one site. +- Deletes now execute in batches of 100 separated by a short delay to bypass Wikidot's single-request limit of 996. +- Made buttons larger and added more support links. + +v1.2.0 (2023-07-07) +- Added a list of sites to the deletion confirmation popup that tells you which Wikidot sites the applications come from, and how many there are per site. + +v1.1.0 (2022-04-11) +- Added new feature 'delete recent applications' that deletes applications page-by-page until encountering a page with no applications. +- Removed feature 'delete applications on current page'. +- After scanning pages of messages, script now puts you back on the first page instead of leaving you wherever it stopped. +- The delete buttons are now visible on all pages of the inbox instead of just the first. + +v1.0.1 (2022-03-06) +- Hid buttons when reading a message. +- Fixed deletion confirmation popup interfering with message composer UI. + +v1.0.0 (2022-03-01) +- Created userscript. +*/ + // ==UserScript== // @name Wikidot applications deleter // @description Adds a button to delete applications from your Wikidot inbox. @@ -17,45 +42,99 @@ For installation instructions, see https://scpwiki.com/usertools /* global WIKIDOT, OZONE */ -let deleteButtonsContainer +/* ===== Utilities ===== */ const deleterDebug = log => console.debug("Applications deleter:", log) +const supportUser = showAvatar => ` + ${ + showAvatar + ? `` + : "" + } + + ${ + showAvatar + ? `` + : "" + }Croquembouche + + ${showAvatar ? `` : ""} +` + +function getMessagesOnPage() { + return Array.from(document.querySelectorAll("tr.message")).map( + el => new Message(el) + ) +} + +function countSelected(messages) { + return messages.reduce((a, b) => a + b.isSelected, 0) +} + +class Counter { + constructor(array) { + array.forEach(val => (this[val] = (this[val] || 0) + 1)) + } +} + /** - * Collates details about a message based on its little preview. + * Waits for the given number of milliseconds. + * @param {Number} ms */ +async function wait(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + class Message { + /** + * Collates details about a message based on its little preview. + * @param {HTMLElement} messageElement - Inbox container. + */ constructor(messageElement) { + /** @type {HTMLInputElement} */ this.selector = messageElement.querySelector("input[type=checkbox]") + /** @type {String} */ this.id = this.selector.value // Extract the sender and the subject const from = messageElement.querySelector("td .from .printuser") - this.fromWikidot = ( + this.fromWikidot = !from.classList.contains("avatarhover") && from.innerText === "Wikidot" - ) this.subject = messageElement.querySelector(".subject").innerText this.previewText = messageElement.querySelector(".preview").innerText // Is this message an application? - this.isApplication = ( - this.fromWikidot - && this.subject === "You received a membership application" - ) + this.isApplication = + this.fromWikidot && + this.subject === "You received a membership application" if (this.isApplication) { // Which wiki is the application for? - const wikiMatch = this.previewText.match(/applied for membership on (.*), one of your sites/) + const wikiMatch = this.previewText.match( + /applied for membership on (.*), one of your sites/ + ) if (wikiMatch) this.applicationWiki = wikiMatch[1] else this.isApplication = false } } - select() { this.selector.checked = true } - deselect() { this.selector.checked = false } - get isSelected() { return this.selector.checked } + select() { + this.selector.checked = true + } + deselect() { + this.selector.checked = false + } + get isSelected() { + return this.selector.checked + } } +/* ===== */ + async function deleteApplications(deleteAll = false) { const applications = [] const messageElement = document.getElementById("message-area") @@ -63,7 +142,11 @@ async function deleteApplications(deleteAll = false) { let goToNextPage = true let thereAreMorePages = true - firstPage(messageElement) + const scanningModal = new OZONE.dialogs.WaitBox() + scanningModal.content = "Scanning your inbox for applications..." + scanningModal.show() + + await firstPage(messageElement) do { const messages = getMessagesOnPage() @@ -79,86 +162,169 @@ async function deleteApplications(deleteAll = false) { }) // Save all selected messages - const selectedMessages = messages - .filter(message => message.isSelected) + const selectedMessages = messages.filter(message => message.isSelected) deleterDebug(`Found ${selectedMessages.length} applications`) applications.push(selectedMessages) - // If there were no selected messages, and we are only deleting recent - // messages (i.e. deleteAll is false), don't go to the next page + // If there were no selected messages, and we are only deleting recent messages (i.e. deleteAll is false), don't go to the next page if (selectedMessages.length === 0 && !deleteAll) goToNextPage = false if (goToNextPage) thereAreMorePages = await nextPage(messageElement) } while (goToNextPage && thereAreMorePages) - // Delete all saved messages - deleteMessages(applications.flat()) - - firstPage(messageElement) -} + // Reset UI back to the first page + await firstPage(messageElement) -function getMessagesOnPage() { - return Array.from( - document.querySelectorAll("tr.message") - ).map(el => new Message(el)) + // Delete all saved messages + createDeleteConfirmationModal(applications.flat()) } -function countSelected(messages) { - return messages.reduce((a, b) => a + b.isSelected, 0) -} +/** + * @param {Message[]} messages + */ +function createDeleteConfirmationModal(messages) { + const messagesCount = messages.length -function deleteMessages(messages) { // Collate the wikis that the applications were for const wikiCounter = new Counter(messages.map(m => m.applicationWiki)) // Produce a confirmation modal with the number of applications to delete const confirmModal = new OZONE.dialogs.ConfirmationDialog() + const applicationSitesList = Object.entries(wikiCounter).map( + ([wiki, count]) => `

  • ${wiki}: ${count}
  • ` + ) confirmModal.content = ` -

    Delete ${messages.length} applications?

    - +

    Delete ${messagesCount} applications?

    +

    Please report any issues during the deletion process to ${supportUser( + true + )}.

    + ` confirmModal.buttons = ["cancel", "delete applications"] confirmModal.addButtonListener("cancel", confirmModal.close) - confirmModal.addButtonListener("delete applications", () => { - const request = { - action: "DashboardMessageAction", - event: "removeMessages", - messages: messages.map(m => m.id) - } - OZONE.ajax.requestModule(null, request, () => { + confirmModal.addButtonListener("delete applications", async () => { + const progressModal = new OZONE.dialogs.SuccessBox() + progressModal.content = ` +

    Deleting ${messagesCount} applications...

    +

    + + ` + progressModal.timeout = null + progressModal.show() + + const success = await deleteMessagesBatches( + messages, + async (batchIndex, batchCount, batchSize) => { + if (batchCount === 1) return + document.getElementById("delete-progress-text").textContent = ` + Batch ${batchIndex + 1} of ${batchCount} (${batchSize} applications) + ` + document.getElementById("delete-progress").max = batchCount + document.getElementById("delete-progress").value = batchIndex + 1 + await wait(1500) + } + ) + + WIKIDOT.modules.DashboardMessagesModule.app.refresh() + + if (success) { const successModal = new OZONE.dialogs.SuccessBox() - successModal.content = "Deleted applications." + successModal.content = ` +

    Deleted ${messagesCount} applications.

    + ` successModal.show() - WIKIDOT.modules.DashboardMessagesModule.app.refresh() - }) + } else { + const errorModal = new OZONE.dialogs.ErrorDialog() + errorModal.content = ` +

    Failed to delete applications.

    +

    Please send a message to ${supportUser(true)}.

    + ` + errorModal.show() + } }) + confirmModal.focusButton = "cancel" confirmModal.show() } -function shouldShowDeleteButtons(hash) { - return hash === "" || hash.indexOf("inbox") !== -1 +/** + * @callback deleteMessagesBatches_beforeBatch + * @param {Number} batchIndex + * @param {Number} batchCount + * @param {Number} batchSize + * @return {Promise} + */ + +/** + * Deletes the given messages in batches. + * @param {Message[]} messages + * @param {deleteMessagesBatches_beforeBatch} beforeBatch - Callback that receives deletion progress info. + * @return {Promise} True when all deletes succeeded. + */ +async function deleteMessagesBatches(messages, beforeBatch) { + const batchSize = 100 + const batchCount = Math.ceil(messages.length / batchSize) + let batchIndex = 0 + while (messages.length) { + const batch = messages.splice(0, batchSize) + await beforeBatch(batchIndex, batchCount, batch.length) + try { + await deleteMessages(batch.map(message => message.id)) + } catch (error) { + deleterDebug("Deletes failed") + console.error(error) + return false + } + batchIndex += 1 + } + return true +} + +/** + * Delete the messages with the given IDs. + * @param {Number[]} messageIds + */ +function deleteMessages(messageIds) { + return new Promise((resolve, reject) => { + try { + OZONE.ajax.requestModule( + null, + { + action: "DashboardMessageAction", + event: "removeMessages", + messages: messageIds, + }, + resolve + ) + } catch (error) { + reject(error) + } + }) } -function toggleDeleteButtons() { - deleteButtonsContainer.style.display = - shouldShowDeleteButtons(location.hash) ? "" : "none" +/** + * Whether to show the deletion buttons, based on the current URL. + * @returns {Boolean} + */ +function shouldShowDeleteButtons() { + return /^(#(\/inbox(\/(p[0-9]+\/?)?)?)?)?$/.test(location.hash) } +/** + * Go to the first page of messages. + * @param {HTMLElement} messageElement - Inbox container + * @returns {Promise} + */ async function firstPage(messageElement) { deleterDebug("Going to first page") const pager = messageElement.querySelector(".pager") - if (pager == null) return + if (pager == null) return false const currentPageButton = pager.querySelector(".current") - if (currentPageButton == null) return - if (currentPageButton.textContent.trim() === "1") return + if (currentPageButton == null) return false + if (currentPageButton.textContent.trim() === "1") return false // The first page button should always be visible const firstPageButton = pager.querySelector(".target [href='#/inbox/p1']") - if (firstPageButton == null) return + if (firstPageButton == null) return false // Click the button and return once the page has reloaded await new Promise(resolve => { @@ -173,20 +339,11 @@ async function firstPage(messageElement) { return true } - -/** - * Like Python's collections.Counter, returns an object with value keys and - * count values. Use with new. - */ -function Counter(array) { - array.forEach(val => (this[val] = (this[val] || 0) + 1)) -} - /** - * Iterate the next page of messages. + * Iterate to the next page of messages. * - * Returns false if this is the last page, otherwise returns true after the - * page has loaded. + * @param {HTMLElement} messageElement - Inbox container + * @returns {Promise} False if last page; otherwise wait for next page to load then true. */ async function nextPage(messageElement) { deleterDebug("Going to next page") @@ -209,39 +366,65 @@ async function nextPage(messageElement) { return true } -(function main() { +;(function () { // Create the buttons const deleteRecentButton = document.createElement("button") deleteRecentButton.innerText = "Delete recent applications" - deleteRecentButton.classList.add("red", "btn", "btn-xs", "btn-danger") + deleteRecentButton.classList.add("red", "btn", "btn-danger") deleteRecentButton.title = ` Delete recent applications. - Deletes applications on the first page, then the second, and so on, until - a page with no applications is found. - `.replace(/\s+/g, " ") + Deletes applications on the first page, then the second, and so on, until a page with no applications is found. + ` + .replace(/\s+/g, " ") + .trim() deleteRecentButton.addEventListener("click", () => deleteApplications(false)) const deleteAllButton = document.createElement("button") deleteAllButton.innerText = "Delete all applications" - deleteAllButton.classList.add("red", "btn", "btn-xs", "btn-danger") + deleteAllButton.classList.add("red", "btn", "btn-danger") deleteAllButton.title = ` Delete all applications in your inbox. May take a while if you have a lot. - `.replace(/\s+/g, " ") + ` + .replace(/\s+/g, " ") + .trim() deleteAllButton.addEventListener("click", () => deleteApplications(true)) - deleteButtonsContainer = document.createElement("div") - deleteButtonsContainer.style.textAlign = "right" - deleteButtonsContainer.append(deleteRecentButton, " ", deleteAllButton) - toggleDeleteButtons() + const deleteButtonsContainer = document.createElement("div") + deleteButtonsContainer.style.border = "thin solid lightgrey" + deleteButtonsContainer.style.borderRadius = "0.5rem" + deleteButtonsContainer.style.display = shouldShowDeleteButtons() + ? "flex" + : "none" + deleteButtonsContainer.style.flexDirection = "column" + deleteButtonsContainer.style.maxWidth = "max-content" + deleteButtonsContainer.style.padding = "1rem 1rem 0" + deleteButtonsContainer.style.margin = "1.5rem 0 1.5rem auto" + deleteButtonsContainer.innerHTML = ` +

    + Delete applications userscript by ${supportUser()} +

    +

    + ` - const buttonLocation = document.getElementById("message-area").parentElement - buttonLocation.prepend(deleteButtonsContainer) + document + .getElementById("message-area") + .parentElement.prepend(deleteButtonsContainer) + document + .getElementById("delete-buttons") + .append(deleteRecentButton, deleteAllButton) + + // Detect clicks to messages and inbox tabs and hide/show buttons as appropriate + addEventListener("click", () => + setTimeout(() => { + deleteButtonsContainer.style.display = shouldShowDeleteButtons() + ? "flex" + : "none" + }, 500) + ) })() - -// Detect clicks to messages and inbox tabs and hide/show buttons as appropriate -addEventListener("click", () => { - setTimeout(() => { - toggleDeleteButtons() - }, 500) -}) diff --git a/screenshot.png b/screenshot.png index a9cb722..b773393 100644 Binary files a/screenshot.png and b/screenshot.png differ