Bugzilla - Merge Comments

Merge comments by the same user on several Bugzilla 5.0.4 instance, merge "Updated" / auxiliary changes by the same user on Mozilla Bugzilla.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();
	}
});

}