/*
multiContribs.js
- allows viewing contributions of multiple users in one page: [[Special:BlankPage/MultiContribs]];
- adds a link to "multiContribs" tool in SPI pages;
*/
/* global mw, $ */
mw.loader.using(["mediawiki.api", "mediawiki.util"]).then(() => {
const RUN_PAGE = "Special:BlankPage/MultiContribs";
const RUN_NS = -1;
class MultiContribs {
constructor() {
if (
mw.config.get("wgNamespaceNumber") !== RUN_NS ||
mw.config.get("wgPageName").toLowerCase() !== RUN_PAGE.toLowerCase()
) {
return;
}
this.content_div = document.getElementById("content");
this.namespaces = [
{ id: "", name: "All namespaces" },
{ id: "0", name: "Main (articles)" },
{ id: "1", name: "Talk" },
{ id: "2", name: "User" },
{ id: "3", name: "User talk" },
{ id: "4", name: "Wikipedia" },
{ id: "5", name: "Wikipedia talk" },
{ id: "6", name: "File" },
{ id: "7", name: "File talk" },
{ id: "8", name: "MediaWiki" },
{ id: "9", name: "MediaWiki talk" },
{ id: "10", name: "Template" },
{ id: "11", name: "Template talk" },
{ id: "12", name: "Help" },
{ id: "13", name: "Help talk" },
{ id: "14", name: "Category" },
{ id: "15", name: "Category talk" },
{ id: "100", name: "Portal" },
{ id: "101", name: "Portal talk" },
{ id: "118", name: "Draft" },
{ id: "119", name: "Draft talk" },
{ id: "828", name: "Module" },
{ id: "829", name: "Module talk" },
];
this.number_of_users_limit = 50;
this.limits = [10, 25, 50, 100, 250, 500];
this.available_tags = [
"mobile edit",
"mobile web edit",
"possible vandalism",
"twinkle",
"visualeditor",
"mw-reverted",
"mw-undo",
"advanced mobile edit",
"mw-replace",
"visualeditor-wikitext",
"mw-rollback",
"mw-new-redirect",
"mobile app edit",
"mw-manual-revert",
"mw-blank",
"huggle",
"mw-changed-redirect-target",
"mw-removed-redirect",
];
this.init();
}
init() {
document.title = "Contributions of multiple users";
this.load_styles();
this.render_header();
this.bind_events();
this.load_from_url();
}
load_styles() {
mw.loader.load(["mediawiki.interface.helpers.styles", "codex-styles"]);
const style = document.createElement("style");
style.textContent = `
#mctb-form {
flex-direction: column;
padding: 15px;
background-color: #f8f9fa;
}
.mctb-card {
display: flex;
align-items: center;
width: 100%;
margin: 10px 0;
}
.mctb-card .input-col1 {
flex: 1;
}
.mctb-option {
margin: 10px 0;
}
#users-input {
min-height: inherit;
padding: 8px;
background-color: #fff;
font-size: 14px;
border-radius: 4px;
resize: vertical;
}
#users-input:focus {
outline: none;
border-color: #0645ad;
}
.users-input-container {
max-width: 600px;
min-height: 200px;
}
#mctb-form select {
width: auto;
min-width: 50px;
max-width: 300px;
}
.mw-uctop {
font-weight: bold;
}
.mw-tag-markers {
margin-right: 5px;
color: #0645ad;
font-size: 0.8em;
}
.mw-tag-markers abbr {
border-bottom: 1px dotted;
cursor: help;
}
.mw-tag {
padding: 0 4px;
border: 1px solid #a2a9b1;
margin-left: 5px;
background-color: #eef2ff;
color: #0645ad;
font-size: 0.85em;
border-radius: 2px;
}
`;
document.head.appendChild(style);
}
render_header() {
this.content_div.innerHTML = `
<div class="vector-body">
<details class="cdx-accordion" open>
<summary>
<h3 class="cdx-accordion__header">Contributions of multiple users</h3>
</summary>
<div id="mctb-form" class="cdx-card">
<div class="cdx-card__text__description mctb-card">
<div class="input-col1">
<label for="users-input">Users/IPs (one per line):</label><br />
<div class="cdx-text-area users-input-container">
<textarea
id="users-input"
class="cdx-text-area__textarea"
rows="5"
cols="50"
placeholder="Enter usernames or IP addresses, one per line"
></textarea>
</div>
</div>
<div class="input-col2">
<div class="mctb-option">
<label for="limit-input">Results per user:</label>
<select id="limit-input" class="cdx-select">
${this.limits
.map(
(limit) => `
<option value="${limit}">${limit}</option>
`
)
.join("")}
</select>
</div>
<div class="mctb-option">
<label for="namespace-input">Namespace:</label>
<select id="namespace-input" class="cdx-select">
${this.namespaces
.map(
(ns) => `
<option value="${ns.id}">${ns.name}</option>
`
)
.join("")}
</select>
</div>
<div class="mctb-option">
<label for="tag-input">Filter by tag:</label>
<select id="tag-input" class="cdx-select">
<option value="">All tags</option>
${this.available_tags
.map(
(tag) => `
<option value="${tag}">${tag}</option>
`
)
.join("")}
</select>
</div>
<div class="mctb-option">
<div class="cdx-checkbox">
<div class="cdx-checkbox__wrapper">
<input
id="show-new-only"
class="cdx-checkbox__input"
type="checkbox"
/>
<span class="cdx-checkbox__icon"></span>
<div class="cdx-checkbox__label cdx-label">
<label for="show-new-only" class="cdx-label__label">
<span class="cdx-label__label__text">
Show only page creations
</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<button
id="load-contribs"
class="cdx-button cdx-button--action-progressive cdx-button--weight-primary"
>
Load Contributions
</button>
</div>
</details>
<div id="mctb-results" class="mctb-option"></div>
</div>
`;
}
bind_events() {
document.getElementById("load-contribs").addEventListener("click", () => {
this.load_contributions();
});
}
load_from_url() {
const params = new URLSearchParams(window.___location.search);
document.getElementById("limit-input").value = 50;
if (params.has("limit")) {
const limit = params.get("limit");
const is_valid_limit = this.limits.includes(parseInt(limit));
document.getElementById("limit-input").value = is_valid_limit
? limit
: "50";
}
if (params.has("namespace")) {
const namespace = params.get("namespace");
const valid_namespaces = this.namespaces.map((ns) => ns.id);
document.getElementById("namespace-input").value =
valid_namespaces.includes(namespace) ? namespace : "";
}
if (params.has("tag")) {
const tag = params.get("tag");
document.getElementById("tag-input").value = tag;
}
if (params.has("new")) {
const new_param = params.get("new");
document.getElementById("show-new-only").checked =
new_param === "1" || new_param === "true";
}
if (params.has("users")) {
document.getElementById("users-input").value = params
.get("users")
.split(",")
.join("\n");
this.load_contributions();
}
}
update_url() {
const users = document
.getElementById("users-input")
.value.trim()
.split("\n")
.filter((u) => u.trim());
const limit = document.getElementById("limit-input").value;
const namespace = document.getElementById("namespace-input").value;
const tag = document.getElementById("tag-input").value;
const show_new_only = document.getElementById("show-new-only").checked;
const params = new URLSearchParams();
if (users.length > 0) {
params.set("users", users.join(","));
}
if (limit !== "50") {
params.set("limit", limit);
}
if (namespace !== "") {
params.set("namespace", namespace);
}
if (tag !== "") {
params.set("tag", tag);
}
if (show_new_only) {
params.set("new", "1");
}
const new_url =
window.___location.pathname +
(params.toString() ? "?" + params.toString() : "");
window.history.___replaceState({}, "", new_url);
}
async load_contributions() {
const raw_users = document
.getElementById("users-input")
.value.trim()
.split("\n")
.map((u) => {
if (!u.trim()) return null;
const parse_title = mw.Title.newFromText(u, 2);
return parse_title ? parse_title.title : null;
})
.filter((u) => u);
const users = [...new Set(raw_users)];
const results_div = document.getElementById("mctb-results");
const load_button = document.getElementById("load-contribs");
if (users.length === 0) {
results_div.innerHTML =
"<p>Please enter at least one username or IP address.</p>";
return;
}
if (users.length > this.number_of_users_limit) {
results_div.innerHTML = `<p>Exceeded the ${this.number_of_users_limit} users limit.</p>`;
return;
}
load_button.disabled = true;
const original_text = load_button.textContent;
load_button.textContent = "Loading...";
this.update_url();
const limit = parseInt(document.getElementById("limit-input").value);
const namespace = document.getElementById("namespace-input").value;
const tag = document.getElementById("tag-input").value;
const show_new_only = document.getElementById("show-new-only").checked;
results_div.innerHTML = "<p>Loading contributions...</p>";
try {
const all_contribs = [];
for (const user of users) {
const api = new mw.Api();
const params = {
action: "query",
list: "usercontribs",
ucuser: user.trim(),
uclimit: limit,
ucprop: "ids|title|timestamp|comment|size|flags|sizediff|tags",
};
if (namespace !== "") {
params.ucnamespace = namespace;
}
if (tag !== "") {
params.uctag = tag;
}
if (show_new_only) {
params.ucshow = "new";
}
const result = await api.get(params);
if (result.query.usercontribs) {
result.query.usercontribs.forEach((contrib) => {
contrib.user = user.trim();
all_contribs.push(contrib);
});
}
}
all_contribs.sort(
(a, b) => new Date(b.timestamp) - new Date(a.timestamp)
);
this.render_results(all_contribs, results_div);
} catch (error) {
results_div.innerHTML =
"<p>Error loading contributions: " + error.message + "</p>";
} finally {
load_button.disabled = false;
load_button.textContent = original_text;
}
}
render_results(contribs, results_div) {
if (contribs.length === 0) {
results_div.innerHTML =
"<p>No contributions found with the selected filters.</p>";
return;
}
let html = `<p>Found ${contribs.length} contributions</p>
<ul class="mw-contributions-list">`;
contribs.forEach((contrib) => {
const full_date_time = this.format_timestamp(contrib.timestamp);
let flags = []; // might add more in future
if ("new" in contrib)
flags.push('<abbr title="This edit created a new page">N</abbr>');
const flags_html =
flags.length > 0
? `<span class="mw-tag-markers">${flags.join(" ")}</span> `
: "";
let tags_html = "";
if (contrib.tags && contrib.tags.length > 0) {
const tag_spans = contrib.tags.map(
(tag) => `<span class="mw-tag" title="${tag}">${tag}</span>`
);
tags_html = tag_spans.join("");
}
// constructing actual list
//
html += `<li data-mw-revid="${contrib.revid}">`;
// diff and history links
html += `
<span class="mw-changeslist-links">
<span><a href="/w/index.php?title=${contrib.title}&diff=prev&oldid=${contrib.revid}"
class="mw-changeslist-diff" title="${contrib.title}">diff</a></span>
<span><a href="/w/index.php?title=${contrib.title}&action=history"
class="mw-changeslist-history" title="${contrib.title}">hist</a></span>
</span>
`;
// relevant user
html += `[<a href="/wiki/Special:Contributions/${contrib.user}" style="font-weight: bold;">${contrib.user}</a>]`;
// UTC date and time
html += `
<bdi>
<a href="/w/index.php?title=${contrib.title}&oldid=${contrib.revid}"
class="mw-changeslist-date" title="${contrib.title}">${full_date_time}</a>
</bdi>
`;
// separator and flags
html += `<span class="mw-changeslist-separator"></span>${flags_html}`;
// size diff
const intensity = Math.min(Math.abs(contrib.sizediff) / 1000, 1);
const green_intensity = Math.floor(200 - intensity * 100);
const red_intensity = Math.floor(200 - intensity * 100);
const fnt_color =
contrib.sizediff > 0
? `rgb(0, ${green_intensity}, 0)`
: `rgb(${red_intensity}, 0, 0)`;
// bold if diff is either higher than 500 OR lower than -500-- not in-between
const fnt_weight =
contrib.sizediff >= 500
? "bold"
: contrib.sizediff <= -500
? "bold"
: "";
const plus_sign = contrib.sizediff > 0 ? "+" : "";
html += `
<span dir="ltr" class="mw-plusminus-pos mw-diff-bytes" title="${
contrib.size
} bytes after change" style="color: ${fnt_color}; font-weight: ${fnt_weight}">${
plus_sign + (contrib.sizediff || 0)
}</span>
<span class="mw-changeslist-separator"></span>`;
// title and edit summary
html += `
<bdi>
<a href="/wiki/${contrib.title}"
class="mw-contributions-title" title="${contrib.title}">${
contrib.title
}</a>
</bdi>
<span class="comment comment--without-parentheses">${
contrib.comment || ""
}</span>
`;
if (tags_html) {
html += tags_html;
}
// if it's the current revision
if ("top" in contrib) {
html += `
<span class="mw-changeslist-separator"></span>
<span class="mw-uctop">current</span>
`;
}
html += `</li>`;
});
html += "</ul>";
results_div.innerHTML = html;
mw.hook("wikipage.content").fire($(results_div));
}
format_timestamp(timestamp) {
const date = new Date(timestamp);
const hours = date.getUTCHours().toString().padStart(2, "0");
const minutes = date.getUTCMinutes().toString().padStart(2, "0");
const day = date.getUTCDate();
const month = date.toLocaleDateString("en-US", {
month: "long",
timeZone: "UTC",
});
const year = date.getUTCFullYear();
return `${hours}:${minutes}, ${day} ${month} ${year}`;
}
}
new MultiContribs();
// multiContribs on suspected sockpuppets' lists
if (
mw.config
.get("wgPageName")
.startsWith("Wikipedia:Sockpuppet_investigations/")
) {
$("ul:has(span.cuEntry)").each(function () {
const users = $(this)
.find("span.cuEntry .plainlinks a")
.map(function () {
return $(this).text();
})
.get();
$(this)
.find("li")
.last()
.find("a")
.first()
.before(
`<a href="/wiki/${RUN_PAGE}?users=${encodeURIComponent(
users.join(",")
)}" style="font-style: italic;">multiContribs</a> <b>·</b> `
);
});
}
// add portlet link
mw.util.addPortletLink(
"p-tb",
"/wiki/" + RUN_PAGE,
"multiContribs",
"t-multicontribs",
"View contributions of multiple users"
);
});