// ==UserScript==
// @name Bugzilla - Merge Comments
// @description Merge comments by the same user on several Bugzilla 5.0.4 instance, merge "Updated" / auxiliary changes by the same user on Mozilla Bugzilla.
// @namespace RainSlide
// @author RainSlide
// @license AGPL-3.0-or-later
// @version 1.1
// @icon https://www.bugzilla.org/assets/favicon/favicon.ico
// @match https://bugzilla.mozilla.org/show_bug.cgi?*
// @match https://bugzilla.redhat.com/show_bug.cgi?*
// @match https://bugs.kde.org/show_bug.cgi?*
// @grant none
// @inject-into content
// @run-at document-end
// ==/UserScript==
"use strict";
const $ = (tagName, ...props) => Object.assign(
document.createElement(tagName), ...props
);
// "move" an id, from an element, to another element
const moveId = (from, to) => {
const id = from.id;
from.removeAttribute("id");
to.id = id;
};
if (location.hostname !== "bugzilla.mozilla.org") {
// Bugzilla 5.0.4; they are easier to deal with
let css = `.bz_comment_text > .bz_comment_number,
.bz_comment_text > .bz_comment_time {
float: right;
white-space: normal;
}
.bz_comment_text > .bz_comment_time {
font-family: monospace;
}
.bz_comment_text:not(:hover):not(:target) > .bz_comment_time {
opacity: .5;
}
.bz_comment:target,
.bz_comment_text:target {
outline: 2px solid #006cbf;
}
.bz_comment_text:target {
outline-offset: 2px;
z-index: 1;
}`;
if (location.hostname === "bugzilla.redhat.com") css += `
.bz_comment_text:not(:last-child) { border-bottom: 1px solid; }
.bz_comment_text:target { outline-offset: 6px; }`;
document.head.append($("style", { textContent: css }));
// groups of continuous comments by the same user
const groups = [];
let currentUser = null;
document.querySelectorAll(".bz_first_comment ~ .bz_comment").forEach(comment => {
// get & check user vcard element
const user = comment.querySelector(":scope .bz_comment_user > .vcard");
if (user === null) {
throw new TypeError('Element ".bz_comment .bz_comment_user > .vcard" not found!');
}
// check if is the same user
if (user.textContent !== currentUser) {
// different user, set currentUser, add a new group directly
currentUser = user.textContent;
groups.push([comment]);
} else {
// same user, push to current group
groups.at(-1).push(comment);
}
});
const prepareText = comment => {
// get & check .bz_comment_text
const text = comment.querySelector(":scope .bz_comment_text");
if (text === null) {
throw new TypeError('Element ".bz_comment .bz_comment_text" not found!');
}
// prepend metadata elements (.bz_comment_number, .bz_comment_time)
// into .bz_comment_text if they exist
text.prepend(
...["number", "time"]
.map(name => comment.querySelector(`:scope .bz_comment_${name}`))
.filter(element => element)
);
return text;
};
groups.forEach(group => {
if (group.length < 2) return;
const first = group[0];
prepareText(first);
// starts from 1 to skip the first comment
for (let i = 1; i < group.length; i++) {
const comment = group[i];
const text = prepareText(comment);
moveId(comment, text);
first.append(text);
comment.remove();
}
});
} else {
// bugzilla.mozilla.org
const css = `.activity .changes-container {
display: flex;
align-items: center;
}
.activity .changes-separator {
display: inline-block;
transform: scaleY(2.5);
white-space: pre;
}
.activity .change-name,
.activity .change-time {
font-size: var(--font-size-medium);
}
.changes-container:target,
.change:target {
outline: 2px solid var(--focused-control-border-color);
}`;
document.head.append($("style", { textContent: css }));
// Continuous groups of:
// 1. auxiliary .change-set (.change-set with no comment text, id starts with "a")
// 2. by the same author
const aGroups = [];
let currentAuthor = null;
let newGroup = true;
document.querySelectorAll("#main-inner > .change-set").forEach(changeSet => {
// check if is auxiliary change set
if (changeSet.id[0] !== "a") {
// no, no longer continuous, add a new group for next auxiliary change set
newGroup = true;
return;
}
// get & check author vcard element
const author = changeSet.querySelector(":scope .change-author > .vcard");
if (author === null) {
throw new TypeError('Element ".change-set .change-author > .vcard" not found!');
}
// check if is the same author
if (author.textContent !== currentAuthor) {
// different author, set currentAuthor, add a new group directly
currentAuthor = author.textContent;
aGroups.push([changeSet]);
newGroup = false;
} else if (!newGroup) {
// same author, push to current group
aGroups.at(-1).push(changeSet);
} else {
// same author, add a new group
aGroups.push([changeSet]);
newGroup = false;
}
});
// append .change to .activity, create container if needed
const appendChanges = (changeSet, activity, isFirst) => {
// get & check .change element(s)
const changes = changeSet.querySelectorAll(":scope > .activity > .change");
if (changes.length === 0) {
throw new TypeError('Element(s) ".change-set > .activity > .change" not found!');
}
// get name & time
const tr = changeSet.querySelector(
':scope > .change > .change-head > tbody > tr[id^="ar-a"]:nth-of-type(2)'
);
const td = tr?.querySelector(":scope > td:only-child");
// move name & time into .change or .changes-container, append .changes-container
if (tr && td) {
if (changes.length > 1) {
// a group of .change, create container for nameTime & themselves
const container = $("div", { className: "changes-container" });
const group = $("div", { className: "changes" });
const nameTime = $("div", { id: tr.id });
const separator = $("span", { className: "changes-separator", textContent: "| " });
nameTime.append(...td.childNodes, separator);
group.append(...changes);
container.append(nameTime, group);
tr.remove();
// appending .changes-container
// "move" an id onto another existing element might mess up the :target highlight,
// so skip that for the first
if (!isFirst) {
moveId(changeSet, container);
}
// but, first .changes-container needs append!
activity.append(container);
return;
} else {
// only one .change, don't create container, just move nameTime to changes[0]
const nameTime = $("span", { id: tr.id });
nameTime.append(...td.childNodes, "| ");
changes[0].prepend(nameTime);
tr.remove();
// no return here, append in if (!isFirst) ... below
}
}
// appending .change / a group of .change
// first doesn't need move id, see before;
// first .change is already in .activity, doesn't need append either.
if (!isFirst) {
moveId(changeSet, changes[0]);
activity.append(...changes);
}
};
// merge the .change of each aGroup into the first .change-set with appendChanges()
aGroups.forEach(group => {
if (group.length < 2) return;
const first = group[0];
const activity = first.querySelector(":scope > .activity");
appendChanges(first, activity, true);
// starts from 1 to skip the first change set
for (let i = 1; i < group.length; i++) {
appendChanges(group[i], activity);
group[i].remove();
}
});
}