Skip to content

Instantly share code, notes, and snippets.

@Clorith
Last active October 8, 2025 22:44
Show Gist options
  • Save Clorith/ff18773cecbe4605455983e562e94a33 to your computer and use it in GitHub Desktop.
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 😅
// ==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