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" }

);

});

});

});

})();