User:L235/wordCountsByEditor.js
/**
* Word Count by Editor
*
* This script provides a word counting tool for MediaWiki discussion pages.
* It analyzes comments from different editors and provides word count statistics
* organized by editor name.
*
* Key Features:
* - Counts words in discussion comments by editor
* - Allows filtering by specific page sections
* - Option to include or exclude subsections
* - Dynamic loading of Convenient Discussions if not already present
*
* Usage: Adds a "Word counts by editor" link to the page actions menu
* that opens a dialog for analyzing comment word counts.
*
* Note: This script depends on Convenient Discussions (CD). If CD is not present,
* the script will dynamically load it. This will change the formatting of the
* remainder of the talk page, but will not persist beyond a page refresh.
*/
(function () {
"use strict";
// Configuration for loading Convenient Discussions script
const CD_SCRIPT_URL =
"https://commons.wikimedia.org/w/index.php?title=User:Jack_who_built_the_house/convenientDiscussions.js&action=raw&ctype=text/javascript";
/**
* Counts words in a text string, excluding URLs and empty strings
* @param {string} text - The text to count words in
* @returns {number} Number of words (containing at least one alphanumeric character)
*/
const countWords = (text) =>
text
.replace(/https?:\/\/\S+/g, "") // Remove URLs
.split(/\s+/) // Split on whitespace
.filter((word) => word && /[A-Za-z0-9]/.test(word)).length; // Filter non-empty words with alphanumeric chars
/**
* Aggregates word counts and comment counts by editor from a collection of comments
* @param {Array} comments - Array of comment objects from Convenient Discussions
* @returns {Object} Object with editor names as keys and objects containing wordCount and commentCount as values
*/
const aggregate = (comments) => {
const totals = Object.create(null); // Create object without prototype
for (const comment of comments) {
const editorName = comment.author?.name || "Unknown";
const wordCount = countWords(comment.getText(true));
// Initialize editor entry if it doesn't exist
if (!totals[editorName]) {
totals[editorName] = { wordCount: 0, commentCount: 0 };
}
// Always increment comment count (even for comments with no words)
totals[editorName].commentCount++;
// Add word count if the comment has words
if (wordCount) {
totals[editorName].wordCount += wordCount;
}
}
return totals;
};
/**
* Gets all sections from Convenient Discussions
* @returns {Array} Array of section objects
*/
const cdSections = () => window.convenientDiscussions.sections;
/**
* Finds the shallowest (highest level) heading level among all sections
* @returns {number} The minimum heading level (1-6)
*/
const shallowestLevel = () =>
Math.min(...cdSections().map((section) => section.level ?? 6));
/**
* Gets top-level sections (sections at the shallowest heading level)
* @returns {Array} Array of top-level section objects
*/
const getTopLevelSections = () =>
cdSections().filter(
(section) => (section.level ?? 6) === shallowestLevel()
);
/**
* Ensures Convenient Discussions is loaded and ready for use
* Dynamically loads the CD script if not already present
* @returns {Promise} Promise that resolves when CD is ready
*/
function ensureCDReady() {
// If CD is already loaded and comments are available, return immediately
if (window.convenientDiscussions?.comments) {
return Promise.resolve();
}
// Reuse existing promise if already loading to prevent multiple loads
if (ensureCDReady._promise) return ensureCDReady._promise;
// Show loading notification
mw.notify("Loading Convenient Discussions…", { type: "info" });
ensureCDReady._promise = new Promise((resolve, reject) => {
// Load the CD script
mw.loader.load(CD_SCRIPT_URL);
// Wait for CD to finish parsing the page
mw.hook("convenientDiscussions.commentsReady").add(() => {
mw.notify("Convenient Discussions loaded.", { type: "info" });
resolve();
});
// Fallback timeout in case the hook never fires (network error, etc.)
setTimeout(() => {
if (!window.convenientDiscussions?.comments) {
reject(new Error("Convenient Discussions failed to load"));
} else {
resolve();
}
}, 30000); // 30 second timeout
});
return ensureCDReady._promise;
}
// Dialog instance - created once and reused
let dialog;
/**
* Opens the word count dialog with filtering options
*/
function openDialog() {
const cd = window.convenientDiscussions;
if (!cd?.comments) {
// Safety check - should not occur if ensureCDReady worked
return mw.notify(
"Word-count script: Convenient Discussions not ready.",
{ type: "error" }
);
}
const sections = getTopLevelSections();
// Create dropdown for section selection
const dropdownOptions = [
new OO.ui.OptionWidget({ data: null, label: "Whole page" }),
];
sections.forEach((section, index) => {
const label =
typeof section.getHeadingText === "function"
? section.getHeadingText()
: section.headingElement
? $(section.headingElement).text().trim()
: "(untitled)";
dropdownOptions.push(
new OO.ui.OptionWidget({ data: index, label })
);
});
const dropdown = new OO.ui.DropdownWidget({
menu: { items: dropdownOptions },
});
// Create checkbox for including subsections
const checkbox = new OO.ui.CheckboxInputWidget({ selected: true });
const checkboxField = new OO.ui.FieldLayout(checkbox, {
label: "Include subsections",
align: "inline",
});
// Show/hide subsection checkbox based on whether a specific section is selected
dropdown
.getMenu()
.on("choose", (option) =>
checkboxField.toggle(option.getData() !== null)
);
// Create results display area
const output = new OO.ui.MultilineTextInputWidget({
readOnly: true,
rows: 12,
});
/**
* Performs the word counting operation based on current selections
*/
function runCount() {
const selectedChoice = dropdown
.getMenu()
.findSelectedItem()
.getData();
let commentPool = cd.comments;
// If a specific section is selected, filter comments accordingly
if (selectedChoice !== null) {
const rootSection = sections[selectedChoice];
const includeSubsections = checkbox.isSelected();
commentPool = cd.comments.filter((comment) => {
const commentSection = comment.section;
if (!commentSection) return false;
// Always include comments from the exact selected section
if (commentSection === rootSection) return true;
if (!includeSubsections) return false;
// Use heading level analysis to determine if comment is in a subsection
const allSections = cdSections();
const rootIndex = allSections.indexOf(rootSection);
const commentIndex = allSections.indexOf(commentSection);
if (commentIndex < rootIndex) return false; // Comment section comes before root
const rootLevel = rootSection.level ?? 0;
// Walk backwards from comment section to find nearest higher/equal heading
for (let i = commentIndex - 1; i >= 0; i--) {
const candidateSection = allSections[i];
if ((candidateSection.level ?? 0) <= rootLevel) {
return candidateSection === rootSection; // Found the root section
}
}
return false;
});
}
// Aggregate and display results
const editorStats = aggregate(commentPool);
output.setValue(
Object.keys(editorStats)
.sort(
(a, b) =>
editorStats[b].wordCount - editorStats[a].wordCount
) // Sort by word count descending
.map(
(editor) =>
`${editor}: ${editorStats[
editor
].wordCount.toLocaleString()} words, ${
editorStats[editor].commentCount
} comment${
editorStats[editor].commentCount !== 1
? "s"
: ""
}`
)
.join("\n") || "No comments detected."
);
}
// Create dialog if it doesn't exist yet
if (!dialog) {
// Define custom dialog class
function WordCountDialog(config) {
WordCountDialog.super.call(this, config);
}
OO.inheritClass(WordCountDialog, OO.ui.ProcessDialog);
// Dialog configuration
WordCountDialog.static.name = "wcDialog";
WordCountDialog.static.title = "Word counts";
WordCountDialog.static.actions = [
{
action: "count",
label: "Count words",
flags: ["progressive"],
},
{ action: "close", label: "Close", flags: ["safe"] },
];
// Initialize dialog content
WordCountDialog.prototype.initialize = function () {
WordCountDialog.super.prototype.initialize.apply(
this,
arguments
);
const panel = new OO.ui.PanelLayout({ padded: true });
panel.$element.append(
new OO.ui.FieldsetLayout({
items: [
new OO.ui.FieldLayout(dropdown, {
label: "Section",
align: "top",
}),
checkboxField,
new OO.ui.FieldLayout(output, {
label: "Results",
align: "top",
}),
],
}).$element
);
this.$body.append(panel.$element);
};
// Handle dialog actions
WordCountDialog.prototype.getActionProcess = function (action) {
if (action === "count") return new OO.ui.Process(runCount);
if (action === "close")
return new OO.ui.Process(() => this.close());
return WordCountDialog.super.prototype.getActionProcess.call(
this,
action
);
};
// Create and set up dialog
dialog = new WordCountDialog();
const windowManager = new OO.ui.WindowManager();
$(document.body).append(windowManager.$element);
windowManager.addWindows([dialog]);
dialog.windowManager = windowManager;
}
// Open the dialog
dialog.windowManager.openWindow(dialog);
}
// Add portlet link to page actions menu
mw.loader
.using(["oojs-ui-core", "oojs-ui-widgets", "oojs-ui-windows"])
.then(() => {
mw.util
.addPortletLink(
"p-cactions", // Page actions portlet
"#",
"Word counts by editor",
"ca-wordcounts-by-editor",
"Open word-count dialog"
)
.addEventListener("click", (event) => {
event.preventDefault();
// Ensure CD is ready, then open dialog
ensureCDReady()
.then(openDialog)
.catch((error) => {
console.error(error);
mw.notify(
"Word-count script: failed to load Convenient Discussions.",
{ type: "error" }
);
});
});
});
})();