Jump to content

User:L235/wordCountsByEditor.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/**
 * 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" }
                            );
                        });
                });
        });
})();