Last active
October 8, 2025 22:44
-
-
Save Clorith/ff18773cecbe4605455983e562e94a33 to your computer and use it in GitHub Desktop.
tampermonkey script to bulk delete comments on Trac within a ticket. This is all AI prompted, no warranties included 😅
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Trac Bulk Comment Delete | |
// @namespace https://core.trac.wordpress.org/ | |
// @version 1.1.0 | |
// @description Add checkboxes next to deletable Trac comments and bulk delete them via AJAX, reusing existing hidden inputs. | |
// @author You | |
// @match https://*.trac.wordpress.org/ticket/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=wordpress.org | |
// @grant none | |
// ==/UserScript== | |
(function () { | |
"use strict"; | |
// Utilities | |
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel)); | |
const el = (tag, props = {}, children = []) => { | |
const n = document.createElement(tag); | |
Object.entries(props).forEach(([k, v]) => { | |
if (k === "class") n.className = v; | |
else if (k === "text") n.textContent = v; | |
else if (k === "html") n.innerHTML = v; | |
else if (k === "dataset") Object.assign(n.dataset, v); | |
else if (k === "style" && typeof v === "string") n.setAttribute("style", v); | |
else if (k === "style" && typeof v === "object") Object.assign(n.style, v); | |
else n.setAttribute(k, v); | |
}); | |
children.forEach(c => n.appendChild(c)); | |
return n; | |
}; | |
// Parse ticket ID (used when constructing delete URLs) | |
const ticketMatch = location.pathname.match(/\/ticket\/(\d+)(?:$|[?#])/); | |
const ticket = ticketMatch ? ticketMatch[1] : ""; | |
// Find each delete button; WordPress Trac shows "– Delete" (en-dash) but we’ll be tolerant. | |
// Buttons live inside a <form><div class="inlinebuttons"> … </div></form> block, with hidden inputs as siblings. | |
const deleteButtons = $$('input.trac-delete[type="submit"], input[type="submit"][value$="Delete"]'); | |
const targets = []; // { checkbox, form, container, cnumInput, cdateInput } | |
deleteButtons.forEach(btn => { | |
// Ensure we’re dealing with a comment-delete control set | |
// Must have sibling hidden inputs: action=delete-comment, cnum, cdate | |
const inline = btn.closest(".inlinebuttons") || btn.parentElement; | |
if (!inline) return; | |
const form = btn.closest("form"); | |
if (!form) return; | |
const actionInput = inline.querySelector('input[type="hidden"][name="action"][value="delete-comment"]'); | |
const cnumInput = inline.querySelector('input[type="hidden"][name="cnum"]'); | |
const cdateInput = inline.querySelector('input[type="hidden"][name="cdate"]'); | |
if (!actionInput || !cnumInput || !cdateInput) return; | |
// Mount a checkbox and label just after the existing buttons. | |
// Avoid duplicating if script re-runs (e.g., PJAX/partial reload). | |
if (inline.querySelector('.tmk-bulkdel-wrap')) return; | |
const wrap = el('label', { | |
class: 'tmk-bulkdel-wrap', | |
style: 'display:inline-flex;gap:.35rem;align-items:center;margin-left:.5rem;' | |
}); | |
const checkbox = el('input', { type: 'checkbox' }); | |
const label = el('span', { text: 'Mark for deletion', style: 'font-weight:500;' }); | |
wrap.appendChild(checkbox); | |
wrap.appendChild(label); | |
inline.appendChild(wrap); | |
// Find the outer comment container for later UI updates (e.g., id="trac-change-<cnum>-<ctime>") | |
const container = btn.closest('[id^="trac-change-"]') || btn.closest('.change') || btn.closest('.box') || btn; | |
targets.push({ checkbox, form, container, cnumInput, cdateInput }); | |
}); | |
if (!targets.length) return; // nothing to do | |
// Bulk action bar | |
const bar = el('div', { | |
class: 'tmk-bulkdel-bar', | |
style: ` | |
position: fixed; left: 0; right: 0; bottom: 0; z-index: 9999; | |
display: flex; gap: .75rem; align-items: center; justify-content: center; | |
padding: .6rem 1rem; background: #222; color: #fff; border-top: 2px solid #444; | |
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; | |
` | |
}); | |
const selCount = el('span', { text: '0 selected' }); | |
const runBtn = el('button', { | |
text: 'Bulk delete selected', | |
disabled: 'true', | |
style: ` | |
padding: .5rem .8rem; border: 0; border-radius: .4rem; font-weight: 600; | |
background: #888; color: #111; cursor: not-allowed; | |
` | |
}); | |
const closeBtn = el('button', { | |
text: 'Hide', | |
style: ` | |
margin-left: .5rem; padding: .4rem .6rem; border: 1px solid #555; border-radius: .35rem; | |
background: transparent; color: #fff; cursor: pointer; | |
` | |
}); | |
bar.appendChild(selCount); | |
bar.appendChild(runBtn); | |
bar.appendChild(closeBtn); | |
document.body.appendChild(bar); | |
function updateBarState() { | |
const selected = targets.filter(t => t.checkbox.checked); | |
selCount.textContent = `${selected.length} selected`; | |
if (selected.length > 0) { | |
runBtn.removeAttribute('disabled'); | |
runBtn.style.background = '#ff6b6b'; | |
runBtn.style.color = '#111'; | |
runBtn.style.cursor = 'pointer'; | |
} else { | |
runBtn.setAttribute('disabled', 'true'); | |
runBtn.style.background = '#888'; | |
runBtn.style.color = '#111'; | |
runBtn.style.cursor = 'not-allowed'; | |
} | |
} | |
targets.forEach(t => t.checkbox.addEventListener('change', updateBarState)); | |
closeBtn.addEventListener('click', () => bar.remove()); | |
updateBarState(); | |
async function getFormToken(deleteUrl) { | |
const html = await fetch(deleteUrl, { credentials: 'include' }).then(r => r.text()); | |
const m = html.match(/__FORM_TOKEN" value="([^"]+)"/); | |
if (!m) throw new Error('Could not find __FORM_TOKEN (not logged in / insufficient perms?)'); | |
return m[1]; | |
} | |
async function deleteComment(cnum, cdate) { | |
const base = `https://${window.location.hostname}/ticket/${ticket}`; | |
const deleteUrl = `${base}?action=delete-comment&cnum=${encodeURIComponent(cnum)}&cdate=${encodeURIComponent(cdate)}#`; | |
const token = await getFormToken(deleteUrl); | |
const body = new URLSearchParams({ | |
action: 'delete-comment', | |
cnum: String(cnum), | |
cdate: String(cdate), | |
__FORM_TOKEN: token | |
}); | |
const res = await fetch(deleteUrl, { | |
method: 'POST', | |
credentials: 'include', | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
body | |
}); | |
if (!res.ok && !res.redirected) { | |
const txt = await res.text().catch(() => ''); | |
throw new Error(`Unexpected response (${res.status}). ${txt.slice(0, 200)}`); | |
} | |
} | |
function replaceWithDeleted(container) { | |
// Try to find a content area; fall back to the container itself | |
const content = container.querySelector('.comment, .searchable, .content, .wiki') || container; | |
content.innerHTML = '<em>This comment has been deleted.</em>'; | |
container.style.opacity = '0.7'; | |
container.style.filter = 'grayscale(0.2)'; | |
$$('.trac-ticket-buttons, .inlinebuttons', container).forEach(n => n.remove()); | |
} | |
runBtn.addEventListener('click', async () => { | |
const selected = targets.filter(t => t.checkbox.checked); | |
if (!selected.length) return; | |
runBtn.setAttribute('disabled', 'true'); | |
runBtn.textContent = 'Deleting…'; | |
for (const t of selected) { | |
const cnum = t.cnumInput.value; | |
const cdate = t.cdateInput.value; | |
// Inline progress hint | |
const hint = el('div', { text: 'Deleting…', style: 'font-size:.9rem;color:#bbb;margin-top:.25rem;' }); | |
t.checkbox.closest('label')?.appendChild(hint); | |
try { | |
await deleteComment(cnum, cdate); | |
replaceWithDeleted(t.container); | |
} catch (err) { | |
console.error(err); | |
const errNode = el('div', { text: `Failed to delete comment #${cnum}: ${err.message}`, style: 'color:#ffb3b3;margin-top:.25rem;font-size:.9rem;' }); | |
t.checkbox.closest('label')?.appendChild(errNode); | |
} finally { | |
t.checkbox.checked = false; | |
updateBarState(); | |
hint.remove(); | |
} | |
} | |
runBtn.textContent = 'Bulk delete selected'; | |
runBtn.removeAttribute('disabled'); | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment